diff --git a/.gitignore b/.gitignore index d7da0c7..5709a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ obj/ *.log *.json *.html +*.zip +*.txt +*.png work/ release/* diff --git a/Dokumentation.md b/Dokumentation.md index 3a51471..8fc0274 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -1,7 +1,7 @@ # BizTalk Platform Management Tool Dokumentation **Stand:** 2026-04-27 -**Implementierung:** C# WinForms, .NET Framework 4.7.1 +**Implementierung:** C# WinForms, .NET Framework 4.6.1 **Archivierte PowerShell-Version:** `archive/powershell/BizTalkPlatformManagementTool.ps1` ## Zweck @@ -40,10 +40,13 @@ Das BizTalk Platform Management Tool unterstützt kontrollierte Wartungsfenster - Dry-run ist standardmäßig aktiviert. - Echte Shutdown-/Restore-Aktionen verlangen bei deaktiviertem Dry-run eine zusätzliche Bestätigung. - 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. +- Logdateien werden rollierend für den aktuellen Tag plus vier vorherige Tage vorgehalten. - Operationspläne werden vor Laufzeitänderungen gespeichert. - WMI-Methodenrückgaben werden geprüft. - 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. ## Shutdown-Reihenfolge @@ -73,6 +76,11 @@ Das BizTalk Platform Management Tool unterstützt kontrollierte Wartungsfenster - 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` + +## Fehleranalyse + +Bei echten Shutdown- und Restore-Aktionen wird jeder Schritt vor der Ausführung mit Artefakttyp, WMI-Klasse, Schlüsselproperty, Schlüsselwert und Methodenname protokolliert. Die Objektauflösung verwendet eine breite `SELECT * FROM `-Abfrage und filtert danach im Prozess auf den Schlüsselwert. Dadurch können Host-Instance-Namen und andere BizTalk-Namen mit Sonderzeichen keine ungültige WMI-WQL-`WHERE`-Query mehr erzeugen. ## Status Mapping @@ -83,7 +91,7 @@ Das BizTalk Platform Management Tool unterstützt kontrollierte Wartungsfenster ## Anforderungen -- Windows Server oder Administrationshost mit .NET Framework 4.7.1 +- Windows Server oder Administrationshost mit .NET Framework 4.6.1 - BizTalk Server 2020 oder installierte BizTalk Administration Tools - Berechtigungen auf den WMI-Namespace `root\MicrosoftBizTalkServer` -- Visual Studio mit .NET Framework 4.7.1 Developer Pack für Builds +- Visual Studio mit .NET Framework 4.6.1 Developer Pack für Builds diff --git a/Installation.md b/Installation.md index 2f7f088..45e640f 100644 --- a/Installation.md +++ b/Installation.md @@ -4,10 +4,11 @@ - Windows Server 2019/2022 oder ein Windows-Administrationshost - Microsoft BizTalk Server 2020 oder BizTalk Administration Tools -- .NET Framework 4.7.1 Runtime -- Für Builds: Visual Studio mit .NET Framework 4.7.1 Developer Pack +- .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 +- Schreibrechte im Verzeichnis der EXE für die tägliche Logdatei ## Build @@ -23,6 +24,7 @@ 2. Sicherstellen, dass der ausführende Benutzer WMI-Zugriff auf `root\MicrosoftBizTalkServer` hat. 3. `BizTalkPlatformManagementTool.exe` starten. 4. Als Ausgabeverzeichnis einen Ordner wählen, in dem Plan-, Snapshot- und Report-Dateien abgelegt werden dürfen. +5. Prüfen, dass im EXE-Verzeichnis `BizTalkPlatformManagementTool-yyyy-MM-dd.log` geschrieben werden kann. Logs werden für maximal fünf Tage vorgehalten. ## Erster Funktionstest diff --git a/README.md b/README.md index 8bae6fb..256e8b0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d ## Current State - Application: C# WinForms -- Target framework: .NET Framework 4.7.1 +- Target framework: .NET Framework 4.6.1 - Solution: `BizTalkPlatformManagementTool.sln` - Project: `src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj` - Primary namespace and assembly: `BizTalkPlatformManagementTool` @@ -20,6 +20,7 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d - Host instance handling for the selected BizTalk server - Dry-run mode enabled by default - WMI access through `root\MicrosoftBizTalkServer` +- Detailed operation logging in the GUI and daily rolling log files next to the executable - No compile-time dependency on BizTalk ExplorerOM assemblies ## Safe Usage @@ -56,12 +57,19 @@ 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` + +Log files are retained for the current day plus the previous four days. Older `BizTalkPlatformManagementTool-*.log` files are removed on startup. + +## Troubleshooting + +The Operation Log shows the WMI class, key property, key value and method for real shutdown and restore steps. WMI objects are resolved with a broad `SELECT * FROM ` query and a client-side key filter so names containing special characters do not break the WMI query parser. ## Build -Open `BizTalkPlatformManagementTool.sln` in Visual Studio on Windows with the .NET Framework 4.7.1 Developer Pack installed, then build the `Release|Any CPU` configuration. +Open `BizTalkPlatformManagementTool.sln` in Visual Studio on Windows with the .NET Framework 4.6.1 Developer Pack installed, then build the `Release|Any CPU` configuration. -The app targets .NET Framework 4.7.1 because BizTalk Server 2020 commonly runs in that Windows/.NET Framework operational environment. +The app targets .NET Framework 4.6.1 for compatibility with customer environments that do not have newer .NET Framework developer packs installed. ## Documentation diff --git a/src/BizTalkPlatformManagementTool/App.config b/src/BizTalkPlatformManagementTool/App.config index f401e47..7ded20c 100644 --- a/src/BizTalkPlatformManagementTool/App.config +++ b/src/BizTalkPlatformManagementTool/App.config @@ -1,6 +1,6 @@ - + diff --git a/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj b/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj index 47ecbce..c84037e 100644 --- a/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj +++ b/src/BizTalkPlatformManagementTool/BizTalkPlatformManagementTool.csproj @@ -8,7 +8,7 @@ WinExe BizTalkPlatformManagementTool BizTalkPlatformManagementTool - v4.7.1 + v4.6.1 512 true true diff --git a/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs b/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs index 5667699..762a325 100644 --- a/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs +++ b/src/BizTalkPlatformManagementTool/Services/BizTalkOperationService.cs @@ -9,7 +9,7 @@ namespace BizTalkPlatformManagementTool.Services { public sealed class BizTalkOperationService { - public const string Version = "2.0.0-net471"; + public const string Version = "2.0.0-net461"; private const string UnknownApplication = "(Unknown Application)"; private readonly OperationLogger _logger; @@ -116,7 +116,7 @@ namespace BizTalkPlatformManagementTool.Services { foreach (var item in app.ReceiveLocations.Where(x => x.Enabled)) { - plan.Steps.Add(Step("ReceiveLocation", app.Application, item.Name, null, "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, "Disable", null, false)); + plan.Steps.Add(Step("ReceiveLocation", app.Application, item.Name, null, "Disable receive location", "MSBTS_ReceiveLocation", "Name", item.Name, "Disable", null, null)); } foreach (var item in app.Orchestrations.Where(x => x.OrchestrationStatus == ArtifactStates.OrchestrationStarted)) @@ -232,14 +232,22 @@ namespace BizTalkPlatformManagementTool.Services continue; } - using (var instance = client.FindByProperty(step.WmiClass, step.KeyProperty, step.KeyValue)) + try { - if (instance == null) + _logger.Info("Executing step: " + DescribeStep(step)); + using (var instance = client.FindByProperty(step.WmiClass, step.KeyProperty, step.KeyValue)) { - throw new InvalidOperationException(step.Kind + " not found: " + step.Name); - } + if (instance == null) + { + throw new InvalidOperationException(step.Kind + " not found: " + step.Name); + } - ExecuteStep(client, instance, step, options); + ExecuteStep(client, instance, step, options); + } + } + catch (Exception ex) + { + throw new InvalidOperationException("Step failed: " + DescribeStep(step) + ". Error: " + ex.Message, ex); } } } @@ -387,6 +395,23 @@ namespace BizTalkPlatformManagementTool.Services return result; } + private static string DescribeStep(OperationStep step) + { + if (step == null) + { + return ""; + } + + return step.Action + + " '" + step.Name + "'" + + " [kind=" + step.Kind + + ", class=" + step.WmiClass + + ", key=" + step.KeyProperty + "=" + (step.KeyValue ?? string.Empty) + + ", method=" + step.MethodName + + (string.IsNullOrWhiteSpace(step.Server) ? string.Empty : ", server=" + step.Server) + + "]"; + } + private static ApplicationSnapshot GetApplication(Dictionary apps, string appName) { ApplicationSnapshot app; diff --git a/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs b/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs index 3523704..4cfef11 100644 --- a/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs +++ b/src/BizTalkPlatformManagementTool/Services/BizTalkWmiClient.cs @@ -34,44 +34,88 @@ namespace BizTalkPlatformManagementTool.Services } public List Query(string className) + { + return Query(className, true); + } + + private List Query(string className, bool logQuery) { EnsureConnected(); var result = new List(); - var query = new ObjectQuery("SELECT * FROM " + className); - using (var searcher = new ManagementObjectSearcher(_scope, query)) - using (var collection = searcher.Get()) + var queryText = "SELECT * FROM " + className; + if (logQuery) { - foreach (ManagementObject item in collection) + _logger.Info("Executing WMI query: " + queryText); + } + + try + { + var query = new ObjectQuery(queryText); + using (var searcher = new ManagementObjectSearcher(_scope, query)) + using (var collection = searcher.Get()) { - result.Add(item); + foreach (ManagementObject item in collection) + { + result.Add(item); + } } } + catch (ManagementException ex) + { + throw new InvalidOperationException("WMI query failed. Query: " + queryText + ". WMI error: " + ex.Message, ex); + } return result; } public ManagementObject FindByProperty(string className, string propertyName, string value) + { + return FindByProperty(className, propertyName, value, true); + } + + private ManagementObject FindByProperty(string className, string propertyName, string value, bool logLookup) { EnsureConnected(); - var query = string.Format( + var queryText = "SELECT * FROM " + className; + var filter = string.Format( CultureInfo.InvariantCulture, - "SELECT * FROM {0} WHERE {1} = \"{2}\"", - className, + "{0} = \"{1}\"", propertyName, - EscapeWql(value)); - - using (var searcher = new ManagementObjectSearcher(_scope, new ObjectQuery(query))) - using (var collection = searcher.Get()) + value ?? string.Empty); + if (logLookup) { - foreach (ManagementObject item in collection) - { - return item; - } + _logger.Info("Resolving WMI object: " + queryText + " with client-side filter " + filter); } - return null; + try + { + ManagementObject match = null; + var items = Query(className, false); + foreach (var item in items) + { + if (MatchesProperty(item, className, propertyName, value)) + { + match = item; + break; + } + } + + foreach (var item in items) + { + if (!object.ReferenceEquals(item, match)) + { + item.Dispose(); + } + } + + return match; + } + catch (ManagementException ex) + { + throw new InvalidOperationException("WMI lookup failed. Query: " + queryText + ". Filter: " + filter + ". WMI error: " + ex.Message, ex); + } } public uint InvokeMethod(ManagementObject instance, string methodName, params object[] arguments) @@ -84,11 +128,20 @@ namespace BizTalkPlatformManagementTool.Services var methodToCall = ResolveMethodName(instance, methodName); _logger.Info("Calling " + instance.Path.ClassName + "." + methodToCall + " on " + SafeGetString(instance, "Name", SafeGetString(instance, "InstanceName", ""))); - object result = instance.InvokeMethod(methodToCall, arguments == null || arguments.Length == 0 ? null : arguments); + object result; + try + { + result = instance.InvokeMethod(methodToCall, arguments == null || arguments.Length == 0 ? null : arguments); + } + catch (ManagementException ex) + { + 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) { - throw new InvalidOperationException(methodToCall + " returned HRESULT/ReturnValue " + returnCode.ToString(CultureInfo.InvariantCulture)); + throw new InvalidOperationException("WMI method returned an error. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ", ReturnValue: " + returnCode.ToString(CultureInfo.InvariantCulture)); } return returnCode; @@ -101,7 +154,7 @@ namespace BizTalkPlatformManagementTool.Services while (DateTime.UtcNow <= deadline) { - using (var current = FindByProperty(className, keyProperty, keyValue)) + using (var current = FindByProperty(className, keyProperty, keyValue, false)) { if (current != null && isReached(current)) { @@ -113,7 +166,7 @@ namespace BizTalkPlatformManagementTool.Services Thread.Sleep(TimeSpan.FromSeconds(delay)); } - throw new TimeoutException("Timeout while waiting for " + description); + throw new TimeoutException("Timeout while waiting for " + description + " [" + className + "." + keyProperty + "=" + keyValue + "]"); } public static string SafeGetString(ManagementBaseObject item, string propertyName, string fallback) @@ -191,13 +244,58 @@ namespace BizTalkPlatformManagementTool.Services return false; } + private static bool MatchesProperty(ManagementBaseObject item, string className, string propertyName, string expectedValue) + { + foreach (var candidate in CandidatePropertyNames(className, propertyName)) + { + if (!HasProperty(item, candidate)) + { + continue; + } + + var actual = SafeGetString(item, candidate, string.Empty); + if (string.Equals(actual, expectedValue ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static IEnumerable CandidatePropertyNames(string className, string propertyName) + { + yield return propertyName; + + if (string.Equals(className, "MSBTS_HostInstance", StringComparison.OrdinalIgnoreCase) + && string.Equals(propertyName, "InstanceName", StringComparison.OrdinalIgnoreCase)) + { + yield return "Name"; + } + } + + private static string SafeObjectName(ManagementObject instance) + { + try + { + return instance.Path == null ? "" : instance.Path.RelativePath; + } + catch + { + return SafeGetString(instance, "Name", SafeGetString(instance, "InstanceName", "")); + } + } + private static string ResolveMethodName(ManagementObject instance, string requestedName) { - foreach (MethodData method in instance.Methods) + using (var managementClass = new ManagementClass(instance.Scope, new ManagementPath(instance.Path.ClassName), null)) { - if (string.Equals(method.Name, requestedName, StringComparison.OrdinalIgnoreCase)) + foreach (MethodData method in managementClass.Methods) { - return method.Name; + if (string.Equals(method.Name, requestedName, StringComparison.OrdinalIgnoreCase)) + { + return method.Name; + } } } @@ -220,11 +318,6 @@ namespace BizTalkPlatformManagementTool.Services return Convert.ToUInt32(result, CultureInfo.InvariantCulture); } - private static string EscapeWql(string value) - { - return (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\""); - } - public void Dispose() { } diff --git a/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs b/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs index 3dd2ecb..882d09d 100644 --- a/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs +++ b/src/BizTalkPlatformManagementTool/Services/OperationLogger.cs @@ -1,4 +1,7 @@ using System; +using System.Globalization; +using System.IO; +using System.Threading; namespace BizTalkPlatformManagementTool.Services { @@ -19,11 +22,28 @@ namespace BizTalkPlatformManagementTool.Services public sealed class OperationLogger { + private const string LogFilePrefix = "BizTalkPlatformManagementTool-"; + private const string LogFileExtension = ".log"; + private const int RetentionDays = 5; + private static readonly object FileLock = new object(); + private static int _cleanupDone; + private readonly Action _sink; + private readonly string _logDirectory; public OperationLogger(Action sink) { _sink = sink; + _logDirectory = AppDomain.CurrentDomain.BaseDirectory; + CleanupOldLogs(); + } + + public string LogFilePath + { + get + { + return Path.Combine(_logDirectory, LogFilePrefix + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + LogFileExtension); + } } public void Info(string message) @@ -48,17 +68,69 @@ namespace BizTalkPlatformManagementTool.Services private void Write(LogLevel level, string message) { + var entry = new LogEntry + { + Timestamp = DateTime.Now, + Level = level, + Message = message + }; + + WriteToFile(entry); + if (_sink == null) { return; } - _sink(new LogEntry + _sink(entry); + } + + private void WriteToFile(LogEntry entry) + { + try { - Timestamp = DateTime.Now, - Level = level, - Message = message - }); + var line = string.Format( + CultureInfo.InvariantCulture, + "[{0:yyyy-MM-dd HH:mm:ss}][{1}] {2}{3}", + entry.Timestamp, + entry.Level.ToString().ToUpperInvariant(), + entry.Message, + Environment.NewLine); + + lock (FileLock) + { + File.AppendAllText(LogFilePath, line); + } + } + catch + { + // Logging must never interrupt BizTalk operations. + } + } + + private void CleanupOldLogs() + { + if (Interlocked.Exchange(ref _cleanupDone, 1) == 1) + { + return; + } + + try + { + var cutoff = DateTime.Now.Date.AddDays(-(RetentionDays - 1)); + foreach (var file in Directory.GetFiles(_logDirectory, LogFilePrefix + "*" + LogFileExtension)) + { + var lastWrite = File.GetLastWriteTime(file); + if (lastWrite.Date < cutoff) + { + File.Delete(file); + } + } + } + catch + { + // Log retention cleanup is best-effort. + } } } } diff --git a/src/BizTalkPlatformManagementTool/Ui/MainForm.cs b/src/BizTalkPlatformManagementTool/Ui/MainForm.cs index 8428cd2..c1fbf9f 100644 --- a/src/BizTalkPlatformManagementTool/Ui/MainForm.cs +++ b/src/BizTalkPlatformManagementTool/Ui/MainForm.cs @@ -20,6 +20,7 @@ namespace BizTalkPlatformManagementTool.Ui private readonly DataGridView _logGrid = new DataGridView(); private readonly StatusStrip _statusStrip = new StatusStrip(); private readonly ToolStripStatusLabel _statusLabel = new ToolStripStatusLabel(); + private readonly OperationLogger _logger; private readonly BizTalkOperationService _service; private Button _diagnoseButton; @@ -37,8 +38,10 @@ namespace BizTalkPlatformManagementTool.Ui MinimumSize = new Size(980, 640); StartPosition = FormStartPosition.CenterScreen; - _service = new BizTalkOperationService(new OperationLogger(AppendLog)); + _logger = new OperationLogger(AppendLog); + _service = new BizTalkOperationService(_logger); BuildUi(); + _logger.Info("Log file: " + _logger.LogFilePath); } private void BuildUi() @@ -264,12 +267,12 @@ namespace BizTalkPlatformManagementTool.Ui try { work(); - AppendLog(new LogEntry { Timestamp = DateTime.Now, Level = LogLevel.Success, Message = "Operation completed." }); + _logger.Success("Operation completed."); SetBusy(false, "Ready."); } catch (Exception ex) { - AppendLog(new LogEntry { Timestamp = DateTime.Now, Level = LogLevel.Error, Message = ex.Message }); + _logger.Error(FormatException(ex)); SetBusy(false, "Failed: " + ex.Message); } }); @@ -458,5 +461,27 @@ namespace BizTalkPlatformManagementTool.Ui grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.AllCells; grid.BackgroundColor = SystemColors.Window; } + + private static string FormatException(Exception ex) + { + if (ex == null) + { + return "Operation failed."; + } + + var message = ex.Message; + var inner = ex.InnerException; + while (inner != null) + { + if (!string.IsNullOrWhiteSpace(inner.Message) && message.IndexOf(inner.Message, StringComparison.OrdinalIgnoreCase) < 0) + { + message += " Inner error: " + inner.Message; + } + + inner = inner.InnerException; + } + + return message; + } } }