Add transactional installer and harden runtime operations

This commit is contained in:
2026-08-11 11:35:11 +02:00
parent e8df6042d2
commit a74528e5c6
41 changed files with 3010 additions and 119 deletions
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration><Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{74A5D422-0BA5-4559-BD81-C89C071A8FE4}</ProjectGuid><OutputType>Exe</OutputType>
<RootNamespace>BizTalkPlatformManagementTool.Packager</RootNamespace><AssemblyName>BizTalkPlatformManagementTool.Packager</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion><FileAlignment>512</FileAlignment><Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "><DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize><OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "><DebugType>pdbonly</DebugType><Optimize>true</Optimize><OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel></PropertyGroup>
<ItemGroup><Reference Include="System" /><Reference Include="System.Core" /><Reference Include="System.IO.Compression" /><Reference Include="System.IO.Compression.FileSystem" /><Reference Include="System.Security" /></ItemGroup>
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
<ItemGroup><ProjectReference Include="..\BizTalkPlatformManagementTool.Setup\BizTalkPlatformManagementTool.Setup.csproj"><Project>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</Project><Name>BizTalkPlatformManagementTool.Setup</Name></ProjectReference></ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,68 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using BizTalkPlatformManagementTool.Setup;
namespace BizTalkPlatformManagementTool.Packager
{
internal static class Program
{
private static int Main(string[] args)
{
try
{
if (args.Length != 2) throw new ArgumentException("Usage: BizTalkPlatformManagementTool.Packager.exe <repository-root> <configuration>");
var root = Path.GetFullPath(args[0]);
var configuration = args[1];
var artifacts = Path.Combine(root, "artifacts");
var package = Path.Combine(artifacts, "BizTalkPlatformManagementTool-Setup");
var application = Path.Combine(package, "application");
var zip = Path.Combine(artifacts, "BizTalkPlatformManagementTool-Setup.zip");
if (Directory.Exists(package)) Directory.Delete(package, true);
Directory.CreateDirectory(application);
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool.Setup", "bin", configuration, "BizTalkPlatformManagementTool.Setup.exe"), Path.Combine(package, "Setup.exe"));
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool", "bin", configuration, "BizTalkPlatformManagementTool.exe"), Path.Combine(application, "BizTalkPlatformManagementTool.exe"));
Copy(Path.Combine(root, "src", "BizTalkPlatformManagementTool", "bin", configuration, "BizTalkPlatformManagementTool.exe.config"), Path.Combine(application, "BizTalkPlatformManagementTool.exe.config"));
Copy(Path.Combine(root, "Installation.md"), Path.Combine(package, "INSTALLATION.md"));
PackageManifest.Write(application, Path.Combine(package, "application.manifest"));
PackageManifest.ValidateAndRead(application, Path.Combine(package, "application.manifest"));
if (File.Exists(zip)) File.Delete(zip);
ZipFile.CreateFromDirectory(package, zip, CompressionLevel.Optimal, false);
WriteBase64(zip, zip + ".b64.txt");
File.WriteAllText(zip + ".sha256.txt", PackageManifest.Sha256(zip) + " " + Path.GetFileName(zip) + Environment.NewLine, new UTF8Encoding(false));
Console.WriteLine("SETUP_PACKAGE=" + package);
Console.WriteLine("SETUP_ZIP=" + zip);
Console.WriteLine("SETUP_BASE64=" + zip + ".b64.txt");
Console.WriteLine("SETUP_SHA256=" + zip + ".sha256.txt");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine("Packaging failed: " + ex);
return 1;
}
}
private static void Copy(string source, string target)
{
if (!File.Exists(source)) throw new FileNotFoundException("Required package file missing: " + source, source);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.Copy(source, target, true);
}
private static void WriteBase64(string source, string target)
{
var encoded = Convert.ToBase64String(File.ReadAllBytes(source));
var builder = new StringBuilder(encoded.Length + encoded.Length / 64 + 2);
for (var offset = 0; offset < encoded.Length; offset += 64)
{
builder.Append(encoded, offset, Math.Min(64, encoded.Length - offset));
builder.Append('\n');
}
File.WriteAllText(target, builder.ToString(), new UTF8Encoding(false));
}
}
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.props" Condition="Exists('$(MSBuildToolsPath)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{675B68A9-BD80-46A5-B8C5-3B11B0B374E2}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>BizTalkPlatformManagementTool.Setup</RootNamespace>
<AssemblyName>BizTalkPlatformManagementTool.Setup</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols><DebugType>full</DebugType><Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath><DefineConstants>DEBUG;TRACE</DefineConstants><WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType><Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath><DefineConstants>TRACE</DefineConstants><WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.CSharp" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Security" />
<Reference Include="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="InstallerEngine.cs" />
<Compile Include="MainForm.cs" />
<Compile Include="PackageManifest.cs" />
<Compile Include="Program.cs" />
<Compile Include="SetupOperationLog.cs" />
</ItemGroup>
<ItemGroup><None Include="app.manifest" /></ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,403 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32;
namespace BizTalkPlatformManagementTool.Setup
{
internal sealed class InstallerEngine
{
internal const string ApplicationExeName = "BizTalkPlatformManagementTool.exe";
private const string ProductName = "BizTalk Platform Management Tool";
private const string ProductVersion = "2.1.0";
private const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\BizTalkPlatformManagementTool";
private readonly string packageDirectory;
private readonly string installDirectory;
private readonly string dataDirectory;
private readonly bool registerWindowsIntegration;
private readonly Func<string, bool> selfTestRunner;
private sealed class WindowsIntegrationSnapshot
{
public bool RegistryKeyExisted { get; set; }
public Dictionary<string, Tuple<object, RegistryValueKind>> RegistryValues { get; set; }
public byte[] DesktopShortcut { get; set; }
public byte[] StartMenuShortcut { get; set; }
public byte[] Uninstaller { get; set; }
}
public InstallerEngine(string packageDirectory)
: this(
packageDirectory,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "BizTalkPlatformManagementTool"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BizTalkPlatformManagementTool"),
true,
RunApplicationSelfTest)
{
}
internal InstallerEngine(string packageDirectory, string installDirectory, string dataDirectory, bool registerWindowsIntegration, Func<string, bool> selfTestRunner)
{
this.packageDirectory = Path.GetFullPath(packageDirectory);
this.installDirectory = Path.GetFullPath(installDirectory);
this.dataDirectory = Path.GetFullPath(dataDirectory);
this.registerWindowsIntegration = registerWindowsIntegration;
this.selfTestRunner = selfTestRunner ?? throw new ArgumentNullException("selfTestRunner");
}
/// <summary>Gets the fixed machine-wide application installation directory.</summary>
public string InstallDirectory { get { return installDirectory; } }
/// <summary>Gets whether this setup copy contains a complete install/update payload.</summary>
public bool HasInstallPayload { get { return Directory.Exists(Path.Combine(packageDirectory, "application")) && File.Exists(Path.Combine(packageDirectory, "application.manifest")); } }
/// <summary>Gets whether the application executable is present at the install target.</summary>
public bool IsInstalled { get { return File.Exists(Path.Combine(installDirectory, ApplicationExeName)); } }
/// <summary>Validates, stages and transactionally installs or updates the application.</summary>
public void Install(bool createDesktopShortcut, Action<string> report)
{
report = report ?? delegate { };
Directory.CreateDirectory(dataDirectory);
var log = SetupOperationLog.Create(dataDirectory, "install-update");
Action<string> write = message => { log.Write("INFO", message); report(message); };
write("Diagnoselog: " + (log.FilePath.Length == 0 ? "nicht verfuegbar" : log.FilePath));
var sourceApplication = Path.Combine(packageDirectory, "application");
var manifestPath = Path.Combine(packageDirectory, "application.manifest");
var stagingDirectory = installDirectory + ".staging." + Guid.NewGuid().ToString("N");
var backupDirectory = installDirectory + ".backup." + Guid.NewGuid().ToString("N");
var hadExistingInstallation = Directory.Exists(installDirectory);
var integrationSnapshot = registerWindowsIntegration ? CaptureWindowsIntegration() : null;
var backupCreated = false;
var activated = false;
try
{
write("Phase 1/6: Paketmanifest und SHA-256 pruefen.");
var files = PackageManifest.ValidateAndRead(sourceApplication, manifestPath);
RequirePayload(files, ApplicationExeName);
RequirePayload(files, ApplicationExeName + ".config");
EnsureApplicationNotRunning();
write("Phase 2/6: Update in isoliertes Staging kopieren.");
CopyPayload(sourceApplication, stagingDirectory, files);
var stagedExe = Path.Combine(stagingDirectory, ApplicationExeName);
if (!selfTestRunner(stagedExe)) throw new InvalidOperationException("Der WMI-freie Self-Test der Staging-Version ist fehlgeschlagen.");
write("Staging-Self-Test erfolgreich.");
write("Phase 3/6: Vorhandene Version sichern und Staging atomar aktivieren.");
if (hadExistingInstallation)
{
Directory.Move(installDirectory, backupDirectory);
backupCreated = true;
}
Directory.Move(stagingDirectory, installDirectory);
activated = true;
write("Phase 4/6: Aktivierte Version erneut pruefen.");
var targetExe = Path.Combine(installDirectory, ApplicationExeName);
if (!selfTestRunner(targetExe)) throw new InvalidOperationException("Der Self-Test der aktivierten Version ist fehlgeschlagen.");
File.WriteAllText(
Path.Combine(installDirectory, "install-state.txt"),
"ProductVersion=" + ProductVersion + Environment.NewLine
+ "InstalledAt=" + DateTimeOffset.Now.ToString("o", CultureInfo.InvariantCulture) + Environment.NewLine
+ "ManifestSha256=" + PackageManifest.Sha256(manifestPath) + Environment.NewLine,
new UTF8Encoding(false));
write("Phase 5/6: Windows-Integration registrieren.");
if (registerWindowsIntegration)
{
RegisterWindowsIntegration(targetExe, createDesktopShortcut);
}
write("Phase 6/6: Backup bereinigen.");
TryDeleteDirectory(backupDirectory, write);
write("Installation/Update erfolgreich abgeschlossen: " + installDirectory);
}
catch (Exception ex)
{
log.Write("ERROR", ex.ToString());
var rollbackErrors = new List<string>();
try
{
if (activated && Directory.Exists(installDirectory)) Directory.Delete(installDirectory, true);
if (backupCreated && Directory.Exists(backupDirectory)) Directory.Move(backupDirectory, installDirectory);
write(backupCreated ? "Rollback: vorherige Programmversion wiederhergestellt." : "Rollback: unvollstaendige Neuinstallation entfernt.");
}
catch (Exception rollbackException)
{
rollbackErrors.Add(rollbackException.Message);
log.Write("ERROR", "Rollback files: " + rollbackException);
}
try
{
if (registerWindowsIntegration)
{
RestoreWindowsIntegration(integrationSnapshot);
}
}
catch (Exception rollbackException)
{
rollbackErrors.Add(rollbackException.Message);
log.Write("ERROR", "Rollback registration: " + rollbackException);
}
TryDeleteDirectory(stagingDirectory, write);
TryDeleteDirectory(backupDirectory, write);
var suffix = rollbackErrors.Count == 0 ? " Rollback erfolgreich." : " Rollback-Fehler: " + string.Join(" | ", rollbackErrors);
throw new InvalidOperationException("Installation/Update fehlgeschlagen." + suffix + " Ursache: " + ex.Message, ex);
}
}
/// <summary>Removes the active program directory and registered Windows integration.</summary>
public void Uninstall(Action<string> report)
{
report = report ?? delegate { };
Directory.CreateDirectory(dataDirectory);
var log = SetupOperationLog.Create(dataDirectory, "uninstall");
Action<string> write = message => { log.Write("INFO", message); report(message); };
var integrationSnapshot = registerWindowsIntegration ? CaptureWindowsIntegration() : null;
var removalDirectory = installDirectory + ".removed." + Guid.NewGuid().ToString("N");
var filesMoved = false;
try
{
EnsureApplicationNotRunning();
if (Directory.Exists(installDirectory))
{
Directory.Move(installDirectory, removalDirectory);
filesMoved = true;
}
if (registerWindowsIntegration) RemoveWindowsIntegration();
TryDeleteDirectory(removalDirectory, write);
write("Deinstallation erfolgreich. Installer-Logs bleiben erhalten: " + Path.Combine(dataDirectory, "InstallerLogs"));
}
catch (Exception ex)
{
log.Write("ERROR", ex.ToString());
if (filesMoved && !Directory.Exists(installDirectory) && Directory.Exists(removalDirectory))
{
try { Directory.Move(removalDirectory, installDirectory); }
catch (Exception rollbackException) { log.Write("ERROR", "Uninstall rollback files: " + rollbackException); }
}
if (registerWindowsIntegration)
{
try { RestoreWindowsIntegration(integrationSnapshot); }
catch (Exception rollbackException) { log.Write("ERROR", "Uninstall rollback registration: " + rollbackException); }
}
throw;
}
}
private void RegisterWindowsIntegration(string targetExe, bool createDesktopShortcut)
{
var programsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName);
Directory.CreateDirectory(programsDirectory);
CreateShortcut(Path.Combine(programsDirectory, ProductName + ".lnk"), targetExe, installDirectory, ProductName);
if (createDesktopShortcut) CreateShortcut(DesktopShortcutPath, targetExe, installDirectory, ProductName);
else if (File.Exists(DesktopShortcutPath)) File.Delete(DesktopShortcutPath);
var setupDirectory = Path.Combine(dataDirectory, "Setup");
Directory.CreateDirectory(setupDirectory);
var uninstaller = Path.Combine(setupDirectory, "Uninstall.exe");
File.Copy(Assembly.GetExecutingAssembly().Location, uninstaller, true);
using (var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath))
{
if (key == null) throw new InvalidOperationException("Windows uninstall registry key could not be created.");
key.SetValue("DisplayName", ProductName);
key.SetValue("DisplayVersion", ProductVersion);
key.SetValue("Publisher", "BEW");
key.SetValue("InstallLocation", installDirectory);
key.SetValue("DisplayIcon", targetExe);
key.SetValue("UninstallString", "\"" + uninstaller + "\" --uninstall");
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
}
}
private void RemoveWindowsIntegration()
{
TryDeleteFile(DesktopShortcutPath);
var programsDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName);
TryDeleteFile(Path.Combine(programsDirectory, ProductName + ".lnk"));
if (Directory.Exists(programsDirectory) && Directory.GetFileSystemEntries(programsDirectory).Length == 0) Directory.Delete(programsDirectory);
Registry.LocalMachine.DeleteSubKeyTree(UninstallKeyPath, false);
}
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDirectory, string description)
{
Directory.CreateDirectory(Path.GetDirectoryName(shortcutPath));
var shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType == null) throw new InvalidOperationException("Windows Script Host is unavailable; shortcut creation failed.");
object shell = null;
object shortcut = null;
try
{
shell = Activator.CreateInstance(shellType);
dynamic dynamicShell = shell;
shortcut = dynamicShell.CreateShortcut(shortcutPath);
dynamic dynamicShortcut = shortcut;
dynamicShortcut.TargetPath = targetPath;
dynamicShortcut.WorkingDirectory = workingDirectory;
dynamicShortcut.Description = description;
dynamicShortcut.IconLocation = targetPath + ",0";
dynamicShortcut.Save();
}
finally
{
if (shortcut != null && Marshal.IsComObject(shortcut)) Marshal.FinalReleaseComObject(shortcut);
if (shell != null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
}
}
private WindowsIntegrationSnapshot CaptureWindowsIntegration()
{
var snapshot = new WindowsIntegrationSnapshot
{
RegistryValues = new Dictionary<string, Tuple<object, RegistryValueKind>>(StringComparer.OrdinalIgnoreCase),
DesktopShortcut = ReadFileOrNull(DesktopShortcutPath),
StartMenuShortcut = ReadFileOrNull(StartMenuShortcutPath),
Uninstaller = ReadFileOrNull(UninstallerPath)
};
using (var key = Registry.LocalMachine.OpenSubKey(UninstallKeyPath, false))
{
snapshot.RegistryKeyExisted = key != null;
if (key != null)
{
foreach (var name in key.GetValueNames())
{
snapshot.RegistryValues[name] = Tuple.Create(key.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames), key.GetValueKind(name));
}
}
}
return snapshot;
}
private void RestoreWindowsIntegration(WindowsIntegrationSnapshot snapshot)
{
if (snapshot == null) return;
Registry.LocalMachine.DeleteSubKeyTree(UninstallKeyPath, false);
if (snapshot.RegistryKeyExisted)
{
using (var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath))
{
if (key == null) throw new InvalidOperationException("Previous uninstall registry key could not be restored.");
foreach (var pair in snapshot.RegistryValues)
{
key.SetValue(pair.Key, pair.Value.Item1, pair.Value.Item2);
}
}
}
RestoreFile(DesktopShortcutPath, snapshot.DesktopShortcut);
RestoreFile(StartMenuShortcutPath, snapshot.StartMenuShortcut);
RestoreFile(UninstallerPath, snapshot.Uninstaller);
}
private static byte[] ReadFileOrNull(string path)
{
return File.Exists(path) ? File.ReadAllBytes(path) : null;
}
private static void RestoreFile(string path, byte[] content)
{
if (content == null)
{
TryDeleteFile(path);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, content);
}
private void EnsureApplicationNotRunning()
{
var target = Path.Combine(installDirectory, ApplicationExeName);
if (!File.Exists(target)) return;
foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(ApplicationExeName)))
{
try
{
if (string.Equals(Path.GetFullPath(process.MainModule.FileName), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(ProductName + " is still running. Close it before installation or removal.");
}
finally
{
process.Dispose();
}
}
}
private static bool RunApplicationSelfTest(string executable)
{
var startInfo = new ProcessStartInfo(executable, "--self-test")
{
WorkingDirectory = Path.GetDirectoryName(executable),
UseShellExecute = false,
CreateNoWindow = true
};
using (var process = Process.Start(startInfo))
{
if (process == null) return false;
if (!process.WaitForExit(60000))
{
try { process.Kill(); } catch { }
return false;
}
return process.ExitCode == 0;
}
}
private static void CopyPayload(string sourceRoot, string targetRoot, IEnumerable<PackageFile> files)
{
Directory.CreateDirectory(targetRoot);
foreach (var file in files)
{
var source = PackageManifest.ResolveContainedPath(sourceRoot, file.RelativePath);
var target = PackageManifest.ResolveContainedPath(targetRoot, file.RelativePath);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.Copy(source, target, false);
if (!string.Equals(PackageManifest.Sha256(target), file.Sha256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Copied payload failed SHA-256 verification: " + file.RelativePath);
}
}
private static void RequirePayload(IEnumerable<PackageFile> files, string relativePath)
{
if (!files.Any(x => string.Equals(x.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)))
throw new InvalidDataException("Required payload file is missing from the manifest: " + relativePath);
}
private static void TryDeleteDirectory(string path, Action<string> report)
{
try { if (Directory.Exists(path)) Directory.Delete(path, true); }
catch (Exception ex) { report("WARNUNG: Verzeichnis konnte nicht bereinigt werden: " + path + " - " + ex.Message); }
}
private static void TryDeleteFile(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
private static string DesktopShortcutPath
{
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), ProductName + ".lnk"); }
}
private static string StartMenuShortcutPath
{
get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), ProductName, ProductName + ".lnk"); }
}
private string UninstallerPath
{
get { return Path.Combine(dataDirectory, "Setup", "Uninstall.exe"); }
}
}
}
@@ -0,0 +1,151 @@
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace BizTalkPlatformManagementTool.Setup
{
internal sealed class MainForm : Form
{
private readonly InstallerEngine engine;
private readonly bool uninstallMode;
private readonly TextBox output = new TextBox();
private readonly CheckBox desktopShortcut = new CheckBox();
private readonly Button installButton = new Button();
private readonly Button uninstallButton = new Button();
private bool busy;
public MainForm(InstallerEngine engine, bool uninstallMode)
{
this.engine = engine;
this.uninstallMode = uninstallMode;
Text = "BizTalk Platform Management Tool Setup";
Width = 780;
Height = 520;
MinimumSize = new Size(680, 420);
StartPosition = FormStartPosition.CenterScreen;
BuildUi();
FormClosing += OnFormClosing;
}
private void BuildUi()
{
var root = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), RowCount = 5, ColumnCount = 1 };
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.Controls.Add(new Label
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.1.0"
});
root.Controls.Add(new Label
{
AutoSize = true,
Padding = new Padding(0, 8, 0, 8),
Text = "Transaktionaler Installer mit SHA-256-Pruefung, Staging-Self-Test und automatischem Rollback.\r\nZiel: " + engine.InstallDirectory
});
desktopShortcut.Text = "Desktop-Verknuepfung fuer alle Benutzer erstellen";
desktopShortcut.Checked = true;
desktopShortcut.AutoSize = true;
desktopShortcut.Enabled = !uninstallMode;
root.Controls.Add(desktopShortcut);
output.Multiline = true;
output.ReadOnly = true;
output.ScrollBars = ScrollBars.Vertical;
output.Dock = DockStyle.Fill;
output.Font = new Font(FontFamily.GenericMonospace, 9);
root.Controls.Add(output);
var buttons = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft };
var closeButton = new Button { Text = "Schliessen", AutoSize = true };
closeButton.Click += (sender, args) => Close();
installButton.Text = engine.IsInstalled ? "Update installieren" : "Installieren";
installButton.AutoSize = true;
installButton.Enabled = engine.HasInstallPayload && !uninstallMode;
installButton.Click += (sender, args) => Run(false);
uninstallButton.Text = "Deinstallieren";
uninstallButton.AutoSize = true;
uninstallButton.Enabled = engine.IsInstalled;
uninstallButton.Click += (sender, args) => Run(true);
buttons.Controls.Add(closeButton);
buttons.Controls.Add(uninstallButton);
buttons.Controls.Add(installButton);
root.Controls.Add(buttons);
Controls.Add(root);
if (uninstallMode) Append("Deinstallationsmodus. Installer-Logs bleiben zu Diagnosezwecken unter ProgramData erhalten.");
else if (!engine.HasInstallPayload) Append("Kein Installationspayload neben Setup.exe gefunden. Dieser Aufruf erlaubt nur die Deinstallation.");
}
private void Run(bool uninstall)
{
if (uninstall && MessageBox.Show(this, "BizTalk Platform Management Tool wirklich deinstallieren?", "Deinstallation bestaetigen", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes)
return;
var createDesktopShortcut = desktopShortcut.Checked;
SetBusy(true);
Task.Run(() =>
{
try
{
if (uninstall) engine.Uninstall(Append);
else engine.Install(createDesktopShortcut, Append);
Append(uninstall ? "FERTIG: Deinstallation erfolgreich." : "FERTIG: Installation/Update erfolgreich.");
Invoke(new Action(() =>
{
installButton.Text = engine.IsInstalled ? "Update installieren" : "Installieren";
uninstallButton.Enabled = engine.IsInstalled;
}));
}
catch (Exception ex)
{
Append("FEHLER: " + ex);
Invoke(new Action(() => MessageBox.Show(this, ex.Message, "Setup fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error)));
}
finally
{
SetBusy(false);
}
});
}
private void Append(string message)
{
if (IsDisposed || Disposing) return;
if (InvokeRequired)
{
try { BeginInvoke(new Action<string>(Append), message); } catch (InvalidOperationException) { }
return;
}
output.AppendText("[" + DateTime.Now.ToString("HH:mm:ss") + "] " + message + Environment.NewLine);
}
private void SetBusy(bool value)
{
if (InvokeRequired)
{
try { BeginInvoke(new Action<bool>(SetBusy), value); } catch (InvalidOperationException) { }
return;
}
busy = value;
installButton.Enabled = !value && engine.HasInstallPayload && !uninstallMode;
uninstallButton.Enabled = !value && engine.IsInstalled;
desktopShortcut.Enabled = !value && !uninstallMode;
UseWaitCursor = value;
}
private void OnFormClosing(object sender, FormClosingEventArgs e)
{
if (!busy) return;
e.Cancel = true;
MessageBox.Show(this, "Das Setup arbeitet noch. Bitte warten Sie bis zum Abschluss.", "Setup laeuft", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace BizTalkPlatformManagementTool.Setup
{
public sealed class PackageFile
{
/// <summary>Gets or sets the normalized payload-relative path.</summary>
public string RelativePath { get; set; }
/// <summary>Gets or sets the declared file length.</summary>
public long Length { get; set; }
/// <summary>Gets or sets the lowercase SHA-256 digest.</summary>
public string Sha256 { get; set; }
}
public static class PackageManifest
{
/// <summary>Reads and cryptographically validates a complete application payload manifest.</summary>
public static IList<PackageFile> ValidateAndRead(string applicationDirectory, string manifestPath)
{
if (!Directory.Exists(applicationDirectory)) throw new DirectoryNotFoundException("Application payload missing: " + applicationDirectory);
if (!File.Exists(manifestPath)) throw new FileNotFoundException("Package manifest missing: " + manifestPath, manifestPath);
var files = new List<PackageFile>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in File.ReadAllLines(manifestPath, Encoding.UTF8))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue;
var parts = line.Split(new[] { '|' }, 3);
long length;
if (parts.Length != 3 || parts[0].Length != 64 || !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out length))
throw new InvalidDataException("Invalid package manifest line: " + rawLine);
var relative = NormalizeRelativePath(parts[2]);
if (!seen.Add(relative)) throw new InvalidDataException("Duplicate package manifest path: " + relative);
var fullPath = ResolveContainedPath(applicationDirectory, relative);
if (!File.Exists(fullPath)) throw new FileNotFoundException("Manifest payload file missing: " + relative, fullPath);
var info = new FileInfo(fullPath);
if (info.Length != length) throw new InvalidDataException("Payload size mismatch: " + relative);
var actualHash = Sha256(fullPath);
if (!string.Equals(actualHash, parts[0], StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Payload SHA-256 mismatch: " + relative);
files.Add(new PackageFile { RelativePath = relative, Length = length, Sha256 = actualHash });
}
if (files.Count == 0) throw new InvalidDataException("The package manifest does not contain payload files.");
var actualFiles = Directory.GetFiles(applicationDirectory, "*", SearchOption.AllDirectories)
.Select(x => NormalizeRelativePath(x.Substring(Path.GetFullPath(applicationDirectory).TrimEnd(Path.DirectorySeparatorChar).Length + 1)))
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
var declaredFiles = files.Select(x => x.RelativePath).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
if (!actualFiles.SequenceEqual(declaredFiles, StringComparer.OrdinalIgnoreCase))
throw new InvalidDataException("The application payload contains files not covered by the package manifest.");
return files;
}
/// <summary>Creates a deterministic manifest covering every application payload file.</summary>
public static void Write(string applicationDirectory, string manifestPath)
{
var root = Path.GetFullPath(applicationDirectory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var lines = Directory.GetFiles(applicationDirectory, "*", SearchOption.AllDirectories)
.Select(path => new FileInfo(path))
.OrderBy(info => info.FullName, StringComparer.OrdinalIgnoreCase)
.Select(info => Sha256(info.FullName) + "|" + info.Length.ToString(CultureInfo.InvariantCulture) + "|" + NormalizeRelativePath(info.FullName.Substring(root.Length)))
.ToArray();
File.WriteAllLines(manifestPath, lines, new UTF8Encoding(false));
}
/// <summary>Calculates the lowercase SHA-256 digest of a file.</summary>
public static string Sha256(string path)
{
using (var stream = File.OpenRead(path))
using (var algorithm = SHA256.Create())
{
var hash = algorithm.ComputeHash(stream);
var builder = new StringBuilder(hash.Length * 2);
foreach (var value in hash) builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
return builder.ToString();
}
}
/// <summary>Resolves a relative payload path and rejects directory traversal.</summary>
public static string ResolveContainedPath(string root, string relative)
{
var normalizedRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var result = Path.GetFullPath(Path.Combine(normalizedRoot, NormalizeRelativePath(relative).Replace('/', Path.DirectorySeparatorChar)));
if (!result.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Package path escapes the payload root: " + relative);
return result;
}
private static string NormalizeRelativePath(string path)
{
path = (path ?? string.Empty).Replace('\\', '/').Trim();
if (path.Length == 0 || path.StartsWith("/", StringComparison.Ordinal) || path.Contains("../") || path == ".." || Path.IsPathRooted(path))
throw new InvalidDataException("Unsafe package path: " + path);
return path;
}
}
}
@@ -0,0 +1,18 @@
using System;
using System.Windows.Forms;
namespace BizTalkPlatformManagementTool.Setup
{
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
var uninstallMode = args != null && args.Length == 1 && string.Equals(args[0], "--uninstall", StringComparison.OrdinalIgnoreCase);
Application.Run(new MainForm(new InstallerEngine(AppDomain.CurrentDomain.BaseDirectory), uninstallMode));
return 0;
}
}
}
@@ -0,0 +1,13 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("BizTalk Platform Management Tool Setup")]
[assembly: AssemblyDescription("Transactional installer and updater for BizTalk Platform Management Tool")]
[assembly: AssemblyCompany("BEW")]
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.1.0.0")]
[assembly: AssemblyFileVersion("2.1.0.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -0,0 +1,48 @@
using System;
using System.Globalization;
using System.IO;
using System.Text;
namespace BizTalkPlatformManagementTool.Setup
{
internal sealed class SetupOperationLog
{
private readonly object sync = new object();
private SetupOperationLog(string filePath)
{
FilePath = filePath;
}
public string FilePath { get; private set; }
public static SetupOperationLog Create(string dataDirectory, string operation)
{
try
{
var directory = Path.Combine(dataDirectory, "InstallerLogs");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "setup-" + DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + "-" + operation + ".log");
return new SetupOperationLog(path);
}
catch
{
return new SetupOperationLog(string.Empty);
}
}
public void Write(string level, string message)
{
if (string.IsNullOrEmpty(FilePath)) return;
var line = "[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + "][" + level + "] " + (message ?? string.Empty) + Environment.NewLine;
try
{
lock (sync) File.AppendAllText(FilePath, line, new UTF8Encoding(false));
}
catch
{
// Setup logging must not hide the actual installation result.
}
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.0.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application>
</compatibility>
</assembly>
@@ -12,6 +12,7 @@
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
@@ -45,7 +46,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 +59,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; }
}
}
+35 -3
View File
@@ -1,15 +1,27 @@
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>
[STAThread]
private static void Main()
private static int Main(string[] args)
{
if (args != null && args.Length == 1 && string.Equals(args[0], "--self-test", StringComparison.OrdinalIgnoreCase))
{
return RuntimeSelfTest.Run();
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
@@ -22,12 +34,32 @@ namespace BizTalkPlatformManagementTool
"Administrator Rights Required",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
return;
return 1;
}
Application.Run(new MainForm());
bool createdNew;
using (var mutex = new Mutex(true, @"Local\BizTalkPlatformManagementTool", out createdNew))
{
if (!createdNew)
{
MessageBox.Show(
"BizTalk Platform Management Tool is already running in this Windows session.",
"BizTalk Platform Management Tool",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return 2;
}
Application.Run(new MainForm());
GC.KeepAlive(mutex);
}
return 0;
}
/// <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.0.0")]
[assembly: AssemblyFileVersion("2.1.0.0")]
@@ -0,0 +1,77 @@
using System;
using System.IO;
using BizTalkPlatformManagementTool.Models;
using BizTalkPlatformManagementTool.Services;
namespace BizTalkPlatformManagementTool
{
/// <summary>
/// Provides a WMI-free smoke test used by the installer before and after activation.
/// </summary>
internal static class RuntimeSelfTest
{
public static int Run()
{
var directory = Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool.SelfTest." + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(directory);
var before = SampleSnapshot(ArtifactStates.SendPortStarted);
var after = SampleSnapshot(ArtifactStates.SendPortStopped);
var snapshotPath = Path.Combine(directory, "snapshot.json");
JsonFileStore.Save(snapshotPath, before);
var loaded = JsonFileStore.Load<BizTalkSnapshot>(snapshotPath);
SnapshotValidator.Validate(loaded);
var diff = SnapshotComparer.Compare(loaded, after);
if (diff.ArtifactDifferences.Count != 1)
{
throw new InvalidOperationException("Snapshot diff self-test returned an unexpected result.");
}
SnapshotStore.SaveSnapshotSet(Path.Combine(directory, "before.json"), loaded);
SnapshotStore.SaveDiffSet(Path.Combine(directory, "diff.json"), diff);
Console.WriteLine("SELF_TEST_OK version=" + BizTalkOperationService.Version);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine("SELF_TEST_FAILED " + ex);
return 1;
}
finally
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, true);
}
}
catch
{
// The self-test result is more important than temporary cleanup.
}
}
}
private static BizTalkSnapshot SampleSnapshot(int sendPortState)
{
var snapshot = new BizTalkSnapshot
{
ToolVersion = BizTalkOperationService.Version,
CreatedAt = DateTimeOffset.Now.ToString("o"),
Server = Environment.MachineName
};
var app = new ApplicationSnapshot { Application = "SelfTest" };
app.SendPorts.Add(new SendPortState
{
Application = app.Application,
Name = "SelfTest.SendPort",
Status = sendPortState
});
snapshot.Applications.Add(app);
return snapshot;
}
}
}
@@ -7,27 +7,60 @@ using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services
{
/// <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.0-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,13 +168,21 @@ namespace BizTalkPlatformManagementTool.Services
}
snapshot.HostInstances = snapshot.HostInstances.OrderBy(x => x.Server).ThenBy(x => x.InstanceName).ToList();
SnapshotValidator.Validate(snapshot);
_logger.Success("Snapshot created. Applications: " + snapshot.Applications.Count + ", host instances: " + snapshot.HostInstances.Count);
return snapshot;
}
}
/// <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);
foreach (var app in snapshot.Applications)
@@ -144,8 +217,15 @@ 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);
foreach (var item in snapshot.HostInstances.Where(x => x.RawState == ArtifactStates.HostStarted))
@@ -214,8 +294,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)
@@ -253,6 +346,13 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Saves a snapshot and its report sidecars.
/// </summary>
/// <param name="outputDirectory">The directory where output files are written.</param>
/// <param name="fileName">The primary JSON file name.</param>
/// <param name="snapshot">The snapshot to persist.</param>
/// <returns>The primary JSON file path.</returns>
public string SaveSnapshot(string outputDirectory, string fileName, BizTalkSnapshot snapshot)
{
Directory.CreateDirectory(outputDirectory);
@@ -262,6 +362,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 +378,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 +394,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 +439,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 +471,11 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Creates and connects a WMI client for a server.
/// </summary>
/// <param name="server">The BizTalk server or management host to connect to.</param>
/// <returns>A connected WMI client.</returns>
private BizTalkWmiClient CreateClient(string server)
{
var client = new BizTalkWmiClient(server, _logger);
@@ -351,16 +483,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 +533,11 @@ namespace BizTalkPlatformManagementTool.Services
};
}
/// <summary>
/// Converts integer method arguments into the object array required by WMI.
/// </summary>
/// <param name="values">The integer values from the operation step.</param>
/// <returns>An object array suitable for ManagementObject.InvokeMethod.</returns>
private static object[] ToObjects(int[] values)
{
if (values == null || values.Length == 0)
@@ -395,6 +553,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 +575,12 @@ namespace BizTalkPlatformManagementTool.Services
+ "]";
}
/// <summary>
/// Gets or creates an application snapshot bucket in a dictionary.
/// </summary>
/// <param name="apps">The application snapshot dictionary keyed by application name.</param>
/// <param name="appName">The application name to resolve.</param>
/// <returns>The existing or newly created application snapshot.</returns>
private static ApplicationSnapshot GetApplication(Dictionary<string, ApplicationSnapshot> apps, string appName)
{
ApplicationSnapshot app;
@@ -423,6 +592,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 +610,20 @@ namespace BizTalkPlatformManagementTool.Services
}
return UnknownApplication;
}
private static void DisposeAll(IEnumerable<ManagementObject> items)
{
if (items == null)
{
return;
}
foreach (var item in items)
{
if (item != null)
{
item.Dispose();
}
}
}
}
}
@@ -6,24 +6,53 @@ using System.Threading;
namespace BizTalkPlatformManagementTool.Services
{
/// <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();
@@ -118,6 +178,13 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Invokes a WMI method and validates that the method returned success.
/// </summary>
/// <param name="instance">The WMI object on which the method should be called.</param>
/// <param name="methodName">The method name to invoke.</param>
/// <param name="arguments">Optional method arguments.</param>
/// <returns>The WMI return code.</returns>
public uint InvokeMethod(ManagementObject instance, string methodName, params object[] arguments)
{
if (instance == null)
@@ -138,15 +205,36 @@ namespace BizTalkPlatformManagementTool.Services
throw new InvalidOperationException("WMI method failed. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ". WMI error: " + ex.Message, ex);
}
var returnCode = ExtractReturnCode(result);
if (returnCode != 0)
var output = result as ManagementBaseObject;
try
{
throw new InvalidOperationException("WMI method returned an error. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ", ReturnValue: " + returnCode.ToString(CultureInfo.InvariantCulture));
}
var returnCode = ExtractReturnCode(result);
if (returnCode != 0)
{
throw new InvalidOperationException("WMI method returned an error. Class: " + instance.Path.ClassName + ", method: " + methodToCall + ", object: " + SafeObjectName(instance) + ", ReturnValue: " + returnCode.ToString(CultureInfo.InvariantCulture));
}
return returnCode;
return returnCode;
}
finally
{
if (output != null)
{
output.Dispose();
}
}
}
/// <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 +251,24 @@ namespace BizTalkPlatformManagementTool.Services
}
}
Thread.Sleep(TimeSpan.FromSeconds(delay));
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
break;
}
Thread.Sleep(remaining < TimeSpan.FromSeconds(delay) ? remaining : TimeSpan.FromSeconds(delay));
}
throw new TimeoutException("Timeout while waiting for " + description + " [" + className + "." + keyProperty + "=" + keyValue + "]");
}
/// <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 +287,13 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Reads a WMI property as an integer without failing on missing or invalid properties.
/// </summary>
/// <param name="item">The WMI object or output parameter object.</param>
/// <param name="propertyName">The property to read.</param>
/// <param name="fallback">The value returned when the property cannot be read.</param>
/// <returns>The property value or the fallback value.</returns>
public static int SafeGetInt32(ManagementBaseObject item, string propertyName, int fallback)
{
try
@@ -205,6 +312,13 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Reads a WMI property as a Boolean without failing on missing or invalid properties.
/// </summary>
/// <param name="item">The WMI object or output parameter object.</param>
/// <param name="propertyName">The property to read.</param>
/// <param name="fallback">The value returned when the property cannot be read.</param>
/// <returns>The property value or the fallback value.</returns>
public static bool SafeGetBoolean(ManagementBaseObject item, string propertyName, bool fallback)
{
try
@@ -223,6 +337,9 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Ensures that the management scope is connected before a WMI operation runs.
/// </summary>
private void EnsureConnected()
{
if (_scope == null || !_scope.IsConnected)
@@ -231,6 +348,12 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Checks whether a WMI object exposes a property, using case-insensitive comparison.
/// </summary>
/// <param name="item">The WMI object to inspect.</param>
/// <param name="propertyName">The property name to find.</param>
/// <returns>True when the property exists; otherwise false.</returns>
private static bool HasProperty(ManagementBaseObject item, string propertyName)
{
foreach (PropertyData property in item.Properties)
@@ -244,6 +367,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 +394,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 +411,11 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Builds a readable object name for diagnostics without letting WMI metadata failures escape.
/// </summary>
/// <param name="instance">The WMI object being described.</param>
/// <returns>A relative WMI path or fallback object name.</returns>
private static string SafeObjectName(ManagementObject instance)
{
try
@@ -286,6 +428,12 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Resolves the exact method casing exposed by the WMI class.
/// </summary>
/// <param name="instance">The WMI object whose class should be inspected.</param>
/// <param name="requestedName">The requested method name.</param>
/// <returns>The method name as exposed by WMI.</returns>
private static string ResolveMethodName(ManagementObject instance, string requestedName)
{
using (var managementClass = new ManagementClass(instance.Scope, new ManagementPath(instance.Path.ClassName), null))
@@ -302,6 +450,11 @@ namespace BizTalkPlatformManagementTool.Services
throw new MissingMethodException(instance.Path.ClassName, requestedName);
}
/// <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 +471,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,18 @@ 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'))
{
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,11 +114,20 @@ 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)
{
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,75 @@ 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>
private static void WriteAtomically(string path, string content)
{
var temporaryPath = path + ".tmp." + Guid.NewGuid().ToString("N");
var backupPath = path + ".bak." + Guid.NewGuid().ToString("N");
try
{
File.WriteAllText(temporaryPath, content, Utf8NoBom);
if (!File.Exists(path))
{
File.Move(temporaryPath, path);
return;
}
try
{
File.Replace(temporaryPath, path, backupPath, true);
TryDelete(backupPath);
}
catch (PlatformNotSupportedException)
{
ReplaceWithRenameFallback(path, temporaryPath, backupPath);
}
catch (NotSupportedException)
{
ReplaceWithRenameFallback(path, temporaryPath, backupPath);
}
}
finally
{
TryDelete(temporaryPath);
}
}
private static void ReplaceWithRenameFallback(string path, string temporaryPath, string backupPath)
{
File.Move(path, backupPath);
try
{
File.Move(temporaryPath, path);
TryDelete(backupPath);
}
catch
{
if (!File.Exists(path) && File.Exists(backupPath))
{
File.Move(backupPath, path);
}
throw;
}
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Temporary cleanup is best-effort and must not hide the save result.
}
}
}
}
@@ -5,39 +5,107 @@ using System.Threading;
namespace BizTalkPlatformManagementTool.Services
{
/// <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
@@ -108,6 +201,9 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>
/// Deletes log files older than the configured retention window.
/// </summary>
private void CleanupOldLogs()
{
if (Interlocked.Exchange(ref _cleanupDone, 1) == 1)
@@ -132,5 +228,20 @@ namespace BizTalkPlatformManagementTool.Services
// Log retention cleanup is best-effort.
}
}
private static string ResolveLogDirectory()
{
var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
var preferred = Path.Combine(commonData, "BizTalkPlatformManagementTool", "Logs");
try
{
Directory.CreateDirectory(preferred);
return preferred;
}
catch
{
return AppDomain.CurrentDomain.BaseDirectory;
}
}
}
}
@@ -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,11 @@ namespace BizTalkPlatformManagementTool.Services
foreach (var item in before)
{
beforeMap[item.InstanceName ?? string.Empty] = item;
beforeMap[SnapshotValidator.ArtifactKey(item.Server, item.InstanceName)] = item;
}
foreach (var item in after)
{
afterMap[item.InstanceName ?? string.Empty] = item;
afterMap[SnapshotValidator.ArtifactKey(item.Server, item.InstanceName)] = item;
}
foreach (var pair in afterMap)
@@ -126,6 +166,15 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <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 +187,11 @@ namespace BizTalkPlatformManagementTool.Services
});
}
/// <summary>
/// Flattens receive locations from all applications into a case-insensitive name map.
/// </summary>
/// <param name="snapshot">The snapshot to flatten.</param>
/// <returns>A map keyed by receive location name.</returns>
private static Dictionary<string, ReceiveLocationState> FlattenReceiveLocations(BizTalkSnapshot snapshot)
{
var map = new Dictionary<string, ReceiveLocationState>(StringComparer.OrdinalIgnoreCase);
@@ -145,12 +199,17 @@ namespace BizTalkPlatformManagementTool.Services
{
foreach (var item in app.ReceiveLocations)
{
map[item.Name ?? string.Empty] = item;
map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item;
}
}
return map;
}
/// <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 +217,17 @@ namespace BizTalkPlatformManagementTool.Services
{
foreach (var item in app.SendPorts)
{
map[item.Name ?? string.Empty] = item;
map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item;
}
}
return map;
}
/// <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 +235,7 @@ namespace BizTalkPlatformManagementTool.Services
{
foreach (var item in app.Orchestrations)
{
map[item.Name ?? string.Empty] = item;
map[SnapshotValidator.ArtifactKey(app.Application, item.Name)] = item;
}
}
return map;
@@ -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,124 @@
using System;
using System.Collections.Generic;
using BizTalkPlatformManagementTool.Models;
namespace BizTalkPlatformManagementTool.Services
{
/// <summary>
/// Normalizes deserialized legacy snapshots and rejects ambiguous or unsafe input.
/// </summary>
public static class SnapshotValidator
{
/// <summary>Normalizes optional collections and rejects missing or duplicate artifact identities.</summary>
public static void Validate(BizTalkSnapshot snapshot)
{
if (snapshot == null)
{
throw new InvalidOperationException("The snapshot is empty.");
}
snapshot.Applications = snapshot.Applications ?? new List<ApplicationSnapshot>();
snapshot.HostInstances = snapshot.HostInstances ?? new List<HostInstanceState>();
var receiveLocations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var sendPorts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var orchestrations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var app in snapshot.Applications)
{
if (app == null || string.IsNullOrWhiteSpace(app.Application))
{
throw new InvalidOperationException("The snapshot contains an application without a name.");
}
app.ReceiveLocations = app.ReceiveLocations ?? new List<ReceiveLocationState>();
app.SendPorts = app.SendPorts ?? new List<SendPortState>();
app.Orchestrations = app.Orchestrations ?? new List<OrchestrationState>();
ValidateArtifacts(app.Application, "receive location", app.ReceiveLocations, x => x == null ? null : x.Name, receiveLocations);
ValidateArtifacts(app.Application, "send port", app.SendPorts, x => x == null ? null : x.Name, sendPorts);
ValidateArtifacts(app.Application, "orchestration", app.Orchestrations, x => x == null ? null : x.Name, orchestrations);
}
var hostInstances = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var host in snapshot.HostInstances)
{
if (host == null || string.IsNullOrWhiteSpace(host.InstanceName))
{
throw new InvalidOperationException("The snapshot contains a host instance without an instance name.");
}
if (!hostInstances.Add(host.InstanceName))
{
throw new InvalidOperationException("The snapshot contains the host instance more than once: " + host.InstanceName);
}
host.StateText = ArtifactStates.FormatHostInstance(host.RawState);
}
}
/// <summary>Validates a snapshot and ensures it belongs to the requested operation server.</summary>
public static void EnsureServerMatches(BizTalkSnapshot snapshot, string targetServer)
{
Validate(snapshot);
if (string.IsNullOrWhiteSpace(snapshot.Server) || string.IsNullOrWhiteSpace(targetServer))
{
throw new InvalidOperationException("Snapshot server and target server must both be specified before a restore plan can be created.");
}
if (!ServerNamesEqual(snapshot.Server, targetServer))
{
throw new InvalidOperationException("The snapshot belongs to server '" + snapshot.Server + "' but the selected restore target is '" + targetServer + "'.");
}
}
/// <summary>Compares server names while accepting short-name/FQDN variants of the same host.</summary>
public static bool ServerNamesEqual(string left, string right)
{
var normalizedLeft = NormalizeServer(left);
var normalizedRight = NormalizeServer(right);
if (string.Equals(normalizedLeft, normalizedRight, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return string.Equals(ShortName(normalizedLeft), ShortName(normalizedRight), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Builds the collision-safe identity used for application artifacts.</summary>
public static string ArtifactKey(string application, string name)
{
return (application ?? string.Empty).Trim() + "\u001f" + (name ?? string.Empty).Trim();
}
private static void ValidateArtifacts<T>(string application, string type, IEnumerable<T> values, Func<T, string> getName, HashSet<string> keys)
{
foreach (var value in values)
{
var name = getName(value);
if (value == null || string.IsNullOrWhiteSpace(name))
{
throw new InvalidOperationException("Application '" + application + "' contains a " + type + " without a name.");
}
var key = ArtifactKey(application, name);
if (!keys.Add(key))
{
throw new InvalidOperationException("Application '" + application + "' contains the " + type + " more than once: " + name);
}
}
}
private static string NormalizeServer(string value)
{
value = (value ?? string.Empty).Trim().TrimStart('\\');
if (value == "." || string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase))
{
return Environment.MachineName;
}
return value;
}
private static string ShortName(string value)
{
var index = value.IndexOf('.');
return index < 0 ? value : value.Substring(0, index);
}
}
}
+290 -25
View File
@@ -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,26 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Handles the Shutdown button click and runs the guarded shutdown workflow.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void ShutdownClick(object sender, EventArgs e)
{
if (!ConfirmDangerousAction("Shutdown"))
{
return;
}
var options = GetOptions();
RunAsync("Preparing shutdown...", () =>
{
var snapshot = _service.CreateSnapshot(options.Server);
_service.SaveSnapshot(options.OutputDirectory, "before.json", snapshot);
var plan = _service.CreateShutdownPlan(snapshot, options.Server);
_service.SavePlan(options.OutputDirectory, "shutdown-plan.json", plan);
var planPath = _service.SavePlan(options.OutputDirectory, "shutdown-plan.json", plan);
ShowPlan(plan);
if (!options.DryRun && !ConfirmPreparedPlan("Shutdown", plan, options.Server, planPath))
{
_logger.Warning("Shutdown cancelled after plan review. No runtime state was changed.");
return;
}
_service.ExecutePlan(plan, options);
if (!options.DryRun)
{
@@ -255,20 +390,25 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <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);
var planPath = _service.SavePlan(options.OutputDirectory, "restore-plan.json", plan);
ShowPlan(plan);
if (!options.DryRun && !ConfirmPreparedPlan("Restore", plan, options.Server, planPath))
{
_logger.Warning("Restore cancelled after plan review. No runtime state was changed.");
return;
}
_service.ExecutePlan(plan, options);
if (!options.DryRun)
{
@@ -279,6 +419,11 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <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 +432,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 +466,10 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Reads and normalizes the current UI options.
/// </summary>
/// <returns>The runtime options selected by the user.</returns>
private OperationOptions GetOptions()
{
var server = string.IsNullOrWhiteSpace(_serverTextBox.Text) ? Environment.MachineName : _serverTextBox.Text.Trim();
@@ -330,27 +489,52 @@ namespace BizTalkPlatformManagementTool.Ui
};
}
/// <summary>
/// Resolves a restore state file against the output directory when it is relative.
/// </summary>
/// <param name="options">The options containing the state file and output directory.</param>
/// <returns>The absolute or output-relative state file path.</returns>
private string ResolveStateFile(OperationOptions options)
{
return Path.IsPathRooted(options.StateFile) ? options.StateFile : Path.Combine(options.OutputDirectory, options.StateFile);
}
private bool ConfirmDangerousAction(string actionName)
/// <summary>
/// Confirms a fully prepared runtime-changing plan immediately before execution.
/// </summary>
/// <param name="actionName">The action name displayed in the confirmation dialog.</param>
/// <returns>True when the action may continue; otherwise false.</returns>
private bool ConfirmPreparedPlan(string actionName, OperationPlan plan, string server, string planPath)
{
if (_dryRunCheckBox.Checked)
var confirmed = false;
Action showConfirmation = () =>
{
return true;
}
var executableSteps = plan.Steps.Count(x => x.Execute);
var result = MessageBox.Show(
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 +563,10 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Displays snapshot differences in the status grid.
/// </summary>
/// <param name="diff">The diff to display.</param>
private void ShowDiff(SnapshotDiff diff)
{
InvokeIfRequired(() =>
@@ -395,6 +583,10 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Displays an operation plan in the status grid before or during execution.
/// </summary>
/// <param name="plan">The plan to display.</param>
private void ShowPlan(OperationPlan plan)
{
InvokeIfRequired(() =>
@@ -407,6 +599,10 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Appends one operation log entry to the log grid.
/// </summary>
/// <param name="entry">The log entry to display.</param>
private void AppendLog(LogEntry entry)
{
InvokeIfRequired(() =>
@@ -429,10 +625,16 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Enables or disables action buttons and updates the status strip.
/// </summary>
/// <param name="busy">True while a background operation is running.</param>
/// <param name="status">The status text to display.</param>
private void SetBusy(bool busy, string status)
{
InvokeIfRequired(() =>
{
_isBusy = busy;
_diagnoseButton.Enabled = !busy;
_beforeButton.Enabled = !busy;
_afterButton.Enabled = !busy;
@@ -445,6 +647,11 @@ namespace BizTalkPlatformManagementTool.Ui
});
}
/// <summary>
/// Handles the output directory Browse button click.
/// </summary>
/// <param name="sender">The control that raised the event.</param>
/// <param name="e">The event arguments.</param>
private void BrowseButtonClick(object sender, EventArgs e)
{
using (var dialog = new FolderBrowserDialog())
@@ -457,16 +664,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)
{
// The form was closed between the state check and BeginInvoke.
}
}
else
{
@@ -474,11 +692,41 @@ namespace BizTalkPlatformManagementTool.Ui
}
}
/// <summary>
/// Prevents the form from being disposed while a maintenance operation is active.
/// </summary>
private void MainFormClosing(object sender, FormClosingEventArgs e)
{
if (!_isBusy)
{
return;
}
e.Cancel = true;
MessageBox.Show(
this,
"A BizTalk operation is still running. Wait for it to finish before closing the tool.",
"Operation in progress",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
}
/// <summary>
/// Creates a right-aligned label for form inputs.
/// </summary>
/// <param name="text">The label text.</param>
/// <returns>The configured label control.</returns>
private static Label Label(string text)
{
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 +734,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 +782,10 @@ namespace BizTalkPlatformManagementTool.Ui
}
}
/// <summary>
/// Applies standard read-only display settings to a grid.
/// </summary>
/// <param name="grid">The grid to configure.</param>
private static void ConfigureGrid(DataGridView grid)
{
grid.Dock = DockStyle.Fill;
@@ -538,6 +798,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.0.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>