carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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.
24func 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).
32func 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
40func 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.)
76func 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
97func 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
130func 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.
168type identityRecorder struct {
169 *plannedBackend
170 captured atomic.Int32
171}
172
173func (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.
184func 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