carlos / platform Public

edge asset cache task 7: ReconcileAliases reports whether the pass was complete

ReconcileAliasesReport fills an AliasReport. Every log-and-continue point
(label count, alias, instance alias, catch-all, wildcard and path writes
and deletes), a listed claim that yielded no claim, and a nil
deploymentRoutes make the pass incomplete while it still returns nil.
ReconcileAliases keeps its signature and passes no report.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5901df12a334abb6785c136c68b45da7c5521bd0 parent 44bd176
4 files changed, +590 −15
  • internal/domains/aliases.go +68 −4
  • internal/domains/aliases_complete_test.go +487 −0
  • internal/domains/claimcache.go +23 −10
  • internal/domains/instancecatchall_test.go +12 −1
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index 599a705e9..6df3710fe 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -116,7 +116,11 @@ func sayInstanceCatchallOnce(host, reason string) {
// instance it could name. If it fails, the catch-all's existing rows are
// kept as they are for this pass rather than dropped: a listing blip must
// not take every team's name down.
-func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []registry.Route, byHost map[string]registry.Route, existing []registry.RouteAlias, deploymentRoutes map[string]bool, desired map[string]bool, labelHosts func(account, app string) (map[string]int, error), reg *registry.Registry, now func() time.Time) {
+//
+// The result is true when the pass was INCOMPLETE here: the label count
+// failed (the names were kept, not decided), or an alias write failed. The
+// caller folds it into the pass's report.
+func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []registry.Route, byHost map[string]registry.Route, existing []registry.RouteAlias, deploymentRoutes map[string]bool, desired map[string]bool, labelHosts func(account, app string) (map[string]int, error), reg *registry.Registry, now func() time.Time) (incomplete bool) {
claimed := make(map[string]Claim, len(claims))
for _, c := range claims {
claimed[c.Host] = c
@@ -162,6 +166,7 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
counts, err := labelHosts(c.Account, c.App)
if err != nil {
log.Printf("domains: instance catch-all %s: list the app's instances: %v — keeping its names as they are this pass", c.Host, err)
+ incomplete = true
for _, a := range existing {
if a.Account == c.Account && a.App == c.App && strings.HasSuffix(a.Alias, "."+parent) &&
!strings.Contains(strings.TrimSuffix(a.Alias, "."+parent), ".") {
@@ -202,9 +207,11 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
CreatedAt: now().Unix(),
}); err != nil {
log.Printf("domains: upsert instance alias %s: %v", name, err)
+ incomplete = true
}
}
}
+ return incomplete
}
// ReconcileAliases derives route_aliases, wildcard_domains and route_paths
@@ -230,10 +237,55 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
// call against one plus a GET per claim in the deployment, every few
// seconds, on every box. nil is legal and means today's uncached fold.
func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool, cache *ClaimCache) error {
- claims, listed, err := cache.listWithHosts(ctx, s)
+ return ReconcileAliasesReport(ctx, s, reg, now, deploymentRoutes, cache, nil)
+}
+
+// AliasReport is what a ReconcileAliasesReport pass says about itself.
+//
+// Complete is true only when every input the pass decided from was read and
+// every write it made landed. The box advances its domain confirmed time
+// (the bound on how stale a path or alias decision may be when the edge asset
+// cache acts on it) only after a complete pass, so a pass that logged a
+// failure and carried on must say so here, or a stale decision would keep
+// being published as current.
+//
+// The pass can be incomplete and still return nil: the callers' error
+// behaviour is unchanged (a failed claim listing is still an error, and an
+// errored pass is never complete).
+type AliasReport struct {
+ Complete bool
+}
+
+// ReconcileAliasesReport is ReconcileAliases plus a completeness report. A
+// nil rep is legal (ReconcileAliases passes one). rep.Complete is false on
+// every error return, and is otherwise written once, at the end, true only if
+// none of these happened:
+//
+// - the label count for an instance catch-all failed
+// - an alias, instance alias, catch-all alias, wildcard or path upsert failed
+// - an alias, wildcard or path delete failed
+// - a listed claim yielded no claim (unreadable, unparseable, newer schema)
+// - deploymentRoutes is nil: its read failed, so the "another box serves
+// this as a route" guard was off for the pass
+//
+// Refusals that are decisions on bytes the pass did read (a reserved suffix, a
+// tenancy mismatch, a cap) are not incomplete: the same input refuses the same
+// way every pass.
+func ReconcileAliasesReport(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool, cache *ClaimCache, rep *AliasReport) error {
+ if rep != nil {
+ rep.Complete = false // any early error return leaves it so
+ }
+ // incomplete is set at every point that logs a failure and carries on;
+ // the report is written once, at the end.
+ incomplete := deploymentRoutes == nil
+ claims, listed, dropped, err := cache.listWithHosts(ctx, s)
if err != nil {
return err
}
+ if dropped > 0 {
+ log.Printf("domains: %d claim(s) listed but unreadable this pass — the pass is incomplete", dropped)
+ incomplete = true
+ }
routes, err := reg.List()
if err != nil {
return err
@@ -308,6 +360,7 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
CreatedAt: now().Unix(),
}); err != nil {
log.Printf("domains: upsert alias %s: %v", c.Host, err)
+ incomplete = true
}
}
}
@@ -529,16 +582,20 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
CreatedAt: now().Unix(),
}); err != nil {
log.Printf("domains: upsert catch-all %s: %v", star, err)
+ incomplete = true
}
}
}
}
- deriveInstanceAliases(claims, listed, routes, byHost, existing, deploymentRoutes, desired,
- func(account, app string) (map[string]int, error) { return InstanceLabelCounts(ctx, s, account, app) }, reg, now)
+ if deriveInstanceAliases(claims, listed, routes, byHost, existing, deploymentRoutes, desired,
+ func(account, app string) (map[string]int, error) { return InstanceLabelCounts(ctx, s, account, app) }, reg, now) {
+ incomplete = true
+ }
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)
+ incomplete = true
}
}
}
@@ -559,6 +616,7 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
if !cur {
if err := reg.UpsertWildcardDomain(w); err != nil {
log.Printf("domains: upsert wildcard %s: %v", h, err)
+ incomplete = true
}
}
}
@@ -566,6 +624,7 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
if _, ok := desiredWild[e.Host]; !ok {
if err := reg.DeleteWildcardDomain(e.Host); err != nil {
log.Printf("domains: remove wildcard %s: %v", e.Host, err)
+ incomplete = true
}
}
}
@@ -582,14 +641,19 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
for _, p := range desiredPaths {
if err := reg.UpsertPath(p); err != nil {
log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err)
+ incomplete = true
}
}
for _, p := range existingPaths {
if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok {
if err := reg.DeletePath(p.Host, p.Prefix); err != nil {
log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err)
+ incomplete = true
}
}
}
+ if rep != nil {
+ rep.Complete = !incomplete
+ }
return nil
}
diff --git a/internal/domains/aliases_complete_test.go b/internal/domains/aliases_complete_test.go
new file mode 100644
index 000000000..166a9506e
--- /dev/null
+++ b/internal/domains/aliases_complete_test.go
@@ -0,0 +1,487 @@
+package domains
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The box advances its domain confirmed time only after a ReconcileAliases
+// pass that reported Complete. These tests pin every way the pass can log a
+// failure and carry on: each one must flip Complete to false while the pass
+// still returns nil (existing callers keep today's error behaviour). Every
+// incomplete case has a control run on the identical fixture with nothing
+// injected, which must report Complete, so a flip is the injection's doing
+// and not the fixture's.
+
+// aliasEnv is one test registry at a known path, so a second handle can be
+// opened on the same file, plus the bucket the pass reads claims from.
+type aliasEnv struct {
+ t *testing.T
+ root string
+ store *release.DirStore
+ reg *registry.Registry
+ path string
+}
+
+func newAliasEnv(t *testing.T) *aliasEnv {
+ t.Helper()
+ root := t.TempDir()
+ path := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := registry.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &aliasEnv{t: t, root: root, store: release.NewDirStore(root), reg: reg, path: path}
+}
+
+// failWhen makes one registry write fail and nothing else. It opens the
+// registry's database file a second time through database/sql (the "sqlite"
+// driver internal/registry registers) and creates a BEFORE trigger on table
+// whose body raises when cond holds (cond refers to NEW or OLD). Reads still
+// succeed, so ListAliases and the other listings do not fail first and the
+// pass runs to the write it is meant to fail at. event is INSERT, UPDATE or
+// DELETE.
+func (e *aliasEnv) failWhen(event, table, cond string) {
+ e.t.Helper()
+ db, err := sql.Open("sqlite", e.path)
+ if err != nil {
+ e.t.Fatal(err)
+ }
+ defer db.Close()
+ for _, q := range []string{
+ "PRAGMA busy_timeout=5000",
+ fmt.Sprintf("CREATE TRIGGER inject_%s_%s BEFORE %s ON %s WHEN %s BEGIN SELECT RAISE(FAIL, 'injected'); END",
+ strings.ToLower(event), table, event, table, cond),
+ } {
+ if _, err := db.Exec(q); err != nil {
+ e.t.Fatalf("%s: %v", q, err)
+ }
+ }
+}
+
+// failUpsert fails the INSERT a registry upsert issues, and its UPDATE arm.
+func (e *aliasEnv) failUpsert(table, cond string) {
+ e.t.Helper()
+ e.failWhen("INSERT", table, "NEW."+cond)
+ e.failWhen("UPDATE", table, "NEW."+cond)
+}
+
+func (e *aliasEnv) failDelete(table, cond string) {
+ e.t.Helper()
+ e.failWhen("DELETE", table, "OLD."+cond)
+}
+
+// pass runs ReconcileAliasesReport and returns the report with the error.
+// The report starts at Complete=true so a pass that never writes it, or
+// writes it wrongly, cannot pass by luck of the zero value.
+func (e *aliasEnv) pass(store release.Store, deploymentRoutes map[string]bool, cache *ClaimCache) (AliasReport, error) {
+ e.t.Helper()
+ rep := AliasReport{Complete: true}
+ err := ReconcileAliasesReport(context.Background(), store, e.reg, aliasNow(), deploymentRoutes, cache, &rep)
+ return rep, err
+}
+
+func (e *aliasEnv) mustPass() {
+ e.t.Helper()
+ if rep, err := e.pass(e.store, map[string]bool{}, nil); err != nil || !rep.Complete {
+ e.t.Fatalf("setup pass: complete=%v err=%v", rep.Complete, err)
+ }
+}
+
+// known is the deployment-routes input of a pass that read it: non-nil, and
+// empty is a real answer.
+func known() map[string]bool { return map[string]bool{} }
+
+// TestAliasReportCompleteOnACleanPass is the baseline: a pass over a real
+// claim, with the deployment's routes known and every write landing, is
+// complete. It also checks the wrapper: ReconcileAliases, with no report to
+// fill, still returns nil.
+func TestAliasReportCompleteOnACleanPass(t *testing.T) {
+ e := newAliasEnv(t)
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"})
+ rep, err := e.pass(e.store, known(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !rep.Complete {
+ t.Fatal("a clean pass reported incomplete")
+ }
+ if _, ok := aliasMap(t, e.reg)["www.example.com"]; !ok {
+ t.Fatalf("the alias was not written: %+v", aliasMap(t, e.reg))
+ }
+ if err := ReconcileAliases(context.Background(), e.store, e.reg, aliasNow(), known(), nil); err != nil {
+ t.Fatalf("ReconcileAliases (no report): %v", err)
+ }
+}
+
+// TestAliasReportCompleteOnAnEmptyBucket: no claims at all is a complete pass
+// (a box with nothing claimed must still be able to confirm).
+func TestAliasReportCompleteOnAnEmptyBucket(t *testing.T) {
+ e := newAliasEnv(t)
+ rep, err := e.pass(e.store, known(), nil)
+ if err != nil || !rep.Complete {
+ t.Fatalf("empty bucket: complete=%v err=%v", rep.Complete, err)
+ }
+}
+
+// TestAliasReportIncompleteOnAFailedWrite drives each registry write the pass
+// logs and survives. The pass still returns nil; only the report changes.
+func TestAliasReportIncompleteOnAFailedWrite(t *testing.T) {
+ const wildHost = "wild.example.com"
+ cases := []struct {
+ name string
+ setup func(e *aliasEnv)
+ // prepare runs after setup on a registry that is still healthy, to
+ // leave a row for the pass to delete; it may change the bucket.
+ prepare func(e *aliasEnv)
+ inject func(e *aliasEnv)
+ // landed reports whether the write the injection targets took effect.
+ landed func(t *testing.T, e *aliasEnv) bool
+ }{
+ {
+ name: "alias upsert",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"})
+ },
+ inject: func(e *aliasEnv) { e.failUpsert("route_aliases", "alias = 'www.example.com'") },
+ landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["www.example.com"]; return ok },
+ },
+ {
+ name: "instance catch-all alias upsert",
+ setup: func(e *aliasEnv) {
+ putClaim(t, e.store, Claim{Host: "example.com", Account: "abc", App: "shop-site"})
+ putClaim(t, e.store, Claim{Host: "*.example.com", Account: "abc", App: "shop", Instances: true})
+ addInstance(t, e.reg, "acme.abc.carlos.example.net", "acme", "abc", "shop")
+ },
+ inject: func(e *aliasEnv) { e.failUpsert("route_aliases", "alias = 'acme.example.com'") },
+ landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["acme.example.com"]; return ok },
+ },
+ {
+ name: "wildcard catch-all alias upsert",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: wildHost, Account: "abc", App: "shop", Route: "shop.example.com", Wildcard: true, Catchall: true})
+ },
+ inject: func(e *aliasEnv) { e.failUpsert("route_aliases", "alias = '*."+wildHost+"'") },
+ landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["*."+wildHost]; return ok },
+ },
+ {
+ name: "alias delete",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"})
+ },
+ prepare: func(e *aliasEnv) {
+ e.mustPass()
+ deleteClaim(t, e.store, "www.example.com")
+ },
+ inject: func(e *aliasEnv) { e.failDelete("route_aliases", "alias = 'www.example.com'") },
+ // The row is meant to be gone; "landed" is the delete happening.
+ landed: func(t *testing.T, e *aliasEnv) bool { _, ok := aliasMap(t, e.reg)["www.example.com"]; return !ok },
+ },
+ {
+ name: "wildcard upsert",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: wildHost, Account: "abc", App: "shop", Route: "shop.example.com", Wildcard: true})
+ },
+ inject: func(e *aliasEnv) { e.failUpsert("wildcard_domains", "host = '"+wildHost+"'") },
+ landed: func(t *testing.T, e *aliasEnv) bool {
+ rows, err := e.reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(rows) == 1
+ },
+ },
+ {
+ name: "wildcard delete",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: wildHost, Account: "abc", App: "shop", Route: "shop.example.com", Wildcard: true})
+ },
+ prepare: func(e *aliasEnv) {
+ e.mustPass()
+ deleteClaim(t, e.store, wildHost)
+ },
+ inject: func(e *aliasEnv) { e.failDelete("wildcard_domains", "host = '"+wildHost+"'") },
+ landed: func(t *testing.T, e *aliasEnv) bool {
+ rows, err := e.reg.ListWildcardDomains()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(rows) == 0
+ },
+ },
+ {
+ name: "path upsert",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ addRoute(t, e.reg, "api.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: "shop.example.com", Account: "abc", App: "shop", Route: "shop.example.com",
+ Paths: map[string]string{"api": "api.example.com"}})
+ },
+ inject: func(e *aliasEnv) { e.failUpsert("route_paths", "host = 'shop.example.com' AND NEW.prefix = 'api'") },
+ landed: func(t *testing.T, e *aliasEnv) bool { return pathsByPrefix(t, e.reg, "shop.example.com")["api"] != "" },
+ },
+ {
+ name: "path delete",
+ setup: func(e *aliasEnv) {
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ addRoute(t, e.reg, "api.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: "shop.example.com", Account: "abc", App: "shop", Route: "shop.example.com",
+ Paths: map[string]string{"api": "api.example.com"}})
+ },
+ prepare: func(e *aliasEnv) {
+ e.mustPass()
+ // putClaim is create-only: drop the old body, write one with no paths.
+ deleteClaim(t, e.store, "shop.example.com")
+ putClaim(t, e.store, Claim{Host: "shop.example.com", Account: "abc", App: "shop", Route: "shop.example.com"})
+ },
+ inject: func(e *aliasEnv) { e.failDelete("route_paths", "host = 'shop.example.com' AND OLD.prefix = 'api'") },
+ landed: func(t *testing.T, e *aliasEnv) bool { return pathsByPrefix(t, e.reg, "shop.example.com")["api"] == "" },
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ // Control: identical fixture, nothing injected.
+ ctl := newAliasEnv(t)
+ c.setup(ctl)
+ if c.prepare != nil {
+ c.prepare(ctl)
+ }
+ rep, err := ctl.pass(ctl.store, known(), nil)
+ if err != nil {
+ t.Fatalf("control: %v", err)
+ }
+ if !rep.Complete {
+ t.Fatal("control pass (no injection) reported incomplete: the fixture, not the write, is the problem")
+ }
+ if !c.landed(t, ctl) {
+ t.Fatal("control pass did not make the write this case targets: the fixture does not reach it")
+ }
+
+ // Injected: the one named write fails.
+ e := newAliasEnv(t)
+ c.setup(e)
+ if c.prepare != nil {
+ c.prepare(e)
+ }
+ c.inject(e)
+ rep, err = e.pass(e.store, known(), nil)
+ if err != nil {
+ t.Fatalf("a failed write must not fail the pass: %v", err)
+ }
+ if c.landed(t, e) {
+ t.Fatal("the injected write landed anyway: the trigger did not fire")
+ }
+ if rep.Complete {
+ t.Fatal("the pass hid a failed registry write as complete")
+ }
+ })
+ }
+}
+
+// TestAliasReportIncompleteWhenTheLabelCountFails is the instance catch-all's
+// listing blip: the names are kept for the pass, and the pass says it did not
+// see the app's instances.
+func TestAliasReportIncompleteWhenTheLabelCountFails(t *testing.T) {
+ e := newAliasEnv(t)
+ putClaim(t, e.store, Claim{Host: "example.com", Account: "abc", App: "shop-site"})
+ putClaim(t, e.store, Claim{Host: "*.example.com", Account: "abc", App: "shop", Instances: true})
+ addInstance(t, e.reg, "acme.abc.carlos.example.net", "acme", "abc", "shop")
+ if rep, err := e.pass(e.store, known(), nil); err != nil || !rep.Complete {
+ t.Fatalf("control: complete=%v err=%v", rep.Complete, err)
+ }
+ failing := listFailStore{e.store, instanceRecordsPrefix("abc", "shop")}
+ rep, err := e.pass(failing, known(), nil)
+ if err != nil {
+ t.Fatalf("a listing blip must not fail the pass: %v", err)
+ }
+ if rep.Complete {
+ t.Fatal("the pass hid a failed instance listing as complete")
+ }
+ if _, ok := aliasMap(t, e.reg)["acme.example.com"]; !ok {
+ t.Fatal("the name was dropped on a listing failure")
+ }
+}
+
+// getFailStore fails GET of one key with a non-NotExist error, the way a
+// transient bucket error reads.
+type getFailStore struct {
+ *release.DirStore
+ key string
+}
+
+func (g getFailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == g.key {
+ return nil, "", errors.New("synthetic read failure")
+ }
+ return g.DirStore.Get(ctx, key)
+}
+
+// getCountStore counts GETs of one key.
+type getCountStore struct {
+ *release.DirStore
+ key string
+ n *atomic.Int32
+}
+
+func (g getCountStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == g.key {
+ g.n.Add(1)
+ }
+ return g.DirStore.Get(ctx, key)
+}
+
+// TestAliasReportIncompleteWhenAClaimBodyIsUnreadable: a claim the listing
+// shows but whose body yields no claim cannot have been decided on, whatever
+// the reason, and so the pass is incomplete. Both folds (plain and cached)
+// and a cache entry carried forward from an earlier pass are covered.
+func TestAliasReportIncompleteWhenAClaimBodyIsUnreadable(t *testing.T) {
+ const host = "www.example.com"
+ key := ClaimKey(host)
+ bodies := []struct {
+ name string
+ put func(t *testing.T, e *aliasEnv)
+ }{
+ {"unparseable", func(t *testing.T, e *aliasEnv) {
+ if _, err := e.store.Put(context.Background(), key, []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ {"schema newer than this binary", func(t *testing.T, e *aliasEnv) {
+ if _, err := e.store.Put(context.Background(), key, []byte(`{"schema":99,"host":"`+host+`"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ }},
+ }
+ for _, b := range bodies {
+ for _, cached := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/cache=%v", b.name, cached), func(t *testing.T) {
+ e := newAliasEnv(t)
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ // A readable neighbour proves the pass reads claims at all.
+ putClaim(t, e.store, Claim{Host: "other.example.com", Account: "abc", App: "shop", Route: "shop.example.com"})
+ b.put(t, e)
+ var cache *ClaimCache
+ if cached {
+ cache = &ClaimCache{}
+ }
+ rep, err := e.pass(e.store, known(), cache)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Complete {
+ t.Fatal("the pass hid an unreadable claim body as complete")
+ }
+ if _, ok := aliasMap(t, e.reg)["other.example.com"]; !ok {
+ t.Fatal("the readable claim was not derived")
+ }
+ })
+ }
+ }
+
+ t.Run("GET fails", func(t *testing.T) {
+ for _, cached := range []bool{false, true} {
+ e := newAliasEnv(t)
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: host, Account: "abc", App: "shop", Route: "shop.example.com"})
+ var cache *ClaimCache
+ if cached {
+ cache = &ClaimCache{}
+ }
+ if rep, err := e.pass(e.store, known(), cache); err != nil || !rep.Complete {
+ t.Fatalf("cache=%v control: complete=%v err=%v", cached, rep.Complete, err)
+ }
+ // A fresh cache, so the read really happens on the failing pass.
+ if cached {
+ cache = &ClaimCache{}
+ }
+ rep, err := e.pass(getFailStore{e.store, key}, known(), cache)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Complete {
+ t.Fatalf("cache=%v: the pass hid a failed claim read as complete", cached)
+ }
+ }
+ })
+
+ t.Run("a cached skip is still counted when carried forward", func(t *testing.T) {
+ e := newAliasEnv(t)
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ if _, err := e.store.Put(context.Background(), key, []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ // Old enough that the cache admits the entry (modSettle).
+ old := time.Now().Add(-time.Hour)
+ if err := os.Chtimes(filepath.Join(e.root, key), old, old); err != nil {
+ t.Fatal(err)
+ }
+ var n atomic.Int32
+ counting := getCountStore{e.store, key, &n}
+ cache := &ClaimCache{}
+ for pass := 1; pass <= 3; pass++ {
+ rep, err := e.pass(counting, known(), cache)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Complete {
+ t.Fatalf("pass %d hid the undecodable claim as complete", pass)
+ }
+ }
+ if n.Load() != 1 {
+ t.Fatalf("the body was read %d times; the test never reached the carried-forward path", n.Load())
+ }
+ })
+}
+
+// TestAliasReportIncompleteWhenDeploymentRoutesIsNil: a failed
+// edgesfile.DeploymentRoutes read comes back nil and every guard reading it
+// allows. The pass still runs (nil stays permissive), but it cannot vouch.
+func TestAliasReportIncompleteWhenDeploymentRoutesIsNil(t *testing.T) {
+ e := newAliasEnv(t)
+ addRoute(t, e.reg, "shop.example.com", "abc", "shop")
+ putClaim(t, e.store, Claim{Host: "www.example.com", Account: "abc", App: "shop", Route: "shop.example.com"})
+ rep, err := e.pass(e.store, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Complete {
+ t.Fatal("a nil deploymentRoutes (read failed) was reported as complete")
+ }
+ if _, ok := aliasMap(t, e.reg)["www.example.com"]; !ok {
+ t.Fatal("nil deploymentRoutes is permissive: the alias should still derive")
+ }
+ if rep, err = e.pass(e.store, known(), nil); err != nil || !rep.Complete {
+ t.Fatalf("an empty, non-nil deploymentRoutes is a real answer: complete=%v err=%v", rep.Complete, err)
+ }
+}
+
+// TestAliasReportIncompleteWhenThePassErrors: a pass that returns an error
+// reports incomplete, even into a report that was left saying otherwise.
+func TestAliasReportIncompleteWhenThePassErrors(t *testing.T) {
+ e := newAliasEnv(t)
+ failing := listFailStore{e.store, ClaimsPrefix}
+ rep, err := e.pass(failing, known(), nil)
+ if err == nil {
+ t.Fatal("a claim listing failure must still be an error")
+ }
+ if rep.Complete {
+ t.Fatal("an errored pass reported complete")
+ }
+}
diff --git a/internal/domains/claimcache.go b/internal/domains/claimcache.go
index f3091c077..5b0d026d7 100644
--- a/internal/domains/claimcache.go
+++ b/internal/domains/claimcache.go
@@ -78,7 +78,7 @@ const modSettle = 2 * time.Second
// Claim is a value type with no reference fields, so cached entries cannot
// be mutated through it.
func (c *ClaimCache) List(ctx context.Context, s release.Store) ([]Claim, error) {
- claims, _, err := c.listWithHosts(ctx, s)
+ claims, _, _, err := c.listWithHosts(ctx, s)
return claims, err
}
@@ -93,28 +93,37 @@ func claimHostFromKey(key string) string {
// listing: a body whose GET failed (a transient store error) is missing from
// the claims, and deciding from those would let a default take the name for
// that pass — failing open on exactly the blip it should ride out.
-func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Claim, map[string]bool, error) {
+//
+// dropped counts the listed keys that produced no claim this pass, for any
+// reason: the GET failed, the object had gone by the time it was read, the
+// body was unparseable, or its schema is newer than this binary. A claim the
+// pass could not read is a claim it did not decide on, so a caller that
+// reports completeness (ReconcileAliasesReport) must treat dropped > 0 as an
+// incomplete pass. It counts carried-forward entries too: a cached skip is
+// still a claim nobody read.
+func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) (claims []Claim, hosts map[string]bool, dropped int, err error) {
lister, ok := s.(release.ModLister)
if c == nil || !ok {
keys, err := s.List(ctx, ClaimsPrefix)
if err != nil {
- return nil, nil, fmt.Errorf("domains: list claims: %w", err)
+ return nil, nil, 0, fmt.Errorf("domains: list claims: %w", err)
}
- hosts := make(map[string]bool, len(keys))
- var claims []Claim
+ hosts = make(map[string]bool, len(keys))
for _, key := range keys {
hosts[claimHostFromKey(key)] = true
if cl, ok, _ := readClaim(ctx, s, key); ok {
claims = append(claims, cl)
+ } else {
+ dropped++
}
}
- return claims, hosts, nil
+ return claims, hosts, dropped, nil
}
listed, err := lister.ListMod(ctx, ClaimsPrefix)
if err != nil {
- return nil, nil, err
+ return nil, nil, 0, err
}
- hosts := make(map[string]bool, len(listed))
+ hosts = make(map[string]bool, len(listed))
for _, km := range listed {
hosts[claimHostFromKey(km.Key)] = true
}
@@ -122,7 +131,7 @@ func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Clai
c.mu.Lock()
defer c.mu.Unlock()
next := make(map[string]cachedClaim, len(listed))
- claims := make([]Claim, 0, len(listed))
+ claims = make([]Claim, 0, len(listed))
for _, km := range listed {
if hit, ok := c.entries[km.Key]; ok && hit.mod.Equal(km.Mod) {
// Carried forward WITHOUT re-checking modSettle: the bound
@@ -131,12 +140,16 @@ func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Clai
next[km.Key] = hit
if !hit.skipped {
claims = append(claims, hit.claim)
+ } else {
+ dropped++
}
continue
}
claim, ok, firm := readClaim(ctx, s, km.Key)
if ok {
claims = append(claims, claim)
+ } else {
+ dropped++
}
if !firm {
// The read itself failed. Remembering that would hide a good
@@ -154,7 +167,7 @@ func (c *ClaimCache) listWithHosts(ctx context.Context, s release.Store) ([]Clai
// IS the eviction, so a detached claim leaves the cache on the same pass
// it leaves the listing, with no separate invalidation to forget.
c.entries = next
- return claims, hosts, nil
+ return claims, hosts, dropped, nil
}
// Reset drops everything. Nothing in this package needs it; it exists so a
diff --git a/internal/domains/instancecatchall_test.go b/internal/domains/instancecatchall_test.go
index b78988c1f..eff0f61e2 100644
--- a/internal/domains/instancecatchall_test.go
+++ b/internal/domains/instancecatchall_test.go
@@ -513,12 +513,23 @@ func TestInstanceCatchallKeepsNamesWhenTheListingFails(t *testing.T) {
t.Fatal("precondition: the name derives")
}
failing := listFailStore{dir, instanceRecordsPrefix("bad", "dev-managed-jelly")}
- if err := ReconcileAliases(context.Background(), failing, reg, aliasNow(), nil, nil); err != nil {
+ // Complete starts true so the assertion below is the pass's doing.
+ rep := AliasReport{Complete: true}
+ if err := ReconcileAliasesReport(context.Background(), failing, reg, aliasNow(), map[string]bool{}, nil, &rep); err != nil {
t.Fatal(err)
}
if a, ok := aliasMap(t, reg)["acme.letsjelly.dev"]; !ok || a.Host != "acme.bad.carlos.exciting.io" {
t.Fatalf("the name was dropped on a listing failure: %+v", aliasMap(t, reg))
}
+ // The pass is still nil-error, but it did not see the app's instances,
+ // so the box must not advance its domain confirmed time on it.
+ if rep.Complete {
+ t.Fatal("a pass that could not list the app's instances reported complete")
+ }
+ // ReconcileAliases keeps its signature and its nil result.
+ if err := ReconcileAliases(context.Background(), failing, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
}
func TestInstanceLabelCounts(t *testing.T) {