| index 2eeceb26f..cd996e5b6 100644 |
| --- a/internal/provision/sidecar_reconcile_test.go |
| +++ b/internal/provision/sidecar_reconcile_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -338,6 +339,59 @@ func TestSidecarReconcileProtectsAChannellessRecord(t *testing.T) { |
| noRoute(t, f.reg, old, "protect is narrow: an unusable record holds back ITS OWN host only, and the pass's removal phase still runs") |
| } |
| |
| +// --- decline-but-keep ------------------------------------------------------------- |
| + |
| +// TestSidecarReconcileKeepsRowsWhenTheManifestIsAbsent pins the invariant that |
| +// stands between "an app's sidecars.json was deleted" and "every sidecar row |
| +// of that app is retired". An absent manifest is a confirmed answer, not a |
| +// blip, so it does NOT arm the pass-wide fail-safe — which is exactly why the |
| +// record must stay in the record set on its own account. Declining to mint is |
| +// the whole of the response. |
| +// |
| +// The hazard is one line away: manifestFor is consulted in the record scan |
| +// (placement needs it), so a natural-looking `if !mKnown { continue }` there |
| +// would convert a missing manifest into a fleet evacuation. |
| +func TestSidecarReconcileKeepsRowsWhenTheManifestIsAbsent(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + // No sidecars.json for this app at all — the feature is not enabled. |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "new", App: "eleven", Channel: "stable"}) |
| + f.mustUpsert(t, registry.Route{Host: host, Kind: "sidecar", Slug: "bot", App: "eleven", |
| + Account: f.sqid, Channel: "stable", Provisioned: true, Args: `["sidecar","run"]`}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt := hasRoute(t, f.reg, host, "an absent sidecars manifest must not RETIRE a running sidecar — it is the app's opt-in door, not a statement that this row is gone") |
| + if rt.Args != `["sidecar","run"]` { |
| + t.Fatalf("Args = %q — with no manifest there is nothing authoritative to converge from, so the row must be left exactly as it was", rt.Args) |
| + } |
| + noRoute(t, f.reg, f.host("new", "eleven"), "with no manifest there is no argv to run, so nothing may be minted either") |
| +} |
| + |
| +// TestSidecarReconcileKeepsRowsOfATrashedApp: a trashed app mints nothing |
| +// here, and its rows are not this pass's to retire either. domains.Reconcile |
| +// drops every route of a trashed app on its own authority (with its own |
| +// production-flag guard) — this pass merely declines to create, or the two |
| +// loops would fight forever, one re-raising what the other removes. |
| +func TestSidecarReconcileKeepsRowsOfATrashedApp(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "new", App: "eleven", Channel: "stable"}) |
| + f.mustUpsert(t, registry.Route{Host: host, Kind: "sidecar", Slug: "bot", App: "eleven", |
| + Account: f.sqid, Channel: "stable", Provisioned: true}) |
| + if err := release.TrashApp(ctx, f.store, f.sqid, "eleven", "someone@example.com", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + hasRoute(t, f.reg, host, "dropping a trashed app's routes is domains.Reconcile's call, with its production-flag guard — this pass only declines to create") |
| + noRoute(t, f.reg, f.host("new", "eleven"), "a trashed app provisions nothing") |
| +} |
| + |
| // --- placement ------------------------------------------------------------------- |
| |
| // TestSidecarReconcilePlacementGate pins §8's second half: a no-inbound |