Add fail-closed shutdown drain checkpoint

This commit is contained in:
2026-08-26 12:02:37 +02:00
parent fe3c84a27f
commit b18adab26d
21 changed files with 520 additions and 44 deletions
@@ -23,7 +23,7 @@ namespace BizTalkPlatformManagementTool.Setup
private const string ProductName = "BizTalk Platform Management Tool";
/// <summary>Aktuelle Produktversion des Installers und Uninstall-Eintrags.</summary>
private const string ProductVersion = "2.3.1";
private const string ProductVersion = "2.3.2";
/// <summary>
/// Wartezeiten zwischen Wiederholungen atomarer Verzeichnisverschiebungen.
@@ -65,7 +65,7 @@ namespace BizTalkPlatformManagementTool.Setup
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.3.1"
Text = "BizTalk Platform Management Tool 2.3.2"
});
root.Controls.Add(new Label
{
@@ -8,6 +8,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.3.1.0")]
[assembly: AssemblyFileVersion("2.3.1.0")]
[assembly: AssemblyVersion("2.3.2.0")]
[assembly: AssemblyFileVersion("2.3.2.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.3.1.0" name="BizTalkPlatformManagementTool.Setup" />
<assemblyIdentity version="2.3.2.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
@@ -35,6 +35,11 @@ namespace BizTalkPlatformManagementTool.Models
/// </summary>
ReceiveLocation,
/// <summary>
/// An operator confirmation boundary between shutdown phases.
/// </summary>
OperatorCheckpoint,
/// <summary>
/// A send port step.
/// </summary>
@@ -249,6 +254,15 @@ namespace BizTalkPlatformManagementTool.Models
/// <summary>The step failed, while subsequent independent steps were still attempted.</summary>
public const string Failed = "Failed";
/// <summary>The operator explicitly confirmed a non-mutating checkpoint.</summary>
public const string Confirmed = "Confirmed";
/// <summary>The step was not reached because the operator stopped at a checkpoint.</summary>
public const string NotExecuted = "NotExecuted";
/// <summary>The operator declined to continue at a checkpoint.</summary>
public const string Declined = "Declined";
}
/// <summary>
@@ -359,10 +373,32 @@ namespace BizTalkPlatformManagementTool.Models
[DataMember(Order = 13)]
public List<OperationStepResult> Steps { get; set; }
/// <summary>Gets or sets the last durable operator-checkpoint decision.</summary>
[DataMember(Order = 14, EmitDefaultValue = false)]
public string CheckpointDecision { get; set; }
/// <summary>Gets or sets the local timestamp of the checkpoint decision.</summary>
[DataMember(Order = 15, EmitDefaultValue = false)]
public string CheckpointAt { get; set; }
/// <summary>Gets or sets whether the operator stopped the remaining shutdown phases.</summary>
[DataMember(Order = 16)]
public bool OperatorStopped { get; set; }
/// <summary>Gets or sets how many remaining plan rows were not reached.</summary>
[DataMember(Order = 17)]
public int NotExecutedCount { get; set; }
/// <summary>Gets whether operator attention is required.</summary>
public bool HasFailures
{
get { return FailedCount > 0 || !string.IsNullOrWhiteSpace(InitializationError) || !string.IsNullOrWhiteSpace(PostSnapshotError); }
}
/// <summary>Gets whether failure or an intentional operator stop needs review.</summary>
public bool RequiresOperatorReview
{
get { return HasFailures || OperatorStopped; }
}
}
}
@@ -9,6 +9,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: ComVisible(false)]
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
[assembly: AssemblyVersion("2.3.1.0")]
[assembly: AssemblyFileVersion("2.3.1.0")]
[assembly: AssemblyVersion("2.3.2.0")]
[assembly: AssemblyFileVersion("2.3.2.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -16,7 +16,7 @@ namespace BizTalkPlatformManagementTool.Services
/// <summary>
/// Current tool version written into generated snapshots.
/// </summary>
public const string Version = "2.3.1-net461";
public const string Version = "2.3.2-net461";
/// <summary>
/// Fallback application name used when WMI does not expose an application property.
@@ -198,6 +198,19 @@ namespace BizTalkPlatformManagementTool.Services
}
}
if (HasShutdownWorkAfterInboundPhase(snapshot, server))
{
plan.Steps.Add(new OperationStep
{
Kind = OperationStepKind.OperatorCheckpoint.ToString(),
Application = string.Empty,
Name = "Inbound drain verification",
Action = "Confirm that in-flight BizTalk processing has drained before continuing shutdown",
Execute = true,
Warning = "The operator must verify Group Hub/runtime monitoring. No automatic empty-state claim is made."
});
}
foreach (var app in snapshot.Applications)
{
foreach (var item in app.Orchestrations.Where(x => x.OrchestrationStatus == ArtifactStates.OrchestrationStarted))
@@ -349,6 +362,21 @@ namespace BizTalkPlatformManagementTool.Services
/// <param name="plan">The ordered plan to execute.</param>
/// <param name="options">The runtime options controlling server, dry-run and wait behavior.</param>
public OperationExecutionReport ExecutePlan(OperationPlan plan, OperationOptions options)
{
return ExecutePlan(plan, options, null);
}
/// <summary>
/// Executes a plan with an optional fail-closed operator checkpoint callback.
/// </summary>
/// <param name="plan">The ordered plan to execute.</param>
/// <param name="options">The runtime execution options.</param>
/// <param name="checkpointHandler">Operator callback used by real shutdown plans.</param>
/// <returns>The complete execution report.</returns>
internal OperationExecutionReport ExecutePlan(
OperationPlan plan,
OperationOptions options,
OperationCheckpointHandler checkpointHandler)
{
if (plan == null || options == null)
{
@@ -362,14 +390,14 @@ namespace BizTalkPlatformManagementTool.Services
if (options.DryRun)
{
// Dry-run benötigt absichtlich weder WMI-Verbindung noch Servicezugriff.
return executor.Execute(plan, options, null);
return executor.Execute(plan, options, null, checkpointHandler);
}
try
{
using (var runtime = new WmiOperationStepRuntime(options.Server, _logger))
{
return executor.Execute(plan, options, runtime);
return executor.Execute(plan, options, runtime, checkpointHandler);
}
}
catch (Exception ex)
@@ -391,6 +419,19 @@ namespace BizTalkPlatformManagementTool.Services
}
}
/// <summary>Checks whether a shutdown has a runtime phase after receive locations.</summary>
/// <param name="snapshot">The validated source snapshot.</param>
/// <param name="server">The selected host-instance server.</param>
/// <returns>True when orchestration, send-port or host-instance work follows.</returns>
private static bool HasShutdownWorkAfterInboundPhase(BizTalkSnapshot snapshot, string server)
{
return snapshot.Applications.Any(app =>
app.Orchestrations.Any(item => item.OrchestrationStatus == ArtifactStates.OrchestrationStarted)
|| app.SendPorts.Any(item => item.Status == ArtifactStates.SendPortStarted))
|| snapshot.HostInstances.Any(item => item.RawState == ArtifactStates.HostStarted
&& (string.IsNullOrWhiteSpace(item.Server) || SnapshotValidator.ServerNamesEqual(item.Server, server)));
}
/// <summary>
/// Saves a snapshot and its report sidecars.
/// </summary>
@@ -30,6 +30,14 @@ namespace BizTalkPlatformManagementTool.Services
RuntimeStepOutcome Execute(OperationStep step, OperationOptions options);
}
/// <summary>
/// Requests an operator decision at a non-mutating boundary in an operation plan.
/// </summary>
/// <param name="checkpoint">The checkpoint plan row.</param>
/// <param name="progress">The durable execution progress before the checkpoint.</param>
/// <returns>True to continue with later phases; false to stop safely.</returns>
internal delegate bool OperationCheckpointHandler(OperationStep checkpoint, OperationExecutionReport progress);
/// <summary>
/// Runs every independent plan step, records durable outcomes and deliberately
/// continues after isolated failures.
@@ -56,6 +64,23 @@ namespace BizTalkPlatformManagementTool.Services
/// <param name="runtime">The runtime implementation; optional only during dry-run.</param>
/// <returns>A complete per-step execution report.</returns>
public OperationExecutionReport Execute(OperationPlan plan, OperationOptions options, IOperationStepRuntime runtime)
{
return Execute(plan, options, runtime, null);
}
/// <summary>
/// Executes or simulates every plan step with an optional operator checkpoint handler.
/// </summary>
/// <param name="plan">The validated operation plan.</param>
/// <param name="options">The execution options.</param>
/// <param name="runtime">The runtime implementation; optional only during dry-run.</param>
/// <param name="checkpointHandler">UI-independent operator decision callback.</param>
/// <returns>A complete per-step execution report, including unreached rows.</returns>
internal OperationExecutionReport Execute(
OperationPlan plan,
OperationOptions options,
IOperationStepRuntime runtime,
OperationCheckpointHandler checkpointHandler)
{
if (plan == null || options == null)
{
@@ -71,6 +96,7 @@ namespace BizTalkPlatformManagementTool.Services
{
var step = plan.Steps[index];
var stepResult = NewStepResult(index + 1, step);
var stopAfterCurrentStep = false;
try
{
if (!step.Execute)
@@ -85,6 +111,30 @@ namespace BizTalkPlatformManagementTool.Services
report.DryRunCount++;
Info("DRY RUN: " + DescribeStep(step));
}
else if (IsOperatorCheckpoint(step))
{
Info("Operator checkpoint reached after the receive-location phase: " + step.Action);
report.CheckpointAt = DateTimeOffset.Now.ToString("o");
if (checkpointHandler == null)
{
throw new InvalidOperationException("No operator checkpoint handler is available. Shutdown stops safely before later runtime phases.");
}
if (checkpointHandler(step, report))
{
report.CheckpointDecision = "Continue";
stepResult.Outcome = OperationStepOutcomes.Confirmed;
Success("Operator confirmed that the BizTalk environment is drained. Continuing with orchestrations, send ports and host instances.");
}
else
{
report.CheckpointDecision = "Stop";
report.OperatorStopped = true;
stepResult.Outcome = OperationStepOutcomes.Declined;
stopAfterCurrentStep = true;
Warning("Operator stopped the shutdown safely at the drain checkpoint. No later shutdown phase will be executed.");
}
}
else
{
Info("Executing step " + (index + 1) + "/" + plan.Steps.Count + ": " + DescribeStep(step));
@@ -107,13 +157,30 @@ namespace BizTalkPlatformManagementTool.Services
stepResult.Outcome = OperationStepOutcomes.Failed;
stepResult.Error = FormatException(ex);
report.FailedCount++;
Error("STEP FAILED; continuing with remaining independent steps: " + DescribeStep(step) + ". Error: " + stepResult.Error);
if (IsOperatorCheckpoint(step))
{
report.CheckpointDecision = "Error";
report.CheckpointAt = report.CheckpointAt ?? DateTimeOffset.Now.ToString("o");
report.OperatorStopped = true;
stopAfterCurrentStep = true;
Error("OPERATOR CHECKPOINT FAILED CLOSED; no later shutdown phase will be executed. Error: " + stepResult.Error);
}
else
{
Error("STEP FAILED; continuing with remaining independent steps: " + DescribeStep(step) + ". Error: " + stepResult.Error);
}
}
finally
{
stepResult.FinishedAt = DateTimeOffset.Now.ToString("o");
report.Steps.Add(stepResult);
}
if (stopAfterCurrentStep)
{
AddNotExecutedSteps(plan, index + 1, report);
break;
}
}
report.FinishedAt = DateTimeOffset.Now.ToString("o");
@@ -121,8 +188,14 @@ namespace BizTalkPlatformManagementTool.Services
+ ", already_satisfied=" + report.AlreadySatisfiedCount
+ ", skipped=" + report.SkippedCount
+ ", dry_run=" + report.DryRunCount
+ ", failed=" + report.FailedCount + ".";
if (report.FailedCount == 0)
+ ", failed=" + report.FailedCount
+ ", not_executed=" + report.NotExecutedCount
+ ", checkpoint=" + (report.CheckpointDecision ?? "not_required") + ".";
if (report.OperatorStopped && report.FailedCount == 0)
{
Warning(summary + " Shutdown stopped safely by operator; review the partial-state snapshot and report.");
}
else if (report.FailedCount == 0)
{
Success(summary);
}
@@ -133,6 +206,31 @@ namespace BizTalkPlatformManagementTool.Services
return report;
}
/// <summary>Checks whether a plan row is the non-mutating operator boundary.</summary>
/// <param name="step">The plan row.</param>
/// <returns>True for the stable operator-checkpoint kind.</returns>
private static bool IsOperatorCheckpoint(OperationStep step)
{
return step != null && string.Equals(step.Kind, OperationStepKind.OperatorCheckpoint.ToString(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Adds auditable results for all rows intentionally not reached after a stop.</summary>
/// <param name="plan">The source plan.</param>
/// <param name="startIndex">The zero-based first unreached row.</param>
/// <param name="report">The report receiving the unreached rows.</param>
private static void AddNotExecutedSteps(OperationPlan plan, int startIndex, OperationExecutionReport report)
{
for (var index = startIndex; index < plan.Steps.Count; index++)
{
var result = NewStepResult(index + 1, plan.Steps[index]);
result.Outcome = OperationStepOutcomes.NotExecuted;
result.Error = "Not reached because the operator stopped at the inbound-drain checkpoint.";
result.FinishedAt = DateTimeOffset.Now.ToString("o");
report.Steps.Add(result);
report.NotExecutedCount++;
}
}
/// <summary>Creates the common execution-report header.</summary>
/// <param name="plan">The source plan.</param>
/// <param name="options">The execution options.</param>
@@ -417,7 +417,7 @@ namespace BizTalkPlatformManagementTool.Ui
_logger.Warning("Shutdown cancelled after plan review. No runtime state was changed.");
return;
}
var report = _service.ExecutePlan(plan, options);
var report = _service.ExecutePlan(plan, options, ConfirmInboundDrainCheckpoint);
CapturePostOperationSnapshot(options, report, "shutdown-after.json");
var reportPath = _service.SaveExecutionReport(options.OutputDirectory, "shutdown-result.json", report);
ShowExecutionReport(report);
@@ -577,8 +577,14 @@ namespace BizTalkPlatformManagementTool.Ui
if (report != null && report.HasFailures)
{
throw new InvalidOperationException(
"Plan completed with failures, but all remaining independent steps were attempted. " +
"Failed steps: " + report.FailedCount + ". Review: " + reportPath);
"Plan requires operator review. Failed steps: " + report.FailedCount
+ "; later steps not executed: " + report.NotExecutedCount + ". Review: " + reportPath);
}
if (report != null && report.OperatorStopped)
{
throw new OperationCanceledException(
"Shutdown stopped safely at the inbound-drain checkpoint. Later steps not executed: "
+ report.NotExecutedCount + ". Review: " + reportPath);
}
}
@@ -641,6 +647,11 @@ namespace BizTalkPlatformManagementTool.Ui
_logger.Success("Operation completed.");
SetBusy(false, "Ready.");
}
catch (OperationCanceledException ex)
{
_logger.Warning(ex.Message);
SetBusy(false, "Stopped safely: " + ex.Message);
}
catch (Exception ex)
{
_logger.Error(FormatException(ex));
@@ -695,9 +706,11 @@ namespace BizTalkPlatformManagementTool.Ui
var confirmed = false;
Action showConfirmation = () =>
{
var executableSteps = plan.Steps.Count(x => x.Execute);
var executableSteps = plan.Steps.Count(x => x.Execute && !string.Equals(x.Kind, OperationStepKind.OperatorCheckpoint.ToString(), StringComparison.OrdinalIgnoreCase));
var checkpointCount = plan.Steps.Count(x => x.Execute && string.Equals(x.Kind, OperationStepKind.OperatorCheckpoint.ToString(), StringComparison.OrdinalIgnoreCase));
var result = MessageBox.Show(
actionName + " will execute " + executableSteps + " step(s) on server '" + server + "'.\n\n"
+ (checkpointCount == 0 ? string.Empty : "The saved plan contains an operator drain checkpoint after all receive locations.\n\n")
+ "The exact plan was saved to:\n" + planPath + "\n\nContinue now?",
"Confirm Prepared BizTalk Plan",
MessageBoxButtons.YesNo,
@@ -717,6 +730,54 @@ namespace BizTalkPlatformManagementTool.Ui
return confirmed;
}
/// <summary>
/// Pauses a real shutdown after the receive-location phase until the operator
/// confirms that the enterprise environment has drained.
/// </summary>
/// <param name="checkpoint">The persisted checkpoint plan row.</param>
/// <param name="progress">The execution results already reached.</param>
/// <returns>True only after an explicit Yes decision.</returns>
private bool ConfirmInboundDrainCheckpoint(OperationStep checkpoint, OperationExecutionReport progress)
{
var confirmed = false;
Action showConfirmation = () =>
{
var receiveResults = progress.Steps
.Where(x => string.Equals(x.Kind, OperationStepKind.ReceiveLocation.ToString(), StringComparison.OrdinalIgnoreCase))
.ToList();
var succeeded = receiveResults.Count(x => x.Outcome == OperationStepOutcomes.Succeeded);
var alreadyDisabled = receiveResults.Count(x => x.Outcome == OperationStepOutcomes.AlreadySatisfied);
var failed = receiveResults.Count(x => x.Outcome == OperationStepOutcomes.Failed);
var failureWarning = failed == 0
? string.Empty
: "\nWARNING: " + failed + " receive location(s) failed. Review the red Operation Log entries before continuing.\n";
var result = MessageBox.Show(
"The receive-location shutdown phase has finished.\n\n"
+ "Disabled successfully: " + succeeded + "\n"
+ "Already disabled: " + alreadyDisabled + "\n"
+ "Failed: " + failed + "\n"
+ failureWarning + "\n"
+ "Keep this dialog open while you verify in BizTalk Group Hub and your enterprise monitoring that no new inbound work arrives and all in-flight service instances/messages have drained.\n\n"
+ "Continue with orchestrations, send ports and host instances?\n\n"
+ "Yes = continue shutdown. No = stop safely and persist all remaining steps as NotExecuted.",
"Confirm BizTalk Inbound Drain",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
confirmed = result == DialogResult.Yes;
};
if (InvokeRequired)
{
Invoke(showConfirmation);
}
else
{
showConfirmation();
}
return confirmed;
}
/// <summary>
/// Confirms the stronger emergency-recovery contract, including the preserved
/// source snapshot and automatic Enterprise SSO prerequisite.
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.3.1.0" name="BizTalkPlatformManagementTool" />
<assemblyIdentity version="2.3.2.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>