diff --git a/internal/steward/deployment_recovery_test.go b/internal/steward/deployment_recovery_test.go index 261bfaa..fe0a16f 100644 --- a/internal/steward/deployment_recovery_test.go +++ b/internal/steward/deployment_recovery_test.go @@ -6,13 +6,13 @@ import ( "testing" ) -func TestDeploymentAuditAllowsOnlyKnownRemoteStates(t *testing.T) { +func TestDeploymentAuditAllowsMutationOnlyFromKnownSafeStates(t *testing.T) { route := &Route{State: "pending"} if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "service-missing"}); err != nil { t.Fatalf("fresh pending Route rejected service-missing state: %v", err) } if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "in-sync"}); err != nil { - t.Fatalf("pending Route rejected verified in-sync state: %v", err) + t.Fatalf("fresh pending Route rejected verified in-sync state: %v", err) } if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "undetermined"}); err == nil { t.Fatal("pending Route accepted undetermined remote state") @@ -22,11 +22,56 @@ func TestDeploymentAuditAllowsOnlyKnownRemoteStates(t *testing.T) { if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "service-missing"}); err != nil { t.Fatalf("retry checkpoint rejected known missing service: %v", err) } + if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "in-sync"}); err == nil { + t.Fatal("deploying Route accepted another remote mutation after audit proved the existing deployment is in sync") + } route.State = "deployed" if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "service-missing"}); err == nil { t.Fatal("deployed Route accepted a missing remote service") } + if err := deploymentAuditAllowsMutation(route, AuditEvidence{Category: "in-sync"}); err == nil { + t.Fatal("deployed Route accepted another remote mutation after audit proved the existing deployment is in sync") + } +} + +func TestAdoptVerifiedRouteRecoversLocalStateWithoutDeployment(t *testing.T) { + state, route := healthFixture(t, "direct", false) + current := findRoute(state.Inventory, route.ID) + current.Enabled = false + current.State = "deploying" + if err := state.Save(false); err != nil { + t.Fatal(err) + } + + exit := findServer(state.Inventory, route.ExitServer) + if exit == nil { + t.Fatal("fixture is missing Route exit Server") + } + actual := exit.Network.ExpectedEgressIPv4 + evidence := AuditEvidence{ + Route: route.ID, + Status: "healthy", + Category: "in-sync", + ActualEgressIPv4: &actual, + EgressMatchesDeclaredExit: true, + } + + result, err := adoptVerifiedRoute(state, current, evidence, true, false) + if err != nil { + t.Fatal(err) + } + if result["state"] != "deployed" || result["enabled"] != true { + t.Fatalf("verified deployment was not adopted: %#v", result) + } + reloaded, err := LoadState(state.PrivateDir) + if err != nil { + t.Fatal(err) + } + recovered := findRoute(reloaded.Inventory, route.ID) + if recovered == nil || recovered.State != "deployed" || !recovered.Enabled { + t.Fatalf("verified remote state was not committed locally: %#v", recovered) + } } func TestMarkRouteDeployingPersistsRetryCheckpoint(t *testing.T) { diff --git a/internal/steward/execution.go b/internal/steward/execution.go index 91a7460..d17eda6 100644 --- a/internal/steward/execution.go +++ b/internal/steward/execution.go @@ -59,7 +59,7 @@ func deployRouteWithoutRender(ctx context.Context, state *State, routeID string) } func deploymentAuditAllowsMutation(route *Route, current AuditEvidence) error { - if current.Category == "in-sync" { + if current.Category == "in-sync" && route.State == "pending" { return nil } if current.Category == "service-missing" && route.State != "deployed" { @@ -85,6 +85,9 @@ func deployRoute(ctx context.Context, state *State, routeID string, skipClientVa } current := AuditRoute(ctx, state, routeID) + if current.Category == "in-sync" && (route.State == "deploying" || route.State == "deployed") { + return adoptVerifiedRoute(state, route, current, skipClientValidation, renderClients) + } if err := deploymentAuditAllowsMutation(route, current); err != nil { return nil, &operationStageError{Stage: "remote-preflight-audit", StateChanged: "remote-state-unchanged", Retry: "audit", Err: err} } diff --git a/version.txt b/version.txt index b1b25a5..5859406 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.2.2 +2.2.3