Files
eezd 403824eccb
Development CI / check-and-package (push) Successful in 4m21s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / image (push) Successful in 22s
Development CI / deploy-development (push) Successful in 16s
fix(ci): deploy version tags and install instance controls
2026-11-02 15:37:54 +08:00

182 lines
10 KiB
Python

#!/usr/bin/env python3
"""Deploy a verified image to one explicitly configured Compose instance."""
import argparse
import contextlib
import fcntl
import hashlib
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_DEV_IMAGE, 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 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(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:
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/dev')['commit']['id'] != commit:
print('Skipped development deployment: dev 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)
install_controls(directory)
backup = None
if previous:
run('docker', 'stop', '--time', '15', previous['Id'])
if any((directory / 'data').iterdir()):
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 and 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',))
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()
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)
if __name__ == '__main__':
main()