carlos / platform Public

feat: alias reconcile pass derives route_aliases from claims (#151)

ReconcileAliases turns global hostname claims into route_aliases rows:
a claim only materializes when its named route exists on this box AND
the route's account/app match the claim (the authoritative tenancy
gate — the console door's check is best-effort since it cannot see
the registry). Routes always win a host collision. The table is
wholly pass-owned, so removal needs no ownership flag: every alias
absent from this pass's desired set is an orphan and is deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 35548cabe7114d78649275e947d6a702be97b772 parent bf35e46
2 files changed, +293 −0
  • internal/domains/aliases.go +74 −0
  • internal/domains/aliases_test.go +219 −0
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
new file mode 100644
index 000000000..46a90b812
--- /dev/null
+++ b/internal/domains/aliases.go
@@ -0,0 +1,74 @@
+// Alias reconcile pass — derives the registry's route_aliases table from
+// the global claims recorded in the bucket (#151). See claims.go for the
+// claim shape and registry.go's RouteAlias for the storage shape; this file
+// is the seam that turns one into the other.
+package domains
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ReconcileAliases derives route_aliases from the global claim records.
+// The table is wholly pass-owned: every row traces to a claim, so removal
+// needs no ownership flag. The authoritative tenancy gate is HERE — the
+// door's route check is best-effort (it cannot see the registry); a claim
+// only materializes when the named route exists locally AND its
+// account/app match the claim. Routes always win a host collision: an
+// alias must never shadow a real route.
+func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error {
+ claims, err := ListClaims(ctx, s)
+ if err != nil {
+ return err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return err
+ }
+ byHost := make(map[string]registry.Route, len(routes))
+ for _, rt := range routes {
+ byHost[rt.Host] = rt
+ }
+ existing, err := reg.ListAliases()
+ if err != nil {
+ return err
+ }
+ desired := map[string]bool{}
+ for _, c := range claims {
+ if c.Route == "" { // first-route claims (#82's case) are not aliases
+ continue
+ }
+ rt, ok := byHost[c.Route]
+ if !ok {
+ continue // primary not on this box — another box's claim
+ }
+ if rt.App != c.App || release.RouteAccount(rt) != c.Account {
+ log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s",
+ c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App)
+ continue
+ }
+ if _, taken := byHost[c.Host]; taken {
+ log.Printf("domains: alias %s skipped: a real route owns that host", c.Host)
+ continue
+ }
+ desired[c.Host] = true
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App,
+ CreatedAt: now().Unix(),
+ }); err != nil {
+ log.Printf("domains: upsert alias %s: %v", c.Host, err)
+ }
+ }
+ for _, a := range existing {
+ if !desired[a.Alias] {
+ if err := reg.DeleteAlias(a.Alias); err != nil {
+ log.Printf("domains: remove alias %s: %v", a.Alias, err)
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/domains/aliases_test.go b/internal/domains/aliases_test.go
new file mode 100644
index 000000000..c07a79467
--- /dev/null
+++ b/internal/domains/aliases_test.go
@@ -0,0 +1,219 @@
+package domains
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// aliasRegWith opens a fresh registry and seeds it with routes, the same
+// pattern reconcile_test.go's regWith uses (that helper lives in the
+// domains_test package, unreachable from here).
+func aliasRegWith(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
+}
+
+// putClaim writes c's JSON straight to its ClaimKey, bypassing AttachClaim's
+// CAS dance — the reconcile pass reads whatever is in the bucket, not just
+// what AttachClaim would have produced.
+func putClaim(t *testing.T, s release.Store, c Claim) {
+ t.Helper()
+ c.Schema = 1
+ body, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), ClaimKey(c.Host), body, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func aliasNow() func() time.Time {
+ return func() time.Time { return time.Unix(1755000000, 0) }
+}
+
+func TestReconcileAliasesMaterializes(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 1 {
+ t.Fatalf("got %d aliases, want 1: %+v", len(aliases), aliases)
+ }
+ a := aliases[0]
+ if a.Alias != "tickets.example.com" || a.Host != "canary/live" ||
+ a.Account != "bab" || a.App != "kass" || a.CreatedAt != 1755000000 {
+ t.Fatalf("alias row mismatch: %+v", a)
+ }
+}
+
+func TestReconcileAliasesOwnershipGate(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ // Claim names the right route but the wrong app.
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "somethingelse", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesRoutesWin(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t,
+ registry.Route{Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready"},
+ registry.Route{Host: "tickets.example.com", App: "other", Account: "bab", Kind: "instance", Status: "ready"},
+ )
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (a real route owns that host): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesMissingRouteSkips(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t) // no routes at all — this box doesn't have the route
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0: %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesRemovesOrphans(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: "orphan.example.com", Host: "canary/live", Account: "bab", App: "kass",
+ CreatedAt: 1000,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // No claim backs orphan.example.com.
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (orphan should have been removed): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesRouteless(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ // A first-route claim (#82's case): no Route set yet.
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(aliases) != 0 {
+ t.Fatalf("got %d aliases, want 0 (routeless claim must be ignored): %+v", len(aliases), aliases)
+ }
+}
+
+func TestReconcileAliasesIdempotent(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := aliasRegWith(t, registry.Route{
+ Host: "canary/live", App: "kass", Account: "bab", Kind: "instance", Status: "ready",
+ })
+ putClaim(t, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live",
+ })
+ if err := ReconcileAliases(context.Background(), s, reg, aliasNow()); err != nil {
+ t.Fatal(err)
+ }
+ first, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first) != 1 {
+ t.Fatalf("got %d aliases after first pass, want 1: %+v", len(first), first)
+ }
+ firstCreatedAt := first[0].CreatedAt
+
+ // Second pass, later clock: CreatedAt must be preserved, not bumped.
+ later := func() time.Time { return time.Unix(1755000000+3600, 0) }
+ if err := ReconcileAliases(context.Background(), s, reg, later); err != nil {
+ t.Fatal(err)
+ }
+ second, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(second) != 1 {
+ t.Fatalf("got %d aliases after second pass, want 1: %+v", len(second), second)
+ }
+ if second[0].CreatedAt != firstCreatedAt {
+ t.Fatalf("CreatedAt changed on re-run: first=%d second=%d", firstCreatedAt, second[0].CreatedAt)
+ }
+ if second[0].Alias != first[0].Alias || second[0].Host != first[0].Host ||
+ second[0].Account != first[0].Account || second[0].App != first[0].App {
+ t.Fatalf("alias row changed on re-run: first=%+v second=%+v", first[0], second[0])
+ }
+}