Harden pre-production deployment with external environment and preflights
This commit is contained in:
@@ -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