arcsecond 4.3.3__tar.gz → 4.4.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 (136) hide show
  1. {arcsecond-4.3.3 → arcsecond-4.4.1}/PKG-INFO +1 -1
  2. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cli.py +4 -2
  3. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/docgen.py +60 -1
  4. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/__init__.py +4 -4
  5. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/composedoc.py +6 -0
  6. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/docker/docker-compose.yml +11 -5
  7. arcsecond-4.3.3/arcsecond/hosting/check.py → arcsecond-4.4.1/arcsecond/hosting/doctor.py +313 -36
  8. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/lifecycle.py +11 -6
  9. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/local.py +121 -190
  10. arcsecond-4.4.1/arcsecond/hosting/network/__init__.py +215 -0
  11. arcsecond-4.4.1/arcsecond/hosting/network/guard.py +204 -0
  12. arcsecond-4.4.1/arcsecond/hosting/network/manifest.toml +649 -0
  13. arcsecond-4.4.1/arcsecond/hosting/network/outbound.py +247 -0
  14. arcsecond-4.4.1/arcsecond/hosting/network/probe.py +258 -0
  15. arcsecond-4.4.1/arcsecond/hosting/network/sheet.py +387 -0
  16. arcsecond-4.4.1/arcsecond/hosting/skybrightness.py +100 -0
  17. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/stack.py +16 -6
  18. {arcsecond-4.3.3 → arcsecond-4.4.1}/pyproject.toml +2 -1
  19. arcsecond-4.4.1/tests/fixtures/docker-compose-6.2.yml +123 -0
  20. arcsecond-4.4.1/tests/fixtures/docker-compose-6.4.yml +139 -0
  21. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_docgen.py +5 -2
  22. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_composedoc.py +2 -2
  23. arcsecond-4.3.3/tests/test_hosting_check.py → arcsecond-4.4.1/tests/test_hosting_doctor.py +211 -6
  24. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_lifecycle.py +55 -2
  25. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_local.py +115 -80
  26. arcsecond-4.4.1/tests/test_hosting_network_guard.py +112 -0
  27. arcsecond-4.4.1/tests/test_hosting_network_manifest.py +132 -0
  28. arcsecond-4.4.1/tests/test_hosting_network_outbound.py +158 -0
  29. arcsecond-4.4.1/tests/test_hosting_network_probe.py +200 -0
  30. arcsecond-4.4.1/tests/test_hosting_network_sheet.py +123 -0
  31. arcsecond-4.4.1/tests/test_hosting_skybrightness.py +116 -0
  32. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_stack.py +11 -0
  33. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_token.py +1 -0
  34. {arcsecond-4.3.3 → arcsecond-4.4.1}/uv.lock +1 -1
  35. {arcsecond-4.3.3 → arcsecond-4.4.1}/.docker/Dockerfile_postgres +0 -0
  36. {arcsecond-4.3.3 → arcsecond-4.4.1}/.docker/Dockerfile_redis +0 -0
  37. {arcsecond-4.3.3 → arcsecond-4.4.1}/.flake8 +0 -0
  38. {arcsecond-4.3.3 → arcsecond-4.4.1}/.gitattributes +0 -0
  39. {arcsecond-4.3.3 → arcsecond-4.4.1}/.github/dependabot.yml +0 -0
  40. {arcsecond-4.3.3 → arcsecond-4.4.1}/.github/workflows/pythonpublish.yml +0 -0
  41. {arcsecond-4.3.3 → arcsecond-4.4.1}/.github/workflows/tests.yml +0 -0
  42. {arcsecond-4.3.3 → arcsecond-4.4.1}/.gitignore +0 -0
  43. {arcsecond-4.3.3 → arcsecond-4.4.1}/LICENSE +0 -0
  44. {arcsecond-4.3.3 → arcsecond-4.4.1}/Makefile +0 -0
  45. {arcsecond-4.3.3 → arcsecond-4.4.1}/README.md +0 -0
  46. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/__init__.py +0 -0
  47. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/__version__.py +0 -0
  48. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/alpaca/__init__.py +0 -0
  49. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/alpaca/commands.py +0 -0
  50. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/alpaca/dome_probe.py +0 -0
  51. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/alpaca/probe.py +0 -0
  52. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/alpaca/telescope_probe.py +0 -0
  53. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/api/__init__.py +0 -0
  54. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/api/config.py +0 -0
  55. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/api/constants.py +0 -0
  56. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/api/endpoint.py +0 -0
  57. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/api/main.py +0 -0
  58. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/api/resources.py +0 -0
  59. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/__init__.py +0 -0
  60. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/auth.py +0 -0
  61. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/resources.py +0 -0
  62. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/__init__.py +0 -0
  63. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/constants.py +0 -0
  64. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/context.py +0 -0
  65. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  66. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  67. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  68. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  69. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  70. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/errors.py +0 -0
  71. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/logger.py +0 -0
  72. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/uploader.py +0 -0
  73. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/utils.py +0 -0
  74. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploader/walker.py +0 -0
  75. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/cloud/uploads.py +0 -0
  76. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/errors.py +0 -0
  77. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/backups.py +0 -0
  78. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/database.py +0 -0
  79. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/docker/__init__.py +0 -0
  80. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/token.py +0 -0
  81. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/hosting/utils.py +0 -0
  82. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/__init__.py +0 -0
  83. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/autostart.py +0 -0
  84. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/commands.py +0 -0
  85. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/detection.py +0 -0
  86. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/proxy.py +0 -0
  87. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/registry.py +0 -0
  88. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/runtime.py +0 -0
  89. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/sources/__init__.py +0 -0
  90. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/sources/base.py +0 -0
  91. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/sources/filewatch.py +0 -0
  92. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/sources/mdns.py +0 -0
  93. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/sources/network.py +0 -0
  94. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/sources/opencv.py +0 -0
  95. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/imagesources/store.py +0 -0
  96. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/options.py +0 -0
  97. {arcsecond-4.3.3 → arcsecond-4.4.1}/arcsecond/targets.py +0 -0
  98. {arcsecond-4.3.3 → arcsecond-4.4.1}/examples/example_upload_files.py +0 -0
  99. {arcsecond-4.3.3 → arcsecond-4.4.1}/requirements.txt +0 -0
  100. {arcsecond-4.3.3 → arcsecond-4.4.1}/setup.cfg +0 -0
  101. {arcsecond-4.3.3 → arcsecond-4.4.1}/setup.py +0 -0
  102. {arcsecond-4.3.3 → arcsecond-4.4.1}/sonar-project.properties +0 -0
  103. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/__init__.py +0 -0
  104. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/api/__init__.py +0 -0
  105. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/api/test_api.py +0 -0
  106. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/api/test_api_endpoint.py +0 -0
  107. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/api/test_config.py +0 -0
  108. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/api/test_config_pointer.py +0 -0
  109. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/api/test_targets.py +0 -0
  110. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/__init__.py +0 -0
  111. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/__init__.py +0 -0
  112. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  113. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  114. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  115. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  116. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  117. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  118. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/conftest.py +0 -0
  119. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/fixtures/file1.fits +0 -0
  120. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_alpaca_probe.py +0 -0
  121. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_cli.py +0 -0
  122. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_cli_api_commands.py +0 -0
  123. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_backups_install_dir.py +0 -0
  124. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_backups_restore.py +0 -0
  125. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_database.py +0 -0
  126. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_hosting_utils_secret_key.py +0 -0
  127. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_autostart.py +0 -0
  128. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_commands.py +0 -0
  129. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_detection.py +0 -0
  130. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_mdns.py +0 -0
  131. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_network.py +0 -0
  132. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_proxy.py +0 -0
  133. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_store.py +0 -0
  134. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_imagesources_webcam.py +0 -0
  135. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/test_targets_planning.py +0 -0
  136. {arcsecond-4.3.3 → arcsecond-4.4.1}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arcsecond
