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>
11 files changed,
+2304
−143
internal/edge/certwarm.go+6 −0internal/edge/decision_test.go+631 −0internal/edge/edge.go+218 −88internal/edge/holding.go+316 −0internal/edge/holding_test.go+335 −0internal/edge/homemap.go+270 −44internal/edge/homemap_test.go+8 −8internal/edge/path_test.go+2 −2internal/edge/rank.go+246 −0internal/edge/rank_test.go+271 −0internal/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, "<b>Shop</b> 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 { |