arcsecond 4.4.1__tar.gz → 4.4.3__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 (136) hide show
  1. {arcsecond-4.4.1 → arcsecond-4.4.3}/PKG-INFO +1 -1
  2. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/composedoc.py +32 -2
  3. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/docker/docker-compose.yml +19 -7
  4. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/doctor.py +2 -8
  5. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/local.py +40 -4
  6. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/utils.py +14 -0
  7. {arcsecond-4.4.1 → arcsecond-4.4.3}/pyproject.toml +1 -1
  8. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_composedoc.py +24 -0
  9. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_local.py +69 -0
  10. {arcsecond-4.4.1 → arcsecond-4.4.3}/uv.lock +1 -1
  11. {arcsecond-4.4.1 → arcsecond-4.4.3}/.docker/Dockerfile_postgres +0 -0
  12. {arcsecond-4.4.1 → arcsecond-4.4.3}/.docker/Dockerfile_redis +0 -0
  13. {arcsecond-4.4.1 → arcsecond-4.4.3}/.flake8 +0 -0
  14. {arcsecond-4.4.1 → arcsecond-4.4.3}/.gitattributes +0 -0
  15. {arcsecond-4.4.1 → arcsecond-4.4.3}/.github/dependabot.yml +0 -0
  16. {arcsecond-4.4.1 → arcsecond-4.4.3}/.github/workflows/pythonpublish.yml +0 -0
  17. {arcsecond-4.4.1 → arcsecond-4.4.3}/.github/workflows/tests.yml +0 -0
  18. {arcsecond-4.4.1 → arcsecond-4.4.3}/.gitignore +0 -0
  19. {arcsecond-4.4.1 → arcsecond-4.4.3}/LICENSE +0 -0
  20. {arcsecond-4.4.1 → arcsecond-4.4.3}/Makefile +0 -0
  21. {arcsecond-4.4.1 → arcsecond-4.4.3}/README.md +0 -0
  22. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/__init__.py +0 -0
  23. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/__version__.py +0 -0
  24. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/alpaca/__init__.py +0 -0
  25. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/alpaca/commands.py +0 -0
  26. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/alpaca/dome_probe.py +0 -0
  27. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/alpaca/probe.py +0 -0
  28. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/alpaca/telescope_probe.py +0 -0
  29. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/api/__init__.py +0 -0
  30. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/api/config.py +0 -0
  31. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/api/constants.py +0 -0
  32. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/api/endpoint.py +0 -0
  33. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/api/main.py +0 -0
  34. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/api/resources.py +0 -0
  35. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cli.py +0 -0
  36. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/__init__.py +0 -0
  37. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/auth.py +0 -0
  38. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/resources.py +0 -0
  39. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/__init__.py +0 -0
  40. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/constants.py +0 -0
  41. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/context.py +0 -0
  42. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  43. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  44. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  45. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  46. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  47. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/errors.py +0 -0
  48. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/logger.py +0 -0
  49. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/uploader.py +0 -0
  50. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/utils.py +0 -0
  51. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploader/walker.py +0 -0
  52. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/cloud/uploads.py +0 -0
  53. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/docgen.py +0 -0
  54. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/errors.py +0 -0
  55. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/__init__.py +0 -0
  56. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/backups.py +0 -0
  57. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/database.py +0 -0
  58. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/docker/__init__.py +0 -0
  59. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/lifecycle.py +0 -0
  60. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/network/__init__.py +0 -0
  61. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/network/guard.py +0 -0
  62. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/network/manifest.toml +0 -0
  63. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/network/outbound.py +0 -0
  64. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/network/probe.py +0 -0
  65. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/network/sheet.py +0 -0
  66. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/skybrightness.py +0 -0
  67. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/stack.py +0 -0
  68. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/hosting/token.py +0 -0
  69. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/__init__.py +0 -0
  70. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/autostart.py +0 -0
  71. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/commands.py +0 -0
  72. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/detection.py +0 -0
  73. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/proxy.py +0 -0
  74. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/registry.py +0 -0
  75. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/runtime.py +0 -0
  76. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/sources/__init__.py +0 -0
  77. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/sources/base.py +0 -0
  78. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/sources/filewatch.py +0 -0
  79. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/sources/mdns.py +0 -0
  80. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/sources/network.py +0 -0
  81. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/sources/opencv.py +0 -0
  82. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/imagesources/store.py +0 -0
  83. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/options.py +0 -0
  84. {arcsecond-4.4.1 → arcsecond-4.4.3}/arcsecond/targets.py +0 -0
  85. {arcsecond-4.4.1 → arcsecond-4.4.3}/examples/example_upload_files.py +0 -0
  86. {arcsecond-4.4.1 → arcsecond-4.4.3}/requirements.txt +0 -0
  87. {arcsecond-4.4.1 → arcsecond-4.4.3}/setup.cfg +0 -0
  88. {arcsecond-4.4.1 → arcsecond-4.4.3}/setup.py +0 -0
  89. {arcsecond-4.4.1 → arcsecond-4.4.3}/sonar-project.properties +0 -0
  90. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/__init__.py +0 -0
  91. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/api/__init__.py +0 -0
  92. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/api/test_api.py +0 -0
  93. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/api/test_api_endpoint.py +0 -0
  94. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/api/test_config.py +0 -0
  95. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/api/test_config_pointer.py +0 -0
  96. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/api/test_targets.py +0 -0
  97. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/__init__.py +0 -0
  98. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/__init__.py +0 -0
  99. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  100. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  101. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  102. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  103. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  104. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  105. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/conftest.py +0 -0
  106. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/fixtures/docker-compose-6.2.yml +0 -0
  107. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/fixtures/docker-compose-6.4.yml +0 -0
  108. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/fixtures/file1.fits +0 -0
  109. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_alpaca_probe.py +0 -0
  110. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_cli.py +0 -0
  111. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_cli_api_commands.py +0 -0
  112. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_docgen.py +0 -0
  113. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_backups_install_dir.py +0 -0
  114. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_backups_restore.py +0 -0
  115. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_database.py +0 -0
  116. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_doctor.py +0 -0
  117. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_lifecycle.py +0 -0
  118. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_network_guard.py +0 -0
  119. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_network_manifest.py +0 -0
  120. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_network_outbound.py +0 -0
  121. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_network_probe.py +0 -0
  122. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_network_sheet.py +0 -0
  123. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_skybrightness.py +0 -0
  124. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_stack.py +0 -0
  125. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_token.py +0 -0
  126. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_hosting_utils_secret_key.py +0 -0
  127. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_autostart.py +0 -0
  128. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_commands.py +0 -0
  129. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_detection.py +0 -0
  130. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_mdns.py +0 -0
  131. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_network.py +0 -0
  132. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_proxy.py +0 -0
  133. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_store.py +0 -0
  134. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_imagesources_webcam.py +0 -0
  135. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/test_targets_planning.py +0 -0
  136. {arcsecond-4.4.1 → arcsecond-4.4.3}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 4.4.1
