Harden pre-production deployment with external environment and preflights
This commit is contained in:
+61
-18
@@ -1,7 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set +x
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
trap 'status=$?; printf "FEHLER: Deployment in Zeile %s abgebrochen (Exit %s).\n" "$LINENO" "$status" >&2' ERR
|
||||
# The only active Pre-Production env source. Never source the checkout's .env.
|
||||
ENV_FILE='/home/kai/.config/metalcircle/preprod.env'
|
||||
DEPLOYMENT_STARTED=0
|
||||
on_error() {
|
||||
local status=$?
|
||||
if [[ "$DEPLOYMENT_STARTED" == 0 ]]; then
|
||||
printf 'Deployment aborted. Running container unchanged.\n' >&2
|
||||
else
|
||||
printf 'Deployment verification failed after the container update. Manual investigation required.\n' >&2
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap on_error ERR
|
||||
|
||||
CHECK_ONLY=0
|
||||
if [[ "${1:-}" == '--check' && "$#" == 1 ]]; then
|
||||
CHECK_ONLY=1
|
||||
elif [[ "$#" != 0 ]]; then
|
||||
printf 'Usage: ./scripts/deploy-preprod.sh [--check]\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
|
||||
@@ -18,48 +40,69 @@ if [[ "$BRANCH" != "main" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then
|
||||
WORKTREE_STATUS="$(git status --porcelain --untracked-files=all)"
|
||||
if [[ -n "$WORKTREE_STATUS" ]]; then
|
||||
printf 'FEHLER: Working Tree ist nicht sauber. Änderungen zuerst committen oder entfernen.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Aktualisiere main mit Fast-Forward ...\n'
|
||||
git pull --ff-only origin main
|
||||
if [[ "$CHECK_ONLY" == 0 ]]; then
|
||||
printf 'Aktualisiere main mit Fast-Forward ...\n'
|
||||
git pull --ff-only origin main
|
||||
fi
|
||||
|
||||
host_check() {
|
||||
python3 "$SCRIPT_DIR/preprod_config.py" --env-file "$ENV_FILE" "$@"
|
||||
}
|
||||
|
||||
compose() {
|
||||
sudo docker compose -f compose.yml -f compose.preprod.yml "$@"
|
||||
# Executes sudo docker compose --env-file "$ENV_FILE" -f compose.yml -f compose.preprod.yml.
|
||||
# The helper clears ambient overrides and suppresses secret-bearing raw diagnostics.
|
||||
host_check compose "$@"
|
||||
}
|
||||
|
||||
run_preflight() {
|
||||
local output
|
||||
output="$("$@" python push_preflight.py \
|
||||
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com)"
|
||||
printf '%s\n' "$output"
|
||||
if [[ "$output" != PASS:* ]]; then
|
||||
printf 'FEHLER: Push-Preflight hat kein PASS geliefert.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
"$@" python push_preflight.py \
|
||||
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
|
||||
"$@" python gitea_preflight.py
|
||||
}
|
||||
|
||||
printf 'Prüfe externe Environment-Datei und Host-Secret ...\n'
|
||||
host_check check
|
||||
CONFIG_FINGERPRINT="$(host_check fingerprint)"
|
||||
printf 'Prüfe Pre-Production-Compose-Konfiguration ...\n'
|
||||
compose config --quiet
|
||||
|
||||
printf 'Baue Web-Image ...\n'
|
||||
compose build web
|
||||
|
||||
printf 'Prüfe Firebase-Credential im temporären Container ...\n'
|
||||
run_preflight compose run --rm --no-deps web
|
||||
printf 'Prüfe Firebase und Gitea im temporären Container ...\n'
|
||||
run_preflight compose run --rm --no-deps -T web
|
||||
|
||||
if [[ "$(host_check fingerprint)" != "$CONFIG_FINGERPRINT" ]]; then
|
||||
printf 'ERROR: Environment or Firebase secret changed during deployment. Start again.\n' >&2
|
||||
false
|
||||
fi
|
||||
|
||||
if [[ "$CHECK_ONLY" == 1 ]]; then
|
||||
printf 'PASS: Pre-Production preflights completed; running container unchanged.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Sichere geprüfte Environment-Datei außerhalb des Checkouts ...\n'
|
||||
host_check backup
|
||||
|
||||
printf 'Aktualisiere ausschließlich den Webcontainer ...\n'
|
||||
DEPLOYMENT_STARTED=1
|
||||
compose up -d --no-deps web
|
||||
|
||||
printf 'Prüfe Firebase-Credential im laufenden Webcontainer ...\n'
|
||||
printf 'Prüfe Firebase und Gitea im laufenden Webcontainer ...\n'
|
||||
run_preflight compose exec -T web
|
||||
|
||||
printf '\nCompose-Status:\n'
|
||||
compose ps
|
||||
compose ps --format json
|
||||
|
||||
printf '\nWeb-Logs der letzten 2 Minuten (maximal 100 Zeilen):\n'
|
||||
printf '\nWeb-Logs der letzten 2 Minuten (maximal 100 Zeilen, sicher gefiltert):\n'
|
||||
compose logs --since=2m --tail=100 --no-color web
|
||||
|
||||
printf '\nDeployter Git-Commit:\n'
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Run the real shell/helper in a disposable checkout with fake Git/Docker commands."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import preprod_config
|
||||
|
||||
|
||||
class DeploymentOrderingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.repo = self.root / 'pingu-concerts'
|
||||
(self.repo / 'scripts').mkdir(parents=True)
|
||||
self.env = self.root / 'preprod.env'
|
||||
self.secret = self.root / 'firebase.json'
|
||||
self.secret.write_text('synthetic test bytes, not a credential')
|
||||
self.secret.chmod(0o600)
|
||||
self.values = {name: 'synthetic-private-value' for name in preprod_config.REQUIRED}
|
||||
self.values.update(PUSH_ENABLED='true', COOKIE_SECURE='true', FIREBASE_PROJECT_ID='metalcircle-30d9b',
|
||||
FIREBASE_SERVICE_ACCOUNT_FILE=str(self.secret))
|
||||
self.write_env()
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
script = (source / 'deploy-preprod.sh').read_text().replace(
|
||||
"ENV_FILE='/home/kai/.config/metalcircle/preprod.env'", 'ENV_FILE=' + repr(str(self.env)))
|
||||
(self.repo / 'scripts/deploy-preprod.sh').write_text(script)
|
||||
shutil.copy(source / 'preprod_config.py', self.repo / 'scripts')
|
||||
self.control = self.root / 'control.json'
|
||||
self.control.write_text('{}')
|
||||
self.trace = self.root / 'trace.jsonl'
|
||||
self.bin = self.root / 'bin'
|
||||
self.bin.mkdir()
|
||||
shared = (f'CONTROL = {str(self.control)!r}\nTRACE = {str(self.trace)!r}\n'
|
||||
'import json, sys\nfrom pathlib import Path\n'
|
||||
'control = json.loads(Path(CONTROL).read_text())\n'
|
||||
'args = sys.argv[1:]\n'
|
||||
'with open(TRACE, "a") as trace: trace.write(json.dumps([Path(sys.argv[0]).name, *args]) + "\\n")\n')
|
||||
self.executable('git', shared + f'''
|
||||
if args == ['rev-parse', '--show-toplevel']: print({str(self.repo)!r})
|
||||
elif args == ['branch', '--show-current']: print(control.get('branch', 'main'))
|
||||
elif args[0] == 'status':
|
||||
if control.get('fail') == 'status': sys.exit(1)
|
||||
print(control.get('dirty', ''))
|
||||
elif args[0] == 'log': print('abc123 synthetic test commit')
|
||||
elif args[0] == 'pull' and control.get('fail') == 'pull': sys.exit(1)
|
||||
''')
|
||||
self.executable('sudo', shared + '''
|
||||
assert args[:3] == ['docker', 'compose', '--env-file']
|
||||
env_file = Path(args[3])
|
||||
assert args[4:8] == ['-f', 'compose.yml', '-f', 'compose.preprod.yml']
|
||||
action = args[8:]
|
||||
step = action[0]
|
||||
if step in ('run', 'exec'):
|
||||
step += '-firebase' if 'push_preflight.py' in action else '-gitea'
|
||||
if control.get('no_pass') == step:
|
||||
print('synthetic-private-value')
|
||||
sys.exit(0)
|
||||
if control.get('fail') == step:
|
||||
print('FAIL: gitea_http_403' if 'gitea' in step else 'FAIL: credential_service_account_mismatch')
|
||||
print('synthetic-private-value', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if action == ['config', '--no-interpolate', '--environment']:
|
||||
print(env_file.read_text())
|
||||
elif action[0] in ('run', 'exec'):
|
||||
print('PASS: synthetic preflight')
|
||||
if control.get('edit') and step == 'run-gitea':
|
||||
env_file.write_text(env_file.read_text() + '\\nCHANGED=true\\n')
|
||||
elif action[0] == 'logs':
|
||||
print('private synthetic-private-value')
|
||||
print('web | INFO \\"POST /private/synthetic-private-value HTTP/1.1\\" 200 OK')
|
||||
elif action[0] == 'ps': print(json.dumps([{'Name': 'pingu-concerts-web', 'State': 'running'}]))
|
||||
''')
|
||||
|
||||
def executable(self, name, code):
|
||||
path = self.bin / name
|
||||
path.write_text('#!/usr/bin/env python3\n' + code)
|
||||
path.chmod(0o700)
|
||||
|
||||
def write_env(self):
|
||||
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()))
|
||||
self.env.chmod(0o600)
|
||||
|
||||
def run_deploy(self, *args, **controls):
|
||||
self.control.write_text(json.dumps(controls))
|
||||
self.trace.write_text('')
|
||||
result = subprocess.run(['bash', str(self.repo / 'scripts/deploy-preprod.sh'), *args],
|
||||
cwd=self.root, env={**os.environ, 'PATH': str(self.bin) + ':' + os.environ['PATH']},
|
||||
capture_output=True, text=True)
|
||||
self.assertNotIn('synthetic-private-value', result.stdout + result.stderr)
|
||||
commands = [json.loads(line) for line in self.trace.read_text().splitlines()]
|
||||
self.actions = [cmd[9:] for cmd in commands if cmd[0] == 'sudo']
|
||||
return result
|
||||
|
||||
def assert_no_update(self, result):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(any(action[0] == 'up' for action in self.actions))
|
||||
|
||||
def test_success_order_and_non_destructive_commands(self):
|
||||
result = self.run_deploy()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
update = self.actions.index(['up', '-d', '--no-deps', 'web'])
|
||||
preflights = [a for a in self.actions[:update] if a[0] == 'run']
|
||||
self.assertEqual(len(preflights), 2)
|
||||
self.assertTrue(all('--no-deps' in a and '--rm' in a for a in preflights))
|
||||
self.assertIn('push_preflight.py', preflights[0])
|
||||
self.assertIn('gitea_preflight.py', preflights[1])
|
||||
self.assertEqual(len([a for a in self.actions[update + 1:] if a[0] == 'exec']), 2)
|
||||
self.assertEqual(len(list((self.root / 'backups').glob('preprod.env.*'))), 1)
|
||||
self.assertTrue(all(not set(a) & {'down', 'rm', '--force-recreate', 'db'} for a in self.actions))
|
||||
|
||||
def test_missing_env_secret_or_any_gitea_value_never_updates(self):
|
||||
for missing in ('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO'):
|
||||
with self.subTest(missing=missing):
|
||||
saved = self.values.pop(missing)
|
||||
self.write_env()
|
||||
result = self.run_deploy()
|
||||
self.assert_no_update(result)
|
||||
self.assertIn('ERROR: required variable ' + missing + ' is missing', result.stdout)
|
||||
self.assertIn('Running container unchanged.', result.stderr)
|
||||
self.values[missing] = saved
|
||||
self.write_env()
|
||||
self.env.unlink()
|
||||
self.assert_no_update(self.run_deploy())
|
||||
self.write_env()
|
||||
self.secret.unlink()
|
||||
self.assert_no_update(self.run_deploy())
|
||||
|
||||
def test_every_failure_before_up_keeps_running_container(self):
|
||||
for fail in ('status', 'pull', 'config', 'build', 'run-firebase', 'run-gitea'):
|
||||
with self.subTest(fail=fail):
|
||||
result = self.run_deploy(fail=fail)
|
||||
self.assert_no_update(result)
|
||||
self.assertIn('Running container unchanged.', result.stderr)
|
||||
self.assertFalse((self.root / 'backups').exists())
|
||||
|
||||
def test_dirty_tree_and_wrong_branch_abort_without_compose(self):
|
||||
for controls in ({'branch': 'feature'}, {'dirty': ' M changed-file'}):
|
||||
result = self.run_deploy(**controls)
|
||||
self.assert_no_update(result)
|
||||
self.assertEqual(self.actions, [])
|
||||
|
||||
def test_check_only_never_updates_or_backs_up(self):
|
||||
result = self.run_deploy('--check')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertFalse(any(a[0] in ('up', 'exec') for a in self.actions))
|
||||
self.assertFalse((self.root / 'backups').exists())
|
||||
|
||||
def test_config_edit_during_preflight_aborts(self):
|
||||
result = self.run_deploy(edit=True)
|
||||
self.assert_no_update(result)
|
||||
self.assertIn('changed during deployment', result.stderr)
|
||||
|
||||
def test_zero_exit_without_pass_is_not_sufficient(self):
|
||||
for step in ('run-firebase', 'run-gitea'):
|
||||
with self.subTest(step=step):
|
||||
self.assert_no_update(self.run_deploy(no_pass=step))
|
||||
|
||||
def test_backup_failure_aborts_before_update(self):
|
||||
(self.root / 'backups').mkdir(mode=0o755)
|
||||
self.assert_no_update(self.run_deploy())
|
||||
|
||||
def test_post_update_failure_does_not_claim_old_container_unchanged(self):
|
||||
result = self.run_deploy(fail='exec-gitea')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('Manual investigation required.', result.stderr)
|
||||
self.assertNotIn('Running container unchanged.', result.stderr)
|
||||
self.assertEqual(len([a for a in self.actions if a[0] == 'up']), 1)
|
||||
@@ -0,0 +1,173 @@
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import preprod_config as config
|
||||
|
||||
|
||||
class HostConfigurationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.repo = self.root / 'pingu-concerts'
|
||||
self.repo.mkdir()
|
||||
self.env = self.root / 'preprod.env'
|
||||
self.env.write_text('synthetic fixture only')
|
||||
self.env.chmod(0o600)
|
||||
self.secret = self.root / 'firebase.json'
|
||||
self.secret.write_text('synthetic fixture only; not credentials')
|
||||
self.secret.chmod(0o600)
|
||||
self.values = {name: 'test-value' for name in config.REQUIRED}
|
||||
self.values.update(PUSH_ENABLED='true', COOKIE_SECURE='true', FIREBASE_PROJECT_ID='metalcircle-30d9b',
|
||||
FIREBASE_SERVICE_ACCOUNT_FILE=str(self.secret),
|
||||
COMPOSE_PROJECT_NAME='pingu-concerts', DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND='docker compose')
|
||||
|
||||
def check(self):
|
||||
response = SimpleNamespace(returncode=0, stdout='\n'.join(k + '=' + v for k, v in self.values.items()))
|
||||
with patch.object(config, 'invoke_compose', return_value=response):
|
||||
return config.check_configuration(self.env, self.repo)
|
||||
|
||||
def test_valid_configuration_and_auto_compose_metadata(self):
|
||||
self.assertEqual(self.check()['PUSH_ENABLED'], 'true')
|
||||
|
||||
def test_each_missing_or_blank_required_variable_is_named_without_values(self):
|
||||
for name in config.REQUIRED:
|
||||
for value in ('', ' '):
|
||||
with self.subTest(name=name, value=value), patch.dict(self.values, {name: value}):
|
||||
with self.assertRaisesRegex(config.PreprodError, '^required variable ' + name + ' is missing$'):
|
||||
self.check()
|
||||
|
||||
def test_missing_env_and_missing_firebase_secret(self):
|
||||
for path, expected in ((self.env, 'Environment file'), (self.secret, 'Firebase secret')):
|
||||
saved = path.read_bytes()
|
||||
path.unlink()
|
||||
with self.assertRaisesRegex(config.PreprodError, expected + ' is missing'):
|
||||
self.check()
|
||||
path.write_bytes(saved)
|
||||
path.chmod(0o600)
|
||||
|
||||
def test_permissions_symlinks_and_repository_paths_are_rejected(self):
|
||||
for path in (self.env, self.secret):
|
||||
path.chmod(0o644)
|
||||
with self.assertRaisesRegex(config.PreprodError, 'requires permissions'):
|
||||
self.check()
|
||||
path.chmod(0o600)
|
||||
inside = self.repo / 'private.json'
|
||||
inside.write_text('fixture')
|
||||
inside.chmod(0o600)
|
||||
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(inside)
|
||||
with self.assertRaisesRegex(config.PreprodError, 'outside the repository'):
|
||||
self.check()
|
||||
linked = self.root / 'linked.json'
|
||||
linked.symlink_to(self.secret)
|
||||
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(linked)
|
||||
with self.assertRaisesRegex(config.PreprodError, 'not a symlink'):
|
||||
self.check()
|
||||
|
||||
def test_wrong_owner_and_directory_fail(self):
|
||||
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(self.root)
|
||||
with self.assertRaisesRegex(config.PreprodError, 'regular file'):
|
||||
self.check()
|
||||
with patch('preprod_config.os.getuid', return_value=os.getuid() + 1):
|
||||
with self.assertRaisesRegex(config.PreprodError, 'deployment user'):
|
||||
config.private_file(self.env, self.repo, 'Environment file')
|
||||
|
||||
def test_disabled_push_insecure_cookies_wrong_project_and_context_overrides_fail(self):
|
||||
for name, value in (('PUSH_ENABLED', 'false'), ('COOKIE_SECURE', 'false'),
|
||||
('FIREBASE_PROJECT_ID', 'other'), ('COMPOSE_PROJECT_NAME', 'other'),
|
||||
('DOCKER_HOST', 'tcp://elsewhere'), ('COMPOSE_ENV_FILES', '.env')):
|
||||
with self.subTest(name=name), patch.dict(self.values, {name: value}):
|
||||
with self.assertRaises(config.PreprodError):
|
||||
self.check()
|
||||
|
||||
def test_compose_parse_errors_never_expose_stderr(self):
|
||||
response = SimpleNamespace(returncode=1, stderr='synthetic-secret', stdout='synthetic-secret')
|
||||
with patch.object(config, 'invoke_compose', return_value=response):
|
||||
with self.assertRaises(config.PreprodError) as caught:
|
||||
config.check_configuration(self.env, self.repo)
|
||||
self.assertNotIn('synthetic-secret', str(caught.exception))
|
||||
|
||||
def test_backups_are_private_keep_ten_and_preserve_unrelated_files(self):
|
||||
directory = self.env.parent / 'backups'
|
||||
directory.mkdir(mode=0o700)
|
||||
unrelated = directory / 'keep-me'
|
||||
unrelated.write_text('unrelated')
|
||||
with redirect_stdout(StringIO()):
|
||||
for _ in range(12):
|
||||
config.backup_environment(self.env, self.repo)
|
||||
backups = list(directory.glob('preprod.env.*'))
|
||||
self.assertEqual(len(backups), 10)
|
||||
self.assertEqual(stat.S_IMODE(directory.stat().st_mode), 0o700)
|
||||
self.assertTrue(unrelated.exists())
|
||||
for backup in backups:
|
||||
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o600)
|
||||
self.assertEqual(backup.read_bytes(), self.env.read_bytes())
|
||||
|
||||
def test_backup_refuses_insecure_directory(self):
|
||||
directory = self.env.parent / 'backups'
|
||||
directory.mkdir(mode=0o755)
|
||||
with self.assertRaisesRegex(config.PreprodError, 'permissions 700'):
|
||||
config.backup_environment(self.env, self.repo)
|
||||
|
||||
def test_compose_cannot_inherit_shell_secrets_or_remote_docker_context(self):
|
||||
with patch.dict(os.environ, {'GITEA_TOKEN': 'stale', 'DOCKER_HOST': 'remote', 'COMPOSE_FILE': 'other'}):
|
||||
cleaned = config.compose_environment()
|
||||
for name in ('GITEA_TOKEN', 'DOCKER_HOST', 'COMPOSE_FILE'):
|
||||
self.assertNotIn(name, cleaned)
|
||||
|
||||
def test_safe_logs_discard_private_text_and_request_urls(self):
|
||||
output = StringIO()
|
||||
with redirect_stdout(output):
|
||||
config.safe_logs('web | INFO "POST /reset/synthetic-secret HTTP/1.1" 200 OK\n'
|
||||
'web | private synthetic-secret\n'
|
||||
'web | Gitea issue submission failed: HTTP 403\n'
|
||||
'web | Push delivery failed: configuration\n')
|
||||
self.assertNotIn('synthetic-secret', output.getvalue())
|
||||
self.assertIn('HTTP POST 200', output.getvalue())
|
||||
self.assertIn('Gitea: HTTP 403', output.getvalue())
|
||||
self.assertIn('Push: configuration', output.getvalue())
|
||||
|
||||
@unittest.skipUnless(shutil.which('docker'), 'requires Compose CLI only; no daemon/network')
|
||||
def test_real_compose_config_requires_gitea_even_without_host_helper(self):
|
||||
def validate():
|
||||
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()
|
||||
if not k.startswith(('COMPOSE_', 'DOCKER_'))))
|
||||
return subprocess.run(['docker', 'compose', '--env-file', str(self.env),
|
||||
'-f', str(config.REPO_ROOT / 'compose.yml'),
|
||||
'-f', str(config.REPO_ROOT / 'compose.preprod.yml'), 'config', '--quiet'],
|
||||
env=config.compose_environment(), capture_output=True, text=True)
|
||||
self.assertEqual(validate().returncode, 0)
|
||||
for name in ('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO'):
|
||||
with self.subTest(name=name), patch.dict(self.values, {name: ''}):
|
||||
result = validate()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('required variable ' + name + ' is missing', result.stderr)
|
||||
|
||||
@unittest.skipUnless(shutil.which('docker'), 'requires Compose CLI only; no daemon/network')
|
||||
def test_real_compose_dotenv_quoting_and_no_checkout_or_shell_fallback(self):
|
||||
self.values['GITEA_TOKEN'] = "'synthetic-$literal#value'"
|
||||
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()
|
||||
if not k.startswith(('COMPOSE_', 'DOCKER_'))))
|
||||
def invoke(env_file, args, repo_root):
|
||||
return subprocess.run(['docker', 'compose', '--env-file', str(env_file),
|
||||
'-f', str(config.REPO_ROOT / 'compose.yml'),
|
||||
'-f', str(config.REPO_ROOT / 'compose.preprod.yml'), *args],
|
||||
env=config.compose_environment(), capture_output=True, text=True)
|
||||
with patch.object(config, 'invoke_compose', side_effect=invoke):
|
||||
checked = config.check_configuration(self.env, config.REPO_ROOT)
|
||||
self.assertEqual(checked['GITEA_TOKEN'], 'synthetic-$literal#value')
|
||||
self.env.write_text(self.env.read_text().replace("GITEA_TOKEN='synthetic-$literal#value'", ''))
|
||||
with patch.dict(os.environ, {'GITEA_TOKEN': 'must-not-be-used'}):
|
||||
with self.assertRaisesRegex(config.PreprodError, 'required variable GITEA_TOKEN is missing'):
|
||||
config.check_configuration(self.env, config.REPO_ROOT)
|
||||
Reference in New Issue
Block a user