arcsecond 3.16.0__tar.gz → 3.16.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {arcsecond-3.16.0 → arcsecond-3.16.1}/PKG-INFO +1 -1
  2. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/backups.py +10 -1
  3. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/database.py +20 -4
  4. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/docker/docker-compose.yml +23 -3
  5. arcsecond-3.16.1/arcsecond/hosting/local.py +466 -0
  6. {arcsecond-3.16.0 → arcsecond-3.16.1}/pyproject.toml +1 -1
  7. arcsecond-3.16.1/tests/test_hosting_local.py +412 -0
  8. {arcsecond-3.16.0 → arcsecond-3.16.1}/uv.lock +1 -1
  9. arcsecond-3.16.0/arcsecond/hosting/local.py +0 -170
  10. arcsecond-3.16.0/tests/test_hosting_local.py +0 -115
  11. {arcsecond-3.16.0 → arcsecond-3.16.1}/.docker/Dockerfile_postgres +0 -0
  12. {arcsecond-3.16.0 → arcsecond-3.16.1}/.docker/Dockerfile_redis +0 -0
  13. {arcsecond-3.16.0 → arcsecond-3.16.1}/.github/dependabot.yml +0 -0
  14. {arcsecond-3.16.0 → arcsecond-3.16.1}/.github/workflows/pythonpublish.yml +0 -0
  15. {arcsecond-3.16.0 → arcsecond-3.16.1}/.github/workflows/tests.yml +0 -0
  16. {arcsecond-3.16.0 → arcsecond-3.16.1}/.gitignore +0 -0
  17. {arcsecond-3.16.0 → arcsecond-3.16.1}/LICENSE +0 -0
  18. {arcsecond-3.16.0 → arcsecond-3.16.1}/Makefile +0 -0
  19. {arcsecond-3.16.0 → arcsecond-3.16.1}/README.md +0 -0
  20. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/__init__.py +0 -0
  21. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/__version__.py +0 -0
  22. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/alpaca/__init__.py +0 -0
  23. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/alpaca/commands.py +0 -0
  24. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/alpaca/dome_probe.py +0 -0
  25. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/api/__init__.py +0 -0
  26. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/api/config.py +0 -0
  27. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/api/constants.py +0 -0
  28. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/api/endpoint.py +0 -0
  29. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/api/main.py +0 -0
  30. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/api/resources.py +0 -0
  31. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cli.py +0 -0
  32. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/__init__.py +0 -0
  33. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/auth.py +0 -0
  34. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/resources.py +0 -0
  35. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/__init__.py +0 -0
  36. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/constants.py +0 -0
  37. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/context.py +0 -0
  38. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  39. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  40. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  41. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  42. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  43. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/errors.py +0 -0
  44. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/logger.py +0 -0
  45. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/uploader.py +0 -0
  46. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/utils.py +0 -0
  47. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploader/walker.py +0 -0
  48. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/cloud/uploads.py +0 -0
  49. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/errors.py +0 -0
  50. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/__init__.py +0 -0
  51. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/checks.py +0 -0
  52. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/constants.py +0 -0
  53. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/docker/__init__.py +0 -0
  54. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/docker/constants.py +0 -0
  55. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/docker/containers.py +0 -0
  56. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/docker/images.py +0 -0
  57. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/docker/utils.py +0 -0
  58. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/keygen/__init__.py +0 -0
  59. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/keygen/client.py +0 -0
  60. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/keygen/utils.py +0 -0
  61. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/main.py +0 -0
  62. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/postgres/init-db.sh +0 -0
  63. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/setup.py +0 -0
  64. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/utils.py +0 -0
  65. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/hosting/validation.py +0 -0
  66. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/__init__.py +0 -0
  67. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/commands.py +0 -0
  68. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/proxy.py +0 -0
  69. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/registry.py +0 -0
  70. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/sources/__init__.py +0 -0
  71. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/sources/base.py +0 -0
  72. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/sources/filewatch.py +0 -0
  73. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/sources/network.py +0 -0
  74. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/sources/opencv.py +0 -0
  75. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/imagesources/store.py +0 -0
  76. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/options.py +0 -0
  77. {arcsecond-3.16.0 → arcsecond-3.16.1}/arcsecond/targets.py +0 -0
  78. {arcsecond-3.16.0 → arcsecond-3.16.1}/examples/example_upload_files.py +0 -0
  79. {arcsecond-3.16.0 → arcsecond-3.16.1}/requirements.txt +0 -0
  80. {arcsecond-3.16.0 → arcsecond-3.16.1}/setup.cfg +0 -0
  81. {arcsecond-3.16.0 → arcsecond-3.16.1}/setup.py +0 -0
  82. {arcsecond-3.16.0 → arcsecond-3.16.1}/sonar-project.properties +0 -0
  83. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/__init__.py +0 -0
  84. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/api/__init__.py +0 -0
  85. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/api/test_api.py +0 -0
  86. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/api/test_api_endpoint.py +0 -0
  87. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/api/test_config.py +0 -0
  88. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/api/test_targets.py +0 -0
  89. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/__init__.py +0 -0
  90. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/__init__.py +0 -0
  91. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  92. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  93. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  94. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  95. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  96. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  97. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/conftest.py +0 -0
  98. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/fixtures/file1.fits +0 -0
  99. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/test_alpaca_probe.py +0 -0
  100. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/test_cli.py +0 -0
  101. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/test_hosting_database.py +0 -0
  102. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/test_imagesources_netcam.py +0 -0
  103. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/test_imagesources_store.py +0 -0
  104. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/test_targets_planning.py +0 -0
  105. {arcsecond-3.16.0 → arcsecond-3.16.1}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcsecond
