//go:build linux package host import ( "context" "crypto/ed25519" "crypto/rand" "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" execlease "github.com/carlosframework/platform/internal/hostexec" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) const participationHost = "shop.example" // participationBox is box "dst" of a relocation of shop.example from // "src", with main's participation owner as production builds it: a real // productionClaimLocal in agent mode over a DirStore control bucket with a // signed roster, real hostexec controllers (their locks under the N12 test // seam), the real census over empty roots, the launch journal, and a // recording transport where root would be asked for a stop. type participationBox struct { t *testing.T store *release.DirStore data string actor hostbox.Actor guard hostguard.Guard p *productionClaimLocal stops int // stops asked of root census *hostbox.EmptyInstallationCensus launches *hostbox.LaunchJournal } func newParticipationBox(t *testing.T) *participationBox { t.Helper() ctx := t.Context() b := &participationBox{t: t, store: release.NewDirStore(t.TempDir()), data: t.TempDir()} for _, name := range []string{"hostguard", ".hostexec"} { if err := os.Mkdir(filepath.Join(b.data, name), 0o770); err != nil { t.Fatal(err) } } pub, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } enrollment := func(box string) string { return strings.Repeat(map[string]string{"src": "b", "dst": "c"}[box], 32) } roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 4, Regions: map[string]hostactors.Region{"ie": {Account: "123", AWSRegion: "eu-west-1"}}, Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} for _, box := range []string{"src", "dst"} { roster.Boxes[box] = hostactors.Box{Enrollment: enrollment(box), Namespace: "platform", BoxID: box, Label: box, Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + box}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} } raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) if err != nil { t.Fatal(err) } if _, err := b.store.Put(ctx, hostactors.Key, raw, ""); err != nil { t.Fatal(err) } b.actor = hostbox.Actor{Box: "dst", Enrollment: enrollment("dst"), Region: "ie", Revision: 4, Binding: "4"} created := time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC) nh := hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + participationHost + ".json", Account: "acct", App: "app", Host: participationHost, Region: "ie", RecordRegion: "ie", CreatedAt: created} body := []byte(`{"host":"` + participationHost + `","account":"acct","app":"app","region":"ie","created_at":"2026-10-07T01:00:00Z"}`) obs, err := hostclaim.Read(ctx, b.store, participationHost) if err != nil { t.Fatal(err) } for _, tr := range []func(*hostclaim.Claim) error{ hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment("src")}), hostclaim.CompleteCreate("c1", "src"), hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}), func(c *hostclaim.Claim) error { c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"}, Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst", "target_enrollment": enrollment("dst")}} return nil }, } { if obs, err = hostclaim.Update(ctx, b.store, obs, tr); err != nil { t.Fatal(err) } } locks := t.TempDir() for _, name := range []string{"hostguard", "hostexec"} { if err := os.Mkdir(filepath.Join(locks, name), 0o700); err != nil { t.Fatal(err) } } b.guard = hostguard.Guard{DataDir: b.data, LockDir: filepath.Join(locks, "hostguard"), Claims: hostguard.StoreReader{Store: b.store}} inventory, err := openLegacyInventory(t, filepath.Join(t.TempDir(), "inventory.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { inventory.Close() }) reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) launches, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { launches.Close() }) b.launches = launches b.census = &hostbox.EmptyInstallationCensus{Inventory: inventory, Registry: reg, Launches: launches, Box: "dst", Enrollment: b.actor.Enrollment, DirectoryRevision: "4", Roots: map[string]string{"data": b.data, "opt": t.TempDir(), "units": t.TempDir()}, SharedPaths: map[string][]string{"data": {".hostexec", "hostguard"}}} a := &ClaimAssembly{Claims: b.store, Guard: b.guard, Wake: &ClaimWakeAssembly{}, Launches: launches, Reconcile: &hostbox.ReconcileAdapter{Census: b.census}, collectStop: func(context.Context, StopRequest, StopDrainer) (StopResult, error) { b.stops++ return StopResult{}, errors.New("root: no stop in this fixture") }} b.p = &productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: b.data, OptDir: t.TempDir()}, Identity: handoverRecoveryIdentity(t, b.actor), Directory: hostactors.Directory{Store: b.store, PublicKey: pub, Bucket: "bucket"}, Store: b.store, slots: map[string]*claimParticipation{}, assembly: a, lifetime: ctx, lockRoot: locks} t.Cleanup(b.closeAll) return b } func (b *participationBox) read() hostclaim.Observed { b.t.Helper() o, err := hostclaim.Read(context.Background(), b.store, participationHost) if err != nil { b.t.Fatal(err) } return o } func (b *participationBox) update(tr func(*hostclaim.Claim) error) hostclaim.Observed { b.t.Helper() o, err := hostclaim.Update(context.Background(), b.store, b.read(), tr) if err != nil { b.t.Fatal(err) } return o } // records is the box's .hostexec records. func (b *participationBox) records() []string { b.t.Helper() entries, err := os.ReadDir(filepath.Join(b.data, ".hostexec")) if err != nil { b.t.Fatal(err) } var out []string for _, e := range entries { out = append(out, e.Name()) } return out } // closeAll stands for the agent's exit: every open controller is closed. func (b *participationBox) closeAll() { b.p.mu.Lock() slots := b.p.slots b.p.mu.Unlock() for _, s := range slots { if s.Controller != nil { _ = s.Controller.Close(context.Background()) } } } // Finding 1, ruling 42: one controller per {host, op}, whichever asks // first. The relocation pass's acknowledgement (Acknowledged, through // RelocationEffects) and main's participation (Prepare, as the loop and // WorkSource.Prepare call it) open or reuse the box's one slot: one // .hostexec record, one lease, one controller id. When the op ends without // the box, main's next Prepare closes the controller, runs its cleanup, // clears the record and drops the slot, and the relocation's Ready refuses. func TestRelocationAcknowledgesThroughMainsParticipation(t *testing.T) { t.Parallel() for _, first := range []string{"relocation pass", "participation loop"} { t.Run(first+" first", func(t *testing.T) { ctx := t.Context() b := newParticipationBox(t) obs := b.read() effects := &RelocationEffects{Participation: b.p.Acknowledged} var ack hostclaim.ExecutionAcknowledgement acknowledge := func() { t.Helper() var err error if ack, err = effects.Acknowledge(ctx, obs); err != nil { t.Fatalf("acknowledge: %v", err) } } participate := func() { t.Helper() if err := b.p.Prepare(ctx, obs); err != nil { t.Fatalf("main's Prepare: %v", err) } } if first == "relocation pass" { acknowledge() participate() } else { participate() acknowledge() } b.p.mu.Lock() slot := b.p.slots[participationHost] n := len(b.p.slots) b.p.mu.Unlock() if slot == nil || n != 1 || slot.Controller == nil { t.Fatalf("want one slot with a controller, have %d", n) } opened, err := slot.Controller.Ready(ctx) if err != nil { t.Fatal(err) } if ack.Op != "rel-1" || ack.ControllerID != opened.ControllerID || ack.LeaseETag == "" || ack.LeaseETag != opened.LeaseETag || ack.Enrollment != b.actor.Enrollment { t.Fatalf("the acknowledgement is not main's controller: %+v, opened %+v", ack, opened.Binding) } again, err := effects.Acknowledge(ctx, obs) if err != nil || again.ControllerID != ack.ControllerID || again.LeaseETag != ack.LeaseETag { t.Fatalf("a second acknowledgement saw another controller: %+v %v", again, err) } if err := effects.Ready(ctx, obs); err != nil { t.Fatalf("ready: %v", err) } if got := b.records(); len(got) != 1 || got[0] != participationHost+".json" { t.Fatalf("records %v", got) } // The op ends without this box: the source serves again. dropped := b.update(func(c *hostclaim.Claim) error { c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "src", Participants: []string{"src"}} return nil }) if err := b.p.Prepare(ctx, dropped); err != nil { t.Fatalf("main's Prepare did not discharge the participation: %v", err) } if !slot.CleanupSucceeded || len(b.p.slots) != 0 || len(b.records()) != 0 { t.Fatalf("the participation outlived its op: cleanup=%v slots=%d records=%v", slot.CleanupSucceeded, len(b.p.slots), b.records()) } if err := effects.Ready(ctx, dropped); !errors.Is(err, hostbox.ErrChanged) { t.Fatalf("ready after the op ended: %v", err) } if _, err := execlease.InspectLocal(b.data, participationHost); !errors.Is(err, os.ErrNotExist) { t.Fatalf("record: %v", err) } }) } }