package host import ( "context" "net/http" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // settings-5 task 8 (ruling R26), the agent's half: the consumed file is // keyed by hostname, and a hostname can move to another tenant on one box. // A file tagged for another tenant is absent to the pre-exec write and to a // move's carry; an untagged file is read there as today. The held wake check // reads the file by the same rule (ruling R28): with a foreign file it holds // and asks root, and an untagged file is read as today. // foreignRecord is tenant A's (cdf/blog) record for the test host: a // resolved request, a failed completion watermark and a manual decision. func foreignRecord() release.Consumed { return release.Consumed{Account: "cdf", App: "blog", Resolved: &release.ResolvedRequest{ID: 7, Target: "v2"}, Completed: 3, CompletedFailed: true, Reason: string(release.ReasonManual), Desired: "v1", Detail: "v1"} } // writeConsumedAsIs writes c for the test host exactly as given, its tenant // tag included or left empty (writeConsumed tags it as root writes now). func writeConsumedAsIs(t *testing.T, b *buildStates, c release.Consumed) { t.Helper() c.Host = astraHost if err := release.WriteConsumed(b.optDir, c); err != nil { t.Fatal(err) } } func TestBeforeExecIgnoresAnotherTenantsFile(t *testing.T) { t.Parallel() b, _, _ := testBuildStates(t) ctx := context.Background() writeConsumedAsIs(t, b, foreignRecord()) if err := b.beforeExec(ctx, astraHost, "v1"); err != nil { t.Fatal(err) } st, _, err := b.read(ctx, astraHost) if err != nil { t.Fatal(err) } if st.LastExecuted != "v1" || st.Resolved != nil || st.Completed != 0 || st.CompletedFailed { t.Fatalf("state = %+v (resolved %+v); want v1 and none of A's request state", st, st.Resolved) } // An untagged file is the route's own, read as today: its resolution is // carried and its failed completion kept. b, _, _ = testBuildStates(t) legacy := foreignRecord() legacy.Account, legacy.App = "", "" writeConsumedAsIs(t, b, legacy) if err := b.beforeExec(ctx, astraHost, "v1"); err != nil { t.Fatal(err) } if st, _, _ = b.read(ctx, astraHost); st.Resolved == nil || st.Resolved.ID != 7 || st.Completed != 3 || !st.CompletedFailed { t.Fatalf("state = %+v (resolved %+v); want the untagged file's request state", st, st.Resolved) } } // Astra r4 finding 3: the pre-exec write learns the route's tenant from the // registry. A read that fails is no answer: B owns the host, A's file is // still on disk, and an unread route cannot tell them apart. beforeExec // returns the error before any build-state write, so the durable object is // left exactly as it was. (A route that is absent, read without error, is // today's behaviour and is not this case.) func TestBeforeExecFailsClosedOnARegistryReadError(t *testing.T) { b, s, reg := testBuildStates(t) ctx := context.Background() if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "B1", Completed: 1}); err != nil { t.Fatal(err) } key := release.BuildStateKey("inst/" + astraHost) etag := mustEtag(t, s, key) writeConsumedAsIs(t, b, foreignRecord()) if err := reg.Close(); err != nil { // every Get now fails t.Fatal(err) } if err := b.beforeExec(ctx, astraHost, "v1"); err == nil { t.Fatal("beforeExec with an unreadable route returned nil") } if got := mustEtag(t, s, key); got != etag { st, _, _ := getState(ctx, s, key, astraHost) t.Fatalf("the durable build state was written with the route unread: %+v (resolved %+v)", st, st.Resolved) } } func TestACarryIgnoresAnotherTenantsFile(t *testing.T) { b, s, reg := testBuildStates(t) ctx := context.Background() if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "A", Completed: 1}); err != nil { t.Fatal(err) } if err := reg.SetStatus(astraHost, "moving"); err != nil { t.Fatal(err) } key := release.BuildStateKey("inst/" + astraHost) etag := mustEtag(t, s, key) writeConsumedAsIs(t, b, foreignRecord()) if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err != nil { t.Fatal(err) } if mustEtag(t, s, key) != etag { st, _, _ := getState(ctx, s, key, astraHost) t.Fatalf("A's record was carried into B's state: %+v", st) } // An untagged file is carried as today. legacy := foreignRecord() legacy.Account, legacy.App = "", "" writeConsumedAsIs(t, b, legacy) if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err != nil { t.Fatal(err) } st, _, err := getState(ctx, s, key, astraHost) if err != nil || st == nil || st.LastExecuted != "A" || st.Completed != 3 || !st.CompletedFailed || st.Resolved == nil || st.Resolved.ID != 7 { t.Fatalf("state %+v, %v; want A with R3 completed failed and R7's resolution carried", st, err) } } func TestTheHeldWakeTrustsOnlyAFileDecidedForTheRoute(t *testing.T) { b, _, reg := testBuildStates(t) u := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 2} setSettings(t, reg, u) setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2", Completed: 1}}) record := release.Consumed{Completed: 1, Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}, Reason: string(release.ReasonRequest), Desired: "v4", Detail: "v4", Settings: release.SettingsDigest(u.Column())} for _, tc := range []struct{ name, account, app string }{ {"another tenant's file", "cdf", "blog"}, {"another app's file on the same account", release.OpsAccount, "blog"}, } { c := record c.Account, c.App = tc.account, tc.app writeConsumedAsIs(t, b, c) if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "v2" { t.Fatalf("%s: heldTarget = %+v; want held on v2, asking root (no decision for this route)", tc.name, h) } } // The route's own file is root's word: the request's T. An untagged file // is read as before task 8 (ruling R28), as the route's own. for _, tc := range []struct{ name, account, app string }{ {"the route's own file", release.OpsAccount, "km"}, {"an untagged file", "", ""}, } { c := record c.Account, c.App = tc.account, tc.app writeConsumedAsIs(t, b, c) if h := b.heldTarget(astraHost); !h.Held || h.AskRoot || h.Target != "v4" { t.Fatalf("%s: heldTarget = %+v; want the request's v4", tc.name, h) } } } // identityRecorder is plannedBackend with a live link it can capture: it // counts the captures and answers with T's identity, so a wake that gets as // far as running the live link records it. type identityRecorder struct { *plannedBackend captured atomic.Int32 } func (r *identityRecorder) LiveIdentity(context.Context, registry.Route) (release.StagedIdentity, error) { r.captured.Add(1) return release.StagedIdentity{Version: r.live}, nil } // Astra r4 finding 1 (ruling R28), through the real wake: a same-tenant // manual instance with T installed, its build state read and absent, and an // UNTAGGED consumed file (a settings-4 build's) recording that T failed. // There is no refusal record and no planner. The activator's held wake check // must refuse the wake as failed: it never captures the live link, never // records T, never runs anything. func TestAnUntaggedFailedDecisionRefusesTheWake(t *testing.T) { b, s, reg := testBuildStates(t) b.prefix = func(h string) string { return activator.HibernatePrefix("", h) } installLive(t, b, "T") setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true}) rt, _ := reg.Get(astraHost) writeConsumedAsIs(t, b, release.Consumed{Reason: string(release.ReasonFailed), Detail: "T", Settings: release.SettingsDigest(rt.UpdateSettings)}) if r, _ := release.ReadRefusal(b.bakeDir, astraHost); r != nil { t.Fatalf("a refusal record exists: %+v", r) } be := &identityRecorder{plannedBackend: &plannedBackend{live: "T"}} svc := activator.New(activator.Config{Bucket: "hib", Idle: time.Hour}, be, reg) svc.OnGen = func(activator.GenEvent) {} svc.BeforeExec = b.beforeExec svc.HeldTarget = b.heldTarget t.Cleanup(func() { svc.Shutdown(context.Background()) }) rec := serve(t, svc, reg) if rec.Code == http.StatusOK { t.Fatalf("the wake ran T, which root reports failed: %q", rec.Body.String()) } if n := be.captured.Load(); n != 0 { t.Fatalf("the wake captured the live link %d times; want it refused before", n) } if got := be.builds(); len(got) != 0 { t.Fatalf("generations %v, want none", got) } if st := durable(t, b, s); st != nil { t.Fatalf("durable state %+v; want none (T never recorded)", st) } }