arcsecond 4.2.1__tar.gz → 4.3.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 (121) hide show
  1. {arcsecond-4.2.1 → arcsecond-4.3.1}/PKG-INFO +1 -1
  2. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/backups.py +4 -0
  3. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/composedoc.py +31 -0
  4. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/database.py +10 -6
  5. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/docker/docker-compose.yml +57 -2
  6. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/lifecycle.py +1 -1
  7. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/token.py +43 -2
  8. {arcsecond-4.2.1 → arcsecond-4.3.1}/pyproject.toml +1 -1
  9. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_composedoc.py +1 -0
  10. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_database.py +17 -0
  11. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_token.py +36 -0
  12. {arcsecond-4.2.1 → arcsecond-4.3.1}/uv.lock +1 -1
  13. {arcsecond-4.2.1 → arcsecond-4.3.1}/.docker/Dockerfile_postgres +0 -0
  14. {arcsecond-4.2.1 → arcsecond-4.3.1}/.docker/Dockerfile_redis +0 -0
  15. {arcsecond-4.2.1 → arcsecond-4.3.1}/.flake8 +0 -0
  16. {arcsecond-4.2.1 → arcsecond-4.3.1}/.gitattributes +0 -0
  17. {arcsecond-4.2.1 → arcsecond-4.3.1}/.github/dependabot.yml +0 -0
  18. {arcsecond-4.2.1 → arcsecond-4.3.1}/.github/workflows/pythonpublish.yml +0 -0
  19. {arcsecond-4.2.1 → arcsecond-4.3.1}/.github/workflows/tests.yml +0 -0
  20. {arcsecond-4.2.1 → arcsecond-4.3.1}/.gitignore +0 -0
  21. {arcsecond-4.2.1 → arcsecond-4.3.1}/LICENSE +0 -0
  22. {arcsecond-4.2.1 → arcsecond-4.3.1}/Makefile +0 -0
  23. {arcsecond-4.2.1 → arcsecond-4.3.1}/README.md +0 -0
  24. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/__init__.py +0 -0
  25. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/__version__.py +0 -0
  26. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/alpaca/__init__.py +0 -0
  27. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/alpaca/commands.py +0 -0
  28. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/alpaca/dome_probe.py +0 -0
  29. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/alpaca/probe.py +0 -0
  30. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/alpaca/telescope_probe.py +0 -0
  31. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/api/__init__.py +0 -0
  32. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/api/config.py +0 -0
  33. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/api/constants.py +0 -0
  34. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/api/endpoint.py +0 -0
  35. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/api/main.py +0 -0
  36. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/api/resources.py +0 -0
  37. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cli.py +0 -0
  38. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/__init__.py +0 -0
  39. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/auth.py +0 -0
  40. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/resources.py +0 -0
  41. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/__init__.py +0 -0
  42. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/constants.py +0 -0
  43. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/context.py +0 -0
  44. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  45. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  46. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  47. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  48. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  49. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/errors.py +0 -0
  50. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/logger.py +0 -0
  51. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/uploader.py +0 -0
  52. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/utils.py +0 -0
  53. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploader/walker.py +0 -0
  54. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/cloud/uploads.py +0 -0
  55. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/docgen.py +0 -0
  56. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/errors.py +0 -0
  57. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/__init__.py +0 -0
  58. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/check.py +0 -0
  59. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/docker/__init__.py +0 -0
  60. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/local.py +0 -0
  61. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/stack.py +0 -0
  62. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/hosting/utils.py +0 -0
  63. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/__init__.py +0 -0
  64. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/autostart.py +0 -0
  65. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/commands.py +0 -0
  66. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/detection.py +0 -0
  67. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/proxy.py +0 -0
  68. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/registry.py +0 -0
  69. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/runtime.py +0 -0
  70. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/sources/__init__.py +0 -0
  71. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/sources/base.py +0 -0
  72. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/sources/filewatch.py +0 -0
  73. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/sources/mdns.py +0 -0
  74. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/sources/network.py +0 -0
  75. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/sources/opencv.py +0 -0
  76. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/imagesources/store.py +0 -0
  77. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/options.py +0 -0
  78. {arcsecond-4.2.1 → arcsecond-4.3.1}/arcsecond/targets.py +0 -0
  79. {arcsecond-4.2.1 → arcsecond-4.3.1}/examples/example_upload_files.py +0 -0
  80. {arcsecond-4.2.1 → arcsecond-4.3.1}/requirements.txt +0 -0
  81. {arcsecond-4.2.1 → arcsecond-4.3.1}/setup.cfg +0 -0
  82. {arcsecond-4.2.1 → arcsecond-4.3.1}/setup.py +0 -0
  83. {arcsecond-4.2.1 → arcsecond-4.3.1}/sonar-project.properties +0 -0
  84. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/__init__.py +0 -0
  85. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/api/__init__.py +0 -0
  86. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/api/test_api.py +0 -0
  87. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/api/test_api_endpoint.py +0 -0
  88. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/api/test_config.py +0 -0
  89. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/api/test_config_pointer.py +0 -0
  90. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/api/test_targets.py +0 -0
  91. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/__init__.py +0 -0
  92. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/__init__.py +0 -0
  93. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  94. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  95. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  96. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  97. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  98. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  99. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/conftest.py +0 -0
  100. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/fixtures/file1.fits +0 -0
  101. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_alpaca_probe.py +0 -0
  102. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_cli.py +0 -0
  103. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_cli_api_commands.py +0 -0
  104. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_docgen.py +0 -0
  105. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_backups_install_dir.py +0 -0
  106. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_backups_restore.py +0 -0
  107. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_check.py +0 -0
  108. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_lifecycle.py +0 -0
  109. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_local.py +0 -0
  110. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_stack.py +0 -0
  111. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_hosting_utils_secret_key.py +0 -0
  112. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_autostart.py +0 -0
  113. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_commands.py +0 -0
  114. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_detection.py +0 -0
  115. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_mdns.py +0 -0
  116. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_network.py +0 -0
  117. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_proxy.py +0 -0
  118. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_store.py +0 -0
  119. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_imagesources_webcam.py +0 -0
  120. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/test_targets_planning.py +0 -0
  121. {arcsecond-4.2.1 → arcsecond-4.3.1}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 4.2.1
