feat: add tested dependency updates with runtime rollback
This commit is contained in:
1 parent
34a7a3d039
commit
6438558ea3
10 files changed
+408
-2
No files matched your search
@@ -12,3 +12,6 @@ tools/mustang/*.jar
|
|||||||
private/
|
private/
|
||||||
invoices/
|
invoices/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Isolated runtime candidates, rollback releases and maintenance logs
|
||||||
|
.runtime/
|
||||||
@@ -224,3 +224,70 @@ Peppol, DATEV-API und automatischer Postfacheingang. Diese Fälle nicht durch bl
|
|||||||
Freitexthinweise nachbilden. Das vorhandene CII-XML ist keine XRechnung, auch wenn
|
Freitexthinweise nachbilden. Das vorhandene CII-XML ist keine XRechnung, auch wenn
|
||||||
beide Formate EN16931 verwenden. Aufbewahrung/Backups und fachliche Freigabe müssen
|
beide Formate EN16931 verwenden. Aufbewahrung/Backups und fachliche Freigabe müssen
|
||||||
in den betrieblichen Ablauf eingebunden werden.
|
in den betrieblichen Ablauf eingebunden werden.
|
||||||
|
|
||||||
|
|
||||||
|
## Updates und Rollback
|
||||||
|
|
||||||
|
Der empfohlene Update-Einstieg für diesen Linux-Checkout ist
|
||||||
|
`python3 scripts/update.py` (nur Standardbibliothek, unabhängig von der venv).
|
||||||
|
Alternativ: `./bin/einvoice --update`. Mit `--apply` aktivieren, mit `--status`
|
||||||
|
den aktiven/vorherigen Stand anzeigen, mit `--rollback` zurückwechseln.
|
||||||
|
`--mustang-version 2.26.0` erlaubt eine gezielte stabile Mustang-Version.
|
||||||
|
Ohne Versionsvorgabe wird Maven `release` bevorzugt; Vorabversionen werden abgewiesen.
|
||||||
|
|
||||||
|
1. Exklusive Dateisperre verhindert gleichzeitige Wartungsläufe.
|
||||||
|
2. Neue venv unter `.runtime/releases/<Zeit-ID>/venv` erstellen und Projekt als
|
||||||
|
Wheel installieren, einschließlich PDF-Extra und transitiver Abhängigkeiten.
|
||||||
|
`pyproject.toml` ist die maßgebliche Abhängigkeitsdefinition. pip wählt die
|
||||||
|
neuesten zum Interpreter und zu diesen Vorgaben passenden Versionen.
|
||||||
|
3. Mustang von Maven herunterladen und veröffentlichte SHA1-Prüfsumme vergleichen.
|
||||||
|
Das prüft Übertragungsfehler, ist keine unabhängige Signaturprüfung.
|
||||||
|
4. Versionen vergleichen und Installationsbericht, vollständigen Paketstand
|
||||||
|
(`requirements.freeze.txt`) sowie Manifest mit Quell-Commit und JAR-SHA256 speichern.
|
||||||
|
5. `pip check`, WeasyPrint-PDF, beide CLI-Hilfen und alle Unittests einschließlich
|
||||||
|
echter Integrationstests ausführen. Dazu gehören PDF/A, eingebettetes XML,
|
||||||
|
EML-Anhang, neuer/bekannter Kunde sowie Ablehnung falscher Summen, Einheiten und
|
||||||
|
PDFs ohne Rechnungs-XML. Tests nutzen temporäre Muster-/Kundendaten.
|
||||||
|
`-I` und ein fremdes Arbeitsverzeichnis verhindern, dass der Checkout die
|
||||||
|
tatsächlich installierte Anwendung bei den Tests verdeckt.
|
||||||
|
6. Nur bei `--apply`: bisherigen Stand sichern und `.runtime/current` atomar auf
|
||||||
|
den Kandidaten setzen. Dieselben Healthchecks nach der Aktivierung wiederholen;
|
||||||
|
bei Fehler/KeyboardInterrupt sofort den bisherigen Zeiger wiederherstellen.
|
||||||
|
|
||||||
|
`bin/einvoice` und `bin/jr-einvoice` lösen den Zeiger pro Aufruf einmal auf und
|
||||||
|
verwenden Python und Mustang aus demselben Release. Nutzerablage bleibt standardmäßig
|
||||||
|
`private/` im Checkout, auch bei Wheel-Installation; `JR_EINVOICE_HOME` und
|
||||||
|
`JR_EINVOICE_PRESETS` werden respektiert. Vor dem ersten verwalteten Update nutzen
|
||||||
|
die Starter `.venv`; beim ersten Aktivieren wird diese unverändert verlinkt und die
|
||||||
|
bisherige Mustang-JAR kopiert. Diese erste Baseline ist noch die ursprüngliche
|
||||||
|
Entwicklerumgebung: nicht löschen/verändern, wenn sie als Rückfall benötigt wird.
|
||||||
|
Spätere Releases sind eigenständige, fest installierte Wheel-Umgebungen.
|
||||||
|
|
||||||
|
Fehlende Downloads, defekte Prüfsummen, Resolverfehler, Timeouts und fehlgeschlagene
|
||||||
|
Tests führen zu Exitcode 1. Erfolgreiche Wartung liefert 0. Kandidaten, vorherige
|
||||||
|
Releases und Logs bleiben zur Diagnose erhalten; es gibt kein automatisches Löschen.
|
||||||
|
Nach Abbruch durch SIGKILL/Stromausfall zeigt `current` auf einen vollständigen
|
||||||
|
vorherigen oder neuen Stand; eine noch ausstehende Nachprüfung wird nicht automatisch
|
||||||
|
nachgeholt. Bei Bedarf `--rollback` ausführen. Rollback prüft den Zielstand vorab;
|
||||||
|
ist er wegen externer Änderungen defekt, bleibt der aktuelle Zeiger erhalten.
|
||||||
|
|
||||||
|
Updates ändern keine Rechnungen, Kunden oder Rechnungsnummern. Rollback umfasst
|
||||||
|
Python-Pakete, den installierten Anwendungscode und Mustang; keine Git-Änderungen,
|
||||||
|
privaten Daten, Python-Systemversion, Java oder nativen Bibliotheken. Nach Systemupdates
|
||||||
|
muss eine bisherige venv ggf. neu aufgebaut werden. Systemupdates auf Arch als
|
||||||
|
vollständiges Update über `sudo pacman -Syu` durchführen und danach den Prüflauf
|
||||||
|
starten. Der Updater führt weder sudo noch Systempaketänderungen aus.
|
||||||
|
|
||||||
|
Es gibt bewusst keinen Hintergrundtimer: neue Stände werden explizit geprüft und
|
||||||
|
übernommen. Zum Beispiel vor dem nächsten Rechnungslauf oder monatlich prüfen.
|
||||||
|
Neue Projektversionen zunächst separat über Git holen, danach den Updater starten;
|
||||||
|
der Updater führt kein `git pull` aus. Uncommittete Quellen werden mit eingebaut,
|
||||||
|
der Manifest-Commit allein beschreibt diese daher nicht vollständig. Die bisherigen
|
||||||
|
`setup-mustang`/`mustang-update --apply` sind einfache Direktinstallationen ohne
|
||||||
|
Transaktionsschutz und verwalten die neue Laufzeit nicht; für Updates den gemeinsamen
|
||||||
|
Ablauf verwenden. Eine Wheel-Installation außerhalb des Checkouts besitzt diesen
|
||||||
|
Checkout-Updater nicht.
|
||||||
|
|
||||||
|
Technische Grundlagen: [pip Versionsauflösung und Installationsbericht](https://pip.pypa.io/en/stable/cli/pip_install/),
|
||||||
|
[Python venv und feste Umgebungspfade](https://docs.python.org/3/library/venv.html).
|
||||||
|
Umgebungen bleiben deshalb an ihrem Erstellungsort; nur der Zeiger wird gewechselt.
|
||||||
@@ -19,3 +19,13 @@ demo:
|
|||||||
mkdir -p invoices/Demo_Invoice
|
mkdir -p invoices/Demo_Invoice
|
||||||
cp examples/invoice.sample.yaml invoices/Demo_Invoice/invoice.yaml
|
cp examples/invoice.sample.yaml invoices/Demo_Invoice/invoice.yaml
|
||||||
$(JR) generate invoices/Demo_Invoice --log-level DEBUG
|
$(JR) generate invoices/Demo_Invoice --log-level DEBUG
|
||||||
|
|
||||||
|
.PHONY: update-check update rollback update-status
|
||||||
|
update-check:
|
||||||
|
python3 scripts/update.py
|
||||||
|
update:
|
||||||
|
python3 scripts/update.py --apply
|
||||||
|
rollback:
|
||||||
|
python3 scripts/update.py --rollback
|
||||||
|
update-status:
|
||||||
|
python3 scripts/update.py --status
|
||||||
@@ -11,7 +11,7 @@ Auf diesem Rechner sind Python-Umgebung, Absenderdaten und Mustang eingerichtet.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/johannes/localdev/einvoice
|
cd /home/johannes/localdev/einvoice
|
||||||
source .venv/bin/activate
|
export PATH="$PWD/bin:$PATH"
|
||||||
|
|
||||||
einvoice --create \
|
einvoice --create \
|
||||||
--Customer "Meine Kundenfirma GmbH" \
|
--Customer "Meine Kundenfirma GmbH" \
|
||||||
@@ -85,6 +85,39 @@ eingelesen. Das Präfix JR trennt die neue Serie von früheren `JJJJ-MM`-Nummern
|
|||||||
Bei einem technischen Fehler bleibt der reservierte Entwurf samt Log zur Diagnose
|
Bei einem technischen Fehler bleibt der reservierte Entwurf samt Log zur Diagnose
|
||||||
erhalten; zum Wiederholen `jr-einvoice generate <Rechnungsordner>` verwenden.
|
erhalten; zum Wiederholen `jr-einvoice generate <Rechnungsordner>` verwenden.
|
||||||
|
|
||||||
|
## Abhängigkeiten sicher aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/johannes/localdev/einvoice
|
||||||
|
python3 scripts/update.py # neueste kompatible Pakete + Mustang aufbauen und testen
|
||||||
|
python3 scripts/update.py --apply # testen, aktivieren, nochmals prüfen
|
||||||
|
python3 scripts/update.py --status # aktiven Stand anzeigen (offline)
|
||||||
|
python3 scripts/update.py --rollback # vorherigen Stand prüfen und wieder aktivieren (offline)
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Ablauf prüft Python-Paketabhängigkeiten, WeasyPrint/PDF-Erzeugung, beide CLIs
|
||||||
|
und die vollständige Testsuite einschließlich echter Mustang-Validierung und
|
||||||
|
ungültiger Gegenbeispiele. Schlägt ein Test vor Aktivierung fehl, bleibt die bisherige
|
||||||
|
Laufzeit aktiv. Schlägt die Prüfung danach fehl, wird automatisch zurückgeschaltet.
|
||||||
|
Die Konsole zeigt Schritte, Versionsänderungen und Fehler; vollständige Logs liegen
|
||||||
|
in `.runtime/logs/`. Netzwerk ist nur für neue Kandidaten nötig. Ohne `--apply`
|
||||||
|
werden Downloads, Installation und Tests ausgeführt, aber keine Umschaltung.
|
||||||
|
|
||||||
|
**Für die verwaltete Laufzeit die Starter aus `bin/` verwenden:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/home/johannes/localdev/einvoice/bin:$PATH" # bei Bedarf in ~/.zshrc aufnehmen
|
||||||
|
# alternativ ohne PATH-Einrichtung:
|
||||||
|
./bin/einvoice --customers
|
||||||
|
./bin/einvoice --update --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese PATH-Zeile nach einer eventuellen venv-Aktivierung ausführen. Direkte Aufrufe
|
||||||
|
von `.venv/bin/einvoice` verwenden weiterhin die ursprüngliche Entwicklerumgebung.
|
||||||
|
Java und native Bibliotheken werden mitgetestet, aber über den Systempaketmanager
|
||||||
|
aktualisiert; dessen Änderungen kann dieser Updater nicht zurückrollen.
|
||||||
|
Details und Grenzen: [Update-Betrieb](Documentation.md#updates-und-rollback).
|
||||||
|
|
||||||
## Daten und Einrichtung
|
## Daten und Einrichtung
|
||||||
|
|
||||||
- Absender: **johannes@jr-it-services.de**, lokale Vorlage `private/presets/seller.default.yaml`.
|
- Absender: **johannes@jr-it-services.de**, lokale Vorlage `private/presets/seller.default.yaml`.
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
if [[ "${1:-}" == --update ]]; then
|
||||||
|
shift
|
||||||
|
exec python3 "$root/scripts/update.py" "$@"
|
||||||
|
fi
|
||||||
|
# Resolve once: one invocation uses one complete release even during an update.
|
||||||
|
if [[ -L "$root/.runtime/current" ]]; then
|
||||||
|
release="$(readlink -f -- "$root/.runtime/current")"
|
||||||
|
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
||||||
|
python="$release/venv/bin/python"
|
||||||
|
else
|
||||||
|
python="$root/.venv/bin/python"
|
||||||
|
fi
|
||||||
|
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
||||||
|
export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}"
|
||||||
|
if [[ "$(basename -- "$0")" == jr-einvoice ]]; then
|
||||||
|
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
||||||
|
fi
|
||||||
|
exec "$python" -I -m jr_einvoice.quick_cli "$@"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
if [[ "${1:-}" == --update ]]; then
|
||||||
|
shift
|
||||||
|
exec python3 "$root/scripts/update.py" "$@"
|
||||||
|
fi
|
||||||
|
# Resolve once: one invocation uses one complete release even during an update.
|
||||||
|
if [[ -L "$root/.runtime/current" ]]; then
|
||||||
|
release="$(readlink -f -- "$root/.runtime/current")"
|
||||||
|
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
||||||
|
python="$release/venv/bin/python"
|
||||||
|
else
|
||||||
|
python="$root/.venv/bin/python"
|
||||||
|
fi
|
||||||
|
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
||||||
|
export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}"
|
||||||
|
if [[ "$(basename -- "$0")" == jr-einvoice ]]; then
|
||||||
|
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
||||||
|
fi
|
||||||
|
exec "$python" -I -m jr_einvoice.quick_cli "$@"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Checkout maintenance; standard library only, so broken dependencies cannot block rollback."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .setup import get_latest_mustang_version, install_mustang
|
||||||
|
|
||||||
|
|
||||||
|
def point(link: Path, target: Path) -> None:
|
||||||
|
temporary = link.with_name(link.name + '.next')
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
temporary.symlink_to(target)
|
||||||
|
os.replace(temporary, link)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def locked(state: Path):
|
||||||
|
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
with (state / 'update.lock').open('a') as handle:
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError as exc:
|
||||||
|
raise RuntimeError('Ein anderer Update-/Rollback-Lauf ist aktiv.') from exc
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
class Maintenance:
|
||||||
|
def __init__(self, root: Path, log):
|
||||||
|
self.root = root.resolve()
|
||||||
|
self.state = self.root / '.runtime'
|
||||||
|
self.log = log
|
||||||
|
|
||||||
|
def run(self, args, *, cwd=None, env=None, timeout=1200):
|
||||||
|
self.log.info('Aufruf: %s', ' '.join(map(str, args)))
|
||||||
|
result = subprocess.run(list(map(str, args)), cwd=cwd or self.root, env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
self.log.info('%s', result.stdout.rstrip())
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f'Befehl fehlgeschlagen (Exit {result.returncode}): {args[0]}')
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
def health(self, release: Path):
|
||||||
|
python = release / 'venv/bin/python'
|
||||||
|
env = os.environ.copy()
|
||||||
|
for key in ('PYTHONPATH', 'JR_EINVOICE_PRESETS', 'JR_EINVOICE_HOME'):
|
||||||
|
env.pop(key, None)
|
||||||
|
env['JR_EINVOICE_MUSTANG_JAR'] = str(release / 'Mustang-CLI.jar')
|
||||||
|
env['JR_EINVOICE_INTEGRATION'] = '1'
|
||||||
|
self.log.info('PRÜFUNG: pip check, WeasyPrint, CLI und echte Rechnungs-/Negativtests')
|
||||||
|
self.run([python, '-I', '-m', 'pip', 'check'], env=env)
|
||||||
|
with tempfile.TemporaryDirectory(prefix='einvoice-health-') as folder:
|
||||||
|
env['JR_EINVOICE_HOME'] = folder
|
||||||
|
self.run([python, '-I', '-c', 'from weasyprint import HTML; HTML(string="<p>PDF-Test</p>").write_pdf()'], cwd=folder, env=env)
|
||||||
|
for module in ('jr_einvoice.quick_cli', 'jr_einvoice.cli'):
|
||||||
|
code = ('from jr_einvoice.cli import app; app()' if module.endswith('.cli')
|
||||||
|
else 'from jr_einvoice.quick_cli import main; main()')
|
||||||
|
self.run([python, '-I', '-c', code, '--help'], cwd=folder, env=env)
|
||||||
|
self.run([python, '-I', '-m', 'unittest', 'discover', '-s', self.root / 'tests', '-v'],
|
||||||
|
cwd=folder, env=env)
|
||||||
|
|
||||||
|
def baseline(self):
|
||||||
|
baseline = self.state / 'releases' / ('baseline-' + uuid.uuid4().hex[:8])
|
||||||
|
baseline.mkdir(parents=True)
|
||||||
|
venv = self.root / '.venv'
|
||||||
|
jar = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', self.root / 'tools/mustang/Mustang-CLI.jar')).resolve()
|
||||||
|
if not (venv / 'bin/python').exists() or not jar.is_file():
|
||||||
|
raise RuntimeError('Erstinstallation nötig: .venv und Mustang fehlen; siehe README.')
|
||||||
|
(baseline / 'venv').symlink_to(venv)
|
||||||
|
shutil.copy2(jar, baseline / 'Mustang-CLI.jar')
|
||||||
|
return baseline
|
||||||
|
|
||||||
|
def activate(self, target: Path):
|
||||||
|
active = self.state / 'current'
|
||||||
|
old = active.resolve() if active.is_symlink() else self.baseline()
|
||||||
|
# Persist rollback target before changing the single runtime pointer.
|
||||||
|
point(self.state / 'previous', old)
|
||||||
|
self.log.info('AKTIVIERUNG: %s', target)
|
||||||
|
point(active, target)
|
||||||
|
try:
|
||||||
|
self.health(active.resolve())
|
||||||
|
except BaseException:
|
||||||
|
point(active, old)
|
||||||
|
self.log.error('ROLLBACK: bisherige Laufzeit wieder aktiv: %s', old)
|
||||||
|
raise
|
||||||
|
self.log.info('ERFOLG: Laufzeit aktiv. Rollback verfügbar: %s', old)
|
||||||
|
|
||||||
|
def update(self, apply=False, version=None):
|
||||||
|
version = version or get_latest_mustang_version(self.log)
|
||||||
|
if not re.fullmatch(r'\d+(?:\.\d+)+', version):
|
||||||
|
raise ValueError(f'Keine stabile Mustang-Version: {version!r}; --mustang-version verwenden.')
|
||||||
|
release = self.state / 'releases' / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-') + uuid.uuid4().hex[:8])
|
||||||
|
release.mkdir(parents=True)
|
||||||
|
self.log.info('KANDIDAT: %s; Mustang %s', release, version)
|
||||||
|
self.run(['java', '-version'])
|
||||||
|
self.run([sys.executable, '-m', 'venv', release / 'venv'])
|
||||||
|
python = release / 'venv/bin/python'
|
||||||
|
self.run([python, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||||
|
self.run([python, '-m', 'pip', 'install', '--upgrade', '--upgrade-strategy', 'eager',
|
||||||
|
'--report', release / 'install-report.json', f'{self.root}[pdf]'])
|
||||||
|
install_mustang(version, release / 'Mustang-CLI.jar', self.log)
|
||||||
|
frozen = self.run([python, '-m', 'pip', 'freeze', '--all'])
|
||||||
|
(release / 'requirements.freeze.txt').write_text(frozen)
|
||||||
|
packages = json.loads(self.run([python, '-m', 'pip', 'list', '--format=json']))
|
||||||
|
old_python = (self.state / 'current/venv/bin/python') if (self.state / 'current').exists() else self.root / '.venv/bin/python'
|
||||||
|
old = {p['name'].lower(): p['version'] for p in json.loads(self.run([old_python, '-m', 'pip', 'list', '--format=json']))}
|
||||||
|
for package in packages:
|
||||||
|
before = old.get(package['name'].lower(), '(neu)')
|
||||||
|
if before != package['version']:
|
||||||
|
self.log.info('VERSION: %s: %s -> %s', package['name'], before, package['version'])
|
||||||
|
manifest = {'mustang': version, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(),
|
||||||
|
'packages': packages, 'source_commit': self.run(['git', 'rev-parse', 'HEAD']).strip()}
|
||||||
|
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||||
|
self.health(release)
|
||||||
|
if apply:
|
||||||
|
self.activate(release)
|
||||||
|
else:
|
||||||
|
self.log.info('PRÜFUNG ERFOLGREICH: keine Umschaltung. Zum Übernehmen erneut mit --apply starten.')
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
previous = self.state / 'previous'
|
||||||
|
if not previous.exists():
|
||||||
|
raise RuntimeError('Kein vorheriger Laufzeitstand vorhanden.')
|
||||||
|
target = previous.resolve()
|
||||||
|
self.health(target)
|
||||||
|
self.activate(target)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description='Python-Abhängigkeiten und Mustang isoliert aktualisieren und testen.')
|
||||||
|
mode = parser.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument('--apply', action='store_true', help='Nach erfolgreichen Tests aktivieren')
|
||||||
|
mode.add_argument('--rollback', action='store_true', help='Vorherigen Stand prüfen und aktivieren')
|
||||||
|
mode.add_argument('--status', action='store_true', help='Aktiven und vorherigen Stand anzeigen, ohne Netzwerk')
|
||||||
|
parser.add_argument('--mustang-version', help='Explizite stabile Version statt Maven-Release')
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
state = root / '.runtime'
|
||||||
|
try:
|
||||||
|
with locked(state):
|
||||||
|
logs = state / 'logs'
|
||||||
|
logs.mkdir(exist_ok=True)
|
||||||
|
logfile = logs / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-') + uuid.uuid4().hex[:8] + '.log')
|
||||||
|
logger = logging.getLogger('maintenance')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.handlers.clear()
|
||||||
|
for handler in (logging.StreamHandler(sys.stdout), logging.FileHandler(logfile)):
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.info('PROTOKOLL: %s', logfile)
|
||||||
|
maintenance = Maintenance(root, logger)
|
||||||
|
if args.status:
|
||||||
|
for name in ('current', 'previous'):
|
||||||
|
link = state / name
|
||||||
|
logger.info('%s: %s', name, link.resolve() if link.exists() else '(noch nicht verwaltet)')
|
||||||
|
elif args.rollback:
|
||||||
|
maintenance.rollback()
|
||||||
|
else:
|
||||||
|
maintenance.update(args.apply, args.mustang_version)
|
||||||
|
except (Exception, KeyboardInterrupt) as exc:
|
||||||
|
logging.getLogger('maintenance').error('FEHLER: %s; nicht aktivierte Kandidaten bleiben zur Diagnose erhalten.', exc)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
@@ -34,7 +34,7 @@ def get_latest_mustang_version(logger) -> str:
|
|||||||
with urllib.request.urlopen(url, timeout=30) as r:
|
with urllib.request.urlopen(url, timeout=30) as r:
|
||||||
xml_data = r.read()
|
xml_data = r.read()
|
||||||
root = ET.fromstring(xml_data)
|
root = ET.fromstring(xml_data)
|
||||||
latest = root.findtext("./versioning/latest") or root.findtext("./versioning/release")
|
latest = root.findtext("./versioning/release") or root.findtext("./versioning/latest")
|
||||||
if not latest:
|
if not latest:
|
||||||
# fallback: last version in versions list
|
# fallback: last version in versions list
|
||||||
versions = [v.text for v in root.findall("./versioning/versions/version") if v.text]
|
versions = [v.text for v in root.findall("./versioning/versions/version") if v.text]
|
||||||
|
|||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run with system Python, independently of the application environment."""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from jr_einvoice.maintenance import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Transaction tests: a failed health check must never leave the candidate active."""
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from jr_einvoice.maintenance import Maintenance, locked, point
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.maintenance = Maintenance(self.root, logging.getLogger('maintenance-test'))
|
||||||
|
self.state = self.maintenance.state
|
||||||
|
self.state.mkdir()
|
||||||
|
self.old = self.state / 'old'
|
||||||
|
self.new = self.state / 'new'
|
||||||
|
self.old.mkdir()
|
||||||
|
self.new.mkdir()
|
||||||
|
point(self.state / 'current', self.old)
|
||||||
|
|
||||||
|
def test_success_and_manual_rollback(self):
|
||||||
|
with patch.object(self.maintenance, 'health'):
|
||||||
|
self.maintenance.activate(self.new)
|
||||||
|
self.assertEqual((self.state / 'current').resolve(), self.new)
|
||||||
|
self.maintenance.rollback()
|
||||||
|
self.assertEqual((self.state / 'current').resolve(), self.old)
|
||||||
|
self.assertEqual((self.state / 'previous').resolve(), self.new)
|
||||||
|
|
||||||
|
def test_post_activation_failure_and_interrupt_restore_old(self):
|
||||||
|
for failure in (RuntimeError('bad invoice'), KeyboardInterrupt()):
|
||||||
|
with self.subTest(failure=type(failure).__name__):
|
||||||
|
with patch.object(self.maintenance, 'health', side_effect=failure):
|
||||||
|
with self.assertRaises(type(failure)):
|
||||||
|
self.maintenance.activate(self.new)
|
||||||
|
self.assertEqual((self.state / 'current').resolve(), self.old)
|
||||||
|
|
||||||
|
def test_failed_rollback_precheck_keeps_active(self):
|
||||||
|
point(self.state / 'previous', self.new)
|
||||||
|
with patch.object(self.maintenance, 'health', side_effect=RuntimeError('bad')):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.maintenance.rollback()
|
||||||
|
self.assertEqual((self.state / 'current').resolve(), self.old)
|
||||||
|
|
||||||
|
def test_parallel_update_is_rejected(self):
|
||||||
|
with locked(self.state):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'anderer'):
|
||||||
|
with locked(self.state):
|
||||||
|
self.fail('lock not enforced')
|
||||||
|
|
||||||
|
def test_download_failure_never_switches(self):
|
||||||
|
with patch.object(self.maintenance, 'run'), patch('jr_einvoice.maintenance.install_mustang', side_effect=RuntimeError('checksum')):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.maintenance.update(True, '2.26.0')
|
||||||
|
self.assertEqual((self.state / 'current').resolve(), self.old)
|
||||||
|
|
||||||
|
def test_invalid_version_is_rejected_before_commands(self):
|
||||||
|
with patch.object(self.maintenance, 'run') as run:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.maintenance.update(True, '../bad')
|
||||||
|
run.assert_not_called()
|
||||||
Reference in new issue
Block a user