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/
|
||||
invoices/
|
||||
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
|
||||
beide Formate EN16931 verwenden. Aufbewahrung/Backups und fachliche Freigabe müssen
|
||||
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
|
||||
cp examples/invoice.sample.yaml invoices/Demo_Invoice/invoice.yaml
|
||||
$(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
|
||||
cd /home/johannes/localdev/einvoice
|
||||
source .venv/bin/activate
|
||||
export PATH="$PWD/bin:$PATH"
|
||||
|
||||
einvoice --create \
|
||||
--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
|
||||
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
|
||||
|
||||
- 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:
|
||||
xml_data = r.read()
|
||||
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:
|
||||
# fallback: last version in versions list
|
||||
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