3
+ Version: 4.3.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
@@ -21,6 +21,9 @@ from .utils import _read_env_value as _read_env_value_from
21
21
  DB_CONTAINER = "arcsecond-db"
22
22
  API_CONTAINER = "arcsecond-api"
23
23
  WORKER_CONTAINER = "arcsecond-worker"
24
+ # Since docker-compose.yml 7.3; absent on older installs, which the stop loop
25
+ # and the docker-start fallback both tolerate, as for alerts below.
26
+ DATA_WORKER_CONTAINER = "arcsecond-dataworker"
24
27
  BEAT_CONTAINER = "arcsecond-beat"
25
28
  WEB_CONTAINER = "arcsecond-web"
26
29
  # Optional service: absent on most installs. The stop loop skips it when it
@@ -31,6 +34,7 @@ ALERTS_CONTAINER = "arcsecond-alerts"
31
34
  SERVICES_TO_STOP = [
32
35
  API_CONTAINER,
33
36
  WORKER_CONTAINER,
37
+ DATA_WORKER_CONTAINER,
34
38
  ALERTS_CONTAINER,
35
39
  BEAT_CONTAINER,
36
40
  WEB_CONTAINER,
@@ -251,6 +251,37 @@ ENV_KEYS: Dict[str, tuple] = {
251
251
  "How long a new member may use the installation before verifying their email, "
252
252
  "on an installation with no mail server.",
253
253
  ),
254
+ "ARCSECOND_DOWNLOADS_PER_CLIENT": (
255
+ OPERATOR,
256
+ "How many downloads one computer may have open at once; past it, the next one is refused with a 429 "
257
+ "until one finishes. Default 6. Then `arcsecond restart web`.",
258
+ ),
259
+ "ARCSECOND_DOWNLOADS_TOTAL": (
260
+ OPERATOR,
261
+ "How many downloads the installation serves at once, all computers together. Default 24.",
262
+ ),
263
+ "ARCSECOND_DOWNLOAD_RATE": (
264
+ OPERATOR,
265
+ "The speed of each download, in bytes per second, `k` and `m` allowed; `0` for no limit. Default `20m`. "
266
+ "Lower it when the observers work remotely over a link the downloads would otherwise fill.",
267
+ ),
268
+ "ARCSECOND_BACKEND_MEMORY": (
269
+ OPERATOR,
270
+ "The most memory the backend container may use, e.g. `6g` (the default). Past it, Docker restarts the "
271
+ "busiest of its processes rather than letting the machine take memory from the database.",
272
+ ),
273
+ "ARCSECOND_WORKER_MEMORY": (
274
+ OPERATOR,
275
+ "The same ceiling for the worker, which runs the exposures and procedures. Default `6g`.",
276
+ ),
277
+ "ARCSECOND_DATA_WORKER_MEMORY": (
278
+ OPERATOR,
279
+ "The same ceiling for the data worker: zip archives, previews, storage pushes, backups. Default `3g`.",
280
+ ),
281
+ "ARCSECOND_DATA_WORKER_CONCURRENCY": (
282
+ OPERATOR,
283
+ "How many jobs the data worker runs at once. Default 2; each can hold a few hundred MB.",
284
+ ),
254
285
  }
