| new file mode 100644 |
| index 000000000..c6891234d |
| --- /dev/null |
| +++ b/internal/provision/reconcile_test.go |
| @@ -0,0 +1,340 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ctx = context.Background() |
| + |
| +// acctID is the id behind every test's account manifest. Its sqid is always |
| +// computed with domains.Sqid, never hardcoded — the same discipline |
| +// internal/domains/reconcile_test.go follows for the identical reason: a |
| +// literal sqid string would silently stop agreeing with domains.Sqid the |
| +// moment the alphabet or offset ever changed. |
| +const acctID int64 = 2 |
| + |
| +func dirStore(t *testing.T) *release.DirStore { |
| + t.Helper() |
| + return release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +func regWith(t *testing.T, routes ...registry.Route) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return reg |
| +} |
| + |
| +func mustGet(t *testing.T, reg *registry.Registry, host string) *registry.Route { |
| + t.Helper() |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt == nil { |
| + t.Fatalf("route %s missing", host) |
| + } |
| + return rt |
| +} |
| + |
| +func accountManifestBody(id int64, sqid, name string) []byte { |
| + return []byte(`{"id":` + fmt.Sprint(id) + `,"sqid":"` + sqid + `","name":"` + name + |
| + `","created_at":"2026-08-02T09:00:00Z"}`) |
| +} |
| + |
| +// writeAccountManifest writes a fresh (create-only) account manifest and |
| +// hands back its sqid — the frozen encoding of id, computed by |
| +// domains.Sqid, never a literal. |
| +func writeAccountManifest(t *testing.T, s release.Store, id int64, name string) string { |
| + t.Helper() |
| + sqid := domains.Sqid(id) |
| + if _, err := s.Put(ctx, "accounts/"+sqid+"/account.json", accountManifestBody(id, sqid, name), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return sqid |
| +} |
| + |
| +// putReplace overwrites an existing object (read-etag-then-put), for tests |
| +// that need to corrupt or repair an object already written by |
| +// writeAccountManifest's create-only Put. |
| +func putReplace(t *testing.T, s release.Store, key string, body []byte) { |
| + t.Helper() |
| + _, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// setup writes a valid account manifest (id acctID) and that account's |
| +// "tito" app instances manifest (domain go.tito.io, pool tito, seed |
| +// ["seed"]) — the fixture every test case below builds records on top of. |
| +func setup(t *testing.T) (*release.DirStore, string) { |
| + t.Helper() |
| + s := dirStore(t) |
| + sqid := writeAccountManifest(t, s, acctID, "Tito") |
| + if err := PutManifest(ctx, s, sqid, "tito", Manifest{ |
| + Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Seed: []string{"seed"}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, sqid |
| +} |
| + |
| +func acmeRecord(sqid string) Record { |
| + return Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now()} |
| +} |
| + |
| +// 1. Mint. |
| +func TestReconcileMints(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := *mustGet(t, reg, "acme.go.tito.io") |
| + got.CreatedAt = 0 // stamped by Upsert; not part of the contract under test |
| + want := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| + Hibernate: true, Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| + } |
| + if got != want { |
| + t.Errorf("minted route = %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +// 2. Scope. |
| +func TestReconcileScope(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + |
| + // Wrong region entirely. |
| + if err := Reconcile(ctx, s, reg, ReconcileConfig{Region: "us", Pools: []string{"tito"}, DataDir: "/data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("minted for a foreign region: %+v %v", rt, err) |
| + } |
| + |
| + // Right region, but this box serves no pools (nil = the shared "" pool |
| + // only) — "tito" is not among them. |
| + if err := Reconcile(ctx, s, reg, ReconcileConfig{Region: "ie", DataDir: "/data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("minted for an unserved pool: %+v %v", rt, err) |
| + } |
| + |
| + // A record with empty pool+region mints under a zero-value cfg (the |
| + // unconstrained region, the shared pool). |
| + unscoped := Record{Schema: 1, Host: "shared.go.tito.io", Slug: "shared", App: "tito", |
| + Account: sqid, Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, unscoped); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, ReconcileConfig{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "shared.go.tito.io") |
| +} |
| + |
| +// 3. Idempotent. |
| +func TestReconcileIdempotent(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Errorf("second pass changed the registry:\nbefore %+v\nafter %+v", before, after) |
| + } |
| +} |
| + |
| +// 4. Converge seed. |
| +func TestReconcileConvergesSeed(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + |
| + if err := PutManifest(ctx, s, sqid, "tito", Manifest{ |
| + Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Seed: []string{"seed", "-v"}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Seed != `["seed","-v"]` { |
| + t.Errorf("seed = %q, want the converged manifest seed", after.Seed) |
| + } |
| + after.Seed = before.Seed |
| + if after != before { |
| + t.Errorf("converge touched more than Seed: before %+v after %+v", before, after) |
| + } |
| +} |
| + |
| +// 5. Conflict. |
| +func TestReconcileLeavesConflictAlone(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "other", |
| + Status: "ready", Addr: "127.0.0.1:9000"} |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if before != after { |
| + t.Errorf("conflicting hand-added route changed: before %+v after %+v", before, after) |
| + } |
| + if n, err := reg.List(); err != nil || len(n) != 1 { |
| + t.Errorf("pass minted something alongside the conflict: %+v %v", n, err) |
| + } |
| +} |
| + |
| +// 6. Remove. |
| +func TestReconcileRemoves(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{Host: "neighbor.go.tito.io", Kind: "instance", App: "other", |
| + Status: "ready", Addr: "127.0.0.1:9001"} |
| + foreign := registry.Route{Host: "us.go.tito.io", Kind: "instance", App: "tito", |
| + Status: "ready", Provisioned: true, Region: "us", Pool: "tito", Account: sqid} |
| + reg := regWith(t, handAdded, foreign) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "acme.go.tito.io") // minted first |
| + |
| + if err := DeleteRecord(ctx, s, sqid, "tito", "acme.go.tito.io"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("retired route survived: %+v %v", rt, err) |
| + } |
| + mustGet(t, reg, "neighbor.go.tito.io") // hand-added, never provisioned: survives |
| + mustGet(t, reg, "us.go.tito.io") // provisioned but out of this box's region: survives |
| +} |
| + |
| +// 7. Fail-safe. |
| +func TestReconcileFailSafeOnBrokenAccountManifest(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "acme.go.tito.io") // minted while the manifest was good |
| + |
| + putReplace(t, s, "accounts/"+sqid+"/account.json", []byte("not json")) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Mints nothing new for the now-unreadable account, but the existing |
| + // provisioned route must NOT be retired: readFailed disables removal for |
| + // the whole pass, even though the record has (from this pass's point of |
| + // view, with the account unknown) effectively vanished from the diff set. |
| + mustGet(t, reg, "acme.go.tito.io") |
| + |
| + putReplace(t, s, "accounts/"+sqid+"/account.json", accountManifestBody(acctID, sqid, "Tito")) |
| + if err := DeleteRecord(ctx, s, sqid, "tito", "acme.go.tito.io"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("route survived after the manifest was fixed and the record removed: %+v %v", rt, err) |
| + } |
| +} |
| + |
| +// 8. Malformed record. |
| +func TestReconcileSkipsMalformedRecord(t *testing.T) { |
| + s, sqid := setup(t) |
| + body, err := json.Marshal(Record{Schema: 1, Host: "wrong-host.go.tito.io", Slug: "wrong", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Written at the key for "acme.go.tito.io" but the body claims a |
| + // different host: the record disagrees with its own path. |
| + if _, err := s.Put(ctx, RecordKey(sqid, "tito", "acme.go.tito.io"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("minted from a malformed record's key host: %+v %v", rt, err) |
| + } |
| + if rt, err := reg.Get("wrong-host.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("minted from a malformed record's body host: %+v %v", rt, err) |
| + } |
| +} |