carlos / platform Public

edge: holds suppress their target's stale publications and the fold decides the rest; one decision and one table per request; local holds in their own snapshot

Self-serve moves plan 3 Task 2.

- rank.go (new): candidates (ready, holding, certificate-only; local
  and remote), decisions and rank: expired remote holds dropped, classes
  OR'd, a hold suppresses only the unstamped lower-pair readies of its
  own target, a surviving local ready wins, the remote survivors go
  through foldEntry (the fold foldHomes uses, now one function), and
  with no ready survivor the holds' sorted fold answers.
- homemap.go: edgesFileView reads offline_moves and carries fetchedAt;
  buildCandidates runs foldHomes' admissions; Home, ExactHome, Owner
  and KnownHost(host, now) answer from the exact and request
  resolutions; Hosts adds platform holding winners; HoldingHosts is
  new; the wildcard wake also watches the platform real holds; the map
  has a clock.
- holding.go: the holding page (503, Retry-After 30, no-store,
  noindex, the four sentences, the script) and the local holds:
  SetHoldView/RefreshHolds rebuild under holdsMu from the latest view
  and the rows the latest Reload listed, explicit publishable aliases
  included; certNames.
- edge.go: WithBoxID; Edge.resolve and selectUpstream (one decision
  and its table per request, the kept fallback exceptions); ServeHTTP's
  request-path table; Allowed admits local holds; currentHosts keeps
  held platform rows; the :80 fallback redirects any decided name;
  Reload rebuilds the holds before its hook.
- certwarm.go: warmSet considers certNames after ServedHosts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8fcd14cbd080cc8e8ab0a7c4d4f7f8bbc163abe6 parent 5d76470
11 files changed, +2304 −143
  • internal/edge/certwarm.go +6 −0
  • internal/edge/decision_test.go +631 −0
  • internal/edge/edge.go +218 −88
  • internal/edge/holding.go +316 −0
  • internal/edge/holding_test.go +335 −0
  • internal/edge/homemap.go +270 −44
  • internal/edge/homemap_test.go +8 −8
  • internal/edge/path_test.go +2 −2
  • internal/edge/rank.go +246 −0
  • internal/edge/rank_test.go +271 −0
  • internal/edge/wildcard_parents_test.go +1 −1
