| 1 | package host |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | func 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 | |
| 22 | func 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. |
| 28 | func 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 | |
| 38 | func 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 | |
| 58 | func 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 | |
| 69 | var 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. |
| 72 | func 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 | |
| 89 | func 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 | |
| 97 | func 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. |
| 117 | func 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 | |
| 137 | func 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". |
| 152 | func 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. |
| 168 | func 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). |
| 186 | func 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. |
| 201 | func 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 | |
| 208 | func 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. |
| 226 | func 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 | |
| 270 | func 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. |
| 293 | func 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. |
| 347 | func 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. |
| 394 | func 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. |
| 433 | func 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. |
| 457 | func 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. |
| 490 | func 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. |
| 500 | func 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. |
| 566 | func 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. |
| 621 | func 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. |
| 649 | func 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 | |