Compare commits
3
Commits
0cb3fda151
...
b32cc61e43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32cc61e43 | ||
|
|
bb2d2f8484 | ||
|
|
a74528e5c6 |
@@ -18,6 +18,7 @@ obj/
|
||||
*.png
|
||||
work/
|
||||
release/*
|
||||
artifacts/
|
||||
|
||||
# PowerShell specifics
|
||||
*.ps1.orig
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [2.1.2] - 2026-08-11
|
||||
### Added
|
||||
- Complete XML documentation for types and methods across application, setup, packager and regression projects, including parameters, generic type parameters and return values.
|
||||
- Focused German inline comments for non-obvious BizTalk ordering, WMI compatibility, persistence, security and installer transaction decisions.
|
||||
- XML documentation output in every Release project configuration for compiler-side validation.
|
||||
|
||||
### Changed
|
||||
- README, technical documentation and installation/build guidance now describe the source-documentation standard and generated XML developer artifacts.
|
||||
|
||||
## [2.1.1] - 2026-08-11
|
||||
### Added
|
||||
- Stable setup/uninstall phase codes, environment and file metadata, complete self-test output, exception chains with HRESULT/stacktrace, and per-step rollback diagnostics.
|
||||
- Installer-log fallback from ProgramData to Temp plus a setup button for opening diagnostic logs.
|
||||
- Post-write verification of shortcuts, uninstall executable and essential uninstall-registry values.
|
||||
- Regression coverage for pre-mutation rollback wording, diagnostic context, log fallback and UI-report isolation.
|
||||
|
||||
### Changed
|
||||
- Pre-mutation failures now explicitly report that no rollback was required; completed rollbacks are only reported as successful when every restoration step succeeded.
|
||||
- Self-test timeout handling is bounded after termination is requested and records kill/capture status.
|
||||
- Technical installer acceptance is explicitly separated from BizTalk WMI/environment validation.
|
||||
- Setup and uninstall preserve undeletable backup/quarantine data and record a successful completion with a cleanup warning.
|
||||
|
||||
## [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.
|
||||
|
||||
+78
-5
@@ -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,8 +21,28 @@ 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`
|
||||
|
||||
## Code-Dokumentationsstandard
|
||||
|
||||
Die vollständige C#-Codebasis in Anwendung, Installer, Packager und Regressionstests ist auf Typ- und Methodenebene mit XML-Dokumentationskommentaren versehen. Methoden dokumentieren ihre Parameter mit `<param>`, generische Typen mit `<typeparam>` und Rückgabewerte mit `<returns>`, soweit jeweils vorhanden. Die Release-Konfiguration jedes Projekts erzeugt zusätzlich eine XML-Dokumentationsdatei im jeweiligen `bin\Release`-Verzeichnis. Dadurch prüft der Compiler Syntax und Referenzen der öffentlichen Dokumentation bei jedem Release-Build.
|
||||
|
||||
Deutsche Inline-Kommentare stehen gezielt an Stellen, deren Zweck nicht allein aus dem Code hervorgeht. Dazu zählen insbesondere:
|
||||
|
||||
- sichere Shutdown-/Restore-Reihenfolge und Schutz gebundener Orchestrierungen,
|
||||
- WMI-Auflösung über breite Abfrage mit clientseitigem Filter,
|
||||
- atomare JSON-Ersetzung auf demselben Volume,
|
||||
- Neutralisierung formelartiger CSV-Werte,
|
||||
- Persistenz eines Operationsplans vor der Benutzerbestätigung,
|
||||
- Staging-, Aktivierungs-, Quarantäne- und Rollbackgrenzen des Installers,
|
||||
- begrenzte Self-Test-Prozess- und Streambehandlung.
|
||||
|
||||
Selbsterklärende Zuweisungen und reine UI-Konstruktion werden nicht zeilenweise kommentiert. Kommentare sollen die fachliche Begründung, Sicherheitsgrenze oder Plattformbesonderheit festhalten und nicht lediglich den unmittelbar sichtbaren Code wiederholen.
|
||||
|
||||
## UI Workflow
|
||||
|
||||
1. Anwendung mit Administratorrechten starten.
|
||||
@@ -42,9 +62,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 +72,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 +104,63 @@ 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 <class>`-Abfrage und filtert danach im Prozess auf den Schlüsselwert. Dadurch können Host-Instance-Namen und andere BizTalk-Namen mit Sonderzeichen keine ungültige WMI-WQL-`WHERE`-Query mehr erzeugen.
|
||||
|
||||
Snapshot- und Plan-JSON-Dateien werden als UTF-8 ohne BOM geschrieben. Beim Laden werden vorhandene Dateien mit UTF-8-BOM oder durch Encoding-Konvertierung sichtbar gewordenem BOM-Marker toleriert.
|
||||
Snapshot- und Plan-JSON-Dateien werden als UTF-8 ohne BOM über eine temporäre Datei im Zielverzeichnis und anschließenden atomaren Austausch geschrieben. Beim Laden werden vorhandene Dateien mit UTF-8-BOM oder durch Encoding-Konvertierung sichtbar gewordenem BOM-Marker toleriert. Deserialisierte Snapshots werden normalisiert und auf leere Namen, Duplikate und fehlende Strukturen geprüft.
|
||||
|
||||
Diffs verwenden den zusammengesetzten Schlüssel aus Anwendung und Artefaktname. Gleichnamige Artefakte in verschiedenen BizTalk-Anwendungen überschreiben sich daher nicht mehr. CSV-Werte mit Präfix `=`, `+`, `-`, `@` oder Tab werden mit einem Apostroph neutralisiert, damit Tabellenkalkulationen sie nicht als Formel ausführen.
|
||||
|
||||
WMI-Abfrageobjekte sowie Rückgabeobjekte von WMI-Methoden werden deterministisch freigegeben. Polling wartet am Timeout-Ende nur noch für die tatsächlich verbleibende Zeit.
|
||||
|
||||
## Installer- und Updatearchitektur
|
||||
|
||||
Das Releasepaket enthält `Setup.exe`, `application.manifest`, den Payload-Ordner `application` und die Installationsanleitung. Vor jeder Änderung prüft der Installer, dass jede Payload-Datei vollständig im Manifest enthalten ist und Länge sowie SHA-256 entsprechen. Nicht deklarierte Zusatzdateien führen zum Abbruch.
|
||||
|
||||
Die Aktivierung ist transaktional aufgebaut:
|
||||
|
||||
1. Payload validieren.
|
||||
2. Eindeutiges Staging-Verzeichnis neben dem Installationsziel erstellen.
|
||||
3. Kopierte Dateien nochmals per SHA-256 und die Staging-EXE per `--self-test` prüfen.
|
||||
4. Laufende Toolinstanz ausschließen.
|
||||
5. Bestehendes Verzeichnis in ein eindeutiges Backup verschieben.
|
||||
6. Staging auf demselben Volume als produktives Verzeichnis aktivieren.
|
||||
7. Aktivierte EXE erneut per `--self-test` prüfen.
|
||||
8. Erst danach Startmenü, optionale Desktop-Verknüpfung und Windows-Uninstall-Eintrag schreiben.
|
||||
9. Windows-Integration durch Rücklesen der Verknüpfungen und der wesentlichen Registrywerte verifizieren.
|
||||
10. Bei einem Fehler nach der ersten Mutation die neue Version entfernen und das Backup einschließlich Windows-Integration wiederherstellen.
|
||||
|
||||
Fehler vor der ersten Mutation werden als **Kein Rollback erforderlich** ausgewiesen. Ein Rollback wird nur als erfolgreich bezeichnet, wenn seine einzelnen Schritte ohne Fehler abgeschlossen wurden; ein verbliebenes Backup ohne aktives Programmverzeichnis verletzt eine zusätzlich geprüfte Invariante und wird als unvollständiger Rollback gemeldet. Bereinigungsprobleme an einem nicht mehr benötigten Backup oder Deinstallations-Quarantäneverzeichnis werden als erfolgreiche Operation mit Warnung protokolliert und zerstören die wiederherstellbaren Dateien nicht blind.
|
||||
|
||||
Das dauerhafte Setup-Log enthält pro Lauf einen Kontextkopf mit Version, Betriebssystem, 32-/64-Bit-Kontext, CLR, Rechner, Identität und Elevation. Hinzu kommen Pfade, freier Speicher, Dateiversion/-größe/-SHA-256, vollständige Self-Test-Ausgaben mit Exitcode, Exception-Ketten mit HRESULT und Stacktrace sowie jeder Rollbackschritt. Stabile `SETUP-*`- und `UNINSTALL-*`-Fehlercodes ermöglichen die Zuordnung unabhängig vom lokalisierten Meldungstext. Die Aufbewahrung beträgt 90 Tage. Wenn ProgramData für die Diagnose nicht verfügbar ist, wird ein Temp-Fallback verwendet und sichtbar gemeldet; die Schaltfläche **Diagnoselogs öffnen** öffnet den regulären Ablageort.
|
||||
|
||||
Der Self-Test ist eine technische Installationsabnahme ohne BizTalk-WMI. Ein fachlicher Umgebungszustand wie fehlender WMI-Zugriff oder `Unknown` führt deshalb nicht zum Installer-Rollback. Nach erfolgreicher Installation bleiben **Diagnose** und ein Dry-run gegen das Zielsystem verpflichtende Abnahmeschritte.
|
||||
|
||||
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.
|
||||
- Eindeutige Fehlerphase ohne irreführende Rollbackmeldung bei einem Staging-Fehler.
|
||||
- Vollständiger Diagnosekontext mit Exception-Kette und Temp-Fallback für das Setup-Log.
|
||||
- Unabhängigkeit der Installation von Fehlern der UI-Fortschrittsanzeige.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+97
-26
@@ -1,40 +1,111 @@
|
||||
# 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: `%ProgramData%\BizTalkPlatformManagementTool\InstallerLogs`
|
||||
- Uninstaller: `%ProgramData%\BizTalkPlatformManagementTool\Setup\Uninstall.exe`
|
||||
- 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. Tritt der Fehler bereits bei Paketprüfung oder Staging auf, meldet das Setup ausdrücklich **Kein Rollback erforderlich**, weil die aktive Installation unverändert blieb. Staging und Backup werden anschließend bestmöglich bereinigt. Kann ein nicht mehr benötigtes Backup wegen einer Dateisperre nicht gelöscht werden, bleibt die erfolgreiche Installation aktiv und das Log kennzeichnet den Abschluss als `success_with_backup_cleanup_warning`.
|
||||
|
||||
## Installer-Diagnose
|
||||
|
||||
Jeder Installations-, Update- und Deinstallationslauf erzeugt ein eigenes UTF-8-Log. Über **Diagnoselogs öffnen** kann der Ordner direkt aus dem Setup geöffnet werden. Die Logs werden 90 Tage aufbewahrt. Ist ProgramData nicht beschreibbar, versucht das Setup ein Fallback unter `%TEMP%\BizTalkPlatformManagementTool\InstallerLogs` und zeigt dessen Pfad an. Kann auch dort kein Log erzeugt werden, bleibt der eigentliche Fehler in der Setup-Meldung sichtbar.
|
||||
|
||||
Die Abschlussmeldung enthält einen stabilen Fehlercode und die betroffene Phase:
|
||||
|
||||
| Fehlercode | Bedeutung |
|
||||
|---|---|
|
||||
| `SETUP-INITIALIZATION` | Daten-/Logverzeichnis oder Ausgangszustand konnte nicht vorbereitet werden |
|
||||
| `SETUP-PACKAGE-VALIDATION` | Manifest, Payload, SHA-256 oder Prüfung auf laufende Anwendung fehlgeschlagen |
|
||||
| `SETUP-STAGING-VALIDATION` | Kopie ins Staging oder erster Self-Test fehlgeschlagen; aktive Installation unverändert |
|
||||
| `SETUP-ACTIVATION` | Sichern oder Aktivieren der Programmdateien fehlgeschlagen |
|
||||
| `SETUP-ACTIVATED-SELFTEST` | Zweiter Self-Test nach der Aktivierung fehlgeschlagen |
|
||||
| `SETUP-WINDOWS-INTEGRATION` | Verknüpfung, Uninstaller oder Registry-Registrierung/Verifikation fehlgeschlagen |
|
||||
| `UNINSTALL-*` | Die entsprechende Initialisierungs-, Prozess-, Quarantäne- oder Integrationsphase der Deinstallation fehlgeschlagen |
|
||||
|
||||
Das Log enthält Betriebssystem, Prozess-/OS-Bitness, .NET-Version, Rechner, Windows-Identität und Elevation sowie Installationspfade, freien Speicher, Dateigrößen, Versionen und SHA-256. Für Self-Tests werden Laufzeit, Exitcode dezimal und hexadezimal sowie vollständiges `stdout` und `stderr` geschrieben. Bei Fehlern folgen Exception-Kette, HRESULT und Stacktrace; jeder Rollbackschritt und sein Ergebnis werden separat protokolliert. Passwörter oder Verbindungsdaten werden vom Installer nicht verarbeitet oder geloggt.
|
||||
|
||||
Für eine Supportanalyse bitte sichern:
|
||||
|
||||
1. Vollständige Fehlermeldung einschließlich Fehlercode und Phase.
|
||||
2. Das jüngste `setup-*.log` aus dem vom Setup angezeigten Pfad.
|
||||
3. Ob Neuinstallation, Update oder Deinstallation ausgeführt wurde und ob Virenscanner/Endpoint Protection eine Datei blockiert hat.
|
||||
|
||||
Ein erfolgreicher Installer-Self-Test bestätigt Paket, Programmstart und lokale Kernfunktionen. Der fachliche BizTalk-Zustand ist bewusst kein Rollbackkriterium: WMI-Erreichbarkeit, Berechtigungen und Umgebungszustand danach separat über **Diagnose** und einen Dry-run prüfen. So wird beispielsweise ein erreichbarer Installer nicht wegen eines fachlichen `Unknown`-Zustands zurückgerollt.
|
||||
|
||||
## 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.
|
||||
|
||||
Die Release-Konfiguration erzeugt außerdem pro Assembly eine XML-Dokumentationsdatei im jeweiligen `bin\Release`-Verzeichnis. Damit werden XML-Kommentare und `cref`-Referenzen während des Builds compilerseitig geprüft; diese Entwicklerartefakte sind für den Betrieb nicht erforderlich und deshalb nicht Bestandteil der Installer-Payload.
|
||||
|
||||
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.
|
||||
|
||||
@@ -21,10 +21,13 @@ 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
|
||||
- Installer diagnostics with stable phase codes, complete child-process output, exception chains and a ProgramData-to-Temp log fallback
|
||||
- WMI-free runtime self-test plus automated regression test executable
|
||||
|
||||
## Safe Usage
|
||||
|
||||
@@ -39,7 +42,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 +69,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 +79,27 @@ 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.
|
||||
|
||||
Installer, update and uninstall diagnostics are retained for 90 days under `%ProgramData%\BizTalkPlatformManagementTool\InstallerLogs`. The setup UI can open that directory directly. If the primary log cannot be created, setup reports and uses a `%TEMP%\BizTalkPlatformManagementTool\InstallerLogs` fallback. See [Installation](Installation.md#installer-diagnose) for phase codes and the support checklist.
|
||||
|
||||
## 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.
|
||||
|
||||
## Source Documentation
|
||||
|
||||
All C# types and methods in the application, setup, packager and regression project use XML documentation comments. Method contracts include `param`, `typeparam` and `returns` elements where applicable. Release builds generate one XML documentation file per assembly, so malformed or missing public documentation becomes visible during compilation.
|
||||
|
||||
Targeted German inline comments explain non-obvious operational decisions such as WMI client-side filtering, shutdown/restore order, atomic file replacement, CSV formula neutralization and installer transaction boundaries. Trivial statements are intentionally not paraphrased in comments; the comments record the reason or safety constraint behind the code.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Installation](Installation.md)
|
||||
- [Dokumentation](Dokumentation.md)
|
||||
- [Installer stability analysis](docs/Installer-Stabilitaetsanalyse-2026-08-11.md)
|
||||
- [References](REFERENCES.md)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
- Temp-Fallback, falls das primäre Installerlog nicht angelegt werden kann.
|
||||
- Stabile Fehlercodes, vollständige Self-Test-Ausgaben, Exception-Ketten, HRESULTs und einzeln protokollierte Rollbackschritte.
|
||||
- Explizite Unterscheidung zwischen Fehlern vor der ersten Mutation und tatsächlich ausgeführtem Rollback.
|
||||
- Rücklesende Verifikation von Verknüpfungen, Uninstaller und zentralen Registrywerten.
|
||||
- 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.
|
||||
- Vierzehn Regressionstests einschließlich manipulierter/zusätzlicher/ausbrechender Payload-Pfade, Staging-Abbruch ohne Mutation, erzwungenem Fehler des zweiten Self-Tests mit Wiederherstellung der Vorversion, Deinstallation über ein Quarantäneverzeichnis, Diagnosekontext, Log-Fallback und Fehlerisolierung der UI-Ausgabe.
|
||||
- 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.
|
||||
|
||||
## Zweite Prüfung nach den Deployment-Erfahrungen des Schwesterprojekts
|
||||
|
||||
Die erneute Prüfung orientierte sich an den dokumentierten Schwierigkeiten aus `biztalk-checkmk-pulse`. Zwei Erkenntnisse wurden bewusst übertragen:
|
||||
|
||||
1. Ein Fehler vor der ersten Änderung darf nicht als erfolgreicher Rollback beschrieben werden. Der Installer meldet daher eindeutig, dass kein Rollback erforderlich war und protokolliert `mutation_started=false`.
|
||||
2. Technische Installationsfähigkeit und fachlicher Laufzeitzustand sind getrennte Abnahmen. Der WMI-freie Self-Test entscheidet über Aktivierung/Rollback; BizTalk-WMI, Berechtigungen und ein möglicher `Unknown`-Zustand werden erst danach mit **Diagnose** und Dry-run bewertet.
|
||||
|
||||
Zusätzlich wurden die Informationen erfasst, die bei einem schwer reproduzierbaren Windows-Deployment typischerweise fehlen: genaue Setup-/Dateiversionen und Hashes, vollständiges `stdout`/`stderr`, dezimaler und hexadezimaler Exitcode, Timeout-/Kill-Ergebnis, Identität/Elevation, Exception-Kette/HRESULT/Stacktrace und jeder Rollbackschritt. Der Self-Test wartet nach einem Timeout höchstens weitere fünf Sekunden auf Prozessende und Ausgabekanäle; ein nicht beendbarer Prozess kann das Setup somit nicht unbegrenzt blockieren.
|
||||
|
||||
## 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. ProgramData-Fehler und Temp-Log-Fallback unter realen Windows-ACLs.
|
||||
7. 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.
|
||||
@@ -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%
|
||||
@@ -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%
|
||||
@@ -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%
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration><Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{74A5D422-0BA5-4559-BD81-C89C071A8FE4}</ProjectGuid><OutputType>Exe</OutputType>
|
||||
<RootNamespace>BizTalkPlatformManagementTool.Packager</RootNamespace><AssemblyName>BizTalkPlatformManagementTool.Packager</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion><FileAlignment>512</FileAlignment><Deterministic>true</Deterministic>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "><DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize><OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "><DebugType>pdbonly</DebugType><Optimize>true</Optimize><OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel><DocumentationFile>bin\Release\BizTalkPlatformManagementTool.Packager.xml</DocumentationFile></PropertyGroup>
|
||||
<ItemGroup><Reference Include="System" /><Reference Include="System.Core" /><Reference Include="System.IO.Compression" /><Reference Include="System.IO.Compression.FileSystem" /><Reference Include="System.Security" /></ItemGroup>
|
||||
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
|
||||
<ItemGroup><ProjectReference Include="..\BizTalkPlatformManagementTool.Setup\BizTalkPlatformManagementTool.Setup.csproj"><Project>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</Project><Name>BizTalkPlatformManagementTool.Setup</Name></ProjectReference></ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using BizTalkPlatformManagementTool.Setup;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Packager
|
||||
{
|
||||
/// <summary>Erzeugt aus den Release-Binärdateien das übertragbare Setup-Paket.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Erstellt Payload, Manifest, ZIP, Certutil-kompatible Base64-TXT und SHA-256-Datei.
|
||||
/// </summary>
|
||||
/// <param name="args">Repository-Wurzel und Build-Konfiguration.</param>
|
||||
/// <returns>Null bei erfolgreicher Paketierung, andernfalls eins.</returns>
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length != 2) throw new ArgumentException("Usage: BizTalkPlatformManagementTool.Packager.exe <repository-root> <configuration>");
|
||||
var root = Path.GetFullPath(args[0]);
|
||||
var configuration = args[1];
|
||||
var artifacts = Path.Combine(root, "artifacts");
|
||||
var package = Path.Combine(artifacts, "BizTalkPlatformManagementTool-Setup");
|
||||
var application = Path.Combine(package, "application");
|
||||
var zip = Path.Combine(artifacts, "BizTalkPlatformManagementTool-Setup.zip");
|
||||
|
||||
if (Directory.Exists(package)) Directory.Delete(package, true);
|
||||
Directory.CreateDirectory(application);
|
||||
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool.Setup", "bin", configuration, "BizTalkPlatformManagementTool.Setup.exe"), Path.Combine(package, "Setup.exe"));
|
||||
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool", "bin", configuration, "BizTalkPlatformManagementTool.exe"), Path.Combine(application, "BizTalkPlatformManagementTool.exe"));
|
||||
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool", "bin", configuration, "BizTalkPlatformManagementTool.exe.config"), Path.Combine(application, "BizTalkPlatformManagementTool.exe.config"));
|
||||
Copy(Path.Combine(root, "Installation.md"), Path.Combine(package, "INSTALLATION.md"));
|
||||
PackageManifest.Write(application, Path.Combine(package, "application.manifest"));
|
||||
// Das frisch erzeugte Manifest wird vor dem äußeren ZIP sofort gegen die Payload geprüft.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kopiert eine erforderliche Release-Datei und legt ihr Zielverzeichnis an.</summary>
|
||||
/// <param name="source">Der vorhandene Quelldateipfad.</param>
|
||||
/// <param name="target">Der Zieldateipfad innerhalb des Pakets.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Schreibt eine Datei als Certutil-kompatible Base64-TXT mit 64 Zeichen pro Zeile.</summary>
|
||||
/// <param name="source">Die binäre Quelldatei.</param>
|
||||
/// <param name="target">Die zu erzeugende Textdatei.</param>
|
||||
private static void WriteBase64(string source, string target)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(File.ReadAllBytes(source));
|
||||
var builder = new StringBuilder(encoded.Length + encoded.Length / 64 + 2);
|
||||
for (var offset = 0; offset < encoded.Length; offset += 64)
|
||||
{
|
||||
builder.Append(encoded, offset, Math.Min(64, encoded.Length - offset));
|
||||
builder.Append('\n');
|
||||
}
|
||||
File.WriteAllText(target, builder.ToString(), new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>BizTalkPlatformManagementTool.Setup</RootNamespace>
|
||||
<AssemblyName>BizTalkPlatformManagementTool.Setup</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<Deterministic>true</Deterministic>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DebugType>pdbonly</DebugType><Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel>
|
||||
<DocumentationFile>bin\Release\BizTalkPlatformManagementTool.Setup.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Drawing" />
|
||||
<Reference Include="System.Security" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<Compile Include="InstallerEngine.cs" />
|
||||
<Compile Include="MainForm.cs" />
|
||||
<Compile Include="PackageManifest.cs" />
|
||||
<Compile Include="Program.cs" />
|
||||
<Compile Include="SetupOperationLog.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup><None Include="app.manifest" /></ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,899 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Führt Installation, Update und Deinstallation mit Staging, Validierung und Rollback aus.
|
||||
/// </summary>
|
||||
internal sealed class InstallerEngine
|
||||
{
|
||||
/// <summary>Dateiname der installierten Hauptanwendung.</summary>
|
||||
internal const string ApplicationExeName = "BizTalkPlatformManagementTool.exe";
|
||||
|
||||
/// <summary>Anzeigename für Verknüpfungen und Windows-Uninstall-Eintrag.</summary>
|
||||
private const string ProductName = "BizTalk Platform Management Tool";
|
||||
|
||||
/// <summary>Aktuelle Produktversion des Installers und Uninstall-Eintrags.</summary>
|
||||
private const string ProductVersion = "2.1.2";
|
||||
|
||||
/// <summary>Maschinenweiter Registrypfad des Windows-Uninstall-Eintrags.</summary>
|
||||
private const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\BizTalkPlatformManagementTool";
|
||||
|
||||
/// <summary>Verzeichnis der entpackten Setup-Dateien.</summary>
|
||||
private readonly string packageDirectory;
|
||||
|
||||
/// <summary>Aktives maschinenweites Programmverzeichnis.</summary>
|
||||
private readonly string installDirectory;
|
||||
|
||||
/// <summary>Dauerhaftes Datenverzeichnis unter ProgramData.</summary>
|
||||
private readonly string dataDirectory;
|
||||
|
||||
/// <summary>Legt fest, ob Registry, Verknüpfungen und Uninstaller verwaltet werden.</summary>
|
||||
private readonly bool registerWindowsIntegration;
|
||||
|
||||
/// <summary>Optional injizierte Self-Test-Funktion für portable Regressionstests.</summary>
|
||||
private readonly Func<string, bool> selfTestRunner;
|
||||
|
||||
/// <summary>Zuletzt tatsächlich verwendetes primäres oder temporäres Diagnoseverzeichnis.</summary>
|
||||
private string lastInstallerLogDirectory;
|
||||
|
||||
/// <summary>
|
||||
/// Enthält den vor einer Mutation gesicherten Zustand der Windows-Integration.
|
||||
/// </summary>
|
||||
private sealed class WindowsIntegrationSnapshot
|
||||
{
|
||||
/// <summary>Ruft ab oder legt fest, ob der Uninstall-Schlüssel vorher existierte.</summary>
|
||||
public bool RegistryKeyExisted { get; set; }
|
||||
|
||||
/// <summary>Ruft die vorherigen Registrywerte einschließlich ihres Typs ab oder legt sie fest.</summary>
|
||||
public Dictionary<string, Tuple<object, RegistryValueKind>> RegistryValues { get; set; }
|
||||
|
||||
/// <summary>Ruft den vorherigen Inhalt der Desktop-Verknüpfung ab oder legt ihn fest.</summary>
|
||||
public byte[] DesktopShortcut { get; set; }
|
||||
|
||||
/// <summary>Ruft den vorherigen Inhalt der Startmenü-Verknüpfung ab oder legt ihn fest.</summary>
|
||||
public byte[] StartMenuShortcut { get; set; }
|
||||
|
||||
/// <summary>Ruft den vorherigen Inhalt der Uninstaller-Datei ab oder legt ihn fest.</summary>
|
||||
public byte[] Uninstaller { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den Installer mit den maschinenweiten Standardzielpfaden.
|
||||
/// </summary>
|
||||
/// <param name="packageDirectory">Das Verzeichnis mit Setup-Payload und Manifest.</param>
|
||||
public InstallerEngine(string packageDirectory)
|
||||
: this(
|
||||
packageDirectory,
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "BizTalkPlatformManagementTool"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BizTalkPlatformManagementTool"),
|
||||
true,
|
||||
null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert den Installer mit expliziten Pfaden und austauschbarer Self-Test-Ausführung.
|
||||
/// </summary>
|
||||
/// <param name="packageDirectory">Das Verzeichnis mit Setup-Payload und Manifest.</param>
|
||||
/// <param name="installDirectory">Das aktive Programmverzeichnis.</param>
|
||||
/// <param name="dataDirectory">Das dauerhafte Daten- und Diagnoseverzeichnis.</param>
|
||||
/// <param name="registerWindowsIntegration"><c>true</c>, wenn Registry und Verknüpfungen verwaltet werden sollen.</param>
|
||||
/// <param name="selfTestRunner">Optionale Testfunktion für Regressionstests; <c>null</c> startet die reale EXE.</param>
|
||||
internal InstallerEngine(string packageDirectory, string installDirectory, string dataDirectory, bool registerWindowsIntegration, Func<string, bool> selfTestRunner)
|
||||
{
|
||||
this.packageDirectory = Path.GetFullPath(packageDirectory);
|
||||
this.installDirectory = Path.GetFullPath(installDirectory);
|
||||
this.dataDirectory = Path.GetFullPath(dataDirectory);
|
||||
this.registerWindowsIntegration = registerWindowsIntegration;
|
||||
this.selfTestRunner = selfTestRunner;
|
||||
}
|
||||
|
||||
/// <summary>Ruft das feste maschinenweite Programmverzeichnis ab.</summary>
|
||||
public string InstallDirectory { get { return installDirectory; } }
|
||||
/// <summary>Ruft das zuletzt verwendete beziehungsweise reguläre Installer-Logverzeichnis ab.</summary>
|
||||
public string InstallerLogDirectory
|
||||
{
|
||||
get { return lastInstallerLogDirectory ?? Path.Combine(dataDirectory, "InstallerLogs"); }
|
||||
}
|
||||
/// <summary>Ruft ab, ob diese Setup-Kopie Payload und Manifest für Installation oder Update enthält.</summary>
|
||||
public bool HasInstallPayload { get { return Directory.Exists(Path.Combine(packageDirectory, "application")) && File.Exists(Path.Combine(packageDirectory, "application.manifest")); } }
|
||||
/// <summary>Ruft ab, ob die Anwendungs-EXE im Installationsziel vorhanden ist.</summary>
|
||||
public bool IsInstalled { get { return File.Exists(Path.Combine(installDirectory, ApplicationExeName)); } }
|
||||
|
||||
/// <summary>
|
||||
/// Validiert und staged die Payload und installiert oder aktualisiert die Anwendung transaktional.
|
||||
/// </summary>
|
||||
/// <param name="createDesktopShortcut"><c>true</c>, wenn eine Desktop-Verknüpfung angelegt werden soll.</param>
|
||||
/// <param name="report">Optionale Fortschrittsausgabe für die Setup-Oberfläche.</param>
|
||||
public void Install(bool createDesktopShortcut, Action<string> report)
|
||||
{
|
||||
var uiReport = report ?? delegate { };
|
||||
var log = SetupOperationLog.Create(dataDirectory, "install-update");
|
||||
Action<string> write = message => ReportSafely(log, uiReport, "INFO", message);
|
||||
if (!string.IsNullOrEmpty(log.CreationError))
|
||||
write((log.IsFallback ? "WARNUNG: Primaeres Diagnoselog nicht verfuegbar; Temp-Fallback wird verwendet: " : "WARNUNG: Diagnoselog konnte nicht angelegt werden: ") + log.CreationError);
|
||||
if (log.FilePath.Length != 0)
|
||||
{
|
||||
lastInstallerLogDirectory = Path.GetDirectoryName(log.FilePath);
|
||||
write("Diagnoselog: " + log.FilePath);
|
||||
}
|
||||
|
||||
var sourceApplication = Path.Combine(packageDirectory, "application");
|
||||
var manifestPath = Path.Combine(packageDirectory, "application.manifest");
|
||||
// Staging und Backup sind Geschwister des Zielverzeichnisses. Dadurch bleiben
|
||||
// die späteren Directory.Move-Operationen auf demselben Volume atomar.
|
||||
var stagingDirectory = installDirectory + ".staging." + Guid.NewGuid().ToString("N");
|
||||
var backupDirectory = installDirectory + ".backup." + Guid.NewGuid().ToString("N");
|
||||
var hadExistingInstallation = Directory.Exists(installDirectory);
|
||||
WindowsIntegrationSnapshot integrationSnapshot = null;
|
||||
var backupCreated = false;
|
||||
var activated = false;
|
||||
var integrationMutationStarted = false;
|
||||
var phaseCode = "SETUP-INITIALIZATION";
|
||||
var phase = "Initialisierung";
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
log.Write(
|
||||
"INFO",
|
||||
"event=setup_context package_directory=\"" + packageDirectory
|
||||
+ "\" source_application=\"" + sourceApplication
|
||||
+ "\" manifest=\"" + manifestPath
|
||||
+ "\" install_directory=\"" + installDirectory
|
||||
+ "\" data_directory=\"" + dataDirectory
|
||||
+ "\" staging_directory=\"" + stagingDirectory
|
||||
+ "\" backup_directory=\"" + backupDirectory
|
||||
+ "\" existing_installation=" + hadExistingInstallation
|
||||
+ " desktop_shortcut_requested=" + createDesktopShortcut
|
||||
+ " windows_integration=" + registerWindowsIntegration);
|
||||
LogDriveSpace(log, installDirectory);
|
||||
log.WriteFileDetails("setup_executable", Assembly.GetExecutingAssembly().Location);
|
||||
log.WriteFileDetails("existing_application", Path.Combine(installDirectory, ApplicationExeName));
|
||||
// Der vollständige Integrationszustand wird vor der ersten Mutation gesichert.
|
||||
integrationSnapshot = registerWindowsIntegration ? CaptureWindowsIntegration() : null;
|
||||
if (integrationSnapshot != null)
|
||||
log.Write("INFO", "event=integration_snapshot registry_key_existed=" + integrationSnapshot.RegistryKeyExisted
|
||||
+ " registry_value_count=" + integrationSnapshot.RegistryValues.Count
|
||||
+ " desktop_shortcut=" + (integrationSnapshot.DesktopShortcut != null)
|
||||
+ " start_menu_shortcut=" + (integrationSnapshot.StartMenuShortcut != null)
|
||||
+ " uninstaller=" + (integrationSnapshot.Uninstaller != null));
|
||||
|
||||
phaseCode = "SETUP-PACKAGE-VALIDATION";
|
||||
phase = "Paketmanifest und SHA-256 pruefen";
|
||||
write("Phase 1/6: " + phase + ".");
|
||||
var files = PackageManifest.ValidateAndRead(sourceApplication, manifestPath);
|
||||
RequirePayload(files, ApplicationExeName);
|
||||
RequirePayload(files, ApplicationExeName + ".config");
|
||||
log.WriteFileDetails("package_manifest", manifestPath);
|
||||
foreach (var file in files)
|
||||
log.Write("INFO", "event=payload_validated relative_path=\"" + file.RelativePath + "\" size_bytes=" + file.Length + " sha256=" + file.Sha256);
|
||||
EnsureApplicationNotRunning(log);
|
||||
|
||||
phaseCode = "SETUP-STAGING-VALIDATION";
|
||||
phase = "Update in isoliertes Staging kopieren und pruefen";
|
||||
write("Phase 2/6: " + phase + ".");
|
||||
CopyPayload(sourceApplication, stagingDirectory, files);
|
||||
var stagedExe = Path.Combine(stagingDirectory, ApplicationExeName);
|
||||
RunAndValidateSelfTest(stagedExe, "staging", log);
|
||||
write("Staging-Self-Test erfolgreich.");
|
||||
|
||||
phaseCode = "SETUP-ACTIVATION";
|
||||
phase = "Vorhandene Version sichern und Staging atomar aktivieren";
|
||||
write("Phase 3/6: " + phase + ". Ab hier beginnt die Systemaenderung.");
|
||||
// Erst nach Manifestprüfung und bestandenem Staging-Self-Test wird die aktive Version verändert.
|
||||
if (hadExistingInstallation)
|
||||
{
|
||||
log.Write("INFO", "event=directory_move role=backup source=\"" + installDirectory + "\" target=\"" + backupDirectory + "\"");
|
||||
Directory.Move(installDirectory, backupDirectory);
|
||||
backupCreated = true;
|
||||
log.Write("INFO", "event=directory_move_complete role=backup");
|
||||
}
|
||||
log.Write("INFO", "event=directory_move role=activate source=\"" + stagingDirectory + "\" target=\"" + installDirectory + "\"");
|
||||
Directory.Move(stagingDirectory, installDirectory);
|
||||
activated = true;
|
||||
log.Write("INFO", "event=directory_move_complete role=activate");
|
||||
|
||||
phaseCode = "SETUP-ACTIVATED-SELFTEST";
|
||||
phase = "Aktivierte Version erneut pruefen";
|
||||
write("Phase 4/6: " + phase + ".");
|
||||
var targetExe = Path.Combine(installDirectory, ApplicationExeName);
|
||||
RunAndValidateSelfTest(targetExe, "activated", log);
|
||||
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));
|
||||
|
||||
phaseCode = "SETUP-WINDOWS-INTEGRATION";
|
||||
phase = "Windows-Integration registrieren und verifizieren";
|
||||
write("Phase 5/6: " + phase + ".");
|
||||
if (registerWindowsIntegration)
|
||||
{
|
||||
integrationMutationStarted = true;
|
||||
RegisterWindowsIntegration(targetExe, createDesktopShortcut);
|
||||
ValidateWindowsIntegration(targetExe, createDesktopShortcut, log);
|
||||
}
|
||||
|
||||
phaseCode = "SETUP-CLEANUP";
|
||||
phase = "Backup bereinigen";
|
||||
write("Phase 6/6: " + phase + ".");
|
||||
var backupCleanupSucceeded = TryDeleteDirectory(backupDirectory, write);
|
||||
log.Write(
|
||||
backupCleanupSucceeded ? "INFO" : "WARN",
|
||||
"event=setup_completed result=" + (backupCleanupSucceeded ? "success" : "success_with_backup_cleanup_warning")
|
||||
+ " backup_directory_preserved=" + Directory.Exists(backupDirectory)
|
||||
+ " install_directory=\"" + installDirectory + "\"");
|
||||
write("Installation/Update erfolgreich abgeschlossen: " + installDirectory);
|
||||
write("Technische Installationsabnahme erfolgreich. BizTalk-WMI wurde bewusst nicht als Installerkriterium verwendet; bitte anschliessend in der Anwendung 'Diagnose' und einen Dry-run ausfuehren.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.WriteException(phaseCode, phase, ex);
|
||||
var rollbackErrors = new List<string>();
|
||||
var mutationStarted = backupCreated || activated || integrationMutationStarted;
|
||||
var fileRollbackSucceeded = true;
|
||||
if (!mutationStarted)
|
||||
{
|
||||
write("Kein Rollback erforderlich: Der Fehler trat vor der ersten Systemaenderung auf.");
|
||||
log.Write("ERROR", "event=rollback_summary result=not_required mutation_started=false");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Dateisystem und Windows-Integration werden unabhängig behandelt, damit
|
||||
// ein Fehler in einem Teil den Diagnosezustand des anderen nicht verdeckt.
|
||||
log.Write("WARN", "event=rollback_started activated=" + activated + " backup_created=" + backupCreated + " integration_mutation_started=" + integrationMutationStarted);
|
||||
try
|
||||
{
|
||||
if (activated && Directory.Exists(installDirectory))
|
||||
{
|
||||
log.Write("WARN", "event=rollback_step step=remove_activated_directory path=\"" + installDirectory + "\"");
|
||||
Directory.Delete(installDirectory, true);
|
||||
}
|
||||
if (backupCreated && Directory.Exists(backupDirectory))
|
||||
{
|
||||
log.Write("WARN", "event=rollback_step step=restore_backup source=\"" + backupDirectory + "\" target=\"" + installDirectory + "\"");
|
||||
Directory.Move(backupDirectory, installDirectory);
|
||||
}
|
||||
write(backupCreated ? "Rollback: vorherige Programmversion wiederhergestellt." : "Rollback: unvollstaendige Neuinstallation entfernt.");
|
||||
log.Write("WARN", "event=rollback_step_complete step=files result=success");
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
fileRollbackSucceeded = false;
|
||||
rollbackErrors.Add("Programmdateien: " + rollbackException.Message);
|
||||
log.WriteException("SETUP-ROLLBACK-FILES", "Rollback Programmdateien", rollbackException);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (registerWindowsIntegration && integrationSnapshot != null)
|
||||
{
|
||||
log.Write("WARN", "event=rollback_step step=restore_windows_integration");
|
||||
RestoreWindowsIntegration(integrationSnapshot);
|
||||
log.Write("WARN", "event=rollback_step_complete step=windows_integration result=success");
|
||||
}
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
rollbackErrors.Add("Windows-Integration: " + rollbackException.Message);
|
||||
log.WriteException("SETUP-ROLLBACK-INTEGRATION", "Rollback Windows-Integration", rollbackException);
|
||||
}
|
||||
}
|
||||
|
||||
TryDeleteDirectory(stagingDirectory, write);
|
||||
if (fileRollbackSucceeded && !Directory.Exists(installDirectory) && Directory.Exists(backupDirectory))
|
||||
{
|
||||
fileRollbackSucceeded = false;
|
||||
rollbackErrors.Add("Programmdateien: Backup blieb ohne aktives Installationsverzeichnis bestehen: " + backupDirectory);
|
||||
log.Write("ERROR", "event=rollback_invariant_failed active_directory=false backup_directory=true backup_path=\"" + backupDirectory + "\"");
|
||||
}
|
||||
var rollback = !mutationStarted
|
||||
? "Kein Rollback erforderlich."
|
||||
: rollbackErrors.Count == 0
|
||||
? "Rollback erfolgreich."
|
||||
: "Rollback unvollstaendig: " + string.Join(" | ", rollbackErrors);
|
||||
log.Write("ERROR", "event=rollback_summary result=\"" + rollback + "\" backup_preserved=" + Directory.Exists(backupDirectory));
|
||||
throw new InvalidOperationException(
|
||||
"Installation/Update fehlgeschlagen. Fehlercode=" + phaseCode + ". Phase='" + phase + "'. "
|
||||
+ rollback + " Ursache: " + ex.Message + " Diagnoselog: " + (log.FilePath.Length == 0 ? "nicht verfuegbar" : log.FilePath),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entfernt das aktive Programmverzeichnis und die registrierte Windows-Integration.
|
||||
/// </summary>
|
||||
/// <param name="report">Optionale Fortschrittsausgabe für die Setup-Oberfläche.</param>
|
||||
public void Uninstall(Action<string> report)
|
||||
{
|
||||
var uiReport = report ?? delegate { };
|
||||
var log = SetupOperationLog.Create(dataDirectory, "uninstall");
|
||||
Action<string> write = message => ReportSafely(log, uiReport, "INFO", message);
|
||||
if (!string.IsNullOrEmpty(log.CreationError))
|
||||
write((log.IsFallback ? "WARNUNG: Primaeres Diagnoselog nicht verfuegbar; Temp-Fallback wird verwendet: " : "WARNUNG: Diagnoselog konnte nicht angelegt werden: ") + log.CreationError);
|
||||
if (log.FilePath.Length != 0)
|
||||
{
|
||||
lastInstallerLogDirectory = Path.GetDirectoryName(log.FilePath);
|
||||
write("Diagnoselog: " + log.FilePath);
|
||||
}
|
||||
WindowsIntegrationSnapshot integrationSnapshot = null;
|
||||
var removalDirectory = installDirectory + ".removed." + Guid.NewGuid().ToString("N");
|
||||
var filesMoved = false;
|
||||
var phaseCode = "UNINSTALL-INITIALIZATION";
|
||||
var phase = "Initialisierung";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
log.Write("INFO", "event=uninstall_context install_directory=\"" + installDirectory + "\" removal_directory=\"" + removalDirectory + "\" data_directory=\"" + dataDirectory + "\"");
|
||||
log.WriteFileDetails("installed_application", Path.Combine(installDirectory, ApplicationExeName));
|
||||
integrationSnapshot = registerWindowsIntegration ? CaptureWindowsIntegration() : null;
|
||||
|
||||
phaseCode = "UNINSTALL-RUNNING-APPLICATION";
|
||||
phase = "Laufende Anwendung ausschliessen";
|
||||
write("Phase 1/3: " + phase + ".");
|
||||
EnsureApplicationNotRunning(log);
|
||||
|
||||
phaseCode = "UNINSTALL-QUARANTINE";
|
||||
phase = "Programmverzeichnis deaktivieren";
|
||||
write("Phase 2/3: " + phase + ".");
|
||||
// Die atomare Umbenennung deaktiviert die Anwendung, ohne die einzige
|
||||
// wiederherstellbare Kopie vor Abschluss der Deinstallation zu löschen.
|
||||
if (Directory.Exists(installDirectory))
|
||||
{
|
||||
log.Write("INFO", "event=directory_move role=uninstall_quarantine source=\"" + installDirectory + "\" target=\"" + removalDirectory + "\"");
|
||||
Directory.Move(installDirectory, removalDirectory);
|
||||
filesMoved = true;
|
||||
log.Write("INFO", "event=directory_move_complete role=uninstall_quarantine");
|
||||
}
|
||||
|
||||
phaseCode = "UNINSTALL-WINDOWS-INTEGRATION";
|
||||
phase = "Windows-Integration entfernen";
|
||||
write("Phase 3/3: " + phase + ".");
|
||||
if (registerWindowsIntegration) RemoveWindowsIntegration(log);
|
||||
var cleanupSucceeded = TryDeleteDirectory(removalDirectory, write);
|
||||
log.Write("INFO", "event=uninstall_completed result=" + (cleanupSucceeded ? "success" : "success_with_quarantine_cleanup_warning")
|
||||
+ " removal_directory_preserved=" + Directory.Exists(removalDirectory));
|
||||
write("Deinstallation erfolgreich. Installer-Logs bleiben erhalten: " + Path.Combine(dataDirectory, "InstallerLogs"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.WriteException(phaseCode, phase, ex);
|
||||
var rollbackErrors = new List<string>();
|
||||
if (filesMoved && !Directory.Exists(installDirectory) && Directory.Exists(removalDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(removalDirectory, installDirectory);
|
||||
log.Write("WARN", "event=uninstall_rollback_step step=restore_program_directory result=success");
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
rollbackErrors.Add("Programmverzeichnis: " + rollbackException.Message);
|
||||
log.WriteException("UNINSTALL-ROLLBACK-FILES", "Deinstallationsrollback Programmverzeichnis", rollbackException);
|
||||
}
|
||||
}
|
||||
if (registerWindowsIntegration && integrationSnapshot != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
RestoreWindowsIntegration(integrationSnapshot);
|
||||
log.Write("WARN", "event=uninstall_rollback_step step=restore_windows_integration result=success");
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
rollbackErrors.Add("Windows-Integration: " + rollbackException.Message);
|
||||
log.WriteException("UNINSTALL-ROLLBACK-INTEGRATION", "Deinstallationsrollback Windows-Integration", rollbackException);
|
||||
}
|
||||
}
|
||||
var rollback = rollbackErrors.Count == 0 ? "Rollback erfolgreich." : "Rollback unvollstaendig: " + string.Join(" | ", rollbackErrors);
|
||||
log.Write("ERROR", "event=uninstall_rollback_summary result=\"" + rollback + "\"");
|
||||
throw new InvalidOperationException(
|
||||
"Deinstallation fehlgeschlagen. Fehlercode=" + phaseCode + ". Phase='" + phase + "'. " + rollback
|
||||
+ " Ursache: " + ex.Message + " Diagnoselog: " + (log.FilePath.Length == 0 ? "nicht verfuegbar" : log.FilePath),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registriert Startmenü, optionale Desktop-Verknüpfung, Uninstaller und Uninstall-Schlüssel.
|
||||
/// </summary>
|
||||
/// <param name="targetExe">Der vollständige Pfad der aktivierten Anwendung.</param>
|
||||
/// <param name="createDesktopShortcut"><c>true</c>, wenn eine Desktop-Verknüpfung gewünscht ist.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entfernt und verifiziert die vom Setup verwaltete Windows-Integration.
|
||||
/// </summary>
|
||||
/// <param name="log">Das Diagnoseprotokoll des aktuellen Setup-Laufs.</param>
|
||||
private void RemoveWindowsIntegration(SetupOperationLog log)
|
||||
{
|
||||
DeleteFileIfExists(DesktopShortcutPath);
|
||||
var programsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName);
|
||||
DeleteFileIfExists(Path.Combine(programsDirectory, ProductName + ".lnk"));
|
||||
if (Directory.Exists(programsDirectory) && Directory.GetFileSystemEntries(programsDirectory).Length == 0) Directory.Delete(programsDirectory);
|
||||
Registry.LocalMachine.DeleteSubKeyTree(UninstallKeyPath, false);
|
||||
using (var remainingKey = Registry.LocalMachine.OpenSubKey(UninstallKeyPath, false))
|
||||
{
|
||||
if (File.Exists(DesktopShortcutPath) || File.Exists(StartMenuShortcutPath) || remainingKey != null)
|
||||
throw new InvalidOperationException("Windows integration removal verification failed.");
|
||||
}
|
||||
log.Write("INFO", "event=windows_integration_removed registry_key=\"HKLM\\" + UninstallKeyPath + "\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt eine Windows-Verknüpfung über Windows Script Host und gibt COM-Objekte deterministisch frei.
|
||||
/// </summary>
|
||||
/// <param name="shortcutPath">Der vollständige Pfad der Verknüpfung.</param>
|
||||
/// <param name="targetPath">Der Zielpfad der Verknüpfung.</param>
|
||||
/// <param name="workingDirectory">Das Arbeitsverzeichnis des Ziels.</param>
|
||||
/// <param name="description">Die in Windows sichtbare Beschreibung.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest Registrywerte, Verknüpfungen und Uninstaller vor einer möglichen Mutation ein.
|
||||
/// </summary>
|
||||
/// <returns>Ein wiederherstellbarer Snapshot der Windows-Integration.</returns>
|
||||
private WindowsIntegrationSnapshot CaptureWindowsIntegration()
|
||||
{
|
||||
var snapshot = new WindowsIntegrationSnapshot
|
||||
{
|
||||
RegistryValues = new Dictionary<string, Tuple<object, RegistryValueKind>>(StringComparer.OrdinalIgnoreCase),
|
||||
DesktopShortcut = ReadFileOrNull(DesktopShortcutPath),
|
||||
StartMenuShortcut = ReadFileOrNull(StartMenuShortcutPath),
|
||||
Uninstaller = ReadFileOrNull(UninstallerPath)
|
||||
};
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(UninstallKeyPath, false))
|
||||
{
|
||||
snapshot.RegistryKeyExisted = key != null;
|
||||
if (key != null)
|
||||
{
|
||||
foreach (var name in key.GetValueNames())
|
||||
{
|
||||
snapshot.RegistryValues[name] = Tuple.Create(key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames), key.GetValueKind(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stellt einen zuvor erfassten Zustand der Windows-Integration wieder her.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Der wiederherzustellende Integrationszustand.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine Datei vollständig oder bildet ihr Nichtvorhandensein als <c>null</c> ab.
|
||||
/// </summary>
|
||||
/// <param name="path">Der zu lesende Dateipfad.</param>
|
||||
/// <returns>Der Dateiinhalt oder <c>null</c>, wenn die Datei nicht existiert.</returns>
|
||||
private static byte[] ReadFileOrNull(string path)
|
||||
{
|
||||
return File.Exists(path) ? File.ReadAllBytes(path) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stellt eine Datei exakt wieder her oder entfernt sie, wenn sie vorher nicht vorhanden war.
|
||||
/// </summary>
|
||||
/// <param name="path">Der wiederherzustellende Dateipfad.</param>
|
||||
/// <param name="content">Der vorherige Inhalt oder <c>null</c> für „nicht vorhanden“.</param>
|
||||
private static void RestoreFile(string path, byte[] content)
|
||||
{
|
||||
if (content == null)
|
||||
{
|
||||
DeleteFileIfExists(path);
|
||||
return;
|
||||
}
|
||||
// Beim Rollback des laufenden Uninstallers kann dessen Datei bereits exakt dem
|
||||
// Snapshot entsprechen; dann vermeiden wir einen unnötigen Schreibzugriff auf die aktive EXE.
|
||||
if (File.Exists(path) && File.ReadAllBytes(path).SequenceEqual(content)) return;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllBytes(path, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verhindert Mutation, solange die installierte Anwendung noch ausgeführt wird.
|
||||
/// </summary>
|
||||
/// <param name="log">Das Diagnoseprotokoll für Prozessfund und Zugriffsfehler.</param>
|
||||
private void EnsureApplicationNotRunning(SetupOperationLog log)
|
||||
{
|
||||
var target = Path.Combine(installDirectory, ApplicationExeName);
|
||||
if (!File.Exists(target))
|
||||
{
|
||||
log.Write("INFO", "event=running_application_check target_exists=false target=\"" + target + "\"");
|
||||
return;
|
||||
}
|
||||
var candidates = 0;
|
||||
foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ApplicationExeName)))
|
||||
{
|
||||
try
|
||||
{
|
||||
candidates++;
|
||||
var processId = process.Id;
|
||||
log.Write("INFO", "event=running_application_candidate_detected pid=" + processId.ToString(CultureInfo.InvariantCulture));
|
||||
string processPath;
|
||||
try
|
||||
{
|
||||
processPath = process.MainModule.FileName;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.WriteException("SETUP-RUNNING-APPLICATION-INSPECTION", "Prozesspfad fuer PID " + processId + " pruefen", ex);
|
||||
throw new InvalidOperationException(
|
||||
"Ein gleichnamiger Prozess (PID " + processId + ") konnte nicht sicher geprueft werden. Beenden Sie ihn oder pruefen Sie Berechtigungen und versuchen Sie es erneut.",
|
||||
ex);
|
||||
}
|
||||
var matches = string.Equals(Path.GetFullPath(processPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase);
|
||||
log.Write("INFO", "event=running_application_candidate pid=" + processId.ToString(CultureInfo.InvariantCulture)
|
||||
+ " session_id=" + process.SessionId.ToString(CultureInfo.InvariantCulture)
|
||||
+ " path=\"" + processPath + "\" matches_target=" + matches);
|
||||
if (matches)
|
||||
throw new InvalidOperationException(ProductName + " is still running. Close it before installation or removal.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
log.Write("INFO", "event=running_application_check target_exists=true candidate_count=" + candidates.ToString(CultureInfo.InvariantCulture) + " result=not_running");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt den WMI-freien Self-Test aus und validiert Exitcode sowie Erfolgstoken.
|
||||
/// </summary>
|
||||
/// <param name="executable">Die zu prüfende Anwendungs-EXE.</param>
|
||||
/// <param name="label">Die Phasenbezeichnung für Log und Fehlermeldung.</param>
|
||||
/// <param name="log">Das Diagnoseprotokoll für Metadaten und Prozessausgaben.</param>
|
||||
private void RunAndValidateSelfTest(string executable, string label, SetupOperationLog log)
|
||||
{
|
||||
log.WriteFileDetails(label + "_self_test_executable", executable);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
if (selfTestRunner != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var successful = selfTestRunner(executable);
|
||||
stopwatch.Stop();
|
||||
log.Write(
|
||||
successful ? "INFO" : "ERROR",
|
||||
"event=self_test_completed label=" + label
|
||||
+ " runner=injected elapsed_ms=" + stopwatch.ElapsedMilliseconds.ToString(CultureInfo.InvariantCulture)
|
||||
+ " result=" + (successful ? "success" : "failure"));
|
||||
if (!successful) throw new InvalidOperationException("Der WMI-freie Self-Test '" + label + "' ist fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
log.WriteException("SETUP-SELFTEST-INJECTED", "Self-Test " + label, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(executable, "--self-test")
|
||||
{
|
||||
WorkingDirectory = Path.GetDirectoryName(executable),
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
using (var process = Process.Start(startInfo))
|
||||
{
|
||||
if (process == null) throw new InvalidOperationException("Self-Test '" + label + "' konnte nicht gestartet werden.");
|
||||
// Beide Kanäle werden parallel geleert, damit ein voller stdout-/stderr-Puffer
|
||||
// den Kindprozess nicht blockiert und dadurch einen künstlichen Timeout erzeugt.
|
||||
var outputRead = process.StandardOutput.ReadToEndAsync();
|
||||
var errorRead = process.StandardError.ReadToEndAsync();
|
||||
if (!process.WaitForExit(60000))
|
||||
{
|
||||
// Auch nach dem Timeout bleiben Prozessende und Stream-Erfassung begrenzt;
|
||||
// ein nicht beendbarer Kindprozess darf das Setup nicht endlos festhalten.
|
||||
var killResult = "sent";
|
||||
try { process.Kill(); }
|
||||
catch (Exception killException)
|
||||
{
|
||||
killResult = "failed";
|
||||
log.WriteException("SETUP-SELFTEST-KILL", "Self-Test " + label + " nach Timeout beenden", killException);
|
||||
}
|
||||
var exitedAfterKill = false;
|
||||
try { exitedAfterKill = process.WaitForExit(5000); } catch { }
|
||||
var streamsCompleted = false;
|
||||
try
|
||||
{
|
||||
streamsCompleted = System.Threading.Tasks.Task.WaitAll(new System.Threading.Tasks.Task[] { outputRead, errorRead }, 5000);
|
||||
}
|
||||
catch (Exception captureException)
|
||||
{
|
||||
log.WriteException("SETUP-SELFTEST-CAPTURE", "Self-Test " + label + " Ausgabe nach Timeout erfassen", captureException);
|
||||
}
|
||||
stopwatch.Stop();
|
||||
log.Write(
|
||||
"ERROR",
|
||||
"event=self_test_timeout label=" + label
|
||||
+ " timeout_ms=60000 elapsed_ms=" + stopwatch.ElapsedMilliseconds.ToString(CultureInfo.InvariantCulture)
|
||||
+ " kill_result=" + killResult
|
||||
+ " exited_after_kill=" + exitedAfterKill
|
||||
+ " streams_completed=" + streamsCompleted
|
||||
+ " stdout=\"" + (outputRead.Status == System.Threading.Tasks.TaskStatus.RanToCompletion ? outputRead.Result : "[capture incomplete]")
|
||||
+ "\" stderr=\"" + (errorRead.Status == System.Threading.Tasks.TaskStatus.RanToCompletion ? errorRead.Result : "[capture incomplete]") + "\"");
|
||||
throw new TimeoutException("Self-Test '" + label + "' hat das Zeitlimit von 60 Sekunden ueberschritten.");
|
||||
}
|
||||
System.Threading.Tasks.Task.WaitAll(outputRead, errorRead);
|
||||
stopwatch.Stop();
|
||||
var output = outputRead.Result;
|
||||
var error = errorRead.Result;
|
||||
log.Write(
|
||||
process.ExitCode == 0 ? "INFO" : "ERROR",
|
||||
"event=self_test_completed label=" + label
|
||||
+ " runner=process elapsed_ms=" + stopwatch.ElapsedMilliseconds.ToString(CultureInfo.InvariantCulture)
|
||||
+ " exit_code=" + process.ExitCode.ToString(CultureInfo.InvariantCulture)
|
||||
+ " exit_code_hex=0x" + process.ExitCode.ToString("X8", CultureInfo.InvariantCulture)
|
||||
+ " stdout=\"" + output + "\" stderr=\"" + error + "\"");
|
||||
if (process.ExitCode != 0 || output.IndexOf("SELF_TEST_OK", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Self-Test '" + label + "' fehlgeschlagen. Exitcode=" + process.ExitCode
|
||||
+ " (0x" + process.ExitCode.ToString("X8", CultureInfo.InvariantCulture) + ")"
|
||||
+ ", Stdout=" + CompactProcessText(output)
|
||||
+ ", Stderr=" + CompactProcessText(error) + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die erzeugte Windows-Integration zurück und vergleicht sie mit dem Sollzustand.
|
||||
/// </summary>
|
||||
/// <param name="targetExe">Der erwartete Zielpfad der Anwendung.</param>
|
||||
/// <param name="desktopRequested">Der gewünschte Zustand der Desktop-Verknüpfung.</param>
|
||||
/// <param name="log">Das Diagnoseprotokoll für die validierten Dateien.</param>
|
||||
private void ValidateWindowsIntegration(string targetExe, bool desktopRequested, SetupOperationLog log)
|
||||
{
|
||||
if (!File.Exists(StartMenuShortcutPath)) throw new FileNotFoundException("Start menu shortcut was not created.", StartMenuShortcutPath);
|
||||
if (desktopRequested != File.Exists(DesktopShortcutPath))
|
||||
throw new InvalidOperationException("Desktop shortcut state does not match the setup selection. Requested=" + desktopRequested + ".");
|
||||
if (!File.Exists(UninstallerPath)) throw new FileNotFoundException("Uninstaller was not installed.", UninstallerPath);
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(UninstallKeyPath, false))
|
||||
{
|
||||
if (key == null) throw new InvalidOperationException("Windows uninstall registry key is missing after registration.");
|
||||
RequireRegistryValue(key, "DisplayName", ProductName);
|
||||
RequireRegistryValue(key, "DisplayVersion", ProductVersion);
|
||||
RequireRegistryValue(key, "InstallLocation", installDirectory);
|
||||
RequireRegistryValue(key, "DisplayIcon", targetExe);
|
||||
RequireRegistryValue(key, "UninstallString", "\"" + UninstallerPath + "\" --uninstall");
|
||||
}
|
||||
|
||||
log.WriteFileDetails("registered_uninstaller", UninstallerPath);
|
||||
log.WriteFileDetails("start_menu_shortcut", StartMenuShortcutPath);
|
||||
if (desktopRequested) log.WriteFileDetails("desktop_shortcut", DesktopShortcutPath);
|
||||
log.Write("INFO", "event=windows_integration_validated registry_key=\"HKLM\\" + UninstallKeyPath + "\" desktop_shortcut=" + desktopRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht einen Registrywert mit seinem erwarteten Zeichenfolgenwert.
|
||||
/// </summary>
|
||||
/// <param name="key">Der geöffnete Uninstall-Schlüssel.</param>
|
||||
/// <param name="name">Der Name des zu prüfenden Registrywerts.</param>
|
||||
/// <param name="expected">Der erwartete Wert.</param>
|
||||
private static void RequireRegistryValue(RegistryKey key, string name, string expected)
|
||||
{
|
||||
var actual = Convert.ToString(key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames), CultureInfo.InvariantCulture);
|
||||
if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Uninstall registry value '" + name + "' is invalid. Expected='" + expected + "', actual='" + actual + "'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt zuerst dauerhaft ins Log und isoliert anschließend Fehler des UI-Callbacks.
|
||||
/// </summary>
|
||||
/// <param name="log">Das dauerhafte Setup-Protokoll.</param>
|
||||
/// <param name="report">Der optionale UI-Callback.</param>
|
||||
/// <param name="level">Der Log-Level.</param>
|
||||
/// <param name="message">Die auszugebende Nachricht.</param>
|
||||
private static void ReportSafely(SetupOperationLog log, Action<string> report, string level, string message)
|
||||
{
|
||||
log.Write(level, message);
|
||||
try
|
||||
{
|
||||
report(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Write("WARN", "event=ui_report_failed " + SetupOperationLog.FormatException(ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protokolliert Dateisystemtyp und freien Speicher des Zielvolumes bestmöglich.
|
||||
/// </summary>
|
||||
/// <param name="log">Das Setup-Protokoll.</param>
|
||||
/// <param name="path">Ein Pfad auf dem zu untersuchenden Volume.</param>
|
||||
private static void LogDriveSpace(SetupOperationLog log, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = Path.GetPathRoot(Path.GetFullPath(path));
|
||||
var drive = new DriveInfo(root);
|
||||
log.Write(
|
||||
"INFO",
|
||||
"event=drive_space root=\"" + root + "\" format=\"" + drive.DriveFormat + "\""
|
||||
+ " available_bytes=" + drive.AvailableFreeSpace.ToString(CultureInfo.InvariantCulture)
|
||||
+ " total_bytes=" + drive.TotalSize.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Write("WARN", "event=drive_space_failed path=\"" + path + "\" " + SetupOperationLog.FormatException(ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdichtet Prozessausgabe für eine begrenzte Bedienermeldung; das Log bleibt vollständig.
|
||||
/// </summary>
|
||||
/// <param name="value">Die rohe Standard- oder Fehlerausgabe.</param>
|
||||
/// <returns>Eine einzeilige Ausgabe mit maximal 2.000 Zeichen.</returns>
|
||||
private static string CompactProcessText(string value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Replace("\r", string.Empty).Replace("\n", " | ").Trim();
|
||||
const int maxLength = 2000;
|
||||
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...[truncated]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kopiert deklarierte Payload-Dateien und prüft jede Zielkopie erneut per SHA-256.
|
||||
/// </summary>
|
||||
/// <param name="sourceRoot">Das validierte Payload-Quellverzeichnis.</param>
|
||||
/// <param name="targetRoot">Das isolierte Staging-Zielverzeichnis.</param>
|
||||
/// <param name="files">Die validierten Manifesteinträge.</param>
|
||||
private static void CopyPayload(string sourceRoot, string targetRoot, IEnumerable<PackageFile> files)
|
||||
{
|
||||
Directory.CreateDirectory(targetRoot);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var source = PackageManifest.ResolveContainedPath(sourceRoot, file.RelativePath);
|
||||
var target = PackageManifest.ResolveContainedPath(targetRoot, file.RelativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target));
|
||||
File.Copy(source, target, false);
|
||||
if (!string.Equals(PackageManifest.Sha256(target), file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Copied payload failed SHA-256 verification: " + file.RelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass eine betriebsnotwendige Datei im Manifest enthalten ist.
|
||||
/// </summary>
|
||||
/// <param name="files">Die validierten Manifesteinträge.</param>
|
||||
/// <param name="relativePath">Der erforderliche relative Dateipfad.</param>
|
||||
private static void RequirePayload(IEnumerable<PackageFile> files, string relativePath)
|
||||
{
|
||||
if (!files.Any(x => string.Equals(x.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidDataException("Required payload file is missing from the manifest: " + relativePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löscht ein temporäres Verzeichnis bestmöglich und meldet eine verbleibende Kopie als Warnung.
|
||||
/// </summary>
|
||||
/// <param name="path">Das zu löschende Verzeichnis.</param>
|
||||
/// <param name="report">Die ausfallsichere Fortschrittsausgabe.</param>
|
||||
/// <returns><c>true</c>, wenn das Verzeichnis anschließend nicht mehr existiert.</returns>
|
||||
private static bool TryDeleteDirectory(string path, Action<string> report)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path)) Directory.Delete(path, true);
|
||||
return !Directory.Exists(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
report("WARNUNG: Verzeichnis konnte nicht bereinigt werden: " + path + " - " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löscht eine Datei, sofern sie vorhanden ist, und lässt echte Löschfehler sichtbar werden.
|
||||
/// </summary>
|
||||
/// <param name="path">Der zu löschende Dateipfad.</param>
|
||||
private static void DeleteFileIfExists(string path)
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
|
||||
/// <summary>Ruft den maschinenweiten Pfad der Desktop-Verknüpfung ab.</summary>
|
||||
private static string DesktopShortcutPath
|
||||
{
|
||||
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), ProductName + ".lnk"); }
|
||||
}
|
||||
|
||||
/// <summary>Ruft den maschinenweiten Pfad der Startmenü-Verknüpfung ab.</summary>
|
||||
private static string StartMenuShortcutPath
|
||||
{
|
||||
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName, ProductName + ".lnk"); }
|
||||
}
|
||||
|
||||
/// <summary>Ruft den dauerhaften Pfad der Uninstaller-Kopie ab.</summary>
|
||||
private string UninstallerPath
|
||||
{
|
||||
get { return Path.Combine(dataDirectory, "Setup", "Uninstall.exe"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Setup
|
||||
{
|
||||
/// <summary>
|
||||
/// Stellt die Bedienoberfläche für Installation, Update, Deinstallation und Diagnosezugriff bereit.
|
||||
/// </summary>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
/// <summary>Ausführende Installer-Engine.</summary>
|
||||
private readonly InstallerEngine engine;
|
||||
|
||||
/// <summary>Gibt an, ob das Fenster direkt zur Deinstallation geöffnet wurde.</summary>
|
||||
private readonly bool uninstallMode;
|
||||
|
||||
/// <summary>Sichtbare Fortschritts- und Diagnoseausgabe.</summary>
|
||||
private readonly TextBox output = new TextBox();
|
||||
|
||||
/// <summary>Auswahl für die optionale maschinenweite Desktop-Verknüpfung.</summary>
|
||||
private readonly CheckBox desktopShortcut = new CheckBox();
|
||||
|
||||
/// <summary>Schaltfläche für Installation oder Update.</summary>
|
||||
private readonly Button installButton = new Button();
|
||||
|
||||
/// <summary>Schaltfläche für die Deinstallation.</summary>
|
||||
private readonly Button uninstallButton = new Button();
|
||||
|
||||
/// <summary>Verhindert parallele Aktionen und Schließen während einer Operation.</summary>
|
||||
private bool busy;
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert das Setup-Fenster für den normalen oder direkten Deinstallationsmodus.
|
||||
/// </summary>
|
||||
/// <param name="engine">Die ausführende Installer-Engine.</param>
|
||||
/// <param name="uninstallMode"><c>true</c>, wenn das Setup über den Uninstall-Eintrag gestartet wurde.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Erstellt und verdrahtet die vollständige Setup-Oberfläche.</summary>
|
||||
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.2"
|
||||
});
|
||||
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();
|
||||
var logsButton = new Button { Text = "Diagnoselogs oeffnen", AutoSize = true };
|
||||
logsButton.Click += OpenLogs;
|
||||
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(logsButton);
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>Öffnet das zuletzt verwendete Diagnoseverzeichnis im Windows Explorer.</summary>
|
||||
/// <param name="sender">Die auslösende Schaltfläche.</param>
|
||||
/// <param name="e">Die Ereignisargumente.</param>
|
||||
private void OpenLogs(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(engine.InstallerLogDirectory);
|
||||
Process.Start("explorer.exe", engine.InstallerLogDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "Diagnoseordner konnte nicht geoeffnet werden: " + ex.Message + "\n\n" + engine.InstallerLogDirectory, "Diagnoselogs", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Startet Installation oder Deinstallation außerhalb des UI-Threads.</summary>
|
||||
/// <param name="uninstall"><c>true</c> für Deinstallation, <c>false</c> für Installation oder Update.</param>
|
||||
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);
|
||||
// Dateisystem-, Registry- und Self-Test-Operationen dürfen die WinForms-Nachrichtenpumpe nicht blockieren.
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Fügt der sichtbaren Ausgabe threadsicher eine zeitgestempelte Nachricht hinzu.</summary>
|
||||
/// <param name="message">Die anzuzeigende Nachricht.</param>
|
||||
private void Append(string message)
|
||||
{
|
||||
if (IsDisposed || Disposing) return;
|
||||
if (InvokeRequired)
|
||||
{
|
||||
try { BeginInvoke(new Action<string>(Append), message); } catch (InvalidOperationException) { }
|
||||
return;
|
||||
}
|
||||
output.AppendText("[" + DateTime.Now.ToString("HH:mm:ss") + "] " + message + Environment.NewLine);
|
||||
}
|
||||
|
||||
/// <summary>Schaltet Steuerelemente und Wartecursor threadsicher in oder aus dem Arbeitszustand.</summary>
|
||||
/// <param name="value"><c>true</c>, solange eine Setup-Operation läuft.</param>
|
||||
private void SetBusy(bool value)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
{
|
||||
try { BeginInvoke(new Action<bool>(SetBusy), value); } catch (InvalidOperationException) { }
|
||||
return;
|
||||
}
|
||||
busy = value;
|
||||
installButton.Enabled = !value && engine.HasInstallPayload && !uninstallMode;
|
||||
uninstallButton.Enabled = !value && engine.IsInstalled;
|
||||
desktopShortcut.Enabled = !value && !uninstallMode;
|
||||
UseWaitCursor = value;
|
||||
}
|
||||
|
||||
/// <summary>Verhindert das Schließen des Fensters während einer laufenden Setup-Operation.</summary>
|
||||
/// <param name="sender">Das zu schließende Setup-Fenster.</param>
|
||||
/// <param name="e">Die abbrechbaren Argumente des Schließereignisses.</param>
|
||||
private void OnFormClosing(object sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (!busy) return;
|
||||
e.Cancel = true;
|
||||
MessageBox.Show(this, "Das Setup arbeitet noch. Bitte warten Sie bis zum Abschluss.", "Setup laeuft", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
{
|
||||
/// <summary>Beschreibt eine durch Länge und SHA-256 abgesicherte Payload-Datei.</summary>
|
||||
public sealed class PackageFile
|
||||
{
|
||||
/// <summary>Gets or sets the normalized payload-relative path.</summary>
|
||||
public string RelativePath { get; set; }
|
||||
/// <summary>Gets or sets the declared file length.</summary>
|
||||
public long Length { get; set; }
|
||||
/// <summary>Gets or sets the lowercase SHA-256 digest.</summary>
|
||||
public string Sha256 { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt und validiert das vollständige kryptografische Payload-Manifest.</summary>
|
||||
public static class PackageManifest
|
||||
{
|
||||
/// <summary>Liest das Manifest und validiert jede sowie ausschließlich jede Payload-Datei.</summary>
|
||||
/// <param name="applicationDirectory">Das Wurzelverzeichnis der Anwendungs-Payload.</param>
|
||||
/// <param name="manifestPath">Der Pfad des zu prüfenden Manifests.</param>
|
||||
/// <returns>Die validierten und normalisierten Manifesteinträge.</returns>
|
||||
public static IList<PackageFile> ValidateAndRead(string applicationDirectory, string manifestPath)
|
||||
{
|
||||
if (!Directory.Exists(applicationDirectory)) throw new DirectoryNotFoundException("Application payload missing: " + applicationDirectory);
|
||||
if (!File.Exists(manifestPath)) throw new FileNotFoundException("Package manifest missing: " + manifestPath, manifestPath);
|
||||
|
||||
var files = new List<PackageFile>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var rawLine in File.ReadAllLines(manifestPath, Encoding.UTF8))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
|
||||
var parts = line.Split(new[] { '|' }, 3);
|
||||
long length;
|
||||
if (parts.Length != 3 || parts[0].Length != 64 || !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out length))
|
||||
throw new InvalidDataException("Invalid package manifest line: " + rawLine);
|
||||
|
||||
var relative = NormalizeRelativePath(parts[2]);
|
||||
if (!seen.Add(relative)) throw new InvalidDataException("Duplicate package manifest path: " + relative);
|
||||
var fullPath = ResolveContainedPath(applicationDirectory, relative);
|
||||
if (!File.Exists(fullPath)) throw new FileNotFoundException("Manifest payload file missing: " + relative, fullPath);
|
||||
var info = new FileInfo(fullPath);
|
||||
if (info.Length != length) throw new InvalidDataException("Payload size mismatch: " + relative);
|
||||
var actualHash = Sha256(fullPath);
|
||||
if (!string.Equals(actualHash, parts[0], StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Payload SHA-256 mismatch: " + relative);
|
||||
files.Add(new PackageFile { RelativePath = relative, Length = length, Sha256 = actualHash });
|
||||
}
|
||||
|
||||
if (files.Count == 0) throw new InvalidDataException("The package manifest does not contain payload files.");
|
||||
// Der Mengenvergleich verhindert, dass nicht deklarierte DLLs oder Konfigurationen
|
||||
// unbemerkt mit administrativen Rechten installiert werden.
|
||||
var actualFiles = Directory.GetFiles(applicationDirectory, "*", SearchOption.AllDirectories)
|
||||
.Select(x => NormalizeRelativePath(x.Substring(Path.GetFullPath(applicationDirectory).TrimEnd(Path.DirectorySeparatorChar).Length + 1)))
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var declaredFiles = files.Select(x => x.RelativePath).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
if (!actualFiles.SequenceEqual(declaredFiles, StringComparer.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("The application payload contains files not covered by the package manifest.");
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt ein deterministisch sortiertes Manifest über die vollständige Payload.</summary>
|
||||
/// <param name="applicationDirectory">Das Wurzelverzeichnis der Anwendungs-Payload.</param>
|
||||
/// <param name="manifestPath">Der Zielpfad des Manifests.</param>
|
||||
public static void Write(string applicationDirectory, string manifestPath)
|
||||
{
|
||||
var root = Path.GetFullPath(applicationDirectory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var lines = Directory.GetFiles(applicationDirectory, "*", SearchOption.AllDirectories)
|
||||
.Select(path => new FileInfo(path))
|
||||
.OrderBy(info => info.FullName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(info => Sha256(info.FullName) + "|" + info.Length.ToString(CultureInfo.InvariantCulture) + "|" + NormalizeRelativePath(info.FullName.Substring(root.Length)))
|
||||
.ToArray();
|
||||
File.WriteAllLines(manifestPath, lines, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
/// <summary>Berechnet den kleingeschriebenen SHA-256-Hash einer Datei.</summary>
|
||||
/// <param name="path">Der Pfad der zu prüfenden Datei.</param>
|
||||
/// <returns>Der SHA-256-Hash als 64-stellige Hexadezimalzeichenfolge.</returns>
|
||||
public static string Sha256(string path)
|
||||
{
|
||||
using (var stream = File.OpenRead(path))
|
||||
using (var algorithm = SHA256.Create())
|
||||
{
|
||||
var hash = algorithm.ComputeHash(stream);
|
||||
var builder = new StringBuilder(hash.Length * 2);
|
||||
foreach (var value in hash) builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Löst einen relativen Payload-Pfad auf und weist Directory Traversal zurück.</summary>
|
||||
/// <param name="root">Das erlaubte Payload-Wurzelverzeichnis.</param>
|
||||
/// <param name="relative">Der relative Pfad aus dem Manifest.</param>
|
||||
/// <returns>Der vollständig aufgelöste, innerhalb von <paramref name="root"/> liegende Pfad.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert Pfadtrenner und weist absolute, leere oder ausbrechende Pfade zurück.
|
||||
/// </summary>
|
||||
/// <param name="path">Der zu normalisierende relative Pfad.</param>
|
||||
/// <returns>Der normalisierte Pfad mit Schrägstrichen.</returns>
|
||||
private static string NormalizeRelativePath(string path)
|
||||
{
|
||||
path = (path ?? string.Empty).Replace('\\', '/').Trim();
|
||||
if (path.Length == 0 || path.StartsWith("/", StringComparison.Ordinal) || path.Contains("../") || path == ".." || Path.IsPathRooted(path))
|
||||
throw new InvalidDataException("Unsafe package path: " + path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Setup
|
||||
{
|
||||
/// <summary>Enthält den Einstiegspunkt des administrativen Windows-Setups.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>Startet die Setup-Oberfläche im Installations- oder Deinstallationsmodus.</summary>
|
||||
/// <param name="args">Befehlszeilenargumente; <c>--uninstall</c> aktiviert die Deinstallation.</param>
|
||||
/// <returns>Null nach regulärem Schließen der Oberfläche.</returns>
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
var uninstallMode = args != null && args.Length == 1 && string.Equals(args[0], "--uninstall", StringComparison.OrdinalIgnoreCase);
|
||||
Application.Run(new MainForm(new InstallerEngine(AppDomain.CurrentDomain.BaseDirectory), uninstallMode));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("BizTalk Platform Management Tool Setup")]
|
||||
[assembly: AssemblyDescription("Transactional installer and updater for BizTalk Platform Management Tool")]
|
||||
[assembly: AssemblyCompany("BEW")]
|
||||
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
|
||||
[assembly: AssemblyVersion("2.1.2.0")]
|
||||
[assembly: AssemblyFileVersion("2.1.2.0")]
|
||||
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
|
||||
@@ -0,0 +1,265 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Setup
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt ein dauerhaftes, einzeiliges Diagnoseprotokoll für genau einen Setup-Lauf.
|
||||
/// Die Komponente übernimmt keine Kennwörter oder andere Geheimnisse.
|
||||
/// </summary>
|
||||
internal sealed class SetupOperationLog
|
||||
{
|
||||
/// <summary>Serialisiert konkurrierende Schreibzugriffe innerhalb des Setup-Prozesses.</summary>
|
||||
private readonly object sync = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Initialisiert ein Protokoll für einen bereits festgelegten Dateipfad.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Der Logpfad oder eine leere Zeichenfolge bei vollständig ausgefallenem Logging.</param>
|
||||
private SetupOperationLog(string filePath)
|
||||
{
|
||||
FilePath = filePath;
|
||||
}
|
||||
|
||||
/// <summary>Ruft den tatsächlich verwendeten Logpfad ab.</summary>
|
||||
public string FilePath { get; private set; }
|
||||
|
||||
/// <summary>Ruft die formatierte Ursache eines Fehlers beim primären Logaufbau ab.</summary>
|
||||
public string CreationError { get; private set; }
|
||||
|
||||
/// <summary>Ruft ab, ob das Log im temporären Rückfallverzeichnis liegt.</summary>
|
||||
public bool IsFallback { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt ein Setup-Protokoll unter ProgramData oder ersatzweise im Temp-Verzeichnis.
|
||||
/// </summary>
|
||||
/// <param name="dataDirectory">Das bevorzugte dauerhafte Datenverzeichnis.</param>
|
||||
/// <param name="operation">Die kurze Operationsbezeichnung für den Dateinamen und Kontextkopf.</param>
|
||||
/// <returns>Ein verwendbares Protokollobjekt, auch wenn keine Logdatei angelegt werden konnte.</returns>
|
||||
public static SetupOperationLog Create(string dataDirectory, string operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CreateInDirectory(Path.Combine(dataDirectory, "InstallerLogs"), operation);
|
||||
}
|
||||
catch (Exception primaryException)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ohne primäres ProgramData-Log bleibt wenigstens im Benutzer-Temp ein
|
||||
// Diagnosepfad erhalten; die ursprüngliche Ursache wird dort mitgeschrieben.
|
||||
var fallback = CreateInDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool", "InstallerLogs"),
|
||||
operation);
|
||||
fallback.CreationError = FormatException(primaryException);
|
||||
fallback.IsFallback = true;
|
||||
fallback.Write(
|
||||
"WARN",
|
||||
"event=primary_log_creation_failed requested_data_directory=\"" + dataDirectory + "\" "
|
||||
+ fallback.CreationError);
|
||||
return fallback;
|
||||
}
|
||||
catch (Exception fallbackException)
|
||||
{
|
||||
return new SetupOperationLog(string.Empty)
|
||||
{
|
||||
CreationError = "primary={" + FormatException(primaryException) + "} fallback={" + FormatException(fallbackException) + "}"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legt eine eindeutig benannte Logdatei an und schreibt den technischen Kontextkopf.
|
||||
/// </summary>
|
||||
/// <param name="directory">Das Zielverzeichnis der Logdatei.</param>
|
||||
/// <param name="operation">Die Bezeichnung des Setup-Laufs.</param>
|
||||
/// <returns>Das initialisierte Setup-Protokoll.</returns>
|
||||
private static SetupOperationLog CreateInDirectory(string directory, string operation)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
CleanupOldLogs(directory);
|
||||
var path = Path.Combine(
|
||||
directory,
|
||||
"setup-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmssfff", CultureInfo.InvariantCulture)
|
||||
+ "-" + Process.GetCurrentProcess().Id.ToString(CultureInfo.InvariantCulture)
|
||||
+ "-" + operation + ".log");
|
||||
using (new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { }
|
||||
var log = new SetupOperationLog(path);
|
||||
log.Write(
|
||||
"INFO",
|
||||
"event=setup_started operation=" + operation
|
||||
+ " setup_version=" + typeof(SetupOperationLog).Assembly.GetName().Version
|
||||
+ " os=\"" + Environment.OSVersion.VersionString + "\""
|
||||
+ " process_bitness=" + (Environment.Is64BitProcess ? "64" : "32")
|
||||
+ " os_bitness=" + (Environment.Is64BitOperatingSystem ? "64" : "32")
|
||||
+ " clr=" + Environment.Version
|
||||
+ " machine=\"" + Environment.MachineName + "\""
|
||||
+ " identity=\"" + CurrentIdentity() + "\""
|
||||
+ " elevated=" + IsElevated()
|
||||
+ " base_directory=\"" + AppDomain.CurrentDomain.BaseDirectory + "\"");
|
||||
return log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt eine UTC-zeitgestempelte, einzeilige Nachricht ausfallsicher in die Logdatei.
|
||||
/// </summary>
|
||||
/// <param name="level">Der textuelle Log-Level.</param>
|
||||
/// <param name="message">Die zu protokollierende Nachricht.</param>
|
||||
public void Write(string level, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(FilePath)) return;
|
||||
var line = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)
|
||||
+ " level=" + (level ?? "INFO").ToUpperInvariant()
|
||||
+ " " + SingleLine(message)
|
||||
+ Environment.NewLine;
|
||||
try
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
File.AppendAllText(FilePath, line, new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Das Diagnose-Logging darf das eigentliche Setup-Ergebnis niemals ersetzen.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protokolliert einen Fehler mit stabiler Kennung, Phase und vollständiger Exception-Kette.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">Die stabile maschinenlesbare Fehlerkennung.</param>
|
||||
/// <param name="phase">Die lesbare Setup-Phase.</param>
|
||||
/// <param name="exception">Die zu protokollierende Ausnahme.</param>
|
||||
public void WriteException(string errorCode, string phase, Exception exception)
|
||||
{
|
||||
Write("ERROR", "event=exception error_code=" + errorCode + " phase=\"" + phase + "\" " + FormatException(exception));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protokolliert Existenz, Größe, Zeitstempel, Dateiversion und SHA-256 einer Datei.
|
||||
/// </summary>
|
||||
/// <param name="label">Die fachliche Rolle der Datei im Setup.</param>
|
||||
/// <param name="path">Der zu untersuchende Dateipfad.</param>
|
||||
public void WriteFileDetails(string label, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Write("INFO", "event=file_details label=\"" + label + "\" path=\"" + path + "\" exists=false");
|
||||
return;
|
||||
}
|
||||
var info = new FileInfo(path);
|
||||
var version = FileVersionInfo.GetVersionInfo(path).FileVersion ?? "(unknown)";
|
||||
Write(
|
||||
"INFO",
|
||||
"event=file_details label=\"" + label + "\" path=\"" + path + "\" exists=true"
|
||||
+ " size_bytes=" + info.Length.ToString(CultureInfo.InvariantCulture)
|
||||
+ " modified_utc=" + info.LastWriteTimeUtc.ToString("o", CultureInfo.InvariantCulture)
|
||||
+ " file_version=\"" + version + "\""
|
||||
+ " sha256=" + PackageManifest.Sha256(path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Write("WARN", "event=file_details_failed label=\"" + label + "\" path=\"" + path + "\" " + FormatException(ex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatiert eine Exception-Kette mit Typ, HRESULT, Nachricht und Stacktrace.
|
||||
/// </summary>
|
||||
/// <param name="exception">Die äußerste Ausnahme.</param>
|
||||
/// <returns>Eine einzeilige Diagnose mit höchstens zwölf Exception-Ebenen.</returns>
|
||||
internal static string FormatException(Exception exception)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
var current = exception;
|
||||
var depth = 0;
|
||||
while (current != null && depth < 12)
|
||||
{
|
||||
if (depth > 0) result.Append(" | inner[").Append(depth).Append("] ");
|
||||
result.Append("exception_type=").Append(current.GetType().FullName)
|
||||
.Append(" hresult=0x").Append(current.HResult.ToString("X8", CultureInfo.InvariantCulture))
|
||||
.Append(" message=\"").Append(current.Message).Append('"');
|
||||
if (!string.IsNullOrWhiteSpace(current.StackTrace)) result.Append(" stack=\"").Append(current.StackTrace).Append('"');
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
return SingleLine(result.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt die aktuelle Windows-Identität ohne einen Diagnosefehler weiterzureichen.
|
||||
/// </summary>
|
||||
/// <returns>Der Identitätsname oder <c>(unknown)</c>.</returns>
|
||||
private static string CurrentIdentity()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var identity = WindowsIdentity.GetCurrent())
|
||||
{
|
||||
return identity == null ? "(unknown)" : identity.Name;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "(unknown)";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt, ob der aktuelle Prozess mit Administratorrechten läuft.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c>, <c>false</c> oder <c>unknown</c>.</returns>
|
||||
private static string IsElevated()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var identity = WindowsIdentity.GetCurrent())
|
||||
{
|
||||
return identity != null && new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator) ? "true" : "false";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maskiert Steuerzeichen, damit jeder Logeintrag genau eine physische Zeile belegt.
|
||||
/// </summary>
|
||||
/// <param name="value">Der zu normalisierende Text.</param>
|
||||
/// <returns>Der einzeilige Text.</returns>
|
||||
private static string SingleLine(string value)
|
||||
{
|
||||
return (value ?? string.Empty).Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entfernt Setup-Protokolle, deren letzte Änderung mehr als 90 Tage zurückliegt.
|
||||
/// </summary>
|
||||
/// <param name="directory">Das zu bereinigende Installer-Logverzeichnis.</param>
|
||||
private static void CleanupOldLogs(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-90);
|
||||
foreach (var file in Directory.GetFiles(directory, "setup-*.log"))
|
||||
{
|
||||
if (File.GetLastWriteTimeUtc(file) < cutoff) File.Delete(file);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Die Aufbewahrungsbereinigung ist bestmöglich und blockiert kein Setup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="2.1.2.0" name="BizTalkPlatformManagementTool.Setup" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -12,6 +12,7 @@
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<Deterministic>true</Deterministic>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
@@ -33,6 +34,7 @@
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
<DocumentationFile>bin\Release\BizTalkPlatformManagementTool.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
@@ -45,7 +47,9 @@
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<Compile Include="Program.cs" />
|
||||
<Compile Include="RuntimeSelfTest.cs" />
|
||||
<Compile Include="Models\ArtifactStates.cs" />
|
||||
<Compile Include="Models\BizTalkSnapshot.cs" />
|
||||
<Compile Include="Models\DiffModels.cs" />
|
||||
@@ -56,12 +60,14 @@
|
||||
<Compile Include="Services\JsonFileStore.cs" />
|
||||
<Compile Include="Services\OperationLogger.cs" />
|
||||
<Compile Include="Services\SnapshotComparer.cs" />
|
||||
<Compile Include="Services\SnapshotValidator.cs" />
|
||||
<Compile Include="Services\SnapshotStore.cs" />
|
||||
<Compile Include="Services\BizTalkOperationService.cs" />
|
||||
<Compile Include="Ui\MainForm.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="App.config" />
|
||||
<None Include="app.manifest" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
|
||||
@@ -1,26 +1,81 @@
|
||||
namespace BizTalkPlatformManagementTool.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains BizTalk WMI state constants and display helpers used by snapshots,
|
||||
/// plans and reports.
|
||||
/// </summary>
|
||||
public static class ArtifactStates
|
||||
{
|
||||
/// <summary>
|
||||
/// WMI status value for a bound send port.
|
||||
/// </summary>
|
||||
public const int SendPortBound = 1;
|
||||
|
||||
/// <summary>
|
||||
/// WMI status value for a stopped send port.
|
||||
/// </summary>
|
||||
public const int SendPortStopped = 2;
|
||||
|
||||
/// <summary>
|
||||
/// WMI status value for a started send port.
|
||||
/// </summary>
|
||||
public const int SendPortStarted = 3;
|
||||
|
||||
/// <summary>
|
||||
/// WMI status value for an unbound orchestration.
|
||||
/// </summary>
|
||||
public const int OrchestrationUnbound = 1;
|
||||
|
||||
/// <summary>
|
||||
/// WMI status value for a bound orchestration.
|
||||
/// </summary>
|
||||
public const int OrchestrationBound = 2;
|
||||
|
||||
/// <summary>
|
||||
/// WMI status value for a stopped orchestration.
|
||||
/// </summary>
|
||||
public const int OrchestrationStopped = 3;
|
||||
|
||||
/// <summary>
|
||||
/// WMI status value for a started orchestration.
|
||||
/// </summary>
|
||||
public const int OrchestrationStarted = 4;
|
||||
|
||||
/// <summary>
|
||||
/// WMI service state value for a stopped host instance.
|
||||
/// </summary>
|
||||
public const int HostStopped = 1;
|
||||
|
||||
/// <summary>
|
||||
/// WMI service state value for a host instance that is starting.
|
||||
/// </summary>
|
||||
public const int HostStartPending = 2;
|
||||
|
||||
/// <summary>
|
||||
/// WMI service state value for a host instance that is stopping.
|
||||
/// </summary>
|
||||
public const int HostStopPending = 3;
|
||||
|
||||
/// <summary>
|
||||
/// WMI service state value for a started host instance.
|
||||
/// </summary>
|
||||
public const int HostStarted = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a receive location enabled flag into the text used in reports.
|
||||
/// </summary>
|
||||
/// <param name="enabled">True when the receive location is enabled.</param>
|
||||
/// <returns>A display value for the receive location state.</returns>
|
||||
public static string FormatReceiveLocation(bool enabled)
|
||||
{
|
||||
return enabled ? "Enabled" : "Disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an MSBTS_SendPort.Status value into a display string.
|
||||
/// </summary>
|
||||
/// <param name="status">The raw WMI send port status value.</param>
|
||||
/// <returns>A known status name or an Unknown value with the raw code.</returns>
|
||||
public static string FormatSendPort(int status)
|
||||
{
|
||||
switch (status)
|
||||
@@ -32,6 +87,11 @@ namespace BizTalkPlatformManagementTool.Models
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an MSBTS_Orchestration.OrchestrationStatus value into a display string.
|
||||
/// </summary>
|
||||
/// <param name="status">The raw WMI orchestration status value.</param>
|
||||
/// <returns>A known status name or an Unknown value with the raw code.</returns>
|
||||
public static string FormatOrchestration(int status)
|
||||
{
|
||||
switch (status)
|
||||
@@ -44,6 +104,11 @@ namespace BizTalkPlatformManagementTool.Models
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an MSBTS_HostInstance.ServiceState value into a display string.
|
||||
/// </summary>
|
||||
/// <param name="state">The raw WMI host instance service state value.</param>
|
||||
/// <returns>A known service state name or an Unknown value with the raw code.</returns>
|
||||
public static string FormatHostInstance(int state)
|
||||
{
|
||||
switch (state)
|
||||
|
||||
@@ -3,34 +3,62 @@ using System.Runtime.Serialization;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents one captured BizTalk runtime state including application artifacts
|
||||
/// and host instances.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class BizTalkSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new snapshot with empty application and host instance collections.
|
||||
/// </summary>
|
||||
public BizTalkSnapshot()
|
||||
{
|
||||
Applications = new List<ApplicationSnapshot>();
|
||||
HostInstances = new List<HostInstanceState>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tool version that created the snapshot.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string ToolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the local timestamp when the snapshot was created.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the BizTalk server name used for the WMI connection.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the BizTalk application snapshots captured from WMI.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public List<ApplicationSnapshot> Applications { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the host instances captured from the BizTalk group.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public List<HostInstanceState> HostInstances { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups the captured artifact states for one BizTalk application.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class ApplicationSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes an application snapshot with empty artifact collections.
|
||||
/// </summary>
|
||||
public ApplicationSnapshot()
|
||||
{
|
||||
ReceiveLocations = new List<ReceiveLocationState>();
|
||||
@@ -38,88 +66,169 @@ namespace BizTalkPlatformManagementTool.Models
|
||||
Orchestrations = new List<OrchestrationState>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the BizTalk application name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the receive locations that belong to the application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public List<ReceiveLocationState> ReceiveLocations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the send ports that belong to the application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public List<SendPortState> SendPorts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the orchestrations that belong to the application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public List<OrchestrationState> Orchestrations { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the relevant WMI state for a BizTalk receive location.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class ReceiveLocationState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the owning BizTalk application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the receive location name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parent receive port name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string ReceivePortName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the receive location is enabled.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the receive adapter name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public string AdapterName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the receive location transport address.
|
||||
/// </summary>
|
||||
[DataMember(Order = 6)]
|
||||
public string Address { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the relevant WMI state for a BizTalk send port.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class SendPortState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the owning BizTalk application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the send port name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw MSBTS_SendPort.Status value.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public int Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primary transport adapter type.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public string PrimaryTransportType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primary transport address.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public string PrimaryTransportAddress { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the relevant WMI state for a BizTalk orchestration.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class OrchestrationState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the owning BizTalk application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the orchestration name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw MSBTS_Orchestration.OrchestrationStatus value.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public int OrchestrationStatus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the relevant WMI state for a BizTalk host instance.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class HostInstanceState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the host instance name used as the WMI key.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string InstanceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the BizTalk host name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string HostName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server on which the host instance runs.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw MSBTS_HostInstance.ServiceState value.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public int RawState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the formatted host instance state.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public string StateText { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,56 +3,104 @@ using System.Runtime.Serialization;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains all differences detected between two BizTalk snapshots.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class SnapshotDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new diff with empty artifact and host instance collections.
|
||||
/// </summary>
|
||||
public SnapshotDiff()
|
||||
{
|
||||
ArtifactDifferences = new List<ArtifactDiffEntry>();
|
||||
HostInstanceDifferences = new List<HostInstanceDiffEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets state differences for receive locations, send ports and orchestrations.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public List<ArtifactDiffEntry> ArtifactDifferences { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets state differences for host instances.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public List<HostInstanceDiffEntry> HostInstanceDifferences { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a before/after state change for one BizTalk application artifact.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class ArtifactDiffEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the owning BizTalk application.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artifact category, such as SendPort or ReceiveLocation.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string ArtifactType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artifact name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the formatted state in the before snapshot.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public string Before { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the formatted state in the after snapshot.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public string After { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a before/after state change for one BizTalk host instance.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class HostInstanceDiffEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the host instance name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string InstanceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the BizTalk host name.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string HostName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server on which the host instance runs.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the formatted state in the before snapshot.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public string Before { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the formatted state in the after snapshot.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public string After { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,92 +3,211 @@ using System.Runtime.Serialization;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the supported operation plan modes.
|
||||
/// </summary>
|
||||
public enum OperationMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan mode for stopping BizTalk runtime artifacts before maintenance.
|
||||
/// </summary>
|
||||
Shutdown,
|
||||
|
||||
/// <summary>
|
||||
/// Plan mode for returning BizTalk runtime artifacts to a captured state.
|
||||
/// </summary>
|
||||
Restore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the artifact categories that can appear in an operation plan.
|
||||
/// </summary>
|
||||
public enum OperationStepKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A receive location step.
|
||||
/// </summary>
|
||||
ReceiveLocation,
|
||||
|
||||
/// <summary>
|
||||
/// A send port step.
|
||||
/// </summary>
|
||||
SendPort,
|
||||
|
||||
/// <summary>
|
||||
/// An orchestration step.
|
||||
/// </summary>
|
||||
Orchestration,
|
||||
|
||||
/// <summary>
|
||||
/// A host instance step.
|
||||
/// </summary>
|
||||
HostInstance,
|
||||
|
||||
/// <summary>
|
||||
/// An informational step that is intentionally not executed.
|
||||
/// </summary>
|
||||
Note
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the ordered shutdown or restore plan written before any
|
||||
/// runtime-changing operation is executed.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class OperationPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new operation plan with an empty step collection.
|
||||
/// </summary>
|
||||
public OperationPlan()
|
||||
{
|
||||
Steps = new List<OperationStep>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the plan mode as a serialized string.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the local timestamp when the plan was created.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target server for server-scoped plan steps.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ordered operation steps.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public List<OperationStep> Steps { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes one executable or informational step in a shutdown or restore plan.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public sealed class OperationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the artifact kind for this step.
|
||||
/// </summary>
|
||||
[DataMember(Order = 1)]
|
||||
public string Kind { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the owning BizTalk application, when applicable.
|
||||
/// </summary>
|
||||
[DataMember(Order = 2)]
|
||||
public string Application { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artifact or host instance name displayed to the user.
|
||||
/// </summary>
|
||||
[DataMember(Order = 3)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server associated with the step, when server scoped.
|
||||
/// </summary>
|
||||
[DataMember(Order = 4)]
|
||||
public string Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the human-readable action description.
|
||||
/// </summary>
|
||||
[DataMember(Order = 5)]
|
||||
public string Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WMI class used to resolve the runtime object.
|
||||
/// </summary>
|
||||
[DataMember(Order = 6)]
|
||||
public string WmiClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WMI key property used to locate the runtime object.
|
||||
/// </summary>
|
||||
[DataMember(Order = 7)]
|
||||
public string KeyProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WMI key value used to locate the runtime object.
|
||||
/// </summary>
|
||||
[DataMember(Order = 8)]
|
||||
public string KeyValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the WMI method or service pseudo-method to execute.
|
||||
/// </summary>
|
||||
[DataMember(Order = 9)]
|
||||
public string MethodName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the numeric arguments passed to the WMI method.
|
||||
/// </summary>
|
||||
[DataMember(Order = 10)]
|
||||
public int[] Arguments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw WMI state expected after the method completes.
|
||||
/// </summary>
|
||||
[DataMember(Order = 11)]
|
||||
public int? TargetState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the step should be executed.
|
||||
/// </summary>
|
||||
[DataMember(Order = 12)]
|
||||
public bool Execute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an operator-facing warning for skipped or risky steps.
|
||||
/// </summary>
|
||||
[DataMember(Order = 13)]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains user-selected runtime options for snapshot, shutdown and restore actions.
|
||||
/// </summary>
|
||||
public sealed class OperationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the BizTalk server used for WMI operations.
|
||||
/// </summary>
|
||||
public string Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the directory where snapshots, plans, reports and diffs are written.
|
||||
/// </summary>
|
||||
public string OutputDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the state file used as restore input.
|
||||
/// </summary>
|
||||
public string StateFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether operations should only be logged.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of seconds to wait for a target runtime state.
|
||||
/// </summary>
|
||||
public int WaitTimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of seconds between WMI polling attempts.
|
||||
/// </summary>
|
||||
public int PollIntervalSeconds { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using BizTalkPlatformManagementTool.Ui;
|
||||
|
||||
namespace BizTalkPlatformManagementTool
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains the WinForms application entry point and startup guard checks.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts the application after verifying that BizTalk WMI operations can run elevated.
|
||||
/// </summary>
|
||||
/// <param name="args">Befehlszeilenargumente; <c>--self-test</c> startet die WMI-freie Installerprüfung.</param>
|
||||
/// <returns>Null bei erfolgreichem Abschluss, andernfalls ein prozessgeeigneter Fehlercode.</returns>
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
// Der Self-Test muss ohne Administratorprüfung und ohne WinForms-Oberfläche laufen,
|
||||
// damit der Installer ihn bereits im isolierten Staging-Verzeichnis ausführen kann.
|
||||
if (args != null && args.Length == 1 && string.Equals(args[0], "--self-test", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return RuntimeSelfTest.Run();
|
||||
}
|
||||
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
@@ -22,12 +38,33 @@ namespace BizTalkPlatformManagementTool
|
||||
"Administrator Rights Required",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
Application.Run(new MainForm());
|
||||
bool createdNew;
|
||||
// Der sitzungsbezogene Mutex verhindert konkurrierende Wartungsoperationen desselben Benutzers.
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the current Windows identity is a local administrator.
|
||||
/// </summary>
|
||||
/// <returns>True when the process is elevated as administrator; otherwise false.</returns>
|
||||
private static bool IsRunningAsAdministrator()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("BizTalk Platform Management Tool")]
|
||||
[assembly: AssemblyDescription("Controlled BizTalk Server maintenance snapshots, plans and runtime operations")]
|
||||
[assembly: AssemblyCompany("BEW")]
|
||||
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2026")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
|
||||
[assembly: AssemblyVersion("2.1.2.0")]
|
||||
[assembly: AssemblyFileVersion("2.1.2.0")]
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using BizTalkPlatformManagementTool.Models;
|
||||
using BizTalkPlatformManagementTool.Services;
|
||||
|
||||
namespace BizTalkPlatformManagementTool
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a WMI-free smoke test used by the installer before and after activation.
|
||||
/// </summary>
|
||||
internal static class RuntimeSelfTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Prüft Serialisierung, Validierung, Vergleich und Report-Persistenz ohne BizTalk-WMI-Zugriff.
|
||||
/// </summary>
|
||||
/// <returns>Null bei erfolgreicher Prüfung, andernfalls eins.</returns>
|
||||
public static int Run()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool.SelfTest." + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var before = SampleSnapshot(ArtifactStates.SendPortStarted);
|
||||
var after = SampleSnapshot(ArtifactStates.SendPortStopped);
|
||||
var snapshotPath = Path.Combine(directory, "snapshot.json");
|
||||
JsonFileStore.Save(snapshotPath, before);
|
||||
var loaded = JsonFileStore.Load<BizTalkSnapshot>(snapshotPath);
|
||||
SnapshotValidator.Validate(loaded);
|
||||
|
||||
var diff = SnapshotComparer.Compare(loaded, after);
|
||||
if (diff.ArtifactDifferences.Count != 1)
|
||||
{
|
||||
throw new InvalidOperationException("Snapshot diff self-test returned an unexpected result.");
|
||||
}
|
||||
|
||||
SnapshotStore.SaveSnapshotSet(Path.Combine(directory, "before.json"), loaded);
|
||||
SnapshotStore.SaveDiffSet(Path.Combine(directory, "diff.json"), diff);
|
||||
Console.WriteLine("SELF_TEST_OK version=" + BizTalkOperationService.Version);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("SELF_TEST_FAILED " + ex);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ein Bereinigungsfehler darf das bereits feststehende Self-Test-Ergebnis nicht überschreiben.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen minimalen, aber vollständig validierbaren Snapshot für den Self-Test.
|
||||
/// </summary>
|
||||
/// <param name="sendPortState">Der rohe BizTalk-Status des enthaltenen Send Ports.</param>
|
||||
/// <returns>Ein Snapshot mit genau einer Anwendung und einem Send Port.</returns>
|
||||
private static BizTalkSnapshot SampleSnapshot(int sendPortState)
|
||||
{
|
||||
var snapshot = new BizTalkSnapshot
|
||||
{
|
||||
ToolVersion = BizTalkOperationService.Version,
|
||||
CreatedAt = DateTimeOffset.Now.ToString("o"),
|
||||
Server = Environment.MachineName
|
||||
};
|
||||
var app = new ApplicationSnapshot { Application = "SelfTest" };
|
||||
app.SendPorts.Add(new SendPortState
|
||||
{
|
||||
Application = app.Application,
|
||||
Name = "SelfTest.SendPort",
|
||||
Status = sendPortState
|
||||
});
|
||||
snapshot.Applications.Add(app);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,60 @@ using BizTalkPlatformManagementTool.Models;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Coordinates BizTalk snapshot, plan, shutdown, restore and persistence operations.
|
||||
/// </summary>
|
||||
public sealed class BizTalkOperationService
|
||||
{
|
||||
public const string Version = "2.0.0-net461";
|
||||
/// <summary>
|
||||
/// Current tool version written into generated snapshots.
|
||||
/// </summary>
|
||||
public const string Version = "2.1.2-net461";
|
||||
|
||||
/// <summary>
|
||||
/// Fallback application name used when WMI does not expose an application property.
|
||||
/// </summary>
|
||||
private const string UnknownApplication = "(Unknown Application)";
|
||||
|
||||
/// <summary>
|
||||
/// Logger used for all operator-facing operation messages.
|
||||
/// </summary>
|
||||
private readonly OperationLogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new operation service.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger used for operator-visible progress and diagnostics.</param>
|
||||
public BizTalkOperationService(OperationLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the BizTalk WMI namespace is reachable and readable.
|
||||
/// </summary>
|
||||
/// <param name="server">The BizTalk server or management host to query.</param>
|
||||
public void Diagnose(string server)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current BizTalk runtime state from WMI.
|
||||
/// </summary>
|
||||
/// <param name="server">The BizTalk server or management host to query.</param>
|
||||
/// <returns>A complete snapshot of supported BizTalk artifacts and host instances.</returns>
|
||||
public BizTalkSnapshot CreateSnapshot(string server)
|
||||
{
|
||||
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<string, ApplicationSnapshot>(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,15 +168,25 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ordered shutdown plan from a snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The runtime state used as the source for the plan.</param>
|
||||
/// <param name="server">The selected server on which host instance steps may execute.</param>
|
||||
/// <returns>An ordered shutdown plan.</returns>
|
||||
public OperationPlan CreateShutdownPlan(BizTalkSnapshot snapshot, string server)
|
||||
{
|
||||
SnapshotValidator.EnsureServerMatches(snapshot, server);
|
||||
var plan = NewPlan(OperationMode.Shutdown, server);
|
||||
|
||||
// Eingehenden Verkehr zuerst stoppen, bevor abhängige Verarbeitungsartefakte
|
||||
// und zuletzt die Host Instances heruntergefahren werden.
|
||||
foreach (var app in snapshot.Applications)
|
||||
{
|
||||
foreach (var item in app.ReceiveLocations.Where(x => x.Enabled))
|
||||
@@ -144,10 +219,19 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ordered restore plan from a previously captured snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The state that should be restored.</param>
|
||||
/// <param name="server">The selected server on which host instance steps may execute.</param>
|
||||
/// <returns>An ordered restore plan.</returns>
|
||||
public OperationPlan CreateRestorePlan(BizTalkSnapshot snapshot, string server)
|
||||
{
|
||||
SnapshotValidator.EnsureServerMatches(snapshot, server);
|
||||
var plan = NewPlan(OperationMode.Restore, server);
|
||||
|
||||
// Beim Restore gilt die umgekehrte Abhängigkeitsrichtung: zuerst Laufzeit-Hosts,
|
||||
// danach ausgehende Verarbeitung und Receive Locations bewusst ganz zum Schluss.
|
||||
foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted))
|
||||
{
|
||||
var step = Step("HostInstance", string.Empty, item.InstanceName, item.Server, "Start host instance", "MSBTS_HostInstance", "InstanceName", item.InstanceName, "Start", null, ArtifactStates.HostStarted);
|
||||
@@ -189,6 +273,8 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
else if (item.OrchestrationStatus == ArtifactStates.OrchestrationBound)
|
||||
{
|
||||
// Ein blindes Unenlist würde Bound nach Unbound verschieben und damit
|
||||
// einen anderen Zustand als im Snapshot herstellen.
|
||||
plan.Steps.Add(new OperationStep
|
||||
{
|
||||
Kind = "Note",
|
||||
@@ -214,8 +300,21 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an operation plan or logs each step when dry-run mode is enabled.
|
||||
/// </summary>
|
||||
/// <param name="plan">The ordered plan to execute.</param>
|
||||
/// <param name="options">The runtime options controlling server, dry-run and wait behavior.</param>
|
||||
public void ExecutePlan(OperationPlan plan, OperationOptions options)
|
||||
{
|
||||
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)
|
||||
@@ -228,6 +327,7 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
// Dry-run löst das Objekt absichtlich nicht erneut per WMI auf und führt keine Methode aus.
|
||||
_logger.Info("DRY RUN: " + step.Action + " '" + step.Name + "'");
|
||||
continue;
|
||||
}
|
||||
@@ -253,6 +353,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a snapshot and its report sidecars.
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">The directory where output files are written.</param>
|
||||
/// <param name="fileName">The primary JSON file name.</param>
|
||||
/// <param name="snapshot">The snapshot to persist.</param>
|
||||
/// <returns>The primary JSON file path.</returns>
|
||||
public string SaveSnapshot(string outputDirectory, string fileName, BizTalkSnapshot snapshot)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
@@ -262,6 +369,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves an operation plan as JSON.
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">The directory where output files are written.</param>
|
||||
/// <param name="fileName">The plan JSON file name.</param>
|
||||
/// <param name="plan">The plan to persist.</param>
|
||||
/// <returns>The saved plan file path.</returns>
|
||||
public string SavePlan(string outputDirectory, string fileName, OperationPlan plan)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
@@ -271,6 +385,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a diff and its report sidecars.
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">The directory where output files are written.</param>
|
||||
/// <param name="fileName">The primary diff JSON file name.</param>
|
||||
/// <param name="diff">The diff to persist.</param>
|
||||
/// <returns>The primary diff JSON file path.</returns>
|
||||
public string SaveDiff(string outputDirectory, string fileName, SnapshotDiff diff)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
@@ -280,6 +401,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one concrete WMI operation step and waits for its target state.
|
||||
/// </summary>
|
||||
/// <param name="client">The connected WMI client.</param>
|
||||
/// <param name="instance">The resolved WMI object for the step.</param>
|
||||
/// <param name="step">The operation step to execute.</param>
|
||||
/// <param name="options">The runtime options controlling wait behavior.</param>
|
||||
private void ExecuteStep(BizTalkWmiClient client, ManagementObject instance, OperationStep step, OperationOptions options)
|
||||
{
|
||||
if (string.Equals(step.MethodName, "StopOrEnlist", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -318,6 +446,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
WaitForTarget(client, step, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until a WMI object reaches the target state described by a plan step.
|
||||
/// </summary>
|
||||
/// <param name="client">The connected WMI client.</param>
|
||||
/// <param name="step">The step whose target state should be verified.</param>
|
||||
/// <param name="options">The runtime options controlling timeout and polling interval.</param>
|
||||
private void WaitForTarget(BizTalkWmiClient client, OperationStep step, OperationOptions options)
|
||||
{
|
||||
if (!step.TargetState.HasValue)
|
||||
@@ -344,6 +478,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and connects a WMI client for a server.
|
||||
/// </summary>
|
||||
/// <param name="server">The BizTalk server or management host to connect to.</param>
|
||||
/// <returns>A connected WMI client.</returns>
|
||||
private BizTalkWmiClient CreateClient(string server)
|
||||
{
|
||||
var client = new BizTalkWmiClient(server, _logger);
|
||||
@@ -351,16 +490,37 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new empty operation plan with common metadata.
|
||||
/// </summary>
|
||||
/// <param name="mode">The operation mode represented by the plan.</param>
|
||||
/// <param name="server">The target server stored in the plan metadata.</param>
|
||||
/// <returns>A new operation plan.</returns>
|
||||
private static OperationPlan NewPlan(OperationMode mode, string server)
|
||||
{
|
||||
return new OperationPlan
|
||||
{
|
||||
Mode = mode.ToString(),
|
||||
CreatedAt = DateTime.Now.ToString("s"),
|
||||
CreatedAt = DateTimeOffset.Now.ToString("o"),
|
||||
Server = server
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates one executable operation step.
|
||||
/// </summary>
|
||||
/// <param name="kind">The artifact kind displayed and serialized for the step.</param>
|
||||
/// <param name="application">The owning BizTalk application, when applicable.</param>
|
||||
/// <param name="name">The artifact or host instance name.</param>
|
||||
/// <param name="server">The server associated with the step, when applicable.</param>
|
||||
/// <param name="action">The human-readable action text.</param>
|
||||
/// <param name="wmiClass">The WMI class used to resolve the target object.</param>
|
||||
/// <param name="keyProperty">The WMI key property used for lookup.</param>
|
||||
/// <param name="keyValue">The WMI key value used for lookup.</param>
|
||||
/// <param name="methodName">The WMI method or pseudo-method to execute.</param>
|
||||
/// <param name="arguments">Optional numeric WMI method arguments.</param>
|
||||
/// <param name="targetState">Optional raw WMI state expected after execution.</param>
|
||||
/// <returns>A configured operation step.</returns>
|
||||
private static OperationStep Step(string kind, string application, string name, string server, string action, string wmiClass, string keyProperty, string keyValue, string methodName, int[] arguments, int? targetState)
|
||||
{
|
||||
return new OperationStep
|
||||
@@ -380,6 +540,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts integer method arguments into the object array required by WMI.
|
||||
/// </summary>
|
||||
/// <param name="values">The integer values from the operation step.</param>
|
||||
/// <returns>An object array suitable for ManagementObject.InvokeMethod.</returns>
|
||||
private static object[] ToObjects(int[] values)
|
||||
{
|
||||
if (values == null || values.Length == 0)
|
||||
@@ -395,6 +560,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an operator-facing description for one plan step.
|
||||
/// </summary>
|
||||
/// <param name="step">The step to describe.</param>
|
||||
/// <returns>A compact step description for logs and errors.</returns>
|
||||
private static string DescribeStep(OperationStep step)
|
||||
{
|
||||
if (step == null)
|
||||
@@ -412,6 +582,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
+ "]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an application snapshot bucket in a dictionary.
|
||||
/// </summary>
|
||||
/// <param name="apps">The application snapshot dictionary keyed by application name.</param>
|
||||
/// <param name="appName">The application name to resolve.</param>
|
||||
/// <returns>The existing or newly created application snapshot.</returns>
|
||||
private static ApplicationSnapshot GetApplication(Dictionary<string, ApplicationSnapshot> apps, string appName)
|
||||
{
|
||||
ApplicationSnapshot app;
|
||||
@@ -423,6 +599,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the application name from the available WMI properties.
|
||||
/// </summary>
|
||||
/// <param name="item">The WMI object being mapped into a snapshot item.</param>
|
||||
/// <returns>The discovered application name or a stable fallback.</returns>
|
||||
private static string GetApplicationName(ManagementObject item)
|
||||
{
|
||||
var names = new[] { "ApplicationName", "Application", "BizTalkApplication" };
|
||||
@@ -436,5 +617,24 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
return UnknownApplication;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt alle von einer WMI-Abfrage übernommenen Objekte deterministisch frei.
|
||||
/// </summary>
|
||||
/// <param name="items">Die freizugebenden WMI-Objekte oder <c>null</c>.</param>
|
||||
private static void DisposeAll(IEnumerable<ManagementObject> items)
|
||||
{
|
||||
if (items == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item != null)
|
||||
{
|
||||
item.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,53 @@ using System.Threading;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides the WMI access layer for BizTalk Server objects in root\MicrosoftBizTalkServer.
|
||||
/// </summary>
|
||||
public sealed class BizTalkWmiClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// BizTalk WMI namespace used for all platform management queries.
|
||||
/// </summary>
|
||||
private const string NamespacePath = "root\\MicrosoftBizTalkServer";
|
||||
|
||||
/// <summary>
|
||||
/// Target server for the WMI connection.
|
||||
/// </summary>
|
||||
private readonly string _server;
|
||||
|
||||
/// <summary>
|
||||
/// Logger used for WMI diagnostics and operation traces.
|
||||
/// </summary>
|
||||
private readonly OperationLogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Connected WMI management scope, created lazily or by Connect.
|
||||
/// </summary>
|
||||
private ManagementScope _scope;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new WMI client for the specified server.
|
||||
/// </summary>
|
||||
/// <param name="server">The target server name, or an empty value to use the local machine.</param>
|
||||
/// <param name="logger">The operation logger used for diagnostics and trace output.</param>
|
||||
public BizTalkWmiClient(string server, OperationLogger logger)
|
||||
{
|
||||
_server = string.IsNullOrWhiteSpace(server) ? Environment.MachineName : server.Trim();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalized target server name used by this client.
|
||||
/// </summary>
|
||||
public string Server
|
||||
{
|
||||
get { return _server; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the BizTalk WMI namespace on the target server.
|
||||
/// </summary>
|
||||
public void Connect()
|
||||
{
|
||||
var path = "\\\\" + _server + "\\" + NamespacePath;
|
||||
@@ -33,11 +62,22 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
_logger.Success("WMI connection established.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a broad WMI query for all instances of the requested BizTalk class.
|
||||
/// </summary>
|
||||
/// <param name="className">The WMI class name to query.</param>
|
||||
/// <returns>The matching WMI objects. The caller owns the returned objects.</returns>
|
||||
public List<ManagementObject> Query(string className)
|
||||
{
|
||||
return Query(className, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a broad WMI query and optionally logs the query text.
|
||||
/// </summary>
|
||||
/// <param name="className">The WMI class name to query.</param>
|
||||
/// <param name="logQuery">True to write the query to the operation log.</param>
|
||||
/// <returns>The matching WMI objects. The caller owns the returned objects.</returns>
|
||||
private List<ManagementObject> Query(string className, bool logQuery)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds one WMI object by comparing a property value client-side.
|
||||
/// </summary>
|
||||
/// <param name="className">The WMI class name to query.</param>
|
||||
/// <param name="propertyName">The property used as the lookup key.</param>
|
||||
/// <param name="value">The expected property value.</param>
|
||||
/// <returns>The matching object, or null when no object matches.</returns>
|
||||
public ManagementObject FindByProperty(string className, string propertyName, string value)
|
||||
{
|
||||
return FindByProperty(className, propertyName, value, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds one WMI object using SELECT * plus a client-side filter so special
|
||||
/// characters in BizTalk names cannot break a WQL WHERE clause.
|
||||
/// </summary>
|
||||
/// <param name="className">The WMI class name to query.</param>
|
||||
/// <param name="propertyName">The property used as the lookup key.</param>
|
||||
/// <param name="value">The expected property value.</param>
|
||||
/// <param name="logLookup">True to write the lookup details to the operation log.</param>
|
||||
/// <returns>The matching object, or null when no object matches.</returns>
|
||||
private ManagementObject FindByProperty(string className, string propertyName, string value, bool logLookup)
|
||||
{
|
||||
EnsureConnected();
|
||||
@@ -91,6 +151,8 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
|
||||
try
|
||||
{
|
||||
// Bewusst keine WQL-WHERE-Klausel: BizTalk-Namen können Zeichen enthalten,
|
||||
// die sonst eine fehlerhafte oder anders interpretierte Query erzeugen.
|
||||
ManagementObject match = null;
|
||||
var items = Query(className, false);
|
||||
foreach (var item in items)
|
||||
@@ -118,6 +180,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a WMI method and validates that the method returned success.
|
||||
/// </summary>
|
||||
/// <param name="instance">The WMI object on which the method should be called.</param>
|
||||
/// <param name="methodName">The method name to invoke.</param>
|
||||
/// <param name="arguments">Optional method arguments.</param>
|
||||
/// <returns>The WMI return code.</returns>
|
||||
public uint InvokeMethod(ManagementObject instance, string methodName, params object[] arguments)
|
||||
{
|
||||
if (instance == null)
|
||||
@@ -138,15 +207,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls one WMI object until it reaches the expected state or times out.
|
||||
/// </summary>
|
||||
/// <param name="className">The WMI class name to query.</param>
|
||||
/// <param name="keyProperty">The WMI key property used to find the object.</param>
|
||||
/// <param name="keyValue">The WMI key value used to find the object.</param>
|
||||
/// <param name="isReached">Predicate that returns true when the state is reached.</param>
|
||||
/// <param name="description">Human-readable state description used in logs and errors.</param>
|
||||
/// <param name="timeoutSeconds">Maximum number of seconds to wait.</param>
|
||||
/// <param name="pollIntervalSeconds">Number of seconds between polling attempts.</param>
|
||||
public void WaitForState(string className, string keyProperty, string keyValue, Func<ManagementObject, bool> isReached, string description, int timeoutSeconds, int pollIntervalSeconds)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(Math.Max(1, timeoutSeconds));
|
||||
@@ -163,12 +253,25 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(TimeSpan.FromSeconds(delay));
|
||||
var remaining = deadline - DateTime.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Am Timeout-Ende nur noch die tatsächlich verbleibende Zeit schlafen.
|
||||
Thread.Sleep(remaining < TimeSpan.FromSeconds(delay) ? remaining : TimeSpan.FromSeconds(delay));
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timeout while waiting for " + description + " [" + className + "." + keyProperty + "=" + keyValue + "]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a WMI property as a string without failing on missing or invalid properties.
|
||||
/// </summary>
|
||||
/// <param name="item">The WMI object or output parameter object.</param>
|
||||
/// <param name="propertyName">The property to read.</param>
|
||||
/// <param name="fallback">The value returned when the property cannot be read.</param>
|
||||
/// <returns>The property value or the fallback value.</returns>
|
||||
public static string SafeGetString(ManagementBaseObject item, string propertyName, string fallback)
|
||||
{
|
||||
try
|
||||
@@ -187,6 +290,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a WMI property as an integer without failing on missing or invalid properties.
|
||||
/// </summary>
|
||||
/// <param name="item">The WMI object or output parameter object.</param>
|
||||
/// <param name="propertyName">The property to read.</param>
|
||||
/// <param name="fallback">The value returned when the property cannot be read.</param>
|
||||
/// <returns>The property value or the fallback value.</returns>
|
||||
public static int SafeGetInt32(ManagementBaseObject item, string propertyName, int fallback)
|
||||
{
|
||||
try
|
||||
@@ -205,6 +315,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a WMI property as a Boolean without failing on missing or invalid properties.
|
||||
/// </summary>
|
||||
/// <param name="item">The WMI object or output parameter object.</param>
|
||||
/// <param name="propertyName">The property to read.</param>
|
||||
/// <param name="fallback">The value returned when the property cannot be read.</param>
|
||||
/// <returns>The property value or the fallback value.</returns>
|
||||
public static bool SafeGetBoolean(ManagementBaseObject item, string propertyName, bool fallback)
|
||||
{
|
||||
try
|
||||
@@ -223,6 +340,9 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the management scope is connected before a WMI operation runs.
|
||||
/// </summary>
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_scope == null || !_scope.IsConnected)
|
||||
@@ -231,6 +351,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a WMI object exposes a property, using case-insensitive comparison.
|
||||
/// </summary>
|
||||
/// <param name="item">The WMI object to inspect.</param>
|
||||
/// <param name="propertyName">The property name to find.</param>
|
||||
/// <returns>True when the property exists; otherwise false.</returns>
|
||||
private static bool HasProperty(ManagementBaseObject item, string propertyName)
|
||||
{
|
||||
foreach (PropertyData property in item.Properties)
|
||||
@@ -244,6 +370,14 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a WMI object matches a requested key value.
|
||||
/// </summary>
|
||||
/// <param name="item">The WMI object to inspect.</param>
|
||||
/// <param name="className">The WMI class name of the object.</param>
|
||||
/// <param name="propertyName">The requested key property.</param>
|
||||
/// <param name="expectedValue">The expected key value.</param>
|
||||
/// <returns>True when the object matches the requested value.</returns>
|
||||
private static bool MatchesProperty(ManagementBaseObject item, string className, string propertyName, string expectedValue)
|
||||
{
|
||||
foreach (var candidate in CandidatePropertyNames(className, propertyName))
|
||||
@@ -263,6 +397,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the primary and compatibility property names for a WMI lookup.
|
||||
/// </summary>
|
||||
/// <param name="className">The WMI class name being queried.</param>
|
||||
/// <param name="propertyName">The requested key property.</param>
|
||||
/// <returns>The candidate property names to inspect.</returns>
|
||||
private static IEnumerable<string> CandidatePropertyNames(string className, string propertyName)
|
||||
{
|
||||
yield return propertyName;
|
||||
@@ -274,6 +414,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a readable object name for diagnostics without letting WMI metadata failures escape.
|
||||
/// </summary>
|
||||
/// <param name="instance">The WMI object being described.</param>
|
||||
/// <returns>A relative WMI path or fallback object name.</returns>
|
||||
private static string SafeObjectName(ManagementObject instance)
|
||||
{
|
||||
try
|
||||
@@ -286,6 +431,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the exact method casing exposed by the WMI class.
|
||||
/// </summary>
|
||||
/// <param name="instance">The WMI object whose class should be inspected.</param>
|
||||
/// <param name="requestedName">The requested method name.</param>
|
||||
/// <returns>The method name as exposed by WMI.</returns>
|
||||
private static string ResolveMethodName(ManagementObject instance, string requestedName)
|
||||
{
|
||||
using (var managementClass = new ManagementClass(instance.Scope, new ManagementPath(instance.Path.ClassName), null))
|
||||
@@ -302,6 +453,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
throw new MissingMethodException(instance.Path.ClassName, requestedName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the WMI return code from either a scalar return value or output parameters.
|
||||
/// </summary>
|
||||
/// <param name="result">The object returned by ManagementObject.InvokeMethod.</param>
|
||||
/// <returns>The numeric WMI return code.</returns>
|
||||
private static uint ExtractReturnCode(object result)
|
||||
{
|
||||
if (result == null)
|
||||
@@ -318,8 +474,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return Convert.ToUInt32(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources owned by this client.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_scope = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,16 @@ using BizTalkPlatformManagementTool.Models;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes snapshot and diff data to CSV files for review outside the GUI.
|
||||
/// </summary>
|
||||
public static class CsvWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes application artifact states from a snapshot to a CSV file.
|
||||
/// </summary>
|
||||
/// <param name="path">The target CSV file path.</param>
|
||||
/// <param name="snapshot">The snapshot whose artifact states should be exported.</param>
|
||||
public static void WriteSnapshotArtifacts(string path, BizTalkSnapshot snapshot)
|
||||
{
|
||||
var lines = new List<string> { "Application,Type,Name,Status" };
|
||||
@@ -28,6 +36,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
File.WriteAllLines(path, lines, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes host instance states from a snapshot to a CSV file.
|
||||
/// </summary>
|
||||
/// <param name="path">The target CSV file path.</param>
|
||||
/// <param name="snapshot">The snapshot whose host instance states should be exported.</param>
|
||||
public static void WriteSnapshotHosts(string path, BizTalkSnapshot snapshot)
|
||||
{
|
||||
var lines = new List<string> { "InstanceName,HostName,Server,State" };
|
||||
@@ -38,6 +51,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
File.WriteAllLines(path, lines, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes snapshot differences to a CSV file.
|
||||
/// </summary>
|
||||
/// <param name="path">The target CSV file path.</param>
|
||||
/// <param name="diff">The diff model to export.</param>
|
||||
public static void WriteDiff(string path, SnapshotDiff diff)
|
||||
{
|
||||
var lines = new List<string> { "Scope,Application,Type,Name,Server,Before,After" };
|
||||
@@ -52,6 +70,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
File.WriteAllLines(path, lines, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds one CSV row from already ordered field values.
|
||||
/// </summary>
|
||||
/// <param name="values">The values that should be escaped and joined.</param>
|
||||
/// <returns>A single CSV row.</returns>
|
||||
private static string Row(params string[] values)
|
||||
{
|
||||
var escaped = new string[values.Length];
|
||||
@@ -62,9 +85,19 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return string.Join(",", escaped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a CSV field when it contains separators, quotes or line breaks.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw field value.</param>
|
||||
/// <returns>The CSV-safe field value.</returns>
|
||||
private static string Escape(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (value.Length > 0 && (value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t'))
|
||||
{
|
||||
// Tabellenkalkulationen dürfen exportierte Namen nicht als Formel ausführen.
|
||||
value = "'" + value;
|
||||
}
|
||||
if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0)
|
||||
{
|
||||
return value;
|
||||
|
||||
@@ -4,8 +4,16 @@ using BizTalkPlatformManagementTool.Models;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes HTML reports for BizTalk snapshots and snapshot differences.
|
||||
/// </summary>
|
||||
public static class HtmlReportWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes a complete HTML snapshot report.
|
||||
/// </summary>
|
||||
/// <param name="path">The target HTML file path.</param>
|
||||
/// <param name="snapshot">The snapshot to render.</param>
|
||||
public static void WriteSnapshot(string path, BizTalkSnapshot snapshot)
|
||||
{
|
||||
var html = new StringBuilder();
|
||||
@@ -50,6 +58,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete HTML diff report.
|
||||
/// </summary>
|
||||
/// <param name="path">The target HTML file path.</param>
|
||||
/// <param name="diff">The diff model to render.</param>
|
||||
public static void WriteDiff(string path, SnapshotDiff diff)
|
||||
{
|
||||
var html = new StringBuilder();
|
||||
@@ -74,12 +87,26 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
System.IO.File.WriteAllText(path, html.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one artifact status row to a report table.
|
||||
/// </summary>
|
||||
/// <param name="html">The report builder receiving the row markup.</param>
|
||||
/// <param name="name">The artifact name.</param>
|
||||
/// <param name="status">The formatted artifact status.</param>
|
||||
/// <param name="ok">True when the row should use the positive status style.</param>
|
||||
/// <param name="detail1">The first detail column value.</param>
|
||||
/// <param name="detail2">The second detail column value.</param>
|
||||
private static void StatusRow(StringBuilder html, string name, string status, bool ok, string detail1, string detail2)
|
||||
{
|
||||
html.Append("<tr><td>").Append(Encode(name)).Append("</td><td class='").Append(ok ? "ok" : "bad").Append("'>").Append(Encode(status))
|
||||
.Append("</td><td>").Append(Encode(detail1)).Append("</td><td>").Append(Encode(detail2)).Append("</td></tr>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the common document header, style block and title.
|
||||
/// </summary>
|
||||
/// <param name="html">The report builder receiving the header markup.</param>
|
||||
/// <param name="title">The document title and main heading.</param>
|
||||
private static void Header(StringBuilder html, string title)
|
||||
{
|
||||
html.Append("<!doctype html><html><head><meta charset='utf-8'><title>").Append(Encode(title)).Append("</title><style>")
|
||||
@@ -87,13 +114,23 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
.Append("</style></head><body><h1>").Append(Encode(title)).Append("</h1>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the common HTML document footer.
|
||||
/// </summary>
|
||||
/// <param name="html">The report builder receiving the footer markup.</param>
|
||||
private static void Footer(StringBuilder html)
|
||||
{
|
||||
html.Append("</body></html>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML-encodes report values and treats null values as empty text.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw value to encode.</param>
|
||||
/// <returns>An HTML-safe value.</returns>
|
||||
private static string Encode(string value)
|
||||
{
|
||||
// Alle aus BizTalk gelesenen Werte werden vor der Aufnahme in HTML neutralisiert.
|
||||
return WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Persists DataContract models as JSON with repository-defined encoding rules.
|
||||
/// </summary>
|
||||
public static class JsonFileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// UTF-8 encoding instance that writes JSON without a byte order mark.
|
||||
/// </summary>
|
||||
private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a value to a UTF-8 JSON file without a byte order mark.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The model type to serialize.</typeparam>
|
||||
/// <param name="path">The target JSON file path.</param>
|
||||
/// <param name="value">The value to serialize.</param>
|
||||
public static void Save<T>(string path, T value)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a JSON file into the requested DataContract model type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The model type to deserialize.</typeparam>
|
||||
/// <param name="path">The JSON file path to load.</param>
|
||||
/// <returns>The deserialized model.</returns>
|
||||
public static T Load<T>(string path)
|
||||
{
|
||||
var serializer = new DataContractJsonSerializer(typeof(T), new DataContractJsonSerializerSettings
|
||||
@@ -46,6 +72,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes byte order mark variants that may exist in previously written files.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw JSON text read from disk.</param>
|
||||
/// <returns>The JSON text without a leading BOM marker.</returns>
|
||||
private static string NormalizeJson(string json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json))
|
||||
@@ -66,6 +97,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a short diagnostic message for a JSON deserialization failure.
|
||||
/// </summary>
|
||||
/// <param name="json">The normalized JSON text that failed to deserialize.</param>
|
||||
/// <returns>A diagnostic suffix describing the beginning of the JSON content.</returns>
|
||||
private static string DescribeJsonStart(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
@@ -82,5 +118,89 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
|
||||
return "The file starts with valid JSON syntax but could not be deserialized into the expected model.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file through a same-directory temporary file so an interrupted
|
||||
/// save cannot leave a truncated snapshot or operation plan behind.
|
||||
/// </summary>
|
||||
/// <param name="path">Der endgültige Zielpfad.</param>
|
||||
/// <param name="content">Der vollständig serialisierte Dateiinhalt.</param>
|
||||
private static void WriteAtomically(string path, string content)
|
||||
{
|
||||
// Temporärdatei und Backup liegen absichtlich im Zielverzeichnis. Dadurch bleiben
|
||||
// Umbenennung und Austausch auf demselben Volume und können atomar erfolgen.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt eine vorhandene Datei über Umbenennungen, wenn <see cref="File.Replace(string, string, string, bool)"/> nicht unterstützt wird.
|
||||
/// </summary>
|
||||
/// <param name="path">Der endgültige Zielpfad.</param>
|
||||
/// <param name="temporaryPath">Die vollständig geschriebene Temporärdatei.</param>
|
||||
/// <param name="backupPath">Der temporäre Sicherungspfad der vorherigen Datei.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löscht eine temporäre Datei bestmöglich, ohne das primäre Speicherergebnis zu verändern.
|
||||
/// </summary>
|
||||
/// <param name="path">Der zu löschende Dateipfad.</param>
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Die Bereinigung ist nachrangig und darf einen erfolgreichen Schreibvorgang nicht verdecken.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,39 +5,107 @@ using System.Threading;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the severity used for visible and file-based operation log entries.
|
||||
/// </summary>
|
||||
public enum LogLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Informational progress or diagnostic entry.
|
||||
/// </summary>
|
||||
Info,
|
||||
|
||||
/// <summary>
|
||||
/// Non-fatal condition that needs operator attention.
|
||||
/// </summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>
|
||||
/// Failed operation or exception entry.
|
||||
/// </summary>
|
||||
Error,
|
||||
|
||||
/// <summary>
|
||||
/// Successful operation entry.
|
||||
/// </summary>
|
||||
Success
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents one operation log entry displayed in the GUI and written to disk.
|
||||
/// </summary>
|
||||
public sealed class LogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the local time when the entry was created.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entry severity.
|
||||
/// </summary>
|
||||
public LogLevel Level { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the operator-facing message.
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes operation log entries to a daily rolling file and an optional UI sink.
|
||||
/// </summary>
|
||||
public sealed class OperationLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Prefix used for daily log files written to the resolved log directory.
|
||||
/// </summary>
|
||||
private const string LogFilePrefix = "BizTalkPlatformManagementTool-";
|
||||
|
||||
/// <summary>
|
||||
/// File extension used for operation log files.
|
||||
/// </summary>
|
||||
private const string LogFileExtension = ".log";
|
||||
|
||||
/// <summary>
|
||||
/// Number of daily log files retained, including the current day.
|
||||
/// </summary>
|
||||
private const int RetentionDays = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide lock that serializes log file appends.
|
||||
/// </summary>
|
||||
private static readonly object FileLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide flag that ensures log cleanup runs only once.
|
||||
/// </summary>
|
||||
private static int _cleanupDone;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback for forwarding entries to the UI.
|
||||
/// </summary>
|
||||
private readonly Action<LogEntry> _sink;
|
||||
|
||||
/// <summary>
|
||||
/// Directory where daily log files are written.
|
||||
/// </summary>
|
||||
private readonly string _logDirectory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new logger that writes beside the executable.
|
||||
/// </summary>
|
||||
/// <param name="sink">Optional callback that receives entries for display.</param>
|
||||
public OperationLogger(Action<LogEntry> sink)
|
||||
{
|
||||
_sink = sink;
|
||||
_logDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
||||
_logDirectory = ResolveLogDirectory();
|
||||
CleanupOldLogs();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path of the daily log file for the current date.
|
||||
/// </summary>
|
||||
public string LogFilePath
|
||||
{
|
||||
get
|
||||
@@ -46,26 +114,47 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an informational entry.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
public void Info(string message)
|
||||
{
|
||||
Write(LogLevel.Info, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a warning entry.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
public void Warning(string message)
|
||||
{
|
||||
Write(LogLevel.Warning, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an error entry.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
public void Error(string message)
|
||||
{
|
||||
Write(LogLevel.Error, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a success entry.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
public void Success(string message)
|
||||
{
|
||||
Write(LogLevel.Success, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a log entry and sends it to both output targets.
|
||||
/// </summary>
|
||||
/// <param name="level">The entry severity.</param>
|
||||
/// <param name="message">The message to log.</param>
|
||||
private void Write(LogLevel level, string message)
|
||||
{
|
||||
var entry = new LogEntry
|
||||
@@ -85,6 +174,10 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
_sink(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one entry to the current daily log file.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to write.</param>
|
||||
private void WriteToFile(LogEntry entry)
|
||||
{
|
||||
try
|
||||
@@ -104,10 +197,13 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging must never interrupt BizTalk operations.
|
||||
// Ein Logfehler darf niemals eine fachliche BizTalk-Operation abbrechen.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes log files older than the configured retention window.
|
||||
/// </summary>
|
||||
private void CleanupOldLogs()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _cleanupDone, 1) == 1)
|
||||
@@ -129,7 +225,26 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Log retention cleanup is best-effort.
|
||||
// Die Aufbewahrungsbereinigung ist bestmöglich und beeinflusst den aktuellen Lauf nicht.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt das bevorzugte maschinenweite Logverzeichnis mit Rückfall auf das EXE-Verzeichnis.
|
||||
/// </summary>
|
||||
/// <returns>Ein verwendbares Verzeichnis für die täglichen Laufzeitlogs.</returns>
|
||||
private static string ResolveLogDirectory()
|
||||
{
|
||||
var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||
var preferred = Path.Combine(commonData, "BizTalkPlatformManagementTool", "Logs");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(preferred);
|
||||
return preferred;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return AppDomain.CurrentDomain.BaseDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,26 @@ using BizTalkPlatformManagementTool.Models;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares two BizTalk snapshots and returns the state differences that matter
|
||||
/// after a maintenance window.
|
||||
/// </summary>
|
||||
public static class SnapshotComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares all supported artifact and host instance states.
|
||||
/// </summary>
|
||||
/// <param name="before">The snapshot captured before maintenance.</param>
|
||||
/// <param name="after">The snapshot captured after maintenance.</param>
|
||||
/// <returns>A diff containing changed, new and missing artifacts.</returns>
|
||||
public static SnapshotDiff Compare(BizTalkSnapshot before, BizTalkSnapshot after)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds receive location differences to the shared diff model.
|
||||
/// </summary>
|
||||
/// <param name="diff">The diff model receiving the entries.</param>
|
||||
/// <param name="before">Receive locations keyed by name from the before snapshot.</param>
|
||||
/// <param name="after">Receive locations keyed by name from the after snapshot.</param>
|
||||
private static void CompareReceiveLocations(SnapshotDiff diff, Dictionary<string, ReceiveLocationState> before, Dictionary<string, ReceiveLocationState> after)
|
||||
{
|
||||
foreach (var pair in after)
|
||||
@@ -42,6 +64,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds send port differences to the shared diff model.
|
||||
/// </summary>
|
||||
/// <param name="diff">The diff model receiving the entries.</param>
|
||||
/// <param name="before">Send ports keyed by name from the before snapshot.</param>
|
||||
/// <param name="after">Send ports keyed by name from the after snapshot.</param>
|
||||
private static void CompareSendPorts(SnapshotDiff diff, Dictionary<string, SendPortState> before, Dictionary<string, SendPortState> after)
|
||||
{
|
||||
foreach (var pair in after)
|
||||
@@ -66,6 +94,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds orchestration differences to the shared diff model.
|
||||
/// </summary>
|
||||
/// <param name="diff">The diff model receiving the entries.</param>
|
||||
/// <param name="before">Orchestrations keyed by name from the before snapshot.</param>
|
||||
/// <param name="after">Orchestrations keyed by name from the after snapshot.</param>
|
||||
private static void CompareOrchestrations(SnapshotDiff diff, Dictionary<string, OrchestrationState> before, Dictionary<string, OrchestrationState> after)
|
||||
{
|
||||
foreach (var pair in after)
|
||||
@@ -90,6 +124,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds host instance differences to the shared diff model.
|
||||
/// </summary>
|
||||
/// <param name="diff">The diff model receiving the entries.</param>
|
||||
/// <param name="before">Host instances from the before snapshot.</param>
|
||||
/// <param name="after">Host instances from the after snapshot.</param>
|
||||
private static void CompareHostInstances(SnapshotDiff diff, List<HostInstanceState> before, List<HostInstanceState> after)
|
||||
{
|
||||
var beforeMap = new Dictionary<string, HostInstanceState>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -97,11 +137,12 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
|
||||
foreach (var item in before)
|
||||
{
|
||||
beforeMap[item.InstanceName ?? string.Empty] = item;
|
||||
// Hostname allein ist gruppenweit nicht eindeutig; der Server gehört zur Identität.
|
||||
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 +167,15 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds one artifact diff entry.
|
||||
/// </summary>
|
||||
/// <param name="diff">The diff model receiving the entry.</param>
|
||||
/// <param name="application">The BizTalk application name.</param>
|
||||
/// <param name="type">The artifact type.</param>
|
||||
/// <param name="name">The artifact name.</param>
|
||||
/// <param name="before">The formatted before state.</param>
|
||||
/// <param name="after">The formatted after state.</param>
|
||||
private static void AddArtifact(SnapshotDiff diff, string application, string type, string name, string before, string after)
|
||||
{
|
||||
diff.ArtifactDifferences.Add(new ArtifactDiffEntry
|
||||
@@ -138,6 +188,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens receive locations from all applications into a case-insensitive name map.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The snapshot to flatten.</param>
|
||||
/// <returns>A map keyed by receive location name.</returns>
|
||||
private static Dictionary<string, ReceiveLocationState> FlattenReceiveLocations(BizTalkSnapshot snapshot)
|
||||
{
|
||||
var map = new Dictionary<string, ReceiveLocationState>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -145,12 +200,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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens send ports from all applications into a case-insensitive name map.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The snapshot to flatten.</param>
|
||||
/// <returns>A map keyed by send port name.</returns>
|
||||
private static Dictionary<string, SendPortState> FlattenSendPorts(BizTalkSnapshot snapshot)
|
||||
{
|
||||
var map = new Dictionary<string, SendPortState>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -158,12 +218,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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens orchestrations from all applications into a case-insensitive name map.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The snapshot to flatten.</param>
|
||||
/// <returns>A map keyed by orchestration name.</returns>
|
||||
private static Dictionary<string, OrchestrationState> FlattenOrchestrations(BizTalkSnapshot snapshot)
|
||||
{
|
||||
var map = new Dictionary<string, OrchestrationState>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -171,7 +236,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;
|
||||
|
||||
@@ -3,8 +3,16 @@ using BizTalkPlatformManagementTool.Models;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves snapshot and diff models together with their sidecar report formats.
|
||||
/// </summary>
|
||||
public static class SnapshotStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a snapshot as JSON and creates CSV, host CSV and HTML sidecars.
|
||||
/// </summary>
|
||||
/// <param name="jsonPath">The primary JSON output path.</param>
|
||||
/// <param name="snapshot">The snapshot to persist.</param>
|
||||
public static void SaveSnapshotSet(string jsonPath, BizTalkSnapshot snapshot)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath)));
|
||||
@@ -14,6 +22,11 @@ namespace BizTalkPlatformManagementTool.Services
|
||||
HtmlReportWriter.WriteSnapshot(jsonPath + ".html", snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a diff as JSON and creates CSV and HTML sidecars.
|
||||
/// </summary>
|
||||
/// <param name="jsonPath">The primary JSON output path.</param>
|
||||
/// <param name="diff">The diff model to persist.</param>
|
||||
public static void SaveDiffSet(string jsonPath, SnapshotDiff diff)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(jsonPath)));
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BizTalkPlatformManagementTool.Models;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes deserialized legacy snapshots and rejects ambiguous or unsafe input.
|
||||
/// </summary>
|
||||
public static class SnapshotValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalisiert optionale Sammlungen und weist fehlende oder doppelte Artefaktidentitäten zurück.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Der zu normalisierende und zu validierende Snapshot.</param>
|
||||
public static void Validate(BizTalkSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null)
|
||||
{
|
||||
throw new InvalidOperationException("The snapshot is empty.");
|
||||
}
|
||||
|
||||
snapshot.Applications = snapshot.Applications ?? new List<ApplicationSnapshot>();
|
||||
snapshot.HostInstances = snapshot.HostInstances ?? new List<HostInstanceState>();
|
||||
|
||||
var receiveLocations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var sendPorts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var orchestrations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var app in snapshot.Applications)
|
||||
{
|
||||
if (app == null || string.IsNullOrWhiteSpace(app.Application))
|
||||
{
|
||||
throw new InvalidOperationException("The snapshot contains an application without a name.");
|
||||
}
|
||||
|
||||
app.ReceiveLocations = app.ReceiveLocations ?? new List<ReceiveLocationState>();
|
||||
app.SendPorts = app.SendPorts ?? new List<SendPortState>();
|
||||
app.Orchestrations = app.Orchestrations ?? new List<OrchestrationState>();
|
||||
|
||||
ValidateArtifacts(app.Application, "receive location", app.ReceiveLocations, x => x == null ? null : x.Name, receiveLocations);
|
||||
ValidateArtifacts(app.Application, "send port", app.SendPorts, x => x == null ? null : x.Name, sendPorts);
|
||||
ValidateArtifacts(app.Application, "orchestration", app.Orchestrations, x => x == null ? null : x.Name, orchestrations);
|
||||
}
|
||||
|
||||
var hostInstances = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var host in snapshot.HostInstances)
|
||||
{
|
||||
if (host == null || string.IsNullOrWhiteSpace(host.InstanceName))
|
||||
{
|
||||
throw new InvalidOperationException("The snapshot contains a host instance without an instance name.");
|
||||
}
|
||||
if (!hostInstances.Add(host.InstanceName))
|
||||
{
|
||||
throw new InvalidOperationException("The snapshot contains the host instance more than once: " + host.InstanceName);
|
||||
}
|
||||
host.StateText = ArtifactStates.FormatHostInstance(host.RawState);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validiert einen Snapshot und stellt sicher, dass er zum angeforderten Zielserver gehört.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Der als Operationsgrundlage verwendete Snapshot.</param>
|
||||
/// <param name="targetServer">Der für die Operation ausgewählte BizTalk-Server.</param>
|
||||
public static void EnsureServerMatches(BizTalkSnapshot snapshot, string targetServer)
|
||||
{
|
||||
Validate(snapshot);
|
||||
if (string.IsNullOrWhiteSpace(snapshot.Server) || string.IsNullOrWhiteSpace(targetServer))
|
||||
{
|
||||
throw new InvalidOperationException("Snapshot server and target server must both be specified before a restore plan can be created.");
|
||||
}
|
||||
if (!ServerNamesEqual(snapshot.Server, targetServer))
|
||||
{
|
||||
throw new InvalidOperationException("The snapshot belongs to server '" + snapshot.Server + "' but the selected restore target is '" + targetServer + "'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht Servernamen und akzeptiert Kurzname und FQDN desselben Hosts als identisch.
|
||||
/// </summary>
|
||||
/// <param name="left">Der erste Servername.</param>
|
||||
/// <param name="right">Der zweite Servername.</param>
|
||||
/// <returns><c>true</c>, wenn beide Namen denselben Server bezeichnen; andernfalls <c>false</c>.</returns>
|
||||
public static bool ServerNamesEqual(string left, string right)
|
||||
{
|
||||
var normalizedLeft = NormalizeServer(left);
|
||||
var normalizedRight = NormalizeServer(right);
|
||||
if (string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(ShortName(normalizedLeft), ShortName(normalizedRight), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt die kollisionsarme Identität für anwendungsbezogene BizTalk-Artefakte.
|
||||
/// </summary>
|
||||
/// <param name="application">Der Name der BizTalk-Anwendung.</param>
|
||||
/// <param name="name">Der Artefaktname.</param>
|
||||
/// <returns>Ein zusammengesetzter Schlüssel aus Anwendung und Artefaktname.</returns>
|
||||
public static string ArtifactKey(string application, string name)
|
||||
{
|
||||
// Das nicht druckbare Trennzeichen kann in normalen BizTalk-Namen nicht mit der
|
||||
// sichtbaren Verkettung von Anwendung und Artefakt verwechselt werden.
|
||||
return (application ?? string.Empty).Trim() + "\u001f" + (name ?? string.Empty).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft eine Artefaktsammlung auf leere Namen und doppelte Identitäten.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Der Typ des zu prüfenden Snapshot-Artefakts.</typeparam>
|
||||
/// <param name="application">Die besitzende BizTalk-Anwendung.</param>
|
||||
/// <param name="type">Die lesbare Artefaktbezeichnung für Fehlermeldungen.</param>
|
||||
/// <param name="values">Die zu prüfenden Artefakte.</param>
|
||||
/// <param name="getName">Funktion zum Ermitteln des Artefaktnamens.</param>
|
||||
/// <param name="keys">Die bereits bekannten Identitäten dieses Artefakttyps.</param>
|
||||
private static void ValidateArtifacts<T>(string application, string type, IEnumerable<T> values, Func<T, string> getName, HashSet<string> keys)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
var name = getName(value);
|
||||
if (value == null || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new InvalidOperationException("Application '" + application + "' contains a " + type + " without a name.");
|
||||
}
|
||||
var key = ArtifactKey(application, name);
|
||||
if (!keys.Add(key))
|
||||
{
|
||||
throw new InvalidOperationException("Application '" + application + "' contains the " + type + " more than once: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalisiert lokale Serveraliasnamen auf den tatsächlichen Rechnernamen.
|
||||
/// </summary>
|
||||
/// <param name="value">Der eingegebene Servername.</param>
|
||||
/// <returns>Der getrimmte und normalisierte Servername.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entfernt den DNS-Suffix eines Servernamens.
|
||||
/// </summary>
|
||||
/// <param name="value">Ein normalisierter Kurzname oder FQDN.</param>
|
||||
/// <returns>Der Hostanteil vor dem ersten Punkt.</returns>
|
||||
private static string ShortName(string value)
|
||||
{
|
||||
var index = value.IndexOf('.');
|
||||
return index < 0 ? value : value.Substring(0, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,31 +9,125 @@ using BizTalkPlatformManagementTool.Services;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Ui
|
||||
{
|
||||
/// <summary>
|
||||
/// Main WinForms surface for diagnosing, snapshotting, comparing, shutting down
|
||||
/// and restoring a BizTalk platform state.
|
||||
/// </summary>
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
/// <summary>
|
||||
/// Text input for the BizTalk server or management host.
|
||||
/// </summary>
|
||||
private readonly TextBox _serverTextBox = new TextBox();
|
||||
|
||||
/// <summary>
|
||||
/// Text input for the directory that receives snapshots, plans and reports.
|
||||
/// </summary>
|
||||
private readonly TextBox _outputTextBox = new TextBox();
|
||||
|
||||
/// <summary>
|
||||
/// Text input for the restore state file name or path.
|
||||
/// </summary>
|
||||
private readonly TextBox _stateFileTextBox = new TextBox();
|
||||
|
||||
/// <summary>
|
||||
/// Numeric input for the maximum wait time of runtime state changes.
|
||||
/// </summary>
|
||||
private readonly NumericUpDown _timeoutInput = new NumericUpDown();
|
||||
|
||||
/// <summary>
|
||||
/// Numeric input for the WMI polling interval.
|
||||
/// </summary>
|
||||
private readonly NumericUpDown _pollInput = new NumericUpDown();
|
||||
|
||||
/// <summary>
|
||||
/// Checkbox that keeps shutdown and restore operations in dry-run mode.
|
||||
/// </summary>
|
||||
private readonly CheckBox _dryRunCheckBox = new CheckBox();
|
||||
|
||||
/// <summary>
|
||||
/// Grid used to show snapshots, diffs and operation plans.
|
||||
/// </summary>
|
||||
private readonly DataGridView _statusGrid = new DataGridView();
|
||||
|
||||
/// <summary>
|
||||
/// Grid used to show operation log entries.
|
||||
/// </summary>
|
||||
private readonly DataGridView _logGrid = new DataGridView();
|
||||
|
||||
/// <summary>
|
||||
/// Status strip at the bottom of the form.
|
||||
/// </summary>
|
||||
private readonly StatusStrip _statusStrip = new StatusStrip();
|
||||
|
||||
/// <summary>
|
||||
/// Text label inside the status strip.
|
||||
/// </summary>
|
||||
private readonly ToolStripStatusLabel _statusLabel = new ToolStripStatusLabel();
|
||||
|
||||
/// <summary>
|
||||
/// Header indicator derived from the latest host instance snapshot.
|
||||
/// </summary>
|
||||
private readonly Label _environmentStatusLabel = new Label();
|
||||
|
||||
/// <summary>
|
||||
/// Logger that writes to disk and mirrors entries into the UI log grid.
|
||||
/// </summary>
|
||||
private readonly OperationLogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Service that performs all BizTalk runtime operations.
|
||||
/// </summary>
|
||||
private readonly BizTalkOperationService _service;
|
||||
|
||||
/// <summary>
|
||||
/// Button that validates WMI access.
|
||||
/// </summary>
|
||||
private Button _diagnoseButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that creates the before snapshot.
|
||||
/// </summary>
|
||||
private Button _beforeButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that creates the after snapshot.
|
||||
/// </summary>
|
||||
private Button _afterButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that compares before and after snapshots.
|
||||
/// </summary>
|
||||
private Button _compareButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that creates and executes the shutdown plan.
|
||||
/// </summary>
|
||||
private Button _shutdownButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that creates and executes the restore plan.
|
||||
/// </summary>
|
||||
private Button _restoreButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that clears visible grids and status.
|
||||
/// </summary>
|
||||
private Button _clearButton;
|
||||
|
||||
/// <summary>
|
||||
/// Button that closes the application.
|
||||
/// </summary>
|
||||
private Button _closeButton;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that a background operation is still active.
|
||||
/// </summary>
|
||||
private bool _isBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the form, operation services and visual controls.
|
||||
/// </summary>
|
||||
public MainForm()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the root form layout and adds settings, action, result and status areas.
|
||||
/// </summary>
|
||||
private void BuildUi()
|
||||
{
|
||||
var root = new TableLayoutPanel
|
||||
@@ -72,6 +170,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
root.Controls.Add(_statusStrip, 0, 3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the settings panel with server, output, state file and timing inputs.
|
||||
/// </summary>
|
||||
/// <returns>The configured settings panel.</returns>
|
||||
private Control BuildSettingsPanel()
|
||||
{
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 10, RowCount = 2 };
|
||||
@@ -140,6 +242,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
return panel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the toolbar-like action panel.
|
||||
/// </summary>
|
||||
/// <returns>The configured action panel.</returns>
|
||||
private Control BuildActionPanel()
|
||||
{
|
||||
var panel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(0, 8, 0, 0), WrapContents = false };
|
||||
@@ -163,6 +269,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
return panel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the tab control containing status/results and operation logs.
|
||||
/// </summary>
|
||||
/// <returns>The configured tab control.</returns>
|
||||
private Control BuildTabs()
|
||||
{
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
@@ -189,12 +299,22 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Diagnose button click.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void DiagnoseClick(object sender, EventArgs e)
|
||||
{
|
||||
var server = _serverTextBox.Text.Trim();
|
||||
RunAsync("Diagnosing WMI access...", () => _service.Diagnose(server));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Snapshot Before button click.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void BeforeClick(object sender, EventArgs e)
|
||||
{
|
||||
var options = GetOptions();
|
||||
@@ -206,6 +326,11 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Snapshot After button click.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void AfterClick(object sender, EventArgs e)
|
||||
{
|
||||
var options = GetOptions();
|
||||
@@ -217,6 +342,11 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Compare button click.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void CompareClick(object sender, EventArgs e)
|
||||
{
|
||||
var options = GetOptions();
|
||||
@@ -230,21 +360,27 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Shutdown button click and runs the guarded shutdown workflow.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void ShutdownClick(object sender, EventArgs e)
|
||||
{
|
||||
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);
|
||||
// Der exakte, frisch erzeugte Plan wird vor Bestätigung und jeder Laufzeitänderung gespeichert.
|
||||
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 +391,26 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Restore button click and runs the guarded restore workflow.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void RestoreClick(object sender, EventArgs e)
|
||||
{
|
||||
if (!ConfirmDangerousAction("Restore"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = GetOptions();
|
||||
RunAsync("Preparing restore...", () =>
|
||||
{
|
||||
var snapshot = JsonFileStore.Load<BizTalkSnapshot>(ResolveStateFile(options));
|
||||
var plan = _service.CreateRestorePlan(snapshot, options.Server);
|
||||
_service.SavePlan(options.OutputDirectory, "restore-plan.json", plan);
|
||||
// Auch beim Restore bestätigt der Bediener genau den bereits auditierbar gespeicherten 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 +421,11 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Clear button click by removing visible state without deleting files.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void ClearClick(object sender, EventArgs e)
|
||||
{
|
||||
_statusGrid.Rows.Clear();
|
||||
@@ -287,11 +434,21 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
_statusLabel.Text = "Ready.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Close button click.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void CloseClick(object sender, EventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs long-running BizTalk work on a background task and keeps the UI responsive.
|
||||
/// </summary>
|
||||
/// <param name="status">The status text shown while the work is running.</param>
|
||||
/// <param name="work">The work to execute on the background task.</param>
|
||||
private void RunAsync(string status, Action work)
|
||||
{
|
||||
SetBusy(true, status);
|
||||
@@ -311,6 +468,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and normalizes the current UI options.
|
||||
/// </summary>
|
||||
/// <returns>The runtime options selected by the user.</returns>
|
||||
private OperationOptions GetOptions()
|
||||
{
|
||||
var server = string.IsNullOrWhiteSpace(_serverTextBox.Text) ? Environment.MachineName : _serverTextBox.Text.Trim();
|
||||
@@ -330,27 +491,55 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a restore state file against the output directory when it is relative.
|
||||
/// </summary>
|
||||
/// <param name="options">The options containing the state file and output directory.</param>
|
||||
/// <returns>The absolute or output-relative state file path.</returns>
|
||||
private string ResolveStateFile(OperationOptions options)
|
||||
{
|
||||
return Path.IsPathRooted(options.StateFile) ? options.StateFile : Path.Combine(options.OutputDirectory, options.StateFile);
|
||||
}
|
||||
|
||||
private bool ConfirmDangerousAction(string actionName)
|
||||
/// <summary>
|
||||
/// Confirms a fully prepared runtime-changing plan immediately before execution.
|
||||
/// </summary>
|
||||
/// <param name="actionName">The action name displayed in the confirmation dialog.</param>
|
||||
/// <param name="plan">Der vollständig vorbereitete und gespeicherte Operationsplan.</param>
|
||||
/// <param name="server">Der Zielserver der geplanten Änderung.</param>
|
||||
/// <param name="planPath">Der Pfad der bereits gespeicherten Plandatei.</param>
|
||||
/// <returns>True when the action may continue; otherwise false.</returns>
|
||||
private bool ConfirmPreparedPlan(string actionName, OperationPlan plan, string server, string planPath)
|
||||
{
|
||||
if (_dryRunCheckBox.Checked)
|
||||
var confirmed = false;
|
||||
Action showConfirmation = () =>
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var executableSteps = plan.Steps.Count(x => x.Execute);
|
||||
var result = MessageBox.Show(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a snapshot in the status grid and updates the environment indicator.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The snapshot to display.</param>
|
||||
private void ShowSnapshot(BizTalkSnapshot snapshot)
|
||||
{
|
||||
InvokeIfRequired(() =>
|
||||
@@ -379,6 +568,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays snapshot differences in the status grid.
|
||||
/// </summary>
|
||||
/// <param name="diff">The diff to display.</param>
|
||||
private void ShowDiff(SnapshotDiff diff)
|
||||
{
|
||||
InvokeIfRequired(() =>
|
||||
@@ -395,6 +588,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays an operation plan in the status grid before or during execution.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to display.</param>
|
||||
private void ShowPlan(OperationPlan plan)
|
||||
{
|
||||
InvokeIfRequired(() =>
|
||||
@@ -407,6 +604,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one operation log entry to the log grid.
|
||||
/// </summary>
|
||||
/// <param name="entry">The log entry to display.</param>
|
||||
private void AppendLog(LogEntry entry)
|
||||
{
|
||||
InvokeIfRequired(() =>
|
||||
@@ -429,10 +630,16 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables action buttons and updates the status strip.
|
||||
/// </summary>
|
||||
/// <param name="busy">True while a background operation is running.</param>
|
||||
/// <param name="status">The status text to display.</param>
|
||||
private void SetBusy(bool busy, string status)
|
||||
{
|
||||
InvokeIfRequired(() =>
|
||||
{
|
||||
_isBusy = busy;
|
||||
_diagnoseButton.Enabled = !busy;
|
||||
_beforeButton.Enabled = !busy;
|
||||
_afterButton.Enabled = !busy;
|
||||
@@ -445,6 +652,11 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the output directory Browse button click.
|
||||
/// </summary>
|
||||
/// <param name="sender">The control that raised the event.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void BrowseButtonClick(object sender, EventArgs e)
|
||||
{
|
||||
using (var dialog = new FolderBrowserDialog())
|
||||
@@ -457,16 +669,27 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a UI update on the UI thread when required.
|
||||
/// </summary>
|
||||
/// <param name="action">The UI action to execute.</param>
|
||||
private void InvokeIfRequired(Action action)
|
||||
{
|
||||
if (IsDisposed)
|
||||
if (IsDisposed || Disposing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (InvokeRequired)
|
||||
{
|
||||
BeginInvoke(action);
|
||||
try
|
||||
{
|
||||
BeginInvoke(action);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Das Formular wurde zwischen Zustandsprüfung und BeginInvoke geschlossen.
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -474,11 +697,43 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prevents the form from being disposed while a maintenance operation is active.
|
||||
/// </summary>
|
||||
/// <param name="sender">Das Formular, das geschlossen werden soll.</param>
|
||||
/// <param name="e">Die abbrechbaren Argumente des Schließereignisses.</param>
|
||||
private void MainFormClosing(object sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (!_isBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"A BizTalk operation is still running. Wait for it to finish before closing the tool.",
|
||||
"Operation in progress",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a right-aligned label for form inputs.
|
||||
/// </summary>
|
||||
/// <param name="text">The label text.</param>
|
||||
/// <returns>The configured label control.</returns>
|
||||
private static Label Label(string text)
|
||||
{
|
||||
return new Label { Text = text, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleRight, Margin = new Padding(0, 5, 4, 5) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard action button and attaches its click handler.
|
||||
/// </summary>
|
||||
/// <param name="text">The button text.</param>
|
||||
/// <param name="handler">The click event handler.</param>
|
||||
/// <returns>The configured button.</returns>
|
||||
private static Button ActionButton(string text, EventHandler handler)
|
||||
{
|
||||
var button = new Button { Text = text, Width = 112, Height = 34, Margin = new Padding(0, 0, 8, 0) };
|
||||
@@ -486,12 +741,20 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies common docking and spacing to an input control.
|
||||
/// </summary>
|
||||
/// <param name="control">The control to configure.</param>
|
||||
private static void ConfigureInput(Control control)
|
||||
{
|
||||
control.Dock = DockStyle.Fill;
|
||||
control.Margin = new Padding(4, 6, 8, 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the environment indicator from host instance states in the latest snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The latest snapshot, or null when the state is unknown.</param>
|
||||
private void UpdateEnvironmentStatus(BizTalkSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null || snapshot.HostInstances == null || snapshot.HostInstances.Count == 0)
|
||||
@@ -526,6 +789,10 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies standard read-only display settings to a grid.
|
||||
/// </summary>
|
||||
/// <param name="grid">The grid to configure.</param>
|
||||
private static void ConfigureGrid(DataGridView grid)
|
||||
{
|
||||
grid.Dock = DockStyle.Fill;
|
||||
@@ -538,6 +805,11 @@ namespace BizTalkPlatformManagementTool.Ui
|
||||
grid.BackgroundColor = SystemColors.Window;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats an exception chain into a concise message for the operation log.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to format.</param>
|
||||
/// <returns>A message containing the top-level and unique inner exception messages.</returns>
|
||||
private static string FormatException(Exception ex)
|
||||
{
|
||||
if (ex == null)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="2.1.2.0" name="BizTalkPlatformManagementTool" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
|
||||
<PropertyGroup><Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration><Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform><ProjectGuid>{318F4307-F62C-47C9-9B90-F0C9BF2F812A}</ProjectGuid><OutputType>Exe</OutputType><RootNamespace>BizTalkPlatformManagementTool.Tests</RootNamespace><AssemblyName>BizTalkPlatformManagementTool.Tests</AssemblyName><TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion><FileAlignment>512</FileAlignment><Deterministic>true</Deterministic></PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "><DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize><OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "><DebugType>pdbonly</DebugType><Optimize>true</Optimize><OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel><DocumentationFile>bin\Release\BizTalkPlatformManagementTool.Tests.xml</DocumentationFile></PropertyGroup>
|
||||
<ItemGroup><Reference Include="System" /><Reference Include="System.Core" /></ItemGroup>
|
||||
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\BizTalkPlatformManagementTool\BizTalkPlatformManagementTool.csproj"><Project>{2C5B2C0A-F407-46C2-9E3B-1FA09FA8445A}</Project><Name>BizTalkPlatformManagementTool</Name></ProjectReference>
|
||||
<ProjectReference Include="..\..\src\BizTalkPlatformManagementTool.Setup\BizTalkPlatformManagementTool.Setup.csproj"><Project>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</Project><Name>BizTalkPlatformManagementTool.Setup</Name></ProjectReference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,351 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using BizTalkPlatformManagementTool.Models;
|
||||
using BizTalkPlatformManagementTool.Services;
|
||||
using BizTalkPlatformManagementTool.Setup;
|
||||
|
||||
namespace BizTalkPlatformManagementTool.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Enthält die portable Regressionstestsuite ohne Abhängigkeit von einem externen Testframework.
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>Anzahl der im aktuellen Testlauf fehlgeschlagenen Prüfungen.</summary>
|
||||
private static int failures;
|
||||
|
||||
/// <summary>Führt alle Regressionstests aus und liefert einen CI-tauglichen Exitcode.</summary>
|
||||
/// <returns>Null, wenn alle Tests bestanden wurden; andernfalls eins.</returns>
|
||||
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);
|
||||
Run("InstallerDiagnosticLogContainsContextAndExceptionChain", InstallerDiagnosticLogContainsContextAndExceptionChain);
|
||||
Run("InstallerDiagnosticLogFallsBackToTemp", InstallerDiagnosticLogFallsBackToTemp);
|
||||
Run("InstallerSurvivesUiReportFailure", InstallerSurvivesUiReportFailure);
|
||||
Console.WriteLine(failures == 0 ? "ALL TESTS PASSED" : failures + " TEST(S) FAILED");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>Führt einen einzelnen Test isoliert aus und protokolliert sein Ergebnis.</summary>
|
||||
/// <param name="name">Der stabile Testname für die Konsolenausgabe.</param>
|
||||
/// <param name="test">Die auszuführende Testfunktion.</param>
|
||||
private static void Run(string name, Action test)
|
||||
{
|
||||
try { test(); Console.WriteLine("PASS " + name); }
|
||||
catch (Exception ex) { failures++; Console.Error.WriteLine("FAIL " + name + ": " + ex); }
|
||||
}
|
||||
|
||||
/// <summary>Prüft BOM-tolerantes Lesen und rückstandsfreies atomisches JSON-Schreiben.</summary>
|
||||
private static void JsonRoundTripIsBomTolerantAndAtomic()
|
||||
{
|
||||
InTemp(directory =>
|
||||
{
|
||||
var path = Path.Combine(directory, "snapshot.json");
|
||||
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
|
||||
JsonFileStore.Save(path, snapshot);
|
||||
JsonFileStore.Save(path, snapshot);
|
||||
var original = File.ReadAllBytes(path);
|
||||
var withBom = new byte[original.Length + 3];
|
||||
withBom[0] = 0xef; withBom[1] = 0xbb; withBom[2] = 0xbf;
|
||||
Buffer.BlockCopy(original, 0, withBom, 3, original.Length);
|
||||
File.WriteAllBytes(path, withBom);
|
||||
Assert(JsonFileStore.Load<BizTalkSnapshot>(path).Applications.Count == 1, "BOM JSON did not load");
|
||||
Assert(Directory.GetFiles(directory, "*.tmp.*").Length == 0, "temporary files remained");
|
||||
Assert(Directory.GetFiles(directory, "*.bak.*").Length == 0, "backup files remained");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft, dass gleichnamige Artefakte verschiedener Anwendungen getrennt verglichen werden.</summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>Prüft Kurzname/FQDN-Kompatibilität und Ablehnung eines fremden Restore-Zielservers.</summary>
|
||||
private static void RestoreRejectsDifferentServer()
|
||||
{
|
||||
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
|
||||
snapshot.Server = "BIZTALK-A.example.local";
|
||||
SnapshotValidator.EnsureServerMatches(snapshot, "BIZTALK-A");
|
||||
Expect<InvalidOperationException>(() => SnapshotValidator.EnsureServerMatches(snapshot, "BIZTALK-B"));
|
||||
}
|
||||
|
||||
/// <summary>Prüft die sichere Restore-Reihenfolge und den Schutz gebundener Orchestrierungen.</summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>Prüft die Neutralisierung formelartiger CSV-Feldwerte.</summary>
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft, dass eine nach Manifestbildung veränderte Payload abgelehnt wird.</summary>
|
||||
private static void PackageManifestRejectsTampering()
|
||||
{
|
||||
InTemp(directory =>
|
||||
{
|
||||
var app = Path.Combine(directory, "application"); Directory.CreateDirectory(app);
|
||||
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), "payload");
|
||||
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
|
||||
var manifest = Path.Combine(directory, "application.manifest");
|
||||
PackageManifest.Write(app, manifest);
|
||||
PackageManifest.ValidateAndRead(app, manifest);
|
||||
File.AppendAllText(Path.Combine(app, InstallerEngine.ApplicationExeName), "tampered");
|
||||
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft die Aktivierung einer vollständig validierten Neuinstallation.</summary>
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft die Ablehnung nicht deklarierter Dateien und ausbrechender Manifestpfade.</summary>
|
||||
private static void PackageManifestRejectsUndeclaredAndTraversalFiles()
|
||||
{
|
||||
InTemp(directory =>
|
||||
{
|
||||
var app = Path.Combine(directory, "application"); Directory.CreateDirectory(app);
|
||||
var exe = Path.Combine(app, InstallerEngine.ApplicationExeName);
|
||||
File.WriteAllText(exe, "payload");
|
||||
File.WriteAllText(Path.Combine(app, InstallerEngine.ApplicationExeName + ".config"), "config");
|
||||
var manifest = Path.Combine(directory, "application.manifest");
|
||||
PackageManifest.Write(app, manifest);
|
||||
File.WriteAllText(Path.Combine(app, "undeclared.dll"), "extra");
|
||||
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
|
||||
File.Delete(Path.Combine(app, "undeclared.dll"));
|
||||
File.WriteAllText(manifest, PackageManifest.Sha256(exe) + "|" + new FileInfo(exe).Length + "|../escape.exe" + Environment.NewLine);
|
||||
Expect<InvalidDataException>(() => PackageManifest.ValidateAndRead(app, manifest));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft, dass ein Staging-Fehler die aktive Installation nicht mutiert.</summary>
|
||||
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 data = Path.Combine(directory, "data");
|
||||
var messages = new List<string>();
|
||||
var engine = new InstallerEngine(package, install, data, false, path => false);
|
||||
var exception = Capture<InvalidOperationException>(() => engine.Install(false, messages.Add));
|
||||
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");
|
||||
Assert(exception.Message.Contains("Fehlercode=SETUP-STAGING-VALIDATION"), "staging error code missing");
|
||||
Assert(exception.Message.Contains("Kein Rollback erforderlich"), "pre-mutation failure reported a rollback");
|
||||
Assert(messages.Any(x => x.Contains("Kein Rollback erforderlich")), "operator output did not distinguish pre-mutation failure");
|
||||
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
|
||||
Assert(log.Contains("event=self_test_completed label=staging"), "staging self-test result missing from log");
|
||||
Assert(log.Contains("event=rollback_summary result=not_required"), "no-rollback evidence missing from log");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft die Wiederherstellung der Vorversion nach fehlgeschlagenem aktiviertem Self-Test.</summary>
|
||||
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);
|
||||
var exception = Capture<InvalidOperationException>(() => engine.Install(false, null));
|
||||
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "previous payload was not restored");
|
||||
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging directory remained");
|
||||
Assert(!Directory.GetDirectories(directory, "install.backup.*").Any(), "backup directory remained");
|
||||
Assert(exception.Message.Contains("Fehlercode=SETUP-ACTIVATED-SELFTEST"), "activated self-test error code missing");
|
||||
Assert(exception.Message.Contains("Rollback erfolgreich"), "successful rollback not reported");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft die Deinstallation über ein atomar umbenanntes Quarantäneverzeichnis.</summary>
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft technischen Kontext, Fehlercode, HRESULT und innere Ausnahme im Setup-Log.</summary>
|
||||
private static void InstallerDiagnosticLogContainsContextAndExceptionChain()
|
||||
{
|
||||
InTemp(directory =>
|
||||
{
|
||||
var log = SetupOperationLog.Create(directory, "diagnostic-test");
|
||||
Assert(!string.IsNullOrEmpty(log.FilePath), "diagnostic log was not created");
|
||||
Exception failure;
|
||||
try { throw new InvalidOperationException("outer", new IOException("inner")); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
log.WriteException("TEST-CODE", "Test phase", failure);
|
||||
var content = File.ReadAllText(log.FilePath);
|
||||
Assert(content.Contains("event=setup_started"), "setup context header missing");
|
||||
Assert(content.Contains("process_bitness="), "process bitness missing");
|
||||
Assert(content.Contains("identity="), "identity missing");
|
||||
Assert(content.Contains("error_code=TEST-CODE"), "error code missing");
|
||||
Assert(content.Contains("inner[1]"), "inner exception missing");
|
||||
Assert(content.Contains("hresult=0x"), "HRESULT missing");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft, dass ein fehlerhafter UI-Callback die Installation nicht beeinflusst.</summary>
|
||||
private static void InstallerSurvivesUiReportFailure()
|
||||
{
|
||||
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 => true);
|
||||
engine.Install(false, message => { throw new InvalidOperationException("UI unavailable"); });
|
||||
Assert(File.Exists(Path.Combine(install, InstallerEngine.ApplicationExeName)), "UI report failure interrupted installation");
|
||||
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
|
||||
Assert(log.Contains("event=ui_report_failed"), "UI report failure was not diagnosed");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Prüft den Diagnose-Log-Fallback bei einem nicht verwendbaren ProgramData-Pfad.</summary>
|
||||
private static void InstallerDiagnosticLogFallsBackToTemp()
|
||||
{
|
||||
InTemp(directory =>
|
||||
{
|
||||
var blockedDataDirectory = Path.Combine(directory, "blocked-data-directory");
|
||||
File.WriteAllText(blockedDataDirectory, "not a directory");
|
||||
var log = SetupOperationLog.Create(blockedDataDirectory, "fallback-test");
|
||||
try
|
||||
{
|
||||
Assert(log.IsFallback, "diagnostic log did not use the Temp fallback");
|
||||
Assert(!string.IsNullOrEmpty(log.CreationError), "primary logging failure was not retained");
|
||||
Assert(File.Exists(log.FilePath), "fallback diagnostic log was not created");
|
||||
Assert(File.ReadAllText(log.FilePath).Contains("event=primary_log_creation_failed"), "fallback reason missing from log");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(log.FilePath)) File.Delete(log.FilePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Erstellt einen minimalen Snapshot für Vergleiche und Planprüfungen.</summary>
|
||||
/// <param name="application">Der Name der Testanwendung.</param>
|
||||
/// <param name="port">Der Name des Test-Send-Ports.</param>
|
||||
/// <param name="state">Der rohe Send-Port-Status.</param>
|
||||
/// <returns>Ein Snapshot mit einer Anwendung und einem Send Port.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Erzeugt eine minimale, manifestierte Installer-Payload.</summary>
|
||||
/// <param name="root">Das temporäre Testwurzelverzeichnis.</param>
|
||||
/// <param name="payload">Der simulierte Inhalt der Anwendungs-EXE.</param>
|
||||
/// <returns>Das Verzeichnis des erzeugten Testpakets.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Führt einen Test in einem eindeutigen temporären Verzeichnis mit garantierter Bereinigung aus.</summary>
|
||||
/// <param name="action">Die Testfunktion, die den temporären Pfad erhält.</param>
|
||||
private static void InTemp(Action<string> action)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool.Tests." + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
try { action(directory); }
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
/// <summary>Bricht den Test ab, wenn eine erwartete Bedingung nicht erfüllt ist.</summary>
|
||||
/// <param name="condition">Die erwartete Bedingung.</param>
|
||||
/// <param name="message">Die Fehlermeldung bei nicht erfüllter Bedingung.</param>
|
||||
private static void Assert(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); }
|
||||
|
||||
/// <summary>Prüft, dass eine Aktion eine bestimmte Ausnahme auslöst.</summary>
|
||||
/// <typeparam name="T">Der erwartete Ausnahmetyp.</typeparam>
|
||||
/// <param name="action">Die auszuführende Aktion.</param>
|
||||
private static void Expect<T>(Action action) where T : Exception
|
||||
{
|
||||
try { action(); }
|
||||
catch (T) { return; }
|
||||
throw new InvalidOperationException("Expected exception " + typeof(T).Name);
|
||||
}
|
||||
|
||||
/// <summary>Führt eine Aktion aus und gibt die erwartete Ausnahme für weitere Prüfungen zurück.</summary>
|
||||
/// <typeparam name="T">Der erwartete Ausnahmetyp.</typeparam>
|
||||
/// <param name="action">Die auszuführende Aktion.</param>
|
||||
/// <returns>Die von der Aktion ausgelöste Ausnahme.</returns>
|
||||
private static T Capture<T>(Action action) where T : Exception
|
||||
{
|
||||
try { action(); }
|
||||
catch (T ex) { return ex; }
|
||||
throw new InvalidOperationException("Expected exception " + typeof(T).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user