3
+ Version: 4.4.3
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
@@ -34,6 +34,7 @@ from .local import (
34
34
  MARKER_START = re.compile(r"^\s*# >>> arcsecond:(\S+)\s*$")
35
35
  MARKER_END = re.compile(r"^\s*# <<< arcsecond:(\S+)\s*$")
36
36
  ENV_REF = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)(?::-[^}]*)?\}")
37
+ ENV_DEFAULT = re.compile(r"^\$\{[A-Z_][A-Z0-9_]*:-([^}]*)\}$")
37
38
 
38
39
 
39
40
  @dataclass
@@ -58,6 +59,12 @@ def _strip_comment(line: str) -> str:
58
59
  return line.strip().lstrip("#").strip()
59
60
 
60
61
 
62
+ def _as_shipped(value: str) -> str:
63
+ """A `${VAR:-default}` value as an installation that leaves VAR unset runs it."""
64
+ match = ENV_DEFAULT.match(value)
65
+ return match.group(1) if match else value
66
+
67
+
61
68
  class _Reader:
62
69
  """Walks the template line by line, keeping the comments."""
63
70
 
@@ -134,7 +141,7 @@ class _Reader:
134
141
  "stop_grace_period": "stop_grace_period",
135
142
  }
136
143
  if key in scalar:
