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>
This commit is contained in:
Cauê Faleiros
2026-09-21 11:36:59 -03:00
parent 9b38c9fe3d
commit 341f154c36
9 changed files with 240 additions and 13 deletions

View File

@@ -21,7 +21,8 @@ jobs:
local.test_dependency_lock \ local.test_dependency_lock \
local.test_staging_readiness \ local.test_staging_readiness \
deploy.test_production_preflight \ deploy.test_production_preflight \
local.test_pricing -v local.test_pricing \
local.test_secrets -v
sh -n local/lock_dependencies.sh sh -n local/lock_dependencies.sh
integration: integration:

View File

@@ -17,7 +17,7 @@ REQUIRED = (
'IMAGE_TAG', 'PUBLIC_ORIGIN', 'PUBLIC_HOST', 'KANBAN_HOST', 'IMAGE_TAG', 'PUBLIC_ORIGIN', 'PUBLIC_HOST', 'KANBAN_HOST',
'SITE_PORT', 'KANBAN_PORT', 'SITE_PORT', 'KANBAN_PORT',
'R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT', 'R2_BUCKET', 'R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT', 'R2_BUCKET',
'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_USER', 'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_EMAIL',
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES', 'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES', 'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES',
'PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER', 'PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER',
@@ -33,13 +33,12 @@ IMAGE_REPOSITORY = re.compile(
DNS = re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$') DNS = re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
NAME = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,127}$') NAME = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,127}$')
SOURCE_BLOCKERS = { SOURCE_BLOCKERS = {
'local/adapters.py': ( # Markers must name something that is still true, or the gate weakens without
'This runtime only supports APP_ENV=local', # failing. Four entries here described a local-only runtime and stopped
'Only local S3 storage is supported', # matching when R2 support landed; they were removed rather than left to rot.
), # What remains is the real blocker: no production payment or messaging adapter
# exists, so these lines must change before a release can be meaningful.
'local/app.py': ( 'local/app.py': (
"allowed_hosts=['localhost', '127.0.0.1']",
"'environment': 'local'",
'payment = FakePayment()', 'payment = FakePayment()',
), ),
'local/worker.py': ( 'local/worker.py': (
@@ -55,12 +54,63 @@ def source_errors(root=ROOT):
for marker in markers: for marker in markers:
if marker in text: if marker in text:
errors.append(f'{relative} remains local-only: {marker}') errors.append(f'{relative} remains local-only: {marker}')
secrets_module = root / 'local' / 'secrets.py' errors.extend(secret_loading_errors(root))
if not secrets_module.exists() or 'DATABASE_URL_FILE' not in secrets_module.read_text():
errors.append('local runtime does not load the production Docker secret *_FILE settings')
return errors return errors
def secret_loading_errors(root=ROOT):
"""Exercise the secret loader instead of grepping it.
Searching for a string passes as soon as someone writes that string, and
fails when a working implementation happens to spell it differently. Load the
module and make it resolve a real file.
"""
import importlib.util
import tempfile
module_path = root / 'local' / 'secrets.py'
if not module_path.exists():
return ['local runtime does not load the production Docker secret *_FILE settings']
try:
spec = importlib.util.spec_from_file_location('_preflight_secrets', module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
except Exception as exc:
return [f'local/secrets.py could not be loaded: {exc}']
stack_names = set()
stack = root / 'deploy' / 'stack.yaml'
if stack.exists():
for line in stack.read_text().splitlines():
if '_FILE:' in line:
key = line.split(':')[0].strip()
# The database image consumes this one; the application does not.
if key and key != 'POSTGRES_PASSWORD_FILE':
stack_names.add(key[:-len('_FILE')])
failures = []
with tempfile.TemporaryDirectory() as directory:
for name in sorted(stack_names):
path = Path(directory) / name
path.write_text('resolved-value\n', encoding='utf-8')
environ = {f'{name}_FILE': str(path)}
try:
module.load(environ)
except Exception as exc:
failures.append(f'{name}_FILE is not resolved by local/secrets.py: {exc}')
continue
if environ.get(name) != 'resolved-value':
failures.append(f'{name}_FILE did not produce {name}')
# A missing secret must stop the service, never start it unconfigured.
try:
module.load({'DATABASE_URL_FILE': str(Path(directory) / 'absent')})
except Exception:
pass
else:
failures.append('local/secrets.py does not fail closed on an unreadable secret')
return failures
def config_errors(values): def config_errors(values):
errors = [] errors = []
for name in APPROVALS: for name in APPROVALS:

View File

@@ -9,7 +9,8 @@ x-app-environment: &app-environment
AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key
AWS_DEFAULT_REGION: auto AWS_DEFAULT_REGION: auto
OPERATOR_USER: ${OPERATOR_USER:?set OPERATOR_USER} # The Kanban authenticates by email; the runtime reads OPERATOR_EMAIL.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL}
OPERATOR_PASSWORD_FILE: /run/secrets/operator_password OPERATOR_PASSWORD_FILE: /run/secrets/operator_password
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER} PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER} FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER}

View File

@@ -27,7 +27,7 @@ def valid_config():
'POSTGRES_USER': 'dtf_admin', 'POSTGRES_USER': 'dtf_admin',
'APP_DB_USER': 'dtf_app', 'APP_DB_USER': 'dtf_app',
'POSTGRES_VOLUME': 'dtf-postgres-data', 'POSTGRES_VOLUME': 'dtf-postgres-data',
'OPERATOR_USER': 'dtf-operator', 'OPERATOR_EMAIL': 'operador@example.com',
'STORAGE_QUOTA_BYTES': '53687091200', 'STORAGE_QUOTA_BYTES': '53687091200',
'OWNER_UPLOAD_QUOTA_BYTES': '10737418240', 'OWNER_UPLOAD_QUOTA_BYTES': '10737418240',
'MAX_UPLOAD_BYTES': '5368709120', 'MAX_UPLOAD_BYTES': '5368709120',

View File

@@ -14,12 +14,14 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from . import db from . import db
from .secrets import load as load_secret_files
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price from .pricing import price
from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit
from .scanning import require_clean from .scanning import require_clean
load_secret_files()
require_runtime() require_runtime()
storage = LocalS3Storage() storage = LocalS3Storage()
payment = FakePayment() payment = FakePayment()

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import psycopg import psycopg
from psycopg import sql from psycopg import sql
from .secrets import load as load_secret_files
def admin_connect(): def admin_connect():
@@ -25,6 +26,7 @@ def admin_password():
def main(): def main():
load_secret_files()
role = os.environ['APP_DB_USER'] role = os.environ['APP_DB_USER']
password = os.environ['APP_DB_PASSWORD'] password = os.environ['APP_DB_PASSWORD']
if password == admin_password(): if password == admin_password():

76
local/secrets.py Normal file
View File

@@ -0,0 +1,76 @@
"""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

93
local/test_secrets.py Normal file
View File

@@ -0,0 +1,93 @@
"""Docker secret-file resolution. Standard library only; no stack required."""
import tempfile
import unittest
from pathlib import Path
from local.secrets import SECRET_FILE_SETTINGS, load, read_secret
class SecretFileTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def secret(self, content, name='secret'):
path = Path(self.dir.name) / name
path.write_text(content, encoding='utf-8')
return str(path)
def test_resolves_file_into_plain_setting(self):
env = {'DATABASE_URL_FILE': self.secret('postgresql://u:p@db:5432/dtf\n')}
self.assertEqual(load(env), ['DATABASE_URL'])
self.assertEqual(env['DATABASE_URL'], 'postgresql://u:p@db:5432/dtf')
def test_strips_only_one_trailing_newline(self):
# A generated password may legitimately end in whitespace, so only the
# newline `docker secret` or an editor appends may be removed.
self.assertEqual(read_secret(self.secret('p@ss \n')), 'p@ss ')
self.assertEqual(read_secret(self.secret('p@ss\n\n')), 'p@ss\n')
self.assertEqual(read_secret(self.secret('p@ss\r\n')), 'p@ss')
self.assertEqual(read_secret(self.secret('p@ss')), 'p@ss')
def test_preserves_characters_that_would_break_a_url(self):
value = 'p@ss:w/rd?#[]&=+$ ,%'
env = {'OPERATOR_PASSWORD_FILE': self.secret(value + '\n')}
load(env)
self.assertEqual(env['OPERATOR_PASSWORD'], value)
def test_resolves_every_documented_production_setting(self):
env = {f'{name}_FILE': self.secret(f'value-for-{name}', name)
for name in SECRET_FILE_SETTINGS}
load(env)
for name in SECRET_FILE_SETTINGS:
self.assertEqual(env[name], f'value-for-{name}')
def test_missing_file_fails_closed(self):
env = {'DATABASE_URL_FILE': str(Path(self.dir.name) / 'absent')}
with self.assertRaises(RuntimeError) as caught:
load(env)
self.assertIn('DATABASE_URL_FILE', str(caught.exception))
self.assertNotIn('DATABASE_URL', env)
def test_empty_secret_fails_closed(self):
with self.assertRaises(RuntimeError):
load({'OPERATOR_PASSWORD_FILE': self.secret('\n')})
def test_empty_path_fails_closed(self):
with self.assertRaises(RuntimeError):
load({'OPERATOR_PASSWORD_FILE': ' '})
def test_value_and_file_together_is_ambiguous(self):
env = {'OPERATOR_PASSWORD': 'inline',
'OPERATOR_PASSWORD_FILE': self.secret('from-file')}
with self.assertRaises(RuntimeError):
load(env)
self.assertEqual(env['OPERATOR_PASSWORD'], 'inline')
def test_never_puts_a_secret_in_the_error_text(self):
value = 'super-secret-value'
env = {'TINY_TOKEN': value, 'TINY_TOKEN_FILE': self.secret(value)}
with self.assertRaises(RuntimeError) as caught:
load(env)
self.assertNotIn(value, str(caught.exception))
def test_ignores_a_bare_suffix_and_leaves_other_settings_alone(self):
env = {'_FILE': '/nowhere', 'APP_ENV': 'production'}
self.assertEqual(load(env), [])
self.assertEqual(env['APP_ENV'], 'production')
def test_documented_list_matches_the_production_stack(self):
stack = Path(__file__).resolve().parent.parent / 'deploy' / 'stack.yaml'
if not stack.exists():
self.skipTest('production stack definition not present')
text = stack.read_text()
# POSTGRES_PASSWORD_FILE is consumed by the database image, not by us.
used = {line.split(':')[0].strip() for line in text.splitlines()
if '_FILE:' in line and 'POSTGRES_PASSWORD_FILE' not in line}
for key in used:
self.assertIn(key[:-len('_FILE')], SECRET_FILE_SETTINGS,
f'{key} is passed by the stack but undocumented in secrets.py')
if __name__ == '__main__':
unittest.main()

View File

@@ -6,9 +6,11 @@ import time
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
from .secrets import load as load_secret_files
from .db import connect from .db import connect
from .scanning import ClamAV, scan_loop from .scanning import ClamAV, scan_loop
load_secret_files()
require_runtime() require_runtime()
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()} adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
last_tick = 0.0 last_tick = 0.0