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>
77 lines
2.8 KiB
Python
77 lines
2.8 KiB
Python
"""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
|