Harden pre-production deployment with external environment and preflights
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""Host-side deployment checks and secret-safe Compose execution (Python stdlib only)."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
|
||||
REQUIRED = (
|
||||
'POSTGRES_DB', 'POSTGRES_USER', 'POSTGRES_PASSWORD',
|
||||
'INITIAL_ADMIN_USERNAME', 'INITIAL_ADMIN_PASSWORD', 'INITIAL_ADMIN_EMAIL',
|
||||
'GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO',
|
||||
'PUSH_ENABLED', 'FIREBASE_PROJECT_ID', 'FIREBASE_SERVICE_ACCOUNT_FILE', 'COOKIE_SECURE',
|
||||
)
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class PreprodError(Exception):
|
||||
"""Only fixed messages or variable names; never external diagnostic text."""
|
||||
|
||||
|
||||
def private_file(path, repo_root, label):
|
||||
try:
|
||||
if not path.is_absolute() or path.resolve().is_relative_to(repo_root.resolve()):
|
||||
raise PreprodError(label + ' must be an absolute path outside the repository')
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise PreprodError(label + ' must be a regular file, not a symlink')
|
||||
if info.st_uid != os.getuid():
|
||||
raise PreprodError(label + ' must belong to the deployment user')
|
||||
if stat.S_IMODE(info.st_mode) not in (0o400, 0o600):
|
||||
raise PreprodError(label + ' requires permissions 600 or 400')
|
||||
except OSError:
|
||||
raise PreprodError(label + ' is missing or unreadable') from None
|
||||
|
||||
|
||||
def compose_environment():
|
||||
# Shell exports must not override the explicit env file (Compose precedence).
|
||||
# In particular, neither COMPOSE_* nor DOCKER_* can select a different stack/host.
|
||||
return {name: os.environ[name] for name in
|
||||
('PATH', 'HOME', 'USER', 'LOGNAME', 'TERM', 'LANG', 'LC_ALL') if name in os.environ}
|
||||
|
||||
|
||||
def invoke_compose(env_file, args, repo_root=REPO_ROOT):
|
||||
try:
|
||||
return subprocess.run(
|
||||
['sudo', 'docker', 'compose', '--env-file', str(env_file),
|
||||
'-f', 'compose.yml', '-f', 'compose.preprod.yml', *args],
|
||||
cwd=repo_root, env=compose_environment(), capture_output=True, text=True,
|
||||
)
|
||||
except (OSError, UnicodeError):
|
||||
raise PreprodError('cannot execute local sudo docker compose') from None
|
||||
|
||||
|
||||
def check_configuration(env_file, repo_root=REPO_ROOT):
|
||||
private_file(env_file, repo_root, 'Environment file')
|
||||
# Let Compose parse dotenv quoting/escapes/interpolation, not Bash or a second parser.
|
||||
# Values are captured in memory only. Never print this command's output or stderr.
|
||||
result = invoke_compose(env_file, ['config', '--no-interpolate', '--environment'], repo_root)
|
||||
if result.returncode:
|
||||
raise PreprodError('Compose cannot read the environment file; check syntax and Compose installation')
|
||||
values = {}
|
||||
for line in result.stdout.splitlines():
|
||||
name, separator, value = line.partition('=')
|
||||
if not separator or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in values:
|
||||
raise PreprodError('environment values must be single-line values')
|
||||
values[name] = value
|
||||
for name in REQUIRED:
|
||||
if not values.get(name, '').strip():
|
||||
raise PreprodError('required variable ' + name + ' is missing')
|
||||
# Compose itself adds these two metadata entries even with an empty env file.
|
||||
metadata = {'COMPOSE_PROJECT_NAME', 'DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND'}
|
||||
if (any(name.startswith(('COMPOSE_', 'DOCKER_')) and name not in metadata for name in values) or
|
||||
values.get('COMPOSE_PROJECT_NAME', repo_root.name) != repo_root.name):
|
||||
raise PreprodError('COMPOSE_* and DOCKER_* overrides are not allowed in preprod.env')
|
||||
for name in ('COOKIE_SECURE', 'PUSH_ENABLED'):
|
||||
if values[name].lower() != 'true':
|
||||
raise PreprodError(name + ' must be true for normal Pre-Production deployment')
|
||||
if values['FIREBASE_PROJECT_ID'] != 'metalcircle-30d9b':
|
||||
raise PreprodError('FIREBASE_PROJECT_ID does not match the Pre-Production project')
|
||||
private_file(Path(values['FIREBASE_SERVICE_ACCOUNT_FILE']), repo_root, 'Firebase secret')
|
||||
return values
|
||||
|
||||
|
||||
def backup_environment(env_file, repo_root=REPO_ROOT):
|
||||
private_file(env_file, repo_root, 'Environment file')
|
||||
directory = env_file.parent / 'backups'
|
||||
directory.mkdir(mode=0o700, exist_ok=True)
|
||||
info = directory.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or
|
||||
stat.S_IMODE(info.st_mode) != 0o700):
|
||||
raise PreprodError('backup directory must belong to the deployment user and have permissions 700')
|
||||
name = 'preprod.env.' + datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')
|
||||
target = directory / name
|
||||
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, 'wb') as output:
|
||||
os.fchmod(output.fileno(), 0o600)
|
||||
output.write(env_file.read_bytes())
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True)
|
||||
raise
|
||||
backups = sorted(path for path in directory.iterdir()
|
||||
if re.fullmatch(r'preprod\.env\.\d{8}-\d{6}-\d{6}', path.name))
|
||||
for old in backups[:-10]:
|
||||
private_file(old, repo_root, 'Environment backup')
|
||||
old.unlink()
|
||||
print('PASS: environment backup saved outside the repository; last 10 retained.')
|
||||
|
||||
|
||||
def safe_logs(output):
|
||||
"""Allowlist lifecycle/error categories; omit request paths and all free-form text."""
|
||||
count = 0
|
||||
for line in output.splitlines():
|
||||
request = re.search(r'"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) .* HTTP/[0-9.]+" (\d{3})', line)
|
||||
category = re.search(r'Gitea issue submission failed: (wrong_account|configuration|invalid_response|'
|
||||
r'invalid_attachment_response|network_or_timeout|HTTP [0-9]{3})\s*$', line)
|
||||
push_error = re.search(r'Push delivery failed: (configuration|unregistered|transient|permanent)\s*$', line)
|
||||
lifecycle = next((text for text in ('Application startup complete.', 'Application shutdown complete.',
|
||||
'Waiting for application startup.', 'Shutting down')
|
||||
if line.rstrip().endswith(text)), None)
|
||||
if request:
|
||||
print('HTTP ' + request[1] + ' ' + request[2])
|
||||
elif category:
|
||||
print('Gitea: ' + category[1])
|
||||
elif push_error:
|
||||
print('Push: ' + push_error[1])
|
||||
elif lifecycle:
|
||||
print(lifecycle)
|
||||
else:
|
||||
count += 1
|
||||
if count:
|
||||
print(str(count) + ' other log lines omitted (secret/privacy protection).')
|
||||
|
||||
|
||||
def run_compose(env_file, args):
|
||||
if not args or args[0] not in {'config', 'build', 'run', 'up', 'exec', 'ps', 'logs'}:
|
||||
raise PreprodError('unsupported deployment Compose command')
|
||||
private_file(env_file, REPO_ROOT, 'Environment file')
|
||||
result = invoke_compose(env_file, args)
|
||||
preflight = args[0] in {'run', 'exec'} and any(
|
||||
item in args for item in ('push_preflight.py', 'gitea_preflight.py'))
|
||||
if preflight:
|
||||
# Only our dedicated preflights' safe stdout; Docker stderr remains private.
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith(('PASS: ', 'FAIL: ')):
|
||||
print(line)
|
||||
if (result.returncode or not result.stdout.startswith('PASS: ') or
|
||||
any(line.startswith('FAIL: ') for line in result.stdout.splitlines())):
|
||||
raise PreprodError('container preflight failed; deployment stopped')
|
||||
elif result.returncode:
|
||||
raise PreprodError('Compose ' + args[0] + ' failed; raw diagnostics suppressed to protect secrets')
|
||||
elif args[0] == 'logs':
|
||||
safe_logs(result.stdout)
|
||||
elif args[0] == 'ps':
|
||||
# Compose versions return either JSON arrays or one JSON object per line.
|
||||
try:
|
||||
rows = json.loads(result.stdout) if result.stdout.lstrip().startswith('[') else [
|
||||
json.loads(line) for line in result.stdout.splitlines() if line.strip()]
|
||||
for row in rows:
|
||||
print('Container:', row['Name'], '| state:', row['State'])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise PreprodError('cannot parse Compose container status') from None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--env-file', type=Path, required=True)
|
||||
parser.add_argument('action', choices=('check', 'fingerprint', 'backup', 'compose'))
|
||||
parser.add_argument('compose_args', nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.action == 'check':
|
||||
check_configuration(args.env_file)
|
||||
for name in REQUIRED:
|
||||
print(name + ': set')
|
||||
print('PASS: environment and host secret checks.')
|
||||
elif args.action == 'fingerprint':
|
||||
# Captured by the shell, never displayed. Detect edits during build/preflights.
|
||||
values = check_configuration(args.env_file)
|
||||
digest = hashlib.sha256(args.env_file.read_bytes())
|
||||
digest.update(b'\x00')
|
||||
digest.update(Path(values['FIREBASE_SERVICE_ACCOUNT_FILE']).read_bytes())
|
||||
print(digest.hexdigest())
|
||||
elif args.action == 'backup':
|
||||
backup_environment(args.env_file)
|
||||
else:
|
||||
run_compose(args.env_file, args.compose_args)
|
||||
except PreprodError as error:
|
||||
print('ERROR: ' + str(error))
|
||||
return 1
|
||||
except (OSError, ValueError):
|
||||
print('ERROR: host file operation failed; check permissions and available disk space')
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user