carlos / platform Public

edge and schedule: the import gate (Plan 7 Task 6)

The edge refuses every request to a gated row with 503, Retry-After: 5
and SD-N99, after the door's scope check and before the meter, the
password gate, the cache and the activator. Only a GET whose verify
token hashes to the gate (constant time), resolved to the gated host's
own row or an alias of it, passes; a path route under a gated host is
refused token or not. The verify header is stripped from every request,
and a gated row's requests never touch the shared cache.

The schedule runner skips a gated row in its pre-filter whether or not
the hold wiring is armed, and its decision read inside the claim checks
the gate too (M14).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b7754cbf59f18e6170702d23349f125121c5703e parent aba67ca
7 files changed, +583 −4
  • approved_seeded_copy_test.go +3 −2
  • internal/edge/door_test.go +37 −0
  • internal/edge/edge.go +10 −1
  • internal/edge/importgate.go +52 −0
  • internal/edge/importgate_test.go +380 −0
  • internal/schedule/runner.go +9 −1
  • internal/schedule/runner_test.go +92 −0

CI passed — run details

diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go
index 5ae45f374..09012e136 100644
--- a/approved_seeded_copy_test.go
+++ b/approved_seeded_copy_test.go
@@ -37,7 +37,7 @@ var seededCopyIDs = func() []string {
// Task 5 SD-N20 to SD-N26, SD-N30 to SD-N39 and SD-N41; Plan 5 Task 6 SD-N40, SD-N42 to SD-N47, SD-N83 to SD-N88 and SD-N114;
// Plan 6 Task 1 SD-N48 to SD-N50, SD-N62 and SD-N67; Plan 6 Task 2 SD-N51 to SD-N53, SD-N59, SD-N61 and SD-N63;
// Plan 6 Task 3 SD-N54, SD-N55, SD-N60, SD-N64, SD-N65 and SD-N115; Plan 6 Task 5 SD-N56 to SD-N58 and SD-N66;
-// Plan 6 Task 6 SD-N68 to SD-N82, SD-N89 and SD-N90; Plan 7 Task 3 SD-N91 to SD-N98.
+// Plan 6 Task 6 SD-N68 to SD-N82, SD-N89 and SD-N90; Plan 7 Task 3 SD-N91 to SD-N98; Plan 7 Task 6 SD-N99.
var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N6", "SD-N7", "SD-N8", "SD-N113", "SD-N9", "SD-N10", "SD-N11", "SD-N12", "SD-N13", "SD-N111", "SD-N112", "SD-N14", "SD-N15", "SD-N16", "SD-N17", "SD-N18", "SD-N19", "SD-N27", "SD-N28", "SD-N29",
"SD-N20", "SD-N21", "SD-N22", "SD-N23", "SD-N24", "SD-N25", "SD-N26", "SD-N30", "SD-N31", "SD-N32", "SD-N33", "SD-N34", "SD-N35", "SD-N36", "SD-N37", "SD-N38", "SD-N39", "SD-N41",
"SD-N40", "SD-N42", "SD-N43", "SD-N44", "SD-N45", "SD-N46", "SD-N47", "SD-N114", "SD-N83", "SD-N84", "SD-N85", "SD-N86", "SD-N87", "SD-N88",
@@ -47,7 +47,8 @@ var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N
"SD-N56", "SD-N57", "SD-N58", "SD-N66",
"SD-N68", "SD-N69", "SD-N70", "SD-N71", "SD-N72", "SD-N73", "SD-N74", "SD-N75", "SD-N76", "SD-N77", "SD-N78", "SD-N79",
"SD-N80", "SD-N81", "SD-N82", "SD-N89", "SD-N90",
- "SD-N91", "SD-N92", "SD-N93", "SD-N94", "SD-N95", "SD-N96", "SD-N97", "SD-N98"}
+ "SD-N91", "SD-N92", "SD-N93", "SD-N94", "SD-N95", "SD-N96", "SD-N97", "SD-N98",
+ "SD-N99"}
func mustParseSeededRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/edge/door_test.go b/internal/edge/door_test.go
index 7abee0793..2069c01f2 100644
--- a/internal/edge/door_test.go
+++ b/internal/edge/door_test.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
)
// doorTestAddr is the box's public address in these tests. It is TEST-NET-3
@@ -739,6 +740,42 @@ func TestDoorGatedRouteStillAnswersWithTheGatePage(t *testing.T) {
}
}
+// 5a. The door is not a bypass of the import gate either (seeded-instances
+// spec §5.4): the caller's own route, swapped for an uncommitted import,
+// passes the door's scope check and then meets the gate. Without the verify
+// token the answer is the gate's 503, not unknown host; with it, a GET
+// reaches the import and the app never sees the header. Another app's key
+// is still refused at the scope check, before the gate says anything.
+func TestDoorMeetsTheImportGate(t *testing.T) {
+ s := newDoorScene(t)
+ importSrv, imported := newGateBackend(t, "imported", "")
+ token, hash, err := seeds.NewVerifyToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ gateForImport(t, s.e.reg, doorOwnHost, importSrv.Listener.Addr().String(), hash, false)
+ if err := s.e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+
+ wantGateRefusal(t, s.do(t, http.MethodGet, doorOwnHost, "/", s.key, nil, nil), token, "the door with no token")
+ wantGateRefusal(t, s.do(t, http.MethodPost, doorOwnHost, "/", s.key, map[string]string{ImportVerifyHeader: token}, nil),
+ token, "the door with the token on a POST")
+ if n, _ := imported.seen(); n != 0 || upstreamCalls(s.own) != 0 {
+ t.Fatalf("the door reached a gated row: import %d, legacy %d", n, upstreamCalls(s.own))
+ }
+ wantUnknownHost(t, s.do(t, http.MethodGet, doorOwnHost, "/", s.otherKey, map[string]string{ImportVerifyHeader: token}, nil),
+ "another app's key on a gated host")
+
+ rec := s.do(t, http.MethodGet, doorOwnHost, "/", s.key, map[string]string{ImportVerifyHeader: token}, nil)
+ if rec.Code != http.StatusOK || rec.Body.String() != "imported" {
+ t.Fatalf("verify through the door = %d %q, want the import", rec.Code, rec.Body.String())
+ }
+ if n, saw := imported.seen(); n != 1 || len(saw) != 0 {
+ t.Fatalf("verify through the door: import reached %d times, header at the app %q", n, saw)
+ }
+}
+
// ---- 5b: a backhauled door request -----------------------------------------------
// 5b. Two edges joined as the backhaul e2e joins them: the door request lands
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 4f95941a8..259d04a81 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -1560,6 +1560,11 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
e.unknownHost(w, r)
return
}
+ // The import gate (importgate.go): after the door's scope, before the
+ // meter, the password gate, the cache and the activator.
+ if !e.importGateAdmit(w, r, host, up.route) {
+ return
+ }
// Observe every route this box serves except the platform's own status
// host (not billable, and not a member's app) — wrapping w here, once,
@@ -1686,8 +1691,12 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// instead of being answered from cache, which is the invariant this cache
// exists to hold: the status host caches for itself; its responses never
// enter the shared response cache.
+ //
+ // A gated row's verify request is exempt in both directions too: it must
+ // reach the import rather than a response cached for the legacy route,
+ // and the import's answer must not outlive a rollback.
cacheable := e.cache != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
- up.route.Kind != "status"
+ up.route.Kind != "status" && up.route.ImportGate == ""
var key CacheKey
if cacheable {
key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery}
diff --git a/internal/edge/importgate.go b/internal/edge/importgate.go
new file mode 100644
index 000000000..eaa8956b7
--- /dev/null
+++ b/internal/edge/importgate.go
@@ -0,0 +1,52 @@
+package edge
+
+import (
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// ImportVerifyHeader carries an uncommitted import's verify token
+// (seeded-instances spec §5.4). The CLI's verify sends it (instances_import.go).
+const ImportVerifyHeader = "Carlos-Import-Verify"
+
+// importGateAdmit is the import gate (spec §5.4, codex 15). host is the
+// request's host and rt the route it resolved to after the door's scope
+// check. ServeHTTP calls it before the meter, the password gate, the cache
+// and the activator, so a refused request is not served, cached, billed or
+// woken.
+//
+// The verify header is removed from every request that gets this far, so
+// the token never reaches an app, gated or not. It is never logged or
+// written into an answer.
+//
+// While host's own row, or the row the request resolved to, carries a gate,
+// exactly one request passes: a GET (the CLI's verify, spec §5) whose token
+// hashes to the gate, compared in constant time (seeds.TokenMatches), and
+// resolved to host's own row. An alias resolves to its primary's row, so an
+// alias of a gated host is that host. A path route does not: a prefix under
+// a gated host that selects another row is refused, token or not, because
+// the verify is the import's own request and a prefix's target is another
+// route; and an ungated host's prefix that selects a gated row is refused
+// for the same reason. ImportGateClosed (a rollback) matches no token.
+//
+// Everything else gets 503 with Retry-After: 5 and Cache-Control: no-store.
+func (e *Edge) importGateAdmit(w http.ResponseWriter, r *http.Request, host string, rt registry.Route) bool {
+ token := r.Header.Get(ImportVerifyHeader)
+ r.Header.Del(ImportVerifyHeader)
+ front := rt
+ if up, ok := lookupIn(e.current(), host); ok {
+ front = up.route
+ }
+ if rt.ImportGate == "" && front.ImportGate == "" {
+ return true
+ }
+ if front.Host == rt.Host && r.Method == http.MethodGet && seeds.TokenMatches(rt.ImportGate, token) {
+ return true
+ }
+ w.Header().Set("Retry-After", "5")
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "this instance is being imported. Try again in a few seconds.", http.StatusServiceUnavailable)
+ return false
+}
diff --git a/internal/edge/importgate_test.go b/internal/edge/importgate_test.go
new file mode 100644
index 000000000..0a0dac869
--- /dev/null
+++ b/internal/edge/importgate_test.go
@@ -0,0 +1,380 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// The import gate (seeded-instances spec §5.4). Neutral fixtures: app shop,
+// hosts under example.com.
+const (
+ gateHost = "old.example.com"
+ gateAliasHost = "www.example.com"
+ gateSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
+ gateRefusal = "this instance is being imported. Try again in a few seconds.\n"
+)
+
+// gateBackend is an upstream that counts its requests and keeps the verify
+// header of the last one, so a test can say whether the app was reached and
+// whether the token reached it.
+type gateBackend struct {
+ mu sync.Mutex
+ n int
+ sawVerify []string
+}
+
+func newGateBackend(t *testing.T, body, cacheControl string) (*httptest.Server, *gateBackend) {
+ t.Helper()
+ b := &gateBackend{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b.mu.Lock()
+ b.n++
+ b.sawVerify = append(b.sawVerify, r.Header.Values(ImportVerifyHeader)...)
+ b.mu.Unlock()
+ if cacheControl != "" {
+ w.Header().Set("Cache-Control", cacheControl)
+ }
+ w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+ return srv, b
+}
+
+func (b *gateBackend) seen() (int, []string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.n, append([]string(nil), b.sawVerify...)
+}
+
+// gateForImport swaps host's stored row for a gated import row backed by
+// addr, the way the swap does (one journal transaction), and returns the
+// row it wrote. The import row keeps the stored row's account and app.
+func gateForImport(t *testing.T, reg *registry.Registry, host, addr, gate string, hibernate bool) registry.Route {
+ t.Helper()
+ stored, err := reg.Get(host)
+ if err != nil || stored == nil {
+ t.Fatalf("get %s: %v %v", host, stored, err)
+ }
+ next := registry.Route{Host: host, Kind: "instance", Addr: addr, Status: "ready", App: stored.App,
+ Account: stored.Account, Hibernate: hibernate, Provisioned: true, ImportGate: gate,
+ SeedSource: `{"id":"` + gateSeedID + `","kind":"import","state":"swapped"}`}
+ j := registry.ImportJournal{Host: host, SeedID: gateSeedID, Original: *stored, At: 1,
+ RecordKey: "apps/bbw/shop/instances/" + host + ".json"}
+ if err := reg.SwapForImport(j, next); err != nil {
+ t.Fatalf("swap %s: %v", host, err)
+ }
+ return next
+}
+
+// gateScene is an edge with a legacy row at gateHost (and an alias of it),
+// swapped for a gated import row whose instance hibernates behind a fake
+// activator.
+type gateScene struct {
+ reg *registry.Registry
+ e *Edge
+ act *fakeActivator
+ legacy *gateBackend
+ imported *gateBackend
+ token string
+}
+
+func newGateScene(t *testing.T, importCacheControl string) *gateScene {
+ t.Helper()
+ legacySrv, legacy := newGateBackend(t, "legacy", "public, max-age=60")
+ importSrv, imported := newGateBackend(t, "imported", importCacheControl)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: gateHost, Kind: "service", Addr: legacySrv.Listener.Addr().String(),
+ Status: "ready", App: "shop", Account: "bbw"}); err != nil {
+ t.Fatal(err)
+ }
+ upsertAlias(t, reg, gateAliasHost, gateHost)
+ token, hash, err := seeds.NewVerifyToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ act := &fakeActivator{}
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(e.probers.Stop)
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ // Before the swap the legacy app answers and its response is cached.
+ if rec := gateDo(e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" {
+ t.Fatalf("before the swap: %d %q", rec.Code, rec.Body.String())
+ }
+ gateForImport(t, reg, gateHost, importSrv.Listener.Addr().String(), hash, true)
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ return &gateScene{reg: reg, e: e, act: act, legacy: legacy, imported: imported, token: token}
+}
+
+func gateDo(h http.Handler, method, host, target, token string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, "http://"+host+target, nil)
+ req.Host = host
+ if token != "" {
+ req.Header.Set(ImportVerifyHeader, token)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// wantGateRefusal is the gate's answer: 503, Retry-After 5, no-store, the
+// deck's sentence, and nothing of the token.
+func wantGateRefusal(t *testing.T, rec *httptest.ResponseRecorder, token, what string) {
+ t.Helper()
+ if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" ||
+ rec.Header().Get("Cache-Control") != "no-store" || rec.Body.String() != gateRefusal {
+ t.Fatalf("%s: %d retry %q cache %q body %q, want the gate's 503", what, rec.Code,
+ rec.Header().Get("Retry-After"), rec.Header().Get("Cache-Control"), rec.Body.String())
+ }
+ if token != "" && strings.Contains(rec.Body.String()+strings.Join(headerValues(rec.Header()), " "), token) {
+ t.Fatalf("%s: the refusal echoed the token", what)
+ }
+}
+
+func headerValues(h http.Header) []string {
+ var out []string
+ for k, vs := range h {
+ out = append(out, k)
+ out = append(out, vs...)
+ }
+ return out
+}
+
+// Every ordinary request to a gated row is refused before the cache and the
+// activator; only a GET carrying the right token reaches the instance, and
+// the app never sees the header. A closed gate refuses even the token, and
+// commit opens the row to everyone.
+func TestImportGateAtTheEdge(t *testing.T) {
+ s := newGateScene(t, "")
+ wrong, _, err := seeds.NewVerifyToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ refused := []struct {
+ name, method, token string
+ }{
+ {"no token", http.MethodGet, ""},
+ {"a wrong token", http.MethodGet, wrong},
+ {"the hash as the token", http.MethodGet, seeds.HashToken(s.token)},
+ {"a POST with the token", http.MethodPost, s.token},
+ {"a HEAD with the token", http.MethodHead, s.token},
+ {"a PUT with the token", http.MethodPut, s.token},
+ }
+ for _, c := range refused {
+ rec := gateDo(s.e, c.method, gateHost, "/up", c.token)
+ if c.method == http.MethodHead {
+ // A HEAD answer has no body to compare.
+ if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" {
+ t.Fatalf("%s: %d, want the gate's 503", c.name, rec.Code)
+ }
+ continue
+ }
+ wantGateRefusal(t, rec, s.token, c.name)
+ }
+ if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 {
+ t.Fatalf("the gate let an ordinary request through: app %d, wakes %d", n, s.act.served)
+ }
+ if n, _ := s.legacy.seen(); n != 1 {
+ t.Fatalf("the legacy app was reached %d times, want only the request before the swap", n)
+ }
+
+ // The verify request: GET with the token, on any path of the host. The
+ // legacy response cached before the swap is not what it gets.
+ rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token)
+ if rec.Code != http.StatusOK || rec.Body.String() != "imported" {
+ t.Fatalf("verify = %d %q, want the imported app", rec.Code, rec.Body.String())
+ }
+ if _, saw := s.imported.seen(); len(saw) != 0 {
+ t.Fatalf("the app saw the verify header: %q", saw)
+ }
+ if s.act.served != 1 {
+ t.Fatalf("the verify request woke the instance %d times, want 1", s.act.served)
+ }
+
+ // A rollback closes the gate: even the token is refused.
+ if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, "/up", s.token), s.token, "a closed gate")
+ if n, _ := s.imported.seen(); n != 1 {
+ t.Fatalf("a closed gate let the token through: app reached %d times", n)
+ }
+}
+
+// Commit clears the gate: ordinary requests reach the instance, and a stray
+// verify header is still stripped before the app.
+func TestImportGateOpensOnCommit(t *testing.T) {
+ s := newGateScene(t, "")
+ if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if rec := gateDo(s.e, http.MethodPost, gateHost, "/orders", ""); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
+ t.Fatalf("after commit = %d %q, want the imported app", rec.Code, rec.Body.String())
+ }
+ if rec := gateDo(s.e, http.MethodGet, gateHost, "/orders", s.token); rec.Code != http.StatusOK {
+ t.Fatalf("after commit with a stray token = %d", rec.Code)
+ }
+ if _, saw := s.imported.seen(); len(saw) != 0 {
+ t.Fatalf("the app saw the verify header after commit: %q", saw)
+ }
+}
+
+// An alias of a gated host is the gated row under another name: refused
+// without the token, and the token admits a GET.
+func TestImportGateCoversAnAlias(t *testing.T) {
+ s := newGateScene(t, "")
+ wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/up", ""), s.token, "an alias with no token")
+ if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 {
+ t.Fatalf("an alias reached a gated row: app %d, wakes %d", n, s.act.served)
+ }
+ if rec := gateDo(s.e, http.MethodGet, gateAliasHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
+ t.Fatalf("verify through an alias = %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+// A path route under a gated host is gated too, and the token does not
+// open it: while the host is gated nothing but the verify request reaches
+// anything, and the verify request reaches only the gated row. The other
+// direction holds as well: an ungated host's prefix that selects a gated
+// row is refused, token or not, because a verify names the import's host.
+func TestImportGateCoversPathRoutes(t *testing.T) {
+ s := newGateScene(t, "")
+ targetSrv, target := newGateBackend(t, "prefix target", "")
+ frontSrv, front := newGateBackend(t, "front", "")
+ for _, rt := range []registry.Route{
+ {Host: "next.example.com", Kind: "service", Addr: targetSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"},
+ {Host: "front.example.com", Kind: "service", Addr: frontSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"},
+ } {
+ if err := s.reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ upsertPath(t, s.reg, gateHost, "shop", "next.example.com")
+ upsertPath(t, s.reg, "front.example.com", "old", gateHost)
+ if err := s.e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, tok := range []string{"", s.token} {
+ for _, p := range []string{"/shop", "/shop/cart", "//shop/cart", "/x/../shop"} {
+ wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, p, tok), s.token, "the gated host's prefix "+p)
+ }
+ wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/shop", tok), s.token, "the prefix through the alias")
+ wantGateRefusal(t, gateDo(s.e, http.MethodGet, "front.example.com", "/old/up", tok), s.token, "a prefix selecting the gated row")
+ }
+ if n, _ := target.seen(); n != 0 {
+ t.Fatalf("a path route under a gated host was reached %d times", n)
+ }
+ if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 {
+ t.Fatalf("the gated row was reached through a prefix: app %d, wakes %d", n, s.act.served)
+ }
+
+ // The ungated front's own route is not the import's business.
+ if rec := gateDo(s.e, http.MethodGet, "front.example.com", "/home", ""); rec.Code != http.StatusOK || rec.Body.String() != "front" {
+ t.Fatalf("the ungated front = %d %q", rec.Code, rec.Body.String())
+ }
+ if n, _ := front.seen(); n != 1 {
+ t.Fatalf("front reached %d times", n)
+ }
+ // A verify on a path no prefix claims reaches the gated row.
+ if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
+ t.Fatalf("verify beside a prefix = %d %q", rec.Code, rec.Body.String())
+ }
+
+ // After commit the prefix routes as before.
+ if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if rec := gateDo(s.e, http.MethodGet, gateHost, "/shop/cart", ""); rec.Code != http.StatusOK || rec.Body.String() != "prefix target" {
+ t.Fatalf("the prefix after commit = %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+// The verify request never touches the shared cache: it does not read the
+// legacy app's cached answer, and its own answer is not stored, so a
+// rollback that puts the legacy route back never replays the import's
+// response to visitors.
+func TestImportGateVerifyBypassesTheCache(t *testing.T) {
+ s := newGateScene(t, "public, max-age=60")
+ for i := 1; i <= 2; i++ {
+ if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
+ t.Fatalf("verify %d = %d %q", i, rec.Code, rec.Body.String())
+ }
+ if n, _ := s.imported.seen(); n != i {
+ t.Fatalf("verify %d reached the app %d times: served from the cache", i, n)
+ }
+ }
+ // The rollback restores the legacy route.
+ if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.reg.RestoreImportRoute(gateHost, gateSeedID); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" {
+ t.Fatalf("after the rollback = %d %q, want the legacy answer", rec.Code, rec.Body.String())
+ }
+}
+
+// A gated host homed on another box is gated by that box's edge: the gate
+// column travels with the row, and the sending edge has no row for the host,
+// so it backhauls the request untouched and the home edge decides. Without
+// the token the client gets the home edge's 503; with it, the import answers
+// and never sees the header.
+func TestImportGateHoldsAcrossABackhaul(t *testing.T) {
+ s := newBackhaulScene(t)
+ s.converge(t)
+ importSrv, imported := newGateBackend(t, "imported", "")
+ token, hash, err := seeds.NewVerifyToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ gateForImport(t, s.a.edge.reg, e2eHomedHost, importSrv.Listener.Addr().String(), hash, false)
+ if err := s.a.edge.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.b.edge.lookupUpstream(e2eHomedHost); ok {
+ t.Fatal("test bug: the sending edge must not route the gated host")
+ }
+ url := "https://" + e2eHomedHost + "/up"
+ home := s.a.calls.Load()
+ resp, body := s.get(t, s.b, url, nil, true)
+ if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "5" || body != gateRefusal {
+ t.Fatalf("backhauled with no token = %d retry %q body %q, want the home edge's gate", resp.StatusCode, resp.Header.Get("Retry-After"), body)
+ }
+ if got := s.a.calls.Load() - home; got != 1 {
+ t.Fatalf("the home edge saw %d request(s), want the one it refused", got)
+ }
+ if n, _ := imported.seen(); n != 0 {
+ t.Fatalf("a backhauled request with no token reached the import %d times", n)
+ }
+ resp, body = s.get(t, s.b, url, map[string]string{ImportVerifyHeader: token}, true)
+ if resp.StatusCode != http.StatusOK || body != "imported" {
+ t.Fatalf("backhauled verify = %d %q, want the import", resp.StatusCode, body)
+ }
+ if n, saw := imported.seen(); n != 1 || len(saw) != 0 {
+ t.Fatalf("backhauled verify: import reached %d times, header at the app %q", n, saw)
+ }
+}
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go
index 75df8a0d5..8f37ef98b 100644
--- a/internal/schedule/runner.go
+++ b/internal/schedule/runner.go
@@ -196,6 +196,12 @@ func (r *Runner) Tick(ctx context.Context) {
if r.holdsArmed() && registry.IsHold(rt.Status) {
continue
}
+ // An uncommitted import's row gets no ticks (seeded-instances spec
+ // §5.4): only verify requests reach it until commit. Whether or not
+ // the hold wiring is armed.
+ if rt.ImportGate != "" {
+ continue
+ }
if r.beforeClaim != nil {
r.beforeClaim(host)
}
@@ -214,7 +220,9 @@ func (r *Runner) Tick(ctx context.Context) {
rt, readErr = r.Reg.Get(host)
return readErr
})
- if err != nil || rt == nil || registry.IsHold(rt.Status) {
+ // The import gate is part of the decision too: a swap that
+ // landed after the pre-filter read gets no tick.
+ if err != nil || rt == nil || registry.IsHold(rt.Status) || rt.ImportGate != "" {
if err != nil {
log.Printf("schedule: %s: registry read inside the claim: %v. skipping this tick", host, err)
}
diff --git a/internal/schedule/runner_test.go b/internal/schedule/runner_test.go
index 0cf11fb0c..2e716c700 100644
--- a/internal/schedule/runner_test.go
+++ b/internal/schedule/runner_test.go
@@ -1258,3 +1258,95 @@ func TestRunnerWithoutTheGateIsMain(t *testing.T) {
}
}
}
+
+// ---- the import gate (seeded-instances spec §5.4) ----
+
+const gateSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
+
+// gateForImport swaps host's row for a gated import row in one journal
+// transaction, the way the swap does.
+func gateForImport(t *testing.T, reg *registry.Registry, host string) {
+ t.Helper()
+ stored, err := reg.Get(host)
+ if err != nil || stored == nil {
+ t.Fatalf("get %s: %v %v", host, stored, err)
+ }
+ next := *stored
+ next.Provisioned = true
+ next.ImportGate = strings.Repeat("a", 64)
+ next.SeedSource = `{"id":"` + gateSeedID + `","kind":"import","state":"swapped"}`
+ if err := reg.SwapForImport(registry.ImportJournal{Host: host, SeedID: gateSeedID, Original: *stored, At: 1,
+ RecordKey: "apps/bab/app/instances/" + host + ".json"}, next); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A gated row gets no ticks, whether or not the hold wiring is armed, and
+// its bookkeeping does not move; commit opens it.
+func TestTickSkipsGatedRows(t *testing.T) {
+ for _, armed := range []bool{false, true} {
+ t.Run(fmt.Sprintf("holds armed %v", armed), func(t *testing.T) {
+ reg, r := runnerFixture(t, nil)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ if armed {
+ r.Gate = &HostGate{}
+ }
+ delivered := 0
+ r.Deliver = deliverFunc(func(registry.Route, *http.Request) (int, error) { delivered++; return 204, nil })
+ gateForImport(t, reg, holdHost)
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1})
+ before := timersOf(t, reg, holdHost)
+ r.tick()
+ if delivered != 0 {
+ t.Fatalf("delivered %d tick(s) to a gated row", delivered)
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, before) {
+ t.Fatalf("a gated row's bookkeeping moved:\n got %+v\nwant %+v", got, before)
+ }
+ if r.Busy(holdHost) {
+ t.Fatal("the runner kept a claim on a gated host")
+ }
+ if err := reg.CommitImport(holdHost, gateSeedID); err != nil {
+ t.Fatal(err)
+ }
+ r.tick()
+ if delivered != 1 {
+ t.Fatalf("after commit delivered %d, want 1", delivered)
+ }
+ })
+ }
+}
+
+// M14: the swap lands between the tick's pre-filter read and its claim. The
+// decision read inside the claim sees the gate and lets go: nothing is
+// delivered and the bookkeeping does not move.
+func TestGateLandingBeforeTheClaimGetsNoTick(t *testing.T) {
+ d := &fakeDeliverer{status: 204}
+ reg, r := runnerFixture(t, d)
+ now := at("2026-08-23T13:07:00Z")
+ r.Now = func() time.Time { return now }
+ r.Gate = &HostGate{}
+ reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1})
+ before := timersOf(t, reg, holdHost)
+ swapped := false
+ r.beforeClaim = func(host string) {
+ if host == holdHost && !swapped {
+ swapped = true
+ gateForImport(t, reg, holdHost)
+ }
+ }
+ r.tick()
+ if !swapped {
+ t.Fatal("test bug: the tick never reached its claim")
+ }
+ if len(d.calls) != 0 {
+ t.Fatalf("a row gated before the claim got %d tick(s)", len(d.calls))
+ }
+ if got := timersOf(t, reg, holdHost); !sameTimers(got, before) {
+ t.Fatalf("the gated row's bookkeeping moved:\n got %+v\nwant %+v", got, before)
+ }
+ if r.Busy(holdHost) {
+ t.Fatal("the tick kept its claim after seeing the gate")
+ }
+}