fix(deployment): delete NotDeployed instances centrally without a site round-trip

A NotDeployed instance has no live Instance Actor at any site, so Delete is a pure
central-side record cleanup. Transport-imported instances always land NotDeployed
(often against an uncommissioned/unreachable site); a site round-trip made them
undeletable until the site came online. Short-circuit the site call for NotDeployed.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 16:22:29 -04:00
parent 83294ab44e
commit ab6077708f
2 changed files with 50 additions and 0 deletions
@@ -563,6 +563,37 @@ public class DeploymentService
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
var commandId = Guid.NewGuid().ToString("N");
// A NotDeployed instance has no live Instance Actor at any site, so Delete is
// a pure central-side record cleanup -- there is nothing to tear down at the
// site and no reason to require the site be reachable. Transport-imported
// instances always land NotDeployed (often against an uncommissioned or
// unreachable site), so a site round-trip here would make them undeletable
// until their site comes online. Skip the site entirely and remove the record.
if (instance.State == InstanceState.NotDeployed)
{
try
{
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to delete NotDeployed instance {Instance} centrally", instance.UniqueName);
return Result<InstanceLifecycleResponse>.Failure(
$"Delete failed: the central record for '{instance.UniqueName}' could not be removed: {ex.Message}.");
}
await TryLogAuditAsync(user, "Delete", "Instance", instanceId.ToString(),
instance.UniqueName,
new { CommandId = commandId, Success = true, SiteRoundTripSkipped = true },
cancellationToken);
return Result<InstanceLifecycleResponse>.Success(new InstanceLifecycleResponse(
commandId, instance.UniqueName, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
}
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
@@ -454,6 +454,25 @@ public class DeploymentServiceTests : TestKit
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task DeleteInstanceAsync_NotDeployed_RemovesRecordWithoutSiteRoundTrip()
{
// #05-T18: a NotDeployed instance has no live actor at any site (Transport-
// imported instances always land NotDeployed, often against an uncommissioned
// or unreachable site). Delete must succeed as a central-side record cleanup
// WITHOUT contacting the site. The default _service's CommunicationService has
// no actor wired, so any attempted site round-trip throws uncaught -- a Success
// result therefore proves the site was never contacted (Times.Never, enforced
// structurally).
var instance = new Instance("ImportedInst") { Id = 70, SiteId = 1, State = InstanceState.NotDeployed };
_repo.GetInstanceByIdAsync(70, Arg.Any<CancellationToken>()).Returns(instance);
var result = await _service.DeleteInstanceAsync(70, "admin");
Assert.True(result.IsSuccess);
await _repo.Received(1).DeleteInstanceAsync(70, Arg.Any<CancellationToken>());
}
// ── DeploymentManager-004: site-success but central-delete-failure must not escape uncaught ──
[Fact]