Add resilient emergency restore for partial BizTalk operations

This commit is contained in:
2026-08-19 17:45:20 +02:00
parent 08626197be
commit 9ce7e8d45a
19 changed files with 1221 additions and 120 deletions
@@ -25,6 +25,12 @@ namespace BizTalkPlatformManagementTool.Tests
Run("DiffUsesApplicationAndNameIdentity", DiffUsesApplicationAndNameIdentity);
Run("RestoreRejectsDifferentServer", RestoreRejectsDifferentServer);
Run("RestorePlanUsesSafeOrder", RestorePlanUsesSafeOrder);
Run("ShutdownPlanUsesGlobalSafeOrder", ShutdownPlanUsesGlobalSafeOrder);
Run("EmergencyRestorePlanStartsSsoFirst", EmergencyRestorePlanStartsSsoFirst);
Run("HostInstancePlanAcceptsShortAndFqdnServer", HostInstancePlanAcceptsShortAndFqdnServer);
Run("PlanExecutionContinuesAfterSchedulerFailure", PlanExecutionContinuesAfterSchedulerFailure);
Run("PlanExecutionSkipsAlreadySatisfiedState", PlanExecutionSkipsAlreadySatisfiedState);
Run("ExecutionReportRoundTripPreservesFailure", ExecutionReportRoundTripPreservesFailure);
Run("CsvNeutralizesFormulaValues", CsvNeutralizesFormulaValues);
Run("PackageManifestRejectsTampering", PackageManifestRejectsTampering);
Run("PackageManifestRejectsUndeclaredAndTraversalFiles", PackageManifestRejectsUndeclaredAndTraversalFiles);
@@ -99,13 +105,108 @@ namespace BizTalkPlatformManagementTool.Tests
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
snapshot.Applications[0].Orchestrations.Add(new OrchestrationState { Application = "APP", Name = "ORCH", OrchestrationStatus = ArtifactStates.OrchestrationBound });
snapshot.Applications[0].ReceiveLocations.Add(new ReceiveLocationState { Application = "APP", Name = "RL", Enabled = true });
var second = Snapshot("APP-B", "PORT-B", ArtifactStates.SendPortStarted).Applications[0];
second.Orchestrations.Add(new OrchestrationState { Application = "APP-B", Name = "ORCH-B", OrchestrationStatus = ArtifactStates.OrchestrationStarted });
second.ReceiveLocations.Add(new ReceiveLocationState { Application = "APP-B", Name = "RL-B", Enabled = true });
snapshot.Applications.Add(second);
var plan = new BizTalkOperationService(null).CreateRestorePlan(snapshot, snapshot.Server);
Assert(plan.Steps.First().Kind == "HostInstance", "host instance must start first");
Assert(plan.Steps.Last().Kind == "ReceiveLocation", "receive location must be restored last");
var firstReceiveLocation = plan.Steps.FindIndex(x => x.Kind == "ReceiveLocation");
var lastSendPort = plan.Steps.FindLastIndex(x => x.Kind == "SendPort");
var lastOrchestration = plan.Steps.FindLastIndex(x => x.Kind == "Orchestration" || x.Kind == "Note");
Assert(firstReceiveLocation > lastSendPort && firstReceiveLocation > lastOrchestration, "all receive locations must be restored after all send ports and orchestrations");
var bound = plan.Steps.Single(x => x.Name == "ORCH");
Assert(!bound.Execute && bound.Kind == "Note", "bound orchestration must remain unchanged");
}
/// <summary>Prüft die globale Shutdown-Reihenfolge auch über mehrere Anwendungen hinweg.</summary>
private static void ShutdownPlanUsesGlobalSafeOrder()
{
var snapshot = Snapshot("APP-A", "SP-A", ArtifactStates.SendPortStarted);
snapshot.Applications[0].ReceiveLocations.Add(new ReceiveLocationState { Application = "APP-A", Name = "RL-A", Enabled = true });
snapshot.Applications[0].Orchestrations.Add(new OrchestrationState { Application = "APP-A", Name = "ORCH-A", OrchestrationStatus = ArtifactStates.OrchestrationStarted });
var appB = Snapshot("APP-B", "SP-B", ArtifactStates.SendPortStarted).Applications[0];
appB.ReceiveLocations.Add(new ReceiveLocationState { Application = "APP-B", Name = "RL-B", Enabled = true });
appB.Orchestrations.Add(new OrchestrationState { Application = "APP-B", Name = "ORCH-B", OrchestrationStatus = ArtifactStates.OrchestrationStarted });
snapshot.Applications.Add(appB);
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
var plan = new BizTalkOperationService(null).CreateShutdownPlan(snapshot, snapshot.Server);
var lastReceiveLocation = plan.Steps.FindLastIndex(x => x.Kind == "ReceiveLocation");
var firstOrchestration = plan.Steps.FindIndex(x => x.Kind == "Orchestration");
var lastOrchestration = plan.Steps.FindLastIndex(x => x.Kind == "Orchestration");
var firstSendPort = plan.Steps.FindIndex(x => x.Kind == "SendPort");
var lastSendPort = plan.Steps.FindLastIndex(x => x.Kind == "SendPort");
var firstHost = plan.Steps.FindIndex(x => x.Kind == "HostInstance");
Assert(lastReceiveLocation < firstOrchestration, "receive locations were not globally first");
Assert(lastOrchestration < firstSendPort, "orchestrations were not globally before send ports");
Assert(lastSendPort < firstHost, "host instances were not globally last");
}
/// <summary>Prüft ENTSSO als erste Voraussetzung des Emergency Restore.</summary>
private static void EmergencyRestorePlanStartsSsoFirst()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.HostInstances.Add(new HostInstanceState { InstanceName = "HOST:SERVER", HostName = "HOST", Server = snapshot.Server, RawState = ArtifactStates.HostStarted });
var plan = new BizTalkOperationService(null).CreateEmergencyRestorePlan(snapshot, snapshot.Server);
Assert(plan.Mode == OperationMode.EmergencyRestore.ToString(), "emergency restore mode missing");
Assert(plan.Steps.First().Kind == "WindowsService" && plan.Steps.First().Name == "ENTSSO", "ENTSSO must be the first emergency step");
Assert(plan.Steps[1].Kind == "HostInstance", "host instance must follow ENTSSO");
}
/// <summary>Prüft, dass Kurzname und FQDN dieselbe Host Instance nicht fälschlich überspringen.</summary>
private static void HostInstancePlanAcceptsShortAndFqdnServer()
{
var snapshot = Snapshot("APP", "PORT", ArtifactStates.SendPortStarted);
snapshot.Server = "BIZTALK-A.example.local";
snapshot.HostInstances.Add(new HostInstanceState
{
InstanceName = "HOST:BIZTALK-A",
HostName = "HOST",
Server = "BIZTALK-A.example.local",
RawState = ArtifactStates.HostStarted
});
var plan = new BizTalkOperationService(null).CreateRestorePlan(snapshot, "BIZTALK-A");
Assert(plan.Steps.First(x => x.Kind == "HostInstance").Execute, "short name/FQDN match incorrectly skipped host instance");
}
/// <summary>Prüft, dass eine Scheduler-artige Exception spätere Schritte nicht mehr verhindert.</summary>
private static void PlanExecutionContinuesAfterSchedulerFailure()
{
var plan = TestPlan("RL-A", "RV_PMP_Trigger_Schedule", "RL-C");
var runtime = new FakeOperationStepRuntime { FailingName = "RV_PMP_Trigger_Schedule" };
var report = new OperationPlanExecutor(null).Execute(plan, TestOptions(false), runtime);
Assert(runtime.Calls.SequenceEqual(new[] { "RL-A", "RV_PMP_Trigger_Schedule", "RL-C" }), "executor stopped after isolated failure");
Assert(report.SucceededCount == 2 && report.FailedCount == 1, "unexpected continuation summary");
Assert(report.Steps[1].Outcome == OperationStepOutcomes.Failed, "failed step outcome missing");
Assert(report.Steps[1].Error.Contains("Microsoft.BizTalk.Scheduler"), "nested Scheduler exception missing from report");
}
/// <summary>Prüft idempotentes Überspringen eines bereits erreichten Sollzustands.</summary>
private static void PlanExecutionSkipsAlreadySatisfiedState()
{
var plan = TestPlan("ALREADY", "CHANGE");
var runtime = new FakeOperationStepRuntime();
runtime.AlreadySatisfiedNames.Add("ALREADY");
var report = new OperationPlanExecutor(null).Execute(plan, TestOptions(false), runtime);
Assert(report.AlreadySatisfiedCount == 1 && report.SucceededCount == 1 && report.FailedCount == 0, "idempotent outcome counts are wrong");
Assert(runtime.MutatedNames.SequenceEqual(new[] { "CHANGE" }), "already-satisfied step was mutated");
}
/// <summary>Prüft die dauerhafte Serialisierung eines Teilfehler-Reports.</summary>
private static void ExecutionReportRoundTripPreservesFailure()
{
InTemp(directory =>
{
var report = new OperationPlanExecutor(null).Execute(TestPlan("FAIL", "CONTINUE"), TestOptions(false), new FakeOperationStepRuntime { FailingName = "FAIL" });
var path = Path.Combine(directory, "restore-result.json");
JsonFileStore.Save(path, report);
var loaded = JsonFileStore.Load<OperationExecutionReport>(path);
Assert(loaded.HasFailures && loaded.FailedCount == 1, "serialized report lost failure summary");
Assert(loaded.Steps.Count == 2 && loaded.Steps[1].Outcome == OperationStepOutcomes.Succeeded, "serialized report lost continued step");
});
}
/// <summary>Prüft die Neutralisierung formelartiger CSV-Feldwerte.</summary>
private static void CsvNeutralizesFormulaValues()
{
@@ -399,6 +500,96 @@ namespace BizTalkPlatformManagementTool.Tests
});
}
/// <summary>Erstellt einen minimalen ausführbaren Testplan.</summary>
/// <param name="names">Die Namen der Testschritte in Ausführungsreihenfolge.</param>
/// <returns>Ein Restore-Testplan.</returns>
private static OperationPlan TestPlan(params string[] names)
{
var plan = new OperationPlan
{
Mode = OperationMode.Restore.ToString(),
CreatedAt = DateTimeOffset.Now.ToString("o"),
Server = Environment.MachineName
};
foreach (var name in names)
{
plan.Steps.Add(new OperationStep
{
Kind = "ReceiveLocation",
Application = "APP",
Name = name,
Action = "Restore " + name,
WmiClass = "MSBTS_ReceiveLocation",
KeyProperty = "Name",
KeyValue = name,
MethodName = "Enable",
Execute = true
});
}
return plan;
}
/// <summary>Erstellt konsistente Optionen für portable Executor-Tests.</summary>
/// <param name="dryRun">True für einen simulierenden Lauf.</param>
/// <returns>Testoptionen.</returns>
private static OperationOptions TestOptions(bool dryRun)
{
return new OperationOptions
{
Server = Environment.MachineName,
OutputDirectory = Path.GetTempPath(),
StateFile = "before.json",
DryRun = dryRun,
WaitTimeoutSeconds = 30,
PollIntervalSeconds = 1
};
}
/// <summary>Simuliert bereits erreichte Zustände, Mutationen und isolierte Laufzeitfehler.</summary>
private sealed class FakeOperationStepRuntime : IOperationStepRuntime
{
/// <summary>Initialisiert die Aufruf- und Mutationslisten.</summary>
public FakeOperationStepRuntime()
{
Calls = new List<string>();
MutatedNames = new List<string>();
AlreadySatisfiedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>Gets or sets the step name that throws the simulated Scheduler error.</summary>
public string FailingName { get; set; }
/// <summary>Gets every runtime call in order.</summary>
public List<string> Calls { get; private set; }
/// <summary>Gets only steps that required a simulated mutation.</summary>
public List<string> MutatedNames { get; private set; }
/// <summary>Gets the names reported as already satisfied.</summary>
public HashSet<string> AlreadySatisfiedNames { get; private set; }
/// <summary>Simuliert einen zustandsbewussten Laufzeitschritt.</summary>
/// <param name="step">Der Testschritt.</param>
/// <param name="options">Die ungenutzten Testoptionen.</param>
/// <returns>Das simulierte Ergebnis.</returns>
public RuntimeStepOutcome Execute(OperationStep step, OperationOptions options)
{
Calls.Add(step.Name);
if (string.Equals(step.Name, FailingName, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Could not validate TransportTypeData.",
new FileNotFoundException("Could not load file or assembly Microsoft.BizTalk.Scheduler, Version=3.13.0.0."));
}
if (AlreadySatisfiedNames.Contains(step.Name))
{
return RuntimeStepOutcome.AlreadySatisfied;
}
MutatedNames.Add(step.Name);
return RuntimeStepOutcome.Succeeded;
}
}
/// <summary>Erstellt einen minimalen Snapshot für Vergleiche und Planprüfungen.</summary>
/// <param name="application">Der Name der Testanwendung.</param>
/// <param name="port">Der Name des Test-Send-Ports.</param>