Files
pingu-concerts/scripts/preprod_config.py
T

206 lines
9.5 KiB
Python

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