package registry import ( "context" "encoding/json" "errors" "path/filepath" "testing" ) // oldShape returns raw JSON with the four rollout fields deleted from the // route object, as a binary from before they existed would have written it. // route is the object holding them: the document itself, or the value under // the given key. func oldShape(t *testing.T, raw []byte, key string) []byte { t.Helper() var doc map[string]json.RawMessage if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } target := doc if key != "" { target = map[string]json.RawMessage{} if err := json.Unmarshal(doc[key], &target); err != nil { t.Fatal(err) } } for _, f := range []string{"GoodVersion", "GoodDigest", "GoodDemand", "ServingVersion"} { if _, ok := target[f]; !ok { t.Fatalf("fixture has no %s to delete", f) } delete(target, f) } if key != "" { b, err := json.Marshal(target) if err != nil { t.Fatal(err) } doc[key] = b } out, err := json.Marshal(doc) if err != nil { t.Fatal(err) } return out } // A cold handover retired by a binary without the rollout fields must still // restore after the upgrade, at both comparison sites (the retirement archive // and the saved handover body), while a route whose fields differ stays // refused: the comparison is an ownership proof and is only canonicalised, // never loosened. func TestRestoreClaimRowsAcceptsPreUpgradeBytes(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", "1") ctx := context.Background() oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} // setup retires a route holding good (and whatever the route carries) // and returns the registry plus the old and next rows. setup := func(t *testing.T, good string) (*Registry, ColdRows, ColdRows) { r, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} if err = r.Upsert(route); err != nil { t.Fatal(err) } if good != "" { if err = r.SetGoodBuild(route.Host, GoodBuild{}, GoodBuild{Version: good, Digest: "aa", Demand: "swap"}); err != nil { t.Fatal(err) } } current, err := r.Get(route.Host) if err != nil || current == nil { t.Fatalf("Get: %v", err) } old := ColdRows{Route: *current} next := ColdRows{Route: *current} next.Route.ClaimOwnership = newOwner targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} if err = r.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { t.Fatal(err) } return r, old, next } // rewriteArchive replaces the stored retirement archive's route contents // with the pre-upgrade shape. rewriteArchive := func(t *testing.T, r *Registry) { var raw []byte if err := r.sql.QueryRow(`SELECT archive FROM claim_row_retirements WHERE journal='ret'`).Scan(&raw); err != nil { t.Fatal(err) } var a ClaimRowArchive if err := json.Unmarshal(raw, &a); err != nil { t.Fatal(err) } a.Contents = oldShape(t, a.Contents, "") raw, err := json.Marshal(a) if err != nil { t.Fatal(err) } if _, err = r.sql.Exec(`UPDATE claim_row_retirements SET archive=? WHERE journal='ret'`, raw); err != nil { t.Fatal(err) } } t.Run("archive site accepts the old shape", func(t *testing.T) { r, old, next := setup(t, "") rewriteArchive(t, r) if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatalf("pre-upgrade archive refused: %v", err) } }) t.Run("archive site refuses a route whose rollout fields differ", func(t *testing.T) { r, old, next := setup(t, "") rewriteArchive(t, r) old.Route.GoodVersion, next.Route.GoodVersion = "v9", "v9" if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("err = %v, want ErrClaimOwnership", err) } }) t.Run("archive site still compares current-shape bytes exactly", func(t *testing.T) { r, old, next := setup(t, "v1") if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatalf("current-shape archive refused: %v", err) } r2, old2, next2 := setup(t, "v1") old2.Route.GoodVersion, next2.Route.GoodVersion = "v2", "v2" if err := r2.RestoreClaimRows(ctx, "h1", "ret", old2, next2); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("err = %v, want ErrClaimOwnership", err) } }) t.Run("replay site accepts the old shape", func(t *testing.T) { r, old, next := setup(t, "") if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatal(err) } var body []byte if err := r.sql.QueryRow(`SELECT body FROM cold_handovers WHERE id='h1'`).Scan(&body); err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`UPDATE cold_handovers SET body=? WHERE id='h1'`, oldShape(t, body, "Route")); err != nil { t.Fatal(err) } if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatalf("pre-upgrade handover body refused on replay: %v", err) } }) t.Run("replay site refuses a route whose rollout fields differ", func(t *testing.T) { r, old, next := setup(t, "") if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatal(err) } var body []byte if err := r.sql.QueryRow(`SELECT body FROM cold_handovers WHERE id='h1'`).Scan(&body); err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`UPDATE cold_handovers SET body=? WHERE id='h1'`, oldShape(t, body, "Route")); err != nil { t.Fatal(err) } old.Route.GoodVersion, next.Route.GoodVersion = "v9", "v9" if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("err = %v, want ErrClaimOwnership", err) } }) t.Run("replay site fails closed on an undecodable body", func(t *testing.T) { r, old, next := setup(t, "") if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`UPDATE cold_handovers SET body=? WHERE id='h1'`, []byte("not json")); err != nil { t.Fatal(err) } if err := r.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("err = %v, want ErrClaimOwnership", err) } }) }