Files
VianoBugAnalysis/deploy/update.py
T

108 lines
4.8 KiB
Python

"""Update an existing Linux deployment; preserve configuration and case data."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path('/opt/viano-bug-analysis')
SERVICES = ('viano-bug-analysis-worker.service', 'viano-bug-analysis-api.service')
def run(*args, check=True):
result = subprocess.run(args, check=False, text=True, stdout=subprocess.PIPE)
if check and result.returncode:
if result.stdout:
print(result.stdout, flush=True)
raise subprocess.CalledProcessError(result.returncode, args)
return result.stdout.strip()
def trusted(root):
for base in (root, root/'.git', root/'src', root/'deploy', root/'pyproject.toml', root/'.venv'):
if not base.exists():
raise RuntimeError('Installation is incomplete')
paths = [base, *base.rglob('*')] if base.is_dir() and base != root else [base]
for path in paths:
stat = path.stat()
if path.is_symlink() and base != root/'.venv':
raise RuntimeError('Symlink in installation sources')
if stat.st_uid != 0 or stat.st_mode & 0o022:
raise RuntimeError('Installation must be root-owned and not group/world writable')
def update(root=ROOT, execute=run):
def git(*args):
return execute('git', '-C', str(root), *args)
if git('status', '--porcelain'):
raise RuntimeError('Checkout has local changes; update refused')
old = git('rev-parse', 'HEAD')
upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}')
# Fetch changes objects/refs only, while services still use the installed code.
git('fetch', '--prune')
target = git('rev-parse', upstream)
git('merge-base', '--is-ancestor', old, target)
active = []
for service in SERVICES:
state = execute('systemctl', 'show', '--property=ActiveState', '--value', service)
if state in {'active', 'activating', 'reloading'}:
active.append(service)
elif state not in {'inactive', 'failed'}:
raise RuntimeError('Service is changing state; retry later')
stopped = False
try:
# Stop the worker first; its unit waits for the current dispatch to finish.
stopped = True
for service in SERVICES:
execute('systemctl', 'stop', service)
git('merge', '--ff-only', target)
trusted(root)
execute(str(root/'.venv/bin/python'), '-m', 'pip', 'install', str(root)+'[api,research]')
# Existing units/configs are intentionally not overwritten. ExecStartPre
# runs readiness checks with the real service restrictions and secrets.
for service in reversed(active):
execute('systemctl', 'start', service)
execute('systemctl', 'is-active', '--quiet', service)
except Exception:
if stopped:
# A partly installed package must never be left serving traffic.
execute('systemctl', 'stop', *SERVICES)
raise
return {'status':'UPDATED', 'previous_commit':old, 'commit':target,
'services_restarted':active, 'configuration':'preserved', 'case_data':'preserved'}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dry-run', action='store_true', help='Show the procedure without fetching or changing anything')
args = parser.parse_args(argv)
if args.dry_run:
print(json.dumps({'status':'PLAN', 'root':str(ROOT), 'steps':[
'require root-owned clean checkout and tracking branch', 'fetch and require fast-forward',
'stop worker and API', 'fast-forward to fetched commit', 'install into existing venv',
'restart previously active services with readiness checks'],
'configurations_and_units':'preserved', 'case_data':'preserved'}))
return 0
if sys.platform != 'linux' or os.geteuid() != 0:
parser.error('Update requires Linux and root')
if Path(__file__).absolute().parents[1] != ROOT:
parser.error('Run the updater from /opt/viano-bug-analysis/deploy/update.py')
import fcntl
try:
trusted(ROOT)
# One updater at a time; kernel releases the lock on process exit.
with (ROOT/'.git/viano-update.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
result = update()
print(json.dumps(result))
return 0
except Exception:
print(json.dumps({'status':'ERROR', 'code':'UPDATE_FAILED',
'instruction':'Inspect command output. After an installation/start failure both services remain stopped; fix the cause, reinstall the package and start the required services. No automatic rollback or case changes.'}))
return 1
if __name__ == '__main__':
raise SystemExit(main())