| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "crypto/ed25519" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "testing" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/appconfig" |
| 16 | "github.com/carlosframework/platform/internal/hostwrite" |
| 17 | "github.com/carlosframework/platform/internal/moves" |
| 18 | "github.com/carlosframework/platform/internal/provision" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | // Held writes under the claim (moves after claim Plan 12, Task 7; Paul's T7 |
| 24 | // ruling of 2026-10-12 and platform-1e's answer to Q2.2). |
| 25 | |
| 26 | // heldSelections is the claim's selector as the held hooks see it: it |
| 27 | // records each selection and runs every write it is handed, recording the |
| 28 | // identity the write names (and, with log, the order of the writes). |
| 29 | type heldSelections struct { |
| 30 | selected []string |
| 31 | ids []hostwrite.Identity |
| 32 | log *[]string |
| 33 | refuse error |
| 34 | } |
| 35 | |
| 36 | func (s *heldSelections) Select(_ context.Context, host, action string) (hostwrite.Work, error) { |
| 37 | s.selected = append(s.selected, host+"/"+action) |
| 38 | if s.refuse != nil { |
| 39 | return nil, s.refuse |
| 40 | } |
| 41 | return heldSelectionWork{s}, nil |
| 42 | } |
| 43 | |
| 44 | type heldSelectionWork struct{ s *heldSelections } |
| 45 | |
| 46 | func (w heldSelectionWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| 47 | w.s.ids = append(w.s.ids, id) |
| 48 | if w.s.log != nil { |
| 49 | *w.s.log = append(*w.s.log, "write") |
| 50 | } |
| 51 | return fn() |
| 52 | } |
| 53 | |
| 54 | func claimWrites(sel *heldSelections) hostwrite.Config { |
| 55 | return hostwrite.Config{Enabled: true, Selector: sel} |
| 56 | } |
| 57 | |
| 58 | // TestT7UnderTheClaimStagesWithoutPublishing: under the claim T7 stages the |
| 59 | // pinned release into the shared version store and verifies it, admitted |
| 60 | // and authorized, and passes without publishing live/<host> and without any |
| 61 | // selection (a moving claim grants no held write). A release D3 does not |
| 62 | // admit is T7's not-proven sentence, a build nothing authorizes fails, and |
| 63 | // a version store this process cannot write is MV-N455, with nothing relayed |
| 64 | // to the root stage pass, which has no publication to make under the claim. |
| 65 | func TestT7UnderTheClaimStagesWithoutPublishing(t *testing.T) { |
| 66 | ctx := context.Background() |
| 67 | setup := func(t *testing.T) (*phase1Harness, provision.Record) { |
| 68 | t.Helper() |
| 69 | h := newPhase1Harness(t) |
| 70 | rec := h.start(t, "manual") |
| 71 | shipPinnedV7(t, h.s) |
| 72 | h.pass(t) |
| 73 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 74 | return h, rec |
| 75 | } |
| 76 | |
| 77 | t.Run("staged and verified, not published", func(t *testing.T) { |
| 78 | h, rec := setup(t) |
| 79 | sel := &heldSelections{} |
| 80 | data, opt := t.TempDir(), t.TempDir() |
| 81 | relayed := false |
| 82 | ready, _ := heldReleaseHooks(claimWrites(sel), testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), |
| 83 | func(string, string, error) { relayed = true }) |
| 84 | for i := 0; i < 2; i++ { |
| 85 | if c := ready(ctx, h.s, rec, "v7"); !c.OK { |
| 86 | t.Fatalf("T7 under the claim, pass %d = %+v", i+1, c) |
| 87 | } |
| 88 | } |
| 89 | if _, err := os.Stat(release.VersionPath(opt, "bab", "v7", "app")); err != nil { |
| 90 | t.Fatalf("v7 was not staged: %v", err) |
| 91 | } |
| 92 | if _, err := os.Lstat(release.LiveLink(opt, p1Host)); !os.IsNotExist(err) { |
| 93 | t.Fatalf("T7 published live/<host> under the claim: %v", err) |
| 94 | } |
| 95 | if len(sel.selected) != 0 || relayed { |
| 96 | t.Fatalf("T7 selected %v (relayed %v); a moving claim grants no held write", sel.selected, relayed) |
| 97 | } |
| 98 | // Through the target's report: T7 passes. |
| 99 | tc := h.targetCfg() |
| 100 | tc.DataDir = data |
| 101 | tc.ReleaseReady = ready |
| 102 | h.targetPass(t, tc) |
| 103 | if f := failing(h.targetReport(t)); f[moves.CheckRelease] != "" { |
| 104 | t.Fatalf("T7 in the report = %q", f[moves.CheckRelease]) |
| 105 | } |
| 106 | }) |
| 107 | |
| 108 | t.Run("not proven", func(t *testing.T) { |
| 109 | h, rec := setup(t) |
| 110 | box := newHeldProofBox(t) |
| 111 | box.require(t, "app") |
| 112 | opt := t.TempDir() |
| 113 | ready, _ := heldReleaseHooks(claimWrites(&heldSelections{}), testRegistry(t), opt, t.TempDir(), "uk-box", hsPub, hsRatchet(t), box.gate(), nil) |
| 114 | if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7NotProven { |
| 115 | t.Fatalf("T7 = %+v, want %q", c, t7NotProven) |
| 116 | } |
| 117 | if _, err := os.Stat(filepath.Join(opt, "versions")); !os.IsNotExist(err) { |
| 118 | t.Fatalf("an unproven release was staged: %v", err) |
| 119 | } |
| 120 | }) |
| 121 | |
| 122 | t.Run("not authorized", func(t *testing.T) { |
| 123 | h, rec := setup(t) |
| 124 | opt := t.TempDir() |
| 125 | ready, _ := heldReleaseHooks(claimWrites(&heldSelections{}), testRegistry(t), opt, t.TempDir(), "uk-box", nil, hsRatchet(t), offProofGate(t), nil) |
| 126 | if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Failed { |
| 127 | t.Fatalf("T7 with no release key = %+v, want %q", c, t7Failed) |
| 128 | } |
| 129 | if _, err := os.Stat(filepath.Join(opt, "versions")); !os.IsNotExist(err) { |
| 130 | t.Fatalf("an unauthorized build was staged: %v", err) |
| 131 | } |
| 132 | }) |
| 133 | |
| 134 | t.Run("blocked", func(t *testing.T) { |
| 135 | h, rec := setup(t) |
| 136 | prev := heldStageVerified |
| 137 | t.Cleanup(func() { heldStageVerified = prev }) |
| 138 | heldStageVerified = func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate) (*release.Manifest, error) { |
| 139 | return nil, fmt.Errorf("%w: read-only", release.ErrStageBlocked) |
| 140 | } |
| 141 | data := t.TempDir() |
| 142 | relayed := false |
| 143 | ready, _ := heldReleaseHooks(claimWrites(&heldSelections{}), testRegistry(t), t.TempDir(), data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), |
| 144 | func(string, string, error) { relayed = true }) |
| 145 | if c := ready(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7NotStaged { |
| 146 | t.Fatalf("T7 on a read-only version store = %+v, want %q", c, t7NotStaged) |
| 147 | } |
| 148 | if _, err := os.Stat(filepath.Join(release.HeldStageDir(data), p1Host+".json")); !os.IsNotExist(err) || relayed { |
| 149 | t.Fatalf("a held stage was relayed to the root pass under the claim (%v, %v)", err, relayed) |
| 150 | } |
| 151 | }) |
| 152 | } |
| 153 | |
| 154 | // TestClaimOffT7IsUnchanged: with the claim off, T7 stages and publishes |
| 155 | // live/<host> exactly as before (heldReleaseReady), relays a blocked stage |
| 156 | // to the root stage pass, and the configured step's release is that same |
| 157 | // check. |
| 158 | func TestClaimOffT7IsUnchanged(t *testing.T) { |
| 159 | ctx := context.Background() |
| 160 | h := newPhase1Harness(t) |
| 161 | rec := h.start(t, "manual") |
| 162 | shipPinnedV7(t, h.s) |
| 163 | h.pass(t) |
| 164 | |
| 165 | data, opt := t.TempDir(), t.TempDir() |
| 166 | ready, live := heldReleaseHooks(hostwrite.Config{}, testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil) |
| 167 | if c := ready(ctx, h.s, rec, "v7"); !c.OK { |
| 168 | t.Fatalf("T7 = %+v", c) |
| 169 | } |
| 170 | if v, _ := release.LiveVersion(opt, p1Host); v != "v7" { |
| 171 | t.Fatalf("live = %q, want v7: T7 publishes with the claim off", v) |
| 172 | } |
| 173 | if c := live(ctx, h.s, rec, "v7"); !c.OK { |
| 174 | t.Fatalf("the configured step's release = %+v", c) |
| 175 | } |
| 176 | |
| 177 | prev := heldMaterialize |
| 178 | t.Cleanup(func() { heldMaterialize = prev }) |
| 179 | heldMaterialize = func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate, func(func() error) error) (*release.Manifest, error) { |
| 180 | return nil, fmt.Errorf("%w: read-only", release.ErrStageBlocked) |
| 181 | } |
| 182 | data2 := t.TempDir() |
| 183 | relayed := false |
| 184 | ready2, _ := heldReleaseHooks(hostwrite.Config{}, testRegistry(t), t.TempDir(), data2, "uk-box", hsPub, hsRatchet(t), offProofGate(t), |
| 185 | func(string, string, error) { relayed = true }) |
| 186 | if c := ready2(ctx, h.s, rec, "v7"); c.OK || c.Reason != t7Waiting { |
| 187 | t.Fatalf("T7 on a read-only version store = %+v, want %q", c, t7Waiting) |
| 188 | } |
| 189 | if _, err := os.Stat(filepath.Join(release.HeldStageDir(data2), p1Host+".json")); err != nil || !relayed { |
| 190 | t.Fatalf("the blocked stage was not relayed to the root pass (%v, %v)", err, relayed) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // heldConfigKit is a real held render's inputs: a store with bab/app's |
| 195 | // default bundle, the secrets key, a data directory and the held row. |
| 196 | type heldConfigKit struct { |
| 197 | s release.Store |
| 198 | priv *ecdh.PrivateKey |
| 199 | dd string |
| 200 | rt registry.Route |
| 201 | pin *provision.MoveInputs |
| 202 | } |
| 203 | |
| 204 | func newHeldConfigKit(t *testing.T) heldConfigKit { |
| 205 | t.Helper() |
| 206 | ctx := context.Background() |
| 207 | s := release.NewDirStore(t.TempDir()) |
| 208 | privB64, _, err := appconfig.GenerateKey() |
| 209 | if err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | priv, err := appconfig.ParsePrivateKey(privB64, "test") |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if err := appconfig.SetEnv(ctx, s, "bab", "app", map[string]string{"GREETING": "hello"}); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | _, def, err := s.Get(ctx, appconfig.Key("bab", "app")) |
| 220 | if err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | return heldConfigKit{s: s, priv: priv, dd: t.TempDir(), |
| 224 | rt: registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "landing", HoldOp: p1ID, Socket: provision.DefaultSocket(p1Host)}, |
| 225 | pin: &provision.MoveInputs{BundleETags: map[string]string{"default": def, "environment": ""}, Release: "v7"}} |
| 226 | } |
| 227 | |
| 228 | // TestConfiguredRendersUnderTheHeldConfigSelection: under the claim the |
| 229 | // landing's held render is selected as held-config for the host, under the |
| 230 | // identity of the pinned configuration, and keeps carry (c): the journal |
| 231 | // learns the sha256 of the bytes before the selection writes them. A render |
| 232 | // whose merged inputs are not the pins selects and writes nothing and is the |
| 233 | // landing's "configuration changed" refusal, never the policy's. With the |
| 234 | // claim off the render is RenderHeldBefore's, unchanged. |
| 235 | func TestConfiguredRendersUnderTheHeldConfigSelection(t *testing.T) { |
| 236 | ctx := context.Background() |
| 237 | |
| 238 | t.Run("the render", func(t *testing.T) { |
| 239 | k := newHeldConfigKit(t) |
| 240 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 241 | var log []string |
| 242 | sel := &heldSelections{log: &log} |
| 243 | render := heldRenderer(claimWrites(sel), k.priv, k.dd, "uk-box") |
| 244 | path := appconfig.ConfigEnvPath(k.dd, k.rt) |
| 245 | var told string |
| 246 | hr, err := render(ctx, k.s, k.rt, p1ID, k.pin, func(sha string) error { |
| 247 | log = append(log, "journal") |
| 248 | if _, err := os.Stat(path); !os.IsNotExist(err) { |
| 249 | t.Errorf("the file was there before the journal: %v", err) |
| 250 | } |
| 251 | told = sha |
| 252 | return nil |
| 253 | }) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if len(sel.selected) != 1 || sel.selected[0] != p1Host+"/"+movesActionHeldConfig { |
| 258 | t.Fatalf("selected %v, want one held-config selection for the host", sel.selected) |
| 259 | } |
| 260 | want := HeldConfigIdentity(k.pin.BundleETags, k.pin.OverlayETag) |
| 261 | if len(sel.ids) == 0 { |
| 262 | t.Fatal("nothing was written through the selection") |
| 263 | } |
| 264 | for _, id := range sel.ids { |
| 265 | if id != want { |
| 266 | t.Fatalf("a write named %+v, want the pinned configuration %+v", id, want) |
| 267 | } |
| 268 | } |
| 269 | if len(log) < 2 || log[0] != "journal" || log[1] != "write" { |
| 270 | t.Fatalf("order %v, want the journal before the writes", log) |
| 271 | } |
| 272 | body, err := os.ReadFile(path) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | sum := sha256.Sum256(body) |
| 277 | if got := hex.EncodeToString(sum[:]); told != got || hr.SHA256 != got { |
| 278 | t.Fatalf("journal %q, render %q, file %q", told, hr.SHA256, got) |
| 279 | } |
| 280 | if !sameBundle(hr.BundleETags, k.pin.BundleETags) { |
| 281 | t.Fatalf("rendered %v, pinned %v", hr.BundleETags, k.pin.BundleETags) |
| 282 | } |
| 283 | }) |
| 284 | |
| 285 | t.Run("a changed configuration selects nothing", func(t *testing.T) { |
| 286 | k := newHeldConfigKit(t) |
| 287 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 288 | if err := appconfig.SetEnv(ctx, k.s, "bab", "app", map[string]string{"GREETING": "changed"}); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | sel := &heldSelections{} |
| 292 | journalled := false |
| 293 | _, err := heldRenderer(claimWrites(sel), k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { journalled = true; return nil }) |
| 294 | if !errors.Is(err, errHeldRenderUnpinned) { |
| 295 | t.Fatalf("a changed configuration: %v, want errHeldRenderUnpinned", err) |
| 296 | } |
| 297 | if len(sel.selected) != 0 || journalled { |
| 298 | t.Fatalf("selected %v, journalled %v", sel.selected, journalled) |
| 299 | } |
| 300 | if _, err := os.Stat(appconfig.ConfigEnvPath(k.dd, k.rt)); !os.IsNotExist(err) { |
| 301 | t.Fatalf("a file was written: %v", err) |
| 302 | } |
| 303 | }) |
| 304 | |
| 305 | t.Run("a refused selection writes nothing", func(t *testing.T) { |
| 306 | k := newHeldConfigKit(t) |
| 307 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 308 | sel := &heldSelections{refuse: hostwrite.ErrUnavailable} |
| 309 | if _, err := heldRenderer(claimWrites(sel), k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { return nil }); !errors.Is(err, hostwrite.ErrUnavailable) { |
| 310 | t.Fatalf("a refused selection: %v", err) |
| 311 | } |
| 312 | if _, err := os.Stat(appconfig.ConfigEnvPath(k.dd, k.rt)); !os.IsNotExist(err) { |
| 313 | t.Fatalf("a file was written: %v", err) |
| 314 | } |
| 315 | }) |
| 316 | |
| 317 | t.Run("claim off", func(t *testing.T) { |
| 318 | k := newHeldConfigKit(t) |
| 319 | hr, err := heldRenderer(hostwrite.Config{}, k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { return nil }) |
| 320 | if err != nil || hr.SHA256 == "" { |
| 321 | t.Fatalf("the claim-off render: %+v, %v", hr, err) |
| 322 | } |
| 323 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 324 | if _, err := heldRenderer(hostwrite.Config{}, k.priv, k.dd, "uk-box")(ctx, k.s, k.rt, p1ID, k.pin, func(string) error { return nil }); !errors.Is(err, appconfig.ErrHeldClaimWired) { |
| 325 | t.Fatalf("the legacy render under the flag: %v, want ErrHeldClaimWired as before", err) |
| 326 | } |
| 327 | }) |
| 328 | |
| 329 | t.Run("configured passes the pins and refuses a changed configuration", func(t *testing.T) { |
| 330 | h := newLandingHarness(t) |
| 331 | h.flip(t) |
| 332 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 333 | withMovesWakeSource(t, true) |
| 334 | var pinned []*provision.MoveInputs |
| 335 | h.renderPin = func(pin *provision.MoveInputs) { pinned = append(pinned, pin) } |
| 336 | h.renderHook = func(func(string) error) (appconfig.HeldRender, error) { |
| 337 | return appconfig.HeldRender{}, errHeldRenderUnpinned |
| 338 | } |
| 339 | h.land(t) |
| 340 | m := h.read(t, p1Host).Move |
| 341 | if m.Reason != provision.RollbackConfigChanged || m.LandingPhase != provision.LandConfigured { |
| 342 | t.Fatalf("after an unpinned render under the claim: %+v", m) |
| 343 | } |
| 344 | if len(pinned) == 0 || pinned[0] == nil || !sameBundle(pinned[0].BundleETags, m.Inputs.BundleETags) || pinned[0].OverlayETag != m.Inputs.OverlayETag { |
| 345 | t.Fatalf("configured rendered with pins %+v, want the record's %+v", pinned, m.Inputs) |
| 346 | } |
| 347 | }) |
| 348 | } |
| 349 | |
| 350 | // TestConfiguredPublishesTheReleaseUnderHeldRelease: under the claim T7 |
| 351 | // leaves live/<host> alone and the configured step publishes it, selected as |
| 352 | // held-release for the host under the identity naming exactly the pinned |
| 353 | // version, then reads it back as T7 used to (VerifyHeldLive). A replay finds |
| 354 | // it live and selects nothing; a refused selection publishes nothing. The |
| 355 | // landing calls this hook at configured, never T7's. |
| 356 | func TestConfiguredPublishesTheReleaseUnderHeldRelease(t *testing.T) { |
| 357 | ctx := context.Background() |
| 358 | |
| 359 | t.Run("the publication", func(t *testing.T) { |
| 360 | h := newPhase1Harness(t) |
| 361 | rec := h.start(t, "manual") |
| 362 | shipPinnedV7(t, h.s) |
| 363 | h.pass(t) |
| 364 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 365 | sel := &heldSelections{} |
| 366 | data, opt := t.TempDir(), t.TempDir() |
| 367 | ready, live := heldReleaseHooks(claimWrites(sel), testRegistry(t), opt, data, "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil) |
| 368 | if c := ready(ctx, h.s, rec, "v7"); !c.OK { |
| 369 | t.Fatalf("T7 = %+v", c) |
| 370 | } |
| 371 | if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| 372 | t.Fatalf("live = %q after T7 under the claim", v) |
| 373 | } |
| 374 | if c := live(ctx, h.s, rec, "v7"); !c.OK { |
| 375 | t.Fatalf("configured's release = %+v", c) |
| 376 | } |
| 377 | if v, _ := release.LiveVersion(opt, p1Host); v != "v7" { |
| 378 | t.Fatalf("live = %q, want v7", v) |
| 379 | } |
| 380 | if len(sel.selected) != 1 || sel.selected[0] != p1Host+"/"+movesActionHeldRelease { |
| 381 | t.Fatalf("selected %v, want one held-release selection for the host", sel.selected) |
| 382 | } |
| 383 | if len(sel.ids) != 1 || sel.ids[0] != (hostwrite.Identity{Release: "v7"}) { |
| 384 | t.Fatalf("published under %v, want the pinned release alone", sel.ids) |
| 385 | } |
| 386 | if c := live(ctx, h.s, rec, "v7"); !c.OK || len(sel.selected) != 1 { |
| 387 | t.Fatalf("a replay: %+v, selections %v", c, sel.selected) |
| 388 | } |
| 389 | }) |
| 390 | |
| 391 | t.Run("a refused selection publishes nothing", func(t *testing.T) { |
| 392 | h := newPhase1Harness(t) |
| 393 | rec := h.start(t, "manual") |
| 394 | shipPinnedV7(t, h.s) |
| 395 | h.pass(t) |
| 396 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 397 | sel := &heldSelections{refuse: hostwrite.ErrUnavailable} |
| 398 | opt := t.TempDir() |
| 399 | _, live := heldReleaseHooks(claimWrites(sel), testRegistry(t), opt, t.TempDir(), "uk-box", hsPub, hsRatchet(t), offProofGate(t), nil) |
| 400 | if c := live(ctx, h.s, rec, "v7"); c.OK { |
| 401 | t.Fatalf("a refused selection published: %+v", c) |
| 402 | } |
| 403 | if v, _ := release.LiveVersion(opt, p1Host); v != "" { |
| 404 | t.Fatalf("live = %q", v) |
| 405 | } |
| 406 | }) |
| 407 | |
| 408 | t.Run("configured calls the publication", func(t *testing.T) { |
| 409 | h := newLandingHarness(t) |
| 410 | h.flip(t) |
| 411 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 412 | withMovesWakeSource(t, true) |
| 413 | var published []string |
| 414 | h.releaseLive = func(_ context.Context, _ release.Store, _ provision.Record, v string) moves.Check { |
| 415 | published = append(published, v) |
| 416 | return moves.Check{OK: true} |
| 417 | } |
| 418 | h.release = moves.Check{Reason: "T7's hook was asked at configured"} |
| 419 | h.land(t) |
| 420 | h.installed(t) |
| 421 | if pin := h.read(t, p1Host).Move.Inputs; len(published) == 0 || published[0] != pin.Release { |
| 422 | t.Fatalf("configured published %v, want the pinned %q", published, pin.Release) |
| 423 | } |
| 424 | }) |
| 425 | |
| 426 | t.Run("under the claim configured never falls back to T7", func(t *testing.T) { |
| 427 | h := newLandingHarness(t) |
| 428 | h.flip(t) |
| 429 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 430 | withMovesWakeSource(t, true) |
| 431 | h.land(t) |
| 432 | if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandConfigured || m.Reason != "" { |
| 433 | t.Fatalf("with no publication hook under the claim: %+v", m) |
| 434 | } |
| 435 | if _, err := os.Stat(provision.DefaultDBPath(h.tdir, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| 436 | t.Fatalf("the database was installed with nothing published: %v", err) |
| 437 | } |
| 438 | }) |
| 439 | } |
| 440 | |
| 441 | // TestCheckingRefusesUntilTheWakeSourceRegisters (Q2.3): under the claim |
| 442 | // the landing keeps refusing at checking with RollbackClaimWired while the |
| 443 | // wake-source gate is shut, and lands once it opens. With the claim off the |
| 444 | // gate does not matter. |
| 445 | func TestCheckingRefusesUntilTheWakeSourceRegisters(t *testing.T) { |
| 446 | t.Run("shut", func(t *testing.T) { |
| 447 | h := newLandingHarness(t) |
| 448 | h.flip(t) |
| 449 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 450 | withMovesWakeSource(t, false) |
| 451 | h.land(t) |
| 452 | if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackClaimWired || m.LandingPhase != provision.LandChecking { |
| 453 | t.Fatalf("under the claim with the gate shut: %+v", m) |
| 454 | } |
| 455 | }) |
| 456 | t.Run("open", func(t *testing.T) { |
| 457 | h := newLandingHarness(t) |
| 458 | h.flip(t) |
| 459 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 460 | withMovesWakeSource(t, true) |
| 461 | h.releaseLive = func(context.Context, release.Store, provision.Record, string) moves.Check { |
| 462 | return moves.Check{OK: true} |
| 463 | } |
| 464 | h.land(t) |
| 465 | if m := h.read(t, p1Host).Move; m.Reason != "" { |
| 466 | t.Fatalf("under the claim with the gate open: %+v", m) |
| 467 | } |
| 468 | h.installed(t) |
| 469 | }) |
| 470 | t.Run("claim off", func(t *testing.T) { |
| 471 | h := newLandingHarness(t) |
| 472 | h.flip(t) |
| 473 | withMovesWakeSource(t, false) |
| 474 | h.land(t) |
| 475 | if m := h.read(t, p1Host).Move; m.Reason != "" { |
| 476 | t.Fatalf("with the claim off: %+v", m) |
| 477 | } |
| 478 | h.installed(t) |
| 479 | }) |
| 480 | } |
| 481 | |