Files
dtf-system/local/secrets.py
Cauê Faleiros 9926d3ab55 chore: remove the unused second stack definition
deploy/stack.yaml arrived in the first commit and was never deployed. Portainer
runs the repository's docker-compose.yml. Keeping both meant two definitions
drifting apart, with the documentation naming the one nobody used, which is how
the credential question came up at all.

The hardening it offered is narrower than it looks: Docker secrets keep values
out of docker inspect and the Portainer console, but local/secrets.py loads them
into the process environment regardless, and anyone able to read docker inspect
can already read the secret files. With a single Portainer user, the benefit that
remains does not outweigh maintaining a divergent copy.

local/secrets.py stays: inert against the deployed file, and it lets a stack
switch to Docker secrets later without touching code. The preflight and its tests
degrade cleanly when no such stack is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:48:22 -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`.
The deployed `docker-compose.yml` passes credentials as plain environment
variables, so this module is inert there. It exists so a stack can supply them as
Docker secrets instead without any code change; see `ROADMAP.md` 2.12.
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 a stack may supply 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.
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