255
286
 
256
287
 
@@ -44,16 +44,20 @@ SERVICES_TO_RECREATE = ["backend", "worker", "beat"]
44
44
 
45
45
 
46
46
  def _services_to_recreate():
47
- """The optional alerts consumer joins the recreate list only when the
48
- operator's compose file actually carries it — naming an unknown service
49
- makes `docker compose up` fail outright on installs without the block."""
47
+ """The optional alerts consumer, and the data worker of docker-compose.yml
48
+ 7.3 and later, join the recreate list only when the operator's compose
49
+ file actually carries them — naming an unknown service makes
50
+ `docker compose up` fail outright on installs without them."""
50
51
  services = list(SERVICES_TO_RECREATE)
51
52
  compose_path = Path.cwd() / "docker-compose.yml"
52
53
  try:
53
- if "# >>> arcsecond:alerts" in compose_path.read_text(encoding="utf-8"):
54
- services.append("alerts")
54
+ text = compose_path.read_text(encoding="utf-8")
55
55
  except OSError:
56
- pass
56
+ return services
57
+ if "container_name: arcsecond-dataworker" in text:
58
+ services.append("dataworker")
59
+ if "# >>> arcsecond:alerts" in text:
60
+ services.append("alerts")
57
61
  return services
58
62
 
59
63
 
@@ -1,6 +1,6 @@
1
1
  # === Arcsecond.local config. ===
2
2
  # *** DO NOT change any name or port number. ***
3
- # Version 6.4
3
+ # Version 7.3
4
4
 
5
5
  services:
6
6
  # Database (PostgresQL)
@@ -54,6 +54,10 @@ services:
54
54
  bind:
55
55
  create_host_path: true
56
56
  stop_grace_period: 60s
57
+ # A ceiling, so no burst of work here can take the memory Postgres needs.
58
+ # Downloads no longer pass through these processes (the web container
59
+ # sends them), but rendering a large frame for review still does.
60
+ mem_limit: ${ARCSECOND_BACKEND_MEMORY:-6g}
57
61
  healthcheck:
58
62
  test: [ "CMD", "curl", "-f", "http://localhost:8800/healthcheck/" ]
59
63
  interval: 10s
@@ -61,7 +65,8 @@ services:
61
65
  retries: 12
62
66
  start_period: 120s
63
67
 
