From 0d1e63b21236cc70077429498303a14dae368ce0 Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 27 Oct 2026 20:17:30 +0800 Subject: [PATCH] feat(deploy): add verified image deployments with consistent backups --- deploy/compose.image.yml | 15 ++ deploy/nginx/raw-dev.134333.xyz.conf | 47 +++++ scripts/deploy.py | 162 ++++++++++++++ scripts/tests/test_cicd.py | 305 +++++++++++++++++++++++++++ 4 files changed, 529 insertions(+) create mode 100644 deploy/compose.image.yml create mode 100644 deploy/nginx/raw-dev.134333.xyz.conf create mode 100644 scripts/deploy.py create mode 100644 scripts/tests/test_cicd.py diff --git a/deploy/compose.image.yml b/deploy/compose.image.yml new file mode 100644 index 0000000..6be861b --- /dev/null +++ b/deploy/compose.image.yml @@ -0,0 +1,15 @@ +# Copy this file to the instance directory; use a private .env beside it. +name: ${RAWMARK_PROJECT:?Set RAWMARK_PROJECT} +services: + rawmark: + image: ${RAWMARK_IMAGE:?Set a verified registry image digest} + ports: + - "${RAWMARK_BIND:-127.0.0.1}:${RAWMARK_PORT:?Set RAWMARK_PORT}:8080" + volumes: + - type: bind + source: ${RAWMARK_DATA_DIR:?Set the absolute persistent data directory} + target: /data + bind: + create_host_path: false + restart: unless-stopped + stop_grace_period: 15s diff --git a/deploy/nginx/raw-dev.134333.xyz.conf b/deploy/nginx/raw-dev.134333.xyz.conf new file mode 100644 index 0000000..3a7978f --- /dev/null +++ b/deploy/nginx/raw-dev.134333.xyz.conf @@ -0,0 +1,47 @@ +# Keep resource tickets and query strings out of proxy logs. +map $uri $rawmark_dev_log_path { + ~^/api/v1/resources/[^/]+(?/.*)$ /api/v1/resources/[redacted]$rawmark_dev_suffix; + default $uri; +} +log_format rawmark_dev '$remote_addr - $remote_user [$time_local] ' + '"$request_method $rawmark_dev_log_path $server_protocol" $status $body_bytes_sent ' + '"$http_user_agent"'; + +server { + listen 80; + server_name raw-dev.134333.xyz; + access_log /var/log/nginx/raw-dev.134333.xyz.log rawmark_dev; + error_log /dev/null; + return 308 https://$host$request_uri; +} +server { + listen 443 ssl; + http2 on; + server_name raw-dev.134333.xyz; + include /etc/nginx/snippets/ssl-134333.xyz.conf; + include /etc/nginx/snippets/no-spider.conf; + access_log /var/log/nginx/raw-dev.134333.xyz.log rawmark_dev; + error_log /var/log/nginx/raw-dev.134333.xyz.error.log warn; + client_max_body_size 0; + + location / { + proxy_pass http://127.0.0.1:21001; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + + # Error logs cannot redact ticket-bearing request paths. + location ^~ /api/v1/resources/ { + proxy_pass http://127.0.0.1:21001; + error_log /dev/null; + } + } +} diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..57a8674 --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Deploy a verified image to one explicitly configured Compose instance.""" + +import argparse +import contextlib +import fcntl +import json +import os +from pathlib import Path +import re +import subprocess +import tarfile +import tempfile +import time +import urllib.request + +from release import API, GITEA_IMAGE, GITHUB_IMAGE, release_receipt, require, run + + +def settings(directory, channel): + values = {} + for line in (directory / '.env').read_text().splitlines(): + if line.strip() and not line.lstrip().startswith('#'): + key, value = line.split('=', 1) + require(key.startswith('RAWMARK_') and value and '\n' not in value, 'Invalid instance setting') + values[key] = value + require(values['RAWMARK_CHANNEL'] == channel, 'Instance channel does not match deployment command') + require(re.fullmatch(r'rawmark(?:-[a-z0-9-]+)?', values['RAWMARK_PROJECT']), 'Invalid Compose project') + require(Path(values['RAWMARK_DATA_DIR']).is_absolute(), 'Persistent data path must be absolute') + require(Path(values['RAWMARK_DATA_DIR']).resolve() == directory / 'data', 'Data must belong to this instance') + require((directory / 'data').is_dir(), 'Persistent data directory does not exist') + require(values['RAWMARK_BIND'] in ('127.0.0.1', '0.0.0.0'), 'Invalid bind address') + require(21000 <= int(values['RAWMARK_PORT']) <= 21999, 'Port must follow the personal-project range') + return values + + +def save(path, data): + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as target: + temporary = Path(target.name) + target.write(data) + target.flush() + os.fsync(target.fileno()) + try: + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def deployment(directory, channel, image, commit, version, client_container=None): + directory = Path(directory).resolve(strict=True) + cfg = settings(directory, channel) + registry = GITEA_IMAGE if channel == 'development' else GITHUB_IMAGE + require(re.fullmatch(re.escape(registry) + r'@sha256:[0-9a-f]{64}', image), 'Expected an immutable registry digest') + require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected a full commit SHA') + # Serializes actual deployments, including manually started jobs and CLI use. + with (directory / '.deploy.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + require(not (directory / 'deployment.pending.json').exists(), + 'Resolve the previous pending deployment before attempting another update') + run('docker', 'pull', image) + inspected = json.loads(run('docker', 'image', 'inspect', image))[0] + labels = inspected['Config'].get('Labels') or {} + require(labels.get('org.opencontainers.image.revision') == commit, 'Image revision mismatch') + require(labels.get('org.opencontainers.image.version') == version, 'Image version mismatch') + require(inspected['Config']['User'].split(':')[0] not in ('', '0', 'root'), 'Image must run as non-root') + require(run('docker', 'run', '--rm', '--network', 'none', image, '--version') == 'RawMark ' + version, + 'Executable version mismatch') + if channel == 'development': + api = API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN']) + if api.repo('/branches/main')['commit']['id'] != commit: + print('Skipped development deployment: main has advanced') + return + identifiers = run('docker', 'ps', '-aq', '--filter', 'label=com.docker.compose.project=' + cfg['RAWMARK_PROJECT'], + '--filter', 'label=com.docker.compose.service=rawmark').split() + require(len(identifiers) <= 1, 'Multiple containers claim this instance') + previous = json.loads(run('docker', 'inspect', identifiers[0]))[0] if identifiers else None + if previous: + mounts = [m for m in previous['Mounts'] if m['Destination'] == '/data'] + require(len(mounts) == 1 and mounts[0]['Type'] == 'bind' + and Path(mounts[0]['Source']).resolve() == directory / 'data', 'Existing data mount differs') + writers = run('docker', 'ps', '-q', '--filter', 'volume=' + str(directory / 'data')).split() + require(set(writers) <= {i[:12] for i in identifiers} | set(identifiers), 'Another container is using the data directory') + env = {**os.environ, **cfg, 'RAWMARK_IMAGE': image} + compose = ['docker', 'compose', '--project-name', cfg['RAWMARK_PROJECT'], '--env-file', str(directory / '.env'), + '-f', str(directory / 'compose.yml')] + run(*compose, 'config', '--quiet', env=env) + backup = None + if previous: + run('docker', 'stop', '--time', '15', previous['Id']) + try: + backups = directory / 'backups' + backups.mkdir(mode=0o700, exist_ok=True) + backup = backups / (time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '-' + commit[:12] + '.tar.gz') + # Exclusively create the backup, including WAL files after the service is stopped. + with backup.open('xb') as target: + os.chmod(backup, 0o600) + with tarfile.open(fileobj=target, mode='w:gz') as archive: + archive.add(directory / 'data', arcname='data') + for name in ('.env', 'compose.yml', 'deployment.json'): + if (directory / name).is_file(): + archive.add(directory / name, arcname=name) + except BaseException: + # No new executable has touched the data; restarting the old container is safe here. + if previous['State']['Running']: + run('docker', 'start', previous['Id']) + raise + pending = {'image': image, 'commit': commit, 'version': version, 'channel': channel, + 'previous_image': previous['Image'] if previous else None, + 'backup': str(backup) if backup else None} + save(directory / 'deployment.pending.json', (json.dumps(pending, indent=2) + '\n').encode()) + try: + run(*compose, 'up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', '120', env=env) + identifier = run(*compose, 'ps', '-q', 'rawmark', env=env) + info = json.loads(run('docker', 'inspect', identifier))[0] + require(info['Image'] == inspected['Id'], 'Running image differs from selected image') + run('docker', 'exec', identifier, '/rawmark', 'healthcheck') + with contextlib.ExitStack() as cleanup: + network = next(iter(info['NetworkSettings']['Networks'].values())) + if client_container: + run('docker', 'network', 'connect', network['NetworkID'], client_container) + cleanup.callback(run, 'docker', 'network', 'disconnect', network['NetworkID'], client_container) + origin = 'http://' + network['IPAddress'] + ':8080' + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(origin + '/', timeout=10) as response: + require(b'Demo') + with patch.object(release, 'run', return_value=SHA), patch.dict(os.environ, {'CI_RUN_ID': '42'}): + release.bundle('release') + self.original = {p.name: p.read_bytes() for p in Path('downloads').iterdir()} + for path in Path('downloads').iterdir(): + path.unlink() + self.gitea = Mock() + self.gitea.assets.return_value = [{'name': name} for name in self.original] + self.gitea.download.side_effect = lambda asset: self.original[asset['name']] + self.draft = {'draft': True, 'body': 'Build run: `42`.'} + + def test_resume_from_release_assets_does_not_require_old_ci_runs(self): + with patch.object(release, 'run', return_value=SHA): + release.restore(self.gitea, self.draft) + self.gitea.repo.assert_not_called() + self.gitea.upload.assert_not_called() + self.assertEqual({p.name: p.read_bytes() for p in Path('downloads').iterdir()}, self.original) + + def test_expired_artifact_fails_instead_of_rebuilding(self): + self.gitea.assets.return_value = [{'name': 'build.json'}] + self.gitea.repo.return_value = {'artifacts': []} + with patch.object(release, 'run', return_value=SHA) as command: + with self.assertRaisesRegex(ValueError, 'refusing to rebuild'): + release.restore(self.gitea, self.draft) + self.assertFalse(any('build' in str(call) for call in command.call_args_list)) + + def test_interrupted_first_receipt_upload_uses_original_run(self): + self.gitea.assets.return_value = [] + names = {'checksums': ['build.json', 'SHA256SUMS'], 'demo': ['demo.zip']} + names.update({platform.replace('/', '-'): [release.archive_name('0.1.1', platform)] for platform in release.PLATFORMS}) + archives = {} + for identifier, (name, files) in enumerate(names.items(), 1): + data = io.BytesIO() + with zipfile.ZipFile(data, 'w') as archive: + for filename in files: + archive.writestr(filename, self.original[filename]) + archives[identifier] = (name, data.getvalue()) + def request(path, **kwargs): + if path == '/actions/runs/42/artifacts': + return {'artifacts': [{'id': identifier, 'name': name, 'expired': False} + for identifier, (name, _) in archives.items()]} + return archives[int(path.split('/')[-2])][1] + self.gitea.repo.side_effect = request + with patch.object(release, 'run', return_value=SHA): + release.restore(self.gitea, self.draft) + self.gitea.upload.assert_called_once_with(self.draft, Path('downloads/build.json')) + self.assertEqual({p.name: p.read_bytes() for p in Path('downloads').iterdir()}, self.original) + + +class DeploymentTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.directory = Path(self.tmp.name).resolve() + (self.directory / 'data').mkdir() + (self.directory / 'data/rawmark.db').write_bytes(b'database') + (self.directory / 'data/rawmark.db-wal').write_bytes(b'wal') + (self.directory / 'compose.yml').write_text('services: {}\n') + (self.directory / '.env').write_text( + f'RAWMARK_CHANNEL=development\nRAWMARK_PROJECT=rawmark-ci-deploy\n' + f'RAWMARK_BIND=127.0.0.1\nRAWMARK_PORT=21899\nRAWMARK_DATA_DIR={self.directory}/data\n') + self.image = release.GITEA_IMAGE + '@' + DIGEST + self.previous = {'Id': 'old-id', 'Image': 'old-image', 'State': {'Running': True}, + 'Mounts': [{'Destination': '/data', 'Type': 'bind', 'Source': str(self.directory / 'data')}]} + self.image_info = {'Id': 'new-image', 'Config': {'User': '65532:65532', 'Labels': { + 'org.opencontainers.image.revision': SHA, 'org.opencontainers.image.version': '0.1.1'}}} + self.commands = [] + self.api = patch.object(deploy, 'API') + self.api.start().return_value.repo.return_value = {'commit': {'id': SHA}} + self.addCleanup(self.api.stop) + self.env = patch.dict(os.environ, {'GITEA_API_URL': 'https://git.example/api/v1', 'GITEA_RELEASE_TOKEN': 'private'}) + self.env.start() + self.addCleanup(self.env.stop) + + def command(self, *args, **kwargs): + self.commands.append(args) + if args[:3] == ('docker', 'image', 'inspect'): + return json.dumps([self.image_info]) + if args[:3] == ('docker', 'run', '--rm'): + return 'RawMark 0.1.1' + if args[:3] == ('docker', 'ps', '-aq'): + return 'old-id' + if args[:3] == ('docker', 'ps', '-q'): + return 'old-id' + if args[:3] == ('docker', 'inspect', 'old-id'): + return json.dumps([self.previous]) + if args[0:2] == ('docker', 'compose') and 'ps' in args: + return 'new-id' + if args[:3] == ('docker', 'inspect', 'new-id'): + return json.dumps([{'Image': 'new-image', 'NetworkSettings': {'Networks': { + 'test': {'NetworkID': 'network', 'IPAddress': '127.0.0.1'}}}}]) + return '' + + def test_channel_mismatch_cannot_touch_docker(self): + with patch.object(deploy, 'run') as docker: + with self.assertRaisesRegex(ValueError, 'channel'): + deploy.deployment(self.directory, 'production', self.image, SHA, '0.1.1') + docker.assert_not_called() + + def test_unresolved_pending_deployment_cannot_be_overwritten(self): + (self.directory / 'deployment.pending.json').write_text('{"backup":"keep"}') + with patch.object(deploy, 'run') as docker: + with self.assertRaisesRegex(ValueError, 'pending deployment'): + deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') + docker.assert_not_called() + + def test_stale_main_skips_without_stopping_existing_service(self): + with patch.object(deploy, 'API') as api, patch.object(deploy, 'run', side_effect=self.command): + api.return_value.repo.return_value = {'commit': {'id': 'c' * 40}} + deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') + self.assertFalse(any(c[:2] == ('docker', 'stop') for c in self.commands)) + self.assertFalse((self.directory / 'deployment.json').exists()) + + def test_wrong_mount_is_rejected_before_stopping(self): + self.previous['Mounts'][0]['Source'] = '/some-other-data' + with patch.object(deploy, 'run', side_effect=self.command): + with self.assertRaisesRegex(ValueError, 'mount differs'): + deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') + self.assertFalse(any(c[:2] == ('docker', 'stop') for c in self.commands)) + + def test_backup_includes_wal_and_precedes_replacement(self): + response = contextlib.nullcontext(io.BytesIO(b'RawMark')) + opener = Mock() + opener.open.side_effect = [response, contextlib.nullcontext(io.BytesIO(b'{"code":0}'))] + with patch.object(deploy, 'run', side_effect=self.command), patch.object(deploy.urllib.request, 'build_opener', return_value=opener): + deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') + receipt = json.loads((self.directory / 'deployment.json').read_text()) + with tarfile.open(receipt['backup']) as archive: + self.assertEqual(archive.extractfile('data/rawmark.db-wal').read(), b'wal') + self.assertEqual(Path(receipt['backup']).stat().st_mode & 0o777, 0o600) + stop = next(i for i,c in enumerate(self.commands) if c[:2] == ('docker', 'stop')) + start = next(i for i,c in enumerate(self.commands) if c[:2] == ('docker', 'compose') and 'up' in c) + self.assertLess(stop, start) + self.assertIn('RAWMARK_IMAGE=' + self.image, (self.directory / '.env').read_text()) + + def test_failed_replacement_keeps_backup_and_does_not_downgrade_data(self): + def failure(*args, **kwargs): + if args[:2] == ('docker', 'compose') and 'up' in args: + raise RuntimeError('candidate failed') + return self.command(*args, **kwargs) + with patch.object(deploy, 'run', side_effect=failure), patch.object(deploy.subprocess, 'run') as stop: + with self.assertRaisesRegex(RuntimeError, 'candidate failed'): + deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1') + stop.assert_called_once() + pending = json.loads((self.directory / 'deployment.pending.json').read_text()) + self.assertTrue(Path(pending['backup']).exists()) + self.assertFalse(any(c[:2] == ('docker', 'start') for c in self.commands)) + self.assertNotIn('RAWMARK_IMAGE=', (self.directory / '.env').read_text()) + + +class CleanupTests(unittest.TestCase): + def test_cleanup_rejects_broad_filter(self): + with patch('subprocess.check_output') as docker: + with self.assertRaises(ValueError): + cleanup('') + docker.assert_not_called() + + +if __name__ == '__main__': + unittest.main()