3
- Version: 4.3.3
3
+ Version: 4.4.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
@@ -12,8 +12,8 @@ from arcsecond.cloud import (
12
12
  from arcsecond.docgen import docs
13
13
  from arcsecond.hosting import (
14
14
  backups,
15
- check_cmd,
16
15
  db,
16
+ doctor_cmd,
17
17
  logs,
18
18
  restart,
19
19
  setup,
@@ -75,7 +75,9 @@ main.add_command(logs)
75
75
  main.add_command(update)
76
76
 
77
77
  # Is it reachable from the rest of the observatory, and if not, why not.
78
- main.add_command(check_cmd)
78
+ main.add_command(doctor_cmd)
79
+ # Its name until 4.4, kept so that scripts and habits go on working.
80
+ main.add_command(doctor_cmd, name="check")
79
81
 
80
82
  # The access token Arcsecond gave the observatory, once per machine.
81
83
  main.add_command(token_group)
@@ -78,7 +78,9 @@ def _visible_commands(
78
78
  commands = []
79
79
  for name in group.list_commands(ctx):
80
80
  command = group.get_command(ctx, name)
81
- if command is not None and not command.hidden:
81
+ # A command mounted a second time under another name is an alias
82
+ # (`check` for `doctor`): it works, and has no page of its own.
83
+ if command is not None and not command.hidden and command.name == name:
82
84
  commands.append((name, command))
83
85
  return commands
84
86
 
@@ -339,3 +341,60 @@ def docs_compose(out, check):
339
341
  )
340
342
  written = write(files, Path(out))
341
343
  click.echo(f"Wrote {len(written)} files to {out}")
344
+
345
+
346
+ @docs.command(
347
+ name="network",
348
+ help="Write the one-page network sheet, and the firewall request, from the network manifest.",
349
+ )
350
+ @click.option(
351
+ "--out",
352
+ "out",
353
+ type=click.Path(file_okay=False),
354
+ default=None,
355
+ help="Folder for the documentation page (index.md).",
356
+ )
357
+ @click.option(
358
+ "--static",
359
+ "static",
360
+ type=click.Path(file_okay=False),
361
+ default=None,
362
+ help="Folder for the printable page and the plain-text firewall request, served as they are.",
363
+ )
364
+ @click.option(
365
+ "--check",
366
+ "check",
367
+ is_flag=True,
368
+ help="Compare against the folders instead of writing; exit 1 if either is out of date.",
369
+ )
370
+ def docs_network(out, static, check):
371
+ from arcsecond.hosting import network
372
+ from arcsecond.hosting.network import sheet
373
+
374
+ if not out or not static:
375
+ raise click.UsageError(
376
+ "Give both --out FOLDER (the page) and --static FOLDER (the files to print and paste)."
377
+ )
378
+ manifest = network.load()
379
+ targets = (
380
+ (sheet.generate_page(manifest), Path(out)),
381
+ (sheet.generate_static(manifest), Path(static)),
382
+ )
383
+ if check:
384
+ changed = [
385
+ f"{folder}/{name}"
386
+ for files, folder in targets
387
+ for name in differences(files, folder)
388
+ ]
389
+ if changed:
390
+ click.echo("The network sheet is out of date:")
391
+ for name in changed:
392
+ click.echo(f" {name}")
393
+ click.echo(
394
+ f"\nRegenerate it: arcsecond docs network --out {out} --static {static}"
395
+ )
396
+ raise SystemExit(1)
397
+ click.echo("The network sheet is up to date.")
398
+ return
399
+ written = sum(len(write(files, folder)) for files, folder in targets)
400
+ click.echo(f"Wrote {written} files to {out} and {static}")
@@ -1,9 +1,9 @@
1
1
  from .backups import backups
2
+ from .database import db
2
3
 
3
- # check_cmd, not check: `check` is also this package's module of that name,
4
+ # doctor_cmd, not doctor: `doctor` is also this package's module of that name,
4
5
  # and re-exporting the command under it would shadow the module for tests.
5
- from .check import check_cmd
6
- from .database import db
6
+ from .doctor import doctor_cmd
7
7
  from .lifecycle import logs, restart, start, status, stop, update
8
8
  from .local import setup
9
9
  from .token import token_group
@@ -11,7 +11,7 @@ from .token import token_group
11
11
  # Re-exported: the command groups `arcsecond.cli` mounts.
12
12
  __all__ = [
13
13
  "backups",
14
- "check_cmd",
14
+ "doctor_cmd",
15
15
  "db",
16
16
  "setup",
17
17
  "token_group",
@@ -241,6 +241,12 @@ ENV_KEYS: Dict[str, tuple] = {
241
241
  "Comma-separated origins, scheme included, to trust besides private-network addresses — a public domain, "
242
242
  "a Tailscale name. Not needed for a LAN address.",
243
243
  ),
244
+ "SKY_BRIGHTNESS_GEOTIFF_PATH": (
245
+ SETUP,
246
+ "Where the backend finds the sky-brightness map kept on this machine (a path under `/data`). "
247
+ "Empty: the map is looked up on statics.arcsecond.io instead. Change the answer with "
248
+ "`arcsecond setup --with-sky-map` or `--without-sky-map`.",
249
+ ),
244
250
  "LIVE_IMAGE_PROXY_URL": (
245
251
  BACKEND,
246
252
  "Where the backend reaches the live-image proxy for cameras. Default http://host.docker.internal:8765; "
@@ -1,6 +1,6 @@
1
1
  # === Arcsecond.local config. ===
2
2
  # *** DO NOT change any name or port number. ***
3
- # Version 7.3
3
+ # Version 7.4
4
4
 
5
5
  services:
6
6
  # Database (PostgresQL)
@@ -33,8 +33,12 @@ services:
33
33
  container_name: arcsecond-api
34
34
  restart: unless-stopped
35
35
  entrypoint: /home/app/entrypoint-server.sh
36
+ # This machine only. Browsers and other computers go through the webapp's
37
+ # port, which serves the very same API under /api/; 8800 is for the
38
+ # `arcsecond` command and scripts run on this computer. Published on every
39
+ # interface, it was a second, unneeded door onto the network.
36
40
  ports:
37
- - "8800:8800"
41
+ - "127.0.0.1:8800:8800"
38
42
  depends_on:
39
43
  - db
40
44
  - broker
@@ -138,7 +142,8 @@ services:
138
142
  image: ghcr.io/arcsecond-io/arcsecond-service-platesolver-astrometry:latest
139
143
  container_name: arcsecond-platesolver
140
144
  restart: unless-stopped
141
- ports: [ "8900:8900" ]
145
+ # No host port. Only the backend calls the solver, over the internal Docker
146
+ # network; it has no authentication and nothing outside the stack needs it.
142
147
 
143
148
  # Arcsecond webapp. Served on 5555 only: a self-hosted install is
144
149
  # organisation-based and therefore always a portal, so the second port that
@@ -172,8 +177,9 @@ services:
172
177
 
173
178
  # >>> arcsecond:alerts
174
179
  # Arcsecond transient-alerts consumer (optional).
175
- # Long-lived Kafka client for NASA GCN. Outbound TLS to kafka.gcn.nasa.gov
176
- # and auth.gcn.nasa.gov only — nothing listens. Requires GCN_CONSUMER_CLIENT_ID /
180
+ # Long-lived Kafka client for NASA GCN. Outbound TLS only — nothing listens:
181
+ # auth.gcn.nasa.gov on 443, then kafka, kafka1, kafka2 and
182
+ # kafka3.gcn.nasa.gov on 9092. Requires GCN_CONSUMER_CLIENT_ID /
177
183
  # GCN_CONSUMER_CLIENT_SECRET in .env; idles harmlessly when they are absent.
178
184
  # Single instance only — do not scale it.
179
185
  alerts:
@@ -1,4 +1,4 @@
1
- """`arcsecond check`: is this installation reachable, and if not, why not.
1
+ """`arcsecond doctor`: is this installation reachable, and if not, why not.
2
2
 
3
3
  The failure this exists for is invisible from where the operator sits: the
4
4
  page does not load from the laptop, and nothing anywhere says why. The
@@ -16,18 +16,32 @@ the rules, not from probing, and says so.
16
16
  prints it otherwise. It never changes the network profile: that changes what
17
17
  every other program on the machine is exposed to, and is the operator's call.
18
18
  `--json` is for pasting into a support conversation.
19
+
20
+ It also looks outward. Every destination the network manifest declares
21
+ (`network/manifest.toml`) is probed from this machine — name, connection,
22
+ encrypted handshake, certificate, and the clock against one reference — and
23
+ each failure comes with the sentence to send to whoever runs the firewall.
24
+ The probes carry nothing about the observatory. `--report` writes the whole
25
+ result to a dated file for an administrator's ticket, with the local
26
+ network's addresses left out unless `--show-local` asks for them.
27
+
28
+ Exit code: 0 when everything required works, 1 when only an optional
29
+ destination is out of reach, 2 when something required fails.
19
30
  """
20
31
 
21
32
  import ctypes
22
33
  import json
23
34
  import os
24
35
  import re
36
+ import shutil
25
37
  import socket
26
38
  import subprocess
27
39
  import sys
28
40
  import urllib.error
29
41
  import urllib.request
30
42
  from dataclasses import asdict, dataclass, field
43
+ from datetime import datetime
44
+ from pathlib import Path
31
45
  from typing import List, Optional
32
46
 
33
47
  import click
@@ -240,6 +254,21 @@ class Host:
240
254
  except (urllib.error.URLError, OSError, ValueError):
241
255
  return None
242
256
 
257
+ def free_bytes(self, path: str) -> Optional[int]:
258
+ try:
259
+ return shutil.disk_usage(path).free
260
+ except OSError:
261
+ return None
262
+
263
+ def host_name(self) -> str:
264
+ return socket.gethostname()
265
+
266
+ def outbound_findings(self) -> List["Finding"]:
267
+ from . import network
268
+ from .network import outbound
269
+
270
+ return outbound.check_destinations(network.load())
271
+
243
272
  def is_admin(self) -> bool:
244
273
  if self.platform != "win32":
245
274
  return os.geteuid() == 0 if hasattr(os, "geteuid") else False
@@ -439,6 +468,31 @@ def check_ports(host: Host, expect_listening: bool) -> List[Finding]:
439
468
  data={"bound": []},
440
469
  )
441
470
  )
471
+ elif port == stack.API_PORT and all(bound_to_loopback(h) for h in hosts):
472
+ # Where the packaged compose file puts it since 7.4: the API port
473
+ # is for this machine, and the network reaches the same API
474
+ # through the web interface's port, under /api/.
475
+ findings.append(
476
+ Finding(
477
+ f"ports.{port}",
478
+ f"Port {port} ({name})",
479
+ OK,
480
+ f"bound to {', '.join(hosts)} — this machine only, as intended",
481
+ data={"bound": hosts},
482
+ )
483
+ )
484
+ elif port == stack.API_PORT and any(bound_everywhere(h) for h in hosts):
485
+ findings.append(
486
+ Finding(
487
+ f"ports.{port}",
488
+ f"Port {port} ({name})",
489
+ WARN,
490
+ "listening on every interface — the network does not need it: "
491
+ f"other computers reach the API through port {stack.WEB_PORT}, under /api/",
492
+ fix="`arcsecond update` restores the packaged mapping, which keeps this port to this machine",
493
+ data={"bound": hosts},
494
+ )
495
+ )
442
496
  elif any(bound_everywhere(h) for h in hosts):
443
497
  findings.append(
444
498
  Finding(
@@ -619,6 +673,93 @@ def check_proxy() -> List[Finding]:
619
673
  return findings
620
674
 
621
675
 
676
+ DISK_WARN_GB = 20
677
+ DISK_FAIL_GB = 2
678
+
679
+
680
+ def check_disk(install, host: "Host") -> Finding:
681
+ """Images fill a disk quietly, and a full one stops the database."""
682
+ shared = install.read_env("SHARED_DATA_PATH")
683
+ if not shared:
684
+ return Finding("disk", "Disk space", SKIP, "no SHARED_DATA_PATH in .env")
685
+ free = host.free_bytes(shared)
686
+ if free is None:
687
+ return Finding("disk", "Disk space", SKIP, "the data folder does not exist yet")
688
+ gigabytes = free / 1e9
689
+ detail = f"{gigabytes:.0f} GB free where the data is kept"
690
+ if gigabytes < DISK_FAIL_GB:
691
+ return Finding(
692
+ "disk",
693
+ "Disk space",
694
+ FAIL,
695
+ detail,
696
+ fix="Free some space, or move the data folder to a larger disk.",
697
+ )
698
+ if gigabytes < DISK_WARN_GB:
699
+ return Finding("disk", "Disk space", WARN, detail)
700
+ return Finding("disk", "Disk space", OK, detail)
701
+
702
+
703
+ def check_privileges(install, host: "Host") -> Finding:
704
+ """What the containers are allowed to do on this machine: the question a
705
+ security review asks first."""
706
+ try:
707
+ names = [
708
+ row.get("Name") for row in stack.services_status(install) if row.get("Name")
709
+ ]
710
+ except ArcsecondError:
711
+ names = []
712
+ if not names:
713
+ return Finding(
714
+ "privileges", "Container privileges", SKIP, "no container to inspect"
715
+ )
716
+ result = host.run(
717
+ [
718
+ "docker",
719
+ "inspect",
720
+ "--format",
721
+ "{{.Name}}|{{.HostConfig.Privileged}}|{{json .HostConfig.CapAdd}}|{{.HostConfig.NetworkMode}}|{{.Config.User}}",
722
+ *names,
723
+ ]
724
+ )
725
+ if result is None or result.returncode != 0:
726
+ return Finding(
727
+ "privileges",
728
+ "Container privileges",
729
+ SKIP,
730
+ "could not inspect the containers",
731
+ )
732
+ rows = [
733
+ line.split("|") for line in result.stdout.splitlines() if line.count("|") == 4
734
+ ]
735
+ privileged = [r[0].lstrip("/") for r in rows if r[1] == "true"]
736
+ capabilities = [r[0].lstrip("/") for r in rows if r[2] not in ("null", "[]")]
737
+ on_host_network = [r[0].lstrip("/") for r in rows if r[3] == "host"]
738
+ as_root = [r[0].lstrip("/") for r in rows if r[4] in ("", "0", "root")]
739
+ data = {
740
+ "privileged": privileged,
741
+ "added_capabilities": capabilities,
742
+ "host_network": on_host_network,
743
+ "run_as_root_inside": as_root,
744
+ }
745
+ worries = privileged + capabilities + on_host_network
746
+ if worries:
747
+ return Finding(
748
+ "privileges",
749
+ "Container privileges",
750
+ WARN,
751
+ f"{', '.join(sorted(set(worries)))} run with more than the packaged configuration grants",
752
+ fix="`arcsecond update` restores the packaged configuration",
753
+ data=data,
754
+ )
755
+ detail = f"{len(rows)} containers: none privileged, none on the machine's own network, no added capability"
756
+ if as_root:
757
+ data["note"] = (
758
+ f"{len(as_root)} of them run as root inside their container: {', '.join(as_root)}."
759
+ )
760
+ return Finding("privileges", "Container privileges", OK, detail, data=data)
761
+
762
+
622
763
  def check_api_pointer() -> Finding:
623
764
  current = ArcsecondConfig.current_api_name()
624
765
  if current == LOCAL_API_NAME:
@@ -645,7 +786,9 @@ def check_api_pointer() -> Finding:
645
786
  )
646
787
 
647
788
 
648
- def run_checks(install, host: Host, fix: bool = False) -> List[Finding]:
789
+ def run_checks(
790
+ install, host: Host, fix: bool = False, outbound: bool = False
791
+ ) -> List[Finding]:
649
792
  findings: List[Finding] = []
650
793
  docker = check_docker(host)
651
794
  findings.append(docker)
@@ -671,9 +814,45 @@ def run_checks(install, host: Host, fix: bool = False) -> List[Finding]:
671
814
 
672
815
  findings += check_proxy()
673
816
  findings.append(check_api_pointer())
817
+ findings.append(check_disk(install, host))
818
+ if docker.status == OK:
819
+ findings.append(check_privileges(install, host))
820
+ if outbound:
821
+ findings += host.outbound_findings()
674
822
  return findings
675
823
 
676
824
 
825
+ def exit_code(findings: List[Finding]) -> int:
826
+ """0: everything required works. 1: only an optional outside destination
827
+ is out of reach. 2: something required fails."""
828
+ if any(f.status == FAIL for f in findings):
829
+ return 2
830
+ if any(
831
+ f.key.startswith("net.") and f.data.get("reachable") is False for f in findings
832
+ ):
833
+ return 1
834
+ return 0
835
+
836
+
837
+ _PRIVATE_ADDRESS = re.compile(
838
+ r"\b(?:10\.\d{1,3}|192\.168|172\.(?:1[6-9]|2\d|3[01])|169\.254)\.\d{1,3}\.\d{1,3}\b"
839
+ )
840
+
841
+
842
+ def redact_local(text: str, host_name: Optional[str] = None) -> str:
843
+ """The same text without what identifies the local network: its private
844
+ addresses and this machine's name. A report leaves the observatory; what
845
+ its network looks like inside is not the recipient's business."""
846
+ text = _PRIVATE_ADDRESS.sub("<local address>", text)
847
+ if host_name:
848
+ for name in {host_name, host_name.split(".")[0]}:
849
+ if len(name) > 2:
850
+ text = re.sub(
851
+ re.escape(name), "<this machine>", text, flags=re.IGNORECASE
852
+ )
853
+ return text
854
+
855
+
677
856
  # ---------------------------------------------------------------------------
678
857
  # The command
679
858
  # ---------------------------------------------------------------------------
@@ -686,38 +865,108 @@ MARKS = {
686
865
  }
687
866
 
688
867
 
689
- def print_findings(findings: List[Finding], install) -> None:
690
- click.echo(f"Installation: {install.path}\n")
868
+ PROMISE = "This check sends nothing about your observatory: it only tests what can be reached."
869
+
870
+ SECTIONS = (
871
+ ("This machine", lambda f: not f.key.startswith("net.")),
872
+ ("Outside destinations", lambda f: f.key.startswith("net.")),
873
+ )
874
+
875
+
876
+ def _finding_lines(f: Finding, width: int) -> List[str]:
877
+ """One finding: its line, then its remedy and its note when it has them."""
878
+ entry = f" [{f.data['manifest']}]" if f.data.get("manifest") else ""
879
+ lines = [
880
+ f" {MARKS[f.status]} {f.label.ljust(width)} {f.detail}{click.style(entry, dim=True)}"
881
+ ]
882
+ if f.fix and f.status in (WARN, FAIL):
883
+ lines += [f" {' ' * width} → {line}" for line in f.fix.splitlines()]
884
+ note = f.data.get("note")
885
+ if note and f.status != SKIP:
886
+ lines.append(click.style(f" {' ' * width} {note}", dim=True))
887
+ return lines
888
+
889
+
890
+ def render_findings(findings: List[Finding], install) -> str:
891
+ """The report as text, the same on screen and in a file."""
892
+ out = [PROMISE, "", f"Installation: {install.path}"]
691
893
  width = max(len(f.label) for f in findings)
692
- for f in findings:
693
- click.echo(f" {MARKS[f.status]} {f.label.ljust(width)} {f.detail}")
694
- if f.fix and f.status in (WARN, FAIL):
695
- for line in f.fix.splitlines():
696
- click.echo(f" {' ' * width} → {line}")
697
- note = f.data.get("note")
698
- if note and f.status != SKIP:
699
- click.echo(click.style(f" {' ' * width} {note}", dim=True))
894
+ for title, belongs in SECTIONS:
895
+ section = [f for f in findings if belongs(f)]
896
+ if not section:
897
+ continue
898
+ out += ["", f"{title}:"]
899
+ for f in section:
900
+ out += _finding_lines(f, width)
700
901
  fails = sum(1 for f in findings if f.status == FAIL)
701
902
  warns = sum(1 for f in findings if f.status == WARN)
702
- click.echo("")
903
+ out.append("")
703
904
  if fails:
704
- click.echo(
905
+ out.append(
705
906
  click.style(f"{fails} problem(s), {warns} warning(s).", fg="red", bold=True)
706
907
  )
707
908
  elif warns:
708
- click.echo(click.style(f"No problem, {warns} warning(s).", fg="yellow"))
909
+ out.append(click.style(f"No problem, {warns} warning(s).", fg="yellow"))
709
910
  else:
710
- click.echo(click.style("Everything checks out.", fg="green", bold=True))
911
+ out.append(click.style("Everything checks out.", fg="green", bold=True))
912
+
913
+ from .network import outbound
914
+
915
+ lost = outbound.unavailable(findings)
916
+ if lost:
917
+ out += [
918
+ "",
919
+ "With the destinations that could not be reached, this installation does without:",
920
+ ]
921
+ out += [f" - {line}" for line in dict.fromkeys(lost)]
711
922
  lan = next((f for f in findings if f.key == "lan.host"), None)
712
923
  if lan and lan.data.get("lan_ipv4"):
713
- click.echo(
714
- f"\nFrom another computer on this network: http://{lan.data['lan_ipv4']}:{stack.WEB_PORT}"
715
- )
924
+ out += [
925
+ "",
926
+ f"From another computer on this network: http://{lan.data['lan_ipv4']}:{stack.WEB_PORT}",
927
+ ]
928
+ return "\n".join(out)
929
+
930
+
931
+ def print_findings(findings: List[Finding], install) -> None:
932
+ click.echo(render_findings(findings, install))
933
+
934
+
935
+ def _payload(findings: List[Finding], install, host: "Host") -> dict:
936
+ return {
937
+ "installation": str(install.path),
938
+ "platform": host.platform,
939
+ "cli": _cli_version(),
940
+ "checked_at": datetime.now().astimezone().isoformat(timespec="seconds"),
941
+ "exit_code": exit_code(findings),
942
+ "findings": [asdict(f) for f in findings],
943
+ }
944
+
945
+
946
+ def write_report(
947
+ findings: List[Finding], install, host: "Host", folder: Path, show_local: bool
948
+ ) -> Path:
949
+ """One dated text file an administrator can attach to a ticket: the
950
+ readable report, then the same content as JSON."""
951
+ text = click.unstyle(render_findings(findings, install))
952
+ body = json.dumps(_payload(findings, install, host), indent=2)
953
+ stamp = f"{datetime.now().astimezone():%Y-%m-%d %H:%M %Z}"
954
+ content = (
955
+ f"Arcsecond.local doctor report, {stamp}\n\n{text}\n\n"
956
+ f"--- machine-readable ---\n{body}\n"
957
+ )
958
+ if not show_local:
959
+ content = redact_local(content, host.host_name())
960
+ content = content.replace(str(install.path), "<installation folder>")
961
+ folder.mkdir(parents=True, exist_ok=True)
962
+ path = folder / f"arcsecond-doctor-{datetime.now():%Y%m%d-%H%M}.txt"
963
+ path.write_text(content, encoding="utf-8")
964
+ return path
716
965
 
717
966
 
718
967
  @click.command(
719
- name="check",
720
- short_help="Check that the installation works, and is reachable from other computers.",
968
+ name="doctor",
969
+ short_help="Check that the installation works, is reachable, and can reach what it needs.",
721
970
  )
722
971
  @dir_option
723
972
  @click.option(
@@ -732,30 +981,58 @@ def print_findings(findings: List[Finding], install) -> None:
732
981
  help="Apply the remedies this command can apply itself (the Windows firewall rule, "
733
982
  "from an Administrator shell). The network profile is never changed.",
734
983
  )
984
+ @click.option(
985
+ "--local-only",
986
+ is_flag=True,
987
+ help="Skip the outside destinations: test this machine and nothing beyond it.",
988
+ )
989
+ @click.option(
990
+ "--report",
991
+ "report",
992
+ type=click.Path(file_okay=False, path_type=Path),
993
+ default=None,
994
+ is_flag=False,
995
+ flag_value=Path("."),
996
+ help="Also write the result to a dated file, in this folder (default: the current one), "
997
+ "to attach to a ticket. Local addresses and this machine's name are left out.",
998
+ )
999
+ @click.option(
1000
+ "--show-local",
1001
+ is_flag=True,
1002
+ help="With --report: keep the local network's addresses and this machine's name in the file.",
1003
+ )
735
1004
  @basic_options
736
- def check_cmd(directory, as_json, fix):
1005
+ def doctor_cmd(directory, as_json, fix, local_only, report, show_local):
737
1006
  """Run every check an installation can run on itself: Docker, the
738
- configuration files, the containers, the ports and what they are bound
739
- to, the address other computers use, and on Windows the network profile
740
- and the firewall rule. Each problem comes with its remedy.
1007
+ configuration files, the containers and what they are allowed to do, the
1008
+ ports and what they are bound to, the address other computers use, disk
1009
+ space, and on Windows the network profile and the firewall rule.
1010
+
1011
+ Then look outward: every destination Arcsecond.local may contact is
1012
+ tested from this machine (name, connection, encryption, certificate),
1013
+ and this machine's clock is compared with a reference. Nothing about your
1014
+ observatory is sent. Each problem comes with its remedy, or with the
1015
+ sentence to send to whoever runs your network.
741
1016
 
742
- Exit code 1 when something fails, 0 otherwise — so it can gate a script.
1017
+ Exit code 0 when everything required works, 1 when only an optional
1018
+ outside destination is out of reach, 2 when something required fails.
1019
+
1020
+ `arcsecond check` is this same command, under its former name.
743
1021
  """
744
1022
  install = stack.resolve_install_dir(directory)
745
1023
  host = Host()
746
- findings = run_checks(install, host, fix=fix)
1024
+ findings = run_checks(install, host, fix=fix, outbound=not local_only)
747
1025
  if as_json:
748
- payload = {
749
- "installation": str(install.path),
750
- "platform": host.platform,
751
- "cli": _cli_version(),
752
- "findings": [asdict(f) for f in findings],
753
- }
754
- click.echo(json.dumps(payload, indent=2))
1026
+ click.echo(json.dumps(_payload(findings, install, host), indent=2))
755
1027
  else:
756
1028
  print_findings(findings, install)
757
- if any(f.status == FAIL for f in findings):
758
- raise SystemExit(1)
1029
+ if report is not None:
1030
+ path = write_report(findings, install, host, report, show_local)
1031
+ if not as_json:
1032
+ click.echo(f"\nReport written to {path}")
1033
+ code = exit_code(findings)
1034
+ if code:
1035
+ raise SystemExit(code)
759
1036
 
760
1037
 
761
1038
  def _cli_version() -> str:
@@ -298,8 +298,10 @@ def logs(directory, service, follow, tail):
298
298
  @dir_option
299
299
  @basic_options
300
300
  def update(directory):
301
- """Bring the installation up to date: refresh docker-compose.yml from this
302
- CLI, download the latest images, and restart what changed.
301
+ """Bring the installation up to date: rewrite docker-compose.yml from this
302
+ CLI, add what .env lacks, download the latest images, and restart what
303
+ changed. A docker-compose.yml that differed is kept aside as a backup;
304
+ local changes belong in docker-compose.override.yml, which is left alone.
303
305
 
304
306
  Update the CLI itself first, so that the compose file it writes is the
305
307
  newest one: pip install --upgrade arcsecond
@@ -309,13 +311,16 @@ def update(directory):
309
311
 
310
312
  click.echo(f"Updating Arcsecond.local in {install.path} ...\n")
311
313
  click.echo("Refreshing the configuration files:")
312
- enabled, removed, to_record = _resolve_optional_services(install.env_path, {})
314
+ enabled, to_record = _resolve_optional_services(
315
+ install.env_path, {}, compose_path=install.compose_path
316
+ )
313
317
  write_env_file(directory=install.path)
314
318
  for name, answer in to_record:
315
319
  _record_optional_service_decision(install.env_path, name, answer)
316
- write_docker_compose_file(
317
- enabled_services=enabled, removed_services=removed, directory=install.path
318
- )
320
+ write_docker_compose_file(enabled_services=enabled, directory=install.path)
321
+ # Installations set up before 4.0 never recorded where they are; from now
322
+ # on the other commands find this one from any folder.
323
+ stack.remember_install_dir(install.path)
319
324
 
320
325
  click.echo("\nDownloading the latest images:")
321
326
  _streamed(install, "docker compose pull", "pull")