| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/ed25519" |
| 8 | "crypto/rand" |
| 9 | "errors" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostactors" |
| 17 | "github.com/carlosframework/platform/internal/hostbox" |
| 18 | "github.com/carlosframework/platform/internal/hostclaim" |
| 19 | execlease "github.com/carlosframework/platform/internal/hostexec" |
| 20 | "github.com/carlosframework/platform/internal/hostguard" |
| 21 | "github.com/carlosframework/platform/internal/registry" |
| 22 | "github.com/carlosframework/platform/internal/release" |
| 23 | ) |
| 24 | |
| 25 | const participationHost = "shop.example" |
| 26 | |
| 27 | // participationBox is box "dst" of a relocation of shop.example from |
| 28 | // "src", with main's participation owner as production builds it: a real |
| 29 | // productionClaimLocal in agent mode over a DirStore control bucket with a |
| 30 | // signed roster, real hostexec controllers (their locks under the N12 test |
| 31 | // seam), the real census over empty roots, the launch journal, and a |
| 32 | // recording transport where root would be asked for a stop. |
| 33 | type participationBox struct { |
| 34 | t *testing.T |
| 35 | store *release.DirStore |
| 36 | data string |
| 37 | actor hostbox.Actor |
| 38 | guard hostguard.Guard |
| 39 | p *productionClaimLocal |
| 40 | stops int // stops asked of root |
| 41 | census *hostbox.EmptyInstallationCensus |
| 42 | launches *hostbox.LaunchJournal |
| 43 | } |
| 44 | |
| 45 | func newParticipationBox(t *testing.T) *participationBox { |
| 46 | t.Helper() |
| 47 | ctx := t.Context() |
| 48 | b := &participationBox{t: t, store: release.NewDirStore(t.TempDir()), data: t.TempDir()} |
| 49 | for _, name := range []string{"hostguard", ".hostexec"} { |
| 50 | if err := os.Mkdir(filepath.Join(b.data, name), 0o770); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | } |
| 54 | pub, key, err := ed25519.GenerateKey(rand.Reader) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | enrollment := func(box string) string { return strings.Repeat(map[string]string{"src": "b", "dst": "c"}[box], 32) } |
| 59 | roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 4, Regions: map[string]hostactors.Region{"ie": {Account: "123", AWSRegion: "eu-west-1"}}, |
| 60 | Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 61 | for _, box := range []string{"src", "dst"} { |
| 62 | roster.Boxes[box] = hostactors.Box{Enrollment: enrollment(box), Namespace: "platform", BoxID: box, Label: box, Region: "ie", Pool: "default", |
| 63 | Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + box}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| 64 | } |
| 65 | raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if _, err := b.store.Put(ctx, hostactors.Key, raw, ""); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | b.actor = hostbox.Actor{Box: "dst", Enrollment: enrollment("dst"), Region: "ie", Revision: 4, Binding: "4"} |
| 73 | |
| 74 | created := time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC) |
| 75 | nh := hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + participationHost + ".json", Account: "acct", App: "app", Host: participationHost, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| 76 | body := []byte(`{"host":"` + participationHost + `","account":"acct","app":"app","region":"ie","created_at":"2026-10-07T01:00:00Z"}`) |
| 77 | obs, err := hostclaim.Read(ctx, b.store, participationHost) |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | for _, tr := range []func(*hostclaim.Claim) error{ |
| 82 | hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment("src")}), |
| 83 | hostclaim.CompleteCreate("c1", "src"), |
| 84 | hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}), |
| 85 | func(c *hostclaim.Claim) error { |
| 86 | c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"}, |
| 87 | Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst", "target_enrollment": enrollment("dst")}} |
| 88 | return nil |
| 89 | }, |
| 90 | } { |
| 91 | if obs, err = hostclaim.Update(ctx, b.store, obs, tr); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | } |
| 95 | locks := t.TempDir() |
| 96 | for _, name := range []string{"hostguard", "hostexec"} { |
| 97 | if err := os.Mkdir(filepath.Join(locks, name), 0o700); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | } |
| 101 | b.guard = hostguard.Guard{DataDir: b.data, LockDir: filepath.Join(locks, "hostguard"), Claims: hostguard.StoreReader{Store: b.store}} |
| 102 | inventory, err := openLegacyInventory(t, filepath.Join(t.TempDir(), "inventory.db")) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | t.Cleanup(func() { inventory.Close() }) |
| 107 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | t.Cleanup(func() { reg.Close() }) |
| 112 | launches, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db")) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | t.Cleanup(func() { launches.Close() }) |
| 117 | b.launches = launches |
| 118 | b.census = &hostbox.EmptyInstallationCensus{Inventory: inventory, Registry: reg, Launches: launches, Box: "dst", Enrollment: b.actor.Enrollment, DirectoryRevision: "4", |
| 119 | Roots: map[string]string{"data": b.data, "opt": t.TempDir(), "units": t.TempDir()}, SharedPaths: map[string][]string{"data": {".hostexec", "hostguard"}}} |
| 120 | a := &ClaimAssembly{Claims: b.store, Guard: b.guard, Wake: &ClaimWakeAssembly{}, Launches: launches, Reconcile: &hostbox.ReconcileAdapter{Census: b.census}, |
| 121 | collectStop: func(context.Context, StopRequest, StopDrainer) (StopResult, error) { |
| 122 | b.stops++ |
| 123 | return StopResult{}, errors.New("root: no stop in this fixture") |
| 124 | }} |
| 125 | b.p = &productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: b.data, OptDir: t.TempDir()}, Identity: handoverRecoveryIdentity(t, b.actor), |
| 126 | Directory: hostactors.Directory{Store: b.store, PublicKey: pub, Bucket: "bucket"}, Store: b.store, slots: map[string]*claimParticipation{}, |
| 127 | assembly: a, lifetime: ctx, lockRoot: locks} |
| 128 | t.Cleanup(b.closeAll) |
| 129 | return b |
| 130 | } |
| 131 | |
| 132 | func (b *participationBox) read() hostclaim.Observed { |
| 133 | b.t.Helper() |
| 134 | o, err := hostclaim.Read(context.Background(), b.store, participationHost) |
| 135 | if err != nil { |
| 136 | b.t.Fatal(err) |
| 137 | } |
| 138 | return o |
| 139 | } |
| 140 | |
| 141 | func (b *participationBox) update(tr func(*hostclaim.Claim) error) hostclaim.Observed { |
| 142 | b.t.Helper() |
| 143 | o, err := hostclaim.Update(context.Background(), b.store, b.read(), tr) |
| 144 | if err != nil { |
| 145 | b.t.Fatal(err) |
| 146 | } |
| 147 | return o |
| 148 | } |
| 149 | |
| 150 | // records is the box's .hostexec records. |
| 151 | func (b *participationBox) records() []string { |
| 152 | b.t.Helper() |
| 153 | entries, err := os.ReadDir(filepath.Join(b.data, ".hostexec")) |
| 154 | if err != nil { |
| 155 | b.t.Fatal(err) |
| 156 | } |
| 157 | var out []string |
| 158 | for _, e := range entries { |
| 159 | out = append(out, e.Name()) |
| 160 | } |
| 161 | return out |
| 162 | } |
| 163 | |
| 164 | // closeAll stands for the agent's exit: every open controller is closed. |
| 165 | func (b *participationBox) closeAll() { |
| 166 | b.p.mu.Lock() |
| 167 | slots := b.p.slots |
| 168 | b.p.mu.Unlock() |
| 169 | for _, s := range slots { |
| 170 | if s.Controller != nil { |
| 171 | _ = s.Controller.Close(context.Background()) |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // Finding 1, ruling 42: one controller per {host, op}, whichever asks |
| 177 | // first. The relocation pass's acknowledgement (Acknowledged, through |
| 178 | // RelocationEffects) and main's participation (Prepare, as the loop and |
| 179 | // WorkSource.Prepare call it) open or reuse the box's one slot: one |
| 180 | // .hostexec record, one lease, one controller id. When the op ends without |
| 181 | // the box, main's next Prepare closes the controller, runs its cleanup, |
| 182 | // clears the record and drops the slot, and the relocation's Ready refuses. |
| 183 | func TestRelocationAcknowledgesThroughMainsParticipation(t *testing.T) { |
| 184 | t.Parallel() |
| 185 | for _, first := range []string{"relocation pass", "participation loop"} { |
| 186 | t.Run(first+" first", func(t *testing.T) { |
| 187 | ctx := t.Context() |
| 188 | b := newParticipationBox(t) |
| 189 | obs := b.read() |
| 190 | effects := &RelocationEffects{Participation: b.p.Acknowledged} |
| 191 | var ack hostclaim.ExecutionAcknowledgement |
| 192 | acknowledge := func() { |
| 193 | t.Helper() |
| 194 | var err error |
| 195 | if ack, err = effects.Acknowledge(ctx, obs); err != nil { |
| 196 | t.Fatalf("acknowledge: %v", err) |
| 197 | } |
| 198 | } |
| 199 | participate := func() { |
| 200 | t.Helper() |
| 201 | if err := b.p.Prepare(ctx, obs); err != nil { |
| 202 | t.Fatalf("main's Prepare: %v", err) |
| 203 | } |
| 204 | } |
| 205 | if first == "relocation pass" { |
| 206 | acknowledge() |
| 207 | participate() |
| 208 | } else { |
| 209 | participate() |
| 210 | acknowledge() |
| 211 | } |
| 212 | b.p.mu.Lock() |
| 213 | slot := b.p.slots[participationHost] |
| 214 | n := len(b.p.slots) |
| 215 | b.p.mu.Unlock() |
| 216 | if slot == nil || n != 1 || slot.Controller == nil { |
| 217 | t.Fatalf("want one slot with a controller, have %d", n) |
| 218 | } |
| 219 | opened, err := slot.Controller.Ready(ctx) |
| 220 | if err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | if ack.Op != "rel-1" || ack.ControllerID != opened.ControllerID || ack.LeaseETag == "" || ack.LeaseETag != opened.LeaseETag || ack.Enrollment != b.actor.Enrollment { |
| 224 | t.Fatalf("the acknowledgement is not main's controller: %+v, opened %+v", ack, opened.Binding) |
| 225 | } |
| 226 | again, err := effects.Acknowledge(ctx, obs) |
| 227 | if err != nil || again.ControllerID != ack.ControllerID || again.LeaseETag != ack.LeaseETag { |
| 228 | t.Fatalf("a second acknowledgement saw another controller: %+v %v", again, err) |
| 229 | } |
| 230 | if err := effects.Ready(ctx, obs); err != nil { |
| 231 | t.Fatalf("ready: %v", err) |
| 232 | } |
| 233 | if got := b.records(); len(got) != 1 || got[0] != participationHost+".json" { |
| 234 | t.Fatalf("records %v", got) |
| 235 | } |
| 236 | |
| 237 | // The op ends without this box: the source serves again. |
| 238 | dropped := b.update(func(c *hostclaim.Claim) error { |
| 239 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "src", Participants: []string{"src"}} |
| 240 | return nil |
| 241 | }) |
| 242 | if err := b.p.Prepare(ctx, dropped); err != nil { |
| 243 | t.Fatalf("main's Prepare did not discharge the participation: %v", err) |
| 244 | } |
| 245 | if !slot.CleanupSucceeded || len(b.p.slots) != 0 || len(b.records()) != 0 { |
| 246 | t.Fatalf("the participation outlived its op: cleanup=%v slots=%d records=%v", slot.CleanupSucceeded, len(b.p.slots), b.records()) |
| 247 | } |
| 248 | if err := effects.Ready(ctx, dropped); !errors.Is(err, hostbox.ErrChanged) { |
| 249 | t.Fatalf("ready after the op ended: %v", err) |
| 250 | } |
| 251 | if _, err := execlease.InspectLocal(b.data, participationHost); !errors.Is(err, os.ErrNotExist) { |
| 252 | t.Fatalf("record: %v", err) |
| 253 | } |
| 254 | }) |
| 255 | } |
| 256 | } |
| 257 | |