| 1 | //go:build unix |
| 2 | |
| 3 | package hostbox |
| 4 | |
| 5 | import ( |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/hostguard" |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | ) |
| 19 | |
| 20 | // N4: a relocation target's held row is minted through the same typed |
| 21 | // inventory as reconcile's own (ownedRow): on an empty installation the |
| 22 | // census initialises, the route entry is recorded and materialized, and |
| 23 | // the row is held by the op with its ClaimOwnership bound to the captured |
| 24 | // stamp. A second call is idempotent. Another incarnation and a flat route |
| 25 | // refuse with ErrChanged; a host-named file under the data root refuses |
| 26 | // with ErrTypedInventory before any row is written. |
| 27 | func TestMintHeldBindsTheRowToTheInstallStamp(t *testing.T) { |
| 28 | type mint struct { |
| 29 | f admissionFixture |
| 30 | reg *registry.Registry |
| 31 | inv *StopInventoryDB |
| 32 | adapter ReconcileAdapter |
| 33 | rec provision.ClaimRecord |
| 34 | captured hostguard.Captured |
| 35 | } |
| 36 | setup := func(t *testing.T) mint { |
| 37 | t.Helper() |
| 38 | f := newAdmissionFixture(t, false) |
| 39 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | t.Cleanup(func() { reg.Close() }) |
| 44 | inv, err := OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db")) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | t.Cleanup(func() { inv.Close() }) |
| 49 | launches, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db")) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | t.Cleanup(func() { launches.Close() }) |
| 54 | census := &EmptyInstallationCensus{Inventory: inv, Registry: reg, Launches: launches, Box: "box", Enrollment: "en", DirectoryRevision: "1", |
| 55 | Roots: map[string]string{"data": f.data, "opt": t.TempDir(), "units": t.TempDir()}, SharedPaths: map[string][]string{"data": {"hostguard"}}} |
| 56 | adapter := ReconcileAdapter{Registry: reg, Inventory: inv, Census: census, Box: "box", Enrollment: "en", DirectoryRevision: "1", |
| 57 | Reconciler: Reconciler{Claims: f.store, Box: "box", Region: "ie", Guard: hostguard.Guard{DataDir: f.data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.store}}}} |
| 58 | record := provision.Record{Schema: 1, Host: f.host, Slug: "shop", App: "app", Account: f.account, Region: "ie", Channel: "stable", CreatedAt: time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)} |
| 59 | rec := provision.ClaimRecord{Host: f.host, Key: f.key, Family: "instance", Account: f.account, App: "app", CreatedAt: record.CreatedAt, Value: record, |
| 60 | Route: provision.RouteFor(record, f.data, "", true, nil)} |
| 61 | return mint{f: f, reg: reg, inv: inv, adapter: adapter, rec: rec, captured: hostguard.Captured{Incarnation: f.holder.Incarnation, Stamp: f.holder.Stamp}} |
| 62 | } |
| 63 | ctx := t.Context() |
| 64 | t.Run("from an empty installation", func(t *testing.T) { |
| 65 | m := setup(t) |
| 66 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | row, err := m.reg.Get(m.f.host) |
| 70 | if err != nil || row == nil { |
| 71 | t.Fatalf("row: %+v %v", row, err) |
| 72 | } |
| 73 | sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", m.f.host, m.captured.Incarnation, m.captured.Stamp, "box"))) |
| 74 | if row.Status != "relocating" || row.HoldOp != "op-1" || row.ClaimOwnership.ClaimStamp != m.captured.Stamp || row.ClaimOwnership.ClaimOwner != hex.EncodeToString(sum[:]) || |
| 75 | row.ClaimOwnership.ClaimIncarnation != m.captured.Incarnation || !provision.NestedPaths(m.f.data, *row) { |
| 76 | t.Fatalf("row %+v", row) |
| 77 | } |
| 78 | in, err := m.inv.ReadInstallation(ctx, m.f.host, m.captured.Incarnation, m.captured.Stamp) |
| 79 | if err != nil || !in.Complete { |
| 80 | t.Fatalf("inventory: %+v %v", in, err) |
| 81 | } |
| 82 | materialized := 0 |
| 83 | for _, e := range in.Entries { |
| 84 | if e.Kind == "route" && e.RelativePath == m.f.host && e.Materialization == "materialized" && e.RowIdentity == row.ClaimOwnership.ClaimRowID { |
| 85 | materialized++ |
| 86 | } |
| 87 | } |
| 88 | if materialized != 1 { |
| 89 | t.Fatalf("route entries: %+v", in.Entries) |
| 90 | } |
| 91 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); err != nil { |
| 92 | t.Fatalf("a second mint: %v", err) |
| 93 | } |
| 94 | again, err := m.reg.Get(m.f.host) |
| 95 | if err != nil || *again != *row { |
| 96 | t.Fatalf("the second mint changed the row: %+v %v", again, err) |
| 97 | } |
| 98 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-2"); !errors.Is(err, registry.ErrClaimOwnership) { |
| 99 | t.Fatalf("another op's mint over a held row: %v", err) |
| 100 | } |
| 101 | }) |
| 102 | t.Run("another incarnation", func(t *testing.T) { |
| 103 | m := setup(t) |
| 104 | m.captured.Incarnation = "other" |
| 105 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); !errors.Is(err, ErrChanged) { |
| 106 | t.Fatalf("got %v", err) |
| 107 | } |
| 108 | }) |
| 109 | t.Run("a flat route", func(t *testing.T) { |
| 110 | m := setup(t) |
| 111 | m.rec.Route = provision.RouteFor(m.rec.Value.(provision.Record), m.f.data, "", false, nil) |
| 112 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); !errors.Is(err, ErrChanged) { |
| 113 | t.Fatalf("got %v", err) |
| 114 | } |
| 115 | }) |
| 116 | t.Run("not a hold status", func(t *testing.T) { |
| 117 | m := setup(t) |
| 118 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "ready", "op-1"); !errors.Is(err, ErrUnsupported) { |
| 119 | t.Fatalf("got %v", err) |
| 120 | } |
| 121 | }) |
| 122 | t.Run("a host-named file under the data root", func(t *testing.T) { |
| 123 | m := setup(t) |
| 124 | if err := os.WriteFile(filepath.Join(m.f.data, m.f.host+".env"), []byte("X=1\n"), 0o600); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); !errors.Is(err, ErrTypedInventory) { |
| 128 | t.Fatalf("got %v", err) |
| 129 | } |
| 130 | if row, err := m.reg.Get(m.f.host); err != nil || row != nil { |
| 131 | t.Fatalf("a row was written: %+v %v", row, err) |
| 132 | } |
| 133 | }) |
| 134 | } |
| 135 | |