3
- Version: 3.16.0
3
+ Version: 3.16.1
4
4
  Summary: CLI for arcsecond.io
5
5
  Project-URL: Homepage, https://github.com/arcsecond-io/cli
6
6
  Project-URL: Issues, https://github.com/arcsecond-io/cli/issues
@@ -20,9 +20,18 @@ API_CONTAINER = "arcsecond-api"
20
20
  WORKER_CONTAINER = "arcsecond-worker"
21
21
  BEAT_CONTAINER = "arcsecond-beat"
22
22
  WEB_CONTAINER = "arcsecond-web"
23
+ # Optional service: absent on most installs. The stop loop skips it when it
24
+ # is not running, and the docker-start fallback tolerates the missing name.
25
+ ALERTS_CONTAINER = "arcsecond-alerts"
23
26
 
24
27
  # Services to stop before wiping the DB. db is kept up so we can talk to it.
25
- SERVICES_TO_STOP = [API_CONTAINER, WORKER_CONTAINER, BEAT_CONTAINER, WEB_CONTAINER]
28
+ SERVICES_TO_STOP = [
29
+ API_CONTAINER,
30
+ WORKER_CONTAINER,
31
+ ALERTS_CONTAINER,
32
+ BEAT_CONTAINER,
33
+ WEB_CONTAINER,
34
+ ]
26
35
 
27
36
  BACKUP_PATTERN = re.compile(r"^backup-(\d{8}-\d{6})\.sql\.gz$")
28
37
  PRE_RESTORE_PREFIX = "pre-restore-"
@@ -37,6 +37,21 @@ DB_CONTAINER = "arcsecond-db"
37
37
  # directory, so recreating it would be downtime for nothing.
38
38
  SERVICES_TO_RECREATE = ["backend", "worker", "beat"]
39
39
 
40
+
41
+ def _services_to_recreate():
42
+ """The optional alerts consumer joins the recreate list only when the
43
+ operator's compose file actually carries it — naming an unknown service
44
+ makes `docker compose up` fail outright on installs without the block."""
45
+ services = list(SERVICES_TO_RECREATE)
46
+ compose_path = Path.cwd() / "docker-compose.yml"
47
+ try:
48
+ if "# >>> arcsecond:alerts" in compose_path.read_text(encoding="utf-8"):
49
+ services.append("alerts")
50
+ except OSError:
51
+ pass
52
+ return services
53
+
54
+
40
55
  ENV_KEY = "POSTGRES_PASSWORD"
41
56
 
42
57
  # The .env in the install directory does double duty: compose also reads it to
