From 448be8dc499752efa3af326e95d5b09b7d626ca7 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Mon, 10 Aug 2026 18:28:49 +0200 Subject: [PATCH] Make installer runtime acceptance transactional --- Dokumentation.md | 18 ++ README.md | 39 ++- deployment/INSTALLATION.md | 19 ++ ...ndpoint-Reachability-Analyse-2026-08-10.md | 6 +- ...k_Checkmk_Pulse_Architekturueberblick.docx | Bin 277060 -> 277162 bytes docs/EndpointCatalog.md | 6 + docs/Integration.md | 11 + scripts/generate-architecture-overview.py | 9 +- .../InstallerEngine.cs | 122 ++++++-- .../Properties/AssemblyInfo.cs | 4 +- .../TaskSchedulerService.cs | 145 +++++++++- src/BizTalkCheckmkPulse/App.config | 2 + .../BizTalkCheckmkPulse.csproj | 1 + .../EndpointConnectivityProbe.cs | 3 +- src/BizTalkCheckmkPulse/MonitoringOptions.cs | 45 +++ src/BizTalkCheckmkPulse/Program.cs | 43 ++- .../Properties/AssemblyInfo.cs | 4 +- src/BizTalkCheckmkPulse/RuntimeValidator.cs | 179 ++++++++++++ src/BizTalkCheckmkPulse/SnapshotStore.cs | 10 +- tests/BizTalkCheckmkPulse.Tests/Program.cs | 273 +++++++++++++++++- 20 files changed, 874 insertions(+), 65 deletions(-) create mode 100644 src/BizTalkCheckmkPulse/RuntimeValidator.cs diff --git a/Dokumentation.md b/Dokumentation.md index d977800..08226e6 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -408,6 +408,17 @@ Task gestoppt und die Programmversion umgeschaltet. Scheitert ein Folgeschritt, versucht der Installer Programmverzeichnis, Checkmk-Wrapper und Scheduled Task auf den vorherigen Stand zurückzusetzen. +Ab Version 2.2.4 ist die Umschaltung erst nach einer transaktionalen +Runtime-Abnahme abgeschlossen. Ein einmaliger, triggerloser Task läuft unter +dem echten Collector-Konto und erzwingt einen vollständigen Endpoint- +Katalogabgleich. Der Installer wartet begrenzt auf `LastTaskResult=0` und lässt +anschließend die installierte Produkt-EXE Snapshot und Katalog mit den normalen +Produktparsern validieren. Der Snapshot muss aus diesem Lauf stammen, die +erwartete Identität und alle neun eindeutigen Services ohne `UNKNOWN` +enthalten. Erst dann wird der normale Minutentask registriert und das Backup +gelöscht. Bei Fehler oder Timeout wird die alte Version wiederhergestellt und +ein frischer Lauf des alten Tasks abgewartet. + Der installierte Task selbst läuft mit `RunLevel Limited`. Der Installer übergibt das Kennwort direkt an die Windows-Task-Scheduler-COM-Schnittstelle; es wird weder in einer Prozesskommandozeile noch in Config oder Log @@ -550,6 +561,8 @@ BizTalk-Probes: | Key | Default | | --- | --- | | `Server` | `.` | +| `EnvironmentName` | leer | +| `IncludeEnvironmentInServiceName` | `false` | | `QueryTimeoutSeconds` | `25` | | `ProbeSqlConnectivity` | `true` | | `SqlConnectionTimeoutSeconds` | `5` | @@ -602,6 +615,11 @@ Automatisiert: einem theoretischen Socket-Budget von etwa 15 Sekunden; das konfigurierte Maximum von 100 Zielen in etwa 21 Sekunden, - Update-Config-Merge erhält bestehende Werte und fügt neue Defaults hinzu, +- erzwungener Installations-Katalogabgleich wird unabhängig vom Katalogalter + ausgeführt, +- Runtime-Abnahme weist alten/manipulierten Snapshot, falsche Identität, + fehlenden Service, `UNKNOWN`, alten/ungültigen Katalog und fehlerhaften oder + nicht rechtzeitig beendeten Tasklauf zurück, - Snapshot-Roundtrip und Ersatz, - SHA-256-Manipulation wird verworfen, - Stale-Snapshot wird verworfen, diff --git a/README.md b/README.md index b24893e..210fb86 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,11 @@ Pro Service werden standardmäßig maximal fünf betroffene Namen gezeigt. Weitere Treffer erscheinen nur als `(+n more)`; Details sind zusätzlich auf 1600 Zeichen begrenzt. Metriken bleiben trotzdem vollständig. -Mit `EnvironmentName=ACC`, `DEV`, `TST` oder `PRD` wird die Umgebung in den -Servicenamen aufgenommen, zum Beispiel `BizTalk ACC Platform`. +`EnvironmentName=ACC`, `DEV`, `TST` oder `PRD` kennzeichnet Snapshot und +Endpoint-Katalog. Die neun Checkmk-Servicenamen bleiben standardmäßig +unverändert. Nur das explizite Opt-in +`IncludeEnvironmentInServiceName=true` ergänzt die Umgebung im Servicenamen +und verlangt anschließend eine Checkmk Service Discovery. Statuslogik und Metriken: [docs/CheckmkServices.md](docs/CheckmkServices.md) @@ -217,7 +220,9 @@ artifacts\BizTalkCheckmkPulse-Setup\application\BizTalkCheckmkPulse.exe --self-t Erwartet werden exakt neun `OK`-Zeilen. Die Regressionstests prüfen zusätzlich Snapshot-Roundtrip, atomaren Ersatz, SHA-256-Manipulation, -Stale-Erkennung, stabile Fallbacks und die bestehenden BizTalk-WMI-Diagnosen. +Stale-Erkennung, stabile Fallbacks, den exakten Servicevertrag, erzwungene +Katalogaktualisierung sowie erfolgreiche und abgewiesene +Post-Install-Runtime-Abnahmen. Ein Mono-Build ist eine hilfreiche Quellcodeprüfung, ersetzt aber nicht die Windows-/BizTalk-Laufzeitvalidierung. @@ -265,17 +270,26 @@ Der Installer: 2. baut die neue Version in einem Staging-Verzeichnis auf und übernimmt vorhandene AppSettings; neue Keys erhalten ihre neuen Defaults. Die alten unveränderten Endpoint-Defaults `12`/`500` werden auf `16`/`100` migriert, -3. schaltet die Programmdateien per Verzeichniswechsel um; bei einem Fehler - werden vorherige Version, Wrapper und Task best effort wiederhergestellt, -4. behält `%ProgramData%\BizTalkCheckmkPulse` mit Endpoint-Katalog, Snapshot +3. vergleicht installierten und neuen Checkmk-Servicevertrag; ein + unbestätigter Rename stoppt vor jeder Umschaltung, +4. schaltet die Programmdateien per Verzeichniswechsel um, +5. startet einen triggerlosen Provider-Abnahmelauf unter dem echten + Collector-Konto und erzwingt einen vollständigen Endpoint-Katalogabgleich, +6. wartet höchstens vier Minuten auf `LastTaskResult=0` und validiert danach + mit der Produkt-EXE einen erst nach Installationsbeginn erzeugten Snapshot, + die Collector-Identität, alle neun eindeutigen Services ohne `UNKNOWN` + sowie den frischen, maschinen- und umgebungsgebundenen Endpoint-Katalog, +7. registriert erst nach dieser Abnahme den normalen Minutentask; andernfalls + werden vorherige Version, Wrapper und Task samt frischem Altsnapshot + wiederhergestellt, +8. behält `%ProgramData%\BizTalkCheckmkPulse` mit Endpoint-Katalog, Snapshot und Logs bei, -5. installiert EXE und Config nach `%ProgramFiles%\BizTalkCheckmkPulse`, -6. setzt explizite ACLs für Administratoren, Provider und `LocalSystem`, -7. installiert nur den kleinen `.cmd`-Consumer unter +9. installiert EXE und Config nach `%ProgramFiles%\BizTalkCheckmkPulse`, setzt + explizite ACLs für Administratoren, Provider und `LocalSystem` und + installiert nur den kleinen `.cmd`-Consumer unter `%ProgramData%\checkmk\agent\local`, -8. registriert `BizTalk Checkmk Pulse Provider` minütlich mit - `IgnoreNew`, fünf Minuten Laufzeitlimit und zwei Wiederholungen, -9. führt den installierten Self-Test erneut aus und startet den Provider. +10. registriert `BizTalk Checkmk Pulse Provider` minütlich mit `IgnoreNew`, + fünf Minuten Laufzeitlimit und zwei Wiederholungen. Installation, Update, Deinstallation und Laufzeit verwenden keine PowerShell. @@ -356,6 +370,7 @@ Wichtige Werte: | Key | Default | Bedeutung | | --- | --- | --- | | `EnvironmentName` | leer | Optional `ACC`, `DEV`, `TST`, `PRD`. | +| `IncludeEnvironmentInServiceName` | `false` | Nur mit geplanter Service Discovery aktivieren. | | `SnapshotPath` | `%ProgramData%\BizTalkCheckmkPulse\data\...` | Gemeinsame Provider-/Consumer-Datei. | | `SnapshotMaxAgeSeconds` | `180` | Ab diesem Alter liefert der Consumer `UNKNOWN`. | | `SnapshotMaxBytes` | `1048576` | Harte Eingabegröße für den Consumer. | diff --git a/deployment/INSTALLATION.md b/deployment/INSTALLATION.md index 4aedd7c..34066ab 100644 --- a/deployment/INSTALLATION.md +++ b/deployment/INSTALLATION.md @@ -36,6 +36,25 @@ Sicherheitsstopp. Die Installation darf nur über die Checkbox fortgesetzt werden. Der Installer zeigt dabei die entfernten und neuen Namen an. Eine bloß geänderte Ausgabereihenfolge gilt nicht als Rename. +Version 2.2.4 nimmt die umgeschaltete Installation zusätzlich vollständig ab: + +- einmaliger, triggerloser Providerlauf unter dem angegebenen Collector-Konto, +- erzwungener vollständiger Abgleich von `endpoints.xml`, wobei gültige + manuelle Overrides erhalten bleiben, +- maximal vier Minuten Warten auf `LastTaskResult=0`, +- Snapshot muss nach Beginn des Abnahmelaufs erzeugt, SHA-256-valid, für die + aktuelle Maschine und vom erwarteten Collector-Konto geschrieben sein, +- alle neun stabilen Services müssen eindeutig vorhanden sein; `WARN` und + `CRIT` sind reale Betriebszustände, `UNKNOWN` blockiert die Installation, +- bei aktivierter Endpoint-Prüfung muss auch der Katalog nach Beginn des + Abnahmelaufs atomar synchronisiert worden sein. + +Erst danach ersetzt der Installer den Abnahmetask durch den normalen +Minutentask und löscht das Backup. Bei Fehler oder Timeout wird die vorherige +Programmversion samt Task wiederhergestellt und ein frischer Lauf der alten +Version abgewartet. Fehler in Paket-, Config- oder Rename-Vorprüfung treten vor +jeder Taskänderung auf und lassen die laufende Installation unangetastet. + Beim korrigierenden Wechsel von 2.2.1 mit `BizTalk ACC ...` auf stabile `BizTalk ...`-Namen ist die Änderung beabsichtigt: Checkbox aktivieren und den Servicebestand danach per Discovery abgleichen. Sind in Checkmk bereits die diff --git a/docs/ACC-Endpoint-Reachability-Analyse-2026-08-10.md b/docs/ACC-Endpoint-Reachability-Analyse-2026-08-10.md index 40875b3..d9d367f 100644 --- a/docs/ACC-Endpoint-Reachability-Analyse-2026-08-10.md +++ b/docs/ACC-Endpoint-Reachability-Analyse-2026-08-10.md @@ -75,6 +75,10 @@ nicht netzwerkprüfbare Kandidaten. Version 2.2.2 weist sie explizit als tatsächliche Servicevertrag der vorhandenen und der neuen EXE wird vor dem Task-Stopp verglichen. Unbestätigte Renames, neue oder entfernte Services blockieren das Update; die Differenz wird vollständig angezeigt. +- Version 2.2.4 erzwingt nach der Umschaltung einen frischen vollständigen + Endpoint-Katalogabgleich und akzeptiert die Installation erst nach einem + erfolgreichen Providerlauf mit neuem, identitäts- und integritätsgeprüftem + Snapshot sowie neun verlässlichen stabilen Services. ### Korrekte Abdeckungssemantik @@ -135,7 +139,7 @@ Endpoint-/Namensfix bewusst nicht grüngefärbt. ## ACC-Abnahme nach Installation -1. Setup 2.2.3 als Update ausführen; Umgebung `ACC` kann bestehen bleiben. +1. Setup 2.2.4 als Update ausführen; Umgebung `ACC` kann bestehen bleiben. Beim Wechsel von der fehlerhaften 2.2.1-Ausgabe `BizTalk ACC ...` auf die stabilen Namen die Rename-/Discovery-Checkbox bewusst aktivieren. 2. In der installierten Config bestätigen: diff --git a/docs/BizTalk_Checkmk_Pulse_Architekturueberblick.docx b/docs/BizTalk_Checkmk_Pulse_Architekturueberblick.docx index d61488eb65b9761c849780721ceb4e91d616d934..cfb15a840c7cd6d828b23c83ac446a6f317a58db 100644 GIT binary patch delta 11576 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 delta 11349 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 G^uGXGrcI9k diff --git a/docs/EndpointCatalog.md b/docs/EndpointCatalog.md index 4d88be9..1cf2109 100644 --- a/docs/EndpointCatalog.md +++ b/docs/EndpointCatalog.md @@ -15,6 +15,12 @@ Abgleich alle `EndpointDiscoveryIntervalHours` (Default: 168 Stunden). Ein Fehler beim Abgleich ersetzt keinen letzten gültigen Katalog. Checkmk meldet den überfälligen/fehlgeschlagenen Abgleich trotzdem als `UNKNOWN`. +Bei Installation oder Update ab Version 2.2.4 erzwingt ein einmaliger +Provider-Abnahmelauf unabhängig vom Alter einen vollständigen Abgleich. Der +Installer akzeptiert den Release erst, wenn der neue Synchronisationszeitpunkt +nach dem Start dieses Laufs liegt. Gültige manuelle Overrides werden dabei +erhalten; der Katalog wird nicht blind gelöscht. + Unabhängig vom Wochenabgleich wird bei jedem Minutenlauf gegen den aktuellen BizTalk-Runtimezustand gefiltert: diff --git a/docs/Integration.md b/docs/Integration.md index 2df07cb..cfb7bea 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -53,6 +53,17 @@ Verbindlicher Agent-Dump: ## Service Discovery +Das Setup 2.2.4 schließt erst erfolgreich ab, nachdem der erste Providerlauf +unter dem echten Collector-Konto einen frischen Snapshot und – bei aktivierter +Endpoint-Prüfung – einen frisch synchronisierten Katalog erzeugt hat. Die neun +stabilen Services müssen vollständig und ohne `UNKNOWN` vorliegen. Diese lokale +Runtime-Abnahme ersetzt nicht die zentrale Checkmk Service Discovery. + +`EnvironmentName` kennzeichnet Snapshot und Katalog, ändert die neun +Servicenamen aber standardmäßig nicht. Nur das bewusste Opt-in +`IncludeEnvironmentInServiceName=true` erzeugt umgebungsabhängige Namen und +verlangt den anschließenden Discovery-Abgleich. + 1. Provider dreimal erfolgreich laufen lassen. 2. `LastTaskResult=0`, frischen Snapshot und Log prüfen. 3. Agent-Dump im `LocalSystem`-Kontext prüfen. diff --git a/scripts/generate-architecture-overview.py b/scripts/generate-architecture-overview.py index 5aa73e4..e5a7f73 100644 --- a/scripts/generate-architecture-overview.py +++ b/scripts/generate-architecture-overview.py @@ -306,7 +306,7 @@ def configure_document(document): p = footer.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(0) - format_run(p.add_run("Version 2.2.3 | Stand 10.08.2026 | Seite "), size=7.5, color=MUTED) + format_run(p.add_run("Version 2.2.4 | Stand 10.08.2026 | Seite "), size=7.5, color=MUTED) add_page_field(p) @@ -320,7 +320,7 @@ def build_document(): core.title = "BizTalk Checkmk Pulse – Architektur- und Lösungsüberblick" core.subject = "Gesamtarchitektur, Funktionsumfang und Datenaustausch" core.author = "BEW" - core.comments = "Externe Lösungsübersicht zum implementierten Stand 2.2.3" + core.comments = "Externe Lösungsübersicht zum implementierten Stand 2.2.4" # Cover banner = document.add_table(rows=1, cols=1) @@ -377,7 +377,7 @@ def build_document(): document, ("Dokumentmerkmal", "Angabe"), ( - ("Lösungsstand", "BizTalk Checkmk Pulse 2.2.3"), + ("Lösungsstand", "BizTalk Checkmk Pulse 2.2.4"), ("Zielplattform", "Microsoft BizTalk Server 2020 · Windows Server 2019 · Checkmk 2.4"), ("Dokumentstand", "10. August 2026"), ("Dokumentzweck", "Architektur- und Leistungsüberblick für technische Stakeholder"), @@ -544,6 +544,7 @@ def build_document(): add_bullet(document, "Erwartet inaktive Receive Locations und Send Ports können exakt allowlisted werden und bleiben als Messwert sichtbar.") add_bullet(document, "Endpoint-Prüfung standardmäßig mit maximal 100 Zielen, 16 parallelen Probes und 3 Sekunden Timeout je Ziel.") add_bullet(document, "Der Installer vergleicht bei Updates die exakten Checkmk-Servicenamen und stoppt unbestätigte Änderungen, bevor Task oder Programmdateien angefasst werden.") + add_bullet(document, "Nach der Umschaltung erzwingt das Setup einen frischen Katalogabgleich und akzeptiert die Installation erst nach einem erfolgreichen, identitäts- und integritätsgeprüften Providerlauf ohne UNKNOWN in den neun stabilen Services.") add_heading(document, "Sicherheitsmodell", level=2) add_table( @@ -572,7 +573,7 @@ def build_document(): add_callout(document, "Kernaussage: ", "Ein privilegierter Read-only-Collector sammelt, ein unprivilegierter Consumer validiert und übergibt – Checkmk erhält neun stabile Services statt direkten Zugriff auf BizTalk oder SQL.", fill=LIGHT_GREEN, border=GREEN) add_rich_paragraph( document, - (("Dokumentbasis: Implementierung und Projektdokumentation von BizTalk Checkmk Pulse 2.2.3 sowie Checkmk User Guide „Local checks“, abgerufen am 10. August 2026.", {"size": 7.8, "color": MUTED}),), + (("Dokumentbasis: Implementierung und Projektdokumentation von BizTalk Checkmk Pulse 2.2.4 sowie Checkmk User Guide „Local checks“, abgerufen am 10. August 2026.", {"size": 7.8, "color": MUTED}),), before=8, after=0, ) diff --git a/src/BizTalkCheckmkPulse.Setup/InstallerEngine.cs b/src/BizTalkCheckmkPulse.Setup/InstallerEngine.cs index 43431f9..27d1099 100644 --- a/src/BizTalkCheckmkPulse.Setup/InstallerEngine.cs +++ b/src/BizTalkCheckmkPulse.Setup/InstallerEngine.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; +using System.Text; using System.Xml; namespace BizTalkCheckmkPulse.Setup @@ -14,6 +15,7 @@ namespace BizTalkCheckmkPulse.Setup internal sealed class InstallerEngine { internal const string TaskName = "BizTalk Checkmk Pulse Provider"; + private static readonly TimeSpan PostInstallValidationTimeout = TimeSpan.FromMinutes(4); private readonly string packageDirectory; private readonly string installDirectory; private readonly string runtimeDirectory; @@ -54,7 +56,8 @@ namespace BizTalkCheckmkPulse.Setup var targetWrapper = Path.Combine(checkmkLocalDirectory, "biztalk_checkmk_pulse.cmd"); var previousWrapper = File.Exists(targetWrapper) ? File.ReadAllBytes(targetWrapper) : null; var hadExistingInstallation = Directory.Exists(installDirectory); - var taskRemoved = false; + var hadExistingExecutable = File.Exists(targetExe); + var mutationStarted = false; var backupCreated = false; var filesActivated = false; var scheduler = new TaskSchedulerService(); @@ -94,8 +97,8 @@ namespace BizTalkCheckmkPulse.Setup } // Erst nach vollstaendiger Staging-Pruefung wird der laufende Provider angehalten. + mutationStarted = true; scheduler.DeleteIfExists(TaskName); - taskRemoved = true; report("Vorhandener Scheduled Task angehalten und fuer das Update entfernt."); if (hadExistingInstallation) @@ -129,28 +132,53 @@ namespace BizTalkCheckmkPulse.Setup RunSelfTest(targetExe); report("Installierter Self-Test erfolgreich: neun Checkmk-Services."); - scheduler.RegisterAndStart( + var runtimeValidationStartedUtc = DateTime.UtcNow; + scheduler.RegisterValidationAndStart( TaskName, targetExe, installDirectory, account, isGmsa ? null : password, isGmsa); - taskRemoved = false; - report("Scheduled Task registriert und einmalig gestartet: " + TaskName); + report("Einmaliger Provider-Abnahmelauf mit erzwungenem Endpoint-Katalogabgleich gestartet."); + + var completedRun = scheduler.WaitForSuccessfulRun( + TaskName, + runtimeValidationStartedUtc, + PostInstallValidationTimeout, + report); + report("Provider-Abnahmelauf erfolgreich: LastTaskResult=" + completedRun.ExitCode + "."); + + var runtimeValidation = RunRuntimeValidation( + targetExe, + runtimeValidationStartedUtc, + account); + report("Post-Install-Runtime-Abnahme erfolgreich: " + runtimeValidation); + + scheduler.RegisterRecurring( + TaskName, + targetExe, + installDirectory, + account, + isGmsa ? null : password, + isGmsa); + report("Validierter minuetlicher Scheduled Task registriert: " + TaskName); TryDeleteDirectory(backupDirectory, report); } catch (Exception installException) { var rollbackFailures = new List(); - try + if (mutationStarted) { - scheduler.DeleteIfExists(TaskName); - } - catch (Exception ex) - { - rollbackFailures.Add("Task stoppen: " + ex.Message); + try + { + scheduler.DeleteIfExists(TaskName); + } + catch (Exception ex) + { + rollbackFailures.Add("Task stoppen: " + ex.Message); + } } if (filesActivated || backupCreated) @@ -167,27 +195,36 @@ namespace BizTalkCheckmkPulse.Setup } } - try - { - RestoreWrapper(targetWrapper, previousWrapper); - } - catch (Exception ex) - { - rollbackFailures.Add("Checkmk-Wrapper wiederherstellen: " + ex.Message); - } - - if (taskRemoved && hadExistingInstallation && File.Exists(targetExe)) + if (mutationStarted) { try { - scheduler.RegisterAndStart( + RestoreWrapper(targetWrapper, previousWrapper); + } + catch (Exception ex) + { + rollbackFailures.Add("Checkmk-Wrapper wiederherstellen: " + ex.Message); + } + } + + if (mutationStarted && hadExistingExecutable && File.Exists(targetExe)) + { + try + { + var rollbackRunStartedUtc = DateTime.UtcNow; + scheduler.RegisterRecurringAndStart( TaskName, targetExe, installDirectory, account, isGmsa ? null : password, isGmsa); - report("Scheduled Task fuer die vorherige Version wiederhergestellt."); + scheduler.WaitForSuccessfulRun( + TaskName, + rollbackRunStartedUtc, + PostInstallValidationTimeout, + report); + report("Scheduled Task und frischer Snapshot der vorherigen Version wiederhergestellt."); } catch (Exception ex) { @@ -445,6 +482,45 @@ namespace BizTalkCheckmkPulse.Setup } } + private static string RunRuntimeValidation( + string executable, + DateTime notBeforeUtc, + string expectedIdentity) + { + var identityBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(expectedIdentity)); + var arguments = "--validate-runtime --validation-not-before-utc " + + notBeforeUtc.ToUniversalTime().ToString("o") + + " --expected-identity-base64 " + identityBase64; + var start = new ProcessStartInfo(executable, arguments) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using (var process = Process.Start(start)) + { + if (process == null) throw new InvalidOperationException("Runtime-Abnahme konnte nicht gestartet werden."); + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd().Trim(); + if (!process.WaitForExit(30000)) + { + process.Kill(); + throw new TimeoutException("Runtime-Abnahme hat das Zeitlimit ueberschritten."); + } + + if (process.ExitCode != 0 + || !output.StartsWith("RUNTIME_VALIDATION_V1 ", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Runtime-Abnahme fehlgeschlagen. Exitcode=" + process.ExitCode + + ". " + (error.Length == 0 ? output : error)); + } + + return output; + } + } + private static string ExtractServiceName(string line) { var firstQuote = line.IndexOf('"'); diff --git a/src/BizTalkCheckmkPulse.Setup/Properties/AssemblyInfo.cs b/src/BizTalkCheckmkPulse.Setup/Properties/AssemblyInfo.cs index fe5b884..86d791e 100644 --- a/src/BizTalkCheckmkPulse.Setup/Properties/AssemblyInfo.cs +++ b/src/BizTalkCheckmkPulse.Setup/Properties/AssemblyInfo.cs @@ -6,5 +6,5 @@ using System.Runtime.CompilerServices; [assembly: AssemblyDescription("PowerShell-free installer and updater for BizTalk Checkmk Pulse")] [assembly: AssemblyCompany("BEW")] [assembly: AssemblyProduct("BizTalk Checkmk Pulse")] -[assembly: AssemblyVersion("2.2.3.0")] -[assembly: AssemblyFileVersion("2.2.3.0")] +[assembly: AssemblyVersion("2.2.4.0")] +[assembly: AssemblyFileVersion("2.2.4.0")] diff --git a/src/BizTalkCheckmkPulse.Setup/TaskSchedulerService.cs b/src/BizTalkCheckmkPulse.Setup/TaskSchedulerService.cs index 6747094..7d3a20a 100644 --- a/src/BizTalkCheckmkPulse.Setup/TaskSchedulerService.cs +++ b/src/BizTalkCheckmkPulse.Setup/TaskSchedulerService.cs @@ -13,18 +13,64 @@ namespace BizTalkCheckmkPulse.Setup private const int TaskInstancesIgnoreNew = 2; private const int TaskStateRunning = 4; - public void RegisterAndStart( + public void RegisterValidationAndStart( string taskName, string executable, string workingDirectory, string account, string password, bool isGmsa) + { + Register( + taskName, + executable, + workingDirectory, + account, + password, + isGmsa, + "--collect --force-endpoint-refresh", + false, + true); + } + + public void RegisterRecurring( + string taskName, + string executable, + string workingDirectory, + string account, + string password, + bool isGmsa) + { + Register(taskName, executable, workingDirectory, account, password, isGmsa, "--collect", true, false); + } + + public void RegisterRecurringAndStart( + string taskName, + string executable, + string workingDirectory, + string account, + string password, + bool isGmsa) + { + Register(taskName, executable, workingDirectory, account, password, isGmsa, "--collect", true, true); + } + + private static void Register( + string taskName, + string executable, + string workingDirectory, + string account, + string password, + bool isGmsa, + string arguments, + bool enableRecurringTrigger, + bool startImmediately) { dynamic service = null; dynamic root = null; dynamic definition = null; dynamic registeredTask = null; + dynamic runningTask = null; try { var schedulerType = Type.GetTypeFromProgID("Schedule.Service", true); @@ -52,12 +98,12 @@ namespace BizTalkCheckmkPulse.Setup dynamic trigger = definition.Triggers.Create(TaskTriggerTime); trigger.StartBoundary = DateTime.Now.AddMinutes(1).ToString("s"); - trigger.Enabled = true; + trigger.Enabled = enableRecurringTrigger; trigger.Repetition.Interval = "PT1M"; dynamic action = definition.Actions.Create(TaskActionExecute); action.Path = executable; - action.Arguments = "--collect"; + action.Arguments = arguments; action.WorkingDirectory = workingDirectory; registeredTask = root.RegisterTaskDefinition( @@ -68,10 +114,14 @@ namespace BizTalkCheckmkPulse.Setup isGmsa ? null : password, TaskLogonPassword, null); - registeredTask.Run(null); + if (startImmediately) + { + runningTask = registeredTask.Run(null); + } } finally { + Release(runningTask); Release(registeredTask); Release(definition); Release(root); @@ -79,6 +129,81 @@ namespace BizTalkCheckmkPulse.Setup } } + public TaskRunResult WaitForSuccessfulRun( + string taskName, + DateTime notBeforeUtc, + TimeSpan timeout, + Action report) + { + dynamic service = null; + dynamic root = null; + dynamic task = null; + var startedUtc = DateTime.UtcNow; + var nextReportUtc = startedUtc.AddSeconds(15); + var lastState = -1; + var lastResult = int.MinValue; + DateTime? lastRunUtc = null; + try + { + var schedulerType = Type.GetTypeFromProgID("Schedule.Service", true); + service = Activator.CreateInstance(schedulerType); + service.Connect(); + root = service.GetFolder("\\"); + task = root.GetTask(taskName); + + while (DateTime.UtcNow - startedUtc < timeout) + { + lastState = (int)task.State; + lastResult = (int)task.LastTaskResult; + var lastRun = (DateTime)task.LastRunTime; + lastRunUtc = lastRun.Year <= 1900 + ? (DateTime?)null + : DateTime.SpecifyKind(lastRun, DateTimeKind.Local).ToUniversalTime(); + + if (IsSuccessfulCompletion(lastState, lastResult, lastRunUtc, notBeforeUtc)) + { + return new TaskRunResult(lastRunUtc.Value, lastResult); + } + + if (report != null && DateTime.UtcNow >= nextReportUtc) + { + report( + "Warte auf erfolgreichen Providerlauf: state=" + lastState + + ", last_result=" + lastResult + + ", last_run=" + (lastRunUtc.HasValue ? lastRunUtc.Value.ToString("o") : "(noch keiner)") + "."); + nextReportUtc = DateTime.UtcNow.AddSeconds(15); + } + + Thread.Sleep(500); + } + + throw new TimeoutException( + "Providerlauf wurde innerhalb von " + Math.Ceiling(timeout.TotalSeconds) + + " Sekunden nicht erfolgreich abgeschlossen. state=" + lastState + + ", last_result=" + lastResult + + ", last_run=" + (lastRunUtc.HasValue ? lastRunUtc.Value.ToString("o") : "(noch keiner)") + "."); + } + finally + { + Release(task); + Release(root); + Release(service); + } + } + + internal static bool IsSuccessfulCompletion( + int taskState, + int lastTaskResult, + DateTime? lastRunUtc, + DateTime notBeforeUtc) + { + return taskState != TaskStateRunning + && lastTaskResult == 0 + && lastRunUtc.HasValue + // Task Scheduler kann LastRunTime mit geringerer Aufloesung als DateTime.UtcNow liefern. + && lastRunUtc.Value >= notBeforeUtc.ToUniversalTime().AddSeconds(-2); + } + public void DeleteIfExists(string taskName) { dynamic service = null; @@ -131,4 +256,16 @@ namespace BizTalkCheckmkPulse.Setup if (value != null && Marshal.IsComObject(value)) Marshal.FinalReleaseComObject(value); } } + + internal sealed class TaskRunResult + { + public TaskRunResult(DateTime lastRunUtc, int exitCode) + { + LastRunUtc = lastRunUtc; + ExitCode = exitCode; + } + + public DateTime LastRunUtc { get; private set; } + public int ExitCode { get; private set; } + } } diff --git a/src/BizTalkCheckmkPulse/App.config b/src/BizTalkCheckmkPulse/App.config index a5b4301..6cc1ba4 100644 --- a/src/BizTalkCheckmkPulse/App.config +++ b/src/BizTalkCheckmkPulse/App.config @@ -5,6 +5,8 @@ + + diff --git a/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj b/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj index 774ffa1..d8d6a28 100644 --- a/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj +++ b/src/BizTalkCheckmkPulse/BizTalkCheckmkPulse.csproj @@ -52,6 +52,7 @@ + diff --git a/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs b/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs index c401b0d..27a575f 100644 --- a/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs +++ b/src/BizTalkCheckmkPulse/EndpointConnectivityProbe.cs @@ -62,7 +62,8 @@ namespace BizTalkCheckmkPulse } var now = DateTime.UtcNow; - state.RefreshRequired = catalog == null + state.RefreshRequired = _options.ForceEndpointCatalogRefresh + || catalog == null || catalog.SynchronizedUtc > now.AddMinutes(5) || now - catalog.SynchronizedUtc >= TimeSpan.FromHours(_options.EndpointDiscoveryIntervalHours); if (state.RefreshRequired) diff --git a/src/BizTalkCheckmkPulse/MonitoringOptions.cs b/src/BizTalkCheckmkPulse/MonitoringOptions.cs index 5567bde..2ab12a8 100644 --- a/src/BizTalkCheckmkPulse/MonitoringOptions.cs +++ b/src/BizTalkCheckmkPulse/MonitoringOptions.cs @@ -4,6 +4,7 @@ using System.Configuration; using System.Globalization; using System.IO; using System.Linq; +using System.Text; namespace BizTalkCheckmkPulse { @@ -49,6 +50,10 @@ namespace BizTalkCheckmkPulse public int EndpointMaxCount { get; set; } public bool Collect { get; set; } public bool SelfTest { get; set; } + public bool ForceEndpointCatalogRefresh { get; set; } + public bool RuntimeValidation { get; set; } + public DateTime? RuntimeValidationNotBeforeUtc { get; set; } + public string ExpectedRuntimeIdentity { get; set; } /// /// Setzt konservative Standardwerte fuer BizTalk Server 2020. @@ -91,6 +96,7 @@ namespace BizTalkCheckmkPulse EndpointProbeTimeoutMilliseconds = 3000; EndpointProbeMaxConcurrency = 16; EndpointMaxCount = 100; + ExpectedRuntimeIdentity = string.Empty; } /// @@ -182,6 +188,33 @@ namespace BizTalkCheckmkPulse { options.Collect = true; } + else if (EqualsAny(arg, "--force-endpoint-refresh", "/force-endpoint-refresh")) + { + options.ForceEndpointCatalogRefresh = true; + } + else if (EqualsAny(arg, "--validate-runtime", "/validate-runtime")) + { + options.RuntimeValidation = true; + } + else if (EqualsAny(arg, "--validation-not-before-utc", "/validation-not-before-utc") && i + 1 < args.Length) + { + DateTime value; + if (!DateTime.TryParseExact( + args[++i], + "o", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out value)) + { + throw new ConfigurationErrorsException("Runtime validation timestamp must use the round-trip UTC format."); + } + options.RuntimeValidationNotBeforeUtc = value; + } + else if (EqualsAny(arg, "--expected-identity-base64", "/expected-identity-base64") && i + 1 < args.Length) + { + options.ExpectedRuntimeIdentity = new UTF8Encoding(false, true) + .GetString(Convert.FromBase64String(args[++i])); + } else if (EqualsAny(arg, "--consume", "/consume")) { options.Collect = false; @@ -214,6 +247,18 @@ namespace BizTalkCheckmkPulse /// private static void Validate(MonitoringOptions options) { + if (options.ForceEndpointCatalogRefresh && !options.Collect) + { + throw new ConfigurationErrorsException("ForceEndpointCatalogRefresh is valid only in provider mode."); + } + + if (options.RuntimeValidation + && (!options.RuntimeValidationNotBeforeUtc.HasValue + || string.IsNullOrWhiteSpace(options.ExpectedRuntimeIdentity))) + { + throw new ConfigurationErrorsException("Runtime validation requires a start timestamp and expected identity."); + } + if (string.IsNullOrWhiteSpace(options.SnapshotPath) || !Path.IsPathRooted(options.SnapshotPath)) { throw new ConfigurationErrorsException("SnapshotPath must be an absolute path."); diff --git a/src/BizTalkCheckmkPulse/Program.cs b/src/BizTalkCheckmkPulse/Program.cs index 2ead3ae..b1877d4 100644 --- a/src/BizTalkCheckmkPulse/Program.cs +++ b/src/BizTalkCheckmkPulse/Program.cs @@ -37,6 +37,11 @@ namespace BizTalkCheckmkPulse return 0; } + if (options.RuntimeValidation) + { + return RunRuntimeValidation(options, formatter); + } + return options.Collect ? RunProvider(options, formatter) : RunConsumer(options, formatter); @@ -51,7 +56,14 @@ namespace BizTalkCheckmkPulse Console.WriteLine(line); } - return options != null && options.Collect ? 1 : 0; + var strictFailureMode = options != null + ? options.Collect || options.RuntimeValidation + : (args ?? new string[0]).Any(x => + string.Equals(x, "--collect", StringComparison.OrdinalIgnoreCase) + || string.Equals(x, "/collect", StringComparison.OrdinalIgnoreCase) + || string.Equals(x, "--validate-runtime", StringComparison.OrdinalIgnoreCase) + || string.Equals(x, "/validate-runtime", StringComparison.OrdinalIgnoreCase)); + return strictFailureMode ? 1 : 0; } } @@ -99,13 +111,9 @@ namespace BizTalkCheckmkPulse + " send_ports_not_started=" + result.SendPorts.Count(x => x.Status != 3) + " endpoint_candidates_active=" - + result.EndpointConnectivity.ActiveCandidates + + result.EndpointCandidates.Count(x => x.Active) + " endpoints_active=" + result.EndpointConnectivity.Active - + " endpoints_unsupported=" - + result.EndpointConnectivity.UnsupportedActive - + " endpoints_excluded=" - + result.EndpointConnectivity.ExcludedActive + " endpoints_failed=" + result.EndpointConnectivity.Results.Count(x => !x.Available) + " endpoints_unresolved=" @@ -137,6 +145,29 @@ namespace BizTalkCheckmkPulse } } + private static int RunRuntimeValidation(MonitoringOptions options, CheckmkLocalFormatter formatter) + { + var validation = RuntimeValidator.Validate( + options, + formatter, + options.RuntimeValidationNotBeforeUtc.Value, + options.ExpectedRuntimeIdentity, + DateTime.UtcNow); + if (!validation.IsSuccess) + { + Console.Error.WriteLine("Runtime validation failed: " + validation.Error); + return 1; + } + + Console.WriteLine( + "RUNTIME_VALIDATION_V1 generatedUtc=" + validation.GeneratedUtc.ToString("o") + + " catalogUtc=" + (validation.CatalogSynchronizedUtc.HasValue + ? validation.CatalogSynchronizedUtc.Value.ToString("o") + : "disabled") + + " stableServices=" + validation.StableServiceCount); + return 0; + } + private static void EnsureProviderIdentity() { using (var identity = WindowsIdentity.GetCurrent()) diff --git a/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs b/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs index 21e8c35..13c46a6 100644 --- a/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs +++ b/src/BizTalkCheckmkPulse/Properties/AssemblyInfo.cs @@ -6,5 +6,5 @@ using System.Reflection; [assembly: AssemblyDescription("Privileged BizTalk data provider and validated Checkmk snapshot consumer")] [assembly: AssemblyCompany("BEW")] [assembly: AssemblyProduct("BizTalk Checkmk Pulse")] -[assembly: AssemblyVersion("2.2.3.0")] -[assembly: AssemblyFileVersion("2.2.3.0")] +[assembly: AssemblyVersion("2.2.4.0")] +[assembly: AssemblyFileVersion("2.2.4.0")] diff --git a/src/BizTalkCheckmkPulse/RuntimeValidator.cs b/src/BizTalkCheckmkPulse/RuntimeValidator.cs new file mode 100644 index 0000000..ab666ed --- /dev/null +++ b/src/BizTalkCheckmkPulse/RuntimeValidator.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BizTalkCheckmkPulse +{ + /// + /// Validiert die vom ersten Providerlauf erzeugten Runtime-Artefakte mit denselben + /// Parsern und Grenzen, die auch der laufende Consumer verwendet. + /// + internal static class RuntimeValidator + { + public static RuntimeValidationResult Validate( + MonitoringOptions options, + CheckmkLocalFormatter formatter, + DateTime notBeforeUtc, + string expectedIdentity, + DateTime utcNow) + { + if (options == null) throw new ArgumentNullException("options"); + if (formatter == null) throw new ArgumentNullException("formatter"); + + var snapshot = new SnapshotStore(options.SnapshotPath, options.SnapshotMaxBytes) + .Read(utcNow, TimeSpan.FromSeconds(options.SnapshotMaxAgeSeconds)); + if (!snapshot.IsSuccess) + { + return RuntimeValidationResult.Failed("Snapshot ist ungueltig: " + snapshot.Error); + } + + var boundary = notBeforeUtc.ToUniversalTime(); + if (snapshot.GeneratedUtc < boundary) + { + return RuntimeValidationResult.Failed( + "Snapshot stammt nicht aus dem gestarteten Providerlauf. generatedUtc=" + + snapshot.GeneratedUtc.ToString("o") + ", requiredUtc=" + boundary.ToString("o") + "."); + } + + if (!string.Equals(snapshot.Identity, expectedIdentity, StringComparison.OrdinalIgnoreCase)) + { + return RuntimeValidationResult.Failed( + "Snapshot wurde von einer unerwarteten Identitaet erzeugt. expected=" + + expectedIdentity + ", actual=" + (snapshot.Identity ?? "(leer)") + "."); + } + + string serviceError; + var expectedServices = formatter.FormatSelfTest().Select(ExtractServiceName).ToArray(); + if (!ContainsExactStableContract(snapshot.Lines, expectedServices, out serviceError)) + { + return RuntimeValidationResult.Failed(serviceError); + } + + DateTime? catalogUtc = null; + if (options.ProbeEndpointConnectivity) + { + EndpointCatalog catalog; + try + { + catalog = new EndpointCatalogStore( + options.EndpointCatalogPath, + options.EndpointCatalogMaxBytes, + options.EndpointCatalogMaxEntries) + .Read(options.EnvironmentName); + } + catch (Exception ex) + { + return RuntimeValidationResult.Failed( + "Endpoint-Katalog ist ungueltig: " + ex.GetType().Name + ": " + ex.Message); + } + + if (catalog.SynchronizedUtc < boundary) + { + return RuntimeValidationResult.Failed( + "Endpoint-Katalog wurde beim Installationslauf nicht aktualisiert. synchronizedUtc=" + + catalog.SynchronizedUtc.ToString("o") + ", requiredUtc=" + boundary.ToString("o") + "."); + } + + if (catalog.SynchronizedUtc > utcNow.ToUniversalTime().AddMinutes(5)) + { + return RuntimeValidationResult.Failed("Endpoint-Katalog-Zeitstempel liegt unplausibel in der Zukunft."); + } + + catalogUtc = catalog.SynchronizedUtc; + } + + return RuntimeValidationResult.Success( + snapshot.GeneratedUtc, + catalogUtc, + expectedServices.Length); + } + + internal static bool ContainsExactStableContract( + IEnumerable lines, + IEnumerable expectedServices, + out string error) + { + string[] actual; + try + { + actual = (lines ?? Enumerable.Empty()).Select(ExtractServiceName).ToArray(); + } + catch (Exception ex) + { + error = "Snapshot-Servicevertrag kann nicht gelesen werden: " + ex.Message; + return false; + } + + if (actual.Distinct(StringComparer.Ordinal).Count() != actual.Length) + { + error = "Snapshot enthaelt doppelte Checkmk-Servicenamen."; + return false; + } + + var actualSet = new HashSet(actual, StringComparer.Ordinal); + var expected = (expectedServices ?? Enumerable.Empty()).ToArray(); + var missing = expected + .Where(x => !actualSet.Contains(x)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + if (missing.Length != 0) + { + error = "Snapshot enthaelt nicht alle stabilen Checkmk-Services. Fehlend=[" + + string.Join(", ", missing) + "]."; + return false; + } + + var unknown = (lines ?? Enumerable.Empty()) + .Where(x => x != null && x.StartsWith("3 ", StringComparison.Ordinal)) + .Select(ExtractServiceName) + .Where(x => expected.Contains(x, StringComparer.Ordinal)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + if (unknown.Length != 0) + { + error = "Snapshot enthaelt unzuverlaessige UNKNOWN-Zustaende in stabilen Services. UNKNOWN=[" + + string.Join(", ", unknown) + "]."; + return false; + } + + error = string.Empty; + return true; + } + + private static string ExtractServiceName(string line) + { + var firstQuote = (line ?? string.Empty).IndexOf('"'); + var secondQuote = firstQuote < 0 ? -1 : line.IndexOf('"', firstQuote + 1); + if (firstQuote < 0 || secondQuote <= firstQuote + 1) + throw new InvalidOperationException("Ungueltige Local-Check-Zeile."); + return line.Substring(firstQuote + 1, secondQuote - firstQuote - 1); + } + } + + internal sealed class RuntimeValidationResult + { + private RuntimeValidationResult() { } + + public bool IsSuccess { get; private set; } + public string Error { get; private set; } + public DateTime GeneratedUtc { get; private set; } + public DateTime? CatalogSynchronizedUtc { get; private set; } + public int StableServiceCount { get; private set; } + + public static RuntimeValidationResult Success(DateTime generatedUtc, DateTime? catalogUtc, int serviceCount) + { + return new RuntimeValidationResult + { + IsSuccess = true, + GeneratedUtc = generatedUtc, + CatalogSynchronizedUtc = catalogUtc, + StableServiceCount = serviceCount + }; + } + + public static RuntimeValidationResult Failed(string error) + { + return new RuntimeValidationResult { Error = error }; + } + } +} diff --git a/src/BizTalkCheckmkPulse/SnapshotStore.cs b/src/BizTalkCheckmkPulse/SnapshotStore.cs index 8083768..24ce6a3 100644 --- a/src/BizTalkCheckmkPulse/SnapshotStore.cs +++ b/src/BizTalkCheckmkPulse/SnapshotStore.cs @@ -114,7 +114,7 @@ namespace BizTalkCheckmkPulse } var expectedMachine = DecodeHeader(headerLines[2], "machineBase64="); - DecodeHeader(headerLines[3], "identityBase64="); + var identity = DecodeHeader(headerLines[3], "identityBase64="); var expectedHash = ReadHeaderValue(headerLines[5], "payloadSha256="); if (!string.Equals(expectedMachine, Environment.MachineName, StringComparison.OrdinalIgnoreCase)) { @@ -159,7 +159,7 @@ namespace BizTalkCheckmkPulse + "s."); } - return SnapshotReadResult.Success(lines, generatedUtc); + return SnapshotReadResult.Success(lines, generatedUtc, identity); } catch (FileNotFoundException) { @@ -330,14 +330,16 @@ namespace BizTalkCheckmkPulse public string Error { get; private set; } public IReadOnlyList Lines { get; private set; } public DateTime GeneratedUtc { get; private set; } + public string Identity { get; private set; } - public static SnapshotReadResult Success(IReadOnlyList lines, DateTime generatedUtc) + public static SnapshotReadResult Success(IReadOnlyList lines, DateTime generatedUtc, string identity) { return new SnapshotReadResult { IsSuccess = true, Lines = lines, - GeneratedUtc = generatedUtc + GeneratedUtc = generatedUtc, + Identity = identity }; } diff --git a/tests/BizTalkCheckmkPulse.Tests/Program.cs b/tests/BizTalkCheckmkPulse.Tests/Program.cs index 08c7765..2f6c6ac 100644 --- a/tests/BizTalkCheckmkPulse.Tests/Program.cs +++ b/tests/BizTalkCheckmkPulse.Tests/Program.cs @@ -37,14 +37,22 @@ namespace BizTalkCheckmkPulse.Tests Run("EndpointOutputListsOnlyUnavailableTargets", EndpointOutputListsOnlyUnavailableTargets); Run("EndpointOutputExplainsOnlyRealResolutionGaps", EndpointOutputExplainsOnlyRealResolutionGaps); Run("EndpointProbeBudgetFitsMinuteInterval", EndpointProbeBudgetFitsMinuteInterval); -<<<<<<< HEAD -======= + Run("ForcedEndpointRefreshRewritesFreshCatalog", ForcedEndpointRefreshRewritesFreshCatalog); Run("EnvironmentLabelDoesNotRenameServicesByDefault", EnvironmentLabelDoesNotRenameServicesByDefault); Run("DefaultServiceContractIsExact", DefaultServiceContractIsExact); Run("InstallerRejectsUnconfirmedServiceRename", InstallerRejectsUnconfirmedServiceRename); Run("InstallerAllowsConfirmedServiceRename", InstallerAllowsConfirmedServiceRename); ->>>>>>> e694e4c (Guard Checkmk service renames during updates) Run("InstallerUpdatePreservesExistingSettings", InstallerUpdatePreservesExistingSettings); + Run("RuntimeValidationAcceptsFreshConsistentArtifacts", RuntimeValidationAcceptsFreshConsistentArtifacts); + Run("RuntimeValidationRejectsPreInstallSnapshot", RuntimeValidationRejectsPreInstallSnapshot); + Run("RuntimeValidationRejectsWrongIdentity", RuntimeValidationRejectsWrongIdentity); + Run("RuntimeValidationRejectsStaleCatalog", RuntimeValidationRejectsStaleCatalog); + Run("RuntimeValidationRejectsChangedServiceContract", RuntimeValidationRejectsChangedServiceContract); + Run("RuntimeValidationRejectsUnknownStableService", RuntimeValidationRejectsUnknownStableService); + Run("RuntimeValidationAllowsDisabledEndpointProbeWithoutCatalog", RuntimeValidationAllowsDisabledEndpointProbeWithoutCatalog); + Run("ForceEndpointRefreshRequiresProviderMode", ForceEndpointRefreshRequiresProviderMode); + Run("RuntimeValidationArgumentsAreParsedStrictly", RuntimeValidationArgumentsAreParsedStrictly); + Run("TaskCompletionRequiresFreshSuccessfulRun", TaskCompletionRequiresFreshSuccessfulRun); Run("SnapshotRoundTripPreservesLines", SnapshotRoundTripPreservesLines); Run("SnapshotRejectsTampering", SnapshotRejectsTampering); Run("SnapshotRejectsStaleData", SnapshotRejectsStaleData); @@ -337,6 +345,7 @@ namespace BizTalkCheckmkPulse.Tests Assert(result.IsSuccess, "snapshot should be readable: " + result.Error); AssertEqual(lines.Length, result.Lines.Count, "snapshot line count"); Assert(lines.SequenceEqual(result.Lines), "snapshot payload changed"); + AssertEqual("DOMAIN\\collector$", result.Identity, "snapshot identity"); var replacement = lines.Select(x => x.Replace("Self test OK", "Replacement OK")).ToArray(); store.Write(replacement, generated.AddSeconds(30), "DOMAIN\\collector$"); @@ -690,8 +699,51 @@ namespace BizTalkCheckmkPulse.Tests Assert(configuredMaximum <= 30000, "configured endpoint worst-case must leave headroom in the minute interval"); } -<<<<<<< HEAD -======= + private static void ForcedEndpointRefreshRewritesFreshCatalog() + { + var directory = Path.Combine(Path.GetTempPath(), "BizTalkCheckmkPulse.ForcedCatalogTests." + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var options = new MonitoringOptions + { + EnvironmentName = "ACC", + EndpointCatalogPath = Path.Combine(directory, "endpoints.xml"), + LogDirectory = Path.Combine(directory, "logs"), + ForceEndpointCatalogRefresh = true + }; + var oldTimestamp = DateTime.UtcNow.AddHours(-1); + new EndpointCatalogStore( + options.EndpointCatalogPath, + options.EndpointCatalogMaxBytes, + options.EndpointCatalogMaxEntries) + .Write(new EndpointCatalog + { + EnvironmentName = options.EnvironmentName, + SynchronizedUtc = oldTimestamp + }); + + var probeResult = new ProbeResult(); + probeResult.Platform.ReceiveLocationsDataAvailable = true; + probeResult.Platform.SendPortsDataAvailable = true; + new EndpointConnectivityProbe(options, new FileLogger(options.LogDirectory, 1, "test")) + .Query(probeResult); + + var refreshed = new EndpointCatalogStore( + options.EndpointCatalogPath, + options.EndpointCatalogMaxBytes, + options.EndpointCatalogMaxEntries) + .Read(options.EnvironmentName); + Assert(probeResult.EndpointConnectivity.RefreshRequired, "forced catalog refresh was not requested"); + Assert(probeResult.EndpointConnectivity.RefreshSucceeded, "forced catalog refresh failed"); + Assert(refreshed.SynchronizedUtc > oldTimestamp, "forced catalog refresh did not replace timestamp"); + } + finally + { + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + } + private static void EnvironmentLabelDoesNotRenameServicesByDefault() { var options = new MonitoringOptions { EnvironmentName = "ACC" }; @@ -765,7 +817,6 @@ namespace BizTalkCheckmkPulse.Tests return line.Substring(start, end - start); } ->>>>>>> e694e4c (Guard Checkmk service renames during updates) private static void InstallerUpdatePreservesExistingSettings() { var directory = Path.Combine(Path.GetTempPath(), "BizTalkCheckmkPulse.InstallerTests." + Guid.NewGuid().ToString("N")); @@ -779,6 +830,7 @@ namespace BizTalkCheckmkPulse.Tests source, "" + "" + + "" + "" + "" + "" @@ -802,6 +854,7 @@ namespace BizTalkCheckmkPulse.Tests string.Empty); var merged = File.ReadAllText(staged); AssertEqual("ACC", effectiveEnvironment, "preserved installer environment"); + Assert(merged.Contains("key=\"IncludeEnvironmentInServiceName\" value=\"false\""), "stable service-name default must be added on update"); Assert(merged.Contains("key=\"EndpointProbeMaxConcurrency\" value=\"7\""), "existing operational value must be preserved"); Assert(merged.Contains("key=\"EndpointMaxCount\" value=\"100\""), "superseded old default must migrate to new bounded default"); Assert(merged.Contains("key=\"EndpointCatalogMaxEntries\" value=\"1000\""), "new catalog entry limit must be added"); @@ -821,6 +874,214 @@ namespace BizTalkCheckmkPulse.Tests } } + private static void RuntimeValidationAcceptsFreshConsistentArtifacts() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(10), + boundary.AddSeconds(5), + "BEW\\t231bizmon", + "BEW\\t231bizmon", + true, + null); + Assert(result.IsSuccess, "fresh consistent runtime artifacts must pass: " + result.Error); + AssertEqual(9, result.StableServiceCount, "validated stable service count"); + } + + private static void RuntimeValidationRejectsPreInstallSnapshot() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(-1), + boundary.AddSeconds(5), + "BEW\\t231bizmon", + "BEW\\t231bizmon", + true, + null); + Assert(!result.IsSuccess, "snapshot from before installation must fail"); + Assert(result.Error.Contains("nicht aus dem gestarteten Providerlauf"), "old snapshot reason missing"); + } + + private static void RuntimeValidationRejectsWrongIdentity() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(10), + boundary.AddSeconds(5), + "BEW\\other-account", + "BEW\\t231bizmon", + true, + null); + Assert(!result.IsSuccess, "unexpected provider identity must fail"); + Assert(result.Error.Contains("unerwarteten Identitaet"), "identity mismatch reason missing"); + } + + private static void RuntimeValidationRejectsStaleCatalog() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(10), + boundary.AddSeconds(-1), + "BEW\\t231bizmon", + "BEW\\t231bizmon", + true, + null); + Assert(!result.IsSuccess, "catalog from before installation must fail"); + Assert(result.Error.Contains("nicht aktualisiert"), "old catalog reason missing"); + } + + private static void RuntimeValidationRejectsChangedServiceContract() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var lines = new CheckmkLocalFormatter(new MonitoringOptions()).FormatSelfTest().ToArray(); + lines[0] = lines[0].Replace("BizTalk Platform", "BizTalk Renamed Platform"); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(10), + boundary.AddSeconds(5), + "BEW\\t231bizmon", + "BEW\\t231bizmon", + true, + lines); + Assert(!result.IsSuccess, "changed runtime service contract must fail"); + Assert(result.Error.Contains("Fehlend=[BizTalk Platform]"), "missing stable service reason missing"); + } + + private static void RuntimeValidationAllowsDisabledEndpointProbeWithoutCatalog() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(10), + null, + "BEW\\t231bizmon", + "BEW\\t231bizmon", + false, + null); + Assert(result.IsSuccess, "disabled endpoint probe must not require catalog: " + result.Error); + Assert(!result.CatalogSynchronizedUtc.HasValue, "disabled endpoint probe must report no catalog timestamp"); + } + + private static void RuntimeValidationRejectsUnknownStableService() + { + var boundary = DateTime.UtcNow.AddMinutes(-1); + var lines = new CheckmkLocalFormatter(new MonitoringOptions()).FormatSelfTest().ToArray(); + lines[0] = "3" + lines[0].Substring(1); + var result = ValidateRuntimeArtifacts( + boundary, + boundary.AddSeconds(10), + boundary.AddSeconds(5), + "BEW\\t231bizmon", + "BEW\\t231bizmon", + true, + lines); + Assert(!result.IsSuccess, "UNKNOWN stable service must fail runtime acceptance"); + Assert(result.Error.Contains("UNKNOWN=[BizTalk Platform]"), "UNKNOWN stable service reason missing"); + } + + private static void ForceEndpointRefreshRequiresProviderMode() + { + var options = MonitoringOptions.Load(new[] { "--collect", "--force-endpoint-refresh" }); + Assert(options.Collect, "forced refresh must retain provider mode"); + Assert(options.ForceEndpointCatalogRefresh, "forced refresh option missing"); + + try + { + MonitoringOptions.Load(new[] { "--force-endpoint-refresh" }); + throw new InvalidOperationException("forced refresh without provider mode was accepted"); + } + catch (Exception ex) + { + Assert(ex.Message.Contains("valid only in provider mode"), "forced refresh rejection reason missing"); + } + } + + private static void RuntimeValidationArgumentsAreParsedStrictly() + { + var timestamp = DateTime.UtcNow; + var identity = "BEW\\t231bizmon"; + var options = MonitoringOptions.Load(new[] + { + "--validate-runtime", + "--validation-not-before-utc", + timestamp.ToString("o"), + "--expected-identity-base64", + Convert.ToBase64String(Encoding.UTF8.GetBytes(identity)) + }); + Assert(options.RuntimeValidation, "runtime validation mode missing"); + AssertEqual(timestamp.ToString("o"), options.RuntimeValidationNotBeforeUtc.Value.ToString("o"), "runtime validation timestamp"); + AssertEqual(identity, options.ExpectedRuntimeIdentity, "runtime validation identity"); + } + + private static void TaskCompletionRequiresFreshSuccessfulRun() + { + var boundary = DateTime.UtcNow; + Assert(BizTalkCheckmkPulse.Setup.TaskSchedulerService.IsSuccessfulCompletion( + 3, 0, boundary.AddSeconds(1), boundary), "fresh successful ready task must pass"); + Assert(!BizTalkCheckmkPulse.Setup.TaskSchedulerService.IsSuccessfulCompletion( + 4, 0, boundary.AddSeconds(1), boundary), "running task must not pass"); + Assert(!BizTalkCheckmkPulse.Setup.TaskSchedulerService.IsSuccessfulCompletion( + 3, 1, boundary.AddSeconds(1), boundary), "failed task result must not pass"); + Assert(!BizTalkCheckmkPulse.Setup.TaskSchedulerService.IsSuccessfulCompletion( + 3, 0, boundary.AddSeconds(-3), boundary), "old successful task must not pass"); + } + + private static RuntimeValidationResult ValidateRuntimeArtifacts( + DateTime boundaryUtc, + DateTime snapshotUtc, + DateTime? catalogUtc, + string snapshotIdentity, + string expectedIdentity, + bool probeEndpoints, + string[] snapshotLines) + { + var directory = Path.Combine(Path.GetTempPath(), "BizTalkCheckmkPulse.RuntimeValidationTests." + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var options = new MonitoringOptions + { + EnvironmentName = "ACC", + SnapshotPath = Path.Combine(directory, "snapshot.txt"), + EndpointCatalogPath = Path.Combine(directory, "endpoints.xml"), + ProbeEndpointConnectivity = probeEndpoints + }; + var formatter = new CheckmkLocalFormatter(options); + new SnapshotStore(options.SnapshotPath, options.SnapshotMaxBytes).Write( + snapshotLines ?? formatter.FormatSelfTest().ToArray(), + snapshotUtc, + snapshotIdentity); + + if (catalogUtc.HasValue) + { + new EndpointCatalogStore( + options.EndpointCatalogPath, + options.EndpointCatalogMaxBytes, + options.EndpointCatalogMaxEntries) + .Write(new EndpointCatalog + { + EnvironmentName = options.EnvironmentName, + SynchronizedUtc = catalogUtc.Value + }); + } + + return RuntimeValidator.Validate( + options, + formatter, + boundaryUtc, + expectedIdentity, + boundaryUtc.AddMinutes(1)); + } + finally + { + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + } + private static void SnapshotRejectsTampering() { WithTemporarySnapshot((path, store) =>