64
- # Arcsecond worker, for offloading background work.
68
+ # Arcsecond worker, for offloading background work: the Control Room's
69
+ # exposures and procedures, and everything else someone is waiting on.
65
70
  worker:
66
71
  image: ghcr.io/arcsecond-io/arcsecond-api:latest
67
72
  container_name: arcsecond-worker
@@ -72,6 +77,9 @@ services:
72
77
  condition: service_healthy
73
78
  env_file:
74
79
  - .env
80
+ environment:
81
+ # The default queue only: the data worker below takes the rest.
82
+ ARCSECOND_WORKER_QUEUES: celery
75
83
  volumes:
76
84
  # Leave /data as is, it's a path inside the container, not in the host machine.
77
85
  # SHARED_DATA_PATH must be a path of your host machine, specified in the .env file.
@@ -81,6 +89,36 @@ services:
81
89
  bind:
82
90
  create_host_path: true
83
91
  stop_grace_period: 120s
92
+ mem_limit: ${ARCSECOND_WORKER_MEMORY:-6g}
93
+
94
+ # Arcsecond data worker, for the background work nobody at the telescope is
95
+ # waiting on: zip archives, previews of uploaded files, pushes to your
96
+ # storages, database backups, keograms, catalogue refreshes. On its own
97
+ # queue, so a long zip can never hold up a frame the observer is waiting for.
98
+ dataworker:
99
+ image: ghcr.io/arcsecond-io/arcsecond-api:latest
100
+ container_name: arcsecond-dataworker
101
+ restart: unless-stopped
102
+ entrypoint: /home/app/entrypoint-worker.sh
103
+ depends_on:
104
+ backend:
105
+ condition: service_healthy
106
+ env_file:
107
+ - .env
108
+ environment:
109
+ ARCSECOND_WORKER_QUEUES: data
110
+ # Few at a time: this work is heavy and none of it is urgent.
111
+ ARCSECOND_WORKER_CONCURRENCY: ${ARCSECOND_DATA_WORKER_CONCURRENCY:-2}
112
+ volumes:
113
+ # Leave /data as is, it's a path inside the container, not in the host machine.
114
+ # SHARED_DATA_PATH must be a path of your host machine, specified in the .env file.
115
+ - type: bind
116
+ source: ${SHARED_DATA_PATH}
117
+ target: /data
118
+ bind:
119
+ create_host_path: true
120
+ stop_grace_period: 120s
121
+ mem_limit: ${ARCSECOND_DATA_WORKER_MEMORY:-3g}
84
122
 
85
123
  # Arcsecond beat, to launch specific background tasks at specific times.
86
124
  beat:
@@ -114,6 +152,23 @@ services:
114
152
  condition: service_started
115
153
  ports:
116
154
  - "5555:5555"
155
+ environment:
156
+ # Download limits: how many downloads one computer may have open at
157
+ # once, how many in total, and the speed of each (bytes per second,
158
+ # k and m allowed, 0 for none). They keep downloads from filling the
159
+ # link a remote observer drives the telescope through.
160
+ ARCSECOND_DOWNLOADS_PER_CLIENT: ${ARCSECOND_DOWNLOADS_PER_CLIENT:-6}
161
+ ARCSECOND_DOWNLOADS_TOTAL: ${ARCSECOND_DOWNLOADS_TOTAL:-24}
162
+ ARCSECOND_DOWNLOAD_RATE: ${ARCSECOND_DOWNLOAD_RATE:-20m}
163
+ volumes:
164
+ # The same folder, read-only: this container sends the stored files
165
+ # itself, once the backend has said who may read them.
166
+ - type: bind
167
+ source: ${SHARED_DATA_PATH}
168
+ target: /data
169
+ read_only: true
170
+ bind:
171
+ create_host_path: true
117
172
 
118
173
  # >>> arcsecond:alerts
119
174
  # Arcsecond transient-alerts consumer (optional).
@@ -272,7 +272,7 @@ def status(directory):
272
272
  @basic_options
273
273
  def logs(directory, service, follow, tail):
