Add guarded Linux deployment updater
This commit is contained in:
1 parent
c4225ebf04
commit
33603db9bd
3 files changed
+214
-6
No files matched your search
@@ -0,0 +1,107 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user