feat: add tested dependency updates with runtime rollback

This commit is contained in:
admin committed 2026-10-16 12:26:00 +02:00
1 parent 34a7a3d039
commit 6438558ea3
10 files changed
+408 -2

No files matched your search

+3
View File
@@ -12,3 +12,6 @@ tools/mustang/*.jar
private/ private/
invoices/ invoices/
dist/ dist/
# Isolated runtime candidates, rollback releases and maintenance logs
.runtime/
+67
View File
@@ -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.
+10
View File
@@ -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
+34 -1
View File
@@ -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
View File
@@ -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 "$@"
+21
View File
@@ -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 "$@"
+177
View File
@@ -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
+1 -1
View File
@@ -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]
+10
View File
@@ -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())
+64
View File
@@ -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()