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 "errors"
6 "path/filepath"
7 "reflect"
8 "sort"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/provision"
13 "github.com/carlosframework/platform/internal/registry"
14 "github.com/carlosframework/platform/internal/release"
15 "github.com/carlosframework/platform/internal/status"
16)
17
18func report(label string, at time.Time, pins map[string]status.BuildStatePins) status.Box {
19 return status.Box{Label: label, GeneratedAt: at, Metrics: &status.BoxMetrics{BuildStates: pins}}
20}
21
22func kmPins(builds ...string) status.BuildStatePins {
23 return status.BuildStatePins{Account: "bab", App: "km", Builds: builds, Complete: true}
24}
25
26// Astra I1 #6: a host whose build state its box could not read stops
27// pruning for the app, even in a fresh, readable report.
28func TestAnIncompleteBuildStatePrunesNothing(t *testing.T) {
29 s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"})
30 inc := kmPins()
31 inc.Complete = false
32 boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": inc})}
33 if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil {
34 t.Fatal("an unreadable build state did not stop pruning")
35 }
36}
37
38func TestAnUnreadBuildStateIsReportedIncomplete(t *testing.T) {
39 _, _, reg := testBuildStates(t) // a.example.com, never refreshed: copy unknown
40 pins, err := buildStatePins(reg, false)
41 if p, ok := pins["a.example.com"]; err != nil || !ok || p.Complete {
42 t.Fatalf("report = %+v, %v, %v; want the host listed and incomplete", p, ok, err)
43 }
44 if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, ReadErr: "timeout"}.Column()); err != nil {
45 t.Fatal(err)
46 }
47 if pins, _ := buildStatePins(reg, false); pins["a.example.com"].Complete {
48 t.Fatal("a failed refresh was reported complete")
49 }
50 if err := reg.SetBuildState("a.example.com", "{"); err != nil {
51 t.Fatal(err)
52 }
53 if pins, _ := buildStatePins(reg, false); pins["a.example.com"].Complete {
54 t.Fatal("an unparseable copy was reported complete")
55 }
56}
57
58func recordsStore(t *testing.T, recs ...provision.Record) release.Store {
59 t.Helper()
60 s := release.NewDirStore(t.TempDir())
61 for _, r := range recs {
62 if err := provision.CreateRecord(context.Background(), s, r); err != nil {
63 t.Fatal(err)
64 }
65 }
66 return s
67}
68
69var now0 = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
70
71// Spec rev 11 §4.4: pins from records, build-state builds from box reports.
72func TestPinsAndReportedBuildStatesAreProtected(t *testing.T) {
73 s := recordsStore(t,
74 provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"},
75 provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable", Region: "uk"})
76 boxes := []status.Box{report("uk-1", now0.Add(-time.Hour), map[string]status.BuildStatePins{
77 "a.example.com": kmPins(), "b.example.com": kmPins("v2", "v3")})}
78 got, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km")
79 if err != nil {
80 t.Fatal(err)
81 }
82 for _, v := range []string{"v1", "v2", "v3"} {
83 if !got[v] {
84 t.Errorf("%s is not protected: %v", v, got)
85 }
86 }
87}
88
89func TestABoxSilentForSixHoursPrunesNothing(t *testing.T) {
90 s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"})
91 boxes := []status.Box{report("uk-1", now0.Add(-6*time.Hour-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")})}
92 if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil {
93 t.Fatal("a box hosting the app that has not reported in 6h did not stop pruning")
94 }
95}
96
97func TestANewerReportForTheHostWins(t *testing.T) {
98 s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"})
99 boxes := []status.Box{
100 report("uk-1", now0.Add(-48*time.Hour), map[string]status.BuildStatePins{"b.example.com": kmPins("v1")}), // the box it moved off
101 report("ie-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")}),
102 }
103 got, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km")
104 if err != nil || !got["v2"] {
105 t.Fatalf("got %v, %v; want v2 from the newest report and no refusal", got, err)
106 }
107 // §4.4 protects every build named in a report: the older report's v1 is
108 // kept too, which can only keep more.
109 if !got["v1"] {
110 t.Errorf("v1, named by the older report, is not protected: %v", got)
111 }
112}
113
114// The newest report decides completeness too: an older box's incomplete
115// entry for a host that has since moved does not block, and a newer
116// incomplete one does even when an older one was complete.
117func TestTheNewestReportDecidesCompleteness(t *testing.T) {
118 s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"})
119 inc := kmPins()
120 inc.Complete = false
121 moved := []status.Box{
122 report("uk-1", now0.Add(-2*time.Hour), map[string]status.BuildStatePins{"b.example.com": inc}),
123 report("ie-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")}),
124 }
125 if _, err := settingsProtected(context.Background(), s, moved, true, now0, "bab", "km"); err != nil {
126 t.Fatalf("an older box's incomplete entry blocked: %v", err)
127 }
128 reversed := []status.Box{
129 report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": inc}),
130 report("ie-1", now0.Add(-2*time.Hour), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")}),
131 }
132 if _, err := settingsProtected(context.Background(), s, reversed, true, now0, "bab", "km"); err == nil {
133 t.Fatal("the newest report's incomplete entry did not stop pruning")
134 }
135}
136
137func TestOtherAppsReportsDoNotBlock(t *testing.T) {
138 s := recordsStore(t, provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"})
139 boxes := []status.Box{
140 report("uk-1", now0.Add(-72*time.Hour), map[string]status.BuildStatePins{
141 "x.example.com": {Account: "bab", App: "blog", Builds: []string{"b1"}}}),
142 report("ie-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"a.example.com": kmPins("v1")}),
143 }
144 if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err != nil {
145 t.Fatalf("another app's silent box blocked km: %v", err)
146 }
147}
148
149// Astra I2 #5: every provisioned instance of the app needs an entry. A
150// fresh, readable report that simply omits the host (its map left out)
151// prunes nothing, not "nothing to protect".
152func TestAnOmittedHostPrunesNothing(t *testing.T) {
153 s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"})
154 boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), nil)}
155 if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil {
156 t.Fatal("a host no report names was treated as having no builds to protect")
157 }
158 // A report naming the host under another account is not this app's.
159 other := []status.Box{report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{
160 "b.example.com": {Account: "zzz", App: "km", Builds: []string{"v1"}, Complete: true}})}
161 if _, err := settingsProtected(context.Background(), s, other, true, now0, "bab", "km"); err == nil {
162 t.Fatal("another account's entry for the host counted as this app's")
163 }
164}
165
166// Only a record that keeps a build state needs a report: a unit-backed or
167// always-on instance, or a static route, has none to report.
168func TestRecordsWithNoBuildStateNeedNoReport(t *testing.T) {
169 off := false
170 s := recordsStore(t,
171 provision.Record{Schema: 1, Host: "u.example.com", App: "km", Account: "bab", Channel: "stable", Unit: "km.service"},
172 provision.Record{Schema: 1, Host: "o.example.com", App: "km", Account: "bab", Channel: "stable", Hibernate: &off, Pin: "v7"},
173 provision.Record{Schema: 1, Host: "s.example.com", App: "km", Account: "bab", Channel: "stable", Kind: "static"})
174 got, err := settingsProtected(context.Background(), s, nil, true, now0, "bab", "km")
175 if err != nil {
176 t.Fatalf("records that keep no build state blocked: %v", err)
177 }
178 if !got["v7"] {
179 t.Errorf("the pin of a record that keeps no build state is not protected: %v", got)
180 }
181}
182
183// Astra I2 #5: a box that could not list its routes says so, and a fresh
184// report saying so stops pruning even when an older report from another
185// box still names the host (it may have moved onto the failing box).
186func TestACollectionFailurePrunesNothing(t *testing.T) {
187 s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"})
188 failed := report("uk-1", now0.Add(-time.Minute), nil)
189 failed.Metrics.BuildStatesErr = "registry: database is locked"
190 boxes := []status.Box{
191 report("ie-1", now0.Add(-2*time.Hour), map[string]status.BuildStatePins{"b.example.com": kmPins("v1")}),
192 failed,
193 }
194 if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil {
195 t.Fatal("a box that could not collect its build states did not stop pruning")
196 }
197}
198
199// registry.List opens its own connection, so a closed registry still lists:
200// the failing list is a fake.
201func TestTheCollectorReportsItsFailure(t *testing.T) {
202 reg := fakeLister{err: errors.New("database is locked")}
203 if pins, err := buildStatePins(reg, false); err == nil || pins != nil {
204 t.Fatalf("a failed registry list returned %v, %v; want an error", pins, err)
205 }
206}
207
208func TestAnUnreadableRecordOrReportPrunesNothing(t *testing.T) {
209 s := recordsStore(t, provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"})
210 if _, err := s.Put(context.Background(), provision.RecordKey("bab", "km", "z.example.com"), []byte("{"), ""); err != nil {
211 t.Fatal(err)
212 }
213 if _, err := settingsProtected(context.Background(), s, nil, true, now0, "bab", "km"); err == nil {
214 t.Fatal("an unreadable record did not stop pruning")
215 }
216 clean := recordsStore(t, provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"})
217 if _, err := settingsProtected(context.Background(), clean, nil, false, now0, "bab", "km"); err == nil {
218 t.Fatal("an incomplete read of box reports did not stop pruning")
219 }
220}
221
222// Ruling R-T9-STORE: provision.ListRecords(..., 0) reports truncated whenever
223// a key exists, and it skips a record it cannot read. Every record is read,
224// and any failure (a record whose body names another host, a failed Get, a
225// failed listing) prunes nothing.
226func TestEveryRecordIsReadAndAnyFailurePrunesNothing(t *testing.T) {
227 ctx := context.Background()
228 recs := []provision.Record{
229 {Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"},
230 {Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v2"},
231 {Schema: 1, Host: "c.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v3"},
232 }
233 all := map[string]status.BuildStatePins{"a.example.com": kmPins(), "b.example.com": kmPins(), "c.example.com": kmPins()}
234 boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), all)}
235 s := recordsStore(t, recs...)
236 got, err := settingsProtected(ctx, s, boxes, true, now0, "bab", "km")
237 if err != nil {
238 t.Fatalf("three good records refused: %v", err)
239 }
240 if !got["v1"] || !got["v2"] || !got["v3"] {
241 t.Fatalf("not every record's pin is protected: %v", got)
242 }
243
244 // A body that disagrees with its own path.
245 moved := recordsStore(t, recs...)
246 raw, _, err := moved.Get(ctx, provision.RecordKey("bab", "km", "a.example.com"))
247 if err != nil {
248 t.Fatal(err)
249 }
250 if _, err := moved.Put(ctx, provision.RecordKey("bab", "km", "d.example.com"), raw, ""); err != nil {
251 t.Fatal(err)
252 }
253 if _, err := settingsProtected(ctx, moved, boxes, true, now0, "bab", "km"); err == nil {
254 t.Fatal("a record that disagrees with its path did not stop pruning")
255 }
256
257 // A Get that fails.
258 getFails := failingGetStore{Store: recordsStore(t, recs...), bad: provision.RecordKey("bab", "km", "b.example.com"), err: errors.New("SlowDown")}
259 if _, err := settingsProtected(ctx, getFails, boxes, true, now0, "bab", "km"); err == nil {
260 t.Fatal("a record that could not be read did not stop pruning")
261 }
262
263 // A listing that fails.
264 listFails := failingListStore{Store: recordsStore(t, recs...), prefix: provision.RecordsPrefix("bab", "km"), err: errors.New("AccessDenied")}
265 if _, err := settingsProtected(ctx, listFails, boxes, true, now0, "bab", "km"); err == nil {
266 t.Fatal("a failed record listing did not stop pruning")
267 }
268}
269
270func TestTheBoxReportsItsBuildStates(t *testing.T) {
271 _, _, reg := testBuildStates(t)
272 if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, Replica: true,
273 State: &release.BuildState{LastExecuted: "v1", Resolved: &release.ResolvedRequest{ID: 2, Target: "v2"}}}.Column()); err != nil {
274 t.Fatal(err)
275 }
276 pins, err := buildStatePins(reg, false)
277 if err != nil {
278 t.Fatal(err)
279 }
280 got := pins["a.example.com"]
281 // testBuildStates' row has no account: RouteAccount's fallback is ops,
282 // whose sqid is "bab", the account the housekeeper keys the app by.
283 if got.Account != "bab" || got.App != "km" || !got.Complete || len(got.Builds) != 2 || got.Builds[0] != "v1" || got.Builds[1] != "v2" {
284 t.Fatalf("report = %+v", got)
285 }
286}
287
288// Task 4's review: the report names exactly the rows keepsBuildState
289// selects, the rows refreshAll keeps a copy of. A unit-backed hibernating
290// instance is never refreshed, so listing it would read as incomplete
291// forever; an exec-backed worker is refreshed with host claims off, and
292// never with them on.
293func TestTheReportNamesExactlyTheRowsThatKeepABuildState(t *testing.T) {
294 reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db"))
295 if err != nil {
296 t.Fatal(err)
297 }
298 defer reg.Close()
299 rows := []registry.Route{
300 {Host: "a.example.com", Kind: "instance", App: "km", Account: "bab", Hibernate: true, Socket: "/run/carlos/a.sock"},
301 {Host: "u.example.com", Kind: "instance", App: "km", Account: "bab", Hibernate: true, Socket: "/run/carlos/u.sock", Unit: "km.service"},
302 {Host: "w.example.com", Kind: "sidecar", App: "km", Account: "bab", Socket: "/run/carlos/w.sock"},
303 {Host: "t.example.com", Kind: "instance", App: "km", Account: "bab", Hibernate: true, Socket: "/run/carlos/t.sock", Tunnel: "fleet-1"},
304 {Host: "r.example.com", Kind: "instance", App: "km", Account: "bab", Socket: "/run/carlos/r.sock"},
305 {Host: "p.example.com", Kind: "instance", App: "edge", Hibernate: true, Socket: "/run/carlos/p.sock", Platform: true},
306 }
307 for _, rt := range rows {
308 if err := reg.Upsert(rt); err != nil {
309 t.Fatal(err)
310 }
311 }
312 routes, err := reg.List()
313 if err != nil {
314 t.Fatal(err)
315 }
316 for _, claims := range []bool{false, true} {
317 pins, err := buildStatePins(reg, claims)
318 if err != nil {
319 t.Fatal(err)
320 }
321 var got, want []string
322 for h := range pins {
323 got = append(got, h)
324 }
325 for _, rt := range routes {
326 if keepsBuildState(rt, claims) {
327 want = append(want, rt.Host)
328 }
329 }
330 sort.Strings(got)
331 sort.Strings(want)
332 if !reflect.DeepEqual(got, want) {
333 t.Errorf("claims %v: report names %v, keepsBuildState selects %v", claims, got, want)
334 }
335 wantHosts := []string{"a.example.com", "w.example.com"}
336 if claims {
337 wantHosts = []string{"a.example.com"}
338 }
339 if !reflect.DeepEqual(got, wantHosts) {
340 t.Errorf("claims %v: report names %v, want %v", claims, got, wantHosts)
341 }
342 }
343}
344
345// The reports are the deployment's and every fleet's, and load says when any
346// could not be read.
347func TestLoadAllBoxesReadsEveryFleet(t *testing.T) {
348 ctx := context.Background()
349 s := release.NewDirStore(t.TempDir())
350 if err := status.WriteBox(ctx, s, report("uk-1", now0, map[string]status.BuildStatePins{"a.example.com": kmPins("v1")})); err != nil {
351 t.Fatal(err)
352 }
353 if err := status.WriteBox(ctx, release.NewPrefixStore(s, "fleets/acme/"), report("acme-1", now0, map[string]status.BuildStatePins{"b.example.com": kmPins("v2")})); err != nil {
354 t.Fatal(err)
355 }
356 boxes, complete, err := (&boxReports{status: s}).load(ctx)
357 if err != nil || !complete {
358 t.Fatalf("load = %v, %v", complete, err)
359 }
360 var labels []string
361 for _, b := range boxes {
362 labels = append(labels, b.Label)
363 }
364 sort.Strings(labels)
365 if !reflect.DeepEqual(labels, []string{"acme-1", "uk-1"}) {
366 t.Fatalf("read %v, want both the deployment's box and the fleet's", labels)
367 }
368
369 if _, err := s.Put(ctx, "fleets/beta/status/boxes/beta-1.json", []byte("{"), ""); err != nil {
370 t.Fatal(err)
371 }
372 if _, complete, err := (&boxReports{status: s}).load(ctx); err != nil || complete {
373 t.Fatalf("a corrupt fleet report read as complete (%v, %v)", complete, err)
374 }
375
376 // R-T9-FLEETS: a failed fleets listing is an incomplete read.
377 fleetsFail := failingListStore{Store: release.NewDirStore(t.TempDir()), prefix: "fleets/", err: errors.New("AccessDenied")}
378 if _, complete, err := (&boxReports{status: fleetsFail}).load(ctx); err != nil || complete {
379 t.Fatalf("a failed fleets listing read as complete (%v, %v)", complete, err)
380 }
381 rootFail := failingListStore{Store: release.NewDirStore(t.TempDir()), prefix: "status/boxes/", err: errors.New("AccessDenied")}
382 if _, _, err := (&boxReports{status: rootFail}).load(ctx); err == nil {
383 t.Fatal("a failed listing of the platform's reports was not an error")
384 }
385 if _, _, err := (&boxReports{}).load(ctx); err == nil {
386 t.Fatal("no report source at all was not an error")
387 }
388}
389
390// retentionFixture is TestRetentionPassPrunesEligibleAndProtectsBoxPins'
391// shape with 27 versions and keep 20, so v01..v07 are the candidates, and
392// a provisioned, hibernating kass.example.com on this box at v07 (previous
393// v06, good v05) whose record pins v04.
394func retentionFixture(t *testing.T) (context.Context, *release.DirStore, *registry.Registry, func(string) bool) {
395 t.Helper()
396 ctx := context.Background()
397 dir := t.TempDir()
398 s := release.NewDirStore(dir)
399 now := time.Now()
400 base := now.Add(-30 * 24 * time.Hour)
401 vname := func(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
402 for i := 1; i <= 27; i++ {
403 stampKey(t, s, dir, release.ManifestKey("bab", "kass", vname(i)), base.Add(time.Duration(i)*time.Hour))
404 stampKey(t, s, dir, release.ArtifactKey("bab", "kass", vname(i), "linux-arm64"), base.Add(time.Duration(i)*time.Hour))
405 }
406 if err := release.SetRetention(ctx, s, "bab", "kass", 20, "test", now); err != nil {
407 t.Fatal(err)
408 }
409 if err := provision.CreateRecord(ctx, s, provision.Record{Schema: 1, Host: "kass.example.com", App: "kass", Account: "bab", Channel: "stable", Pin: "v04"}); err != nil {
410 t.Fatal(err)
411 }
412 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
413 if err != nil {
414 t.Fatal(err)
415 }
416 t.Cleanup(func() { reg.Close() })
417 if err := reg.Upsert(registry.Route{
418 Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", Hibernate: true, Socket: "/run/carlos/kass.sock",
419 Version: "v07", PrevVersion: "v06", GoodVersion: "v05", Status: "ready",
420 }); err != nil {
421 t.Fatal(err)
422 }
423 present := func(v string) bool {
424 _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", v))
425 return err == nil
426 }
427 return ctx, s, reg, present
428}
429
430// The pass itself: this box's Version, PrevVersion and GoodVersion (zdd-5
431// obligation 9), the record's pin, and the reported last_executed and
432// resolved T are all kept; only what nothing names is pruned.
433func TestRetentionPassProtectsPinsGoodVersionAndReportedBuilds(t *testing.T) {
434 t.Parallel()
435 ctx, s, reg, present := retentionFixture(t)
436 _, pub := retentionKeys(t)
437 reports := release.NewDirStore(t.TempDir())
438 if err := status.WriteBox(ctx, reports, report("uk-1", time.Now().Add(-time.Minute), map[string]status.BuildStatePins{
439 "kass.example.com": {Account: "bab", App: "kass", Builds: []string{"v03", "v02"}, Complete: true}})); err != nil {
440 t.Fatal(err)
441 }
442 if err := retentionPass(ctx, s, &boxReports{status: reports}, pub, reg); err != nil {
443 t.Fatal(err)
444 }
445 if present("v01") {
446 t.Error("v01 survived; nothing names it")
447 }
448 for _, v := range []string{"v02", "v03", "v04", "v05", "v06", "v07"} {
449 if !present(v) {
450 t.Errorf("%s was pruned", v)
451 }
452 }
453}
454
455// With no report naming the provisioned instance, or no readable reports at
456// all, the pass prunes nothing for the app.
457func TestRetentionPassPrunesNothingWithoutTheReports(t *testing.T) {
458 t.Parallel()
459 t.Run("no report names the host", func(t *testing.T) {
460 ctx, s, reg, present := retentionFixture(t)
461 _, pub := retentionKeys(t)
462 if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil {
463 t.Fatal(err)
464 }
465 if !present("v01") {
466 t.Fatal("v01 was pruned with no report naming kass.example.com's build state")
467 }
468 })
469 t.Run("the reports cannot be listed", func(t *testing.T) {
470 ctx, s, reg, present := retentionFixture(t)
471 _, pub := retentionKeys(t)
472 reports := failingListStore{Store: release.NewDirStore(t.TempDir()), prefix: "status/boxes/", err: errors.New("AccessDenied")}
473 _ = retentionPass(ctx, s, &boxReports{status: reports}, pub, reg)
474 if !present("v01") {
475 t.Fatal("v01 was pruned with the box reports unreadable")
476 }
477 })
478 t.Run("no report source", func(t *testing.T) {
479 ctx, s, reg, present := retentionFixture(t)
480 _, pub := retentionKeys(t)
481 _ = retentionPass(ctx, s, nil, pub, reg)
482 _ = retentionPass(ctx, s, &boxReports{}, pub, reg)
483 if !present("v01") {
484 t.Fatal("v01 was pruned with no report source")
485 }
486 })
487}
488
489// kassPins is retentionFixture's host as this box's own collector reports it.
490func kassPins(builds ...string) func() (map[string]status.BuildStatePins, error) {
491 return func() (map[string]status.BuildStatePins, error) {
492 return map[string]status.BuildStatePins{"kass.example.com": {Account: "bab", App: "kass", Builds: builds, Complete: true}}, nil
493 }
494}
495
496// Ruling R-T9-SOLE: a directory-backed deployment is one box by
497// construction and publishes no status. Its pass takes this box's own
498// collector as a fresh report: what it names is protected, and a collector
499// that fails, or that omits a provisioned host, prunes nothing.
500func TestSoleModeReadsThisBoxsOwnReport(t *testing.T) {
501 t.Parallel()
502 t.Run("protects what this box names", func(t *testing.T) {
503 ctx, s, reg, present := retentionFixture(t)
504 _, pub := retentionKeys(t)
505 if err := retentionPass(ctx, s, &boxReports{deploy: s, local: kassPins("v03", "v02")}, pub, reg); err != nil {
506 t.Fatal(err)
507 }
508 if present("v01") {
509 t.Error("v01 survived; nothing names it")
510 }
511 for _, v := range []string{"v02", "v03", "v04", "v05", "v06", "v07"} {
512 if !present(v) {
513 t.Errorf("%s was pruned", v)
514 }
515 }
516 })
517 t.Run("a failed collection", func(t *testing.T) {
518 ctx, s, reg, present := retentionFixture(t)
519 _, pub := retentionKeys(t)
520 failing := func() (map[string]status.BuildStatePins, error) { return nil, errors.New("database is locked") }
521 boxes, _, err := (&boxReports{local: failing}).load(ctx)
522 if err != nil || len(boxes) != 1 || boxes[0].Metrics == nil || boxes[0].Metrics.BuildStatesErr != "database is locked" {
523 t.Fatalf("load = %+v, %v; want this box's report saying it could not collect", boxes, err)
524 }
525 _ = retentionPass(ctx, s, &boxReports{deploy: s, local: failing}, pub, reg)
526 if !present("v01") {
527 t.Fatal("v01 was pruned while this box could not collect its build states")
528 }
529 })
530 t.Run("the host is not named", func(t *testing.T) {
531 ctx, s, reg, present := retentionFixture(t)
532 _, pub := retentionKeys(t)
533 none := func() (map[string]status.BuildStatePins, error) { return nil, nil }
534 _ = retentionPass(ctx, s, &boxReports{deploy: s, local: none}, pub, reg)
535 if !present("v01") {
536 t.Fatal("v01 was pruned with kass.example.com named by no report")
537 }
538 })
539 t.Run("fleets come from the deployment store", func(t *testing.T) {
540 ctx := context.Background()
541 deploy := release.NewDirStore(t.TempDir())
542 if err := status.WriteBox(ctx, release.NewPrefixStore(deploy, "fleets/acme/"), report("acme-1", now0, map[string]status.BuildStatePins{"b.example.com": kmPins("v2")})); err != nil {
543 t.Fatal(err)
544 }
545 boxes, complete, err := (&boxReports{deploy: deploy, local: kassPins("v03"), now: func() time.Time { return now0 }}).load(ctx)
546 if err != nil || !complete || len(boxes) != 2 {
547 t.Fatalf("load = %+v, %v, %v; want this box and the fleet's", boxes, complete, err)
548 }
549 var local status.Box
550 for _, b := range boxes {
551 if b.Label != "acme-1" {
552 local = b
553 }
554 }
555 if !local.GeneratedAt.Equal(now0) || local.Metrics == nil || local.Metrics.BuildStates["kass.example.com"].App != "kass" {
556 t.Fatalf("this box's report = %+v, want it fresh and naming kass.example.com", local)
557 }
558 })
559}
560
561// Ruling R-T9-FLEETS: fleet reports are read from the status store and from
562// the deployment store (a fleet box that publishes through the store door
563// lands in the deployment bucket), and the union is used. A report found in
564// both is read twice, which the newest-per-host rule tolerates. A failed
565// listing or read in either store is an incomplete read.
566func TestFleetReportsComeFromBothStores(t *testing.T) {
567 ctx := context.Background()
568 st, dep := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
569 both := report("both-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"a.example.com": kmPins("v1")})
570 for _, w := range []struct {
571 s release.Store
572 b status.Box
573 }{
574 {release.NewPrefixStore(st, "fleets/acme/"), report("acme-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": kmPins("v2")})},
575 {release.NewPrefixStore(dep, "fleets/door/"), report("door-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"c.example.com": kmPins("v3")})},
576 {release.NewPrefixStore(st, "fleets/same/"), both},
577 {release.NewPrefixStore(dep, "fleets/same/"), both},
578 } {
579 if err := status.WriteBox(ctx, w.s, w.b); err != nil {
580 t.Fatal(err)
581 }
582 }
583 boxes, complete, err := (&boxReports{status: st, deploy: dep}).load(ctx)
584 if err != nil || !complete {
585 t.Fatalf("load = %v, %v", complete, err)
586 }
587 var labels []string
588 for _, b := range boxes {
589 labels = append(labels, b.Label)
590 }
591 sort.Strings(labels)
592 if !reflect.DeepEqual(labels, []string{"acme-1", "both-1", "both-1", "door-1"}) {
593 t.Fatalf("read %v, want the union of both stores' fleets", labels)
594 }
595 s := recordsStore(t,
596 provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"},
597 provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"},
598 provision.Record{Schema: 1, Host: "c.example.com", App: "km", Account: "bab", Channel: "stable"})
599 got, err := settingsProtected(ctx, s, boxes, complete, now0, "bab", "km")
600 if err != nil || !got["v1"] || !got["v2"] || !got["v3"] {
601 t.Fatalf("got %v, %v; want every fleet's builds and no refusal", got, err)
602 }
603
604 deployListFails := failingListStore{Store: dep, prefix: "fleets/", err: errors.New("AccessDenied")}
605 if _, complete, err := (&boxReports{status: st, deploy: deployListFails}).load(ctx); err != nil || complete {
606 t.Fatalf("a failed fleets listing in the deployment store read as complete (%v, %v)", complete, err)
607 }
608 deployGetFails := failingGetStore{Store: dep, bad: "fleets/door/status/boxes/door-1.json", err: errors.New("SlowDown")}
609 if _, complete, err := (&boxReports{status: st, deploy: deployGetFails}).load(ctx); err != nil || complete {
610 t.Fatalf("a failed fleet report read in the deployment store read as complete (%v, %v)", complete, err)
611 }
612 statusListFails := failingListStore{Store: st, prefix: "fleets/", err: errors.New("AccessDenied")}
613 if _, complete, err := (&boxReports{status: statusListFails, deploy: dep}).load(ctx); err != nil || complete {
614 t.Fatalf("a failed fleets listing in the status store read as complete (%v, %v)", complete, err)
615 }
616}
617
618// CARLOS_STATUS_PREFIX rebases the platform's own reports, as the status
619// loop writes them; fleet reports stay at fleets/<name>/ from the bucket
620// root, where fleet boxes write them.
621func TestTheStatusPrefixRebasesThePlatformReports(t *testing.T) {
622 ctx := context.Background()
623 s := release.NewDirStore(t.TempDir())
624 if err := status.WriteBox(ctx, release.NewPrefixStore(s, "x/"), report("uk-1", now0, nil)); err != nil {
625 t.Fatal(err)
626 }
627 if err := status.WriteBox(ctx, s, report("someone-else", now0, nil)); err != nil {
628 t.Fatal(err)
629 }
630 if err := status.WriteBox(ctx, release.NewPrefixStore(s, "fleets/acme/"), report("acme-1", now0, nil)); err != nil {
631 t.Fatal(err)
632 }
633 boxes, complete, err := (&boxReports{status: s, prefix: "x/"}).load(ctx)
634 if err != nil || !complete {
635 t.Fatalf("load = %v, %v", complete, err)
636 }
637 var labels []string
638 for _, b := range boxes {
639 labels = append(labels, b.Label)
640 }
641 sort.Strings(labels)
642 if !reflect.DeepEqual(labels, []string{"acme-1", "uk-1"}) {
643 t.Fatalf("read %v, want the prefixed platform report and the fleet's", labels)
644 }
645}
646
647// retentionReports is retentionLoop's choice of report source, from the
648// environment.
649func TestRetentionReportsFromTheEnvironment(t *testing.T) {
650 ctx := context.Background()
651 deploy := release.NewDirStore(t.TempDir())
652 local := kassPins("v1")
653 env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
654
655 r, err := retentionReports(ctx, env(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "deploy", "CARLOS_STATUS_BUCKET": "status", "CARLOS_STATUS_PREFIX": "x/"}), deploy, local)
656 if err != nil || r.status == nil || r.prefix != "x/" || r.deploy == nil || r.local != nil {
657 t.Fatalf("separate status bucket: %+v, %v; want status, prefix x/, the deployment store for fleets, no local report", r, err)
658 }
659 r, err = retentionReports(ctx, env(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "deploy"}), deploy, local)
660 if err != nil || r.status == nil || r.deploy != nil || r.local != nil {
661 t.Fatalf("status in the deployment bucket: %+v, %v; want the bucket's fleets read once", r, err)
662 }
663 r, err = retentionReports(ctx, env(map[string]string{"CARLOS_DEPLOYMENT_DIR": "/srv/deploy"}), deploy, local)
664 if err != nil || r.status != nil || r.deploy == nil || r.local == nil {
665 t.Fatalf("directory-backed: %+v, %v; want this box's own report and the deployment store's fleets", r, err)
666 }
667 if _, err := retentionReports(ctx, env(map[string]string{}), deploy, local); err == nil {
668 t.Fatal("no status store and not a sole housekeeper: want an error")
669 }
670}
671