fix(ci): deploy version tags and install instance controls
This commit is contained in:
1 parent
6cbd604a28
commit
403824eccb
11 files changed
+335
-16
No files matched your search
+27
-2
@@ -4,6 +4,7 @@
|
||||
import argparse
|
||||
import contextlib
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -46,12 +47,35 @@ def save(path, data):
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def install_controls(directory):
|
||||
# Recognize the original templates; subsequent updates verify the installed receipt.
|
||||
legacy = {'start.sh': 'b4709f36a66b428671a09f373f640a99259845924779dcb122f5cd9c17e85ab6',
|
||||
'stop.sh': '83348a7781b7d738a715bde51387951041a5cfcd31796805422ef83139a21571'}
|
||||
receipt = directory / '.control-scripts.json'
|
||||
previous = json.loads(receipt.read_text()) if receipt.exists() else {}
|
||||
sources = Path(__file__).resolve().parents[1] / 'deploy'
|
||||
scripts = {name: (sources / name).read_bytes() for name in legacy}
|
||||
hashes = {name: hashlib.sha256(data).hexdigest() for name, data in scripts.items()}
|
||||
# Check both entries before replacing either; preserve custom or locally edited scripts.
|
||||
for name in scripts:
|
||||
target = directory / name
|
||||
require(not target.is_symlink(), f'Refusing to replace symlink: {target}')
|
||||
if target.exists():
|
||||
current = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
require(current in (legacy[name], hashes[name], previous.get(name)),
|
||||
f'Locally modified control script requires review: {target}')
|
||||
for name, data in scripts.items():
|
||||
save(directory / name, data)
|
||||
(directory / name).chmod(0o755)
|
||||
save(receipt, (json.dumps(hashes, indent=2) + '\n').encode())
|
||||
|
||||
|
||||
def deployment(directory, channel, image, commit, version, client_container=None):
|
||||
directory = Path(directory).resolve(strict=True)
|
||||
require(channel == 'development', 'Only the persistent DEV instance supports deployment')
|
||||
cfg = settings(directory, channel)
|
||||
registry = GITEA_DEV_IMAGE
|
||||
require(re.fullmatch(re.escape(registry) + r'@sha256:[0-9a-f]{64}', image), 'Expected an immutable registry digest')
|
||||
require(image == registry + ':' + version, 'Expected the selected development version tag')
|
||||
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:
|
||||
@@ -85,6 +109,7 @@ def deployment(directory, channel, image, commit, version, client_container=None
|
||||
compose = ['docker', 'compose', '--project-name', cfg['RAWMARK_PROJECT'], '--env-file', str(directory / '.env'),
|
||||
'-f', str(directory / 'compose.yml')]
|
||||
run(*compose, 'config', '--quiet', env=env)
|
||||
install_controls(directory)
|
||||
backup = None
|
||||
if previous:
|
||||
run('docker', 'stop', '--time', '15', previous['Id'])
|
||||
@@ -148,7 +173,7 @@ def main():
|
||||
parser.add_argument('--version', required=True)
|
||||
parser.add_argument('--client-container')
|
||||
args = parser.parse_args()
|
||||
require(args.image and args.commit, 'Development requires an image digest and commit')
|
||||
require(args.image and args.commit, 'Development requires an image version tag and commit')
|
||||
deployment(args.directory, args.channel, args.image, args.commit, args.version, args.client_container)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -532,7 +532,7 @@ def promote_development(local_image):
|
||||
input=token, text=True, check=True)
|
||||
existing = registry_digest(GITEA_DEV_IMAGE, build['version'])
|
||||
if existing:
|
||||
reference = GITEA_DEV_IMAGE + '@' + existing
|
||||
reference = GITEA_DEV_IMAGE + ':' + build['version']
|
||||
run('docker', 'pull', reference)
|
||||
info = json.loads(run('docker', 'image', 'inspect', reference))[0]
|
||||
labels = info['Config'].get('Labels') or {}
|
||||
@@ -561,7 +561,7 @@ def promote_development(local_image):
|
||||
run('docker', 'buildx', 'imagetools', 'create', '--prefer-index=false', '--tag',
|
||||
GITEA_DEV_IMAGE + ':' + tag, GITEA_DEV_IMAGE + '@' + value)
|
||||
require(registry_digest(GITEA_DEV_IMAGE, tag) == value, 'Development tag digest changed')
|
||||
output('image', GITEA_DEV_IMAGE + '@' + value)
|
||||
output('image', GITEA_DEV_IMAGE + ':' + build['version'])
|
||||
output('version', build['version'])
|
||||
finally:
|
||||
run('docker', 'image', 'rm', candidate)
|
||||
|
||||
@@ -204,7 +204,7 @@ class DeploymentTests(unittest.TestCase):
|
||||
(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_DEV_IMAGE + '@' + DIGEST
|
||||
self.image = release.GITEA_DEV_IMAGE + ':0.1.1'
|
||||
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': {
|
||||
@@ -236,6 +236,24 @@ class DeploymentTests(unittest.TestCase):
|
||||
'test': {'NetworkID': 'network', 'IPAddress': '127.0.0.1'}}}}])
|
||||
return ''
|
||||
|
||||
def test_existing_verified_build_outputs_a_tag(self):
|
||||
build={'commit':SHA,'version':'0.1.1'}
|
||||
info=json.dumps([{'Config':{'Labels':{'org.opencontainers.image.revision':SHA,
|
||||
'org.opencontainers.image.version':'0.1.1'}}}])
|
||||
with patch.object(release,'verify_bundle',return_value=build), \
|
||||
patch.object(release.subprocess,'run'), patch.object(release,'registry_digest',return_value=DIGEST), \
|
||||
patch.object(release,'run',return_value=info) as run, patch.object(release,'output') as output:
|
||||
release.promote_development('synthetic')
|
||||
output.assert_any_call('image',release.GITEA_DEV_IMAGE+':0.1.1')
|
||||
self.assertEqual(run.call_args_list[0].args,('docker','pull',release.GITEA_DEV_IMAGE+':0.1.1'))
|
||||
|
||||
def test_digest_and_other_version_tags_cannot_deploy(self):
|
||||
for image in (release.GITEA_DEV_IMAGE + '@' + DIGEST, release.GITEA_DEV_IMAGE + ':other'):
|
||||
with self.subTest(image=image), patch.object(deploy, 'run') as docker:
|
||||
with self.assertRaisesRegex(ValueError, 'version tag'):
|
||||
deploy.deployment(self.directory, 'development', image, SHA, '0.1.1')
|
||||
docker.assert_not_called()
|
||||
|
||||
def test_channel_mismatch_cannot_touch_docker(self):
|
||||
with patch.object(deploy, 'run') as docker:
|
||||
with self.assertRaisesRegex(ValueError, 'channel'):
|
||||
@@ -277,6 +295,16 @@ class DeploymentTests(unittest.TestCase):
|
||||
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())
|
||||
self.assertTrue((self.directory / 'start.sh').stat().st_mode & 0o111)
|
||||
self.assertTrue((self.directory / 'stop.sh').stat().st_mode & 0o111)
|
||||
|
||||
def test_custom_control_script_is_preserved_before_stopping(self):
|
||||
(self.directory / 'stop.sh').write_text('custom behavior\n')
|
||||
with patch.object(deploy, 'run', side_effect=self.command):
|
||||
with self.assertRaisesRegex(ValueError, 'Locally modified'):
|
||||
deploy.deployment(self.directory, 'development', self.image, SHA, '0.1.1')
|
||||
self.assertFalse(any(c[:2] == ('docker', 'stop') for c in self.commands))
|
||||
self.assertEqual((self.directory / 'stop.sh').read_text(), 'custom behavior\n')
|
||||
|
||||
def test_existing_data_is_backed_up_without_a_container(self):
|
||||
def without_container(*args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Verify instance controls with synthetic files and no container mutations."""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
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 deploy
|
||||
|
||||
|
||||
SOURCES = Path(__file__).resolve().parents[2] / 'deploy'
|
||||
|
||||
|
||||
class InstanceControlTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix='rawmark controls ')
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.directory = Path(self.tmp.name)
|
||||
self.bin = self.directory / 'bin'
|
||||
self.bin.mkdir()
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
shutil.copy2(SOURCES / name, self.directory / name)
|
||||
(self.directory / 'compose.yml').write_text(
|
||||
'name: ${RAWMARK_PROJECT:?missing project}\nservices:\n app:\n'
|
||||
' image: ${RAWMARK_IMAGE:?missing image}\n environment:\n'
|
||||
' APP_SETTING: ${APP_SETTING:?missing setting}\n'
|
||||
' volumes:\n - ${RAWMARK_DATA_DIR:?missing data}:/data\n')
|
||||
(self.directory / '.env').write_text(
|
||||
'RAWMARK_PROJECT=rawmark-controls\nRAWMARK_IMAGE=example.invalid/verified:old\n'
|
||||
f'APP_SETTING=from-dotenv\nRAWMARK_DATA_DIR={self.directory}/data\n')
|
||||
(self.directory / 'data').mkdir()
|
||||
(self.directory / 'data/keep').write_text('persistent')
|
||||
(self.bin / 'docker').write_text(f'#!{sys.executable}\n' + '''
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
directory = Path(__file__).resolve().parents[1]
|
||||
args = sys.argv[1:]
|
||||
with (directory / 'calls.jsonl').open('a') as log:
|
||||
log.write(json.dumps({'args': args, 'env': dict(os.environ)}) + '\\n')
|
||||
if 'config' in args and (directory / 'real-docker').exists():
|
||||
real = (directory / 'real-docker').read_text()
|
||||
result = subprocess.run([real, *args[:-1], '--format', 'json'], capture_output=True, text=True)
|
||||
(directory / 'resolved.json').write_text(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
sys.exit(result.returncode)
|
||||
status = directory / 'failure-code'
|
||||
if 'config' not in args and (directory / 'action-failure-code').exists():
|
||||
status = directory / 'action-failure-code'
|
||||
sys.exit(int(status.read_text()) if status.exists() else 0)
|
||||
''')
|
||||
(self.bin / 'docker').chmod(0o755)
|
||||
|
||||
def invoke(self, name, **environment):
|
||||
return subprocess.run(['bash', str(self.directory / name)], cwd='/',
|
||||
env={**os.environ, 'PATH': str(self.bin) + ':' + os.environ['PATH'], **environment},
|
||||
capture_output=True, text=True)
|
||||
|
||||
def calls(self):
|
||||
path = self.directory / 'calls.jsonl'
|
||||
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
|
||||
|
||||
def test_start_and_stop_use_instance_files_and_preserve_data(self):
|
||||
(self.directory / 'images.env').write_text('RAWMARK_IMAGE=example.invalid/verified:new\n')
|
||||
for name, action in (('start.sh', ['up', '-d', '--no-build', '--pull', 'never']),
|
||||
('stop.sh', ['stop'])):
|
||||
with self.subTest(name=name):
|
||||
result = self.invoke(name, RAWMARK_PROJECT='other-project', RAWMARK_IMAGE='unverified',
|
||||
RAWMARK_DATA_DIR='/other-data', APP_SETTING='caller',
|
||||
COMPOSE_FILE='/wrong.yml', COMPOSE_PROJECT_NAME='other',
|
||||
COMPOSE_PROFILES='extra', COMPOSE_DISABLE_ENV_FILE='true')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
call = self.calls()[-1]
|
||||
self.assertEqual(call['args'][-len(action):], action)
|
||||
files = [call['args'][i + 1] for i, arg in enumerate(call['args']) if arg == '--env-file']
|
||||
self.assertEqual(files, [str(self.directory / '.env'), str(self.directory / 'images.env')])
|
||||
self.assertEqual(call['args'][call['args'].index('--project-directory') + 1], str(self.directory))
|
||||
self.assertFalse(any(key.startswith(('RAWMARK_', 'COMPOSE_')) or key == 'APP_SETTING'
|
||||
for key in call['env']))
|
||||
self.assertEqual((self.directory / 'data/keep').read_text(), 'persistent')
|
||||
|
||||
def test_docker_client_configuration_is_preserved(self):
|
||||
result = self.invoke('stop.sh', DOCKER_CONFIG='/private/docker-config')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.calls()[-1]['env']['DOCKER_CONFIG'], '/private/docker-config')
|
||||
|
||||
def test_pending_blocks_start_but_allows_stop(self):
|
||||
(self.directory / 'deployment.pending.json').write_text('{}')
|
||||
self.assertNotEqual(self.invoke('start.sh').returncode, 0)
|
||||
self.assertEqual(self.calls(), [])
|
||||
self.assertEqual(self.invoke('stop.sh').returncode, 0)
|
||||
self.assertEqual(self.calls()[-1]['args'][-1], 'stop')
|
||||
|
||||
def test_lock_blocks_both_controls(self):
|
||||
with (self.directory / '.deploy.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
self.assertNotEqual(self.invoke(name).returncode, 0)
|
||||
self.assertEqual(self.calls(), [])
|
||||
|
||||
def test_invalid_compose_count_rejects_before_docker(self):
|
||||
(self.directory / 'compose.yaml').write_text('services: {}')
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
self.assertNotEqual(self.invoke(name).returncode, 0)
|
||||
self.assertEqual(self.calls(), [])
|
||||
|
||||
def test_config_failure_prevents_action_and_propagates_status(self):
|
||||
(self.directory / 'failure-code').write_text('17')
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
result = self.invoke(name)
|
||||
self.assertEqual(result.returncode, 17)
|
||||
self.assertEqual(self.calls()[-1]['args'][-2:], ['config', '--quiet'])
|
||||
|
||||
def test_action_failure_propagates_without_success_message(self):
|
||||
(self.directory / 'action-failure-code').write_text('19')
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
result = self.invoke(name)
|
||||
self.assertEqual(result.returncode, 19)
|
||||
self.assertEqual(result.stdout, '')
|
||||
|
||||
def test_real_compose_loads_dotenv_and_image_override_without_caller_overrides(self):
|
||||
docker = shutil.which('docker')
|
||||
if not docker or subprocess.run([docker, 'compose', 'version'], capture_output=True).returncode:
|
||||
self.skipTest('Docker Compose is not installed')
|
||||
(self.directory / 'real-docker').write_text(docker)
|
||||
(self.directory / 'images.env').write_text('RAWMARK_IMAGE=example.invalid/verified:new\n')
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
result = self.invoke(name, RAWMARK_PROJECT='wrong', RAWMARK_IMAGE='wrong',
|
||||
RAWMARK_DATA_DIR='/wrong', APP_SETTING='wrong')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
resolved = json.loads((self.directory / 'resolved.json').read_text())
|
||||
self.assertEqual(resolved['name'], 'rawmark-controls')
|
||||
self.assertEqual(resolved['services']['app']['image'], 'example.invalid/verified:new')
|
||||
self.assertEqual(resolved['services']['app']['environment']['APP_SETTING'], 'from-dotenv')
|
||||
self.assertEqual(resolved['services']['app']['volumes'][0]['source'], str(self.directory / 'data'))
|
||||
|
||||
|
||||
class ControlInstallationTests(unittest.TestCase):
|
||||
def test_fresh_install_and_update_preserve_local_edits(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
deploy.install_controls(directory)
|
||||
for name in ('start.sh', 'stop.sh'):
|
||||
self.assertEqual((directory / name).stat().st_mode & 0o777, 0o755)
|
||||
# A previously managed version can be updated using its recorded digest.
|
||||
(directory / 'start.sh').write_text('previous managed version')
|
||||
receipt = json.loads((directory / '.control-scripts.json').read_text())
|
||||
receipt['start.sh'] = hashlib.sha256((directory / 'start.sh').read_bytes()).hexdigest()
|
||||
(directory / '.control-scripts.json').write_text(json.dumps(receipt))
|
||||
deploy.install_controls(directory)
|
||||
self.assertEqual((directory / 'start.sh').read_bytes(), (SOURCES / 'start.sh').read_bytes())
|
||||
(directory / 'stop.sh').write_text('custom user behavior')
|
||||
with self.assertRaisesRegex(ValueError, 'Locally modified'):
|
||||
deploy.install_controls(directory)
|
||||
self.assertEqual((directory / 'stop.sh').read_text(), 'custom user behavior')
|
||||
Reference in new issue
Block a user