diff --git a/.gitignore b/.gitignore index a1b1517..7ae5cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ tools/mustang/*.jar private/ invoices/ dist/ + +# Isolated runtime candidates, rollback releases and maintenance logs +.runtime/ diff --git a/Documentation.md b/Documentation.md index ffd5f6d..d243d9f 100644 --- a/Documentation.md +++ b/Documentation.md @@ -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//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. diff --git a/Makefile b/Makefile index ecbf559..a89c526 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 731ce2f..84da1bd 100644 --- a/README.md +++ b/README.md @@ -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 ` 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`. diff --git a/bin/einvoice b/bin/einvoice new file mode 100755 index 0000000..bf91ceb --- /dev/null +++ b/bin/einvoice @@ -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 "$@" diff --git a/bin/jr-einvoice b/bin/jr-einvoice new file mode 100755 index 0000000..bf91ceb --- /dev/null +++ b/bin/jr-einvoice @@ -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 "$@" diff --git a/jr_einvoice/maintenance.py b/jr_einvoice/maintenance.py new file mode 100644 index 0000000..b9aad68 --- /dev/null +++ b/jr_einvoice/maintenance.py @@ -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="

PDF-Test

").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 diff --git a/jr_einvoice/setup.py b/jr_einvoice/setup.py index 2667518..a0a8e71 100644 --- a/jr_einvoice/setup.py +++ b/jr_einvoice/setup.py @@ -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] diff --git a/scripts/update.py b/scripts/update.py new file mode 100755 index 0000000..186f977 --- /dev/null +++ b/scripts/update.py @@ -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()) diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py new file mode 100644 index 0000000..eac9ab3 --- /dev/null +++ b/tests/test_maintenance.py @@ -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()