"""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())