first commit
This commit is contained in:
159
deploy/production_preflight.py
Normal file
159
deploy/production_preflight.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user