177 lines
9.5 KiB
Python
177 lines
9.5 KiB
Python
"""Exercise real Bash release/rollback file operations with all infrastructure faked."""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from deploy.gray_config import GrayConfig, write_release
|
|
|
|
|
|
FAKE_COMMAND = '''import json, os, pathlib, sys
|
|
name = pathlib.Path(sys.argv[0]).name
|
|
args = sys.argv[1:]
|
|
log = pathlib.Path(os.environ['FAKE_LOG'])
|
|
with log.open('a') as output:
|
|
output.write(json.dumps([name, args]) + '\\n')
|
|
scenario = os.environ['FAKE_SCENARIO']
|
|
if name == 'docker':
|
|
if args[:2] == ['image', 'inspect']:
|
|
print('sha256:' + 'b'*64 if args[3] == '{{.Id}}' else 'a'*40)
|
|
elif args[:2] == ['container', 'inspect']:
|
|
sys.exit(0)
|
|
elif args[0] == 'inspect':
|
|
print('baiyue-logistics-chat-gray')
|
|
elif args[0] == 'port':
|
|
print('127.0.0.1:29013')
|
|
elif args[0] == 'compose' and 'up' in args and scenario == 'app-fail' and os.environ['FAKE_NEW'] + '/release.env' in args:
|
|
sys.exit(17)
|
|
elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
|
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
|
if sum(item[0] == 'nginx' and '-t' in item[1] for item in calls) == 1:
|
|
sys.exit(19)
|
|
elif name == 'curl' and '-w' in args:
|
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
|
if scenario == 'reload-pending' and sum(item[0] == 'curl' and '-w' in item[1] for item in calls) == 1:
|
|
sys.exit(60)
|
|
if scenario == 'proxy-unsafe':
|
|
print('200', end='')
|
|
sys.exit(0)
|
|
path = '/' + args[-1].split('://', 1)[-1].partition('/')[2]
|
|
if '-c' in args:
|
|
pathlib.Path(args[args.index('-c') + 1]).write_text('fixture-visitor-cookie')
|
|
if '-b' in args:
|
|
assert pathlib.Path(args[args.index('-b') + 1]).read_text() == 'fixture-visitor-cookie'
|
|
status = '200' if path == '/' or path == '/api/visitor/session' or (path == '/api/get_history' and '-b' in args) else '401'
|
|
if scenario == 'visitor-bootstrap-fail' and path == '/api/visitor/session':
|
|
status = '503'
|
|
if scenario == 'visitor-history-fail' and path == '/api/get_history' and '-b' in args:
|
|
status = '503'
|
|
print(status, end='')
|
|
elif name == 'sleep':
|
|
pass
|
|
'''
|
|
|
|
|
|
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Gray activation requires Linux Bash')
|
|
class GrayActivationTests(unittest.TestCase):
|
|
def run_activation(self, scenario):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
directory = Path(temporary.name)
|
|
root = directory / 'gray-logistics-chat'
|
|
proxy = directory / 'proxy'
|
|
auth = directory / 'baiyue-logistics-chat-gray-proxy'
|
|
for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
|
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
|
|
certificate, private_key = directory / 'cert.pem', directory / 'key.pem'
|
|
certificate.touch()
|
|
private_key.touch()
|
|
previous = root / 'releases/previous'
|
|
previous.mkdir()
|
|
(previous / 'release.env').write_text('CHAT_IMAGE=previous\n')
|
|
(previous / 'compose.yml').write_text('services: {}\n')
|
|
(root / 'current-release').write_text(str(previous) + '\n')
|
|
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
|
|
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
|
|
(auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n')
|
|
(auth / 'access.htpasswd').write_text('previous-auth-hash\n')
|
|
unrelated = proxy / 'unrelated.conf'
|
|
unrelated.write_text('# unrelated site\n')
|
|
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
|
|
str(proxy), str(certificate), str(private_key), 'a'*40,
|
|
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth))
|
|
release = Path(config.release)
|
|
write_release(release, config, 'fixture-model-key')
|
|
# Docker 26 and 29 may assign different IDs after an archive is loaded.
|
|
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
|
|
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
|
|
(release / 'site.htpasswd').write_text('new-auth-hash\n')
|
|
commands = directory / 'commands'
|
|
commands.mkdir()
|
|
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl', 'sleep'):
|
|
command = commands / name
|
|
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
|
|
command.chmod(0o755)
|
|
environment = os.environ.copy()
|
|
log = directory / 'commands.jsonl'
|
|
environment.update(PATH=str(commands) + os.pathsep + environment['PATH'],
|
|
FAKE_LOG=str(log), FAKE_SCENARIO=scenario, FAKE_NEW=str(release))
|
|
result = subprocess.run(['bash', str(release / 'activate.sh'), str(root), str(release)],
|
|
env=environment, capture_output=True, text=True, timeout=30)
|
|
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
|
|
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
|
|
self.assertFalse(list(release.glob('.visitor-smoke.*')), 'Temporary visitor cookies must be removed on success and rollback')
|
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
|
return result, root, proxy, previous, release, old_site, calls
|
|
|
|
def test_success_publishes_only_the_owned_site_and_release_pointer(self):
|
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('success')
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
|
|
self.assertIn('server_name chat.example.com', (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
|
|
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
|
|
self.assertEqual((auth / 'access.htpasswd').read_text(), 'new-auth-hash\n')
|
|
self.assertIn(str(auth / 'access.htpasswd'), (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
|
|
self.assertEqual(auth.stat().st_mode & 0o777, 0o755)
|
|
self.assertEqual((auth / 'access.htpasswd').stat().st_mode & 0o777, 0o644)
|
|
|
|
def assert_rolled_back(self, scenario):
|
|
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
|
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
|
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
|
|
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
|
|
for name, args in calls), result.stderr)
|
|
return result
|
|
|
|
def test_failed_application_restores_previous_release(self):
|
|
self.assert_rolled_back('app-fail')
|
|
|
|
def test_async_nginx_reload_waits_for_the_new_tls_workers(self):
|
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('reload-pending')
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
|
|
self.assertTrue(any(name == 'sleep' for name, args in calls))
|
|
|
|
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
|
|
self.assert_rolled_back('proxy-unsafe')
|
|
|
|
def test_failed_visitor_bootstrap_rolls_back_and_removes_cookie_file(self):
|
|
result = self.assert_rolled_back('visitor-bootstrap-fail')
|
|
self.assertIn('Guest session bootstrap verification failed.', result.stderr)
|
|
|
|
def test_failed_cookie_history_rolls_back_and_removes_cookie_file(self):
|
|
result = self.assert_rolled_back('visitor-history-fail')
|
|
self.assertIn('Guest history verification failed.', result.stderr)
|
|
|
|
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
|
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
|
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
|
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
|
|
|
|
def test_password_directory_owned_by_another_service_is_not_modified(self):
|
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('unowned-auth')
|
|
self.assertNotEqual(result.returncode, 0)
|
|
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
|
|
self.assertEqual((auth / '.project').read_text(), 'another-service\n')
|
|
self.assertEqual((auth / 'access.htpasswd').read_text(), 'previous-auth-hash\n')
|
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
|
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
|
|
|
|
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
|
|
self.assert_rolled_back('proxy-fail')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|