All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m18s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m26s
Thirteen files at the repository root, seven of them documents. Only README.md earns a place there; the rest are now in docs/ beside the meeting notes, the client roadmap and the historical material. The compose files stay. docker-compose.yml is the path the dtf-cloud Portainer stack reads, so moving it would break deployment, and Docker resolves a compose file's relative build contexts against its own directory, so moving the other two would silently break every build. Both reasons are now written down where someone would otherwise try it. Correcting references turned up a live fault: the Portainer stack creation instructions still named deploy/stack.yaml as the compose path. That file was removed, so anyone recreating the stack from these instructions would have failed. It names docker-compose.yml now, with the reason it stays at the root. ROADMAP.md keeps the paths its closed findings were written with, and says so at the top. Those entries record where a fault was when it was found; rewriting them to match a later layout would make the record less true, not more. 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 `docs/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
|