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>
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`.
|
|
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
|