| new file mode 100644 |
| index 000000000..0e818c6ba |
| --- /dev/null |
| +++ b/internal/console/createleftovers_test.go |
| @@ -0,0 +1,347 @@ |
| +package console |
| + |
| +// The leftover-replica rail on the two create doors (discussion #199). A |
| +// deleted instance or sidecar leaves its replica at instances/<host> and any |
| +// move staging at moves/<host>/, and both are keyed by HOST alone, so the next |
| +// record created on that host would inherit the old database on its first |
| +// wake or through `carlos db pull`. Until the full fix lands, both doors |
| +// refuse a create on a host whose replica or staging prefix in this |
| +// console's data bucket still holds an LTX file. These tests use real |
| +// litestream replicas on disk behind the db door's replica seam, so "holds an |
| +// LTX file" means exactly what the activator would restore from. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + |
| + lsfile "github.com/benbjohnson/litestream/file" |
| + "github.com/superfly/ltx" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/dbdoor/dbdoortest" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +type leftoverFx struct { |
| + *consoleFixture |
| + token string |
| + root string // the fake data bucket: one file replica per prefix |
| + opens *atomic.Int32 // how many times a door reached for a replica client |
| +} |
| + |
| +// leftoverFixture is a console in region ie with a data bucket configured, |
| +// and titogo enabled for both instances and sidecars in ie. |
| +func leftoverFixture(t *testing.T, code string) *leftoverFx { |
| + t.Helper() |
| + fx, token := instanceFixture(t, code) |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| + root := t.TempDir() |
| + fx.srv.cfg.HibernateBucket = "data-test" |
| + fx.srv.cfg.HibernatePrefix = "instances" |
| + fx.srv.cfg.Region = "ie" |
| + opens := new(atomic.Int32) |
| + fx.srv.dbReplica = func(_ context.Context, prefix string) (replicaReader, error) { |
| + opens.Add(1) |
| + return lsfile.NewReplicaClient(filepath.Join(root, prefix)), nil |
| + } |
| + return &leftoverFx{consoleFixture: fx, token: token, root: root, opens: opens} |
| +} |
| + |
| +// leaveReplica writes a real litestream replica where a deleted record's |
| +// activator left it. |
| +func (fx *leftoverFx) leaveReplica(t *testing.T, host string) { |
| + t.Helper() |
| + dbdoortest.Replicate(t, filepath.Join(fx.root, activator.HibernatePrefix("instances", host)), 1) |
| +} |
| + |
| +// leaveStaging writes one where an abandoned move staged it. |
| +func (fx *leftoverFx) leaveStaging(t *testing.T, host string) { |
| + t.Helper() |
| + dbdoortest.Replicate(t, filepath.Join(fx.root, activator.MovesPrefix(host)), 1) |
| +} |
| + |
| +// createDoor is one of the two doors, reduced to what the matrix needs. |
| +type createDoor struct { |
| + name string |
| + url func(fx *consoleFixture) string |
| + body string // a create the door accepts on a clean host, in region ie |
| + host string // the host that create claims |
| + exists func(t *testing.T, fx *consoleFixture) bool |
| +} |
| + |
| +var leftoverDoors = []createDoor{ |
| + { |
| + name: "instance", |
| + url: func(fx *consoleFixture) string { return instancesURL(fx, "bab", "titogo") }, |
| + body: `{"host":"acme.go.tito.io","region":"ie"}`, |
| + host: "acme.go.tito.io", |
| + exists: func(t *testing.T, fx *consoleFixture) bool { |
| + t.Helper() |
| + _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + return err == nil |
| + }, |
| + }, |
| + { |
| + name: "sidecar", |
| + url: func(fx *consoleFixture) string { return sidecarsURL(fx, "bab", "titogo") }, |
| + body: `{"name":"bot","region":"ie"}`, |
| + host: botHost, |
| + exists: func(t *testing.T, fx *consoleFixture) bool { |
| + t.Helper() |
| + _, err := provision.GetSidecarRecord(context.Background(), fx.rel, "bab", "titogo", "bot") |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + return err == nil |
| + }, |
| + }, |
| +} |
| + |
| +// A host whose replica prefix still holds LTX files is refused with 409, |
| +// and the refusal writes no record. |
| +func TestCreateRefusesLeftoverReplica(t *testing.T) { |
| + for _, d := range leftoverDoors { |
| + t.Run(d.name, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-rep-"+d.name) |
| + fx.leaveReplica(t, d.host) |
| + status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body) |
| + if status != http.StatusConflict || !strings.Contains(out, "still holds data from a deleted") { |
| + t.Fatalf("create = %d %s, want the leftover-data 409", status, out) |
| + } |
| + if d.exists(t, fx.consoleFixture) { |
| + t.Fatal("a refused create wrote a record anyway") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Move staging counts too: a host whose only leftover is an abandoned |
| +// moves/<host>/ is refused the same way, because a landing promotes that |
| +// staging into the instance's prefix. |
| +func TestCreateRefusesLeftoverMoveStaging(t *testing.T) { |
| + for _, d := range leftoverDoors { |
| + t.Run(d.name, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-mov-"+d.name) |
| + fx.leaveStaging(t, d.host) |
| + status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body) |
| + if status != http.StatusConflict || !strings.Contains(out, "still holds data from a deleted") { |
| + t.Fatalf("create = %d %s, want the leftover-data 409", status, out) |
| + } |
| + if d.exists(t, fx.consoleFixture) { |
| + t.Fatal("a refused create wrote a record anyway") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A clean host is created as before, and the check really looked: both |
| +// prefixes were opened. |
| +func TestCreateAllowsACleanHost(t *testing.T) { |
| + for _, d := range leftoverDoors { |
| + t.Run(d.name, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-clean-"+d.name) |
| + // Another host's leftovers are not this host's business. |
| + fx.leaveReplica(t, "other.go.tito.io") |
| + status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201", status, out) |
| + } |
| + if !d.exists(t, fx.consoleFixture) { |
| + t.Fatal("the create answered 201 and wrote no record") |
| + } |
| + if n := fx.opens.Load(); n != 2 { |
| + t.Fatalf("replica clients opened = %d, want 2 (the replica prefix and the staging prefix)", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A record homed in another region is not checked: its data lives in that |
| +// region's bucket, which this console cannot read. The gap is documented at |
| +// the rail; this pins that the rail does not refuse what it cannot see. |
| +func TestCreateSkipsTheCheckInAnotherRegion(t *testing.T) { |
| + for _, d := range leftoverDoors { |
| + t.Run(d.name, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-region-"+d.name) |
| + fx.srv.cfg.Region = "uk" |
| + fx.leaveReplica(t, d.host) |
| + status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201 (unchecked)", status, out) |
| + } |
| + if n := fx.opens.Load(); n != 0 { |
| + t.Fatalf("replica clients opened = %d for an out-of-region record, want 0", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A console with no data bucket creates as it always did, and never reaches |
| +// for a replica client. |
| +func TestCreateSkipsTheCheckWithNoDataBucket(t *testing.T) { |
| + for _, d := range leftoverDoors { |
| + t.Run(d.name, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-nobucket-"+d.name) |
| + fx.srv.cfg.HibernateBucket = "" |
| + fx.leaveReplica(t, d.host) |
| + status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201 (unchecked)", status, out) |
| + } |
| + if n := fx.opens.Load(); n != 0 { |
| + t.Fatalf("replica clients opened = %d with no bucket, want 0", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// failingReplica fails every listing, with a message carrying a bucket name |
| +// the way a real S3 error does. |
| +type failingReplica struct{} |
| + |
| +func (failingReplica) LTXFiles(context.Context, int, ltx.TXID, bool) (ltx.FileIterator, error) { |
| + return nil, errors.New("list secret-bucket: connection reset") |
| +} |
| + |
| +func (failingReplica) OpenLTXFile(context.Context, int, ltx.TXID, ltx.TXID, int64, int64) (io.ReadCloser, error) { |
| + return nil, errors.New("not used") |
| +} |
| + |
| +// erroringIterator fails on the first Next, the way the S3 client surfaces |
| +// a paging error. |
| +type erroringIterator struct{} |
| + |
| +func (erroringIterator) Close() error { return nil } |
| +func (erroringIterator) Next() bool { return false } |
| +func (erroringIterator) Err() error { return errors.New("page secret-bucket: timeout") } |
| +func (erroringIterator) Item() *ltx.FileInfo { return nil } |
| + |
| +type pagingFailReplica struct{ failingReplica } |
| + |
| +func (pagingFailReplica) LTXFiles(context.Context, int, ltx.TXID, bool) (ltx.FileIterator, error) { |
| + return erroringIterator{}, nil |
| +} |
| + |
| +// A check that cannot complete refuses with 503: never create on |
| +// uncertainty. The store's own error, which names the bucket, stays in the |
| +// log. |
| +func TestCreateRefusesWhenTheCheckFails(t *testing.T) { |
| + seams := map[string]func(context.Context, string) (replicaReader, error){ |
| + "client": func(context.Context, string) (replicaReader, error) { |
| + return nil, errors.New("resolve region for secret-bucket: no credentials") |
| + }, |
| + "listing": func(context.Context, string) (replicaReader, error) { return failingReplica{}, nil }, |
| + "paging": func(context.Context, string) (replicaReader, error) { return pagingFailReplica{}, nil }, |
| + } |
| + for _, d := range leftoverDoors { |
| + for seam, open := range seams { |
| + t.Run(d.name+"/"+seam, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-fail-"+d.name+"-"+seam) |
| + fx.srv.dbReplica = open |
| + status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body) |
| + if status != http.StatusServiceUnavailable || !strings.Contains(out, "could not check whether") { |
| + t.Fatalf("create = %d %s, want the 503 that refuses on uncertainty", status, out) |
| + } |
| + if strings.Contains(out, "secret-bucket") || strings.Contains(out, "data-test") { |
| + t.Fatalf("the refusal leaked a bucket name: %s", out) |
| + } |
| + if d.exists(t, fx.consoleFixture) { |
| + t.Fatal("a create that could not be checked wrote a record anyway") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// Every existing validation answers first: a create the door would refuse |
| +// anyway never reaches for the bucket, and keeps its own answer. |
| +func TestCreateValidatesBeforeTheCheck(t *testing.T) { |
| + // consoleRegion is the region the rail would check, so a check run |
| + // too early would reach for the bucket rather than skip. |
| + cases := []struct{ door, url, body, consoleRegion string }{ |
| + {"instance", "instances", `{"host":"acme.elsewhere.io","region":"ie"}`, "ie"}, |
| + {"instance", "instances", `{"host":"acme.go.tito.io","region":"mars"}`, "mars"}, |
| + {"sidecar", "sidecars", `{"name":"../bot","region":"ie"}`, "ie"}, |
| + {"sidecar", "sidecars", `{"name":"bot","region":"mars"}`, "mars"}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.door+" "+c.body, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-order") |
| + fx.srv.cfg.Region = c.consoleRegion |
| + fx.srv.dbReplica = func(context.Context, string) (replicaReader, error) { |
| + fx.opens.Add(1) |
| + return failingReplica{}, nil |
| + } |
| + u := fx.ts.URL + "/api/cli/apps/bab/titogo/" + c.url |
| + status, _, out := rawReq(t, http.MethodPost, u, fx.token, c.body) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("create = %d %s, want the door's own 400", status, out) |
| + } |
| + if n := fx.opens.Load(); n != 0 { |
| + t.Fatalf("an invalid create reached for the bucket %d time(s)", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Paths that create no record are untouched: deleting a record whose host |
| +// holds a replica (the ordinary case) never consults the rail. |
| +func TestDeleteNeverConsultsTheLeftoverCheck(t *testing.T) { |
| + for _, d := range leftoverDoors { |
| + t.Run(d.name, func(t *testing.T) { |
| + fx := leftoverFixture(t, "code-left-del-"+d.name) |
| + if status, _, out := rawReq(t, http.MethodPost, d.url(fx.consoleFixture), fx.token, d.body); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + fx.leaveReplica(t, d.host) |
| + fx.opens.Store(0) |
| + target := d.url(fx.consoleFixture) + "/" + d.host |
| + if d.name == "sidecar" { |
| + target = d.url(fx.consoleFixture) + "/bot" |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, target, fx.token, ""); status >= 300 { |
| + t.Fatalf("delete = %d %s, want success", status, out) |
| + } |
| + if n := fx.opens.Load(); n != 0 { |
| + t.Fatalf("a delete reached for the bucket %d time(s)", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The residency rule the rail applies is resolveReplica's: in-region only, |
| +// and a fleet placement is never this console's to read, whatever its own |
| +// region string says. |
| +func TestLeftoverCheckResidency(t *testing.T) { |
| + s := &Server{} |
| + s.cfg.HibernateBucket = "data-test" |
| + s.cfg.Region = "fleet/acme" |
| + for _, c := range []struct { |
| + region, console string |
| + bucket string |
| + want bool |
| + }{ |
| + {"", "", "data-test", true}, |
| + {"ie", "ie", "data-test", true}, |
| + {"uk", "ie", "data-test", false}, |
| + {"", "ie", "data-test", false}, |
| + {"fleet/acme", "fleet/acme", "data-test", false}, |
| + {"ie", "ie", "", false}, |
| + } { |
| + s.cfg.Region, s.cfg.HibernateBucket = c.console, c.bucket |
| + if got := s.leftoverCheckApplies(c.region); got != c.want { |
| + t.Errorf("record %q on console %q (bucket %q) = %v, want %v", c.region, c.console, c.bucket, got, c.want) |
| + } |
| + } |
| +} |