arcsecond 4.1.0__tar.gz → 4.2.0__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.1.0 → arcsecond-4.2.0}/PKG-INFO +2 -1
  2. {arcsecond-4.1.0 → arcsecond-4.2.0}/README.md +1 -0
  3. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cli.py +4 -0
  4. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/docgen.py +40 -0
  5. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/__init__.py +2 -0
  6. arcsecond-4.2.0/arcsecond/hosting/composedoc.py +411 -0
  7. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/local.py +41 -1
  8. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/stack.py +1 -2
  9. arcsecond-4.2.0/arcsecond/hosting/token.py +184 -0
  10. {arcsecond-4.1.0 → arcsecond-4.2.0}/pyproject.toml +1 -1
  11. arcsecond-4.2.0/tests/test_hosting_composedoc.py +110 -0
  12. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_local.py +2 -0
  13. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_stack.py +1 -1
  14. arcsecond-4.2.0/tests/test_hosting_token.py +163 -0
  15. {arcsecond-4.1.0 → arcsecond-4.2.0}/uv.lock +1 -1
  16. {arcsecond-4.1.0 → arcsecond-4.2.0}/.docker/Dockerfile_postgres +0 -0
  17. {arcsecond-4.1.0 → arcsecond-4.2.0}/.docker/Dockerfile_redis +0 -0
  18. {arcsecond-4.1.0 → arcsecond-4.2.0}/.flake8 +0 -0
  19. {arcsecond-4.1.0 → arcsecond-4.2.0}/.gitattributes +0 -0
  20. {arcsecond-4.1.0 → arcsecond-4.2.0}/.github/dependabot.yml +0 -0
  21. {arcsecond-4.1.0 → arcsecond-4.2.0}/.github/workflows/pythonpublish.yml +0 -0
  22. {arcsecond-4.1.0 → arcsecond-4.2.0}/.github/workflows/tests.yml +0 -0
  23. {arcsecond-4.1.0 → arcsecond-4.2.0}/.gitignore +0 -0
  24. {arcsecond-4.1.0 → arcsecond-4.2.0}/LICENSE +0 -0
  25. {arcsecond-4.1.0 → arcsecond-4.2.0}/Makefile +0 -0
  26. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/__init__.py +0 -0
  27. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/__version__.py +0 -0
  28. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/alpaca/__init__.py +0 -0
  29. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/alpaca/commands.py +0 -0
  30. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/alpaca/dome_probe.py +0 -0
  31. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/alpaca/probe.py +0 -0
  32. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/alpaca/telescope_probe.py +0 -0
  33. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/api/__init__.py +0 -0
  34. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/api/config.py +0 -0
  35. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/api/constants.py +0 -0
  36. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/api/endpoint.py +0 -0
  37. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/api/main.py +0 -0
  38. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/api/resources.py +0 -0
  39. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/__init__.py +0 -0
  40. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/auth.py +0 -0
  41. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/resources.py +0 -0
  42. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/__init__.py +0 -0
  43. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/constants.py +0 -0
  44. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/context.py +0 -0
  45. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  46. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  47. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  48. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  49. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  50. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/errors.py +0 -0
  51. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/logger.py +0 -0
  52. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/uploader.py +0 -0
  53. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/utils.py +0 -0
  54. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploader/walker.py +0 -0
  55. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/cloud/uploads.py +0 -0
  56. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/errors.py +0 -0
  57. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/backups.py +0 -0
  58. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/check.py +0 -0
  59. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/database.py +0 -0
  60. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/docker/__init__.py +0 -0
  61. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  62. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/lifecycle.py +0 -0
  63. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/hosting/utils.py +0 -0
  64. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/__init__.py +0 -0
  65. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/autostart.py +0 -0
  66. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/commands.py +0 -0
  67. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/detection.py +0 -0
  68. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/proxy.py +0 -0
  69. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/registry.py +0 -0
  70. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/runtime.py +0 -0
  71. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/sources/__init__.py +0 -0
  72. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/sources/base.py +0 -0
  73. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
  74. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/sources/mdns.py +0 -0
  75. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/sources/network.py +0 -0
  76. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/sources/opencv.py +0 -0
  77. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/imagesources/store.py +0 -0
  78. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/options.py +0 -0
  79. {arcsecond-4.1.0 → arcsecond-4.2.0}/arcsecond/targets.py +0 -0
  80. {arcsecond-4.1.0 → arcsecond-4.2.0}/examples/example_upload_files.py +0 -0
  81. {arcsecond-4.1.0 → arcsecond-4.2.0}/requirements.txt +0 -0
  82. {arcsecond-4.1.0 → arcsecond-4.2.0}/setup.cfg +0 -0
  83. {arcsecond-4.1.0 → arcsecond-4.2.0}/setup.py +0 -0
  84. {arcsecond-4.1.0 → arcsecond-4.2.0}/sonar-project.properties +0 -0
  85. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/__init__.py +0 -0
  86. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/api/__init__.py +0 -0
  87. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/api/test_api.py +0 -0
  88. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/api/test_api_endpoint.py +0 -0
  89. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/api/test_config.py +0 -0
  90. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/api/test_config_pointer.py +0 -0
  91. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/api/test_targets.py +0 -0
  92. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/__init__.py +0 -0
  93. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/__init__.py +0 -0
  94. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  95. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  96. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  97. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  98. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  99. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  100. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/conftest.py +0 -0
  101. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/fixtures/file1.fits +0 -0
  102. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_alpaca_probe.py +0 -0
  103. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_cli.py +0 -0
  104. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_cli_api_commands.py +0 -0
  105. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_docgen.py +0 -0
  106. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_backups_install_dir.py +0 -0
  107. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_backups_restore.py +0 -0
  108. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_check.py +0 -0
  109. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_database.py +0 -0
  110. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_lifecycle.py +0 -0
  111. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_hosting_utils_secret_key.py +0 -0
  112. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_autostart.py +0 -0
  113. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_commands.py +0 -0
  114. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_detection.py +0 -0
  115. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_mdns.py +0 -0
  116. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_network.py +0 -0
  117. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_proxy.py +0 -0
  118. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_store.py +0 -0
  119. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_imagesources_webcam.py +0 -0
  120. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/test_targets_planning.py +0 -0
  121. {arcsecond-4.1.0 → arcsecond-4.2.0}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 4.1.0
