domains: reconcile — the bucket decides, the agent materializes
One pass, five idempotent steps in spec order: account manifests → registry accounts + route linkage, platform-domain minting (first ready upstream, host order, deterministic), canary-host minting for hostless pointers, removal ONLY of platform-flagged followers of retired canary channels, then DNS/TLS health stamps. Unknowns stay unknown: resolver failure stamps "", a missing or unparseable cert stamps 0. AppsDomain unset = the whole pass is inert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+713
−0
internal/domains/reconcile.go+384 −0internal/domains/reconcile_test.go+329 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| new file mode 100644 |
| index 000000000..80c846bfa |
| --- /dev/null |
| +++ b/internal/domains/reconcile.go |
| @@ -0,0 +1,384 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "crypto/x509" |
| + "encoding/json" |
| + "encoding/pem" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Config is everything one reconcile pass reads its world through. Every |
| +// field is a seam: the tests inject fixtures, the agent injects production |
| +// values (internal/host wires it). |
| +type Config struct { |
| + // AppsDomain is the deployment's platform apps domain (flagship: |
| + // "oncarlos.com"). "" makes the WHOLE pass inert — no minting, no |
| + // manifests read, zero registry writes — the litestream convention: |
| + // unconfigured means the feature does not exist. |
| + AppsDomain string |
| + // EdgeIPs are this deployment's edge addresses; a host resolving to any |
| + // of them is "pointing". |
| + EdgeIPs []string |
| + // Resolve is the DNS seam (net resolver in production, a fake in |
| + // tests); nil skips DNS stamping entirely. |
| + Resolve func(host string) ([]string, error) |
| + // AcmeDir is the edge's autocert DirCache directory; "" skips TLS |
| + // stamping entirely. |
| + AcmeDir string |
| + // Now is the clock seam; nil = time.Now. |
| + Now func() time.Time |
| +} |
| + |
| +// Reconcile runs the spec §3 steps in order, idempotently: bucket account |
| +// manifests → registry accounts + route linkage, platform-domain minting, |
| +// canary-host minting/removal, then DNS and TLS health stamps. Per-item |
| +// failures are logged and skipped — one bad manifest or one unresolvable |
| +// host must never take the pass down; only setup-level failures (listing the |
| +// bucket or the registry) return an error. |
| +// |
| +// The one destructive move — removing a route — happens ONLY for routes with |
| +// Platform set whose canary channel pointer is gone from the bucket. Custom |
| +// domains are owner property; reconciliation never touches them. |
| +func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg Config) error { |
| + if cfg.AppsDomain == "" { |
| + return nil // inert until configured |
| + } |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + |
| + // One bucket listing feeds both the manifest scan and the canary-pointer |
| + // scan below. |
| + keys, err := s.List(ctx, "apps/") |
| + if err != nil { |
| + return fmt.Errorf("domains: list bucket: %w", err) |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return fmt.Errorf("domains: list routes: %w", err) |
| + } |
| + |
| + // --- 1. account materialization ------------------------------------------ |
| + // The console originates account truth as apps/<app>/account.json in the |
| + // bucket; this is the only place it becomes registry state. A deployment |
| + // with no console has no manifests: everything stays under ops. |
| + accountOf := map[string]string{} // app -> manifest account name |
| + for _, key := range keys { |
| + app, ok := manifestApp(key) |
| + if !ok { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("domains: skipping %s: read failed: %v", key, err) |
| + continue |
| + } |
| + var m struct{ Account, Owner string } |
| + if err := json.Unmarshal(body, &m); err != nil || m.Account == "" { |
| + log.Printf("domains: skipping %s: malformed manifest: %v", key, err) |
| + continue |
| + } |
| + if _, err := reg.EnsureAccount(m.Account); err != nil { |
| + log.Printf("domains: ensure account %q: %v", m.Account, err) |
| + continue |
| + } |
| + accountOf[app] = m.Account |
| + for i := range routes { |
| + if routes[i].App != app || routes[i].Account == m.Account { |
| + continue |
| + } |
| + if err := reg.SetAccount(routes[i].Host, m.Account); err != nil { |
| + log.Printf("domains: stamp account on %s: %v", routes[i].Host, err) |
| + continue |
| + } |
| + routes[i].Account = m.Account |
| + } |
| + } |
| + |
| + // --- 2. platform-domain minting ------------------------------------------ |
| + byHost := map[string]bool{} |
| + for _, rt := range routes { |
| + byHost[rt.Host] = true |
| + } |
| + for _, app := range candidateApps(routes) { |
| + acct := appAccount(routes, accountOf, app) |
| + sq, err := accountSqid(reg, acct) |
| + if err != nil { |
| + log.Printf("domains: account id for %q: %v", acct, err) |
| + continue |
| + } |
| + host := fmt.Sprintf("%s.%s.%s", app, sq, cfg.AppsDomain) |
| + if byHost[host] { |
| + continue // already minted — idempotent |
| + } |
| + src := firstReadyUpstream(routes, app) |
| + if src == nil { |
| + continue // nothing serving to copy an upstream from; next pass |
| + } |
| + nr := mintedRoute(host, app, acct, *src) |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("domains: mint %s: %v", host, err) |
| + continue |
| + } |
| + log.Printf("domains: minted platform domain %s (upstream %s)", host, src.Host) |
| + routes = append(routes, nr) |
| + byHost[host] = true |
| + } |
| + |
| + // --- 3. canary hosts: mint for hostless pointers, remove for retired ---- |
| + canaryChannels := map[string]map[string]bool{} // app -> set of live canary/<name> channels |
| + for _, key := range keys { |
| + app, name, ok := canaryPointer(key) |
| + if !ok { |
| + continue |
| + } |
| + if canaryChannels[app] == nil { |
| + canaryChannels[app] = map[string]bool{} |
| + } |
| + channel := "canary/" + name |
| + canaryChannels[app][channel] = true |
| + followed := false |
| + for _, rt := range routes { |
| + if rt.App == app && rt.Channel == channel { |
| + followed = true |
| + break |
| + } |
| + } |
| + if followed { |
| + continue |
| + } |
| + acct := appAccount(routes, accountOf, app) |
| + sq, err := accountSqid(reg, acct) |
| + if err != nil { |
| + log.Printf("domains: account id for %q: %v", acct, err) |
| + continue |
| + } |
| + src := firstReadyUpstream(routes, app) |
| + if src == nil { |
| + continue // app has nothing serving yet; the pointer waits |
| + } |
| + host := fmt.Sprintf("%s.%s.%s.%s", name, app, sq, cfg.AppsDomain) |
| + nr := mintedRoute(host, app, acct, *src) |
| + nr.Channel = channel |
| + nr.Canary = true |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("domains: mint canary %s: %v", host, err) |
| + continue |
| + } |
| + log.Printf("domains: minted canary domain %s (channel %s)", host, channel) |
| + routes = append(routes, nr) |
| + byHost[host] = true |
| + } |
| + // Removal: ONLY platform-minted followers of a canary channel whose |
| + // pointer is gone. A custom route on the same channel is untouched — the |
| + // platform flag, not the channel, is what grants removal authority. |
| + kept := routes[:0] |
| + for _, rt := range routes { |
| + if rt.Platform && strings.HasPrefix(rt.Channel, "canary/") && !canaryChannels[rt.App][rt.Channel] { |
| + if err := reg.Delete(rt.Host); err != nil { |
| + log.Printf("domains: remove retired canary route %s: %v", rt.Host, err) |
| + kept = append(kept, rt) // still there; stamp it honestly below |
| + continue |
| + } |
| + log.Printf("domains: removed retired canary route %s (channel %s)", rt.Host, rt.Channel) |
| + continue |
| + } |
| + kept = append(kept, rt) |
| + } |
| + routes = kept |
| + |
| + // --- 4. DNS health -------------------------------------------------------- |
| + if cfg.Resolve != nil { |
| + edge := map[string]bool{} |
| + for _, ip := range cfg.EdgeIPs { |
| + edge[ip] = true |
| + } |
| + now := cfg.Now().Unix() |
| + for _, rt := range routes { |
| + status := "" // resolver failure is UNKNOWN, never a guess |
| + if ips, err := cfg.Resolve(rt.Host); err == nil { |
| + status = "elsewhere" |
| + for _, ip := range ips { |
| + if edge[ip] { |
| + status = "pointing" |
| + break |
| + } |
| + } |
| + } |
| + if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| + log.Printf("domains: stamp dns on %s: %v", rt.Host, err) |
| + } |
| + } |
| + } |
| + |
| + // --- 5. TLS health -------------------------------------------------------- |
| + if cfg.AcmeDir != "" { |
| + for _, rt := range routes { |
| + at := certExpiry(filepath.Join(cfg.AcmeDir, rt.Host)) |
| + if err := reg.SetCertExpiresAt(rt.Host, at); err != nil { |
| + log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// manifestApp reports whether key is an account manifest (apps/<app>/ |
| +// account.json, exactly one path level deep) and which app it names. |
| +func manifestApp(key string) (string, bool) { |
| + rest, ok := strings.CutPrefix(key, "apps/") |
| + if !ok { |
| + return "", false |
| + } |
| + app, ok := strings.CutSuffix(rest, "/account.json") |
| + if !ok || app == "" || strings.Contains(app, "/") { |
| + return "", false |
| + } |
| + return app, true |
| +} |
| + |
| +// canaryPointer reports whether key is a canary channel pointer |
| +// (apps/<app>/channels/canary/<name>.json) and its app + canary name. |
| +func canaryPointer(key string) (app, name string, ok bool) { |
| + rest, ok := strings.CutPrefix(key, "apps/") |
| + if !ok { |
| + return "", "", false |
| + } |
| + app, rest, ok = strings.Cut(rest, "/channels/canary/") |
| + if !ok || app == "" || strings.Contains(app, "/") { |
| + return "", "", false |
| + } |
| + name, ok = strings.CutSuffix(rest, ".json") |
| + if !ok || name == "" || strings.Contains(name, "/") { |
| + return "", "", false |
| + } |
| + return app, name, true |
| +} |
| + |
| +// candidateApps returns the distinct app names eligible for a platform |
| +// domain, in first-seen (host) order: non-static, non-service-namespace |
| +// (svc/*) apps with at least one route that is NOT itself platform-minted — |
| +// an app whose only routes are minted ones has been removed by its operator, |
| +// and re-minting from a mint would keep a ghost alive forever. |
| +func candidateApps(routes []registry.Route) []string { |
| + seen := map[string]bool{} |
| + real := map[string]bool{} // has a non-platform route |
| + var order []string |
| + for _, rt := range routes { |
| + if rt.App == "" || rt.Kind == "static" || strings.HasPrefix(rt.App, "svc/") { |
| + continue |
| + } |
| + if !seen[rt.App] { |
| + seen[rt.App] = true |
| + order = append(order, rt.App) |
| + } |
| + if !rt.Platform { |
| + real[rt.App] = true |
| + } |
| + } |
| + var out []string |
| + for _, app := range order { |
| + if real[app] { |
| + out = append(out, app) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// appAccount is the account an app's domains mint under: the bucket |
| +// manifest's word first, else whatever the app's routes already carry, else |
| +// the built-in ops — a deployment with no console lives entirely there. |
| +func appAccount(routes []registry.Route, accountOf map[string]string, app string) string { |
| + if a := accountOf[app]; a != "" { |
| + return a |
| + } |
| + for _, rt := range routes { |
| + if rt.App == app && rt.Account != "" { |
| + return rt.Account |
| + } |
| + } |
| + return "ops" |
| +} |
| + |
| +// accountSqid resolves an account name to its frozen sqid, creating the |
| +// account row if needed (idempotent — the row is the sqid's anchor). |
| +func accountSqid(reg *registry.Registry, name string) (string, error) { |
| + id, err := reg.EnsureAccount(name) |
| + if err != nil { |
| + return "", err |
| + } |
| + return Sqid(id), nil |
| +} |
| + |
| +// firstReadyUpstream picks the deterministic upstream a minted domain |
| +// proxies to: the app's first ready route in host order (reg.List's order) |
| +// that actually carries an upstream to copy (Socket or Addr) and is not |
| +// itself a minted route — minting from a mint would be self-reference. |
| +func firstReadyUpstream(routes []registry.Route, app string) *registry.Route { |
| + for i := range routes { |
| + rt := &routes[i] |
| + if rt.App == app && !rt.Platform && rt.Status == "ready" && |
| + (rt.Socket != "" || rt.Addr != "") { |
| + return rt |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// mintedRoute is the platform route shape: same upstream trio |
| +// (Socket/Addr/Backing) and Kind as the source, ready (the upstream is — |
| +// that's how it was chosen), platform-flagged, account-stamped. Channel is |
| +// deliberately NOT copied for the apex domain: the platform domain is an |
| +// address for the app's primary backend, not a release-channel follower — |
| +// and a copied canary channel would make the apex route removable when that |
| +// canary retires. |
| +func mintedRoute(host, app, account string, src registry.Route) registry.Route { |
| + return registry.Route{ |
| + Host: host, |
| + Kind: src.Kind, |
| + Socket: src.Socket, |
| + Addr: src.Addr, |
| + Backing: src.Backing, |
| + App: app, |
| + Status: "ready", |
| + Platform: true, |
| + Account: account, |
| + } |
| +} |
| + |
| +// certExpiry reads the autocert DirCache entry at path (the PEM bundle |
| +// cachePut writes: private key block first, then the cert chain) and returns |
| +// the leaf's NotAfter as unix seconds. Anything short of a parseable |
| +// certificate — no file, no CERTIFICATE block, a corrupt one — is 0: |
| +// unknown, never a guess. |
| +func certExpiry(path string) int64 { |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + return 0 |
| + } |
| + for len(body) > 0 { |
| + var block *pem.Block |
| + block, body = pem.Decode(body) |
| + if block == nil { |
| + return 0 |
| + } |
| + if block.Type != "CERTIFICATE" { |
| + continue // the private-key block(s) come first in the bundle |
| + } |
| + cert, err := x509.ParseCertificate(block.Bytes) |
| + if err != nil { |
| + return 0 |
| + } |
| + return cert.NotAfter.Unix() |
| + } |
| + return 0 |
| +} |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| new file mode 100644 |
| index 000000000..1ba90d3f5 |
| --- /dev/null |
| +++ b/internal/domains/reconcile_test.go |
| @@ -0,0 +1,329 @@ |
| +package domains_test |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/pem" |
| + "errors" |
| + "math/big" |
| + "os" |
| + "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() |
| + |
| +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 |
| +} |
| + |
| +// manifest writes the console-shaped account manifest for app. |
| +func manifest(t *testing.T, s release.Store, app, account, owner string) { |
| + t.Helper() |
| + body := []byte(`{"account":"` + account + `","owner":"` + owner + `"}`) |
| + if _, err := s.Put(ctx, "apps/"+app+"/account.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// cfg is the standard reconcile config: apps domain set, a fake resolver |
| +// saying every host points at the edge, DNS/TLS seams otherwise defaulted. |
| +func cfg(t *testing.T) domains.Config { |
| + t.Helper() |
| + return domains.Config{ |
| + AppsDomain: "oncarlos.test", |
| + EdgeIPs: []string{"203.0.113.7"}, |
| + Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| + Now: func() time.Time { return time.Unix(1754000000, 0) }, |
| + } |
| +} |
| + |
| +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 routeCount(t *testing.T, reg *registry.Registry) int { |
| + t.Helper() |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(routes) |
| +} |
| + |
| +func TestReconcileMintsPlatformDomainUnderOps(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No manifest anywhere: the app lives under the built-in ops account |
| + // (id 1, Sqid "bab" — frozen). |
| + got := mustGet(t, reg, "kass.bab.oncarlos.test") |
| + if !got.Platform || got.Addr != "127.0.0.1:9000" || got.Account != "ops" || got.App != "kass" { |
| + t.Errorf("minted route = %+v", got) |
| + } |
| + n := routeCount(t, reg) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if routeCount(t, reg) != n { |
| + t.Errorf("second Reconcile changed the route count: %d -> %d", n, routeCount(t, reg)) |
| + } |
| +} |
| + |
| +func TestReconcileUsesManifestAccount(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // paul is the first ensured account after ops: id 2 → frozen sqid "bac". |
| + got := mustGet(t, reg, "kass.bac.oncarlos.test") |
| + if !got.Platform || got.Account != "paul" { |
| + t.Errorf("minted route = %+v", got) |
| + } |
| + if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| + t.Errorf("source route account not stamped from manifest: %+v", src) |
| + } |
| +} |
| + |
| +func TestReconcileMintsCanaryHost(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + // The pointer's existence is what reconcile reads — content is not its |
| + // business (the edge verifies signatures when serving). |
| + if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := mustGet(t, reg, "pricing.kass.bab.oncarlos.test") |
| + if !got.Platform || got.Channel != "canary/pricing" || got.Addr != "127.0.0.1:9000" || !got.Canary { |
| + t.Errorf("minted canary route = %+v", got) |
| + } |
| + // A followed canary never doubles up: the minted route follows the |
| + // channel now, so a second pass mints nothing new. |
| + n := routeCount(t, reg) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if routeCount(t, reg) != n { |
| + t.Errorf("second Reconcile changed the route count: %d -> %d", n, routeCount(t, reg)) |
| + } |
| +} |
| + |
| +func TestReconcileRemovesRetiredCanaryHostOnly(t *testing.T) { |
| + // The canary/old pointer is GONE from the bucket; its platform-minted |
| + // follower must be removed, but the custom route following the same |
| + // channel is owner property — reconciliation never touches it. |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + registry.Route{Host: "old.kass.bab.oncarlos.test", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000", Channel: "canary/old", Platform: true, Canary: true}, |
| + registry.Route{Host: "rc.kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000", Channel: "canary/old"}, |
| + ) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("old.kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("retired canary's platform route still present: %+v %v", rt, err) |
| + } |
| + mustGet(t, reg, "rc.kass.training") // the custom follower survives |
| +} |
| + |
| +func TestReconcileNeverTouchesCustomDomains(t *testing.T) { |
| + // A custom route whose app is dead (nothing ready, nothing shipped): |
| + // reconciliation must leave it exactly alone — and mint nothing, since |
| + // there is no ready upstream to copy. |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "failed", Addr: "127.0.0.1:9000"}) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "kass.training") |
| + if rt, err := reg.Get("kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("minted a platform route with no ready upstream: %+v %v", rt, err) |
| + } |
| +} |
| + |
| +func TestReconcileStampsDNS(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "points.test", Kind: "static", Status: "ready"}, |
| + registry.Route{Host: "elsewhere.test", Kind: "static", Status: "ready"}, |
| + registry.Route{Host: "broken.test", Kind: "static", Status: "ready"}, |
| + ) |
| + c := cfg(t) |
| + c.Resolve = func(host string) ([]string, error) { |
| + switch host { |
| + case "points.test": |
| + return []string{"198.51.100.9", "203.0.113.7"}, nil // edge IP among others |
| + case "elsewhere.test": |
| + return []string{"198.51.100.9"}, nil |
| + default: |
| + return nil, errors.New("NXDOMAIN or worse") |
| + } |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := c.Now().Unix() |
| + for host, want := range map[string]string{ |
| + "points.test": "pointing", |
| + "elsewhere.test": "elsewhere", |
| + "broken.test": "", // resolver failure is UNKNOWN, never a guess |
| + } { |
| + got := mustGet(t, reg, host) |
| + if got.DNSStatus != want { |
| + t.Errorf("%s dns_status = %q, want %q", host, got.DNSStatus, want) |
| + } |
| + if got.DNSCheckedAt != now { |
| + t.Errorf("%s dns_checked_at = %d, want %d", host, got.DNSCheckedAt, now) |
| + } |
| + } |
| +} |
| + |
| +// writeCertBundle writes what autocert's cachePut writes under DirCache key |
| +// <host>: the private key PEM block first, then the CERTIFICATE chain — so |
| +// the parser is exercised on skipping the key block. |
| +func writeCertBundle(t *testing.T, dir, host string, notAfter time.Time) { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: big.NewInt(1), |
| + Subject: pkix.Name{CommonName: host}, |
| + DNSNames: []string{host}, |
| + NotBefore: notAfter.Add(-24 * time.Hour), |
| + NotAfter: notAfter, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + kb, err := x509.MarshalECPrivateKey(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var buf bytes.Buffer |
| + if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, host), buf.Bytes(), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestReconcileStampsCertExpiry(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + registry.Route{Host: "junk.test", Kind: "static", Status: "ready"}, |
| + ) |
| + acmeDir := t.TempDir() |
| + notAfter := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) |
| + writeCertBundle(t, acmeDir, "kass.training", notAfter) |
| + // An unparseable cache entry is unknown, same as absent — never a crash, |
| + // never a guess. |
| + if err := os.WriteFile(filepath.Join(acmeDir, "junk.test"), []byte("not pem at all"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := cfg(t) |
| + c.AcmeDir = acmeDir |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "kass.training"); got.CertExpiresAt != notAfter.Unix() { |
| + t.Errorf("kass.training cert_expires_at = %d, want %d", got.CertExpiresAt, notAfter.Unix()) |
| + } |
| + if got := mustGet(t, reg, "junk.test"); got.CertExpiresAt != 0 { |
| + t.Errorf("junk.test cert_expires_at = %d, want 0 (unparseable = unknown)", got.CertExpiresAt) |
| + } |
| + // The freshly minted platform route has no cached cert yet: 0. |
| + if got := mustGet(t, reg, "kass.bab.oncarlos.test"); got.CertExpiresAt != 0 { |
| + t.Errorf("minted route cert_expires_at = %d, want 0 (absent = unknown)", got.CertExpiresAt) |
| + } |
| +} |
| + |
| +func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") // present but must not be read |
| + before, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := cfg(t) |
| + c.AppsDomain = "" |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Errorf("unconfigured Reconcile changed the registry:\nbefore %+v\nafter %+v", before, after) |
| + } |
| + if id, _ := reg.AccountID("paul"); id != 0 { |
| + t.Error("unconfigured Reconcile read a manifest and minted an account") |
| + } |
| +} |
| + |
| +func TestReconcileSkipsMalformedManifest(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + if _, err := s.Put(ctx, "apps/bad/account.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatalf("one bad manifest must not fail the pass: %v", err) |
| + } |
| + if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| + t.Errorf("good manifest not processed after the bad one: %+v", src) |
| + } |
| +} |