package domains_test import ( "context" "database/sql" "path/filepath" "reflect" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // trashedImportScene is a trashed app "shop" with two routes on this box: // a.shop.example.com, swapped for an uncommitted import (so journaled), // and b.shop.example.com, an ordinary route. func trashedImportScene(t *testing.T) (*release.DirStore, *registry.Registry, string, registry.Route) { t.Helper() s := dirStore(t) sqid := writeAccountManifest(t, s, 793481920157, "Shop") putKey(t, s, "apps/"+sqid+"/shop/channels/stable.json") path := filepath.Join(t.TempDir(), "registry.db") reg, err := registry.Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) legacy := registry.Route{Host: "a.shop.example.com", Kind: "service", Addr: "127.0.0.1:8401", App: "shop", Account: sqid, Status: "ready", Channel: "stable", CreatedAt: 1700000000} plain := legacy plain.Host, plain.Addr = "b.shop.example.com", "127.0.0.1:8402" for _, rt := range []registry.Route{legacy, plain} { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } original := *mustGet(t, reg, legacy.Host) const seedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" next := registry.Route{Host: legacy.Host, Kind: "instance", Socket: "/run/carlos/instances/a/app.sock", DBPath: "/data/instances/a/a.shop.example.com.db", App: "shop", Account: sqid, Status: "ready", Channel: "stable", Hibernate: true, Provisioned: true, ImportGate: "abc123", SeedSource: `{"id":"` + seedID + `","kind":"import","state":"swapped"}`} if err := reg.SwapForImport(registry.ImportJournal{Host: legacy.Host, SeedID: seedID, Original: original, RecordKey: "apps/" + sqid + "/shop/instances/a.shop.example.com.json", At: 1}, next); err != nil { t.Fatal(err) } if err := release.TrashApp(ctx, s, sqid, "shop", "owner@example.com", time.Unix(1753900000, 0)); err != nil { t.Fatal(err) } return s, reg, path, *mustGet(t, reg, legacy.Host) } // Task 5 (the Task 4 review's carry): the trash removal skips a host the // import journal holds, as the provision pass's removal does. The journal // owns the gated row until its rollback restores the original route and // lets go; every other route of the trashed app goes as before. func TestReconcileTrashLeavesAJournaledImportsRow(t *testing.T) { s, reg, _, gated := trashedImportScene(t) for pass := 1; pass <= 2; pass++ { if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { t.Fatalf("pass %d: %v", pass, err) } if got := mustGet(t, reg, gated.Host); !reflect.DeepEqual(*got, gated) { t.Fatalf("pass %d: the journaled row changed: %+v", pass, got) } if rt, _ := reg.Get("b.shop.example.com"); rt != nil { t.Fatalf("pass %d: the trashed app's other route survived: %+v", pass, rt) } } // Once the journal lets go, the trash removes the host like any other. if err := reg.CloseImportGate(gated.Host, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", 1); err != nil { t.Fatal(err) } if err := reg.RestoreImportRoute(gated.Host, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil { t.Fatal(err) } if err := reg.DeleteImportJournal(gated.Host, "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"); err != nil { t.Fatal(err) } if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { t.Fatal(err) } if rt, _ := reg.Get(gated.Host); rt != nil { t.Fatalf("the trashed app's restored route survived: %+v", rt) } } // A journal that cannot be read could hide any host: no route of a // trashed app is removed that pass. func TestReconcileTrashRemovesNothingWhenTheJournalCannotBeRead(t *testing.T) { s, reg, path, gated := trashedImportScene(t) db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := db.Exec(`UPDATE import_journal SET original = '{' WHERE host = ?`, gated.Host); err != nil { t.Fatal(err) } db.Close() if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { t.Fatal(err) } mustGet(t, reg, gated.Host) mustGet(t, reg, "b.shop.example.com") } // hookStore runs once, on the first read of one key, a change another // process makes in the middle of a domains pass. type hookStore struct { release.Store key string once func() } func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) { if key == h.key && h.once != nil { f := h.once h.once = nil f() } return h.Store.Get(ctx, key) } // Fix round 1, I2: the domains pass lists the routes and sees the trashed // app's import row. Before its trash loop runs, the provision pass's // rollback restores the original row (the operator's, carrying a grant) // and retires the journal row. The trash delete must not remove that row // on the strength of the import row it listed. func TestReconcileTrashLeavesARowARollbackRestoredMidPass(t *testing.T) { s := dirStore(t) sqid := writeAccountManifest(t, s, 793481920157, "Shop") putKey(t, s, "apps/"+sqid+"/shop/channels/stable.json") reg := regWith(t, registry.Route{Host: "a.shop.example.com", Kind: "service", Addr: "127.0.0.1:8401", App: "shop", Account: "bab", Status: "ready", Channel: "stable", Caps: "port:25", CreatedAt: 1700000000}) original := *mustGet(t, reg, "a.shop.example.com") const seedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" next := registry.Route{Host: original.Host, Kind: "instance", Socket: "/run/carlos/instances/a/app.sock", DBPath: "/data/instances/a/a.shop.example.com.db", App: "shop", Account: sqid, Status: "ready", Channel: "stable", Hibernate: true, Provisioned: true, ImportGate: "abc123", SeedSource: `{"id":"` + seedID + `","kind":"import","state":"swapped"}`} if err := reg.SwapForImport(registry.ImportJournal{Host: original.Host, SeedID: seedID, Original: original, RecordKey: "apps/" + sqid + "/shop/instances/a.shop.example.com.json", At: 1}, next); err != nil { t.Fatal(err) } if err := release.TrashApp(ctx, s, sqid, "shop", "owner@example.com", time.Unix(1753900000, 0)); err != nil { t.Fatal(err) } ran := false store := &hookStore{Store: s, key: release.AppTombstoneKey(sqid, "shop"), once: func() { ran = true for _, step := range []func() error{ func() error { return reg.CloseImportGate(original.Host, seedID, 1) }, func() error { return reg.RestoreImportRoute(original.Host, seedID) }, func() error { return reg.DeleteImportJournal(original.Host, seedID) }, } { if err := step(); err != nil { t.Fatal(err) } } }} if err := domains.Reconcile(ctx, store, reg, cfg(t)); err != nil { t.Fatal(err) } if !ran { t.Fatal("the rollback never ran between the list and the trash loop") } if got := mustGet(t, reg, original.Host); !reflect.DeepEqual(*got, original) { t.Fatalf("the restored row changed: %+v, want %+v", got, original) } }