"""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())