3
+ Version: 4.2.0
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
@@ -65,6 +65,7 @@ The CLI owns the whole lifecycle of a self-hosted installation; there is no
65
65
 
66
66
  ```bash
67
67
  $ arcsecond setup --lan-host 192.168.1.42 # writes .env and docker-compose.yml
68
+ $ arcsecond token set # the access token Arcsecond gave you, once per machine (setup asks too)
68
69
  $ arcsecond start # first run downloads the images
69
70
  $ arcsecond status
70
71
  $ arcsecond logs backend -f
@@ -16,6 +16,7 @@ The CLI owns the whole lifecycle of a self-hosted installation; there is no
16
16
 
17
17
  ```bash
18
18
  $ arcsecond setup --lan-host 192.168.1.42 # writes .env and docker-compose.yml
19
+ $ arcsecond token set # the access token Arcsecond gave you, once per machine (setup asks too)
19
20
  $ arcsecond start # first run downloads the images
20
21
  $ arcsecond status
21
22
  $ arcsecond logs backend -f
@@ -20,6 +20,7 @@ from arcsecond.hosting import (
20
20
  start,
21
21
  status,
22
22
  stop,
23
+ token_group,
23
24
  update,
24
25
  )
25
26
  from arcsecond.imagesources import commands as imagesources
@@ -76,6 +77,9 @@ main.add_command(update)
76
77
  # Is it reachable from the rest of the observatory, and if not, why not.