@@ -248,7 +263,7 @@ def set_password_cmd(password, show, no_restart, dry_run):
248
263
  click.echo(f" would run ALTER ROLE \"{db_user}\" WITH PASSWORD '***'")
249
264
  if not no_restart:
250
265
  click.echo(
251
- f" would recreate {', '.join(SERVICES_TO_RECREATE)} "
266
+ f" would recreate {', '.join(_services_to_recreate())} "
252
267
  "(docker compose up -d --force-recreate)"
253
268
  )
254
269
  sys.exit(0)
@@ -324,21 +339,22 @@ def set_password_cmd(password, show, no_restart, dry_run):
324
339
 
325
340
  click.echo(click.style("Password changed and verified.", fg="green"))
326
341
 
342
+ services_to_recreate = _services_to_recreate()
327
343
  if no_restart:
328
344
  click.echo(
329
345
  "\nSkipping the restart, as asked. The running containers still hold the\n"
330
346
  "old password and will fail on their next reconnect. Apply it with:\n"
331
- f" docker compose up -d --force-recreate {' '.join(SERVICES_TO_RECREATE)}"
347
+ f" docker compose up -d --force-recreate {' '.join(services_to_recreate)}"
332
348
  )
333
349
  else:
334
- click.echo(f"Recreating {', '.join(SERVICES_TO_RECREATE)}...")
350
+ click.echo(f"Recreating {', '.join(services_to_recreate)}...")
335
351
  cmd = [
336
352
  "docker",
337
353
  "compose",
338
354
  "up",
339
355
  "-d",
340
356
  "--force-recreate",
341
- *SERVICES_TO_RECREATE,
357
+ *services_to_recreate,
342
358
  ]
343
359
  click.echo(click.style(f"$ {' '.join(cmd)}", fg="cyan"))
344
360
  recreate = subprocess.run(cmd, capture_output=True, text=True, check=False)
@@ -1,6 +1,6 @@
1
1
  # === Arcsecond.local config. ===
2
2
  # *** DO NOT change any name or port number. ***
3
- # Version 6.2
3
+ # Version 6.3
4
4
 
5
5
  services:
6
6
  # Database (PostgresQL)
@@ -105,7 +105,9 @@ services:
105
105
  restart: unless-stopped
106
106
  ports: [ "8900:8900" ]
107
107
 
108
- # Arcsecond webapp.
108
+ # Arcsecond webapp. Served on 5555 only: a self-hosted install is
109
+ # organisation-based and therefore always a portal, so the second port that
110
+ # used to be here (5577) served the very same app.
109
111
  web:
110
112
  image: ghcr.io/arcsecond-io/arcsecond-web:latest
111
113
  container_name: arcsecond-web
@@ -115,7 +117,25 @@ services:
115
117
  condition: service_started
116
118
  ports:
117
119
  - "5555:5555"
118
- - "5577:5577"
120
+
121
+ # >>> arcsecond:alerts
122
+ # Arcsecond transient-alerts consumer (optional).
123
+ # Long-lived Kafka client for NASA GCN. Outbound TLS to kafka.gcn.nasa.gov
124
+ # and auth.gcn.nasa.gov only — nothing listens. Requires GCN_CONSUMER_CLIENT_ID /
125
+ # GCN_CONSUMER_CLIENT_SECRET in .env; idles harmlessly when they are absent.
126
+ # Single instance only — do not scale it.
127
+ alerts:
128
+ image: ghcr.io/arcsecond-io/arcsecond-api:latest
129
+ container_name: arcsecond-alerts
130
+ restart: unless-stopped
131
+ entrypoint: /home/app/entrypoint-alerts.sh
132
+ depends_on:
133
+ backend:
134
+ condition: service_healthy
135
+ env_file:
136
+ - .env
137
+ stop_grace_period: 30s
138
+ # <<< arcsecond:alerts
119
139
 
120
140
  volumes:
121
141
  arcsecond_postgres_data:
