"""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 `_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 `_FILE` path with `` 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