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 d61488e..cfb15a8 100644 Binary files a/docs/BizTalk_Checkmk_Pulse_Architekturueberblick.docx and b/docs/BizTalk_Checkmk_Pulse_Architekturueberblick.docx differ 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) =>