77
78
  main.add_command(check_cmd)
78
79
 
80
+ # The access token Arcsecond gave the observatory, once per machine.
81
+ main.add_command(token_group)
82
+
79
83
  # Browse and restore Arcsecond.local DB backups.
80
84
  main.add_command(backups)
81
85
 
@@ -293,3 +293,43 @@ def docs_commands(ctx, out, check):
293
293
  )
294
294
  written = write(files, Path(out))
295
295
  click.echo(f"Wrote {len(written)} files to {out}")
296
+
297
+
298
+ @docs.command(
299
+ name="compose",
300
+ help="Write the services and environment reference from the packaged compose template.",
301
+ )
302
+ @click.option(
303
+ "--out",
304
+ "out",
305
+ type=click.Path(file_okay=False),
306
+ default=None,
307
+ help="Folder to write into.",
308
+ )
309
+ @click.option(
310
+ "--check",
311
+ "check",
312
+ type=click.Path(file_okay=False, exists=True),
313
+ default=None,
314
+ help="Compare against this folder instead of writing; exit 1 if it is out of date.",
315
+ )
316
+ def docs_compose(out, check):
317
+ from arcsecond.hosting import composedoc
318
+
319
+ files = composedoc.generate(__version__.__version__)
320
+ if check:
321
+ changed = differences(files, Path(check))
322
+ if changed:
323
+ click.echo("The services/environment reference is out of date:")
324
+ for name in changed:
325
+ click.echo(f" {name}")
326
+ click.echo("\nRegenerate it: arcsecond docs compose --out " + check)
327
+ raise SystemExit(1)
328
+ click.echo(f"The services/environment reference in {check} is up to date.")
329
+ return
330
+ if not out:
331
+ raise click.UsageError(
332
+ "Give --out FOLDER to write, or --check FOLDER to verify."
333
+ )
334
+ written = write(files, Path(out))
335
+ click.echo(f"Wrote {len(written)} files to {out}")
@@ -6,6 +6,7 @@ from .check import check_cmd
6
6
  from .database import db
7
7
  from .lifecycle import logs, restart, start, status, stop, update
8
8
  from .local import setup
9
+ from .token import token_group
9
10
 
10
11
  # Re-exported: the command groups `arcsecond.cli` mounts.
