feat: global hostname claim records (#151)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+458
−0
internal/domains/claims.go+215 −0internal/domains/claims_test.go+243 −0
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| new file mode 100644 |
| index 000000000..cf0823f80 |
| --- /dev/null |
| +++ b/internal/domains/claims.go |
| @@ -0,0 +1,215 @@ |
| +// Global hostname claims — the desired-state object behind attaching an |
| +// extra hostname to a route (#151). A claim lives at ClaimKey(host), the |
| +// SAME key regardless of which account or app is asking, so the bucket key |
| +// itself is the cross-account uniqueness gate: two accounts racing for the |
| +// same host cannot both win, because there is only ever one object to Put. |
| +// Later tasks derive registry rows from these claims; nothing here talks to |
| +// the registry. |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "regexp" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ClaimsPrefix is where every hostname claim lives. |
| +const ClaimsPrefix = "domains/" |
| + |
| +// ClaimKey is the bucket key for host's claim. |
| +func ClaimKey(host string) string { return ClaimsPrefix + host + ".json" } |
| + |
| +// Claim is the wire shape at ClaimKey(Host) — the global record of who owns |
| +// an extra hostname attached to a route. Route is omitempty because a claim |
| +// can exist ahead of the route it will back (reserved but not yet wired); |
| +// CreatedBy is omitempty because a claim minted by a box-side process, not a |
| +// human door, has no operator to name. |
| +type Claim struct { |
| + Schema int `json:"schema"` // 1 |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Route string `json:"route,omitempty"` |
| + CreatedAt string `json:"created_at"` |
| + CreatedBy string `json:"created_by,omitempty"` |
| +} |
| + |
| +// ErrHostUnavailable is returned by AttachClaim when host is already claimed |
| +// by a different account or app. Deliberately neutral: it never names the |
| +// current holder, so a caller cannot use this door to discover who owns a |
| +// hostname they do not. |
| +var ErrHostUnavailable = errors.New("hostname unavailable") |
| + |
| +// getClaimETag reads and parses host's claim, carrying the etag it was read |
| +// under. Not-found stays os.ErrNotExist-wrapped. A schema newer than this |
| +// binary understands is refused rather than silently truncated by |
| +// json.Unmarshal — the GetManifestETag precedent (provision.go). |
| +func getClaimETag(ctx context.Context, s release.Store, host string) (*Claim, string, error) { |
| + raw, etag, err := s.Get(ctx, ClaimKey(host)) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + var c Claim |
| + if err := json.Unmarshal(raw, &c); err != nil { |
| + return nil, "", fmt.Errorf("domains: claim %s: %w", host, err) |
| + } |
| + if c.Schema > 1 { |
| + return nil, "", fmt.Errorf("domains: claim %s: schema %d is newer than this binary understands — upgrade carlos", host, c.Schema) |
| + } |
| + return &c, etag, nil |
| +} |
| + |
| +// GetClaim reads host's claim. Not-found stays os.ErrNotExist-wrapped. |
| +func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) { |
| + c, _, err := getClaimETag(ctx, s, host) |
| + return c, err |
| +} |
| + |
| +// AttachClaim writes host's global claim, create-only by key: the bucket |
| +// key IS the cross-account uniqueness gate. Same owner re-attaching is |
| +// idempotent (converging Route in place); any other owner gets |
| +// ErrHostUnavailable — deliberately neutral, never naming the holder. |
| +func AttachClaim(ctx context.Context, s release.Store, c Claim) (bool, error) { |
| + c.Schema = 1 |
| + body, err := json.Marshal(c) |
| + if err != nil { |
| + return false, err |
| + } |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, c.Host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil { |
| + return true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // lost the create race — re-read and judge the winner |
| + } else { |
| + return false, err |
| + } |
| + } else if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != c.Account || cur.App != c.App { |
| + return false, ErrHostUnavailable |
| + } |
| + if cur.Route == c.Route { |
| + return false, nil |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil { |
| + return false, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| + continue // converge race — one retry, then surface |
| + } else { |
| + return false, err |
| + } |
| + } |
| +} |
| + |
| +// DetachClaim removes host's claim, but only for the owner that holds it: |
| +// a mismatched account/app is quietly ignored (removed=false, nil error) |
| +// rather than erroring, the same neutrality AttachClaim's ErrHostUnavailable |
| +// observes — a caller must not learn who the real owner is by probing |
| +// detach. An already-absent claim is likewise quiet: detach is idempotent. |
| +func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (bool, error) { |
| + cur, err := GetClaim(ctx, s, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != account || cur.App != app { |
| + return false, nil |
| + } |
| + if err := s.Delete(ctx, ClaimKey(host)); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// ListClaims returns every valid claim in the bucket. An unparseable or |
| +// newer-schema object is logged and skipped — one bad claim must never take |
| +// the whole listing down. |
| +func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) { |
| + keys, err := s.List(ctx, ClaimsPrefix) |
| + if err != nil { |
| + return nil, fmt.Errorf("domains: list claims: %w", err) |
| + } |
| + var claims []Claim |
| + for _, key := range keys { |
| + raw, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("domains: claim %s: %v", key, err) |
| + continue |
| + } |
| + var c Claim |
| + if err := json.Unmarshal(raw, &c); err != nil { |
| + log.Printf("domains: claim %s: unparseable: %v", key, err) |
| + continue |
| + } |
| + if c.Schema > 1 { |
| + log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema) |
| + continue |
| + } |
| + claims = append(claims, c) |
| + } |
| + return claims, nil |
| +} |
| + |
| +// hostLabelRe mirrors provision.labelRe (which itself mirrors |
| +// domains.accountNameRe — see that var's comment) — pinned HERE rather than |
| +// imported from internal/provision, because internal/provision/reconcile.go |
| +// already imports internal/domains for account/route materialization; |
| +// importing provision.ValidHostShape back would be a cycle. Same DNS label |
| +// rule: lowercase a-z, 0-9, hyphen, no leading/trailing hyphen, 1-63 chars. |
| +var hostLabelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| + |
| +// validHostShape is provision.ValidHostShape's check, duplicated for the |
| +// import-cycle reason hostLabelRe explains: every label of host must match |
| +// hostLabelRe, and the whole host must fit in the DNS 253-character ceiling. |
| +func validHostShape(host string) bool { |
| + if host == "" || len(host) > 253 { |
| + return false |
| + } |
| + for _, label := range strings.Split(host, ".") { |
| + if !hostLabelRe.MatchString(label) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// NormalizeAliasHost lowercases raw, strips one trailing dot, and validates |
| +// the result: validHostShape (the same label rules a route host is held to) |
| +// AND at least one interior dot — a bare label like "localhost" is never a |
| +// public hostname, however it parses. |
| +func NormalizeAliasHost(raw string) (string, bool) { |
| + h := strings.ToLower(raw) |
| + h = strings.TrimSuffix(h, ".") |
| + if !validHostShape(h) || !strings.Contains(h, ".") { |
| + return "", false |
| + } |
| + return h, true |
| +} |
| + |
| +// HostDenied reports whether host equals any entry in deny, or is a |
| +// subdomain of one (ends in "."+entry). Empty entries are skipped, so a |
| +// deny list padded with "" (e.g. from a blank config line) can never match |
| +// every host. |
| +func HostDenied(host string, deny []string) bool { |
| + for _, entry := range deny { |
| + if entry == "" { |
| + continue |
| + } |
| + if host == entry || strings.HasSuffix(host, "."+entry) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go| new file mode 100644 |
| index 000000000..697cfc36c |
| --- /dev/null |
| +++ b/internal/domains/claims_test.go |
| @@ -0,0 +1,243 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestAttachClaimCreates(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + created, err := AttachClaim(ctx, s, Claim{ |
| + Host: "tickets.example.com", Account: "bab", App: "kass", |
| + Route: "canary/live", CreatedAt: "2026-08-12T00:00:00Z", CreatedBy: "paul@tito.io", |
| + }) |
| + if err != nil || !created { |
| + t.Fatalf("AttachClaim: created=%v err=%v, want true nil", created, err) |
| + } |
| + got, err := GetClaim(ctx, s, "tickets.example.com") |
| + if err != nil { |
| + t.Fatalf("GetClaim: %v", err) |
| + } |
| + if got.Schema != 1 || got.Host != "tickets.example.com" || got.Account != "bab" || |
| + got.App != "kass" || got.Route != "canary/live" || got.CreatedAt != "2026-08-12T00:00:00Z" || |
| + got.CreatedBy != "paul@tito.io" { |
| + t.Fatalf("GetClaim round-trip mismatch: %+v", got) |
| + } |
| +} |
| + |
| +func TestAttachClaimIdempotent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| + if _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + created, err := AttachClaim(ctx, s, claim) |
| + if err != nil || created { |
| + t.Fatalf("AttachClaim re-attach: created=%v err=%v, want false nil", created, err) |
| + } |
| + after, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if *before != *after { |
| + t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after) |
| + } |
| +} |
| + |
| +func TestAttachClaimConvergesRoute(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| + if _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + claim.Route = "canary/next" |
| + created, err := AttachClaim(ctx, s, claim) |
| + if err != nil || created { |
| + t.Fatalf("AttachClaim route change: created=%v err=%v, want false nil", created, err) |
| + } |
| + got, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Route != "canary/next" { |
| + t.Fatalf("Route = %q, want canary/next", got.Route) |
| + } |
| +} |
| + |
| +func TestAttachClaimOtherOwnerRefused(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"} |
| + if _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + other := claim |
| + other.Account = "bad" |
| + if _, err := AttachClaim(ctx, s, other); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("other account: err = %v, want ErrHostUnavailable", err) |
| + } |
| + |
| + otherApp := claim |
| + otherApp.App = "console" |
| + if _, err := AttachClaim(ctx, s, otherApp); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("other app: err = %v, want ErrHostUnavailable", err) |
| + } |
| + |
| + after, err := GetClaim(ctx, s, claim.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if *before != *after { |
| + t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after) |
| + } |
| +} |
| + |
| +func TestAttachClaimNewerSchemaRefused(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + body := []byte(`{"schema":2,"host":"tickets.example.com","account":"bab","app":"kass"}`) |
| + if _, err := s.Put(ctx, ClaimKey("tickets.example.com"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err := AttachClaim(ctx, s, Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}) |
| + if err == nil || !strings.Contains(err.Error(), "schema") { |
| + t.Fatalf("AttachClaim over newer schema: err = %v, want error mentioning schema", err) |
| + } |
| + got, _, gerr := s.Get(ctx, ClaimKey("tickets.example.com")) |
| + if gerr != nil { |
| + t.Fatal(gerr) |
| + } |
| + if string(got) != string(body) { |
| + t.Fatalf("object changed on newer-schema refusal: got %s", got) |
| + } |
| +} |
| + |
| +func TestDetachClaimRemoves(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"} |
| + if _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + removed, err := DetachClaim(ctx, s, claim.Host, claim.Account, claim.App) |
| + if err != nil || !removed { |
| + t.Fatalf("DetachClaim: removed=%v err=%v, want true nil", removed, err) |
| + } |
| + if _, err := GetClaim(ctx, s, claim.Host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("GetClaim after detach: err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +func TestDetachClaimUnknownQuiet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + removed, err := DetachClaim(ctx, s, "nope.example.com", "bab", "kass") |
| + if err != nil || removed { |
| + t.Fatalf("DetachClaim unknown: removed=%v err=%v, want false nil", removed, err) |
| + } |
| +} |
| + |
| +func TestDetachClaimOtherOwnerQuiet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"} |
| + if _, err := AttachClaim(ctx, s, claim); err != nil { |
| + t.Fatal(err) |
| + } |
| + removed, err := DetachClaim(ctx, s, claim.Host, "bad", "console") |
| + if err != nil || removed { |
| + t.Fatalf("DetachClaim other owner: removed=%v err=%v, want false nil", removed, err) |
| + } |
| + if _, err := GetClaim(ctx, s, claim.Host); err != nil { |
| + t.Fatalf("GetClaim after other-owner detach: %v, want claim still present", err) |
| + } |
| +} |
| + |
| +func TestListClaimsSkipsGarbage(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := AttachClaim(ctx, s, Claim{Host: "a.example.com", Account: "bab", App: "kass"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AttachClaim(ctx, s, Claim{Host: "b.example.com", Account: "bad", App: "console"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ClaimKey("garbage.example.com"), []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + claims, err := ListClaims(ctx, s) |
| + if err != nil { |
| + t.Fatalf("ListClaims: %v", err) |
| + } |
| + if len(claims) != 2 { |
| + t.Fatalf("ListClaims returned %d claims, want 2: %+v", len(claims), claims) |
| + } |
| + hosts := map[string]bool{} |
| + for _, c := range claims { |
| + hosts[c.Host] = true |
| + } |
| + if !hosts["a.example.com"] || !hosts["b.example.com"] { |
| + t.Fatalf("ListClaims missing expected hosts: %+v", claims) |
| + } |
| +} |
| + |
| +func TestNormalizeAliasHost(t *testing.T) { |
| + cases := []struct { |
| + raw string |
| + want string |
| + ok bool |
| + }{ |
| + {"Tickets.Example.COM.", "tickets.example.com", true}, |
| + {"localhost", "", false}, |
| + {"a..b", "", false}, |
| + {"", "", false}, |
| + {strings.Repeat("a", 254), "", false}, |
| + } |
| + for _, c := range cases { |
| + got, ok := NormalizeAliasHost(c.raw) |
| + if ok != c.ok { |
| + t.Errorf("NormalizeAliasHost(%q) ok = %v, want %v", c.raw, ok, c.ok) |
| + continue |
| + } |
| + if ok && got != c.want { |
| + t.Errorf("NormalizeAliasHost(%q) = %q, want %q", c.raw, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestHostDenied(t *testing.T) { |
| + deny := []string{"oncarlos.com"} |
| + cases := []struct { |
| + host string |
| + want bool |
| + }{ |
| + {"x.oncarlos.com", true}, |
| + {"oncarlos.com", true}, |
| + {"xoncarlos.com", false}, |
| + } |
| + for _, c := range cases { |
| + if got := HostDenied(c.host, deny); got != c.want { |
| + t.Errorf("HostDenied(%q, %v) = %v, want %v", c.host, deny, got, c.want) |
| + } |
| + } |
| + if HostDenied("anything.example.com", []string{"", "oncarlos.com"}) { |
| + t.Fatal("empty deny entry must never match") |
| + } |
| +} |