274
274
  """Show recent logs. Services are named as in `arcsecond status`:
275
- backend, worker, beat, web, db, broker, platesolver, alerts."""
275
+ backend, worker, dataworker, beat, web, db, broker, platesolver, alerts."""
276
276
  install = stack.resolve_install_dir(directory)
277
277
  stack.ensure_docker()
278
278
  args = ["logs", "--tail", str(tail)]
@@ -4,7 +4,8 @@ From the operator's side there is one fact: Arcsecond sent a token, and it
4
4
  has to be entered once so the machine can download Arcsecond.local. What it
5
5
  is underneath — a login to the container registry the images are pulled
6
6
  from, ghcr.io, as the organisation user — is this module's business, not
7
- theirs, and the words "registry" and "docker" stay out of what they read.
7
+ theirs, and the words "registry" and "docker" stay out of what they read —
8
+ except when Docker's own password store is what failed, which only they can fix.
8
9
 
9
10
  The token is asked for without echo, or read from standard input for a
10
11
  script, and handed to Docker over a pipe. There is deliberately no
@@ -96,7 +97,10 @@ def store_token(token: str) -> None:
96
97
  token=token + "\n",
97
98
  )
98
99
  if result.returncode != 0:
99
- detail = (result.stderr or result.stdout or "").strip().splitlines()
100
+ output = (result.stderr or result.stdout or "").strip()
101
+ if _is_credential_store_failure(output):
102
+ raise ArcsecondError(_credential_store_advice(output))
103
+ detail = output.splitlines()
100
104
  reason = detail[-1] if detail else "Docker gave no reason"
101
105
  raise ArcsecondError(
102
106
  f"The token was refused: {reason}\n"
@@ -105,6 +109,43 @@ def store_token(token: str) -> None:
105
109
  )
106
110
 
107
111
 
112
+ # Docker only saves the credential once the registry has accepted it, so these
113
+ # words mean the token was good and the machine's own keychain failed. Docker
114
+ # Desktop on Linux keeps credentials in `pass`, which is empty until someone
115
+ # runs `pass init` — it says so in its install notes, and nobody reads them.
116
+ _CREDENTIAL_STORE_FAILURES = ("error saving credentials", "error storing credentials")
117
+
118
+
119
+ def _is_credential_store_failure(output: str) -> bool:
120
+ lowered = output.lower()
121
+ return any(words in lowered for words in _CREDENTIAL_STORE_FAILURES)
122
+
123
+
124
+ def _credential_store_advice(output: str) -> str:
125
+ lowered = output.lower()
126
+ head = (
127
+ "The token is good — Arcsecond accepted it — but this machine could not "
128
+ "save it: Docker keeps it in a password store that refused to write.\n"
129
+ )
130
+ if "pass init" in lowered or "pass not initialized" in lowered:
131
+ return head + (
132
+ "Docker Desktop on Linux uses `pass`, and it has not been set up yet. "
133
+ "Set it up once, then run `arcsecond token set` again:\n"
134
+ " gpg --generate-key (any name and e-mail; note the key id it prints)\n"
135
+ " pass init <that key id>\n"
136
+ "Or, if this machine runs Docker Engine without Docker Desktop, remove the "
137
+ f'"credsStore" line from {docker_config_path()} and Docker will keep the '
138
+ "token in that file instead."
139
+ )
140
+ detail = output.splitlines()[-1] if output else ""
141
+ return head + (
142
+ f"It said: {detail}\n"
143
+ 'Unlock or repair that store, or remove the "credsStore" line from '
144
+ f"{docker_config_path()} so Docker keeps the token in that file, then run "
145
+ "`arcsecond token set` again."
146
+ )
147
+
148
+
108
149
  def prompt_for_token() -> str:
109
150
  return click.prompt(
110
151
  "Access token from Arcsecond (nothing is shown while you type)", hide_input=True
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arcsecond"
7
- version = "4.2.1"
7
+ version = "4.3.1"
8
8
  authors = [
9
9
  { name = "Cedric Foellmi", email = "cedric@arcsecond.io" },
10
10
  ]
@@ -19,6 +19,7 @@ def test_every_service_of_the_template_is_read():
19
19
  "broker",
20
20
  "backend",
21
21
  "worker",
22
+ "dataworker",
22
23
  "beat",
23
24
  "platesolver",
24
25
  "web",
@@ -328,3 +328,20 @@ def test_failed_recreate_reports_but_keeps_the_new_password(tmp_path, monkeypatc
328
328
  # The rotation itself succeeded, so the file must keep the new value.
329
329
  assert database._read_env_value("POSTGRES_PASSWORD", env_path) == cluster.password
330
330
  assert "password was changed successfully" in result.output
331
+
332
+
333
+ def test_the_data_worker_is_recreated_only_where_the_compose_file_has_it(tmp_path, monkeypatch):
334
+ """docker-compose.yml 7.3 added it; naming it on an older install would
335
+ make `docker compose up` fail outright."""
336
+ monkeypatch.chdir(tmp_path)
337
+ compose = Path(tmp_path) / "docker-compose.yml"
338
+
339
+ compose.write_text("services:\n worker:\n container_name: arcsecond-worker\n", encoding="utf-8")
340
+ assert database._services_to_recreate() == ["backend", "worker", "beat"]
341
+
342
+ compose.write_text(
343
+ "services:\n dataworker:\n container_name: arcsecond-dataworker\n"
344
+ " # >>> arcsecond:alerts\n alerts:\n # <<< arcsecond:alerts\n",
345
+ encoding="utf-8",
346
+ )
347
+ assert database._services_to_recreate() == ["backend", "worker", "beat", "dataworker", "alerts"]
@@ -65,6 +65,42 @@ def test_a_refused_token_is_explained_without_docker_words(docker):
65
65
  assert "registry" not in result.output.lower()
66
66
 
67
67
 
68
+ def _login_fails_with(monkeypatch, stderr):
69
+ def fake_run(cmd, input=None, **kwargs):
70
+ return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=stderr)
71
+
72
+ monkeypatch.setattr(token.subprocess, "run", fake_run)
73
+
74
+
75
+ def test_an_uninitialised_pass_store_is_not_a_refused_token(docker, monkeypatch):
76
+ _login_fails_with(
77
+ monkeypatch,
78
+ "Error saving credentials: error storing credentials - err: exit status 1, "
79
+ 'out: `pass not initialized: exit status 1: Error: password store is empty. Try "pass init".`',
80
+ )
81
+ result = _invoke("token", "set", input="ghp_good\n")
82
+ assert result.exit_code == 1
83
+ assert (
84
+ "token is good" in result.output
85
+ and "refused" not in result.output.split("\n")[0]
86
+ )
87
+ assert "pass init" in result.output and "gpg --generate-key" in result.output
88
+ assert "team@arcsecond.io" not in result.output
89
+
90
+
91
+ def test_any_other_credential_store_failure_names_the_config(
92
+ docker, monkeypatch, tmp_path
93
+ ):
94
+ _login_fails_with(
95
+ monkeypatch,
96
+ "Error saving credentials: error storing credentials - err: exit status 1, out: `keychain locked`",
97
+ )
98
+ result = _invoke("token", "set", input="ghp_good\n")
99
+ assert result.exit_code == 1
100
+ assert "token is good" in result.output and "keychain locked" in result.output
101
+ assert "credsStore" in result.output and "gpg" not in result.output
102
+
103
+
68
104
  def test_chevrons_and_empty_input_never_reach_docker(docker):
69
105
  assert "placeholder" in _invoke("token", "set", input="<ghp_good>\n").output
70
106
  assert _invoke("token", "set", "--stdin", input="\n").exit_code == 1
@@ -155,7 +155,7 @@ wheels = [
155
155
 
156
156
  [[package]]
157
157
  name = "arcsecond"
158
- version = "4.2.1"
158
+ version = "4.3.1"
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