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>
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
"""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()
|