137
- setattr(self.current, scalar[key], value)
144
+ setattr(self.current, scalar[key], _as_shipped(value))
138
145
  elif key == "healthcheck":
139
146
  self.current.healthcheck = True
140
147
  elif key == "ports" and value.startswith("["):
@@ -226,7 +233,8 @@ ENV_KEYS: Dict[str, tuple] = {
226
233
  OPERATOR,
227
234
  "The address other computers reach the installation at, port included (e.g. 192.168.1.42:5555). The backend "
228
235
  "builds invitation and password-reset links from it. Empty means localhost:5555. "
229
- "Set with `arcsecond setup --lan-host`.",
236
+ "`arcsecond setup` fills it in with this machine's network address when it is empty; "
237
+ "set it with `arcsecond setup --lan-host`.",
230
238
  ),
231
239
  OPTIONAL_SERVICES_ENV_KEY: (
232
240
  SETUP,
@@ -288,6 +296,28 @@ ENV_KEYS: Dict[str, tuple] = {
288
296
  OPERATOR,
289
297
  "How many jobs the data worker runs at once. Default 2; each can hold a few hundred MB.",
290
298
  ),
299
+ "ARCSECOND_API_IMAGE": (
300
+ OPERATOR,
301
+ "The image the backend, both workers, beat and the alerts consumer run instead of the published one: "
302
+ "one built from the backend's sources, e.g. `arcsecond-api:latest`. Empty: the published image. Set "
303
+ "ARCSECOND_API_PULL_POLICY to `never` with it, then `arcsecond restart`.",
304
+ ),
305
+ "ARCSECOND_API_PULL_POLICY": (
306
+ OPERATOR,
307
+ "`never` when ARCSECOND_API_IMAGE names an image that exists on this machine only, so `arcsecond update` "
308
+ "and `arcsecond start --pull` leave it alone rather than fail to download it. Default `missing`.",
309
+ ),
310
+ "ARCSECOND_WEB_IMAGE": (
311
+ OPERATOR,
312
+ "The image the web interface runs instead of the published one: one built from the webapp's sources, "
313
+ "e.g. `arcsecond-web:latest`. Empty: the published image. Set ARCSECOND_WEB_PULL_POLICY to `never` "
314
+ "with it, then `arcsecond restart web`.",
315
+ ),
316
+ "ARCSECOND_WEB_PULL_POLICY": (
317
+ OPERATOR,
318
+ "`never` when ARCSECOND_WEB_IMAGE names an image that exists on this machine only, as "
319
+ "ARCSECOND_API_PULL_POLICY does for the backend's. Default `missing`.",
320
+ ),
291
321
  }
292
322
 
293
323
 
@@ -1,6 +1,6 @@
1
1
  # === Arcsecond.local config. ===
2
2
  # *** DO NOT change any name or port number. ***
3
- # Version 7.4
3
+ # Version 7.5
4
4
 
5
5
  services:
6
6
  # Database (PostgresQL)
@@ -29,7 +29,12 @@ services:
29
29
 
30
30
  # Arcsecond backend (REST APIs). Can be used for API calls and external pipelines, scripts etc.
31
31
  backend:
32
- image: ghcr.io/arcsecond-io/arcsecond-api:latest
32
+ # ARCSECOND_API_IMAGE swaps the published image for one built from the
33
+ # backend's sources, here and on every service below that runs it.
34
+ # ARCSECOND_API_PULL_POLICY=never goes with it: such an image exists on
35
+ # this machine only, and `arcsecond update` must not try to download it.
36
+ image: ${ARCSECOND_API_IMAGE:-ghcr.io/arcsecond-io/arcsecond-api:latest}
37
+ pull_policy: ${ARCSECOND_API_PULL_POLICY:-missing}
33
38
  container_name: arcsecond-api
