From 33603db9bd3680d4e2cc06ca8f2667e48d259ea9 Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Sat, 24 Oct 2026 18:37:13 +0200 Subject: [PATCH] Add guarded Linux deployment updater --- README.md | 30 ++++++++-- deploy/update.py | 107 ++++++++++++++++++++++++++++++++++++ tests/test_deploy_update.py | 83 ++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 deploy/update.py create mode 100644 tests/test_deploy_update.py diff --git a/README.md b/README.md index befab8b..18b13aa 100644 --- a/README.md +++ b/README.md @@ -275,14 +275,32 @@ deren separate Serverschlüsseldatei unter `runtime/integration-secrets`. Dieses Verzeichnis ist kein beliebig löschbarer Cache. Für eine einfache konsistente Dateisicherung beide Dienste anhalten. Zugriffe auf Backups mit Secrets schützen. -Für ein bewusstes Codeupdate im root-eigenen Checkout beispielsweise: +Für reguläre Codeupdates steht ein Update-Skript bereit: ```sh -sudo systemctl stop viano-bug-analysis-worker.service viano-bug-analysis-api.service -cd /opt/viano-bug-analysis -sudo git pull --ff-only -sudo .venv/bin/python -m pip install '.[api,research]' -sudo systemctl start viano-bug-analysis-api.service viano-bug-analysis-worker.service +sudo python3 /opt/viano-bug-analysis/deploy/update.py +``` + +Eine Vorschau ohne Netzwerkzugriff oder Änderungen ist mit `--dry-run` möglich. +Das Skript benötigt root und einen sauberen, root-eigenen Checkout mit Tracking-Branch. +Es holt den Remote-Stand, prüft Fast-forward, stoppt zuerst den Worker kontrolliert +und danach die API, installiert das aktualisierte Paket in die vorhandene venv +und startet nur die zuvor aktiven Dienste. Deren Startprüfungen testen die +Bereitschaft ohne LLM-Aufruf. Vorher inaktive Dienste werden nicht aktiviert. +Auch bei identischem Commit wird das Paket erneut installiert, etwa nach einem +vorherigen manuellen Pull. Parallele Updates sind gesperrt. +Konfigurationen, Zugangsdaten, Fallakten, Apache, Source-Checkout und Capture-Worker +werden nicht verändert. Das Skript erstellt keine Datensicherung und führt keine +projektspezifischen Migrationen aus. + +Scheitert Installation oder Dienststart, bleiben beide Dienste gestoppt. Es gibt +keinen automatischen Rollback von Code oder Daten. Nach Behebung der Ursache das +Paket erneut installieren (auch mit diesem Skript möglich) und die gewünschten +Dienste ausdrücklich starten. Falls das Skript im bisherigen Checkout noch fehlt, +einmal vorab den folgenden Befehl ausführen und danach das Skript starten: + +```sh +sudo git -C /opt/viano-bug-analysis pull --ff-only ``` Releasehinweise und geänderte Konfigurations-/Unit-Vorlagen vorher prüfen. diff --git a/deploy/update.py b/deploy/update.py new file mode 100644 index 0000000..e16b446 --- /dev/null +++ b/deploy/update.py @@ -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()) diff --git a/tests/test_deploy_update.py b/tests/test_deploy_update.py new file mode 100644 index 0000000..cb9bfb0 --- /dev/null +++ b/tests/test_deploy_update.py @@ -0,0 +1,83 @@ +import importlib.util +from pathlib import Path +import subprocess +import sys +import json +import pytest + + +def updater(): + path=Path(__file__).resolve().parents[1]/'deploy/update.py' + spec=importlib.util.spec_from_file_location('linux_updater',path) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + return module + + +class Commands: + def __init__(self, *, dirty=False, same=False, diverged=False, failed=None, states=None): + self.calls=[];self.dirty=dirty;self.same=same;self.diverged=diverged + self.failed=failed;self.states=states or ['active','active'] + + def __call__(self,*args): + self.calls.append(args) + if self.failed and self.failed(args):raise RuntimeError('injected failure') + if args[0]=='git': + cmd=args[3:] + if cmd[0]=='status':return ' M edited.py' if self.dirty else '' + if cmd==('rev-parse','HEAD'):return 'a'*40 + if cmd[0]=='rev-parse':return 'origin/main' if '--abbrev-ref' in cmd else ('a' if self.same else 'b')*40 + if cmd[0]=='merge-base' and self.diverged:raise RuntimeError('not fast forward') + if args[:2]==('systemctl','show'): + return self.states[0 if 'worker' in args[-1] else 1] + return '' + + +def test_plan_has_no_mutations(): + module=updater() + result=subprocess.run([sys.executable,module.__file__,'--dry-run'],capture_output=True,text=True,check=True) + assert json.loads(result.stdout)['status']=='PLAN' + + +@pytest.mark.parametrize('kwargs',[{'dirty':True},{'diverged':True}]) +def test_refuses_local_changes_or_divergence_before_stopping(kwargs): + module=updater();commands=Commands(**kwargs) + with pytest.raises(RuntimeError):module.update(execute=commands) + assert not any(c[0]=='systemctl' for c in commands.calls) + + +def test_same_revision_is_installed_after_bootstrap_pull(monkeypatch): + module=updater();commands=Commands(same=True) + monkeypatch.setattr(module,'trusted',lambda root:None) + assert module.update(execute=commands)['status']=='UPDATED' + assert any('pip' in c for c in commands.calls) + + +@pytest.mark.parametrize('states,started',[(['active','active'],['api','worker']),(['active','inactive'],['worker']),(['inactive','inactive'],[])]) +def test_update_order_and_preserves_inactive_services(monkeypatch,states,started): + module=updater();commands=Commands(states=states) + monkeypatch.setattr(module,'trusted',lambda root:None) + result=module.update(execute=commands) + assert result['status']=='UPDATED' + calls=commands.calls + stops=[c for c in calls if c[:2]==('systemctl','stop')] + assert stops==[('systemctl','stop',s) for s in module.SERVICES] + merged=next(i for i,c in enumerate(calls) if c[0]=='git' and c[3]=='merge') + installed=next(i for i,c in enumerate(calls) if 'pip' in c) + assert calls.index(stops[-1])