feat: load Docker secret files so the production stack can boot
deploy/stack.yaml passes DATABASE_URL_FILE, AWS_ACCESS_KEY_ID_FILE, OPERATOR_PASSWORD_FILE and the provider tokens as Swarm secret paths, but the runtime only ever read the plain names. That stack could not start: the database URL and R2 credentials were absent, and operator login raised KeyError, so it returned 500 instead of the intended 503. local/secrets.py resolves every <NAME>_FILE into <NAME> before configuration is read, from the API, worker and bootstrap entrypoints. It fails closed on an unreadable or empty secret and on a name supplied both directly and as a file, because starting with a credential nobody intended is worse than not starting. Only one trailing newline is stripped, so a generated password keeps any whitespace that belongs to it, and no value reaches an error message. The stack also passed OPERATOR_USER while the Kanban authenticates by email; it now passes OPERATOR_EMAIL, matching the runtime. The release gate checked this by searching local/secrets.py for the literal "DATABASE_URL_FILE", which would pass for any file containing that string. It now loads the module and makes it resolve every secret the stack declares, and asserts it fails closed on a missing one. Four marker strings that stopped matching when R2 support landed are removed rather than left to rot; the two that still describe real blockers stay, so the gate continues to refuse a release while payment and messaging adapters are fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
76
local/secrets.py
Normal file
76
local/secrets.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Resolve Docker secret files into the environment before configuration is read.
|
||||
|
||||
Swarm mounts each secret as a file and the stack passes its path as `<NAME>_FILE`.
|
||||
Nothing read `_FILE` settings, so `deploy/stack.yaml` could not boot: the runtime
|
||||
looked for `DATABASE_URL`, `AWS_ACCESS_KEY_ID` and `OPERATOR_PASSWORD` while the
|
||||
stack supplied only the `_FILE` form.
|
||||
|
||||
Call `load()` in every entrypoint before any configuration is read.
|
||||
|
||||
Note for readers: this module is `local.secrets`. Python 3 resolves `import
|
||||
secrets` elsewhere in the package to the standard library, not to this file.
|
||||
"""
|
||||
import os
|
||||
|
||||
# The settings production supplies as secret files. Any other `*_FILE` variable is
|
||||
# resolved the same way; this list documents the contract and is what the release
|
||||
# gate checks against, so keep it in step with `deploy/stack.yaml`.
|
||||
SECRET_FILE_SETTINGS = (
|
||||
'DATABASE_URL',
|
||||
'DATABASE_ADMIN_URL',
|
||||
'DATABASE_PASSWORD',
|
||||
'DATABASE_ADMIN_PASSWORD',
|
||||
'APP_DB_PASSWORD',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'OPERATOR_PASSWORD',
|
||||
'PAYMENT_TOKEN',
|
||||
'PAYMENT_WEBHOOK_SECRET',
|
||||
'TINY_TOKEN',
|
||||
'WHATSAPP_TOKEN',
|
||||
)
|
||||
|
||||
SUFFIX = '_FILE'
|
||||
|
||||
|
||||
def read_secret(path):
|
||||
"""One secret's value, without the newline an editor or `docker secret` adds.
|
||||
|
||||
Only a single trailing newline is removed: everything else is part of the
|
||||
value, because a generated password may legitimately end in whitespace.
|
||||
"""
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
value = handle.read()
|
||||
if value.endswith('\r\n'):
|
||||
return value[:-2]
|
||||
if value.endswith('\n'):
|
||||
return value[:-1]
|
||||
return value
|
||||
|
||||
|
||||
def load(environ=None):
|
||||
"""Replace every `<NAME>_FILE` path with `<NAME>` holding the file's contents.
|
||||
|
||||
Fails closed. An unreadable secret, an empty one, or a name supplied both
|
||||
directly and as a file is a configuration error, and starting anyway would
|
||||
mean running with a credential nobody intended. Never logs a value.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
resolved = []
|
||||
for key in sorted(k for k in environ if k.endswith(SUFFIX) and len(k) > len(SUFFIX)):
|
||||
name = key[:-len(SUFFIX)]
|
||||
path = environ[key].strip()
|
||||
if not path:
|
||||
raise RuntimeError(f'{key} is set but empty; point it at a secret file')
|
||||
if environ.get(name):
|
||||
raise RuntimeError(
|
||||
f'{name} and {key} are both set; supply the value or the file, not both')
|
||||
try:
|
||||
value = read_secret(path)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f'{key} could not be read: {exc.strerror}') from None
|
||||
if not value:
|
||||
raise RuntimeError(f'{key} points at an empty secret file')
|
||||
environ[name] = value
|
||||
resolved.append(name)
|
||||
return resolved
|
||||
Reference in New Issue
Block a user