| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "reflect" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/activator" |
| 17 | "github.com/carlosframework/platform/internal/appconfig" |
| 18 | "github.com/carlosframework/platform/internal/genselect" |
| 19 | "github.com/carlosframework/platform/internal/hostbox" |
| 20 | "github.com/carlosframework/platform/internal/hostclaim" |
| 21 | "github.com/carlosframework/platform/internal/hostfiles" |
| 22 | "github.com/carlosframework/platform/internal/hostguard" |
| 23 | "github.com/carlosframework/platform/internal/hostwrite" |
| 24 | "github.com/carlosframework/platform/internal/instancekey" |
| 25 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 26 | "github.com/carlosframework/platform/internal/moves" |
| 27 | "github.com/carlosframework/platform/internal/moves/archive" |
| 28 | "github.com/carlosframework/platform/internal/park" |
| 29 | "github.com/carlosframework/platform/internal/provision" |
| 30 | "github.com/carlosframework/platform/internal/registry" |
| 31 | "github.com/carlosframework/platform/internal/release" |
| 32 | "github.com/superfly/ltx" |
| 33 | ) |
| 34 | |
| 35 | const ( |
| 36 | poHost = "shop.bab.oncarlos.com" |
| 37 | poOp = "mv_ownersprobe" |
| 38 | ) |
| 39 | |
| 40 | // poWork is a selected work that records the identities it runs under. |
| 41 | type poWork struct { |
| 42 | action string |
| 43 | f *poFixture |
| 44 | shut bool |
| 45 | } |
| 46 | |
| 47 | func (w poWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| 48 | w.f.order = append(w.f.order, w.action+":"+id.Config+"|"+id.Release) |
| 49 | if w.shut { |
| 50 | return hostwrite.ErrUnavailable |
| 51 | } |
| 52 | return fn() |
| 53 | } |
| 54 | |
| 55 | type poFixture struct { |
| 56 | t *testing.T |
| 57 | claims release.Store |
| 58 | bucket *leasetest.VersionedBucket |
| 59 | rec *provision.Record |
| 60 | dataDir, files string |
| 61 | route registry.Route |
| 62 | manifest moves.InstallManifest |
| 63 | launch ClaimServingLaunch |
| 64 | admitted int |
| 65 | renderETags map[string]string |
| 66 | renderOverlay string |
| 67 | order []string |
| 68 | owners *movesProbeOwners |
| 69 | seq int |
| 70 | launches, reads int |
| 71 | seal moves.Seal |
| 72 | inc string |
| 73 | snapshotVersion string |
| 74 | sourceGeneration string |
| 75 | // The move's sides (poSides): the source box and its engine region, the |
| 76 | // target box, its engine region (its roster name) and its placement |
| 77 | // code, the claim's intent "to" and the fleet's home designation. |
| 78 | poSides |
| 79 | } |
| 80 | |
| 81 | // poSides names the fixture's two boxes. The default is ie-box in ie to |
| 82 | // uk-box in uk; poIntoHome is a move from ca-box in ca into the home |
| 83 | // region, whose box home-box has the roster name ie, the record code "" and |
| 84 | // the claim's to @home (home part 2). |
| 85 | type poSides struct { |
| 86 | srcBox, srcRegion string |
| 87 | dstBox, dstEngine, dstCode, dstTo string |
| 88 | home string |
| 89 | } |
| 90 | |
| 91 | var poNamed = poSides{srcBox: "ie-box", srcRegion: "ie", dstBox: "uk-box", dstEngine: "uk", dstCode: "uk", dstTo: "uk"} |
| 92 | |
| 93 | // poOption adjusts the fixture's sides before it is built. |
| 94 | type poOption func(*poSides) |
| 95 | |
| 96 | // poIntoHome is a move from ca-box into the home region: the target is |
| 97 | // home-box, its roster name ie, the record's code "" and the claim's to the |
| 98 | // placement-derived @home, with the fleet's home designation ie. |
| 99 | func poIntoHome(s *poSides) { |
| 100 | *s = poSides{srcBox: "ca-box", srcRegion: "ca", dstBox: "home-box", dstEngine: "ie", dstCode: "", dstTo: park.PlacementRegion(""), home: "ie"} |
| 101 | } |
| 102 | |
| 103 | // poFlipClaim is the claim as the flip leaves it (moves §7.5): landing at |
| 104 | // probing with uk-box executing; the holder is still the source's session 15 |
| 105 | // in generation "src" on ie-box, at the flip's stamp 18. |
| 106 | func (f *poFixture) poFlipClaim() *hostclaim.Claim { |
| 107 | f.t.Helper() |
| 108 | journal := map[string]string{hostclaim.JournalSeal: f.seal.Encode(), hostclaim.JournalCutSession: "15", hostclaim.JournalEvidenceSource: "moves/bab/jam/" + poHost + "/" + poOp + "/stopped-source.json", hostclaim.JournalSourceStamp: "15", hostclaim.JournalSrcGen: "src"} |
| 109 | intent := map[string]string{"to": f.dstTo, "source": f.srcBox, "target": f.dstBox} |
| 110 | if f.home != "" { |
| 111 | intent["home_region"] = f.home |
| 112 | } |
| 113 | c := &hostclaim.Claim{V: 2, Host: poHost, Gen: 19, |
| 114 | Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + poHost + ".json", Account: "bab", App: "jam", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Incarnation: f.inc, Location: "instances/" + poHost, Generation: "src", Stamp: 18, Session: 15, WriterBox: f.srcBox, |
| 115 | Admissions: []hostclaim.AdmissionRecord{{Session: 15, Box: f.srcBox, Generation: "src", Predecessor: "", Region: f.srcRegion, Mode: "cold"}}}, |
| 116 | Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: poOp, Phase: hostclaim.PhaseLandingProbing, Exec: f.dstBox, Participants: []string{f.srcBox, f.dstBox}, Intent: intent, Journal: journal}} |
| 117 | if err := c.Validate(); err != nil { |
| 118 | f.t.Fatal(err) |
| 119 | } |
| 120 | return c |
| 121 | } |
| 122 | |
| 123 | func newPOFixture(t *testing.T, opts ...poOption) *poFixture { |
| 124 | t.Helper() |
| 125 | // Claim Plan 15: admissions through the real CAS record their origin |
| 126 | // under both switches; the real C8's proof reads that record. |
| 127 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 128 | t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| 129 | at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| 130 | inc, err := hostclaim.Incarnation("bab", "jam", poHost, at) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | f := &poFixture{t: t, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket(), dataDir: t.TempDir(), files: t.TempDir(), inc: inc, poSides: poNamed} |
| 135 | for _, o := range opts { |
| 136 | o(&f.poSides) |
| 137 | } |
| 138 | f.bucket.AddBucket("data") |
| 139 | f.seal = moves.Seal{V: moves.SealVersion, N: 42, FromDB: true, SrcCovered: true, SealedAt: at, LeaseUntil: at.Add(time.Minute), |
| 140 | Replica: []moves.SealedObject{{Key: "moves/" + poHost + "/" + poOp + "/replica/0009/" + ltx.FormatFilename(1, 42), VersionID: "v1", Size: int64(ltx.HeaderSize)}}, |
| 141 | Files: []moves.SealedObject{{Key: "moves/" + poHost + "/" + poOp + "/files/archive.sealed", VersionID: "v2", Size: 10}, {Key: "moves/" + poHost + "/" + poOp + "/files/archive.json", VersionID: "v3", Size: 10}}} |
| 142 | f.putClaim(f.poFlipClaim()) |
| 143 | // The prepared generation the build wrote, and its manifest in the op |
| 144 | // journal (Plan 2 Task 8). |
| 145 | key := "instances/" + poHost + "/g-" + poOp + "/0009/" + ltx.FormatFilename(1, 42) |
| 146 | f.snapshotVersion = f.bucket.Put("data", key, f.ltxHeader(1, 42)) |
| 147 | f.manifest = moves.InstallManifest{V: 1, Op: poOp, Host: poHost, N: 42, SnapshotKey: key, SnapshotVersion: f.snapshotVersion, SnapshotCRC64: 7, DBSHA256: strings.Repeat("d", 64)} |
| 148 | f.bucket.Put("data", moves.InstallManifestKey(moves.GenerationPrefix("instances/"+poHost, poOp)), mustJSON(t, f.manifest)) |
| 149 | j, err := moves.ReadJournal(f.dataDir, poHost, poOp) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | body, _ := json.Marshal(f.manifest) |
| 154 | j.Mark(landStepInstall, string(body)) |
| 155 | // The install receipt the landing journals: the files directory and |
| 156 | // <host>.env, placed (moveslandinstall.go). |
| 157 | // The files the landing extracted (fix round 3 changes them in place). |
| 158 | if err := os.WriteFile(filepath.Join(f.files, "a.txt"), []byte("the landing's file"), 0o600); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | envPath := filepath.Join(f.dataDir, poHost+".env") |
| 162 | if err := os.WriteFile(envPath, []byte("CARLOS_ADMIN_TOKEN=t\n"), 0o600); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | filesID, err := pathIdentity(f.files) |
| 166 | if err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | envID, err := pathIdentity(envPath) |
| 170 | if err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | placed, _ := json.Marshal(stagedFiles{Files: filesID, Env: envID}) |
| 174 | j.Mark(landStepFilesPlaced, string(placed)) |
| 175 | if err := j.Commit(f.dataDir); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | // The prepared files manifest local wrote at the flip's stamp, before |
| 179 | // .files-gen, and its binding in the op journal (C8, Task 6). |
| 180 | f.preparedManifestJournalledAt(poOp, poOp, 18, f.files, envPath) |
| 181 | f.route = registry.Route{Host: poHost, Kind: "instance", App: "jam", Account: "bab", Status: "landing", HoldOp: poOp, Socket: filepath.Join(f.dataDir, poHost+".sock"), DBPath: filepath.Join(f.dataDir, poHost+".db")} |
| 182 | f.writeLocal(poOp, poOp, inc) |
| 183 | pins := mpPins() |
| 184 | pins.EnvReport = "sha256:env" |
| 185 | deadline := time.Date(2026, 10, 9, 12, 10, 0, 0, time.UTC) |
| 186 | f.rec = &provision.Record{Host: poHost, Account: "bab", App: "jam", Region: f.dstCode, |
| 187 | Move: &provision.MoveState{ID: poOp, Incarnation: inc, From: f.srcRegion, To: f.dstCode, State: provision.MoveLanding, LandingPhase: provision.LandProbing, Inputs: pins, CutDeadline: &deadline, |
| 188 | Landing: &provision.MoveLandingJournal{Attempts: 1, InstalledDBSHA256: "sha256:" + f.manifest.DBSHA256}}} |
| 189 | f.launch = ClaimServingLaunch{Identity: hostwrite.Identity{Config: "sha256:snapshot", Release: "sha256:bytes"}, Executable: "/opt/carlos/live/" + poHost, ExecutableSHA256: strings.Repeat("b", 64), Args: []string{"--socket", f.route.Socket, "--db", f.route.DBPath}, Env: []string{"A=1", "CARLOS_MOVE_PROBE=0"}, Version: pins.Release} |
| 190 | pins.EnvReport = envDigest(poEnvKey, f.launch.Env) |
| 191 | f.admitted = 3 |
| 192 | f.renderETags, f.renderOverlay = pins.BundleETags, pins.OverlayETag |
| 193 | f.owners = &movesProbeOwners{Claims: f.claims, Data: f.bucket, Bucket: "data", Region: f.dstEngine, Box: f.dstBox, DataDir: f.dataDir, |
| 194 | Records: func(context.Context, string, string, string) (*provision.Record, error) { f.reads++; return f.rec, nil }, |
| 195 | ReadyPath: func(context.Context, string, string) (string, error) { return "/healthz", nil }, |
| 196 | FilesDir: func(string) (string, string) { return f.files, filepath.Join(f.dataDir, poHost+".env") }, |
| 197 | Markers: plan9D5Markers{f.bucket}, |
| 198 | Route: func(string) (*registry.Route, error) { rt := f.route; return &rt, nil }, |
| 199 | // The launch and its env report, made from the same env as the |
| 200 | // production launch source makes it (claimMoveLaunchSource). |
| 201 | Launch: func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { |
| 202 | f.launches++ |
| 203 | return f.launch, moves.ReportLaunch(poEnvKey, launchVars(f.launch.Env), nil), nil |
| 204 | }, |
| 205 | EnvKey: poEnvKey, |
| 206 | Admitted: func(context.Context, *provision.Record) (int, error) { return f.admitted, nil }, |
| 207 | Works: func(_ context.Context, host, action string, captured hostguard.Captured) (hostwrite.Work, error) { |
| 208 | f.order = append(f.order, "select:"+action) |
| 209 | return poWork{action: action, f: f}, nil |
| 210 | }, |
| 211 | Render: func(ctx context.Context, rt registry.Route, op string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) { |
| 212 | f.order = append(f.order, "render") |
| 213 | w, err := work(appconfig.HeldRender{BundleETags: f.renderETags, OverlayETag: f.renderOverlay}) |
| 214 | if err != nil { |
| 215 | return appconfig.HeldRender{}, err |
| 216 | } |
| 217 | if err := w.With(ctx, hostwrite.Identity{Config: "rendered-content-identity"}, func() error { return nil }); err != nil { |
| 218 | return appconfig.HeldRender{}, err |
| 219 | } |
| 220 | return appconfig.HeldRender{Path: "p", SHA256: "s", BundleETags: f.renderETags, OverlayETag: f.renderOverlay}, nil |
| 221 | }, |
| 222 | Release: func(ctx context.Context, rt registry.Route, version string, work hostwrite.Work) error { |
| 223 | f.order = append(f.order, "release:"+version) |
| 224 | return work.With(ctx, hostwrite.Identity{Release: "ignored"}, func() error { return nil }) |
| 225 | }, |
| 226 | NewID: func() string { f.seq++; return fmt.Sprintf("fresh%d", f.seq) }, |
| 227 | Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) }} |
| 228 | if f.home != "" { |
| 229 | // The target is the home box: the roster grants it the alias. |
| 230 | f.owners.HomeRegion, f.owners.FleetHomeRegion = f.home, f.home |
| 231 | } |
| 232 | // The C8 provider is the real one over the fixture's bucket, as the |
| 233 | // claim assembly binds it (whole-branch fixes, item 1). |
| 234 | f.owners.Files = f.realC8() |
| 235 | return f |
| 236 | } |
| 237 | |
| 238 | func (f *poFixture) ltxHeader(min, max uint64) []byte { |
| 239 | h := ltx.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, MinTXID: ltx.TXID(min), MaxTXID: ltx.TXID(max)} |
| 240 | body, err := h.MarshalBinary() |
| 241 | if err != nil { |
| 242 | f.t.Fatal(err) |
| 243 | } |
| 244 | return body |
| 245 | } |
| 246 | |
| 247 | // writeLocal writes the two local markers the landing left: .files-gen |
| 248 | // naming files/inc and <dbPath>.gen naming db ("" removes it). |
| 249 | func (f *poFixture) writeLocal(files, db, inc string) { |
| 250 | f.t.Helper() |
| 251 | body, _ := json.Marshal(moves.FilesGen{Op: files, Incarnation: inc}) |
| 252 | if err := os.WriteFile(filepath.Join(f.files, archive.MarkerName), body, 0o600); err != nil { |
| 253 | f.t.Fatal(err) |
| 254 | } |
| 255 | if db == "" { |
| 256 | os.Remove(f.route.DBPath + ".gen") |
| 257 | return |
| 258 | } |
| 259 | if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", db); err != nil { |
| 260 | f.t.Fatal(err) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func (f *poFixture) putClaim(c *hostclaim.Claim) { |
| 265 | f.t.Helper() |
| 266 | body, err := json.Marshal(c) |
| 267 | if err != nil { |
| 268 | f.t.Fatal(err) |
| 269 | } |
| 270 | key, err := hostclaim.Key(poHost) |
| 271 | if err != nil { |
| 272 | f.t.Fatal(err) |
| 273 | } |
| 274 | etag := "" |
| 275 | if _, current, err := f.claims.Get(f.t.Context(), key); err == nil { |
| 276 | etag = current |
| 277 | } |
| 278 | if _, err := f.claims.Put(f.t.Context(), key, body, etag); err != nil { |
| 279 | f.t.Fatal(err) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func (f *poFixture) read() hostclaim.Observed { |
| 284 | f.t.Helper() |
| 285 | o, err := hostclaim.Read(f.t.Context(), f.claims, poHost) |
| 286 | if err != nil { |
| 287 | f.t.Fatal(err) |
| 288 | } |
| 289 | return o |
| 290 | } |
| 291 | |
| 292 | var poEnvKey = []byte("0123456789abcdef0123456789abcdef") |
| 293 | |
| 294 | var poProbe = activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second, Probe: true} |
| 295 | |
| 296 | // admit applies the selected admission with the claim's own CAS. |
| 297 | func (f *poFixture) admit(o hostclaim.Observed, in ClaimLaunchInputs) hostclaim.Observed { |
| 298 | f.t.Helper() |
| 299 | admitted, err := hostclaim.Update(f.t.Context(), f.claims, o, hostclaim.AdmitWriter(in.Admission)) |
| 300 | if err != nil { |
| 301 | f.t.Fatal(err) |
| 302 | } |
| 303 | return admitted |
| 304 | } |
| 305 | |
| 306 | // TestProbeIsAColdAdmissionIntoThePreparedGeneration (owner half; the |
| 307 | // runtime half is TestMovesProbeOwnersThroughTheClaimRuntime): the first |
| 308 | // probe's admission is cold, into the move id, keeping the flip's stamp, |
| 309 | // sealed, from the prepared pin; the restarted attempt's is a fresh |
| 310 | // generation that keeps no stamp and claims no coverage. |
| 311 | func TestProbeIsAColdAdmissionIntoThePreparedGeneration(t *testing.T) { |
| 312 | f := newPOFixture(t) |
| 313 | ctx := t.Context() |
| 314 | o := f.read() |
| 315 | in, err := f.owners.Select(ctx, f.route, o, poProbe) |
| 316 | if err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | want := hostclaim.Admission{ObservedOpID: poOp, ObservedPhase: hostclaim.PhaseLandingProbing, ObservedSession: 15, ObservedGeneration: "src", Box: "uk-box", Mode: "cold", NewGeneration: poOp, KeepStamp: true, Coverage: "sealed", Region: "uk", PredecessorRegion: "ie", RemoteMaxTXID: 42} |
| 320 | if !reflect.DeepEqual(in.Admission, want) { |
| 321 | t.Fatalf("admission:\n got %+v\nwant %+v", in.Admission, want) |
| 322 | } |
| 323 | if !in.Prepared || in.Empty || in.Baseline == nil || in.Baseline.Generation != poOp || in.Baseline.MaxTXID != 42 || in.Baseline.Pin == nil || in.Baseline.Pin.SnapshotVersion != f.snapshotVersion { |
| 324 | t.Fatalf("baseline: prepared=%v %+v", in.Prepared, in.Baseline) |
| 325 | } |
| 326 | if in.Identity != mpLaunchIdentity(f.rec.Move.Inputs) || in.ReadyPath != "/healthz" || in.DBPath != f.route.DBPath || in.Version != f.launch.Version || in.Executable != f.launch.Executable || len(in.Roles) != 1 || in.Roles[0] != "main" { |
| 327 | t.Fatalf("launch inputs: %+v", in) |
| 328 | } |
| 329 | probe := 0 |
| 330 | for _, kv := range in.Env { |
| 331 | if strings.HasPrefix(kv, "CARLOS_MOVE_PROBE=") { |
| 332 | probe++ |
| 333 | if kv != "CARLOS_MOVE_PROBE=1" { |
| 334 | t.Fatalf("env %q", kv) |
| 335 | } |
| 336 | } |
| 337 | } |
| 338 | if probe != 1 || !contains(in.Env, "A=1") { |
| 339 | t.Fatalf("env: %v", in.Env) |
| 340 | } |
| 341 | if err := f.owners.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | for name, mutate := range map[string]func(*hostclaim.Admission, *genselect.Plan){ |
| 345 | "no coverage": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Coverage = "" }, |
| 346 | "stamp not kept": func(a *hostclaim.Admission, _ *genselect.Plan) { a.KeepStamp = false }, |
| 347 | "another region": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Region = "ie" }, |
| 348 | "another box": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Box = "ie-box" }, |
| 349 | "a stale baseline": func(a *hostclaim.Admission, p *genselect.Plan) { p.MaxTXID, a.RemoteMaxTXID = 41, 41 }, |
| 350 | "another session": func(a *hostclaim.Admission, _ *genselect.Plan) { a.ObservedSession = 14 }, |
| 351 | "warm": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Mode = "warm" }, |
| 352 | "another new gen": func(a *hostclaim.Admission, _ *genselect.Plan) { a.NewGeneration = "other" }, |
| 353 | "a loss": func(a *hostclaim.Admission, _ *genselect.Plan) { a.LossID = "loss" }, |
| 354 | "certified": func(a *hostclaim.Admission, _ *genselect.Plan) { a.PredecessorCertified = true }, |
| 355 | "predecessor where": func(a *hostclaim.Admission, _ *genselect.Plan) { a.PredecessorRegion = "uk" }, |
| 356 | } { |
| 357 | a, p := in.Admission, *in.Baseline |
| 358 | mutate(&a, &p) |
| 359 | if err := f.owners.AdmissionEvidence(ctx, o, a, &p); !errors.Is(err, hostbox.ErrChanged) { |
| 360 | t.Fatalf("%s: %v", name, err) |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | // The first attempt is admitted; the next attempt over it is a fresh |
| 365 | // generation with no kept stamp and no coverage, restored from the |
| 366 | // prepared pin while the move id published nothing. |
| 367 | admitted := f.admit(o, in) |
| 368 | h := admitted.Claim.Holder |
| 369 | if h.Generation != poOp || h.Stamp != 18 || h.WriterBox != "uk-box" || h.Admissions[len(h.Admissions)-1].Coverage != "sealed" { |
| 370 | t.Fatalf("first admission: %+v", h) |
| 371 | } |
| 372 | again, err := f.owners.Select(ctx, f.route, admitted, poProbe) |
| 373 | if err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | if a := again.Admission; a.NewGeneration != "fresh1" || a.KeepStamp || a.Coverage != "" || a.ObservedGeneration != poOp || a.ObservedSession != h.Session || a.Mode != "cold" || a.PredecessorRegion != "uk" || again.Prepared { |
| 377 | t.Fatalf("restarted admission: %+v prepared=%v", a, again.Prepared) |
| 378 | } |
| 379 | if again.Baseline == nil || again.Baseline.Generation != poOp || again.Baseline.MaxTXID != 42 { |
| 380 | t.Fatalf("restarted baseline: %+v", again.Baseline) |
| 381 | } |
| 382 | if err := f.owners.AdmissionEvidence(ctx, admitted, again.Admission, again.Baseline); err != nil { |
| 383 | t.Fatal(err) |
| 384 | } |
| 385 | reused := again.Admission |
| 386 | reused.NewGeneration = poOp |
| 387 | if err := f.owners.AdmissionEvidence(ctx, admitted, reused, again.Baseline); !errors.Is(err, hostbox.ErrChanged) { |
| 388 | t.Fatalf("the move id twice: %v", err) |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | // realC8 is the production C8 provider over the fixture's bucket, data |
| 393 | // directory and files paths, as the claim assembly binds it: the probe |
| 394 | // owners' Files is this, not a fake (whole-branch fixes, item 1). |
| 395 | func (f *poFixture) realC8() *movesFiles { |
| 396 | return &movesFiles{Files: casBucketStore{b: f.bucket, bucket: "data"}, Data: f.bucket, Bucket: "data", HomeRegion: "ie", Region: f.owners.Region, Box: f.owners.Box, DataDir: f.dataDir, EnvFile: envFileOf(f.owners.FilesDir), |
| 397 | // Plan 15's proof over the fixture's claim store: the admissions the |
| 398 | // fixture makes through the real CAS are recorded (both switches |
| 399 | // on), and recordOrigins records what a hand-built claim or the |
| 400 | // #250 backfill would. |
| 401 | Proof: ClaimOriginProof{Claims: f.claims, HomeRegion: "ie", Region: f.owners.Region}} |
| 402 | } |
| 403 | |
| 404 | // preparedManifestJournalled builds the prepared manifest local writes for |
| 405 | // generation at the installation's stamp and journals its binding, as |
| 406 | // local does (movesinstall.go). |
| 407 | func (f *poFixture) preparedManifestJournalled(generation, op string, stamp uint64) preparedManifest { |
| 408 | f.t.Helper() |
| 409 | _, envPath := f.owners.FilesDir(poHost) |
| 410 | return f.preparedManifestJournalledAt(generation, op, stamp, f.files, envPath) |
| 411 | } |
| 412 | |
| 413 | func (f *poFixture) preparedManifestJournalledAt(generation, op string, stamp uint64, dir, envPath string) preparedManifest { |
| 414 | f.t.Helper() |
| 415 | pm, err := buildPreparedManifest(f.t.Context(), f.bucket, "data", "instances/"+poHost, generation, op, stamp, dir, envPath, true, hostfiles.WakeState{V: 1, Host: poHost, Timers: []hostfiles.WakeTimer{}}) |
| 416 | if err != nil { |
| 417 | f.t.Fatal(err) |
| 418 | } |
| 419 | j, err := moves.ReadJournal(f.dataDir, poHost, generation) |
| 420 | if err != nil { |
| 421 | f.t.Fatal(err) |
| 422 | } |
| 423 | j.Mark(landStepFilesManifest, string(mustJSON(f.t, pm))) |
| 424 | if err := j.Commit(f.dataDir); err != nil { |
| 425 | f.t.Fatal(err) |
| 426 | } |
| 427 | return pm |
| 428 | } |
| 429 | |
| 430 | // The landing's first probe and its committed wake establish the admitted |
| 431 | // generation's files manifest through the registered C8 provider before |
| 432 | // the start check (whole-branch fixes, item 1): the probe's establishment |
| 433 | // takes P's prepared manifest at the journalled version, stages the env |
| 434 | // file on disk and drops the adoption record; the committed wake's copies |
| 435 | // it into the fresh generation. A refusal from C8 stops the start, and |
| 436 | // without a provider the wake is refused by name. |
| 437 | func TestLandingWakesEstablishThroughC8(t *testing.T) { |
| 438 | f := newPOFixture(t) |
| 439 | ctx := t.Context() |
| 440 | j, err := moves.ReadJournal(f.dataDir, poHost, poOp) |
| 441 | if err != nil { |
| 442 | t.Fatal(err) |
| 443 | } |
| 444 | var pm preparedManifest |
| 445 | if err := json.Unmarshal([]byte(j.Steps[landStepFilesManifest]), &pm); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | // The launch source minted the instance key into <host>.env before the |
| 449 | // establishment (O1'): the env file differs from the prepared item. |
| 450 | env := []byte("CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=abc\n") |
| 451 | if err := os.WriteFile(filepath.Join(f.dataDir, poHost+".env"), env, 0o600); err != nil { |
| 452 | t.Fatal(err) |
| 453 | } |
| 454 | adoption := filepath.Join(f.dataDir, poHost+instancekey.AdoptionSuffix) |
| 455 | if err := os.WriteFile(adoption, []byte(`{"v":2}`), 0o600); err != nil { |
| 456 | t.Fatal(err) |
| 457 | } |
| 458 | o := f.read() |
| 459 | in, err := f.owners.Select(ctx, f.route, o, poProbe) |
| 460 | if err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | admitted := f.admit(o, in) |
| 464 | if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f}); err != nil { |
| 465 | t.Fatalf("the probe's materialization: %v", err) |
| 466 | } |
| 467 | prefix := movesFilesPrefix("instances/"+poHost, poOp) |
| 468 | m, found, err := hostfiles.Load(ctx, casBucketStore{b: f.bucket, bucket: "data"}, prefix) |
| 469 | if err != nil || !found { |
| 470 | t.Fatal(err, found) |
| 471 | } |
| 472 | if m.Seq != 1 || m.Platform[hostfiles.EnvItem].SHA256 != sha(env) || m.Entries["a.txt"].Seq != 0 { |
| 473 | t.Fatalf("P's manifest after the probe: %+v", m) |
| 474 | } |
| 475 | if _, has := m.Platform[instancekey.AdoptionItem]; has { |
| 476 | t.Fatal("the adoption item was carried") |
| 477 | } |
| 478 | if _, err := os.Stat(adoption); !errors.Is(err, os.ErrNotExist) { |
| 479 | t.Fatalf("the adoption record remains: %v", err) |
| 480 | } |
| 481 | if raw, err := f.bucket.GetVersion(ctx, "data", pm.Key, pm.Version); err != nil || sha(raw) != pm.SHA256 { |
| 482 | t.Fatalf("the prepared version: %v", err) |
| 483 | } |
| 484 | if err := f.owners.Publish(ctx, admitted, in); err != nil { |
| 485 | t.Fatal(err) |
| 486 | } |
| 487 | |
| 488 | // The committed wake: the claim at landing/committed, the record |
| 489 | // committed, a cold admission into a fresh generation whose files |
| 490 | // manifest is copied from P's. |
| 491 | c := *f.read().Claim |
| 492 | c.Op.Phase = hostclaim.PhaseLandingCommitted |
| 493 | f.putClaim(&c) |
| 494 | f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveCommitted, "" |
| 495 | f.route.Status, f.route.HoldOp = "ready", "" |
| 496 | o = f.read() |
| 497 | in, err = f.owners.Select(ctx, f.route, o, activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second}) |
| 498 | if err != nil { |
| 499 | t.Fatalf("the committed select: %v", err) |
| 500 | } |
| 501 | if in.Prepared || in.Admission.NewGeneration == poOp { |
| 502 | t.Fatalf("the committed admission: %+v", in.Admission) |
| 503 | } |
| 504 | admitted = f.admit(o, in) |
| 505 | f.writeLocal(poOp, admitted.Claim.Holder.Generation, f.inc) |
| 506 | if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f}); err != nil { |
| 507 | t.Fatalf("the committed materialization: %v", err) |
| 508 | } |
| 509 | fresh, found, err := hostfiles.Load(ctx, casBucketStore{b: f.bucket, bucket: "data"}, movesFilesPrefix("instances/"+poHost, in.Admission.NewGeneration)) |
| 510 | if err != nil || !found { |
| 511 | t.Fatalf("the committed wake's manifest: %v %v", err, found) |
| 512 | } |
| 513 | if fresh.Entries["a.txt"].Object != m.Entries["a.txt"].Object || fresh.Platform[hostfiles.EnvItem].SHA256 != sha(env) { |
| 514 | t.Fatalf("the committed wake's manifest: %+v", fresh) |
| 515 | } |
| 516 | |
| 517 | // C8's refusal stops the start: no binding in the op journal. |
| 518 | g := newPOFixture(t) |
| 519 | gj, err := moves.ReadJournal(g.dataDir, poHost, poOp) |
| 520 | if err != nil { |
| 521 | t.Fatal(err) |
| 522 | } |
| 523 | gj.Unmark(landStepFilesManifest) |
| 524 | if err := gj.Commit(g.dataDir); err != nil { |
| 525 | t.Fatal(err) |
| 526 | } |
| 527 | o = g.read() |
| 528 | in, err = g.owners.Select(ctx, g.route, o, poProbe) |
| 529 | if err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | admitted = g.admit(o, in) |
| 533 | if err := g.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: g}); err == nil || !errors.Is(err, errLandingChanged) { |
| 534 | t.Fatalf("a probe without a prepared manifest binding: %v", err) |
| 535 | } |
| 536 | if m, found, _ := hostfiles.Load(ctx, casBucketStore{b: g.bucket, bucket: "data"}, movesFilesPrefix("instances/"+poHost, poOp)); !found || m.Seq != 0 { |
| 537 | t.Fatalf("a refused establishment changed the prepared manifest: %+v %v", m, found) |
| 538 | } |
| 539 | // Without a provider the wake is refused by name, before anything. |
| 540 | g.owners.Files = nil |
| 541 | var dep *ClaimDependencyError |
| 542 | if err := g.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: g}); !errors.As(err, &dep) { |
| 543 | t.Fatalf("no provider: %v", err) |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | // noopFiles is a C8 provider that establishes nothing: for tests of the |
| 548 | // runtime's wiring, where the real provider's inputs are not laid out. |
| 549 | type noopFiles struct{} |
| 550 | |
| 551 | func (noopFiles) Establish(ctx context.Context, _ hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| 552 | return work.With(ctx, in.Identity, func() error { return nil }) |
| 553 | } |
| 554 | |
| 555 | func mpLaunchIdentity(p *provision.MoveInputs) hostwrite.Identity { |
| 556 | return hostwrite.Identity{Config: HeldConfigIdentity(p.BundleETags, p.OverlayETag).Config, Release: p.Release} |
| 557 | } |
| 558 | |
| 559 | func contains(xs []string, x string) bool { |
| 560 | for _, v := range xs { |
| 561 | if v == x { |
| 562 | return true |
| 563 | } |
| 564 | } |
| 565 | return false |
| 566 | } |
| 567 | |
| 568 | // TestProbeRefusesUnheldRowOrForeignExec: the row must be held by the |
| 569 | // move, and the claim must name this box the executor of the move's |
| 570 | // landing at probing; anything else is refused before any launch is read. |
| 571 | func TestProbeRefusesUnheldRowOrForeignExec(t *testing.T) { |
| 572 | for name, tc := range map[string]struct { |
| 573 | route func(*registry.Route) |
| 574 | claim func(*hostclaim.Claim) |
| 575 | rec func(*provision.Record) |
| 576 | policy func(*activator.StartPolicy) |
| 577 | }{ |
| 578 | "a ready row": {route: func(r *registry.Route) { r.Status, r.HoldOp = "ready", "" }}, |
| 579 | "held by another op": {route: func(r *registry.Route) { r.HoldOp = "mv_other" }}, |
| 580 | "another host's row": {route: func(r *registry.Route) { r.Host = "other.bab.oncarlos.com" }}, |
| 581 | "a foreign executor": {claim: func(c *hostclaim.Claim) { c.Op.Exec = "ie-box" }}, |
| 582 | "not the target": {claim: func(c *hostclaim.Claim) { c.Op.Intent["target"] = "ie-box" }}, |
| 583 | "another region": {claim: func(c *hostclaim.Claim) { c.Op.Intent["to"] = "ie" }}, |
| 584 | "not probing": {claim: func(c *hostclaim.Claim) { c.Op.Phase = provision.LandRestored }}, |
| 585 | "not a landing": {claim: func(c *hostclaim.Claim) { c.Op.Kind = hostclaim.KindReverting }}, |
| 586 | "declared lost": {claim: func(c *hostclaim.Claim) { c.Lost = []string{"uk-box"} }}, |
| 587 | "a marked record": {rec: func(r *provision.Record) { r.Move.Reason = provision.RollbackProbeAttempts }}, |
| 588 | "a record past probing": {rec: func(r *provision.Record) { r.Move.LandingPhase = provision.LandProbed }}, |
| 589 | "no pinned inputs": {rec: func(r *provision.Record) { r.Move.Inputs = nil }}, |
| 590 | "not a probe": {policy: func(p *activator.StartPolicy) { p.Probe = false }}, |
| 591 | "other pins": {policy: func(p *activator.StartPolicy) { p.Inputs = &provision.MoveInputs{Release: "v0"} }}, |
| 592 | "another manifest": {policy: func(p *activator.StartPolicy) { p.Prepared = &moves.InstallManifest{Op: poOp, N: 41} }}, |
| 593 | } { |
| 594 | t.Run(name, func(t *testing.T) { |
| 595 | f := newPOFixture(t) |
| 596 | if tc.claim != nil { |
| 597 | c := f.poFlipClaim() |
| 598 | tc.claim(c) |
| 599 | f.putClaim(c) |
| 600 | } |
| 601 | if tc.route != nil { |
| 602 | tc.route(&f.route) |
| 603 | } |
| 604 | if tc.rec != nil { |
| 605 | tc.rec(f.rec) |
| 606 | } |
| 607 | p := poProbe |
| 608 | if tc.policy != nil { |
| 609 | tc.policy(&p) |
| 610 | } |
| 611 | if _, err := f.owners.Select(t.Context(), f.route, f.read(), p); err == nil { |
| 612 | t.Fatal("selected") |
| 613 | } |
| 614 | if f.launches != 0 { |
| 615 | t.Fatal("the launch was read") |
| 616 | } |
| 617 | }) |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | // The prepared inputs are validated before any admission: the local markers |
| 622 | // name the move id and the holder's incarnation, and the journalled |
| 623 | // manifest is the seal's. |
| 624 | func TestMovesProbeOwnersValidateThePreparedInputs(t *testing.T) { |
| 625 | for name, tc := range map[string]struct { |
| 626 | mutate func(*poFixture) |
| 627 | want error |
| 628 | }{ |
| 629 | "files of another op": {func(f *poFixture) { f.writeLocal("mv_other", poOp, f.inc) }, moves.ErrStartFiles}, |
| 630 | "files of another incarnation": {func(f *poFixture) { f.writeLocal(poOp, poOp, "other") }, moves.ErrStartFiles}, |
| 631 | "no files marker": {func(f *poFixture) { |
| 632 | os.Remove(filepath.Join(f.files, archive.MarkerName)) |
| 633 | }, moves.ErrStartFiles}, |
| 634 | "a database of another op": {func(f *poFixture) { f.writeLocal(poOp, "src", f.inc) }, moves.ErrStartDB}, |
| 635 | "no database marker": {func(f *poFixture) { f.writeLocal(poOp, "", f.inc) }, moves.ErrStartDB}, |
| 636 | "a manifest at another N": {func(f *poFixture) { |
| 637 | f.manifest.N = 41 |
| 638 | j, _ := moves.ReadJournal(f.dataDir, poHost, poOp) |
| 639 | body, _ := json.Marshal(f.manifest) |
| 640 | j.Mark(landStepInstall, string(body)) |
| 641 | j.Commit(f.dataDir) |
| 642 | }, hostbox.ErrChanged}, |
| 643 | "no manifest": {func(f *poFixture) { |
| 644 | j, _ := moves.ReadJournal(f.dataDir, poHost, poOp) |
| 645 | j.Unmark(landStepInstall) |
| 646 | j.Commit(f.dataDir) |
| 647 | }, hostbox.ErrChanged}, |
| 648 | } { |
| 649 | t.Run(name, func(t *testing.T) { |
| 650 | f := newPOFixture(t) |
| 651 | tc.mutate(f) |
| 652 | if _, err := f.owners.Select(t.Context(), f.route, f.read(), poProbe); !errors.Is(err, tc.want) { |
| 653 | t.Fatalf("got %v, want %v", err, tc.want) |
| 654 | } |
| 655 | }) |
| 656 | } |
| 657 | } |
| 658 | |
| 659 | // The launch-input pin at spawn (§7.7, round-2 finding 14): the live |
| 660 | // release and the launch environment's report are the ones pinned at |
| 661 | // readiness, else the probe fails with the recorded rollback sentence |
| 662 | // before any admission. |
| 663 | func TestMovesProbeOwnersPinTheLaunchInputs(t *testing.T) { |
| 664 | for name, mutate := range map[string]func(*poFixture){ |
| 665 | "another release": func(f *poFixture) { f.launch.Version = "v9" }, |
| 666 | "another environment": func(f *poFixture) { f.rec.Move.Inputs.EnvReport = "sha256:other" }, |
| 667 | } { |
| 668 | t.Run(name, func(t *testing.T) { |
| 669 | f := newPOFixture(t) |
| 670 | mutate(f) |
| 671 | _, err := f.owners.Select(t.Context(), f.route, f.read(), poProbe) |
| 672 | var changed *ProbeInputsChanged |
| 673 | if !errors.As(err, &changed) || changed.Reason != provision.RollbackConfigChanged { |
| 674 | t.Fatalf("got %v", err) |
| 675 | } |
| 676 | }) |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | // TestFirstProbeFromTheSourceGenerationIsAdmitted: from the claim as the |
| 681 | // flip left it, the prepared inputs validate, the admission names the move |
| 682 | // id, and Materialize, before every start, renders and materializes the held |
| 683 | // inputs under their own selections and passes the §5.1 start check after |
| 684 | // the CAS. A hand-built claim already in the move id whose files name |
| 685 | // another op is refused there. |
| 686 | func TestFirstProbeFromTheSourceGenerationIsAdmitted(t *testing.T) { |
| 687 | f := newPOFixture(t) |
| 688 | ctx := t.Context() |
| 689 | o := f.read() |
| 690 | in, err := f.owners.Select(ctx, f.route, o, poProbe) |
| 691 | if err != nil { |
| 692 | t.Fatal(err) |
| 693 | } |
| 694 | admitted := f.admit(o, in) |
| 695 | f.order = nil |
| 696 | spawn := poWork{action: "spawn", f: f} |
| 697 | if err := f.owners.Materialize(ctx, admitted, in, spawn); err != nil { |
| 698 | t.Fatal(err) |
| 699 | } |
| 700 | pins := f.rec.Move.Inputs |
| 701 | launch := mpLaunchIdentity(pins) |
| 702 | want := []string{ |
| 703 | "spawn:" + launch.Config + "|" + launch.Release, |
| 704 | "render", "select:held-config", "held-config:" + HeldConfigIdentity(pins.BundleETags, pins.OverlayETag).Config + "|", |
| 705 | "select:held-release", "release:" + pins.Release, "held-release:|" + pins.Release, |
| 706 | // The prepared marker under the writer's fence: before the request |
| 707 | // and in the store before its attempt. |
| 708 | "spawn:" + launch.Config + "|" + launch.Release, "spawn:" + launch.Config + "|" + launch.Release, |
| 709 | // C8's establishment (item 1): the env file is still the prepared |
| 710 | // item's and there is no adoption item, so nothing is published and |
| 711 | // only the local effect runs under the work. |
| 712 | "spawn:" + launch.Config + "|" + launch.Release, |
| 713 | } |
| 714 | if !reflect.DeepEqual(f.order, want) { |
| 715 | t.Fatalf("order:\n got %q\nwant %q", f.order, want) |
| 716 | } |
| 717 | |
| 718 | // A render that is not the pinned configuration fails the probe and |
| 719 | // materializes nothing. |
| 720 | f.order = nil |
| 721 | f.renderOverlay = "e-changed" |
| 722 | err = f.owners.Materialize(ctx, admitted, in, spawn) |
| 723 | var changed *ProbeInputsChanged |
| 724 | if !errors.As(err, &changed) || changed.Reason != provision.RollbackConfigChanged || contains(f.order, "select:held-release") { |
| 725 | t.Fatalf("a changed render: %v %v", err, f.order) |
| 726 | } |
| 727 | f.renderOverlay = pins.OverlayETag |
| 728 | |
| 729 | // The start check against the claim after the CAS. |
| 730 | f.writeLocal("mv_other", poOp, f.inc) |
| 731 | if err := f.owners.Materialize(ctx, admitted, in, spawn); !errors.Is(err, moves.ErrStartFiles) { |
| 732 | t.Fatalf("files of another op: %v", err) |
| 733 | } |
| 734 | f.writeLocal(poOp, "src", f.inc) |
| 735 | if err := f.owners.Materialize(ctx, admitted, in, spawn); !errors.Is(err, moves.ErrStartDB) { |
| 736 | t.Fatalf("a database the restore did not write: %v", err) |
| 737 | } |
| 738 | f.writeLocal(poOp, poOp, f.inc) |
| 739 | // The spawn selection itself refused: nothing is rendered. |
| 740 | f.order = nil |
| 741 | if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f, shut: true}); !errors.Is(err, hostwrite.ErrUnavailable) || contains(f.order, "render") { |
| 742 | t.Fatalf("a refused spawn selection: %v %v", err, f.order) |
| 743 | } |
| 744 | } |
| 745 | |
| 746 | func mustJSON(t *testing.T, v any) []byte { |
| 747 | t.Helper() |
| 748 | b, err := json.Marshal(v) |
| 749 | if err != nil { |
| 750 | t.Fatal(err) |
| 751 | } |
| 752 | return b |
| 753 | } |
| 754 | |
| 755 | // The prepared marker (moves §7.14 marker row, R10-3): the first probe's |
| 756 | // admission publishes generation.json naming the move id with the build's |
| 757 | // pin, superseding an earlier stay's marker by its exact ETag, before |
| 758 | // replication; the evidence is this process's receipt. |
| 759 | func TestMovesProbeOwnersPublishThePreparedMarker(t *testing.T) { |
| 760 | f := newPOFixture(t) |
| 761 | ctx := t.Context() |
| 762 | key := "instances/" + poHost + "/generation.json" |
| 763 | f.bucket.Put("data", key, mustJSON(t, genselect.Marker{Op: "earlier", Incarnation: f.inc, Host: poHost, Box: "uk-box", InstalledAt: time.Unix(1, 0).UTC(), Pin: genselect.Pin{SnapshotTXID: 3}})) |
| 764 | o := f.read() |
| 765 | in, err := f.owners.Select(ctx, f.route, o, poProbe) |
| 766 | if err != nil { |
| 767 | t.Fatal(err) |
| 768 | } |
| 769 | admitted := f.admit(o, in) |
| 770 | if err := f.owners.PublicationEvidence(ctx, admitted); !errors.Is(err, hostbox.ErrChanged) { |
| 771 | t.Fatalf("evidence before publication: %v", err) |
| 772 | } |
| 773 | // Materialize publishes under the writer's fence; Group D's Publish |
| 774 | // records it. |
| 775 | if err := f.owners.Materialize(ctx, admitted, in, poWork{action: "spawn", f: f}); err != nil { |
| 776 | t.Fatal(err) |
| 777 | } |
| 778 | if err := f.owners.Publish(ctx, admitted, in); err != nil { |
| 779 | t.Fatal(err) |
| 780 | } |
| 781 | body, ok := f.bucket.Current("data", key) |
| 782 | var m genselect.Marker |
| 783 | if !ok || json.Unmarshal(body, &m) != nil || m.Op != poOp || m.Box != "uk-box" || m.Incarnation != f.inc || m.Host != poHost || |
| 784 | m.Pin != (genselect.Pin{SnapshotKey: f.manifest.SnapshotKey, SnapshotVersion: f.manifest.SnapshotVersion, SnapshotTXID: 42, SnapshotCRC64: 7}) || m.ManifestSHA256 == "" { |
| 785 | t.Fatalf("marker: %s", body) |
| 786 | } |
| 787 | if err := f.owners.PublicationEvidence(ctx, admitted); err != nil { |
| 788 | t.Fatal(err) |
| 789 | } |
| 790 | written := f.bucket.LatestID("data", key) |
| 791 | if err := f.owners.Publish(ctx, admitted, in); err != nil || f.bucket.LatestID("data", key) != written { |
| 792 | t.Fatalf("replay: %v", err) |
| 793 | } |
| 794 | // A restarted attempt's baseline is now the published move id. |
| 795 | again, err := f.owners.Select(ctx, f.route, admitted, poProbe) |
| 796 | if err != nil || again.Baseline.Generation != poOp || again.Baseline.Marker == nil || again.Baseline.Marker.Op != poOp { |
| 797 | t.Fatalf("restarted baseline after publication: %+v %v", again.Baseline, err) |
| 798 | } |
| 799 | f.bucket.Put("data", key, mustJSON(t, genselect.Marker{Op: poOp, Incarnation: f.inc, Host: poHost, Box: "ie-box", InstalledAt: time.Unix(2, 0).UTC(), Pin: m.Pin, ManifestSHA256: m.ManifestSHA256})) |
| 800 | if err := f.owners.PublicationEvidence(ctx, admitted); !errors.Is(err, hostbox.ErrChanged) { |
| 801 | t.Fatalf("evidence for a marker this session did not write: %v", err) |
| 802 | } |
| 803 | if err := f.owners.EmptyAuthority(ctx, admitted, in); err == nil { |
| 804 | t.Fatal("a move admission claimed the empty bootstrap") |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | // A restarted attempt's fresh generation is published fenced, after its own |
| 809 | // first root, as serving publishes. |
| 810 | func TestMovesProbeOwnersPublishARestartedAttemptFenced(t *testing.T) { |
| 811 | f := newPOFixture(t) |
| 812 | ctx := t.Context() |
| 813 | o := f.read() |
| 814 | in, err := f.owners.Select(ctx, f.route, o, poProbe) |
| 815 | if err != nil { |
| 816 | t.Fatal(err) |
| 817 | } |
| 818 | first := f.admit(o, in) |
| 819 | again, err := f.owners.Select(ctx, f.route, first, poProbe) |
| 820 | if err != nil { |
| 821 | t.Fatal(err) |
| 822 | } |
| 823 | admitted := f.admit(first, again) |
| 824 | f.restore(admitted.Claim.Holder.Generation) |
| 825 | if err := f.owners.Materialize(ctx, admitted, again, newFenceWork()); err != nil { |
| 826 | t.Fatal(err) |
| 827 | } |
| 828 | synced := 0 |
| 829 | pub := ClaimPublication{Sync: func(context.Context) error { synced++; return nil }, Check: func(context.Context) error { return nil }} |
| 830 | if err := f.owners.Publish(ctx, admitted, again); !errors.Is(err, hostbox.ErrChanged) { |
| 831 | t.Fatalf("an unfenced marker for a fresh generation: %v", err) |
| 832 | } |
| 833 | if _, err := f.owners.PublishFenced(ctx, admitted, again, pub); !errors.Is(err, hostbox.ErrChanged) || synced != 1 { |
| 834 | t.Fatalf("a marker before the root: %v synced=%d", err, synced) |
| 835 | } |
| 836 | root := "instances/" + poHost + "/g-" + again.Admission.NewGeneration + "/0009/" + ltx.FormatFilename(1, 44) |
| 837 | f.bucket.Put("data", root, f.ltxHeader(1, 44)) |
| 838 | receipt, err := f.owners.PublishFenced(ctx, admitted, again, pub) |
| 839 | if err != nil || receipt.Root.Key != root || receipt.MarkerVersion == "" { |
| 840 | t.Fatalf("publish: %+v %v", receipt, err) |
| 841 | } |
| 842 | if err := f.owners.PublicationEvidence(ctx, admitted); err != nil { |
| 843 | t.Fatal(err) |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | // TestMovesProbeOwnersThroughTheClaimRuntime drives the claim's own runtime |
| 848 | // (claim Plan 9 Group D's ClaimWakeAssembly, the recovery fixture's |
| 849 | // controller) with this owner set and the moves probe attempts bound, as |
| 850 | // production binds them: the admission is the prepared cold admission, |
| 851 | // journalled before any start; the probe start carries the moves attempt |
| 852 | // journal; and Materialize renders the held inputs before any launch is |
| 853 | // prepared. |
| 854 | func TestMovesProbeOwnersThroughTheClaimRuntime(t *testing.T) { |
| 855 | f := newRecoveryFixture(t) |
| 856 | ctx := t.Context() |
| 857 | journalDir, filesDir := t.TempDir(), t.TempDir() |
| 858 | seal := moves.Seal{V: moves.SealVersion, N: 42, FromDB: true, SrcCovered: true, SealedAt: f.now, LeaseUntil: f.now.Add(time.Minute), |
| 859 | Replica: []moves.SealedObject{{Key: "moves/" + recoveryHost + "/" + poOp + "/replica/0009/" + ltx.FormatFilename(1, 42), VersionID: "v1", Size: int64(ltx.HeaderSize)}}, |
| 860 | Files: []moves.SealedObject{{Key: "moves/" + recoveryHost + "/" + poOp + "/files/archive.sealed", VersionID: "v2", Size: 10}}} |
| 861 | if _, err := hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { |
| 862 | c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: poOp, Phase: hostclaim.PhaseLandingProbing, Exec: "box-1", Participants: []string{"box-0", "box-1"}, |
| 863 | Intent: map[string]string{"to": "ie", "source": "box-0", "target": "box-1"}, Journal: map[string]string{hostclaim.JournalSeal: seal.Encode()}} |
| 864 | return nil |
| 865 | }); err != nil { |
| 866 | t.Fatal(err) |
| 867 | } |
| 868 | o := f.read() |
| 869 | h := o.Claim.Holder |
| 870 | bucket := leasetest.NewVersionedBucket() |
| 871 | bucket.AddBucket("data") |
| 872 | key := strings.TrimSuffix(h.Location, "/") + "/g-" + poOp + "/0009/" + ltx.FormatFilename(1, 42) |
| 873 | hdr, _ := (<x.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, MinTXID: 1, MaxTXID: 42}).MarshalBinary() |
| 874 | manifest := moves.InstallManifest{V: 1, Op: poOp, Host: recoveryHost, N: 42, SnapshotKey: key, SnapshotVersion: bucket.Put("data", key, hdr), DBSHA256: strings.Repeat("d", 64)} |
| 875 | bucket.Put("data", moves.InstallManifestKey(moves.GenerationPrefix(h.Location, poOp)), mustJSON(t, manifest)) |
| 876 | j, _ := moves.ReadJournal(journalDir, recoveryHost, poOp) |
| 877 | body, _ := json.Marshal(manifest) |
| 878 | j.Mark(landStepInstall, string(body)) |
| 879 | if err := j.Commit(journalDir); err != nil { |
| 880 | t.Fatal(err) |
| 881 | } |
| 882 | route := registry.Route{Host: recoveryHost, Kind: "instance", App: "jam", Account: "bab", Status: "landing", HoldOp: poOp, Socket: filepath.Join(f.guard.DataDir, "app.sock"), DBPath: filepath.Join(f.guard.DataDir, "state", "data.db")} |
| 883 | if err := os.MkdirAll(filepath.Dir(route.DBPath), 0o700); err != nil { |
| 884 | t.Fatal(err) |
| 885 | } |
| 886 | if err := hostbox.WriteGenerationMarker(route.DBPath+".gen", poOp); err != nil { |
| 887 | t.Fatal(err) |
| 888 | } |
| 889 | if err := os.WriteFile(filepath.Join(filesDir, archive.MarkerName), mustJSON(t, moves.FilesGen{Op: poOp, Incarnation: h.Incarnation}), 0o600); err != nil { |
| 890 | t.Fatal(err) |
| 891 | } |
| 892 | pins := mpPins() |
| 893 | pins.EnvReport = envDigest(poEnvKey, []string{"A=1"}) |
| 894 | deadline := f.now.Add(time.Hour) |
| 895 | rec := &provision.Record{Host: recoveryHost, Account: "bab", App: "jam", Region: "ie", |
| 896 | Move: &provision.MoveState{ID: poOp, Incarnation: h.Incarnation, From: "uk", To: "ie", State: provision.MoveLanding, LandingPhase: provision.LandProbing, Inputs: pins, CutDeadline: &deadline, CutAttempts: 3, |
| 897 | Landing: &provision.MoveLandingJournal{Attempts: 1, InstalledDBSHA256: "sha256:" + manifest.DBSHA256}}} |
| 898 | records := func(context.Context, string, string, string) (*provision.Record, error) { return rec, nil } |
| 899 | var order []string |
| 900 | launches := 0 |
| 901 | owners := &movesProbeOwners{Claims: f.claims, Records: records, Data: bucket, Bucket: "data", Region: "ie", Box: "box-1", DataDir: journalDir, Markers: plan9D5Markers{bucket}, Files: noopFiles{}, |
| 902 | ReadyPath: func(context.Context, string, string) (string, error) { return "/ready", nil }, |
| 903 | FilesDir: func(string) (string, string) { return filesDir, "" }, |
| 904 | Route: func(string) (*registry.Route, error) { rt := route; return &rt, nil }, |
| 905 | Launch: func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { |
| 906 | return ClaimServingLaunch{Executable: "/usr/bin/true", ExecutableSHA256: strings.Repeat("a", 64), Env: []string{"A=1"}, Version: pins.Release}, moves.ReportLaunch(poEnvKey, map[string]string{"A": "1"}, nil), nil |
| 907 | }, |
| 908 | EnvKey: poEnvKey, |
| 909 | Admitted: MovesRecordAdmittedAttempts, |
| 910 | Works: func(_ context.Context, _, action string, _ hostguard.Captured) (hostwrite.Work, error) { |
| 911 | order = append(order, "select:"+action) |
| 912 | return plan9D5Work{}, nil |
| 913 | }, |
| 914 | Render: func(ctx context.Context, _ registry.Route, _ string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) { |
| 915 | order = append(order, "render") |
| 916 | if _, err := work(appconfig.HeldRender{BundleETags: pins.BundleETags, OverlayETag: pins.OverlayETag}); err != nil { |
| 917 | return appconfig.HeldRender{}, err |
| 918 | } |
| 919 | return appconfig.HeldRender{BundleETags: pins.BundleETags, OverlayETag: pins.OverlayETag}, nil |
| 920 | }, |
| 921 | Release: func(context.Context, registry.Route, string, hostwrite.Work) error { |
| 922 | order = append(order, "release") |
| 923 | return nil |
| 924 | }, |
| 925 | NewID: f.newID, Now: func() time.Time { return f.now }} |
| 926 | attempts := &movesProbeAttempts{Claims: f.claims, Records: records, DataDir: journalDir, NewID: f.newID, Now: func() time.Time { return f.now }, |
| 927 | Admitted: MovesRecordAdmittedAttempts} |
| 928 | c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| 929 | bound := owners.MovesOwners(attempts) |
| 930 | c.wake.Inputs = ClaimOwnerDispatch{Moves: &bound.ClaimKindOwners} |
| 931 | c.wake.Continue = hostclaim.SessionContinues |
| 932 | c.wake.Operations = bound.Operations |
| 933 | c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| 934 | c.wake.Launch = func(context.Context, hostclaim.Observed, hostwrite.Work, ClaimLaunchInputs, *activator.RoleLaunch) (*activator.RoleLaunch, error) { |
| 935 | launches++ |
| 936 | return nil, hostwrite.ErrUnavailable |
| 937 | } |
| 938 | live, cancel := context.WithCancel(context.Background()) |
| 939 | t.Cleanup(cancel) |
| 940 | done := make(chan struct{}) |
| 941 | go func() { <-live.Done(); close(done) }() |
| 942 | c.wake.mu.Lock() |
| 943 | c.wake.pending[recoveryHost] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| 944 | c.wake.mu.Unlock() |
| 945 | |
| 946 | admitted, err := c.runtime.Admit(ctx, route, o, poProbe) |
| 947 | if err != nil { |
| 948 | t.Fatal(err) |
| 949 | } |
| 950 | ah := admitted.Claim.Holder |
| 951 | last := ah.Admissions[len(ah.Admissions)-1] |
| 952 | if ah.Generation != poOp || ah.Stamp != h.Stamp || ah.WriterBox != "box-1" || last.Mode != "cold" || last.Coverage != "sealed" || last.Generation != poOp || last.Region != "ie" { |
| 953 | t.Fatalf("the admission: %+v %+v", ah, last) |
| 954 | } |
| 955 | s, err := c.runtime.Prepare(ctx, route, admitted, poProbe) |
| 956 | if err != nil { |
| 957 | t.Fatal(err) |
| 958 | } |
| 959 | if s.OpenProbe == nil || s.Identity != mpLaunchIdentity(pins) { |
| 960 | t.Fatalf("the probe start: %+v", s.Identity) |
| 961 | } |
| 962 | if opened, err := s.OpenProbe(ctx, f.now); err != nil || !opened.Equal(f.now) { |
| 963 | t.Fatalf("open: %v %v", opened, err) |
| 964 | } |
| 965 | if err := s.Reserve(ctx, 0); err != nil { |
| 966 | t.Fatal(err) |
| 967 | } |
| 968 | if a := s.CurrentAttempt(); a.ID == "" || a.Session != ah.Session || a.ControllerID != "controller-1" { |
| 969 | t.Fatalf("the reservation: %+v", a) |
| 970 | } |
| 971 | if err := s.Materialize(ctx); err != nil { |
| 972 | t.Fatal(err) |
| 973 | } |
| 974 | if want := []string{"render", "select:held-config", "select:held-release", "release"}; !reflect.DeepEqual(order, want) || launches != 0 { |
| 975 | t.Fatalf("before the launch: %v launches=%d", order, launches) |
| 976 | } |
| 977 | if _, err := s.PrepareLaunch(ctx); err == nil || launches != 1 { |
| 978 | t.Fatalf("the launch after Materialize: %v %d", err, launches) |
| 979 | } |
| 980 | } |
| 981 | |