| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdsa" |
| 6 | "crypto/elliptic" |
| 7 | "crypto/rand" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "os" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/dbdoor" |
| 19 | "github.com/carlosframework/platform/internal/hostclaim" |
| 20 | "github.com/carlosframework/platform/internal/lease" |
| 21 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 22 | "github.com/carlosframework/platform/internal/provision" |
| 23 | "github.com/carlosframework/platform/internal/release" |
| 24 | ) |
| 25 | |
| 26 | type doorVersionHook struct { |
| 27 | lease.VersionedStore |
| 28 | after func() |
| 29 | } |
| 30 | |
| 31 | func (v doorVersionHook) ListVersions(ctx context.Context, b, p string) ([]lease.ObjectVersion, error) { |
| 32 | out, err := v.VersionedStore.ListVersions(ctx, b, p) |
| 33 | if v.after != nil { |
| 34 | v.after() |
| 35 | } |
| 36 | return out, err |
| 37 | } |
| 38 | |
| 39 | type doorReadFault struct { |
| 40 | release.Store |
| 41 | err error |
| 42 | } |
| 43 | |
| 44 | func (s *doorReadFault) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 45 | if s.err != nil { |
| 46 | return nil, "", s.err |
| 47 | } |
| 48 | return s.Store.Get(ctx, key) |
| 49 | } |
| 50 | |
| 51 | func doorPlanFixture(t *testing.T, legacy bool) (*Server, dbRequest, provision.Record, *leasetest.VersionedBucket) { |
| 52 | t.Helper() |
| 53 | rec := provision.Record{Host: "shop.example", Account: "bab", App: "jam"} |
| 54 | inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | loc := "instances/" + rec.Host |
| 59 | if !legacy { |
| 60 | loc += "/" + inc |
| 61 | } |
| 62 | claim := hostclaim.Claim{V: 2, Host: rec.Host, Gen: 1, Holder: &hostclaim.Holder{Family: "instance", Key: provision.RecordKey(rec.Account, rec.App, rec.Host), Account: rec.Account, App: rec.App, Incarnation: inc, Location: loc}, Op: hostclaim.Op{Kind: hostclaim.KindServing}} |
| 63 | store := release.NewDirStore(t.TempDir()) |
| 64 | data, _ := json.Marshal(claim) |
| 65 | key, _ := hostclaim.Key(rec.Host) |
| 66 | if _, err := store.Put(t.Context(), key, data, ""); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | model := leasetest.NewVersionedBucket() |
| 70 | model.AddBucket("data") |
| 71 | s := &Server{cfg: Config{HomeRegion: "ie", HostClaim: true, Releases: store, HibernateBucket: "data", DBVersions: model}, dbHandleKey: []byte(strings.Repeat("s", 32))} |
| 72 | g := dbRequest{acct: &Account{Sqid: "bab"}, app: "jam", host: rec.Host} |
| 73 | g.claim, err = s.dbClaim(t.Context(), g, rec) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | return s, g, rec, model |
| 78 | } |
| 79 | |
| 80 | func writeDoorClaim(t *testing.T, s *Server, g dbRequest, c *hostclaim.Claim) { |
| 81 | t.Helper() |
| 82 | key, _ := hostclaim.Key(g.host) |
| 83 | _, etag, err := s.cfg.Releases.Get(t.Context(), key) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | b, err := json.Marshal(c) |
| 88 | if err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | if _, err = s.cfg.Releases.Put(t.Context(), key, b, etag); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | } |
| 95 | func cloneDoorClaim(c *hostclaim.Claim) *hostclaim.Claim { |
| 96 | b, _ := json.Marshal(c) |
| 97 | var out hostclaim.Claim |
| 98 | _ = json.Unmarshal(b, &out) |
| 99 | return &out |
| 100 | } |
| 101 | |
| 102 | func TestDoorPlanHandles(t *testing.T) { |
| 103 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | secret := dbHandleSecret(key) |
| 108 | h := dbFileHandle{V: 1, Host: "shop.example", Location: "instances/shop.example/inc", Key: "instances/shop.example/inc/0000/a", VersionID: "v+/?", Incarnation: "inc", Expiry: 200, Account: "bab", App: "jam", Size: 6} |
| 109 | token, err := signDBHandle(secret, h) |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | got, err := verifyDBHandle(dbHandleSecret(key), token, time.Unix(199, 0)) |
| 114 | if err != nil || got != h { |
| 115 | t.Fatal(got, err) |
| 116 | } |
| 117 | for _, bad := range []string{"", token + "x", "x" + token, strings.Repeat("x", 8193)} { |
| 118 | if _, err := verifyDBHandle(secret, bad, time.Unix(199, 0)); !errors.Is(err, errDBHandle) { |
| 119 | t.Fatal(bad, err) |
| 120 | } |
| 121 | } |
| 122 | if _, err := verifyDBHandle([]byte(strings.Repeat("x", 32)), token, time.Unix(199, 0)); !errors.Is(err, errDBHandle) { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if _, err := verifyDBHandle(secret, token, time.Unix(200, 0)); !errors.Is(err, errDBHandleExpired) { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestDoorPlanListingAndPinnedOpen(t *testing.T) { |
| 131 | for _, legacy := range []bool{false, true} { |
| 132 | t.Run(map[bool]string{true: "legacy", false: "scoped"}[legacy], func(t *testing.T) { |
| 133 | s, g, _, m := doorPlanFixture(t, legacy) |
| 134 | loc := g.claim.Claim.Holder.Location |
| 135 | key := dbVersionKey(loc, 0, 1, 1) |
| 136 | m.Put("data", key, []byte("older")) |
| 137 | id := m.Put("data", key, append(doorLTXHeader(t, 1, 1), []byte("original")...)) |
| 138 | deleted := dbVersionKey(loc, 0, 2, 2) |
| 139 | m.Put("data", deleted, []byte("hidden")) |
| 140 | m.BareDelete("data", deleted) |
| 141 | m.Put("data", loc+"/lock.json", []byte("secret")) |
| 142 | m.Put("data", dbVersionKey(loc+"/other-incarnation", 0, 3, 3), []byte("other")) |
| 143 | m.Put("data", dbVersionKey(loc+"-sibling", 0, 4, 4), []byte("sibling")) |
| 144 | m.Put("data", dbVersionKey(loc+"/g-old", 0, 5, 5), []byte("generation")) |
| 145 | m.PageSize = 1 |
| 146 | out, err := s.listDBVersions(t.Context(), g, 0) |
| 147 | if err != nil || out.Protocol != 2 || len(out.Files) != 1 { |
| 148 | t.Fatalf("%+v %v", out, err) |
| 149 | } |
| 150 | token := out.Files[0].Handle |
| 151 | h, err := verifyDBHandle(s.dbHandleKey, token, time.Now()) |
| 152 | if err != nil || h.VersionID != id || h.Location != loc { |
| 153 | t.Fatal(h, err) |
| 154 | } |
| 155 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 156 | req.Header.Set(dbdoor.HandleHeader, token) |
| 157 | rd, err := s.pinnedDBReader(req, g, 0, 1, 1) |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | // Stall after admission, then overwrite the identical legacy key and |
| 162 | // advance the holder to a new incarnation before the physical open. |
| 163 | m.Put("data", key, []byte("replacement")) |
| 164 | next := cloneDoorClaim(g.claim.Claim) |
| 165 | next.Gen++ |
| 166 | next.Holder.CreatedAt = time.Unix(1, 0) |
| 167 | next.Holder.Incarnation, _ = hostclaim.Incarnation("bab", "jam", g.host, next.Holder.CreatedAt) |
| 168 | next.Holder.Location = "instances/" + g.host + "/" + next.Holder.Incarnation |
| 169 | writeDoorClaim(t, s, g, next) |
| 170 | rc, err := rd.OpenLTXFile(t.Context(), 0, 1, 1, 102, 3) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | body, err := io.ReadAll(rc) |
| 175 | rc.Close() |
| 176 | if err != nil || string(body) != "igi" { |
| 177 | t.Fatalf("%q %v", body, err) |
| 178 | } |
| 179 | g2 := g |
| 180 | g2.claim.Claim = next |
| 181 | if _, err := s.pinnedDBReader(req, g2, 0, 1, 1); err == nil { |
| 182 | t.Fatal("new incarnation accepted old handle") |
| 183 | } |
| 184 | bad := g |
| 185 | bad.host = "other.example" |
| 186 | if _, err := s.pinnedDBReader(req, bad, 0, 1, 1); err == nil { |
| 187 | t.Fatal("cross host") |
| 188 | } |
| 189 | if _, err := s.pinnedDBReader(req, g, 0, 2, 2); err == nil { |
| 190 | t.Fatal("cross coordinate") |
| 191 | } |
| 192 | if err := m.DeleteVersion(t.Context(), "data", key, id); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if _, err := rd.OpenLTXFile(t.Context(), 0, 1, 1, 0, 0); !errors.Is(err, os.ErrNotExist) { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | }) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestDoorPlanBracket(t *testing.T) { |
| 203 | for _, change := range []string{"holder", "op", "same-gen-op", "chore", "absent", "malformed", "read-error"} { |
| 204 | t.Run(change, func(t *testing.T) { |
| 205 | s, g, _, m := doorPlanFixture(t, false) |
| 206 | m.Put("data", dbVersionKey(g.claim.Claim.Holder.Location, 0, 1, 1), doorLTXHeader(t, 1, 1)) |
| 207 | fault := &doorReadFault{Store: s.cfg.Releases} |
| 208 | s.cfg.Releases = fault |
| 209 | s.cfg.DBVersions = doorVersionHook{VersionedStore: m, after: func() { |
| 210 | next := cloneDoorClaim(g.claim.Claim) |
| 211 | next.Gen++ |
| 212 | switch change { |
| 213 | case "holder": |
| 214 | next.Holder.Stamp = next.Gen |
| 215 | case "op": |
| 216 | next.Op = hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "delete"} |
| 217 | case "same-gen-op": |
| 218 | next.Gen-- |
| 219 | next.Op.Exec = "another-box" |
| 220 | case "chore": |
| 221 | next.Touched.Complete = true |
| 222 | case "absent": |
| 223 | fault.err = os.ErrNotExist |
| 224 | return |
| 225 | case "read-error": |
| 226 | fault.err = errors.New("offline") |
| 227 | return |
| 228 | case "malformed": |
| 229 | next.V = 999 |
| 230 | } |
| 231 | writeDoorClaim(t, s, g, next) |
| 232 | }} |
| 233 | out, err := s.listDBVersions(t.Context(), g, 0) |
| 234 | if change == "chore" { |
| 235 | if err != nil || len(out.Files) != 1 { |
| 236 | t.Fatal(out, err) |
| 237 | } |
| 238 | } else if err == nil || len(out.Files) != 0 { |
| 239 | t.Fatalf("partial/success: %+v %v", out, err) |
| 240 | } |
| 241 | }) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | func TestDoorPlanClaimRefusals(t *testing.T) { |
| 246 | for _, change := range []string{"absent", "free", "deleting", "parking", "purging", "owner", "incarnation", "location", "malformed", "read-error"} { |
| 247 | t.Run(change, func(t *testing.T) { |
| 248 | s, g, rec, m := doorPlanFixture(t, true) |
| 249 | m.FailOn = func(op, key string) error { t.Fatalf("refusal reached data bucket: %s %s", op, key); return nil } |
| 250 | c := cloneDoorClaim(g.claim.Claim) |
| 251 | want := errDBClaimClosed |
| 252 | switch change { |
| 253 | case "absent": |
| 254 | s.cfg.Releases = release.NewDirStore(t.TempDir()) |
| 255 | want = errDBNotAdopted |
| 256 | case "free": |
| 257 | c.Holder = nil |
| 258 | c.Op = hostclaim.Op{} |
| 259 | case "deleting", "parking", "purging": |
| 260 | c.Op = hostclaim.Op{Kind: change, ID: "x"} |
| 261 | case "owner": |
| 262 | g.app = "other" |
| 263 | want = os.ErrNotExist |
| 264 | case "incarnation": |
| 265 | rec.CreatedAt = time.Unix(1, 0) |
| 266 | want = os.ErrNotExist |
| 267 | case "location": |
| 268 | c.Holder.Location = "" |
| 269 | want = hostclaim.ErrMalformed |
| 270 | case "malformed": |
| 271 | c.V = 99 |
| 272 | want = hostclaim.ErrMalformed |
| 273 | case "read-error": |
| 274 | want = errors.New("offline") |
| 275 | s.cfg.Releases = &doorReadFault{Store: s.cfg.Releases, err: want} |
| 276 | } |
| 277 | if change != "absent" && change != "read-error" { |
| 278 | writeDoorClaim(t, s, dbRequest{host: rec.Host}, c) |
| 279 | } |
| 280 | if _, err := s.dbClaim(t.Context(), g, rec); !errors.Is(err, want) { |
| 281 | t.Fatalf("%v want %v", err, want) |
| 282 | } |
| 283 | }) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func TestDoorPlanListingFailureAndRanges(t *testing.T) { |
| 288 | s, g, _, m := doorPlanFixture(t, true) |
| 289 | for _, lo := range []uint64{1, 2, 3} { |
| 290 | m.Put("data", dbVersionKey(g.claim.Claim.Holder.Location, 0, lo, lo), []byte("sixsix")) |
| 291 | } |
| 292 | m.PageSize = 1 |
| 293 | m.FailListAfterPages = 1 |
| 294 | if out, err := s.listDBVersions(t.Context(), g, 0); err == nil || len(out.Files) != 0 { |
| 295 | t.Fatal(out, err) |
| 296 | } |
| 297 | for _, tc := range []struct { |
| 298 | offset, size, want int64 |
| 299 | ok bool |
| 300 | }{{0, 0, 6, true}, {2, 0, 4, true}, {2, 2, 2, true}, {2, 100, 4, true}, {6, 0, 0, false}, {-1, 0, 0, false}, {0, -1, 0, false}} { |
| 301 | n, err := dbVersionRange(6, tc.offset, tc.size) |
| 302 | if (err == nil) != tc.ok || n != tc.want { |
| 303 | t.Fatal(tc, n, err) |
| 304 | } |
| 305 | } |
| 306 | w := httptest.NewRecorder() |
| 307 | r := httptest.NewRequest("GET", "/", nil) |
| 308 | r.Header.Set("Range", "bytes=2-3") |
| 309 | r.Header.Set(dbdoor.HandleHeader, "signed") |
| 310 | if err := dbVersionHeaders(w, r, 6, 2, 2); err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | if w.Header().Get("Content-Length") != "2" || w.Header().Get("Content-Range") != "bytes 2-3/6" || w.Header().Get(dbdoor.HandleHeader) != "signed" { |
| 314 | t.Fatal(w.Header()) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | type doorOpenHook struct { |
| 319 | lease.VersionedStore |
| 320 | open func(context.Context, string, string, string) (io.ReadCloser, error) |
| 321 | } |
| 322 | |
| 323 | func (v doorOpenHook) OpenVersion(ctx context.Context, b, k, id string) (io.ReadCloser, error) { |
| 324 | return v.open(ctx, b, k, id) |
| 325 | } |
| 326 | |
| 327 | type doorTrackedReader struct { |
| 328 | io.Reader |
| 329 | closed bool |
| 330 | } |
| 331 | |
| 332 | func (r *doorTrackedReader) Close() error { r.closed = true; return nil } |
| 333 | |
| 334 | type doorCancelledReader struct{ ctx context.Context } |
| 335 | |
| 336 | func (r doorCancelledReader) Read([]byte) (int, error) { return 0, r.ctx.Err() } |
| 337 | |
| 338 | func TestDoorPlanRangeReaderCloses(t *testing.T) { |
| 339 | for _, mode := range []string{"complete", "short-skip", "cancelled-skip"} { |
| 340 | t.Run(mode, func(t *testing.T) { |
| 341 | s, g, _, m := doorPlanFixture(t, true) |
| 342 | ctx, cancel := context.WithCancel(t.Context()) |
| 343 | defer cancel() |
| 344 | tracked := &doorTrackedReader{Reader: strings.NewReader("abcdef")} |
| 345 | if mode == "short-skip" { |
| 346 | tracked.Reader = strings.NewReader("a") |
| 347 | } |
| 348 | if mode == "cancelled-skip" { |
| 349 | cancel() |
| 350 | tracked.Reader = doorCancelledReader{ctx: ctx} |
| 351 | } |
| 352 | key := dbVersionKey(g.claim.Claim.Holder.Location, 0, 1, 1) |
| 353 | s.cfg.DBVersions = doorOpenHook{VersionedStore: m, open: func(ctx context.Context, b, k, id string) (io.ReadCloser, error) { |
| 354 | if b != "data" || k != key || id != "selected" { |
| 355 | t.Fatal(b, k, id) |
| 356 | } |
| 357 | return tracked, nil |
| 358 | }} |
| 359 | rd := &dbVersionReader{store: s.cfg.DBVersions, bucket: "data", handle: dbFileHandle{Location: g.claim.Claim.Holder.Location, Key: key, VersionID: "selected", Size: 6}} |
| 360 | rc, err := rd.OpenLTXFile(ctx, 0, 1, 1, 2, 2) |
| 361 | if mode != "complete" { |
| 362 | if err == nil || !tracked.closed { |
| 363 | t.Fatal(err, tracked.closed) |
| 364 | } |
| 365 | if mode == "cancelled-skip" && !errors.Is(err, context.Canceled) { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | return |
| 369 | } |
| 370 | if err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | b, err := io.ReadAll(rc) |
| 374 | if err != nil || string(b) != "cd" { |
| 375 | t.Fatal(string(b), err) |
| 376 | } |
| 377 | if tracked.closed { |
| 378 | t.Fatal("closed before caller finished") |
| 379 | } |
| 380 | rc.Close() |
| 381 | if !tracked.closed { |
| 382 | t.Fatal("reader leaked") |
| 383 | } |
| 384 | }) |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | // TestDBDoorRefusesDuringMoves (moves spec §19.1, Review Focus 4, K14): the |
| 389 | // db door refuses on every move kind, in every phase, including |
| 390 | // landing/committed and the restoring and reverting opened phases; it |
| 391 | // reopens at the close, when the claim is serving again. The refusal is a |
| 392 | // 409 with Retry-After: 1, and the data bucket is never read. |
| 393 | func TestDBDoorRefusesDuringMoves(t *testing.T) { |
| 394 | const move = "mv_abcdefghijklmnopqrstuvwxyz" |
| 395 | reloc := func(phase, exec string) hostclaim.Op { |
| 396 | return hostclaim.Op{Kind: hostclaim.KindRelocating, ID: move, Phase: phase, Exec: exec, |
| 397 | Intent: map[string]string{"region": "uk", "source_box": "ie-box", "target_box": "uk-box"}} |
| 398 | } |
| 399 | cases := []hostclaim.Op{ |
| 400 | {Kind: hostclaim.KindMoving, ID: move, Phase: hostclaim.PhaseMoving, Exec: "ie-box"}, |
| 401 | {Kind: hostclaim.KindMoving, ID: move, Phase: hostclaim.PhaseSealed}, |
| 402 | {Kind: hostclaim.KindMoving, ID: move, Phase: hostclaim.PhaseAborting, Exec: "ie-box"}, |
| 403 | {Kind: hostclaim.KindLanding, ID: move, Phase: "checking", Exec: "uk-box"}, |
| 404 | {Kind: hostclaim.KindLanding, ID: move, Phase: hostclaim.PhaseLandingProbing, Exec: "uk-box"}, |
| 405 | {Kind: hostclaim.KindLanding, ID: move, Phase: hostclaim.PhaseLandingProbed, Exec: "uk-box"}, |
| 406 | {Kind: hostclaim.KindLanding, ID: move, Phase: hostclaim.PhaseLandingCommitted, Exec: "uk-box"}, |
| 407 | {Kind: hostclaim.KindReverting, ID: move, Exec: "ie-box", Journal: map[string]string{hostclaim.JournalRevPhase: hostclaim.RevPhaseEvidence}}, |
| 408 | {Kind: hostclaim.KindReverting, ID: move, Exec: "ie-box", Journal: map[string]string{hostclaim.JournalRevPhase: hostclaim.RevPhaseVerify}}, |
| 409 | {Kind: hostclaim.KindReverting, ID: move, Phase: hostclaim.PhaseProbing, Exec: "ie-box"}, |
| 410 | {Kind: hostclaim.KindReverting, ID: move, Phase: hostclaim.PhaseOpened, Exec: "ie-box"}, |
| 411 | {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseReleasing, Exec: "uk-box"}, |
| 412 | {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseReleased}, |
| 413 | {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseClaimed, Exec: "ie-box"}, |
| 414 | {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseProbing, Exec: "ie-box"}, |
| 415 | {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseProbed, Exec: "ie-box"}, |
| 416 | {Kind: hostclaim.KindRestoring, ID: move, Phase: hostclaim.PhaseOpened, Exec: "ie-box"}, |
| 417 | reloc("starting", "ie-box"), |
| 418 | reloc("releasing", "none"), |
| 419 | reloc("pinning", "none"), |
| 420 | reloc("installing", "uk-box"), |
| 421 | reloc("installed", "uk-box"), |
| 422 | reloc("probing", "uk-box"), |
| 423 | reloc("probe-failed", "uk-box"), |
| 424 | reloc("serving", "uk-box"), |
| 425 | } |
| 426 | kinds := map[string]bool{} |
| 427 | for _, op := range cases { |
| 428 | kinds[op.Kind] = true |
| 429 | name := op.Kind + "/" + op.Phase |
| 430 | if rp := op.Journal[hostclaim.JournalRevPhase]; rp != "" { |
| 431 | name += "rev_phase=" + rp |
| 432 | } |
| 433 | t.Run(name, func(t *testing.T) { |
| 434 | s, g, rec, m := doorPlanFixture(t, false) |
| 435 | m.FailOn = func(op, key string) error { t.Fatalf("a refusal reached the data bucket: %s %s", op, key); return nil } |
| 436 | c := cloneDoorClaim(g.claim.Claim) |
| 437 | c.Op = op |
| 438 | writeDoorClaim(t, s, dbRequest{host: rec.Host}, c) |
| 439 | _, err := s.dbClaim(t.Context(), g, rec) |
| 440 | if !errors.Is(err, errDBClaimClosed) { |
| 441 | t.Fatalf("%v, want the door closed", err) |
| 442 | } |
| 443 | w := httptest.NewRecorder() |
| 444 | s.dbClaimFailure(w, httptest.NewRequest(http.MethodGet, "/", nil), g, err) |
| 445 | if w.Code != http.StatusConflict || w.Header().Get("Retry-After") != "1" { |
| 446 | t.Fatalf("%d Retry-After %q, want 409 and 1", w.Code, w.Header().Get("Retry-After")) |
| 447 | } |
| 448 | }) |
| 449 | } |
| 450 | for _, k := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} { |
| 451 | if !kinds[k] { |
| 452 | t.Errorf("the table has no %s row", k) |
| 453 | } |
| 454 | } |
| 455 | // The close: serving again, the door reopens. |
| 456 | t.Run("serving", func(t *testing.T) { |
| 457 | s, g, rec, _ := doorPlanFixture(t, false) |
| 458 | c := cloneDoorClaim(g.claim.Claim) |
| 459 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing} |
| 460 | writeDoorClaim(t, s, dbRequest{host: rec.Host}, c) |
| 461 | if _, err := s.dbClaim(t.Context(), g, rec); err != nil { |
| 462 | t.Fatalf("serving: %v", err) |
| 463 | } |
| 464 | }) |
| 465 | } |
| 466 | |