carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package registry
2
3import (
4 "context"
5 "errors"
6 "path/filepath"
7 "testing"
8)
9
10const buildStateCol = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:00Z"}`
11
12func TestBuildStateColumn(t *testing.T) {
13 reg, err := Open(filepath.Join(t.TempDir(), "r.db"))
14 if err != nil {
15 t.Fatal(err)
16 }
17 if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil {
18 t.Fatal(err)
19 }
20 rt, _ := reg.Get("a.example.com")
21 if rt.BuildState != "" {
22 t.Fatalf("a new row has build state %q", rt.BuildState)
23 }
24 before, err := reg.Fingerprint()
25 if err != nil {
26 t.Fatal(err)
27 }
28 col := buildStateCol
29 if err := reg.SetBuildState("a.example.com", col); err != nil {
30 t.Fatal(err)
31 }
32 if rt, _ = reg.Get("a.example.com"); rt.BuildState != col {
33 t.Fatalf("column = %q", rt.BuildState)
34 }
35 after, err := reg.Fingerprint()
36 if err != nil {
37 t.Fatal(err)
38 }
39 if after != before {
40 t.Fatal("build state changed the edge's fingerprint")
41 }
42 // A full-row Upsert that does not carry the column keeps it.
43 if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil {
44 t.Fatal(err)
45 }
46 if rt, _ = reg.Get("a.example.com"); rt.BuildState != col {
47 t.Fatalf("Upsert cleared the column: %q", rt.BuildState)
48 }
49 if err := reg.SetBuildState("a.example.com", ""); err != nil {
50 t.Fatal(err)
51 }
52 if rt, _ = reg.Get("a.example.com"); rt.BuildState != "" {
53 t.Fatal("the narrow setter could not clear the column")
54 }
55 if err := reg.SetBuildState("nobody.example.com", col); err == nil {
56 t.Fatal("an unknown host was not an error")
57 }
58}
59
60// build_state is rewritten (ReadAt) on every reconcile refresh with nothing an
61// operator did behind it, so it must not trigger a registry snapshot (#207).
62func TestBuildStateIsChurnForTheSnapshotGate(t *testing.T) {
63 reg, err := Open(filepath.Join(t.TempDir(), "r.db"))
64 if err != nil {
65 t.Fatal(err)
66 }
67 const host = "a.example.com"
68 if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "km"}); err != nil {
69 t.Fatal(err)
70 }
71 before, err := reg.SnapshotFingerprint()
72 if err != nil {
73 t.Fatal(err)
74 }
75 if err := reg.SetBuildState(host, buildStateCol); err != nil {
76 t.Fatal(err)
77 }
78 after, err := reg.SnapshotFingerprint()
79 if err != nil {
80 t.Fatal(err)
81 }
82 if before != after {
83 t.Fatal("SetBuildState moved the snapshot fingerprint")
84 }
85}
86
87// A cold handover INSERTs the whole row positionally: the column must be in
88// that list or every handover fails on column count, and its value must
89// survive.
90func TestBuildStateSurvivesColdHandover(t *testing.T) {
91 t.Setenv("CARLOS_HOST_CLAIM", "1")
92 ctx := context.Background()
93 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
94 if err != nil {
95 t.Fatal(err)
96 }
97 t.Cleanup(func() { reg.Close() })
98 oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"}
99 newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"}
100 route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner}
101 if err = reg.Upsert(route); err != nil {
102 t.Fatal(err)
103 }
104 if err = reg.SetBuildState(route.Host, buildStateCol); err != nil {
105 t.Fatal(err)
106 }
107 current, err := reg.Get(route.Host)
108 if err != nil || current == nil {
109 t.Fatalf("Get: %v", err)
110 }
111 old := ColdRows{Route: *current}
112 next := ColdRows{Route: *current}
113 next.Route.ClaimOwnership = newOwner
114 targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}}
115 if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil {
116 t.Fatal(err)
117 }
118 if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil {
119 t.Fatalf("handover refused: %v", err)
120 }
121 got, err := reg.Get(route.Host)
122 if err != nil || got == nil {
123 t.Fatalf("Get after handover: %v", err)
124 }
125 if got.BuildState != buildStateCol {
126 t.Fatalf("build state after handover = %q", got.BuildState)
127 }
128}
129
130// buildStateColLater is buildStateCol after one more reconcile refresh: only
131// read_at moved, as it does every reconcile even for a stopped host.
132const buildStateColLater = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:30Z"}`
133
134// A cold handover replayed after its lost reply must not be refused because
135// the agent refreshed the row's build-state copy in between: the column is
136// the agent's cache of the bucket object (snapshotChurnCols), not part of
137// the installation the replay checks.
138func TestColdHandoverReplayIgnoresABuildStateRefresh(t *testing.T) {
139 t.Setenv("CARLOS_HOST_CLAIM", "1")
140 ctx := context.Background()
141 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
142 if err != nil {
143 t.Fatal(err)
144 }
145 t.Cleanup(func() { reg.Close() })
146 oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"}
147 newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"}
148 route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner}
149 if err = reg.Upsert(route); err != nil {
150 t.Fatal(err)
151 }
152 if err = reg.SetBuildState(route.Host, buildStateCol); err != nil {
153 t.Fatal(err)
154 }
155 current, err := reg.Get(route.Host)
156 if err != nil || current == nil {
157 t.Fatalf("Get: %v", err)
158 }
159 old := ColdRows{Route: *current}
160 next := ColdRows{Route: *current}
161 next.Route.ClaimOwnership = newOwner
162 targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}}
163 if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil {
164 t.Fatal(err)
165 }
166 if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil {
167 t.Fatalf("handover refused: %v", err)
168 }
169 if err = reg.SetBuildState(route.Host, buildStateColLater); err != nil {
170 t.Fatal(err)
171 }
172 if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil {
173 t.Fatalf("replay after a build-state refresh refused: %v", err)
174 }
175 if got, _ := reg.Get(route.Host); got == nil || got.BuildState != buildStateColLater {
176 t.Fatalf("the replay rewrote the refreshed copy: %+v", got)
177 }
178 // Any other column still decides the replay.
179 if _, err = reg.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, route.Host); err != nil {
180 t.Fatal(err)
181 }
182 if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) {
183 t.Fatalf("replay over a changed row: %v", err)
184 }
185}
186
187// A recovery handover's rebind (main's RebindClaimRows) reads the row, then
188// rebinds it in a transaction, and replays from a fresh read after a lost
189// reply or a crash. A build-state refresh between any two of those steps is
190// not a concurrent replacement of the installation: it must neither refuse
191// the rebind nor its replay, and the rebind must not write the column.
192func TestRecoveryRebindIgnoresABuildStateRefresh(t *testing.T) {
193 for _, mode := range []string{"between-read-and-rebind", "replay-from-a-fresh-read", "replay-with-the-first-read"} {
194 t.Run(mode, func(t *testing.T) {
195 r, err := Open(filepath.Join(t.TempDir(), "registry.db"))
196 if err != nil {
197 t.Fatal(err)
198 }
199 defer r.Close()
200 const host = "app.example"
201 oldOwner := ClaimOwnership{"inc", 1, "old", "old-row"}
202 newOwner := ClaimOwnership{"inc", 2, "new", "new-row"}
203 if err = r.Upsert(Route{Host: host, Kind: "instance", ClaimOwnership: oldOwner}); err != nil {
204 t.Fatal(err)
205 }
206 if err = r.SetBuildState(host, buildStateCol); err != nil {
207 t.Fatal(err)
208 }
209 saved, err := r.Get(host)
210 if err != nil {
211 t.Fatal(err)
212 }
213 old := ColdRows{Route: *saved}
214 next := old
215 next.Route.ClaimOwnership = newOwner
216 if mode == "between-read-and-rebind" {
217 if err = r.SetBuildState(host, buildStateColLater); err != nil {
218 t.Fatal(err)
219 }
220 }
221 if err = r.RebindClaimRows(t.Context(), old, next); err != nil {
222 t.Fatalf("rebind: %v", err)
223 }
224 if mode != "between-read-and-rebind" {
225 if err = r.SetBuildState(host, buildStateColLater); err != nil {
226 t.Fatal(err)
227 }
228 replayOld, replayNext := old, next
229 if mode == "replay-from-a-fresh-read" {
230 // As claimCrossBoxHandover.rebindRecoveryRows replays: a
231 // fresh Get, the old ownership from the inventory.
232 fresh, err := r.Get(host)
233 if err != nil {
234 t.Fatal(err)
235 }
236 replayOld, replayNext = ColdRows{Route: *fresh}, ColdRows{Route: *fresh}
237 replayOld.Route.ClaimOwnership = oldOwner
238 }
239 if err = r.RebindClaimRows(t.Context(), replayOld, replayNext); err != nil {
240 t.Fatalf("replay after a build-state refresh refused: %v", err)
241 }
242 }
243 got, err := r.Get(host)
244 if err != nil || got.ClaimOwnership != newOwner || got.BuildState != buildStateColLater {
245 t.Fatalf("route = %+v, %v", got, err)
246 }
247 // Any other column still decides the replay.
248 if _, err = r.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, host); err != nil {
249 t.Fatal(err)
250 }
251 if err = r.RebindClaimRows(t.Context(), old, next); !errors.Is(err, ErrClaimOwnership) {
252 t.Fatalf("replay over a changed row: %v", err)
253 }
254 })
255 }
256}
257
258// The host's registry child carries the writer across the transport.
259func TestRemoteSetBuildState(t *testing.T) {
260 r, _ := remoteTestRegistry(t)
261 const host = "remote.example.com"
262 if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil {
263 t.Fatal(err)
264 }
265 if err := r.SetBuildState(host, buildStateCol); err != nil {
266 t.Fatal(err)
267 }
268 got, err := r.Get(host)
269 if err != nil || got == nil || got.BuildState != buildStateCol {
270 t.Fatalf("route = %+v, %v", got, err)
271 }
272 if err := r.SetBuildState("nobody.example.com", buildStateCol); err == nil {
273 t.Fatal("an unknown host was not an error")
274 }
275}
276