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
@@ -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
|
Verzeichnis ist kein beliebig löschbarer Cache. Für eine einfache konsistente
|
||||||
Dateisicherung beide Dienste anhalten. Zugriffe auf Backups mit Secrets schützen.
|
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
|
```sh
|
||||||
sudo systemctl stop viano-bug-analysis-worker.service viano-bug-analysis-api.service
|
sudo python3 /opt/viano-bug-analysis/deploy/update.py
|
||||||
cd /opt/viano-bug-analysis
|
```
|
||||||
sudo git pull --ff-only
|
|
||||||
sudo .venv/bin/python -m pip install '.[api,research]'
|
Eine Vorschau ohne Netzwerkzugriff oder Änderungen ist mit `--dry-run` möglich.
|
||||||
sudo systemctl start viano-bug-analysis-api.service viano-bug-analysis-worker.service
|
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.
|
Releasehinweise und geänderte Konfigurations-/Unit-Vorlagen vorher prüfen.
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user