feat(deploy): add verified image deployments with consistent backups
This commit is contained in:
1 parent
d0c3f0f8d5
commit
0d1e63b212
4 files changed
+529
No files matched your search
@@ -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'<html' in response.read().lower(), 'Embedded frontend is missing')
|
||||
with opener.open(origin + '/api/v1/auth/state', timeout=10) as response:
|
||||
require(json.load(response)['code'] == 0, 'Authentication state failed')
|
||||
except BaseException:
|
||||
# Data compatibility cannot be inferred from a failed health check. Do not restore or downgrade it automatically.
|
||||
subprocess.run([*compose, 'stop', 'rawmark'], env=env, check=True)
|
||||
print('Deployment failed; inspect deployment.pending.json and the consistent backup before recovery', flush=True)
|
||||
raise
|
||||
cfg['RAWMARK_IMAGE'] = image
|
||||
save(directory / '.env', ''.join(f'{key}={value}\n' for key, value in cfg.items()).encode())
|
||||
save(directory / 'deployment.json', (json.dumps(pending, indent=2) + '\n').encode())
|
||||
(directory / 'deployment.pending.json').unlink()
|
||||
print(json.dumps(pending))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('channel', choices=('development', 'production'))
|
||||
parser.add_argument('--directory', required=True)
|
||||
parser.add_argument('--image')
|
||||
parser.add_argument('--commit')
|
||||
parser.add_argument('--version', required=True)
|
||||
parser.add_argument('--client-container')
|
||||
args = parser.parse_args()
|
||||
if args.channel == 'production':
|
||||
require(not args.image and not args.commit, 'Production accepts a completed version, not an arbitrary image')
|
||||
gitea = API(os.environ['GITEA_API_URL'], os.environ['GITEA_READ_TOKEN'])
|
||||
github = API('https://api.github.com', '', github=True)
|
||||
build, receipt = release_receipt(gitea, github, args.version)
|
||||
image, commit = GITHUB_IMAGE + '@' + receipt['image_digest'], build['commit']
|
||||
else:
|
||||
image, commit = args.image, args.commit
|
||||
require(image and commit, 'Development requires an image digest and commit')
|
||||
deployment(args.directory, args.channel, image, commit, args.version, args.client_container)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Exercise artifact integrity and deployment failure boundaries with synthetic data."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import deploy
|
||||
import release
|
||||
cleanup = runpy.run_path(str(Path(__file__).resolve().parents[1] / 'ci-cleanup.py'))['cleanup']
|
||||
|
||||
|
||||
SHA = 'a' * 40
|
||||
DIGEST = 'sha256:' + 'b' * 64
|
||||
|
||||
|
||||
class BundleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.cwd = Path.cwd()
|
||||
os.chdir(self.tmp.name)
|
||||
self.addCleanup(os.chdir, self.cwd)
|
||||
Path('VERSION').write_text('0.1.1\n')
|
||||
Path('downloads').mkdir()
|
||||
Path('downloads/rawmark_0.1.1_linux_amd64.tar.gz').write_bytes(b'archive')
|
||||
self.environment = patch.dict(os.environ, {'CI_RUN_ID': '42'})
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
self.git = patch.object(release, 'run', return_value=SHA)
|
||||
self.git.start()
|
||||
self.addCleanup(self.git.stop)
|
||||
|
||||
def test_development_has_only_one_target_and_exact_checksums(self):
|
||||
release.bundle('development')
|
||||
build = release.verify_bundle()
|
||||
self.assertEqual(build['platforms'], ['linux/amd64'])
|
||||
self.assertEqual(build['run_id'], 42)
|
||||
self.assertEqual(Path('downloads/SHA256SUMS').read_text(),
|
||||
hashlib.sha256(b'archive').hexdigest() + ' rawmark_0.1.1_linux_amd64.tar.gz\n')
|
||||
|
||||
def test_corrupted_download_is_rejected(self):
|
||||
release.bundle('development')
|
||||
Path('downloads/rawmark_0.1.1_linux_amd64.tar.gz').write_bytes(b'corrupt')
|
||||
with self.assertRaisesRegex(ValueError, 'Checksum mismatch'):
|
||||
release.verify_bundle()
|
||||
|
||||
def test_archive_at_cdn_budget_is_rejected(self):
|
||||
with patch.object(release, 'UPLOAD_LIMIT', 7):
|
||||
with self.assertRaisesRegex(ValueError, 'CDN upload budget'):
|
||||
release.bundle('development')
|
||||
|
||||
def test_receipt_from_other_commit_is_rejected(self):
|
||||
release.bundle('development')
|
||||
with patch.object(release, 'run', return_value='c' * 40):
|
||||
with self.assertRaisesRegex(ValueError, 'commit mismatch'):
|
||||
release.verify_bundle()
|
||||
|
||||
def test_missing_release_platform_is_rejected(self):
|
||||
release.bundle('development')
|
||||
path = Path('downloads/build.json')
|
||||
build = json.loads(path.read_text())
|
||||
build['mode'] = 'release'
|
||||
release.write_json(path, build)
|
||||
with self.assertRaisesRegex(ValueError, 'platforms mismatch'):
|
||||
release.verify_bundle()
|
||||
|
||||
|
||||
class PublicationTests(unittest.TestCase):
|
||||
def test_cross_host_redirect_strips_credentials(self):
|
||||
request = urllib.request.Request('https://api.github.com/asset', headers={'Authorization': 'Bearer private'})
|
||||
redirected = release.SafeRedirect().redirect_request(request, None, 302, 'Found', {}, 'https://objects.example/asset')
|
||||
self.assertFalse(redirected.has_header('Authorization'))
|
||||
|
||||
def test_anonymous_client_cannot_write(self):
|
||||
api = release.API('https://api.github.com', '', github=True)
|
||||
with self.assertRaisesRegex(ValueError, 'cannot mutate'):
|
||||
api.repo('/releases', data={}, method='POST')
|
||||
|
||||
def test_existing_asset_conflict_is_not_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'archive.zip'
|
||||
path.write_bytes(b'new')
|
||||
api = release.API('https://git.example/api/v1', 'private')
|
||||
api.assets = Mock(return_value=[{'name': path.name}])
|
||||
api.download = Mock(return_value=b'old')
|
||||
api.request = Mock()
|
||||
with self.assertRaisesRegex(ValueError, 'conflicts'):
|
||||
api.upload({'draft': True}, path)
|
||||
api.request.assert_not_called()
|
||||
|
||||
def test_identical_asset_is_reused_even_after_publication(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'archive.zip'
|
||||
path.write_bytes(b'same')
|
||||
api = release.API('https://git.example/api/v1', 'private')
|
||||
api.assets = Mock(return_value=[{'name': path.name}])
|
||||
api.download = Mock(return_value=b'same')
|
||||
api.request = Mock()
|
||||
api.upload({'draft': False}, path)
|
||||
api.request.assert_not_called()
|
||||
|
||||
def test_incomplete_release_cannot_be_deployed(self):
|
||||
gitea, github = Mock(), Mock()
|
||||
gitea.release.return_value = {'draft': True, 'prerelease': False}
|
||||
github.release.return_value = {'draft': False, 'prerelease': False}
|
||||
with self.assertRaisesRegex(ValueError, 'not completely published'):
|
||||
release.release_receipt(gitea, github, '0.1.1')
|
||||
|
||||
def test_registry_permission_error_is_not_treated_as_missing_image(self):
|
||||
result = Mock(returncode=1, stderr='403 Forbidden', stdout='')
|
||||
with patch.object(release.subprocess, 'run', return_value=result):
|
||||
with self.assertRaisesRegex(RuntimeError, '403 Forbidden'):
|
||||
release.registry_digest(release.GITEA_IMAGE, '0.1.1')
|
||||
|
||||
def test_registry_missing_tag_is_distinct_from_other_failures(self):
|
||||
result = Mock(returncode=1, stderr='manifest unknown', stdout='')
|
||||
with patch.object(release.subprocess, 'run', return_value=result):
|
||||
self.assertIsNone(release.registry_digest(release.GITEA_IMAGE, '0.1.1'))
|
||||
|
||||
|
||||
class ResumeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.cwd = Path.cwd()
|
||||
os.chdir(self.tmp.name)
|
||||
self.addCleanup(os.chdir, self.cwd)
|
||||
Path('VERSION').write_text('0.1.1\n')
|
||||
Path('downloads').mkdir()
|
||||
for platform in release.PLATFORMS:
|
||||
Path('downloads', release.archive_name('0.1.1', platform)).write_bytes(platform.encode())
|
||||
Path('web/dist-demo').mkdir(parents=True)
|
||||
Path('web/dist-demo/index.html').write_text('<html>Demo</html>')
|
||||
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'<html>RawMark</html>'))
|
||||
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()
|
||||
Reference in new issue
Block a user