mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec5a5debd | |||
| ff35270d5d | |||
| 312e849573 | |||
| 7433df528d |
@@ -132,10 +132,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_install_without_wait_arg_uses_agent_default_of_600()
|
||||
public void First_install_without_wait_arg_persists_null_so_runtime_default_applies()
|
||||
{
|
||||
// Generic bootstrap path: PS1 calls `--install` without --tenant-id-wait,
|
||||
// and there is no persisted config yet. Agent owns the default (600 s).
|
||||
// and there is no persisted config yet. "Not configured" is persisted as null
|
||||
// so BuildAgentConfiguration applies the agent-side default (600 s) — freezing
|
||||
// a number into the file would decouple deployed devices from default changes.
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
existing: null,
|
||||
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
|
||||
@@ -144,20 +146,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
Assert.Null(merged.BootstrapToken);
|
||||
Assert.Null(merged.TenantId);
|
||||
Assert.Equal(600, merged.TenantIdWaitSeconds);
|
||||
Assert.Null(merged.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_config_without_wait_field_treated_as_zero()
|
||||
public void Legacy_config_without_wait_field_stays_null_on_plain_reinstall()
|
||||
{
|
||||
// BackCompat: pre-fix bootstrap-config.json has no TenantIdWaitSeconds field;
|
||||
// Newtonsoft deserialises that to 0. New install with --tenant-id-wait 600
|
||||
// overrides cleanly; no extra migration logic required.
|
||||
// BackCompat: pre-feature bootstrap-config.json has no TenantIdWaitSeconds
|
||||
// property → nullable deserialisation yields null. A reinstall that does not
|
||||
// pass --tenant-id-wait must NOT turn that into 0 (which would silently
|
||||
// disable the wait); null carries through and the runtime default applies.
|
||||
var existing = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-legacy",
|
||||
TenantId = "tenant-legacy",
|
||||
TenantIdWaitSeconds = 0,
|
||||
TenantIdWaitSeconds = null,
|
||||
};
|
||||
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
existing,
|
||||
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
|
||||
tenantIdArg: null!, tenantIdGiven: false,
|
||||
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
|
||||
|
||||
Assert.Equal("tok-legacy", merged.BootstrapToken);
|
||||
Assert.Equal("tenant-legacy", merged.TenantId);
|
||||
Assert.Null(merged.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_config_without_wait_field_takes_explicit_wait_arg()
|
||||
{
|
||||
var existing = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-legacy",
|
||||
TenantId = "tenant-legacy",
|
||||
TenantIdWaitSeconds = null,
|
||||
};
|
||||
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
@@ -170,5 +194,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("tenant-legacy", merged.TenantId);
|
||||
Assert.Equal(600, merged.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_zero_opt_out_survives_redeploy_without_wait_arg()
|
||||
{
|
||||
// An admin's deliberate `--tenant-id-wait 0` must not be resurrected to a
|
||||
// wait by a later redeploy that omits the flag.
|
||||
var existing = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-abc",
|
||||
TenantId = "tenant-xyz",
|
||||
TenantIdWaitSeconds = 0,
|
||||
};
|
||||
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
existing,
|
||||
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
|
||||
tenantIdArg: null!, tenantIdGiven: false,
|
||||
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
|
||||
|
||||
Assert.Equal(0, merged.TenantIdWaitSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using AutopilotMonitor.Agent.V2;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the TenantIdWaitSeconds resolution order in
|
||||
/// <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>:
|
||||
/// CLI arg > persisted bootstrap-config.json value > agent default (600 s).
|
||||
/// The regression this guards: a legacy bootstrap-config.json written before the
|
||||
/// TenantIdWaitSeconds property existed deserialises to null — that must fall
|
||||
/// through to the 600 s default, NOT be treated as an explicit 0 opt-out
|
||||
/// (which silently disabled the wait and broke Hybrid-AAD-join devices whose
|
||||
/// registry TenantId lands minutes after first boot).
|
||||
/// </summary>
|
||||
public sealed class TenantIdWaitResolutionTests
|
||||
{
|
||||
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
|
||||
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
|
||||
args: args,
|
||||
tenantId: "tenant-t",
|
||||
sessionId: "session-s",
|
||||
bootstrapConfig: bootstrapConfig,
|
||||
awaitConfig: null);
|
||||
|
||||
[Fact]
|
||||
public void No_cli_and_no_bootstrap_config_uses_default_600()
|
||||
{
|
||||
var cfg = Build(bootstrapConfig: null);
|
||||
|
||||
Assert.Equal(600, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_bootstrap_config_with_null_wait_uses_default_600()
|
||||
{
|
||||
var legacy = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-legacy",
|
||||
TenantId = "tenant-legacy",
|
||||
TenantIdWaitSeconds = null,
|
||||
};
|
||||
|
||||
var cfg = Build(legacy);
|
||||
|
||||
Assert.Equal(600, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_zero_in_bootstrap_config_disables_wait()
|
||||
{
|
||||
var optedOut = new BootstrapConfigFile { TenantIdWaitSeconds = 0 };
|
||||
|
||||
var cfg = Build(optedOut);
|
||||
|
||||
Assert.Equal(0, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nonzero_bootstrap_config_value_wins_over_default()
|
||||
{
|
||||
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
|
||||
|
||||
var cfg = Build(configured);
|
||||
|
||||
Assert.Equal(1800, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_arg_wins_over_bootstrap_config_value()
|
||||
{
|
||||
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
|
||||
|
||||
var cfg = Build(configured, "--tenant-id-wait", "300");
|
||||
|
||||
Assert.Equal(300, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_zero_wins_over_bootstrap_config_value()
|
||||
{
|
||||
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
|
||||
|
||||
var cfg = Build(configured, "--tenant-id-wait", "0");
|
||||
|
||||
Assert.Equal(0, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,9 +272,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// CloudDomainJoin keys and re-probes on every change until either resolution
|
||||
/// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
|
||||
/// <para>
|
||||
/// Set via the install-time CLI flag <c>--tenant-id-wait <sec></c>, persisted
|
||||
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
|
||||
/// default; the agent itself never assumes one.
|
||||
/// Set via the CLI flag <c>--tenant-id-wait <sec></c> or a persisted
|
||||
/// <c>bootstrap-config.json</c> value; when neither supplies a value the agent
|
||||
/// applies its own default of 600 s in BuildAgentConfiguration. The 0 default
|
||||
/// here is only the POCO baseline, not the effective runtime default.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int TenantIdWaitSeconds { get; set; } = 0;
|
||||
|
||||
@@ -16,8 +16,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// <summary>
|
||||
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
|
||||
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
|
||||
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
|
||||
/// TenantId on first try. 0 = explicit opt-out (legacy fast-fail).
|
||||
/// Null / missing (files written before this property existed) = not configured —
|
||||
/// the agent-side default (600 s) applies. Nullable so a legacy file without the
|
||||
/// property cannot masquerade as an explicit 0 and silently disable the wait.
|
||||
/// </summary>
|
||||
public int TenantIdWaitSeconds { get; set; }
|
||||
public int? TenantIdWaitSeconds { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
$"{(bootstrapTokenGiven ? "*" : "")}, " +
|
||||
$"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" +
|
||||
$"{(tenantIdGiven ? "*" : "")}, " +
|
||||
$"tenantIdWait={merged.TenantIdWaitSeconds}s" +
|
||||
$"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" +
|
||||
$"{(tenantIdWaitGiven ? "*" : "")})." +
|
||||
" Star = explicitly set on this --install invocation, others were preserved from prior config.");
|
||||
}
|
||||
@@ -361,9 +361,12 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken,
|
||||
TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId,
|
||||
// Not given → carry the prior value through, INCLUDING null. Persisting a
|
||||
// number here would freeze the agent-side default into the file; persisting
|
||||
// null keeps "not configured" distinguishable from an explicit opt-out (0).
|
||||
TenantIdWaitSeconds = tenantIdWaitGiven
|
||||
? tenantIdWaitSeconds
|
||||
: (existing?.TenantIdWaitSeconds ?? 600),
|
||||
: existing?.TenantIdWaitSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -339,13 +339,15 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// over the agent-side default (600 s). Hybrid-AAD-joined devices typically
|
||||
// need ~5 min for the AAD device cert to land in the registry; 600 s leaves
|
||||
// headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail).
|
||||
// A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the
|
||||
// property) is "not configured", NOT an opt-out — the default must still apply.
|
||||
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
|
||||
const int tenantIdWaitDefaultSeconds = 600;
|
||||
int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds;
|
||||
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
|
||||
tenantIdWaitSeconds = parsedTenantIdWait;
|
||||
else if (bootstrapConfig != null)
|
||||
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
|
||||
else if (bootstrapConfig?.TenantIdWaitSeconds != null)
|
||||
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value;
|
||||
|
||||
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
|
||||
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ public sealed class TenantCustomsArchiveContractTests
|
||||
reEnqueuer: null!,
|
||||
opsEvents: null!,
|
||||
customsArchive: archive,
|
||||
configRepo: null!,
|
||||
farewellEmail: new FakeOffboardFarewellEmailSender(),
|
||||
logger: NullLogger<TenantOffboardingHandler>.Instance);
|
||||
}
|
||||
|
||||
+46
-1
@@ -9,6 +9,7 @@ using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Azure.Storage.Blobs;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
@@ -301,6 +302,47 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
"Phase 2.G must delete the Expectations blob as its last step");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnoffboardRace_ConfigNoLongerTombstone_FailsClosed_NoSafeWipe()
|
||||
{
|
||||
// 2.D-guard: an operator re-enabled the tenant while the cascade was queued/draining
|
||||
// (the config write paths refuse this, but the guard is the last line of defense).
|
||||
// Wiping now would destroy data of a live tenant → fail-closed, zero wipes.
|
||||
var harness = Harness.New();
|
||||
harness.ConfigRepo
|
||||
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, Disabled = false });
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
|
||||
|
||||
Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
|
||||
Assert.Equal("unoffboard_race", harness.Repo.Markers[TenantId].FailedPhase);
|
||||
Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
|
||||
Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnoffboardGuard_TombstoneStillInPlace_ProceedsToWipeAndCompleted()
|
||||
{
|
||||
// The normal case: Phase 1's tombstone is untouched → the guard must NOT get in
|
||||
// the way of a legitimate offboarding.
|
||||
var harness = Harness.New();
|
||||
harness.ConfigRepo
|
||||
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Disabled = true,
|
||||
DisabledReason = TenantOffboardFunction.OffboardingDisabledReason,
|
||||
});
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
|
||||
Assert.True(harness.SafeWipeProbe.WipeCallCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionNotFound_CountsAsSatisfied_NoDrainProbeCall()
|
||||
{
|
||||
@@ -393,6 +435,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
public Mock<OffboardingSessionEnumerator> Enumerator { get; }
|
||||
public CountingSafeWipeService SafeWipeProbe { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public Mock<IConfigRepository> ConfigRepo { get; } = new();
|
||||
public List<string> EnumeratorYields { get; } = new();
|
||||
public Exception? EnumeratorThrow { get; set; }
|
||||
|
||||
@@ -450,6 +493,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
h.ReEnqueuer.Object,
|
||||
opsService,
|
||||
Mock.Of<ITenantCustomsArchiveRepository>(),
|
||||
h.ConfigRepo.Object,
|
||||
new FakeOffboardFarewellEmailSender(),
|
||||
NullLogger<TenantOffboardingHandler>.Instance);
|
||||
|
||||
@@ -622,10 +666,11 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
ITenantOffboardingEnqueuer reEnqueuer,
|
||||
OpsEventService opsEvents,
|
||||
ITenantCustomsArchiveRepository customsArchive,
|
||||
IConfigRepository configRepo,
|
||||
IOffboardFarewellEmailSender farewellEmail,
|
||||
ILogger<TenantOffboardingHandler> logger)
|
||||
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
|
||||
storage, maintenance, reEnqueuer, opsEvents, customsArchive, farewellEmail, logger)
|
||||
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -358,6 +358,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
h.ReEnqueuer.Object,
|
||||
BuildOpsService(),
|
||||
Mock.Of<ITenantCustomsArchiveRepository>(),
|
||||
Mock.Of<IConfigRepository>(),
|
||||
h.FarewellEmail,
|
||||
NullLogger<TenantOffboardingHandler>.Instance);
|
||||
|
||||
@@ -459,9 +460,9 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
|
||||
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
|
||||
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
|
||||
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
|
||||
ILogger<TenantOffboardingHandler> log)
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
|
||||
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
|
||||
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
|
||||
|
||||
internal override Task ArchiveAndWipeRulesTableAsync(
|
||||
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
|
||||
|
||||
+4
-3
@@ -245,6 +245,7 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
Mock.Of<ITenantOffboardingEnqueuer>(),
|
||||
BuildOpsService(),
|
||||
Mock.Of<ITenantCustomsArchiveRepository>(),
|
||||
Mock.Of<IConfigRepository>(),
|
||||
new FakeOffboardFarewellEmailSender(),
|
||||
NullLogger<TenantOffboardingHandler>.Instance);
|
||||
}
|
||||
@@ -255,9 +256,9 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
|
||||
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
|
||||
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
|
||||
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
|
||||
ILogger<TenantOffboardingHandler> log)
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
|
||||
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
|
||||
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
|
||||
|
||||
internal override Task ArchiveAndWipeRulesTableAsync(
|
||||
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -154,6 +155,68 @@ public class TenantConfigPatchServiceTests
|
||||
Assert.Null(harness.Current!.ContactEmail);
|
||||
}
|
||||
|
||||
// ── Mid-offboarding freeze ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_OffboardingTombstone_IsFrozen_EvenForGa()
|
||||
{
|
||||
// While the row is the offboarding tombstone, the cascade owns it: a GA patch
|
||||
// lifting Disabled would un-gate agents against a tenant whose partitions are
|
||||
// queued for deletion (un-offboard race).
|
||||
var stored = Stored();
|
||||
stored.Disabled = true;
|
||||
stored.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
var harness = new Harness(stored);
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("disabled", false)), Ga, "mcp-patch", "trying to un-offboard");
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(harness.Current!.Disabled, "tombstone must be untouched");
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_OrdinarySuspension_IsNotFrozen()
|
||||
{
|
||||
// The freeze is specific to the offboarding tombstone — a normally suspended
|
||||
// tenant (different DisabledReason) stays fully patchable.
|
||||
var stored = Stored();
|
||||
stored.Disabled = true;
|
||||
stored.DisabledReason = "Manual suspension";
|
||||
var harness = new Harness(stored);
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 60)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(60, harness.Current!.DataRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_OffboardingTombstone_IsFrozen()
|
||||
{
|
||||
// A revert restores a pre-offboarding snapshot with Disabled=false — the exact
|
||||
// un-offboard race. Build a backup while the tenant is healthy, then tombstone.
|
||||
var harness = new Harness(Stored());
|
||||
var patched = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
Assert.True(patched.Success);
|
||||
|
||||
harness.Current!.Disabled = true;
|
||||
harness.Current.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(harness.Current.Disabled, "tombstone must be untouched");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
|
||||
{
|
||||
|
||||
@@ -517,6 +517,17 @@ public class TenantOffboardFunction
|
||||
/// </summary>
|
||||
internal const string OffboardingDisabledReason = "Offboarding in progress";
|
||||
|
||||
/// <summary>
|
||||
/// True when the row is the offboarding tombstone written by
|
||||
/// <c>EnsureTenantDisabledAsync</c>. Shared predicate for every config writer and
|
||||
/// wiper that must distinguish the mid-offboarding freeze from an ordinary
|
||||
/// suspended (or re-onboarded) tenant: while this is true, the tenant's
|
||||
/// configuration is frozen and the offboarding cascade owns the row.
|
||||
/// </summary>
|
||||
internal static bool IsOffboardingTombstone(TenantConfiguration? config) =>
|
||||
config is { Disabled: true }
|
||||
&& string.Equals(config.DisabledReason, OffboardingDisabledReason, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="EnsureTenantDisabledAsync"/>. Carries both whether a fresh
|
||||
/// write happened AND the tombstone's <c>LastUpdated</c> timestamp so the resume-path
|
||||
|
||||
+18
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -71,6 +72,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// before validation/save and (2) protect GA-only fields below.
|
||||
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
|
||||
// Mid-offboarding freeze: the Disabled tombstone written by TenantOffboardFunction
|
||||
// is the cascade's ONLY auth gate. A PUT that lifts it (or edits anything else)
|
||||
// races the queued wipe — data written afterwards gets destroyed, and agents
|
||||
// would be un-gated against a tenant whose partitions are being deleted. Refuse
|
||||
// for ALL callers, GA included; once the cascade completes the row is deleted
|
||||
// and re-onboarding starts from a fresh default config anyway.
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(existingConfig))
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "Tenant offboarding is in progress — the configuration is frozen until the cascade completes."
|
||||
});
|
||||
return conflict;
|
||||
}
|
||||
|
||||
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
|
||||
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
|
||||
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
|
||||
|
||||
@@ -39,9 +39,9 @@ public static class EmailTemplates
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
|
||||
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
|
||||
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
|
||||
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
|
||||
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -68,8 +68,8 @@ public static class EmailTemplates
|
||||
</p>
|
||||
|
||||
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
|
||||
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
|
||||
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
|
||||
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
|
||||
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
|
||||
</ul>
|
||||
|
||||
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
|
||||
@@ -117,9 +117,9 @@ public static class EmailTemplates
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
|
||||
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
|
||||
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
|
||||
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
|
||||
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -140,7 +140,7 @@ public static class EmailTemplates
|
||||
<!-- CTA Button -->
|
||||
<table cellpadding=""0"" cellspacing=""0"" style=""margin:0 auto 32px;"">
|
||||
<tr>
|
||||
<td style=""background-color:#2563eb; border-radius:8px;"">
|
||||
<td style=""background-color:#1e8a4c; border-radius:8px;"">
|
||||
<a href=""{Constants.DocsBaseUrl}"" target=""_blank""
|
||||
style=""display:inline-block; padding:14px 32px; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none;"">
|
||||
View Documentation
|
||||
@@ -164,8 +164,8 @@ public static class EmailTemplates
|
||||
</p>
|
||||
|
||||
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
|
||||
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
|
||||
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
|
||||
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
|
||||
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
|
||||
</ul>
|
||||
|
||||
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
|
||||
|
||||
+25
@@ -5,6 +5,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -167,6 +168,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
private readonly ITenantOffboardingEnqueuer _reEnqueuer;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
private readonly ITenantCustomsArchiveRepository _customsArchive;
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IOffboardFarewellEmailSender _farewellEmail;
|
||||
private readonly ILogger<TenantOffboardingHandler> _logger;
|
||||
|
||||
@@ -182,6 +184,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
ITenantOffboardingEnqueuer reEnqueuer,
|
||||
OpsEventService opsEvents,
|
||||
ITenantCustomsArchiveRepository customsArchive,
|
||||
IConfigRepository configRepo,
|
||||
IOffboardFarewellEmailSender farewellEmail,
|
||||
ILogger<TenantOffboardingHandler> logger)
|
||||
{
|
||||
@@ -196,6 +199,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
_reEnqueuer = reEnqueuer;
|
||||
_opsEvents = opsEvents;
|
||||
_customsArchive = customsArchive;
|
||||
_configRepo = configRepo;
|
||||
_farewellEmail = farewellEmail;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -582,6 +586,27 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
private async Task RunPostDrainPhasesAsync(
|
||||
OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
|
||||
{
|
||||
// 2.D-guard — un-offboard race check. The destructive phases below are only
|
||||
// legal while the TenantConfiguration row is still the Phase-1 offboarding
|
||||
// tombstone (or already deleted by a previous 2.F-final on this resume path;
|
||||
// null passes). If an operator re-enabled the tenant mid-cascade — the config
|
||||
// write paths refuse that, but this is the last line of defense — wiping now
|
||||
// would destroy data of a live tenant, and sessions registered after the 2.B
|
||||
// enumeration would survive as orphan rows while their Events/Signals
|
||||
// partitions get wiped. Fail-closed; the Failed marker + OpsEvent route the
|
||||
// conflict to an operator instead.
|
||||
var currentConfig = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
if (currentConfig != null && !TenantOffboardFunction.IsOffboardingTombstone(currentConfig))
|
||||
{
|
||||
var msg =
|
||||
$"TenantConfiguration is no longer the offboarding tombstone " +
|
||||
$"(Disabled={currentConfig.Disabled}, Reason={currentConfig.DisabledReason ?? "<null>"}) " +
|
||||
"— refusing to wipe a re-enabled tenant.";
|
||||
await FailAsync(history, tenantId, "unoffboard_race", msg, ct);
|
||||
throw new InvalidOperationException(
|
||||
$"TenantOffboarding fail-closed for tenant={tenantId} phase=unoffboard_race");
|
||||
}
|
||||
|
||||
// 2.D-archive (PR3.B §3) — Archive then wipe GatherRules / AnalyzeRules /
|
||||
// ImeLogPatterns. Fail-loud: a row that cannot be archived must NOT be wiped.
|
||||
// After the third iteration if the source still has rows, throw with
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -185,6 +186,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
|
||||
var (initial, etag) = read.Value;
|
||||
|
||||
// Mid-offboarding freeze: while the row is the offboarding tombstone, the
|
||||
// cascade owns it. Any patch — a GA lifting Disabled included — would race
|
||||
// the queued wipe: data written afterwards gets destroyed, and sessions
|
||||
// registered after enumeration would survive as orphans.
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(initial))
|
||||
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
|
||||
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
|
||||
|
||||
// Patch onto a deep clone; MissingMemberHandling.Error is belt-and-braces on
|
||||
// top of the key gate above. JSON null = explicit clear; omitted = untouched;
|
||||
// camelCase input binds case-insensitively to the PascalCase model.
|
||||
@@ -275,6 +284,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
|
||||
var (current, etag) = read.Value;
|
||||
|
||||
// Same mid-offboarding freeze as ApplyFieldPatchAsync: a revert restores a
|
||||
// pre-offboarding snapshot with Disabled=false — the exact un-offboard race.
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(current))
|
||||
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
|
||||
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
|
||||
|
||||
var candidate = DeepClone(target);
|
||||
// Identity is never restored from a snapshot; protected/system-owned fields
|
||||
// only with the explicit GA opt-in.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -80,7 +80,9 @@ export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: "/icon.svg",
|
||||
shortcut: "/icon.svg",
|
||||
apple: "/icon.svg",
|
||||
// Safari cannot use SVG for apple-touch-icon; must be an opaque PNG
|
||||
// (served by the app/apple-icon.png file convention).
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 161 KiB |
Reference in New Issue
Block a user