| index 191a73fe8..b1b8da306 100644 |
| --- a/internal/host/movesoffline_test.go |
| +++ b/internal/host/movesoffline_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -339,7 +340,7 @@ func TestOfflineFreshTargetMintsTheLandingRowHeld(t *testing.T) { |
| wd.flip() |
| |
| uk := wd.box("uk-box", "uk", nil) |
| - uk.w.cfg.Offline.refresh(nil) // stale: no offline move in it |
| + uk.w.cfg.Offline.refresh(nil, nil) // stale: no offline move in it |
| uk.reconcile() |
| rt := uk.row(offHost) |
| if rt == nil || rt.Status != "landing" || rt.HoldOp != offID { |
| @@ -706,6 +707,88 @@ func assertRetired(t *testing.T, got []moves.RetiredPath, want map[string]string |
| } |
| } |
| |
| +// M3: a path the agent can never rename (here a file in a directory it may |
| +// not write, as a root-owned /data/programs is) is reported left once, with |
| +// its reason, and is not tried again: later passes, an agent restart |
| +// included, neither fence nor rename, and the report stays parked. |
| +func TestOfflineParkLeavesWhatCannotBeRenamedOnce(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("root can rename anything") |
| + } |
| + wd := newOffWorld(t) |
| + a := wd.box("ie-a", "", nil) |
| + a.tick() |
| + db := a.row(offHost).DBPath |
| + writeFile(t, db, "sqlite") |
| + programs := filepath.Join(a.dd, "programs") |
| + writeFile(t, filepath.Join(programs, offHost+".json"), "{}") |
| + if err := os.Chmod(programs, 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.Chmod(programs, 0o755) }) |
| + fences := 0 |
| + count := func() { |
| + inner := a.w.cfg.FenceAs |
| + a.w.cfg.FenceAs = func(ctx context.Context, host string, gen uint64, owner string) error { |
| + fences++ |
| + return inner(ctx, host, gen, owner) |
| + } |
| + } |
| + wd.start() |
| + a.tick() |
| + wd.seal() |
| + wd.sealed() |
| + wd.flip() |
| + wd.landed() |
| + a.tick() |
| + wd.open() |
| + count() |
| + before := fences |
| + a.tick() |
| + if fences != before+1 { |
| + t.Fatalf("the first park fenced %d times", fences-before) |
| + } |
| + r := wd.report(moves.OfflineRoleSource, "ie-a") |
| + if r.Phase != moves.OfflinePhaseParked { |
| + t.Fatalf("phase %s", r.Phase) |
| + } |
| + var left moves.RetiredPath |
| + for _, rp := range r.Retired { |
| + if rp.Path == filepath.Join(programs, offHost+".json") { |
| + left = rp |
| + } |
| + if rp.Path == db && rp.Action != moves.RetiredParked { |
| + t.Fatalf("the DB: %+v", rp) |
| + } |
| + } |
| + if left.Action != moves.RetiredLeft || !strings.HasPrefix(left.Reason, leftCannot) { |
| + t.Fatalf("the unrenameable path: %+v", left) |
| + } |
| + for i := 0; i < 2; i++ { |
| + before = fences |
| + a.tick() |
| + if fences != before { |
| + t.Fatal("a later pass fenced again for a path it cannot rename") |
| + } |
| + if again := wd.report(moves.OfflineRoleSource, "ie-a"); again.Phase != moves.OfflinePhaseParked || !reflect.DeepEqual(again.Retired, r.Retired) { |
| + t.Fatalf("pass %d: %+v", i, again) |
| + } |
| + } |
| + a.restart() |
| + count() |
| + before = fences |
| + a.tick() |
| + if fences != before || a.svc.Fenced(offHost) { |
| + t.Fatal("after a restart the pass fenced again for a path it cannot rename") |
| + } |
| + if again := wd.report(moves.OfflineRoleSource, "ie-a"); !reflect.DeepEqual(again.Retired, r.Retired) { |
| + t.Fatalf("after a restart: %+v", again.Retired) |
| + } |
| + if _, err := os.Stat(filepath.Join(programs, offHost+".json")); err != nil { |
| + t.Fatalf("the left path: %v", err) |
| + } |
| +} |
| + |
| // offline-open on a nested source: the contents of the raw instance |
| // directory are renamed into the park directory, the directory and its |
| // view stay, empty, and the inventory names each path. |
| @@ -834,11 +917,28 @@ func TestOfflineCapabilityAndAnIncapableBox(t *testing.T) { |
| } |
| return func(k string) string { return m[k] } |
| } |
| - if got := edgesFileCapabilities(env()); !slices.Equal(got, []string{capOfflineMove1}) { |
| + // The published capability is the armed worker's own answer (M5). |
| + prev := armedMovesWorker.Load() |
| + t.Cleanup(func() { armedMovesWorker.Store(prev) }) |
| + armedMovesWorker.Store(nil) |
| + if armedOfflineCapable() { |
| + t.Fatal("capable with no worker armed") |
| + } |
| + if got := withOfflineCapability(edgesFileCapabilities(env()), armedOfflineCapable()); len(got) != 0 { |
| + t.Fatalf("no worker armed: %v", got) |
| + } |
| + wdCap := newOffWorld(t) |
| + armed := wdCap.box("armed", "", nil) |
| + armedMovesWorker.Store(armed.w) |
| + if !armedOfflineCapable() { |
| + t.Fatal("a capable armed worker does not publish") |
| + } |
| + if got := withOfflineCapability(edgesFileCapabilities(env()), armedOfflineCapable()); !slices.Equal(got, []string{capOfflineMove1}) { |
| t.Fatalf("capable box: %v", got) |
| } |
| - if got := edgesFileCapabilities(env("CARLOS_REGION_HA", "1")); !slices.Equal(got, []string{capRegionHA1, capOfflineMove1}) { |
| - t.Fatalf("region HA too: %v", got) |
| + base := edgesFileCapabilities(env("CARLOS_REGION_HA", "1")) |
| + if got := withOfflineCapability(base, true); !slices.Equal(got, []string{capRegionHA1, capOfflineMove1}) || !slices.Equal(base, []string{capRegionHA1}) { |
| + t.Fatalf("region HA too: %v (base %v)", got, base) |
| } |
| for _, kv := range [][]string{ |
| {"CARLOS_HIBERNATE_BUCKET", ""}, |
| @@ -847,13 +947,16 @@ func TestOfflineCapabilityAndAnIncapableBox(t *testing.T) { |
| {moves.SwitchEnv, "on"}, |
| {moves.SwitchEnv, "off"}, // set at all |
| } { |
| - if got := edgesFileCapabilities(env(kv...)); slices.Contains(got, capOfflineMove1) { |
| - t.Fatalf("%s=%q: %v", kv[0], kv[1], got) |
| - } |
| if offlineCapable(env(kv...), hibernationConfigured(env(kv...))) { |
| t.Fatalf("%s=%q is capable", kv[0], kv[1]) |
| } |
| + incapable := wdCap.box("incapable", "", map[string]string{kv[0]: kv[1]}) |
| + armedMovesWorker.Store(incapable.w) |
| + if got := withOfflineCapability(edgesFileCapabilities(env(kv...)), armedOfflineCapable()); slices.Contains(got, capOfflineMove1) { |
| + t.Fatalf("%s=%q publishes: %v", kv[0], kv[1], got) |
| + } |
| } |
| + armedMovesWorker.Store(prev) |
| |
| wd := newOffWorld(t) |
| a := wd.box("ie-a", "", map[string]string{"CARLOS_HOST_CLAIM": "1"}) |
| @@ -877,119 +980,146 @@ func TestOfflineCapabilityAndAnIncapableBox(t *testing.T) { |
| } |
| } |
| |
| -// --- dormant ---------------------------------------------------------------- |
| +// --- dormant --------------------------------------------------------------- |
| |
| -// With no offline record anywhere: the hold answers false for every record, |
| -// keep answers false once the startup listing is read (and true only while |
| -// none has been, or when it failed), a retired row is removed on the first |
| -// pass exactly as with no hooks at all, and the edges file differs by the |
| -// one capability string and nothing else. |
| -func TestOfflineDormantIsMain(t *testing.T) { |
| - ctx := context.Background() |
| +// flatLayout is a reconcile config's layout read, always flat. |
| +func flatLayout(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil } |
| + |
| +// dormantWorld is a deployment with no offline record anywhere: offHost at |
| +// rest, gone.example.com (whose record the test deletes), a legacy move and |
| +// a Phase 1 move on records of their own. |
| +func dormantWorld(t *testing.T) *offWorld { |
| + t.Helper() |
| wd := newOffWorld(t) |
| - wd.create("gone.example.com") |
| - // A Phase 1 and a legacy move on records of their own: no offline hook |
| - // answers for them either. |
| - wd.create("legacy.example.com") |
| + for _, h := range []string{"gone.example.com", "legacy.example.com", "phase1.example.com"} { |
| + wd.create(h) |
| + } |
| wd.moveHost("legacy.example.com", func(rec *provision.Record) error { |
| rec.Move = &provision.MoveState{To: "uk", State: provision.MovePreparing} |
| return nil |
| }) |
| + wd.moveHost("phase1.example.com", func(rec *provision.Record) error { |
| + rec.Move = &provision.MoveState{ID: offID2, From: "", To: "uk", State: provision.MovePreparing, RequestedAt: wd.clock()} |
| + return nil |
| + }) |
| + return wd |
| +} |
| |
| - run := func(withHooks bool) *offBox { |
| - b := wd.box(map[bool]string{false: "main", true: "hooked"}[withHooks], "", nil) |
| - w := b.w |
| - if !withHooks { |
| - w = nil |
| - } |
| - cfg := provision.ReconcileConfig{Region: "", DataDir: b.dd, Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil }} |
| - if err := reconcileSerializedWith(ctx, wd.s, b.reg, cfg, w); err != nil { |
| +// dormantBoxes are two home boxes minted from the same records with no |
| +// hooks at all, so their registries start equal: the plain one stands for |
| +// main, the hooked one has a capable worker whose view no listing has read. |
| +func dormantBoxes(t *testing.T, wd *offWorld) (plain, hooked *offBox) { |
| + t.Helper() |
| + plain, hooked = wd.box("main", "", nil), wd.box("hooked", "", nil) |
| + for _, b := range []*offBox{plain, hooked} { |
| + if err := reconcileSerializedWith(context.Background(), wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, nil); err != nil { |
| t.Fatal(err) |
| } |
| - return b |
| + if b.row("gone.example.com") == nil { |
| + t.Fatalf("fixture: %s has no row to retire", b.name) |
| + } |
| + } |
| + if hooked.w.cfg.Offline.built() || !hooked.w.cfg.OfflineCapable { |
| + t.Fatal("fixture: the hooked box's view is built, or the box is not capable") |
| } |
| - plainBox := run(false) |
| - hooked := run(true) |
| + return plain, hooked |
| +} |
| |
| - // The startup refresh: keep is false for every host. |
| - refreshOfflineView(ctx, wd.s, hooked.w.cfg.Offline) |
| - for _, h := range []string{offHost, "gone.example.com", "legacy.example.com", "never.example.com"} { |
| - if hooked.w.cfg.Offline.keep(h) { |
| - t.Fatalf("keep(%s) after the startup refresh", h) |
| +// sameRegistries fails unless the two boxes' registries match, paths and |
| +// creation times aside (each box has its own data directory and clock). |
| +func sameRegistries(t *testing.T, a, b *offBox) { |
| + t.Helper() |
| + strip := func(rs []registry.Route) []registry.Route { |
| + out := append([]registry.Route(nil), rs...) |
| + for i := range out { |
| + out[i].DBPath, out[i].Socket, out[i].CreatedAt = "", "", 0 |
| } |
| + return out |
| } |
| - recs, err := provision.ListAllRecords(ctx, wd.s) |
| + ar, err := a.reg.List() |
| if err != nil { |
| t.Fatal(err) |
| } |
| - for _, rec := range recs { |
| - if _, _, ok := offlineHold(hooked.w.cfg)(rec); ok { |
| - t.Fatalf("held %s", rec.Host) |
| - } |
| + br, err := b.reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| + if !reflect.DeepEqual(strip(ar), strip(br)) { |
| + t.Fatalf("the registries differ:\n %s %+v\n %s %+v", a.name, ar, b.name, br) |
| + } |
| +} |
| |
| - // The record of gone.example.com goes; the first pass removes its row |
| - // on both boxes alike. |
| +// With no offline record anywhere (a legacy and a Phase 1 move included): |
| +// the very first reconcile on a box whose view no listing has read removes |
| +// a retired row exactly as main does; after it the hold answers false for |
| +// every record and keep false for every host; and the edges file differs |
| +// by the one capability string and nothing else. |
| +func TestOfflineDormantIsMain(t *testing.T) { |
| + ctx := context.Background() |
| + wd := dormantWorld(t) |
| + plain, hooked := dormantBoxes(t, wd) |
| if err := wd.s.Delete(ctx, provision.RecordKey(wd.acct, "app", "gone.example.com")); err != nil { |
| t.Fatal(err) |
| } |
| - for _, b := range []*offBox{plainBox, hooked} { |
| + for _, b := range []*offBox{plain, hooked} { |
| w := b.w |
| - if b == plainBox { |
| + if b == plain { |
| w = nil |
| } |
| - cfg := provision.ReconcileConfig{Region: "", DataDir: b.dd, Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil }} |
| - if err := reconcileSerializedWith(ctx, wd.s, b.reg, cfg, w); err != nil { |
| + if err := reconcileSerializedWith(ctx, wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, w); err != nil { |
| t.Fatal(err) |
| } |
| if rt := b.row("gone.example.com"); rt != nil { |
| - t.Fatalf("%s kept a retired row: %+v", b.name, rt) |
| + t.Fatalf("%s kept a retired row on its first pass: %+v", b.name, rt) |
| } |
| } |
| - strip := func(rs []registry.Route) []registry.Route { |
| - out := append([]registry.Route(nil), rs...) |
| - for i := range out { |
| - out[i].DBPath, out[i].Socket, out[i].CreatedAt = "", "", 0 |
| + sameRegistries(t, plain, hooked) |
| + v := hooked.w.cfg.Offline |
| + if !v.built() || v.failed() { |
| + t.Fatal("the first reconcile did not read the view") |
| + } |
| + for _, h := range []string{offHost, "gone.example.com", "legacy.example.com", "phase1.example.com", "never.example.com"} { |
| + if v.keep(h) { |
| + t.Fatalf("keep(%s)", h) |
| } |
| - return out |
| } |
| - mr, _ := plainBox.reg.List() |
| - hr, _ := hooked.reg.List() |
| - if !reflect.DeepEqual(strip(mr), strip(hr)) { |
| - t.Fatalf("the registries differ:\n main %+v\n hooked %+v", mr, hr) |
| + recs, err := provision.ListAllRecords(ctx, wd.s) |
| + if err != nil { |
| + t.Fatal(err) |
| } |
| - |
| - // keep fails closed only with no listing read: never built, or failed. |
| - unbuilt := newOfflineView(moveCfg{Region: ""}, nil) |
| - if !unbuilt.keep(offHost) { |
| - t.Fatal("an unbuilt view keeps nothing") |
| + if len(recs) != 3 { |
| + t.Fatalf("fixture: %d records", len(recs)) |
| } |
| - refreshOfflineView(ctx, failingListStore{Store: wd.s, prefix: "apps/", err: errors.New("listing refused")}, unbuilt) |
| - if !unbuilt.built() || !unbuilt.keep(offHost) { |
| - t.Fatal("a failed startup listing does not keep") |
| + for _, rec := range recs { |
| + if _, _, ok := offlineHold(hooked.w.cfg)(rec); ok { |
| + t.Fatalf("held %s", rec.Host) |
| + } |
| } |
| - unbuilt.refresh(recs) |
| - if unbuilt.keep(offHost) { |
| - t.Fatal("a listing read after a failure still keeps") |
| + // A further pass changes nothing on either box. |
| + for _, b := range []*offBox{plain, hooked} { |
| + w := b.w |
| + if b == plain { |
| + w = nil |
| + } |
| + if err := reconcileSerializedWith(ctx, wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, w); err != nil { |
| + t.Fatal(err) |
| + } |
| } |
| + sameRegistries(t, plain, hooked) |
| |
| // The edges file: the one string, and no headroom with it. |
| var last1, last2 []byte |
| - plain := edgesFile{Box: "plain", Placement: ""} |
| - capable := edgesFile{Box: "plain", Placement: "", Capabilities: edgesFileCapabilities(func(k string) string { |
| - if k == "CARLOS_HIBERNATE_BUCKET" { |
| - return "data" |
| - } |
| - return "" |
| - })} |
| + none := func(string) string { return "" } |
| + plainFile := edgesFile{Box: "plain", Placement: "", Capabilities: withOfflineCapability(edgesFileCapabilities(none), false)} |
| + capable := edgesFile{Box: "plain", Placement: "", Capabilities: withOfflineCapability(edgesFileCapabilities(none), true)} |
| if headroomCapabilities(capable.Capabilities) != nil { |
| t.Fatal("offline-move-1 asks for headroom") |
| } |
| s1, s2 := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| - if err := edgesFileWrite(ctx, s1, plainBox.reg, plain, "1.2.3.4", &last1, false); err != nil { |
| + if err := edgesFileWrite(ctx, s1, plain.reg, plainFile, "1.2.3.4", &last1, false); err != nil { |
| t.Fatal(err) |
| } |
| - if err := edgesFileWrite(ctx, s2, plainBox.reg, capable, "1.2.3.4", &last2, false); err != nil { |
| + if err := edgesFileWrite(ctx, s2, plain.reg, capable, "1.2.3.4", &last2, false); err != nil { |
| t.Fatal(err) |
| } |
| b1, _, _ := s1.Get(ctx, edgesFileKey("plain")) |
| @@ -1012,6 +1142,96 @@ func TestOfflineDormantIsMain(t *testing.T) { |
| } |
| } |
| |
| +// M1: a failed startup listing keeps every row only until the next |
| +// reconcile, which reads the listing again: the retired row then goes as on |
| +// main, without waiting for a moves pass. |
| +func TestOfflineFailedListingHealsOnTheNextReconcile(t *testing.T) { |
| + ctx := context.Background() |
| + wd := dormantWorld(t) |
| + plain, hooked := dormantBoxes(t, wd) |
| + v := hooked.w.cfg.Offline |
| + refreshOfflineView(ctx, failingListStore{Store: wd.s, prefix: "apps/", err: errors.New("listing refused")}, v) |
| + if !v.built() || !v.failed() || !v.keep(offHost) { |
| + t.Fatal("a failed startup listing does not keep") |
| + } |
| + if err := wd.s.Delete(ctx, provision.RecordKey(wd.acct, "app", "gone.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, b := range []*offBox{plain, hooked} { |
| + w := b.w |
| + if b == plain { |
| + w = nil |
| + } |
| + if err := reconcileSerializedWith(ctx, wd.s, b.reg, provision.ReconcileConfig{DataDir: b.dd, Layout: flatLayout}, w); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if v.failed() || v.keep("gone.example.com") { |
| + t.Fatal("the reconcile did not read the listing again") |
| + } |
| + if rt := hooked.row("gone.example.com"); rt != nil { |
| + t.Fatalf("the retired row stayed after the view healed: %+v", rt) |
| + } |
| + sameRegistries(t, plain, hooked) |
| + // While the listing keeps failing the view stays closed. |
| + refreshOfflineView(ctx, failingListStore{Store: wd.s, prefix: "apps/", err: errors.New("listing refused")}, v) |
| + if !v.keep(offHost) { |
| + t.Fatal("a failed listing does not keep") |
| + } |
| + unbuilt := newOfflineView(moveCfg{Region: ""}, nil) |
| + if !unbuilt.keep(offHost) { |
| + t.Fatal("an unbuilt view keeps nothing") |
| + } |
| +} |
| + |
| +// M4: a record that does not decode is kept by keep alone (its move cannot |
| +// be known), logged, and every other host is judged as usual. The moves |
| +// pass's listing carries the same answer. |
| +func TestOfflineUndecodableRecordIsKept(t *testing.T) { |
| + ctx := context.Background() |
| + wd := newOffWorld(t) |
| + wd.create("broken.example.com") |
| + if _, err := wd.s.Put(ctx, provision.RecordKey(wd.acct, "app", "broken.example.com"), []byte("{not json"), mustETag(t, wd.s, provision.RecordKey(wd.acct, "app", "broken.example.com"))); err != nil { |
| + t.Fatal(err) |
| + } |
| + a := wd.box("ie-a", "", nil) |
| + refreshOfflineView(ctx, wd.s, a.w.cfg.Offline) |
| + v := a.w.cfg.Offline |
| + if v.failed() || !v.keep("broken.example.com") || v.keep(offHost) { |
| + t.Fatalf("startup view: failed %v, keep(broken) %v, keep(%s) %v", v.failed(), v.keep("broken.example.com"), offHost, v.keep(offHost)) |
| + } |
| + fresh := newOfflineView(a.w.cfg, nil) |
| + a.w.cfg.Offline = fresh |
| + a.pass() |
| + if !fresh.keep("broken.example.com") || fresh.keep(offHost) { |
| + t.Fatal("the moves pass's listing does not keep the undecodable host") |
| + } |
| + if _, _, ok := offlineHold(a.w.cfg)(provision.Record{Host: "broken.example.com"}); ok { |
| + t.Fatal("held a record with no move") |
| + } |
| +} |
| + |
| +func mustETag(t *testing.T, s release.Store, key string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return etag |
| +} |
| + |
| +// hostSliceCgroup is the slice instancectl's units run in. |
| +func TestOfflineHostSliceIsInstancectlsSlice(t *testing.T) { |
| + got := hostSliceCgroup("/sys/fs/cgroup", offHost) |
| + want := filepath.Join("/sys/fs/cgroup", "carlos.slice", "carlos-instances.slice", instancectl.SliceName(offHost)) |
| + if got != want { |
| + t.Fatalf("hostSliceCgroup = %s, want %s", got, want) |
| + } |
| + if filepath.Dir(filepath.Join("/sys/fs/cgroup", filepath.FromSlash(instancectl.ClaimUnitControlGroup(offHost)))) != got { |
| + t.Fatal("the run unit is not in the slice") |
| + } |
| +} |
| + |
| // The seal record's writer is create-only: the first body stays, a re-run |
| // gets it back and writes nothing, and the report writer beside it replaces. |
| func TestOfflineCreateOnlyKeepsTheFirstWrite(t *testing.T) { |
| @@ -1045,17 +1265,17 @@ func TestOfflineKeep(t *testing.T) { |
| for _, st := range states { |
| recs := []provision.Record{rec("uk", st)} |
| src := newOfflineView(moveCfg{Region: ""}, nil) |
| - src.refresh(recs) |
| + src.refresh(recs, nil) |
| if got, want := src.keep(offHost), st != provision.OfflineFinishing; got != want { |
| t.Fatalf("source at %s: keep %v", st, got) |
| } |
| tgt := newOfflineView(moveCfg{Region: "uk"}, nil) |
| - tgt.refresh(recs) |
| + tgt.refresh(recs, nil) |
| if !tgt.keep(offHost) { |
| t.Fatalf("target at %s: not kept", st) |
| } |
| other := newOfflineView(moveCfg{Region: "fr"}, nil) |
| - other.refresh(recs) |
| + other.refresh(recs, nil) |
| if other.keep(offHost) { |
| t.Fatalf("a third region keeps at %s", st) |
| } |