34
39
  restart: unless-stopped
35
40
  entrypoint: /home/app/entrypoint-server.sh
@@ -72,7 +77,8 @@ services:
72
77
  # Arcsecond worker, for offloading background work: the Control Room's
73
78
  # exposures and procedures, and everything else someone is waiting on.
74
79
  worker:
75
- image: ghcr.io/arcsecond-io/arcsecond-api:latest
80
+ image: ${ARCSECOND_API_IMAGE:-ghcr.io/arcsecond-io/arcsecond-api:latest}
81
+ pull_policy: ${ARCSECOND_API_PULL_POLICY:-missing}
76
82
  container_name: arcsecond-worker
77
83
  restart: unless-stopped
78
84
  entrypoint: /home/app/entrypoint-worker.sh
@@ -100,7 +106,8 @@ services:
100
106
  # storages, database backups, keograms, catalogue refreshes. On its own
101
107
  # queue, so a long zip can never hold up a frame the observer is waiting for.
102
108
  dataworker:
103
- image: ghcr.io/arcsecond-io/arcsecond-api:latest
109
+ image: ${ARCSECOND_API_IMAGE:-ghcr.io/arcsecond-io/arcsecond-api:latest}
110
+ pull_policy: ${ARCSECOND_API_PULL_POLICY:-missing}
104
111
  container_name: arcsecond-dataworker
105
112
  restart: unless-stopped
106
113
  entrypoint: /home/app/entrypoint-worker.sh
@@ -126,7 +133,8 @@ services:
126
133
 
127
134
  # Arcsecond beat, to launch specific background tasks at specific times.
128
135
  beat:
129
- image: ghcr.io/arcsecond-io/arcsecond-api:latest
136
+ image: ${ARCSECOND_API_IMAGE:-ghcr.io/arcsecond-io/arcsecond-api:latest}
137
+ pull_policy: ${ARCSECOND_API_PULL_POLICY:-missing}
130
138
  container_name: arcsecond-beat
131
139
  restart: unless-stopped
132
140
  entrypoint: /home/app/entrypoint-beat.sh
@@ -149,7 +157,10 @@ services:
149
157
  # organisation-based and therefore always a portal, so the second port that
150
158
  # used to be here (5577) served the very same app.
151
159
  web:
152
- image: ghcr.io/arcsecond-io/arcsecond-web:latest
160
+ # ARCSECOND_WEB_IMAGE and ARCSECOND_WEB_PULL_POLICY: the same as the
161
+ # backend's pair above, for an image built from the webapp's sources.
162
+ image: ${ARCSECOND_WEB_IMAGE:-ghcr.io/arcsecond-io/arcsecond-web:latest}
163
+ pull_policy: ${ARCSECOND_WEB_PULL_POLICY:-missing}
153
164
  container_name: arcsecond-web
154
165
  restart: unless-stopped
155
166
  depends_on:
@@ -183,7 +194,8 @@ services:
183
194
  # GCN_CONSUMER_CLIENT_SECRET in .env; idles harmlessly when they are absent.
184
195
  # Single instance only — do not scale it.
185
196
  alerts:
186
- image: ghcr.io/arcsecond-io/arcsecond-api:latest
197
+ image: ${ARCSECOND_API_IMAGE:-ghcr.io/arcsecond-io/arcsecond-api:latest}
198
+ pull_policy: ${ARCSECOND_API_PULL_POLICY:-missing}
187
199
  container_name: arcsecond-alerts
188
200
  restart: unless-stopped
189
201
  entrypoint: /home/app/entrypoint-alerts.sh
@@ -58,6 +58,7 @@ from .local import (
58
58
  REQUIRED_ENV_PROVIDERS,
59
59
  template_versions,
60
60
  )
61
+ from .utils import lan_ipv4
61
62
 
62
63
  OK, WARN, FAIL, SKIP = "ok", "warn", "fail", "skip"
63
64
 
@@ -236,14 +237,7 @@ class Host:
236
237
  return parse_ss(result.stdout)