11
12
  __all__ = [
@@ -13,6 +14,7 @@ __all__ = [
13
14
  "check_cmd",
14
15
  "db",
15
16
  "setup",
17
+ "token_group",
16
18
  "start",
17
19
  "stop",
18
20
  "restart",
@@ -0,0 +1,411 @@
1
+ """The services and the environment of an installation, described from the
2
+ files that define them.
3
+
4
+ The packaged docker-compose.yml is written by this tool, and its comments are
5
+ deliberately operator documentation — they say why a port is not published,
6
+ what a bind mount is for, which service is optional. Paraphrasing them into a
7
+ page would give the page a life of its own and let it drift; instead the page
8
+ is produced from the template, so what an installation runs and what the
9
+ documentation says it runs are the same text.
10
+
11
+ The same goes for .env: the keys `arcsecond setup` writes are listed from the
12
+ provider table it writes them from, and the ones only the backend reads are
13
+ listed here, next to it, with a test that keeps the two in step.
14
+
15
+ No YAML library: the template is regular (services at two spaces, their keys
16
+ at four) and a small reader that keeps the comments is the whole point — a
17
+ YAML parser would drop them. Any change to the template's shape that this
18
+ reader cannot follow fails the tests that run it against the packaged file.
19
+ """
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from typing import Dict, List, Optional
24
+
25
+ from .local import (
26
+ FRONTEND_HOST_ENV_KEY,
27
+ OPTIONAL_SERVICES,
28
+ OPTIONAL_SERVICES_ENV_KEY,
29
+ REQUIRED_ENV_PROVIDERS,
30
+ _compose_version,
31
+ packaged_compose_text,
32
+ )
33
+
34
+ MARKER_START = re.compile(r"^\s*# >>> arcsecond:(\S+)\s*$")
35
+ MARKER_END = re.compile(r"^\s*# <<< arcsecond:(\S+)\s*$")
36
+ ENV_REF = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)(?::-[^}]*)?\}")
37
+
38
+
39
+ @dataclass
40
+ class Service:
41
+ name: str
42
+ description: List[str] = field(default_factory=list) # the comment block above it
43
+ notes: List[str] = field(default_factory=list) # comments inside it
44
+ image: Optional[str] = None
45
+ container_name: Optional[str] = None
46
+ restart: Optional[str] = None
47
+ entrypoint: Optional[str] = None
48
+ ports: List[str] = field(default_factory=list)
49
+ depends_on: List[str] = field(default_factory=list)
50
+ volumes: List[str] = field(default_factory=list)
51
+ healthcheck: bool = False
52
+ stop_grace_period: Optional[str] = None
53
+ env_vars: List[str] = field(default_factory=list)
54
+ optional: Optional[str] = None # the marker name, e.g. "alerts"
55
+
56
+
57
+ def _strip_comment(line: str) -> str:
58
+ return line.strip().lstrip("#").strip()
59
+
60
+
61
+ class _Reader:
62
+ """Walks the template line by line, keeping the comments."""
63
+
64
+ def __init__(self):
65
+ self.services: List[Service] = []
66
+ self.current: Optional[Service] = None
67
+ self.pending: List[str] = [] # comments waiting for the next service
68
+ self.optional: Optional[str] = None
69
+ self.in_services = False
70
+ self.section: Optional[str] = None # the 4-space key whose list we are in
71
+
72
+ def feed(self, raw: str) -> None:
73
+ if not raw.strip():
74
+ self.pending = []
75
+ return
76
+ if self._marker(raw):
77
+ return
78
+ indent = len(raw) - len(raw.lstrip(" "))
79
+ stripped = raw.strip()
80
+ if indent == 0:
81
+ self.in_services = stripped == "services:"
82
+ self.current = None
83
+ self.pending = []
84
+ elif not self.in_services:
85
+ return
86
+ elif indent == 2:
87
+ self._service_line(stripped)
88
+ elif self.current is not None:
89
+ self._body_line(indent, stripped)
90
+
91
+ def _marker(self, raw: str) -> bool:
92
+ start = MARKER_START.match(raw)
93
+ if start:
94
+ self.optional = start.group(1)
95
+ return True
96
+ if MARKER_END.match(raw):
97
+ self.optional = None
98
+ self.current = None
99
+ return True
100
+ return False
101
+
102
+ def _service_line(self, stripped: str) -> None:
103
+ if stripped.startswith("#"):
104
+ self.pending.append(_strip_comment(stripped))
105
+ elif stripped.endswith(":"):
106
+ self.current = Service(
107
+ name=stripped[:-1], description=self.pending, optional=self.optional
108
+ )
109
+ self.services.append(self.current)
110
+ self.pending = []
111
+ self.section = None
112
+
113
+ def _body_line(self, indent: int, stripped: str) -> None:
114
+ current = self.current
115
+ for var in ENV_REF.findall(stripped):
116
+ if var not in current.env_vars:
117
+ current.env_vars.append(var)
118
+ if stripped.startswith("#"):
119
+ current.notes.append(_strip_comment(stripped))
120
+ elif indent == 4:
121
+ self._key(stripped)
122
+ elif indent >= 6:
123
+ self._list_item(indent, stripped)
124
+
125
+ def _key(self, stripped: str) -> None:
126
+ key, _, value = stripped.partition(":")
127
+ value = value.strip()
128
+ self.section = key
129
+ scalar = {
130
+ "image": "image",
131
+ "container_name": "container_name",
132
+ "restart": "restart",
133
+ "entrypoint": "entrypoint",
134
+ "stop_grace_period": "stop_grace_period",
135
+ }
136
+ if key in scalar:
137
+ setattr(self.current, scalar[key], value)
138
+ elif key == "healthcheck":
139
+ self.current.healthcheck = True
140
+ elif key == "ports" and value.startswith("["):
141
+ self.current.ports += [
142
+ p.strip().strip('"') for p in value.strip("[]").split(",") if p.strip()
143
+ ]
144
+
145
+ def _list_item(self, indent: int, stripped: str) -> None:
146
+ current, section = self.current, self.section
147
+ if section == "ports" and stripped.startswith("- "):
148
+ current.ports.append(stripped[2:].strip().strip('"'))
149
+ elif section == "depends_on":
150
+ if stripped.startswith("- "):
151
+ current.depends_on.append(stripped[2:].strip())
152
+ elif indent == 6 and stripped.endswith(":"):
153
+ current.depends_on.append(stripped[:-1])
154
+ elif section == "volumes":
155
+ self._volume(indent, stripped)
156
+
157
+ def _volume(self, indent: int, stripped: str) -> None:
158
+ volumes = self.current.volumes
159
+ if (
160
+ indent == 6
161
+ and stripped.startswith("- ")
162
+ and not stripped.startswith("- type")
163
+ ):
164
+ volumes.append(stripped[2:].strip())
165
+ elif stripped.startswith("source:"):
166
+ volumes.append(stripped.split(":", 1)[1].strip())
167
+ elif stripped.startswith("target:") and volumes:
168
+ volumes[-1] += f" → {stripped.split(':', 1)[1].strip()}"
169
+
170
+
171
+ def parse_services(text: str) -> List[Service]:
172
+ """The services of a compose template, comments kept."""
173
+ reader = _Reader()
174
+ for raw in text.splitlines():
175
+ reader.feed(raw)
176
+ return reader.services
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # .env
181
+ # ---------------------------------------------------------------------------
182
+
183
+ SETUP = "written by `arcsecond setup`"
184
+ OPERATOR = "set by the operator"
185
+ BACKEND = "read by the backend"
186
+
187
+ # key -> (who sets it, what it is for). Every key `setup` writes must appear;
188
+ # the test enforces it. Keys only the backend reads are listed too, since an
189
+ # operator editing .env has no other place to learn they exist.
190
+ ENV_KEYS: Dict[str, tuple] = {
191
+ "SECRET_KEY": (
192
+ SETUP,
193
+ "Django's secret key: signs sessions and tokens. Generated once; changing it logs everyone out.",
194
+ ),
195
+ "AUTH_JWT_SIGNING_KEY": (
196
+ SETUP,
197
+ "Signs the access tokens of the web interface and the CLI. Changing it logs everyone out.",
198
+ ),
199
+ "AGENT_JWT_SIGNING_KEY": (
200
+ SETUP,
201
+ "Signs the tokens of the equipment agents talking to the Control Room.",
202
+ ),
203
+ "FIELD_ENCRYPTION_KEY": (
204
+ SETUP,
205
+ "Encrypts sensitive fields in the database — external-storage credentials among them. "
206
+ "Losing it makes those fields unreadable.",
207
+ ),
208
+ "SHARED_DATA_PATH": (
209
+ SETUP,
210
+ "The folder on this machine where the installation keeps everything it persists: data files, previews, "
211
+ "caches, database backups. Mounted as /data inside the containers. Asked at setup.",
212
+ ),
213
+ "POSTGRES_USER": (SETUP, "The database role. Stable across installs."),
214
+ "POSTGRES_PASSWORD": (
215
+ SETUP,
216
+ "The role's password, generated per install. Only read by Postgres on the very first boot; rotate it with "
217
+ "`arcsecond db set-password`, never by editing this line alone.",
218
+ ),
219
+ "POSTGRES_DB": (SETUP, "The database name."),
220
+ "GCN_CONSUMER_CLIENT_ID": (
221
+ OPERATOR,
222
+ "NASA GCN client credentials for the optional transient-alerts service. Empty until you paste yours.",
223
+ ),
224
+ "GCN_CONSUMER_CLIENT_SECRET": (OPERATOR, "See GCN_CONSUMER_CLIENT_ID."),
225
+ FRONTEND_HOST_ENV_KEY: (
226
+ OPERATOR,
227
+ "The address other computers reach the installation at, port included (e.g. 192.168.1.42:5555). The backend "
228
+ "builds invitation and password-reset links from it. Empty means localhost:5555. "
229
+ "Set with `arcsecond setup --lan-host`.",
230
+ ),
231
+ OPTIONAL_SERVICES_ENV_KEY: (
232
+ SETUP,
233
+ "The operator's yes/no answers about optional services (e.g. `alerts:yes`), so setup does not ask again.",
234
+ ),
235
+ "HOSTED_FRONTEND_SCHEME": (
236
+ BACKEND,
237
+ "`http` (default) or `https`, when a TLS-terminating reverse proxy of your own sits in front of the installation.",
238
+ ),
239
+ "HOSTED_EXTRA_TRUSTED_ORIGINS": (
240
+ BACKEND,
241
+ "Comma-separated origins, scheme included, to trust besides private-network addresses — a public domain, "
242
+ "a Tailscale name. Not needed for a LAN address.",
243
+ ),
244
+ "LIVE_IMAGE_PROXY_URL": (
245
+ BACKEND,
246
+ "Where the backend reaches the live-image proxy for cameras. Default http://host.docker.internal:8765; "
247
+ "change it only for a proxy on another machine, then `arcsecond restart backend`.",
248
+ ),
249
+ "LOCAL_EMAIL_VERIFICATION_GRACE_HOURS": (
250
+ BACKEND,
251
+ "How long a new member may use the installation before verifying their email, "
252
+ "on an installation with no mail server.",
253
+ ),
254
+ }
255
+
256
+
257
+ def env_keys_written_by_setup() -> List[str]:
258
+ return list(REQUIRED_ENV_PROVIDERS)
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Rendering
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ def _frontmatter(title: str, cli_version: str, template_version: Optional[str]) -> str:
267
+ return (
268
+ "---\n"
269
+ f'title: "{title}"\n'
270
+ "visibility: public\n"
271
+ "audience: operator\n"
272
+ "tier: reference\n"
273
+ "source: generated\n"
274
+ f'cli: "{cli_version}"\n'
275
+ f'template: "{template_version or "?"}"\n'
276
+ "---\n\n\n"
277
+ )
278
+
279
+
280
+ def _esc(text: str) -> str:
281
+ return (
282
+ text.replace("<", "&lt;")
283
+ .replace(">", "&gt;")
284
+ .replace("{{", "&#123;&#123;")
285
+ .replace("|", "\\|")
286
+ )
287
+
288
+
289
+ def _service_row(s: Service) -> str:
290
+ ports = ", ".join(f"`{p}`" for p in s.ports) or "none"
291
+ deps = ", ".join(f"`{d}`" for d in s.depends_on) or "—"
292
+ optional = f"yes (`{s.optional}`)" if s.optional else "no"
293
+ return f"| `{s.name}` | `{s.container_name or '?'}` | `{s.image or '?'}` | {ports} | {deps} | {optional} |"
294
+
295
+
296
+ def _service_facts(s: Service) -> List[str]:
297
+ ports = (
298
+ ", ".join(f"`{p}`" for p in s.ports)
299
+ if s.ports
300
+ else "none — reachable from the other containers only"
301
+ )
302
+ facts = [
303
+ f"- **Container**: `{s.container_name}`" if s.container_name else None,
304
+ f"- **Image**: `{s.image}`" if s.image else None,
305
+ f"- **Ports published on the machine**: {ports}",
306
+ (
307
+ f"- **Starts after**: {', '.join(f'`{d}`' for d in s.depends_on)}"
308
+ if s.depends_on
309
+ else None
310
+ ),
311
+ (
312
+ f"- **Restart policy**: `{s.restart}` — comes back with Docker after a reboot unless stopped on purpose"
313
+ if s.restart
314
+ else None
315
+ ),
316
+ (
317
+ "- **Healthcheck**: yes — `arcsecond start` waits for it"
318
+ if s.healthcheck
319
+ else None
320
+ ),
321
+ (
322
+ f"- **Time allowed to stop cleanly**: `{s.stop_grace_period}`"
323
+ if s.stop_grace_period
324
+ else None
325
+ ),
326
+ (
327
+ f"- **Storage**: {', '.join(f'`{_esc(v)}`' for v in s.volumes)}"
328
+ if s.volumes
329
+ else None
330
+ ),
331
+ (
332
+ f"- **Reads from .env**: {', '.join(f'[`{v}`](./environment#{v.lower()})' for v in s.env_vars)}"
333
+ if s.env_vars
334
+ else None
335
+ ),
336
+ (
337
+ f"- **Optional**: added with `arcsecond setup --with-{s.optional}`, removed with `--without-{s.optional}`"
338
+ if s.optional
339
+ else None
340
+ ),
341
+ ]
342
+ return [f for f in facts if f]
343
+
344
+
345
+ def render_services(
346
+ services: List[Service], cli_version: str, template_version: Optional[str]
347
+ ) -> str:
348
+ out = [_frontmatter("Services", cli_version, template_version)]
349
+ out += [
350
+ "# Services",
351
+ "",
352
+ f"What `docker-compose.yml` version {template_version} starts, generated from the file itself — the comments "
353
+ "below are the file's own. `arcsecond status` lists the same services with their state; "
354
+ "`arcsecond logs <service>` shows one's log.",
355
+ "",
356
+ "| Service | Container | Image | Ports on the machine | Depends on | Optional |",
357
+ "| --- | --- | --- | --- | --- | --- |",
358
+ ]
359
+ out += [_service_row(s) for s in services]
360
+ out.append("")
361
+ for s in services:
362
+ out += [f"## `{s.name}`", ""]
363
+ if s.description:
364
+ out += [_esc(" ".join(s.description)), ""]
365
+ out += _service_facts(s) + [""]
366
+ if s.notes:
367
+ out += ["From the file:", ""] + [f"> {_esc(n)}" for n in s.notes] + [""]
368
+ return "\n".join(out)
369
+
370
+
371
+ def render_environment(cli_version: str, template_version: Optional[str]) -> str:
372
+ out = [_frontmatter("Environment (.env)", cli_version, template_version)]
373
+ out += [
374
+ "# Environment (.env)",
375
+ "",
376
+ "Every key the `.env` file next to `docker-compose.yml` can hold: the ones `arcsecond setup` writes, "
377
+ "the ones you fill in, and the ones only the backend reads. Compose reads this file and hands the values "
378
+ "to the containers; a container keeps the values it was created with, so after editing it run "
379
+ "`arcsecond restart`.",
380
+ "",
381
+ "::: warning",
382
+ "A `$` in a value is interpolated by compose. Quotes, backslashes and `#` break the parse. Keep values plain.",
383
+ ":::",
384
+ "",
385
+ "| Key | Who sets it | What it is for |",
386
+ "| --- | --- | --- |",
387
+ ]
388
+ for key, (who, what) in ENV_KEYS.items():
389
+ out.append(f"| `{key}` | {who} | {_esc(what)} |")
390
+ out.append("")
391
+ for key, (who, what) in ENV_KEYS.items():
392
+ out += [
393
+ f"## `{key}` {{#{key.lower()}}}",
394
+ "",
395
+ f"*{who.capitalize()}.* {_esc(what)}",
396
+ "",
397
+ ]
398
+ return "\n".join(out)
399
+
400
+
401
+ def generate(cli_version: str) -> dict:
402
+ text = packaged_compose_text()
403
+ version = _compose_version(text)
404
+ services = parse_services(text)
405
+ return {
406
+ "services.md": render_services(services, cli_version, version),
407
+ "environment.md": render_environment(cli_version, version),
408
+ }
409
+
410
+
411
+ __all__ = ["Service", "parse_services", "ENV_KEYS", "generate", "OPTIONAL_SERVICES"]
@@ -7,6 +7,7 @@ from pathlib import Path, PurePosixPath, PureWindowsPath
7
7
  import click
