appconfig, release: the held render and the held release under the claim selection (Plan 3 Task 5, H13)
RenderHeldUnder writes the held config through a work its caller selects from the merged etags; MaterializeHeldUnder stages as adoption does and swaps live/<host> inside the selection under the pinned release's identity. The flag-off paths and their CARLOS_HOST_CLAIM refusals are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+249
−3
internal/appconfig/renderheld.go+28 −1internal/appconfig/renderheld_claim_test.go+99 −0internal/release/materializeheld.go+51 −2internal/release/materializeheld_claim_test.go+71 −0
diff --git a/internal/appconfig/renderheld.go b/internal/appconfig/renderheld.go| index 836f8ce8c..1258805b0 100644 |
| --- a/internal/appconfig/renderheld.go |
| +++ b/internal/appconfig/renderheld.go |
| @@ -52,6 +52,26 @@ func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dat |
| if hostwrite.Enabled(os.Getenv) { |
| return HeldRender{}, ErrHeldClaimWired |
| } |
| + return renderHeld(ctx, s, priv, dataDir, rt, op, box, func(HeldRender) (hostwrite.Work, error) { return legacyHostWork{}, nil }) |
| +} |
| + |
| +// RenderHeldUnder is RenderHeld for a held row under the host claim (moves |
| +// Phase 1 Plan 3 Task 5, ruling H13): the same ownership check, merge, |
| +// renderer, writer and move-bound ack, with the file written through the |
| +// claim selection work returns instead of legacyHostWork. work is called |
| +// once the merged versions are known, with BundleETags and OverlayETag set |
| +// (the rest of the HeldRender empty), so its caller can select a held-config |
| +// work bound to exactly the etags this render writes; nothing is written |
| +// before it returns. A nil work, or a work that is nil, is refused. It does |
| +// not consult CARLOS_HOST_CLAIM: the selection is the authority. |
| +func RenderHeldUnder(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error)) (HeldRender, error) { |
| + if work == nil { |
| + return HeldRender{}, hostwrite.ErrUnavailable |
| + } |
| + return renderHeld(ctx, s, priv, dataDir, rt, op, box, work) |
| +} |
| + |
| +func renderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string, work func(HeldRender) (hostwrite.Work, error)) (HeldRender, error) { |
| if err := heldRenderable(rt, op, box); err != nil { |
| return HeldRender{}, err |
| } |
| @@ -60,7 +80,14 @@ func RenderHeld(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, dat |
| if err != nil { |
| return HeldRender{}, err |
| } |
| - r, err := renderExecConfig(ctx, legacyHostWork{}, rt, m, priv, dataDir, "", v.Overlay) |
| + w, err := work(HeldRender{BundleETags: map[string]string{"default": v.Default, "environment": v.Environment}, OverlayETag: v.Overlay}) |
| + if err != nil { |
| + return HeldRender{}, err |
| + } |
| + if w == nil { |
| + return HeldRender{}, hostwrite.ErrUnavailable |
| + } |
| + r, err := renderExecConfig(ctx, w, rt, m, priv, dataDir, "", v.Overlay) |
| if err != nil { |
| return HeldRender{}, err |
| } |
diff --git a/internal/appconfig/renderheld_claim_test.go b/internal/appconfig/renderheld_claim_test.go| new file mode 100644 |
| index 000000000..002944b63 |
| --- /dev/null |
| +++ b/internal/appconfig/renderheld_claim_test.go |
| @@ -0,0 +1,99 @@ |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +// recordingWork is a claim selection stand-in: it records each identity it |
| +// is asked to write under and runs the effect only while open. |
| +type recordingWork struct { |
| + ids []hostwrite.Identity |
| + shut bool |
| +} |
| + |
| +func (w *recordingWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| + w.ids = append(w.ids, id) |
| + if w.shut { |
| + return hostwrite.ErrUnavailable |
| + } |
| + return fn() |
| +} |
| + |
| +// TestRenderHeldUnderWritesThroughTheClaimSelection (moves Plan 3 Task 5, |
| +// H13): with CARLOS_HOST_CLAIM on, the held render writes the config file |
| +// through the work its caller selects, after the merged versions are known |
| +// (the work is chosen from them), and acks the move as RenderHeld does. |
| +func TestRenderHeldUnderWritesThroughTheClaimSelection(t *testing.T) { |
| + ctx, s, _, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + def, env, overlay := heldFixture(t, ctx, s) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + rt := landingRow() |
| + work := &recordingWork{} |
| + var seen HeldRender |
| + hr, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, func(pre HeldRender) (hostwrite.Work, error) { |
| + seen = pre |
| + return work, nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if seen.BundleETags["default"] != def || seen.BundleETags["environment"] != env || seen.OverlayETag != overlay { |
| + t.Fatalf("the work was chosen from %+v", seen) |
| + } |
| + if hr.BundleETags["default"] != def || hr.OverlayETag != overlay || hr.SHA256 == "" || hr.Path != ConfigEnvPath(dd, rt) { |
| + t.Fatalf("render: %+v", hr) |
| + } |
| + if len(work.ids) == 0 { |
| + t.Fatal("the file was not written through the selection") |
| + } |
| + if _, err := os.Stat(hr.Path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a := readAck(t, ctx, s); a.MoveOp != heldOp || a.FileSHA256 != hr.SHA256 || a.Box != heldBox { |
| + t.Fatalf("ack: %+v", a) |
| + } |
| +} |
| + |
| +// A refused selection writes nothing, and a missing work is refused before |
| +// any write; the ownership checks are RenderHeld's. |
| +func TestRenderHeldUnderRefusals(t *testing.T) { |
| + ctx, s, _, dd, privB64, _ := execKit(t) |
| + priv, err := ParsePrivateKey(privB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + heldFixture(t, ctx, s) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + rt := landingRow() |
| + shut := &recordingWork{shut: true} |
| + if _, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, func(HeldRender) (hostwrite.Work, error) { return shut, nil }); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("a refused selection: %v", err) |
| + } |
| + selectErr := errors.New("no selection") |
| + if _, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, func(HeldRender) (hostwrite.Work, error) { return nil, selectErr }); !errors.Is(err, selectErr) { |
| + t.Fatalf("a failed selection: %v", err) |
| + } |
| + if _, err := RenderHeldUnder(ctx, s, priv, dd, rt, heldOp, heldBox, nil); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("no selection: %v", err) |
| + } |
| + ready := rt |
| + ready.Status = "ready" |
| + if _, err := RenderHeldUnder(ctx, s, priv, dd, ready, heldOp, heldBox, func(HeldRender) (hostwrite.Work, error) { return &recordingWork{}, nil }); !errors.Is(err, ErrNotHeld) { |
| + t.Fatalf("a row SyncExec owns: %v", err) |
| + } |
| + if _, err := os.Stat(ConfigEnvPath(dd, rt)); !os.IsNotExist(err) { |
| + t.Fatalf("a file was written: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, ConfigAckKey(heldBox, heldHost)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an ack was written: %v", err) |
| + } |
| +} |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| index c36d618a0..e5f4a9ee7 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -136,6 +136,40 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro |
| if err := heldStageable(rt, version); err != nil { |
| return nil, err |
| } |
| + return materializeHeld(ctx, s, optDir, rt, version, proof, guard) |
| +} |
| + |
| +// MaterializeHeldUnder is MaterializeHeld for a held row under the host |
| +// claim (moves Phase 1 Plan 3 Task 5, ruling H13). As adoption does under |
| +// the claim, the version is staged outside the selection (the version store |
| +// is shared, and a download must not hold the hostguard flock), and the |
| +// host-specific publication, the bind, the live/<host> swap and its |
| +// rollback, runs inside work under the identity naming exactly version (the |
| +// held-release action's identity). guard, when not nil, runs inside work |
| +// around the publication (the caller's held-stage lock). D3's admit and bind |
| +// are MaterializeHeld's. A nil work is refused before anything is staged; it |
| +// does not consult CARLOS_HOST_CLAIM, since the selection is the authority. |
| +// A stage this process cannot write is ErrStageBlocked: there is no root |
| +// relay under the claim. |
| +func MaterializeHeldUnder(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, work hostwrite.Work, guard func(publish func() error) error) (*Manifest, error) { |
| + if err := heldShape(rt, version); err != nil { |
| + return nil, err |
| + } |
| + if work == nil { |
| + return nil, errHeldNoGuard |
| + } |
| + id := hostwrite.Identity{Release: version} |
| + return materializeHeld(ctx, s, optDir, rt, version, proof, func(publish func() error) error { |
| + return work.With(ctx, id, func() error { |
| + if guard == nil { |
| + return publish() |
| + } |
| + return guard(publish) |
| + }) |
| + }) |
| +} |
| + |
| +func materializeHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error) (*Manifest, error) { |
| if guard == nil { |
| return nil, errHeldNoGuard |
| } |
| @@ -147,7 +181,7 @@ func MaterializeHeld(ctx context.Context, s Store, optDir string, rt registry.Ro |
| if err != nil { |
| return nil, err |
| } |
| - m, err := StageHeld(ctx, s, optDir, rt, version) |
| + m, err := stageHeld(ctx, s, optDir, rt, version) |
| if err != nil { |
| return nil, err |
| } |
| @@ -165,6 +199,13 @@ func StageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, v |
| if err := heldStageable(rt, version); err != nil { |
| return nil, err |
| } |
| + return stageHeld(ctx, s, optDir, rt, version) |
| +} |
| + |
| +func stageHeld(ctx context.Context, s Store, optDir string, rt registry.Route, version string) (*Manifest, error) { |
| + if err := heldShape(rt, version); err != nil { |
| + return nil, err |
| + } |
| m, err := materializeHeldStage(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host) |
| if err != nil { |
| if StageBlocked(err) { |
| @@ -218,7 +259,9 @@ func PublishHeld(ctx context.Context, s Store, optDir string, rt registry.Route, |
| // MoveOp) in the held namespace (HeldRefusalBase), never in adoption's; a |
| // publication that succeeds retires its move's earlier refusal. |
| func publishHeld(optDir string, rt registry.Route, version string, proof *ProofGate, ptr *ChannelPointer, v proofVerdict, guard func(publish func() error) error) error { |
| - if err := heldStageable(rt, version); err != nil { |
| + // The CARLOS_HOST_CLAIM refusal is the callers' (heldStageable): the |
| + // claim path reaches here only inside its selection. |
| + if err := heldShape(rt, version); err != nil { |
| return err |
| } |
| if guard == nil { |
| @@ -384,6 +427,12 @@ func heldStageable(rt registry.Route, version string) error { |
| if hostwrite.Enabled(os.Getenv) { |
| return ErrHeldClaimWired |
| } |
| + return heldShape(rt, version) |
| +} |
| + |
| +// heldShape is heldStageable without the CARLOS_HOST_CLAIM refusal, for the |
| +// claim path, which writes only inside its selection. |
| +func heldShape(rt registry.Route, version string) error { |
| if !controlname.ValidHostShape(rt.Host) || !ValidApp(rt.App) || !ValidVersion(version) { |
| return fmt.Errorf("release: held stage of %q/%q at %q: malformed", rt.Host, rt.App, version) |
| } |
diff --git a/internal/release/materializeheld_claim_test.go b/internal/release/materializeheld_claim_test.go| new file mode 100644 |
| index 000000000..cf9dc5349 |
| --- /dev/null |
| +++ b/internal/release/materializeheld_claim_test.go |
| @@ -0,0 +1,71 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +type heldClaimWork struct { |
| + ids []hostwrite.Identity |
| + shut bool |
| +} |
| + |
| +func (w *heldClaimWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| + w.ids = append(w.ids, id) |
| + if w.shut { |
| + return hostwrite.ErrUnavailable |
| + } |
| + return fn() |
| +} |
| + |
| +// TestMaterializeHeldUnderPublishesThroughTheClaimSelection (moves Plan 3 |
| +// Task 5, H13): with CARLOS_HOST_CLAIM on, the held release is staged as |
| +// adoption stages and live/<host> is pointed at it inside the claim |
| +// selection, under the identity naming exactly the pinned version, with the |
| +// caller's lock inside it. |
| +func TestMaterializeHeldUnderPublishesThroughTheClaimSelection(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + work := &heldClaimWork{} |
| + locked := 0 |
| + m, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), work, func(publish func() error) error { |
| + locked++ |
| + return publish() |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m == nil || m.Version != "v1" { |
| + t.Fatalf("manifest %+v", m) |
| + } |
| + if got, err := LiveVersion(opt, heldTestHost); err != nil || got != "v1" { |
| + t.Fatalf("LiveVersion = %q, %v", got, err) |
| + } |
| + if len(work.ids) != 1 || work.ids[0] != (hostwrite.Identity{Release: "v1"}) || locked != 1 { |
| + t.Fatalf("publication ran under %v with the lock taken %d times", work.ids, locked) |
| + } |
| +} |
| + |
| +// A refused selection never points live/<host>; a missing selection is |
| +// refused before anything is staged. |
| +func TestMaterializeHeldUnderRefusals(t *testing.T) { |
| + ctx, s, opt := heldFixture(t) |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + if _, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), nil, nil); !errors.Is(err, errHeldNoGuard) { |
| + t.Fatalf("no selection: %v", err) |
| + } |
| + if _, err := MaterializeHeldUnder(ctx, s, opt, heldRoute(), "v1", offGate(t), &heldClaimWork{shut: true}, nil); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("a refused selection: %v", err) |
| + } |
| + if got, _ := LiveVersion(opt, heldTestHost); got != "" { |
| + t.Fatalf("live/<host> names %q", got) |
| + } |
| + noOp := heldRoute() |
| + noOp.HoldOp = "" |
| + if _, err := MaterializeHeldUnder(ctx, s, opt, noOp, "v1", offGate(t), &heldClaimWork{}, nil); err == nil { |
| + t.Fatal("a row no move holds was staged") |
| + } |
| +} |