Add transactional installer and harden runtime operations

This commit is contained in:
2026-08-11 11:35:11 +02:00
parent e8df6042d2
commit a74528e5c6
41 changed files with 3010 additions and 119 deletions
+1
View File
@@ -18,6 +18,7 @@ obj/
*.png *.png
work/ work/
release/* release/*
artifacts/
# PowerShell specifics # PowerShell specifics
*.ps1.orig *.ps1.orig
+18
View File
@@ -4,6 +4,12 @@ VisualStudioVersion = 15.0.28307.168
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkPlatformManagementTool", "src\BizTalkPlatformManagementTool\BizTalkPlatformManagementTool.csproj", "{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkPlatformManagementTool", "src\BizTalkPlatformManagementTool\BizTalkPlatformManagementTool.csproj", "{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkPlatformManagementTool.Setup", "src\BizTalkPlatformManagementTool.Setup\BizTalkPlatformManagementTool.Setup.csproj", "{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkPlatformManagementTool.Packager", "src\BizTalkPlatformManagementTool.Packager\BizTalkPlatformManagementTool.Packager.csproj", "{74A5D422-0BA5-4559-BD81-C89C071A8FE4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkPlatformManagementTool.Tests", "tests\BizTalkPlatformManagementTool.Tests\BizTalkPlatformManagementTool.Tests.csproj", "{318F4307-F62C-47C9-9B90-F0C9BF2F812A}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -14,6 +20,18 @@ Global
{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}.Release|Any CPU.Build.0 = Release|Any CPU {2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}.Release|Any CPU.Build.0 = Release|Any CPU
{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}.Release|Any CPU.Build.0 = Release|Any CPU
{74A5D422-0BA5-4559-BD81-C89C071A8FE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{74A5D422-0BA5-4559-BD81-C89C071A8FE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{74A5D422-0BA5-4559-BD81-C89C071A8FE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{74A5D422-0BA5-4559-BD81-C89C071A8FE4}.Release|Any CPU.Build.0 = Release|Any CPU
{318F4307-F62C-47C9-9B90-F0C9BF2F812A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{318F4307-F62C-47C9-9B90-F0C9BF2F812A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{318F4307-F62C-47C9-9B90-F0C9BF2F812A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{318F4307-F62C-47C9-9B90-F0C9BF2F812A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+16
View File
@@ -1,6 +1,22 @@
# Changelog # Changelog
## [2.1.0] - 2026-08-11
### Added
- Transactional Windows setup/updater with SHA-256 payload manifest, isolated staging, pre/post activation self-tests, rollback, shortcuts and uninstall registration.
- Certutil-compatible installer ZIP as Base64 TXT plus separate ZIP SHA-256 file.
- WMI-free application self-test and regression test project covering persistence, diffs, restore safety, CSV hardening and installer rollback.
- UAC manifest and single-instance protection for the management application.
### Changed
- Snapshot and plan JSON writes now replace files atomically and keep BOM-tolerant reads.
- Snapshot diffs identify artifacts by application and name.
- Restore planning rejects snapshots from a different target server.
- Real shutdown/restore requires confirmation of the fully prepared and saved plan.
- Runtime logs are written to `%ProgramData%\BizTalkPlatformManagementTool\Logs` with executable-directory fallback.
- WMI query objects and WMI method output objects are disposed deterministically.
- CSV exports neutralize formula-like cell prefixes.
## [Unreleased] - 2026-04-27 ## [Unreleased] - 2026-04-27
### Changed ### Changed
- Projekt auf `BizTalkPlatformManagementTool` umbenannt. - Projekt auf `BizTalkPlatformManagementTool` umbenannt.
+52 -5
View File
@@ -1,6 +1,6 @@
# BizTalk Platform Management Tool Dokumentation # BizTalk Platform Management Tool Dokumentation
**Stand:** 2026-04-27 **Stand:** 2026-08-11
**Implementierung:** C# WinForms, .NET Framework 4.6.1 **Implementierung:** C# WinForms, .NET Framework 4.6.1
**Archivierte PowerShell-Version:** `archive/powershell/BizTalkPlatformManagementTool.ps1` **Archivierte PowerShell-Version:** `archive/powershell/BizTalkPlatformManagementTool.ps1`
@@ -21,6 +21,10 @@ Das BizTalk Platform Management Tool unterstützt kontrollierte Wartungsfenster
- UI: `src/BizTalkPlatformManagementTool/Ui/MainForm.cs` - UI: `src/BizTalkPlatformManagementTool/Ui/MainForm.cs`
- WMI-Zugriff: `src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs` - WMI-Zugriff: `src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs`
- Operationslogik: `src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs` - Operationslogik: `src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs`
- Snapshot-Validierung: `src/BizTalkPlatformManagementTool/Services/SnapshotValidator.cs`
- Installer: `src/BizTalkPlatformManagementTool.Setup`
- Release-Paketierung: `src/BizTalkPlatformManagementTool.Packager`
- Regressionstests: `tests/BizTalkPlatformManagementTool.Tests`
- PowerShell-Archiv: `archive/powershell/BizTalkPlatformManagementTool.ps1` - PowerShell-Archiv: `archive/powershell/BizTalkPlatformManagementTool.ps1`
## UI Workflow ## UI Workflow
@@ -42,9 +46,9 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des
- Dry-run ist standardmäßig aktiviert. - Dry-run ist standardmäßig aktiviert.
- Beim Start wird geprüft, ob die Anwendung mit Administratorrechten läuft. Ohne erhöhte Rechte wird eine Fehlermeldung angezeigt und die Anwendung beendet. - Beim Start wird geprüft, ob die Anwendung mit Administratorrechten läuft. Ohne erhöhte Rechte wird eine Fehlermeldung angezeigt und die Anwendung beendet.
- Echte Shutdown-/Restore-Aktionen verlangen bei deaktiviertem Dry-run eine zusätzliche Bestätigung. - Echte Shutdown-/Restore-Aktionen verlangen erst nach Erzeugung und Speicherung des frischen Plans eine zusätzliche Bestätigung mit Zielserver, Plandatei und exakter Zahl ausführbarer Schritte.
- Jede Operation schreibt Einträge in das sichtbare Operation Log. - Jede Operation schreibt Einträge in das sichtbare Operation Log.
- Zusätzlich wird neben der EXE eine tägliche Logdatei `BizTalkPlatformManagementTool-yyyy-MM-dd.log` geschrieben. - Zusätzlich wird unter `%ProgramData%\BizTalkPlatformManagementTool\Logs` eine tägliche Logdatei `BizTalkPlatformManagementTool-yyyy-MM-dd.log` geschrieben. Nur wenn ProgramData nicht verfügbar ist, wird auf das EXE-Verzeichnis zurückgefallen.
- Logdateien werden rollierend für den aktuellen Tag plus vier vorherige Tage vorgehalten. - Logdateien werden rollierend für den aktuellen Tag plus vier vorherige Tage vorgehalten.
- Der Kopfbereich zeigt den zuletzt erkannten Umgebungsstatus aus den Host Instances. - Der Kopfbereich zeigt den zuletzt erkannten Umgebungsstatus aus den Host Instances.
- Operationspläne werden vor Laufzeitänderungen gespeichert. - Operationspläne werden vor Laufzeitänderungen gespeichert.
@@ -52,6 +56,9 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des
- Wartezeiten nutzen konfigurierbare Timeout- und Polling-Werte. - Wartezeiten nutzen konfigurierbare Timeout- und Polling-Werte.
- Host Instances auf anderen Servern werden übersprungen und als Warnung protokolliert. - Host Instances auf anderen Servern werden übersprungen und als Warnung protokolliert.
- Echte Shutdown-/Restore-Schritte protokollieren WMI-Klasse, Schlüssel, Zielobjekt und Methode, damit Fehler wie WMI-Query- oder Methodenfehler eindeutig zugeordnet werden können. - Echte Shutdown-/Restore-Schritte protokollieren WMI-Klasse, Schlüssel, Zielobjekt und Methode, damit Fehler wie WMI-Query- oder Methodenfehler eindeutig zugeordnet werden können.
- Restore-Pläne werden abgelehnt, wenn Snapshot-Server und ausgewählter Zielserver nicht übereinstimmen; Kurzname und FQDN desselben Hosts gelten als identisch.
- Pro Windows-Sitzung ist nur eine Toolinstanz zulässig; das Fenster kann während einer aktiven WMI-Operation nicht geschlossen werden.
- Die EXE enthält zusätzlich einen WMI-freien `--self-test`, den der Installer vor und nach der Aktivierung ausführt.
## Shutdown-Reihenfolge ## Shutdown-Reihenfolge
@@ -81,13 +88,53 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des
- Nachher-Snapshots: `shutdown-after.json`, `restore-after.json` - Nachher-Snapshots: `shutdown-after.json`, `restore-after.json`
- Diff: `diff.json`, `diff.csv`, `diff.html` - Diff: `diff.json`, `diff.csv`, `diff.html`
- Snapshot-Reports: `*.csv`, `*.hosts.csv`, `*.html` - Snapshot-Reports: `*.csv`, `*.hosts.csv`, `*.html`
- Laufzeitlogs neben der EXE: `BizTalkPlatformManagementTool-yyyy-MM-dd.log` - Laufzeitlogs: `%ProgramData%\BizTalkPlatformManagementTool\Logs\BizTalkPlatformManagementTool-yyyy-MM-dd.log`
- Installerlogs: `%ProgramData%\BizTalkPlatformManagementTool\InstallerLogs\setup-*.log`
## Fehleranalyse ## Fehleranalyse
Bei echten Shutdown- und Restore-Aktionen wird jeder Schritt vor der Ausführung mit Artefakttyp, WMI-Klasse, Schlüsselproperty, Schlüsselwert und Methodenname protokolliert. Die Objektauflösung verwendet eine breite `SELECT * FROM <class>`-Abfrage und filtert danach im Prozess auf den Schlüsselwert. Dadurch können Host-Instance-Namen und andere BizTalk-Namen mit Sonderzeichen keine ungültige WMI-WQL-`WHERE`-Query mehr erzeugen. Bei echten Shutdown- und Restore-Aktionen wird jeder Schritt vor der Ausführung mit Artefakttyp, WMI-Klasse, Schlüsselproperty, Schlüsselwert und Methodenname protokolliert. Die Objektauflösung verwendet eine breite `SELECT * FROM <class>`-Abfrage und filtert danach im Prozess auf den Schlüsselwert. Dadurch können Host-Instance-Namen und andere BizTalk-Namen mit Sonderzeichen keine ungültige WMI-WQL-`WHERE`-Query mehr erzeugen.
Snapshot- und Plan-JSON-Dateien werden als UTF-8 ohne BOM geschrieben. Beim Laden werden vorhandene Dateien mit UTF-8-BOM oder durch Encoding-Konvertierung sichtbar gewordenem BOM-Marker toleriert. Snapshot- und Plan-JSON-Dateien werden als UTF-8 ohne BOM über eine temporäre Datei im Zielverzeichnis und anschließenden atomaren Austausch geschrieben. Beim Laden werden vorhandene Dateien mit UTF-8-BOM oder durch Encoding-Konvertierung sichtbar gewordenem BOM-Marker toleriert. Deserialisierte Snapshots werden normalisiert und auf leere Namen, Duplikate und fehlende Strukturen geprüft.
Diffs verwenden den zusammengesetzten Schlüssel aus Anwendung und Artefaktname. Gleichnamige Artefakte in verschiedenen BizTalk-Anwendungen überschreiben sich daher nicht mehr. CSV-Werte mit Präfix `=`, `+`, `-`, `@` oder Tab werden mit einem Apostroph neutralisiert, damit Tabellenkalkulationen sie nicht als Formel ausführen.
WMI-Abfrageobjekte sowie Rückgabeobjekte von WMI-Methoden werden deterministisch freigegeben. Polling wartet am Timeout-Ende nur noch für die tatsächlich verbleibende Zeit.
## Installer- und Updatearchitektur
Das Releasepaket enthält `Setup.exe`, `application.manifest`, den Payload-Ordner `application` und die Installationsanleitung. Vor jeder Änderung prüft der Installer, dass jede Payload-Datei vollständig im Manifest enthalten ist und Länge sowie SHA-256 entsprechen. Nicht deklarierte Zusatzdateien führen zum Abbruch.
Die Aktivierung ist transaktional aufgebaut:
1. Payload validieren.
2. Eindeutiges Staging-Verzeichnis neben dem Installationsziel erstellen.
3. Kopierte Dateien nochmals per SHA-256 und die Staging-EXE per `--self-test` prüfen.
4. Laufende Toolinstanz ausschließen.
5. Bestehendes Verzeichnis in ein eindeutiges Backup verschieben.
6. Staging auf demselben Volume als produktives Verzeichnis aktivieren.
7. Aktivierte EXE erneut per `--self-test` prüfen.
8. Erst danach Startmenü, optionale Desktop-Verknüpfung und Windows-Uninstall-Eintrag schreiben.
9. Bei einem Fehler die neue Version entfernen und das Backup einschließlich Windows-Integration wiederherstellen.
Das äußere ZIP erhält zusätzlich eine SHA-256-Datei und eine Certutil-kompatible Base64-TXT-Datei. Diese äußere Prüfsumme erkennt Übertragungsfehler; sie ist keine digitale Herausgebersignatur. Details und Befehle stehen in `Installation.md`.
## Automatisierte Verifikation
`tests/BizTalkPlatformManagementTool.Tests` prüft derzeit:
- Atomare JSON-Aktualisierung und BOM-Kompatibilität.
- Anwendungsbezogene Diff-Identität bei gleichnamigen Artefakten.
- Restore-Servergrenze und sichere Restore-Reihenfolge.
- Neutralisierung formelartiger CSV-Werte.
- Erkennung manipulierter Payload-Dateien.
- Ablehnung nicht deklarierter Dateien und aus dem Payload-Verzeichnis ausbrechender Manifestpfade.
- Staging-Fehler ohne Mutation einer bestehenden Installation.
- Erfolgreiche Staging-Aktivierung.
- Wiederherstellung der Vorversion, wenn der Self-Test nach Aktivierung fehlschlägt.
- Deinstallation durch atomare Umbenennung des Programmverzeichnisses vor der bestmöglichen Bereinigung.
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.
## Status Mapping ## Status Mapping
+68 -26
View File
@@ -1,40 +1,82 @@
# Installation # Installation und Update
## Voraussetzungen ## Voraussetzungen
- Windows Server 2019/2022 oder ein Windows-Administrationshost - Windows Server 2019/2022 oder ein Windows-Administrationshost
- Microsoft BizTalk Server 2020 oder BizTalk Administration Tools - Microsoft BizTalk Server 2020 oder BizTalk Administration Tools
- .NET Framework 4.6.1 Runtime - .NET Framework 4.6.1 Runtime
- Für Builds: Visual Studio mit .NET Framework 4.6.1 Developer Pack - Lokale Administratorrechte; `Setup.exe` und die Anwendung fordern diese per UAC-Manifest an
- Zugriff auf den WMI-Namespace `root\MicrosoftBizTalkServer` - Zugriff auf `root\MicrosoftBizTalkServer` mit den erforderlichen BizTalk-Rechten
- Ausreichende Rechte zum Lesen und Ändern von BizTalk-Artefakten
- Lokale Administratorrechte und Start der EXE mit **Als Administrator ausführen**
- Schreibrechte im Verzeichnis der EXE für die tägliche Logdatei
## Build ## Übergabe als TXT
1. Repository öffnen. Das Release erzeugt folgende Dateien unter `artifacts`:
2. `BizTalkPlatformManagementTool.sln` in Visual Studio öffnen.
3. Konfiguration `Release|Any CPU` auswählen.
4. Solution bauen.
5. Das Ergebnis liegt unter `src\BizTalkPlatformManagementTool\bin\Release\`.
## Deployment - `BizTalkPlatformManagementTool-Setup.zip.b64.txt`: Certutil-kompatible Base64-Übertragung
- `BizTalkPlatformManagementTool-Setup.zip.sha256.txt`: SHA-256 des ZIP-Archivs
- `BizTalkPlatformManagementTool-Setup.zip`: direkt entpackbares Installationspaket
1. Den Release-Ordner auf einen BizTalk-Server oder einen Administrationshost kopieren. Auf dem Zielsystem wird die TXT-Datei so rekonstruiert und geprüft:
2. Sicherstellen, dass der ausführende Benutzer WMI-Zugriff auf `root\MicrosoftBizTalkServer` hat.
3. `BizTalkPlatformManagementTool.exe` mit **Als Administrator ausführen** starten.
4. Als Ausgabeverzeichnis einen Ordner wählen, in dem Plan-, Snapshot- und Report-Dateien abgelegt werden dürfen.
5. Prüfen, dass im EXE-Verzeichnis `BizTalkPlatformManagementTool-yyyy-MM-dd.log` geschrieben werden kann. Logs werden für maximal fünf Tage vorgehalten.
## Erster Funktionstest ```bat
certutil -decode BizTalkPlatformManagementTool-Setup.zip.b64.txt BizTalkPlatformManagementTool-Setup.zip
certutil -hashfile BizTalkPlatformManagementTool-Setup.zip SHA256
type BizTalkPlatformManagementTool-Setup.zip.sha256.txt
```
1. Anwendung mit **Als Administrator ausführen** starten. Der Hash aus `certutil` muss exakt dem Wert in der SHA-256-Datei entsprechen. Danach das ZIP in einen neuen Ordner entpacken und `Setup.exe` starten. Ein Code-Signing-Zertifikat ist derzeit nicht Bestandteil des Repositories; deshalb schützt SHA-256 gegen Übertragungsfehler, ersetzt aber keine Signaturprüfung der Herausgeberidentität.
2. Zielserver eintragen oder den vorgeschlagenen lokalen Server verwenden.
3. **Dry run** aktiviert lassen.
4. **Diagnose** ausführen.
5. **Snapshot Before** ausführen und prüfen, ob `before.json` sowie CSV/HTML-Reports erzeugt wurden.
## Produktive Nutzung ## Neuinstallation
Vor produktiven Änderungen immer zuerst einen Dry-run ausführen und die erzeugten `shutdown-plan.json` beziehungsweise `restore-plan.json` prüfen. Dry-run erst deaktivieren, wenn der Plan fachlich und technisch korrekt ist. 1. ZIP vollständig entpacken; `Setup.exe`, `application.manifest` und der Ordner `application` müssen nebeneinander liegen.
2. `Setup.exe` starten und die UAC-Abfrage bestätigen.
3. Optional die Desktop-Verknüpfung abwählen.
4. **Installieren** wählen.
5. Den Abschluss und den Pfad des Diagnoselogs prüfen.
6. Die Anwendung starten, **Dry run** aktiviert lassen und zuerst **Diagnose** ausführen.
Installationsziele:
- Programm: `%ProgramFiles%\BizTalkPlatformManagementTool`
- Laufzeitlogs: `%ProgramData%\BizTalkPlatformManagementTool\Logs`
- Installerlogs und Uninstaller: `%ProgramData%\BizTalkPlatformManagementTool`
- Startmenü: `BizTalk Platform Management Tool`
## Update und Rollback
Der Installer verändert eine bestehende Installation erst nach erfolgreicher Paketprüfung:
1. Jede Payload-Datei wird gegen Länge und SHA-256 im `application.manifest` geprüft; unbekannte Zusatzdateien werden abgelehnt.
2. Die neue Version wird in ein eindeutiges Staging-Verzeichnis kopiert und dort mit `--self-test` geprüft.
3. Eine laufende Toolinstanz blockiert das Update.
4. Die bestehende Installation wird in ein Backup-Verzeichnis verschoben.
5. Das validierte Staging wird auf demselben Volume aktiviert.
6. Die aktivierte EXE führt den Self-Test erneut aus.
7. Erst danach werden Verknüpfungen und Windows-Uninstall-Eintrag aktualisiert.
Schlägt ein Schritt nach Beginn der Umschaltung fehl, entfernt das Setup die neue Version und stellt das Backup wieder her. Staging und Backup werden anschließend bestmöglich bereinigt. Das genaue Phasenprotokoll steht unter `%ProgramData%\BizTalkPlatformManagementTool\InstallerLogs`.
## 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.
## Build, Test und Paketierung
In einer Visual-Studio-Developer-Eingabeaufforderung mit .NET Framework 4.6.1 Developer Pack:
```bat
scripts\test-release.cmd
scripts\package-release.cmd
```
`test-release.cmd` baut alle vier Projekte und führt die Regressionstests aus. `package-release.cmd` baut und testet erneut, erzeugt Paket, ZIP, Base64-TXT und SHA-256-Datei und validiert dabei das interne Payload-Manifest.
Unter Mono kann der portable Anteil lokal geprüft werden:
```sh
msbuild BizTalkPlatformManagementTool.sln /p:Configuration=Release /p:Platform="Any CPU" /m:1
mono tests/BizTalkPlatformManagementTool.Tests/bin/Release/BizTalkPlatformManagementTool.Tests.exe
mono src/BizTalkPlatformManagementTool/bin/Release/BizTalkPlatformManagementTool.exe --self-test
```
Mono ersetzt nicht die abschließende Prüfung von UAC, Registry, Verknüpfungen und BizTalk-WMI auf einem repräsentativen Windows-/BizTalk-System.
+13 -4
View File
@@ -21,10 +21,12 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d
- Dry-run mode enabled by default - Dry-run mode enabled by default
- WMI access through `root\MicrosoftBizTalkServer` - WMI access through `root\MicrosoftBizTalkServer`
- Startup check for administrator rights - Startup check for administrator rights
- Detailed operation logging in the GUI and daily rolling log files next to the executable - Detailed operation logging in the GUI and daily rolling log files under ProgramData
- Environment status indicator based on host instance state - Environment status indicator based on host instance state
- Clear and Close actions in the main toolbar - Clear and Close actions in the main toolbar
- No compile-time dependency on BizTalk ExplorerOM assemblies - No compile-time dependency on BizTalk ExplorerOM assemblies
- Transactional Windows installer/updater with SHA-256 payload validation and rollback
- WMI-free runtime self-test plus automated regression test executable
## Safe Usage ## Safe Usage
@@ -39,7 +41,9 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d
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 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 checks administrator rights during startup. If it is not elevated, it shows an error message and exits because BizTalk WMI operations require an elevated administrator process. The application requests administrator rights through its UAC manifest and checks them again during startup. Only one GUI instance can run per Windows session.
Before a real shutdown or restore, the exact fresh plan is saved and a second dialog shows its executable step count, target server and plan path. Restore is rejected when the snapshot server does not match the selected target (short name and FQDN of the same host are accepted).
## Operation Order ## Operation Order
@@ -64,9 +68,9 @@ Orchestrations that were `Bound` are deliberately left unchanged during restore
- `shutdown-after.json`, `restore-after.json` - `shutdown-after.json`, `restore-after.json`
- `diff.json`, `diff.csv`, `diff.html` - `diff.json`, `diff.csv`, `diff.html`
- Snapshot sidecars: `*.csv`, `*.hosts.csv`, `*.html` - Snapshot sidecars: `*.csv`, `*.hosts.csv`, `*.html`
- Runtime logs next to the executable: `BizTalkPlatformManagementTool-yyyy-MM-dd.log` - Runtime logs under `%ProgramData%\BizTalkPlatformManagementTool\Logs`
Log files are retained for the current day plus the previous four days. Older `BizTalkPlatformManagementTool-*.log` files are removed on startup. Log files are retained for the current day plus the previous four days. Older `BizTalkPlatformManagementTool-*.log` files are removed on startup. If ProgramData is unexpectedly unavailable, logging falls back to the executable directory.
## Troubleshooting ## Troubleshooting
@@ -74,14 +78,19 @@ The Operation Log shows the WMI class, key property, key value and method for re
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. 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.
## Build ## Build
Open `BizTalkPlatformManagementTool.sln` in Visual Studio on Windows with the .NET Framework 4.6.1 Developer Pack installed, then build the `Release|Any CPU` configuration. Open `BizTalkPlatformManagementTool.sln` in Visual Studio on Windows with the .NET Framework 4.6.1 Developer Pack installed, then build the `Release|Any CPU` configuration.
The app targets .NET Framework 4.6.1 for compatibility with customer environments that do not have newer .NET Framework developer packs installed. The app targets .NET Framework 4.6.1 for compatibility with customer environments that do not have newer .NET Framework developer packs installed.
Use `scripts\test-release.cmd` for the build and regression suite and `scripts\package-release.cmd` for the tested installer ZIP, Certutil-compatible Base64 TXT and SHA-256 file. See [Installation](Installation.md) for decoding and update/rollback details.
## Documentation ## Documentation
- [Installation](Installation.md) - [Installation](Installation.md)
- [Dokumentation](Dokumentation.md) - [Dokumentation](Dokumentation.md)
- [Installer stability analysis](docs/Installer-Stabilitaetsanalyse-2026-08-11.md)
- [References](REFERENCES.md) - [References](REFERENCES.md)
@@ -0,0 +1,40 @@
# Installer-Stabilitätsanalyse vom 11.08.2026
## Ausgangslage
Vor Version 2.1.0 enthielt das Repository keinen Installer für die C#-Anwendung. Die Datei `release/BizTalkPlatformManagementTool.ps1` ist eine ältere PowerShell-Implementierung des Tools und kein Installationsprogramm. Frühere `release/*.zip.txt` waren Base64-Quellpakete ohne Update-, Abnahme- oder Rollbacklogik.
## Implementierte Sicherheitsgrenzen
- Vollständiges internes Payload-Manifest mit Dateipfad, Länge und SHA-256.
- Ablehnung fehlender, veränderter, doppelter, zusätzlicher oder aus dem Payload-Verzeichnis ausbrechender Pfade.
- Keine Mutation vor vollständig bestandenem Manifest- und Staging-Self-Test.
- Update nur bei geschlossener produktiver Toolinstanz.
- Staging und Backup als eindeutige Geschwister des Installationsverzeichnisses auf demselben Volume.
- Zweiter Self-Test nach Aktivierung und vor Windows-Registrierung.
- Automatisches Datei- und Registrierungsrollback bei Fehlern.
- Dauerhaftes phasenbezogenes Installerlog unter ProgramData.
- Deinstallation über Windows-Uninstall-Eintrag; Diagnoseprotokolle bleiben erhalten.
- Äußere ZIP-Prüfsumme und Certutil-kompatible Base64-TXT für kontrollierte Übertragung.
## Lokal verifiziert
- Release-Build aller Projekte mit Mono MSBuild.
- Elf Regressionstests einschließlich manipulierter/zusätzlicher/ausbrechender Payload-Pfade, Staging-Abbruch ohne Mutation, erzwungenem Fehler des zweiten Self-Tests mit Wiederherstellung der Vorversion und Deinstallation über ein Quarantäneverzeichnis.
- WMI-freier Self-Test der produktiven EXE.
- Erstellung des Installationsordners, ZIPs, Base64-TXTs und der SHA-256-Datei.
- Rückdekodierung der Base64-TXT und Bytevergleich mit dem ZIP.
- Erneute Prüfung des internen Manifests nach der Paketierung.
## Noch auf Windows/BizTalk zu validieren
Die lokale Linux-/Mono-Verifikation kann folgende Windows-spezifische Punkte nicht abschließend beweisen:
1. UAC-Anforderung beider EXE-Dateien auf Windows Server 2019/2022.
2. Startmenü- und optionale Desktop-Verknüpfung über Windows Script Host.
3. 64-Bit-Uninstall-Eintrag und Aufruf über Apps & Features.
4. Updateblockade bei laufender installierter GUI.
5. Reales Rollback bei Dateisperren, Virenscannerzugriff oder Registryfehlern.
6. Diagnose und Laufzeitoperationen gegen `root\MicrosoftBizTalkServer` auf BizTalk Server 2020.
Bis diese Punkte repräsentativ geprüft sind, ist der Installer lokal automatisiert gehärtet, aber noch nicht als vollständig produktionsvalidiert auf Windows/BizTalk zu bezeichnen.
+9
View File
@@ -0,0 +1,9 @@
@echo off
setlocal
where msbuild.exe >nul 2>nul
if errorlevel 1 (
echo MSBuild.exe was not found in PATH. Run this from a Visual Studio Developer Command Prompt.
exit /b 1
)
msbuild "%~dp0..\BizTalkPlatformManagementTool.sln" /p:Configuration=Release /p:Platform="Any CPU" /m:1 /v:minimal
exit /b %ERRORLEVEL%
+6
View File
@@ -0,0 +1,6 @@
@echo off
setlocal
call "%~dp0test-release.cmd"
if errorlevel 1 exit /b 1
"%~dp0..\src\BizTalkPlatformManagementTool.Packager\bin\Release\BizTalkPlatformManagementTool.Packager.exe" "%~dp0.." Release
exit /b %ERRORLEVEL%
+6
View File
@@ -0,0 +1,6 @@
@echo off
setlocal
call "%~dp0build-release.cmd"
if errorlevel 1 exit /b 1
"%~dp0..\tests\BizTalkPlatformManagementTool.Tests\bin\Release\BizTalkPlatformManagementTool.Tests.exe"
exit /b %ERRORLEVEL%
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration><Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{74A5D422-0BA5-4559-BD81-C89C071A8FE4}</ProjectGuid><OutputType>Exe</OutputType>
<RootNamespace>BizTalkPlatformManagementTool.Packager</RootNamespace><AssemblyName>BizTalkPlatformManagementTool.Packager</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion><FileAlignment>512</FileAlignment><Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "><DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize><OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "><DebugType>pdbonly</DebugType><Optimize>true</Optimize><OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
<ItemGroup><Reference Include="System" /><Reference Include="System.Core" /><Reference Include="System.IO.Compression" /><Reference Include="System.IO.Compression.FileSystem" /><Reference Include="System.Security" /></ItemGroup>
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
<ItemGroup><ProjectReference Include="..\BizTalkPlatformManagementTool.Setup\BizTalkPlatformManagementTool.Setup.csproj"><Project>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</Project><Name>BizTalkPlatformManagementTool.Setup</Name></ProjectReference></ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,68 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using BizTalkPlatformManagementTool.Setup;
namespace BizTalkPlatformManagementTool.Packager
{
internal static class Program
{
private static int Main(string[] args)
{
try
{
if (args.Length != 2) throw new ArgumentException("Usage: BizTalkPlatformManagementTool.Packager.exe <repository-root> <configuration>");
var root = Path.GetFullPath(args[0]);
var configuration = args[1];
var artifacts = Path.Combine(root, "artifacts");
var package = Path.Combine(artifacts, "BizTalkPlatformManagementTool-Setup");
var application = Path.Combine(package, "application");
var zip = Path.Combine(artifacts, "BizTalkPlatformManagementTool-Setup.zip");
if (Directory.Exists(package)) Directory.Delete(package, true);
Directory.CreateDirectory(application);
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool.Setup", "bin", configuration, "BizTalkPlatformManagementTool.Setup.exe"), Path.Combine(package, "Setup.exe"));
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool", "bin", configuration, "BizTalkPlatformManagementTool.exe"), Path.Combine(application, "BizTalkPlatformManagementTool.exe"));
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool", "bin", configuration, "BizTalkPlatformManagementTool.exe.config"), Path.Combine(application, "BizTalkPlatformManagementTool.exe.config"));
Copy(Path.Combine(root, "Installation.md"), Path.Combine(package, "INSTALLATION.md"));
PackageManifest.Write(application, Path.Combine(package, "application.manifest"));
PackageManifest.ValidateAndRead(application, Path.Combine(package, "application.manifest"));
if (File.Exists(zip)) File.Delete(zip);
ZipFile.CreateFromDirectory(package, zip, CompressionLevel.Optimal, false);
WriteBase64(zip, zip + ".b64.txt");
File.WriteAllText(zip + ".sha256.txt", PackageManifest.Sha256(zip) + " " + Path.GetFileName(zip) + Environment.NewLine, new UTF8Encoding(false));
Console.WriteLine("SETUP_PACKAGE=" + package);
Console.WriteLine("SETUP_ZIP=" + zip);
Console.WriteLine("SETUP_BASE64=" + zip + ".b64.txt");
Console.WriteLine("SETUP_SHA256=" + zip + ".sha256.txt");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine("Packaging failed: " + ex);
return 1;
}
}
private static void Copy(string source, string target)
{
if (!File.Exists(source)) throw new FileNotFoundException("Required package file missing: " + source, source);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.Copy(source, target, true);
}
private static void WriteBase64(string source, string target)
{
var encoded = Convert.ToBase64String(File.ReadAllBytes(source));
var builder = new StringBuilder(encoded.Length + encoded.Length / 64 + 2);
for (var offset = 0; offset < encoded.Length; offset += 64)
{
builder.Append(encoded, offset, Math.Min(64, encoded.Length - offset));
builder.Append('\n');
}
File.WriteAllText(target, builder.ToString(), new UTF8Encoding(false));
}
}
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>BizTalkPlatformManagementTool.Setup</RootNamespace>
<AssemblyName>BizTalkPlatformManagementTool.Setup</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType><Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.CSharp" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Security" />
<Reference Include="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="InstallerEngine.cs" />
<Compile Include="MainForm.cs" />
<Compile Include="PackageManifest.cs" />
<Compile Include="Program.cs" />
<Compile Include="SetupOperationLog.cs" />
</ItemGroup>
<ItemGroup><None Include="app.manifest" /></ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,403 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32;
namespace BizTalkPlatformManagementTool.Setup
{
internal sealed class InstallerEngine
{
internal const string ApplicationExeName = "BizTalkPlatformManagementTool.exe";
private const string ProductName = "BizTalk Platform Management Tool";
private const string ProductVersion = "2.1.0";
private const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\BizTalkPlatformManagementTool";
private readonly string packageDirectory;
private readonly string installDirectory;
private readonly string dataDirectory;
private readonly bool registerWindowsIntegration;
private readonly Func<string, bool> selfTestRunner;
private sealed class WindowsIntegrationSnapshot
{
public bool RegistryKeyExisted { get; set; }
public Dictionary<string, Tuple<object, RegistryValueKind>> RegistryValues { get; set; }
public byte[] DesktopShortcut { get; set; }
public byte[] StartMenuShortcut { get; set; }
public byte[] Uninstaller { get; set; }
}
public InstallerEngine(string packageDirectory)
: this(
packageDirectory,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "BizTalkPlatformManagementTool"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BizTalkPlatformManagementTool"),
true,
RunApplicationSelfTest)
{
}
internal InstallerEngine(string packageDirectory, string installDirectory, string dataDirectory, bool registerWindowsIntegration, Func<string, bool> selfTestRunner)
{
this.packageDirectory = Path.GetFullPath(packageDirectory);
this.installDirectory = Path.GetFullPath(installDirectory);
this.dataDirectory = Path.GetFullPath(dataDirectory);
this.registerWindowsIntegration = registerWindowsIntegration;
this.selfTestRunner = selfTestRunner ?? throw new ArgumentNullException("selfTestRunner");
}
/// <summary>Gets the fixed machine-wide application installation directory.</summary>
public string InstallDirectory { get { return installDirectory; } }
/// <summary>Gets whether this setup copy contains a complete install/update payload.</summary>
public bool HasInstallPayload { get { return Directory.Exists(Path.Combine(packageDirectory, "application")) && File.Exists(Path.Combine(packageDirectory, "application.manifest")); } }
/// <summary>Gets whether the application executable is present at the install target.</summary>
public bool IsInstalled { get { return File.Exists(Path.Combine(installDirectory, ApplicationExeName)); } }
/// <summary>Validates, stages and transactionally installs or updates the application.</summary>
public void Install(bool createDesktopShortcut, Action<string> report)
{
report = report ?? delegate { };
Directory.CreateDirectory(dataDirectory);
var log = SetupOperationLog.Create(dataDirectory, "install-update");
Action<string> write = message => { log.Write("INFO", message); report(message); };
write("Diagnoselog: " + (log.FilePath.Length == 0 ? "nicht verfuegbar" : log.FilePath));
var sourceApplication = Path.Combine(packageDirectory, "application");
var manifestPath = Path.Combine(packageDirectory, "application.manifest");
var stagingDirectory = installDirectory + ".staging." + Guid.NewGuid().ToString("N");
var backupDirectory = installDirectory + ".backup." + Guid.NewGuid().ToString("N");
var hadExistingInstallation = Directory.Exists(installDirectory);
var integrationSnapshot = registerWindowsIntegration ? CaptureWindowsIntegration() : null;
var backupCreated = false;
var activated = false;
try
{
write("Phase 1/6: Paketmanifest und SHA-256 pruefen.");
var files = PackageManifest.ValidateAndRead(sourceApplication, manifestPath);
RequirePayload(files, ApplicationExeName);
RequirePayload(files, ApplicationExeName + ".config");
EnsureApplicationNotRunning();
write("Phase 2/6: Update in isoliertes Staging kopieren.");
CopyPayload(sourceApplication, stagingDirectory, files);
var stagedExe = Path.Combine(stagingDirectory, ApplicationExeName);
if (!selfTestRunner(stagedExe)) throw new InvalidOperationException("Der WMI-freie Self-Test der Staging-Version ist fehlgeschlagen.");
write("Staging-Self-Test erfolgreich.");
write("Phase 3/6: Vorhandene Version sichern und Staging atomar aktivieren.");
if (hadExistingInstallation)
{
Directory.Move(installDirectory, backupDirectory);
backupCreated = true;
}
Directory.Move(stagingDirectory, installDirectory);
activated = true;
write("Phase 4/6: Aktivierte Version erneut pruefen.");
var targetExe = Path.Combine(installDirectory, ApplicationExeName);
if (!selfTestRunner(targetExe)) throw new InvalidOperationException("Der Self-Test der aktivierten Version ist fehlgeschlagen.");
File.WriteAllText(
Path.Combine(installDirectory, "install-state.txt"),
"ProductVersion=" + ProductVersion + Environment.NewLine
+ "InstalledAt=" + DateTimeOffset.Now.ToString("o", CultureInfo.InvariantCulture) + Environment.NewLine
+ "ManifestSha256=" + PackageManifest.Sha256(manifestPath) + Environment.NewLine,
new UTF8Encoding(false));
write("Phase 5/6: Windows-Integration registrieren.");
if (registerWindowsIntegration)
{
RegisterWindowsIntegration(targetExe, createDesktopShortcut);
}
write("Phase 6/6: Backup bereinigen.");
TryDeleteDirectory(backupDirectory, write);
write("Installation/Update erfolgreich abgeschlossen: " + installDirectory);
}
catch (Exception ex)
{
log.Write("ERROR", ex.ToString());
var rollbackErrors = new List<string>();
try
{
if (activated && Directory.Exists(installDirectory)) Directory.Delete(installDirectory, true);
if (backupCreated && Directory.Exists(backupDirectory)) Directory.Move(backupDirectory, installDirectory);
write(backupCreated ? "Rollback: vorherige Programmversion wiederhergestellt." : "Rollback: unvollstaendige Neuinstallation entfernt.");
}
catch (Exception rollbackException)
{
rollbackErrors.Add(rollbackException.Message);
log.Write("ERROR", "Rollback files: " + rollbackException);
}
try
{
if (registerWindowsIntegration)
{
RestoreWindowsIntegration(integrationSnapshot);
}
}
catch (Exception rollbackException)
{
rollbackErrors.Add(rollbackException.Message);
log.Write("ERROR", "Rollback registration: " + rollbackException);
}
TryDeleteDirectory(stagingDirectory, write);
TryDeleteDirectory(backupDirectory, write);
var suffix = rollbackErrors.Count == 0 ? " Rollback erfolgreich." : " Rollback-Fehler: " + string.Join(" | ", rollbackErrors);
throw new InvalidOperationException("Installation/Update fehlgeschlagen." + suffix + " Ursache: " + ex.Message, ex);
}
}
/// <summary>Removes the active program directory and registered Windows integration.</summary>
public void Uninstall(Action<string> report)
{
report = report ?? delegate { };
Directory.CreateDirectory(dataDirectory);
var log = SetupOperationLog.Create(dataDirectory, "uninstall");
Action<string> write = message => { log.Write("INFO", message); report(message); };
var integrationSnapshot = registerWindowsIntegration ? CaptureWindowsIntegration() : null;
var removalDirectory = installDirectory + ".removed." + Guid.NewGuid().ToString("N");
var filesMoved = false;
try
{
EnsureApplicationNotRunning();
if (Directory.Exists(installDirectory))
{
Directory.Move(installDirectory, removalDirectory);
filesMoved = true;
}
if (registerWindowsIntegration) RemoveWindowsIntegration();
TryDeleteDirectory(removalDirectory, write);
write("Deinstallation erfolgreich. Installer-Logs bleiben erhalten: " + Path.Combine(dataDirectory, "InstallerLogs"));
}
catch (Exception ex)
{
log.Write("ERROR", ex.ToString());
if (filesMoved && !Directory.Exists(installDirectory) && Directory.Exists(removalDirectory))
{
try { Directory.Move(removalDirectory, installDirectory); }
catch (Exception rollbackException) { log.Write("ERROR", "Uninstall rollback files: " + rollbackException); }
}
if (registerWindowsIntegration)
{
try { RestoreWindowsIntegration(integrationSnapshot); }
catch (Exception rollbackException) { log.Write("ERROR", "Uninstall rollback registration: " + rollbackException); }
}
throw;
}
}
private void RegisterWindowsIntegration(string targetExe, bool createDesktopShortcut)
{
var programsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName);
Directory.CreateDirectory(programsDirectory);
CreateShortcut(Path.Combine(programsDirectory, ProductName + ".lnk"), targetExe, installDirectory, ProductName);
if (createDesktopShortcut) CreateShortcut(DesktopShortcutPath, targetExe, installDirectory, ProductName);
else if (File.Exists(DesktopShortcutPath)) File.Delete(DesktopShortcutPath);
var setupDirectory = Path.Combine(dataDirectory, "Setup");
Directory.CreateDirectory(setupDirectory);
var uninstaller = Path.Combine(setupDirectory, "Uninstall.exe");
File.Copy(Assembly.GetExecutingAssembly().Location, uninstaller, true);
using (var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath))
{
if (key == null) throw new InvalidOperationException("Windows uninstall registry key could not be created.");
key.SetValue("DisplayName", ProductName);
key.SetValue("DisplayVersion", ProductVersion);
key.SetValue("Publisher", "BEW");
key.SetValue("InstallLocation", installDirectory);
key.SetValue("DisplayIcon", targetExe);
key.SetValue("UninstallString", "\"" + uninstaller + "\" --uninstall");
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
}
}
private void RemoveWindowsIntegration()
{
TryDeleteFile(DesktopShortcutPath);
var programsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName);
TryDeleteFile(Path.Combine(programsDirectory, ProductName + ".lnk"));
if (Directory.Exists(programsDirectory) && Directory.GetFileSystemEntries(programsDirectory).Length == 0) Directory.Delete(programsDirectory);
Registry.LocalMachine.DeleteSubKeyTree(UninstallKeyPath, false);
}
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDirectory, string description)
{
Directory.CreateDirectory(Path.GetDirectoryName(shortcutPath));
var shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType == null) throw new InvalidOperationException("Windows Script Host is unavailable; shortcut creation failed.");
object shell = null;
object shortcut = null;
try
{
shell = Activator.CreateInstance(shellType);
dynamic dynamicShell = shell;
shortcut = dynamicShell.CreateShortcut(shortcutPath);
dynamic dynamicShortcut = shortcut;
dynamicShortcut.TargetPath = targetPath;
dynamicShortcut.WorkingDirectory = workingDirectory;
dynamicShortcut.Description = description;
dynamicShortcut.IconLocation = targetPath + ",0";
dynamicShortcut.Save();
}
finally
{
if (shortcut != null && Marshal.IsComObject(shortcut)) Marshal.FinalReleaseComObject(shortcut);
if (shell != null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
}
}
private WindowsIntegrationSnapshot CaptureWindowsIntegration()
{
var snapshot = new WindowsIntegrationSnapshot
{
RegistryValues = new Dictionary<string, Tuple<object, RegistryValueKind>>(StringComparer.OrdinalIgnoreCase),
DesktopShortcut = ReadFileOrNull(DesktopShortcutPath),
StartMenuShortcut = ReadFileOrNull(StartMenuShortcutPath),
Uninstaller = ReadFileOrNull(UninstallerPath)
};
using (var key = Registry.LocalMachine.OpenSubKey(UninstallKeyPath, false))
{
snapshot.RegistryKeyExisted = key != null;
if (key != null)
{
foreach (var name in key.GetValueNames())
{
snapshot.RegistryValues[name] = Tuple.Create(key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames), key.GetValueKind(name));
}
}
}
return snapshot;
}
private void RestoreWindowsIntegration(WindowsIntegrationSnapshot snapshot)
{
if (snapshot == null) return;
Registry.LocalMachine.DeleteSubKeyTree(UninstallKeyPath, false);
if (snapshot.RegistryKeyExisted)
{
using (var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath))
{
if (key == null) throw new InvalidOperationException("Previous uninstall registry key could not be restored.");
foreach (var pair in snapshot.RegistryValues)
{
key.SetValue(pair.Key, pair.Value.Item1, pair.Value.Item2);
}
}
}
RestoreFile(DesktopShortcutPath, snapshot.DesktopShortcut);
RestoreFile(StartMenuShortcutPath, snapshot.StartMenuShortcut);
RestoreFile(UninstallerPath, snapshot.Uninstaller);
}
private static byte[] ReadFileOrNull(string path)
{
return File.Exists(path) ? File.ReadAllBytes(path) : null;
}
private static void RestoreFile(string path, byte[] content)
{
if (content == null)
{
TryDeleteFile(path);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, content);
}
private void EnsureApplicationNotRunning()
{
var target = Path.Combine(installDirectory, ApplicationExeName);
if (!File.Exists(target)) return;
foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ApplicationExeName)))
{
try
{
if (string.Equals(Path.GetFullPath(process.MainModule.FileName), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(ProductName + " is still running. Close it before installation or removal.");
}
finally
{
process.Dispose();
}
}
}
private static bool RunApplicationSelfTest(string executable)
{
var startInfo = new ProcessStartInfo(executable, "--self-test")
{
WorkingDirectory = Path.GetDirectoryName(executable),
UseShellExecute = false,
CreateNoWindow = true
};
using (var process = Process.Start(startInfo))
{
if (process == null) return false;
if (!process.WaitForExit(60000))
{
try { process.Kill(); } catch { }
return false;
}
return process.ExitCode == 0;
}
}
private static void CopyPayload(string sourceRoot, string targetRoot, IEnumerable<PackageFile> files)
{
Directory.CreateDirectory(targetRoot);
foreach (var file in files)
{
var source = PackageManifest.ResolveContainedPath(sourceRoot, file.RelativePath);
var target = PackageManifest.ResolveContainedPath(targetRoot, file.RelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.Copy(source, target, false);
if (!string.Equals(PackageManifest.Sha256(target), file.Sha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Copied payload failed SHA-256 verification: " + file.RelativePath);
}
}
private static void RequirePayload(IEnumerable<PackageFile> files, string relativePath)
{
if (!files.Any(x => string.Equals(x.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)))
throw new InvalidDataException("Required payload file is missing from the manifest: " + relativePath);
}
private static void TryDeleteDirectory(string path, Action<string> report)
{
try { if (Directory.Exists(path)) Directory.Delete(path, true); }
catch (Exception ex) { report("WARNUNG: Verzeichnis konnte nicht bereinigt werden: " + path + " - " + ex.Message); }
}
private static void TryDeleteFile(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
private static string DesktopShortcutPath
{
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), ProductName + ".lnk"); }
}
private static string StartMenuShortcutPath
{
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName, ProductName + ".lnk"); }
}
private string UninstallerPath
{
get { return Path.Combine(dataDirectory, "Setup", "Uninstall.exe"); }
}
}
}
@@ -0,0 +1,151 @@
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace BizTalkPlatformManagementTool.Setup
{
internal sealed class MainForm : Form
{
private readonly InstallerEngine engine;
private readonly bool uninstallMode;
private readonly TextBox output = new TextBox();
private readonly CheckBox desktopShortcut = new CheckBox();
private readonly Button installButton = new Button();
private readonly Button uninstallButton = new Button();
private bool busy;
public MainForm(InstallerEngine engine, bool uninstallMode)
{
this.engine = engine;
this.uninstallMode = uninstallMode;
Text = "BizTalk Platform Management Tool Setup";
Width = 780;
Height = 520;
MinimumSize = new Size(680, 420);
StartPosition = FormStartPosition.CenterScreen;
BuildUi();
FormClosing += OnFormClosing;
}
private void BuildUi()
{
var root = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), RowCount = 5, ColumnCount = 1 };
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.Controls.Add(new Label
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.1.0"
});
root.Controls.Add(new Label
{
AutoSize = true,
Padding = new Padding(0, 8, 0, 8),
Text = "Transaktionaler Installer mit SHA-256-Pruefung, Staging-Self-Test und automatischem Rollback.\r\nZiel: " + engine.InstallDirectory
});
desktopShortcut.Text = "Desktop-Verknuepfung fuer alle Benutzer erstellen";
desktopShortcut.Checked = true;
desktopShortcut.AutoSize = true;
desktopShortcut.Enabled = !uninstallMode;
root.Controls.Add(desktopShortcut);
output.Multiline = true;
output.ReadOnly = true;
output.ScrollBars = ScrollBars.Vertical;
output.Dock = DockStyle.Fill;
output.Font = new Font(FontFamily.GenericMonospace, 9);
root.Controls.Add(output);
var buttons = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft };
var closeButton = new Button { Text = "Schliessen", AutoSize = true };
closeButton.Click += (sender, args) => Close();
installButton.Text = engine.IsInstalled ? "Update installieren" : "Installieren";
installButton.AutoSize = true;
installButton.Enabled = engine.HasInstallPayload && !uninstallMode;
installButton.Click += (sender, args) => Run(false);
uninstallButton.Text = "Deinstallieren";
uninstallButton.AutoSize = true;
uninstallButton.Enabled = engine.IsInstalled;
uninstallButton.Click += (sender, args) => Run(true);
buttons.Controls.Add(closeButton);
buttons.Controls.Add(uninstallButton);
buttons.Controls.Add(installButton);
root.Controls.Add(buttons);
Controls.Add(root);
if (uninstallMode) Append("Deinstallationsmodus. Installer-Logs bleiben zu Diagnosezwecken unter ProgramData erhalten.");
else if (!engine.HasInstallPayload) Append("Kein Installationspayload neben Setup.exe gefunden. Dieser Aufruf erlaubt nur die Deinstallation.");
}
private void Run(bool uninstall)
{
if (uninstall && MessageBox.Show(this, "BizTalk Platform Management Tool wirklich deinstallieren?", "Deinstallation bestaetigen", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes)
return;
var createDesktopShortcut = desktopShortcut.Checked;
SetBusy(true);
Task.Run(() =>
{
try
{
if (uninstall) engine.Uninstall(Append);
else engine.Install(createDesktopShortcut, Append);
Append(uninstall ? "FERTIG: Deinstallation erfolgreich." : "FERTIG: Installation/Update erfolgreich.");
Invoke(new Action(() =>
{
installButton.Text = engine.IsInstalled ? "Update installieren" : "Installieren";
uninstallButton.Enabled = engine.IsInstalled;
}));
}
catch (Exception ex)
{
Append("FEHLER: " + ex);
Invoke(new Action(() => MessageBox.Show(this, ex.Message, "Setup fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error)));
}
finally
{
SetBusy(false);
}
});
}
private void Append(string message)
{
if (IsDisposed || Disposing) return;
if (InvokeRequired)
{
try { BeginInvoke(new Action<string>(Append), message); } catch (InvalidOperationException) { }
return;
}
output.AppendText("[" + DateTime.Now.ToString("HH:mm:ss") + "] " + message + Environment.NewLine);
}
private void SetBusy(bool value)
{
if (InvokeRequired)
{
try { BeginInvoke(new Action<bool>(SetBusy), value); } catch (InvalidOperationException) { }
return;
}
busy = value;
installButton.Enabled = !value && engine.HasInstallPayload && !uninstallMode;
uninstallButton.Enabled = !value && engine.IsInstalled;
desktopShortcut.Enabled = !value && !uninstallMode;
UseWaitCursor = value;
}
private void OnFormClosing(object sender, FormClosingEventArgs e)
{
if (!busy) return;
e.Cancel = true;
MessageBox.Show(this, "Das Setup arbeitet noch. Bitte warten Sie bis zum Abschluss.", "Setup laeuft", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace BizTalkPlatformManagementTool.Setup
{
public sealed class PackageFile
{
/// <summary>Gets or sets the normalized payload-relative path.</summary>
public string RelativePath { get; set; }
/// <summary>Gets or sets the declared file length.</summary>
public long Length { get; set; }
/// <summary>Gets or sets the lowercase SHA-256 digest.</summary>
public string Sha256 { get; set; }
}
public static class PackageManifest
{
/// <summary>Reads and cryptographically validates a complete application payload manifest.</summary>
public static IList<PackageFile> ValidateAndRead(string applicationDirectory, string manifestPath)
{
if (!Directory.Exists(applicationDirectory)) throw new DirectoryNotFoundException("Application payload missing: " + applicationDirectory);
if (!File.Exists(manifestPath)) throw new FileNotFoundException("Package manifest missing: " + manifestPath, manifestPath);
var files = new List<PackageFile>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in File.ReadAllLines(manifestPath, Encoding.UTF8))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
var parts = line.Split(new[] { '|' }, 3);
long length;
if (parts.Length != 3 || parts[0].Length != 64 || !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out length))
throw new InvalidDataException("Invalid package manifest line: " + rawLine);
var relative = NormalizeRelativePath(parts[2]);
if (!seen.Add(relative)) throw new InvalidDataException("Duplicate package manifest path: " + relative);
var fullPath = ResolveContainedPath(applicationDirectory, relative);
if (!File.Exists(fullPath)) throw new FileNotFoundException("Manifest payload file missing: " + relative, fullPath);
var info = new FileInfo(fullPath);
if (info.Length != length) throw new InvalidDataException("Payload size mismatch: " + relative);
var actualHash = Sha256(fullPath);
if (!string.Equals(actualHash, parts[0], StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Payload SHA-256 mismatch: " + relative);
files.Add(new PackageFile { RelativePath = relative, Length = length, Sha256 = actualHash });
}
if (files.Count == 0) throw new InvalidDataException("The package manifest does not contain payload files.");
var actualFiles = Directory.GetFiles(applicationDirectory, "*", SearchOption.AllDirectories)
.Select(x => NormalizeRelativePath(x.Substring(Path.GetFullPath(applicationDirectory).TrimEnd(Path.DirectorySeparatorChar).Length + 1)))
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
var declaredFiles = files.Select(x => x.RelativePath).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
if (!actualFiles.SequenceEqual(declaredFiles, StringComparer.OrdinalIgnoreCase))
throw new InvalidDataException("The application payload contains files not covered by the package manifest.");
return files;
}
/// <summary>Creates a deterministic manifest covering every application payload file.</summary>
public static void Write(string applicationDirectory, string manifestPath)
{
var root = Path.GetFullPath(applicationDirectory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var lines = Directory.GetFiles(applicationDirectory, "*", SearchOption.AllDirectories)
.Select(path => new FileInfo(path))
.OrderBy(info => info.FullName, StringComparer.OrdinalIgnoreCase)
.Select(info => Sha256(info.FullName) + "|" + info.Length.ToString(CultureInfo.InvariantCulture) + "|" + NormalizeRelativePath(info.FullName.Substring(root.Length)))
.ToArray();
File.WriteAllLines(manifestPath, lines, new UTF8Encoding(false));
}
/// <summary>Calculates the lowercase SHA-256 digest of a file.</summary>
public static string Sha256(string path)
{
using (var stream = File.OpenRead(path))
using (var algorithm = SHA256.Create())
{
var hash = algorithm.ComputeHash(stream);
var builder = new StringBuilder(hash.Length * 2);
foreach (var value in hash) builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
return builder.ToString();
}
}
/// <summary>Resolves a relative payload path and rejects directory traversal.</summary>
public static string ResolveContainedPath(string root, string relative)
{
var normalizedRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var result = Path.GetFullPath(Path.Combine(normalizedRoot, NormalizeRelativePath(relative).Replace('/', Path.DirectorySeparatorChar)));
if (!result.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Package path escapes the payload root: " + relative);
return result;
}
private static string NormalizeRelativePath(string path)
{
path = (path ?? string.Empty).Replace('\\', '/').Trim();
if (path.Length == 0 || path.StartsWith("/", StringComparison.Ordinal) || path.Contains("../") || path == ".." || Path.IsPathRooted(path))
throw new InvalidDataException("Unsafe package path: " + path);
return path;
}
}
}
@@ -0,0 +1,18 @@
using System;
using System.Windows.Forms;
namespace BizTalkPlatformManagementTool.Setup
{
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
var uninstallMode = args != null && args.Length == 1 && string.Equals(args[0], "--uninstall", StringComparison.OrdinalIgnoreCase);
Application.Run(new MainForm(new InstallerEngine(AppDomain.CurrentDomain.BaseDirectory), uninstallMode));
return 0;
}
}
}
@@ -0,0 +1,13 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("BizTalk Platform Management Tool Setup")]
[assembly: AssemblyDescription("Transactional installer and updater for BizTalk Platform Management Tool")]
[assembly: AssemblyCompany("BEW")]
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.1.0.0")]
[assembly: AssemblyFileVersion("2.1.0.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -0,0 +1,48 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
namespace BizTalkPlatformManagementTool.Setup
{
internal sealed class SetupOperationLog
{
private readonly object sync = new object();
private SetupOperationLog(string filePath)
{
FilePath = filePath;
}
public string FilePath { get; private set; }
public static SetupOperationLog Create(string dataDirectory, string operation)
{
try
{
var directory = Path.Combine(dataDirectory, "InstallerLogs");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "setup-" + DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + "-" + operation + ".log");
return new SetupOperationLog(path);
}
catch
{
return new SetupOperationLog(string.Empty);
}
}
public void Write(string level, string message)
{
if (string.IsNullOrEmpty(FilePath)) return;
var line = "[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + "][" + level + "] " + (message ?? string.Empty) + Environment.NewLine;
try
{
lock (sync) File.AppendAllText(FilePath, line, new UTF8Encoding(false));
}
catch
{
// Setup logging must not hide the actual installation result.
}
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.0.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application>
</compatibility>
</assembly>
@@ -12,6 +12,7 @@
<FileAlignment>512</FileAlignment> <FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
@@ -45,7 +46,9 @@
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Program.cs" /> <Compile Include="Program.cs" />
<Compile Include="RuntimeSelfTest.cs" />
<Compile Include="Models\ArtifactStates.cs" /> <Compile Include="Models\ArtifactStates.cs" />
<Compile Include="Models\BizTalkSnapshot.cs" /> <Compile Include="Models\BizTalkSnapshot.cs" />
<Compile Include="Models\DiffModels.cs" /> <Compile Include="Models\DiffModels.cs" />
@@ -56,12 +59,14 @@
<Compile Include="Services\JsonFileStore.cs" /> <Compile Include="Services\JsonFileStore.cs" />
<Compile Include="Services\OperationLogger.cs" /> <Compile Include="Services\OperationLogger.cs" />
<Compile Include="Services\SnapshotComparer.cs" /> <Compile Include="Services\SnapshotComparer.cs" />
<Compile Include="Services\SnapshotValidator.cs" />
<Compile Include="Services\SnapshotStore.cs" /> <Compile Include="Services\SnapshotStore.cs" />
<Compile Include="Services\BizTalkOperationService.cs" /> <Compile Include="Services\BizTalkOperationService.cs" />
<Compile Include="Ui\MainForm.cs" /> <Compile Include="Ui\MainForm.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="App.config" /> <None Include="App.config" />
<None Include="app.manifest" />
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project> </Project>
@@ -1,26 +1,81 @@
namespace BizTalkPlatformManagementTool.Models namespace BizTalkPlatformManagementTool.Models
{ {
/// <summary>
/// Contains BizTalk WMI state constants and display helpers used by snapshots,
/// plans and reports.
/// </summary>
public static class ArtifactStates public static class ArtifactStates
{ {
/// <summary>
/// WMI status value for a bound send port.
/// </summary>
public const int SendPortBound = 1; public const int SendPortBound = 1;
/// <summary>
/// WMI status value for a stopped send port.
/// </summary>
public const int SendPortStopped = 2; public const int SendPortStopped = 2;
/// <summary>
/// WMI status value for a started send port.
/// </summary>
public const int SendPortStarted = 3; public const int SendPortStarted = 3;
/// <summary>
/// WMI status value for an unbound orchestration.
/// </summary>
public const int OrchestrationUnbound = 1; public const int OrchestrationUnbound = 1;
/// <summary>
/// WMI status value for a bound orchestration.
/// </summary>
public const int OrchestrationBound = 2; public const int OrchestrationBound = 2;
/// <summary>
/// WMI status value for a stopped orchestration.
/// </summary>
public const int OrchestrationStopped = 3; public const int OrchestrationStopped = 3;
/// <summary>
/// WMI status value for a started orchestration.
/// </summary>
public const int OrchestrationStarted = 4; public const int OrchestrationStarted = 4;
/// <summary>
/// WMI service state value for a stopped host instance.
/// </summary>
public const int HostStopped = 1; public const int HostStopped = 1;
/// <summary>
/// WMI service state value for a host instance that is starting.
/// </summary>
public const int HostStartPending = 2; public const int HostStartPending = 2;
/// <summary>
/// WMI service state value for a host instance that is stopping.
/// </summary>
public const int HostStopPending = 3; public const int HostStopPending = 3;
/// <summary>
/// WMI service state value for a started host instance.
/// </summary>
public const int HostStarted = 4; public const int HostStarted = 4;
/// <summary>
/// Converts a receive location enabled flag into the text used in reports.
/// </summary>
/// <param name="enabled">True when the receive location is enabled.</param>
/// <returns>A display value for the receive location state.</returns>
public static string FormatReceiveLocation(bool enabled) public static string FormatReceiveLocation(bool enabled)
{ {
return enabled ? "Enabled" : "Disabled"; return enabled ? "Enabled" : "Disabled";
} }
/// <summary>
/// Converts an MSBTS_SendPort.Status value into a display string.
/// </summary>
/// <param name="status">The raw WMI send port status value.</param>
/// <returns>A known status name or an Unknown value with the raw code.</returns>
public static string FormatSendPort(int status) public static string FormatSendPort(int status)
{ {
switch (status) switch (status)
@@ -32,6 +87,11 @@ namespace BizTalkPlatformManagementTool.Models
} }
} }
/// <summary>
/// Converts an MSBTS_Orchestration.OrchestrationStatus value into a display string.
/// </summary>
/// <param name="status">The raw WMI orchestration status value.</param>
/// <returns>A known status name or an Unknown value with the raw code.</returns>
public static string FormatOrchestration(int status) public static string FormatOrchestration(int status)
{ {
switch (status) switch (status)
@@ -44,6 +104,11 @@ namespace BizTalkPlatformManagementTool.Models
} }
} }
/// <summary>
/// Converts an MSBTS_HostInstance.ServiceState value into a display string.
/// </summary>
/// <param name="state">The raw WMI host instance service state value.</param>
/// <returns>A known service state name or an Unknown value with the raw code.</returns>
public static string FormatHostInstance(int state) public static string FormatHostInstance(int state)
{ {
switch (state) switch (state)
@@ -3,34 +3,62 @@ using System.Runtime.Serialization;
namespace BizTalkPlatformManagementTool.Models namespace BizTalkPlatformManagementTool.Models
{ {
/// <summary>
/// Represents one captured BizTalk runtime state including application artifacts
/// and host instances.
/// </summary>
[DataContract] [DataContract]
public sealed class BizTalkSnapshot public sealed class BizTalkSnapshot
{ {
/// <summary>
/// Initializes a new snapshot with empty application and host instance collections.
/// </summary>
public BizTalkSnapshot() public BizTalkSnapshot()
{ {
Applications = new List<ApplicationSnapshot>(); Applications = new List<ApplicationSnapshot>();
HostInstances = new List<HostInstanceState>(); HostInstances = new List<HostInstanceState>();
} }
/// <summary>
/// Gets or sets the tool version that created the snapshot.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string ToolVersion { get; set; } public string ToolVersion { get; set; }
/// <summary>
/// Gets or sets the local timestamp when the snapshot was created.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string CreatedAt { get; set; } public string CreatedAt { get; set; }
/// <summary>
/// Gets or sets the BizTalk server name used for the WMI connection.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string Server { get; set; } public string Server { get; set; }
/// <summary>
/// Gets or sets the BizTalk application snapshots captured from WMI.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public List<ApplicationSnapshot> Applications { get; set; } public List<ApplicationSnapshot> Applications { get; set; }
/// <summary>
/// Gets or sets the host instances captured from the BizTalk group.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public List<HostInstanceState> HostInstances { get; set; } public List<HostInstanceState> HostInstances { get; set; }
} }
/// <summary>
/// Groups the captured artifact states for one BizTalk application.
/// </summary>
[DataContract] [DataContract]
public sealed class ApplicationSnapshot public sealed class ApplicationSnapshot
{ {
/// <summary>
/// Initializes an application snapshot with empty artifact collections.
/// </summary>
public ApplicationSnapshot() public ApplicationSnapshot()
{ {
ReceiveLocations = new List<ReceiveLocationState>(); ReceiveLocations = new List<ReceiveLocationState>();
@@ -38,88 +66,169 @@ namespace BizTalkPlatformManagementTool.Models
Orchestrations = new List<OrchestrationState>(); Orchestrations = new List<OrchestrationState>();
} }
/// <summary>
/// Gets or sets the BizTalk application name.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Application { get; set; } public string Application { get; set; }
/// <summary>
/// Gets or sets the receive locations that belong to the application.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public List<ReceiveLocationState> ReceiveLocations { get; set; } public List<ReceiveLocationState> ReceiveLocations { get; set; }
/// <summary>
/// Gets or sets the send ports that belong to the application.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public List<SendPortState> SendPorts { get; set; } public List<SendPortState> SendPorts { get; set; }
/// <summary>
/// Gets or sets the orchestrations that belong to the application.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public List<OrchestrationState> Orchestrations { get; set; } public List<OrchestrationState> Orchestrations { get; set; }
} }
/// <summary>
/// Captures the relevant WMI state for a BizTalk receive location.
/// </summary>
[DataContract] [DataContract]
public sealed class ReceiveLocationState public sealed class ReceiveLocationState
{ {
/// <summary>
/// Gets or sets the owning BizTalk application.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Application { get; set; } public string Application { get; set; }
/// <summary>
/// Gets or sets the receive location name.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// Gets or sets the parent receive port name.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string ReceivePortName { get; set; } public string ReceivePortName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the receive location is enabled.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the receive adapter name.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public string AdapterName { get; set; } public string AdapterName { get; set; }
/// <summary>
/// Gets or sets the receive location transport address.
/// </summary>
[DataMember(Order = 6)] [DataMember(Order = 6)]
public string Address { get; set; } public string Address { get; set; }
} }
/// <summary>
/// Captures the relevant WMI state for a BizTalk send port.
/// </summary>
[DataContract] [DataContract]
public sealed class SendPortState public sealed class SendPortState
{ {
/// <summary>
/// Gets or sets the owning BizTalk application.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Application { get; set; } public string Application { get; set; }
/// <summary>
/// Gets or sets the send port name.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// Gets or sets the raw MSBTS_SendPort.Status value.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public int Status { get; set; } public int Status { get; set; }
/// <summary>
/// Gets or sets the primary transport adapter type.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public string PrimaryTransportType { get; set; } public string PrimaryTransportType { get; set; }
/// <summary>
/// Gets or sets the primary transport address.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public string PrimaryTransportAddress { get; set; } public string PrimaryTransportAddress { get; set; }
} }
/// <summary>
/// Captures the relevant WMI state for a BizTalk orchestration.
/// </summary>
[DataContract] [DataContract]
public sealed class OrchestrationState public sealed class OrchestrationState
{ {
/// <summary>
/// Gets or sets the owning BizTalk application.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Application { get; set; } public string Application { get; set; }
/// <summary>
/// Gets or sets the orchestration name.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// Gets or sets the raw MSBTS_Orchestration.OrchestrationStatus value.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public int OrchestrationStatus { get; set; } public int OrchestrationStatus { get; set; }
} }
/// <summary>
/// Captures the relevant WMI state for a BizTalk host instance.
/// </summary>
[DataContract] [DataContract]
public sealed class HostInstanceState public sealed class HostInstanceState
{ {
/// <summary>
/// Gets or sets the host instance name used as the WMI key.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string InstanceName { get; set; } public string InstanceName { get; set; }
/// <summary>
/// Gets or sets the BizTalk host name.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string HostName { get; set; } public string HostName { get; set; }
/// <summary>
/// Gets or sets the server on which the host instance runs.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string Server { get; set; } public string Server { get; set; }
/// <summary>
/// Gets or sets the raw MSBTS_HostInstance.ServiceState value.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public int RawState { get; set; } public int RawState { get; set; }
/// <summary>
/// Gets or sets the formatted host instance state.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public string StateText { get; set; } public string StateText { get; set; }
} }
@@ -3,56 +3,104 @@ using System.Runtime.Serialization;
namespace BizTalkPlatformManagementTool.Models namespace BizTalkPlatformManagementTool.Models
{ {
/// <summary>
/// Contains all differences detected between two BizTalk snapshots.
/// </summary>
[DataContract] [DataContract]
public sealed class SnapshotDiff public sealed class SnapshotDiff
{ {
/// <summary>
/// Initializes a new diff with empty artifact and host instance collections.
/// </summary>
public SnapshotDiff() public SnapshotDiff()
{ {
ArtifactDifferences = new List<ArtifactDiffEntry>(); ArtifactDifferences = new List<ArtifactDiffEntry>();
HostInstanceDifferences = new List<HostInstanceDiffEntry>(); HostInstanceDifferences = new List<HostInstanceDiffEntry>();
} }
/// <summary>
/// Gets or sets state differences for receive locations, send ports and orchestrations.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public List<ArtifactDiffEntry> ArtifactDifferences { get; set; } public List<ArtifactDiffEntry> ArtifactDifferences { get; set; }
/// <summary>
/// Gets or sets state differences for host instances.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public List<HostInstanceDiffEntry> HostInstanceDifferences { get; set; } public List<HostInstanceDiffEntry> HostInstanceDifferences { get; set; }
} }
/// <summary>
/// Describes a before/after state change for one BizTalk application artifact.
/// </summary>
[DataContract] [DataContract]
public sealed class ArtifactDiffEntry public sealed class ArtifactDiffEntry
{ {
/// <summary>
/// Gets or sets the owning BizTalk application.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Application { get; set; } public string Application { get; set; }
/// <summary>
/// Gets or sets the artifact category, such as SendPort or ReceiveLocation.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string ArtifactType { get; set; } public string ArtifactType { get; set; }
/// <summary>
/// Gets or sets the artifact name.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// Gets or sets the formatted state in the before snapshot.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public string Before { get; set; } public string Before { get; set; }
/// <summary>
/// Gets or sets the formatted state in the after snapshot.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public string After { get; set; } public string After { get; set; }
} }
/// <summary>
/// Describes a before/after state change for one BizTalk host instance.
/// </summary>
[DataContract] [DataContract]
public sealed class HostInstanceDiffEntry public sealed class HostInstanceDiffEntry
{ {
/// <summary>
/// Gets or sets the host instance name.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string InstanceName { get; set; } public string InstanceName { get; set; }
/// <summary>
/// Gets or sets the BizTalk host name.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string HostName { get; set; } public string HostName { get; set; }
/// <summary>
/// Gets or sets the server on which the host instance runs.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string Server { get; set; } public string Server { get; set; }
/// <summary>
/// Gets or sets the formatted state in the before snapshot.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public string Before { get; set; } public string Before { get; set; }
/// <summary>
/// Gets or sets the formatted state in the after snapshot.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public string After { get; set; } public string After { get; set; }
} }
@@ -3,92 +3,211 @@ using System.Runtime.Serialization;
namespace BizTalkPlatformManagementTool.Models namespace BizTalkPlatformManagementTool.Models
{ {
/// <summary>
/// Defines the supported operation plan modes.
/// </summary>
public enum OperationMode public enum OperationMode
{ {
/// <summary>
/// Plan mode for stopping BizTalk runtime artifacts before maintenance.
/// </summary>
Shutdown, Shutdown,
/// <summary>
/// Plan mode for returning BizTalk runtime artifacts to a captured state.
/// </summary>
Restore Restore
} }
/// <summary>
/// Defines the artifact categories that can appear in an operation plan.
/// </summary>
public enum OperationStepKind public enum OperationStepKind
{ {
/// <summary>
/// A receive location step.
/// </summary>
ReceiveLocation, ReceiveLocation,
/// <summary>
/// A send port step.
/// </summary>
SendPort, SendPort,
/// <summary>
/// An orchestration step.
/// </summary>
Orchestration, Orchestration,
/// <summary>
/// A host instance step.
/// </summary>
HostInstance, HostInstance,
/// <summary>
/// An informational step that is intentionally not executed.
/// </summary>
Note Note
} }
/// <summary>
/// Represents the ordered shutdown or restore plan written before any
/// runtime-changing operation is executed.
/// </summary>
[DataContract] [DataContract]
public sealed class OperationPlan public sealed class OperationPlan
{ {
/// <summary>
/// Initializes a new operation plan with an empty step collection.
/// </summary>
public OperationPlan() public OperationPlan()
{ {
Steps = new List<OperationStep>(); Steps = new List<OperationStep>();
} }
/// <summary>
/// Gets or sets the plan mode as a serialized string.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Mode { get; set; } public string Mode { get; set; }
/// <summary>
/// Gets or sets the local timestamp when the plan was created.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string CreatedAt { get; set; } public string CreatedAt { get; set; }
/// <summary>
/// Gets or sets the target server for server-scoped plan steps.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string Server { get; set; } public string Server { get; set; }
/// <summary>
/// Gets or sets the ordered operation steps.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public List<OperationStep> Steps { get; set; } public List<OperationStep> Steps { get; set; }
} }
/// <summary>
/// Describes one executable or informational step in a shutdown or restore plan.
/// </summary>
[DataContract] [DataContract]
public sealed class OperationStep public sealed class OperationStep
{ {
/// <summary>
/// Gets or sets the artifact kind for this step.
/// </summary>
[DataMember(Order = 1)] [DataMember(Order = 1)]
public string Kind { get; set; } public string Kind { get; set; }
/// <summary>
/// Gets or sets the owning BizTalk application, when applicable.
/// </summary>
[DataMember(Order = 2)] [DataMember(Order = 2)]
public string Application { get; set; } public string Application { get; set; }
/// <summary>
/// Gets or sets the artifact or host instance name displayed to the user.
/// </summary>
[DataMember(Order = 3)] [DataMember(Order = 3)]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// Gets or sets the server associated with the step, when server scoped.
/// </summary>
[DataMember(Order = 4)] [DataMember(Order = 4)]
public string Server { get; set; } public string Server { get; set; }
/// <summary>
/// Gets or sets the human-readable action description.
/// </summary>
[DataMember(Order = 5)] [DataMember(Order = 5)]
public string Action { get; set; } public string Action { get; set; }
/// <summary>
/// Gets or sets the WMI class used to resolve the runtime object.
/// </summary>
[DataMember(Order = 6)] [DataMember(Order = 6)]
public string WmiClass { get; set; } public string WmiClass { get; set; }
/// <summary>
/// Gets or sets the WMI key property used to locate the runtime object.
/// </summary>
[DataMember(Order = 7)] [DataMember(Order = 7)]
public string KeyProperty { get; set; } public string KeyProperty { get; set; }
/// <summary>
/// Gets or sets the WMI key value used to locate the runtime object.
/// </summary>
[DataMember(Order = 8)] [DataMember(Order = 8)]
public string KeyValue { get; set; } public string KeyValue { get; set; }
/// <summary>
/// Gets or sets the WMI method or service pseudo-method to execute.
/// </summary>
[DataMember(Order = 9)] [DataMember(Order = 9)]
public string MethodName { get; set; } public string MethodName { get; set; }
/// <summary>
/// Gets or sets the numeric arguments passed to the WMI method.
/// </summary>
[DataMember(Order = 10)] [DataMember(Order = 10)]
public int[] Arguments { get; set; } public int[] Arguments { get; set; }
/// <summary>
/// Gets or sets the raw WMI state expected after the method completes.
/// </summary>
[DataMember(Order = 11)] [DataMember(Order = 11)]
public int? TargetState { get; set; } public int? TargetState { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the step should be executed.
/// </summary>
[DataMember(Order = 12)] [DataMember(Order = 12)]
public bool Execute { get; set; } public bool Execute { get; set; }
/// <summary>
/// Gets or sets an operator-facing warning for skipped or risky steps.
/// </summary>
[DataMember(Order = 13)] [DataMember(Order = 13)]
public string Warning { get; set; } public string Warning { get; set; }
} }
/// <summary>
/// Contains user-selected runtime options for snapshot, shutdown and restore actions.
/// </summary>
public sealed class OperationOptions public sealed class OperationOptions
{ {
/// <summary>
/// Gets or sets the BizTalk server used for WMI operations.
/// </summary>
public string Server { get; set; } public string Server { get; set; }
/// <summary>
/// Gets or sets the directory where snapshots, plans, reports and diffs are written.
/// </summary>
public string OutputDirectory { get; set; } public string OutputDirectory { get; set; }
/// <summary>
/// Gets or sets the state file used as restore input.
/// </summary>
public string StateFile { get; set; } public string StateFile { get; set; }
/// <summary>
/// Gets or sets a value indicating whether operations should only be logged.
/// </summary>
public bool DryRun { get; set; } public bool DryRun { get; set; }
/// <summary>
/// Gets or sets the maximum number of seconds to wait for a target runtime state.
/// </summary>
public int WaitTimeoutSeconds { get; set; } public int WaitTimeoutSeconds { get; set; }
/// <summary>
/// Gets or sets the number of seconds between WMI polling attempts.
/// </summary>
public int PollIntervalSeconds { get; set; } public int PollIntervalSeconds { get; set; }
} }
} }
+34 -2
View File
@@ -1,15 +1,27 @@
using System; using System;
using System.Security.Principal; using System.Security.Principal;
using System.Threading;
using System.Windows.Forms; using System.Windows.Forms;
using BizTalkPlatformManagementTool.Ui; using BizTalkPlatformManagementTool.Ui;
namespace BizTalkPlatformManagementTool namespace BizTalkPlatformManagementTool
{ {
/// <summary>
/// Contains the WinForms application entry point and startup guard checks.
/// </summary>
internal static class Program internal static class Program
{ {
/// <summary>
/// Starts the application after verifying that BizTalk WMI operations can run elevated.
/// </summary>
[STAThread] [STAThread]
private static void Main() private static int Main(string[] args)
{ {
if (args != null && args.Length == 1 && string.Equals(args[0], "--self-test", StringComparison.OrdinalIgnoreCase))
{
return RuntimeSelfTest.Run();
}
Application.EnableVisualStyles(); Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false); Application.SetCompatibleTextRenderingDefault(false);
@@ -22,12 +34,32 @@ namespace BizTalkPlatformManagementTool
"Administrator Rights Required", "Administrator Rights Required",
MessageBoxButtons.OK, MessageBoxButtons.OK,
MessageBoxIcon.Error); MessageBoxIcon.Error);
return; return 1;
}
bool createdNew;
using (var mutex = new Mutex(true, @"Local\BizTalkPlatformManagementTool", out createdNew))
{
if (!createdNew)
{
MessageBox.Show(
"BizTalk Platform Management Tool is already running in this Windows session.",
"BizTalk Platform Management Tool",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return 2;
} }
Application.Run(new MainForm()); Application.Run(new MainForm());
GC.KeepAlive(mutex);
}
return 0;
} }
/// <summary>
/// Determines whether the current Windows identity is a local administrator.
/// </summary>
/// <returns>True when the process is elevated as administrator; otherwise false.</returns>
private static bool IsRunningAsAdministrator() private static bool IsRunningAsAdministrator()
{ {
try try
@@ -0,0 +1,12 @@
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("BizTalk Platform Management Tool")]
[assembly: AssemblyDescription("Controlled BizTalk Server maintenance snapshots, plans and runtime operations")]
[assembly: AssemblyCompany("BEW")]
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: ComVisible(false)]
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
[assembly: AssemblyVersion("2.1.0.0")]
[assembly: AssemblyFileVersion("2.1.0.0")]
@@ -0,0 +1,77 @@
using System;
using System.IO;
using BizTalkPlatformManagementTool.Models;
using BizTalkPlatformManagementTool.Services;
namespace BizTalkPlatformManagementTool
{
/// <summary>
/// Provides a WMI-free smoke test used by the installer before and after activation.
/// </summary>
internal static class RuntimeSelfTest
{
public static int Run()
{
var directory = Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool.SelfTest." + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(directory);
var before = SampleSnapshot(ArtifactStates.SendPortStarted);
var after = SampleSnapshot(ArtifactStates.SendPortStopped);
var snapshotPath = Path.Combine(directory, "snapshot.json");
JsonFileStore.Save(snapshotPath, before);
var loaded = JsonFileStore.Load<BizTalkSnapshot>(snapshotPath);
SnapshotValidator.Validate(loaded);
var diff = SnapshotComparer.Compare(loaded, after);
if (diff.ArtifactDifferences.Count != 1)
{
throw new InvalidOperationException("Snapshot diff self-test returned an unexpected result.");
}
SnapshotStore.SaveSnapshotSet(Path.Combine(directory, "before.json"), loaded);
SnapshotStore.SaveDiffSet(Path.Combine(directory, "diff.json"), diff);
Console.WriteLine("SELF_TEST_OK version=" + BizTalkOperationService.Version);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine("SELF_TEST_FAILED " + ex);
return 1;
}
finally
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, true);
}
}
catch
{
// The self-test result is more important than temporary cleanup.
}
}
}
private static BizTalkSnapshot SampleSnapshot(int sendPortState)
{
var snapshot = new BizTalkSnapshot
{
ToolVersion = BizTalkOperationService.Version,
CreatedAt = DateTimeOffset.Now.ToString("o"),
Server = Environment.MachineName
};
var app = new ApplicationSnapshot { Application = "SelfTest" };
app.SendPorts.Add(new SendPortState
{
Application = app.Application,
Name = "SelfTest.SendPort",
Status = sendPortState
});
snapshot.Applications.Add(app);
return snapshot;
}
}
}
@@ -7,27 +7,60 @@ using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Coordinates BizTalk snapshot, plan, shutdown, restore and persistence operations.
/// </summary>
public sealed class BizTalkOperationService public sealed class BizTalkOperationService
{ {
public const string Version = "2.0.0-net461"; /// <summary>
/// Current tool version written into generated snapshots.
/// </summary>
public const string Version = "2.1.0-net461";
/// <summary>
/// Fallback application name used when WMI does not expose an application property.
/// </summary>
private const string UnknownApplication = "(Unknown Application)"; private const string UnknownApplication = "(Unknown Application)";
/// <summary>
/// Logger used for all operator-facing operation messages.
/// </summary>
private readonly OperationLogger _logger; private readonly OperationLogger _logger;
/// <summary>
/// Initializes a new operation service.
/// </summary>
/// <param name="logger">The logger used for operator-visible progress and diagnostics.</param>
public BizTalkOperationService(OperationLogger logger) public BizTalkOperationService(OperationLogger logger)
{ {
_logger = logger; _logger = logger;
} }
/// <summary>
/// Verifies that the BizTalk WMI namespace is reachable and readable.
/// </summary>
/// <param name="server">The BizTalk server or management host to query.</param>
public void Diagnose(string server) public void Diagnose(string server)
{ {
using (var client = CreateClient(server)) using (var client = CreateClient(server))
{ {
var ports = client.Query("MSBTS_SendPort"); var ports = client.Query("MSBTS_SendPort");
try
{
_logger.Success("WMI/CIM diagnostic succeeded. Send ports visible: " + ports.Count); _logger.Success("WMI/CIM diagnostic succeeded. Send ports visible: " + ports.Count);
} }
finally
{
DisposeAll(ports);
}
}
} }
/// <summary>
/// Captures the current BizTalk runtime state from WMI.
/// </summary>
/// <param name="server">The BizTalk server or management host to query.</param>
/// <returns>A complete snapshot of supported BizTalk artifacts and host instances.</returns>
public BizTalkSnapshot CreateSnapshot(string server) public BizTalkSnapshot CreateSnapshot(string server)
{ {
using (var client = CreateClient(server)) using (var client = CreateClient(server))
@@ -35,12 +68,15 @@ namespace BizTalkPlatformManagementTool.Services
var snapshot = new BizTalkSnapshot var snapshot = new BizTalkSnapshot
{ {
ToolVersion = Version, ToolVersion = Version,
CreatedAt = DateTime.Now.ToString("s"), CreatedAt = DateTimeOffset.Now.ToString("o"),
Server = client.Server Server = client.Server
}; };
var apps = new Dictionary<string, ApplicationSnapshot>(StringComparer.OrdinalIgnoreCase); var apps = new Dictionary<string, ApplicationSnapshot>(StringComparer.OrdinalIgnoreCase);
foreach (var item in client.Query("MSBTS_ReceiveLocation")) var receiveLocations = client.Query("MSBTS_ReceiveLocation");
try
{
foreach (var item in receiveLocations)
{ {
var appName = GetApplicationName(item); var appName = GetApplicationName(item);
var app = GetApplication(apps, appName); var app = GetApplication(apps, appName);
@@ -54,8 +90,16 @@ namespace BizTalkPlatformManagementTool.Services
Address = BizTalkWmiClient.SafeGetString(item, "InboundTransportURL", string.Empty) Address = BizTalkWmiClient.SafeGetString(item, "InboundTransportURL", string.Empty)
}); });
} }
}
finally
{
DisposeAll(receiveLocations);
}
foreach (var item in client.Query("MSBTS_SendPort")) var sendPorts = client.Query("MSBTS_SendPort");
try
{
foreach (var item in sendPorts)
{ {
var appName = GetApplicationName(item); var appName = GetApplicationName(item);
var app = GetApplication(apps, appName); var app = GetApplication(apps, appName);
@@ -68,8 +112,16 @@ namespace BizTalkPlatformManagementTool.Services
PrimaryTransportAddress = BizTalkWmiClient.SafeGetString(item, "PTAddress", string.Empty) PrimaryTransportAddress = BizTalkWmiClient.SafeGetString(item, "PTAddress", string.Empty)
}); });
} }
}
finally
{
DisposeAll(sendPorts);
}
foreach (var item in client.Query("MSBTS_Orchestration")) var orchestrations = client.Query("MSBTS_Orchestration");
try
{
foreach (var item in orchestrations)
{ {
var appName = GetApplicationName(item); var appName = GetApplicationName(item);
var app = GetApplication(apps, appName); var app = GetApplication(apps, appName);
@@ -80,8 +132,16 @@ namespace BizTalkPlatformManagementTool.Services
OrchestrationStatus = BizTalkWmiClient.SafeGetInt32(item, "OrchestrationStatus", 0) OrchestrationStatus = BizTalkWmiClient.SafeGetInt32(item, "OrchestrationStatus", 0)
}); });
} }
}
finally
{
DisposeAll(orchestrations);
}
foreach (var item in client.Query("MSBTS_HostInstance")) var hostInstances = client.Query("MSBTS_HostInstance");
try
{
foreach (var item in hostInstances)
{ {
var state = BizTalkWmiClient.SafeGetInt32(item, "ServiceState", 0); var state = BizTalkWmiClient.SafeGetInt32(item, "ServiceState", 0);
snapshot.HostInstances.Add(new HostInstanceState snapshot.HostInstances.Add(new HostInstanceState
@@ -93,6 +153,11 @@ namespace BizTalkPlatformManagementTool.Services
StateText = ArtifactStates.FormatHostInstance(state) StateText = ArtifactStates.FormatHostInstance(state)
}); });
} }
}
finally
{
DisposeAll(hostInstances);
}
foreach (var app in apps.Values.OrderBy(a => a.Application)) foreach (var app in apps.Values.OrderBy(a => a.Application))
{ {
@@ -103,13 +168,21 @@ namespace BizTalkPlatformManagementTool.Services
} }
snapshot.HostInstances = snapshot.HostInstances.OrderBy(x => x.Server).ThenBy(x => x.InstanceName).ToList(); snapshot.HostInstances = snapshot.HostInstances.OrderBy(x => x.Server).ThenBy(x => x.InstanceName).ToList();
SnapshotValidator.Validate(snapshot);
_logger.Success("Snapshot created. Applications: " + snapshot.Applications.Count + ", host instances: " + snapshot.HostInstances.Count); _logger.Success("Snapshot created. Applications: " + snapshot.Applications.Count + ", host instances: " + snapshot.HostInstances.Count);
return snapshot; return snapshot;
} }
} }
/// <summary>
/// Creates an ordered shutdown plan from a snapshot.
/// </summary>
/// <param name="snapshot">The runtime state used as the source for the plan.</param>
/// <param name="server">The selected server on which host instance steps may execute.</param>
/// <returns>An ordered shutdown plan.</returns>
public OperationPlan CreateShutdownPlan(BizTalkSnapshot snapshot, string server) public OperationPlan CreateShutdownPlan(BizTalkSnapshot snapshot, string server)
{ {
SnapshotValidator.EnsureServerMatches(snapshot, server);
var plan = NewPlan(OperationMode.Shutdown, server); var plan = NewPlan(OperationMode.Shutdown, server);
foreach (var app in snapshot.Applications) foreach (var app in snapshot.Applications)
@@ -144,8 +217,15 @@ namespace BizTalkPlatformManagementTool.Services
return plan; return plan;
} }
/// <summary>
/// Creates an ordered restore plan from a previously captured snapshot.
/// </summary>
/// <param name="snapshot">The state that should be restored.</param>
/// <param name="server">The selected server on which host instance steps may execute.</param>
/// <returns>An ordered restore plan.</returns>
public OperationPlan CreateRestorePlan(BizTalkSnapshot snapshot, string server) public OperationPlan CreateRestorePlan(BizTalkSnapshot snapshot, string server)
{ {
SnapshotValidator.EnsureServerMatches(snapshot, server);
var plan = NewPlan(OperationMode.Restore, server); var plan = NewPlan(OperationMode.Restore, server);
foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted)) foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted))
@@ -214,8 +294,21 @@ namespace BizTalkPlatformManagementTool.Services
return plan; 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 void ExecutePlan(OperationPlan plan, OperationOptions options)
{ {
if (plan == null || options == null)
{
throw new ArgumentNullException(plan == null ? "plan" : "options");
}
if (!SnapshotValidator.ServerNamesEqual(plan.Server, options.Server))
{
throw new InvalidOperationException("The operation plan targets server '" + plan.Server + "' but execution was requested for '" + options.Server + "'.");
}
using (var client = CreateClient(options.Server)) using (var client = CreateClient(options.Server))
{ {
foreach (var step in plan.Steps) foreach (var step in plan.Steps)
@@ -253,6 +346,13 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Saves a snapshot and its report sidecars.
/// </summary>
/// <param name="outputDirectory">The directory where output files are written.</param>
/// <param name="fileName">The primary JSON file name.</param>
/// <param name="snapshot">The snapshot to persist.</param>
/// <returns>The primary JSON file path.</returns>
public string SaveSnapshot(string outputDirectory, string fileName, BizTalkSnapshot snapshot) public string SaveSnapshot(string outputDirectory, string fileName, BizTalkSnapshot snapshot)
{ {
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
@@ -262,6 +362,13 @@ namespace BizTalkPlatformManagementTool.Services
return path; return path;
} }
/// <summary>
/// Saves an operation plan as JSON.
/// </summary>
/// <param name="outputDirectory">The directory where output files are written.</param>
/// <param name="fileName">The plan JSON file name.</param>
/// <param name="plan">The plan to persist.</param>
/// <returns>The saved plan file path.</returns>
public string SavePlan(string outputDirectory, string fileName, OperationPlan plan) public string SavePlan(string outputDirectory, string fileName, OperationPlan plan)
{ {
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
@@ -271,6 +378,13 @@ namespace BizTalkPlatformManagementTool.Services
return path; return path;
} }
/// <summary>
/// Saves a diff and its report sidecars.
/// </summary>
/// <param name="outputDirectory">The directory where output files are written.</param>
/// <param name="fileName">The primary diff JSON file name.</param>
/// <param name="diff">The diff to persist.</param>
/// <returns>The primary diff JSON file path.</returns>
public string SaveDiff(string outputDirectory, string fileName, SnapshotDiff diff) public string SaveDiff(string outputDirectory, string fileName, SnapshotDiff diff)
{ {
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
@@ -280,6 +394,13 @@ namespace BizTalkPlatformManagementTool.Services
return path; return path;
} }
/// <summary>
/// Executes one concrete WMI operation step and waits for its target state.
/// </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 void ExecuteStep(BizTalkWmiClient client, ManagementObject instance, OperationStep step, OperationOptions options)
{ {
if (string.Equals(step.MethodName, "StopOrEnlist", StringComparison.OrdinalIgnoreCase)) if (string.Equals(step.MethodName, "StopOrEnlist", StringComparison.OrdinalIgnoreCase))
@@ -318,6 +439,12 @@ namespace BizTalkPlatformManagementTool.Services
WaitForTarget(client, step, options); WaitForTarget(client, 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) private void WaitForTarget(BizTalkWmiClient client, OperationStep step, OperationOptions options)
{ {
if (!step.TargetState.HasValue) if (!step.TargetState.HasValue)
@@ -344,6 +471,11 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Creates and connects a WMI client for a server.
/// </summary>
/// <param name="server">The BizTalk server or management host to connect to.</param>
/// <returns>A connected WMI client.</returns>
private BizTalkWmiClient CreateClient(string server) private BizTalkWmiClient CreateClient(string server)
{ {
var client = new BizTalkWmiClient(server, _logger); var client = new BizTalkWmiClient(server, _logger);
@@ -351,16 +483,37 @@ namespace BizTalkPlatformManagementTool.Services
return client; return client;
} }
/// <summary>
/// Creates a new empty operation plan with common metadata.
/// </summary>
/// <param name="mode">The operation mode represented by the plan.</param>
/// <param name="server">The target server stored in the plan metadata.</param>
/// <returns>A new operation plan.</returns>
private static OperationPlan NewPlan(OperationMode mode, string server) private static OperationPlan NewPlan(OperationMode mode, string server)
{ {
return new OperationPlan return new OperationPlan
{ {
Mode = mode.ToString(), Mode = mode.ToString(),
CreatedAt = DateTime.Now.ToString("s"), CreatedAt = DateTimeOffset.Now.ToString("o"),
Server = server Server = server
}; };
} }
/// <summary>
/// Creates one executable operation step.
/// </summary>
/// <param name="kind">The artifact kind displayed and serialized for the step.</param>
/// <param name="application">The owning BizTalk application, when applicable.</param>
/// <param name="name">The artifact or host instance name.</param>
/// <param name="server">The server associated with the step, when applicable.</param>
/// <param name="action">The human-readable action text.</param>
/// <param name="wmiClass">The WMI class used to resolve the target object.</param>
/// <param name="keyProperty">The WMI key property used for lookup.</param>
/// <param name="keyValue">The WMI key value used for lookup.</param>
/// <param name="methodName">The WMI method or pseudo-method to execute.</param>
/// <param name="arguments">Optional numeric WMI method arguments.</param>
/// <param name="targetState">Optional raw WMI state expected after execution.</param>
/// <returns>A configured operation step.</returns>
private static OperationStep Step(string kind, string application, string name, string server, string action, string wmiClass, string keyProperty, string keyValue, string methodName, int[] arguments, int? targetState) private static OperationStep Step(string kind, string application, string name, string server, string action, string wmiClass, string keyProperty, string keyValue, string methodName, int[] arguments, int? targetState)
{ {
return new OperationStep return new OperationStep
@@ -380,6 +533,11 @@ namespace BizTalkPlatformManagementTool.Services
}; };
} }
/// <summary>
/// Converts integer method arguments into the object array required by WMI.
/// </summary>
/// <param name="values">The integer values from the operation step.</param>
/// <returns>An object array suitable for ManagementObject.InvokeMethod.</returns>
private static object[] ToObjects(int[] values) private static object[] ToObjects(int[] values)
{ {
if (values == null || values.Length == 0) if (values == null || values.Length == 0)
@@ -395,6 +553,11 @@ namespace BizTalkPlatformManagementTool.Services
return result; return result;
} }
/// <summary>
/// Builds an operator-facing description for one plan step.
/// </summary>
/// <param name="step">The step to describe.</param>
/// <returns>A compact step description for logs and errors.</returns>
private static string DescribeStep(OperationStep step) private static string DescribeStep(OperationStep step)
{ {
if (step == null) if (step == null)
@@ -412,6 +575,12 @@ namespace BizTalkPlatformManagementTool.Services
+ "]"; + "]";
} }
/// <summary>
/// Gets or creates an application snapshot bucket in a dictionary.
/// </summary>
/// <param name="apps">The application snapshot dictionary keyed by application name.</param>
/// <param name="appName">The application name to resolve.</param>
/// <returns>The existing or newly created application snapshot.</returns>
private static ApplicationSnapshot GetApplication(Dictionary<string, ApplicationSnapshot> apps, string appName) private static ApplicationSnapshot GetApplication(Dictionary<string, ApplicationSnapshot> apps, string appName)
{ {
ApplicationSnapshot app; ApplicationSnapshot app;
@@ -423,6 +592,11 @@ namespace BizTalkPlatformManagementTool.Services
return app; return app;
} }
/// <summary>
/// Reads the application name from the available WMI properties.
/// </summary>
/// <param name="item">The WMI object being mapped into a snapshot item.</param>
/// <returns>The discovered application name or a stable fallback.</returns>
private static string GetApplicationName(ManagementObject item) private static string GetApplicationName(ManagementObject item)
{ {
var names = new[] { "ApplicationName", "Application", "BizTalkApplication" }; var names = new[] { "ApplicationName", "Application", "BizTalkApplication" };
@@ -436,5 +610,20 @@ namespace BizTalkPlatformManagementTool.Services
} }
return UnknownApplication; return UnknownApplication;
} }
private static void DisposeAll(IEnumerable<ManagementObject> items)
{
if (items == null)
{
return;
}
foreach (var item in items)
{
if (item != null)
{
item.Dispose();
}
}
}
} }
} }
@@ -6,24 +6,53 @@ using System.Threading;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Provides the WMI access layer for BizTalk Server objects in root\MicrosoftBizTalkServer.
/// </summary>
public sealed class BizTalkWmiClient : IDisposable public sealed class BizTalkWmiClient : IDisposable
{ {
/// <summary>
/// BizTalk WMI namespace used for all platform management queries.
/// </summary>
private const string NamespacePath = "root\\MicrosoftBizTalkServer"; private const string NamespacePath = "root\\MicrosoftBizTalkServer";
/// <summary>
/// Target server for the WMI connection.
/// </summary>
private readonly string _server; private readonly string _server;
/// <summary>
/// Logger used for WMI diagnostics and operation traces.
/// </summary>
private readonly OperationLogger _logger; private readonly OperationLogger _logger;
/// <summary>
/// Connected WMI management scope, created lazily or by Connect.
/// </summary>
private ManagementScope _scope; private ManagementScope _scope;
/// <summary>
/// Initializes a new WMI client for the specified server.
/// </summary>
/// <param name="server">The target server name, or an empty value to use the local machine.</param>
/// <param name="logger">The operation logger used for diagnostics and trace output.</param>
public BizTalkWmiClient(string server, OperationLogger logger) public BizTalkWmiClient(string server, OperationLogger logger)
{ {
_server = string.IsNullOrWhiteSpace(server) ? Environment.MachineName : server.Trim(); _server = string.IsNullOrWhiteSpace(server) ? Environment.MachineName : server.Trim();
_logger = logger; _logger = logger;
} }
/// <summary>
/// Gets the normalized target server name used by this client.
/// </summary>
public string Server public string Server
{ {
get { return _server; } get { return _server; }
} }
/// <summary>
/// Connects to the BizTalk WMI namespace on the target server.
/// </summary>
public void Connect() public void Connect()
{ {
var path = "\\\\" + _server + "\\" + NamespacePath; var path = "\\\\" + _server + "\\" + NamespacePath;
@@ -33,11 +62,22 @@ namespace BizTalkPlatformManagementTool.Services
_logger.Success("WMI connection established."); _logger.Success("WMI connection established.");
} }
/// <summary>
/// Executes a broad WMI query for all instances of the requested BizTalk class.
/// </summary>
/// <param name="className">The WMI class name to query.</param>
/// <returns>The matching WMI objects. The caller owns the returned objects.</returns>
public List<ManagementObject> Query(string className) public List<ManagementObject> Query(string className)
{ {
return Query(className, true); return Query(className, true);
} }
/// <summary>
/// Executes a broad WMI query and optionally logs the query text.
/// </summary>
/// <param name="className">The WMI class name to query.</param>
/// <param name="logQuery">True to write the query to the operation log.</param>
/// <returns>The matching WMI objects. The caller owns the returned objects.</returns>
private List<ManagementObject> Query(string className, bool logQuery) private List<ManagementObject> Query(string className, bool logQuery)
{ {
EnsureConnected(); EnsureConnected();
@@ -63,17 +103,37 @@ namespace BizTalkPlatformManagementTool.Services
} }
catch (ManagementException ex) catch (ManagementException ex)
{ {
foreach (var item in result)
{
item.Dispose();
}
throw new InvalidOperationException("WMI query failed. Query: " + queryText + ". WMI error: " + ex.Message, ex); throw new InvalidOperationException("WMI query failed. Query: " + queryText + ". WMI error: " + ex.Message, ex);
} }
return result; return result;
} }
/// <summary>
/// Finds one WMI object by comparing a property value client-side.
/// </summary>
/// <param name="className">The WMI class name to query.</param>
/// <param name="propertyName">The property used as the lookup key.</param>
/// <param name="value">The expected property value.</param>
/// <returns>The matching object, or null when no object matches.</returns>
public ManagementObject FindByProperty(string className, string propertyName, string value) public ManagementObject FindByProperty(string className, string propertyName, string value)
{ {
return FindByProperty(className, propertyName, value, true); return FindByProperty(className, propertyName, value, true);
} }
/// <summary>
/// Finds one WMI object using SELECT * plus a client-side filter so special
/// characters in BizTalk names cannot break a WQL WHERE clause.
/// </summary>
/// <param name="className">The WMI class name to query.</param>
/// <param name="propertyName">The property used as the lookup key.</param>
/// <param name="value">The expected property value.</param>
/// <param name="logLookup">True to write the lookup details to the operation log.</param>
/// <returns>The matching object, or null when no object matches.</returns>
private ManagementObject FindByProperty(string className, string propertyName, string value, bool logLookup) private ManagementObject FindByProperty(string className, string propertyName, string value, bool logLookup)
{ {
EnsureConnected(); EnsureConnected();
@@ -118,6 +178,13 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Invokes a WMI method and validates that the method returned success.
/// </summary>
/// <param name="instance">The WMI object on which the method should be called.</param>
/// <param name="methodName">The method name to invoke.</param>
/// <param name="arguments">Optional method arguments.</param>
/// <returns>The WMI return code.</returns>
public uint InvokeMethod(ManagementObject instance, string methodName, params object[] arguments) public uint InvokeMethod(ManagementObject instance, string methodName, params object[] arguments)
{ {
if (instance == null) if (instance == null)
@@ -138,6 +205,9 @@ namespace BizTalkPlatformManagementTool.Services
throw new InvalidOperationException("WMI method failed. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ". WMI error: " + ex.Message, ex); throw new InvalidOperationException("WMI method failed. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ". WMI error: " + ex.Message, ex);
} }
var output = result as ManagementBaseObject;
try
{
var returnCode = ExtractReturnCode(result); var returnCode = ExtractReturnCode(result);
if (returnCode != 0) if (returnCode != 0)
{ {
@@ -146,7 +216,25 @@ namespace BizTalkPlatformManagementTool.Services
return returnCode; return returnCode;
} }
finally
{
if (output != null)
{
output.Dispose();
}
}
}
/// <summary>
/// Polls one WMI object until it reaches the expected state or times out.
/// </summary>
/// <param name="className">The WMI class name to query.</param>
/// <param name="keyProperty">The WMI key property used to find the object.</param>
/// <param name="keyValue">The WMI key value used to find the object.</param>
/// <param name="isReached">Predicate that returns true when the state is reached.</param>
/// <param name="description">Human-readable state description used in logs and errors.</param>
/// <param name="timeoutSeconds">Maximum number of seconds to wait.</param>
/// <param name="pollIntervalSeconds">Number of seconds between polling attempts.</param>
public void WaitForState(string className, string keyProperty, string keyValue, Func<ManagementObject, bool> isReached, string description, int timeoutSeconds, int pollIntervalSeconds) public void WaitForState(string className, string keyProperty, string keyValue, Func<ManagementObject, bool> isReached, string description, int timeoutSeconds, int pollIntervalSeconds)
{ {
var deadline = DateTime.UtcNow.AddSeconds(Math.Max(1, timeoutSeconds)); var deadline = DateTime.UtcNow.AddSeconds(Math.Max(1, timeoutSeconds));
@@ -163,12 +251,24 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
Thread.Sleep(TimeSpan.FromSeconds(delay)); var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
break;
}
Thread.Sleep(remaining < TimeSpan.FromSeconds(delay) ? remaining : TimeSpan.FromSeconds(delay));
} }
throw new TimeoutException("Timeout while waiting for " + description + " [" + className + "." + keyProperty + "=" + keyValue + "]"); throw new TimeoutException("Timeout while waiting for " + description + " [" + className + "." + keyProperty + "=" + keyValue + "]");
} }
/// <summary>
/// Reads a WMI property as a string without failing on missing or invalid properties.
/// </summary>
/// <param name="item">The WMI object or output parameter object.</param>
/// <param name="propertyName">The property to read.</param>
/// <param name="fallback">The value returned when the property cannot be read.</param>
/// <returns>The property value or the fallback value.</returns>
public static string SafeGetString(ManagementBaseObject item, string propertyName, string fallback) public static string SafeGetString(ManagementBaseObject item, string propertyName, string fallback)
{ {
try try
@@ -187,6 +287,13 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Reads a WMI property as an integer without failing on missing or invalid properties.
/// </summary>
/// <param name="item">The WMI object or output parameter object.</param>
/// <param name="propertyName">The property to read.</param>
/// <param name="fallback">The value returned when the property cannot be read.</param>
/// <returns>The property value or the fallback value.</returns>
public static int SafeGetInt32(ManagementBaseObject item, string propertyName, int fallback) public static int SafeGetInt32(ManagementBaseObject item, string propertyName, int fallback)
{ {
try try
@@ -205,6 +312,13 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Reads a WMI property as a Boolean without failing on missing or invalid properties.
/// </summary>
/// <param name="item">The WMI object or output parameter object.</param>
/// <param name="propertyName">The property to read.</param>
/// <param name="fallback">The value returned when the property cannot be read.</param>
/// <returns>The property value or the fallback value.</returns>
public static bool SafeGetBoolean(ManagementBaseObject item, string propertyName, bool fallback) public static bool SafeGetBoolean(ManagementBaseObject item, string propertyName, bool fallback)
{ {
try try
@@ -223,6 +337,9 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Ensures that the management scope is connected before a WMI operation runs.
/// </summary>
private void EnsureConnected() private void EnsureConnected()
{ {
if (_scope == null || !_scope.IsConnected) if (_scope == null || !_scope.IsConnected)
@@ -231,6 +348,12 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Checks whether a WMI object exposes a property, using case-insensitive comparison.
/// </summary>
/// <param name="item">The WMI object to inspect.</param>
/// <param name="propertyName">The property name to find.</param>
/// <returns>True when the property exists; otherwise false.</returns>
private static bool HasProperty(ManagementBaseObject item, string propertyName) private static bool HasProperty(ManagementBaseObject item, string propertyName)
{ {
foreach (PropertyData property in item.Properties) foreach (PropertyData property in item.Properties)
@@ -244,6 +367,14 @@ namespace BizTalkPlatformManagementTool.Services
return false; return false;
} }
/// <summary>
/// Checks whether a WMI object matches a requested key value.
/// </summary>
/// <param name="item">The WMI object to inspect.</param>
/// <param name="className">The WMI class name of the object.</param>
/// <param name="propertyName">The requested key property.</param>
/// <param name="expectedValue">The expected key value.</param>
/// <returns>True when the object matches the requested value.</returns>
private static bool MatchesProperty(ManagementBaseObject item, string className, string propertyName, string expectedValue) private static bool MatchesProperty(ManagementBaseObject item, string className, string propertyName, string expectedValue)
{ {
foreach (var candidate in CandidatePropertyNames(className, propertyName)) foreach (var candidate in CandidatePropertyNames(className, propertyName))
@@ -263,6 +394,12 @@ namespace BizTalkPlatformManagementTool.Services
return false; return false;
} }
/// <summary>
/// Returns the primary and compatibility property names for a WMI lookup.
/// </summary>
/// <param name="className">The WMI class name being queried.</param>
/// <param name="propertyName">The requested key property.</param>
/// <returns>The candidate property names to inspect.</returns>
private static IEnumerable<string> CandidatePropertyNames(string className, string propertyName) private static IEnumerable<string> CandidatePropertyNames(string className, string propertyName)
{ {
yield return propertyName; yield return propertyName;
@@ -274,6 +411,11 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Builds a readable object name for diagnostics without letting WMI metadata failures escape.
/// </summary>
/// <param name="instance">The WMI object being described.</param>
/// <returns>A relative WMI path or fallback object name.</returns>
private static string SafeObjectName(ManagementObject instance) private static string SafeObjectName(ManagementObject instance)
{ {
try try
@@ -286,6 +428,12 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Resolves the exact method casing exposed by the WMI class.
/// </summary>
/// <param name="instance">The WMI object whose class should be inspected.</param>
/// <param name="requestedName">The requested method name.</param>
/// <returns>The method name as exposed by WMI.</returns>
private static string ResolveMethodName(ManagementObject instance, string requestedName) private static string ResolveMethodName(ManagementObject instance, string requestedName)
{ {
using (var managementClass = new ManagementClass(instance.Scope, new ManagementPath(instance.Path.ClassName), null)) using (var managementClass = new ManagementClass(instance.Scope, new ManagementPath(instance.Path.ClassName), null))
@@ -302,6 +450,11 @@ namespace BizTalkPlatformManagementTool.Services
throw new MissingMethodException(instance.Path.ClassName, requestedName); throw new MissingMethodException(instance.Path.ClassName, requestedName);
} }
/// <summary>
/// Extracts the WMI return code from either a scalar return value or output parameters.
/// </summary>
/// <param name="result">The object returned by ManagementObject.InvokeMethod.</param>
/// <returns>The numeric WMI return code.</returns>
private static uint ExtractReturnCode(object result) private static uint ExtractReturnCode(object result)
{ {
if (result == null) if (result == null)
@@ -318,8 +471,12 @@ namespace BizTalkPlatformManagementTool.Services
return Convert.ToUInt32(result, CultureInfo.InvariantCulture); return Convert.ToUInt32(result, CultureInfo.InvariantCulture);
} }
/// <summary>
/// Releases resources owned by this client.
/// </summary>
public void Dispose() public void Dispose()
{ {
_scope = null;
} }
} }
} }
@@ -5,8 +5,16 @@ using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Writes snapshot and diff data to CSV files for review outside the GUI.
/// </summary>
public static class CsvWriter public static class CsvWriter
{ {
/// <summary>
/// Writes application artifact states from a snapshot to a CSV file.
/// </summary>
/// <param name="path">The target CSV file path.</param>
/// <param name="snapshot">The snapshot whose artifact states should be exported.</param>
public static void WriteSnapshotArtifacts(string path, BizTalkSnapshot snapshot) public static void WriteSnapshotArtifacts(string path, BizTalkSnapshot snapshot)
{ {
var lines = new List<string> { "Application,Type,Name,Status" }; var lines = new List<string> { "Application,Type,Name,Status" };
@@ -28,6 +36,11 @@ namespace BizTalkPlatformManagementTool.Services
File.WriteAllLines(path, lines, Encoding.UTF8); File.WriteAllLines(path, lines, Encoding.UTF8);
} }
/// <summary>
/// Writes host instance states from a snapshot to a CSV file.
/// </summary>
/// <param name="path">The target CSV file path.</param>
/// <param name="snapshot">The snapshot whose host instance states should be exported.</param>
public static void WriteSnapshotHosts(string path, BizTalkSnapshot snapshot) public static void WriteSnapshotHosts(string path, BizTalkSnapshot snapshot)
{ {
var lines = new List<string> { "InstanceName,HostName,Server,State" }; var lines = new List<string> { "InstanceName,HostName,Server,State" };
@@ -38,6 +51,11 @@ namespace BizTalkPlatformManagementTool.Services
File.WriteAllLines(path, lines, Encoding.UTF8); File.WriteAllLines(path, lines, Encoding.UTF8);
} }
/// <summary>
/// Writes snapshot differences to a CSV file.
/// </summary>
/// <param name="path">The target CSV file path.</param>
/// <param name="diff">The diff model to export.</param>
public static void WriteDiff(string path, SnapshotDiff diff) public static void WriteDiff(string path, SnapshotDiff diff)
{ {
var lines = new List<string> { "Scope,Application,Type,Name,Server,Before,After" }; var lines = new List<string> { "Scope,Application,Type,Name,Server,Before,After" };
@@ -52,6 +70,11 @@ namespace BizTalkPlatformManagementTool.Services
File.WriteAllLines(path, lines, Encoding.UTF8); File.WriteAllLines(path, lines, Encoding.UTF8);
} }
/// <summary>
/// Builds one CSV row from already ordered field values.
/// </summary>
/// <param name="values">The values that should be escaped and joined.</param>
/// <returns>A single CSV row.</returns>
private static string Row(params string[] values) private static string Row(params string[] values)
{ {
var escaped = new string[values.Length]; var escaped = new string[values.Length];
@@ -62,9 +85,18 @@ namespace BizTalkPlatformManagementTool.Services
return string.Join(",", escaped); return string.Join(",", escaped);
} }
/// <summary>
/// Escapes a CSV field when it contains separators, quotes or line breaks.
/// </summary>
/// <param name="value">The raw field value.</param>
/// <returns>The CSV-safe field value.</returns>
private static string Escape(string value) private static string Escape(string value)
{ {
value = value ?? string.Empty; value = value ?? string.Empty;
if (value.Length > 0 && (value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t'))
{
value = "'" + value;
}
if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0)
{ {
return value; return value;
@@ -4,8 +4,16 @@ using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Writes HTML reports for BizTalk snapshots and snapshot differences.
/// </summary>
public static class HtmlReportWriter public static class HtmlReportWriter
{ {
/// <summary>
/// Writes a complete HTML snapshot report.
/// </summary>
/// <param name="path">The target HTML file path.</param>
/// <param name="snapshot">The snapshot to render.</param>
public static void WriteSnapshot(string path, BizTalkSnapshot snapshot) public static void WriteSnapshot(string path, BizTalkSnapshot snapshot)
{ {
var html = new StringBuilder(); var html = new StringBuilder();
@@ -50,6 +58,11 @@ namespace BizTalkPlatformManagementTool.Services
System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8); System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8);
} }
/// <summary>
/// Writes a complete HTML diff report.
/// </summary>
/// <param name="path">The target HTML file path.</param>
/// <param name="diff">The diff model to render.</param>
public static void WriteDiff(string path, SnapshotDiff diff) public static void WriteDiff(string path, SnapshotDiff diff)
{ {
var html = new StringBuilder(); var html = new StringBuilder();
@@ -74,12 +87,26 @@ namespace BizTalkPlatformManagementTool.Services
System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8); System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8);
} }
/// <summary>
/// Appends one artifact status row to a report table.
/// </summary>
/// <param name="html">The report builder receiving the row markup.</param>
/// <param name="name">The artifact name.</param>
/// <param name="status">The formatted artifact status.</param>
/// <param name="ok">True when the row should use the positive status style.</param>
/// <param name="detail1">The first detail column value.</param>
/// <param name="detail2">The second detail column value.</param>
private static void StatusRow(StringBuilder html, string name, string status, bool ok, string detail1, string detail2) private static void StatusRow(StringBuilder html, string name, string status, bool ok, string detail1, string detail2)
{ {
html.Append("<tr><td>").Append(Encode(name)).Append("</td><td class='").Append(ok ? "ok" : "bad").Append("'>").Append(Encode(status)) html.Append("<tr><td>").Append(Encode(name)).Append("</td><td class='").Append(ok ? "ok" : "bad").Append("'>").Append(Encode(status))
.Append("</td><td>").Append(Encode(detail1)).Append("</td><td>").Append(Encode(detail2)).Append("</td></tr>"); .Append("</td><td>").Append(Encode(detail1)).Append("</td><td>").Append(Encode(detail2)).Append("</td></tr>");
} }
/// <summary>
/// Appends the common document header, style block and title.
/// </summary>
/// <param name="html">The report builder receiving the header markup.</param>
/// <param name="title">The document title and main heading.</param>
private static void Header(StringBuilder html, string title) private static void Header(StringBuilder html, string title)
{ {
html.Append("<!doctype html><html><head><meta charset='utf-8'><title>").Append(Encode(title)).Append("</title><style>") html.Append("<!doctype html><html><head><meta charset='utf-8'><title>").Append(Encode(title)).Append("</title><style>")
@@ -87,11 +114,20 @@ namespace BizTalkPlatformManagementTool.Services
.Append("</style></head><body><h1>").Append(Encode(title)).Append("</h1>"); .Append("</style></head><body><h1>").Append(Encode(title)).Append("</h1>");
} }
/// <summary>
/// Appends the common HTML document footer.
/// </summary>
/// <param name="html">The report builder receiving the footer markup.</param>
private static void Footer(StringBuilder html) private static void Footer(StringBuilder html)
{ {
html.Append("</body></html>"); html.Append("</body></html>");
} }
/// <summary>
/// HTML-encodes report values and treats null values as empty text.
/// </summary>
/// <param name="value">The raw value to encode.</param>
/// <returns>An HTML-safe value.</returns>
private static string Encode(string value) private static string Encode(string value)
{ {
return WebUtility.HtmlEncode(value ?? string.Empty); return WebUtility.HtmlEncode(value ?? string.Empty);
@@ -1,3 +1,4 @@
using System;
using System.IO; using System.IO;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Runtime.Serialization.Json; using System.Runtime.Serialization.Json;
@@ -5,13 +6,32 @@ using System.Text;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Persists DataContract models as JSON with repository-defined encoding rules.
/// </summary>
public static class JsonFileStore public static class JsonFileStore
{ {
/// <summary>
/// UTF-8 encoding instance that writes JSON without a byte order mark.
/// </summary>
private static readonly Encoding Utf8NoBom = new UTF8Encoding(false); private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
/// <summary>
/// Serializes a value to a UTF-8 JSON file without a byte order mark.
/// </summary>
/// <typeparam name="T">The model type to serialize.</typeparam>
/// <param name="path">The target JSON file path.</param>
/// <param name="value">The value to serialize.</param>
public static void Save<T>(string path, T value) public static void Save<T>(string path, T value)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("A JSON target path is required.", "path");
}
var fullPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath);
Directory.CreateDirectory(directory);
var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings
{ {
UseSimpleDictionaryFormat = true UseSimpleDictionaryFormat = true
@@ -21,10 +41,16 @@ namespace BizTalkPlatformManagementTool.Services
{ {
serializer.WriteObject(stream, value); serializer.WriteObject(stream, value);
var json = Utf8NoBom.GetString(stream.ToArray()); var json = Utf8NoBom.GetString(stream.ToArray());
File.WriteAllText(path, json, Utf8NoBom); WriteAtomically(fullPath, json);
} }
} }
/// <summary>
/// Loads a JSON file into the requested DataContract model type.
/// </summary>
/// <typeparam name="T">The model type to deserialize.</typeparam>
/// <param name="path">The JSON file path to load.</param>
/// <returns>The deserialized model.</returns>
public static T Load<T>(string path) public static T Load<T>(string path)
{ {
var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings
@@ -46,6 +72,11 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Removes byte order mark variants that may exist in previously written files.
/// </summary>
/// <param name="json">The raw JSON text read from disk.</param>
/// <returns>The JSON text without a leading BOM marker.</returns>
private static string NormalizeJson(string json) private static string NormalizeJson(string json)
{ {
if (string.IsNullOrEmpty(json)) if (string.IsNullOrEmpty(json))
@@ -66,6 +97,11 @@ namespace BizTalkPlatformManagementTool.Services
return json; return json;
} }
/// <summary>
/// Builds a short diagnostic message for a JSON deserialization failure.
/// </summary>
/// <param name="json">The normalized JSON text that failed to deserialize.</param>
/// <returns>A diagnostic suffix describing the beginning of the JSON content.</returns>
private static string DescribeJsonStart(string json) private static string DescribeJsonStart(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -82,5 +118,75 @@ namespace BizTalkPlatformManagementTool.Services
return "The file starts with valid JSON syntax but could not be deserialized into the expected model."; return "The file starts with valid JSON syntax but could not be deserialized into the expected model.";
} }
/// <summary>
/// Writes a file through a same-directory temporary file so an interrupted
/// save cannot leave a truncated snapshot or operation plan behind.
/// </summary>
private static void WriteAtomically(string path, string content)
{
var temporaryPath = path + ".tmp." + Guid.NewGuid().ToString("N");
var backupPath = path + ".bak." + Guid.NewGuid().ToString("N");
try
{
File.WriteAllText(temporaryPath, content, Utf8NoBom);
if (!File.Exists(path))
{
File.Move(temporaryPath, path);
return;
}
try
{
File.Replace(temporaryPath, path, backupPath, true);
TryDelete(backupPath);
}
catch (PlatformNotSupportedException)
{
ReplaceWithRenameFallback(path, temporaryPath, backupPath);
}
catch (NotSupportedException)
{
ReplaceWithRenameFallback(path, temporaryPath, backupPath);
}
}
finally
{
TryDelete(temporaryPath);
}
}
private static void ReplaceWithRenameFallback(string path, string temporaryPath, string backupPath)
{
File.Move(path, backupPath);
try
{
File.Move(temporaryPath, path);
TryDelete(backupPath);
}
catch
{
if (!File.Exists(path) && File.Exists(backupPath))
{
File.Move(backupPath, path);
}
throw;
}
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Temporary cleanup is best-effort and must not hide the save result.
}
}
} }
} }
@@ -5,39 +5,107 @@ using System.Threading;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Defines the severity used for visible and file-based operation log entries.
/// </summary>
public enum LogLevel public enum LogLevel
{ {
/// <summary>
/// Informational progress or diagnostic entry.
/// </summary>
Info, Info,
/// <summary>
/// Non-fatal condition that needs operator attention.
/// </summary>
Warning, Warning,
/// <summary>
/// Failed operation or exception entry.
/// </summary>
Error, Error,
/// <summary>
/// Successful operation entry.
/// </summary>
Success Success
} }
/// <summary>
/// Represents one operation log entry displayed in the GUI and written to disk.
/// </summary>
public sealed class LogEntry public sealed class LogEntry
{ {
/// <summary>
/// Gets or sets the local time when the entry was created.
/// </summary>
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
/// <summary>
/// Gets or sets the entry severity.
/// </summary>
public LogLevel Level { get; set; } public LogLevel Level { get; set; }
/// <summary>
/// Gets or sets the operator-facing message.
/// </summary>
public string Message { get; set; } public string Message { get; set; }
} }
/// <summary>
/// Writes operation log entries to a daily rolling file and an optional UI sink.
/// </summary>
public sealed class OperationLogger public sealed class OperationLogger
{ {
/// <summary>
/// Prefix used for daily log files written to the resolved log directory.
/// </summary>
private const string LogFilePrefix = "BizTalkPlatformManagementTool-"; private const string LogFilePrefix = "BizTalkPlatformManagementTool-";
/// <summary>
/// File extension used for operation log files.
/// </summary>
private const string LogFileExtension = ".log"; private const string LogFileExtension = ".log";
/// <summary>
/// Number of daily log files retained, including the current day.
/// </summary>
private const int RetentionDays = 5; private const int RetentionDays = 5;
/// <summary>
/// Process-wide lock that serializes log file appends.
/// </summary>
private static readonly object FileLock = new object(); private static readonly object FileLock = new object();
/// <summary>
/// Process-wide flag that ensures log cleanup runs only once.
/// </summary>
private static int _cleanupDone; private static int _cleanupDone;
/// <summary>
/// Optional callback for forwarding entries to the UI.
/// </summary>
private readonly Action<LogEntry> _sink; private readonly Action<LogEntry> _sink;
/// <summary>
/// Directory where daily log files are written.
/// </summary>
private readonly string _logDirectory; private readonly string _logDirectory;
/// <summary>
/// Initializes a new logger that writes beside the executable.
/// </summary>
/// <param name="sink">Optional callback that receives entries for display.</param>
public OperationLogger(Action<LogEntry> sink) public OperationLogger(Action<LogEntry> sink)
{ {
_sink = sink; _sink = sink;
_logDirectory = AppDomain.CurrentDomain.BaseDirectory; _logDirectory = ResolveLogDirectory();
CleanupOldLogs(); CleanupOldLogs();
} }
/// <summary>
/// Gets the path of the daily log file for the current date.
/// </summary>
public string LogFilePath public string LogFilePath
{ {
get get
@@ -46,26 +114,47 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Writes an informational entry.
/// </summary>
/// <param name="message">The message to log.</param>
public void Info(string message) public void Info(string message)
{ {
Write(LogLevel.Info, message); Write(LogLevel.Info, message);
} }
/// <summary>
/// Writes a warning entry.
/// </summary>
/// <param name="message">The message to log.</param>
public void Warning(string message) public void Warning(string message)
{ {
Write(LogLevel.Warning, message); Write(LogLevel.Warning, message);
} }
/// <summary>
/// Writes an error entry.
/// </summary>
/// <param name="message">The message to log.</param>
public void Error(string message) public void Error(string message)
{ {
Write(LogLevel.Error, message); Write(LogLevel.Error, message);
} }
/// <summary>
/// Writes a success entry.
/// </summary>
/// <param name="message">The message to log.</param>
public void Success(string message) public void Success(string message)
{ {
Write(LogLevel.Success, message); Write(LogLevel.Success, message);
} }
/// <summary>
/// Creates a log entry and sends it to both output targets.
/// </summary>
/// <param name="level">The entry severity.</param>
/// <param name="message">The message to log.</param>
private void Write(LogLevel level, string message) private void Write(LogLevel level, string message)
{ {
var entry = new LogEntry var entry = new LogEntry
@@ -85,6 +174,10 @@ namespace BizTalkPlatformManagementTool.Services
_sink(entry); _sink(entry);
} }
/// <summary>
/// Appends one entry to the current daily log file.
/// </summary>
/// <param name="entry">The entry to write.</param>
private void WriteToFile(LogEntry entry) private void WriteToFile(LogEntry entry)
{ {
try try
@@ -108,6 +201,9 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Deletes log files older than the configured retention window.
/// </summary>
private void CleanupOldLogs() private void CleanupOldLogs()
{ {
if (Interlocked.Exchange(ref _cleanupDone, 1) == 1) if (Interlocked.Exchange(ref _cleanupDone, 1) == 1)
@@ -132,5 +228,20 @@ namespace BizTalkPlatformManagementTool.Services
// Log retention cleanup is best-effort. // Log retention cleanup is best-effort.
} }
} }
private static string ResolveLogDirectory()
{
var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
var preferred = Path.Combine(commonData, "BizTalkPlatformManagementTool", "Logs");
try
{
Directory.CreateDirectory(preferred);
return preferred;
}
catch
{
return AppDomain.CurrentDomain.BaseDirectory;
}
}
} }
} }
@@ -4,10 +4,26 @@ using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Compares two BizTalk snapshots and returns the state differences that matter
/// after a maintenance window.
/// </summary>
public static class SnapshotComparer public static class SnapshotComparer
{ {
/// <summary>
/// Compares all supported artifact and host instance states.
/// </summary>
/// <param name="before">The snapshot captured before maintenance.</param>
/// <param name="after">The snapshot captured after maintenance.</param>
/// <returns>A diff containing changed, new and missing artifacts.</returns>
public static SnapshotDiff Compare(BizTalkSnapshot before, BizTalkSnapshot after) public static SnapshotDiff Compare(BizTalkSnapshot before, BizTalkSnapshot after)
{ {
SnapshotValidator.Validate(before);
SnapshotValidator.Validate(after);
if (!SnapshotValidator.ServerNamesEqual(before.Server, after.Server))
{
throw new InvalidOperationException("Snapshots from different servers cannot be compared: '" + before.Server + "' and '" + after.Server + "'.");
}
var diff = new SnapshotDiff(); var diff = new SnapshotDiff();
CompareReceiveLocations(diff, FlattenReceiveLocations(before), FlattenReceiveLocations(after)); CompareReceiveLocations(diff, FlattenReceiveLocations(before), FlattenReceiveLocations(after));
@@ -18,6 +34,12 @@ namespace BizTalkPlatformManagementTool.Services
return diff; return diff;
} }
/// <summary>
/// Adds receive location differences to the shared diff model.
/// </summary>
/// <param name="diff">The diff model receiving the entries.</param>
/// <param name="before">Receive locations keyed by name from the before snapshot.</param>
/// <param name="after">Receive locations keyed by name from the after snapshot.</param>
private static void CompareReceiveLocations(SnapshotDiff diff, Dictionary<string, ReceiveLocationState> before, Dictionary<string, ReceiveLocationState> after) private static void CompareReceiveLocations(SnapshotDiff diff, Dictionary<string, ReceiveLocationState> before, Dictionary<string, ReceiveLocationState> after)
{ {
foreach (var pair in after) foreach (var pair in after)
@@ -42,6 +64,12 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Adds send port differences to the shared diff model.
/// </summary>
/// <param name="diff">The diff model receiving the entries.</param>
/// <param name="before">Send ports keyed by name from the before snapshot.</param>
/// <param name="after">Send ports keyed by name from the after snapshot.</param>
private static void CompareSendPorts(SnapshotDiff diff, Dictionary<string, SendPortState> before, Dictionary<string, SendPortState> after) private static void CompareSendPorts(SnapshotDiff diff, Dictionary<string, SendPortState> before, Dictionary<string, SendPortState> after)
{ {
foreach (var pair in after) foreach (var pair in after)
@@ -66,6 +94,12 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Adds orchestration differences to the shared diff model.
/// </summary>
/// <param name="diff">The diff model receiving the entries.</param>
/// <param name="before">Orchestrations keyed by name from the before snapshot.</param>
/// <param name="after">Orchestrations keyed by name from the after snapshot.</param>
private static void CompareOrchestrations(SnapshotDiff diff, Dictionary<string, OrchestrationState> before, Dictionary<string, OrchestrationState> after) private static void CompareOrchestrations(SnapshotDiff diff, Dictionary<string, OrchestrationState> before, Dictionary<string, OrchestrationState> after)
{ {
foreach (var pair in after) foreach (var pair in after)
@@ -90,6 +124,12 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Adds host instance differences to the shared diff model.
/// </summary>
/// <param name="diff">The diff model receiving the entries.</param>
/// <param name="before">Host instances from the before snapshot.</param>
/// <param name="after">Host instances from the after snapshot.</param>
private static void CompareHostInstances(SnapshotDiff diff, List<HostInstanceState> before, List<HostInstanceState> after) private static void CompareHostInstances(SnapshotDiff diff, List<HostInstanceState> before, List<HostInstanceState> after)
{ {
var beforeMap = new Dictionary<string, HostInstanceState>(StringComparer.OrdinalIgnoreCase); var beforeMap = new Dictionary<string, HostInstanceState>(StringComparer.OrdinalIgnoreCase);
@@ -97,11 +137,11 @@ namespace BizTalkPlatformManagementTool.Services
foreach (var item in before) foreach (var item in before)
{ {
beforeMap[item.InstanceName ?? string.Empty] = item; beforeMap[SnapshotValidator.ArtifactKey(item.Server, item.InstanceName)] = item;
} }
foreach (var item in after) foreach (var item in after)
{ {
afterMap[item.InstanceName ?? string.Empty] = item; afterMap[SnapshotValidator.ArtifactKey(item.Server, item.InstanceName)] = item;
} }
foreach (var pair in afterMap) foreach (var pair in afterMap)
@@ -126,6 +166,15 @@ namespace BizTalkPlatformManagementTool.Services
} }
} }
/// <summary>
/// Adds one artifact diff entry.
/// </summary>
/// <param name="diff">The diff model receiving the entry.</param>
/// <param name="application">The BizTalk application name.</param>
/// <param name="type">The artifact type.</param>
/// <param name="name">The artifact name.</param>
/// <param name="before">The formatted before state.</param>
/// <param name="after">The formatted after state.</param>
private static void AddArtifact(SnapshotDiff diff, string application, string type, string name, string before, string after) private static void AddArtifact(SnapshotDiff diff, string application, string type, string name, string before, string after)
{ {
diff.ArtifactDifferences.Add(new ArtifactDiffEntry diff.ArtifactDifferences.Add(new ArtifactDiffEntry
@@ -138,6 +187,11 @@ namespace BizTalkPlatformManagementTool.Services
}); });
} }
/// <summary>
/// Flattens receive locations from all applications into a case-insensitive name map.
/// </summary>
/// <param name="snapshot">The snapshot to flatten.</param>
/// <returns>A map keyed by receive location name.</returns>
private static Dictionary<string, ReceiveLocationState> FlattenReceiveLocations(BizTalkSnapshot snapshot) private static Dictionary<string, ReceiveLocationState> FlattenReceiveLocations(BizTalkSnapshot snapshot)
{ {
var map = new Dictionary<string, ReceiveLocationState>(StringComparer.OrdinalIgnoreCase); var map = new Dictionary<string, ReceiveLocationState>(StringComparer.OrdinalIgnoreCase);
@@ -145,12 +199,17 @@ namespace BizTalkPlatformManagementTool.Services
{ {
foreach (var item in app.ReceiveLocations) foreach (var item in app.ReceiveLocations)
{ {
map[item.Name ?? string.Empty] = item; map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item;
} }
} }
return map; return map;
} }
/// <summary>
/// Flattens send ports from all applications into a case-insensitive name map.
/// </summary>
/// <param name="snapshot">The snapshot to flatten.</param>
/// <returns>A map keyed by send port name.</returns>
private static Dictionary<string, SendPortState> FlattenSendPorts(BizTalkSnapshot snapshot) private static Dictionary<string, SendPortState> FlattenSendPorts(BizTalkSnapshot snapshot)
{ {
var map = new Dictionary<string, SendPortState>(StringComparer.OrdinalIgnoreCase); var map = new Dictionary<string, SendPortState>(StringComparer.OrdinalIgnoreCase);
@@ -158,12 +217,17 @@ namespace BizTalkPlatformManagementTool.Services
{ {
foreach (var item in app.SendPorts) foreach (var item in app.SendPorts)
{ {
map[item.Name ?? string.Empty] = item; map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item;
} }
} }
return map; return map;
} }
/// <summary>
/// Flattens orchestrations from all applications into a case-insensitive name map.
/// </summary>
/// <param name="snapshot">The snapshot to flatten.</param>
/// <returns>A map keyed by orchestration name.</returns>
private static Dictionary<string, OrchestrationState> FlattenOrchestrations(BizTalkSnapshot snapshot) private static Dictionary<string, OrchestrationState> FlattenOrchestrations(BizTalkSnapshot snapshot)
{ {
var map = new Dictionary<string, OrchestrationState>(StringComparer.OrdinalIgnoreCase); var map = new Dictionary<string, OrchestrationState>(StringComparer.OrdinalIgnoreCase);
@@ -171,7 +235,7 @@ namespace BizTalkPlatformManagementTool.Services
{ {
foreach (var item in app.Orchestrations) foreach (var item in app.Orchestrations)
{ {
map[item.Name ?? string.Empty] = item; map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item;
} }
} }
return map; return map;
@@ -3,8 +3,16 @@ using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services namespace BizTalkPlatformManagementTool.Services
{ {
/// <summary>
/// Saves snapshot and diff models together with their sidecar report formats.
/// </summary>
public static class SnapshotStore public static class SnapshotStore
{ {
/// <summary>
/// Saves a snapshot as JSON and creates CSV, host CSV and HTML sidecars.
/// </summary>
/// <param name="jsonPath">The primary JSON output path.</param>
/// <param name="snapshot">The snapshot to persist.</param>
public static void SaveSnapshotSet(string jsonPath, BizTalkSnapshot snapshot) public static void SaveSnapshotSet(string jsonPath, BizTalkSnapshot snapshot)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath))); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath)));
@@ -14,6 +22,11 @@ namespace BizTalkPlatformManagementTool.Services
HtmlReportWriter.WriteSnapshot(jsonPath + ".html", snapshot); HtmlReportWriter.WriteSnapshot(jsonPath + ".html", snapshot);
} }
/// <summary>
/// Saves a diff as JSON and creates CSV and HTML sidecars.
/// </summary>
/// <param name="jsonPath">The primary JSON output path.</param>
/// <param name="diff">The diff model to persist.</param>
public static void SaveDiffSet(string jsonPath, SnapshotDiff diff) public static void SaveDiffSet(string jsonPath, SnapshotDiff diff)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath))); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath)));
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services
{
/// <summary>
/// Normalizes deserialized legacy snapshots and rejects ambiguous or unsafe input.
/// </summary>
public static class SnapshotValidator
{
/// <summary>Normalizes optional collections and rejects missing or duplicate artifact identities.</summary>
public static void Validate(BizTalkSnapshot snapshot)
{
if (snapshot == null)
{
throw new InvalidOperationException("The snapshot is empty.");
}
snapshot.Applications = snapshot.Applications ?? new List<ApplicationSnapshot>();
snapshot.HostInstances = snapshot.HostInstances ?? new List<HostInstanceState>();
var receiveLocations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sendPorts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var orchestrations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var app in snapshot.Applications)
{
if (app == null || string.IsNullOrWhiteSpace(app.Application))
{
throw new InvalidOperationException("The snapshot contains an application without a name.");
}
app.ReceiveLocations = app.ReceiveLocations ?? new List<ReceiveLocationState>();
app.SendPorts = app.SendPorts ?? new List<SendPortState>();
app.Orchestrations = app.Orchestrations ?? new List<OrchestrationState>();
ValidateArtifacts(app.Application, "receive location", app.ReceiveLocations, x => x == null ? null : x.Name, receiveLocations);
ValidateArtifacts(app.Application, "send port", app.SendPorts, x => x == null ? null : x.Name, sendPorts);
ValidateArtifacts(app.Application, "orchestration", app.Orchestrations, x => x == null ? null : x.Name, orchestrations);
}
var hostInstances = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var host in snapshot.HostInstances)
{
if (host == null || string.IsNullOrWhiteSpace(host.InstanceName))
{
throw new InvalidOperationException("The snapshot contains a host instance without an instance name.");
}
if (!hostInstances.Add(host.InstanceName))
{
throw new InvalidOperationException("The snapshot contains the host instance more than once: " + host.InstanceName);
}
host.StateText = ArtifactStates.FormatHostInstance(host.RawState);
}
}
/// <summary>Validates a snapshot and ensures it belongs to the requested operation server.</summary>
public static void EnsureServerMatches(BizTalkSnapshot snapshot, string targetServer)
{
Validate(snapshot);
if (string.IsNullOrWhiteSpace(snapshot.Server) || string.IsNullOrWhiteSpace(targetServer))
{
throw new InvalidOperationException("Snapshot server and target server must both be specified before a restore plan can be created.");
}
if (!ServerNamesEqual(snapshot.Server, targetServer))
{
throw new InvalidOperationException("The snapshot belongs to server '" + snapshot.Server + "' but the selected restore target is '" + targetServer + "'.");
}
}
/// <summary>Compares server names while accepting short-name/FQDN variants of the same host.</summary>
public static bool ServerNamesEqual(string left, string right)
{
var normalizedLeft = NormalizeServer(left);
var normalizedRight = NormalizeServer(right);
if (string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return string.Equals(ShortName(normalizedLeft), ShortName(normalizedRight), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Builds the collision-safe identity used for application artifacts.</summary>
public static string ArtifactKey(string application, string name)
{
return (application ?? string.Empty).Trim() + "\u001f" + (name ?? string.Empty).Trim();
}
private static void ValidateArtifacts<T>(string application, string type, IEnumerable<T> values, Func<T, string> getName, HashSet<string> keys)
{
foreach (var value in values)
{
var name = getName(value);
if (value == null || string.IsNullOrWhiteSpace(name))
{
throw new InvalidOperationException("Application '" + application + "' contains a " + type + " without a name.");
}
var key = ArtifactKey(application, name);
if (!keys.Add(key))
{
throw new InvalidOperationException("Application '" + application + "' contains the " + type + " more than once: " + name);
}
}
}
private static string NormalizeServer(string value)
{
value = (value ?? string.Empty).Trim().TrimStart('\\');
if (value == "." || string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase))
{
return Environment.MachineName;
}
return value;
}
private static string ShortName(string value)
{
var index = value.IndexOf('.');
return index < 0 ? value : value.Substring(0, index);
}
}
}
+286 -21
View File
@@ -9,31 +9,125 @@ using BizTalkPlatformManagementTool.Services;
namespace BizTalkPlatformManagementTool.Ui namespace BizTalkPlatformManagementTool.Ui
{ {
/// <summary>
/// Main WinForms surface for diagnosing, snapshotting, comparing, shutting down
/// and restoring a BizTalk platform state.
/// </summary>
public sealed class MainForm : Form public sealed class MainForm : Form
{ {
/// <summary>
/// Text input for the BizTalk server or management host.
/// </summary>
private readonly TextBox _serverTextBox = new TextBox(); private readonly TextBox _serverTextBox = new TextBox();
/// <summary>
/// Text input for the directory that receives snapshots, plans and reports.
/// </summary>
private readonly TextBox _outputTextBox = new TextBox(); private readonly TextBox _outputTextBox = new TextBox();
/// <summary>
/// Text input for the restore state file name or path.
/// </summary>
private readonly TextBox _stateFileTextBox = new TextBox(); private readonly TextBox _stateFileTextBox = new TextBox();
/// <summary>
/// Numeric input for the maximum wait time of runtime state changes.
/// </summary>
private readonly NumericUpDown _timeoutInput = new NumericUpDown(); private readonly NumericUpDown _timeoutInput = new NumericUpDown();
/// <summary>
/// Numeric input for the WMI polling interval.
/// </summary>
private readonly NumericUpDown _pollInput = new NumericUpDown(); private readonly NumericUpDown _pollInput = new NumericUpDown();
/// <summary>
/// Checkbox that keeps shutdown and restore operations in dry-run mode.
/// </summary>
private readonly CheckBox _dryRunCheckBox = new CheckBox(); private readonly CheckBox _dryRunCheckBox = new CheckBox();
/// <summary>
/// Grid used to show snapshots, diffs and operation plans.
/// </summary>
private readonly DataGridView _statusGrid = new DataGridView(); private readonly DataGridView _statusGrid = new DataGridView();
/// <summary>
/// Grid used to show operation log entries.
/// </summary>
private readonly DataGridView _logGrid = new DataGridView(); private readonly DataGridView _logGrid = new DataGridView();
/// <summary>
/// Status strip at the bottom of the form.
/// </summary>
private readonly StatusStrip _statusStrip = new StatusStrip(); private readonly StatusStrip _statusStrip = new StatusStrip();
/// <summary>
/// Text label inside the status strip.
/// </summary>
private readonly ToolStripStatusLabel _statusLabel = new ToolStripStatusLabel(); private readonly ToolStripStatusLabel _statusLabel = new ToolStripStatusLabel();
/// <summary>
/// Header indicator derived from the latest host instance snapshot.
/// </summary>
private readonly Label _environmentStatusLabel = new Label(); private readonly Label _environmentStatusLabel = new Label();
/// <summary>
/// Logger that writes to disk and mirrors entries into the UI log grid.
/// </summary>
private readonly OperationLogger _logger; private readonly OperationLogger _logger;
/// <summary>
/// Service that performs all BizTalk runtime operations.
/// </summary>
private readonly BizTalkOperationService _service; private readonly BizTalkOperationService _service;
/// <summary>
/// Button that validates WMI access.
/// </summary>
private Button _diagnoseButton; private Button _diagnoseButton;
/// <summary>
/// Button that creates the before snapshot.
/// </summary>
private Button _beforeButton; private Button _beforeButton;
/// <summary>
/// Button that creates the after snapshot.
/// </summary>
private Button _afterButton; private Button _afterButton;
/// <summary>
/// Button that compares before and after snapshots.
/// </summary>
private Button _compareButton; private Button _compareButton;
/// <summary>
/// Button that creates and executes the shutdown plan.
/// </summary>
private Button _shutdownButton; private Button _shutdownButton;
/// <summary>
/// Button that creates and executes the restore plan.
/// </summary>
private Button _restoreButton; private Button _restoreButton;
/// <summary>
/// Button that clears visible grids and status.
/// </summary>
private Button _clearButton; private Button _clearButton;
/// <summary>
/// Button that closes the application.
/// </summary>
private Button _closeButton; private Button _closeButton;
/// <summary>
/// Indicates that a background operation is still active.
/// </summary>
private bool _isBusy;
/// <summary>
/// Initializes the form, operation services and visual controls.
/// </summary>
public MainForm() public MainForm()
{ {
Text = "BizTalk Platform Management Tool"; Text = "BizTalk Platform Management Tool";
@@ -45,9 +139,13 @@ namespace BizTalkPlatformManagementTool.Ui
_logger = new OperationLogger(AppendLog); _logger = new OperationLogger(AppendLog);
_service = new BizTalkOperationService(_logger); _service = new BizTalkOperationService(_logger);
BuildUi(); BuildUi();
FormClosing += MainFormClosing;
_logger.Info("Log file: " + _logger.LogFilePath); _logger.Info("Log file: " + _logger.LogFilePath);
} }
/// <summary>
/// Builds the root form layout and adds settings, action, result and status areas.
/// </summary>
private void BuildUi() private void BuildUi()
{ {
var root = new TableLayoutPanel var root = new TableLayoutPanel
@@ -72,6 +170,10 @@ namespace BizTalkPlatformManagementTool.Ui
root.Controls.Add(_statusStrip, 0, 3); root.Controls.Add(_statusStrip, 0, 3);
} }
/// <summary>
/// Builds the settings panel with server, output, state file and timing inputs.
/// </summary>
/// <returns>The configured settings panel.</returns>
private Control BuildSettingsPanel() private Control BuildSettingsPanel()
{ {
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 10, RowCount = 2 }; var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 10, RowCount = 2 };
@@ -140,6 +242,10 @@ namespace BizTalkPlatformManagementTool.Ui
return panel; return panel;
} }
/// <summary>
/// Builds the toolbar-like action panel.
/// </summary>
/// <returns>The configured action panel.</returns>
private Control BuildActionPanel() 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 = false };
@@ -163,6 +269,10 @@ namespace BizTalkPlatformManagementTool.Ui
return panel; return panel;
} }
/// <summary>
/// Builds the tab control containing status/results and operation logs.
/// </summary>
/// <returns>The configured tab control.</returns>
private Control BuildTabs() private Control BuildTabs()
{ {
var tabs = new TabControl { Dock = DockStyle.Fill }; var tabs = new TabControl { Dock = DockStyle.Fill };
@@ -189,12 +299,22 @@ namespace BizTalkPlatformManagementTool.Ui
return tabs; return tabs;
} }
/// <summary>
/// Handles the Diagnose button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void DiagnoseClick(object sender, EventArgs e) private void DiagnoseClick(object sender, EventArgs e)
{ {
var server = _serverTextBox.Text.Trim(); var server = _serverTextBox.Text.Trim();
RunAsync("Diagnosing WMI access...", () => _service.Diagnose(server)); RunAsync("Diagnosing WMI access...", () => _service.Diagnose(server));
} }
/// <summary>
/// Handles the Snapshot Before button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void BeforeClick(object sender, EventArgs e) private void BeforeClick(object sender, EventArgs e)
{ {
var options = GetOptions(); var options = GetOptions();
@@ -206,6 +326,11 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Handles the Snapshot After button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void AfterClick(object sender, EventArgs e) private void AfterClick(object sender, EventArgs e)
{ {
var options = GetOptions(); var options = GetOptions();
@@ -217,6 +342,11 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Handles the Compare button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void CompareClick(object sender, EventArgs e) private void CompareClick(object sender, EventArgs e)
{ {
var options = GetOptions(); var options = GetOptions();
@@ -230,21 +360,26 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Handles the Shutdown button click and runs the guarded shutdown workflow.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void ShutdownClick(object sender, EventArgs e) private void ShutdownClick(object sender, EventArgs e)
{ {
if (!ConfirmDangerousAction("Shutdown"))
{
return;
}
var options = GetOptions(); var options = GetOptions();
RunAsync("Preparing shutdown...", () => RunAsync("Preparing shutdown...", () =>
{ {
var snapshot = _service.CreateSnapshot(options.Server); var snapshot = _service.CreateSnapshot(options.Server);
_service.SaveSnapshot(options.OutputDirectory, "before.json", snapshot); _service.SaveSnapshot(options.OutputDirectory, "before.json", snapshot);
var plan = _service.CreateShutdownPlan(snapshot, options.Server); var plan = _service.CreateShutdownPlan(snapshot, options.Server);
_service.SavePlan(options.OutputDirectory, "shutdown-plan.json", plan); var planPath = _service.SavePlan(options.OutputDirectory, "shutdown-plan.json", plan);
ShowPlan(plan); ShowPlan(plan);
if (!options.DryRun && !ConfirmPreparedPlan("Shutdown", plan, options.Server, planPath))
{
_logger.Warning("Shutdown cancelled after plan review. No runtime state was changed.");
return;
}
_service.ExecutePlan(plan, options); _service.ExecutePlan(plan, options);
if (!options.DryRun) if (!options.DryRun)
{ {
@@ -255,20 +390,25 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Handles the Restore button click and runs the guarded restore workflow.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void RestoreClick(object sender, EventArgs e) private void RestoreClick(object sender, EventArgs e)
{ {
if (!ConfirmDangerousAction("Restore"))
{
return;
}
var options = GetOptions(); var options = GetOptions();
RunAsync("Preparing restore...", () => RunAsync("Preparing restore...", () =>
{ {
var snapshot = JsonFileStore.Load<BizTalkSnapshot>(ResolveStateFile(options)); var snapshot = JsonFileStore.Load<BizTalkSnapshot>(ResolveStateFile(options));
var plan = _service.CreateRestorePlan(snapshot, options.Server); var plan = _service.CreateRestorePlan(snapshot, options.Server);
_service.SavePlan(options.OutputDirectory, "restore-plan.json", plan); var planPath = _service.SavePlan(options.OutputDirectory, "restore-plan.json", plan);
ShowPlan(plan); ShowPlan(plan);
if (!options.DryRun && !ConfirmPreparedPlan("Restore", plan, options.Server, planPath))
{
_logger.Warning("Restore cancelled after plan review. No runtime state was changed.");
return;
}
_service.ExecutePlan(plan, options); _service.ExecutePlan(plan, options);
if (!options.DryRun) if (!options.DryRun)
{ {
@@ -279,6 +419,11 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Handles the Clear button click by removing visible state without deleting files.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void ClearClick(object sender, EventArgs e) private void ClearClick(object sender, EventArgs e)
{ {
_statusGrid.Rows.Clear(); _statusGrid.Rows.Clear();
@@ -287,11 +432,21 @@ namespace BizTalkPlatformManagementTool.Ui
_statusLabel.Text = "Ready."; _statusLabel.Text = "Ready.";
} }
/// <summary>
/// Handles the Close button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void CloseClick(object sender, EventArgs e) private void CloseClick(object sender, EventArgs e)
{ {
Close(); Close();
} }
/// <summary>
/// Runs long-running BizTalk work on a background task and keeps the UI responsive.
/// </summary>
/// <param name="status">The status text shown while the work is running.</param>
/// <param name="work">The work to execute on the background task.</param>
private void RunAsync(string status, Action work) private void RunAsync(string status, Action work)
{ {
SetBusy(true, status); SetBusy(true, status);
@@ -311,6 +466,10 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Reads and normalizes the current UI options.
/// </summary>
/// <returns>The runtime options selected by the user.</returns>
private OperationOptions GetOptions() private OperationOptions GetOptions()
{ {
var server = string.IsNullOrWhiteSpace(_serverTextBox.Text) ? Environment.MachineName : _serverTextBox.Text.Trim(); var server = string.IsNullOrWhiteSpace(_serverTextBox.Text) ? Environment.MachineName : _serverTextBox.Text.Trim();
@@ -330,27 +489,52 @@ namespace BizTalkPlatformManagementTool.Ui
}; };
} }
/// <summary>
/// Resolves a restore state file against the output directory when it is relative.
/// </summary>
/// <param name="options">The options containing the state file and output directory.</param>
/// <returns>The absolute or output-relative state file path.</returns>
private string ResolveStateFile(OperationOptions options) private string ResolveStateFile(OperationOptions options)
{ {
return Path.IsPathRooted(options.StateFile) ? options.StateFile : Path.Combine(options.OutputDirectory, options.StateFile); return Path.IsPathRooted(options.StateFile) ? options.StateFile : Path.Combine(options.OutputDirectory, options.StateFile);
} }
private bool ConfirmDangerousAction(string actionName) /// <summary>
/// Confirms a fully prepared runtime-changing plan immediately before execution.
/// </summary>
/// <param name="actionName">The action name displayed in the confirmation dialog.</param>
/// <returns>True when the action may continue; otherwise false.</returns>
private bool ConfirmPreparedPlan(string actionName, OperationPlan plan, string server, string planPath)
{ {
if (_dryRunCheckBox.Checked) var confirmed = false;
Action showConfirmation = () =>
{ {
return true; var executableSteps = plan.Steps.Count(x => x.Execute);
}
var result = MessageBox.Show( var result = MessageBox.Show(
actionName + " will change the BizTalk runtime state on server '" + _serverTextBox.Text + "'. Continue?", actionName + " will execute " + executableSteps + " step(s) on server '" + server + "'.\n\n"
"Confirm BizTalk Runtime Change", + "The exact plan was saved to:\n" + planPath + "\n\nContinue now?",
"Confirm Prepared BizTalk Plan",
MessageBoxButtons.YesNo, MessageBoxButtons.YesNo,
MessageBoxIcon.Warning, MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2); MessageBoxDefaultButton.Button2);
return result == DialogResult.Yes; 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>
/// <param name="snapshot">The snapshot to display.</param>
private void ShowSnapshot(BizTalkSnapshot snapshot) private void ShowSnapshot(BizTalkSnapshot snapshot)
{ {
InvokeIfRequired(() => InvokeIfRequired(() =>
@@ -379,6 +563,10 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Displays snapshot differences in the status grid.
/// </summary>
/// <param name="diff">The diff to display.</param>
private void ShowDiff(SnapshotDiff diff) private void ShowDiff(SnapshotDiff diff)
{ {
InvokeIfRequired(() => InvokeIfRequired(() =>
@@ -395,6 +583,10 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Displays an operation plan in the status grid before or during execution.
/// </summary>
/// <param name="plan">The plan to display.</param>
private void ShowPlan(OperationPlan plan) private void ShowPlan(OperationPlan plan)
{ {
InvokeIfRequired(() => InvokeIfRequired(() =>
@@ -407,6 +599,10 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Appends one operation log entry to the log grid.
/// </summary>
/// <param name="entry">The log entry to display.</param>
private void AppendLog(LogEntry entry) private void AppendLog(LogEntry entry)
{ {
InvokeIfRequired(() => InvokeIfRequired(() =>
@@ -429,10 +625,16 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Enables or disables action buttons and updates the status strip.
/// </summary>
/// <param name="busy">True while a background operation is running.</param>
/// <param name="status">The status text to display.</param>
private void SetBusy(bool busy, string status) private void SetBusy(bool busy, string status)
{ {
InvokeIfRequired(() => InvokeIfRequired(() =>
{ {
_isBusy = busy;
_diagnoseButton.Enabled = !busy; _diagnoseButton.Enabled = !busy;
_beforeButton.Enabled = !busy; _beforeButton.Enabled = !busy;
_afterButton.Enabled = !busy; _afterButton.Enabled = !busy;
@@ -445,6 +647,11 @@ namespace BizTalkPlatformManagementTool.Ui
}); });
} }
/// <summary>
/// Handles the output directory Browse button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void BrowseButtonClick(object sender, EventArgs e) private void BrowseButtonClick(object sender, EventArgs e)
{ {
using (var dialog = new FolderBrowserDialog()) using (var dialog = new FolderBrowserDialog())
@@ -457,28 +664,69 @@ namespace BizTalkPlatformManagementTool.Ui
} }
} }
/// <summary>
/// Executes a UI update on the UI thread when required.
/// </summary>
/// <param name="action">The UI action to execute.</param>
private void InvokeIfRequired(Action action) private void InvokeIfRequired(Action action)
{ {
if (IsDisposed) if (IsDisposed || Disposing)
{ {
return; return;
} }
if (InvokeRequired) if (InvokeRequired)
{
try
{ {
BeginInvoke(action); BeginInvoke(action);
} }
catch (InvalidOperationException)
{
// The form was closed between the state check and BeginInvoke.
}
}
else else
{ {
action(); action();
} }
} }
/// <summary>
/// Prevents the form from being disposed while a maintenance operation is active.
/// </summary>
private void MainFormClosing(object sender, FormClosingEventArgs e)
{
if (!_isBusy)
{
return;
}
e.Cancel = true;
MessageBox.Show(
this,
"A BizTalk operation is still running. Wait for it to finish before closing the tool.",
"Operation in progress",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
}
/// <summary>
/// Creates a right-aligned label for form inputs.
/// </summary>
/// <param name="text">The label text.</param>
/// <returns>The configured label control.</returns>
private static Label Label(string text) private static Label Label(string text)
{ {
return new Label { Text = text, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleRight, Margin = new Padding(0, 5, 4, 5) }; return new Label { Text = text, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleRight, Margin = new Padding(0, 5, 4, 5) };
} }
/// <summary>
/// Creates a standard action button and attaches its click handler.
/// </summary>
/// <param name="text">The button text.</param>
/// <param name="handler">The click event handler.</param>
/// <returns>The configured button.</returns>
private static Button ActionButton(string text, EventHandler handler) private static Button ActionButton(string text, EventHandler handler)
{ {
var button = new Button { Text = text, Width = 112, Height = 34, Margin = new Padding(0, 0, 8, 0) }; var button = new Button { Text = text, Width = 112, Height = 34, Margin = new Padding(0, 0, 8, 0) };
@@ -486,12 +734,20 @@ namespace BizTalkPlatformManagementTool.Ui
return button; return button;
} }
/// <summary>
/// Applies common docking and spacing to an input control.
/// </summary>
/// <param name="control">The control to configure.</param>
private static void ConfigureInput(Control control) private static void ConfigureInput(Control control)
{ {
control.Dock = DockStyle.Fill; control.Dock = DockStyle.Fill;
control.Margin = new Padding(4, 6, 8, 6); control.Margin = new Padding(4, 6, 8, 6);
} }
/// <summary>
/// Updates the environment indicator from host instance states in the latest snapshot.
/// </summary>
/// <param name="snapshot">The latest snapshot, or null when the state is unknown.</param>
private void UpdateEnvironmentStatus(BizTalkSnapshot snapshot) private void UpdateEnvironmentStatus(BizTalkSnapshot snapshot)
{ {
if (snapshot == null || snapshot.HostInstances == null || snapshot.HostInstances.Count == 0) if (snapshot == null || snapshot.HostInstances == null || snapshot.HostInstances.Count == 0)
@@ -526,6 +782,10 @@ namespace BizTalkPlatformManagementTool.Ui
} }
} }
/// <summary>
/// Applies standard read-only display settings to a grid.
/// </summary>
/// <param name="grid">The grid to configure.</param>
private static void ConfigureGrid(DataGridView grid) private static void ConfigureGrid(DataGridView grid)
{ {
grid.Dock = DockStyle.Fill; grid.Dock = DockStyle.Fill;
@@ -538,6 +798,11 @@ namespace BizTalkPlatformManagementTool.Ui
grid.BackgroundColor = SystemColors.Window; grid.BackgroundColor = SystemColors.Window;
} }
/// <summary>
/// Formats an exception chain into a concise message for the operation log.
/// </summary>
/// <param name="ex">The exception to format.</param>
/// <returns>A message containing the top-level and unique inner exception messages.</returns>
private static string FormatException(Exception ex) private static string FormatException(Exception ex)
{ {
if (ex == null) if (ex == null)
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.0.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
<PropertyGroup><Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration><Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform><ProjectGuid>{318F4307-F62C-47C9-9B90-F0C9BF2F812A}</ProjectGuid><OutputType>Exe</OutputType><RootNamespace>BizTalkPlatformManagementTool.Tests</RootNamespace><AssemblyName>BizTalkPlatformManagementTool.Tests</AssemblyName><TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion><FileAlignment>512</FileAlignment><Deterministic>true</Deterministic></PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "><DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize><OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "><DebugType>pdbonly</DebugType><Optimize>true</Optimize><OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
<ItemGroup><Reference Include="System" /><Reference Include="System.Core" /></ItemGroup>
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BizTalkPlatformManagementTool\BizTalkPlatformManagementTool.csproj"><Project>{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}</Project><Name>BizTalkPlatformManagementTool</Name></ProjectReference>
<ProjectReference Include="..\..\src\BizTalkPlatformManagementTool.Setup\BizTalkPlatformManagementTool.Setup.csproj"><Project>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</Project><Name>BizTalkPlatformManagementTool.Setup</Name></ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,229 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using BizTalkPlatformManagementTool.Models;
using BizTalkPlatformManagementTool.Services;
using BizTalkPlatformManagementTool.Setup;
namespace BizTalkPlatformManagementTool.Tests
{
internal static class Program
{
private static int failures;
private static int Main()
{
Run("JsonRoundTripIsBomTolerantAndAtomic", JsonRoundTripIsBomTolerantAndAtomic);
Run("DiffUsesApplicationAndNameIdentity", DiffUsesApplicationAndNameIdentity);
Run("RestoreRejectsDifferentServer", RestoreRejectsDifferentServer);
Run("RestorePlanUsesSafeOrder", RestorePlanUsesSafeOrder);
Run("CsvNeutralizesFormulaValues", CsvNeutralizesFormulaValues);
Run("PackageManifestRejectsTampering", PackageManifestRejectsTampering);
Run("PackageManifestRejectsUndeclaredAndTraversalFiles", PackageManifestRejectsUndeclaredAndTraversalFiles);
Run("InstallerActivatesValidatedPayload", InstallerActivatesValidatedPayload);
Run("InstallerDoesNotMutateOnStagingFailure", InstallerDoesNotMutateOnStagingFailure);
Run("InstallerRollsBackFailedActivatedSelfTest", InstallerRollsBackFailedActivatedSelfTest);
Run("InstallerUninstallRemovesProgramDirectory", InstallerUninstallRemovesProgramDirectory);
Console.WriteLine(failures == 0 ? "ALL TESTS PASSED" : failures + " TEST(S) FAILED");
return failures == 0 ? 0 : 1;
}
private static void Run(string name, Action test)
{
try { test(); Console.WriteLine("PASS " + name); }
catch (Exception ex) { failures++; Console.Error.WriteLine("FAIL " + name + ": " + ex); }
}
private static void JsonRoundTripIsBomTolerantAndAtomic()
{
InTemp(directory =>
{
var path = Path.Combine(directory, "snapshot.json");
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
JsonFileStore.Save(path, snapshot);
JsonFileStore.Save(path, snapshot);
var original = File.ReadAllBytes(path);
var withBom = new byte[original.Length + 3];
withBom[0] = 0xef; withBom[1] = 0xbb; withBom[2] = 0xbf;
Buffer.BlockCopy(original, 0, withBom, 3, original.Length);
File.WriteAllBytes(path, withBom);
Assert(JsonFileStore.Load<BizTalkSnapshot>(path).Applications.Count == 1, "BOM JSON did not load");
Assert(Directory.GetFiles(directory, "*.tmp.*").Length == 0, "temporary files remained");
Assert(Directory.GetFiles(directory, "*.bak.*").Length == 0, "backup files remained");
});
}
private static void DiffUsesApplicationAndNameIdentity()
{
var before = Snapshot("APP-A", "SHARED", ArtifactStates.SendPortStarted);
before.Applications.Add(Snapshot("APP-B", "SHARED", ArtifactStates.SendPortStarted).Applications[0]);
var after = Snapshot("APP-A", "SHARED", ArtifactStates.SendPortStopped);
after.Applications.Add(Snapshot("APP-B", "SHARED", ArtifactStates.SendPortStarted).Applications[0]);
var diff = SnapshotComparer.Compare(before, after);
Assert(diff.ArtifactDifferences.Count == 1, "expected one application-scoped difference");
Assert(diff.ArtifactDifferences[0].Application == "APP-A", "wrong application was compared");
}
private static void RestoreRejectsDifferentServer()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.Server = "BIZTALK-A.example.local";
SnapshotValidator.EnsureServerMatches(snapshot, "BIZTALK-A");
Expect<InvalidOperationException>(() => SnapshotValidator.EnsureServerMatches(snapshot, "BIZTALK-B"));
}
private static void RestorePlanUsesSafeOrder()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
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 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 bound = plan.Steps.Single(x => x.Name == "ORCH");
Assert(!bound.Execute && bound.Kind == "Note", "bound orchestration must remain unchanged");
}
private static void CsvNeutralizesFormulaValues()
{
InTemp(directory =>
{
var path = Path.Combine(directory, "diff.csv");
var diff = new SnapshotDiff();
diff.ArtifactDifferences.Add(new ArtifactDiffEntry { Application = "=cmd|' /C calc'!A0", ArtifactType = "SendPort", Name = "PORT", Before = "Started", After = "Stopped" });
CsvWriter.WriteDiff(path, diff);
Assert(File.ReadAllText(path).Contains("'=cmd"), "formula-like CSV field was not neutralized");
});
}
private static void PackageManifestRejectsTampering()
{
InTemp(directory =>
{
var app = Path.Combine(directory, "application"); Directory.CreateDirectory(app);
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), "payload");
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
var manifest = Path.Combine(directory, "application.manifest");
PackageManifest.Write(app, manifest);
PackageManifest.ValidateAndRead(app, manifest);
File.AppendAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), "tampered");
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
});
}
private static void InstallerActivatesValidatedPayload()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var engine = new InstallerEngine(package, install, data, false, path => File.Exists(path));
engine.Install(false, null);
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "new", "new payload not activated");
Assert(File.Exists(Path.Combine(install, "install-state.txt")), "install state missing");
});
}
private static void PackageManifestRejectsUndeclaredAndTraversalFiles()
{
InTemp(directory =>
{
var app = Path.Combine(directory, "application"); Directory.CreateDirectory(app);
var exe = Path.Combine(app, InstallerEngine.ApplicationExeName);
File.WriteAllText(exe, "payload");
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
var manifest = Path.Combine(directory, "application.manifest");
PackageManifest.Write(app, manifest);
File.WriteAllText(Path.Combine(app, "undeclared.dll"), "extra");
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
File.Delete(Path.Combine(app, "undeclared.dll"));
File.WriteAllText(manifest, PackageManifest.Sha256(exe) + "|" + new FileInfo(exe).Length + "|../escape.exe" + Environment.NewLine);
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
});
}
private static void InstallerDoesNotMutateOnStagingFailure()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install"); Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "old");
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName + ".config"), "old-config");
var engine = new InstallerEngine(package, install, Path.Combine(directory, "data"), false, path => false);
Expect<InvalidOperationException>(() => engine.Install(false, null));
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "staging failure modified the installed payload");
Assert(!Directory.GetDirectories(directory, "install.backup.*").Any(), "backup was created before staging passed");
});
}
private static void InstallerRollsBackFailedActivatedSelfTest()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install"); Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "old");
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName + ".config"), "old-config");
var calls = 0;
var engine = new InstallerEngine(package, install, Path.Combine(directory, "data"), false, path => ++calls == 1);
Expect<InvalidOperationException>(() => engine.Install(false, null));
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "previous payload was not restored");
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging directory remained");
Assert(!Directory.GetDirectories(directory, "install.backup.*").Any(), "backup directory remained");
});
}
private static void InstallerUninstallRemovesProgramDirectory()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install"); Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "installed");
var engine = new InstallerEngine(package, install, Path.Combine(directory, "data"), false, path => true);
engine.Uninstall(null);
Assert(!Directory.Exists(install), "program directory still exists after uninstall");
Assert(!Directory.GetDirectories(directory, "install.removed.*").Any(), "uninstall quarantine directory remained");
});
}
private static BizTalkSnapshot Snapshot(string application, string port, int state)
{
var result = new BizTalkSnapshot { ToolVersion = "test", CreatedAt = DateTimeOffset.Now.ToString("o"), Server = Environment.MachineName };
var app = new ApplicationSnapshot { Application = application };
app.SendPorts.Add(new SendPortState { Application = application, Name = port, Status = state });
result.Applications.Add(app);
return result;
}
private static string CreatePackage(string root, string payload)
{
var package = Path.Combine(root, "package");
var app = Path.Combine(package, "application"); Directory.CreateDirectory(app);
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), payload);
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
PackageManifest.Write(app, Path.Combine(package, "application.manifest"));
return package;
}
private static void InTemp(Action<string> action)
{
var directory = Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool.Tests." + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
try { action(directory); }
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
private static void Assert(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); }
private static void Expect<T>(Action action) where T : Exception
{
try { action(); }
catch (T) { return; }
throw new InvalidOperationException("Expected exception " + typeof(T).Name);
}
}
}