@@ -0,0 +1,466 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from importlib import resources
5
+ from pathlib import Path, PurePosixPath, PureWindowsPath
6
+
7
+ import click
8
+
9
+ from arcsecond.options import basic_options
10
+
11
+ from .utils import (
12
+ _get_encryption_key,
13
+ _get_random_postgres_password,
14
+ _get_random_secret_key,
15
+ )
16
+
17
+ ENV_FILENAME = ".env"
18
+
19
+ # Stable across installs — operators connect with this username when running
20
+ # manual psql / pg_dump commands. The actual security boundary is the password
21
+ # (generated per-install) and the network exposure (localhost-only).
22
+ POSTGRES_USER = "arcsecond_docker"
23
+ POSTGRES_DB = "arcsecond_docker"
24
+
25
+ # Services an installation can opt out of. Each one lives in the packaged
26
+ # docker-compose.yml between "# >>> arcsecond:<name>" / "# <<< arcsecond:<name>"
27
+ # marker lines, and is added or removed by splicing those blocks as text —
28
+ # never by parsing and re-emitting YAML, which would destroy the compose
29
+ # file's comments (they are operator documentation).
30
+ OPTIONAL_SERVICES = {
31
+ "alerts": "transient-alerts (ToO)",
32
+ }
33
+
34
+ # One .env key records every answered yes/no, e.g. "alerts:yes". A service
35
+ # absent from the value has never been decided, so setup may still ask —
36
+ # that distinction is what lets a future CLI version introduce a new
37
+ # optional service without re-asking about the old ones.
38
+ OPTIONAL_SERVICES_ENV_KEY = "ARCSECOND_OPTIONAL_SERVICES"
39
+
40
+ GCN_ENV_COMMENT = (
41
+ "# NASA GCN credentials (optional — used by the transient-alerts service):"
42
+ " see https://docs.arcsecond.io/local/transient-alerts"
43
+ )
44
+
45
+
46
+ # Compose reads .env itself, and a backslash in a value is an escape sequence
47
+ # there — a Windows default like C:\Users\Obs\Data reaches the daemon mangled
48
+ # (\U, \O, \D swallowed), and the bind mount then points somewhere that does
49
+ # not exist. Windows accepts forward slashes in every path API and Docker
50
+ # accepts C:/Users/Obs/Data, so we write the path posix-style on every
51
+ # platform. Picked at import time rather than branching inside expand_path so
52
+ # tests can exercise the Windows flavour from any host.
53
+ _PATH_FLAVOUR = PureWindowsPath if os.name == "nt" else PurePosixPath
54
+
55
+
56
+ def expand_path(value: str) -> str:
57
+ expanded = os.path.expandvars(value)
58
+ expanded = os.path.expanduser(expanded)
59
+ # A backslash is a legal filename character on POSIX, so the conversion
60
+ # must only ever happen with the Windows flavour.
61
+ return _PATH_FLAVOUR(expanded).as_posix()
62
+
63
+
64
+ def prompt_shared_data_path() -> str:
65
+ default_path = str(Path.cwd())
66
+
67
+ print("SHARED_DATA_PATH configuration")
68
+ print(f"Default (current folder): {default_path}")
69
+ user_input = input(
70
+ "Press Enter to accept, or type a different path (supports ~ and $VARS): "
71
+ ).strip()
72
+
73
+ chosen = user_input if user_input else default_path
74
+ return expand_path(chosen)
75
+
76
+
77
+ # Values are callables so nothing is computed — and no prompt fires — for a
78
+ # key that is already present in an existing .env.
79
+ REQUIRED_ENV_PROVIDERS = {
80
+ "SECRET_KEY": lambda: _get_random_secret_key(),
81
+ "AUTH_JWT_SIGNING_KEY": lambda: _get_random_secret_key(),
82
+ "AGENT_JWT_SIGNING_KEY": lambda: _get_random_secret_key(),
83
+ "FIELD_ENCRYPTION_KEY": lambda: _get_encryption_key(),
84
+ "SHARED_DATA_PATH": lambda: prompt_shared_data_path(),
85
+ "POSTGRES_USER": lambda: POSTGRES_USER,
86
+ # Per-install random; never overwritten on repeat runs (see write_env_file).
87
+ # Postgres only reads this on first container boot to bootstrap the role,
88
+ # so the .env value and the live DB password must stay in sync — that's
89
+ # why we never regenerate it after the .env exists.
90
+ "POSTGRES_PASSWORD": lambda: _get_random_postgres_password(),
91
+ "POSTGRES_DB": lambda: POSTGRES_DB,
92
+ # Empty placeholders: the operator pastes their own GCN credentials here.
93
+ "GCN_CONSUMER_CLIENT_ID": lambda: "",
94
+ "GCN_CONSUMER_CLIENT_SECRET": lambda: "",
95
+ }
96
+
97
+
98
+ def _parse_env_keys(lines):
99
+ keys = set()
100
+ for line in lines:
101
+ stripped = line.strip()
102
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
103
+ continue
104
+ key, _ = stripped.split("=", 1)
105
+ keys.add(key.strip())
106
+ return keys
107
+
108
+
109
+ def _format_env_line(key, value):
110
+ if key == "SHARED_DATA_PATH":
111
+ return f'{key}="{value}"'
112
+ return f"{key}={value}"
113
+
114
+
115
+ def write_env_file():
116
+ env_path = Path.cwd() / ENV_FILENAME
117
+ ordered_required_keys = [
118
+ "SECRET_KEY",
119
+ "AUTH_JWT_SIGNING_KEY",
120
+ "AGENT_JWT_SIGNING_KEY",
121
+ "FIELD_ENCRYPTION_KEY",
122
+ "SHARED_DATA_PATH",
123
+ "POSTGRES_USER",
124
+ "POSTGRES_PASSWORD",
125
+ "POSTGRES_DB",
126
+ "GCN_CONSUMER_CLIENT_ID",
127
+ "GCN_CONSUMER_CLIENT_SECRET",
128
+ ]
129
+
130
+ def env_lines_for(keys):
131
+ lines = []
132
+ gcn_comment_pending = any(key.startswith("GCN_CONSUMER_") for key in keys)
133
+ for key in keys:
134
+ if key.startswith("GCN_CONSUMER_") and gcn_comment_pending:
135
+ # Once, before whichever GCN key lands first — an operator may
136
+ # have hand-added one of the two already.
137
+ lines.append(GCN_ENV_COMMENT)
138
+ gcn_comment_pending = False
139
+ lines.append(_format_env_line(key, REQUIRED_ENV_PROVIDERS[key]()))
140
+ return lines
141
+
142
+ if env_path.exists():
143
+ existing_lines = env_path.read_text(encoding="utf-8").splitlines()
144
+ existing_keys = _parse_env_keys(existing_lines)
145
+ missing_keys = [
146
+ key for key in ordered_required_keys if key not in existing_keys
147
+ ]
148
+
149
+ if not missing_keys:
150
+ print(f"{ENV_FILENAME} already contains all required keys.")
151
+ return
152
+
153
+ if existing_lines and existing_lines[-1].strip():
154
+ existing_lines.append("")
155
+ existing_lines.extend(env_lines_for(missing_keys))
156
+
157
+ env_path.write_text("\n".join(existing_lines) + "\n", encoding="utf-8")
158
+ print(
159
+ f"Updated {ENV_FILENAME} at: {env_path} (added keys: {', '.join(missing_keys)})"
160
+ )
161
+ return
162
+
163
+ env_contents = "\n".join(env_lines_for(ordered_required_keys))
164
+ env_path.write_text(env_contents + "\n", encoding="utf-8")
165
+ print(f"Wrote {ENV_FILENAME} to: {env_path}")
166
+
167
+
168
+ def _read_optional_service_decisions(env_path):
169
+ """The recorded yes/no answers, as {name: bool}. Unknown names are kept
170
+ out of the dict but preserved in the file (see the recorder)."""
171
+ decisions = {}
172
+ if not env_path.exists():
173
+ return decisions
174
+ for line in env_path.read_text(encoding="utf-8").splitlines():
175
+ stripped = line.strip()
176
+ if not stripped.startswith(OPTIONAL_SERVICES_ENV_KEY + "="):
177
+ continue
178
+ _, _, value = stripped.partition("=")
179
+ for token in value.split(","):
180
+ token = token.strip()
181
+ if not token:
182
+ continue
183
+ name, sep, verdict = token.partition(":")
184
+ if not sep or verdict not in ("yes", "no"):
185
+ print(
186
+ f"Ignoring malformed token '{token}' in {OPTIONAL_SERVICES_ENV_KEY}."
187
+ )
188
+ continue
189
+ if name in OPTIONAL_SERVICES:
190
+ decisions[name] = verdict == "yes"
191
+ return decisions
192
+
193
+
194
+ def _record_optional_service_decision(env_path, name, enabled):
195
+ """Rewrite only this service's token; tokens for services this CLI
196
+ version does not know about survive verbatim (downgrades happen)."""
197
+ verdict = f"{name}:{'yes' if enabled else 'no'}"
198
+ lines = (
199
+ env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
200
+ )
201
+ for index, line in enumerate(lines):
202
+ stripped = line.strip()
203
+ if not stripped.startswith(OPTIONAL_SERVICES_ENV_KEY + "="):
204
+ continue
205
+ _, _, value = stripped.partition("=")
206
+ tokens = [t.strip() for t in value.split(",") if t.strip()]
207
+ tokens = [t for t in tokens if t.partition(":")[0] != name]
208
+ tokens.append(verdict)
209
+ lines[index] = f"{OPTIONAL_SERVICES_ENV_KEY}={','.join(sorted(tokens))}"
210
+ break
211
+ else:
212
+ if lines and lines[-1].strip():
213
+ lines.append("")
214
+ lines.append(f"{OPTIONAL_SERVICES_ENV_KEY}={verdict}")
215
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
216
+
217
+
218
+ def _optional_service_markers(name):
219
+ return f"# >>> arcsecond:{name}", f"# <<< arcsecond:{name}"
220
+
221
+
222
+ def _extract_optional_service_block(packaged_text, name):
223
+ begin, end = _optional_service_markers(name)
224
+ lines = packaged_text.splitlines()
225
+ begin_index = next(
226
+ (i for i, line in enumerate(lines) if line.strip() == begin), None
227
+ )
228
+ end_index = next((i for i, line in enumerate(lines) if line.strip() == end), None)
229
+ if begin_index is None or end_index is None or end_index < begin_index:
230
+ return None
231
+ return lines[begin_index:end_index + 1]
232
+
233
+
234
+ def _with_trailing_newline_like(lines, original_text):
235
+ return "\n".join(lines) + ("\n" if original_text.endswith("\n") else "")
236
+
237
+
238
+ def _strip_optional_service_block(text, name):
239
+ begin, end = _optional_service_markers(name)
240
+ lines = text.splitlines()
241
+ begin_index = next(
242
+ (i for i, line in enumerate(lines) if line.strip() == begin), None
243
+ )
244
+ end_index = next((i for i, line in enumerate(lines) if line.strip() == end), None)
245
+ if begin_index is None or end_index is None or end_index < begin_index:
246
+ return text
247
+ del lines[begin_index:end_index + 1]
248
+ # Drop the blank separator the block carried, so strip(splice(x)) == x.
249
+ if begin_index < len(lines) and not lines[begin_index].strip():
250
+ del lines[begin_index]
251
+ return _with_trailing_newline_like(lines, text)
252
+
253
+
254
+ def _splice_optional_service_block(current_text, packaged_text, name):
255
+ """Insert the packaged block before the top-level "volumes:" line.
256
+ Returns the new text, current_text if the block is already there,
257
+ or None when there is no anchor to splice against."""
258
+ begin, _ = _optional_service_markers(name)
259
+ lines = current_text.splitlines()
260
+ if any(line.strip() == begin for line in lines):
261
+ return current_text
262
+ block = _extract_optional_service_block(packaged_text, name)
263
+ if block is None:
264
+ return None
265
+ anchor = next(
266
+ (
267
+ i
268
+ for i, line in enumerate(lines)
269
+ if line.rstrip() == "volumes:" and not line[:1].isspace()
270
+ ),
271
+ None,
272
+ )
273
+ if anchor is None:
274
+ return None
275
+ new_lines = lines[:anchor] + block + [""] + lines[anchor:]
276
+ return _with_trailing_newline_like(new_lines, current_text)
277
+
278
+
279
+ VERSION_HEADER_RE = re.compile(r"^# Version .+$", flags=re.MULTILINE)
280
+
281
+
282
+ def _compose_version(text):
283
+ match = re.search(r"^# Version (.+)$", text, flags=re.MULTILINE)
284
+ return match.group(1).strip() if match else None
285
+
286
+
287
+ def _reconcile_version_header(current_text, expected_text):
288
+ """When the *only* remaining difference is the '# Version X.Y' comment
289
+ line, adopt the packaged one — the header is ours, not operator content.
290
+ Without this, every pre-existing install would trail one version behind
291
+ forever and collect a spurious docker-compose.latest.yml on every run.
292
+ Returns the updated text, or None when the files differ beyond it."""
293
+ current_match = VERSION_HEADER_RE.search(current_text)
294
+ expected_match = VERSION_HEADER_RE.search(expected_text)
295
+ if current_match is None or expected_match is None:
296
+ return None
297
+ updated = (
298
+ current_text[: current_match.start()]
299
+ + expected_match.group(0)
300
+ + current_text[current_match.end():]
301
+ )
302
+ return updated if updated == expected_text else None
303
+
304
+
305
+ def write_docker_compose_file(enabled_services=frozenset(), removed_services=frozenset()) -> Path:
306
+ """
307
+ Materialise the packaged docker-compose.yml in the current directory.
308
+
309
+ The expected content is the packaged file minus the marker-delimited
310
+ blocks of optional services that are not enabled. Three cases:
311
+ 1. No file present → write the expected content.
312
+ 2. File present and identical to the expected content → no-op.
313
+ 3. File present but different → splice enabled optional services in
314
+ (idempotently, before the top-level "volumes:" line) and remove
315
+ explicitly-disabled ones; if the file still differs — it carries
316
+ local customisations — leave it untouched and drop the expected
317
+ version next to it as docker-compose.latest.yml so the operator
318
+ can diff and merge intentionally.
319
+
320
+ Works from any CWD and when installed from a wheel/sdist.
321
+ """
322
+ dest = Path.cwd() / "docker-compose.yml"
323
+
324
+ # arcsecond/hosting/docker/docker-compose.yml
325
+ compose = resources.files("arcsecond.hosting.docker").joinpath("docker-compose.yml")
326
+
327
+ with compose.open("rb") as src:
328
+ packaged_text = src.read().decode("utf-8")
329
+
330
+ expected_text = packaged_text
331
+ for name in OPTIONAL_SERVICES:
332
+ if name not in enabled_services:
333
+ expected_text = _strip_optional_service_block(expected_text, name)
334
+ expected_content = expected_text.encode("utf-8")
335
+
336
+ if not dest.exists():
337
+ dest.write_bytes(expected_content)
338
+ print(f"Wrote docker-compose.yml to: {dest}")
339
+ return dest
340
+
341
+ if dest.read_bytes() == expected_content:
342
+ print("docker-compose.yml is already up to date.")
343
+ return dest
344
+
345
+ current_text = dest.read_text(encoding="utf-8")
346
+ changes = []
347
+ unspliceable = []
348
+
349
+ for name in sorted(enabled_services):
350
+ spliced = _splice_optional_service_block(current_text, packaged_text, name)
351
+ if spliced is None:
352
+ unspliceable.append(name)
353
+ elif spliced != current_text:
354
+ current_text = spliced
355
+ changes.append(f"added the optional '{name}' service")
356
+
357
+ for name in sorted(removed_services):
358
+ stripped = _strip_optional_service_block(current_text, name)
359
+ if stripped != current_text:
360
+ current_text = stripped
361
+ changes.append(f"removed the optional '{name}' service")
362
+
363
+ if current_text.encode("utf-8") != expected_content:
364
+ reconciled = _reconcile_version_header(current_text, expected_text)
365
+ if reconciled is not None:
366
+ current_text = reconciled
367
+ changes.append(
368
+ f"updated the version header to {_compose_version(expected_text)}"
369
+ )
370
+
371
+ if changes:
372
+ dest.write_text(current_text, encoding="utf-8")
373
+ print(f"Updated docker-compose.yml: {', '.join(changes)}.")
374
+
375
+ if current_text.encode("utf-8") == expected_content:
376
+ return dest
377
+
378
+ current_version = _compose_version(current_text)
379
+ yours = f"Version {current_version}" if current_version else "no Version header"
380
+ latest = dest.with_name("docker-compose.latest.yml")
381
+ latest.write_bytes(expected_content)
382
+ messages = [
383
+ "docker-compose.yml differs from the packaged version "
384
+ f"(yours: {yours}, packaged: Version {_compose_version(expected_text)}); "
385
+ f"leaving it untouched and writing the latest packaged copy to: {latest}"
386
+ ]
387
+ for name in unspliceable:
388
+ messages.append(
389
+ f"Could not find a top-level 'volumes:' line to splice the "
390
+ f"'{name}' service into — merge it from {latest.name} by hand."
391
+ )
392
+ print("\n".join(messages))
393
+ return dest
394
+
395
+
396
+ def _stdin_is_interactive():
397
+ return sys.stdin.isatty()
398
+
399
+
400
+ def _resolve_optional_services(env_path, flags):
401
+ """Fold the explicit flags, the recorded decisions and (on a TTY) the
402
+ operator's answers into (enabled, removed, decisions_to_record)."""
403
+ decisions = _read_optional_service_decisions(env_path)
404
+ enabled, removed, to_record = set(), set(), []
405
+ prompts_skipped = False
406
+
407
+ for name, label in OPTIONAL_SERVICES.items():
408
+ flag = flags.get(name)
409
+ if flag is not None:
410
+ # An explicit flag always wins and rewrites the recorded answer.
411
+ (enabled if flag else removed).add(name)
412
+ to_record.append((name, flag))
413
+ elif name in decisions:
414
+ if decisions[name]:
415
+ enabled.add(name)
416
+ elif _stdin_is_interactive():
417
+ answer = click.confirm(
418
+ f"Include the optional {label} service in docker-compose.yml?",
419
+ default=False,
420
+ )
421
+ if answer:
422
+ enabled.add(name)
423
+ to_record.append((name, answer))
424
+ else:
425
+ prompts_skipped = True
426
+
427
+ if prompts_skipped:
428
+ print(
429
+ "Skipping optional-service prompts (non-interactive run); "
430
+ "use --with-alerts/--without-alerts to decide."
431
+ )
432
+ return enabled, removed, to_record
433
+
434
+
435
+ @click.command(help="Prepare the installation of Arcsecond.local.")
436
+ @click.option(
437
+ "--with-alerts/--without-alerts",
438
+ "with_alerts",
439
+ default=None,
440
+ help="Include (or remove) the optional transient-alerts (ToO) service in "
441
+ "docker-compose.yml without prompting.",
442
+ )
443
+ @basic_options
444
+ def setup(with_alerts):
445
+ click.echo("\nWelcome to Arcsecond.local setup.")
446
+ click.echo(
447
+ "\nThis will write or update two files in this folder (.env and docker-compose.yml)."
448
+ )
449
+ click.echo("")
450
+
451
+ env_path = Path.cwd() / ENV_FILENAME
452
+ enabled, removed, to_record = _resolve_optional_services(
453
+ env_path, {"alerts": with_alerts}
454
+ )
455
+ write_env_file()
456
+ for name, answer in to_record:
457
+ _record_optional_service_decision(env_path, name, answer)
458
+ write_docker_compose_file(enabled_services=enabled, removed_services=removed)
459
+
460
+ if "alerts" in enabled:
461
+ click.echo(
462
+ "\nTransient alerts next steps: create GCN credentials (see "
463
+ "https://docs.arcsecond.io/local/transient-alerts), paste them into "
464
+ ".env as GCN_CONSUMER_CLIENT_ID / GCN_CONSUMER_CLIENT_SECRET, then "
465
+ "run: docker compose up -d"
466
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "3.16.0"
7
+ version = "3.16.1"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]