Files
dtf-system/local/secrets.py
Cauê Faleiros 341f154c36 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>
2026-09-21 11:36:59 -03:00

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