arcsecond 3.13.0__tar.gz → 3.14.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 (122) hide show
  1. {arcsecond-3.13.0 → arcsecond-3.14.0}/.gitignore +0 -3
  2. {arcsecond-3.13.0 → arcsecond-3.14.0}/PKG-INFO +2 -2
  3. {arcsecond-3.13.0 → arcsecond-3.14.0}/README.md +1 -1
  4. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cli.py +7 -3
  5. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/__init__.py +1 -0
  6. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/backups.py +1 -14
  7. arcsecond-3.14.0/arcsecond/hosting/database.py +365 -0
  8. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/utils.py +14 -0
  9. arcsecond-3.14.0/arcsecond/imagesources/commands.py +527 -0
  10. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/proxy.py +111 -7
  11. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/registry.py +88 -1
  12. arcsecond-3.14.0/arcsecond/imagesources/sources/network.py +283 -0
  13. arcsecond-3.14.0/arcsecond/imagesources/store.py +152 -0
  14. {arcsecond-3.13.0 → arcsecond-3.14.0}/pyproject.toml +1 -1
  15. {arcsecond-3.13.0 → arcsecond-3.14.0}/sonar-project.properties +1 -1
  16. arcsecond-3.14.0/tests/test_hosting_database.py +330 -0
  17. arcsecond-3.14.0/tests/test_imagesources_netcam.py +418 -0
  18. arcsecond-3.14.0/tests/test_imagesources_store.py +327 -0
  19. {arcsecond-3.13.0 → arcsecond-3.14.0}/uv.lock +58 -58
  20. arcsecond-3.13.0/.github/workflows/docsdeploy.yml +0 -28
  21. arcsecond-3.13.0/arcsecond/imagesources/commands.py +0 -206
  22. arcsecond-3.13.0/deploy.sh +0 -30
  23. arcsecond-3.13.0/docs/.vitepress/config.js +0 -37
  24. arcsecond-3.13.0/docs/.vitepress/theme/custom.css +0 -35
  25. arcsecond-3.13.0/docs/.vitepress/theme/index.js +0 -4
  26. arcsecond-3.13.0/docs/api-basics.md +0 -103
  27. arcsecond-3.13.0/docs/backups.md +0 -158
  28. arcsecond-3.13.0/docs/img/icon-logo.png +0 -0
  29. arcsecond-3.13.0/docs/index.md +0 -36
  30. arcsecond-3.13.0/docs/install-windows.md +0 -215
  31. arcsecond-3.13.0/docs/install.md +0 -85
  32. arcsecond-3.13.0/docs/public/img/icon-logo.png +0 -0
  33. arcsecond-3.13.0/docs/resources.md +0 -358
  34. arcsecond-3.13.0/docs/rotate-postgres-password.md +0 -78
  35. arcsecond-3.13.0/docs/upload.md +0 -90
  36. arcsecond-3.13.0/docs/webcam.md +0 -178
  37. arcsecond-3.13.0/package-lock.json +0 -3528
  38. arcsecond-3.13.0/package.json +0 -29
  39. {arcsecond-3.13.0 → arcsecond-3.14.0}/.docker/Dockerfile_postgres +0 -0
  40. {arcsecond-3.13.0 → arcsecond-3.14.0}/.docker/Dockerfile_redis +0 -0
  41. {arcsecond-3.13.0 → arcsecond-3.14.0}/.github/dependabot.yml +0 -0
  42. {arcsecond-3.13.0 → arcsecond-3.14.0}/.github/workflows/pythonpublish.yml +0 -0
  43. {arcsecond-3.13.0 → arcsecond-3.14.0}/.github/workflows/tests.yml +0 -0
  44. {arcsecond-3.13.0 → arcsecond-3.14.0}/LICENSE +0 -0
  45. {arcsecond-3.13.0 → arcsecond-3.14.0}/Makefile +0 -0
  46. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/__init__.py +0 -0
  47. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/__version__.py +0 -0
  48. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/alpaca/__init__.py +0 -0
  49. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/alpaca/commands.py +0 -0
  50. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/alpaca/dome_probe.py +0 -0
  51. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/__init__.py +0 -0
  52. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/config.py +0 -0
  53. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/constants.py +0 -0
  54. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/endpoint.py +0 -0
  55. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/main.py +0 -0
  56. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/resources.py +0 -0
  57. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/__init__.py +0 -0
  58. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/auth.py +0 -0
  59. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/resources.py +0 -0
  60. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/__init__.py +0 -0
  61. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/constants.py +0 -0
  62. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/context.py +0 -0
  63. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
  64. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
  65. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
  66. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
  67. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
  68. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/errors.py +0 -0
  69. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/logger.py +0 -0
  70. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/uploader.py +0 -0
  71. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/utils.py +0 -0
  72. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/walker.py +0 -0
  73. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploads.py +0 -0
  74. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/errors.py +0 -0
  75. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/checks.py +0 -0
  76. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/constants.py +0 -0
  77. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/__init__.py +0 -0
  78. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/constants.py +0 -0
  79. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/containers.py +0 -0
  80. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
  81. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/images.py +0 -0
  82. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/utils.py +0 -0
  83. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/keygen/__init__.py +0 -0
  84. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/keygen/client.py +0 -0
  85. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/keygen/utils.py +0 -0
  86. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/local.py +0 -0
  87. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/main.py +0 -0
  88. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
  89. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/setup.py +0 -0
  90. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/validation.py +0 -0
  91. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/__init__.py +0 -0
  92. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/__init__.py +0 -0
  93. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/base.py +0 -0
  94. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
  95. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/opencv.py +0 -0
  96. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/options.py +0 -0
  97. {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/targets.py +0 -0
  98. {arcsecond-3.13.0 → arcsecond-3.14.0}/examples/example_upload_files.py +0 -0
  99. {arcsecond-3.13.0 → arcsecond-3.14.0}/requirements.txt +0 -0
  100. {arcsecond-3.13.0 → arcsecond-3.14.0}/setup.cfg +0 -0
  101. {arcsecond-3.13.0 → arcsecond-3.14.0}/setup.py +0 -0
  102. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/__init__.py +0 -0
  103. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/__init__.py +0 -0
  104. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_api.py +0 -0
  105. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_api_endpoint.py +0 -0
  106. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_config.py +0 -0
  107. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_targets.py +0 -0
  108. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/__init__.py +0 -0
  109. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/__init__.py +0 -0
  110. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
  111. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
  112. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
  113. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
  114. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
  115. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
  116. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/conftest.py +0 -0
  117. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/fixtures/file1.fits +0 -0
  118. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_alpaca_probe.py +0 -0
  119. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_cli.py +0 -0
  120. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_hosting_local.py +0 -0
  121. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_targets_planning.py +0 -0
  122. {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/utils.py +0 -0
@@ -12,9 +12,6 @@ build/
12
12
  venv
13
13
  /env
14
14
  .DS_Store
15
- node_modules
16
-
17
- /docs/.vitepress/cache
18
15
 
19
16
  *.tar
20
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcsecond
3
- Version: 3.13.0
3
+ Version: 3.14.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
@@ -49,7 +49,7 @@ Requires-Dist: aiohttp>=3.9; extra == 'webcam'
49
49
  Requires-Dist: opencv-python-headless<5,>=4.10; extra == 'webcam'
50
50
  Description-Content-Type: text/markdown
51
51
 
52
- [![Deploy Docs](https://github.com/arcsecond-io/cli/actions/workflows/docsdeploy.yml/badge.svg)](https://github.com/arcsecond-io/cli/actions/workflows/docsdeploy.yml) [![Downloads](http://pepy.tech/badge/arcsecond)](http://pepy.tech/project/arcsecond)
52
+ [![Downloads](http://pepy.tech/badge/arcsecond)](http://pepy.tech/project/arcsecond)
53
53
  [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Farcsecond-io%2Fcli.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Farcsecond-io%2Fcli?ref=badge_shield)
54
54
 
55
55
  # Arcsecond CLI
@@ -1,4 +1,4 @@
1
- [![Deploy Docs](https://github.com/arcsecond-io/cli/actions/workflows/docsdeploy.yml/badge.svg)](https://github.com/arcsecond-io/cli/actions/workflows/docsdeploy.yml) [![Downloads](http://pepy.tech/badge/arcsecond)](http://pepy.tech/project/arcsecond)
1
+ [![Downloads](http://pepy.tech/badge/arcsecond)](http://pepy.tech/project/arcsecond)
2
2
  [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Farcsecond-io%2Fcli.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Farcsecond-io%2Fcli?ref=badge_shield)
3
3
 
4
4
  # Arcsecond CLI
@@ -9,7 +9,7 @@ from arcsecond.cloud import (
9
9
  telescopes,
10
10
  upload_data,
11
11
  )
12
- from arcsecond.hosting import backups, setup
12
+ from arcsecond.hosting import backups, db, setup
13
13
  from arcsecond.imagesources import commands as imagesources
14
14
 
15
15
  from . import __version__
@@ -61,10 +61,14 @@ main.add_command(setup)
61
61
  # Browse and restore Arcsecond.local DB backups.
62
62
  main.add_command(backups)
63
63
 
64
- # Native live-image proxy — exposes USB webcams and all-sky cameras to
65
- # Arcsecond.local Docker containers via host.docker.internal.
64
+ # Manage the Arcsecond.local database (e.g. `arcsecond db set-password`).
65
+ main.add_command(db)
66
+
67
+ # Native live-image proxy — exposes USB webcams, all-sky cameras and network
68
+ # cameras to Arcsecond.local Docker containers via host.docker.internal.
66
69
  main.add_command(imagesources.webcam)
67
70
  main.add_command(imagesources.allsky)
71
+ main.add_command(imagesources.netcam)
68
72
 
69
73
  # Local ASCOM Alpaca diagnostics (e.g. `arcsecond alpaca probe dome ...`).
70
74
  main.add_command(alpaca_group)
@@ -1,3 +1,4 @@
1
1
  # from .main import install, status, stop
2
2
  from .backups import backups
3
+ from .database import db
3
4
  from .local import setup
@@ -12,7 +12,7 @@ import click
12
12
 
13
13
  from arcsecond.options import basic_options
14
14
 
15
- from .utils import _read_env_value
15
+ from .utils import _container_running, _read_env_value
16
16
 
17
17
  DB_CONTAINER = "arcsecond-db"
18
18
  API_CONTAINER = "arcsecond-api"
@@ -105,19 +105,6 @@ def _human_size(num_bytes):
105
105
  return f"{num_bytes:.1f} PB"
106
106
 
107
107
 
108
- def _container_running(name):
109
- try:
110
- out = subprocess.run(
111
- ["docker", "inspect", "-f", "{{.State.Running}}", name],
112
- capture_output=True,
113
- text=True,
114
- check=False,
115
- )
116
- except FileNotFoundError:
117
- return False
118
- return out.returncode == 0 and out.stdout.strip() == "true"
119
-
120
-
121
108
  def _classify_dump_health(backup_path):
122
109
  """Quick check that a dump file looks usable before we try to parse it.
123
110
 
@@ -0,0 +1,365 @@
1
+ """Database credential management for an Arcsecond.local installation.
2
+
3
+ This lives in the CLI rather than as a Django management command because the
4
+ .env file is on the host, next to docker-compose.yml, and is never mounted
5
+ into the containers — compose reads it and injects the values as environment
6
+ variables. Nothing running inside arcsecond-api can rewrite it.
7
+
8
+ Rotating the password means changing two things that must agree:
9
+
10
+ * the role's password in the live Postgres cluster, and
11
+ * POSTGRES_PASSWORD in .env, which is what compose feeds to the backend
12
+ (and to the db container, where initdb would use it if the volume were
13
+ ever recreated from scratch).
14
+
15
+ Postgres only reads POSTGRES_PASSWORD on initdb, so editing .env alone does
16
+ nothing to a database that already exists — that asymmetry is exactly what
17
+ makes a dedicated command worth having.
18
+ """
19
+
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ import click
27
+
28
+ from arcsecond.options import basic_options
29
+
30
+ from .utils import _container_running, _get_random_postgres_password, _read_env_value
31
+
32
+ DB_CONTAINER = "arcsecond-db"
33
+
34
+ # Compose services that hold database credentials and therefore need to be
35
+ # recreated to pick up the new .env. `db` is deliberately absent: its password
36
+ # now lives in the volume, and POSTGRES_PASSWORD is ignored on a non-empty data
37
+ # directory, so recreating it would be downtime for nothing.
38
+ SERVICES_TO_RECREATE = ["backend", "worker", "beat"]
39
+
40
+ ENV_KEY = "POSTGRES_PASSWORD"
41
+
42
+ # The .env in the install directory does double duty: compose also reads it to
43
+ # expand ${SHARED_DATA_PATH} in docker-compose.yml, so a '$' in a value can be
44
+ # swallowed or mangled before it ever reaches a container. Quotes, backslashes,
45
+ # '#' and whitespace break either the .env parse or the psql literal. Rather
46
+ # than trying to escape our way through all of that, restrict passwords to a
47
+ # character set that is unambiguous everywhere.
48
+ SAFE_PASSWORD_RE = re.compile(r"^[A-Za-z0-9._~-]+$")
49
+ MIN_PASSWORD_LENGTH = 16
50
+
51
+
52
+ def _cwd_env_path():
53
+ return Path.cwd() / ".env"
54
+
55
+
56
+ def _print_wrong_dir_hint():
57
+ click.echo(
58
+ "Could not find an Arcsecond.local installation in the current directory.\n"
59
+ "Run `arcsecond db ...` from the directory that contains your "
60
+ "docker-compose.yml and .env files (the one you used for `arcsecond setup`)."
61
+ )
62
+
63
+
64
+ # Connect to the container's own network address rather than the unix socket or
65
+ # loopback. Postgres' stock pg_hba.conf trusts `local`, 127.0.0.1 and ::1
66
+ # unconditionally — only non-loopback traffic reaches the scram-sha-256 rule. Over
67
+ # a socket, psql therefore succeeds with any password at all, which would make
68
+ # both the pre-flight check and the post-rotation verification below meaningless.
69
+ # `hostname -i` yields the container IP; keep only the first if there are several.
70
+ _PSQL_SHELL = """\
71
+ read -r PGPASSWORD
72
+ export PGPASSWORD
73
+ host=$(hostname -i 2>/dev/null | tr ' ' '\\n' | head -n 1)
74
+ [ -n "$host" ] || host=127.0.0.1
75
+ exec psql -v ON_ERROR_STOP=1 -q -h "$host" -U "$1" -d "$2" -f -
76
+ """
77
+
78
+
79
+ def _psql(sql, user, password, database="postgres"):
80
+ """Run one SQL statement in the db container, as `user`, authenticating.
81
+
82
+ Both the password and the SQL go in over stdin, never in argv: `docker exec
83
+ -e PGPASSWORD=...` would publish the credential to every `ps` on the host,
84
+ which is not a great look for the command whose whole job is to handle
85
+ credentials.
86
+ """
87
+ return subprocess.run(
88
+ [
89
+ "docker",
90
+ "exec",
91
+ "-i",
92
+ DB_CONTAINER,
93
+ "sh",
94
+ "-c",
95
+ _PSQL_SHELL,
96
+ "sh",
97
+ user,
98
+ database,
99
+ ],
100
+ input=f"{password}\n{sql}\n",
101
+ capture_output=True,
102
+ text=True,
103
+ check=False,
104
+ )
105
+
106
+
107
+ def _credentials_work(user, password, database):
108
+ return _psql("SELECT 1;", user, password, database).returncode == 0
109
+
110
+
111
+ def _sql_literal(value):
112
+ """Quote a Postgres string literal.
113
+
114
+ With standard_conforming_strings (the default since 9.1) doubling single
115
+ quotes is sufficient — and SAFE_PASSWORD_RE has already ruled out quotes
116
+ and backslashes, so this is belt and braces.
117
+ """
118
+ return "'" + value.replace("'", "''") + "'"
119
+
120
+
121
+ def _validate_password(password):
122
+ """Return an error string, or None when the password is acceptable."""
123
+ if len(password) < MIN_PASSWORD_LENGTH:
124
+ return f"too short: {MIN_PASSWORD_LENGTH} characters minimum."
125
+ if not SAFE_PASSWORD_RE.match(password):
126
+ return (
127
+ "contains characters that are unsafe in a .env file read by docker "
128
+ "compose.\nAllowed: letters, digits, and . _ ~ -\n"
129
+ "In particular '$' is expanded by compose, and quotes, backslashes, "
130
+ "'#' and spaces break the .env parse."
131
+ )
132
+ return None
133
+
134
+
135
+ def _replace_env_value(text, key, new_value):
136
+ """Return `text` with `key`'s value replaced, preserving line order.
137
+
138
+ Only the first assignment is rewritten, matching how docker compose reads
139
+ the file. Returns None when the key is absent, so the caller can refuse
140
+ rather than silently appending a second one.
141
+ """
142
+ out = []
143
+ replaced = False
144
+ for line in text.splitlines():
145
+ stripped = line.strip()
146
+ if (
147
+ not replaced
148
+ and stripped
149
+ and not stripped.startswith("#")
150
+ and "=" in stripped
151
+ and stripped.split("=", 1)[0].strip() == key
152
+ ):
153
+ out.append(f"{key}={new_value}")
154
+ replaced = True
155
+ else:
156
+ out.append(line)
157
+ if not replaced:
158
+ return None
159
+ return "\n".join(out) + "\n"
160
+
161
+
162
+ @click.group(name="db", help="Manage the Arcsecond.local database.")
163
+ def db():
164
+ pass
165
+
166
+
167
+ @db.command(
168
+ name="set-password",
169
+ help="Rotate the Postgres password, in the database and in .env together.",
170
+ )
171
+ @click.option(
172
+ "--password",
173
+ default=None,
174
+ help="Password to set. Omit to generate a strong random one (recommended).",
175
+ )
176
+ @click.option(
177
+ "--show", is_flag=True, help="Print the new password instead of only writing it."
178
+ )
179
+ @click.option(
180
+ "--no-restart",
181
+ is_flag=True,
182
+ help="Do not recreate the app containers. They keep using the old password "
183
+ "until you recreate them yourself.",
184
+ )
185
+ @click.option("--dry-run", is_flag=True, help="Show what would happen, change nothing.")
186
+ @basic_options
187
+ def set_password_cmd(password, show, no_restart, dry_run):
188
+ env_path = _cwd_env_path()
189
+ if not env_path.exists():
190
+ _print_wrong_dir_hint()
191
+ sys.exit(1)
192
+
193
+ # Validate the operator's input before anything that needs Docker: a
194
+ # rejected password is a typo to fix, not a reason to go start the stack.
195
+ if password is not None:
196
+ error = _validate_password(password)
197
+ if error:
198
+ click.echo(f"Refusing that password — {error}")
199
+ sys.exit(1)
200
+
201
+ db_user = _read_env_value("POSTGRES_USER") or "arcsecond_docker"
202
+ db_name = _read_env_value("POSTGRES_DB") or "arcsecond_docker"
203
+ old_password = _read_env_value(ENV_KEY) or ""
204
+
205
+ if not _container_running(DB_CONTAINER):
206
+ click.echo(
207
+ f"The {DB_CONTAINER} container is not running.\n"
208
+ "Start the stack first: docker compose up -d db"
209
+ )
210
+ sys.exit(1)
211
+
212
+ # Rotating from credentials that don't work would just replace one broken
213
+ # state with another, so establish that .env currently matches the cluster.
214
+ click.echo("Checking the current credentials...")
215
+ if not _credentials_work(db_user, old_password, db_name):
216
+ click.echo(
217
+ f"\nCannot authenticate as '{db_user}' with the {ENV_KEY} currently in "
218
+ f"{env_path.name}.\n\n"
219
+ "Fix that mismatch before rotating: .env has to match the password the\n"
220
+ "database volume was initialised with. Installs created before Arcsecond\n"
221
+ "read .env at all were always bootstrapped as\n"
222
+ "'arcsecond_docker'/'arcsecond_docker'."
223
+ )
224
+ sys.exit(1)
225
+
226
+ new_password = password if password else _get_random_postgres_password()
227
+
228
+ if new_password == old_password:
229
+ click.echo("That is already the current password. Nothing to do.")
230
+ sys.exit(0)
231
+
232
+ original_text = env_path.read_text(encoding="utf-8")
233
+ updated_text = _replace_env_value(original_text, ENV_KEY, new_password)
234
+ if updated_text is None:
235
+ click.echo(
236
+ f"No {ENV_KEY} line found in {env_path}. "
237
+ "Re-run `arcsecond setup` from this directory to repair the file."
238
+ )
239
+ sys.exit(1)
240
+
241
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
242
+ backup_path = env_path.with_name(f".env.bak-{timestamp}")
243
+
244
+ if dry_run:
245
+ click.echo(click.style("\nDry run — nothing was changed.", fg="yellow"))
246
+ click.echo(f" would back up {env_path.name} -> {backup_path.name}")
247
+ click.echo(f" would rewrite {ENV_KEY} in {env_path.name}")
248
+ click.echo(f" would run ALTER ROLE \"{db_user}\" WITH PASSWORD '***'")
249
+ if not no_restart:
250
+ click.echo(
251
+ f" would recreate {', '.join(SERVICES_TO_RECREATE)} "
252
+ "(docker compose up -d --force-recreate)"
253
+ )
254
+ sys.exit(0)
255
+
256
+ backup_path.write_text(original_text, encoding="utf-8")
257
+ click.echo(f"Backed up {env_path.name} to {backup_path.name}")
258
+
259
+ # Write .env before touching the cluster. Either order leaves a window
260
+ # where the two disagree, but a failed ALTER lets us put the file back
261
+ # exactly as it was, whereas a failed write after a successful ALTER would
262
+ # leave the operator holding a password nothing has recorded.
263
+ env_path.write_text(updated_text, encoding="utf-8")
264
+
265
+ click.echo(f"Setting the new password for role '{db_user}'...")
266
+ sql = f'ALTER ROLE "{db_user}" WITH PASSWORD {_sql_literal(new_password)};'
267
+ result = _psql(sql, db_user, old_password, db_name)
268
+
269
+ if result.returncode != 0:
270
+ env_path.write_text(original_text, encoding="utf-8")
271
+ click.echo(
272
+ click.style(
273
+ f"\nALTER ROLE failed, so {env_path.name} was restored and nothing "
274
+ "changed:\n" + (result.stderr or "").strip(),
275
+ fg="red",
276
+ )
277
+ )
278
+ sys.exit(1)
279
+
280
+ if not _credentials_work(db_user, new_password, db_name):
281
+ # The ALTER reported success but the new password does not work. Try to
282
+ # put the cluster back where it was, authenticating with the password we
283
+ # were just told is in effect.
284
+ revert = _psql(
285
+ f'ALTER ROLE "{db_user}" WITH PASSWORD {_sql_literal(old_password)};',
286
+ db_user,
287
+ new_password,
288
+ db_name,
289
+ )
290
+ env_path.write_text(original_text, encoding="utf-8")
291
+
292
+ if revert.returncode == 0:
293
+ click.echo(
294
+ click.style(
295
+ "\nThe new password did not verify; rolled the database and "
296
+ f"{env_path.name} back to the old one.",
297
+ fg="red",
298
+ )
299
+ )
300
+ elif _credentials_work(db_user, old_password, db_name):
301
+ # Neither password change took effect, so the cluster is exactly
302
+ # where it started. Nothing is broken — say so plainly rather than
303
+ # sending the operator hunting for a password that was never set.
304
+ click.echo(
305
+ click.style(
306
+ "\nThe new password did not verify and the database still "
307
+ f"accepts the old one, so nothing changed. {env_path.name} has "
308
+ "been restored.",
309
+ fg="red",
310
+ )
311
+ )
312
+ else:
313
+ click.echo(
314
+ click.style(
315
+ "\nThe new password did not verify AND the database no longer "
316
+ "accepts the old one. It may now expect:\n\n"
317
+ f" {new_password}\n\n"
318
+ f"{env_path.name} has been restored to the old value — set it to "
319
+ "whichever of the two actually works.",
320
+ fg="red",
321
+ )
322
+ )
323
+ sys.exit(1)
324
+
325
+ click.echo(click.style("Password changed and verified.", fg="green"))
326
+
327
+ if no_restart:
328
+ click.echo(
329
+ "\nSkipping the restart, as asked. The running containers still hold the\n"
330
+ "old password and will fail on their next reconnect. Apply it with:\n"
331
+ f" docker compose up -d --force-recreate {' '.join(SERVICES_TO_RECREATE)}"
332
+ )
333
+ else:
334
+ click.echo(f"Recreating {', '.join(SERVICES_TO_RECREATE)}...")
335
+ cmd = [
336
+ "docker",
337
+ "compose",
338
+ "up",
339
+ "-d",
340
+ "--force-recreate",
341
+ *SERVICES_TO_RECREATE,
342
+ ]
343
+ click.echo(click.style(f"$ {' '.join(cmd)}", fg="cyan"))
344
+ recreate = subprocess.run(cmd, capture_output=True, text=True, check=False)
345
+ if recreate.returncode != 0:
346
+ click.echo(
347
+ click.style(
348
+ "\nThe password was changed successfully, but recreating the "
349
+ "containers failed:\n" + (recreate.stderr or "").strip() + "\n\n"
350
+ "Re-run the command above once Docker is happy.",
351
+ fg="yellow",
352
+ )
353
+ )
354
+ sys.exit(1)
355
+
356
+ if show:
357
+ click.echo(f"\nNew password: {new_password}")
358
+ else:
359
+ click.echo(
360
+ f"\nThe new password is in {env_path.name} (use --show to print it here)."
361
+ )
362
+ click.echo(
363
+ f"Keep {backup_path.name} until you have confirmed the stack is healthy, "
364
+ "then delete it —\nit still contains the old password."
365
+ )
@@ -1,9 +1,23 @@
1
1
  import base64
2
2
  import os
3
3
  import secrets
4
+ import subprocess
4
5
  from pathlib import Path
5
6
 
6
7
 
8
+ def _container_running(name):
9
+ try:
10
+ out = subprocess.run(
11
+ ["docker", "inspect", "-f", "{{.State.Running}}", name],
12
+ capture_output=True,
13
+ text=True,
14
+ check=False,
15
+ )
16
+ except FileNotFoundError:
17
+ return False
18
+ return out.returncode == 0 and out.stdout.strip() == "true"
19
+
20
+
7
21
  def _read_env_value(key, env_path=None):
8
22
  """Read a single value from a KEY=value / KEY="value" .env file.
9
23