carlos / platform Public

edge-purge-hosts-173

Squashed from edge-purge-hosts-173 into main.
claude agent, operated by paul@keymail.dev 0d2fdd441015b067b5884dd0fbdc43f0e3d7db59 parent 413256d
7 files changed, +829 −124
  • internal/edge/edge.go +3 −3
  • internal/edge/purge.go +29 −0
  • internal/edge/purge_header.go +228 −113
  • internal/edge/purge_header_test.go +12 −7
  • internal/edge/purge_hosts_test.go +484 −0
  • internal/edge/purge_path_test.go +1 −1
  • internal/edge/purge_test.go +72 −0

CI passed — run details

diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 2045b8eeb..063435f7a 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -1065,7 +1065,7 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
// 3. Purge tenancy is the TARGET's app and account (purgeModifier is built
// from up.route), while the response CACHE keys on the REQUESTED host.
// Those two disagree, and closing that is what the path-routing branch
-// in resolvePurgeHost exists for — see below.
+// in purgeHostInTenancy exists for — see below.
// 4. Activator keying and hibernation are the TARGET's (up.hibernating and
// up.route come from the same entry), so a request for a flipped prefix
// wakes the target instance, not the requested hostname's. Correct: the
@@ -1076,7 +1076,7 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
// the target's route, so a path-routed app emitting a bare Carlos-Purge still
// purges under go.tito.io while its entry is stored under ti.to, and drops
// nothing. The fix is the documented escape hatch: name the hostname the
-// request arrived on, Carlos-Purge-Host: ti.to. resolvePurgeHost used to
+// request arrived on, Carlos-Purge-Host: ti.to. The override check used to
// refuse that — it required the override to name a host in the SAME app, and
// path routing exists precisely to cross an app boundary — and now allows it
// when a prefix on the named host selects this route. Account is still
@@ -1085,7 +1085,7 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
//
// So an app can emit Carlos-Purge-Host set to the Host header it received,
// unconditionally, from shared code. The inbound Host is preserved end to end,
-// so on an ordinary request that equals rt.Host and takes resolvePurgeHost's
+// 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) {
diff --git a/internal/edge/purge.go b/internal/edge/purge.go
index 50b4558c2..4e2f61466 100644
--- a/internal/edge/purge.go
+++ b/internal/edge/purge.go
@@ -29,11 +29,29 @@ type ControlListener struct {
// keep compiling.
type PurgeListener = ControlListener
+// purgeRequest is the body of POST /purge: one exact URL, or {"path":"*"}
+// for every cached entry of the listener's host — the socket-channel twin of
+// a Carlos-Purge: * header (amadan carlos/platform#173). Without it an exec
+// instance had to enumerate its own paths to do what the header channel does
+// in one token.
+//
+// "Whole host" means the one host the listener was built for. Entries cached
+// under that route's aliases or custom domains are keyed by those names, and
+// this channel cannot name them; an app that needs them uses the header
+// channel's Carlos-Purge-Host list.
+//
+// "*" is a whole-host purge only on its own. With a query beside it the body
+// names nothing a request could be stored under, so it is refused rather than
+// guessed at: widening a malformed exact purge to the whole host is not a
+// reading the caller asked for.
type purgeRequest struct {
Path string `json:"path"`
Query string `json:"query"`
}
+// purgeAllPath is the purgeRequest.Path that asks for the whole host.
+const purgeAllPath = "*"
+
// ListenControl binds socketPath (removing any stale file left behind by a
// crashed previous instance) and serves this host's control verbs on it.
// POST /purge is registered only when cache is non-nil — a box with no edge
@@ -119,6 +137,17 @@ func ListenControl(socketPath, host string, cache *Cache, mount func(*http.Serve
http.Error(w, "bad request", http.StatusBadRequest)
return
}
+ if req.Path == purgeAllPath {
+ if req.Query != "" {
+ http.Error(w, "bad request: path \"*\" takes no query", http.StatusBadRequest)
+ return
+ }
+ // host is still the one this listener was built for: "*" widens
+ // the purge to every path of that host, never to another host.
+ cache.PurgeHost(host)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
cache.PurgeURL(host, req.Path, req.Query)
w.WriteHeader(http.StatusOK)
})
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
index 1d8830132..9b590e2a0 100644
--- a/internal/edge/purge_header.go
+++ b/internal/edge/purge_header.go
@@ -62,12 +62,24 @@ import (
const PurgeHeader = "Carlos-Purge"
// PurgeHostHeader lets one response redirect its whole Carlos-Purge
-// declaration at a SIBLING route of the same app and account — spec §6.4.
+// declaration at one or more SIBLING routes of the same app and account —
+// spec §6.4, widened from one host to a list by amadan carlos/platform#173.
//
// HTTP/1.1 302 Found
-// Carlos-Purge-Host: acme.go.tito.io
+// Carlos-Purge-Host: acme.go.tito.io, tickets.acme.example
// Carlos-Purge: /events/42
//
+// # Why a list (#173)
+//
+// A Tito account with custom domains serves its public pages on several hosts
+// at once: its canonical {slug}.go.tito.io plus any domain wired to one event
+// or series. With one host per declaration a write could only ever reach the
+// canonical one, so pages under the others stayed stale for their full
+// s-maxage, and the app's only defence was to stop caching them at all. A
+// list lets one response reach every host of its own tenancy. Each entry is
+// checked exactly as the single host always was; the list adds reach, never a
+// weaker check.
+//
// # Why the host-confinement above had to be relaxed, and along which seam
//
// Tito's production topology splits one app across two route groups: admin
@@ -89,17 +101,33 @@ const PurgeHeader = "Carlos-Purge"
//
// # The contract, precisely
//
-// - AT MOST ONE host. The value is a bare hostname. Several lines, or a
-// comma-joined list, naming more than one distinct host is a MALFORMED
-// declaration, not a request to purge several.
-// - ALL-OR-NOTHING. Any failure — unknown host, different app, different
-// account, malformed value — discards the ENTIRE purge declaration for
-// this response, targets and all. There is deliberately no fallback to the
-// serving host: a half-honoured purge is worse than a refused one, because
-// it clears something and so looks like it worked.
+// - A LIST of bare hostnames, comma-joined or on several lines (RFC 9110
+// §5.3 — both spellings are the same declaration). Duplicates collapse.
+// At most maxPurgeHosts distinct hosts, and at most maxPurgeHeaderBytes
+// across every line; beyond either the declaration is malformed.
+// - It REPLACES the serving host, it does not add to it. A response that
+// wants its own host purged as well lists it, which is always allowed.
+// - ALL-OR-NOTHING on shape and tenancy. A malformed entry, an entry of a
+// different app or a different account, an over-long list, or a serving
+// route that changed while the request was in flight discards the ENTIRE
+// purge declaration for this response, every host and every target.
+// There is deliberately no fallback to the serving host, and no "honour
+// the good entries": a half-honoured purge is worse than a refused one,
+// because it clears something and so looks like it worked.
+// - An entry this edge does not SERVE — no ready route, exact or wildcard,
+// that it would route the name to — is
+// skipped (and logged), and the rest of the list is honoured. That is the
+// one exception, and it is not a half-honoured purge: an unserved host has
+// nothing this edge would serve from cache, and it cannot be checked
+// against a tenancy either, so skipping it neither misses a stale page nor
+// reaches outside the tenant. It matters because an app lists its hosts
+// from its own records, which do not say which box serves which host: a
+// domain still waiting on DNS, or one placed on another box, must not
+// cost the whole purge. A declaration whose every entry is unserved
+// purges nothing, exactly as a lone unknown host always did.
// - It redirects EVERY target in the same response, "*" and the cap-degrade
-// path included. The override decides WHICH host; §6.1 still decides which
-// entries of it.
+// path included, to EVERY accepted host. The override decides WHICH
+// hosts; §6.1 still decides which entries of each.
// - Resolved against the CURRENT route table at response time (e.current()),
// never a snapshot taken when the proxy was built — a sibling added by the
// very reload that rebuilt this proxy must still validate.
@@ -154,8 +182,16 @@ const PurgeHostHeader = "Carlos-Purge-Host"
// 64 targets is well above what a real write invalidates (a page, its
// paginated index, a couple of locale variants). 8 KiB bounds the joined
// header itself, since 64 targets says nothing about how long each one is.
+//
+// maxPurgeHosts bounds Carlos-Purge-Host the same way, but it cannot degrade:
+// over-purging past the cap would mean acting on hosts nobody validated, so a
+// longer list is refused outright. 32 is well above a real account's domain
+// count, and it keeps the worst case (32 hosts x 64 targets, two index groups
+// each) a few thousand map lookups. An app with more hosts than that should
+// not be naming them one response at a time.
const (
maxPurgeTargets = 64
+ maxPurgeHosts = 32
maxPurgeHeaderBytes = 8 << 10 // 8 KiB
)
@@ -258,7 +294,7 @@ func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) {
// time the response comes back.
//
// The OVERRIDE side is deliberately the other way round: the named host is
-// looked up in e.current() at response time (see resolvePurgeHost), because
+// looked up in e.current() at response time (see resolvePurgeHosts), because
// the route table is live and a sibling added since this proxy was built must
// still validate. e.current() is an atomic pointer load, so reading it from
// this hook needs no lock and cannot tear against a concurrent Reload.
@@ -287,100 +323,157 @@ func (e *Edge) applyPurgeHeader(rt registry.Route, h http.Header) {
// would silently drop every target after the first line — an under-purge,
// i.e. a stale page nobody notices until a customer does.
joined := joinHeader(h, PurgeHeader)
- // Every line, for the opposite reason: Carlos-Purge-Host is SINGLE-valued,
- // so reading only the first line would let a second line pass unnoticed —
- // and "the app named two hosts" must be detected, not silently resolved to
- // whichever came first.
+ // Every line, for the same reason: Carlos-Purge-Host is a list too, and a
+ // host on a second line is as much a part of the declaration as the first.
+ // Reading only the first line would also let a malformed or cross-tenant
+ // entry on a later line escape the all-or-nothing check.
declared := h.Values(PurgeHostHeader)
h.Del(PurgeHeader)
h.Del(PurgeHostHeader)
if joined == "" || e.cache == nil {
return
}
- host, ok := e.resolvePurgeHost(rt, declared)
+ hosts, ok := e.resolvePurgeHosts(rt, declared)
if !ok {
return
}
- e.applyPurgeTargets(host, joined)
+ e.applyPurgeTargets(rt.Host, hosts, joined)
}
-// resolvePurgeHost decides which host this response's purge targets apply to:
-// the serving route's own host, or the sibling a valid Carlos-Purge-Host names.
-// ok=false means the whole declaration is refused (see PurgeHostHeader's
-// all-or-nothing note) — the caller must purge nothing at all, not fall back.
+// resolvePurgeHosts decides which hosts this response's purge targets apply
+// to: the serving route's own host, or the hosts a valid Carlos-Purge-Host
+// lists. ok=false means the whole declaration is refused (see
+// PurgeHostHeader's all-or-nothing note) — the caller must purge nothing at
+// all, not fall back. ok=true with no hosts means every listed host was one
+// this edge does not serve, and there is nothing to purge.
//
// declared is every Carlos-Purge-Host LINE, already removed from the response.
-func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, bool) {
+func (e *Edge) resolvePurgeHosts(rt registry.Route, declared []string) ([]string, bool) {
if len(declared) == 0 {
- return rt.Host, true
+ return []string{rt.Host}, true
+ }
+ // The byte cap first, so that nothing below it — the split, the dedupe,
+ // the log lines — does work proportional to a header the app made huge.
+ size := 0
+ for _, line := range declared {
+ size += len(line)
}
- // Distinct values across every line, comma-split (a single-valued field
- // arriving comma-joined is the same declaration as two lines, RFC 9110
- // §5.3 — so both spellings of "two hosts" have to be refused, and both
- // spellings of "the same host twice" have to be tolerated).
+ if size > maxPurgeHeaderBytes {
+ e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
+ "%s declared a %d-byte Carlos-Purge-Host (max %d) — the whole purge declaration is ignored",
+ rt.Host, size, maxPurgeHeaderBytes))
+ return nil, false
+ }
+ // Distinct values across every line, comma-split: a comma-joined line and
+ // several lines are the same list (RFC 9110 §5.3), and naming one host
+ // twice is one host.
var vals []string
for _, line := range declared {
for _, v := range strings.Split(line, ",") {
- if v = strings.TrimSpace(v); v != "" && !slices.Contains(vals, v) {
- vals = append(vals, v)
+ if v = strings.TrimSpace(v); v == "" || slices.Contains(vals, v) {
+ continue
+ }
+ if len(vals) == maxPurgeHosts {
+ e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
+ "%s declared more than %d Carlos-Purge-Host values — the whole purge declaration is ignored",
+ rt.Host, maxPurgeHosts))
+ return nil, false
}
+ vals = append(vals, v)
}
}
- if len(vals) != 1 {
- // Includes the header-present-but-empty case (len 0): a declaration
- // that names no host is malformed, not absent.
- e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
- "%s declared %d Carlos-Purge-Host value(s) (exactly one host is allowed) — the whole purge declaration is ignored",
- rt.Host, len(vals)))
- return "", false
- }
- host := vals[0]
- if !validPurgeOverrideHost(host) {
+ if len(vals) == 0 {
+ // A declaration that names no host is malformed, not absent.
e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
- "%s declared a malformed Carlos-Purge-Host %q — the value must be a bare lowercase hostname, with no scheme, port or path; the whole purge declaration is ignored",
- rt.Host, truncateForLog(host)))
- return "", false
+ "%s declared an empty Carlos-Purge-Host — the whole purge declaration is ignored",
+ rt.Host))
+ return nil, false
}
- if host == rt.Host {
- // Naming your own host is trivially in-tenancy and needs no lookup, so
- // an app can emit the header unconditionally from shared code even on
- // the route whose pages it is already allowed to purge.
- return rt.Host, true
+ // Every entry's SHAPE before any entry's tenancy, so a malformed entry
+ // anywhere in the list refuses the lot regardless of where it sits.
+ for _, host := range vals {
+ if !validPurgeOverrideHost(host) {
+ e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
+ "%s declared a malformed Carlos-Purge-Host %q — each value must be a bare lowercase hostname, with no scheme, port or path; the whole purge declaration is ignored",
+ rt.Host, truncateForLog(host)))
+ return nil, false
+ }
}
// e.current(), not a captured map: the route table is live, and a sibling
- // added after this proxy was built must still validate.
+ // added after this proxy was built must still validate. ONE snapshot for
+ // the whole list, so every entry is judged against the same table.
cur := e.current()
- // The SERVING side is a value captured when this proxy was built, and a
- // reload can land between that capture and this response — the request is
- // in flight, its proxy already resolved, while `carlos add` or an adopt
- // re-stamps the row. Re-read it: if the serving host has left the table, or
- // its App/Account no longer match what was captured, the tenancy this
- // declaration would be validated against is one the edge no longer serves,
- // and honouring it would purge a sibling on the strength of an identity
- // that has expired. Refuse instead — the all-or-nothing rule applied to the
- // capture itself.
- //
- // Only genuine cross-host overrides reach here: naming your own host
- // returned above, and stays exactly equivalent to omitting the header.
- if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App ||
- release.RouteAccount(live.route) != release.RouteAccount(rt) {
- e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf(
- "%s declared Carlos-Purge-Host %q, but the serving route changed or left the route table while the request was in flight — the whole purge declaration is ignored",
- rt.Host, truncateForLog(host)))
- return "", false
- }
- up, ok := cur[host]
- if !ok {
- e.logPurgeIssue("override-unknown", rt.Host, fmt.Sprintf(
- "%s declared Carlos-Purge-Host %q, which is not a route this edge serves — the whole purge declaration is ignored",
- rt.Host, truncateForLog(host)))
- return "", false
+ hosts := make([]string, 0, len(vals))
+ var unserved []string
+ servingChecked := false
+ for _, host := range vals {
+ if host == rt.Host {
+ // Naming your own host is trivially in-tenancy and needs no lookup,
+ // so an app can emit the header unconditionally from shared code
+ // even on the route whose pages it is already allowed to purge.
+ hosts = append(hosts, host)
+ continue
+ }
+ // lookupIn, not cur[host]: a host served through a wildcard key
+ // ("*.acme.example") has no exact entry, but the edge routes it and
+ // caches its pages under the name the browser asked for, which is
+ // the name listed here. An exact key, another tenant's included,
+ // still wins over the wildcard, and either way the route found is the
+ // one judged below.
+ up, ok := lookupIn(cur, host)
+ if !ok {
+ unserved = append(unserved, host)
+ continue
+ }
+ if !servingChecked {
+ // The SERVING side is a value captured when this proxy was built,
+ // and a reload can land between that capture and this response —
+ // the request is in flight, its proxy already resolved, while
+ // `carlos add` or an adopt re-stamps the row. Re-read it: if the
+ // serving host has left the table, or its App/Account no longer
+ // match what was captured, the tenancy this declaration would be
+ // validated against is one the edge no longer serves, and
+ // honouring it would purge a sibling on the strength of an
+ // identity that has expired. Refuse instead — the all-or-nothing
+ // rule applied to the capture itself.
+ //
+ // Only a genuine cross-host entry that the table serves needs it:
+ // naming your own host stays exactly equivalent to omitting the
+ // header, and an unserved entry is skipped without being judged.
+ if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App ||
+ release.RouteAccount(live.route) != release.RouteAccount(rt) {
+ e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf(
+ "%s declared Carlos-Purge-Host %q, but the serving route changed or left the route table while the request was in flight — the whole purge declaration is ignored",
+ rt.Host, truncateForLog(host)))
+ return nil, false
+ }
+ servingChecked = true
+ }
+ if !purgeHostInTenancy(rt, up) {
+ e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf(
+ "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored",
+ rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host)))
+ return nil, false
+ }
+ hosts = append(hosts, host)
}
- // App AND Account, both, and App must be a real one: a route with no App
- // has no releases and no provable tenancy, so ""=="" is not a match — two
- // unrelated App-less routes on one box must not be able to purge each
- // other.
- //
+ if len(unserved) > 0 {
+ // One line per response, not one per entry, and only after the whole
+ // list has passed: a refused declaration logs its refusal instead.
+ // Its own kind, apart from the refusals: a domain waiting on DNS makes
+ // this line on every write, and sharing a throttle with the refusals
+ // would bury the rare one an operator needs to see.
+ e.logPurgeIssue("override-unserved", rt.Host, fmt.Sprintf(
+ "%s declared %d Carlos-Purge-Host value(s) this edge does not serve, first %q — skipped, the rest of the declaration is honoured",
+ rt.Host, len(unserved), truncateForLog(unserved[0])))
+ }
+ return hosts, true
+}
+
+// purgeHostInTenancy reports whether the route the serving route rt is
+// purging on behalf of, up, is inside rt's tenancy. The serving host itself
+// never reaches here; resolvePurgeHosts admits it without a lookup.
+func purgeHostInTenancy(rt registry.Route, up upstream) bool {
// Account is compared through release.RouteAccount, not raw, because that
// is how the rest of the platform resolves a route's account — it is the
// sqid that keys the app's bucket objects. An unset account column, or a
@@ -389,6 +482,7 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b
// everywhere else on this box. A raw comparison never lets a purge cross a
// real account boundary, but it does split one account in two and refuse a
// purge between two routes that demonstrably share one key space.
+ //
// The path-routing exception, and the ONLY place an override crosses an
// app boundary.
//
@@ -410,28 +504,22 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b
// from the reconcile pass that writes path rows. That pass does enforce
// same-account on a target, but a row written by an older binary, or by
// hand, must not be able to reach another tenant's cache — the same
- // defence-in-depth reasoning the staleness re-read above applies to the
- // serving side.
+ // defence-in-depth reasoning the staleness re-read applies to the serving
+ // side.
//
// Scoped to the route the prefix map actually names: "host path-routes
// somewhere" is not enough, or any app in the account could purge host by
// naming it.
if rt.App != "" && pathRoutesTo(up, rt.Host) &&
release.RouteAccount(up.route) == release.RouteAccount(rt) {
- return host, true
+ return true
}
// App AND Account, both, and App must be a real one: a route with no App
// has no releases and no provable tenancy, so ""=="" is not a match — two
// unrelated App-less routes on one box must not be able to purge each
// other.
- if rt.App == "" || up.route.App != rt.App ||
- release.RouteAccount(up.route) != release.RouteAccount(rt) {
- e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf(
- "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored",
- rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host)))
- return "", false
- }
- return host, true
+ return rt.App != "" && up.route.App == rt.App &&
+ release.RouteAccount(up.route) == release.RouteAccount(rt)
}
// pathRoutesTo reports whether any prefix on up selects the route at target.
@@ -479,6 +567,12 @@ func pathRoutesTo(up upstream, target string) bool {
// out, and "["/"]" are refused with it for consistency. Route keys are the
// hosts ServeHTTP matches r.Host against — DNS names, in every deployment this
// platform has — so the exclusion costs nothing real.
+//
+// "*" is refused too. A wildcard route key ("*.acme.example") is a routing
+// pattern, not a name anything is cached under: the cache keys on the host
+// the browser asked for. Naming the pattern would pass the tenancy check and
+// purge nothing, which looks like it worked. An app names the real hosts,
+// which resolvePurgeHosts routes through the wildcard.
func validPurgeOverrideHost(h string) bool {
if h == "" {
return false
@@ -492,28 +586,37 @@ func validPurgeOverrideHost(h string) bool {
return false
}
switch c {
- case ':', '/', '\\', '@', '?', '#', '%', '[', ']':
+ case ':', '/', '\\', '@', '?', '#', '%', '[', ']', '*':
return false
}
}
return true
}
-// applyPurgeTargets parses one response's joined Carlos-Purge value and
-// applies it to host's entries.
+// applyPurgeTargets parses one response's joined Carlos-Purge value once and
+// applies it to every host in hosts. from is the serving route's host, and
+// keys the log lines: it is the route whose response declared the purge.
//
-// host has already been decided by resolvePurgeHost — the serving route's own
-// host, or the sibling a valid Carlos-Purge-Host named. Everything below,
+// hosts has already been decided by resolvePurgeHosts — the serving route's
+// own host, or the hosts a valid Carlos-Purge-Host listed. Everything below,
// including "*" and both cap degrades, therefore follows the override by
// construction: degrading to the SERVING host instead would be the worst of
// both, wiping a host nobody asked about while leaving the stale pages the app
// was trying to invalidate.
-func (e *Edge) applyPurgeTargets(host, joined string) {
+func (e *Edge) applyPurgeTargets(from string, hosts []string, joined string) {
+ if len(hosts) == 0 {
+ return
+ }
+ purgeHosts := func() {
+ for _, host := range hosts {
+ e.cache.PurgeHost(host)
+ }
+ }
if len(joined) > maxPurgeHeaderBytes {
- e.logPurgeIssue("oversize", host, fmt.Sprintf(
- "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole host instead",
- host, len(joined), maxPurgeHeaderBytes))
- e.cache.PurgeHost(host)
+ e.logPurgeIssue("oversize", from, fmt.Sprintf(
+ "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole of %d host(s) instead",
+ from, len(joined), maxPurgeHeaderBytes, len(hosts)))
+ purgeHosts()
return
}
targets := make([]string, 0, 8)
@@ -523,18 +626,25 @@ func (e *Edge) applyPurgeTargets(host, joined string) {
}
}
if len(targets) > maxPurgeTargets {
- e.logPurgeIssue("count", host, fmt.Sprintf(
- "%s declared %d Carlos-Purge targets (max %d) — purging the whole host instead",
- host, len(targets), maxPurgeTargets))
- e.cache.PurgeHost(host)
+ e.logPurgeIssue("count", from, fmt.Sprintf(
+ "%s declared %d Carlos-Purge targets (max %d) — purging the whole of %d host(s) instead",
+ from, len(targets), maxPurgeTargets, len(hosts)))
+ purgeHosts()
return
}
+ // Parse every target before touching the cache, so the list is parsed once
+ // however many hosts it applies to, and so "*" anywhere subsumes the rest.
+ type key struct {
+ path, rawQuery string
+ exact bool
+ }
+ keys := make([]key, 0, len(targets))
rejected, firstBad := 0, ""
for _, t := range targets {
if t == "*" {
- // "every cache entry for this host" — and it subsumes any other
+ // "every cache entry for each host" — and it subsumes any other
// target in the same declaration, so there is nothing left to do.
- e.cache.PurgeHost(host)
+ purgeHosts()
return
}
path, rawQuery, exact, ok := purgeTargetKey(t)
@@ -545,10 +655,15 @@ func (e *Edge) applyPurgeTargets(host, joined string) {
}
continue
}
- if exact {
- e.cache.PurgeURL(host, path, rawQuery)
- } else {
- e.cache.PurgePath(host, path)
+ keys = append(keys, key{path, rawQuery, exact})
+ }
+ for _, host := range hosts {
+ for _, k := range keys {
+ if k.exact {
+ e.cache.PurgeURL(host, k.path, k.rawQuery)
+ } else {
+ e.cache.PurgePath(host, k.path)
+ }
}
}
if rejected > 0 {
@@ -556,9 +671,9 @@ func (e *Edge) applyPurgeTargets(host, joined string) {
// otherwise turn every request into 64 log lines. The count is the
// signal; the first offender is the diagnostic. %q quotes any control
// bytes rather than emitting them into the journal.
- e.logPurgeIssue("malformed", host, fmt.Sprintf(
+ e.logPurgeIssue("malformed", from, fmt.Sprintf(
"%s: ignored %d malformed Carlos-Purge target(s), first %q — a target must be an absolute path or \"*\", and may never carry a host",
- host, rejected, truncateForLog(firstBad)))
+ from, rejected, truncateForLog(firstBad)))
}
}
diff --git a/internal/edge/purge_header_test.go b/internal/edge/purge_header_test.go
index 07cdc7de2..ba9fbc4e5 100644
--- a/internal/edge/purge_header_test.go
+++ b/internal/edge/purge_header_test.go
@@ -1316,11 +1316,13 @@ func TestPurgeHostOverrideUnknownHostIsRefused(t *testing.T) {
}
}
-// TestPurgeHostOverrideConflictingValuesRefused — "at most ONE host". Two
-// names is not "purge both", it is a malformed declaration, and neither host
-// is touched. The multi-value shapes are equivalent whether they arrive on
-// separate lines or comma-joined on one (RFC 9110 §5.3).
-func TestPurgeHostOverrideConflictingValuesRefused(t *testing.T) {
+// TestPurgeHostOverrideTwoValuesPurgeTheServedOne — this used to pin "at most ONE
+// host", with two names refused as malformed. #173 made the header a list, so
+// two names now mean "purge both" — on separate lines or comma-joined on one
+// (RFC 9110 §5.3). c.example.com has no route here, so it is skipped rather
+// than purged (see TestPurgeHostListSkipsUnservedHosts), and the serving host
+// a.example.com is untouched because neither spelling names it.
+func TestPurgeHostOverrideTwoValuesPurgeTheServedOne(t *testing.T) {
for _, tc := range []struct {
name string
set func(http.Header)
@@ -1349,9 +1351,12 @@ func TestPurgeHostOverrideConflictingValuesRefused(t *testing.T) {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
- for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
+ if cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("b.example.com survived a list that named it")
+ }
+ for _, h := range []string{"a.example.com", "c.example.com"} {
if !cached(e.cache, h, "/x", "") {
- t.Fatalf("%s was purged by a multi-valued Carlos-Purge-Host", h)
+ t.Fatalf("%s was purged, but it is unnamed or unserved", h)
}
}
})
diff --git a/internal/edge/purge_hosts_test.go b/internal/edge/purge_hosts_test.go
new file mode 100644
index 000000000..405f7b603
--- /dev/null
+++ b/internal/edge/purge_hosts_test.go
@@ -0,0 +1,484 @@
+package edge
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ---------------------------------------------------------------------------
+// Carlos-Purge-Host as a LIST: one response reaching every host of its own
+// app and account (amadan carlos/platform#173).
+// ---------------------------------------------------------------------------
+
+// purgeHostsEdge is purgeOverrideEdge for any number of routes. The first is
+// the one requests are served on in these tests.
+func purgeHostsEdge(t *testing.T, routes []registry.Route, h http.HandlerFunc) *Edge {
+ t.Helper()
+ backend := httptest.NewServer(h)
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ for _, rt := range routes {
+ rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e
+}
+
+// titoAccount is the #173 topology: a canonical account host, a custom domain
+// wired to one event, another wired to a series, all one app and one account.
+func titoAccount() []registry.Route {
+ return []registry.Route{
+ route("go.tito.example", "tito", "tenanta"),
+ route("acme.go.tito.example", "tito", "tenanta"),
+ route("tickets.acme.example", "tito", "tenanta"),
+ route("series.acme.example", "tito", "tenanta"),
+ }
+}
+
+func servePOST(e *Edge, url string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", url, nil))
+ return rec
+}
+
+// TestPurgeHostListPurgesEveryListedHost is the headline of #173: an admin
+// write on the root domain purges the same page under the canonical host and
+// every custom domain at once, whichever list spelling the app uses, and the
+// serving host is still left alone because the list did not name it.
+func TestPurgeHostListPurgesEveryListedHost(t *testing.T) {
+ listed := []string{"acme.go.tito.example", "tickets.acme.example", "series.acme.example"}
+ for _, tc := range []struct {
+ name string
+ set func(http.Header)
+ }{
+ {"comma joined", func(h http.Header) {
+ h.Set("Carlos-Purge-Host", strings.Join(listed, ", "))
+ }},
+ {"several lines", func(h http.Header) {
+ for _, host := range listed {
+ h.Add("Carlos-Purge-Host", host)
+ }
+ }},
+ {"mixed, with a duplicate", func(h http.Header) {
+ h.Add("Carlos-Purge-Host", listed[0]+", "+listed[1])
+ h.Add("Carlos-Purge-Host", listed[2]+","+listed[0])
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ tc.set(w.Header())
+ w.Header().Set("Carlos-Purge", "/events/42")
+ w.WriteHeader(http.StatusFound)
+ })
+ for _, host := range listed {
+ seed(t, e.cache, host, "/events/42", "", "old")
+ seed(t, e.cache, host, "/events/43", "", "untouched")
+ }
+ seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host")
+
+ rec := servePOST(e, "http://go.tito.example/admin/events/42")
+
+ for _, host := range listed {
+ if cached(e.cache, host, "/events/42", "") {
+ t.Fatalf("%s /events/42 survived a list that named it", host)
+ }
+ if !cached(e.cache, host, "/events/43", "") {
+ t.Fatalf("%s /events/43 was purged but nothing named it", host)
+ }
+ }
+ if !cached(e.cache, "go.tito.example", "/events/42", "") {
+ t.Fatal("the SERVING host was purged — the list replaces it, and did not name it")
+ }
+ if got := rec.Header().Values("Carlos-Purge-Host"); len(got) != 0 {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+ })
+ }
+}
+
+// TestPurgeHostListNamingTheServingHostPurgesItToo — the list replaces the
+// serving host rather than adding to it, so an app that wants both says so.
+func TestPurgeHostListNamingTheServingHostPurgesItToo(t *testing.T) {
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "go.tito.example, tickets.acme.example")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "go.tito.example", "/x", "", "serving")
+ seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
+ seed(t, e.cache, "series.acme.example", "/x", "", "unnamed")
+
+ servePOST(e, "http://go.tito.example/write")
+
+ if cached(e.cache, "go.tito.example", "/x", "") {
+ t.Fatal("the serving host survived a list that named it")
+ }
+ if cached(e.cache, "tickets.acme.example", "/x", "") {
+ t.Fatal("the listed custom domain survived")
+ }
+ if !cached(e.cache, "series.acme.example", "/x", "") {
+ t.Fatal("a host the list did not name was purged")
+ }
+}
+
+// TestPurgeHostListStarPurgesEveryListedHost — Tito's "every organiser write
+// purges the whole host" rule, reaching every host of the account in one
+// response.
+func TestPurgeHostListStarPurgesEveryListedHost(t *testing.T) {
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example")
+ w.Header().Set("Carlos-Purge", "/ignored, *")
+ w.WriteHeader(http.StatusOK)
+ })
+ for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} {
+ seed(t, e.cache, host, "/one", "", "1")
+ seed(t, e.cache, host, "/two", "q=1", "2")
+ }
+ seed(t, e.cache, "series.acme.example", "/one", "", "unnamed")
+
+ servePOST(e, "http://go.tito.example/write")
+
+ for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} {
+ if cached(e.cache, host, "/one", "") || cached(e.cache, host, "/two", "q=1") {
+ t.Fatalf("* did not purge the whole of %s", host)
+ }
+ }
+ if !cached(e.cache, "series.acme.example", "/one", "") {
+ t.Fatal("* reached a host the list did not name")
+ }
+}
+
+// TestPurgeHostListCapDegradeHitsEveryListedHost — the target-count degrade
+// follows the list, not the serving host.
+func TestPurgeHostListCapDegradeHitsEveryListedHost(t *testing.T) {
+ targets := make([]string, maxPurgeTargets+1)
+ for i := range targets {
+ targets[i] = fmt.Sprintf("/p/%d", i)
+ }
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example")
+ w.Header().Set("Carlos-Purge", strings.Join(targets, ", "))
+ w.WriteHeader(http.StatusOK)
+ })
+ for _, host := range []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example"} {
+ seed(t, e.cache, host, "/unnamed", "", host)
+ }
+
+ servePOST(e, "http://go.tito.example/write")
+
+ for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} {
+ if cached(e.cache, host, "/unnamed", "") {
+ t.Fatalf("the cap degrade did not purge listed host %s", host)
+ }
+ }
+ if !cached(e.cache, "go.tito.example", "/unnamed", "") {
+ t.Fatal("the cap degrade purged the SERVING host, which the list did not name")
+ }
+}
+
+// TestPurgeHostListOneBadEntryRefusesTheWholeList — all-or-nothing survives
+// the widening. Wherever the bad entry sits in the list, and whatever is
+// wrong with it, no host is purged: a list that clears the good entries and
+// drops the bad one looks like it worked.
+func TestPurgeHostListOneBadEntryRefusesTheWholeList(t *testing.T) {
+ routes := append(titoAccount(),
+ route("other-app.example", "other", "tenanta"),
+ route("other-account.example", "tito", "tenantb"),
+ )
+ for _, tc := range []struct{ name, list string }{
+ {"different app, last", "acme.go.tito.example, tickets.acme.example, other-app.example"},
+ {"different app, first", "other-app.example, acme.go.tito.example"},
+ {"different account", "acme.go.tito.example, other-account.example"},
+ {"malformed, last", "acme.go.tito.example, tickets.acme.example:443"},
+ {"malformed, first", "HTTPS://acme.go.tito.example, tickets.acme.example"},
+ {"malformed beside an unserved one", "ghost.example, tickets.acme.example/x"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", tc.list)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ all := []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example",
+ "series.acme.example", "other-app.example", "other-account.example"}
+ for _, host := range all {
+ seed(t, e.cache, host, "/x", "", host)
+ }
+
+ servePOST(e, "http://go.tito.example/write")
+
+ for _, host := range all {
+ if !cached(e.cache, host, "/x", "") {
+ t.Fatalf("%s was purged by a list carrying a bad entry (%q)", host, tc.list)
+ }
+ }
+ })
+ }
+}
+
+// TestPurgeHostListSkipsUnservedHosts — the one exception to all-or-nothing.
+// An app lists its domains from its own records, which do not know which box
+// serves which host; a domain waiting on DNS, or placed elsewhere, must not
+// cost the purge of the hosts this edge does serve. The unserved host's own
+// (orphaned) entry is left alone: skipped means not judged, never purged.
+func TestPurgeHostListSkipsUnservedHosts(t *testing.T) {
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ghost.example, tickets.acme.example")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ghost.example", "/x", "", "orphan")
+ seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
+ seed(t, e.cache, "go.tito.example", "/x", "", "serving")
+
+ servePOST(e, "http://go.tito.example/write")
+
+ if cached(e.cache, "tickets.acme.example", "/x", "") {
+ t.Fatal("an unserved entry cost the purge of a served, in-tenancy host")
+ }
+ if !cached(e.cache, "ghost.example", "/x", "") {
+ t.Fatal("an unserved host's entries were purged without any tenancy check")
+ }
+ if !cached(e.cache, "go.tito.example", "/x", "") {
+ t.Fatal("the serving host was purged, but the list did not name it")
+ }
+ if !strings.Contains(buf.String(), "ghost.example") {
+ t.Fatalf("the skipped host was not logged; log:\n%s", buf.String())
+ }
+}
+
+// TestPurgeHostListOverTheCapIsRefused — the host cap cannot degrade the way
+// the target cap does, because over-purging past it would mean acting on
+// hosts nobody validated. Distinct hosts are what count: duplicates collapse
+// before the cap is applied.
+func TestPurgeHostListOverTheCapIsRefused(t *testing.T) {
+ names := make([]string, maxPurgeHosts)
+ names[0] = "acme.go.tito.example"
+ for i := 1; i < len(names); i++ {
+ names[i] = fmt.Sprintf("unserved-%d.example", i)
+ }
+
+ t.Run("at the cap, with duplicates", func(t *testing.T) {
+ list := strings.Join(append(names, names...), ", ")
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", list)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "acme.go.tito.example", "/x", "", "A")
+ servePOST(e, "http://go.tito.example/write")
+ if cached(e.cache, "acme.go.tito.example", "/x", "") {
+ t.Fatalf("a list of exactly %d distinct hosts was refused", maxPurgeHosts)
+ }
+ })
+
+ t.Run("one over", func(t *testing.T) {
+ list := strings.Join(append(names, "one-too-many.example"), ", ")
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", list)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "acme.go.tito.example", "/x", "", "A")
+ servePOST(e, "http://go.tito.example/write")
+ if !cached(e.cache, "acme.go.tito.example", "/x", "") {
+ t.Fatalf("a list of %d distinct hosts was honoured", maxPurgeHosts+1)
+ }
+ })
+}
+
+// TestPurgeHostListOversizeIsRefused — the byte cap bounds the header before
+// any splitting happens, so a huge header of duplicates cannot buy work by
+// staying under the distinct-host cap.
+func TestPurgeHostListOversizeIsRefused(t *testing.T) {
+ list := strings.Repeat("acme.go.tito.example,", maxPurgeHeaderBytes/len("acme.go.tito.example,")+1)
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", list)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "acme.go.tito.example", "/x", "", "A")
+ servePOST(e, "http://go.tito.example/write")
+ if !cached(e.cache, "acme.go.tito.example", "/x", "") {
+ t.Fatalf("a %d-byte Carlos-Purge-Host was honoured", len(list))
+ }
+}
+
+// TestPurgeHostListEveryEntryUnservedPurgesNothing — the lone-unknown-host
+// case generalised: nothing served, nothing purged, no fallback.
+func TestPurgeHostListEveryEntryUnservedPurgesNothing(t *testing.T) {
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ghost-1.example, ghost-2.example")
+ w.Header().Set("Carlos-Purge", "*")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "go.tito.example", "/x", "", "serving")
+ servePOST(e, "http://go.tito.example/write")
+ if !cached(e.cache, "go.tito.example", "/x", "") {
+ t.Fatal("a list of unserved hosts fell back to the serving host")
+ }
+}
+
+// TestPurgeHostListReachesAHostServedThroughAWildcard — a catch-all alias
+// ("*.acme.example") routes every subdomain to one route, and the cache keys
+// each page on the name the browser asked for. Listing that name must purge
+// it: the edge serves it, so skipping it as "unserved" would leave a stale
+// page behind a purge that cleared the rest of the list.
+func TestPurgeHostListReachesAHostServedThroughAWildcard(t *testing.T) {
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.wild.example")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example")
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ if _, ok := e.current()["*.wild.example"]; !ok {
+ t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing")
+ }
+ seed(t, e.cache, "foo.wild.example", "/x", "", "wild")
+ seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
+
+ servePOST(e, "http://go.tito.example/write")
+
+ if cached(e.cache, "foo.wild.example", "/x", "") {
+ t.Fatal("a host served through a wildcard alias was skipped as unserved")
+ }
+ if cached(e.cache, "tickets.acme.example", "/x", "") {
+ t.Fatal("the rest of the list was not honoured")
+ }
+}
+
+// TestPurgeHostListWildcardOfAnotherTenantIsRefused — resolving through the
+// wildcard is still a tenancy check on the route it lands on: a name under
+// another account's catch-all refuses the whole list.
+func TestPurgeHostListWildcardOfAnotherTenantIsRefused(t *testing.T) {
+ routes := append(titoAccount(), route("other-account.example", "tito", "tenantb"))
+ e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.theirs.example")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ upsertAlias(t, e.reg, "*.theirs.example", "other-account.example")
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ if _, ok := e.current()["*.theirs.example"]; !ok {
+ t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing")
+ }
+ seed(t, e.cache, "foo.theirs.example", "/x", "", "theirs")
+ seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
+
+ servePOST(e, "http://go.tito.example/write")
+
+ if !cached(e.cache, "foo.theirs.example", "/x", "") {
+ t.Fatal("a host under ANOTHER ACCOUNT's wildcard was purged")
+ }
+ if !cached(e.cache, "tickets.acme.example", "/x", "") {
+ t.Fatal("a list carrying a cross-tenant entry was partly honoured")
+ }
+}
+
+// TestPurgeHostListNamingAWildcardKeyIsRefused — "*.acme.example" is a
+// routing pattern, not a name anything is cached under, so naming it is a
+// malformed entry and refuses the list rather than purging nothing quietly.
+func TestPurgeHostListNamingAWildcardKeyIsRefused(t *testing.T) {
+ e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, *.wild.example")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example")
+ if err := e.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+ seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
+
+ servePOST(e, "http://go.tito.example/write")
+
+ if !cached(e.cache, "tickets.acme.example", "/x", "") {
+ t.Fatal("a list naming a wildcard key was honoured")
+ }
+}
+
+// TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture — the
+// staleness re-read runs for the first SERVED cross-host entry wherever it
+// sits, not only when it leads the list. Own host first and an unserved host
+// first are the two orders that reach a served sibling without it being
+// first.
+func TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) {
+ for _, list := range []string{
+ "a.example.com, b.example.com",
+ "ghost.example.com, b.example.com",
+ } {
+ t.Run(list, func(t *testing.T) {
+ var (
+ e *Edge
+ reg *registry.Registry
+ addr string
+ )
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The route table moves under the in-flight request.
+ if err := reg.Upsert(registry.Route{
+ Host: "a.example.com", App: "tito", Account: "tenantb",
+ Kind: "instance", Addr: addr, Status: "ready",
+ }); err != nil {
+ t.Errorf("upsert during flight: %v", err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Errorf("reload during flight: %v", err)
+ }
+ w.Header().Set("Carlos-Purge-Host", list)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(backend.Close)
+ addr = backend.Listener.Addr().String()
+ reg = openReg(t)
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := reg.Upsert(registry.Route{
+ Host: h, App: "tito", Account: "tenanta",
+ Kind: "instance", Addr: addr, Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", h, err)
+ }
+ }
+ var err error
+ if e, err = New(reg, nil); err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ servePOST(e, "http://a.example.com/write")
+
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("the sibling was purged on the strength of a STALE captured identity")
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("a refused list was partly honoured on the serving host")
+ }
+ })
+ }
+}
diff --git a/internal/edge/purge_path_test.go b/internal/edge/purge_path_test.go
index 29d4d6b4d..698bafdfd 100644
--- a/internal/edge/purge_path_test.go
+++ b/internal/edge/purge_path_test.go
@@ -48,7 +48,7 @@ func purgePathEdge(t *testing.T, front, target registry.Route, prefix string, h
//
// The request really is path-routed here rather than simulated: it arrives on
// ti.to/paulca and reaches the target through the prefix map, so this pins the
-// composition and not just resolvePurgeHost in isolation.
+// composition and not just resolvePurgeHosts in isolation.
func TestPurgeHostOverrideAllowedFromAPathRoutedHost(t *testing.T) {
e := purgePathEdge(t,
route("ti.to", "classic", "tenanta"),
diff --git a/internal/edge/purge_test.go b/internal/edge/purge_test.go
index 60d86f8b1..03951d986 100644
--- a/internal/edge/purge_test.go
+++ b/internal/edge/purge_test.go
@@ -183,3 +183,75 @@ func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
}
pl.Close()
}
+
+// TestListenPurgeStarPurgesTheWholeHost — {"path":"*"} is the socket twin of
+// a Carlos-Purge: * header (#173): every entry of the listener's own host, and
+// still never another host's.
+func TestListenPurgeStarPurgesTheWholeHost(t *testing.T) {
+ sock := filepath.Join(t.TempDir(), "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ put := func(host, path, query string) CacheKey {
+ k := CacheKey{Host: host, Method: "GET", Path: path, RawQuery: query}
+ cache.Put(k, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+ return k
+ }
+ mine := []CacheKey{put("a.example", "/one", ""), put("a.example", "/two", "q=1")}
+ other := put("other.example", "/one", "")
+
+ pl, err := ListenPurge(sock, "a.example", cache)
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "*"})
+ resp, err := dialPurgeSocket(t, sock).Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("purge POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("purge status = %d, want 200", resp.StatusCode)
+ }
+ for _, k := range mine {
+ if _, ok := cache.get(k); ok {
+ t.Fatalf("%s%s survived a whole-host purge", k.Path, k.RawQuery)
+ }
+ }
+ if _, ok := cache.get(other); !ok {
+ t.Fatal("a whole-host purge on a.example's socket reached other.example")
+ }
+}
+
+// TestListenPurgeStarWithAQueryIsRefused — "*" with a query names nothing a
+// request could be stored under, so it is a malformed body, not a whole-host
+// purge and not an exact purge of a path literally called "*".
+func TestListenPurgeStarWithAQueryIsRefused(t *testing.T) {
+ sock := filepath.Join(t.TempDir(), "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ k := CacheKey{Host: "a.example", Method: "GET", Path: "/one"}
+ cache.Put(k, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache)
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+ defer pl.Close()
+
+ body, _ := json.Marshal(map[string]string{"path": "*", "query": "x=1"})
+ resp, err := dialPurgeSocket(t, sock).Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("purge POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("purge status = %d, want 400", resp.StatusCode)
+ }
+ if _, ok := cache.get(k); !ok {
+ t.Fatal("a refused body purged the host anyway")
+ }
+}