| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "slices" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/activator" |
| 12 | "github.com/carlosframework/platform/internal/hostbox" |
| 13 | "github.com/carlosframework/platform/internal/hostclaim" |
| 14 | "github.com/carlosframework/platform/internal/hostguard" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // unreadableClaim is a control bucket whose reads of one claim key fail. |
| 20 | type unreadableClaim struct { |
| 21 | release.Store |
| 22 | key string |
| 23 | } |
| 24 | |
| 25 | func (s unreadableClaim) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 26 | if key == s.key { |
| 27 | return nil, "", errors.New("the claim could not be read") |
| 28 | } |
| 29 | return s.Store.Get(ctx, key) |
| 30 | } |
| 31 | |
| 32 | // Absence is never evidence: a claim the pass cannot read is said, once, |
| 33 | // and read again next pass; it is never passed over as nothing to do. The |
| 34 | // other hosts' steps run as before. |
| 35 | func TestRelocatePassSaysAClaimItCannotRead(t *testing.T) { |
| 36 | ctx := t.Context() |
| 37 | dir := release.NewDirStore(t.TempDir()) |
| 38 | passClaim(t, dir, "a.example", relocatingOp("rel-a")) |
| 39 | passClaim(t, dir, "b.example", relocatingOp("rel-b")) |
| 40 | key, _ := hostclaim.Key("b.example") |
| 41 | store := unreadableClaim{Store: dir, key: key} |
| 42 | buf := captureRacyLog(t) |
| 43 | var order []string |
| 44 | r := &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &order}, Participant: stepRecorder{name: "participant", order: &order}, Box: "dst"} |
| 45 | r.once(ctx, store) |
| 46 | r.once(ctx, store) |
| 47 | if want := []string{"participant:a.example", "worker:a.example", "participant:a.example", "worker:a.example"}; !slices.Equal(order, want) { |
| 48 | t.Fatalf("stepped %v, want %v", order, want) |
| 49 | } |
| 50 | if n := strings.Count(buf.String(), "relocate b.example: read: the claim could not be read"); n != 1 { |
| 51 | t.Fatalf("an unreadable claim was said %d times, want once:\n%s", n, buf.String()) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // Absence is never evidence: with region HA on, a full pass that could not |
| 56 | // be built at startup (the roster, the enrollment or the release key not |
| 57 | // readable then) is not "no relocation on this box". The completion-only |
| 58 | // runtime stands in, finishing completions, and tries the build again each |
| 59 | // pass until it builds; then the same Run is the full pass. A build that |
| 60 | // answers the owners are not bound ends the tries. |
| 61 | func TestRelocationPassBuildsTheFullPassOnceItCan(t *testing.T) { |
| 62 | ctx := t.Context() |
| 63 | store := release.NewDirStore(t.TempDir()) |
| 64 | passClaim(t, store, "a.example", relocatingOp("rel-a")) |
| 65 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | defer reg.Close() |
| 70 | local := &productionClaimLocal{legacyParticipation: true, assembly: &ClaimAssembly{Claims: store, Guard: hostguard.Guard{DataDir: t.TempDir()}, stopLocal: func(context.Context) (claimStopLocal, error) { |
| 71 | return claimStopLocal{Actor: hostbox.Actor{Box: "dst"}}, nil |
| 72 | }}} |
| 73 | buf := captureRacyLog(t) |
| 74 | var order []string |
| 75 | builds := 0 |
| 76 | unreadable := errors.New("the roster could not be read") |
| 77 | full := func(context.Context) (*RelocationRuntime, error) { |
| 78 | builds++ |
| 79 | if builds <= 2 { |
| 80 | return nil, unreadable |
| 81 | } |
| 82 | return &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &order}, Participant: stepRecorder{name: "participant", order: &order}, Box: "dst", |
| 83 | Finish: func(context.Context, string, string) error { return nil }}, nil |
| 84 | } |
| 85 | r, err := relocationPassFor(local, reg, &activator.Service{}, full) |
| 86 | if err != nil || r == nil || r.Worker != nil || r.Finish == nil || r.Hosts == nil { |
| 87 | t.Fatalf("not the completion-only stand-in: %+v %v", r, err) |
| 88 | } |
| 89 | // Review M9: nothing is built on the startup path. |
| 90 | if builds != 0 { |
| 91 | t.Fatalf("built %d times before the first pass", builds) |
| 92 | } |
| 93 | r.once(ctx, store) // the first build fails |
| 94 | r.once(ctx, store) // the second build fails too |
| 95 | if len(order) != 0 || builds != 2 { |
| 96 | t.Fatalf("stepped %v after %d builds", order, builds) |
| 97 | } |
| 98 | r.once(ctx, store) // the third builds |
| 99 | if want := []string{"participant:a.example", "worker:a.example"}; !slices.Equal(order, want) || r.Box != "dst" || r.Hosts != nil { |
| 100 | t.Fatalf("not upgraded: stepped %v, %+v", order, r) |
| 101 | } |
| 102 | r.once(ctx, store) |
| 103 | if builds != 3 { |
| 104 | t.Fatalf("built %d times, want no more once built", builds) |
| 105 | } |
| 106 | if n := strings.Count(buf.String(), unreadable.Error()); n != 1 { |
| 107 | t.Fatalf("the failed build was said %d times, want once:\n%s", n, buf.String()) |
| 108 | } |
| 109 | // Owners not bound: the build answers the completion-only runtime, and |
| 110 | // the tries end. |
| 111 | builds = 0 |
| 112 | r, err = relocationPassFor(local, reg, &activator.Service{}, func(context.Context) (*RelocationRuntime, error) { |
| 113 | builds++ |
| 114 | if builds == 1 { |
| 115 | return nil, unreadable |
| 116 | } |
| 117 | return newCompletionRuntime(local, reg, &activator.Service{}) |
| 118 | }) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | r.once(ctx, store) |
| 123 | r.once(ctx, store) |
| 124 | if builds != 2 || r.Worker != nil { |
| 125 | t.Fatalf("built %d times (want 2), worker %v", builds, r.Worker) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // Claim mode is on, so a completion recorded here must be finished here: |
| 130 | // the agent never leaves the completion pass unstarted without saying so. |
| 131 | func TestStartRelocationPassSaysWhyItDidNotStart(t *testing.T) { |
| 132 | buf := captureRacyLog(t) |
| 133 | claimCtx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true}) |
| 134 | startRelocationPass(t.Context(), claimCtx, &ClaimWiring{}, nil, &activator.Service{}) |
| 135 | if !strings.Contains(buf.String(), "relocation completion pass not started") { |
| 136 | t.Fatalf("not said:\n%s", buf.String()) |
| 137 | } |
| 138 | off := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{FlagRead: true}) |
| 139 | before := buf.String() |
| 140 | startRelocationPass(t.Context(), off, &ClaimWiring{}, nil, &activator.Service{}) |
| 141 | if buf.String() != before { |
| 142 | t.Fatalf("claim mode off: %q", strings.TrimPrefix(buf.String(), before)) |
| 143 | } |
| 144 | } |
| 145 | |