108 lines
4.8 KiB
Python
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())
|