Files
dtf-system/deploy/production_preflight.py
Cauê Faleiros 98c951d374
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s
first commit
2026-09-15 16:42:34 -03:00

160 lines
7.6 KiB
Python

"""Fail closed until the application and non-secret production inputs are ready."""
import os
from pathlib import Path
import re
import sys
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
APPROVALS = (
'PRODUCTION_DEPLOY_ENABLED',
'PRODUCTION_INPUTS_APPROVED',
'PRODUCTION_SECURITY_REVIEW_APPROVED',
'PRODUCTION_RESTORE_REHEARSED',
)
REQUIRED = (
'API_IMAGE', 'WEB_IMAGE', 'POSTGRES_IMAGE', 'CLAMAV_IMAGE',
'IMAGE_TAG', 'PUBLIC_ORIGIN', 'PUBLIC_HOST', 'KANBAN_HOST',
'SITE_PORT', 'KANBAN_PORT',
'R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT', 'R2_BUCKET',
'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_USER',
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES',
'PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER',
'DATABASE_URL_SECRET', 'DATABASE_ADMIN_URL_SECRET',
'DB_ADMIN_PASSWORD_SECRET', 'APP_DB_PASSWORD_SECRET',
'R2_ACCESS_KEY_ID_SECRET', 'R2_SECRET_ACCESS_KEY_SECRET',
'OPERATOR_PASSWORD_SECRET', 'PAYMENT_TOKEN_SECRET',
'PAYMENT_WEBHOOK_SECRET', 'TINY_TOKEN_SECRET', 'WHATSAPP_TOKEN_SECRET',
)
IMMUTABLE_IMAGE = re.compile(r'^[a-z0-9][a-z0-9._:/-]*@sha256:([0-9a-f]{64})$')
IMAGE_REPOSITORY = re.compile(
r'^[a-z0-9][a-z0-9.-]*(?::[0-9]{1,5})?(?:/[a-z0-9][a-z0-9._-]*)+$')
DNS = re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
NAME = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,127}$')
SOURCE_BLOCKERS = {
'local/adapters.py': (
'This runtime only supports APP_ENV=local',
'Only local S3 storage is supported',
),
'local/app.py': (
"allowed_hosts=['localhost', '127.0.0.1']",
"'environment': 'local'",
'payment = FakePayment()',
),
'local/worker.py': (
"adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}",
),
}
def source_errors(root=ROOT):
errors = []
for relative, markers in SOURCE_BLOCKERS.items():
text = (root / relative).read_text()
for marker in markers:
if marker in text:
errors.append(f'{relative} remains local-only: {marker}')
secrets_module = root / 'local' / 'secrets.py'
if not secrets_module.exists() or 'DATABASE_URL_FILE' not in secrets_module.read_text():
errors.append('local runtime does not load the production Docker secret *_FILE settings')
return errors
def config_errors(values):
errors = []
for name in APPROVALS:
if values.get(name) != 'approved':
errors.append(f'{name} must equal approved')
for name in REQUIRED:
value = values.get(name, '')
if not value or value.lower() in {'tbd', 'todo', 'replace-me', 'unconfirmed'}:
errors.append(f'{name} is missing or unresolved')
for name in ('API_IMAGE', 'WEB_IMAGE'):
if not IMAGE_REPOSITORY.fullmatch(values.get(name, '')):
errors.append(f'{name} must be a lowercase registry repository without a tag')
for name in ('POSTGRES_IMAGE', 'CLAMAV_IMAGE'):
match = IMMUTABLE_IMAGE.fullmatch(values.get(name, ''))
if not match or match.group(1) == '0' * 64:
errors.append(f'{name} must be an immutable non-placeholder digest reference')
image_tag = values.get('IMAGE_TAG', '')
if image_tag != 'latest' and not re.fullmatch(r'(?:[0-9a-f]{40}|[0-9a-f]{64})', image_tag):
errors.append('IMAGE_TAG must be latest or a full commit SHA published by Gitea')
origin = urlparse(values.get('PUBLIC_ORIGIN', ''))
if (origin.scheme != 'https' or not origin.hostname or origin.path not in ('', '/')
or origin.params or origin.query or origin.fragment):
errors.append('PUBLIC_ORIGIN must be an HTTPS origin without a path')
for name in ('PUBLIC_HOST', 'KANBAN_HOST'):
if not DNS.fullmatch(values.get(name, '')):
errors.append(f'{name} must be a valid lowercase DNS hostname')
if origin.hostname and values.get('PUBLIC_HOST') != origin.hostname:
errors.append('PUBLIC_ORIGIN hostname must equal PUBLIC_HOST')
for name in ('R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT'):
endpoint = urlparse(values.get(name, ''))
host = endpoint.hostname or ''
if (endpoint.scheme != 'https' or not host.endswith('.r2.cloudflarestorage.com')
or endpoint.path not in ('', '/') or endpoint.query or endpoint.fragment):
errors.append(f'{name} must be an HTTPS Cloudflare R2 S3 API endpoint')
bucket = values.get('R2_BUCKET', '')
if bucket and not re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', bucket):
errors.append('R2_BUCKET must be a valid S3 bucket name')
for name in ('PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER'):
if values.get(name, '').lower() in {'', 'fake', 'mock', 'local'}:
errors.append(f'{name} must select an approved non-fake implementation')
for name in REQUIRED:
if name.endswith('_SECRET') and values.get(name) and not NAME.fullmatch(values[name]):
errors.append(f'{name} must name a pre-provisioned external Swarm secret')
secret_names = [values.get(name, '') for name in REQUIRED if name.endswith('_SECRET')]
populated_secret_names = [name for name in secret_names if name]
if len(populated_secret_names) != len(set(populated_secret_names)):
errors.append('Every production secret setting must use a distinct external Swarm secret')
if values.get('POSTGRES_USER') == values.get('APP_DB_USER'):
errors.append('Database administrator and runtime user must differ')
if values.get('PUBLIC_HOST') == values.get('KANBAN_HOST'):
errors.append('Site and Kanban hosts must differ')
numeric = {}
for name in (
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES'):
try:
numeric[name] = int(values.get(name, '0'))
if numeric[name] <= 0:
raise ValueError
except ValueError:
errors.append(f'{name} must be a positive integer')
if numeric.get('OWNER_UPLOAD_QUOTA_BYTES', 0) > numeric.get('STORAGE_QUOTA_BYTES', 0):
errors.append('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES')
if numeric.get('SCAN_MAX_BYTES', 0) > numeric.get('MAX_UPLOAD_BYTES', 0):
errors.append('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES')
ports = {}
for name in ('SITE_PORT', 'KANBAN_PORT'):
try:
ports[name] = int(values.get(name, '0'))
if not 1024 <= ports[name] <= 65535:
raise ValueError
except ValueError:
errors.append(f'{name} must be an unprivileged TCP port from 1024 to 65535')
if ports.get('SITE_PORT') == ports.get('KANBAN_PORT'):
errors.append('SITE_PORT and KANBAN_PORT must differ')
return errors
def main(source_only=False):
errors = source_errors()
if not source_only:
errors.extend(config_errors(os.environ))
if errors:
print('BLOCKED: production preflight failed:')
for error in errors:
print(f'- {error}')
return 2
print('PASS: production source and non-secret deployment metadata passed preflight.')
print('This does not replace staging acceptance, image scanning, or human approval.')
return 0
if __name__ == '__main__':
if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] != '--source-only'):
raise SystemExit('usage: python deploy/production_preflight.py [--source-only]')
raise SystemExit(main(len(sys.argv) == 2))