Add resilient emergency restore for partial BizTalk operations

This commit is contained in:
2026-08-19 17:45:20 +02:00
parent 08626197be
commit 9ce7e8d45a
19 changed files with 1221 additions and 120 deletions
+13 -1
View File
@@ -2,7 +2,7 @@
## Projekt und Sicherheitsziel
Das Repository enthält ein .NET-Framework-4.6.1-WinForms-Tool für kontrollierte BizTalk-2020-Wartungsoperationen. Änderungen müssen Dry-run, explizite Freigabe realer Aktionen, sichere Reihenfolgen und wiederherstellbare Installergrenzen erhalten. Die aktuelle Produktversion ist 2.1.3.
Das Repository enthält ein .NET-Framework-4.6.1-WinForms-Tool für kontrollierte BizTalk-2020-Wartungsoperationen. Änderungen müssen Dry-run, explizite Freigabe realer Aktionen, sichere Reihenfolgen und wiederherstellbare Installergrenzen erhalten. Die aktuelle Produktversion ist 2.2.0.
## Installerinvarianten
@@ -18,6 +18,18 @@ Das Repository enthält ein .NET-Framework-4.6.1-WinForms-Tool für kontrolliert
Die zentrale Implementierung liegt in `src/BizTalkPlatformManagementTool.Setup/InstallerEngine.cs`. Move-Retries sind auf acht Versuche und 19,75 Sekunden Wartezeit begrenzt. Die injizierbaren `directoryMover`- und `retryDelay`-Delegates existieren ausschließlich, damit Sperrpfade ohne echte Wartezeit portabel getestet werden können.
## Runtime- und Recovery-Invarianten
- Ein isolierter WMI-, Adapter- oder Servicefehler darf keine späteren unabhängigen Planschritte verhindern.
- Teilfehler werden vollständig pro Schritt persistiert; der Gesamtlauf bleibt sichtbar fehlgeschlagen und darf nicht als Erfolg ausgegeben werden.
- Vor jeder Mutation wird der aktuelle Zustand geprüft. Bereits erreichte Sollzustände werden ohne Methodenaufruf als `AlreadySatisfied` erfasst.
- Der Nachher-Snapshot wird unabhängig von Einzelfehlern versucht; sein Fehler gehört in denselben Ergebnisreport.
- Shutdown-Kategorien gelten global über alle Anwendungen: Receive Locations, Orchestrations, Send Ports, Host Instances.
- Restore-Kategorien gelten global über alle Anwendungen: Host Instances, Send Ports, Orchestrations, Receive Locations.
- Emergency Restore überschreibt niemals die Eingabe-`before.json`, erzeugt eine timestamp-basierte Kopie und stellt `ENTSSO` vor Host Instances sicher.
- Emergency Restore muss mit genau einer validen `before.json` funktionieren; Dateien eines vorherigen fehlgeschlagenen Laufs dürfen keine Voraussetzung sein.
- Die zentrale best-effort Orchestrierung liegt in `OperationPlanExecutor`; die WMI-/Service-Zustandsprüfung bleibt im produktiven Runtime-Adapter.
## Versionierung
Bei einem Release sind mindestens diese Stellen konsistent zu ändern:
+19
View File
@@ -1,6 +1,25 @@
# Changelog
## [2.2.0] - 2026-08-19
### Added
- State-aware **Emergency Restore** from an existing `before.json` without overwriting the recovery source.
- Enterprise Single Sign-On (`ENTSSO`) startup as the first emergency-recovery prerequisite.
- Durable per-step `shutdown-result.json`, `restore-result.json` and timestamped emergency result reports.
- Regression coverage for the ACC `Microsoft.BizTalk.Scheduler` failure, best-effort continuation, already-satisfied states, emergency ordering and global cross-application ordering.
### Changed
- Shutdown and restore continue with remaining independent steps after an isolated WMI or adapter failure and finish with an explicit operator-review result.
- Runtime operations skip artifacts that already match the requested target state, making recovery from partial shutdowns idempotent.
- Post-operation snapshots are attempted and diagnosed independently after partial execution failures.
- Shutdown order is now global across all applications: receive locations, orchestrations, send ports, then host instances.
- Restore order is now global across all applications: host instances, send ports, orchestrations, then receive locations.
### Fixed
- An ACC shutdown no longer aborts every later step when `RV_PMP_Trigger_Schedule` fails validation because `Microsoft.BizTalk.Scheduler` or a dependency cannot be loaded.
- A partial shutdown no longer prevents recovery evidence from being written after the first failed step.
- Host-instance steps no longer treat the short name and FQDN of the same target server as different machines.
## [2.1.3] - 2026-08-11
### Added
- Bounded retry/backoff diagnostics for transient `Directory.Move` locks caused by the Windows loader, antivirus or endpoint protection.
+31 -1
View File
@@ -1,6 +1,6 @@
# BizTalk Platform Management Tool Dokumentation
**Stand:** 2026-08-11
**Stand:** 2026-08-19
**Implementierung:** C# WinForms, .NET Framework 4.6.1
**Archivierte PowerShell-Version:** `archive/powershell/BizTalkPlatformManagementTool.ps1`
@@ -56,6 +56,8 @@ Selbsterklärende Zuweisungen und reine UI-Konstruktion werden nicht zeilenweise
9. Nach der Wartung mit **Restore** aus `before.json` wiederherstellen.
10. Mit **Snapshot After** und **Compare** die Umgebung validieren.
Wenn ein echter Shutdown oder Restore nur teilweise ausgeführt wurde und lediglich die ursprüngliche `before.json` verfügbar ist, wird **Emergency Restore** verwendet. Der Modus kopiert die Quelle unter einem timestamp-basierten Namen, überschreibt `before.json` nicht, stellt zuerst `ENTSSO` sicher und gleicht danach den gespeicherten Sollzustand zustandsbewusst ab. Der erste Lauf muss als Dry-run erfolgen.
Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des letzten Snapshots und zeigt `Started`, `Stopped`, `Partial` oder `Unknown`. **Clear** leert die sichtbaren Ergebnis- und Log-Grids, loescht aber keine Dateien. **Close** beendet die Anwendung.
## Sicherheitsdesign
@@ -68,6 +70,10 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des
- Logdateien werden rollierend für den aktuellen Tag plus vier vorherige Tage vorgehalten.
- Der Kopfbereich zeigt den zuletzt erkannten Umgebungsstatus aus den Host Instances.
- Operationspläne werden vor Laufzeitänderungen gespeichert.
- Jeder ausführbare Schritt prüft vor der Mutation, ob sein Sollzustand bereits erreicht ist.
- Ein Einzelfehler stoppt nicht mehr die restlichen unabhängigen Schritte; alle Ergebnisse werden einzeln persistiert.
- Ein Teilfehler bleibt im GUI und im Ergebnisreport ausdrücklich fehlgeschlagen und wird nicht als Gesamterfolg ausgegeben.
- Der Nachher-Snapshot wird auch nach Einzelfehlern separat versucht; ein Snapshotfehler wird im Ergebnisreport gesichert.
- WMI-Methodenrückgaben werden geprüft.
- Wartezeiten nutzen konfigurierbare Timeout- und Polling-Werte.
- Host Instances auf anderen Servern werden übersprungen und als Warnung protokolliert.
@@ -90,6 +96,22 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des
3. Orchestrations wiederherstellen, soweit dies sicher möglich ist.
4. Receive Locations zuletzt wiederherstellen.
Die Kategorienreihenfolge gilt global über alle BizTalk-Anwendungen. Dadurch wird keine Receive Location einer alphabetisch früheren Anwendung aktiviert, bevor Send Ports und Orchestrations späterer Anwendungen behandelt wurden.
## Emergency Restore
Der Emergency Restore benötigt nur eine valide `before.json` und denselben Zielserver. Er erzeugt keine neue Vorheraufnahme und überschreibt die Recovery-Quelle nicht.
1. Timestamp-basierte Kopie der Eingabedatei sichern.
2. Enterprise Single Sign-On (`ENTSSO`) auf dem Zielserver auf `Running` bringen.
3. Zuvor gestartete Host Instances zustandsbewusst starten.
4. Send Ports, Orchestrations und Receive Locations in global sicherer Reihenfolge auf den Snapshotzustand abgleichen.
5. Bereits korrekte Zustände als `AlreadySatisfied` überspringen.
6. Isolierte WMI-/Adapterfehler als `Failed` erfassen und mit dem nächsten unabhängigen Schritt fortfahren.
7. Nachher-Snapshot unabhängig versuchen und vollständigen Ergebnisreport schreiben.
Der Best-effort-Ansatz bedeutet nicht, dass Fehler ignoriert werden: Jeder Teilfehler erzeugt einen roten/fehlgeschlagenen Abschluss und erfordert die Prüfung des Ergebnisreports. Er verhindert lediglich, dass beispielsweise eine einzelne nicht validierbare Receive Location alle späteren Host-, Port- oder Receive-Location-Schritte blockiert.
## Restore-Grenzen
- Send Ports werden auf `Started`, `Stopped` oder `Bound` zurückgesetzt.
@@ -102,6 +124,8 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des
- Snapshots: `before.json`, `after.json`
- Operationspläne: `shutdown-plan.json`, `restore-plan.json`
- Nachher-Snapshots: `shutdown-after.json`, `restore-after.json`
- Ergebnisreports: `shutdown-result.json`, `restore-result.json`
- Emergency-Dateien: `emergency-source-before-*`, `emergency-restore-plan-*`, `emergency-restore-result-*`, `emergency-restore-after-*`
- Diff: `diff.json`, `diff.csv`, `diff.html`
- Snapshot-Reports: `*.csv`, `*.hosts.csv`, `*.html`
- Laufzeitlogs: `%ProgramData%\BizTalkPlatformManagementTool\Logs\BizTalkPlatformManagementTool-yyyy-MM-dd.log`
@@ -164,6 +188,12 @@ Das äußere ZIP erhält zusätzlich eine SHA-256-Datei und eine Certutil-kompat
- Eindeutige Fehlerphase ohne irreführende Rollbackmeldung bei einem Staging-Fehler.
- Vollständiger Diagnosekontext mit Exception-Kette und Temp-Fallback für das Setup-Log.
- Unabhängigkeit der Installation von Fehlern der UI-Fortschrittsanzeige.
- Fortsetzung nach einer simulierten `Microsoft.BizTalk.Scheduler`-Exception.
- Idempotentes Überspringen bereits erreichter Sollzustände.
- `ENTSSO` als erster Emergency-Restore-Schritt.
- Globale Kategorie-Reihenfolge über mehrere BizTalk-Anwendungen.
- Kurzname/FQDN-Gleichheit für Host-Instance-Schritte.
- Persistenz vollständiger Teilfehlerreports.
Der portable Build, die Tests, der Anwendungsselftest und die Paketkonsistenz sind lokal unter Mono prüfbar. Die endgültige Freigabe erfordert zusätzlich einen Windows-Test von UAC, Registry, Verknüpfungen und Setup-Rollback sowie einen repräsentativen BizTalk-2020-Test von Diagnose, Dry-run, Shutdown und Restore.
+15
View File
@@ -87,6 +87,21 @@ Für eine Supportanalyse bitte sichern:
Ein erfolgreicher Installer-Self-Test bestätigt Paket, Programmstart und lokale Kernfunktionen. Der fachliche BizTalk-Zustand ist bewusst kein Rollbackkriterium: WMI-Erreichbarkeit, Berechtigungen und Umgebungszustand danach separat über **Diagnose** und einen Dry-run prüfen. So wird beispielsweise ein erreichbarer Installer nicht wegen eines fachlichen `Unknown`-Zustands zurückgerollt.
## Emergency Restore nach einem Teilabbruch
Version 2.2.0 kann einen Wiederanlauf allein aus einer erhaltenen `before.json` vorbereiten und ausführen. Zusätzliche Plan- oder Nachher-Dateien des fehlgeschlagenen Laufs sind nicht erforderlich.
1. Die erhaltene `before.json` außerhalb des Arbeitsverzeichnisses zusätzlich sichern.
2. Anwendung als Administrator starten und denselben Zielserver wählen, der im Snapshot gespeichert ist.
3. Im Feld **State** die erhaltene Datei auswählen oder ihren vollständigen Pfad eintragen.
4. **Dry run** aktiviert lassen und **Emergency Restore** wählen.
5. Den timestamp-basierten `emergency-restore-plan-*` prüfen. Die bestehende `before.json` wird dabei nicht überschrieben.
6. Dry run deaktivieren, **Emergency Restore** erneut wählen und den expliziten Dialog bestätigen.
Der Emergency Restore stellt zuerst sicher, dass der Windows-Dienst `ENTSSO` läuft. Danach folgen Host Instances, Send Ports, Orchestrations und zuletzt Receive Locations. Vor jeder Mutation wird der aktuelle Zustand geprüft; bereits korrekte Zustände werden als `AlreadySatisfied` protokolliert. Ein isolierter Fehler wird als `Failed` festgehalten, während alle späteren unabhängigen Schritte weiter versucht werden.
Jeder Lauf schreibt eine unveränderte Snapshot-Kopie sowie timestamp-basierte Plan-, Ergebnis- und Nachher-Dateien. Ein Ergebnis mit mindestens einem fehlgeschlagenen Schritt bleibt im GUI ausdrücklich `Failed` und verlangt Operator-Review, auch wenn alle anderen Schritte erfolgreich waren.
## Deinstallation
Die Deinstallation ist über **Apps & Features / Programme und Features** oder über den Setup-Button **Deinstallieren** möglich. Vorher muss die Anwendung geschlossen sein. Das Programmverzeichnis wird zuerst atomar aus dem aktiven Pfad in ein eindeutiges Quarantäneverzeichnis verschoben; erst danach werden Verknüpfungen und Uninstall-Eintrag entfernt und die Dateien bestmöglich gelöscht. Scheitert die Windows-Integration, werden Programmverzeichnis, Registrywerte, Verknüpfungen und vorheriger Uninstaller wiederhergestellt. Installerlogs und der supportfähige Setup-Ordner bleiben bewusst zur Fehleranalyse unter `%ProgramData%\BizTalkPlatformManagementTool` erhalten.
+11
View File
@@ -17,11 +17,15 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d
- Diff between `before.json` and `after.json`
- Controlled shutdown from the current runtime state
- Controlled restore from `before.json`
- State-aware emergency restore from a preserved `before.json`, including Enterprise SSO startup
- Host instance handling for the selected BizTalk server
- Dry-run mode enabled by default
- WMI access through `root\MicrosoftBizTalkServer`
- Startup check for administrator rights
- Detailed operation logging in the GUI and daily rolling log files under ProgramData
- Best-effort plan execution: one isolated WMI failure is recorded while remaining independent steps continue
- Idempotent execution that skips artifacts already in the requested target state
- Durable per-step result reports even when a shutdown or restore completes only partially
- Environment status indicator based on host instance state
- Clear and Close actions in the main toolbar
- No compile-time dependency on BizTalk ExplorerOM assemblies
@@ -41,6 +45,8 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d
7. After maintenance, click **Restore** using the saved `before.json`.
8. Click **Snapshot After** and **Compare**.
If a shutdown was interrupted and only the original `before.json` remains, select that file, keep **Dry run** enabled and click **Emergency Restore**. The recovery plan never overwrites the source snapshot, ensures the `ENTSSO` service is running first, skips already-correct runtime states and continues after isolated step failures. Disable Dry run only after reviewing the timestamped emergency plan.
The environment indicator shows `Started`, `Stopped`, `Partial` or `Unknown` from the most recent snapshot. `Clear` removes the visible status and operation log grids; it does not delete files.
The application requests administrator rights through its UAC manifest and checks them again during startup. Only one GUI instance can run per Windows session.
@@ -68,6 +74,8 @@ Orchestrations that were `Bound` are deliberately left unchanged during restore
- `before.json`, `after.json`
- `shutdown-plan.json`, `restore-plan.json`
- `shutdown-after.json`, `restore-after.json`
- `shutdown-result.json`, `restore-result.json`
- Timestamped `emergency-source-before-*`, `emergency-restore-plan-*`, `emergency-restore-result-*` and `emergency-restore-after-*` files
- `diff.json`, `diff.csv`, `diff.html`
- Snapshot sidecars: `*.csv`, `*.hosts.csv`, `*.html`
- Runtime logs under `%ProgramData%\BizTalkPlatformManagementTool\Logs`
@@ -78,6 +86,8 @@ Log files are retained for the current day plus the previous four days. Older `B
The Operation Log shows the WMI class, key property, key value and method for real shutdown and restore steps. WMI objects are resolved with a broad `SELECT * FROM <class>` query and a client-side key filter so names containing special characters do not break the WMI query parser.
Execution is deliberately best-effort. A failure such as an adapter-specific validation exception is written as `Failed` in the result report, but later independent plan steps are still attempted. The GUI ends in a failed/operator-review state when any step failed; it never reports a partial execution as an unconditional success. The post-operation snapshot is attempted independently and its own failure is preserved in the same report.
Snapshot and plan JSON files are written as UTF-8 without BOM. Loading is tolerant of existing files that contain a UTF-8 BOM or a visible BOM marker from previous encoding conversions.
JSON snapshots and plans are written through a same-directory temporary file and atomic replacement. Snapshot comparison keys artifacts by application plus name, preventing collisions between equal artifact names in different applications. CSV fields that could be interpreted as spreadsheet formulas are neutralized.
@@ -104,5 +114,6 @@ Targeted German inline comments explain non-obvious operational decisions such a
- [Dokumentation](Dokumentation.md)
- [Installer stability analysis](docs/Installer-Stabilitaetsanalyse-2026-08-11.md)
- [ACC activation incident analysis](docs/ACC-Installer-Aktivierungsfehler-2026-08-11.md)
- [ACC runtime shutdown incident and recovery fix](docs/ACC-Runtime-Shutdown-Exception-2026-08-19.md)
- [AI maintainer handoff](AI-README.md)
- [References](REFERENCES.md)
@@ -0,0 +1,92 @@
# ACC-Runtime-Abbruch beim Shutdown vom 19.08.2026
## Befund
Der reale Shutdown auf `AV23AGPWBI01` mit Version 2.1.3 wurde bei der Receive Location `RV_PMP_Trigger_Schedule` abgebrochen. Der WMI-Aufruf `MSBTS_ReceiveLocation.Disable` meldete sinngemäß:
```text
Could not validate TransportTypeData, Address or Public Address properties for Receive Location.
Could not load file or assembly Microsoft.BizTalk.Scheduler, Version=3.13.0.0 or one of its dependencies.
```
Die unmittelbar vorher erzeugte `before.json` blieb vorhanden. Weil der Executor die erste Schrittausnahme erneut warf, wurden alle späteren Planschritte und der Nachher-Snapshot nicht mehr ausgeführt. Anschließend wurden die Host Instances und Enterprise Single Sign-On manuell gestoppt. Damit entstand ein gemischter Teilzustand.
Die Exception belegt einen Validierungs-/Assemblyfehler des Scheduler-Adapters oder einer Abhängigkeit im BizTalk-WMI-Pfad. Sie belegt nicht, dass `before.json` beschädigt wurde. Der Snapshot wurde bereits vor jeder Laufzeitmutation atomar gespeichert und bleibt die maßgebliche Wiederherstellungsquelle.
## Sicherheitsproblem in Version 2.1.3
- Ein isolierter Adapterfehler stoppte alle späteren unabhängigen Schritte.
- Der erste Fehler verhinderte `shutdown-after.json` und einen strukturierten Ergebnisreport.
- Restore-Pläne beschrieben zwar den Sollzustand aus `before.json`, prüften aber vor `Start`, `Stop`, `Enable` oder `Disable` nicht allgemein, ob dieser Zustand bereits erreicht war.
- Ein erneuter Restore nach einem Teilabbruch konnte deshalb an einer redundanten Operation erneut stoppen.
- Die dokumentierte Kategorie-Reihenfolge war nur innerhalb einer einzelnen Anwendung umgesetzt; über mehrere Anwendungen hinweg konnten Kategorien ineinandergreifen.
- Enterprise SSO lag außerhalb des Snapshots und wurde bei einer manuellen Abschaltung nicht automatisch als Wiederanlaufvoraussetzung behandelt.
## Fix in Version 2.2.0
### Best-effort mit klarer Fehlergrenze
Jeder Planschritt hat nun einen eigenen Fehlerfang. Ein Fehler erhält das Ergebnis `Failed` mit kompletter Exception-Kette; anschließend wird der nächste unabhängige Schritt versucht. Der Gesamtlauf bleibt bei mindestens einem Fehler ausdrücklich fehlgeschlagen und verlangt Operator-Review.
### Zustandsbewusste und idempotente Ausführung
Vor jeder Mutation wird der aktuelle WMI- beziehungsweise Servicezustand gelesen. Entspricht er bereits dem Ziel, wird keine Methode aufgerufen und das Ergebnis lautet `AlreadySatisfied`. Dadurch kann ein Restore sicher an einem gemischten Zustand ansetzen. Im konkreten ACC-Fall wird die weiterhin aktiv gebliebene Scheduler-Receive-Location beim Restore nicht erneut über WMI aktiviert, wenn `before.json` ebenfalls `Enabled=true` enthält.
### Dauerhafte Evidenz
Reguläre Operationen schreiben `shutdown-result.json` beziehungsweise `restore-result.json`. Jeder Report enthält Ergebnis, Zeit und Exception pro Schritt. Der Nachher-Snapshot wird auch nach Einzelfehlern separat versucht; ein Fehler dieser Aufnahme wird ebenfalls im Report gesichert.
### Emergency Restore nur aus `before.json`
Der neue Button **Emergency Restore** benötigt außer der erhaltenen `before.json` keine Datei des fehlgeschlagenen Laufs. Er:
1. validiert Snapshot und Zielserver,
2. schreibt eine timestamp-basierte, unveränderte Recovery-Kopie,
3. erzeugt und speichert einen neuen Emergency-Plan,
4. stellt zuerst den Windows-Dienst `ENTSSO` auf `Running`,
5. gleicht Host Instances, Send Ports, Orchestrations und Receive Locations zustandsbewusst ab,
6. arbeitet nach isolierten Fehlern weiter,
7. versucht einen Nachher-Snapshot und schreibt immer den vollständigen Ergebnisreport, soweit das Ausgabeverzeichnis verfügbar ist.
### Globale Reihenfolge
Shutdown:
1. alle Receive Locations,
2. alle Orchestrations,
3. alle Send Ports,
4. alle Host Instances.
Restore und Emergency Restore:
1. bei Emergency Restore zuerst `ENTSSO`,
2. alle Host Instances,
3. alle Send Ports,
4. alle Orchestrations,
5. alle Receive Locations.
Diese Reihenfolge gilt nun über alle Anwendungen hinweg.
## Verifikation
Die portable Regressionstestsuite enthält ab Version 2.2.0 unter anderem folgende Vorfallsprüfungen:
- simulierte `Microsoft.BizTalk.Scheduler`-Exception in einem mittleren Schritt,
- erfolgreiche Ausführung aller späteren Schritte,
- vollständige Persistenz des Teilfehlerreports,
- Überspringen eines bereits erreichten Sollzustands ohne Mutation,
- `ENTSSO` als erster Emergency-Schritt,
- globale Shutdown-/Restore-Reihenfolge mit mehreren Anwendungen,
- Kurzname/FQDN-Gleichheit für Host-Instance-Schritte.
Die lokale Suite umfasst 23 bestandene Tests. Die abschließende Freigabe für PROD benötigt weiterhin einen Windows-/BizTalk-Test auf ACC: echter Scheduler-Fehlerpfad, vollständige Fortsetzung, Ergebnisreport, Emergency-Dry-run und realer Wiederanlauf aus einer Kopie der vorhandenen `before.json`.
## PROD-Freigabekriterien
- Version 2.2.0 installieren und Installer-Self-Test bestätigen.
- `Diagnose` und einen vollständigen Shutdown-Dry-run ausführen.
- Kontrolliert nachweisen, dass eine isolierte Scheduler-Exception spätere Schritte nicht blockiert.
- `shutdown-result.json` und Nachher-Snapshot prüfen.
- Emergency Restore zuerst ausschließlich als Dry-run aus einer Snapshot-Kopie prüfen.
- Realen Emergency Restore auf ACC abschließen und `FailedCount=0` oder jeden verbleibenden Einzelfehler fachlich freigeben.
- Erst danach dasselbe Releasepaket für PROD verwenden.
@@ -23,7 +23,7 @@ namespace BizTalkPlatformManagementTool.Setup
private const string ProductName = "BizTalk Platform Management Tool";
/// <summary>Aktuelle Produktversion des Installers und Uninstall-Eintrags.</summary>
private const string ProductVersion = "2.1.3";
private const string ProductVersion = "2.2.0";
/// <summary>
/// Wartezeiten zwischen Wiederholungen atomarer Verzeichnisverschiebungen.
@@ -65,7 +65,7 @@ namespace BizTalkPlatformManagementTool.Setup
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.1.3"
Text = "BizTalk Platform Management Tool 2.2.0"
});
root.Controls.Add(new Label
{
@@ -8,6 +8,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.1.3.0")]
[assembly: AssemblyFileVersion("2.1.3.0")]
[assembly: AssemblyVersion("2.2.0.0")]
[assembly: AssemblyFileVersion("2.2.0.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.3.0" name="BizTalkPlatformManagementTool.Setup" />
<assemblyIdentity version="2.2.0.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
@@ -43,6 +43,7 @@
<Reference Include="System.Drawing" />
<Reference Include="System.Management" />
<Reference Include="System.Runtime.Serialization" />
<Reference Include="System.ServiceProcess" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
</ItemGroup>
@@ -59,6 +60,7 @@
<Compile Include="Services\HtmlReportWriter.cs" />
<Compile Include="Services\JsonFileStore.cs" />
<Compile Include="Services\OperationLogger.cs" />
<Compile Include="Services\OperationPlanExecutor.cs" />
<Compile Include="Services\SnapshotComparer.cs" />
<Compile Include="Services\SnapshotValidator.cs" />
<Compile Include="Services\SnapshotStore.cs" />
@@ -16,7 +16,13 @@ namespace BizTalkPlatformManagementTool.Models
/// <summary>
/// Plan mode for returning BizTalk runtime artifacts to a captured state.
/// </summary>
Restore
Restore,
/// <summary>
/// Recovery mode that uses an existing snapshot without creating or
/// overwriting a new before snapshot.
/// </summary>
EmergencyRestore
}
/// <summary>
@@ -44,6 +50,11 @@ namespace BizTalkPlatformManagementTool.Models
/// </summary>
HostInstance,
/// <summary>
/// A Windows service prerequisite such as Enterprise Single Sign-On.
/// </summary>
WindowsService,
/// <summary>
/// An informational step that is intentionally not executed.
/// </summary>
@@ -210,4 +221,140 @@ namespace BizTalkPlatformManagementTool.Models
/// </summary>
public int PollIntervalSeconds { get; set; }
}
/// <summary>
/// Defines the outcome recorded for one attempted operation-plan step.
/// </summary>
public static class OperationStepOutcomes
{
/// <summary>The step was executed and reached its target state.</summary>
public const string Succeeded = "Succeeded";
/// <summary>The runtime object was already in the requested target state.</summary>
public const string AlreadySatisfied = "AlreadySatisfied";
/// <summary>The step was intentionally not executable.</summary>
public const string Skipped = "Skipped";
/// <summary>The step was shown without mutation because dry-run was enabled.</summary>
public const string DryRun = "DryRun";
/// <summary>The step failed, while subsequent independent steps were still attempted.</summary>
public const string Failed = "Failed";
}
/// <summary>
/// Represents one durable step result from a shutdown, restore or emergency restore.
/// </summary>
[DataContract]
public sealed class OperationStepResult
{
/// <summary>Gets or sets the one-based plan-step number.</summary>
[DataMember(Order = 1)]
public int Index { get; set; }
/// <summary>Gets or sets the artifact kind.</summary>
[DataMember(Order = 2)]
public string Kind { get; set; }
/// <summary>Gets or sets the owning BizTalk application.</summary>
[DataMember(Order = 3)]
public string Application { get; set; }
/// <summary>Gets or sets the artifact, host instance or service name.</summary>
[DataMember(Order = 4)]
public string Name { get; set; }
/// <summary>Gets or sets the requested action.</summary>
[DataMember(Order = 5)]
public string Action { get; set; }
/// <summary>Gets or sets the stable outcome value.</summary>
[DataMember(Order = 6)]
public string Outcome { get; set; }
/// <summary>Gets or sets the local start timestamp.</summary>
[DataMember(Order = 7)]
public string StartedAt { get; set; }
/// <summary>Gets or sets the local completion timestamp.</summary>
[DataMember(Order = 8)]
public string FinishedAt { get; set; }
/// <summary>Gets or sets the complete operator-facing exception chain.</summary>
[DataMember(Order = 9)]
public string Error { get; set; }
}
/// <summary>
/// Durable summary of a best-effort plan execution. A failed step never
/// prevents later independent steps from being attempted.
/// </summary>
[DataContract]
public sealed class OperationExecutionReport
{
/// <summary>Initializes an empty report.</summary>
public OperationExecutionReport()
{
Steps = new List<OperationStepResult>();
}
/// <summary>Gets or sets the shutdown or restore mode.</summary>
[DataMember(Order = 1)]
public string Mode { get; set; }
/// <summary>Gets or sets the target server.</summary>
[DataMember(Order = 2)]
public string Server { get; set; }
/// <summary>Gets or sets whether the execution was a dry-run.</summary>
[DataMember(Order = 3)]
public bool DryRun { get; set; }
/// <summary>Gets or sets the local execution start timestamp.</summary>
[DataMember(Order = 4)]
public string StartedAt { get; set; }
/// <summary>Gets or sets the local execution completion timestamp.</summary>
[DataMember(Order = 5)]
public string FinishedAt { get; set; }
/// <summary>Gets or sets the number of successfully executed steps.</summary>
[DataMember(Order = 6)]
public int SucceededCount { get; set; }
/// <summary>Gets or sets the number of steps already at their target state.</summary>
[DataMember(Order = 7)]
public int AlreadySatisfiedCount { get; set; }
/// <summary>Gets or sets the number of intentionally skipped steps.</summary>
[DataMember(Order = 8)]
public int SkippedCount { get; set; }
/// <summary>Gets or sets the number of dry-run-only steps.</summary>
[DataMember(Order = 9)]
public int DryRunCount { get; set; }
/// <summary>Gets or sets the number of failed steps.</summary>
[DataMember(Order = 10)]
public int FailedCount { get; set; }
/// <summary>Gets or sets an error that prevented runtime initialization.</summary>
[DataMember(Order = 11)]
public string InitializationError { get; set; }
/// <summary>Gets or sets an error encountered while creating the post-operation snapshot.</summary>
[DataMember(Order = 12)]
public string PostSnapshotError { get; set; }
/// <summary>Gets or sets the ordered per-step results.</summary>
[DataMember(Order = 13)]
public List<OperationStepResult> Steps { get; set; }
/// <summary>Gets whether operator attention is required.</summary>
public bool HasFailures
{
get { return FailedCount > 0 || !string.IsNullOrWhiteSpace(InitializationError) || !string.IsNullOrWhiteSpace(PostSnapshotError); }
}
}
}
@@ -1,4 +1,5 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("BizTalk Platform Management Tool")]
@@ -8,5 +9,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: ComVisible(false)]
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
[assembly: AssemblyVersion("2.1.3.0")]
[assembly: AssemblyFileVersion("2.1.3.0")]
[assembly: AssemblyVersion("2.2.0.0")]
[assembly: AssemblyFileVersion("2.2.0.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using BizTalkPlatformManagementTool.Models;
using BizTalkPlatformManagementTool.Services;
@@ -35,6 +36,27 @@ namespace BizTalkPlatformManagementTool
SnapshotStore.SaveSnapshotSet(Path.Combine(directory, "before.json"), loaded);
SnapshotStore.SaveDiffSet(Path.Combine(directory, "diff.json"), diff);
var operationService = new BizTalkOperationService(null);
var emergencyPlan = operationService.CreateEmergencyRestorePlan(loaded, loaded.Server);
if (emergencyPlan.Steps.Count == 0 || emergencyPlan.Steps[0].Kind != "WindowsService" || emergencyPlan.Steps[0].Name != "ENTSSO")
{
throw new InvalidOperationException("Emergency restore self-test did not place ENTSSO first.");
}
var dryRunReport = operationService.ExecutePlan(emergencyPlan, new OperationOptions
{
Server = loaded.Server,
OutputDirectory = directory,
StateFile = snapshotPath,
DryRun = true,
WaitTimeoutSeconds = 30,
PollIntervalSeconds = 1
});
if (dryRunReport.HasFailures || dryRunReport.DryRunCount != emergencyPlan.Steps.Count(x => x.Execute))
{
throw new InvalidOperationException("Emergency restore dry-run self-test returned an unexpected result.");
}
operationService.SaveExecutionReport(directory, "emergency-result.json", dryRunReport);
Console.WriteLine("SELF_TEST_OK version=" + BizTalkOperationService.Version);
return 0;
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management;
using System.ServiceProcess;
using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services
@@ -15,7 +16,7 @@ namespace BizTalkPlatformManagementTool.Services
/// <summary>
/// Current tool version written into generated snapshots.
/// </summary>
public const string Version = "2.1.3-net461";
public const string Version = "2.2.0-net461";
/// <summary>
/// Fallback application name used when WMI does not expose an application property.
@@ -193,12 +194,18 @@ namespace BizTalkPlatformManagementTool.Services
{
plan.Steps.Add(Step("ReceiveLocation", app.Application, item.Name, null, "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, "Disable", null, null));
}
}
foreach (var app in snapshot.Applications)
{
foreach (var item in app.Orchestrations.Where(x => x.OrchestrationStatus == ArtifactStates.OrchestrationStarted))
{
plan.Steps.Add(Step("Orchestration", app.Application, item.Name, null, "Stop orchestration", "MSBTS_Orchestration", "Name", item.Name, "Stop", new[] { 1, 1 }, ArtifactStates.OrchestrationStopped));
}
}
foreach (var app in snapshot.Applications)
{
foreach (var item in app.SendPorts.Where(x => x.Status == ArtifactStates.SendPortStarted))
{
plan.Steps.Add(Step("SendPort", app.Application, item.Name, null, "Stop send port", "MSBTS_SendPort", "Name", item.Name, "Stop", null, ArtifactStates.SendPortStopped));
@@ -208,7 +215,7 @@ namespace BizTalkPlatformManagementTool.Services
foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted))
{
var step = Step("HostInstance", string.Empty, item.InstanceName, item.Server, "Stop host instance", "MSBTS_HostInstance", "InstanceName", item.InstanceName, "Stop", null, ArtifactStates.HostStopped);
if (!string.IsNullOrEmpty(item.Server) && !string.Equals(item.Server, server, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrEmpty(item.Server) && !SnapshotValidator.ServerNamesEqual(item.Server, server))
{
step.Execute = false;
step.Warning = "Host instance belongs to server '" + item.Server + "'. Run this action on that server.";
@@ -235,7 +242,7 @@ namespace BizTalkPlatformManagementTool.Services
foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted))
{
var step = Step("HostInstance", string.Empty, item.InstanceName, item.Server, "Start host instance", "MSBTS_HostInstance", "InstanceName", item.InstanceName, "Start", null, ArtifactStates.HostStarted);
if (!string.IsNullOrEmpty(item.Server) && !string.Equals(item.Server, server, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrEmpty(item.Server) && !SnapshotValidator.ServerNamesEqual(item.Server, server))
{
step.Execute = false;
step.Warning = "Host instance belongs to server '" + item.Server + "'. Run this action on that server.";
@@ -260,7 +267,10 @@ namespace BizTalkPlatformManagementTool.Services
plan.Steps.Add(Step("SendPort", app.Application, item.Name, null, "Ensure send port is bound", "MSBTS_SendPort", "Name", item.Name, "UnEnlist", null, ArtifactStates.SendPortBound));
}
}
}
foreach (var app in snapshot.Applications)
{
foreach (var item in app.Orchestrations)
{
if (item.OrchestrationStatus == ArtifactStates.OrchestrationStarted)
@@ -290,7 +300,10 @@ namespace BizTalkPlatformManagementTool.Services
plan.Steps.Add(Step("Orchestration", app.Application, item.Name, null, "Unenlist orchestration", "MSBTS_Orchestration", "Name", item.Name, "UnenlistService", new[] { 1 }, ArtifactStates.OrchestrationUnbound));
}
}
}
foreach (var app in snapshot.Applications)
{
foreach (var item in app.ReceiveLocations)
{
plan.Steps.Add(Step("ReceiveLocation", app.Application, item.Name, null, item.Enabled ? "Enable receive location" : "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, item.Enabled ? "Enable" : "Disable", null, null));
@@ -300,12 +313,38 @@ namespace BizTalkPlatformManagementTool.Services
return plan;
}
/// <summary>
/// Creates a recovery plan from an existing snapshot without taking or
/// overwriting a new before snapshot. Enterprise SSO is added as the first prerequisite.
/// </summary>
/// <param name="snapshot">The preserved pre-maintenance state.</param>
/// <param name="server">The selected target server.</param>
/// <returns>An ordered, state-aware emergency restore plan.</returns>
public OperationPlan CreateEmergencyRestorePlan(BizTalkSnapshot snapshot, string server)
{
var plan = CreateRestorePlan(snapshot, server);
plan.Mode = OperationMode.EmergencyRestore.ToString();
plan.Steps.Insert(0, Step(
"WindowsService",
string.Empty,
"ENTSSO",
server,
"Ensure Enterprise Single Sign-On service is running",
"Win32_Service",
"Name",
"ENTSSO",
"Start",
null,
(int)ServiceControllerStatus.Running));
return plan;
}
/// <summary>
/// Executes an operation plan or logs each step when dry-run mode is enabled.
/// </summary>
/// <param name="plan">The ordered plan to execute.</param>
/// <param name="options">The runtime options controlling server, dry-run and wait behavior.</param>
public void ExecutePlan(OperationPlan plan, OperationOptions options)
public OperationExecutionReport ExecutePlan(OperationPlan plan, OperationOptions options)
{
if (plan == null || options == null)
{
@@ -315,41 +354,36 @@ namespace BizTalkPlatformManagementTool.Services
{
throw new InvalidOperationException("The operation plan targets server '" + plan.Server + "' but execution was requested for '" + options.Server + "'.");
}
using (var client = CreateClient(options.Server))
{
foreach (var step in plan.Steps)
{
if (!step.Execute)
{
_logger.Warning(step.Action + (string.IsNullOrEmpty(step.Warning) ? string.Empty : " " + step.Warning));
continue;
}
var executor = new OperationPlanExecutor(_logger);
if (options.DryRun)
{
// Dry-run löst das Objekt absichtlich nicht erneut per WMI auf und führt keine Methode aus.
_logger.Info("DRY RUN: " + step.Action + " '" + step.Name + "'");
continue;
// Dry-run benötigt absichtlich weder WMI-Verbindung noch Servicezugriff.
return executor.Execute(plan, options, null);
}
try
{
_logger.Info("Executing step: " + DescribeStep(step));
using (var instance = client.FindByProperty(step.WmiClass, step.KeyProperty, step.KeyValue))
using (var runtime = new WmiOperationStepRuntime(options.Server, _logger))
{
if (instance == null)
{
throw new InvalidOperationException(step.Kind + " not found: " + step.Name);
}
ExecuteStep(client, instance, step, options);
return executor.Execute(plan, options, runtime);
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Step failed: " + DescribeStep(step) + ". Error: " + ex.Message, ex);
}
var error = OperationPlanExecutor.FormatException(ex);
if (_logger != null)
{
_logger.Error("Plan runtime initialization failed. No executable step could be started. Error: " + error);
}
return new OperationExecutionReport
{
Mode = plan.Mode,
Server = plan.Server,
DryRun = false,
StartedAt = DateTimeOffset.Now.ToString("o"),
FinishedAt = DateTimeOffset.Now.ToString("o"),
InitializationError = error
};
}
}
@@ -385,6 +419,25 @@ namespace BizTalkPlatformManagementTool.Services
return path;
}
/// <summary>
/// Saves a complete execution report, including every failure and continued step.
/// </summary>
/// <param name="outputDirectory">The directory where the report is written.</param>
/// <param name="fileName">The report file name.</param>
/// <param name="report">The report to persist.</param>
/// <returns>The saved report path.</returns>
public string SaveExecutionReport(string outputDirectory, string fileName, OperationExecutionReport report)
{
Directory.CreateDirectory(outputDirectory);
var path = Path.Combine(outputDirectory, fileName);
JsonFileStore.Save(path, report);
if (_logger != null)
{
_logger.Success("Saved execution report: " + path);
}
return path;
}
/// <summary>
/// Saves a diff and its report sidecars.
/// </summary>
@@ -402,79 +455,221 @@ namespace BizTalkPlatformManagementTool.Services
}
/// <summary>
/// Executes one concrete WMI operation step and waits for its target state.
/// Implements state-aware Windows-service and BizTalk-WMI execution for a plan step.
/// </summary>
/// <param name="client">The connected WMI client.</param>
/// <param name="instance">The resolved WMI object for the step.</param>
/// <param name="step">The operation step to execute.</param>
/// <param name="options">The runtime options controlling wait behavior.</param>
private void ExecuteStep(BizTalkWmiClient client, ManagementObject instance, OperationStep step, OperationOptions options)
private sealed class WmiOperationStepRuntime : IOperationStepRuntime, IDisposable
{
/// <summary>Target server used for service and WMI operations.</summary>
private readonly string _server;
/// <summary>Operation logger.</summary>
private readonly OperationLogger _logger;
/// <summary>Lazily connected BizTalk WMI client.</summary>
private BizTalkWmiClient _client;
/// <summary>Initializes the production runtime adapter.</summary>
/// <param name="server">The target server.</param>
/// <param name="logger">The operation logger.</param>
public WmiOperationStepRuntime(string server, OperationLogger logger)
{
_server = string.IsNullOrWhiteSpace(server) ? Environment.MachineName : server.Trim();
_logger = logger;
}
/// <summary>Executes one Windows-service or BizTalk-WMI step state-aware.</summary>
/// <param name="step">The operation step.</param>
/// <param name="options">Timeout and polling options.</param>
/// <returns>The successful runtime outcome.</returns>
public RuntimeStepOutcome Execute(OperationStep step, OperationOptions options)
{
if (string.Equals(step.Kind, "WindowsService", StringComparison.OrdinalIgnoreCase))
{
return ExecuteWindowsService(step, options);
}
var client = EnsureClient();
using (var instance = client.FindByProperty(step.WmiClass, step.KeyProperty, step.KeyValue))
{
if (instance == null)
{
throw new InvalidOperationException(step.Kind + " not found: " + step.Name);
}
if (IsTargetStateReached(instance, step))
{
return RuntimeStepOutcome.AlreadySatisfied;
}
ExecuteWmiStep(instance, step, options);
return RuntimeStepOutcome.Succeeded;
}
}
/// <summary>
/// Releases the lazily created WMI client.
/// </summary>
public void Dispose()
{
if (_client != null)
{
_client.Dispose();
_client = null;
}
}
/// <summary>
/// Connects to BizTalk WMI only when the first BizTalk step is reached,
/// allowing ENTSSO to start before any provider dependency is evaluated.
/// </summary>
/// <returns>The connected client.</returns>
private BizTalkWmiClient EnsureClient()
{
if (_client != null)
{
return _client;
}
var client = new BizTalkWmiClient(_server, _logger);
try
{
client.Connect();
_client = client;
return _client;
}
catch
{
client.Dispose();
throw;
}
}
/// <summary>Starts or resumes a Windows-service prerequisite and waits for Running.</summary>
private RuntimeStepOutcome ExecuteWindowsService(OperationStep step, OperationOptions options)
{
var targetServer = string.IsNullOrWhiteSpace(step.Server) ? _server : step.Server;
using (var service = new ServiceController(step.Name, targetServer))
{
service.Refresh();
if (service.Status == ServiceControllerStatus.Running)
{
return RuntimeStepOutcome.AlreadySatisfied;
}
if (service.Status == ServiceControllerStatus.StartPending
|| service.Status == ServiceControllerStatus.ContinuePending)
{
service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(Math.Max(1, options.WaitTimeoutSeconds)));
}
else if (service.Status == ServiceControllerStatus.PausePending)
{
service.WaitForStatus(ServiceControllerStatus.Paused, TimeSpan.FromSeconds(Math.Max(1, options.WaitTimeoutSeconds)));
service.Refresh();
_logger.Info("Continuing Windows service '" + step.Name + "' on " + targetServer + ".");
service.Continue();
service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(Math.Max(1, options.WaitTimeoutSeconds)));
}
else if (service.Status == ServiceControllerStatus.Paused)
{
_logger.Info("Continuing Windows service '" + step.Name + "' on " + targetServer + ".");
service.Continue();
service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(Math.Max(1, options.WaitTimeoutSeconds)));
}
else
{
if (service.Status == ServiceControllerStatus.StopPending)
{
service.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(Math.Max(1, options.WaitTimeoutSeconds)));
service.Refresh();
}
_logger.Info("Starting Windows service '" + step.Name + "' on " + targetServer + ".");
service.Start();
service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(Math.Max(1, options.WaitTimeoutSeconds)));
}
_logger.Success("Reached: Windows service '" + step.Name + "' is Running.");
return RuntimeStepOutcome.Succeeded;
}
}
/// <summary>Executes one BizTalk WMI method or pseudo-method and verifies the target state.</summary>
private void ExecuteWmiStep(ManagementObject instance, OperationStep step, OperationOptions options)
{
if (string.Equals(step.MethodName, "StopOrEnlist", StringComparison.OrdinalIgnoreCase))
{
var status = BizTalkWmiClient.SafeGetInt32(instance, "Status", 0);
if (status == ArtifactStates.SendPortBound)
{
client.InvokeMethod(instance, "Enlist");
_client.InvokeMethod(instance, "Enlist");
}
else if (status == ArtifactStates.SendPortStarted)
{
client.InvokeMethod(instance, "Stop");
}
else
{
_logger.Info("Already stopped: " + step.Name);
_client.InvokeMethod(instance, "Stop");
}
}
else if (string.Equals(step.MethodName, "StopIfStarted", StringComparison.OrdinalIgnoreCase))
{
var status = BizTalkWmiClient.SafeGetInt32(instance, "OrchestrationStatus", 0);
if (status == ArtifactStates.OrchestrationStarted)
if (BizTalkWmiClient.SafeGetInt32(instance, "OrchestrationStatus", 0) == ArtifactStates.OrchestrationStarted)
{
client.InvokeMethod(instance, "Stop", ToObjects(step.Arguments));
}
else
{
_logger.Info("No stop required: " + step.Name);
_client.InvokeMethod(instance, "Stop", ToObjects(step.Arguments));
}
}
else
{
client.InvokeMethod(instance, step.MethodName, ToObjects(step.Arguments));
_client.InvokeMethod(instance, step.MethodName, ToObjects(step.Arguments));
}
WaitForTarget(client, step, options);
WaitForTarget(step, options);
}
/// <summary>
/// Waits until a WMI object reaches the target state described by a plan step.
/// </summary>
/// <param name="client">The connected WMI client.</param>
/// <param name="step">The step whose target state should be verified.</param>
/// <param name="options">The runtime options controlling timeout and polling interval.</param>
private void WaitForTarget(BizTalkWmiClient client, OperationStep step, OperationOptions options)
/// <summary>Checks whether a live WMI object already matches the requested target state.</summary>
private static bool IsTargetStateReached(ManagementObject instance, OperationStep step)
{
if (string.Equals(step.Kind, "ReceiveLocation", StringComparison.OrdinalIgnoreCase))
{
var enabled = !BizTalkWmiClient.SafeGetBoolean(instance, "IsDisabled", true);
return enabled == string.Equals(step.MethodName, "Enable", StringComparison.OrdinalIgnoreCase);
}
if (!step.TargetState.HasValue)
{
return false;
}
if (string.Equals(step.Kind, "SendPort", StringComparison.OrdinalIgnoreCase))
{
return BizTalkWmiClient.SafeGetInt32(instance, "Status", 0) == step.TargetState.Value;
}
if (string.Equals(step.Kind, "Orchestration", StringComparison.OrdinalIgnoreCase))
{
return BizTalkWmiClient.SafeGetInt32(instance, "OrchestrationStatus", 0) == step.TargetState.Value;
}
if (string.Equals(step.Kind, "HostInstance", StringComparison.OrdinalIgnoreCase))
{
return BizTalkWmiClient.SafeGetInt32(instance, "ServiceState", 0) == step.TargetState.Value;
}
return false;
}
/// <summary>Waits for the WMI target state after a mutation.</summary>
private void WaitForTarget(OperationStep step, OperationOptions options)
{
if (!step.TargetState.HasValue)
{
if (step.Kind == "ReceiveLocation")
{
var shouldBeEnabled = string.Equals(step.MethodName, "Enable", StringComparison.OrdinalIgnoreCase);
client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => !BizTalkWmiClient.SafeGetBoolean(o, "IsDisabled", true) == shouldBeEnabled, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
_client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => !BizTalkWmiClient.SafeGetBoolean(o, "IsDisabled", true) == shouldBeEnabled, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
}
return;
}
if (step.Kind == "SendPort")
{
client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => BizTalkWmiClient.SafeGetInt32(o, "Status", 0) == step.TargetState.Value, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
_client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => BizTalkWmiClient.SafeGetInt32(o, "Status", 0) == step.TargetState.Value, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
}
else if (step.Kind == "Orchestration")
{
client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => BizTalkWmiClient.SafeGetInt32(o, "OrchestrationStatus", 0) == step.TargetState.Value, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
_client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => BizTalkWmiClient.SafeGetInt32(o, "OrchestrationStatus", 0) == step.TargetState.Value, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
}
else if (step.Kind == "HostInstance")
{
client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => BizTalkWmiClient.SafeGetInt32(o, "ServiceState", 0) == step.TargetState.Value, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
_client.WaitForState(step.WmiClass, step.KeyProperty, step.KeyValue, o => BizTalkWmiClient.SafeGetInt32(o, "ServiceState", 0) == step.TargetState.Value, step.Action + " completed", options.WaitTimeoutSeconds, options.PollIntervalSeconds);
}
}
}
@@ -0,0 +1,220 @@
using System;
using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services
{
/// <summary>
/// Defines the successful outcomes returned by the runtime-specific execution layer.
/// </summary>
internal enum RuntimeStepOutcome
{
/// <summary>The requested method was executed and verified.</summary>
Succeeded,
/// <summary>The object was already in the requested target state.</summary>
AlreadySatisfied
}
/// <summary>
/// Abstracts one state-aware runtime step so best-effort orchestration can be
/// regression-tested without a BizTalk WMI provider.
/// </summary>
internal interface IOperationStepRuntime
{
/// <summary>
/// Executes one step or reports that its target state already exists.
/// </summary>
/// <param name="step">The operation-plan step.</param>
/// <param name="options">Timeout and polling options.</param>
/// <returns>The successful runtime outcome.</returns>
RuntimeStepOutcome Execute(OperationStep step, OperationOptions options);
}
/// <summary>
/// Runs every independent plan step, records durable outcomes and deliberately
/// continues after isolated failures.
/// </summary>
internal sealed class OperationPlanExecutor
{
/// <summary>Logger used for detailed progress and failure diagnostics.</summary>
private readonly OperationLogger _logger;
/// <summary>
/// Initializes a new best-effort plan executor.
/// </summary>
/// <param name="logger">The operation logger, or null for isolated tests.</param>
public OperationPlanExecutor(OperationLogger logger)
{
_logger = logger;
}
/// <summary>
/// Executes or simulates every plan step and returns a complete report.
/// </summary>
/// <param name="plan">The validated operation plan.</param>
/// <param name="options">The execution options.</param>
/// <param name="runtime">The runtime implementation; optional only during dry-run.</param>
/// <returns>A complete per-step execution report.</returns>
public OperationExecutionReport Execute(OperationPlan plan, OperationOptions options, IOperationStepRuntime runtime)
{
if (plan == null || options == null)
{
throw new ArgumentNullException(plan == null ? "plan" : "options");
}
if (!options.DryRun && runtime == null)
{
throw new ArgumentNullException("runtime");
}
var report = NewReport(plan, options);
for (var index = 0; index < plan.Steps.Count; index++)
{
var step = plan.Steps[index];
var stepResult = NewStepResult(index + 1, step);
try
{
if (!step.Execute)
{
stepResult.Outcome = OperationStepOutcomes.Skipped;
report.SkippedCount++;
Warning(step.Action + (string.IsNullOrWhiteSpace(step.Warning) ? string.Empty : " " + step.Warning));
}
else if (options.DryRun)
{
stepResult.Outcome = OperationStepOutcomes.DryRun;
report.DryRunCount++;
Info("DRY RUN: " + DescribeStep(step));
}
else
{
Info("Executing step " + (index + 1) + "/" + plan.Steps.Count + ": " + DescribeStep(step));
var outcome = runtime.Execute(step, options);
if (outcome == RuntimeStepOutcome.AlreadySatisfied)
{
stepResult.Outcome = OperationStepOutcomes.AlreadySatisfied;
report.AlreadySatisfiedCount++;
Info("Already in target state: " + DescribeStep(step));
}
else
{
stepResult.Outcome = OperationStepOutcomes.Succeeded;
report.SucceededCount++;
}
}
}
catch (Exception ex)
{
stepResult.Outcome = OperationStepOutcomes.Failed;
stepResult.Error = FormatException(ex);
report.FailedCount++;
Error("STEP FAILED; continuing with remaining independent steps: " + DescribeStep(step) + ". Error: " + stepResult.Error);
}
finally
{
stepResult.FinishedAt = DateTimeOffset.Now.ToString("o");
report.Steps.Add(stepResult);
}
}
report.FinishedAt = DateTimeOffset.Now.ToString("o");
var summary = "Plan execution summary: succeeded=" + report.SucceededCount
+ ", already_satisfied=" + report.AlreadySatisfiedCount
+ ", skipped=" + report.SkippedCount
+ ", dry_run=" + report.DryRunCount
+ ", failed=" + report.FailedCount + ".";
if (report.FailedCount == 0)
{
Success(summary);
}
else
{
Error(summary + " Operator review is required.");
}
return report;
}
/// <summary>Creates the common execution-report header.</summary>
/// <param name="plan">The source plan.</param>
/// <param name="options">The execution options.</param>
/// <returns>An initialized report.</returns>
private static OperationExecutionReport NewReport(OperationPlan plan, OperationOptions options)
{
return new OperationExecutionReport
{
Mode = plan.Mode,
Server = plan.Server,
DryRun = options.DryRun,
StartedAt = DateTimeOffset.Now.ToString("o")
};
}
/// <summary>Creates one report row from a plan step.</summary>
/// <param name="index">The one-based step index.</param>
/// <param name="step">The source step.</param>
/// <returns>An initialized step result.</returns>
private static OperationStepResult NewStepResult(int index, OperationStep step)
{
return new OperationStepResult
{
Index = index,
Kind = step == null ? string.Empty : step.Kind,
Application = step == null ? string.Empty : step.Application,
Name = step == null ? string.Empty : step.Name,
Action = step == null ? string.Empty : step.Action,
StartedAt = DateTimeOffset.Now.ToString("o")
};
}
/// <summary>Builds a stable operator-facing step description.</summary>
/// <param name="step">The plan step.</param>
/// <returns>The compact description.</returns>
private static string DescribeStep(OperationStep step)
{
if (step == null)
{
return "<unknown step>";
}
return step.Action + " '" + step.Name + "'"
+ " [kind=" + step.Kind
+ ", class=" + step.WmiClass
+ ", key=" + step.KeyProperty + "=" + (step.KeyValue ?? string.Empty)
+ ", method=" + step.MethodName
+ (string.IsNullOrWhiteSpace(step.Server) ? string.Empty : ", server=" + step.Server)
+ "]";
}
/// <summary>Formats the complete unique exception-message chain.</summary>
/// <param name="exception">The exception to format.</param>
/// <returns>A single diagnostic message.</returns>
internal static string FormatException(Exception exception)
{
if (exception == null)
{
return "Unknown operation error.";
}
var message = exception.Message;
var inner = exception.InnerException;
while (inner != null)
{
if (!string.IsNullOrWhiteSpace(inner.Message) && message.IndexOf(inner.Message, StringComparison.OrdinalIgnoreCase) < 0)
{
message += " Inner error: " + inner.Message;
}
inner = inner.InnerException;
}
return message;
}
/// <summary>Writes an informational message when a logger is available.</summary>
private void Info(string message) { if (_logger != null) _logger.Info(message); }
/// <summary>Writes a warning when a logger is available.</summary>
private void Warning(string message) { if (_logger != null) _logger.Warning(message); }
/// <summary>Writes an error when a logger is available.</summary>
private void Error(string message) { if (_logger != null) _logger.Error(message); }
/// <summary>Writes a success message when a logger is available.</summary>
private void Success(string message) { if (_logger != null) _logger.Success(message); }
}
}
+157 -14
View File
@@ -110,6 +110,11 @@ namespace BizTalkPlatformManagementTool.Ui
/// </summary>
private Button _restoreButton;
/// <summary>
/// Button that performs a state-aware best-effort recovery from an existing snapshot.
/// </summary>
private Button _emergencyRestoreButton;
/// <summary>
/// Button that clears visible grids and status.
/// </summary>
@@ -132,7 +137,7 @@ namespace BizTalkPlatformManagementTool.Ui
{
Text = "BizTalk Platform Management Tool";
Width = 1180;
Height = 760;
Height = 800;
MinimumSize = new Size(980, 640);
StartPosition = FormStartPosition.CenterScreen;
@@ -156,7 +161,7 @@ namespace BizTalkPlatformManagementTool.Ui
Padding = new Padding(10)
};
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 54));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 96));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
Controls.Add(root);
@@ -248,13 +253,15 @@ namespace BizTalkPlatformManagementTool.Ui
/// <returns>The configured action panel.</returns>
private Control BuildActionPanel()
{
var panel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(0, 8, 0, 0), WrapContents = false };
var panel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(0, 8, 0, 0), WrapContents = true, AutoScroll = true };
_diagnoseButton = ActionButton("Diagnose", DiagnoseClick);
_beforeButton = ActionButton("Snapshot Before", BeforeClick);
_afterButton = ActionButton("Snapshot After", AfterClick);
_compareButton = ActionButton("Compare", CompareClick);
_shutdownButton = ActionButton("Shutdown", ShutdownClick);
_restoreButton = ActionButton("Restore", RestoreClick);
_emergencyRestoreButton = ActionButton("Emergency Restore", EmergencyRestoreClick);
_emergencyRestoreButton.Width = 142;
_clearButton = ActionButton("Clear", ClearClick);
_closeButton = ActionButton("Close", CloseClick);
@@ -264,6 +271,7 @@ namespace BizTalkPlatformManagementTool.Ui
panel.Controls.Add(_compareButton);
panel.Controls.Add(_shutdownButton);
panel.Controls.Add(_restoreButton);
panel.Controls.Add(_emergencyRestoreButton);
panel.Controls.Add(_clearButton);
panel.Controls.Add(_closeButton);
return panel;
@@ -381,13 +389,11 @@ namespace BizTalkPlatformManagementTool.Ui
_logger.Warning("Shutdown cancelled after plan review. No runtime state was changed.");
return;
}
_service.ExecutePlan(plan, options);
if (!options.DryRun)
{
var after = _service.CreateSnapshot(options.Server);
_service.SaveSnapshot(options.OutputDirectory, "shutdown-after.json", after);
ShowSnapshot(after);
}
var report = _service.ExecutePlan(plan, options);
CapturePostOperationSnapshot(options, report, "shutdown-after.json");
var reportPath = _service.SaveExecutionReport(options.OutputDirectory, "shutdown-result.json", report);
ShowExecutionReport(report);
ThrowWhenOperatorReviewIsRequired(report, reportPath);
});
}
@@ -411,14 +417,89 @@ namespace BizTalkPlatformManagementTool.Ui
_logger.Warning("Restore cancelled after plan review. No runtime state was changed.");
return;
}
_service.ExecutePlan(plan, options);
if (!options.DryRun)
var report = _service.ExecutePlan(plan, options);
CapturePostOperationSnapshot(options, report, "restore-after.json");
var reportPath = _service.SaveExecutionReport(options.OutputDirectory, "restore-result.json", report);
ShowExecutionReport(report);
ThrowWhenOperatorReviewIsRequired(report, reportPath);
});
}
/// <summary>
/// Handles emergency recovery from the preserved state file without creating
/// or overwriting <c>before.json</c>.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void EmergencyRestoreClick(object sender, EventArgs e)
{
var options = GetOptions();
RunAsync("Preparing emergency restore from preserved state...", () =>
{
var sourcePath = ResolveStateFile(options);
var snapshot = JsonFileStore.Load<BizTalkSnapshot>(sourcePath);
SnapshotValidator.EnsureServerMatches(snapshot, options.Server);
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
var sourceCopyName = "emergency-source-before-" + stamp + ".json";
_service.SaveSnapshot(options.OutputDirectory, sourceCopyName, snapshot);
var plan = _service.CreateEmergencyRestorePlan(snapshot, options.Server);
var planName = "emergency-restore-plan-" + stamp + ".json";
var planPath = _service.SavePlan(options.OutputDirectory, planName, plan);
ShowPlan(plan);
if (!options.DryRun && !ConfirmEmergencyRestore(plan, options.Server, sourcePath, planPath))
{
_logger.Warning("Emergency restore cancelled after plan review. No runtime state was changed.");
return;
}
var report = _service.ExecutePlan(plan, options);
CapturePostOperationSnapshot(options, report, "emergency-restore-after-" + stamp + ".json");
var reportPath = _service.SaveExecutionReport(options.OutputDirectory, "emergency-restore-result-" + stamp + ".json", report);
ShowExecutionReport(report);
ThrowWhenOperatorReviewIsRequired(report, reportPath);
});
}
/// <summary>
/// Attempts the post-operation snapshot even after individual plan-step failures.
/// </summary>
/// <param name="options">The operation options.</param>
/// <param name="report">The report that receives a snapshot error.</param>
/// <param name="fileName">The snapshot file name.</param>
private void CapturePostOperationSnapshot(OperationOptions options, OperationExecutionReport report, string fileName)
{
if (options.DryRun)
{
return;
}
try
{
var after = _service.CreateSnapshot(options.Server);
_service.SaveSnapshot(options.OutputDirectory, "restore-after.json", after);
_service.SaveSnapshot(options.OutputDirectory, fileName, after);
ShowSnapshot(after);
}
});
catch (Exception ex)
{
report.PostSnapshotError = FormatException(ex);
_logger.Error("Post-operation snapshot failed, but the execution report will still be saved. Error: " + report.PostSnapshotError);
}
}
/// <summary>
/// Converts a completed partial failure into the visible failed UI status only
/// after its complete execution report has been saved.
/// </summary>
/// <param name="report">The completed report.</param>
/// <param name="reportPath">The durable report path.</param>
private static void ThrowWhenOperatorReviewIsRequired(OperationExecutionReport report, string reportPath)
{
if (report != null && report.HasFailures)
{
throw new InvalidOperationException(
"Plan completed with failures, but all remaining independent steps were attempted. " +
"Failed steps: " + report.FailedCount + ". Review: " + reportPath);
}
}
/// <summary>
@@ -536,6 +617,43 @@ namespace BizTalkPlatformManagementTool.Ui
return confirmed;
}
/// <summary>
/// Confirms the stronger emergency-recovery contract, including the preserved
/// source snapshot and automatic Enterprise SSO prerequisite.
/// </summary>
/// <param name="plan">The prepared emergency plan.</param>
/// <param name="server">The selected target server.</param>
/// <param name="sourcePath">The preserved source snapshot.</param>
/// <param name="planPath">The saved emergency plan.</param>
/// <returns>True when the operator explicitly approves the recovery.</returns>
private bool ConfirmEmergencyRestore(OperationPlan plan, string server, string sourcePath, string planPath)
{
var confirmed = false;
Action showConfirmation = () =>
{
var executableSteps = plan.Steps.Count(x => x.Execute);
var result = MessageBox.Show(
"EMERGENCY RESTORE will reconcile " + executableSteps + " step(s) on server '" + server + "'.\n\n"
+ "Source snapshot (will not be overwritten):\n" + sourcePath + "\n\n"
+ "Enterprise Single Sign-On will be ensured Running first. Already-correct states are skipped; isolated failures are recorded and later steps continue.\n\n"
+ "Saved plan:\n" + planPath + "\n\nContinue now?",
"Confirm Emergency Restore",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
confirmed = result == DialogResult.Yes;
};
if (InvokeRequired)
{
Invoke(showConfirmation);
}
else
{
showConfirmation();
}
return confirmed;
}
/// <summary>
/// Displays a snapshot in the status grid and updates the environment indicator.
/// </summary>
@@ -604,6 +722,30 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Displays the durable per-step outcomes after best-effort execution.
/// </summary>
/// <param name="report">The execution report to display.</param>
private void ShowExecutionReport(OperationExecutionReport report)
{
InvokeIfRequired(() =>
{
_statusGrid.Rows.Clear();
foreach (var item in report.Steps)
{
_statusGrid.Rows.Add(item.Kind, item.Application, item.Name, item.Outcome, item.Error ?? item.Action);
}
if (!string.IsNullOrWhiteSpace(report.InitializationError))
{
_statusGrid.Rows.Add("Runtime", string.Empty, report.Server, "Failed", report.InitializationError);
}
if (!string.IsNullOrWhiteSpace(report.PostSnapshotError))
{
_statusGrid.Rows.Add("Snapshot", string.Empty, report.Server, "Failed", report.PostSnapshotError);
}
});
}
/// <summary>
/// Appends one operation log entry to the log grid.
/// </summary>
@@ -646,6 +788,7 @@ namespace BizTalkPlatformManagementTool.Ui
_compareButton.Enabled = !busy;
_shutdownButton.Enabled = !busy;
_restoreButton.Enabled = !busy;
_emergencyRestoreButton.Enabled = !busy;
_clearButton.Enabled = !busy;
_closeButton.Enabled = !busy;
_statusLabel.Text = status;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.3.0" name="BizTalkPlatformManagementTool" />
<assemblyIdentity version="2.2.0.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
@@ -25,6 +25,12 @@ namespace BizTalkPlatformManagementTool.Tests
Run("DiffUsesApplicationAndNameIdentity", DiffUsesApplicationAndNameIdentity);
Run("RestoreRejectsDifferentServer", RestoreRejectsDifferentServer);
Run("RestorePlanUsesSafeOrder", RestorePlanUsesSafeOrder);
Run("ShutdownPlanUsesGlobalSafeOrder", ShutdownPlanUsesGlobalSafeOrder);
Run("EmergencyRestorePlanStartsSsoFirst", EmergencyRestorePlanStartsSsoFirst);
Run("HostInstancePlanAcceptsShortAndFqdnServer", HostInstancePlanAcceptsShortAndFqdnServer);
Run("PlanExecutionContinuesAfterSchedulerFailure", PlanExecutionContinuesAfterSchedulerFailure);
Run("PlanExecutionSkipsAlreadySatisfiedState", PlanExecutionSkipsAlreadySatisfiedState);
Run("ExecutionReportRoundTripPreservesFailure", ExecutionReportRoundTripPreservesFailure);
Run("CsvNeutralizesFormulaValues", CsvNeutralizesFormulaValues);
Run("PackageManifestRejectsTampering", PackageManifestRejectsTampering);
Run("PackageManifestRejectsUndeclaredAndTraversalFiles", PackageManifestRejectsUndeclaredAndTraversalFiles);
@@ -99,13 +105,108 @@ namespace BizTalkPlatformManagementTool.Tests
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
snapshot.Applications[0].Orchestrations.Add(new OrchestrationState { Application = "APP", Name = "ORCH", OrchestrationStatus = ArtifactStates.OrchestrationBound });
snapshot.Applications[0].ReceiveLocations.Add(new ReceiveLocationState { Application = "APP", Name = "RL", Enabled = true });
var second = Snapshot("APP-B", "PORT-B", ArtifactStates.SendPortStarted).Applications[0];
second.Orchestrations.Add(new OrchestrationState { Application = "APP-B", Name = "ORCH-B", OrchestrationStatus = ArtifactStates.OrchestrationStarted });
second.ReceiveLocations.Add(new ReceiveLocationState { Application = "APP-B", Name = "RL-B", Enabled = true });
snapshot.Applications.Add(second);
var plan = new BizTalkOperationService(null).CreateRestorePlan(snapshot, snapshot.Server);
Assert(plan.Steps.First().Kind == "HostInstance", "host instance must start first");
Assert(plan.Steps.Last().Kind == "ReceiveLocation", "receive location must be restored last");
var firstReceiveLocation = plan.Steps.FindIndex(x => x.Kind == "ReceiveLocation");
var lastSendPort = plan.Steps.FindLastIndex(x => x.Kind == "SendPort");
var lastOrchestration = plan.Steps.FindLastIndex(x => x.Kind == "Orchestration" || x.Kind == "Note");
Assert(firstReceiveLocation > lastSendPort && firstReceiveLocation > lastOrchestration, "all receive locations must be restored after all send ports and orchestrations");
var bound = plan.Steps.Single(x => x.Name == "ORCH");
Assert(!bound.Execute && bound.Kind == "Note", "bound orchestration must remain unchanged");
}
/// <summary>Prüft die globale Shutdown-Reihenfolge auch über mehrere Anwendungen hinweg.</summary>
private static void ShutdownPlanUsesGlobalSafeOrder()
{
var snapshot = Snapshot("APP-A", "SP-A", ArtifactStates.SendPortStarted);
snapshot.Applications[0].ReceiveLocations.Add(new ReceiveLocationState { Application = "APP-A", Name = "RL-A", Enabled = true });
snapshot.Applications[0].Orchestrations.Add(new OrchestrationState { Application = "APP-A", Name = "ORCH-A", OrchestrationStatus = ArtifactStates.OrchestrationStarted });
var appB = Snapshot("APP-B", "SP-B", ArtifactStates.SendPortStarted).Applications[0];
appB.ReceiveLocations.Add(new ReceiveLocationState { Application = "APP-B", Name = "RL-B", Enabled = true });
appB.Orchestrations.Add(new OrchestrationState { Application = "APP-B", Name = "ORCH-B", OrchestrationStatus = ArtifactStates.OrchestrationStarted });
snapshot.Applications.Add(appB);
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
var plan = new BizTalkOperationService(null).CreateShutdownPlan(snapshot, snapshot.Server);
var lastReceiveLocation = plan.Steps.FindLastIndex(x => x.Kind == "ReceiveLocation");
var firstOrchestration = plan.Steps.FindIndex(x => x.Kind == "Orchestration");
var lastOrchestration = plan.Steps.FindLastIndex(x => x.Kind == "Orchestration");
var firstSendPort = plan.Steps.FindIndex(x => x.Kind == "SendPort");
var lastSendPort = plan.Steps.FindLastIndex(x => x.Kind == "SendPort");
var firstHost = plan.Steps.FindIndex(x => x.Kind == "HostInstance");
Assert(lastReceiveLocation < firstOrchestration, "receive locations were not globally first");
Assert(lastOrchestration < firstSendPort, "orchestrations were not globally before send ports");
Assert(lastSendPort < firstHost, "host instances were not globally last");
}
/// <summary>Prüft ENTSSO als erste Voraussetzung des Emergency Restore.</summary>
private static void EmergencyRestorePlanStartsSsoFirst()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
var plan = new BizTalkOperationService(null).CreateEmergencyRestorePlan(snapshot, snapshot.Server);
Assert(plan.Mode == OperationMode.EmergencyRestore.ToString(), "emergency restore mode missing");
Assert(plan.Steps.First().Kind == "WindowsService" && plan.Steps.First().Name == "ENTSSO", "ENTSSO must be the first emergency step");
Assert(plan.Steps[1].Kind == "HostInstance", "host instance must follow ENTSSO");
}
/// <summary>Prüft, dass Kurzname und FQDN dieselbe Host Instance nicht fälschlich überspringen.</summary>
private static void HostInstancePlanAcceptsShortAndFqdnServer()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.Server = "BIZTALK-A.example.local";
snapshot.HostInstances.Add(new HostInstanceState
{
InstanceName = "HOST:BIZTALK-A",
HostName = "HOST",
Server = "BIZTALK-A.example.local",
RawState = ArtifactStates.HostStarted
});
var plan = new BizTalkOperationService(null).CreateRestorePlan(snapshot, "BIZTALK-A");
Assert(plan.Steps.First(x => x.Kind == "HostInstance").Execute, "short name/FQDN match incorrectly skipped host instance");
}
/// <summary>Prüft, dass eine Scheduler-artige Exception spätere Schritte nicht mehr verhindert.</summary>
private static void PlanExecutionContinuesAfterSchedulerFailure()
{
var plan = TestPlan("RL-A", "RV_PMP_Trigger_Schedule", "RL-C");
var runtime = new FakeOperationStepRuntime { FailingName = "RV_PMP_Trigger_Schedule" };
var report = new OperationPlanExecutor(null).Execute(plan, TestOptions(false), runtime);
Assert(runtime.Calls.SequenceEqual(new[] { "RL-A", "RV_PMP_Trigger_Schedule", "RL-C" }), "executor stopped after isolated failure");
Assert(report.SucceededCount == 2 && report.FailedCount == 1, "unexpected continuation summary");
Assert(report.Steps[1].Outcome == OperationStepOutcomes.Failed, "failed step outcome missing");
Assert(report.Steps[1].Error.Contains("Microsoft.BizTalk.Scheduler"), "nested Scheduler exception missing from report");
}
/// <summary>Prüft idempotentes Überspringen eines bereits erreichten Sollzustands.</summary>
private static void PlanExecutionSkipsAlreadySatisfiedState()
{
var plan = TestPlan("ALREADY", "CHANGE");
var runtime = new FakeOperationStepRuntime();
runtime.AlreadySatisfiedNames.Add("ALREADY");
var report = new OperationPlanExecutor(null).Execute(plan, TestOptions(false), runtime);
Assert(report.AlreadySatisfiedCount == 1 && report.SucceededCount == 1 && report.FailedCount == 0, "idempotent outcome counts are wrong");
Assert(runtime.MutatedNames.SequenceEqual(new[] { "CHANGE" }), "already-satisfied step was mutated");
}
/// <summary>Prüft die dauerhafte Serialisierung eines Teilfehler-Reports.</summary>
private static void ExecutionReportRoundTripPreservesFailure()
{
InTemp(directory =>
{
var report = new OperationPlanExecutor(null).Execute(TestPlan("FAIL", "CONTINUE"), TestOptions(false), new FakeOperationStepRuntime { FailingName = "FAIL" });
var path = Path.Combine(directory, "restore-result.json");
JsonFileStore.Save(path, report);
var loaded = JsonFileStore.Load<OperationExecutionReport>(path);
Assert(loaded.HasFailures && loaded.FailedCount == 1, "serialized report lost failure summary");
Assert(loaded.Steps.Count == 2 && loaded.Steps[1].Outcome == OperationStepOutcomes.Succeeded, "serialized report lost continued step");
});
}
/// <summary>Prüft die Neutralisierung formelartiger CSV-Feldwerte.</summary>
private static void CsvNeutralizesFormulaValues()
{
@@ -399,6 +500,96 @@ namespace BizTalkPlatformManagementTool.Tests
});
}
/// <summary>Erstellt einen minimalen ausführbaren Testplan.</summary>
/// <param name="names">Die Namen der Testschritte in Ausführungsreihenfolge.</param>
/// <returns>Ein Restore-Testplan.</returns>
private static OperationPlan TestPlan(params string[] names)
{
var plan = new OperationPlan
{
Mode = OperationMode.Restore.ToString(),
CreatedAt = DateTimeOffset.Now.ToString("o"),
Server = Environment.MachineName
};
foreach (var name in names)
{
plan.Steps.Add(new OperationStep
{
Kind = "ReceiveLocation",
Application = "APP",
Name = name,
Action = "Restore " + name,
WmiClass = "MSBTS_ReceiveLocation",
KeyProperty = "Name",
KeyValue = name,
MethodName = "Enable",
Execute = true
});
}
return plan;
}
/// <summary>Erstellt konsistente Optionen für portable Executor-Tests.</summary>
/// <param name="dryRun">True für einen simulierenden Lauf.</param>
/// <returns>Testoptionen.</returns>
private static OperationOptions TestOptions(bool dryRun)
{
return new OperationOptions
{
Server = Environment.MachineName,
OutputDirectory = Path.GetTempPath(),
StateFile = "before.json",
DryRun = dryRun,
WaitTimeoutSeconds = 30,
PollIntervalSeconds = 1
};
}
/// <summary>Simuliert bereits erreichte Zustände, Mutationen und isolierte Laufzeitfehler.</summary>
private sealed class FakeOperationStepRuntime : IOperationStepRuntime
{
/// <summary>Initialisiert die Aufruf- und Mutationslisten.</summary>
public FakeOperationStepRuntime()
{
Calls = new List<string>();
MutatedNames = new List<string>();
AlreadySatisfiedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>Gets or sets the step name that throws the simulated Scheduler error.</summary>
public string FailingName { get; set; }
/// <summary>Gets every runtime call in order.</summary>
public List<string> Calls { get; private set; }
/// <summary>Gets only steps that required a simulated mutation.</summary>
public List<string> MutatedNames { get; private set; }
/// <summary>Gets the names reported as already satisfied.</summary>
public HashSet<string> AlreadySatisfiedNames { get; private set; }
/// <summary>Simuliert einen zustandsbewussten Laufzeitschritt.</summary>
/// <param name="step">Der Testschritt.</param>
/// <param name="options">Die ungenutzten Testoptionen.</param>
/// <returns>Das simulierte Ergebnis.</returns>
public RuntimeStepOutcome Execute(OperationStep step, OperationOptions options)
{
Calls.Add(step.Name);
if (string.Equals(step.Name, FailingName, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Could not validate TransportTypeData.",
new FileNotFoundException("Could not load file or assembly Microsoft.BizTalk.Scheduler, Version=3.13.0.0."));
}
if (AlreadySatisfiedNames.Contains(step.Name))
{
return RuntimeStepOutcome.AlreadySatisfied;
}
MutatedNames.Add(step.Name);
return RuntimeStepOutcome.Succeeded;
}
}
/// <summary>Erstellt einen minimalen Snapshot für Vergleiche und Planprüfungen.</summary>
/// <param name="application">Der Name der Testanwendung.</param>
/// <param name="port">Der Name des Test-Send-Ports.</param>