package host import ( "context" "errors" "path/filepath" "slices" "strings" "testing" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // unreadableClaim is a control bucket whose reads of one claim key fail. type unreadableClaim struct { release.Store key string } func (s unreadableClaim) Get(ctx context.Context, key string) ([]byte, string, error) { if key == s.key { return nil, "", errors.New("the claim could not be read") } return s.Store.Get(ctx, key) } // Absence is never evidence: a claim the pass cannot read is said, once, // and read again next pass; it is never passed over as nothing to do. The // other hosts' steps run as before. func TestRelocatePassSaysAClaimItCannotRead(t *testing.T) { ctx := t.Context() dir := release.NewDirStore(t.TempDir()) passClaim(t, dir, "a.example", relocatingOp("rel-a")) passClaim(t, dir, "b.example", relocatingOp("rel-b")) key, _ := hostclaim.Key("b.example") store := unreadableClaim{Store: dir, key: key} buf := captureRacyLog(t) var order []string r := &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &order}, Participant: stepRecorder{name: "participant", order: &order}, Box: "dst"} r.once(ctx, store) r.once(ctx, store) if want := []string{"participant:a.example", "worker:a.example", "participant:a.example", "worker:a.example"}; !slices.Equal(order, want) { t.Fatalf("stepped %v, want %v", order, want) } if n := strings.Count(buf.String(), "relocate b.example: read: the claim could not be read"); n != 1 { t.Fatalf("an unreadable claim was said %d times, want once:\n%s", n, buf.String()) } } // Absence is never evidence: with region HA on, a full pass that could not // be built at startup (the roster, the enrollment or the release key not // readable then) is not "no relocation on this box". The completion-only // runtime stands in, finishing completions, and tries the build again each // pass until it builds; then the same Run is the full pass. A build that // answers the owners are not bound ends the tries. func TestRelocationPassBuildsTheFullPassOnceItCan(t *testing.T) { ctx := t.Context() store := release.NewDirStore(t.TempDir()) passClaim(t, store, "a.example", relocatingOp("rel-a")) reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer reg.Close() local := &productionClaimLocal{legacyParticipation: true, assembly: &ClaimAssembly{Claims: store, Guard: hostguard.Guard{DataDir: t.TempDir()}, stopLocal: func(context.Context) (claimStopLocal, error) { return claimStopLocal{Actor: hostbox.Actor{Box: "dst"}}, nil }}} buf := captureRacyLog(t) var order []string builds := 0 unreadable := errors.New("the roster could not be read") full := func(context.Context) (*RelocationRuntime, error) { builds++ if builds <= 2 { return nil, unreadable } return &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &order}, Participant: stepRecorder{name: "participant", order: &order}, Box: "dst", Finish: func(context.Context, string, string) error { return nil }}, nil } r, err := relocationPassFor(local, reg, &activator.Service{}, full) if err != nil || r == nil || r.Worker != nil || r.Finish == nil || r.Hosts == nil { t.Fatalf("not the completion-only stand-in: %+v %v", r, err) } // Review M9: nothing is built on the startup path. if builds != 0 { t.Fatalf("built %d times before the first pass", builds) } r.once(ctx, store) // the first build fails r.once(ctx, store) // the second build fails too if len(order) != 0 || builds != 2 { t.Fatalf("stepped %v after %d builds", order, builds) } r.once(ctx, store) // the third builds if want := []string{"participant:a.example", "worker:a.example"}; !slices.Equal(order, want) || r.Box != "dst" || r.Hosts != nil { t.Fatalf("not upgraded: stepped %v, %+v", order, r) } r.once(ctx, store) if builds != 3 { t.Fatalf("built %d times, want no more once built", builds) } if n := strings.Count(buf.String(), unreadable.Error()); n != 1 { t.Fatalf("the failed build was said %d times, want once:\n%s", n, buf.String()) } // Owners not bound: the build answers the completion-only runtime, and // the tries end. builds = 0 r, err = relocationPassFor(local, reg, &activator.Service{}, func(context.Context) (*RelocationRuntime, error) { builds++ if builds == 1 { return nil, unreadable } return newCompletionRuntime(local, reg, &activator.Service{}) }) if err != nil { t.Fatal(err) } r.once(ctx, store) r.once(ctx, store) if builds != 2 || r.Worker != nil { t.Fatalf("built %d times (want 2), worker %v", builds, r.Worker) } } // Claim mode is on, so a completion recorded here must be finished here: // the agent never leaves the completion pass unstarted without saying so. func TestStartRelocationPassSaysWhyItDidNotStart(t *testing.T) { buf := captureRacyLog(t) claimCtx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true}) startRelocationPass(t.Context(), claimCtx, &ClaimWiring{}, nil, &activator.Service{}) if !strings.Contains(buf.String(), "relocation completion pass not started") { t.Fatalf("not said:\n%s", buf.String()) } off := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{FlagRead: true}) before := buf.String() startRelocationPass(t.Context(), off, &ClaimWiring{}, nil, &activator.Service{}) if buf.String() != before { t.Fatalf("claim mode off: %q", strings.TrimPrefix(buf.String(), before)) } }