237
238
 
238
239
  def lan_ipv4(self) -> Optional[str]:
239
- # No packet is sent: connecting a UDP socket only picks the interface
240
- # the default route would use, and that is the address to hand around.
241
- try:
242
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
243
- s.connect(("10.255.255.255", 1))
244
- return s.getsockname()[0]
245
- except OSError:
246
- return None
240
+ return lan_ipv4()
247
241
 
248
242
  def http_ok(self, url: str) -> Optional[int]:
249
243
  try:
@@ -15,7 +15,9 @@ from .utils import (
15
15
  _get_encryption_key,
16
16
  _get_random_postgres_password,
17
17
  _get_random_secret_key,
18
+ _read_env_value,
18
19
  _set_env_value,
20
+ lan_ipv4,
19
21
  )
20
22
 
21
23
  ENV_FILENAME = ".env"
@@ -113,7 +115,8 @@ REQUIRED_ENV_PROVIDERS = {
113
115
  # Empty placeholders: the operator pastes their own GCN credentials here.
114
116
  "GCN_CONSUMER_CLIENT_ID": lambda: "",
115
117
  "GCN_CONSUMER_CLIENT_SECRET": lambda: "",
116
- # Empty placeholder: set by `arcsecond setup --lan-host`, or by hand.
118
+ # Empty placeholder: filled in by `setup` with this machine's LAN address
119
+ # (_fill_in_lan_host), or set by `arcsecond setup --lan-host`, or by hand.
117
120
  FRONTEND_HOST_ENV_KEY: lambda: "",
118
121
  }
119
122
 
@@ -500,6 +503,30 @@ def _normalise_lan_host(value):
500
503
  return value
501
504
 
502
505
 
506
+ # What `--lan-host ""` writes. Spelled out rather than left empty, so that the
507
+ # next `setup` does not take an empty value for "never decided" and declare the
508
+ # LAN address over the operator's choice.
509
+ THIS_MACHINE_ONLY_HOST = "localhost:5555"
510
+
511
+
512
+ def _fill_in_lan_host(env_path):
513
+ """Declare this machine's LAN address when no address is declared yet.
514
+
515
+ Left empty, every invitation and password-reset link the server emails
516
+ says localhost, which only works on this machine. A value already there,
517
+ whoever wrote it, is never touched: `doctor` is what notices when a
518
+ declared IP no longer matches the machine.
519
+ """
520
+ if (_read_env_value(FRONTEND_HOST_ENV_KEY, env_path) or "").strip():
521
+ return None
522
+ detected = lan_ipv4()
523
+ if not detected or detected.startswith("127."):
524
+ return None
525
+ host = _normalise_lan_host(detected)
526
+ _set_env_value(env_path, FRONTEND_HOST_ENV_KEY, host)
527
+ return host
528
+
529
+
503
530
  @click.command(short_help="Prepare the installation of Arcsecond.local.")
504
531
  @click.option(
505
532
  "--with-alerts/--without-alerts",
@@ -515,7 +542,9 @@ def _normalise_lan_host(value):
515
542
  metavar="HOST[:PORT]",
516
543
  help="The address other computers reach this machine at (e.g. "
517
544
  "192.168.1.42 or arcsecond.local). Needed for invitation and "
518
- "password-reset links to work from other computers. Port defaults to 5555.",
545
+ "password-reset links to work from other computers. Port defaults to 5555. "
546
+ "Without it, setup declares this machine's network address when none is "
547
+ "declared yet; an empty value keeps the links on this machine.",
519
548
  )
