diff --git a/.gitignore b/.gitignore index 5709a8f..f5c326d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ obj/ *.png work/ release/* +artifacts/ # PowerShell specifics *.ps1.orig diff --git a/BizTalkPlatformManagementTool.sln b/BizTalkPlatformManagementTool.sln index 0f2dfdf..d5137c2 100644 --- a/BizTalkPlatformManagementTool.sln +++ b/BizTalkPlatformManagementTool.sln @@ -4,6 +4,12 @@ VisualStudioVersion = 15.0.28307.168 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkPlatformManagementTool", "src\BizTalkPlatformManagementTool\BizTalkPlatformManagementTool.csproj", "{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}" 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 GlobalSection(SolutionConfigurationPlatforms) = preSolution 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}.Release|Any CPU.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CHANGELOG.md b/CHANGELOG.md index 1394fa5..68b8508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # 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 ### Changed - Projekt auf `BizTalkPlatformManagementTool` umbenannt. diff --git a/Dokumentation.md b/Dokumentation.md index e92d8a8..d240c09 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -1,6 +1,6 @@ # BizTalk Platform Management Tool Dokumentation -**Stand:** 2026-04-27 +**Stand:** 2026-08-11 **Implementierung:** C# WinForms, .NET Framework 4.6.1 **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` - WMI-Zugriff: `src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.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` ## UI Workflow @@ -42,9 +46,9 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des - 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. -- 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. -- 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. - Der Kopfbereich zeigt den zuletzt erkannten Umgebungsstatus aus den Host Instances. - 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. - 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. +- 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 @@ -81,13 +88,53 @@ Die Statusanzeige rechts im Kopfbereich bewertet die Host-Instance-Zustaende des - Nachher-Snapshots: `shutdown-after.json`, `restore-after.json` - Diff: `diff.json`, `diff.csv`, `diff.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 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 `-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 diff --git a/Installation.md b/Installation.md index 2985f19..ce6b7a1 100644 --- a/Installation.md +++ b/Installation.md @@ -1,40 +1,82 @@ -# Installation +# Installation und Update ## Voraussetzungen - Windows Server 2019/2022 oder ein Windows-Administrationshost - Microsoft BizTalk Server 2020 oder BizTalk Administration Tools - .NET Framework 4.6.1 Runtime -- Für Builds: Visual Studio mit .NET Framework 4.6.1 Developer Pack -- Zugriff auf den WMI-Namespace `root\MicrosoftBizTalkServer` -- 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 +- Lokale Administratorrechte; `Setup.exe` und die Anwendung fordern diese per UAC-Manifest an +- Zugriff auf `root\MicrosoftBizTalkServer` mit den erforderlichen BizTalk-Rechten -## Build +## Übergabe als TXT -1. Repository öffnen. -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\`. +Das Release erzeugt folgende Dateien unter `artifacts`: -## 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. -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. +Auf dem Zielsystem wird die TXT-Datei so rekonstruiert und geprüft: -## 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. -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. +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. -## 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. diff --git a/README.md b/README.md index b6d1208..35995dd 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,12 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d - Dry-run mode enabled by default - WMI access through `root\MicrosoftBizTalkServer` - Startup check for administrator rights -- Detailed operation logging in the GUI and daily rolling log files 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 - Clear and Close actions in the main toolbar - 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 @@ -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 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 @@ -64,9 +68,9 @@ Orchestrations that were `Bound` are deliberately left unchanged during restore - `shutdown-after.json`, `restore-after.json` - `diff.json`, `diff.csv`, `diff.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 @@ -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. +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 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. +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 - [Installation](Installation.md) - [Dokumentation](Dokumentation.md) +- [Installer stability analysis](docs/Installer-Stabilitaetsanalyse-2026-08-11.md) - [References](REFERENCES.md) diff --git a/docs/Installer-Stabilitaetsanalyse-2026-08-11.md b/docs/Installer-Stabilitaetsanalyse-2026-08-11.md new file mode 100644 index 0000000..9680623 --- /dev/null +++ b/docs/Installer-Stabilitaetsanalyse-2026-08-11.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. diff --git a/scripts/build-release.cmd b/scripts/build-release.cmd new file mode 100644 index 0000000..6d7ee96 --- /dev/null +++ b/scripts/build-release.cmd @@ -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% diff --git a/scripts/package-release.cmd b/scripts/package-release.cmd new file mode 100644 index 0000000..11b02b0 --- /dev/null +++ b/scripts/package-release.cmd @@ -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% diff --git a/scripts/test-release.cmd b/scripts/test-release.cmd new file mode 100644 index 0000000..68e862c --- /dev/null +++ b/scripts/test-release.cmd @@ -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% diff --git a/src/BizTalkPlatformManagementTool.Packager/BizTalkPlatformManagementTool.Packager.csproj b/src/BizTalkPlatformManagementTool.Packager/BizTalkPlatformManagementTool.Packager.csproj new file mode 100644 index 0000000..84871e9 --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Packager/BizTalkPlatformManagementTool.Packager.csproj @@ -0,0 +1,16 @@ + + + + + DebugAnyCPU + {74A5D422-0BA5-4559-BD81-C89C071A8FE4}Exe + BizTalkPlatformManagementTool.PackagerBizTalkPlatformManagementTool.Packager + v4.6.1512true + + truefullfalsebin\Debug\DEBUG;TRACE4 + pdbonlytruebin\Release\TRACE4 + + + {675B68A9-BD80-46A5-B8C5-3B11B0B374E2}BizTalkPlatformManagementTool.Setup + + diff --git a/src/BizTalkPlatformManagementTool.Packager/Program.cs b/src/BizTalkPlatformManagementTool.Packager/Program.cs new file mode 100644 index 0000000..e71bfd3 --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Packager/Program.cs @@ -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 "); + 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)); + } + } +} diff --git a/src/BizTalkPlatformManagementTool.Setup/BizTalkPlatformManagementTool.Setup.csproj b/src/BizTalkPlatformManagementTool.Setup/BizTalkPlatformManagementTool.Setup.csproj new file mode 100644 index 0000000..83fd386 --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/BizTalkPlatformManagementTool.Setup.csproj @@ -0,0 +1,44 @@ + + + + + Debug + AnyCPU + {675B68A9-BD80-46A5-B8C5-3B11B0B374E2} + WinExe + BizTalkPlatformManagementTool.Setup + BizTalkPlatformManagementTool.Setup + v4.6.1 + 512 + true + true + app.manifest + false + + + truefullfalse + bin\Debug\DEBUG;TRACE4 + + + pdbonlytrue + bin\Release\TRACE4 + + + + + + + + + + + + + + + + + + + + diff --git a/src/BizTalkPlatformManagementTool.Setup/InstallerEngine.cs b/src/BizTalkPlatformManagementTool.Setup/InstallerEngine.cs new file mode 100644 index 0000000..69cfcd7 --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/InstallerEngine.cs @@ -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 selfTestRunner; + + private sealed class WindowsIntegrationSnapshot + { + public bool RegistryKeyExisted { get; set; } + public Dictionary> 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 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"); + } + + /// Gets the fixed machine-wide application installation directory. + public string InstallDirectory { get { return installDirectory; } } + /// Gets whether this setup copy contains a complete install/update payload. + public bool HasInstallPayload { get { return Directory.Exists(Path.Combine(packageDirectory, "application")) && File.Exists(Path.Combine(packageDirectory, "application.manifest")); } } + /// Gets whether the application executable is present at the install target. + public bool IsInstalled { get { return File.Exists(Path.Combine(installDirectory, ApplicationExeName)); } } + + /// Validates, stages and transactionally installs or updates the application. + public void Install(bool createDesktopShortcut, Action report) + { + report = report ?? delegate { }; + Directory.CreateDirectory(dataDirectory); + var log = SetupOperationLog.Create(dataDirectory, "install-update"); + Action 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(); + 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); + } + } + + /// Removes the active program directory and registered Windows integration. + public void Uninstall(Action report) + { + report = report ?? delegate { }; + Directory.CreateDirectory(dataDirectory); + var log = SetupOperationLog.Create(dataDirectory, "uninstall"); + Action 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>(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 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 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 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"); } + } + } +} diff --git a/src/BizTalkPlatformManagementTool.Setup/MainForm.cs b/src/BizTalkPlatformManagementTool.Setup/MainForm.cs new file mode 100644 index 0000000..ba3631d --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/MainForm.cs @@ -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(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(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); + } + } +} diff --git a/src/BizTalkPlatformManagementTool.Setup/PackageManifest.cs b/src/BizTalkPlatformManagementTool.Setup/PackageManifest.cs new file mode 100644 index 0000000..eeb9a8d --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/PackageManifest.cs @@ -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 + { + /// Gets or sets the normalized payload-relative path. + public string RelativePath { get; set; } + /// Gets or sets the declared file length. + public long Length { get; set; } + /// Gets or sets the lowercase SHA-256 digest. + public string Sha256 { get; set; } + } + + public static class PackageManifest + { + /// Reads and cryptographically validates a complete application payload manifest. + public static IList 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(); + var seen = new HashSet(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; + } + + /// Creates a deterministic manifest covering every application payload file. + 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)); + } + + /// Calculates the lowercase SHA-256 digest of a file. + 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(); + } + } + + /// Resolves a relative payload path and rejects directory traversal. + 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; + } + } +} diff --git a/src/BizTalkPlatformManagementTool.Setup/Program.cs b/src/BizTalkPlatformManagementTool.Setup/Program.cs new file mode 100644 index 0000000..6e993f1 --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/Program.cs @@ -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; + } + } +} diff --git a/src/BizTalkPlatformManagementTool.Setup/Properties/AssemblyInfo.cs b/src/BizTalkPlatformManagementTool.Setup/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f6492cb --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/Properties/AssemblyInfo.cs @@ -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")] diff --git a/src/BizTalkPlatformManagementTool.Setup/SetupOperationLog.cs b/src/BizTalkPlatformManagementTool.Setup/SetupOperationLog.cs new file mode 100644 index 0000000..8861ed2 --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/SetupOperationLog.cs @@ -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. + } + } + } +} diff --git a/src/BizTalkPlatformManagementTool.Setup/app.manifest b/src/BizTalkPlatformManagementTool.Setup/app.manifest new file mode 100644 index 0000000..4391beb --- /dev/null +++ b/src/BizTalkPlatformManagementTool.Setup/app.manifest @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj b/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj index c84037e..a86763d 100644 --- a/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj +++ b/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj @@ -12,6 +12,7 @@ 512 true true + app.manifest AnyCPU @@ -45,7 +46,9 @@ + + @@ -56,12 +59,14 @@ + + diff --git a/src/BizTalkPlatformManagementTool/Models/ArtifactStates.cs b/src/BizTalkPlatformManagementTool/Models/ArtifactStates.cs index 6809fa3..09c8b25 100644 --- a/src/BizTalkPlatformManagementTool/Models/ArtifactStates.cs +++ b/src/BizTalkPlatformManagementTool/Models/ArtifactStates.cs @@ -1,26 +1,81 @@ namespace BizTalkPlatformManagementTool.Models { + /// + /// Contains BizTalk WMI state constants and display helpers used by snapshots, + /// plans and reports. + /// public static class ArtifactStates { + /// + /// WMI status value for a bound send port. + /// public const int SendPortBound = 1; + + /// + /// WMI status value for a stopped send port. + /// public const int SendPortStopped = 2; + + /// + /// WMI status value for a started send port. + /// public const int SendPortStarted = 3; + /// + /// WMI status value for an unbound orchestration. + /// public const int OrchestrationUnbound = 1; + + /// + /// WMI status value for a bound orchestration. + /// public const int OrchestrationBound = 2; + + /// + /// WMI status value for a stopped orchestration. + /// public const int OrchestrationStopped = 3; + + /// + /// WMI status value for a started orchestration. + /// public const int OrchestrationStarted = 4; + /// + /// WMI service state value for a stopped host instance. + /// public const int HostStopped = 1; + + /// + /// WMI service state value for a host instance that is starting. + /// public const int HostStartPending = 2; + + /// + /// WMI service state value for a host instance that is stopping. + /// public const int HostStopPending = 3; + + /// + /// WMI service state value for a started host instance. + /// public const int HostStarted = 4; + /// + /// Converts a receive location enabled flag into the text used in reports. + /// + /// True when the receive location is enabled. + /// A display value for the receive location state. public static string FormatReceiveLocation(bool enabled) { return enabled ? "Enabled" : "Disabled"; } + /// + /// Converts an MSBTS_SendPort.Status value into a display string. + /// + /// The raw WMI send port status value. + /// A known status name or an Unknown value with the raw code. public static string FormatSendPort(int status) { switch (status) @@ -32,6 +87,11 @@ namespace BizTalkPlatformManagementTool.Models } } + /// + /// Converts an MSBTS_Orchestration.OrchestrationStatus value into a display string. + /// + /// The raw WMI orchestration status value. + /// A known status name or an Unknown value with the raw code. public static string FormatOrchestration(int status) { switch (status) @@ -44,6 +104,11 @@ namespace BizTalkPlatformManagementTool.Models } } + /// + /// Converts an MSBTS_HostInstance.ServiceState value into a display string. + /// + /// The raw WMI host instance service state value. + /// A known service state name or an Unknown value with the raw code. public static string FormatHostInstance(int state) { switch (state) diff --git a/src/BizTalkPlatformManagementTool/Models/BizTalkSnapshot.cs b/src/BizTalkPlatformManagementTool/Models/BizTalkSnapshot.cs index d769252..3d044b2 100644 --- a/src/BizTalkPlatformManagementTool/Models/BizTalkSnapshot.cs +++ b/src/BizTalkPlatformManagementTool/Models/BizTalkSnapshot.cs @@ -3,34 +3,62 @@ using System.Runtime.Serialization; namespace BizTalkPlatformManagementTool.Models { + /// + /// Represents one captured BizTalk runtime state including application artifacts + /// and host instances. + /// [DataContract] public sealed class BizTalkSnapshot { + /// + /// Initializes a new snapshot with empty application and host instance collections. + /// public BizTalkSnapshot() { Applications = new List(); HostInstances = new List(); } + /// + /// Gets or sets the tool version that created the snapshot. + /// [DataMember(Order = 1)] public string ToolVersion { get; set; } + /// + /// Gets or sets the local timestamp when the snapshot was created. + /// [DataMember(Order = 2)] public string CreatedAt { get; set; } + /// + /// Gets or sets the BizTalk server name used for the WMI connection. + /// [DataMember(Order = 3)] public string Server { get; set; } + /// + /// Gets or sets the BizTalk application snapshots captured from WMI. + /// [DataMember(Order = 4)] public List Applications { get; set; } + /// + /// Gets or sets the host instances captured from the BizTalk group. + /// [DataMember(Order = 5)] public List HostInstances { get; set; } } + /// + /// Groups the captured artifact states for one BizTalk application. + /// [DataContract] public sealed class ApplicationSnapshot { + /// + /// Initializes an application snapshot with empty artifact collections. + /// public ApplicationSnapshot() { ReceiveLocations = new List(); @@ -38,88 +66,169 @@ namespace BizTalkPlatformManagementTool.Models Orchestrations = new List(); } + /// + /// Gets or sets the BizTalk application name. + /// [DataMember(Order = 1)] public string Application { get; set; } + /// + /// Gets or sets the receive locations that belong to the application. + /// [DataMember(Order = 2)] public List ReceiveLocations { get; set; } + /// + /// Gets or sets the send ports that belong to the application. + /// [DataMember(Order = 3)] public List SendPorts { get; set; } + /// + /// Gets or sets the orchestrations that belong to the application. + /// [DataMember(Order = 4)] public List Orchestrations { get; set; } } + /// + /// Captures the relevant WMI state for a BizTalk receive location. + /// [DataContract] public sealed class ReceiveLocationState { + /// + /// Gets or sets the owning BizTalk application. + /// [DataMember(Order = 1)] public string Application { get; set; } + /// + /// Gets or sets the receive location name. + /// [DataMember(Order = 2)] public string Name { get; set; } + /// + /// Gets or sets the parent receive port name. + /// [DataMember(Order = 3)] public string ReceivePortName { get; set; } + /// + /// Gets or sets a value indicating whether the receive location is enabled. + /// [DataMember(Order = 4)] public bool Enabled { get; set; } + /// + /// Gets or sets the receive adapter name. + /// [DataMember(Order = 5)] public string AdapterName { get; set; } + /// + /// Gets or sets the receive location transport address. + /// [DataMember(Order = 6)] public string Address { get; set; } } + /// + /// Captures the relevant WMI state for a BizTalk send port. + /// [DataContract] public sealed class SendPortState { + /// + /// Gets or sets the owning BizTalk application. + /// [DataMember(Order = 1)] public string Application { get; set; } + /// + /// Gets or sets the send port name. + /// [DataMember(Order = 2)] public string Name { get; set; } + /// + /// Gets or sets the raw MSBTS_SendPort.Status value. + /// [DataMember(Order = 3)] public int Status { get; set; } + /// + /// Gets or sets the primary transport adapter type. + /// [DataMember(Order = 4)] public string PrimaryTransportType { get; set; } + /// + /// Gets or sets the primary transport address. + /// [DataMember(Order = 5)] public string PrimaryTransportAddress { get; set; } } + /// + /// Captures the relevant WMI state for a BizTalk orchestration. + /// [DataContract] public sealed class OrchestrationState { + /// + /// Gets or sets the owning BizTalk application. + /// [DataMember(Order = 1)] public string Application { get; set; } + /// + /// Gets or sets the orchestration name. + /// [DataMember(Order = 2)] public string Name { get; set; } + /// + /// Gets or sets the raw MSBTS_Orchestration.OrchestrationStatus value. + /// [DataMember(Order = 3)] public int OrchestrationStatus { get; set; } } + /// + /// Captures the relevant WMI state for a BizTalk host instance. + /// [DataContract] public sealed class HostInstanceState { + /// + /// Gets or sets the host instance name used as the WMI key. + /// [DataMember(Order = 1)] public string InstanceName { get; set; } + /// + /// Gets or sets the BizTalk host name. + /// [DataMember(Order = 2)] public string HostName { get; set; } + /// + /// Gets or sets the server on which the host instance runs. + /// [DataMember(Order = 3)] public string Server { get; set; } + /// + /// Gets or sets the raw MSBTS_HostInstance.ServiceState value. + /// [DataMember(Order = 4)] public int RawState { get; set; } + /// + /// Gets or sets the formatted host instance state. + /// [DataMember(Order = 5)] public string StateText { get; set; } } diff --git a/src/BizTalkPlatformManagementTool/Models/DiffModels.cs b/src/BizTalkPlatformManagementTool/Models/DiffModels.cs index f4a19d4..509ac00 100644 --- a/src/BizTalkPlatformManagementTool/Models/DiffModels.cs +++ b/src/BizTalkPlatformManagementTool/Models/DiffModels.cs @@ -3,56 +3,104 @@ using System.Runtime.Serialization; namespace BizTalkPlatformManagementTool.Models { + /// + /// Contains all differences detected between two BizTalk snapshots. + /// [DataContract] public sealed class SnapshotDiff { + /// + /// Initializes a new diff with empty artifact and host instance collections. + /// public SnapshotDiff() { ArtifactDifferences = new List(); HostInstanceDifferences = new List(); } + /// + /// Gets or sets state differences for receive locations, send ports and orchestrations. + /// [DataMember(Order = 1)] public List ArtifactDifferences { get; set; } + /// + /// Gets or sets state differences for host instances. + /// [DataMember(Order = 2)] public List HostInstanceDifferences { get; set; } } + /// + /// Describes a before/after state change for one BizTalk application artifact. + /// [DataContract] public sealed class ArtifactDiffEntry { + /// + /// Gets or sets the owning BizTalk application. + /// [DataMember(Order = 1)] public string Application { get; set; } + /// + /// Gets or sets the artifact category, such as SendPort or ReceiveLocation. + /// [DataMember(Order = 2)] public string ArtifactType { get; set; } + /// + /// Gets or sets the artifact name. + /// [DataMember(Order = 3)] public string Name { get; set; } + /// + /// Gets or sets the formatted state in the before snapshot. + /// [DataMember(Order = 4)] public string Before { get; set; } + /// + /// Gets or sets the formatted state in the after snapshot. + /// [DataMember(Order = 5)] public string After { get; set; } } + /// + /// Describes a before/after state change for one BizTalk host instance. + /// [DataContract] public sealed class HostInstanceDiffEntry { + /// + /// Gets or sets the host instance name. + /// [DataMember(Order = 1)] public string InstanceName { get; set; } + /// + /// Gets or sets the BizTalk host name. + /// [DataMember(Order = 2)] public string HostName { get; set; } + /// + /// Gets or sets the server on which the host instance runs. + /// [DataMember(Order = 3)] public string Server { get; set; } + /// + /// Gets or sets the formatted state in the before snapshot. + /// [DataMember(Order = 4)] public string Before { get; set; } + /// + /// Gets or sets the formatted state in the after snapshot. + /// [DataMember(Order = 5)] public string After { get; set; } } diff --git a/src/BizTalkPlatformManagementTool/Models/OperationModels.cs b/src/BizTalkPlatformManagementTool/Models/OperationModels.cs index 0ff62a4..fdb1fbc 100644 --- a/src/BizTalkPlatformManagementTool/Models/OperationModels.cs +++ b/src/BizTalkPlatformManagementTool/Models/OperationModels.cs @@ -3,92 +3,211 @@ using System.Runtime.Serialization; namespace BizTalkPlatformManagementTool.Models { + /// + /// Defines the supported operation plan modes. + /// public enum OperationMode { + /// + /// Plan mode for stopping BizTalk runtime artifacts before maintenance. + /// Shutdown, + + /// + /// Plan mode for returning BizTalk runtime artifacts to a captured state. + /// Restore } + /// + /// Defines the artifact categories that can appear in an operation plan. + /// public enum OperationStepKind { + /// + /// A receive location step. + /// ReceiveLocation, + + /// + /// A send port step. + /// SendPort, + + /// + /// An orchestration step. + /// Orchestration, + + /// + /// A host instance step. + /// HostInstance, + + /// + /// An informational step that is intentionally not executed. + /// Note } + /// + /// Represents the ordered shutdown or restore plan written before any + /// runtime-changing operation is executed. + /// [DataContract] public sealed class OperationPlan { + /// + /// Initializes a new operation plan with an empty step collection. + /// public OperationPlan() { Steps = new List(); } + /// + /// Gets or sets the plan mode as a serialized string. + /// [DataMember(Order = 1)] public string Mode { get; set; } + /// + /// Gets or sets the local timestamp when the plan was created. + /// [DataMember(Order = 2)] public string CreatedAt { get; set; } + /// + /// Gets or sets the target server for server-scoped plan steps. + /// [DataMember(Order = 3)] public string Server { get; set; } + /// + /// Gets or sets the ordered operation steps. + /// [DataMember(Order = 4)] public List Steps { get; set; } } + /// + /// Describes one executable or informational step in a shutdown or restore plan. + /// [DataContract] public sealed class OperationStep { + /// + /// Gets or sets the artifact kind for this step. + /// [DataMember(Order = 1)] public string Kind { get; set; } + /// + /// Gets or sets the owning BizTalk application, when applicable. + /// [DataMember(Order = 2)] public string Application { get; set; } + /// + /// Gets or sets the artifact or host instance name displayed to the user. + /// [DataMember(Order = 3)] public string Name { get; set; } + /// + /// Gets or sets the server associated with the step, when server scoped. + /// [DataMember(Order = 4)] public string Server { get; set; } + /// + /// Gets or sets the human-readable action description. + /// [DataMember(Order = 5)] public string Action { get; set; } + /// + /// Gets or sets the WMI class used to resolve the runtime object. + /// [DataMember(Order = 6)] public string WmiClass { get; set; } + /// + /// Gets or sets the WMI key property used to locate the runtime object. + /// [DataMember(Order = 7)] public string KeyProperty { get; set; } + /// + /// Gets or sets the WMI key value used to locate the runtime object. + /// [DataMember(Order = 8)] public string KeyValue { get; set; } + /// + /// Gets or sets the WMI method or service pseudo-method to execute. + /// [DataMember(Order = 9)] public string MethodName { get; set; } + /// + /// Gets or sets the numeric arguments passed to the WMI method. + /// [DataMember(Order = 10)] public int[] Arguments { get; set; } + /// + /// Gets or sets the raw WMI state expected after the method completes. + /// [DataMember(Order = 11)] public int? TargetState { get; set; } + /// + /// Gets or sets a value indicating whether the step should be executed. + /// [DataMember(Order = 12)] public bool Execute { get; set; } + /// + /// Gets or sets an operator-facing warning for skipped or risky steps. + /// [DataMember(Order = 13)] public string Warning { get; set; } } + /// + /// Contains user-selected runtime options for snapshot, shutdown and restore actions. + /// public sealed class OperationOptions { + /// + /// Gets or sets the BizTalk server used for WMI operations. + /// public string Server { get; set; } + + /// + /// Gets or sets the directory where snapshots, plans, reports and diffs are written. + /// public string OutputDirectory { get; set; } + + /// + /// Gets or sets the state file used as restore input. + /// public string StateFile { get; set; } + + /// + /// Gets or sets a value indicating whether operations should only be logged. + /// public bool DryRun { get; set; } + + /// + /// Gets or sets the maximum number of seconds to wait for a target runtime state. + /// public int WaitTimeoutSeconds { get; set; } + + /// + /// Gets or sets the number of seconds between WMI polling attempts. + /// public int PollIntervalSeconds { get; set; } } } diff --git a/src/BizTalkPlatformManagementTool/Program.cs b/src/BizTalkPlatformManagementTool/Program.cs index 4882bd6..f1da365 100644 --- a/src/BizTalkPlatformManagementTool/Program.cs +++ b/src/BizTalkPlatformManagementTool/Program.cs @@ -1,15 +1,27 @@ using System; using System.Security.Principal; +using System.Threading; using System.Windows.Forms; using BizTalkPlatformManagementTool.Ui; namespace BizTalkPlatformManagementTool { + /// + /// Contains the WinForms application entry point and startup guard checks. + /// internal static class Program { + /// + /// Starts the application after verifying that BizTalk WMI operations can run elevated. + /// [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.SetCompatibleTextRenderingDefault(false); @@ -22,12 +34,32 @@ namespace BizTalkPlatformManagementTool "Administrator Rights Required", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; + return 1; } - Application.Run(new MainForm()); + 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()); + GC.KeepAlive(mutex); + } + return 0; } + /// + /// Determines whether the current Windows identity is a local administrator. + /// + /// True when the process is elevated as administrator; otherwise false. private static bool IsRunningAsAdministrator() { try diff --git a/src/BizTalkPlatformManagementTool/Properties/AssemblyInfo.cs b/src/BizTalkPlatformManagementTool/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..31b0388 --- /dev/null +++ b/src/BizTalkPlatformManagementTool/Properties/AssemblyInfo.cs @@ -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")] diff --git a/src/BizTalkPlatformManagementTool/RuntimeSelfTest.cs b/src/BizTalkPlatformManagementTool/RuntimeSelfTest.cs new file mode 100644 index 0000000..56e5bca --- /dev/null +++ b/src/BizTalkPlatformManagementTool/RuntimeSelfTest.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using BizTalkPlatformManagementTool.Models; +using BizTalkPlatformManagementTool.Services; + +namespace BizTalkPlatformManagementTool +{ + /// + /// Provides a WMI-free smoke test used by the installer before and after activation. + /// + 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(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; + } + } +} diff --git a/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs b/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs index 762a325..5a86519 100644 --- a/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs +++ b/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs @@ -7,27 +7,60 @@ using BizTalkPlatformManagementTool.Models; namespace BizTalkPlatformManagementTool.Services { + /// + /// Coordinates BizTalk snapshot, plan, shutdown, restore and persistence operations. + /// public sealed class BizTalkOperationService { - public const string Version = "2.0.0-net461"; + /// + /// Current tool version written into generated snapshots. + /// + public const string Version = "2.1.0-net461"; + + /// + /// Fallback application name used when WMI does not expose an application property. + /// private const string UnknownApplication = "(Unknown Application)"; + /// + /// Logger used for all operator-facing operation messages. + /// private readonly OperationLogger _logger; + /// + /// Initializes a new operation service. + /// + /// The logger used for operator-visible progress and diagnostics. public BizTalkOperationService(OperationLogger logger) { _logger = logger; } + /// + /// Verifies that the BizTalk WMI namespace is reachable and readable. + /// + /// The BizTalk server or management host to query. public void Diagnose(string server) { using (var client = CreateClient(server)) { var ports = client.Query("MSBTS_SendPort"); - _logger.Success("WMI/CIM diagnostic succeeded. Send ports visible: " + ports.Count); + try + { + _logger.Success("WMI/CIM diagnostic succeeded. Send ports visible: " + ports.Count); + } + finally + { + DisposeAll(ports); + } } } + /// + /// Captures the current BizTalk runtime state from WMI. + /// + /// The BizTalk server or management host to query. + /// A complete snapshot of supported BizTalk artifacts and host instances. public BizTalkSnapshot CreateSnapshot(string server) { using (var client = CreateClient(server)) @@ -35,63 +68,95 @@ namespace BizTalkPlatformManagementTool.Services var snapshot = new BizTalkSnapshot { ToolVersion = Version, - CreatedAt = DateTime.Now.ToString("s"), + CreatedAt = DateTimeOffset.Now.ToString("o"), Server = client.Server }; var apps = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var item in client.Query("MSBTS_ReceiveLocation")) + var receiveLocations = client.Query("MSBTS_ReceiveLocation"); + try { - var appName = GetApplicationName(item); - var app = GetApplication(apps, appName); - app.ReceiveLocations.Add(new ReceiveLocationState + foreach (var item in receiveLocations) { - Application = app.Application, - Name = BizTalkWmiClient.SafeGetString(item, "Name", string.Empty), - ReceivePortName = BizTalkWmiClient.SafeGetString(item, "ReceivePortName", string.Empty), - Enabled = !BizTalkWmiClient.SafeGetBoolean(item, "IsDisabled", true), - AdapterName = BizTalkWmiClient.SafeGetString(item, "AdapterName", string.Empty), - Address = BizTalkWmiClient.SafeGetString(item, "InboundTransportURL", string.Empty) - }); + var appName = GetApplicationName(item); + var app = GetApplication(apps, appName); + app.ReceiveLocations.Add(new ReceiveLocationState + { + Application = app.Application, + Name = BizTalkWmiClient.SafeGetString(item, "Name", string.Empty), + ReceivePortName = BizTalkWmiClient.SafeGetString(item, "ReceivePortName", string.Empty), + Enabled = !BizTalkWmiClient.SafeGetBoolean(item, "IsDisabled", true), + AdapterName = BizTalkWmiClient.SafeGetString(item, "AdapterName", 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 { - var appName = GetApplicationName(item); - var app = GetApplication(apps, appName); - app.SendPorts.Add(new SendPortState + foreach (var item in sendPorts) { - Application = app.Application, - Name = BizTalkWmiClient.SafeGetString(item, "Name", string.Empty), - Status = BizTalkWmiClient.SafeGetInt32(item, "Status", 0), - PrimaryTransportType = BizTalkWmiClient.SafeGetString(item, "PTTransportType", string.Empty), - PrimaryTransportAddress = BizTalkWmiClient.SafeGetString(item, "PTAddress", string.Empty) - }); + var appName = GetApplicationName(item); + var app = GetApplication(apps, appName); + app.SendPorts.Add(new SendPortState + { + Application = app.Application, + Name = BizTalkWmiClient.SafeGetString(item, "Name", string.Empty), + Status = BizTalkWmiClient.SafeGetInt32(item, "Status", 0), + PrimaryTransportType = BizTalkWmiClient.SafeGetString(item, "PTTransportType", 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 { - var appName = GetApplicationName(item); - var app = GetApplication(apps, appName); - app.Orchestrations.Add(new OrchestrationState + foreach (var item in orchestrations) { - Application = app.Application, - Name = BizTalkWmiClient.SafeGetString(item, "Name", string.Empty), - OrchestrationStatus = BizTalkWmiClient.SafeGetInt32(item, "OrchestrationStatus", 0) - }); + var appName = GetApplicationName(item); + var app = GetApplication(apps, appName); + app.Orchestrations.Add(new OrchestrationState + { + Application = app.Application, + Name = BizTalkWmiClient.SafeGetString(item, "Name", string.Empty), + OrchestrationStatus = BizTalkWmiClient.SafeGetInt32(item, "OrchestrationStatus", 0) + }); + } + } + finally + { + DisposeAll(orchestrations); } - foreach (var item in client.Query("MSBTS_HostInstance")) + var hostInstances = client.Query("MSBTS_HostInstance"); + try { - var state = BizTalkWmiClient.SafeGetInt32(item, "ServiceState", 0); - snapshot.HostInstances.Add(new HostInstanceState + foreach (var item in hostInstances) { - InstanceName = BizTalkWmiClient.SafeGetString(item, "InstanceName", BizTalkWmiClient.SafeGetString(item, "Name", string.Empty)), - HostName = BizTalkWmiClient.SafeGetString(item, "HostName", string.Empty), - Server = BizTalkWmiClient.SafeGetString(item, "RunningServer", string.Empty), - RawState = state, - StateText = ArtifactStates.FormatHostInstance(state) - }); + var state = BizTalkWmiClient.SafeGetInt32(item, "ServiceState", 0); + snapshot.HostInstances.Add(new HostInstanceState + { + InstanceName = BizTalkWmiClient.SafeGetString(item, "InstanceName", BizTalkWmiClient.SafeGetString(item, "Name", string.Empty)), + HostName = BizTalkWmiClient.SafeGetString(item, "HostName", string.Empty), + Server = BizTalkWmiClient.SafeGetString(item, "RunningServer", string.Empty), + RawState = state, + StateText = ArtifactStates.FormatHostInstance(state) + }); + } + } + finally + { + DisposeAll(hostInstances); } 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(); + SnapshotValidator.Validate(snapshot); _logger.Success("Snapshot created. Applications: " + snapshot.Applications.Count + ", host instances: " + snapshot.HostInstances.Count); return snapshot; } } + /// + /// Creates an ordered shutdown plan from a snapshot. + /// + /// The runtime state used as the source for the plan. + /// The selected server on which host instance steps may execute. + /// An ordered shutdown plan. public OperationPlan CreateShutdownPlan(BizTalkSnapshot snapshot, string server) { + SnapshotValidator.EnsureServerMatches(snapshot, server); var plan = NewPlan(OperationMode.Shutdown, server); foreach (var app in snapshot.Applications) @@ -144,8 +217,15 @@ namespace BizTalkPlatformManagementTool.Services return plan; } + /// + /// Creates an ordered restore plan from a previously captured snapshot. + /// + /// The state that should be restored. + /// The selected server on which host instance steps may execute. + /// An ordered restore plan. public OperationPlan CreateRestorePlan(BizTalkSnapshot snapshot, string server) { + SnapshotValidator.EnsureServerMatches(snapshot, server); var plan = NewPlan(OperationMode.Restore, server); foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted)) @@ -214,8 +294,21 @@ namespace BizTalkPlatformManagementTool.Services return plan; } + /// + /// Executes an operation plan or logs each step when dry-run mode is enabled. + /// + /// The ordered plan to execute. + /// The runtime options controlling server, dry-run and wait behavior. 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)) { foreach (var step in plan.Steps) @@ -253,6 +346,13 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Saves a snapshot and its report sidecars. + /// + /// The directory where output files are written. + /// The primary JSON file name. + /// The snapshot to persist. + /// The primary JSON file path. public string SaveSnapshot(string outputDirectory, string fileName, BizTalkSnapshot snapshot) { Directory.CreateDirectory(outputDirectory); @@ -262,6 +362,13 @@ namespace BizTalkPlatformManagementTool.Services return path; } + /// + /// Saves an operation plan as JSON. + /// + /// The directory where output files are written. + /// The plan JSON file name. + /// The plan to persist. + /// The saved plan file path. public string SavePlan(string outputDirectory, string fileName, OperationPlan plan) { Directory.CreateDirectory(outputDirectory); @@ -271,6 +378,13 @@ namespace BizTalkPlatformManagementTool.Services return path; } + /// + /// Saves a diff and its report sidecars. + /// + /// The directory where output files are written. + /// The primary diff JSON file name. + /// The diff to persist. + /// The primary diff JSON file path. public string SaveDiff(string outputDirectory, string fileName, SnapshotDiff diff) { Directory.CreateDirectory(outputDirectory); @@ -280,6 +394,13 @@ namespace BizTalkPlatformManagementTool.Services return path; } + /// + /// Executes one concrete WMI operation step and waits for its target state. + /// + /// The connected WMI client. + /// The resolved WMI object for the step. + /// The operation step to execute. + /// The runtime options controlling wait behavior. private void ExecuteStep(BizTalkWmiClient client, ManagementObject instance, OperationStep step, OperationOptions options) { if (string.Equals(step.MethodName, "StopOrEnlist", StringComparison.OrdinalIgnoreCase)) @@ -318,6 +439,12 @@ namespace BizTalkPlatformManagementTool.Services WaitForTarget(client, step, options); } + /// + /// Waits until a WMI object reaches the target state described by a plan step. + /// + /// The connected WMI client. + /// The step whose target state should be verified. + /// The runtime options controlling timeout and polling interval. private void WaitForTarget(BizTalkWmiClient client, OperationStep step, OperationOptions options) { if (!step.TargetState.HasValue) @@ -344,6 +471,11 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Creates and connects a WMI client for a server. + /// + /// The BizTalk server or management host to connect to. + /// A connected WMI client. private BizTalkWmiClient CreateClient(string server) { var client = new BizTalkWmiClient(server, _logger); @@ -351,16 +483,37 @@ namespace BizTalkPlatformManagementTool.Services return client; } + /// + /// Creates a new empty operation plan with common metadata. + /// + /// The operation mode represented by the plan. + /// The target server stored in the plan metadata. + /// A new operation plan. private static OperationPlan NewPlan(OperationMode mode, string server) { return new OperationPlan { Mode = mode.ToString(), - CreatedAt = DateTime.Now.ToString("s"), + CreatedAt = DateTimeOffset.Now.ToString("o"), Server = server }; } + /// + /// Creates one executable operation step. + /// + /// The artifact kind displayed and serialized for the step. + /// The owning BizTalk application, when applicable. + /// The artifact or host instance name. + /// The server associated with the step, when applicable. + /// The human-readable action text. + /// The WMI class used to resolve the target object. + /// The WMI key property used for lookup. + /// The WMI key value used for lookup. + /// The WMI method or pseudo-method to execute. + /// Optional numeric WMI method arguments. + /// Optional raw WMI state expected after execution. + /// A configured operation step. 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 @@ -380,6 +533,11 @@ namespace BizTalkPlatformManagementTool.Services }; } + /// + /// Converts integer method arguments into the object array required by WMI. + /// + /// The integer values from the operation step. + /// An object array suitable for ManagementObject.InvokeMethod. private static object[] ToObjects(int[] values) { if (values == null || values.Length == 0) @@ -395,6 +553,11 @@ namespace BizTalkPlatformManagementTool.Services return result; } + /// + /// Builds an operator-facing description for one plan step. + /// + /// The step to describe. + /// A compact step description for logs and errors. private static string DescribeStep(OperationStep step) { if (step == null) @@ -412,6 +575,12 @@ namespace BizTalkPlatformManagementTool.Services + "]"; } + /// + /// Gets or creates an application snapshot bucket in a dictionary. + /// + /// The application snapshot dictionary keyed by application name. + /// The application name to resolve. + /// The existing or newly created application snapshot. private static ApplicationSnapshot GetApplication(Dictionary apps, string appName) { ApplicationSnapshot app; @@ -423,6 +592,11 @@ namespace BizTalkPlatformManagementTool.Services return app; } + /// + /// Reads the application name from the available WMI properties. + /// + /// The WMI object being mapped into a snapshot item. + /// The discovered application name or a stable fallback. private static string GetApplicationName(ManagementObject item) { var names = new[] { "ApplicationName", "Application", "BizTalkApplication" }; @@ -436,5 +610,20 @@ namespace BizTalkPlatformManagementTool.Services } return UnknownApplication; } + + private static void DisposeAll(IEnumerable items) + { + if (items == null) + { + return; + } + foreach (var item in items) + { + if (item != null) + { + item.Dispose(); + } + } + } } } diff --git a/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs b/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs index 4cfef11..ddbe628 100644 --- a/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs +++ b/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs @@ -6,24 +6,53 @@ using System.Threading; namespace BizTalkPlatformManagementTool.Services { + /// + /// Provides the WMI access layer for BizTalk Server objects in root\MicrosoftBizTalkServer. + /// public sealed class BizTalkWmiClient : IDisposable { + /// + /// BizTalk WMI namespace used for all platform management queries. + /// private const string NamespacePath = "root\\MicrosoftBizTalkServer"; + + /// + /// Target server for the WMI connection. + /// private readonly string _server; + + /// + /// Logger used for WMI diagnostics and operation traces. + /// private readonly OperationLogger _logger; + + /// + /// Connected WMI management scope, created lazily or by Connect. + /// private ManagementScope _scope; + /// + /// Initializes a new WMI client for the specified server. + /// + /// The target server name, or an empty value to use the local machine. + /// The operation logger used for diagnostics and trace output. public BizTalkWmiClient(string server, OperationLogger logger) { _server = string.IsNullOrWhiteSpace(server) ? Environment.MachineName : server.Trim(); _logger = logger; } + /// + /// Gets the normalized target server name used by this client. + /// public string Server { get { return _server; } } + /// + /// Connects to the BizTalk WMI namespace on the target server. + /// public void Connect() { var path = "\\\\" + _server + "\\" + NamespacePath; @@ -33,11 +62,22 @@ namespace BizTalkPlatformManagementTool.Services _logger.Success("WMI connection established."); } + /// + /// Executes a broad WMI query for all instances of the requested BizTalk class. + /// + /// The WMI class name to query. + /// The matching WMI objects. The caller owns the returned objects. public List Query(string className) { return Query(className, true); } + /// + /// Executes a broad WMI query and optionally logs the query text. + /// + /// The WMI class name to query. + /// True to write the query to the operation log. + /// The matching WMI objects. The caller owns the returned objects. private List Query(string className, bool logQuery) { EnsureConnected(); @@ -63,17 +103,37 @@ namespace BizTalkPlatformManagementTool.Services } catch (ManagementException ex) { + foreach (var item in result) + { + item.Dispose(); + } throw new InvalidOperationException("WMI query failed. Query: " + queryText + ". WMI error: " + ex.Message, ex); } return result; } + /// + /// Finds one WMI object by comparing a property value client-side. + /// + /// The WMI class name to query. + /// The property used as the lookup key. + /// The expected property value. + /// The matching object, or null when no object matches. public ManagementObject FindByProperty(string className, string propertyName, string value) { return FindByProperty(className, propertyName, value, true); } + /// + /// Finds one WMI object using SELECT * plus a client-side filter so special + /// characters in BizTalk names cannot break a WQL WHERE clause. + /// + /// The WMI class name to query. + /// The property used as the lookup key. + /// The expected property value. + /// True to write the lookup details to the operation log. + /// The matching object, or null when no object matches. private ManagementObject FindByProperty(string className, string propertyName, string value, bool logLookup) { EnsureConnected(); @@ -118,6 +178,13 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Invokes a WMI method and validates that the method returned success. + /// + /// The WMI object on which the method should be called. + /// The method name to invoke. + /// Optional method arguments. + /// The WMI return code. public uint InvokeMethod(ManagementObject instance, string methodName, params object[] arguments) { if (instance == null) @@ -138,15 +205,36 @@ namespace BizTalkPlatformManagementTool.Services throw new InvalidOperationException("WMI method failed. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ". WMI error: " + ex.Message, ex); } - var returnCode = ExtractReturnCode(result); - if (returnCode != 0) + var output = result as ManagementBaseObject; + try { - throw new InvalidOperationException("WMI method returned an error. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ", ReturnValue: " + returnCode.ToString(CultureInfo.InvariantCulture)); - } + var returnCode = ExtractReturnCode(result); + if (returnCode != 0) + { + throw new InvalidOperationException("WMI method returned an error. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ", ReturnValue: " + returnCode.ToString(CultureInfo.InvariantCulture)); + } - return returnCode; + return returnCode; + } + finally + { + if (output != null) + { + output.Dispose(); + } + } } + /// + /// Polls one WMI object until it reaches the expected state or times out. + /// + /// The WMI class name to query. + /// The WMI key property used to find the object. + /// The WMI key value used to find the object. + /// Predicate that returns true when the state is reached. + /// Human-readable state description used in logs and errors. + /// Maximum number of seconds to wait. + /// Number of seconds between polling attempts. public void WaitForState(string className, string keyProperty, string keyValue, Func isReached, string description, int timeoutSeconds, int pollIntervalSeconds) { 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 + "]"); } + /// + /// Reads a WMI property as a string without failing on missing or invalid properties. + /// + /// The WMI object or output parameter object. + /// The property to read. + /// The value returned when the property cannot be read. + /// The property value or the fallback value. public static string SafeGetString(ManagementBaseObject item, string propertyName, string fallback) { try @@ -187,6 +287,13 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Reads a WMI property as an integer without failing on missing or invalid properties. + /// + /// The WMI object or output parameter object. + /// The property to read. + /// The value returned when the property cannot be read. + /// The property value or the fallback value. public static int SafeGetInt32(ManagementBaseObject item, string propertyName, int fallback) { try @@ -205,6 +312,13 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Reads a WMI property as a Boolean without failing on missing or invalid properties. + /// + /// The WMI object or output parameter object. + /// The property to read. + /// The value returned when the property cannot be read. + /// The property value or the fallback value. public static bool SafeGetBoolean(ManagementBaseObject item, string propertyName, bool fallback) { try @@ -223,6 +337,9 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Ensures that the management scope is connected before a WMI operation runs. + /// private void EnsureConnected() { if (_scope == null || !_scope.IsConnected) @@ -231,6 +348,12 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Checks whether a WMI object exposes a property, using case-insensitive comparison. + /// + /// The WMI object to inspect. + /// The property name to find. + /// True when the property exists; otherwise false. private static bool HasProperty(ManagementBaseObject item, string propertyName) { foreach (PropertyData property in item.Properties) @@ -244,6 +367,14 @@ namespace BizTalkPlatformManagementTool.Services return false; } + /// + /// Checks whether a WMI object matches a requested key value. + /// + /// The WMI object to inspect. + /// The WMI class name of the object. + /// The requested key property. + /// The expected key value. + /// True when the object matches the requested value. private static bool MatchesProperty(ManagementBaseObject item, string className, string propertyName, string expectedValue) { foreach (var candidate in CandidatePropertyNames(className, propertyName)) @@ -263,6 +394,12 @@ namespace BizTalkPlatformManagementTool.Services return false; } + /// + /// Returns the primary and compatibility property names for a WMI lookup. + /// + /// The WMI class name being queried. + /// The requested key property. + /// The candidate property names to inspect. private static IEnumerable CandidatePropertyNames(string className, string propertyName) { yield return propertyName; @@ -274,6 +411,11 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Builds a readable object name for diagnostics without letting WMI metadata failures escape. + /// + /// The WMI object being described. + /// A relative WMI path or fallback object name. private static string SafeObjectName(ManagementObject instance) { try @@ -286,6 +428,12 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Resolves the exact method casing exposed by the WMI class. + /// + /// The WMI object whose class should be inspected. + /// The requested method name. + /// The method name as exposed by WMI. private static string ResolveMethodName(ManagementObject instance, string requestedName) { 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); } + /// + /// Extracts the WMI return code from either a scalar return value or output parameters. + /// + /// The object returned by ManagementObject.InvokeMethod. + /// The numeric WMI return code. private static uint ExtractReturnCode(object result) { if (result == null) @@ -318,8 +471,12 @@ namespace BizTalkPlatformManagementTool.Services return Convert.ToUInt32(result, CultureInfo.InvariantCulture); } + /// + /// Releases resources owned by this client. + /// public void Dispose() { + _scope = null; } } } diff --git a/src/BizTalkPlatformManagementTool/Services/CsvWriter.cs b/src/BizTalkPlatformManagementTool/Services/CsvWriter.cs index ec8b08c..22b07ff 100644 --- a/src/BizTalkPlatformManagementTool/Services/CsvWriter.cs +++ b/src/BizTalkPlatformManagementTool/Services/CsvWriter.cs @@ -5,8 +5,16 @@ using BizTalkPlatformManagementTool.Models; namespace BizTalkPlatformManagementTool.Services { + /// + /// Writes snapshot and diff data to CSV files for review outside the GUI. + /// public static class CsvWriter { + /// + /// Writes application artifact states from a snapshot to a CSV file. + /// + /// The target CSV file path. + /// The snapshot whose artifact states should be exported. public static void WriteSnapshotArtifacts(string path, BizTalkSnapshot snapshot) { var lines = new List { "Application,Type,Name,Status" }; @@ -28,6 +36,11 @@ namespace BizTalkPlatformManagementTool.Services File.WriteAllLines(path, lines, Encoding.UTF8); } + /// + /// Writes host instance states from a snapshot to a CSV file. + /// + /// The target CSV file path. + /// The snapshot whose host instance states should be exported. public static void WriteSnapshotHosts(string path, BizTalkSnapshot snapshot) { var lines = new List { "InstanceName,HostName,Server,State" }; @@ -38,6 +51,11 @@ namespace BizTalkPlatformManagementTool.Services File.WriteAllLines(path, lines, Encoding.UTF8); } + /// + /// Writes snapshot differences to a CSV file. + /// + /// The target CSV file path. + /// The diff model to export. public static void WriteDiff(string path, SnapshotDiff diff) { var lines = new List { "Scope,Application,Type,Name,Server,Before,After" }; @@ -52,6 +70,11 @@ namespace BizTalkPlatformManagementTool.Services File.WriteAllLines(path, lines, Encoding.UTF8); } + /// + /// Builds one CSV row from already ordered field values. + /// + /// The values that should be escaped and joined. + /// A single CSV row. private static string Row(params string[] values) { var escaped = new string[values.Length]; @@ -62,9 +85,18 @@ namespace BizTalkPlatformManagementTool.Services return string.Join(",", escaped); } + /// + /// Escapes a CSV field when it contains separators, quotes or line breaks. + /// + /// The raw field value. + /// The CSV-safe field value. private static string Escape(string value) { 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) { return value; diff --git a/src/BizTalkPlatformManagementTool/Services/HtmlReportWriter.cs b/src/BizTalkPlatformManagementTool/Services/HtmlReportWriter.cs index 89bb520..5a356fb 100644 --- a/src/BizTalkPlatformManagementTool/Services/HtmlReportWriter.cs +++ b/src/BizTalkPlatformManagementTool/Services/HtmlReportWriter.cs @@ -4,8 +4,16 @@ using BizTalkPlatformManagementTool.Models; namespace BizTalkPlatformManagementTool.Services { + /// + /// Writes HTML reports for BizTalk snapshots and snapshot differences. + /// public static class HtmlReportWriter { + /// + /// Writes a complete HTML snapshot report. + /// + /// The target HTML file path. + /// The snapshot to render. public static void WriteSnapshot(string path, BizTalkSnapshot snapshot) { var html = new StringBuilder(); @@ -50,6 +58,11 @@ namespace BizTalkPlatformManagementTool.Services System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8); } + /// + /// Writes a complete HTML diff report. + /// + /// The target HTML file path. + /// The diff model to render. public static void WriteDiff(string path, SnapshotDiff diff) { var html = new StringBuilder(); @@ -74,12 +87,26 @@ namespace BizTalkPlatformManagementTool.Services System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8); } + /// + /// Appends one artifact status row to a report table. + /// + /// The report builder receiving the row markup. + /// The artifact name. + /// The formatted artifact status. + /// True when the row should use the positive status style. + /// The first detail column value. + /// The second detail column value. private static void StatusRow(StringBuilder html, string name, string status, bool ok, string detail1, string detail2) { html.Append("").Append(Encode(name)).Append("").Append(Encode(status)) .Append("").Append(Encode(detail1)).Append("").Append(Encode(detail2)).Append(""); } + /// + /// Appends the common document header, style block and title. + /// + /// The report builder receiving the header markup. + /// The document title and main heading. private static void Header(StringBuilder html, string title) { html.Append("").Append(Encode(title)).Append("

").Append(Encode(title)).Append("

"); } + /// + /// Appends the common HTML document footer. + /// + /// The report builder receiving the footer markup. private static void Footer(StringBuilder html) { html.Append(""); } + /// + /// HTML-encodes report values and treats null values as empty text. + /// + /// The raw value to encode. + /// An HTML-safe value. private static string Encode(string value) { return WebUtility.HtmlEncode(value ?? string.Empty); diff --git a/src/BizTalkPlatformManagementTool/Services/JsonFileStore.cs b/src/BizTalkPlatformManagementTool/Services/JsonFileStore.cs index 4818aec..71028c1 100644 --- a/src/BizTalkPlatformManagementTool/Services/JsonFileStore.cs +++ b/src/BizTalkPlatformManagementTool/Services/JsonFileStore.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Runtime.Serialization; using System.Runtime.Serialization.Json; @@ -5,13 +6,32 @@ using System.Text; namespace BizTalkPlatformManagementTool.Services { + /// + /// Persists DataContract models as JSON with repository-defined encoding rules. + /// public static class JsonFileStore { + /// + /// UTF-8 encoding instance that writes JSON without a byte order mark. + /// private static readonly Encoding Utf8NoBom = new UTF8Encoding(false); + /// + /// Serializes a value to a UTF-8 JSON file without a byte order mark. + /// + /// The model type to serialize. + /// The target JSON file path. + /// The value to serialize. public static void Save(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 { UseSimpleDictionaryFormat = true @@ -21,10 +41,16 @@ namespace BizTalkPlatformManagementTool.Services { serializer.WriteObject(stream, value); var json = Utf8NoBom.GetString(stream.ToArray()); - File.WriteAllText(path, json, Utf8NoBom); + WriteAtomically(fullPath, json); } } + /// + /// Loads a JSON file into the requested DataContract model type. + /// + /// The model type to deserialize. + /// The JSON file path to load. + /// The deserialized model. public static T Load(string path) { var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings @@ -46,6 +72,11 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Removes byte order mark variants that may exist in previously written files. + /// + /// The raw JSON text read from disk. + /// The JSON text without a leading BOM marker. private static string NormalizeJson(string json) { if (string.IsNullOrEmpty(json)) @@ -66,6 +97,11 @@ namespace BizTalkPlatformManagementTool.Services return json; } + /// + /// Builds a short diagnostic message for a JSON deserialization failure. + /// + /// The normalized JSON text that failed to deserialize. + /// A diagnostic suffix describing the beginning of the JSON content. private static string DescribeJsonStart(string 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."; } + + /// + /// Writes a file through a same-directory temporary file so an interrupted + /// save cannot leave a truncated snapshot or operation plan behind. + /// + 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. + } + } } } diff --git a/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs b/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs index 882d09d..374e35b 100644 --- a/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs +++ b/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs @@ -5,39 +5,107 @@ using System.Threading; namespace BizTalkPlatformManagementTool.Services { + /// + /// Defines the severity used for visible and file-based operation log entries. + /// public enum LogLevel { + /// + /// Informational progress or diagnostic entry. + /// Info, + + /// + /// Non-fatal condition that needs operator attention. + /// Warning, + + /// + /// Failed operation or exception entry. + /// Error, + + /// + /// Successful operation entry. + /// Success } + /// + /// Represents one operation log entry displayed in the GUI and written to disk. + /// public sealed class LogEntry { + /// + /// Gets or sets the local time when the entry was created. + /// public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the entry severity. + /// public LogLevel Level { get; set; } + + /// + /// Gets or sets the operator-facing message. + /// public string Message { get; set; } } + /// + /// Writes operation log entries to a daily rolling file and an optional UI sink. + /// public sealed class OperationLogger { + /// + /// Prefix used for daily log files written to the resolved log directory. + /// private const string LogFilePrefix = "BizTalkPlatformManagementTool-"; + + /// + /// File extension used for operation log files. + /// private const string LogFileExtension = ".log"; + + /// + /// Number of daily log files retained, including the current day. + /// private const int RetentionDays = 5; + + /// + /// Process-wide lock that serializes log file appends. + /// private static readonly object FileLock = new object(); + + /// + /// Process-wide flag that ensures log cleanup runs only once. + /// private static int _cleanupDone; + /// + /// Optional callback for forwarding entries to the UI. + /// private readonly Action _sink; + + /// + /// Directory where daily log files are written. + /// private readonly string _logDirectory; + /// + /// Initializes a new logger that writes beside the executable. + /// + /// Optional callback that receives entries for display. public OperationLogger(Action sink) { _sink = sink; - _logDirectory = AppDomain.CurrentDomain.BaseDirectory; + _logDirectory = ResolveLogDirectory(); CleanupOldLogs(); } + /// + /// Gets the path of the daily log file for the current date. + /// public string LogFilePath { get @@ -46,26 +114,47 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Writes an informational entry. + /// + /// The message to log. public void Info(string message) { Write(LogLevel.Info, message); } + /// + /// Writes a warning entry. + /// + /// The message to log. public void Warning(string message) { Write(LogLevel.Warning, message); } + /// + /// Writes an error entry. + /// + /// The message to log. public void Error(string message) { Write(LogLevel.Error, message); } + /// + /// Writes a success entry. + /// + /// The message to log. public void Success(string message) { Write(LogLevel.Success, message); } + /// + /// Creates a log entry and sends it to both output targets. + /// + /// The entry severity. + /// The message to log. private void Write(LogLevel level, string message) { var entry = new LogEntry @@ -85,6 +174,10 @@ namespace BizTalkPlatformManagementTool.Services _sink(entry); } + /// + /// Appends one entry to the current daily log file. + /// + /// The entry to write. private void WriteToFile(LogEntry entry) { try @@ -108,6 +201,9 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Deletes log files older than the configured retention window. + /// private void CleanupOldLogs() { if (Interlocked.Exchange(ref _cleanupDone, 1) == 1) @@ -132,5 +228,20 @@ namespace BizTalkPlatformManagementTool.Services // 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; + } + } } } diff --git a/src/BizTalkPlatformManagementTool/Services/SnapshotComparer.cs b/src/BizTalkPlatformManagementTool/Services/SnapshotComparer.cs index 9ee419d..4d71f29 100644 --- a/src/BizTalkPlatformManagementTool/Services/SnapshotComparer.cs +++ b/src/BizTalkPlatformManagementTool/Services/SnapshotComparer.cs @@ -4,10 +4,26 @@ using BizTalkPlatformManagementTool.Models; namespace BizTalkPlatformManagementTool.Services { + /// + /// Compares two BizTalk snapshots and returns the state differences that matter + /// after a maintenance window. + /// public static class SnapshotComparer { + /// + /// Compares all supported artifact and host instance states. + /// + /// The snapshot captured before maintenance. + /// The snapshot captured after maintenance. + /// A diff containing changed, new and missing artifacts. 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(); CompareReceiveLocations(diff, FlattenReceiveLocations(before), FlattenReceiveLocations(after)); @@ -18,6 +34,12 @@ namespace BizTalkPlatformManagementTool.Services return diff; } + /// + /// Adds receive location differences to the shared diff model. + /// + /// The diff model receiving the entries. + /// Receive locations keyed by name from the before snapshot. + /// Receive locations keyed by name from the after snapshot. private static void CompareReceiveLocations(SnapshotDiff diff, Dictionary before, Dictionary after) { foreach (var pair in after) @@ -42,6 +64,12 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Adds send port differences to the shared diff model. + /// + /// The diff model receiving the entries. + /// Send ports keyed by name from the before snapshot. + /// Send ports keyed by name from the after snapshot. private static void CompareSendPorts(SnapshotDiff diff, Dictionary before, Dictionary after) { foreach (var pair in after) @@ -66,6 +94,12 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Adds orchestration differences to the shared diff model. + /// + /// The diff model receiving the entries. + /// Orchestrations keyed by name from the before snapshot. + /// Orchestrations keyed by name from the after snapshot. private static void CompareOrchestrations(SnapshotDiff diff, Dictionary before, Dictionary after) { foreach (var pair in after) @@ -90,6 +124,12 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Adds host instance differences to the shared diff model. + /// + /// The diff model receiving the entries. + /// Host instances from the before snapshot. + /// Host instances from the after snapshot. private static void CompareHostInstances(SnapshotDiff diff, List before, List after) { var beforeMap = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -97,11 +137,11 @@ namespace BizTalkPlatformManagementTool.Services foreach (var item in before) { - beforeMap[item.InstanceName ?? string.Empty] = item; + beforeMap[SnapshotValidator.ArtifactKey(item.Server, item.InstanceName)] = item; } foreach (var item in after) { - afterMap[item.InstanceName ?? string.Empty] = item; + afterMap[SnapshotValidator.ArtifactKey(item.Server, item.InstanceName)] = item; } foreach (var pair in afterMap) @@ -126,6 +166,15 @@ namespace BizTalkPlatformManagementTool.Services } } + /// + /// Adds one artifact diff entry. + /// + /// The diff model receiving the entry. + /// The BizTalk application name. + /// The artifact type. + /// The artifact name. + /// The formatted before state. + /// The formatted after state. private static void AddArtifact(SnapshotDiff diff, string application, string type, string name, string before, string after) { diff.ArtifactDifferences.Add(new ArtifactDiffEntry @@ -138,6 +187,11 @@ namespace BizTalkPlatformManagementTool.Services }); } + /// + /// Flattens receive locations from all applications into a case-insensitive name map. + /// + /// The snapshot to flatten. + /// A map keyed by receive location name. private static Dictionary FlattenReceiveLocations(BizTalkSnapshot snapshot) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -145,12 +199,17 @@ namespace BizTalkPlatformManagementTool.Services { foreach (var item in app.ReceiveLocations) { - map[item.Name ?? string.Empty] = item; + map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item; } } return map; } + /// + /// Flattens send ports from all applications into a case-insensitive name map. + /// + /// The snapshot to flatten. + /// A map keyed by send port name. private static Dictionary FlattenSendPorts(BizTalkSnapshot snapshot) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -158,12 +217,17 @@ namespace BizTalkPlatformManagementTool.Services { foreach (var item in app.SendPorts) { - map[item.Name ?? string.Empty] = item; + map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item; } } return map; } + /// + /// Flattens orchestrations from all applications into a case-insensitive name map. + /// + /// The snapshot to flatten. + /// A map keyed by orchestration name. private static Dictionary FlattenOrchestrations(BizTalkSnapshot snapshot) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -171,7 +235,7 @@ namespace BizTalkPlatformManagementTool.Services { foreach (var item in app.Orchestrations) { - map[item.Name ?? string.Empty] = item; + map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item; } } return map; diff --git a/src/BizTalkPlatformManagementTool/Services/SnapshotStore.cs b/src/BizTalkPlatformManagementTool/Services/SnapshotStore.cs index 817fc88..a58a255 100644 --- a/src/BizTalkPlatformManagementTool/Services/SnapshotStore.cs +++ b/src/BizTalkPlatformManagementTool/Services/SnapshotStore.cs @@ -3,8 +3,16 @@ using BizTalkPlatformManagementTool.Models; namespace BizTalkPlatformManagementTool.Services { + /// + /// Saves snapshot and diff models together with their sidecar report formats. + /// public static class SnapshotStore { + /// + /// Saves a snapshot as JSON and creates CSV, host CSV and HTML sidecars. + /// + /// The primary JSON output path. + /// The snapshot to persist. public static void SaveSnapshotSet(string jsonPath, BizTalkSnapshot snapshot) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath))); @@ -14,6 +22,11 @@ namespace BizTalkPlatformManagementTool.Services HtmlReportWriter.WriteSnapshot(jsonPath + ".html", snapshot); } + /// + /// Saves a diff as JSON and creates CSV and HTML sidecars. + /// + /// The primary JSON output path. + /// The diff model to persist. public static void SaveDiffSet(string jsonPath, SnapshotDiff diff) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath))); diff --git a/src/BizTalkPlatformManagementTool/Services/SnapshotValidator.cs b/src/BizTalkPlatformManagementTool/Services/SnapshotValidator.cs new file mode 100644 index 0000000..60c6f5c --- /dev/null +++ b/src/BizTalkPlatformManagementTool/Services/SnapshotValidator.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using BizTalkPlatformManagementTool.Models; + +namespace BizTalkPlatformManagementTool.Services +{ + /// + /// Normalizes deserialized legacy snapshots and rejects ambiguous or unsafe input. + /// + public static class SnapshotValidator + { + /// Normalizes optional collections and rejects missing or duplicate artifact identities. + public static void Validate(BizTalkSnapshot snapshot) + { + if (snapshot == null) + { + throw new InvalidOperationException("The snapshot is empty."); + } + + snapshot.Applications = snapshot.Applications ?? new List(); + snapshot.HostInstances = snapshot.HostInstances ?? new List(); + + var receiveLocations = new HashSet(StringComparer.OrdinalIgnoreCase); + var sendPorts = new HashSet(StringComparer.OrdinalIgnoreCase); + var orchestrations = new HashSet(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(); + app.SendPorts = app.SendPorts ?? new List(); + app.Orchestrations = app.Orchestrations ?? new List(); + + 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(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); + } + } + + /// Validates a snapshot and ensures it belongs to the requested operation server. + 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 + "'."); + } + } + + /// Compares server names while accepting short-name/FQDN variants of the same host. + 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); + } + + /// Builds the collision-safe identity used for application artifacts. + public static string ArtifactKey(string application, string name) + { + return (application ?? string.Empty).Trim() + "\u001f" + (name ?? string.Empty).Trim(); + } + + private static void ValidateArtifacts(string application, string type, IEnumerable values, Func getName, HashSet 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); + } + } +} diff --git a/src/BizTalkPlatformManagementTool/Ui/MainForm.cs b/src/BizTalkPlatformManagementTool/Ui/MainForm.cs index 17d52dd..9971ceb 100644 --- a/src/BizTalkPlatformManagementTool/Ui/MainForm.cs +++ b/src/BizTalkPlatformManagementTool/Ui/MainForm.cs @@ -9,31 +9,125 @@ using BizTalkPlatformManagementTool.Services; namespace BizTalkPlatformManagementTool.Ui { + /// + /// Main WinForms surface for diagnosing, snapshotting, comparing, shutting down + /// and restoring a BizTalk platform state. + /// public sealed class MainForm : Form { + /// + /// Text input for the BizTalk server or management host. + /// private readonly TextBox _serverTextBox = new TextBox(); + + /// + /// Text input for the directory that receives snapshots, plans and reports. + /// private readonly TextBox _outputTextBox = new TextBox(); + + /// + /// Text input for the restore state file name or path. + /// private readonly TextBox _stateFileTextBox = new TextBox(); + + /// + /// Numeric input for the maximum wait time of runtime state changes. + /// private readonly NumericUpDown _timeoutInput = new NumericUpDown(); + + /// + /// Numeric input for the WMI polling interval. + /// private readonly NumericUpDown _pollInput = new NumericUpDown(); + + /// + /// Checkbox that keeps shutdown and restore operations in dry-run mode. + /// private readonly CheckBox _dryRunCheckBox = new CheckBox(); + + /// + /// Grid used to show snapshots, diffs and operation plans. + /// private readonly DataGridView _statusGrid = new DataGridView(); + + /// + /// Grid used to show operation log entries. + /// private readonly DataGridView _logGrid = new DataGridView(); + + /// + /// Status strip at the bottom of the form. + /// private readonly StatusStrip _statusStrip = new StatusStrip(); + + /// + /// Text label inside the status strip. + /// private readonly ToolStripStatusLabel _statusLabel = new ToolStripStatusLabel(); + + /// + /// Header indicator derived from the latest host instance snapshot. + /// private readonly Label _environmentStatusLabel = new Label(); + + /// + /// Logger that writes to disk and mirrors entries into the UI log grid. + /// private readonly OperationLogger _logger; + + /// + /// Service that performs all BizTalk runtime operations. + /// private readonly BizTalkOperationService _service; + /// + /// Button that validates WMI access. + /// private Button _diagnoseButton; + + /// + /// Button that creates the before snapshot. + /// private Button _beforeButton; + + /// + /// Button that creates the after snapshot. + /// private Button _afterButton; + + /// + /// Button that compares before and after snapshots. + /// private Button _compareButton; + + /// + /// Button that creates and executes the shutdown plan. + /// private Button _shutdownButton; + + /// + /// Button that creates and executes the restore plan. + /// private Button _restoreButton; + + /// + /// Button that clears visible grids and status. + /// private Button _clearButton; + + /// + /// Button that closes the application. + /// private Button _closeButton; + /// + /// Indicates that a background operation is still active. + /// + private bool _isBusy; + + /// + /// Initializes the form, operation services and visual controls. + /// public MainForm() { Text = "BizTalk Platform Management Tool"; @@ -45,9 +139,13 @@ namespace BizTalkPlatformManagementTool.Ui _logger = new OperationLogger(AppendLog); _service = new BizTalkOperationService(_logger); BuildUi(); + FormClosing += MainFormClosing; _logger.Info("Log file: " + _logger.LogFilePath); } + /// + /// Builds the root form layout and adds settings, action, result and status areas. + /// private void BuildUi() { var root = new TableLayoutPanel @@ -72,6 +170,10 @@ namespace BizTalkPlatformManagementTool.Ui root.Controls.Add(_statusStrip, 0, 3); } + /// + /// Builds the settings panel with server, output, state file and timing inputs. + /// + /// The configured settings panel. private Control BuildSettingsPanel() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 10, RowCount = 2 }; @@ -140,6 +242,10 @@ namespace BizTalkPlatformManagementTool.Ui return panel; } + /// + /// Builds the toolbar-like action panel. + /// + /// The configured action panel. private Control BuildActionPanel() { 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; } + /// + /// Builds the tab control containing status/results and operation logs. + /// + /// The configured tab control. private Control BuildTabs() { var tabs = new TabControl { Dock = DockStyle.Fill }; @@ -189,12 +299,22 @@ namespace BizTalkPlatformManagementTool.Ui return tabs; } + /// + /// Handles the Diagnose button click. + /// + /// The control that raised the event. + /// The event arguments. private void DiagnoseClick(object sender, EventArgs e) { var server = _serverTextBox.Text.Trim(); RunAsync("Diagnosing WMI access...", () => _service.Diagnose(server)); } + /// + /// Handles the Snapshot Before button click. + /// + /// The control that raised the event. + /// The event arguments. private void BeforeClick(object sender, EventArgs e) { var options = GetOptions(); @@ -206,6 +326,11 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Handles the Snapshot After button click. + /// + /// The control that raised the event. + /// The event arguments. private void AfterClick(object sender, EventArgs e) { var options = GetOptions(); @@ -217,6 +342,11 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Handles the Compare button click. + /// + /// The control that raised the event. + /// The event arguments. private void CompareClick(object sender, EventArgs e) { var options = GetOptions(); @@ -230,21 +360,26 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Handles the Shutdown button click and runs the guarded shutdown workflow. + /// + /// The control that raised the event. + /// The event arguments. private void ShutdownClick(object sender, EventArgs e) { - if (!ConfirmDangerousAction("Shutdown")) - { - return; - } - var options = GetOptions(); RunAsync("Preparing shutdown...", () => { var snapshot = _service.CreateSnapshot(options.Server); _service.SaveSnapshot(options.OutputDirectory, "before.json", snapshot); 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); + 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); if (!options.DryRun) { @@ -255,20 +390,25 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Handles the Restore button click and runs the guarded restore workflow. + /// + /// The control that raised the event. + /// The event arguments. private void RestoreClick(object sender, EventArgs e) { - if (!ConfirmDangerousAction("Restore")) - { - return; - } - var options = GetOptions(); RunAsync("Preparing restore...", () => { var snapshot = JsonFileStore.Load(ResolveStateFile(options)); 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); + 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); if (!options.DryRun) { @@ -279,6 +419,11 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Handles the Clear button click by removing visible state without deleting files. + /// + /// The control that raised the event. + /// The event arguments. private void ClearClick(object sender, EventArgs e) { _statusGrid.Rows.Clear(); @@ -287,11 +432,21 @@ namespace BizTalkPlatformManagementTool.Ui _statusLabel.Text = "Ready."; } + /// + /// Handles the Close button click. + /// + /// The control that raised the event. + /// The event arguments. private void CloseClick(object sender, EventArgs e) { Close(); } + /// + /// Runs long-running BizTalk work on a background task and keeps the UI responsive. + /// + /// The status text shown while the work is running. + /// The work to execute on the background task. private void RunAsync(string status, Action work) { SetBusy(true, status); @@ -311,6 +466,10 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Reads and normalizes the current UI options. + /// + /// The runtime options selected by the user. private OperationOptions GetOptions() { var server = string.IsNullOrWhiteSpace(_serverTextBox.Text) ? Environment.MachineName : _serverTextBox.Text.Trim(); @@ -330,27 +489,52 @@ namespace BizTalkPlatformManagementTool.Ui }; } + /// + /// Resolves a restore state file against the output directory when it is relative. + /// + /// The options containing the state file and output directory. + /// The absolute or output-relative state file path. private string ResolveStateFile(OperationOptions options) { return Path.IsPathRooted(options.StateFile) ? options.StateFile : Path.Combine(options.OutputDirectory, options.StateFile); } - private bool ConfirmDangerousAction(string actionName) + /// + /// Confirms a fully prepared runtime-changing plan immediately before execution. + /// + /// The action name displayed in the confirmation dialog. + /// True when the action may continue; otherwise false. + 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( + actionName + " will execute " + executableSteps + " step(s) on server '" + server + "'.\n\n" + + "The exact plan was saved to:\n" + planPath + "\n\nContinue now?", + "Confirm Prepared BizTalk Plan", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + confirmed = result == DialogResult.Yes; + }; - var result = MessageBox.Show( - actionName + " will change the BizTalk runtime state on server '" + _serverTextBox.Text + "'. Continue?", - "Confirm BizTalk Runtime Change", - MessageBoxButtons.YesNo, - MessageBoxIcon.Warning, - MessageBoxDefaultButton.Button2); - return result == DialogResult.Yes; + if (InvokeRequired) + { + Invoke(showConfirmation); + } + else + { + showConfirmation(); + } + return confirmed; } + /// + /// Displays a snapshot in the status grid and updates the environment indicator. + /// + /// The snapshot to display. private void ShowSnapshot(BizTalkSnapshot snapshot) { InvokeIfRequired(() => @@ -379,6 +563,10 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Displays snapshot differences in the status grid. + /// + /// The diff to display. private void ShowDiff(SnapshotDiff diff) { InvokeIfRequired(() => @@ -395,6 +583,10 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Displays an operation plan in the status grid before or during execution. + /// + /// The plan to display. private void ShowPlan(OperationPlan plan) { InvokeIfRequired(() => @@ -407,6 +599,10 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Appends one operation log entry to the log grid. + /// + /// The log entry to display. private void AppendLog(LogEntry entry) { InvokeIfRequired(() => @@ -429,10 +625,16 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Enables or disables action buttons and updates the status strip. + /// + /// True while a background operation is running. + /// The status text to display. private void SetBusy(bool busy, string status) { InvokeIfRequired(() => { + _isBusy = busy; _diagnoseButton.Enabled = !busy; _beforeButton.Enabled = !busy; _afterButton.Enabled = !busy; @@ -445,6 +647,11 @@ namespace BizTalkPlatformManagementTool.Ui }); } + /// + /// Handles the output directory Browse button click. + /// + /// The control that raised the event. + /// The event arguments. private void BrowseButtonClick(object sender, EventArgs e) { using (var dialog = new FolderBrowserDialog()) @@ -457,16 +664,27 @@ namespace BizTalkPlatformManagementTool.Ui } } + /// + /// Executes a UI update on the UI thread when required. + /// + /// The UI action to execute. private void InvokeIfRequired(Action action) { - if (IsDisposed) + if (IsDisposed || Disposing) { return; } if (InvokeRequired) { - BeginInvoke(action); + try + { + BeginInvoke(action); + } + catch (InvalidOperationException) + { + // The form was closed between the state check and BeginInvoke. + } } else { @@ -474,11 +692,41 @@ namespace BizTalkPlatformManagementTool.Ui } } + /// + /// Prevents the form from being disposed while a maintenance operation is active. + /// + 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); + } + + /// + /// Creates a right-aligned label for form inputs. + /// + /// The label text. + /// The configured label control. private static Label Label(string text) { return new Label { Text = text, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleRight, Margin = new Padding(0, 5, 4, 5) }; } + /// + /// Creates a standard action button and attaches its click handler. + /// + /// The button text. + /// The click event handler. + /// The configured button. 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) }; @@ -486,12 +734,20 @@ namespace BizTalkPlatformManagementTool.Ui return button; } + /// + /// Applies common docking and spacing to an input control. + /// + /// The control to configure. private static void ConfigureInput(Control control) { control.Dock = DockStyle.Fill; control.Margin = new Padding(4, 6, 8, 6); } + /// + /// Updates the environment indicator from host instance states in the latest snapshot. + /// + /// The latest snapshot, or null when the state is unknown. private void UpdateEnvironmentStatus(BizTalkSnapshot snapshot) { if (snapshot == null || snapshot.HostInstances == null || snapshot.HostInstances.Count == 0) @@ -526,6 +782,10 @@ namespace BizTalkPlatformManagementTool.Ui } } + /// + /// Applies standard read-only display settings to a grid. + /// + /// The grid to configure. private static void ConfigureGrid(DataGridView grid) { grid.Dock = DockStyle.Fill; @@ -538,6 +798,11 @@ namespace BizTalkPlatformManagementTool.Ui grid.BackgroundColor = SystemColors.Window; } + /// + /// Formats an exception chain into a concise message for the operation log. + /// + /// The exception to format. + /// A message containing the top-level and unique inner exception messages. private static string FormatException(Exception ex) { if (ex == null) diff --git a/src/BizTalkPlatformManagementTool/app.manifest b/src/BizTalkPlatformManagementTool/app.manifest new file mode 100644 index 0000000..4eaf355 --- /dev/null +++ b/src/BizTalkPlatformManagementTool/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/BizTalkPlatformManagementTool.Tests/BizTalkPlatformManagementTool.Tests.csproj b/tests/BizTalkPlatformManagementTool.Tests/BizTalkPlatformManagementTool.Tests.csproj new file mode 100644 index 0000000..ceae66d --- /dev/null +++ b/tests/BizTalkPlatformManagementTool.Tests/BizTalkPlatformManagementTool.Tests.csproj @@ -0,0 +1,14 @@ + + + + DebugAnyCPU{318F4307-F62C-47C9-9B90-F0C9BF2F812A}ExeBizTalkPlatformManagementTool.TestsBizTalkPlatformManagementTool.Testsv4.6.1512true + truefullfalsebin\Debug\DEBUG;TRACE4 + pdbonlytruebin\Release\TRACE4 + + + + {2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}BizTalkPlatformManagementTool + {675B68A9-BD80-46A5-B8C5-3B11B0B374E2}BizTalkPlatformManagementTool.Setup + + + diff --git a/tests/BizTalkPlatformManagementTool.Tests/Program.cs b/tests/BizTalkPlatformManagementTool.Tests/Program.cs new file mode 100644 index 0000000..34f4324 --- /dev/null +++ b/tests/BizTalkPlatformManagementTool.Tests/Program.cs @@ -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(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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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 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(Action action) where T : Exception + { + try { action(); } + catch (T) { return; } + throw new InvalidOperationException("Expected exception " + typeof(T).Name); + } + } +}