8
8
 
9
9
  from arcsecond.api.config import ArcsecondConfig
10
+ from arcsecond.errors import ArcsecondError
10
11
  from arcsecond.options import basic_options
11
12
 
12
13
  from .utils import (
@@ -522,6 +523,43 @@ def _register_local_api():
522
523
  config.api_server = LOCAL_API_ADDRESS
523
524
 
524
525
 
526
+ def _offer_token():
527
+ """Ask for the observatory's access token, once, so that `start` can
528
+ download the images. Skipped when Docker already holds it, when Docker is
529
+ not reachable yet (the message says what to do instead), and when there is
530
+ no terminal to ask on."""
531
+ from . import stack, token
532
+
533
+ try:
534
+ stack.ensure_docker()
535
+ except ArcsecondError:
536
+ click.echo(
537
+ "\nDocker is not reachable right now. Once it is, enter the access token "
538
+ "Arcsecond gave your observatory: arcsecond token set"
539
+ )
540
+ return
541
+ if token.has_token():
542
+ return
543
+ if not _stdin_is_interactive():
544
+ click.echo(
545
+ "\nThis machine has no access token yet. Enter it with: arcsecond token set"
546
+ )
547
+ return
548
+ click.echo("\n" + token.WHAT_IT_IS)
549
+ click.echo("Leave it empty to do this later with `arcsecond token set`.")
550
+ entered = click.prompt(
551
+ "Access token from Arcsecond (nothing is shown while you type)",
552
+ hide_input=True,
553
+ default="",
554
+ show_default=False,
555
+ ).strip()
556
+ if not entered:
557
+ click.echo("Skipped. Before `arcsecond start`: arcsecond token set")
558
+ return
559
+ token.store_token(entered)
560
+ click.echo(click.style("Token accepted.", fg="green"))
561
+
562
+
525
563
  def _normalise_lan_host(value):
526
564
  """`192.168.1.42` → `192.168.1.42:5555`; a scheme or a path is refused."""
527
565
  value = (value or "").strip()
@@ -558,7 +596,8 @@ def _normalise_lan_host(value):
558
596
  def setup(with_alerts, lan_host):
559
597
  """Write (or update) the two files an installation is made of, in the
560
598
  current folder: .env, with this installation's secrets, and
561
- docker-compose.yml. Then: arcsecond start
599
+ docker-compose.yml — and ask for the access token Arcsecond gave your
600
+ observatory, if this machine has none yet. Then: arcsecond start
562
601
 
563
602
  Run it again after upgrading the CLI to bring docker-compose.yml up to
564
603
  date; nothing of yours is overwritten.
@@ -592,6 +631,7 @@ def setup(with_alerts, lan_host):
592
631
 
593
632
  remember_install_dir(directory)
594
633
  _register_local_api()
634
+ _offer_token()
595
635
 
596
636
  if "alerts" in enabled:
597
637
  click.echo(
@@ -268,8 +268,7 @@ _KNOWN_FAILURES = (
268
268
  (
269
269
  ("denied", "unauthorized", "pull access denied"),
270
270
  "Docker could not download an Arcsecond image: the registry refused.\n"
271
- "Log in once with your token: echo <PAT> | docker login ghcr.io -u "
272
- "arcsecond-io --password-stdin",
271
+ "Enter the token Arcsecond gave your observatory: arcsecond token set",
273
272
  ),
274
273
  )
275
274