| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "sync/atomic" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/activator" |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // settings-5 task 8 (ruling R26), the agent's half: the consumed file is |
| 16 | // keyed by hostname, and a hostname can move to another tenant on one box. |
| 17 | // A file tagged for another tenant is absent to the pre-exec write and to a |
| 18 | // move's carry; an untagged file is read there as today. The held wake check |
| 19 | // reads the file by the same rule (ruling R28): with a foreign file it holds |
| 20 | // and asks root, and an untagged file is read as today. |
| 21 | |
| 22 | // foreignRecord is tenant A's (cdf/blog) record for the test host: a |
| 23 | // resolved request, a failed completion watermark and a manual decision. |
| 24 | func foreignRecord() release.Consumed { |
| 25 | return release.Consumed{Account: "cdf", App: "blog", |
| 26 | Resolved: &release.ResolvedRequest{ID: 7, Target: "v2"}, Completed: 3, CompletedFailed: true, |
| 27 | Reason: string(release.ReasonManual), Desired: "v1", Detail: "v1"} |
| 28 | } |
| 29 | |
| 30 | // writeConsumedAsIs writes c for the test host exactly as given, its tenant |
| 31 | // tag included or left empty (writeConsumed tags it as root writes now). |
| 32 | func writeConsumedAsIs(t *testing.T, b *buildStates, c release.Consumed) { |
| 33 | t.Helper() |
| 34 | c.Host = astraHost |
| 35 | if err := release.WriteConsumed(b.optDir, c); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestBeforeExecIgnoresAnotherTenantsFile(t *testing.T) { |
| 41 | t.Parallel() |
| 42 | b, _, _ := testBuildStates(t) |
| 43 | ctx := context.Background() |
| 44 | writeConsumedAsIs(t, b, foreignRecord()) |
| 45 | if err := b.beforeExec(ctx, astraHost, "v1"); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | st, _, err := b.read(ctx, astraHost) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if st.LastExecuted != "v1" || st.Resolved != nil || st.Completed != 0 || st.CompletedFailed { |
| 53 | t.Fatalf("state = %+v (resolved %+v); want v1 and none of A's request state", st, st.Resolved) |
| 54 | } |
| 55 | |
| 56 | // An untagged file is the route's own, read as today: its resolution is |
| 57 | // carried and its failed completion kept. |
| 58 | b, _, _ = testBuildStates(t) |
| 59 | legacy := foreignRecord() |
| 60 | legacy.Account, legacy.App = "", "" |
| 61 | writeConsumedAsIs(t, b, legacy) |
| 62 | if err := b.beforeExec(ctx, astraHost, "v1"); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if st, _, _ = b.read(ctx, astraHost); st.Resolved == nil || st.Resolved.ID != 7 || st.Completed != 3 || !st.CompletedFailed { |
| 66 | t.Fatalf("state = %+v (resolved %+v); want the untagged file's request state", st, st.Resolved) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // Astra r4 finding 3: the pre-exec write learns the route's tenant from the |
| 71 | // registry. A read that fails is no answer: B owns the host, A's file is |
| 72 | // still on disk, and an unread route cannot tell them apart. beforeExec |
| 73 | // returns the error before any build-state write, so the durable object is |
| 74 | // left exactly as it was. (A route that is absent, read without error, is |
| 75 | // today's behaviour and is not this case.) |
| 76 | func TestBeforeExecFailsClosedOnARegistryReadError(t *testing.T) { |
| 77 | b, s, reg := testBuildStates(t) |
| 78 | ctx := context.Background() |
| 79 | if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "B1", Completed: 1}); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | key := release.BuildStateKey("inst/" + astraHost) |
| 83 | etag := mustEtag(t, s, key) |
| 84 | writeConsumedAsIs(t, b, foreignRecord()) |
| 85 | if err := reg.Close(); err != nil { // every Get now fails |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if err := b.beforeExec(ctx, astraHost, "v1"); err == nil { |
| 89 | t.Fatal("beforeExec with an unreadable route returned nil") |
| 90 | } |
| 91 | if got := mustEtag(t, s, key); got != etag { |
| 92 | st, _, _ := getState(ctx, s, key, astraHost) |
| 93 | t.Fatalf("the durable build state was written with the route unread: %+v (resolved %+v)", st, st.Resolved) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestACarryIgnoresAnotherTenantsFile(t *testing.T) { |
| 98 | b, s, reg := testBuildStates(t) |
| 99 | ctx := context.Background() |
| 100 | if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "A", Completed: 1}); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | if err := reg.SetStatus(astraHost, "moving"); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | key := release.BuildStateKey("inst/" + astraHost) |
| 107 | etag := mustEtag(t, s, key) |
| 108 | writeConsumedAsIs(t, b, foreignRecord()) |
| 109 | if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if mustEtag(t, s, key) != etag { |
| 113 | st, _, _ := getState(ctx, s, key, astraHost) |
| 114 | t.Fatalf("A's record was carried into B's state: %+v", st) |
| 115 | } |
| 116 | |
| 117 | // An untagged file is carried as today. |
| 118 | legacy := foreignRecord() |
| 119 | legacy.Account, legacy.App = "", "" |
| 120 | writeConsumedAsIs(t, b, legacy) |
| 121 | if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | st, _, err := getState(ctx, s, key, astraHost) |
| 125 | if err != nil || st == nil || st.LastExecuted != "A" || st.Completed != 3 || !st.CompletedFailed || st.Resolved == nil || st.Resolved.ID != 7 { |
| 126 | t.Fatalf("state %+v, %v; want A with R3 completed failed and R7's resolution carried", st, err) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestTheHeldWakeTrustsOnlyAFileDecidedForTheRoute(t *testing.T) { |
| 131 | b, _, reg := testBuildStates(t) |
| 132 | u := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 2} |
| 133 | setSettings(t, reg, u) |
| 134 | setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2", Completed: 1}}) |
| 135 | record := release.Consumed{Completed: 1, Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}, |
| 136 | Reason: string(release.ReasonRequest), Desired: "v4", Detail: "v4", Settings: release.SettingsDigest(u.Column())} |
| 137 | |
| 138 | for _, tc := range []struct{ name, account, app string }{ |
| 139 | {"another tenant's file", "cdf", "blog"}, |
| 140 | {"another app's file on the same account", release.OpsAccount, "blog"}, |
| 141 | } { |
| 142 | c := record |
| 143 | c.Account, c.App = tc.account, tc.app |
| 144 | writeConsumedAsIs(t, b, c) |
| 145 | if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "v2" { |
| 146 | t.Fatalf("%s: heldTarget = %+v; want held on v2, asking root (no decision for this route)", tc.name, h) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // The route's own file is root's word: the request's T. An untagged file |
| 151 | // is read as before task 8 (ruling R28), as the route's own. |
| 152 | for _, tc := range []struct{ name, account, app string }{ |
| 153 | {"the route's own file", release.OpsAccount, "km"}, |
| 154 | {"an untagged file", "", ""}, |
| 155 | } { |
| 156 | c := record |
| 157 | c.Account, c.App = tc.account, tc.app |
| 158 | writeConsumedAsIs(t, b, c) |
| 159 | if h := b.heldTarget(astraHost); !h.Held || h.AskRoot || h.Target != "v4" { |
| 160 | t.Fatalf("%s: heldTarget = %+v; want the request's v4", tc.name, h) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // identityRecorder is plannedBackend with a live link it can capture: it |
| 166 | // counts the captures and answers with T's identity, so a wake that gets as |
| 167 | // far as running the live link records it. |
| 168 | type identityRecorder struct { |
| 169 | *plannedBackend |
| 170 | captured atomic.Int32 |
| 171 | } |
| 172 | |
| 173 | func (r *identityRecorder) LiveIdentity(context.Context, registry.Route) (release.StagedIdentity, error) { |
| 174 | r.captured.Add(1) |
| 175 | return release.StagedIdentity{Version: r.live}, nil |
| 176 | } |
| 177 | |
| 178 | // Astra r4 finding 1 (ruling R28), through the real wake: a same-tenant |
| 179 | // manual instance with T installed, its build state read and absent, and an |
| 180 | // UNTAGGED consumed file (a settings-4 build's) recording that T failed. |
| 181 | // There is no refusal record and no planner. The activator's held wake check |
| 182 | // must refuse the wake as failed: it never captures the live link, never |
| 183 | // records T, never runs anything. |
| 184 | func TestAnUntaggedFailedDecisionRefusesTheWake(t *testing.T) { |
| 185 | b, s, reg := testBuildStates(t) |
| 186 | b.prefix = func(h string) string { return activator.HibernatePrefix("", h) } |
| 187 | installLive(t, b, "T") |
| 188 | setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) |
| 189 | setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true}) |
| 190 | rt, _ := reg.Get(astraHost) |
| 191 | writeConsumedAsIs(t, b, release.Consumed{Reason: string(release.ReasonFailed), Detail: "T", |
| 192 | Settings: release.SettingsDigest(rt.UpdateSettings)}) |
| 193 | if r, _ := release.ReadRefusal(b.bakeDir, astraHost); r != nil { |
| 194 | t.Fatalf("a refusal record exists: %+v", r) |
| 195 | } |
| 196 | |
| 197 | be := &identityRecorder{plannedBackend: &plannedBackend{live: "T"}} |
| 198 | svc := activator.New(activator.Config{Bucket: "hib", Idle: time.Hour}, be, reg) |
| 199 | svc.OnGen = func(activator.GenEvent) {} |
| 200 | svc.BeforeExec = b.beforeExec |
| 201 | svc.HeldTarget = b.heldTarget |
| 202 | t.Cleanup(func() { svc.Shutdown(context.Background()) }) |
| 203 | |
| 204 | rec := serve(t, svc, reg) |
| 205 | if rec.Code == http.StatusOK { |
| 206 | t.Fatalf("the wake ran T, which root reports failed: %q", rec.Body.String()) |
| 207 | } |
| 208 | if n := be.captured.Load(); n != 0 { |
| 209 | t.Fatalf("the wake captured the live link %d times; want it refused before", n) |
| 210 | } |
| 211 | if got := be.builds(); len(got) != 0 { |
| 212 | t.Fatalf("generations %v, want none", got) |
| 213 | } |
| 214 | if st := durable(t, b, s); st != nil { |
| 215 | t.Fatalf("durable state %+v; want none (T never recorded)", st) |
| 216 | } |
| 217 | } |
| 218 | |