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.
- {arcsecond-3.13.0 → arcsecond-3.14.0}/.gitignore +0 -3
- {arcsecond-3.13.0 → arcsecond-3.14.0}/PKG-INFO +2 -2
- {arcsecond-3.13.0 → arcsecond-3.14.0}/README.md +1 -1
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cli.py +7 -3
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/__init__.py +1 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/backups.py +1 -14
- arcsecond-3.14.0/arcsecond/hosting/database.py +365 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/utils.py +14 -0
- arcsecond-3.14.0/arcsecond/imagesources/commands.py +527 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/proxy.py +111 -7
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/registry.py +88 -1
- arcsecond-3.14.0/arcsecond/imagesources/sources/network.py +283 -0
- arcsecond-3.14.0/arcsecond/imagesources/store.py +152 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/pyproject.toml +1 -1
- {arcsecond-3.13.0 → arcsecond-3.14.0}/sonar-project.properties +1 -1
- arcsecond-3.14.0/tests/test_hosting_database.py +330 -0
- arcsecond-3.14.0/tests/test_imagesources_netcam.py +418 -0
- arcsecond-3.14.0/tests/test_imagesources_store.py +327 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/uv.lock +58 -58
- arcsecond-3.13.0/.github/workflows/docsdeploy.yml +0 -28
- arcsecond-3.13.0/arcsecond/imagesources/commands.py +0 -206
- arcsecond-3.13.0/deploy.sh +0 -30
- arcsecond-3.13.0/docs/.vitepress/config.js +0 -37
- arcsecond-3.13.0/docs/.vitepress/theme/custom.css +0 -35
- arcsecond-3.13.0/docs/.vitepress/theme/index.js +0 -4
- arcsecond-3.13.0/docs/api-basics.md +0 -103
- arcsecond-3.13.0/docs/backups.md +0 -158
- arcsecond-3.13.0/docs/img/icon-logo.png +0 -0
- arcsecond-3.13.0/docs/index.md +0 -36
- arcsecond-3.13.0/docs/install-windows.md +0 -215
- arcsecond-3.13.0/docs/install.md +0 -85
- arcsecond-3.13.0/docs/public/img/icon-logo.png +0 -0
- arcsecond-3.13.0/docs/resources.md +0 -358
- arcsecond-3.13.0/docs/rotate-postgres-password.md +0 -78
- arcsecond-3.13.0/docs/upload.md +0 -90
- arcsecond-3.13.0/docs/webcam.md +0 -178
- arcsecond-3.13.0/package-lock.json +0 -3528
- arcsecond-3.13.0/package.json +0 -29
- {arcsecond-3.13.0 → arcsecond-3.14.0}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/.github/dependabot.yml +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/.github/workflows/pythonpublish.yml +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/.github/workflows/tests.yml +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/LICENSE +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/Makefile +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/__version__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/alpaca/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/alpaca/commands.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/alpaca/dome_probe.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/config.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/endpoint.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/main.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/api/resources.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/errors.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/local.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/main.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/setup.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/base.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/imagesources/sources/opencv.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/options.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/arcsecond/targets.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/examples/example_upload_files.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/requirements.txt +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/setup.cfg +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/setup.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_api.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_api_endpoint.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_config.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/api/test_targets.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/conftest.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_alpaca_probe.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_cli.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_hosting_local.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/test_targets_planning.py +0 -0
- {arcsecond-3.13.0 → arcsecond-3.14.0}/tests/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arcsecond
|
|
3
|
-
Version: 3.
|
|
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
|
-
[](http://pepy.tech/project/arcsecond)
|
|
53
53
|
[](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
|
-
[](http://pepy.tech/project/arcsecond)
|
|
2
2
|
[](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
|
-
#
|
|
65
|
-
|
|
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)
|
|
@@ -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
|
|