diff --git a/internal/edge/certwarm.go b/internal/edge/certwarm.go
index 3e4700a74..16d56ff31 100644
--- a/internal/edge/certwarm.go
+++ b/internal/edge/certwarm.go
@@ -373,6 +373,12 @@ func (e *Edge) warmSet(appsDomain string, parents func() []string) []string {
for _, h := range e.homes.Load().ServedHosts() {
consider(h)
}
+ // The names this box answers with the holding page (self-serve moves
+ // plan 3): its own holds and the remote live holds' winners, under the
+ // same exclusions. Never served evidence; only a certificate to keep.
+ for _, h := range e.certNames() {
+ consider(h)
+ }
sort.Strings(out)
return out
}
diff --git a/internal/edge/decision_test.go b/internal/edge/decision_test.go
new file mode 100644
index 000000000..22959460a
--- /dev/null
+++ b/internal/edge/decision_test.go
@@ -0,0 +1,631 @@
+package edge
+
+// The local edge's half of the ranking (self-serve moves plan 3 Task 2):
+// local ready candidates are the upstream table, local holds are their own
+// snapshot built from the box's view and registry, and one decision (with
+// the table it was resolved against) carries a request from resolution to
+// dispatch.
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// scene is one edge (this box, labelled self) over a real registry, with a
+// home map over a store that holds a mesh key and the other boxes' files,
+// on a clock the test moves.
+type scene struct {
+ t *testing.T
+ e *Edge
+ reg *registry.Registry
+ m *homeMap
+ key []byte
+ now time.Time
+ rev uint64
+ mu sync.Mutex
+ held map[string]HoldState
+}
+
+func newScene(t *testing.T, self string) *scene {
+ t.Helper()
+ sc := &scene{t: t, reg: openReg(t), now: rankNow, held: map[string]HoldState{}}
+ s := meshTestStore(t)
+ if err := mintMeshKey(context.Background(), s); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(sc.reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ m := newHomeMap(s, self)
+ m.now = func() time.Time { return sc.now }
+ refreshHomeMap(t, m)
+ e.homes.Store(m)
+ sc.e, sc.m = e, m
+ sc.key, _ = m.meshKeys()
+ return sc
+}
+
+// backendAddr is an upstream answering body.
+func backendAddr(t *testing.T, body string) string {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) }))
+ t.Cleanup(ts.Close)
+ return ts.Listener.Addr().String()
+}
+
+func (sc *scene) reload() {
+ sc.t.Helper()
+ if err := sc.e.Reload(); err != nil {
+ sc.t.Fatal(err)
+ }
+}
+
+// route upserts a ready service row answering body and reloads.
+func (sc *scene) route(host, body, account, app string, mod ...func(*registry.Route)) {
+ sc.t.Helper()
+ rt := registry.Route{Host: host, Kind: "service", Addr: backendAddr(sc.t, body), Status: "ready", Account: account, App: app}
+ for _, f := range mod {
+ f(&rt)
+ }
+ if err := sc.reg.Upsert(rt); err != nil {
+ sc.t.Fatal(err)
+ }
+ sc.reload()
+}
+
+// heldRow upserts a row held moving under op at (inc, seq), with a backend
+// that must never answer, and reloads.
+func (sc *scene) heldRow(host, account, app, op string, inc, seq uint64) {
+ sc.t.Helper()
+ rt := registry.Route{Host: host, Kind: "service", Addr: backendAddr(sc.t, "the held row's backend"), Status: "moving", HoldOp: op,
+ Account: account, App: app, Incarnation: inc, ServingSeq: seq}
+ if err := sc.reg.Upsert(rt); err != nil {
+ sc.t.Fatal(err)
+ }
+ if got, _ := sc.reg.Get(host); got == nil || got.HoldOp != op {
+ sc.t.Fatalf("fixture: %s = %+v", host, got)
+ }
+ sc.reload()
+}
+
+func (sc *scene) alias(alias, host, dns string, explicit bool) {
+ sc.t.Helper()
+ if err := sc.reg.UpsertAlias(registry.RouteAlias{Alias: alias, Host: host, Account: "bab", App: "shop", DNSStatus: dns, Explicit: explicit}); err != nil {
+ sc.t.Fatal(err)
+ }
+ sc.reload()
+}
+
+// view publishes the box's view: host -> move, as SetHoldView receives it.
+func (sc *scene) view(holds map[string]HoldState) {
+ sc.rev++
+ sc.e.SetHoldView(HoldSnapshot{Rev: sc.rev, Holds: holds})
+}
+
+// publish lands another box's edges file in the home map, fetched now.
+func (sc *scene) publish(box, eip, hosts, offline string) {
+ sc.t.Helper()
+ putJSON(sc.t, sc.m.s, "control/edges/"+box+".json", fmt.Sprintf(`{"v":1,"box":%q,"eip":%q,"hosts":{%s},"offline_moves":[%s]}`, box, eip, hosts, offline))
+ refreshHomeMap(sc.t, sc.m)
+}
+
+func (sc *scene) dropKey() {
+ sc.m.keyMu.Lock()
+ sc.m.keyCurrent, sc.m.keyNext = nil, nil
+ sc.m.keyMu.Unlock()
+}
+
+// via is how a request arrives: an accepted hop, a door caller, or neither.
+type via struct {
+ hop bool
+ door *doorCaller
+}
+
+var ordinary = via{}
+
+func (sc *scene) serve(host, path string, v via) *httptest.ResponseRecorder {
+ sc.t.Helper()
+ r := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil)
+ r.Host = host
+ if v.hop {
+ r.Header.Set(hopHeader, "1")
+ r.Header.Set(hopAuthHeader, signHop(sc.key, host, time.Now()))
+ }
+ if v.door != nil {
+ r = r.WithContext(context.WithValue(r.Context(), doorCallerKey{}, *v.door))
+ }
+ w := httptest.NewRecorder()
+ sc.e.ServeHTTP(w, r)
+ return w
+}
+
+func readyJSON(host, account, app string, inc, seq uint64) string {
+ return fmt.Sprintf(`%q:{"role":"home","account":%q,"app":%q,"incarnation":%d,"serving_seq":%d}`, host, account, app, inc, seq)
+}
+
+func holdJSON(host, move string, platform bool, inc, seq uint64, aliases ...string) string {
+ al := "[]"
+ if len(aliases) > 0 {
+ al = `["` + strings.Join(aliases, `","`) + `"]`
+ }
+ return fmt.Sprintf(`{"host":%q,"platform":%v,"account":"bab","app":"shop","aliases":%s,"move_id":%q,"incarnation":%d,"serving_seq":%d,"offline_at":%d,"estimate_seconds":600,"display_name":"Shop"}`,
+ host, platform, al, move, inc, seq, rankNow.Unix())
+}
+
+func isPage(w *httptest.ResponseRecorder) bool {
+ return w.Code == http.StatusServiceUnavailable && w.Header().Get("Retry-After") == holdingRetryAfter &&
+ strings.Contains(w.Body.String(), "temporarily offline")
+}
+
+func wantPage(t *testing.T, w *httptest.ResponseRecorder, what string) {
+ t.Helper()
+ if !isPage(w) {
+ t.Fatalf("%s = %d %q, want the holding page", what, w.Code, w.Body.String())
+ }
+}
+
+func wantBody(t *testing.T, w *httptest.ResponseRecorder, body, what string) {
+ t.Helper()
+ if w.Code != http.StatusOK || w.Body.String() != body {
+ t.Fatalf("%s = %d %q, want 200 %q", what, w.Code, w.Body.String(), body)
+ }
+}
+
+func wantUnknown(t *testing.T, w *httptest.ResponseRecorder, what string) {
+ t.Helper()
+ if w.Code != http.StatusNotFound || !strings.Contains(w.Body.String(), "unknown host") {
+ t.Fatalf("%s = %d %q, want the unknown-host answer", what, w.Code, w.Body.String())
+ }
+}
+
+func wantStaleHop(t *testing.T, w *httptest.ResponseRecorder, what string) {
+ t.Helper()
+ if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") != "2" || !strings.Contains(w.Body.String(), "not served here") {
+ t.Fatalf("%s = %d %q, want the stale-hop refusal", what, w.Code, w.Body.String())
+ }
+}
+
+var moveR = HoldState{MoveID: "mv_r", DisplayName: "Shop", OfflineAt: rankNow.Unix(), EstimateSeconds: 600}
+
+func TestResolveExactVersusRequest(t *testing.T) {
+ t.Run("an expired exact hold falls to the wildcard; a live one blocks it", func(t *testing.T) {
+ sc := newScene(t, "carol")
+ sc.publish("betty", "2.2.2.2", `"*.example.com":{"role":"home","account":"bab","app":"wild"}`, "")
+ sc.publish("annie", "1.1.1.1", "", holdJSON("shop.example.com", "mv_a", false, 1, 1))
+ at := sc.now
+ sc.now = at.Add(holdingValidity - time.Second)
+ if d := sc.e.resolve("shop.example.com", sc.now); d.kind != decHolding {
+ t.Fatalf("at 4 m 59 s = %v, want the live hold, which blocks the catch-all", d.kind)
+ }
+ if _, _, ok := sc.m.Home("shop.example.com"); ok {
+ t.Fatal("Home must not offer the catch-all past a live exact hold")
+ }
+ sc.now = at.Add(holdingValidity)
+ if d := sc.e.resolve("shop.example.com", sc.now); d.kind != decRemote || d.box != "betty" {
+ t.Fatalf("at 5 m 0 s = %v %q, want betty's catch-all", d.kind, d.box)
+ }
+ if d := sc.m.resolveExact("shop.example.com", sc.now); d.kind != decNone {
+ t.Fatalf("the exact question never answers with a catch-all: %v", d.kind)
+ }
+ })
+ t.Run("a hold that lost to a surviving ready blocks nothing", func(t *testing.T) {
+ sc := newScene(t, "carol")
+ sc.route("*.example.com", "local wildcard", "bab", "wild")
+ sc.publish("annie", "1.1.1.1", "", holdJSON("shop.example.com", "mv_a", false, 1, 1))
+ sc.publish("betty", "2.2.2.2", readyJSON("shop.example.com", "bab", "shop", 1, 2), "")
+ d := sc.e.resolve("shop.example.com", sc.now)
+ if d.kind != decRemote || d.box != "betty" || d.fallback == nil || d.fallback.up.route.App != "wild" {
+ t.Fatalf("decision = %v %q fallback %v, want betty's ready with the local wildcard kept as the fallback", d.kind, d.box, d.fallback)
+ }
+ })
+ t.Run("membership is exact", func(t *testing.T) {
+ sc := newScene(t, "carol")
+ sc.publish("betty", "2.2.2.2", `"*.example.com":{"role":"home"}`, holdJSON("shop.example.com", "mv_b", false, 1, 1, "shop.example.net"))
+ if _, _, ok := sc.m.Home("invented.example.com"); !ok {
+ t.Fatal("test bug: the invented label must be dialable through the catch-all")
+ }
+ if sc.m.KnownHost("invented.example.com", sc.now) || sc.m.KnownHost("*.example.com", sc.now) {
+ t.Fatal("an invented label, and the catch-all key, are never members")
+ }
+ if !sc.m.KnownHost("shop.example.com", sc.now) || !sc.m.KnownHost("shop.example.net", sc.now) {
+ t.Fatal("a live held host and its alias are members")
+ }
+ if sc.m.KnownHost("shop.example.com", sc.now.Add(holdingValidity)) {
+ t.Fatal("an expired hold with nothing behind it is no longer a member")
+ }
+ })
+ t.Run("Allowed rejects the wildcard label and admits the tunnel host", func(t *testing.T) {
+ sc := newScene(t, "carol")
+ sc.route("*.example.com", "local wildcard", "bab", "wild")
+ sc.e.EnableTunnels(NewTunnelHub("tunnel.example.com", map[string]string{"pi-box": "sekrit"}))
+ if sc.e.Allowed("*.example.com") {
+ t.Fatal("the wildcard label is never certable")
+ }
+ if !sc.e.Allowed("tunnel.example.com") {
+ t.Fatal("the tunnel hostname is admitted, as today")
+ }
+ if sc.e.Allowed("invented.example.com") {
+ t.Fatal("a label under a local catch-all is not exact")
+ }
+ })
+ t.Run("an unlabelled edge refuses to dial and keeps its membership view", func(t *testing.T) {
+ t.Setenv("PLATFORM_APPS_DOMAIN", "apps.example.com")
+ sc := newScene(t, "")
+ sc.publish("betty", "2.2.2.2", readyJSON("shop.example.com", "bab", "shop", 0, 0)+
+ `,"acme.bab.apps.example.com":{"role":"home","platform":true}`, holdJSON("cart.example.com", "mv_b", true, 1, 1))
+ for _, h := range []string{"shop.example.com", "acme.bab.apps.example.com"} {
+ if _, _, ok := sc.m.Home(h); ok {
+ t.Fatalf("Home(%s) on an unlabelled edge", h)
+ }
+ if !sc.m.KnownHost(h, sc.now) {
+ t.Fatalf("KnownHost(%s) must still see the admitted publication", h)
+ }
+ if d := sc.e.resolve(h, sc.now); d.kind != decNone {
+ t.Fatalf("resolve(%s) = %v on an unlabelled edge, want decNone", h, d.kind)
+ }
+ }
+ if hs := sc.m.Hosts(); len(hs) != 0 {
+ t.Fatalf("Hosts() = %v, want none on an unlabelled edge", hs)
+ }
+ if d := sc.e.resolve("cart.example.com", sc.now); d.kind != decNone {
+ t.Fatalf("a remote hold on an unlabelled edge = %v, want decNone", d.kind)
+ }
+ if !slices.Contains(sc.m.HoldingHosts(sc.now), "cart.example.com") || !slices.Contains(sc.e.certNames(), "cart.example.com") {
+ t.Fatal("HoldingHosts and certNames keep the membership view")
+ }
+ })
+}
+
+func TestDecisionCarriesTheTable(t *testing.T) {
+ sc := newScene(t, "carol")
+ sc.route("app.example.com", "app v1", "bab", "app")
+ sc.route("front.example.com", "front", "bab", "front")
+ if err := sc.reg.UpsertPath(registry.RoutePath{Host: "front.example.com", Prefix: "bab", Target: "app.example.com", Account: "bab", App: "app"}); err != nil {
+ t.Fatal(err)
+ }
+ sc.reload()
+
+ d := sc.e.resolve("front.example.com", sc.now)
+ if d.kind != decLocal || d.table == nil {
+ t.Fatalf("decision = %v, want decLocal with its table", d.kind)
+ }
+ // A reload between resolution and dispatch moves the prefix target to
+ // another owner and backend.
+ sc.route("app.example.com", "app v2", "bab", "other")
+ if up := selectUpstream(d, "/bab/x"); up.route.App != "app" {
+ t.Fatalf("the prefix target came from the live table (%s), not the decision's", up.route.App)
+ }
+ if up := selectUpstream(d, "/bab"); up.route.App != "app" {
+ t.Fatalf("the login path re-resolved in the live table (%s)", up.route.App)
+ }
+ if up := selectUpstream(d, "/"); up.route.App != "front" {
+ t.Fatalf("no prefix selects the host's own route, got %s", up.route.App)
+ }
+
+ t.Run("one table for the whole request", func(t *testing.T) {
+ sc.route("app.example.com", "app v1", "bab", "app")
+ sc.e.afterResolve = func() { sc.route("app.example.com", "app v2", "bab", "other") }
+ defer func() { sc.e.afterResolve = nil }()
+ wantBody(t, sc.serve("front.example.com", "/bab/x", ordinary), "app v1", "a reload after resolution")
+ })
+ t.Run("the door check runs against the finally selected route", func(t *testing.T) {
+ sc.route("app.example.com", "app v1", "bab", "app")
+ door := &doorCaller{Account: "bab", App: "app"}
+ wantBody(t, sc.serve("front.example.com", "/bab/x", via{door: door}), "app v1", "the door owns the prefix target")
+ wantUnknown(t, sc.serve("front.example.com", "/x", via{door: door}), "the door does not own the front route")
+ })
+}
+
+func TestLocalCandidatesAreTheUpstreamTable(t *testing.T) {
+ sc := newScene(t, "carol")
+ sc.route("shop.example.com", "shop", "bab", "shop", func(r *registry.Route) { r.Platform = true; r.Incarnation, r.ServingSeq = 2, 3 })
+ sc.alias("shop.example.net", "shop.example.com", "pending", true)
+ sc.route("*.example.com", "wild", "bab", "wild", func(r *registry.Route) { r.Platform = true })
+ sc.route("side.example.com", "sidecar", "bab", "shop", func(r *registry.Route) { r.Kind = "sidecar" })
+ if err := sc.reg.Upsert(registry.Route{Host: "bare.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ sc.reload()
+
+ wantBody(t, sc.serve("shop.example.net", "/", ordinary), "shop", "a non-pointing local alias")
+ for _, h := range []string{"side.example.com", "bare.example.com"} {
+ if _, ok := sc.e.current()[h]; ok {
+ t.Fatalf("%s is in the table", h)
+ }
+ if d := sc.e.resolve(h, sc.now); d.kind != decLocal || d.up.route.App != "wild" {
+ t.Fatalf("%s = %v, want the catch-all: the row itself is no candidate", h, d.kind)
+ }
+ }
+ table := sc.e.current()
+ route := localReady("shop.example.com", table["shop.example.com"], table)
+ if route.kind != candReady || !route.local || route.order != -1 || route.targetHost != "shop.example.com" ||
+ !route.platform || route.incarnation != 2 || route.servingSeq != 3 || route.stamp != 0 || route.app != "shop" {
+ t.Fatalf("route candidate = %+v", route)
+ }
+ alias := localReady("shop.example.net", table["shop.example.net"], table)
+ if !alias.alias || alias.platform || alias.targetHost != "shop.example.com" || alias.incarnation != 2 {
+ t.Fatalf("alias candidate = %+v: an alias is never platform and targets its primary", alias)
+ }
+ wild := localReady("*.example.com", table["*.example.com"], table)
+ if !wild.wildcard || wild.platform {
+ t.Fatalf("catch-all candidate = %+v", wild)
+ }
+}
+
+func TestLocalHoldsIncludeExplicitAliases(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.heldRow("shop.example.com", "bab", "shop", "mv_r", 1, 1)
+ sc.alias("shop.example.net", "shop.example.com", "pointing", true)
+ sc.alias("www.example.org", "shop.example.com", "pending", true)
+ sc.alias("derived.example.com", "shop.example.com", "pointing", false)
+ // The box's own publication of the same hold: the home map excludes it.
+ sc.publish("annie", "1.1.1.1", "", holdJSON("shop.example.com", "mv_r", false, 1, 1, "shop.example.net"))
+ if c := sc.m.snapshot().cands; len(c) != 0 {
+ t.Fatalf("the own publication reached the home map: %v", c)
+ }
+ sc.view(map[string]HoldState{"shop.example.com": moveR})
+
+ wantPage(t, sc.serve("shop.example.com", "/", ordinary), "the held host")
+ wantPage(t, sc.serve("shop.example.net", "/", ordinary), "its explicit pointing alias")
+ wantUnknown(t, sc.serve("www.example.org", "/", ordinary), "its non-pointing alias, as today")
+ wantUnknown(t, sc.serve("derived.example.com", "/", ordinary), "a derived alias")
+ if !sc.e.Allowed("shop.example.net") || sc.e.Allowed("derived.example.com") {
+ t.Fatal("a local hold's names are Allowed, a derived alias's is not")
+ }
+}
+
+func TestLocalAndRemoteArbitrateTogether(t *testing.T) {
+ t.Run("a local hold over a retained remote ready, and under a newer one", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ rec, eip := fakeHomeEdge(t, nil)
+ sc.heldRow("shop.example.com", "bab", "shop", "mv_r", 1, 1)
+ sc.view(map[string]HoldState{"shop.example.com": moveR})
+ sc.publish("betty", eip, readyJSON("shop.example.com", "bab", "shop", 1, 0), "")
+ wantPage(t, sc.serve("shop.example.com", "/", ordinary), "a retained (1,0) against the local (1,1)")
+ sc.publish("betty", eip, readyJSON("shop.example.com", "bab", "shop", 1, 2), "")
+ wantBody(t, sc.serve("shop.example.com", "/", ordinary), homeEdgeBody, "a remote (1,2)")
+ if n := rec.get().n; n != 1 {
+ t.Fatalf("home edge saw %d request(s), want 1", n)
+ }
+ })
+ t.Run("a local ready alias against a fetched hold of its target", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.route("shop.example.com", "shop", "bab", "shop", func(r *registry.Route) { r.Incarnation, r.ServingSeq = 1, 0 })
+ sc.alias("shop.example.net", "shop.example.com", "pointing", true)
+ sc.alias("www.example.org", "shop.example.com", "pending", true)
+ sc.publish("betty", "2.2.2.2", "", holdJSON("shop.example.com", "mv_b", false, 1, 1, "shop.example.net"))
+ wantPage(t, sc.serve("shop.example.net", "/", ordinary), "the stale local alias of the moving target")
+ wantPage(t, sc.serve("shop.example.com", "/", ordinary), "the stale local route")
+ wantBody(t, sc.serve("www.example.org", "/", ordinary), "shop", "a non-pointing local alias keeps serving")
+ })
+}
+
+func TestEveryRequestPathHonoursTheDecision(t *testing.T) {
+ bab := &doorCaller{Account: "bab", App: "shop"}
+ other := &doorCaller{Account: "bab", App: "other"}
+ wild := &doorCaller{Account: "bab", App: "wild"}
+
+ t.Run("decLocal", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.route("shop.example.com", "shop", "bab", "shop")
+ wantBody(t, sc.serve("shop.example.com", "/", via{hop: true}), "shop", "hop")
+ wantBody(t, sc.serve("shop.example.com", "/", via{door: bab}), "shop", "owning door")
+ wantUnknown(t, sc.serve("shop.example.com", "/", via{door: other}), "another app's door")
+ wantBody(t, sc.serve("shop.example.com", "/", ordinary), "shop", "ordinary")
+ })
+ t.Run("decRemote without a fallback", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ rec, eip := fakeHomeEdge(t, nil)
+ sc.publish("betty", eip, readyJSON("shop.example.com", "bab", "shop", 1, 0), "")
+ wantStaleHop(t, sc.serve("shop.example.com", "/", via{hop: true}), "hop")
+ wantBody(t, sc.serve("shop.example.com", "/", via{door: bab}), homeEdgeBody, "owning door")
+ wantUnknown(t, sc.serve("shop.example.com", "/", via{door: other}), "another app's door")
+ wantBody(t, sc.serve("shop.example.com", "/", ordinary), homeEdgeBody, "ordinary")
+ if n := rec.get().n; n != 2 {
+ t.Fatalf("home edge saw %d, want 2", n)
+ }
+ })
+ t.Run("decHolding", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ rec, eip := fakeHomeEdge(t, nil)
+ sc.publish("betty", eip, readyJSON("shop.example.com", "bab", "shop", 1, 0), holdJSON("shop.example.com", "mv_b", false, 1, 1))
+ wantPage(t, sc.serve("shop.example.com", "/", via{hop: true}), "hop")
+ wantPage(t, sc.serve("shop.example.com", "/", via{door: bab}), "owning door")
+ wantUnknown(t, sc.serve("shop.example.com", "/", via{door: other}), "another app's door")
+ wantPage(t, sc.serve("shop.example.com", "/", ordinary), "ordinary")
+ if n := rec.get().n; n != 0 {
+ t.Fatalf("a hold dialled the home edge %d time(s)", n)
+ }
+ ownerless := strings.Replace(holdJSON("cart.example.com", "mv_c", false, 1, 1), `"account":"bab","app":"shop"`, `"account":"","app":""`, 1)
+ sc.publish("carol", eip, "", ownerless)
+ wantUnknown(t, sc.serve("cart.example.com", "/", via{door: bab}), "a door against an ownerless hold")
+ wantPage(t, sc.serve("cart.example.com", "/", ordinary), "an ownerless hold, ordinary")
+ })
+ t.Run("decNone", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ wantStaleHop(t, sc.serve("shop.example.com", "/", via{hop: true}), "hop")
+ wantUnknown(t, sc.serve("shop.example.com", "/", ordinary), "ordinary")
+ })
+ t.Run("the HTTP fallback", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.route("local.example.com", "local", "bab", "shop")
+ sc.publish("betty", "2.2.2.2", readyJSON("remote.example.com", "bab", "shop", 0, 0), holdJSON("held.example.com", "mv_b", false, 1, 1))
+ h := sc.e.httpFallbackHandler()
+ for _, host := range []string{"local.example.com", "remote.example.com", "held.example.com"} {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "http://"+host+"/p", nil)
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusFound || w.Header().Get("Location") != "https://"+host+"/p" {
+ t.Fatalf("%s on :80 = %d %q, want the HTTPS redirect", host, w.Code, w.Header().Get("Location"))
+ }
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", "http://nobody.example.com/", nil))
+ wantUnknown(t, w, "an unknown host on :80")
+ })
+ t.Run("the kept exceptions over a local wildcard", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ rec, eip := fakeHomeEdge(t, nil)
+ sc.route("*.example.com", "local wildcard", "bab", "wild")
+ sc.publish("betty", eip, readyJSON("x.example.com", "bab", "shop", 1, 0), "")
+ wantBody(t, sc.serve("x.example.com", "/", via{hop: true}), "local wildcard", "an accepted hop")
+ wantBody(t, sc.serve("x.example.com", "/", via{door: wild}), "local wildcard", "a door request")
+ wantBody(t, sc.serve("x.example.com", "/", ordinary), homeEdgeBody, "ordinary, with a mesh key")
+ hopKey := sc.key
+ sc.dropKey()
+ wantBody(t, sc.serve("x.example.com", "/", ordinary), "local wildcard", "ordinary, no mesh key")
+
+ // A reload between the remote resolution and the failed backhaul
+ // moves the wildcard to another backend and owner.
+ sc.e.afterResolve = func() { sc.route("*.example.com", "reloaded wildcard", "bab", "other") }
+ wantBody(t, sc.serve("x.example.com", "/", ordinary), "local wildcard", "the fallback is the decision's")
+ sc.e.afterResolve = nil
+ sc.route("*.example.com", "local wildcard", "bab", "wild")
+
+ // The same three with a live hold for x.example.com.
+ sc.m.keyMu.Lock()
+ sc.m.keyCurrent = hopKey
+ sc.m.keyMu.Unlock()
+ sc.publish("betty", eip, readyJSON("x.example.com", "bab", "shop", 1, 0), holdJSON("x.example.com", "mv_b", false, 1, 1))
+ wantPage(t, sc.serve("x.example.com", "/", via{hop: true}), "hop, held")
+ wantPage(t, sc.serve("x.example.com", "/", via{door: bab}), "owning door, held")
+ wantUnknown(t, sc.serve("x.example.com", "/", via{door: wild}), "the wildcard's door, held")
+ wantPage(t, sc.serve("x.example.com", "/", ordinary), "ordinary, held")
+ if n := rec.get().n; n != 1 {
+ t.Fatalf("home edge saw %d, want 1", n)
+ }
+ })
+ t.Run("the losing local hold keeps the fallback", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ _, eip := fakeHomeEdge(t, nil)
+ sc.route("*.example.com", "local wildcard", "bab", "wild")
+ sc.heldRow("x.example.com", "bab", "shop", "mv_r", 1, 1)
+ sc.view(map[string]HoldState{"x.example.com": moveR})
+ sc.publish("betty", eip, readyJSON("x.example.com", "bab", "shop", 1, 2), "")
+ d := sc.e.resolve("x.example.com", sc.now)
+ if d.kind != decRemote || d.fallback == nil {
+ t.Fatalf("decision = %v fallback %v, want decRemote with the fallback", d.kind, d.fallback)
+ }
+ wantBody(t, sc.serve("x.example.com", "/", via{hop: true}), "local wildcard", "hop")
+ wantBody(t, sc.serve("x.example.com", "/", via{door: wild}), "local wildcard", "door")
+ sc.dropKey()
+ wantBody(t, sc.serve("x.example.com", "/", ordinary), "local wildcard", "ordinary without a signing key")
+ })
+}
+
+func TestLocalHoldsFollowTheViewAndTheRegistry(t *testing.T) {
+ held := func(sc *scene, host string) bool {
+ hs := sc.e.holds.Load()
+ return hs != nil && len(hs.cands[host]) > 0
+ }
+ t.Run("a failed snapshot drops an installed hold; recovery reinstalls it", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.heldRow("shop.example.com", "bab", "shop", "mv_r", 1, 1)
+ sc.view(map[string]HoldState{"shop.example.com": moveR})
+ if !held(sc, "shop.example.com") {
+ t.Fatal("the hold did not install")
+ }
+ sc.rev++
+ sc.e.SetHoldView(HoldSnapshot{Rev: sc.rev, Failed: true})
+ if held(sc, "shop.example.com") {
+ t.Fatal("a failed snapshot must drop the hold")
+ }
+ wantUnknown(t, sc.serve("shop.example.com", "/", ordinary), "a failed view")
+ sc.view(map[string]HoldState{"shop.example.com": moveR})
+ wantPage(t, sc.serve("shop.example.com", "/", ordinary), "recovery without a registry change")
+ })
+ t.Run("a view ahead of the registry installs on the next Reload", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.route("shop.example.com", "shop", "bab", "shop")
+ sc.view(map[string]HoldState{"shop.example.com": moveR})
+ if held(sc, "shop.example.com") {
+ t.Fatal("a ready row is no hold")
+ }
+ if err := sc.reg.SetHold("shop.example.com", "moving", "mv_r"); err != nil {
+ t.Fatal(err)
+ }
+ sc.reload()
+ if !held(sc, "shop.example.com") {
+ t.Fatal("Reload must rebuild the holds with the view unchanged")
+ }
+ if err := sc.reg.SetHold("shop.example.com", "moving", "mv_other"); err != nil {
+ t.Fatal(err)
+ }
+ sc.reload()
+ if held(sc, "shop.example.com") {
+ t.Fatal("a hold op the view does not know is no hold")
+ }
+ })
+ t.Run("alias detach, provenance and DNS eligibility each rebuild on Reload", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.heldRow("shop.example.com", "bab", "shop", "mv_r", 1, 1)
+ sc.alias("a.example.net", "shop.example.com", "pointing", true)
+ sc.alias("b.example.net", "shop.example.com", "pointing", true)
+ sc.alias("c.example.net", "shop.example.com", "pointing", true)
+ sc.view(map[string]HoldState{"shop.example.com": moveR})
+ for _, a := range []string{"a.example.net", "b.example.net", "c.example.net"} {
+ if !held(sc, a) {
+ t.Fatalf("%s is not held", a)
+ }
+ }
+ if err := sc.reg.DeleteAlias("a.example.net"); err != nil {
+ t.Fatal(err)
+ }
+ sc.reload()
+ sc.alias("b.example.net", "shop.example.com", "pointing", false)
+ if err := sc.reg.SetAliasDNSStatus("c.example.net", "pending", time.Now().Unix()); err != nil {
+ t.Fatal(err)
+ }
+ sc.reload()
+ for _, a := range []string{"a.example.net", "b.example.net", "c.example.net"} {
+ if held(sc, a) {
+ t.Fatalf("%s is still held", a)
+ }
+ }
+ if !held(sc, "shop.example.com") {
+ t.Fatal("the host itself stays held")
+ }
+ })
+ t.Run("rebuilds install in the order they read", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.heldRow("a.example.com", "bab", "shop", "mv_a", 1, 1)
+ sc.heldRow("b.example.com", "bab", "shop", "mv_b", 1, 1)
+ viewA := HoldSnapshot{Rev: 1, Holds: map[string]HoldState{"a.example.com": {MoveID: "mv_a"}}}
+ viewB := HoldSnapshot{Rev: 2, Holds: map[string]HoldState{"b.example.com": {MoveID: "mv_b"}}}
+ readA, release := make(chan struct{}), make(chan struct{})
+ var once sync.Once
+ sc.e.holdsRead = func(v *HoldSnapshot) {
+ if v != nil && v.Rev == 1 {
+ once.Do(func() { close(readA) })
+ <-release
+ }
+ }
+ done := make(chan struct{})
+ go func() { sc.e.SetHoldView(viewA); close(done) }()
+ <-readA
+ doneB := make(chan struct{})
+ go func() { sc.e.SetHoldView(viewB); close(doneB) }()
+ // B is published while A's rebuild still holds the lock.
+ for sc.e.holdView.Load().Rev != 2 {
+ time.Sleep(time.Millisecond)
+ }
+ close(release)
+ <-done
+ <-doneB
+ if held(sc, "a.example.com") || !held(sc, "b.example.com") {
+ t.Fatal("the installed holds must be B's: the later rebuild read the later view")
+ }
+ })
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 06c60b869..37b31454f 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -295,6 +295,23 @@ type Edge struct {
// routes get unixProxy(t.Socket) and the row's version, as before.
gens GenSource
genPools *GenPools
+ // boxID is this box's id, the one plan 2's moves worker names it by
+ // (moveCfg.BoxID), handed down through WithBoxID. "" when unset.
+ boxID string
+ // The local holds (self-serve moves plan 3, holding.go). holdView is
+ // the latest view the box's offline view published (SetHoldView);
+ // holdInputs the registry rows and table the latest Reload read; holds
+ // the candidates built from both, written only by RefreshHolds under
+ // holdsMu. Reload never touches holds except through that rebuild.
+ holdView atomic.Pointer[HoldSnapshot]
+ holdInputs atomic.Pointer[holdInputs]
+ holdsMu sync.Mutex
+ holds atomic.Pointer[holdSnapshot]
+ // holdsRead and afterResolve are test seams: run inside a hold rebuild
+ // after its inputs are read, and in ServeHTTP right after the request's
+ // decision. nil in production.
+ holdsRead func(*HoldSnapshot)
+ afterResolve func()
}
// RunOption configures the Edge that Run/RunWithActivator* builds, before it
@@ -331,6 +348,11 @@ func WithBoxLabel(label string) RunOption { return func(e *Edge) { e.box = label
// address of every door request. The door opens once the home map and the
// door-key mirror are armed, and only with a deployment store, a release
// public key and a public address; otherwise the edge logs why it is off.
+// WithBoxID hands the edge this box's id, the value plan 2 gives
+// moveCfg.BoxID (internal/host), for the served-by stamp of a probe answered
+// here (self-serve moves plan 3).
+func WithBoxID(id string) RunOption { return func(e *Edge) { e.boxID = id } }
+
func WithEdgeDoor(path, publicAddr string) RunOption {
return func(e *Edge) { e.doorPath, e.doorAddr = path, publicAddr }
}
@@ -361,7 +383,7 @@ type upstream struct {
// route that serves it, resolved against the same live map at request
// time. Nil for every host that does not use prefix routing, which is
// every host on the platform until a claim publishes a map — the nil
- // check in lookupUpstreamPath is the entire cost this feature imposes on
+ // check in selectUpstream is the entire cost this feature imposes on
// everyone else.
//
// Hostnames rather than resolved upstreams, deliberately: an upstream
@@ -991,6 +1013,12 @@ func (e *Edge) Reload() error {
// simply wrong.
e.probers.Sync(probeSpecs)
e.upstreams.Store(&m)
+ // The local holds (holding.go) are rebuilt from the rows this reload
+ // read: a row turning held, an alias detached, a provenance or DNS
+ // eligibility change all land here with the view unchanged. Before the
+ // reload hook, so the wildcard manager's re-derivation sees them.
+ e.holdInputs.Store(&holdInputs{routes: routes, aliases: aliases, table: m})
+ e.rebuildHolds()
// Reconcile the activator with the new route set: a host un-flagged (or
// removed) since the last reload may still have a live child holding its
// socket and lease — retire it so a systemd unit can take over cleanly.
@@ -1068,6 +1096,10 @@ func (e *Edge) current() map[string]upstream {
// entry carries the PRIMARY's route row verbatim, Platform flag included, so
// without the flag a customer domain would inherit its platform primary's
// trust and mint a parent for a zone the platform doesn't own.
+//
+// The box's own real holds of platform rows count too (self-serve moves
+// plan 3): a held row has left the table, and its account's parent must stay
+// loaded through the move.
func (e *Edge) currentHosts() []string {
m := e.current()
out := make([]string, 0, len(m))
@@ -1076,6 +1108,11 @@ func (e *Edge) currentHosts() []string {
out = append(out, h)
}
}
+ for _, h := range e.holds.Load().platformNames() {
+ if _, ok := m[h]; !ok {
+ out = append(out, h)
+ }
+ }
return out
}
@@ -1148,8 +1185,15 @@ func (e *Edge) Allowed(host string) bool {
// widening it to the wildcard fallback would let a stranger's SNI
// <anything>.<domain> drive a fresh order and exhaust the box's shared
// ACME quota for every tenant on it.
- _, ok := e.current()[host]
- return ok
+ //
+ // A local holding candidate is admitted on the same terms (self-serve
+ // moves plan 3): a held row's host and its explicit publishable aliases
+ // are registry-backed exact names this box answers with the holding
+ // page, and need their certificate through the move.
+ if _, ok := e.current()[host]; ok {
+ return true
+ }
+ return len(e.localHoldsFor(host)) > 0
}
// deploymentHost reports whether host is served HERE (Allowed) or is exactly
@@ -1180,14 +1224,16 @@ func (e *Edge) Allowed(host string) bool {
// written in. e.homes may hold nil before the map arms; KnownHost is
// nil-receiver-safe, matching Home's pattern.
func (e *Edge) deploymentHost(host string) bool {
- return e.Allowed(host) || e.homes.Load().KnownHost(host)
+ hm := e.homes.Load()
+ return e.Allowed(host) || hm.KnownHost(host, hm.clock())
}
// lookupIn resolves host to its upstream entry within a given map — the
// HOST half of the request-routing lookup (NOT Allowed's autocert HostPolicy
-// — see the comment there). ServeHTTP does not call it directly: it calls
-// lookupUpstreamPath, which wraps this and then resolves a leading path
-// segment for the hostnames that carry a prefix map. An exact match (a real route, or a plain #151
+// — see the comment there). ServeHTTP does not call it: it ranks the same
+// two keys (resolve, exact then "*.<parent>") with every other box's
+// candidates, then selectUpstream resolves a leading path segment for the
+// hostnames that carry a prefix map. An exact match (a real route, or a plain #151
// alias landed in the same map) always wins; on a miss, a one-label wildcard
// key folds in: "*.<parent>" is the shape Task 3's reconcile pass derives
// from a claim's catch-all row, and it lands in the SAME map as every other
@@ -1211,29 +1257,88 @@ func lookupIn(m map[string]upstream, host string) (upstream, bool) {
return upstream{}, false
}
-// lookupUpstream resolves a hostname alone. Kept as the primitive because not
-// every caller is answering "where does this REQUEST go" — the :80->:443
-// routability check in httpFallbackHandler asks only whether this box serves
-// the hostname at all, and giving that question a path would make a
-// hostname's routability depend on which URL happened to ask.
-// servesExactly reports whether this box's table has host as an exact key,
-// as opposed to answering it only through a "*.<parent>" fallback.
-func (e *Edge) servesExactly(host string) bool {
- _, ok := e.current()[host]
- return ok
-}
-
+// lookupUpstream resolves a hostname alone in this box's table, exact then
+// the one-label catch-all.
func (e *Edge) lookupUpstream(host string) (upstream, bool) {
return lookupIn(e.current(), host)
}
-// lookupUpstreamPath resolves a request: the hostname exactly as
-// lookupUpstream does, then — only for a hostname carrying a prefix map — the
-// first path segment.
+// localReady is a table entry as a ranking candidate (self-serve moves plan
+// 3): the local ready candidates ARE the upstream table, its eligibility
+// (ready rows with a backend, no sidecars, aliases of a primary in the
+// table, routes shadowing aliases) unchanged. An alias is a lifetime of its
+// primary and never a platform class (currentHosts' rule); a catch-all key
+// is the wildcard class. Local candidates are never stamped: local first is
+// the rule, as before.
+func localReady(name string, up upstream, table map[string]upstream) candidate {
+ wild := strings.HasPrefix(name, "*.")
+ c := candidate{kind: candReady, local: true, order: -1, alias: up.alias, wildcard: wild,
+ account: release.RouteAccount(up.route), app: up.route.App,
+ incarnation: up.route.Incarnation, servingSeq: up.route.ServingSeq,
+ platform: (up.route.Platform || up.route.Provisioned) && !up.alias && !wild,
+ targetHost: name, up: &up, table: table}
+ if up.alias {
+ c.targetHost = up.route.Host
+ }
+ return c
+}
+
+// resolve is a request's one decision for host at now (self-serve moves
+// plan 3): the exact name's candidates (this box's table entry, its local
+// holds, and what the home map admitted from other boxes) through rank;
+// when that has no answer at all (no ready winner, no live holding
+// winner), the same over the one-label "*.<parent>" key (the local
+// catch-all and the remote ones).
+//
+// On a box with no self label the home map's candidates are left out, so
+// nothing remote is dialled or held, as Home refused before.
+//
+// The fallback keeps today's dispatch exceptions: when the exact decision is
+// a remote ready winner, the table has no exact ready upstream for host (a
+// losing local hold does not count) and it has the "*.<parent>" upstream,
+// that upstream, from this same table, travels with the decision.
+func (e *Edge) resolve(host string, now time.Time) decision {
+ table := e.current()
+ var remote map[string][]candidate
+ if hm := e.homes.Load(); hm != nil && hm.self != "" {
+ remote = hm.snapshot().cands
+ }
+ gather := func(name string) []candidate {
+ var cs []candidate
+ if up, ok := table[name]; ok {
+ cs = append(cs, localReady(name, up, table))
+ }
+ cs = append(cs, e.localHoldsFor(name)...)
+ return append(cs, remote[name]...)
+ }
+ d := rank(host, gather(host), now)
+ key := wildcardKey(host)
+ if key == "" {
+ return d
+ }
+ switch d.kind {
+ case decNone:
+ return rank(key, gather(key), now)
+ case decRemote:
+ if _, exact := table[host]; !exact {
+ if wu, ok := table[key]; ok {
+ d.fallback = &localFallback{up: &wu, table: table}
+ }
+ }
+ }
+ return d
+}
+
+// selectUpstream finishes a request's local resolution: dec names the
+// hostname's upstream (the decision's, or its local fallback's) and the
+// table it was resolved in, and — only for a hostname carrying a prefix map
+// — the first path segment selects the target route in that SAME table.
//
-// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then
-// current() again would let a reload land between them and resolve a prefix
-// against a different table than the host came from.
+// One table for the whole request (self-serve moves plan 3): the decision
+// carries the table it was resolved against, so a reload landing between
+// resolution and dispatch cannot resolve a prefix (or the login
+// re-resolution, or the door's check) against a different table than the
+// host came from.
//
// Every miss falls through to the hostname's own route rather than failing:
// an unlisted prefix, a dangling target, a request for "/". A prefix map is a
@@ -1278,11 +1383,10 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
// so on an ordinary request that equals rt.Host and takes resolvePurgeHosts'
// own-host branch; on a path-routed one it names the front hostname and takes
// the path-routing branch. Nothing in the app has to know which it is.
-func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
- m := e.current()
- up, ok := lookupIn(m, host)
- if !ok || up.paths == nil {
- return up, ok
+func selectUpstream(dec decision, urlPath string) *upstream {
+ m, up := dec.table, dec.up
+ if up == nil || up.paths == nil {
+ return up
}
// BOTH FORMS, cleaned first. The prefix map is consulted for the
// path.Clean'd first segment and then, if that missed and the two differ,
@@ -1350,7 +1454,7 @@ func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
target, ok = up.paths[raw]
}
if !ok {
- return up, true
+ return up
}
// m[target], not lookupIn(m, target): the TARGET is resolved exactly,
// while the host half above also accepts a one-label wildcard key. The
@@ -1363,7 +1467,7 @@ func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
// anyway.
tu, ok := m[target]
if !ok {
- return up, true
+ return up
}
// ONE HOP, deliberately: if tu itself carries a prefix map, it is NOT
// re-resolved against this request's path. A prefix selects a route, and
@@ -1371,7 +1475,7 @@ func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
// this one's. Do not add recursion here — chaining buys nothing the
// publisher cannot express by pointing the prefix at the final target
// directly, and it would introduce a cycle to detect.
- return tu, true
+ return &tu
}
// firstSegment returns the first path segment of an absolute URL path,
@@ -1384,7 +1488,7 @@ func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
// equal to "paulca", so a longer account name can never be dragged across a
// migration by a shorter one.
//
-// This is the RAW half of the matcher. See lookupUpstreamPath's match block
+// This is the RAW half of the matcher. See selectUpstream's match block
// for why both halves exist and why neither is safe alone.
func firstSegment(p string) string {
if p == "" || p[0] != '/' {
@@ -1496,61 +1600,89 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// settled which X-Forwarded-For this edge will send on.
r = e.withForwardKey(r)
- up, ok := e.lookupUpstreamPath(host, r.URL.Path)
- // A local match through a one-label "*.<parent>" key (a route
- // catch-all) does not beat ANOTHER box's exact host. That is the same
- // precedence one box keeps (lookupIn: exact first, then the star),
- // extended across the deployment: without it, a parent's route
- // catch-all on this box would swallow a name another box serves
- // exactly — an instance catch-all's derived name, or any alias homed
- // elsewhere — for every request that landed here. An arrived hop and a
- // door call keep the local answer: neither may start a backhaul of its
- // own here.
- if ok && !hopped && !isDoor && !e.servesExactly(host) {
- if eip, box, remote := e.homes.Load().ExactHome(host); remote && e.backhaulTo(w, r, host, eip, box) {
- return
+ // One decision for the whole request (self-serve moves plan 3, rank.go):
+ // this box's table, its own holds and the other boxes' candidates,
+ // ranked once. Everything below reads dec and the table it carries, so
+ // the dial, the owner, the prefix target, the login re-resolution and
+ // the door's check cannot come from two different reads.
+ dec := e.resolve(host, e.homes.Load().clock())
+ if e.afterResolve != nil {
+ e.afterResolve()
+ }
+ // local is the decision this request is served locally from, set by
+ // every branch that does not answer here.
+ var local decision
+ switch dec.kind {
+ case decLocal:
+ local = dec
+ case decRemote:
+ // Another box's ready winner. The kept exceptions first: an exact
+ // name this box answers only through its own "*.<parent>"
+ // (dec.fallback) is served from that catch-all when the request is
+ // an accepted hop or a door call, neither of which may start a
+ // backhaul of its own here; and an ordinary request falls through
+ // to it when the backhaul cannot dial for want of a mesh key.
+ // Saturation and proxy failures are answered remotely, as before.
+ if fb := dec.fallback; fb != nil && (hopped || isDoor) {
+ local = decision{kind: decLocal, up: fb.up, table: fb.table}
+ break
}
- }
- if !ok {
if hopped {
- // A PROVEN hop from a sibling edge, for a host that has moved (or
- // never lived) here: its map is stale. 503 + Retry-After: 2, and
- // this return is the structural half of "one hop, ever" — the
- // backhaul dial below lives further down this same branch, so an
- // accepted hop physically cannot reach it. (e.backhaul opens with
- // the same refusal anyway, so moving this branch cannot open a
- // forwarding loop either — see its doc.)
+ // A PROVEN hop from a sibling edge, for a host that has moved
+ // (or never lived) here: its map is stale. 503 + Retry-After:
+ // 2, and this return is the structural half of "one hop, ever"
+ // — an accepted hop never reaches the dial below. (backhaulTo
+ // opens with the same refusal anyway, so moving this branch
+ // cannot open a forwarding loop either — see its doc.)
e.homes.Load().staleHopRefusal(w, host)
return
}
// A door caller reaches a host served elsewhere only when the owner
- // its home box published is the caller's app, and is dialled at
- // exactly the box that same Owner read named, so the check and the
- // dial cannot disagree. A hostname split between apps publishes no
- // owner and is refused here. The far edge sees an ordinary hop.
+ // the decision's box published is the caller's app, and is dialled
+ // at exactly that box, so the check and the dial cannot disagree.
+ // A hostname split between apps publishes no owner and is refused.
+ // The far edge sees an ordinary hop.
if isDoor {
- if acct, app, eip, box, ok := e.homes.Load().Owner(host); ok && acct == door.Account && app == door.App {
- if e.backhaulTo(w, r, host, eip, box) {
- return
- }
+ if dec.account != "" && dec.app != "" && dec.account == door.Account && dec.app == door.App &&
+ e.backhaulTo(w, r, host, dec.eip, dec.box) {
+ return
}
e.unknownHost(w, r)
return
}
- // Nothing here serves this host — but another box in the deployment
- // may (backhaul, below). It answers false when no box homes the host,
- // or when nothing could prove the hop, and today's unknown-host answer
- // then follows exactly as it always has.
- if e.backhaul(w, r, host) {
+ if e.backhaulTo(w, r, host, dec.eip, dec.box) {
+ return
+ }
+ if fb := dec.fallback; fb != nil {
+ local = decision{kind: decLocal, up: fb.up, table: fb.table}
+ break
+ }
+ e.unknownHost(w, r)
+ return
+ case decHolding:
+ // The name is mid-move: the holding page, whoever asks. Never
+ // forwarded (an accepted hop is answered here too). A door call
+ // gets it only for its own app's hold; an ownerless hold is
+ // refused to every door.
+ if isDoor && (dec.hold.Account == "" || dec.hold.App == "" || dec.hold.Account != door.Account || dec.hold.App != door.App) {
+ e.unknownHost(w, r)
+ return
+ }
+ e.holdingAnswer(w, r, dec.hold)
+ return
+ default:
+ if hopped {
+ e.homes.Load().staleHopRefusal(w, host)
return
}
e.unknownHost(w, r)
return
}
+ up := *selectUpstream(local, r.URL.Path)
// A login POST re-resolves its upstream by the path in `next`, not by
// its own (spec §4.2). On a hostname that routes by path prefix,
- // lookupUpstreamPath keys on the first segment; "/.carlos/gate" has no
+ // selectUpstream keys on the first segment; "/.carlos/gate" has no
// segment that can ever be a prefix, so a login POST falls through to
// the FRONT host's route — the password would be judged by the wrong
// row (or proxied to an app that never asked for it, which the
@@ -1559,10 +1691,10 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// on, so resolving by it lands on the route that put up the page. On a
// host with no prefix map it resolves to the same route either way.
//
- // Placed AFTER the lookup above and its !ok branch, which stay
- // byte-for-byte what they were, because this reads the request BODY: a
- // host this box does not serve may be backhauled to the box that homes
- // it (e.backhaul, above), and that reverse-proxies this very request —
+ // Placed AFTER the decision above and its remote branches, because this
+ // reads the request BODY: a host this box does not serve may be
+ // backhauled to the box that homes it (backhaulTo, above), and that
+ // reverse-proxies this very request —
// draining its body here would send the home box a ContentLength it
// cannot fill, and the hop would 502. Nothing is read until the host is
// known to be ours. Placed before the meter so every step below,
@@ -1599,8 +1731,9 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if u, err := url.ParseRequestURI(next); err == nil && u.Path != "" {
lookupPath = u.Path
}
- if target, hit := e.lookupUpstreamPath(host, lookupPath); hit {
- up = target
+ // Re-resolved in the decision's own table, never the live one.
+ if target := selectUpstream(local, lookupPath); target != nil {
+ up = *target
}
}
@@ -2257,21 +2390,18 @@ func (e *Edge) httpFallbackHandler() http.Handler {
// redirecting like every other routable host.
routable := e.tunnels != nil && host == e.tunnels.Host()
if !routable {
- _, routable = e.lookupUpstream(host)
- }
- if !routable {
- // A host this box does not serve but another box HOMES: 443
- // backhauls it (the hop, edge.go's backhaul), so :80 must send the
- // visitor there rather than 404 an app that is up — a first-time
- // visitor typing a bare hostname lands on whichever edge DNS
- // picked, which in a steered deployment is routinely not the home.
- // Home() is the same wildcard-capable lookup the dial uses, is
- // nil-safe (no deployment store ⇒ no answer), and excludes this
- // box's own hosts, so a single-box deployment is untouched.
+ // The request's own decision (resolve): a local route, catch-all
+ // included; another box's ready winner, which 443 backhauls (a
+ // first-time visitor typing a bare hostname lands on whichever
+ // edge DNS picked, which in a steered deployment is routinely
+ // not the home); or a holding page, which 443 answers. Only a
+ // name with no answer at all keeps the unknown-host answer. The
+ // home map half is nil-safe and refuses to dial on an
+ // unlabelled box, so a single-box deployment is untouched.
//
// No hop logic here on purpose: the hop dials 443, so a verified
// hop can never arrive on this handler at all.
- _, _, routable = e.homes.Load().Home(host)
+ routable = e.resolve(host, e.homes.Load().clock()).kind != decNone
}
if !routable {
e.unknownHost(w, r)
diff --git a/internal/edge/holding.go b/internal/edge/holding.go
index 574eed74b..977392a62 100644
--- a/internal/edge/holding.go
+++ b/internal/edge/holding.go
@@ -1,5 +1,20 @@
package edge
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "net/http"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
// HoldSnapshot is one published revision of a box's offline view, as the
// local edge receives it (self-serve moves plan 3): the offline moves the
// box's last record listing carried, by host. internal/host's offline view
@@ -26,3 +41,304 @@ type HoldState struct {
OfflineAt int64
EstimateSeconds int
}
+
+// holdingView is one holding entry of a box's edges file (offline_moves,
+// internal/host's holdingEntry) as this reader decodes it, and the page
+// inputs of a local hold built from this box's own row and view.
+type holdingView struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Aliases []string `json:"aliases"`
+ MoveID string `json:"move_id"`
+ Incarnation uint64 `json:"incarnation"`
+ ServingSeq uint64 `json:"serving_seq"`
+ OfflineAt int64 `json:"offline_at"`
+ EstimateSeconds int `json:"estimate_seconds"`
+ DisplayName string `json:"display_name"`
+}
+
+// The holding page (self-serve moves plan 3, design §7.4): what every edge
+// answers a moving host and its explicit aliases with, from the start of an
+// offline move until the target's row is ready and published.
+//
+// The sentences are copy pending review (docs/superpowers/plans/
+// 2026-10-06-moves-phase1-copy.md, MV-O rows). The display name is the only
+// text on the page that is not this package's, and it is rendered by
+// html/template as text: nothing from the request (URL, headers, host)
+// reaches the body. The storage key's host is read by the script from
+// location.hostname, in the visitor's own browser.
+
+const (
+ // holdingRetryAfter is the Retry-After an API client gets, in seconds.
+ holdingRetryAfter = "30"
+ // The page's two fixed sentences; holdingSentence writes the two that
+ // carry N in place, so the copy gate reads each whole.
+ holdingNoEstimate = "This account is temporarily offline. Please check back in a few minutes."
+ holdingLonger = "This is taking longer than expected. Please keep checking back."
+)
+
+var holdingPage = template.Must(template.New("holding").Parse(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex">
+<title>{{.Sentence}}</title>
+<style>
+body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:20vh auto 0;padding:0 1.5rem;color:#222}
+p{margin:0}
+</style>
+</head>
+<body>
+<p id="s">{{.Sentence}}</p>
+<script>
+(function(){
+ var c={{.Config}}, k="carlos-holding-"+location.hostname, now=Date.now()/1000, t=null;
+ try{if(!sessionStorage.getItem(k))sessionStorage.setItem(k,String(Date.now()))}catch(e){}
+ function again(ms){clearTimeout(t);t=setTimeout(function(){location.replace(location.href)},ms)}
+ if(c.until>0&&c.until>now){
+ again(30000);
+ setTimeout(function(){
+ var s={{.Longer}};
+ document.getElementById("s").textContent=s;
+ document.title=s;
+ again(60000);
+ },(c.until-now)*1000);
+ return;
+ }
+ again(60000);
+})();
+</script>
+</body>
+</html>
+`))
+
+// holdingSentence is the page's sentence for a move whose estimate ends at
+// untilUnix (0 for none), at now: N, the minutes left, rounded up and at
+// least 1, is the server's; the client never recomputes it.
+func holdingSentence(displayName string, untilUnix int64, now time.Time) string {
+ if untilUnix == 0 {
+ return holdingNoEstimate
+ }
+ left := time.Unix(untilUnix, 0).Sub(now)
+ if left <= 0 {
+ return holdingLonger
+ }
+ n := int((left + time.Minute - 1) / time.Minute)
+ if n < 1 {
+ n = 1
+ }
+ if displayName == "" {
+ return fmt.Sprintf("This account is temporarily offline. Please check back in approximately %d minutes.", n)
+ }
+ // The display name is escaped by the template, as text.
+ return fmt.Sprintf("%s is temporarily offline. Please check back in approximately %d minutes.", displayName, n)
+}
+
+// renderHoldingPage renders the page for one request: the sentence at now,
+// and the script's until (unix seconds, 0 for none).
+func renderHoldingPage(displayName string, untilUnix int64, now time.Time) []byte {
+ var buf bytes.Buffer
+ err := holdingPage.Execute(&buf, map[string]any{
+ "Sentence": holdingSentence(displayName, untilUnix, now),
+ "Longer": holdingLonger,
+ "Config": map[string]int64{"until": untilUnix},
+ })
+ if err != nil {
+ // The template is fixed at build time and its inputs are a string
+ // and a number; a failure is a programming error the tests catch.
+ panic(err)
+ }
+ return buf.Bytes()
+}
+
+// holdingAnswer writes the holding page for h: 503, Retry-After: 30,
+// no-store (the answer is about a moment), noindex, and HTML; HEAD gets the
+// headers alone. until is the move's offline time plus its estimate, when
+// it has both.
+func (e *Edge) holdingAnswer(w http.ResponseWriter, r *http.Request, h *holdingView) {
+ var until int64
+ if h.OfflineAt != 0 && h.EstimateSeconds != 0 {
+ until = h.OfflineAt + int64(h.EstimateSeconds)
+ }
+ hdr := w.Header()
+ hdr.Set("Content-Type", "text/html; charset=utf-8")
+ hdr.Set("Cache-Control", "no-store")
+ hdr.Set("Retry-After", holdingRetryAfter)
+ hdr.Set("X-Robots-Tag", "noindex")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ if r.Method != http.MethodHead {
+ w.Write(renderHoldingPage(h.DisplayName, until, e.homes.Load().clock())) //nolint:errcheck
+ }
+}
+
+// ---- the local holds ----------------------------------------------------
+
+// holdSnapshot is this box's own holding candidates, by name: one real
+// candidate per held row the view knows the move of, and one alias
+// candidate per explicit alias the edges-file writer would publish for it.
+// Immutable once installed; RefreshHolds is its only writer.
+type holdSnapshot struct {
+ cands map[string][]candidate
+}
+
+// holdInputs is the registry as the latest Reload read it: every row, every
+// alias row, and the upstream table that Reload built from them (the
+// shadowing set of an alias). RefreshHolds reads the latest, never the
+// registry itself, so a view change rebuilds without I/O.
+type holdInputs struct {
+ routes []registry.Route
+ aliases []registry.RouteAlias
+ table map[string]upstream
+}
+
+// SetHoldView is the offline view's onChange (internal/host's
+// offlineView.Subscribe): it stores s as the latest view and rebuilds the
+// local holds (RefreshHolds).
+//
+// It runs INSIDE the view's publication lock, and depending on the caller
+// also under the moves pass's or the reconcile's own serialization. So
+// neither it nor anything it calls may publish to or subscribe on the view,
+// refresh the view, run a moves pass or a reconcile, or wait on a goroutine
+// that does: any of those would deadlock. It is bounded and does no bucket,
+// network or registry I/O: the rebuild reads the rows the latest Reload
+// read, and the wildcard manager is told on its own goroutine.
+func (e *Edge) SetHoldView(s HoldSnapshot) {
+ e.holdView.Store(&s)
+ e.RefreshHolds()
+}
+
+// RefreshHolds rebuilds the local holds from the latest view and the latest
+// Reload's rows, and installs them. The inputs are read under holdsMu, the
+// lock that installs, so rebuilds install in the order they read and none
+// installs older inputs than one before it; no revision is compared (a
+// view's Rev is publication order, not freshness). When the hold names that
+// feed the wildcard parents changed, the manager is told the way Reload
+// tells it, off this goroutine.
+func (e *Edge) RefreshHolds() {
+ if e.rebuildHolds() {
+ if fn := e.reloadHook.Load(); fn != nil {
+ go (*fn)()
+ }
+ }
+}
+
+// rebuildHolds is RefreshHolds without the notification (Reload notifies
+// itself, after it). It reports whether the platform hold names changed.
+func (e *Edge) rebuildHolds() bool {
+ e.holdsMu.Lock()
+ defer e.holdsMu.Unlock()
+ view := e.holdView.Load()
+ in := e.holdInputs.Load()
+ if e.holdsRead != nil {
+ e.holdsRead(view)
+ }
+ next := localHolds(view, in)
+ prev := e.holds.Load()
+ e.holds.Store(next)
+ return !slices.Equal(prev.platformNames(), next.platformNames())
+}
+
+// localHolds builds the box's holding candidates: none without a view, or
+// with a failed one. A held row (registry.IsHold, not a sidecar) whose hold
+// op is the move the view knows for its host is one real candidate, owned
+// as the door judges a route (release.RouteAccount, the row's app), at the
+// row's pair. Each of its alias rows that is explicit and that
+// edgesfile.AliasPublishable admits (the writer's own rule: the held row is
+// a primary the writer publishes; shadowed by a real route in the table) is
+// one alias candidate of the same hold, never platform. So the box serves
+// its own alias the page even though the home map excludes its own file.
+func localHolds(view *HoldSnapshot, in *holdInputs) *holdSnapshot {
+ out := &holdSnapshot{cands: map[string][]candidate{}}
+ if view == nil || view.Failed || in == nil {
+ return out
+ }
+ held := map[string]*holdingView{}
+ for _, r := range in.routes {
+ if !registry.IsHold(r.Status) || r.HoldOp == "" || r.IsSidecar() {
+ continue
+ }
+ st, ok := view.Holds[r.Host]
+ if !ok || st.MoveID != r.HoldOp {
+ continue
+ }
+ hv := &holdingView{Host: r.Host, Platform: r.Platform || r.Provisioned, Account: release.RouteAccount(r), App: r.App,
+ MoveID: st.MoveID, Incarnation: r.Incarnation, ServingSeq: r.ServingSeq,
+ OfflineAt: st.OfflineAt, EstimateSeconds: st.EstimateSeconds, DisplayName: st.DisplayName}
+ held[r.Host] = hv
+ out.cands[r.Host] = append(out.cands[r.Host], candidate{kind: candHolding, local: true, order: -1,
+ account: hv.Account, app: hv.App, platform: hv.Platform, targetHost: r.Host,
+ incarnation: r.Incarnation, servingSeq: r.ServingSeq, hold: hv})
+ }
+ for _, a := range in.aliases {
+ hv, ok := held[a.Host]
+ if !ok || !a.Explicit {
+ continue
+ }
+ shadow, taken := in.table[a.Alias]
+ if !edgesfile.AliasPublishable(a, true, taken && !shadow.alias) {
+ continue
+ }
+ hv.Aliases = append(hv.Aliases, a.Alias)
+ out.cands[a.Alias] = append(out.cands[a.Alias], candidate{kind: candHolding, local: true, order: -1, alias: true,
+ account: hv.Account, app: hv.App, targetHost: a.Host,
+ incarnation: hv.Incarnation, servingSeq: hv.ServingSeq, hold: hv})
+ }
+ return out
+}
+
+// names is every name the snapshot holds, sorted; platformNames the real
+// holds of platform rows (wildcard parent inputs, currentHosts). Both
+// nil-receiver-safe.
+func (h *holdSnapshot) names() []string {
+ if h == nil {
+ return nil
+ }
+ out := make([]string, 0, len(h.cands))
+ for n := range h.cands {
+ out = append(out, n)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func (h *holdSnapshot) platformNames() []string {
+ if h == nil {
+ return nil
+ }
+ var out []string
+ for n, cs := range h.cands {
+ if slices.ContainsFunc(cs, func(c candidate) bool { return !c.alias && c.platform }) && !strings.HasPrefix(n, "*.") {
+ out = append(out, n)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// localHoldsFor is name's local holding candidates.
+func (e *Edge) localHoldsFor(name string) []candidate {
+ if h := e.holds.Load(); h != nil {
+ return h.cands[name]
+ }
+ return nil
+}
+
+// certNames is every exact name this box must keep a certificate for
+// because it answers the holding page: its own holds (hosts and aliases)
+// and every name another box's live hold wins (homeMap.HoldingHosts). The
+// cert warm considers them after the served names (warmSet); they are never
+// served evidence.
+func (e *Edge) certNames() []string {
+ out := e.holds.Load().names()
+ hm := e.homes.Load()
+ for _, n := range hm.HoldingHosts(hm.clock()) {
+ if !slices.Contains(out, n) {
+ out = append(out, n)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/edge/holding_test.go b/internal/edge/holding_test.go
new file mode 100644
index 000000000..15f83ba60
--- /dev/null
+++ b/internal/edge/holding_test.go
@@ -0,0 +1,335 @@
+package edge
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// pageNow is the render clock of the page tests: the until values below
+// are offsets from it.
+var pageNow = time.Unix(1792833000, 0)
+
+func TestHoldingPageRendersEveryCase(t *testing.T) {
+ cases := []struct {
+ name, display string
+ until int64
+ want string
+ }{
+ {"name and an estimate ahead", "Shop", pageNow.Unix() + 600,
+ "Shop is temporarily offline. Please check back in approximately 10 minutes."},
+ {"no name", "", pageNow.Unix() + 600,
+ "This account is temporarily offline. Please check back in approximately 10 minutes."},
+ {"no estimate", "Shop", 0,
+ "This account is temporarily offline. Please check back in a few minutes."},
+ {"the estimate has passed", "Shop", pageNow.Unix() - 5,
+ "This is taking longer than expected. Please keep checking back."},
+ {"exactly at until", "Shop", pageNow.Unix(),
+ "This is taking longer than expected. Please keep checking back."},
+ {"N at 61 s", "Shop", pageNow.Unix() + 61, "in approximately 2 minutes."},
+ {"N at 60 s", "Shop", pageNow.Unix() + 60, "in approximately 1 minutes."},
+ {"N at 59 s", "Shop", pageNow.Unix() + 59, "in approximately 1 minutes."},
+ {"N at 1 s", "Shop", pageNow.Unix() + 1, "in approximately 1 minutes."},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ body := string(renderHoldingPage(c.display, c.until, pageNow))
+ if !strings.Contains(body, c.want) {
+ t.Fatalf("page does not say %q:\n%s", c.want, body)
+ }
+ })
+ }
+
+ t.Run("the display name is escaped", func(t *testing.T) {
+ body := string(renderHoldingPage("<b>Shop</b>", pageNow.Unix()+600, pageNow))
+ if strings.Contains(body, "<b>") || !strings.Contains(body, "&lt;b&gt;Shop&lt;/b&gt; is temporarily offline.") {
+ t.Fatalf("the name must be text, not markup:\n%s", body)
+ }
+ })
+
+ t.Run("the script", func(t *testing.T) {
+ until := pageNow.Unix() + 600
+ body := string(renderHoldingPage("Shop", until, pageNow))
+ for _, want := range []string{
+ `{"until":1792833600}`, // until embedded as JSON, unix seconds
+ `"carlos-holding-"+location.hostname`, // the storage key, distinct from the pending page's
+ `location.replace(location.href)`, // a GET, so a POST never repeats
+ `again(30000)`, // 30 s while the estimate is ahead
+ `again(60000)`, // 60 s once it has passed, and with none
+ `c.until>0&&c.until>now`, // the estimate-ahead branch
+ `(c.until-now)*1000`, // the local switch at until
+ `"This is taking longer than expected. Please keep checking back."`,
+ `try{`, `}catch(e){}`, // storage never stops the reload
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("script lacks %q:\n%s", want, body)
+ }
+ }
+ if body := string(renderHoldingPage("", 0, pageNow)); !strings.Contains(body, `{"until":0}`) {
+ t.Fatalf("no estimate embeds until 0:\n%s", body)
+ }
+ })
+
+ t.Run("no request text in the body", func(t *testing.T) {
+ e := &Edge{}
+ r := httptest.NewRequest("GET", "https://shop.example.com/secret-path?q=echo", nil)
+ r.Header.Set("User-Agent", "agent-echo")
+ w := httptest.NewRecorder()
+ e.holdingAnswer(w, r, &holdingView{Host: "shop.example.com", DisplayName: "Shop", OfflineAt: pageNow.Unix(), EstimateSeconds: 600})
+ body := w.Body.String()
+ for _, leak := range []string{"secret-path", "echo", "agent-echo", "shop.example.com"} {
+ if strings.Contains(body, leak) {
+ t.Fatalf("the body carries request text %q", leak)
+ }
+ }
+ })
+
+ t.Run("the headers, and HEAD", func(t *testing.T) {
+ e := &Edge{}
+ h := &holdingView{Host: "shop.example.com", DisplayName: "Shop", OfflineAt: time.Now().Unix(), EstimateSeconds: 600}
+ for _, method := range []string{"GET", "HEAD", "POST"} {
+ w := httptest.NewRecorder()
+ e.holdingAnswer(w, httptest.NewRequest(method, "https://shop.example.com/", nil), h)
+ res := w.Result()
+ if res.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("%s status = %d", method, res.StatusCode)
+ }
+ for k, v := range map[string]string{"Retry-After": "30", "Cache-Control": "no-store",
+ "X-Robots-Tag": "noindex", "Content-Type": "text/html; charset=utf-8"} {
+ if got := res.Header.Get(k); got != v {
+ t.Fatalf("%s %s = %q, want %q", method, k, got, v)
+ }
+ }
+ if method == "HEAD" && w.Body.Len() != 0 {
+ t.Fatal("HEAD writes no body")
+ }
+ if method != "HEAD" && !strings.Contains(w.Body.String(), "approximately 10 minutes") {
+ t.Fatalf("%s body = %s", method, w.Body.String())
+ }
+ }
+ })
+
+ t.Run("until needs both the offline time and the estimate", func(t *testing.T) {
+ e := &Edge{}
+ for _, h := range []*holdingView{{DisplayName: "Shop", EstimateSeconds: 600}, {DisplayName: "Shop", OfflineAt: time.Now().Unix()}} {
+ w := httptest.NewRecorder()
+ e.holdingAnswer(w, httptest.NewRequest("GET", "https://shop.example.com/", nil), h)
+ if !strings.Contains(w.Body.String(), "in a few minutes.") || !strings.Contains(w.Body.String(), `{"until":0}`) {
+ t.Fatalf("a half estimate is no estimate:\n%s", w.Body.String())
+ }
+ }
+ })
+}
+
+// handshakeName completes one real TLS handshake with SNI name against a
+// listener whose certificate chain is getCert, and returns the leaf the
+// server presented.
+func handshakeName(t *testing.T, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), name string) (*x509.Certificate, error) {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ tc := tls.Server(conn, &tls.Config{GetCertificate: getCert})
+ _ = tc.HandshakeContext(context.Background())
+ _ = tc.Close()
+ }()
+ conn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ServerName: name, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+ return conn.ConnectionState().PeerCertificates[0], nil
+}
+
+// noPerHost is a per-host chain that has nothing: only the wildcard can
+// answer.
+func noPerHost(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ return nil, errors.New("no per-host certificate")
+}
+
+// cachedParent puts a wildcard for parent in cache, as the mirror lands one.
+func cachedParent(t *testing.T, cache autocert.Cache, parent string) {
+ t.Helper()
+ cert, key := selfSignedWildcard(t, parent, time.Now().Add(60*24*time.Hour))
+ raw, err := encodeWildcardPEM(cert, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cache.Put(context.Background(), wildcardPEMKey(parent), raw); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func wantServedFor(t *testing.T, w *wildcardManager, name string) {
+ t.Helper()
+ leaf, err := handshakeName(t, wrapGetCertificate(w, noPerHost), name)
+ if err != nil {
+ t.Fatalf("handshake for %s: %v", name, err)
+ }
+ if leaf.VerifyHostname(name) != nil {
+ t.Fatalf("the certificate served for %s does not cover it (%v)", name, leaf.DNSNames)
+ }
+}
+
+func TestTLSCoverageForHoldingHosts(t *testing.T) {
+ const apps = "apps.example.com"
+ t.Run("a wildcard-only platform host held keeps its parent", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ sc.route("cart.bab."+apps, "cart", "bab", "shop", func(r *registry.Route) { r.Platform = true })
+ cache := autocert.DirCache(t.TempDir())
+ cachedParent(t, cache, "bab."+apps)
+ w := newWildcardManager(nil, apps, sc.e.deploymentHosts, nil, "", cache, nil)
+ sc.e.OnReload(w.refresh)
+ w.refresh()
+ waitParents(t, w, []string{"bab." + apps})
+ w.loadCached(context.Background(), "bab."+apps)
+ wantServedFor(t, w, "cart.bab."+apps)
+
+ if err := sc.reg.SetHold("cart.bab."+apps, "moving", "mv_r"); err != nil {
+ t.Fatal(err)
+ }
+ sc.view(map[string]HoldState{"cart.bab." + apps: moveR})
+ sc.reload()
+ if _, ok := sc.e.current()["cart.bab."+apps]; ok {
+ t.Fatal("test bug: a held row left the table")
+ }
+ waitParents(t, w, []string{"bab." + apps})
+ wantServedFor(t, w, "cart.bab."+apps)
+ wantPage(t, sc.serve("cart.bab."+apps, "/", ordinary), "the held platform host")
+
+ // Without the hold (a failed view) nothing keeps the parent: it is
+ // the hold that did.
+ sc.rev++
+ sc.e.SetHoldView(HoldSnapshot{Rev: sc.rev, Failed: true})
+ deadline := time.Now().Add(2 * time.Second)
+ for len(w.currentParents()) != 0 {
+ if time.Now().After(deadline) {
+ t.Fatalf("parents = %v after the hold dropped, want none", w.currentParents())
+ }
+ time.Sleep(time.Millisecond)
+ }
+ })
+ t.Run("a platform hold arriving on the home map wakes the manager", func(t *testing.T) {
+ sc := newScene(t, "annie")
+ cache := autocert.DirCache(t.TempDir())
+ cachedParent(t, cache, "bdj."+apps)
+ w := newWildcardManager(nil, apps, sc.e.deploymentHosts, nil, "", cache, nil)
+ sc.m.setHostsWake(w.refresh)
+ sc.publish("betty", "2.2.2.2", readyJSON("shop.example.net", "bab", "shop", 0, 0), "")
+ w.refresh()
+ if len(w.currentParents()) != 0 {
+ t.Fatalf("seed parents = %v", w.currentParents())
+ }
+ before := sc.m.snapshot().hosts
+ sc.publish("betty", "2.2.2.2", readyJSON("shop.example.net", "bab", "shop", 0, 0), holdJSON("cart.bdj."+apps, "mv_b", true, 1, 1))
+ if !samePlatformSet(before, sc.m.snapshot().hosts) {
+ t.Fatal("test bug: the ready fold's platform set must be unchanged")
+ }
+ waitParents(t, w, []string{"bdj." + apps})
+ w.loadCached(context.Background(), "bdj."+apps)
+ wantServedFor(t, w, "cart.bdj."+apps)
+ })
+ t.Run("an invented label gets no issuance and no challenge read; the tunnel host is admitted", func(t *testing.T) {
+ e2eQuietLogs(t)
+ bucket := t.TempDir()
+ b := newE2EEdge(t, bucket, true, "http://127.0.0.1:1/directory")
+ b.edge.EnableTunnels(NewTunnelHub("tunnel.example.com", map[string]string{"pi-box": "sekrit"}))
+ homes := newHomeMap(release.NewDirStore(bucket), "edge-b")
+ putJSON(t, homes.s, "control/edges/edge-c.json", fmt.Sprintf(`{"v":1,"box":"edge-c","eip":"203.0.113.30","hosts":{"*.example.net":{"role":"home"}},"offline_moves":[%s]}`,
+ holdJSON("cart.example.net", "mv_c", false, 1, 1, "shop.example.org")))
+ refreshHomeMap(t, homes)
+ b.edge.homes.Store(homes)
+ const invented = "invented.example.net"
+ if _, _, ok := homes.Home(invented); !ok {
+ t.Fatal("test bug: the invented label must be dialable")
+ }
+ if err := b.edge.issuerHostPolicy()(context.Background(), invented); err == nil {
+ t.Fatal("an invented label under a catch-all must never drive an order")
+ }
+ putBucket(t, release.NewDirStore(bucket), invented+alpnTokenSuffix, hostCertPEM(t, invented, time.Now().Add(24*time.Hour)))
+ before, _ := b.store.snapshot()
+ if c := b.mirror.alpnTokenCert(context.Background(), invented); c != nil {
+ t.Fatal("an invented label was served a token cert")
+ }
+ if _, ok := b.mirror.challengeResponse(context.Background(), invented, "tok"); ok {
+ t.Fatal("an invented label was answered a challenge")
+ }
+ after, _ := b.store.snapshot()
+ if len(after) != len(before) {
+ t.Fatalf("the refusals read the bucket: %v", after[len(before):])
+ }
+ for _, name := range []string{"cart.example.net", "shop.example.org", "tunnel.example.com"} {
+ if err := b.edge.issuerHostPolicy()(context.Background(), name); err != nil {
+ t.Fatalf("%s refused: %v", name, err)
+ }
+ }
+ })
+ t.Run("a holding-only custom domain is warmed by the issuer and served by a non-issuer", func(t *testing.T) {
+ e2eQuietLogs(t)
+ bucket := t.TempDir()
+ ca := newFakeACME(t)
+ const name = "shop.example.org"
+ publishHold := func(m *homeMap) {
+ putJSON(t, m.s, "control/edges/edge-c.json", fmt.Sprintf(`{"v":1,"box":"edge-c","eip":"203.0.113.30","hosts":{},"offline_moves":[%s]}`,
+ holdJSON("cart.example.net", "mv_c", false, 1, 1, name)))
+ refreshHomeMap(t, m)
+ }
+ edgeB := newE2EEdge(t, bucket, false, ca.directoryURL())
+ ca.validateAgainst(edgeB.serveHTTP(t))
+ hb := newHomeMap(release.NewDirStore(bucket), "edge-b")
+ publishHold(hb)
+ edgeB.edge.homes.Store(hb)
+ edgeA := newE2EEdge(t, bucket, true, ca.directoryURL())
+ edgeA.serveHTTP(t)
+ ha := newHomeMap(release.NewDirStore(bucket), "edge-a")
+ refreshHomeMap(t, ha)
+ edgeA.edge.homes.Store(ha)
+
+ if !slices.Contains(edgeA.edge.certNames(), name) || slices.Contains(ha.ServedHosts(), name) {
+ t.Fatalf("certNames %v, ServedHosts %v: a holding name is a certificate to keep, never served evidence", edgeA.edge.certNames(), ha.ServedHosts())
+ }
+ if !slices.Contains(edgeA.edge.warmSet("", nil), name) {
+ t.Fatalf("warm set %v lacks the holding name", edgeA.edge.warmSet("", nil))
+ }
+ // The warm's order: the issuer's per-host chain, validated through
+ // edge B, which admits the name only because of the hold.
+ minted, err := edgeA.getCert(ecdsaHello(name))
+ if err != nil {
+ t.Fatalf("the issuer could not warm %s: %v", name, err)
+ }
+ ca.checkFaults(t)
+ lnB := edgeB.serveTLS(t)
+ if err := edgeB.mirror.Refresh(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ state, err := e2eDial(lnB, name, ca.rootPool())
+ if err != nil {
+ t.Fatalf("the non-issuer's handshake for %s: %v", name, err)
+ }
+ if !bytes.Equal(state.PeerCertificates[0].Raw, minted.Leaf.Raw) {
+ t.Fatal("edge B served a certificate other than the one the issuer minted")
+ }
+ })
+}
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
index fac1840e8..61df9cc85 100644
--- a/internal/edge/homemap.go
+++ b/internal/edge/homemap.go
@@ -142,6 +142,14 @@ type edgesFileView struct {
PrivateCIDR string `json:"private_cidr"`
Region string `json:"region"`
Placement string `json:"placement"`
+ // OfflineMoves are the box's held offline-move rows (self-serve moves
+ // plan 3): each answers its host and its explicit aliases with the
+ // holding page. Never served evidence (ServedHosts reads Hosts alone).
+ OfflineMoves []holdingView `json:"offline_moves"`
+ // fetchedAt is when this parse was fetched (refreshHosts): the expiry
+ // clock of the file's holding entries. A carried-forward parse keeps
+ // the time it was fetched at.
+ fetchedAt time.Time
}
// dialAddress is where this edge dials f's box (region HA spec 2 §6). With
@@ -244,9 +252,15 @@ type homeSnapshot struct {
files map[string]edgesFileView
// hosts is the fold: host → home, own box already excluded.
hosts map[string]homeEntry
+ // cands is every admitted candidate by name (buildCandidates), the
+ // input of the ranking; platformHolds the names a remote real holding
+ // candidate marks platform, the second half of the wildcard parent
+ // inputs (samePlatformSet's wake).
+ cands map[string][]candidate
+ platformHolds map[string]bool
}
-var emptyHomeSnapshot = &homeSnapshot{files: map[string]edgesFileView{}, hosts: map[string]homeEntry{}}
+var emptyHomeSnapshot = &homeSnapshot{files: map[string]edgesFileView{}, hosts: map[string]homeEntry{}, cands: map[string][]candidate{}}
type homeMap struct {
s release.Store
@@ -302,6 +316,19 @@ type homeMap struct {
// nil without region HA: Refresh latches the first loaded map and key
// and stamps every attempt it completes.
health *lbhealth.State
+
+ // now is the map's clock: the fetch time a poll stamps on each file it
+ // reads, and the ranking time of Home, ExactHome and Owner. nil is
+ // time.Now.
+ now func() time.Time
+}
+
+// clock is the map's time, nil-receiver-safe.
+func (m *homeMap) clock() time.Time {
+ if m != nil && m.now != nil {
+ return m.now()
+ }
+ return time.Now()
}
func newHomeMap(s release.Store, self string) *homeMap {
@@ -361,6 +388,7 @@ func (m *homeMap) refreshHosts(ctx context.Context) error {
}
prev := m.snapshot()
next := make(map[string]edgesFileView, len(keys))
+ fetched := m.clock()
// failed counts both fault classes; unread counts only the objects the
// BUCKET would not hand over, which is the one that says "this is not an
// object fault at all" when it covers everything (see below).
@@ -390,6 +418,7 @@ func (m *homeMap) refreshHosts(ctx context.Context) error {
} else if f, perr := parseEdgesFile(key, body); perr != nil {
err = perr
} else {
+ f.fetchedAt = fetched
next[key] = f
continue
}
@@ -417,22 +446,52 @@ func (m *homeMap) refreshHosts(ctx context.Context) error {
// deterministic; say so once a refresh.
log.Printf("edge: home map: %d host(s) published by two boxes at the same installation stamp, e.g. %s", len(tied), tied[0])
}
- snap := &homeSnapshot{files: next, hosts: hosts}
+ cands := buildCandidates(next, m.self, m.privateDial)
+ snap := &homeSnapshot{files: next, hosts: hosts, cands: cands, platformHolds: platformHolds(cands)}
m.mu.Lock()
m.snap = snap
wake := m.hostsWake
m.mu.Unlock()
- if wake != nil && !samePlatformSet(prev.hosts, snap.hosts) {
+ if wake != nil && (!samePlatformSet(prev.hosts, snap.hosts) || !sameNames(prev.platformHolds, snap.platformHolds)) {
// The deployment's platform name set moved: a wildcard parent may
- // have appeared for an account homed elsewhere (#300). Off this
- // goroutine — the wake re-derives through Hosts() (its own locks)
- // and may touch the registry, and refreshKeys should not wait on
- // that.
+ // have appeared for an account homed elsewhere (#300). The set is
+ // the ready fold's platform names plus the platform real holds, so
+ // a platform hold arriving while the fold is unchanged wakes it
+ // too. Off this goroutine — the wake re-derives through Hosts()
+ // (its own locks) and may touch the registry, and refreshKeys
+ // should not wait on that.
go wake()
}
return nil
}
+// platformHolds is the names a remote real holding candidate marks
+// platform: wildcard parent inputs (Hosts) the ready fold does not carry.
+func platformHolds(cands map[string][]candidate) map[string]bool {
+ out := map[string]bool{}
+ for name, cs := range cands {
+ for _, c := range cs {
+ if c.kind == candHolding && !c.alias && c.platform && !strings.HasPrefix(name, "*.") {
+ out[name] = true
+ }
+ }
+ }
+ return out
+}
+
+// sameNames reports whether two name sets are equal.
+func sameNames(a, b map[string]bool) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for n := range a {
+ if !b[n] {
+ return false
+ }
+ }
+ return true
+}
+
// samePlatformSet reports whether two folds name the same PLATFORM hosts —
// the only names that can move a wildcard parent (homes may differ; an
// unclassed name may come and go).
@@ -511,18 +570,57 @@ func (m *homeMap) WildcardParents() []string {
// remote catch-all is a claim's row, never an account parent. An
// unlabelled box (self == "") reports nothing, as Home does — its own
// file would otherwise fold in as "another box".
+//
+// A held platform row another box publishes (self-serve moves plan 3) is a
+// parent input too, whenever its name's exact resolution is the holding
+// page: the account's parent must stay loaded through the move, or every
+// sibling one-label host under it would lose its certificate here.
func (m *homeMap) Hosts() []string {
if m == nil || m.self == "" {
return nil
}
snap := m.snapshot()
out := make([]string, 0, len(snap.hosts))
+ seen := make(map[string]bool, len(snap.hosts))
for h, e := range snap.hosts {
if !parentInput(h, e) {
continue
}
+ seen[h] = true
out = append(out, h)
}
+ now := m.clock()
+ for h := range snap.platformHolds {
+ if seen[h] {
+ continue
+ }
+ if d := rank(h, snap.cands[h], now); d.kind == decHolding && d.platform {
+ out = append(out, h)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// HoldingHosts lists, sorted, every exact name (hosts and aliases alike)
+// whose exact resolution at now is a live holding page: the remote half of
+// the certificate names the edge keeps warm through a move (certNames). Not
+// served evidence: ServedHosts never includes a hold. Like KnownHost it has
+// no self gate (membership, not a dial target).
+func (m *homeMap) HoldingHosts(now time.Time) []string {
+ if m == nil {
+ return nil
+ }
+ snap := m.snapshot()
+ var out []string
+ for name, cs := range snap.cands {
+ if strings.HasPrefix(name, "*.") || !slices.ContainsFunc(cs, func(c candidate) bool { return c.kind == candHolding }) {
+ continue
+ }
+ if rank(name, cs, now).kind == decHolding {
+ out = append(out, name)
+ }
+ }
sort.Strings(out)
return out
}
@@ -680,31 +778,119 @@ func foldHomes(files map[string]edgesFileView, self string, privateDial bool) (m
hosts[host] = in
continue
}
- // A collision merges CLASSES and picks ONE dial target: the
- // classes are facts about the name (a delegated parent's PEM
- // must load everywhere even if some other box also routes the
- // key), the dial target is a choice. First box in sorted order
- // wins the dial — except that a ROUTE beats an ALIAS whatever
- // the order (#287), any dial target beats none, and stamps and
- // then lifetime pairs order the rest (dialBeats). The owner, the
- // stamp, the pair and the target are part of the dial choice,
- // not classes: they come whole from the winner, so a route that
- // replaces an alias never inherits the alias's owner or pair,
- // even when it publishes none of its own.
- merged := prev
- merged.platform = prev.platform || in.platform
- merged.wildcard = prev.wildcard || in.wildcard
- if dialBeats(in, prev) {
- merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true
- merged.account, merged.app, merged.stamp = in.account, in.app, in.stamp
- merged.incarnation, merged.servingSeq, merged.targetHost = in.incarnation, in.servingSeq, in.targetHost
- }
- hosts[host] = merged
+ hosts[host] = foldEntry(prev, in)
}
}
return hosts, tiedStamps(files)
}
+// foldEntry is the fold's one collision rule, shared by foldHomes and the
+// ranking's ready stage (rank), so the two cannot disagree: in is a later
+// file's entry for a name prev already holds.
+//
+// A collision merges CLASSES and picks ONE dial target: the classes are
+// facts about the name (a delegated parent's PEM must load everywhere even
+// if some other box also routes the key), the dial target is a choice.
+// First box in sorted order wins the dial — except that a ROUTE beats an
+// ALIAS whatever the order (#287), any dial target beats none, and stamps
+// and then lifetime pairs order the rest (dialBeats). The owner, the stamp,
+// the pair and the target are part of the dial choice, not classes: they
+// come whole from the winner, so a route that replaces an alias never
+// inherits the alias's owner or pair, even when it publishes none of its
+// own.
+func foldEntry(prev, in homeEntry) homeEntry {
+ merged := prev
+ merged.platform = prev.platform || in.platform
+ merged.wildcard = prev.wildcard || in.wildcard
+ if dialBeats(in, prev) {
+ merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true
+ merged.account, merged.app, merged.stamp = in.account, in.app, in.stamp
+ merged.incarnation, merged.servingSeq, merged.targetHost = in.incarnation, in.servingSeq, in.targetHost
+ }
+ return merged
+}
+
+// buildCandidates is every candidate the per-box files offer, by name: the
+// ranking's input (rank). The per-file admission is foldHomes', unchanged:
+// sorted file keys (order is a file's index among them), the own box and a
+// box-less file skipped, the dial address dialAddress's (so same-region
+// private dialling is kept), a "home" entry with no address a certificate
+// entry only when it is a marked catch-all, the cert-only "wildcard" role
+// only on a marked catch-all, and every other role skipped.
+//
+// A file's offline_moves entries (self-serve moves plan 3) need what a dial
+// target needs, an address: each is one real holding candidate for its host
+// and one alias holding candidate per listed alias, all of the entry's
+// owner and pair, never platform for an alias, and never stamped.
+func buildCandidates(files map[string]edgesFileView, self string, privateDial bool) map[string][]candidate {
+ keys := make([]string, 0, len(files))
+ for k := range files {
+ keys = append(keys, k)
+ }
+ slices.Sort(keys)
+ var own edgesFileView
+ ownKnown := false
+ for _, k := range keys {
+ if f := files[k]; self != "" && f.Box == self {
+ own, ownKnown = f, true
+ }
+ }
+ out := make(map[string][]candidate)
+ for order, k := range keys {
+ f := files[k]
+ if f.Box == "" || f.Box == self {
+ continue
+ }
+ addr := dialAddress(f, own, ownKnown, privateDial)
+ for host, h := range f.Hosts {
+ if host == "" {
+ continue
+ }
+ catchAll := h.Wildcard && strings.HasPrefix(host, "*.")
+ switch {
+ case h.Role == edgeRoleHome && addr != "":
+ c := candidate{kind: candReady, order: order, eip: addr, box: f.Box, account: h.Account, app: h.App,
+ alias: h.Alias, wildcard: h.Wildcard, platform: h.Platform && !h.Alias && !h.Wildcard,
+ targetHost: host, incarnation: h.Incarnation, servingSeq: h.ServingSeq, stamp: h.Stamp, fetchedAt: f.fetchedAt}
+ if h.Alias {
+ c.targetHost = h.TargetHost
+ }
+ out[host] = append(out[host], c)
+ case (h.Role == edgeRoleHome || h.Role == edgeRoleWildcard) && catchAll:
+ // The certificate half of a marked catch-all: a home entry
+ // with nothing to dial, or the cert-only role. Constructed
+ // here and nowhere else.
+ out[host] = append(out[host], candidate{kind: candCert, order: order, box: f.Box, wildcard: true,
+ platform: h.Platform && !h.Alias && !h.Wildcard, fetchedAt: f.fetchedAt})
+ }
+ }
+ if addr == "" {
+ continue
+ }
+ for i := range f.OfflineMoves {
+ entry := f.OfflineMoves[i]
+ if entry.Host == "" || strings.HasPrefix(entry.Host, "*.") {
+ continue
+ }
+ hold := &entry
+ base := candidate{kind: candHolding, order: order, eip: addr, box: f.Box, account: entry.Account, app: entry.App,
+ targetHost: entry.Host, incarnation: entry.Incarnation, servingSeq: entry.ServingSeq, fetchedAt: f.fetchedAt, hold: hold}
+ real := base
+ real.platform = entry.Platform
+ out[entry.Host] = append(out[entry.Host], real)
+ for _, a := range entry.Aliases {
+ if a == "" || strings.HasPrefix(a, "*.") || a == entry.Host {
+ continue
+ }
+ alias := base
+ alias.alias = true
+ out[a] = append(out[a], alias)
+ }
+ }
+ }
+ return out
+}
+
// FoldedHome folds raw edges-file bodies, keyed by bucket key, as
// refreshHosts folds one poll (parseEdgesFile, then foldHomes with the
// private dial off) and answers the box whose entry won host's dial, as an
@@ -837,6 +1023,12 @@ func (m *homeMap) snapshot() *homeSnapshot {
// "nothing". The self case is fail-closed on purpose: without knowing which
// box it is, this edge cannot exclude its own hosts, and an edge that cannot
// exclude itself must not be handing out dial targets at all.
+//
+// Since self-serve moves plan 3 the answer is the request resolution's
+// (resolveRequest): the exact name's ranking, then its "*.<parent>" only
+// when the exact name has no answer at all, so a live holding page for the
+// exact name blocks the catch-all. Only a remote ready winner is a dial
+// target; a holding winner is not (Edge.resolve serves its page).
func (m *homeMap) Home(host string) (eip, box string, ok bool) {
h, ok := m.lookupHome(host)
if !ok {
@@ -846,16 +1038,17 @@ func (m *homeMap) Home(host string) (eip, box string, ok bool) {
}
// ExactHome is Home without the one-label wildcard fallback: the dial target
-// another box published for exactly host. The edge asks it when its own
-// answer for host came only through a local "*.<parent>" key, so that an
-// exact name served elsewhere still wins (ServeHTTP). Fail-closed without a
-// self label, exactly as Home is.
+// another box published for exactly host (resolveExact's remote ready
+// winner). An exact name served elsewhere still beats this box's own
+// "*.<parent>": the request path gets that from resolve, which ranks the
+// exact name before the catch-all. Fail-closed without a self label,
+// exactly as Home is.
func (m *homeMap) ExactHome(host string) (eip, box string, ok bool) {
if m == nil || m.self == "" {
return "", "", false
}
- if h, found := m.snapshot().hosts[host]; found && h.dial {
- return h.eip, h.box, true
+ if d := m.resolveExact(host, m.clock()); d.kind == decRemote {
+ return d.eip, d.box, true
}
return "", "", false
}
@@ -867,16 +1060,41 @@ func (m *homeMap) lookupHome(host string) (homeEntry, bool) {
if m == nil || m.self == "" {
return homeEntry{}, false
}
- hosts := m.snapshot().hosts
- if h, ok := hosts[host]; ok && h.dial {
- return h, true
+ d := m.resolveRequest(host, m.clock())
+ if d.kind != decRemote {
+ return homeEntry{}, false
}
- if i := strings.IndexByte(host, '.'); i > 0 {
- if h, ok := hosts["*"+host[i:]]; ok && h.dial {
- return h, true
- }
+ return homeEntry{eip: d.eip, box: d.box, account: d.account, app: d.app, stamp: d.stamp, dial: true}, true
+}
+
+// resolveExact ranks name's own candidates at now: the exact question,
+// never a catch-all. It does not apply the self gate; the dialling callers
+// do (Home, ExactHome, Owner, and Edge.resolve, which takes no remote
+// candidate on an unlabelled box), while the membership queries (KnownHost,
+// HoldingHosts) look at what the fold admitted, as they always have.
+func (m *homeMap) resolveExact(name string, now time.Time) decision {
+ if m == nil {
+ return decision{}
}
- return homeEntry{}, false
+ return rank(name, m.snapshot().cands[name], now)
+}
+
+// resolveRequest is the request's question: name's exact ranking, and only
+// when that has no answer at all (no ready winner, no live holding winner)
+// the ranking of its one-label "*.<parent>" key. Both from one snapshot.
+func (m *homeMap) resolveRequest(name string, now time.Time) decision {
+ if m == nil {
+ return decision{}
+ }
+ snap := m.snapshot()
+ d := rank(name, snap.cands[name], now)
+ if d.kind != decNone {
+ return d
+ }
+ if key := wildcardKey(name); key != "" {
+ return rank(key, snap.cands[key], now)
+ }
+ return d
}
// Owner answers, from one read of one snapshot, both whom the edge-local
@@ -929,12 +1147,20 @@ func (m *homeMap) Owner(host string) (account, app, eip, box string, ok bool) {
//
// Nil-receiver-safe, like Home: the map may not be armed yet (or at all, on a
// box with no deployment store).
-func (m *homeMap) KnownHost(host string) bool {
+//
+// Since self-serve moves plan 3 membership is the exact resolution at now:
+// a ready winner or a live holding winner (a held row's host or explicit
+// alias another box publishes, which still needs its certificate). An
+// expired hold with nothing behind it is no longer a member.
+func (m *homeMap) KnownHost(host string, now time.Time) bool {
if m == nil || strings.HasPrefix(host, "*.") {
return false
}
- _, ok := m.snapshot().hosts[host]
- return ok
+ switch m.resolveExact(host, now).kind {
+ case decRemote, decHolding:
+ return true
+ }
+ return false
}
// refreshKeys re-reads the mesh key pair. loadMeshKeys is already tolerant of
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go
index 5fd4e03f8..06938ba56 100644
--- a/internal/edge/homemap_test.go
+++ b/internal/edge/homemap_test.go
@@ -236,7 +236,7 @@ func TestNilHomeMapIsInert(t *testing.T) {
if _, _, ok := m.Home("b.test"); ok {
t.Fatal("a nil home map must offer nothing")
}
- if m.KnownHost("b.test") {
+ if m.KnownHost("b.test", time.Now()) {
t.Fatal("a nil home map knows no host")
}
if cur, next := m.meshKeys(); cur != nil || next != nil {
@@ -260,7 +260,7 @@ func TestKnownHostExactOnly(t *testing.T) {
m := newHomeMap(s, "annie")
refreshHomeMap(t, m)
- if !m.KnownHost("exact.apps.test") {
+ if !m.KnownHost("exact.apps.test", time.Now()) {
t.Fatal("an exactly-named remote-homed host is the whole point of the predicate")
}
// Precondition: the fallback Home applies is live on this very map — so
@@ -268,22 +268,22 @@ func TestKnownHostExactOnly(t *testing.T) {
if _, _, ok := m.Home("invented.apps.test"); !ok {
t.Fatal("test bug: the one-label wildcard rule did not apply")
}
- if m.KnownHost("invented.apps.test") {
+ if m.KnownHost("invented.apps.test", time.Now()) {
t.Fatal("a label under a mirrored catch-all must never be admitted — that is #180's rule")
}
- if m.KnownHost("*.apps.test") {
+ if m.KnownHost("*.apps.test", time.Now()) {
t.Fatal("the catch-all key itself is not a certifiable name")
}
- if m.KnownHost("stranger.example") {
+ if m.KnownHost("stranger.example", time.Now()) {
t.Fatal("a host no box in the deployment homes is a stranger")
}
// Own-box rows are excluded by the fold and stay excluded here. Harmless:
// deploymentHost's other half (Allowed) is what admits this box's routes.
- if m.KnownHost("own.test") {
+ if m.KnownHost("own.test", time.Now()) {
t.Fatal("the fold excludes this box's own file, so KnownHost inherits that exclusion")
}
// Nothing polled yet: no membership, and no panic on the gate's path.
- if newHomeMap(s, "annie").KnownHost("exact.apps.test") {
+ if newHomeMap(s, "annie").KnownHost("exact.apps.test", time.Now()) {
t.Fatal("a map that has never polled knows no host")
}
}
@@ -296,7 +296,7 @@ func TestKnownHostSkipsBoxesWithNoEIP(t *testing.T) {
putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"","hosts":{"b.test":{"role":"home"}}}`)
m := newHomeMap(s, "annie")
refreshHomeMap(t, m)
- if m.KnownHost("b.test") {
+ if m.KnownHost("b.test", time.Now()) {
t.Fatal("a box with no public IP offers no validation target")
}
}
diff --git a/internal/edge/path_test.go b/internal/edge/path_test.go
index b6c48e3ba..ddfafec22 100644
--- a/internal/edge/path_test.go
+++ b/internal/edge/path_test.go
@@ -348,7 +348,7 @@ func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
}
// TestPathRoutedRequestMetersAgainstTheTarget pins the attribution ruling
-// documented on lookupUpstreamPath: a path hit returns the TARGET's upstream
+// documented on selectUpstream: a path hit returns the TARGET's upstream
// entry wholesale, so the usage meter bills the target's route, account and
// app — not the hostname the browser asked for. That is correct (the target's
// backend burned the bytes) and it is easy to "fix" the wrong way later, so
@@ -403,7 +403,7 @@ func TestPathRoutedRequestMetersAgainstTheTarget(t *testing.T) {
// TestFirstSegment is the matcher's two halves, tested directly because every
// routing decision in this feature rests on them. Both columns are pinned for
// every row, INCLUDING the rows where they disagree — the disagreements are
-// the whole reason lookupUpstreamPath consults both (see its match block).
+// the whole reason selectUpstream consults both (see its match block).
func TestFirstSegment(t *testing.T) {
for _, tc := range []struct{ in, wantRaw, wantClean string }{
// Ordinary shapes: the two halves agree, and this is every real
diff --git a/internal/edge/rank.go b/internal/edge/rank.go
new file mode 100644
index 000000000..a6cd354ea
--- /dev/null
+++ b/internal/edge/rank.go
@@ -0,0 +1,246 @@
+package edge
+
+// The ranking (self-serve moves plan 3, design §6.4): every edge keeps every
+// candidate it knows for a name (ready routes, holding entries and
+// certificate-only entries; this box's own and other boxes') and decides a
+// request by one function, rank.
+//
+// A holding candidate's only power over ready candidates is to SUPPRESS the
+// stale ready publications of its own target: an unstamped ready candidate
+// naming the same non-empty target with a lower lifetime pair. Whatever ready
+// candidates survive are decided exactly as they are today: a local one first
+// (the upstream table), else the remote ones through the fold foldHomes uses
+// (foldEntry, over dialBeats). Only when no ready candidate survives does a
+// hold answer, and then by a sorted fold of its own. With no live hold the
+// answer is therefore today's by construction.
+
+import (
+ "slices"
+ "strings"
+ "time"
+)
+
+// holdingValidity is how long a remote holding candidate is believed after
+// the poll that last fetched its file: at exactly five minutes it is expired.
+// A local hold is never expired by this rule; it lives as long as the
+// box's own view says so.
+const holdingValidity = 5 * time.Minute
+
+type candKind uint8
+
+const (
+ // candReady is a route or an alias some box serves.
+ candReady candKind = iota + 1
+ // candHolding is a held offline-move row (or one of its explicit
+ // aliases): the name answers the holding page.
+ candHolding
+ // candCert is a certificate-only catch-all entry: a class, never an
+ // answer.
+ candCert
+)
+
+// candidate is one thing a box says about a name.
+type candidate struct {
+ kind candKind
+ // local marks this box's own candidate (the upstream table or the local
+ // hold snapshot); order is the publishing file's index in sorted key
+ // order, -1 for a local one.
+ local bool
+ order int
+ // eip and box are where a remote candidate is dialled.
+ eip, box string
+ // account and app are the owner the candidate names.
+ account, app string
+ // The classes are facts about the name, OR'd over every candidate.
+ // An alias candidate never carries platform.
+ alias, wildcard, platform bool
+ // targetHost is the host the candidate is a lifetime of: the route's own
+ // name, an alias's primary (empty for a legacy alias publication that
+ // names none), a hold's held host.
+ targetHost string
+ // incarnation and servingSeq are the lifetime pair, stamp the claim
+ // stamp (holds and local candidates are never stamped).
+ incarnation, servingSeq uint64
+ stamp int64
+ // fetchedAt is when the publishing file was last fetched (remote only):
+ // the expiry clock of a remote hold.
+ fetchedAt time.Time
+ // hold is a holding candidate's page inputs.
+ hold *holdingView
+ // up and table are a local ready candidate's upstream and the table it
+ // came from.
+ up *upstream
+ table map[string]upstream
+}
+
+type decKind uint8
+
+const (
+ decNone decKind = iota
+ decLocal
+ decRemote
+ decHolding
+)
+
+// decision is a name's answer, resolved once per request: everything the
+// request path needs comes from it, so the table, the owner and the dial
+// cannot be read twice and disagree.
+type decision struct {
+ kind decKind
+ // up and table (decLocal): the upstream and the table it was resolved
+ // in, which path selection and the login re-resolution read.
+ up *upstream
+ table map[string]upstream
+ // eip, box, account, app and stamp (decRemote): the dial and its owner.
+ eip, box, account, app string
+ stamp int64
+ // platform and wildcard are the name's classes.
+ platform, wildcard bool
+ // hold (decHolding): the page's inputs and owner.
+ hold *holdingView
+ // fallback (Edge.resolve, decRemote only): this box's own "*.<parent>"
+ // upstream, captured from the same table, which keeps today's dispatch
+ // exceptions (an accepted hop, a door request, a backhaul with no mesh
+ // key).
+ fallback *localFallback
+}
+
+// localFallback is a local wildcard upstream and the table it came from.
+type localFallback struct {
+ up *upstream
+ table map[string]upstream
+}
+
+// pairLess reports whether (ai, as) is lower than (bi, bs).
+func pairLess(ai, as, bi, bs uint64) bool {
+ if ai != bi {
+ return ai < bi
+ }
+ return as < bs
+}
+
+// suppresses reports whether hold h removes ready candidate c: c is an
+// unstamped publication of h's own (non-empty) target with a lower pair.
+func suppresses(h, c candidate) bool {
+ return c.targetHost != "" && c.targetHost == h.targetHost && c.stamp == 0 &&
+ pairLess(c.incarnation, c.servingSeq, h.incarnation, h.servingSeq)
+}
+
+// entry is a remote ready candidate as the fold sees it.
+func (c candidate) entry() homeEntry {
+ return homeEntry{eip: c.eip, box: c.box, platform: c.platform, alias: c.alias, wildcard: c.wildcard, dial: true,
+ account: c.account, app: c.app, stamp: c.stamp, incarnation: c.incarnation, servingSeq: c.servingSeq, targetHost: c.targetHost}
+}
+
+// rank decides name from its candidates at now.
+//
+// 1. remote holds fetched holdingValidity or longer ago are dropped;
+// 2. the classes are OR'd over what remains; certificate entries never
+// decide, so a name with nothing else is decNone;
+// 3. every remaining hold suppresses the ready candidates it suppresses
+// (suppresses) and nothing else;
+// 4. a surviving local ready candidate decides (decLocal); else the remote
+// survivors are folded in order by foldEntry, the fold foldHomes uses,
+// and its winner decides (decRemote);
+// 5. with no ready survivor, the holds are folded in order (local first): a
+// later one replaces the incumbent when it is real and the incumbent an
+// alias, or when both are of one kind, share a non-empty target and its
+// pair is higher. The winner's page answers (decHolding).
+func rank(name string, cands []candidate, now time.Time) decision {
+ live := make([]candidate, 0, len(cands))
+ for _, c := range cands {
+ if c.kind == candHolding && !c.local && now.Sub(c.fetchedAt) >= holdingValidity {
+ continue
+ }
+ live = append(live, c)
+ }
+ // Sorted by order, local (-1) first; within one order (one file), a real
+ // candidate before an alias, so the answer never depends on the order
+ // the candidates were collected in.
+ slices.SortStableFunc(live, func(a, b candidate) int {
+ if a.order != b.order {
+ return a.order - b.order
+ }
+ if a.alias != b.alias {
+ if a.alias {
+ return 1
+ }
+ return -1
+ }
+ return 0
+ })
+ var d decision
+ decides := false
+ var holds []candidate
+ for _, c := range live {
+ d.platform = d.platform || c.platform
+ d.wildcard = d.wildcard || c.wildcard
+ switch c.kind {
+ case candReady:
+ decides = true
+ case candHolding:
+ decides = true
+ holds = append(holds, c)
+ }
+ }
+ if !decides {
+ return d
+ }
+ var local *candidate
+ var remote []candidate
+ for i, c := range live {
+ if c.kind != candReady {
+ continue
+ }
+ if slices.ContainsFunc(holds, func(h candidate) bool { return suppresses(h, c) }) {
+ continue
+ }
+ if c.local {
+ if local == nil {
+ local = &live[i]
+ }
+ continue
+ }
+ remote = append(remote, c)
+ }
+ switch {
+ case local != nil:
+ d.kind, d.up, d.table = decLocal, local.up, local.table
+ return d
+ case len(remote) > 0:
+ w := foldReady(remote)
+ d.kind = decRemote
+ d.eip, d.box, d.account, d.app, d.stamp = w.eip, w.box, w.account, w.app, w.stamp
+ return d
+ }
+ inc := holds[0]
+ for _, c := range holds[1:] {
+ if (!c.alias && inc.alias) ||
+ (c.alias == inc.alias && c.targetHost != "" && c.targetHost == inc.targetHost &&
+ pairLess(inc.incarnation, inc.servingSeq, c.incarnation, c.servingSeq)) {
+ inc = c
+ }
+ }
+ d.kind, d.hold = decHolding, inc.hold
+ return d
+}
+
+// foldReady folds remote ready candidates, already in order, exactly as
+// foldHomes folds one host's entries across files.
+func foldReady(remote []candidate) homeEntry {
+ w := remote[0].entry()
+ for _, c := range remote[1:] {
+ w = foldEntry(w, c.entry())
+ }
+ return w
+}
+
+// wildcardKey is the one-label catch-all key a request host falls back to,
+// lookupIn's rule: the leading dot of the first label swapped for "*", or ""
+// for a name with no dot after its first character.
+func wildcardKey(host string) string {
+ if i := strings.IndexByte(host, '.'); i > 0 {
+ return "*" + host[i:]
+ }
+ return ""
+}
diff --git a/internal/edge/rank_test.go b/internal/edge/rank_test.go
new file mode 100644
index 000000000..1cf5f83e9
--- /dev/null
+++ b/internal/edge/rank_test.go
@@ -0,0 +1,271 @@
+package edge
+
+// The ranking's own witnesses (self-serve moves plan 3 Task 2): what a
+// holding candidate may do to ready candidates (suppress the stale ready
+// publications of its own target, nothing else), that the ready survivors
+// are decided by the fold foldHomes uses, and that the holding stage is a
+// sorted fold. Task 3 runs the invariants over the whole small model; these
+// are the named cases.
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+)
+
+// rankNow is the clock every candidate below was fetched at.
+var rankNow = time.Date(2026, 10, 24, 9, 0, 0, 0, time.UTC)
+
+const rankHost = "shop.example.com"
+
+// rready is a remote ready candidate for rankHost published by box at
+// order: a route unless alias, whose target is target.
+func rready(order int, box, target string, alias bool, inc, seq uint64, stamp int64) candidate {
+ return candidate{kind: candReady, order: order, eip: box + "-ip", box: box, account: "bab", app: "shop",
+ alias: alias, targetHost: target, incarnation: inc, servingSeq: seq, stamp: stamp, fetchedAt: rankNow}
+}
+
+// rhold is a remote holding candidate for target published by box at
+// order: the real one unless alias.
+func rhold(order int, box, target string, alias bool, inc, seq uint64) candidate {
+ h := &holdingView{Host: target, Account: "bab", App: "shop", MoveID: "mv_" + box, Incarnation: inc, ServingSeq: seq}
+ return candidate{kind: candHolding, order: order, eip: box + "-ip", box: box, account: "bab", app: "shop",
+ alias: alias, targetHost: target, incarnation: inc, servingSeq: seq, fetchedAt: rankNow, hold: h}
+}
+
+func wantRemote(t *testing.T, d decision, box, why string) {
+ t.Helper()
+ if d.kind != decRemote || d.box != box {
+ t.Fatalf("decision = %v box %q, want decRemote at %q: %s", d.kind, d.box, box, why)
+ }
+}
+
+func wantHolding(t *testing.T, d decision, move, why string) {
+ t.Helper()
+ if d.kind != decHolding || d.hold == nil || d.hold.MoveID != move {
+ got := ""
+ if d.hold != nil {
+ got = d.hold.MoveID
+ }
+ t.Fatalf("decision = %v hold %q, want decHolding of %q: %s", d.kind, got, move, why)
+ }
+}
+
+func TestHoldingViewDecodesTheWriterFixture(t *testing.T) {
+ body, err := os.ReadFile("../edgesfile/testdata/offline-moves.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, err := parseEdgesFile("control/edges/annie.json", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(f.OfflineMoves) != 1 {
+ t.Fatalf("offline_moves = %d entries, want 1", len(f.OfflineMoves))
+ }
+ got := f.OfflineMoves[0]
+ want := holdingView{Host: "cart.bab.example.net", Platform: true, Account: "bab", App: "shop",
+ Aliases: []string{"cart.example.com"}, MoveID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa",
+ Incarnation: 1, ServingSeq: 1, OfflineAt: 1792832400, EstimateSeconds: 600, DisplayName: "Shop"}
+ gj, _ := json.Marshal(got)
+ wj, _ := json.Marshal(want)
+ if string(gj) != string(wj) {
+ t.Fatalf("decoded %s, want %s", gj, wj)
+ }
+ // The holding entry is not a served host: hosts keeps its own two.
+ if _, ok := f.Hosts["cart.bab.example.net"]; ok {
+ t.Fatal("a held row must not appear in hosts")
+ }
+}
+
+func TestRankSuppressionAndFold(t *testing.T) {
+ t.Run("a hold suppresses only its target's lower unstamped pairs", func(t *testing.T) {
+ h := rhold(1, "betty", rankHost, false, 1, 1)
+ wantHolding(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 1, 0, 0), h}, rankNow),
+ "mv_betty", "an unstamped (1,0) of the moving target is its stale publication")
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 1, 0, 5), h}, rankNow),
+ "annie", "a stamped ready is never suppressed")
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", "other.example.com", true, 1, 0, 0), h}, rankNow),
+ "annie", "a ready of another target is not the hold's to suppress")
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", "", true, 1, 0, 0), h}, rankNow),
+ "annie", "a ready with no target (a legacy alias) is a different target")
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 1, 1, 0), h}, rankNow),
+ "annie", "an equal pair survives")
+ wantRemote(t, rank(rankHost, []candidate{rready(2, "carol", rankHost, false, 1, 2, 0), h}, rankNow),
+ "carol", "a higher pair survives and beats every hold")
+ })
+ t.Run("a retained unstamped (1,2) against a stamped (0,0) with no hold is the fold's", func(t *testing.T) {
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 1, 2, 0), rready(1, "betty", rankHost, false, 0, 0, 7)}, rankNow),
+ "betty", "a stamp outranks any pair (dialBeats)")
+ })
+ t.Run("the three-hold sorted fold", func(t *testing.T) {
+ d := rank(rankHost, []candidate{
+ rhold(2, "annie-new", rankHost, true, 1, 2),
+ rhold(0, "annie-old", rankHost, true, 1, 1),
+ rhold(1, "betty", "cart.example.net", true, 1, 5),
+ }, rankNow)
+ wantHolding(t, d, "mv_annie-new", "A-new replaces A-old (same kind, same target, higher pair); B's other target never displaces the incumbent")
+ })
+ t.Run("an old hold, a ready and a current hold of one target", func(t *testing.T) {
+ d := rank(rankHost, []candidate{
+ rhold(0, "annie", rankHost, false, 1, 1),
+ rready(1, "betty", rankHost, false, 1, 2, 0),
+ rhold(2, "carol", rankHost, false, 2, 1),
+ }, rankNow)
+ wantHolding(t, d, "mv_carol", "the (2,1) hold suppresses the (1,2) ready and wins the holding fold")
+ })
+ t.Run("F1: four candidates, two boxes", func(t *testing.T) {
+ d := rank(rankHost, []candidate{
+ rready(0, "annie", rankHost, false, 1, 0, 0), rhold(0, "annie", rankHost, false, 1, 1),
+ rready(1, "betty", rankHost, false, 1, 0, 0), rhold(1, "betty", rankHost, false, 1, 1),
+ }, rankNow)
+ wantHolding(t, d, "mv_annie", "both readies are suppressed and the lower-order hold is the incumbent")
+ })
+ t.Run("F2: three publications and no hold, as foldHomes", func(t *testing.T) {
+ files := map[string]edgesFileView{
+ "control/edges/a-old.json": file("annie-old", "1.1.1.1", map[string]edgesHostView{rankHost: {Role: "home", Incarnation: 1}}),
+ "control/edges/b.json": file("betty", "2.2.2.2", map[string]edgesHostView{rankHost: {Role: "home", Incarnation: 1}}),
+ "control/edges/c-new.json": file("annie-new", "3.3.3.3", map[string]edgesHostView{rankHost: {Role: "home", Incarnation: 1, ServingSeq: 2}}),
+ }
+ folded, _ := foldHomes(files, "zed", false)
+ d := rank(rankHost, buildCandidates(files, "zed", false)[rankHost], rankNow)
+ wantRemote(t, d, "annie-new", "the higher pair wins")
+ if d.box != folded[rankHost].box || d.eip != folded[rankHost].eip {
+ t.Fatalf("rank %s/%s, foldHomes %s/%s: with no hold the ready stage is the fold", d.box, d.eip, folded[rankHost].box, folded[rankHost].eip)
+ }
+ })
+ t.Run("a stamped alias against a real hold is the stamped alias (§6.4)", func(t *testing.T) {
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, true, 1, 0, 9), rhold(1, "betty", rankHost, false, 1, 1)}, rankNow),
+ "annie", "a stamped ready is eligible under ordinary routing")
+ })
+ t.Run("a real ready beats an alias ready whatever the pairs and stamps (#287)", func(t *testing.T) {
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, true, 9, 9, 9), rready(1, "betty", rankHost, false, 0, 0, 0)}, rankNow),
+ "betty", "the fold's own rule")
+ })
+ t.Run("an expired remote hold neither suppresses nor decides", func(t *testing.T) {
+ old := rhold(1, "betty", rankHost, false, 1, 1)
+ old.fetchedAt = rankNow.Add(-holdingValidity)
+ wantRemote(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 1, 0, 0), old}, rankNow),
+ "annie", "at exactly five minutes the hold is expired")
+ if d := rank(rankHost, []candidate{old}, rankNow); d.kind != decNone {
+ t.Fatalf("an expired hold alone = %v, want decNone", d.kind)
+ }
+ fresh := old
+ fresh.fetchedAt = rankNow.Add(-holdingValidity + time.Second)
+ wantHolding(t, rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 1, 0, 0), fresh}, rankNow),
+ "mv_betty", "at 4 m 59 s it is believed")
+ })
+ t.Run("a surviving local ready wins over every remote candidate and every hold", func(t *testing.T) {
+ up := &upstream{}
+ local := candidate{kind: candReady, local: true, order: -1, targetHost: rankHost, incarnation: 2, up: up, table: map[string]upstream{rankHost: *up}}
+ d := rank(rankHost, []candidate{rready(0, "annie", rankHost, false, 3, 0, 9), rhold(1, "betty", rankHost, false, 1, 1), local}, rankNow)
+ if d.kind != decLocal || d.up != up {
+ t.Fatalf("decision = %v, want decLocal with the local upstream", d.kind)
+ }
+ })
+ t.Run("classes are OR'd, the owner is the winner's", func(t *testing.T) {
+ a := rready(0, "annie", rankHost, false, 1, 0, 0)
+ a.platform = true
+ a.account, a.app = "bab", "old"
+ b := rready(1, "betty", rankHost, false, 1, 2, 0)
+ b.account, b.app = "bab", "new"
+ cert := candidate{kind: candCert, order: 2, wildcard: true}
+ d := rank(rankHost, []candidate{a, b, cert}, rankNow)
+ if d.kind != decRemote || d.box != "betty" || d.app != "new" || !d.platform || !d.wildcard {
+ t.Fatalf("decision = %+v, want betty's owner with both classes", d)
+ }
+ if d := rank(rankHost, []candidate{cert}, rankNow); d.kind != decNone || !d.wildcard {
+ t.Fatalf("a cert-only name = %v (wildcard %v), want decNone carrying its class", d.kind, d.wildcard)
+ }
+ })
+}
+
+func TestRemoteCandidatesKeepTheAdmissions(t *testing.T) {
+ hold := []holdingView{{Host: "cart.bab.example.net", Platform: true, Aliases: []string{"cart.example.com"}, MoveID: "mv_1", Incarnation: 1, ServingSeq: 1}}
+ t.Run("own box excluded", func(t *testing.T) {
+ f := file("annie", "1.1.1.1", map[string]edgesHostView{"a.example.com": {Role: "home"}})
+ f.OfflineMoves = hold
+ if c := buildCandidates(map[string]edgesFileView{"control/edges/annie.json": f}, "annie", false); len(c) != 0 {
+ t.Fatalf("own file yielded %v", c)
+ }
+ })
+ t.Run("a file with no box skipped", func(t *testing.T) {
+ f := file("", "1.1.1.1", map[string]edgesHostView{"a.example.com": {Role: "home"}})
+ f.OfflineMoves = hold
+ if c := buildCandidates(map[string]edgesFileView{"control/edges/x.json": f}, "zed", false); len(c) != 0 {
+ t.Fatalf("a box-less file yielded %v", c)
+ }
+ })
+ t.Run("unknown roles skipped, cert-only wildcards explicit", func(t *testing.T) {
+ f := file("betty", "2.2.2.2", map[string]edgesHostView{
+ "read.example.com": {Role: "read"},
+ "blank.example.com": {},
+ "exact.example.com": {Role: "wildcard", Wildcard: true},
+ "*.cert.example.com": {Role: "wildcard", Wildcard: true},
+ "*.free.example.com": {Role: "wildcard"},
+ })
+ c := buildCandidates(map[string]edgesFileView{"control/edges/betty.json": f}, "zed", false)
+ if len(c) != 1 || len(c["*.cert.example.com"]) != 1 || c["*.cert.example.com"][0].kind != candCert {
+ t.Fatalf("candidates = %v, want only the marked catch-all's certificate entry", c)
+ }
+ })
+ t.Run("a file without an address yields cert-only wildcards and no holds", func(t *testing.T) {
+ f := file("betty", "", map[string]edgesHostView{
+ "a.example.com": {Role: "home", Platform: true},
+ "*.cert.example.com": {Role: "home", Wildcard: true},
+ })
+ f.OfflineMoves = hold
+ c := buildCandidates(map[string]edgesFileView{"control/edges/betty.json": f}, "zed", false)
+ if len(c) != 1 || c["*.cert.example.com"][0].kind != candCert || !c["*.cert.example.com"][0].wildcard {
+ t.Fatalf("candidates = %v, want the one certificate entry", c)
+ }
+ })
+ t.Run("holds and their aliases", func(t *testing.T) {
+ f := file("betty", "2.2.2.2", nil)
+ f.OfflineMoves = hold
+ c := buildCandidates(map[string]edgesFileView{"control/edges/betty.json": f}, "zed", false)
+ real, alias := c["cart.bab.example.net"], c["cart.example.com"]
+ if len(real) != 1 || real[0].kind != candHolding || real[0].alias || !real[0].platform || real[0].targetHost != "cart.bab.example.net" || real[0].stamp != 0 {
+ t.Fatalf("real hold = %+v", real)
+ }
+ if len(alias) != 1 || alias[0].kind != candHolding || !alias[0].alias || alias[0].platform || alias[0].targetHost != "cart.bab.example.net" || alias[0].hold != real[0].hold {
+ t.Fatalf("alias hold = %+v", alias)
+ }
+ if real[0].eip != "2.2.2.2" || real[0].servingSeq != 1 {
+ t.Fatalf("hold dial and pair = %s (%d,%d)", real[0].eip, real[0].incarnation, real[0].servingSeq)
+ }
+ })
+ t.Run("same-region private dialling is retained", func(t *testing.T) {
+ own := file("annie", "1.1.1.1", nil)
+ own.Region, own.Placement, own.PrivateCIDR = "eu-west-1", "", "10.0.0.0/16"
+ far := file("betty", "2.2.2.2", map[string]edgesHostView{"b.example.com": {Role: "home"}})
+ far.Region, far.PrivateIP = "eu-west-1", "10.0.3.4"
+ far.OfflineMoves = hold
+ files := map[string]edgesFileView{"control/edges/annie.json": own, "control/edges/betty.json": far}
+ c := buildCandidates(files, "annie", true)
+ if got := c["b.example.com"][0].eip; got != "10.0.3.4" {
+ t.Fatalf("dial = %s, want the private address", got)
+ }
+ if got := c["cart.bab.example.net"][0].eip; got != "10.0.3.4" {
+ t.Fatalf("hold dial = %s, want the private address", got)
+ }
+ if got := buildCandidates(files, "annie", false)["b.example.com"][0].eip; got != "2.2.2.2" {
+ t.Fatalf("dial with the switch off = %s, want the EIP", got)
+ }
+ })
+ t.Run("ready routes and aliases carry their targets and pairs", func(t *testing.T) {
+ f := file("betty", "2.2.2.2", map[string]edgesHostView{
+ "r.example.com": {Role: "home", Platform: true, Incarnation: 2, ServingSeq: 3, Stamp: 4},
+ "a.example.com": {Role: "home", Alias: true, Platform: true, TargetHost: "r.example.com"},
+ })
+ c := buildCandidates(map[string]edgesFileView{"control/edges/betty.json": f}, "zed", false)
+ r, a := c["r.example.com"][0], c["a.example.com"][0]
+ if r.kind != candReady || r.targetHost != "r.example.com" || !r.platform || r.incarnation != 2 || r.servingSeq != 3 || r.stamp != 4 {
+ t.Fatalf("route = %+v", r)
+ }
+ if a.targetHost != "r.example.com" || a.platform || !a.alias {
+ t.Fatalf("alias = %+v: an alias is never a platform class", a)
+ }
+ })
+}
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go
index 9bc2fa747..056405cc6 100644
--- a/internal/edge/wildcard_parents_test.go
+++ b/internal/edge/wildcard_parents_test.go
@@ -410,7 +410,7 @@ func TestHomeMapWildcardCollisionMergesClasses(t *testing.T) {
if got := m2.WildcardParents(); !reflect.DeepEqual(got, []string{"noeip.example"}) {
t.Fatalf("EIP-less file: WildcardParents = %v", got)
}
- if m2.KnownHost("victim.example") || m2.KnownHost("served.example") {
+ if m2.KnownHost("victim.example", time.Now()) || m2.KnownHost("served.example", time.Now()) {
t.Fatal("an exact key from an EIP-less file or with role wildcard was admitted")
}
if _, _, ok := m2.Home("x.noeip.example"); ok {