host: wire the import swap and rollback; fence generations survive restarts (Plan 7 Task 7)
wireSeedPlacement (preflight M7) now arms, with claim control off, the swap's NestedOK and LegacyOwners and all six rollback hooks together: fenceForRollback over the activator (the layout pass's rule, typed by layout.Fence, m13), LiftFence, RemoveLocal as activator.PurgeSeedLocal, LocalSeedID as activator.ReadSeedMarker, deleteReplica under <prefix>/<host>/ and NextFenceGen. agent.go passes the activator, cfg.Prefix, the startup-sweep flag and the generation source; the recovery closure records the sweep. Claim-armed boxes arm only Data. Fence generations come from the registry (fence_generation): the later of the wall clock and one past the last handed out, stored before use, so none repeats across an agent restart or a clock step back. The layout pass draws from the same source. NextFenceGen returns an error, and a failed or zero generation closes nothing; fenceForRollback refuses gen 0. seeds.ProtocolVersion stays 0 (M9): Plan 8's last task raises it. TestWithSeedsFields asserts seeds.ProtocolVersion (M8); a test pins seeds.ClosedGate to registry.ImportGateClosed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+727
−21
internal/host/agent.go+6 −2internal/host/layoutleg.go+14 −0internal/host/seedsfloor_linux_test.go+3 −2internal/host/seedwiring.go+121 −2internal/host/seedwiring_linux_test.go+63 −0internal/host/seedwiring_test.go+303 −3internal/provision/seedreconcile.go+5 −2internal/provision/seedrollback.go+9 −1internal/provision/seedrollback_test.go+30 −3internal/registry/fencegen.go+52 −0internal/registry/fencegen_test.go+90 −0internal/registry/registry.go+3 −0internal/seeds/floor.go+11 −3internal/seeds/floor_test.go+6 −3internal/seeds/seeds_test.go+11 −0
CI passed — run details
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a9959a147..80f3c1d29 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -410,9 +410,12 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // Seeded instances (seeded-instances spec §3.4): the seed reader, |
| // the placed recorder, the provision pass's hooks and the claim |
| // flag the seed fallback refuses under. Before Start, like the |
| - // mirror. |
| + // mirror. The import swap and rollback (spec §5.4, §5.6) fence |
| + // through this activator, delete the replica under cfg.Prefix, and |
| + // trust an unproven stop only after the startup sweep below. |
| wireSeedPlacement(eb, claims.Writes.Enabled, seedDataOpener(cfg.Bucket, cfg.Region), |
| - func() (release.Store, error) { return openDeployStore(context.Background()) }) |
| + func() (release.Store, error) { return openDeployStore(context.Background()) }, |
| + seedBox{Fence: svc, Prefix: cfg.Prefix, Swept: seedSweptAtStart.Load, NextFenceGen: fenceGenerations(reg)}) |
| armMovesPass(*cfg, dd, reg, svc, eb, claims.Writes) |
| armRunsPass(svc, reg, dd) |
| // The startup sweep (design §6.7): a leftover instance unit from a |
| @@ -442,6 +445,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| return err |
| }, |
| func(ctx context.Context, swept bool) { |
| + seedSweptAtStart.Store(swept) |
| pass := newLayoutPass(reg, svc, dd, eb.NestedOK, swept) |
| intent := startupRecoveryIntent(ctx, dd, openDeployStore, metricsBoxLabel(os.Getenv, readMachineID())) |
| recoverLayoutAtStart(ctx, pass, intent) |
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go| index a035f412b..396d3bacf 100644 |
| --- a/internal/host/layoutleg.go |
| +++ b/internal/host/layoutleg.go |
| @@ -325,6 +325,20 @@ func newLayoutPass(reg *registry.Registry, svc *activator.Service, dd string, ne |
| if svc != nil { |
| p.Fence = svc |
| } |
| + // The pass fences from the box's generation source, the one the import |
| + // rollback's journal stores its generation from (fenceGenerations), so |
| + // a rollback resumed after a restart never meets this pass's fence |
| + // under its own number. A source that fails falls back to the pass's |
| + // own default, the wall clock. |
| + if reg != nil { |
| + next := fenceGenerations(reg) |
| + p.Generation = func() uint64 { |
| + if g, err := next(); err == nil { |
| + return g |
| + } |
| + return uint64(time.Now().UnixNano()) |
| + } |
| + } |
| // The default's readiness is this same gate (defaultLayoutIntent). |
| if nestedOK != nil { |
| layoutDefaultGate.Store(&nestedOK) |
diff --git a/internal/host/seedsfloor_linux_test.go b/internal/host/seedsfloor_linux_test.go| index a85a068c8..59a4b84eb 100644 |
| --- a/internal/host/seedsfloor_linux_test.go |
| +++ b/internal/host/seedsfloor_linux_test.go |
| @@ -5,6 +5,7 @@ package host |
| import ( |
| "context" |
| "encoding/json" |
| + "fmt" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -138,7 +139,7 @@ func TestWithSeedsFields(t *testing.T) { |
| t.Fatalf("armed = %+v", f) |
| } |
| body, _ := json.Marshal(f) |
| - if !strings.Contains(string(body), `"seeds_protocol":0`) { |
| - t.Fatalf("seeds_protocol must be on the wire even at 0: %s", body) |
| + if want := fmt.Sprintf(`"seeds_protocol":%d`, seeds.ProtocolVersion); !strings.Contains(string(body), want) { |
| + t.Fatalf("seeds_protocol must be on the wire, even at 0: want %s in %s", want, body) |
| } |
| } |
diff --git a/internal/host/seedwiring.go b/internal/host/seedwiring.go| index f4dca6af7..85faac55e 100644 |
| --- a/internal/host/seedwiring.go |
| +++ b/internal/host/seedwiring.go |
| @@ -8,9 +8,13 @@ import ( |
| "log" |
| "os" |
| "sync/atomic" |
| + "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/seeds" |
| ) |
| @@ -103,6 +107,17 @@ func seedDataOpener(bucket, region string) func() (release.Store, error) { |
| } |
| } |
| +// seedBox is what the import swap and rollback need from this box beyond |
| +// the two stores (preflight M7): the activator's fence, the key prefix of |
| +// the replicas in the data bucket (cfg.Prefix), whether the startup unit |
| +// sweep succeeded, and the fence generation source. |
| +type seedBox struct { |
| + Fence layout.Fence |
| + Prefix string |
| + Swept func() bool |
| + NextFenceGen func() (uint64, error) |
| +} |
| + |
| // wireSeedPlacement arms seeded instances on this box (seeded-instances |
| // spec §3.4): the claim flag that makes the seed fallback refuse, the data |
| // bucket for snapshots, the record store for the placed CAS, and the |
| @@ -110,7 +125,19 @@ func seedDataOpener(bucket, region string) func() (release.Store, error) { |
| // rest is best-effort like the staging-fallback mirror, and all or nothing: |
| // a box that cannot build both stores places no seed, and a seeded row's |
| // wake fails instead of starting empty. |
| -func wireSeedPlacement(eb *activator.ExecBackend, claimArmed bool, openData, openDeploy func() (release.Store, error)) { |
| +// |
| +// The import swap's and rollback's hooks (spec §5.4, §5.6) are armed only |
| +// with claim control off: a claim-armed box refuses seeds until #250, so it |
| +// never swaps, and an import journaled before claims came on stays gated |
| +// and untouched there. The rollback's six hooks are armed together or not |
| +// at all (provision runs no rollback missing one); without a fence from the |
| +// activator none is. |
| +// |
| +// The rollback's fence is known by the generation its journal stores, so |
| +// box.NextFenceGen must never repeat one across a restart |
| +// (fenceGenerations). The hooks set no clock: the swap and the rollback |
| +// read the pass's own (ReconcileConfig.Now). |
| +func wireSeedPlacement(eb *activator.ExecBackend, claimArmed bool, openData, openDeploy func() (release.Store, error), box seedBox) { |
| eb.ClaimArmed = claimArmed |
| data, err := openData() |
| if err != nil { |
| @@ -124,5 +151,97 @@ func wireSeedPlacement(eb *activator.ExecBackend, claimArmed bool, openData, ope |
| } |
| eb.Seeds = storeSeedObjects{data} |
| eb.OnSeedPlaced = seedPlacedHook(deploy, data) |
| - armSeedHooks(&provision.SeedHooks{Data: data}) |
| + h := &provision.SeedHooks{Data: data} |
| + if !claimArmed { |
| + h.NestedOK = eb.NestedOK |
| + h.LegacyOwners = func(ctx context.Context) (*seeds.LegacyOwners, error) { |
| + o, _, err := seeds.ReadLegacyOwners(ctx, deploy) |
| + return o, err |
| + } |
| + if box.Fence != nil && box.Swept != nil && box.NextFenceGen != nil { |
| + fence := box.Fence |
| + h.FenceForRollback = fenceForRollback(fence, box.Swept) |
| + h.LiftFence = func(host string, gen uint64) { fence.LiftFence(host, gen) } |
| + h.RemoveLocal = func(rt registry.Route) error { return activator.PurgeSeedLocal(rt.DBPath) } |
| + h.LocalSeedID = activator.ReadSeedMarker |
| + h.DeleteReplica = deleteReplica(data, box.Prefix) |
| + h.NextFenceGen = box.NextFenceGen |
| + } |
| + } |
| + armSeedHooks(h) |
| +} |
| + |
| +// seedSweptAtStart is whether the agent's startup unit sweep succeeded, |
| +// for the import rollback's stop proof: the same fact the layout pass is |
| +// built with. It stays false with claim control on, where the activator |
| +// starts without a sweep (and no rollback is armed). |
| +var seedSweptAtStart atomic.Bool |
| + |
| +// fenceGenerations is this box's fence generation source, the registry's: |
| +// never 0, and never one it handed out before, across agent restarts. The |
| +// layout pass draws from it too (newLayoutPass), so a rollback resumed |
| +// after a restart can never meet the pass's fence under its own number. |
| +func fenceGenerations(reg *registry.Registry) func() (uint64, error) { |
| + return func() (uint64, error) { return reg.NextFenceGeneration(time.Now()) } |
| +} |
| + |
| +// rollbackFenceTimeout bounds one fence-and-stop, as the layout pass's. |
| +const rollbackFenceTimeout = 30 * time.Second |
| + |
| +// fenceForRollback is the import rollback's step 2 (seeded-instances spec |
| +// §5.6), by internal/layout/pass.go's rule (Pass.quiesce) for the same |
| +// activator API. A fence already holding gen is this rollback's own, from |
| +// an earlier pass of this process: proceed. Otherwise fence and stop under |
| +// a 30-second deadline. A proven stop proceeds; another operation's fence |
| +// defers, touching nothing; an unproven stop proceeds only when the box |
| +// was swept at start, the host is quiesced and its stop is not uncertain. |
| +// Anything else lifts this attempt's fence and defers. |
| +// |
| +// Generation 0 is no fence's: FenceGeneration answers 0 for a host with |
| +// none, which must never read as ours. |
| +func fenceForRollback(f layout.Fence, swept func() bool) func(ctx context.Context, host string, gen uint64) (bool, error) { |
| + return func(ctx context.Context, host string, gen uint64) (bool, error) { |
| + if gen == 0 { |
| + return false, activator.ErrFenceGeneration |
| + } |
| + if f.FenceGeneration(host) == gen { |
| + return true, nil |
| + } |
| + fctx, cancel := context.WithTimeout(ctx, rollbackFenceTimeout) |
| + defer cancel() |
| + _, err := f.FenceAndStopIfUnfenced(fctx, host, gen) |
| + switch { |
| + case err == nil: |
| + return true, nil |
| + case errors.Is(err, activator.ErrFenceHeld): |
| + return false, err |
| + case errors.Is(err, activator.ErrStopUnproven) && swept() && f.Quiesced(host) && !f.StopUncertain(host): |
| + return true, nil |
| + } |
| + f.LiftFence(host, gen) |
| + return false, err |
| + } |
| +} |
| + |
| +// deleteReplica is the rollback's step 6 for the host's replica: every key |
| +// under <prefix>/<host>/ in the region data bucket, the prefix the |
| +// activator replicates to (activator.HibernatePrefix). The trailing slash |
| +// keeps a host whose name only begins with this one out of it. Nothing |
| +// there is not an error. |
| +func deleteReplica(data release.Store, prefix string) func(ctx context.Context, host string) error { |
| + return func(ctx context.Context, host string) error { |
| + if !controlname.ValidHostShape(host) { |
| + return fmt.Errorf("delete the replica of %q: not a host name", host) |
| + } |
| + keys, err := data.List(ctx, activator.HibernatePrefix(prefix, host)+"/") |
| + if err != nil { |
| + return err |
| + } |
| + for _, k := range keys { |
| + if err := data.Delete(ctx, k); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + } |
| + return nil |
| + } |
| } |
diff --git a/internal/host/seedwiring_linux_test.go b/internal/host/seedwiring_linux_test.go| new file mode 100644 |
| index 000000000..a316f2c8d |
| --- /dev/null |
| +++ b/internal/host/seedwiring_linux_test.go |
| @@ -0,0 +1,63 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The wired LocalSeedID and RemoveLocal are Plan 4's marker read and purge |
| +// (preflight M12), through instancefs: the marker names the seed, and the |
| +// removal takes the database, its WAL and SHM, the marker and the seed |
| +// fetch leftovers, and leaves anything else in the directory. |
| +func TestWiredRollbackReadsAndRemovesTheLocalSeed(t *testing.T) { |
| + h := wiredSeedHooks(t, &fakeFence{gens: map[string]uint64{}}, restartTestRegistry(t), |
| + release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())) |
| + const id = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| + dir := filepath.Join(t.TempDir(), "shop-1.example.com") |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + db := filepath.Join(dir, "shop-1.example.com.db") |
| + gone := []string{db, db + "-wal", db + "-shm", db + activator.SeedMarkerSuffix, filepath.Join(dir, ".seed-sd_x.tmp")} |
| + for _, p := range gone { |
| + body := "data" |
| + if p == db+activator.SeedMarkerSuffix { |
| + body = id + "\n" |
| + } |
| + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + kept := filepath.Join(dir, "uploads.txt") |
| + if err := os.WriteFile(kept, []byte("keep"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if got, err := h.LocalSeedID(db); err != nil || got != id { |
| + t.Fatalf("LocalSeedID = %q, %v; want %q", got, err, id) |
| + } |
| + if err := h.RemoveLocal(registry.Route{Host: "shop-1.example.com", Kind: "instance", DBPath: db}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, p := range gone { |
| + if _, err := os.Lstat(p); !os.IsNotExist(err) { |
| + t.Errorf("%s survived the removal (%v)", p, err) |
| + } |
| + } |
| + if _, err := os.Stat(kept); err != nil { |
| + t.Errorf("an unrelated file went: %v", err) |
| + } |
| + if got, err := h.LocalSeedID(db); err != nil || got != "" { |
| + t.Fatalf("LocalSeedID after the removal = %q, %v", got, err) |
| + } |
| + if err := h.RemoveLocal(registry.Route{Host: "shop-1.example.com", Kind: "instance", DBPath: db}); err != nil { |
| + t.Fatalf("a second removal: %v", err) |
| + } |
| +} |
diff --git a/internal/host/seedwiring_test.go b/internal/host/seedwiring_test.go| index 010bf1be8..aad57d368 100644 |
| --- a/internal/host/seedwiring_test.go |
| +++ b/internal/host/seedwiring_test.go |
| @@ -5,13 +5,16 @@ import ( |
| "errors" |
| "io" |
| "os" |
| + "path/filepath" |
| "sync" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/seeds" |
| ) |
| @@ -250,7 +253,7 @@ func TestWireSeedPlacement(t *testing.T) { |
| t.Run("armed", func(t *testing.T) { |
| resetArmedSeedHooks(t) |
| eb := &activator.ExecBackend{} |
| - wireSeedPlacement(eb, true, ok(data), ok(deploy)) |
| + wireSeedPlacement(eb, true, ok(data), ok(deploy), seedBox{}) |
| if !eb.ClaimArmed || eb.Seeds == nil || eb.OnSeedPlaced == nil { |
| t.Fatalf("ClaimArmed %v Seeds %v OnSeedPlaced set %v", eb.ClaimArmed, eb.Seeds, eb.OnSeedPlaced != nil) |
| } |
| @@ -262,7 +265,7 @@ func TestWireSeedPlacement(t *testing.T) { |
| t.Run("claims off", func(t *testing.T) { |
| resetArmedSeedHooks(t) |
| eb := &activator.ExecBackend{ClaimArmed: true} |
| - wireSeedPlacement(eb, false, ok(data), ok(deploy)) |
| + wireSeedPlacement(eb, false, ok(data), ok(deploy), seedBox{}) |
| if eb.ClaimArmed { |
| t.Fatal("ClaimArmed set with claim control off") |
| } |
| @@ -274,7 +277,7 @@ func TestWireSeedPlacement(t *testing.T) { |
| t.Run(name, func(t *testing.T) { |
| resetArmedSeedHooks(t) |
| eb := &activator.ExecBackend{} |
| - wireSeedPlacement(eb, true, opens[0], opens[1]) |
| + wireSeedPlacement(eb, true, opens[0], opens[1], seedBox{}) |
| if !eb.ClaimArmed { |
| t.Fatal("ClaimArmed not set when a store failed") |
| } |
| @@ -320,3 +323,300 @@ func TestReconcileSerializedCarriesTheArmedSeedHooks(t *testing.T) { |
| t.Fatal("the aborted seed's snapshot survived an armed pass") |
| } |
| } |
| + |
| +// fakeFence is the activator's fence as the import rollback meets it: one |
| +// fence per host, an exclusive install that refuses any fence already |
| +// held, and a lift that takes only the generation it names. |
| +// FenceAndStopIfUnfenced answers err once it would install; nil and |
| +// ErrStopUnproven leave the fence installed, as the activator does. |
| +type fakeFence struct { |
| + gens map[string]uint64 |
| + err error |
| + quiesced bool |
| + uncertain bool |
| + calls int |
| + deadline time.Duration // the time left on the last install's context |
| + lifted []uint64 |
| +} |
| + |
| +func (f *fakeFence) FenceAndStopIfUnfenced(ctx context.Context, host string, gen uint64) (*activator.StopEvidence, error) { |
| + f.calls++ |
| + if d, ok := ctx.Deadline(); ok { |
| + f.deadline = time.Until(d) |
| + } |
| + if f.gens[host] != 0 { |
| + return nil, activator.ErrFenceHeld |
| + } |
| + if f.err == nil || errors.Is(f.err, activator.ErrStopUnproven) { |
| + f.gens[host] = gen |
| + } |
| + return nil, f.err |
| +} |
| + |
| +func (f *fakeFence) LiftFence(host string, gen uint64) bool { |
| + f.lifted = append(f.lifted, gen) |
| + if gen == 0 || f.gens[host] != gen { |
| + return false |
| + } |
| + delete(f.gens, host) |
| + return true |
| +} |
| +func (f *fakeFence) FenceGeneration(host string) uint64 { return f.gens[host] } |
| +func (f *fakeFence) Quiesced(string) bool { return f.quiesced } |
| +func (f *fakeFence) StopUncertain(string) bool { return f.uncertain } |
| + |
| +func storeOpener(s release.Store) func() (release.Store, error) { |
| + return func() (release.Store, error) { return s, nil } |
| +} |
| + |
| +// wiredSeedHooks arms seed placement as the agent does on a box without |
| +// claim control, with f as the activator and reg's generation source, and |
| +// returns the provision pass's hooks. |
| +func wiredSeedHooks(t *testing.T, f layout.Fence, reg *registry.Registry, data, deploy release.Store) *provision.SeedHooks { |
| + t.Helper() |
| + resetArmedSeedHooks(t) |
| + eb := &activator.ExecBackend{NestedOK: func() error { return nil }} |
| + wireSeedPlacement(eb, false, storeOpener(data), storeOpener(deploy), seedBox{ |
| + Fence: f, Prefix: "instances", Swept: func() bool { return true }, NextFenceGen: fenceGenerations(reg)}) |
| + h := armedSeedHooks.Load() |
| + if h == nil { |
| + t.Fatal("no seed hooks armed") |
| + } |
| + return h |
| +} |
| + |
| +// The rollback's step 2 (spec §5.6) by the layout pass's rule, and the |
| +// generation it is known by surviving an agent restart. |
| +func TestRollbackFenceAfterRestart(t *testing.T) { |
| + ctx := context.Background() |
| + const host = "shop-1.example.com" |
| + swept := true |
| + f := &fakeFence{gens: map[string]uint64{}, err: activator.ErrStopUnproven, quiesced: true} |
| + fence := fenceForRollback(f, func() bool { return swept }) |
| + if ok, _ := fence(ctx, host, 7); !ok { |
| + t.Fatal("cold, swept, quiesced: want proceed") |
| + } |
| + if f.deadline <= 0 || f.deadline > 30*time.Second { |
| + t.Fatalf("the install ran with %v left, want a deadline of at most 30s", f.deadline) |
| + } |
| + // Ours already (an earlier pass installed it): proceed without asking. |
| + f.err, f.calls = errors.New("must not be called"), 0 |
| + if ok, _ := fence(ctx, host, 7); !ok || f.calls != 0 { |
| + t.Fatalf("our own fence: want proceed without an install (calls %d)", f.calls) |
| + } |
| + // Not swept at start: lift and defer. |
| + f = &fakeFence{gens: map[string]uint64{}, err: activator.ErrStopUnproven, quiesced: true} |
| + swept = false |
| + fence = fenceForRollback(f, func() bool { return swept }) |
| + if ok, _ := fence(ctx, host, 8); ok || len(f.lifted) != 1 || f.lifted[0] != 8 || f.gens[host] != 0 { |
| + t.Fatalf("not swept: proceed %v lifted %v fences %v", ok, f.lifted, f.gens) |
| + } |
| + // Swept, but not quiesced, or its stop uncertain: lift and defer. |
| + for name, f := range map[string]*fakeFence{ |
| + "not quiesced": {gens: map[string]uint64{}, err: activator.ErrStopUnproven}, |
| + "stop uncertain": {gens: map[string]uint64{}, err: activator.ErrStopUnproven, quiesced: true, uncertain: true}, |
| + "another error": {gens: map[string]uint64{}, err: activator.ErrFenceNotInstalled, quiesced: true}, |
| + } { |
| + fence := fenceForRollback(f, func() bool { return true }) |
| + if ok, err := fence(ctx, host, 9); ok || err == nil || f.gens[host] != 0 { |
| + t.Fatalf("%s: proceed %v, %v, fences %v", name, ok, err, f.gens) |
| + } |
| + } |
| + // Another operation's fence: defer and touch nothing. |
| + f = &fakeFence{gens: map[string]uint64{host: 3}} |
| + fence = fenceForRollback(f, func() bool { return true }) |
| + if ok, err := fence(ctx, host, 9); ok || !errors.Is(err, activator.ErrFenceHeld) || len(f.lifted) != 0 || f.gens[host] != 3 { |
| + t.Fatalf("held: proceed %v, %v, lifted %v", ok, err, f.lifted) |
| + } |
| + // Generation 0 is no fence's: on a host with none, FenceGeneration's 0 |
| + // would otherwise read as "ours already" and proceed unfenced. |
| + f = &fakeFence{gens: map[string]uint64{}} |
| + fence = fenceForRollback(f, func() bool { return true }) |
| + if ok, err := fence(ctx, host, 0); ok || err == nil || f.calls != 0 { |
| + t.Fatalf("generation 0: proceed %v, %v, installs %d", ok, err, f.calls) |
| + } |
| + |
| + // After an agent restart. The journal kept the generation the rollback |
| + // was given before it; the activator's fences died with the process, |
| + // and another operation, the layout pass, has fenced the host since. |
| + // The resumed rollback must neither take that fence for its own nor |
| + // lift it: provision lifts at the journal's generation on every path |
| + // that does not proceed, the held-stale-journal lift |
| + // (seedrollback.go, rollBackRoute's first check) among them. |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := registry.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The clock before the restart ran an hour ahead of the clock after it |
| + // (a box that booted before NTP synced): the generation the journal |
| + // stores is ahead of every wall-clock reading the restarted agent makes. |
| + if _, err := reg.NextFenceGeneration(time.Now().Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + data, deploy := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + before := wiredSeedHooks(t, &fakeFence{gens: map[string]uint64{}}, reg, data, deploy) |
| + stored, err := before.NextFenceGen() |
| + if err != nil || stored == 0 { |
| + t.Fatalf("generation before the restart: %d, %v", stored, err) |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reg, err = registry.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + other := newLayoutPass(reg, nil, t.TempDir(), nil, true).Generation() |
| + if other <= stored { |
| + t.Fatalf("the layout pass after the restart drew generation %d, not above the %d the journal holds", other, stored) |
| + } |
| + f = &fakeFence{gens: map[string]uint64{host: other}, err: activator.ErrStopUnproven, quiesced: true} |
| + after := wiredSeedHooks(t, f, reg, data, deploy) |
| + if ok, err := after.FenceForRollback(ctx, host, stored); ok || !errors.Is(err, activator.ErrFenceHeld) { |
| + t.Fatalf("resumed under another operation's fence: proceed %v, %v", ok, err) |
| + } |
| + after.LiftFence(host, stored) |
| + if f.gens[host] != other { |
| + t.Fatalf("the resumed rollback lifted another operation's fence: fences %v", f.gens) |
| + } |
| + if next, err := after.NextFenceGen(); err != nil || next <= other { |
| + t.Fatalf("a generation minted after the restart: %d (%v); before %d, the layout pass's %d", next, err, stored, other) |
| + } |
| + // Once the other operation lets go, the rollback fences at its own |
| + // stored generation and proceeds, as on a cold host. |
| + f.LiftFence(host, other) |
| + if ok, err := after.FenceForRollback(ctx, host, stored); !ok || f.gens[host] != stored { |
| + t.Fatalf("after the other fence lifted: proceed %v, %v, fences %v", ok, err, f.gens) |
| + } |
| +} |
| + |
| +// The rollback's six hooks are all or none (provision skips a rollback |
| +// missing any), so production wiring must set every one; with claim control |
| +// on, seeds are refused until #250 and none of the swap's or the |
| +// rollback's hooks is armed. |
| +func TestWireSeedPlacementArmsTheSwapAndTheRollback(t *testing.T) { |
| + ctx := context.Background() |
| + data, deploy := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + f := &fakeFence{gens: map[string]uint64{}} |
| + reg := restartTestRegistry(t) |
| + nestedErr := errors.New("probe") |
| + box := seedBox{Fence: f, Prefix: "instances", Swept: func() bool { return true }, NextFenceGen: fenceGenerations(reg)} |
| + |
| + rollbackHooks := func(h *provision.SeedHooks) map[string]bool { |
| + return map[string]bool{ |
| + "FenceForRollback": h.FenceForRollback != nil, "LiftFence": h.LiftFence != nil, |
| + "RemoveLocal": h.RemoveLocal != nil, "DeleteReplica": h.DeleteReplica != nil, |
| + "NextFenceGen": h.NextFenceGen != nil, "LocalSeedID": h.LocalSeedID != nil, |
| + } |
| + } |
| + |
| + t.Run("armed", func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + eb := &activator.ExecBackend{NestedOK: func() error { return nestedErr }} |
| + wireSeedPlacement(eb, false, storeOpener(data), storeOpener(deploy), box) |
| + h := armedSeedHooks.Load() |
| + if h == nil || h.Data != release.Store(data) { |
| + t.Fatalf("armed hooks = %+v", h) |
| + } |
| + for name, set := range rollbackHooks(h) { |
| + if !set { |
| + t.Errorf("the rollback hook %s is not wired: no rollback runs", name) |
| + } |
| + } |
| + if h.NestedOK == nil || h.NestedOK() != nestedErr { |
| + t.Error("NestedOK is not the exec backend's probe") |
| + } |
| + if h.LegacyOwners == nil { |
| + t.Fatal("LegacyOwners is not wired") |
| + } |
| + body := `{"v":1,"names":{"old.example.com":{"account":"bbw","app":"shop"}}}` |
| + if _, err := deploy.Put(ctx, seeds.LegacyOwnersKey, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if o, err := h.LegacyOwners(ctx); err != nil || o == nil || o.Names["old.example.com"].App != "shop" { |
| + t.Errorf("LegacyOwners read %+v, %v from the record store", o, err) |
| + } |
| + if g, err := h.NextFenceGen(); err != nil || g == 0 { |
| + t.Errorf("NextFenceGen = %d, %v", g, err) |
| + } |
| + f.gens["shop-1.example.com"] = 41 |
| + h.LiftFence("shop-1.example.com", 41) |
| + if f.gens["shop-1.example.com"] != 0 { |
| + t.Error("LiftFence did not reach the activator") |
| + } |
| + }) |
| + t.Run("claim control on", func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + eb := &activator.ExecBackend{NestedOK: func() error { return nil }} |
| + wireSeedPlacement(eb, true, storeOpener(data), storeOpener(deploy), box) |
| + h := armedSeedHooks.Load() |
| + if h == nil || h.Data != release.Store(data) { |
| + t.Fatalf("armed hooks = %+v, want the data bucket", h) |
| + } |
| + for name, set := range rollbackHooks(h) { |
| + if set { |
| + t.Errorf("the rollback hook %s is armed with claim control on", name) |
| + } |
| + } |
| + if h.NestedOK != nil || h.LegacyOwners != nil { |
| + t.Error("the swap's hooks are armed with claim control on") |
| + } |
| + }) |
| + t.Run("no activator fence", func(t *testing.T) { |
| + resetArmedSeedHooks(t) |
| + wireSeedPlacement(&activator.ExecBackend{}, false, storeOpener(data), storeOpener(deploy), seedBox{Prefix: "instances"}) |
| + h := armedSeedHooks.Load() |
| + if h == nil { |
| + t.Fatal("no hooks") |
| + } |
| + for name, set := range rollbackHooks(h) { |
| + if set { |
| + t.Errorf("the rollback hook %s is armed without a fence: half the rollback", name) |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// The rollback's step 6 deletes the host's replica and nothing beside it: |
| +// not a host whose name only begins with this one, not another host, not |
| +// the seeds/ prefix. |
| +func TestDeleteReplicaDeletesOnlyTheHostsReplica(t *testing.T) { |
| + ctx := context.Background() |
| + data := release.NewDirStore(t.TempDir()) |
| + const host = "shop-1.example.com" |
| + put := func(k string) { |
| + if _, err := data.Put(ctx, k, []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + mine := []string{"instances/" + host + "/generations/a/wal/1.ltx", "instances/" + host + "/lock.json"} |
| + theirs := []string{"instances/" + host + ".au/lock.json", "instances/other.example.com/lock.json", "seeds/" + host + "/sd_aaaaaaaaaaaaaaaaaaaaaaaaaa/snapshot.db"} |
| + for _, k := range append(append([]string{}, mine...), theirs...) { |
| + put(k) |
| + } |
| + del := deleteReplica(data, "instances") |
| + for _, bad := range []string{"", "..", "a/b", "."} { |
| + if err := del(ctx, bad); err == nil { |
| + t.Fatalf("host %q accepted", bad) |
| + } |
| + } |
| + if err := del(ctx, host); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range mine { |
| + if snapshotPresent(t, data, k) { |
| + t.Errorf("%s survived", k) |
| + } |
| + } |
| + for _, k := range theirs { |
| + if !snapshotPresent(t, data, k) { |
| + t.Errorf("%s was deleted", k) |
| + } |
| + } |
| + if err := del(ctx, host); err != nil { |
| + t.Fatalf("nothing there: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/seedreconcile.go b/internal/provision/seedreconcile.go| index 3e87583e7..deef939cc 100644 |
| --- a/internal/provision/seedreconcile.go |
| +++ b/internal/provision/seedreconcile.go |
| @@ -46,8 +46,11 @@ type SeedHooks struct { |
| // DeleteReplica deletes host's instances/ replica in the region data |
| // bucket. Nothing there is not an error. |
| DeleteReplica func(ctx context.Context, host string) error |
| - // NextFenceGen mints a fence generation no fence on this box holds. |
| - NextFenceGen func() uint64 |
| + // NextFenceGen mints a fence generation no fence on this box holds, |
| + // never one it minted before, across agent restarts: the journal |
| + // stores it, and a resumed rollback recognises and lifts its fence by |
| + // it. An error, or 0, closes nothing this pass. |
| + NextFenceGen func() (uint64, error) |
| // LocalSeedID is the seed id the <db>.seed marker beside dbPath names |
| // ("" for none or no marker), read through instancefs. An error is a |
| // marker that could not be read. |
diff --git a/internal/provision/seedrollback.go b/internal/provision/seedrollback.go| index fa376d87d..c52dd33e6 100644 |
| --- a/internal/provision/seedrollback.go |
| +++ b/internal/provision/seedrollback.go |
| @@ -95,7 +95,15 @@ func rollBackRoute(ctx context.Context, s release.Store, reg *registry.Registry, |
| // Step 1. The generation an earlier close stored is the one the fence |
| // is known by, so the journal is read back rather than trusted. |
| if j.State == registry.JournalSwapped { |
| - if err := reg.CloseImportGate(j.Host, j.SeedID, h.NextFenceGen()); err != nil { |
| + gen, err := h.NextFenceGen() |
| + if err == nil && gen == 0 { |
| + err = errors.New("the generation source answered 0") |
| + } |
| + if err != nil { |
| + log.Printf("provision: %s: import %s rollback deferred: no fence generation: %v", j.Host, j.SeedID, err) |
| + return j, false |
| + } |
| + if err := reg.CloseImportGate(j.Host, j.SeedID, gen); err != nil { |
| log.Printf("provision: %s: import %s: close the import gate: %v", j.Host, j.SeedID, err) |
| return j, false |
| } |
diff --git a/internal/provision/seedrollback_test.go b/internal/provision/seedrollback_test.go| index 0f61fcfee..7ac0f5ae2 100644 |
| --- a/internal/provision/seedrollback_test.go |
| +++ b/internal/provision/seedrollback_test.go |
| @@ -26,7 +26,7 @@ type rollbackRecorder struct { |
| } |
| func (r *rollbackRecorder) hooks(h *SeedHooks) { |
| - h.NextFenceGen = func() uint64 { r.gen++; return r.gen } |
| + h.NextFenceGen = func() (uint64, error) { r.gen++; return r.gen, nil } |
| h.FenceForRollback = func(_ context.Context, host string, gen uint64) (bool, error) { |
| r.steps = append(r.steps, "fence") |
| return r.proceed, r.fenceErr |
| @@ -327,10 +327,10 @@ func (b *rollbackBox) wantRestored(what string) { |
| } |
| func (b *rollbackBox) hooks(h *SeedHooks) { |
| - h.NextFenceGen = func() uint64 { |
| + h.NextFenceGen = func() (uint64, error) { |
| b.steps = append(b.steps, "gen") |
| b.gen++ |
| - return b.gen |
| + return b.gen, nil |
| } |
| h.FenceForRollback = func(_ context.Context, host string, gen uint64) (bool, error) { |
| b.steps = append(b.steps, "fence") |
| @@ -1133,3 +1133,30 @@ func TestRollbackOfAMissingRowLeavesTheDataWhenARowAppears(t *testing.T) { |
| t.Fatalf("journal = %q", st) |
| } |
| } |
| + |
| +// A generation that cannot be minted (the box's source failed, or answered |
| +// 0, which is no fence's) closes nothing: the journal stays swapped with |
| +// no generation stored, so a later pass mints a real one. A 0 stored there |
| +// would read as "ours already" on any unfenced host. |
| +func TestRollbackWaitsForAFenceGeneration(t *testing.T) { |
| + for name, mint := range map[string]func() (uint64, error){ |
| + "an error": func() (uint64, error) { return 0, errors.New("registry busy") }, |
| + "zero": func() (uint64, error) { return 0, nil }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + x := abortedImport(t) |
| + x.cfg.Seeds.NextFenceGen = mint |
| + x.pass(t) |
| + j, _ := x.reg.ImportJournal(x.rec.Host) |
| + if j == nil || j.State != registry.JournalSwapped || j.FenceGen != 0 { |
| + t.Fatalf("journal = %+v, want swapped with no generation", j) |
| + } |
| + if got := mustGet(t, x.reg, x.rec.Host); got.ImportGate == registry.ImportGateClosed { |
| + t.Fatalf("the gate closed without a generation: %+v", got) |
| + } |
| + if len(x.box.steps) != 0 { |
| + t.Fatalf("steps = %q", x.box.steps) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/registry/fencegen.go b/internal/registry/fencegen.go| new file mode 100644 |
| index 000000000..e425cefc7 |
| --- /dev/null |
| +++ b/internal/registry/fencegen.go |
| @@ -0,0 +1,52 @@ |
| +package registry |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "time" |
| +) |
| + |
| +// The box's fence generation source. The activator's fences live in the |
| +// agent's memory and die with it, but a generation outlives the process: |
| +// the import journal stores the one its rollback fences under |
| +// (CloseImportGate), and a resumed rollback recognises and lifts its fence |
| +// by that number. A generation handed out after a restart must therefore |
| +// never equal one handed out before it, or the resumed rollback could take |
| +// another operation's fence for its own, or lift it. |
| +// |
| +// Each generation is the later of the wall clock in nanoseconds (the space |
| +// the layout pass and the moves cut draw theirs from) and one past the |
| +// last generation this registry handed out, which is stored before it is |
| +// returned. The stored floor survives a restart, so a clock that went back |
| +// cannot repeat a number; the clock covers a registry restored from an |
| +// older snapshot. |
| + |
| +func (r *Registry) migrateFenceGenerations() error { |
| + _, err := r.sql.Exec(`CREATE TABLE IF NOT EXISTS fence_generation ( |
| + id INTEGER PRIMARY KEY CHECK (id = 1), |
| + last INTEGER NOT NULL |
| +);`) |
| + return err |
| +} |
| + |
| +// NextFenceGeneration hands out a fence generation no earlier call on this |
| +// registry file returned, across restarts: never 0, and above every |
| +// generation it handed out before. now is the wall clock. |
| +func (r *Registry) NextFenceGeneration(now time.Time) (uint64, error) { |
| + var next int64 |
| + err := r.inTx(func(tx txer) error { |
| + var last int64 |
| + err := tx.QueryRow(`SELECT last FROM fence_generation WHERE id = 1`).Scan(&last) |
| + if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| + return err |
| + } |
| + next = max(now.UnixNano(), last+1, 1) |
| + _, err = tx.Exec(`INSERT INTO fence_generation (id, last) VALUES (1, ?) |
| +ON CONFLICT(id) DO UPDATE SET last = excluded.last`, next) |
| + return err |
| + }) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return uint64(next), nil |
| +} |
diff --git a/internal/registry/fencegen_test.go b/internal/registry/fencegen_test.go| new file mode 100644 |
| index 000000000..69fa1d9c2 |
| --- /dev/null |
| +++ b/internal/registry/fencegen_test.go |
| @@ -0,0 +1,90 @@ |
| +package registry |
| + |
| +import ( |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// A generation is never handed out twice by one registry file, across a |
| +// close and reopen (an agent restart) and whatever the clock says: a |
| +// rollback resumed after a restart compares and lifts by the generation it |
| +// stored before it, so a later fence must never carry the same number. |
| +func TestNextFenceGenerationIsUniqueAcrossRestarts(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Unix(1_800_000_000, 0) |
| + before := map[uint64]bool{} |
| + var last uint64 |
| + for i := 0; i < 5; i++ { |
| + g, err := reg.NextFenceGeneration(now) // the same instant: still distinct |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if g == 0 || g <= last { |
| + t.Fatalf("generation %d after %d: want nonzero and rising", g, last) |
| + } |
| + before[g], last = true, g |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reg, err = Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + // The clock went back across the restart (a box that booted before NTP |
| + // synced): the generations still rise past every one handed out before. |
| + for _, at := range []time.Time{now.Add(-time.Hour), now, time.Unix(0, 0), now.Add(-time.Nanosecond)} { |
| + g, err := reg.NextFenceGeneration(at) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before[g] || g <= last { |
| + t.Fatalf("after the restart, at %v: generation %d (last before %d)", at, g, last) |
| + } |
| + last = g |
| + } |
| + // A clock ahead of every earlier generation is followed: generations |
| + // stay in wall-clock nanoseconds, the space the layout pass and the |
| + // moves cut draw theirs from. |
| + later := now.Add(time.Hour) |
| + if g, err := reg.NextFenceGeneration(later); err != nil || g != uint64(later.UnixNano()) { |
| + t.Fatalf("a clock ahead: generation %d, %v; want %d", g, err, later.UnixNano()) |
| + } |
| +} |
| + |
| +func TestNextFenceGenerationConcurrentCallersNeverShare(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + now := time.Unix(1_800_000_000, 0) |
| + const n = 40 |
| + gens := make(chan uint64, n) |
| + var wg sync.WaitGroup |
| + for i := 0; i < n; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + g, err := reg.NextFenceGeneration(now) |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + gens <- g |
| + }() |
| + } |
| + wg.Wait() |
| + close(gens) |
| + seen := map[uint64]bool{} |
| + for g := range gens { |
| + if seen[g] { |
| + t.Fatalf("generation %d handed out twice", g) |
| + } |
| + seen[g] = true |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index e1628cf64..981509218 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -623,6 +623,9 @@ CREATE INDEX IF NOT EXISTS timers_due ON timers (next_at) WHERE next_at > 0;`); |
| if err := r.migrateImportJournal(); err != nil { |
| return err |
| } |
| + if err := r.migrateFenceGenerations(); err != nil { |
| + return err |
| + } |
| if err := r.migrateAccounts(); err != nil { |
| return err |
| } |
diff --git a/internal/seeds/floor.go b/internal/seeds/floor.go| index 0f0ad76df..00158620b 100644 |
| --- a/internal/seeds/floor.go |
| +++ b/internal/seeds/floor.go |
| @@ -11,9 +11,17 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// ProtocolVersion is the seeds protocol this build speaks. 0 until the box |
| -// can place, swap and roll back a seed (Plan 7 raises it to 1): a box that |
| -// reports 0 can never satisfy the console's RequiredProtocol. |
| +// ProtocolVersion is the seeds protocol this build speaks. A box that |
| +// reports 0 can never satisfy the console's RequiredProtocol, so every |
| +// seed door stays shut. |
| +// |
| +// It stays 0 through Plan 7, although this build places, swaps and rolls |
| +// back a seed (Plans 4 and 7): spec §5.8's move refusals, the seeds sweep |
| +// and the legacy-routes assign door must exist before any door opens |
| +// (preflight M9). Plan 8's last task raises it to 1, with the comment |
| +// "1: this build places, swaps and rolls back a seed", and rewrites |
| +// floor_test.go's TestProtocolStartsBelowWhatTheConsoleRequires as |
| +// TestProtocolMeetsWhatTheConsoleRequires. |
| const ProtocolVersion = 0 |
| // RequiredProtocol is what the console requires every box in the region to |
diff --git a/internal/seeds/floor_test.go b/internal/seeds/floor_test.go| index 373ce0889..111be296c 100644 |
| --- a/internal/seeds/floor_test.go |
| +++ b/internal/seeds/floor_test.go |
| @@ -109,9 +109,12 @@ func TestReleaseAtOrAbove(t *testing.T) { |
| } |
| func TestProtocolStartsBelowWhatTheConsoleRequires(t *testing.T) { |
| - // Plan 3 ships the floor dark: no box built from it can satisfy the |
| - // console's check. Plan 7 Task 7 raises ProtocolVersion and rewrites |
| - // this test. |
| + // The floor ships dark: no box built from it can satisfy the console's |
| + // check. Plan 7 builds the swap and the rollback but leaves the switch |
| + // off (preflight M9): spec §5.8's move refusals, the seeds sweep and |
| + // the legacy-routes assign door must exist before any door opens. Plan |
| + // 8's last task raises ProtocolVersion and rewrites this test as |
| + // TestProtocolMeetsWhatTheConsoleRequires. |
| if ProtocolVersion >= RequiredProtocol { |
| t.Fatalf("ProtocolVersion = %d, RequiredProtocol = %d", ProtocolVersion, RequiredProtocol) |
| } |
diff --git a/internal/seeds/seeds_test.go b/internal/seeds/seeds_test.go| index c33836e54..108f3693a 100644 |
| --- a/internal/seeds/seeds_test.go |
| +++ b/internal/seeds/seeds_test.go |
| @@ -4,6 +4,8 @@ import ( |
| "errors" |
| "strings" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| func TestNewIDShape(t *testing.T) { |
| @@ -169,3 +171,12 @@ func TestRowSeedEncoding(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The edge refuses a row whose import gate is the registry's closed value, |
| +// and the console and the box name it as seeds.ClosedGate: the two must |
| +// be one value (seeds may import registry; registry does not import seeds). |
| +func TestClosedGateIsTheRegistrysClosedGate(t *testing.T) { |
| + if ClosedGate != registry.ImportGateClosed { |
| + t.Fatalf("seeds.ClosedGate = %q, registry.ImportGateClosed = %q", ClosedGate, registry.ImportGateClosed) |
| + } |
| +} |