Add guarded Linux deployment updater

This commit is contained in:
Nico committed 2026-10-24 18:37:13 +02:00
1 parent c4225ebf04
commit 33603db9bd
3 files changed
+214 -6

No files matched your search

+24 -6
View File
@@ -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.
+107
View File
@@ -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())
+83
View File
@@ -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])<merged<installed
assert [c[-1].split('-')[-1].split('.')[0] for c in calls if c[:2]==('systemctl','start')]==started
assert not any('enable' in c or 'reset' in c or 'clean' in c for c in calls)
@pytest.mark.parametrize('failure',['install','start'])
def test_failure_keeps_both_services_stopped(monkeypatch,failure):
module=updater()
commands=Commands(failed=lambda c: ('pip' in c) if failure=='install' else c[:2]==('systemctl','start'))
monkeypatch.setattr(module,'trusted',lambda root:None)
with pytest.raises(RuntimeError):module.update(execute=commands)
assert commands.calls[-1]==('systemctl','stop',*module.SERVICES)
def test_fetch_failure_does_not_stop_services():
module=updater();commands=Commands(failed=lambda c:'fetch' in c)
with pytest.raises(RuntimeError):module.update(execute=commands)
assert not any(c[0]=='systemctl' for c in commands.calls)