520
549
  @click.option(
521
550
  "--with-sky-map/--without-sky-map",
@@ -549,12 +578,19 @@ def setup(with_alerts, lan_host, with_sky_map):
549
578
  for name, answer in to_record:
550
579
  _record_optional_service_decision(env_path, name, answer)
551
580
  if lan_host is not None:
552
- host = _normalise_lan_host(lan_host)
581
+ host = _normalise_lan_host(lan_host) or THIS_MACHINE_ONLY_HOST
553
582
  _set_env_value(env_path, FRONTEND_HOST_ENV_KEY, host)
554
- if host:
583
+ if host != THIS_MACHINE_ONLY_HOST:
555
584
  print(f"Other computers will reach this installation at http://{host}")
556
585
  else:
557
586
  print("This installation is reachable from this machine only.")
587
+ else:
588
+ host = _fill_in_lan_host(env_path)
589
+ if host:
590
+ print(
591
+ f"Other computers will reach this installation at http://{host}"
592
+ " (this machine's network address; change it with --lan-host)"
593
+ )
558
594
  write_docker_compose_file(enabled_services=enabled, directory=directory)
559
595
 
560
596
  from .stack import remember_install_dir
@@ -1,10 +1,24 @@
1
1
  import base64
2
2
  import os
3
3
  import secrets
4
+ import socket
4
5
  import subprocess
5
6
  from pathlib import Path
6
7
 
7
8
 
9
+ def lan_ipv4():
10
+ """This machine's address on the local network, or None.
11
+
12
+ No packet is sent: connecting a UDP socket only picks the interface the
13
+ default route would use, and that is the address to hand around."""
14
+ try:
15
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
16
+ s.connect(("10.255.255.255", 1))
17
+ return s.getsockname()[0]
18
+ except OSError:
19
+ return None
20
+
21
+
8
22
  def _container_running(name):
9
23
  try:
10
24
  out = subprocess.run(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "4.4.1"
7
+ version = "4.4.3"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]
@@ -58,6 +58,30 @@ def test_optional_services_are_recognised_by_their_markers():
58
58
  assert "GCN_CONSUMER_CLIENT_ID" in " ".join(s["alerts"].description)
59
59
 
60
60
 
61
+ def test_every_service_on_the_backend_image_can_be_given_a_local_build():
62
+ s = _services()
63
+ api = [name for name, service in s.items() if "arcsecond-api" in service.image]
64
+ assert api == ["backend", "worker", "dataworker", "beat", "alerts"]
65
+ for name in api:
66
+ assert "ARCSECOND_API_IMAGE" in s[name].env_vars, name
67
+ assert "ARCSECOND_API_PULL_POLICY" in s[name].env_vars, name
68
+
69
+
70
+ def test_the_web_interface_can_be_given_a_local_build():
71
+ web = _services()["web"]
72
+ assert (
73
+ web.image == "ghcr.io/arcsecond-io/arcsecond-web:latest"
74
+ ) # what an unset key runs
75
+ assert "ARCSECOND_WEB_IMAGE" in web.env_vars
76
+ assert "ARCSECOND_WEB_PULL_POLICY" in web.env_vars
77
+
78
+
79
+ def test_every_key_the_template_reads_is_documented():
80
+ for service in composedoc.parse_services(local.packaged_compose_text()):
81
+ for key in service.env_vars:
82
+ assert key in composedoc.ENV_KEYS, (service.name, key)
83
+
84
+
61
85
  def test_every_key_setup_writes_is_documented():
62
86
  for key in local.REQUIRED_ENV_PROVIDERS:
63
87
  assert key in composedoc.ENV_KEYS, key
@@ -17,6 +17,9 @@ def _stub_env_generators(monkeypatch):
17
17
  monkeypatch.setattr(local, "prompt_shared_data_path", lambda: "/tmp/shared-data")
18
18
  monkeypatch.setattr(local, "_offer_token", lambda: None)
19
19
  monkeypatch.setattr(local, "_offer_sky_map", lambda env_path, flag: None)
20
+ # The machine running the tests has a LAN address of its own; tests that
21
+ # want one give it.
22
+ monkeypatch.setattr(local, "lan_ipv4", lambda: None)
20
23
 
21
24
 
22
25
  def _packaged_compose_text():
@@ -495,6 +498,72 @@ def test_setup_lan_host_refuses_a_scheme_or_a_path(tmp_path, monkeypatch):
495
498
  assert "without scheme" in result.output
496
499
 
497
500
 
501
+ def _setup_on_lan(tmp_path, monkeypatch, lan, *args):
502
+ monkeypatch.chdir(tmp_path)
503
+ _stub_env_generators(monkeypatch)
504
+ monkeypatch.setattr(local, "_stdin_is_interactive", lambda: False)
505
+ monkeypatch.setattr(local, "lan_ipv4", lambda: lan)
506
+ return CliRunner().invoke(local.setup, list(args))
507
+
508
+
509
+ def _declared(tmp_path):
510
+ for line in (tmp_path / ".env").read_text(encoding="utf-8").splitlines():
511
+ if line.startswith(local.FRONTEND_HOST_ENV_KEY + "="):
512
+ return line.split("=", 1)[1]
513
+ return None
514
+
515
+
516
+ def test_setup_declares_the_lan_address_when_none_is_declared(tmp_path, monkeypatch):
517
+ result = _setup_on_lan(tmp_path, monkeypatch, "192.168.1.42")
518
+ assert result.exit_code == 0, result.output
519
+ assert _declared(tmp_path) == "192.168.1.42:5555"
520
+ assert "http://192.168.1.42:5555" in result.output
521
+ assert "--lan-host" in result.output
522
+
523
+
524
+ def test_setup_fills_in_an_empty_address_left_by_an_older_setup(tmp_path, monkeypatch):
525
+ (tmp_path / ".env").write_text(f"{local.FRONTEND_HOST_ENV_KEY}=\n")
526
+ result = _setup_on_lan(tmp_path, monkeypatch, "192.168.1.42")
527
+ assert result.exit_code == 0, result.output
528
+ assert _declared(tmp_path) == "192.168.1.42:5555"
529
+
530
+
531
+ def test_setup_never_overwrites_a_declared_address(tmp_path, monkeypatch):
532
+ (tmp_path / ".env").write_text(
533
+ f"{local.FRONTEND_HOST_ENV_KEY}=arcsecond.local:5555\n"
534
+ )
535
+ result = _setup_on_lan(tmp_path, monkeypatch, "192.168.1.42")
536
+ assert result.exit_code == 0, result.output
537
+ assert _declared(tmp_path) == "arcsecond.local:5555"
538
+ assert "192.168.1.42" not in result.output
539
+
540
+
541
+ def test_setup_lan_host_wins_over_the_detected_address(tmp_path, monkeypatch):
542
+ result = _setup_on_lan(
543
+ tmp_path, monkeypatch, "192.168.1.42", "--lan-host", "arcsecond.local"
544
+ )
545
+ assert result.exit_code == 0, result.output
546
+ assert _declared(tmp_path) == "arcsecond.local:5555"
547
+
548
+
549
+ def test_setup_leaves_the_address_empty_without_a_network(tmp_path, monkeypatch):
550
+ for lan in (None, "127.0.0.1"):
551
+ result = _setup_on_lan(tmp_path, monkeypatch, lan)
552
+ assert result.exit_code == 0, result.output
553
+ assert _declared(tmp_path) == ""
554
+
555
+
556
+ def test_an_empty_lan_host_keeps_the_links_on_this_machine_across_setups(
557
+ tmp_path, monkeypatch
558
+ ):
559
+ first = _setup_on_lan(tmp_path, monkeypatch, "192.168.1.42", "--lan-host", "")
560
+ assert first.exit_code == 0, first.output
561
+ assert "this machine only" in first.output
562
+ second = _setup_on_lan(tmp_path, monkeypatch, "192.168.1.42")
563
+ assert second.exit_code == 0, second.output
564
+ assert _declared(tmp_path) == local.THIS_MACHINE_ONLY_HOST
565
+
566
+
498
567
  def test_setup_remembers_the_folder_for_start_to_find(tmp_path, monkeypatch):
499
568
  from arcsecond.hosting import stack
500
569
 
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "arcsecond"
158
- version = "4.4.1"
158
+ version = "4.4.3"
159
159
  source = { editable = "." }
160
160
  dependencies = [
161
161
  { name = "alpyca" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes