host: root's held release under the claim, over the root control socket (moves Task 7b, platform-1e reviews the publish path)
POST /claim/held-release on the claim-mode instance-control socket, the peer authenticated as the edge unit's main process (R10's transport; no request file, #267). Wired only with CARLOS_MOVES_PHASE1 on. stage: root re-reads the claim (holder tuple, op, this box the target), the record, the registry row and the source's report, then release.StageHeldVerified. No selection, no hostguard, live/<host> never touched. publish: a receipt for exactly this request (session, render hash, identity) answers a replay with no read and no effect. Otherwise root re-reads the claim, record and row; the version must be the pin (landing configured or probing) or the return's release (dirty configured, probe) or the revert's; the render hash journalled under the hold op must be present (else a typed not-yet at once, holding nothing) and equal the request's, read through safefile (no symlink anywhere, regular file, one link, bounded). Root then selects held-release itself, requires the selection's tuple to be the request's, and runs MaterializeHeldUnder inside it with the held-stage lock. Moving publishes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+1934
−7
approved_moves_copy_test.go+5 −0internal/host/claimlaunch_production.go+14 −3internal/host/claimstop_run.go+5 −0internal/host/movesheldrelease.go+362 −0internal/host/movesheldrelease_linux.go+59 −0internal/host/movesheldrelease_linux_test.go+204 −0internal/host/movesheldrelease_other.go+14 −0internal/host/movesheldrelease_production.go+80 −0internal/host/movesheldrelease_root.go+503 −0internal/host/movesheldrelease_test.go+681 −0internal/release/materializeheld.go+7 −4
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index d0413a447..6d98e4f3d 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -1237,6 +1237,11 @@ var movesScanFiles = []string{ |
| "internal/host/movesrevert.go", |
| "internal/host/movesrollback.go", |
| "internal/host/movesstage.go", |
| + // moves after claim Plan 12, Task 7b: root's held release. |
| + "internal/host/movesheldrelease.go", |
| + "internal/host/movesheldrelease_linux.go", |
| + "internal/host/movesheldrelease_production.go", |
| + "internal/host/movesheldrelease_root.go", |
| "internal/host/movesstore.go", |
| "internal/host/refusalwatch.go", |
| "internal/host/systemapply.go", |
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index f122c3dc6..cc2edf67d 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -14,13 +14,24 @@ import ( |
| ) |
| // ClaimRootWithLaunch is the claim-mode root handler with the isolated |
| -// launch route beside the existing claim verbs. |
| +// launch route beside the existing claim verbs, and the moves held-release |
| +// route (Task 7b), which is nil, and refuses, unless CARLOS_MOVES_PHASE1 is |
| +// on (WithProductionMovesHeldRelease). |
| type ClaimRootWithLaunch struct { |
| - Root ClaimRootHTTP |
| - Launch *ClaimLaunchRoot |
| + Root ClaimRootHTTP |
| + Launch *ClaimLaunchRoot |
| + HeldRelease http.Handler |
| } |
| func (h ClaimRootWithLaunch) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path == heldReleasePath { |
| + if h.HeldRelease == nil { |
| + http.Error(w, "refused", http.StatusServiceUnavailable) |
| + return |
| + } |
| + h.HeldRelease.ServeHTTP(w, r) |
| + return |
| + } |
| if r.URL.Path != "/claim/launch" { |
| h.Root.ServeHTTP(w, r) |
| return |
diff --git a/internal/host/claimstop_run.go b/internal/host/claimstop_run.go| index 9c8efa9b0..97bfdf93a 100644 |
| --- a/internal/host/claimstop_run.go |
| +++ b/internal/host/claimstop_run.go |
| @@ -52,6 +52,11 @@ func RunInstanceControl(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // moves Task 7b: root's held release, only with CARLOS_MOVES_PHASE1 on. |
| + handler, err = WithProductionMovesHeldRelease(startup, handler, reg, claimEntryEnv(ctx)) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| recovery, ok := handler.(interface{ RecoverPending(context.Context) error }) |
| if !ok { |
| log.Fatal(ErrClaimStopUnavailable) |
diff --git a/internal/host/movesheldrelease.go b/internal/host/movesheldrelease.go| new file mode 100644 |
| index 000000000..779101ba6 |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease.go |
| @@ -0,0 +1,362 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "regexp" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/regionname" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The held release under the host claim (moves after claim Plan 12, Task |
| +// 7b; task-7b-design.md with platform-1e's binding review of 2026-10-12). |
| +// |
| +// Under the claim the sandboxed agent cannot write /opt/carlos, so it can |
| +// neither stage T7's release nor publish live/<host> for the held row. It |
| +// asks root instead, over the root instance-control socket (claim Plan 11's |
| +// R10 transport: POST /claim/held-release, the peer authenticated as the |
| +// edge unit's main process), never through a file root would read by name |
| +// (#267). There are two actions: |
| +// |
| +// - stage: T7. Root stages and verifies the pinned release into the |
| +// shared version store (release.StageHeldVerified) and never touches |
| +// live/<host>. The agent then verifies the staged bytes itself; root's |
| +// answer is a hint. |
| +// - publish: the landing's configured step, a dirty return's configured |
| +// step and a probe's release. Root publishes live/<host> inside its own |
| +// held-release selection (release.MaterializeHeldUnder with the |
| +// held-stage lock), and writes a receipt keyed by the session and the |
| +// render hash, so a replay after success is a no-op with the same |
| +// answer. |
| +// |
| +// Every request field is an expectation root compares with authoritative |
| +// state it reads itself (the claim, the record, the registry row, the |
| +// render journal); none grants anything. The route is new, so an older root |
| +// answers 404 and never reads a stage as a publication, and the old file |
| +// relay (release.ServeHeldStageRequests) is never handed a request by this |
| +// path. |
| + |
| +const ( |
| + heldReleaseSchema = "carlos.moves.held-release/1" |
| + heldReleaseStage = "stage" |
| + heldReleasePublish = "publish" |
| + heldReleaseOutcomeStaged = "staged" |
| + heldReleaseOutcomePublished = "published" |
| + heldReleasePath = "/claim/held-release" |
| + // heldReleaseRequestMax bounds a request body: its fields fit many times |
| + // over. |
| + heldReleaseRequestMax = 4096 |
| + // heldReleaseDeadline bounds one call. A stage may download the build, |
| + // as T7 does in the agent with the claim off. |
| + heldReleaseDeadline = 15 * time.Minute |
| +) |
| + |
| +var ( |
| + errHeldReleaseShape = errors.New("moves: held release: the request is malformed") |
| + errHeldReleaseUnavailable = errors.New("moves: held release: root serves no held-release requests") |
| + // errHeldReleaseNotYet is root finding no render hash journalled for |
| + // the publication (platform-1e Q2): it refuses at once, holding nothing, |
| + // and the agent asks again on a later pass. |
| + errHeldReleaseNotYet = errors.New("moves: held release: the held render's hash is not journalled yet") |
| + errHeldReleaseUnsafe = errors.New("moves: held release: the render journal is not a regular file with one link and a bounded size") |
| +) |
| + |
| +// heldReleaseRequest is one request. Kind, Phase, Incarnation, Stamp, |
| +// Session and Generation are the claim tuple the agent observed; MoveID is |
| +// the claim's op id; HoldOp the op its registry row is held by (the |
| +// rollback id for a return); RenderHash, on a publication, the held |
| +// render's journalled hash ("sha256:<hex>"). |
| +type heldReleaseRequest struct { |
| + Schema string `json:"schema"` |
| + RequestID string `json:"request_id"` |
| + Action string `json:"action"` |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Version string `json:"version"` |
| + MoveID string `json:"move_id"` |
| + HoldOp string `json:"hold_op"` |
| + Box string `json:"box"` |
| + Region string `json:"region"` |
| + Incarnation string `json:"incarnation"` |
| + Stamp uint64 `json:"stamp"` |
| + Session uint64 `json:"session"` |
| + Generation string `json:"generation"` |
| + Kind string `json:"kind"` |
| + Phase string `json:"phase"` |
| + RenderHash string `json:"render_hash,omitempty"` |
| +} |
| + |
| +type heldReleaseResponse struct { |
| + Schema string `json:"schema"` |
| + RequestID string `json:"request_id"` |
| + Outcome string `json:"outcome"` |
| + Replayed bool `json:"replayed,omitempty"` |
| +} |
| + |
| +var ( |
| + heldReleaseIDShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) |
| + heldReleaseBoxShape = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) |
| + heldReleaseHexShape = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| + heldReleaseRenderShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) |
| + heldReleaseTokenShape = regexp.MustCompile(`^[A-Za-z0-9._:@-]{0,128}$`) |
| +) |
| + |
| +// validate is the shape root trusts before any read: every name a path |
| +// component or a key could be built from, and the action's own fields. |
| +func (q heldReleaseRequest) validate() error { |
| + bad := func(what string) error { return fmt.Errorf("%w: %s", errHeldReleaseShape, what) } |
| + switch { |
| + case q.Schema != heldReleaseSchema: |
| + return bad("schema") |
| + case q.Action != heldReleaseStage && q.Action != heldReleasePublish: |
| + return bad("action") |
| + case !heldReleaseIDShape.MatchString(q.RequestID): |
| + return bad("request id") |
| + case !controlname.ValidHostShape(q.Host): |
| + return bad("host") |
| + case !release.ValidAccount(q.Account): |
| + return bad("account") |
| + case !release.ValidApp(q.App): |
| + return bad("app") |
| + case !release.ValidVersion(q.Version): |
| + return bad("version") |
| + case !moves.ValidOpID(q.MoveID) || moves.OpIDKind(q.MoveID) != "move": |
| + return bad("move id") |
| + case !moves.ValidOpID(q.HoldOp): |
| + return bad("hold op") |
| + case !heldReleaseBoxShape.MatchString(q.Box): |
| + return bad("box") |
| + case q.Region != regionname.HomeRegion && (q.Region == "" || regionname.PlacementRegion(q.Region) != q.Region): |
| + return bad("region") |
| + case !heldReleaseHexShape.MatchString(q.Incarnation): |
| + return bad("incarnation") |
| + case q.Stamp == 0: |
| + return bad("stamp") |
| + case !heldReleaseTokenShape.MatchString(q.Generation) || len(q.Phase) > 64 || !heldReleaseTokenShape.MatchString(q.Phase): |
| + return bad("generation or phase") |
| + } |
| + switch q.Kind { |
| + case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting: |
| + default: |
| + return bad("kind") |
| + } |
| + if q.Action == heldReleasePublish && !heldReleaseRenderShape.MatchString(q.RenderHash) { |
| + return bad("render hash") |
| + } |
| + if q.Action == heldReleaseStage && q.RenderHash != "" { |
| + return bad("a stage names no render") |
| + } |
| + return nil |
| +} |
| + |
| +// decodeHeldReleaseRequest reads one request body: bounded, one JSON object, |
| +// no field the protocol does not carry. |
| +func decodeHeldReleaseRequest(r io.Reader) (heldReleaseRequest, error) { |
| + body, err := io.ReadAll(io.LimitReader(r, heldReleaseRequestMax+1)) |
| + if err != nil || len(body) > heldReleaseRequestMax { |
| + return heldReleaseRequest{}, fmt.Errorf("%w: unreadable or oversized", errHeldReleaseShape) |
| + } |
| + dec := json.NewDecoder(bytes.NewReader(body)) |
| + dec.DisallowUnknownFields() |
| + var q heldReleaseRequest |
| + if err := dec.Decode(&q); err != nil { |
| + return heldReleaseRequest{}, fmt.Errorf("%w: %v", errHeldReleaseShape, err) |
| + } |
| + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { |
| + return heldReleaseRequest{}, fmt.Errorf("%w: trailing data", errHeldReleaseShape) |
| + } |
| + return q, nil |
| +} |
| + |
| +func newHeldReleaseRequestID() string { |
| + var b [16]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return fmt.Sprintf("t%d", time.Now().UnixNano()) |
| + } |
| + return hex.EncodeToString(b[:]) |
| +} |
| + |
| +// heldReleaseRelay is the agent's way to root. |
| +type heldReleaseRelay interface { |
| + HeldRelease(context.Context, heldReleaseRequest) (heldReleaseResponse, error) |
| +} |
| + |
| +// heldReleaseRootClient is the agent's client of POST /claim/held-release |
| +// on the root instance-control socket. |
| +type heldReleaseRootClient struct { |
| + Socket string |
| + // Dial is the dialer; nil dials Socket as a unix socket. |
| + Dial func(context.Context, string) (net.Conn, error) |
| +} |
| + |
| +func newHeldReleaseRootClient(socket string) *heldReleaseRootClient { |
| + return &heldReleaseRootClient{Socket: socket} |
| +} |
| + |
| +// HeldRelease sends q and returns root's answer for exactly q. Root's |
| +// refusals come back typed: a stale or forged request wraps |
| +// release.ErrStaleHeldStage, a release D3 does not admit |
| +// release.ErrHeldNotProven, no render journal errHeldReleaseNotYet, and a |
| +// root with no such route (an older build, or one without its providers) |
| +// errHeldReleaseUnavailable. |
| +func (c *heldReleaseRootClient) HeldRelease(ctx context.Context, q heldReleaseRequest) (heldReleaseResponse, error) { |
| + if c == nil || c.Socket == "" { |
| + return heldReleaseResponse{}, errHeldReleaseUnavailable |
| + } |
| + body, err := json.Marshal(q) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, heldReleaseDeadline) |
| + defer cancel() |
| + dial := c.Dial |
| + if dial == nil { |
| + dial = func(ctx context.Context, path string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", path) |
| + } |
| + } |
| + transport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx, c.Socket) }} |
| + defer transport.CloseIdleConnections() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://control"+heldReleasePath, bytes.NewReader(body)) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := (&http.Client{Transport: transport}).Do(req) |
| + if err != nil { |
| + return heldReleaseResponse{}, fmt.Errorf("moves: held release: %w", err) |
| + } |
| + defer resp.Body.Close() |
| + defer io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) |
| + switch resp.StatusCode { |
| + case http.StatusOK: |
| + case http.StatusBadRequest: |
| + return heldReleaseResponse{}, errHeldReleaseShape |
| + case http.StatusForbidden: |
| + return heldReleaseResponse{}, ErrClaimStopIdentity |
| + case http.StatusConflict: |
| + return heldReleaseResponse{}, staleHeldStage("root refused the held release") |
| + case http.StatusUnprocessableEntity: |
| + return heldReleaseResponse{}, fmt.Errorf("%w: root's proof gate refused it", release.ErrHeldNotProven) |
| + case http.StatusTooEarly: |
| + return heldReleaseResponse{}, errHeldReleaseNotYet |
| + case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusServiceUnavailable: |
| + return heldReleaseResponse{}, errHeldReleaseUnavailable |
| + default: |
| + return heldReleaseResponse{}, fmt.Errorf("moves: held release: root answered %d", resp.StatusCode) |
| + } |
| + dec := json.NewDecoder(io.LimitReader(resp.Body, heldReleaseRequestMax)) |
| + dec.DisallowUnknownFields() |
| + var out heldReleaseResponse |
| + if err := dec.Decode(&out); err != nil { |
| + return heldReleaseResponse{}, fmt.Errorf("moves: held release: root's answer: %w", err) |
| + } |
| + want := heldReleaseOutcomeStaged |
| + if q.Action == heldReleasePublish { |
| + want = heldReleaseOutcomePublished |
| + } |
| + if out.Schema != heldReleaseSchema || out.RequestID != q.RequestID || out.Outcome != want { |
| + return heldReleaseResponse{}, fmt.Errorf("moves: held release: root's answer is not for this request: %+v", out) |
| + } |
| + return out, nil |
| +} |
| + |
| +// heldReleaseVia is how the agent's held-release hooks reach root: the |
| +// relay (nil: no root path, Task 7's local behaviour), this box's |
| +// placement code, sent as its engine region, and the claim reader (nil: |
| +// hostclaim.Read over the hook's store; a test seam). |
| +type heldReleaseVia struct { |
| + Relay heldReleaseRelay |
| + Region string |
| + Claims func(context.Context, string) (hostclaim.Observed, error) |
| +} |
| + |
| +func (v heldReleaseVia) claim(ctx context.Context, s release.Store, host string) (hostclaim.Observed, error) { |
| + if v.Claims != nil { |
| + return v.Claims(ctx, host) |
| + } |
| + return hostclaim.Read(ctx, s, host) |
| +} |
| + |
| +// request builds the agent's request for action from its own reads: the |
| +// claim tuple as it observes it now, moveID ("" takes the claim's op id) |
| +// and holdOp from its record or route, and on a publication the render hash |
| +// configured journalled under holdOp. None of it is authority: root reads |
| +// each again and compares. |
| +func (v heldReleaseVia) request(ctx context.Context, s release.Store, action, host, account, app, version, moveID, holdOp, box, dataDir string) (heldReleaseRequest, error) { |
| + obs, err := v.claim(ctx, s, host) |
| + if err != nil { |
| + return heldReleaseRequest{}, err |
| + } |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return heldReleaseRequest{}, staleHeldStage("the host has no claim") |
| + } |
| + c := obs.Claim |
| + if moveID == "" { |
| + moveID = c.Op.ID |
| + } |
| + q := heldReleaseRequest{Schema: heldReleaseSchema, RequestID: newHeldReleaseRequestID(), Action: action, Host: host, Account: account, App: app, |
| + Version: version, MoveID: moveID, HoldOp: holdOp, Box: box, Region: regionname.PlacementRegion(v.Region), |
| + Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp, Session: c.Holder.Session, Generation: c.Holder.Generation, |
| + Kind: c.Op.Kind, Phase: c.Op.Phase} |
| + if action == heldReleasePublish { |
| + j, err := moves.ReadJournal(dataDir, host, holdOp) |
| + if err != nil { |
| + return heldReleaseRequest{}, err |
| + } |
| + if q.RenderHash = j.Steps[landStepRendered]; q.RenderHash == "" { |
| + return heldReleaseRequest{}, errHeldReleaseNotYet |
| + } |
| + } |
| + return q, nil |
| +} |
| + |
| +// heldReleaseHoldOp is the op rec's registry row is held by: the rollback |
| +// id while a rollback runs (ruling J3), else the move id. |
| +func heldReleaseHoldOp(rec provision.Record) string { |
| + if m := rec.Move; m != nil && m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.ID != "" { |
| + return m.Rollback.ID |
| + } |
| + if rec.Move == nil { |
| + return "" |
| + } |
| + return rec.Move.ID |
| +} |
| + |
| +// stage asks root to stage T7's release for rec (the move's target, its |
| +// row held, if at all, by the move id). |
| +func (v heldReleaseVia) stage(ctx context.Context, s release.Store, rec provision.Record, box, version string) error { |
| + q, err := v.request(ctx, s, heldReleaseStage, rec.Host, rec.Account, rec.App, version, rec.Move.ID, rec.Move.ID, box, "") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = v.Relay.HeldRelease(ctx, q) |
| + return err |
| +} |
| + |
| +// publish asks root to publish version for rt under holdOp's render |
| +// journal. |
| +func (v heldReleaseVia) publish(ctx context.Context, s release.Store, rt registry.Route, moveID, holdOp, box, dataDir, version string) error { |
| + q, err := v.request(ctx, s, heldReleasePublish, rt.Host, release.RouteAccount(rt), rt.App, version, moveID, holdOp, box, dataDir) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = v.Relay.HeldRelease(ctx, q) |
| + return err |
| +} |
diff --git a/internal/host/movesheldrelease_linux.go b/internal/host/movesheldrelease_linux.go| new file mode 100644 |
| index 000000000..e4c9b417a |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_linux.go |
| @@ -0,0 +1,59 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +// heldRenderJournalMax bounds the render journal root reads: a move's |
| +// journal holds step values (digests, ids, one install manifest), well |
| +// under this. |
| +const heldRenderJournalMax = 1 << 20 |
| + |
| +// readHeldRenderHash is root's read of the held render's journalled hash |
| +// for (host, op): moves.JournalFile under the agent's data directory. The |
| +// journal is agent-written, so root never opens it by name (#267, |
| +// platform-1e): safefile.OpenFile walks every directory from / with |
| +// O_NOFOLLOW, pins the leaf with O_PATH|O_NOFOLLOW and fstats it before |
| +// opening it for reading (with O_NONBLOCK), so a symlink at any component |
| +// fails, and a FIFO, a device or a file with more than one link is refused |
| +// without being opened for reading; the bytes are read from that one |
| +// descriptor, bounded. A journal, or a directory on its path, that does not |
| +// exist is os.ErrNotExist (not yet); anything else unsafe is |
| +// errHeldReleaseUnsafe. A journal with no rendered step is "". |
| +func readHeldRenderHash(dataDir, host, op string) (string, error) { |
| + f, err := safefile.OpenFile(moves.JournalFile(dataDir, host, op), os.O_RDONLY, 0) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "", os.ErrNotExist |
| + } |
| + if err != nil { |
| + return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err) |
| + } |
| + defer f.Close() |
| + st, err := f.Stat() |
| + if err != nil { |
| + return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err) |
| + } |
| + if !st.Mode().IsRegular() || st.Size() > heldRenderJournalMax { |
| + return "", errHeldReleaseUnsafe |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(f, heldRenderJournalMax+1)) |
| + if err != nil { |
| + return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err) |
| + } |
| + if len(raw) > heldRenderJournalMax { |
| + return "", errHeldReleaseUnsafe |
| + } |
| + j, err := moves.DecodeJournal(raw, host, op) |
| + if err != nil { |
| + return "", fmt.Errorf("%w: %v", errHeldReleaseUnsafe, err) |
| + } |
| + return j.Steps[landStepRendered], nil |
| +} |
diff --git a/internal/host/movesheldrelease_linux_test.go b/internal/host/movesheldrelease_linux_test.go| new file mode 100644 |
| index 000000000..a6e69b940 |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_linux_test.go |
| @@ -0,0 +1,204 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestHeldReleaseRootRefusesUnsafeRenderJournals (#267, platform-1e): the |
| +// render-hash journal lives under the agent's /data, so root reads it only |
| +// through the anchored path: no symlink at any component, a regular file |
| +// with one link, one descriptor, a bounded size. A journal that is a |
| +// symlink to a root-only file holding the right hash, a directory on its |
| +// path that is a symlink to such a journal, and a FIFO are each refused at |
| +// once, and root selects and publishes nothing. |
| +func TestHeldReleaseRootRefusesUnsafeRenderJournals(t *testing.T) { |
| + ctx := context.Background() |
| + journalPath := func(f *hrFixture) string { |
| + return filepath.Join(moves.JournalDir(f.data, p1Host, p1ID), "journal.json") |
| + } |
| + // rootOnly is a journal with the right hash in a directory only its |
| + // owner can read: what a symlink would make root read on the agent's |
| + // behalf. |
| + rootOnly := func(t *testing.T) string { |
| + t.Helper() |
| + dir := t.TempDir() |
| + g := &hrFixture{data: dir} |
| + g.journal(t, p1ID, hrRendered) |
| + if err := os.Chmod(filepath.Join(moves.JournalDir(dir, p1Host, p1ID), "journal.json"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + return moves.JournalDir(dir, p1Host, p1ID) |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + prep func(t *testing.T, f *hrFixture) |
| + }{ |
| + {"a symlink to a root-only journal", func(t *testing.T, f *hrFixture) { |
| + p := journalPath(f) |
| + if err := os.Remove(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(filepath.Join(rootOnly(t), "journal.json"), p); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"a symlinked directory on the path", func(t *testing.T, f *hrFixture) { |
| + dir := moves.JournalDir(f.data, p1Host, p1ID) |
| + if err := os.RemoveAll(dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(rootOnly(t), dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"a FIFO", func(t *testing.T, f *hrFixture) { |
| + p := journalPath(f) |
| + if err := os.Remove(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mkfifo(p, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"a hard link", func(t *testing.T, f *hrFixture) { |
| + if err := os.Link(journalPath(f), filepath.Join(t.TempDir(), "other")); err != nil { |
| + t.Skipf("no hard link across temp dirs: %v", err) |
| + } |
| + }}, |
| + {"an oversized journal", func(t *testing.T, f *hrFixture) { |
| + if err := os.WriteFile(journalPath(f), []byte(strings.Repeat(" ", heldRenderJournalMax+1)), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newHRFixture(t) |
| + tc.prep(t, f) |
| + done := make(chan error, 1) |
| + go func() { |
| + _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) |
| + done <- err |
| + }() |
| + select { |
| + case err := <-done: |
| + if err == nil { |
| + t.Fatal("root published on an unsafe journal") |
| + } |
| + if errors.Is(err, errHeldReleaseNotYet) { |
| + // Not yet would make the agent retry an unsafe file forever |
| + // without anyone seeing it; it is a refusal. |
| + t.Fatalf("an unsafe journal was read as not yet: %v", err) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("root blocked on the journal") |
| + } |
| + f.untouched(t) |
| + }) |
| + } |
| +} |
| + |
| +// TestHeldReleaseOverTheRootSocket: the agent's client reaches root's |
| +// held-release route on the claim-mode instance-control mux, with root's |
| +// peer authentication; root's refusals come back typed; and a root with no |
| +// such route (an older build, or one without its providers) and a mux with |
| +// no claim handler at all both read as unavailable. |
| +func TestHeldReleaseOverTheRootSocket(t *testing.T) { |
| + ctx := context.Background() |
| + f := newHRFixture(t) |
| + authenticated := 0 |
| + f.root.Authenticate = func(_ context.Context, conn net.Conn) (StopPeer, error) { |
| + if conn == nil { |
| + t.Error("no request connection") |
| + } |
| + authenticated++ |
| + return hrPeer, nil |
| + } |
| + sock := serveClaimRoot(t, ClaimRootWithLaunch{HeldRelease: f.root}) |
| + client := newHeldReleaseRootClient(sock) |
| + |
| + stale := f.request(heldReleasePublish) |
| + stale.Session = 99 |
| + if _, err := client.HeldRelease(ctx, stale); !errors.Is(err, release.ErrStaleHeldStage) { |
| + t.Fatalf("a stale request over the socket: %v", err) |
| + } |
| + resp, err := client.HeldRelease(ctx, f.request(heldReleasePublish)) |
| + if err != nil || resp.Outcome != heldReleaseOutcomePublished { |
| + t.Fatalf("publish over the socket: %+v %v", resp, err) |
| + } |
| + if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q", v) |
| + } |
| + if authenticated != 2 { |
| + t.Fatalf("authenticated %d of 2 requests", authenticated) |
| + } |
| + |
| + f.root.DataDir = t.TempDir() |
| + f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 }) |
| + if _, err := client.HeldRelease(ctx, f.request(heldReleasePublish)); !errors.Is(err, errHeldReleaseNotYet) { |
| + t.Fatalf("not yet over the socket: %v", err) |
| + } |
| + |
| + f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("not the edge") } |
| + if _, err := client.HeldRelease(ctx, f.request(heldReleasePublish)); !errors.Is(err, ErrClaimStopIdentity) { |
| + t.Fatalf("an unauthenticated peer: %v", err) |
| + } |
| + |
| + // A body the protocol cannot carry is refused before authority checks. |
| + f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return hrPeer, nil } |
| + httpc := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", sock) |
| + }}} |
| + r, err := httpc.Post("http://control/claim/held-release", "application/json", strings.NewReader(`{"schema":"x","path":"/etc/shadow"}`)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Body.Close() |
| + if r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("an unknown field: status %d", r.StatusCode) |
| + } |
| + |
| + for name, h := range map[string]http.Handler{ |
| + "a root without the route": ClaimRootWithLaunch{}, |
| + "a claim root of old": http.NotFoundHandler(), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + old := newHeldReleaseRootClient(serveClaimRoot(t, h)) |
| + if _, err := old.HeldRelease(ctx, f.request(heldReleaseStage)); !errors.Is(err, errHeldReleaseUnavailable) { |
| + t.Fatalf("an old root: %v, want errHeldReleaseUnavailable", err) |
| + } |
| + }) |
| + } |
| + t.Run("a mux with the claim off", func(t *testing.T) { |
| + dir, err := os.MkdirTemp("", "hr") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + sock := filepath.Join(dir, "c.sock") |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := &http.Server{Handler: http.NewServeMux()} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + if _, err := newHeldReleaseRootClient(sock).HeldRelease(ctx, f.request(heldReleaseStage)); !errors.Is(err, errHeldReleaseUnavailable) { |
| + t.Fatalf("no claim mux: %v", err) |
| + } |
| + }) |
| +} |
diff --git a/internal/host/movesheldrelease_other.go b/internal/host/movesheldrelease_other.go| new file mode 100644 |
| index 000000000..b3cb92155 |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_other.go |
| @@ -0,0 +1,14 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +// heldRenderJournalMax is the Linux reader's bound; off Linux nothing is |
| +// read. |
| +const heldRenderJournalMax = 1 << 20 |
| + |
| +// readHeldRenderHash refuses off Linux: root's held release runs on a Linux |
| +// box only, and without safefile's anchored open there is no safe read of |
| +// an agent-written journal. Fail closed, like openMovesDir. |
| +func readHeldRenderHash(dataDir, host, op string) (string, error) { |
| + return "", errHeldReleaseUnavailable |
| +} |
diff --git a/internal/host/movesheldrelease_production.go b/internal/host/movesheldrelease_production.go| new file mode 100644 |
| index 000000000..c8cb4904e |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_production.go |
| @@ -0,0 +1,80 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// WithProductionMovesHeldRelease adds the moves held-release route (Task |
| +// 7b) to the claim-mode root handler WithProductionClaimLaunch returned, |
| +// only while CARLOS_MOVES_PHASE1 is on; otherwise the handler is returned |
| +// unchanged and the route refuses, exactly main's behaviour. It reuses the |
| +// claim root's peer authentication and the assembly's roster identity, |
| +// guard, policy and local authority, and builds root's held-release |
| +// selection from them as the assembly builds the agent's (Build's |
| +// WorkSource, without Prepare: participation is the agent's). A release |
| +// key that does not parse leaves the route unwired, so every request is |
| +// refused and T7 says MV-N455: fail closed, without stopping |
| +// instance-control. |
| +func WithProductionMovesHeldRelease(ctx context.Context, handler http.Handler, reg *registry.Registry, getenv func(string) string) (http.Handler, error) { |
| + if !moves.Enabled(getenv) { |
| + return handler, nil |
| + } |
| + root, ok := handler.(ClaimRootWithLaunch) |
| + if !ok { |
| + return nil, ErrClaimStopUnavailable |
| + } |
| + entry, _ := ctx.Value(claimEntryKey{}).(claimEntry) |
| + a := entry.Assembly |
| + if a == nil || entry.Store == nil || reg == nil || a.Guard.DataDir == "" || !filepath.IsAbs(a.ManagedRoots["opt"]) || root.Root.Stop.Authenticate == nil { |
| + return nil, &ClaimDependencyError{Contracts: []string{"moves Task 7b held release: production claim assembly, registry and root peer authentication"}} |
| + } |
| + pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Printf("moves: held releases are refused: %v", err) |
| + return root, nil |
| + } |
| + store := entry.Store |
| + guard := a.Guard |
| + guard.Claims = hostguard.StoreReader{Store: store} |
| + optDir, dataDir := a.ManagedRoots["opt"], a.Guard.DataDir |
| + policy, identity, local := a.Policy, a.Identity, a.Local |
| + var continues func(kind, from, to string) bool |
| + if a.Wake != nil { |
| + continues = a.Wake.Continue |
| + } |
| + root.HeldRelease = &movesHeldReleaseRoot{ |
| + Authenticate: root.Root.Stop.Authenticate, |
| + Actor: identity.Verify, |
| + Store: store, |
| + Claims: func(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + return hostclaim.Read(ctx, store, host) |
| + }, |
| + Records: MovesRecordsFrom(store), |
| + Route: reg.Get, |
| + Works: func(ctx context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) { |
| + return hostbox.WorkSource{FleetHomeRegion: actor.FleetHomeRegion, HomeRegion: actor.HomeRegion, Guard: guard, Box: actor.Box, |
| + Region: park.PlacementRegion(actor.Region), Now: time.Now, Policy: policy.Check, Local: identity.Local(local), Continue: continues}.Select(ctx, host, action) |
| + }, |
| + Selected: hostbox.Selected, |
| + Pub: pub, |
| + Ratchet: func() *release.DigestRatchet { return pointerRatchet(true, optDir, dataDir, getenv) }, |
| + Proof: func() *release.ProofGate { return release.NewProofGate(release.ProofPolicyPath, dataDir) }, |
| + OptDir: optDir, |
| + DataDir: dataDir, |
| + ReceiptDir: heldReleaseReceiptDir, |
| + } |
| + return root, nil |
| +} |
diff --git a/internal/host/movesheldrelease_root.go b/internal/host/movesheldrelease_root.go| new file mode 100644 |
| index 000000000..65f87529c |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_root.go |
| @@ -0,0 +1,503 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/regionname" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +// heldReleaseReceiptDir is where root keeps its publish receipts: its own |
| +// claim state directory (carlos-instance-control's StateDirectory in claim |
| +// mode), which the agent cannot write. |
| +const heldReleaseReceiptDir = "/var/lib/carlos-claims/held-release" |
| + |
| +// movesHeldReleaseRoot is root's side of the held release (Task 7b; see |
| +// movesheldrelease.go). Every authority it checks comes from root's own |
| +// providers: the authenticated peer, the claim and record it reads from the |
| +// deployment store, the registry row, the render journal it reads through |
| +// the anchored path, and its own held-release selection. A request only |
| +// names what the agent expects them to say. |
| +type movesHeldReleaseRoot struct { |
| + // Authenticate is root's peer authentication (the edge unit's main |
| + // process), the claim root's own. |
| + Authenticate func(context.Context, net.Conn) (StopPeer, error) |
| + // Actor is this box's verified roster identity. |
| + Actor func(context.Context) (hostbox.Actor, error) |
| + // Store is the deployment store: releases and the source's report. |
| + Store release.Store |
| + Claims func(context.Context, string) (hostclaim.Observed, error) |
| + Records MovesRecordReader |
| + Route func(string) (*registry.Route, error) |
| + // Works selects the claim's held-release work for host |
| + // (hostbox.WorkSource.Select, built for actor); Selected reads back the |
| + // selection it captured (hostbox.Selected). |
| + Works func(ctx context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) |
| + Selected func(hostwrite.Work) (hostbox.Selection, error) |
| + // Pub, Ratchet and Proof are §4.1's and D3's evidence, as for root's |
| + // adoption pass: the release key, root's pointer digest ratchet and a |
| + // fresh proof gate per request. |
| + Pub ed25519.PublicKey |
| + Ratchet func() *release.DigestRatchet |
| + Proof func() *release.ProofGate |
| + OptDir string |
| + // DataDir is the box's data directory: the render journals and the |
| + // held-stage lock. |
| + DataDir string |
| + ReceiptDir string |
| + |
| + // Test seams. Nil is production. |
| + stage func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate) (*release.Manifest, error) |
| + materialize func(context.Context, release.Store, ed25519.PublicKey, *release.DigestRatchet, string, registry.Route, string, *release.ProofGate, hostwrite.Work, func(func() error) error) (*release.Manifest, error) |
| + renderHash func(dataDir, host, op string) (string, error) |
| + afterPublish func(host string) error |
| +} |
| + |
| +func (h *movesHeldReleaseRoot) complete() bool { |
| + return h != nil && h.Actor != nil && h.Store != nil && h.Claims != nil && h.Records != nil && h.Route != nil && h.Works != nil && h.Selected != nil && |
| + len(h.Pub) == ed25519.PublicKeySize && h.Ratchet != nil && h.Proof != nil && filepath.IsAbs(h.OptDir) && filepath.IsAbs(h.DataDir) && filepath.IsAbs(h.ReceiptDir) |
| +} |
| + |
| +// ServeHTTP answers POST /claim/held-release for an authenticated peer. |
| +// The peer is authenticated before the body is read. |
| +func (h *movesHeldReleaseRoot) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if h == nil || h.Authenticate == nil || r.Method != http.MethodPost || r.URL.Path != heldReleasePath { |
| + http.Error(w, "refused", http.StatusServiceUnavailable) |
| + return |
| + } |
| + conn := instancectl.RequestConn(r.Context()) |
| + if conn == nil { |
| + http.Error(w, "refused", http.StatusForbidden) |
| + return |
| + } |
| + peer, err := h.Authenticate(r.Context(), conn) |
| + if err != nil { |
| + http.Error(w, "refused", http.StatusForbidden) |
| + return |
| + } |
| + q, err := decodeHeldReleaseRequest(r.Body) |
| + if err != nil { |
| + http.Error(w, "refused", http.StatusBadRequest) |
| + return |
| + } |
| + out, err := h.Serve(r.Context(), peer, q) |
| + if err != nil { |
| + log.Printf("moves: %s: held release %s of %s refused: %v", q.Host, q.Action, q.Version, err) |
| + http.Error(w, "refused", heldReleaseStatus(err)) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(http.StatusOK) |
| + _ = json.NewEncoder(w).Encode(out) |
| +} |
| + |
| +// heldReleaseStatus is the status each refusal answers with; the agent's |
| +// client maps it back to the same typed error. |
| +func heldReleaseStatus(err error) int { |
| + var refusal *MovesPolicyRefusal |
| + switch { |
| + case errors.Is(err, errHeldReleaseShape): |
| + return http.StatusBadRequest |
| + case errors.Is(err, ErrClaimStopIdentity): |
| + return http.StatusForbidden |
| + case errors.Is(err, errHeldReleaseNotYet): |
| + return http.StatusTooEarly |
| + case errors.Is(err, release.ErrHeldNotProven): |
| + return http.StatusUnprocessableEntity |
| + case errors.Is(err, errHeldReleaseUnavailable), errors.Is(err, hostwrite.ErrUnavailable): |
| + return http.StatusServiceUnavailable |
| + case errors.Is(err, release.ErrStaleHeldStage), errors.Is(err, hostbox.ErrChanged), errors.Is(err, errHeldReleaseUnsafe), |
| + errors.Is(err, release.ErrHeldNotAuthorized), errors.Is(err, release.ErrManifestDigest), errors.As(err, &refusal): |
| + return http.StatusConflict |
| + } |
| + return http.StatusInternalServerError |
| +} |
| + |
| +// Serve answers one request for an authenticated peer. Its order: the peer, |
| +// the request's shape, this box's identity, then the action's own reads, |
| +// every refusal before any effect. |
| +func (h *movesHeldReleaseRoot) Serve(ctx context.Context, peer StopPeer, q heldReleaseRequest) (heldReleaseResponse, error) { |
| + if !h.complete() { |
| + return heldReleaseResponse{}, errHeldReleaseUnavailable |
| + } |
| + if peer.UID == 0 || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" { |
| + return heldReleaseResponse{}, ErrClaimStopIdentity |
| + } |
| + if err := q.validate(); err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + actor, err := h.Actor(ctx) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + if !actor.Eligible || actor.Quarantined || actor.Box == "" { |
| + return heldReleaseResponse{}, hostbox.ErrChanged |
| + } |
| + if q.Box != actor.Box { |
| + return heldReleaseResponse{}, staleHeldStage("it names box %q and this is %q", q.Box, actor.Box) |
| + } |
| + if !regionname.SameRegion(actor.HomeRegion, q.Region, regionname.PlacementRegion(actor.Region)) { |
| + return heldReleaseResponse{}, staleHeldStage("it names region %q and this box is in %q", q.Region, actor.Region) |
| + } |
| + if q.Action == heldReleaseStage { |
| + return h.stageRelease(ctx, actor, q) |
| + } |
| + return h.publishRelease(ctx, actor, q) |
| +} |
| + |
| +// heldReleaseAuthority is root's own read of what a request names. |
| +type heldReleaseAuthority struct { |
| + claim *hostclaim.Claim |
| + rec *provision.Record |
| + row *registry.Route |
| +} |
| + |
| +// authority re-reads the claim, the record and the registry row, and |
| +// refuses a request that is not exactly theirs: the holder's incarnation, |
| +// stamp, session, generation, account and app; the op's id, kind and |
| +// phase; this box as the move's target (moving, landing) or as the |
| +// executing source (restoring, reverting), and not declared lost; the hold |
| +// op the row must be held by (the move id, or the claim's intent.rollback |
| +// for a restoring op, ruling J3); the record's move and incarnation; and a |
| +// registry row, when there is one, held by that op for that account and |
| +// app. |
| +func (h *movesHeldReleaseRoot) authority(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseAuthority, error) { |
| + obs, err := h.Claims(ctx, q.Host) |
| + if err != nil { |
| + return heldReleaseAuthority{}, fmt.Errorf("host claim for %s: %w", q.Host, err) |
| + } |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil || obs.Claim.Host != q.Host { |
| + return heldReleaseAuthority{}, staleHeldStage("the host has no claim") |
| + } |
| + c, hd := obs.Claim, obs.Claim.Holder |
| + if hd.Incarnation != q.Incarnation || hd.Stamp != q.Stamp || hd.Session != q.Session || hd.Generation != q.Generation { |
| + return heldReleaseAuthority{}, staleHeldStage("the claim's holder is not the one the request observed") |
| + } |
| + if hd.Account != q.Account || hd.App != q.App { |
| + return heldReleaseAuthority{}, staleHeldStage("the claim is not held by %s/%s", q.Account, q.App) |
| + } |
| + if c.Op.ID != q.MoveID || c.Op.Kind != q.Kind || c.Op.Phase != q.Phase { |
| + return heldReleaseAuthority{}, staleHeldStage("the claim is %s %s at %q", c.Op.Kind, c.Op.ID, c.Op.Phase) |
| + } |
| + if slices.Contains(c.Lost, actor.Box) { |
| + return heldReleaseAuthority{}, staleHeldStage("this box is declared lost") |
| + } |
| + hold := q.MoveID |
| + switch c.Op.Kind { |
| + case hostclaim.KindMoving, hostclaim.KindLanding: |
| + if c.Op.Intent["target"] != actor.Box { |
| + return heldReleaseAuthority{}, staleHeldStage("the claim's target is %q", c.Op.Intent["target"]) |
| + } |
| + case hostclaim.KindRestoring, hostclaim.KindReverting: |
| + if c.Op.Exec != actor.Box || c.Op.Intent["source"] != actor.Box { |
| + return heldReleaseAuthority{}, staleHeldStage("this box is not the move's executing source") |
| + } |
| + if c.Op.Kind == hostclaim.KindRestoring { |
| + if hold = c.Op.Intent["rollback"]; !provision.ValidRollbackID(hold) { |
| + return heldReleaseAuthority{}, staleHeldStage("the claim names no rollback") |
| + } |
| + } |
| + default: |
| + return heldReleaseAuthority{}, staleHeldStage("the claim's op is %s", c.Op.Kind) |
| + } |
| + if q.HoldOp != hold { |
| + return heldReleaseAuthority{}, staleHeldStage("the row is held by %q, not %q", hold, q.HoldOp) |
| + } |
| + rec, err := h.Records(ctx, q.Account, q.App, q.Host) |
| + if err != nil { |
| + return heldReleaseAuthority{}, fmt.Errorf("record for %s: %w", q.Host, err) |
| + } |
| + if rec == nil || rec.Host != q.Host || rec.Account != q.Account || rec.App != q.App || rec.Move == nil || rec.Move.ID != q.MoveID || rec.Move.Incarnation != hd.Incarnation { |
| + return heldReleaseAuthority{}, staleHeldStage("the record is not this move") |
| + } |
| + if c.Op.Kind == hostclaim.KindRestoring { |
| + if rb := rec.Move.Rollback; rec.Move.State != provision.MoveRollingBack || rb == nil || rb.ID != hold { |
| + return heldReleaseAuthority{}, staleHeldStage("the record is not this rollback") |
| + } |
| + } |
| + row, err := h.Route(q.Host) |
| + if err != nil { |
| + return heldReleaseAuthority{}, fmt.Errorf("registry row for %s: %w", q.Host, err) |
| + } |
| + if row != nil && (row.HoldOp != hold || release.RouteAccount(*row) != q.Account || row.App != q.App) { |
| + return heldReleaseAuthority{}, staleHeldStage("the registry row is %s/%s held by %q", release.RouteAccount(*row), row.App, row.HoldOp) |
| + } |
| + return heldReleaseAuthority{claim: c, rec: rec, row: row}, nil |
| +} |
| + |
| +// heldReleaseRoute is the held route a request's effect runs for. |
| +func heldReleaseRoute(q heldReleaseRequest) registry.Route { |
| + return registry.Route{Host: q.Host, Kind: "instance", App: q.App, Account: q.Account, HoldOp: q.HoldOp} |
| +} |
| + |
| +// stageRelease is T7 under the claim, for a move's target (moving or |
| +// landing) or a dirty return's source (restoring): the version must be the |
| +// source's reported release and, once pinned, the record's pin (for a |
| +// return, its recorded recovery release, else the pin). It stages and |
| +// verifies (release.StageHeldVerified), never touches live/<host>, selects |
| +// nothing and holds no hostguard: the version store is shared and a stage |
| +// is no host's write. |
| +func (h *movesHeldReleaseRoot) stageRelease(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseResponse, error) { |
| + a, err := h.authority(ctx, actor, q) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + m := a.rec.Move |
| + switch q.Kind { |
| + case hostclaim.KindMoving, hostclaim.KindLanding: |
| + raw, _, err := h.Store.Get(ctx, moves.SourceReportKey(moves.ReportPrefix(q.Account, q.App, q.Host, q.MoveID))) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return heldReleaseResponse{}, staleHeldStage("the source has not reported its release") |
| + } |
| + if err != nil { |
| + return heldReleaseResponse{}, fmt.Errorf("source report for %s: %w", q.Host, err) |
| + } |
| + var sr moves.SourceReport |
| + if err := json.Unmarshal(raw, &sr); err != nil { |
| + return heldReleaseResponse{}, fmt.Errorf("source report for %s: %w", q.Host, err) |
| + } |
| + if sr.Release == "" || sr.Release != q.Version { |
| + return heldReleaseResponse{}, staleHeldStage("the source runs %q", sr.Release) |
| + } |
| + if m.Inputs != nil && m.Inputs.Release != q.Version { |
| + return heldReleaseResponse{}, staleHeldStage("the pinned release is %q", m.Inputs.Release) |
| + } |
| + case hostclaim.KindRestoring: |
| + if want := heldReturnRelease(m); want == "" || want != q.Version { |
| + return heldReleaseResponse{}, staleHeldStage("the return's release is %q", want) |
| + } |
| + default: |
| + return heldReleaseResponse{}, staleHeldStage("a %s op stages nothing", q.Kind) |
| + } |
| + stage := h.stage |
| + if stage == nil { |
| + stage = release.StageHeldVerified |
| + } |
| + if _, err := stage(ctx, h.Store, h.Pub, h.Ratchet(), h.OptDir, heldReleaseRoute(q), q.Version, h.Proof()); err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + log.Printf("moves: %s: held release %s staged for %s (no publication)", q.Host, q.Version, q.MoveID) |
| + return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomeStaged}, nil |
| +} |
| + |
| +// heldReturnRelease is a return's release: its recorded recovery release, |
| +// else the source's pin. |
| +func heldReturnRelease(m *provision.MoveState) string { |
| + if rb := m.Rollback; rb != nil && rb.RecoveryInputs != nil && rb.RecoveryInputs.Release != "" { |
| + return rb.RecoveryInputs.Release |
| + } |
| + if m.Inputs != nil { |
| + return m.Inputs.Release |
| + } |
| + return "" |
| +} |
| + |
| +// publishVersion is the release a publication may name, by the claim's |
| +// kind and phase, and "" with the reason when the claim grants none: a |
| +// landing's configured or probing step, on this box as the executor, with |
| +// the record at that landing phase (the pinned release); a dirty return's |
| +// configured step (claimed) or a return's probe (its recovery release, the |
| +// pin at configured when none is recorded yet); a revert's probe (its |
| +// recovery release). Anything else, a moving op included, publishes |
| +// nothing: the policy is never widened. |
| +func publishVersion(c *hostclaim.Claim, m *provision.MoveState, box string) (string, error) { |
| + if c.Op.Exec != box { |
| + return "", staleHeldStage("this box is not the executor") |
| + } |
| + switch c.Op.Kind { |
| + case hostclaim.KindLanding: |
| + if c.Op.Phase != provision.LandConfigured && c.Op.Phase != provision.LandProbing { |
| + return "", staleHeldStage("a landing at %q publishes nothing", c.Op.Phase) |
| + } |
| + if m.State != provision.MoveLanding || m.LandingPhase != c.Op.Phase { |
| + return "", staleHeldStage("the record is %s at %q", m.State, m.LandingPhase) |
| + } |
| + if m.Inputs == nil || m.Inputs.Release == "" { |
| + return "", staleHeldStage("no pinned release") |
| + } |
| + return m.Inputs.Release, nil |
| + case hostclaim.KindRestoring: |
| + rb := m.Rollback |
| + switch { |
| + case c.Op.Phase == hostclaim.PhaseClaimed && rb.SourcePhase == provision.RbSourceConfigured && rb.Dirty: |
| + case c.Op.Phase == hostclaim.PhaseProbing && rb.SourcePhase == provision.RbSourceProbing: |
| + if rb.RecoveryInputs == nil || rb.RecoveryInputs.Release == "" { |
| + return "", staleHeldStage("no recorded recovery release") |
| + } |
| + default: |
| + return "", staleHeldStage("a rollback at %q (source %q) publishes nothing", c.Op.Phase, rb.SourcePhase) |
| + } |
| + if v := heldReturnRelease(m); v != "" { |
| + return v, nil |
| + } |
| + return "", staleHeldStage("no recorded recovery release") |
| + case hostclaim.KindReverting: |
| + if c.Op.Phase != hostclaim.PhaseProbing || c.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing || m.State != provision.MoveReverting { |
| + return "", staleHeldStage("a revert at %q publishes nothing", c.Op.Phase) |
| + } |
| + if m.RecoveryInputs == nil || m.RecoveryInputs.Release == "" { |
| + return "", staleHeldStage("no recorded recovery release") |
| + } |
| + return m.RecoveryInputs.Release, nil |
| + } |
| + return "", staleHeldStage("a %s op publishes nothing", c.Op.Kind) |
| +} |
| + |
| +// publishRelease is a publication under the claim. In order: a receipt for |
| +// exactly this request (session, render hash and identity) answers it |
| +// again, with no read and no effect; the claim, record and row are re-read |
| +// and must be the request's; the version must be the one publishVersion |
| +// grants; the render hash journalled at configured must be present (else |
| +// errHeldReleaseNotYet, at once, holding nothing) and equal the request's; |
| +// then root selects held-release itself, requires the selection's tuple to |
| +// be the request's, and runs release.MaterializeHeldUnder inside it with |
| +// the held-stage lock (bind, swap, re-hash and rollback, the lock a leaf |
| +// inside Work.With, which re-checks the claim under the hostguard right |
| +// before the swap). The receipt is written last. |
| +func (h *movesHeldReleaseRoot) publishRelease(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseResponse, error) { |
| + if h.replayed(q) { |
| + return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomePublished, Replayed: true}, nil |
| + } |
| + a, err := h.authority(ctx, actor, q) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + want, err := publishVersion(a.claim, a.rec.Move, actor.Box) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + if q.Version != want { |
| + return heldReleaseResponse{}, staleHeldStage("the release to publish is %q", want) |
| + } |
| + if a.row == nil { |
| + return heldReleaseResponse{}, staleHeldStage("the host has no held row") |
| + } |
| + read := h.renderHash |
| + if read == nil { |
| + read = readHeldRenderHash |
| + } |
| + rendered, err := read(h.DataDir, q.Host, q.HoldOp) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist) || (err == nil && rendered == ""): |
| + return heldReleaseResponse{}, errHeldReleaseNotYet |
| + case err != nil: |
| + return heldReleaseResponse{}, err |
| + case rendered != q.RenderHash: |
| + return heldReleaseResponse{}, staleHeldStage("the journalled render is %s", rendered) |
| + } |
| + work, err := h.Works(ctx, actor, q.Host, movesActionHeldRelease) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + sel, err := h.Selected(work) |
| + if err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + if sel.Host != q.Host || sel.Action != movesActionHeldRelease || sel.Box != actor.Box || sel.Captured.Incarnation != q.Incarnation || sel.Captured.Stamp != q.Stamp || |
| + sel.Session != q.Session || sel.Generation != q.Generation || sel.Kind != q.Kind || sel.Op != q.MoveID || sel.Phase != q.Phase { |
| + return heldReleaseResponse{}, hostbox.ErrChanged |
| + } |
| + materialize := h.materialize |
| + if materialize == nil { |
| + materialize = release.MaterializeHeldUnder |
| + } |
| + lock := func(publish func() error) error { return withHeldStageLock(h.DataDir, q.Host, publish) } |
| + if _, err := materialize(ctx, h.Store, h.Pub, h.Ratchet(), h.OptDir, heldReleaseRoute(q), q.Version, h.Proof(), work, lock); err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + if h.afterPublish != nil { |
| + if err := h.afterPublish(q.Host); err != nil { |
| + return heldReleaseResponse{}, err |
| + } |
| + } |
| + if err := h.writeReceipt(q); err != nil { |
| + // The publication stands; a replay without a receipt runs the |
| + // whole path again and publishes the same release. |
| + log.Printf("moves: %s: held release %s published, receipt not written: %v", q.Host, q.Version, err) |
| + } |
| + log.Printf("moves: %s: held release %s published for %s at %s %q", q.Host, q.Version, q.MoveID, q.Kind, q.Phase) |
| + return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomePublished}, nil |
| +} |
| + |
| +// heldReleaseReceipt is root's record of a publication it made, one per |
| +// host (the latest), keyed by the session and the render hash with the |
| +// identity the publication was for. |
| +type heldReleaseReceipt struct { |
| + Schema string `json:"schema"` |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Version string `json:"version"` |
| + MoveID string `json:"move_id"` |
| + HoldOp string `json:"hold_op"` |
| + Incarnation string `json:"incarnation"` |
| + Stamp uint64 `json:"stamp"` |
| + Session uint64 `json:"session"` |
| + Generation string `json:"generation"` |
| + RenderHash string `json:"render_hash"` |
| + Outcome string `json:"outcome"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +func receiptFor(q heldReleaseRequest) heldReleaseReceipt { |
| + return heldReleaseReceipt{Schema: heldReleaseSchema, Host: q.Host, Account: q.Account, App: q.App, Version: q.Version, MoveID: q.MoveID, HoldOp: q.HoldOp, |
| + Incarnation: q.Incarnation, Stamp: q.Stamp, Session: q.Session, Generation: q.Generation, RenderHash: q.RenderHash, Outcome: heldReleaseOutcomePublished} |
| +} |
| + |
| +func (h *movesHeldReleaseRoot) receiptPath(host string) string { |
| + return filepath.Join(h.ReceiptDir, host+".json") |
| +} |
| + |
| +// replayed reports whether root's receipt for q's host is for exactly q: |
| +// the same session and render hash, and the same identity. Anything else, |
| +// an unreadable receipt included, is not a replay and runs the whole path. |
| +func (h *movesHeldReleaseRoot) replayed(q heldReleaseRequest) bool { |
| + f, err := safefile.OpenFile(h.receiptPath(q.Host), os.O_RDONLY, 0) |
| + if err != nil { |
| + return false |
| + } |
| + defer f.Close() |
| + raw, err := io.ReadAll(io.LimitReader(f, heldReleaseRequestMax+1)) |
| + if err != nil || len(raw) > heldReleaseRequestMax { |
| + return false |
| + } |
| + var got heldReleaseReceipt |
| + if err := json.Unmarshal(raw, &got); err != nil { |
| + return false |
| + } |
| + got.At = time.Time{} |
| + return got == receiptFor(q) |
| +} |
| + |
| +func (h *movesHeldReleaseRoot) writeReceipt(q heldReleaseRequest) error { |
| + r := receiptFor(q) |
| + r.At = time.Now().UTC() |
| + raw, err := json.Marshal(r) |
| + if err != nil { |
| + return err |
| + } |
| + if err := safefile.MkdirAll(h.ReceiptDir, 0o700); err != nil { |
| + return err |
| + } |
| + return safefile.WriteFile(h.receiptPath(q.Host), raw, 0o600) |
| +} |
diff --git a/internal/host/movesheldrelease_test.go b/internal/host/movesheldrelease_test.go| new file mode 100644 |
| index 000000000..6a0283090 |
| --- /dev/null |
| +++ b/internal/host/movesheldrelease_test.go |
| @@ -0,0 +1,681 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Task 7b: the root-side held release under the host claim (moves after |
| +// claim Plan 12; task-7b-design.md and platform-1e's binding review of |
| +// 2026-10-12). The sandboxed agent cannot write /opt/carlos, so under the |
| +// claim it asks root, over the root control socket, to stage T7's release |
| +// (never publishing it) and to publish the held row's release at configured |
| +// and before a probe's start. Root re-reads the claim, the record, the |
| +// registry row and the render-hash journal, refuses anything that does not |
| +// match before any effect, and publishes only inside its own held-release |
| +// selection. |
| + |
| +const ( |
| + hrRendered = "sha256:" + "aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11" |
| + hrOther = "sha256:" + "bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22" |
| + hrRB = "rb_abcdefghijklmnopqrstuvwxyz" |
| +) |
| + |
| +var hrPeer = StopPeer{UID: 1000, PID: 4242, ProcessStart: "77", BootID: "boot", ControllerID: "edge"} |
| + |
| +// hrSelector is root's held-release selection as the tests see it: each |
| +// selection is recorded with the claim tuple it was made at, and each write |
| +// it is handed runs, recording the identity it names. |
| +type hrSelector struct { |
| + mu sync.Mutex |
| + f *hrFixture |
| + selected []string |
| + ids []hostwrite.Identity |
| + refuse error |
| +} |
| + |
| +type hrWork struct { |
| + s *hrSelector |
| + sel hostbox.Selection |
| +} |
| + |
| +func (w hrWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| + w.s.mu.Lock() |
| + w.s.ids = append(w.s.ids, id) |
| + w.s.mu.Unlock() |
| + return fn() |
| +} |
| + |
| +func (s *hrSelector) count() int { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return len(s.selected) |
| +} |
| + |
| +// hrFixture is one box (uk-box in uk) with a held row of bab/app at a |
| +// landing's configured step: the claim, the record, the registry row, the |
| +// render journal, a store with v7 shipped and authorized, and root. |
| +type hrFixture struct { |
| + s release.Store |
| + opt, data, receipts string |
| + inc string |
| + mu sync.Mutex |
| + claim hostclaim.Claim |
| + rec provision.Record |
| + row *registry.Route |
| + sel *hrSelector |
| + claimReads int |
| + root *movesHeldReleaseRoot |
| +} |
| + |
| +func newHRFixture(t *testing.T) *hrFixture { |
| + t.Helper() |
| + s := release.NewDirStore(t.TempDir()) |
| + shipPinnedV7(t, s) |
| + inc, err := hostclaim.Incarnation("bab", "app", p1Host, p1Created) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &hrFixture{s: s, opt: t.TempDir(), data: t.TempDir(), receipts: t.TempDir(), inc: inc} |
| + f.claim = hostclaim.Claim{V: 1, Host: p1Host, Gen: 5, |
| + Holder: &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", p1Host), Account: "bab", App: "app", Incarnation: inc, |
| + CreatedAt: p1Created, Location: "instances/" + p1Host + "/" + inc, Generation: p1ID, Stamp: 7, Session: 3, WriterBox: "uk-box"}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: p1ID, Phase: provision.LandConfigured, Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, |
| + Intent: map[string]string{"source": "ie-box", "target": "uk-box", "to": "uk"}}} |
| + f.rec = provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: "uk", CreatedAt: p1Created, |
| + Move: &provision.MoveState{ID: p1ID, Incarnation: inc, From: "ie", To: "uk", State: provision.MoveLanding, LandingPhase: provision.LandConfigured, |
| + Inputs: &provision.MoveInputs{Release: "v7"}}} |
| + f.row = ®istry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "landing", HoldOp: p1ID} |
| + f.sel = &hrSelector{f: f} |
| + raw, err := json.Marshal(moves.SourceReport{V: 1, Box: "ie-box", MoveID: p1ID, At: time.Now(), Release: "v7"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), moves.SourceReportKey(moves.ReportPrefix("bab", "app", p1Host, p1ID)), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.journal(t, p1ID, hrRendered) |
| + f.root = &movesHeldReleaseRoot{ |
| + Authenticate: nil, |
| + Actor: func(context.Context) (hostbox.Actor, error) { |
| + return hostbox.Actor{Box: "uk-box", Region: "uk", Eligible: true}, nil |
| + }, |
| + Store: s, |
| + Claims: f.claims, |
| + Records: f.records, |
| + Route: func(host string) (*registry.Route, error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if f.row == nil || host != f.row.Host { |
| + return nil, nil |
| + } |
| + rt := *f.row |
| + return &rt, nil |
| + }, |
| + Works: func(_ context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) { |
| + f.sel.mu.Lock() |
| + defer f.sel.mu.Unlock() |
| + f.sel.selected = append(f.sel.selected, host+"/"+action) |
| + if f.sel.refuse != nil { |
| + return nil, f.sel.refuse |
| + } |
| + f.mu.Lock() |
| + c := f.claim |
| + f.mu.Unlock() |
| + return hrWork{s: f.sel, sel: hostbox.Selection{Host: host, Action: action, Box: actor.Box, Region: actor.Region, |
| + Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, |
| + Generation: c.Holder.Generation, Session: c.Holder.Session}}, nil |
| + }, |
| + Selected: func(w hostwrite.Work) (hostbox.Selection, error) { |
| + hw, ok := w.(hrWork) |
| + if !ok { |
| + return hostbox.Selection{}, hostbox.ErrUnsupported |
| + } |
| + return hw.sel, nil |
| + }, |
| + Pub: hsPub, |
| + Ratchet: func() *release.DigestRatchet { return hsRatchet(t) }, |
| + Proof: func() *release.ProofGate { return offProofGate(t) }, |
| + OptDir: f.opt, |
| + DataDir: f.data, |
| + ReceiptDir: f.receipts, |
| + } |
| + return f |
| +} |
| + |
| +func (f *hrFixture) claims(_ context.Context, host string) (hostclaim.Observed, error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.claimReads++ |
| + if host != f.claim.Host { |
| + return hostclaim.Observed{Host: host, Absent: true}, nil |
| + } |
| + raw, err := json.Marshal(f.claim) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + var c hostclaim.Claim |
| + if err := json.Unmarshal(raw, &c); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + return hostclaim.Observed{Host: host, Claim: &c}, nil |
| +} |
| + |
| +func (f *hrFixture) records(_ context.Context, acct, app, host string) (*provision.Record, error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if acct != f.rec.Account || app != f.rec.App || host != f.rec.Host { |
| + return nil, nil |
| + } |
| + raw, err := json.Marshal(f.rec) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return nil, err |
| + } |
| + return &rec, nil |
| +} |
| + |
| +// journal journals the held render's hash for op, as configured does. |
| +func (f *hrFixture) journal(t *testing.T, op, hash string) { |
| + t.Helper() |
| + if err := moves.UpdateJournal(f.data, p1Host, op, func(j *moves.Journal) error { |
| + j.Mark(landStepRendered, hash) |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// request is the agent's request for action, built from the fixture's claim |
| +// and record as the agent builds it from its own reads. |
| +func (f *hrFixture) request(action string) heldReleaseRequest { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + c := f.claim |
| + q := heldReleaseRequest{Schema: heldReleaseSchema, RequestID: "req-1", Action: action, Host: p1Host, Account: "bab", App: "app", Version: "v7", |
| + MoveID: c.Op.ID, HoldOp: c.Op.ID, Box: "uk-box", Region: "uk", Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp, |
| + Session: c.Holder.Session, Generation: c.Holder.Generation, Kind: c.Op.Kind, Phase: c.Op.Phase} |
| + if c.Op.Kind == hostclaim.KindRestoring { |
| + q.HoldOp = c.Op.Intent["rollback"] |
| + } |
| + if action == heldReleasePublish { |
| + q.RenderHash = hrRendered |
| + } |
| + return q |
| +} |
| + |
| +func (f *hrFixture) set(fn func(f *hrFixture)) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + fn(f) |
| +} |
| + |
| +// untouched fails unless root had no effect: nothing selected, nothing |
| +// staged, live/<host> absent and no receipt. |
| +func (f *hrFixture) untouched(t *testing.T) { |
| + t.Helper() |
| + if n := f.sel.count(); n != 0 { |
| + t.Fatalf("root selected %d time(s) for a refused request", n) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.opt, "versions")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("root staged for a refused request: %v", err) |
| + } |
| + if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("root published for a refused request: %v", err) |
| + } |
| + if ents, _ := os.ReadDir(f.receipts); len(ents) != 0 { |
| + t.Fatalf("root wrote a receipt for a refused request: %v", ents) |
| + } |
| +} |
| + |
| +// TestHeldReleaseRootPublishesAtConfigured: a landing's configured step |
| +// asks root to publish the pinned release; root selects held-release for |
| +// the host, publishes live/<host> under the identity naming exactly v7, |
| +// writes the receipt, and the agent's own reading then passes. |
| +func TestHeldReleaseRootPublishesAtConfigured(t *testing.T) { |
| + ctx := context.Background() |
| + f := newHRFixture(t) |
| + resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) |
| + if err != nil { |
| + t.Fatalf("publish: %v", err) |
| + } |
| + if resp.Outcome != heldReleaseOutcomePublished || resp.Replayed || resp.RequestID != "req-1" { |
| + t.Fatalf("response = %+v", resp) |
| + } |
| + if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q, want v7", v) |
| + } |
| + if len(f.sel.selected) != 1 || f.sel.selected[0] != p1Host+"/"+movesActionHeldRelease { |
| + t.Fatalf("selected %v, want one held-release selection", f.sel.selected) |
| + } |
| + if len(f.sel.ids) != 1 || f.sel.ids[0] != (hostwrite.Identity{Release: "v7"}) { |
| + t.Fatalf("published under %v, want the pinned release alone", f.sel.ids) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.receipts, p1Host+".json")); err != nil { |
| + t.Fatalf("no publish receipt: %v", err) |
| + } |
| + rt := registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", HoldOp: p1ID} |
| + lock := func(read func() error) error { return withHeldStageLock(f.data, p1Host, read) } |
| + if err := release.VerifyHeldLive(ctx, f.s, f.opt, rt, "v7", offProofGate(t), lock); err != nil { |
| + t.Fatalf("the agent's reading after root's publication: %v", err) |
| + } |
| +} |
| + |
| +// TestHeldReleaseRootPublishesForTheDirtyReturnAndTheProbe: the same path |
| +// serves a landing's probe and a dirty return, at its configured (claimed) |
| +// and its probe, each under its own hold op and render journal. |
| +func TestHeldReleaseRootPublishesForTheDirtyReturnAndTheProbe(t *testing.T) { |
| + ctx := context.Background() |
| + asReturn := func(f *hrFixture, phase, sourcePhase string, recovery *provision.MoveInputs) { |
| + f.claim.Op = hostclaim.Op{Kind: hostclaim.KindRestoring, ID: p1ID, Phase: phase, Exec: "uk-box", Participants: []string{"uk-box", "ie-box"}, |
| + Intent: map[string]string{"source": "uk-box", "target": "ie-box", "to": "ie", "rollback": hrRB}} |
| + f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveRollingBack, "" |
| + f.rec.Move.From, f.rec.Move.To = "uk", "ie" |
| + f.rec.Move.Rollback = &provision.MoveRollback{ID: hrRB, Dirty: true, SourcePhase: sourcePhase, RecoveryInputs: recovery} |
| + f.row.HoldOp = hrRB |
| + } |
| + cases := []struct { |
| + name string |
| + set func(f *hrFixture) |
| + op string |
| + }{ |
| + {"the landing's probe", func(f *hrFixture) { |
| + f.claim.Op.Phase = provision.LandProbing |
| + f.rec.Move.LandingPhase = provision.LandProbing |
| + }, p1ID}, |
| + {"a dirty return's configured", func(f *hrFixture) { |
| + asReturn(f, hostclaim.PhaseClaimed, provision.RbSourceConfigured, nil) |
| + }, hrRB}, |
| + {"a dirty return's probe", func(f *hrFixture) { |
| + asReturn(f, hostclaim.PhaseProbing, provision.RbSourceProbing, &provision.MoveInputs{Release: "v7"}) |
| + }, hrRB}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newHRFixture(t) |
| + f.set(tc.set) |
| + f.journal(t, tc.op, hrRendered) |
| + q := f.request(heldReleasePublish) |
| + if q.HoldOp != tc.op { |
| + t.Fatalf("request hold op %q, want %q", q.HoldOp, tc.op) |
| + } |
| + resp, err := f.root.Serve(ctx, hrPeer, q) |
| + if err != nil || resp.Outcome != heldReleaseOutcomePublished { |
| + t.Fatalf("publish: %+v %v", resp, err) |
| + } |
| + if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q, want v7", v) |
| + } |
| + if len(f.sel.ids) != 1 || f.sel.ids[0] != (hostwrite.Identity{Release: "v7"}) { |
| + t.Fatalf("published under %v", f.sel.ids) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestHeldReleaseRootRefusesBeforeAnyEffect: every request whose tuple is |
| +// not the claim's, whose pins or render are not the record's and the |
| +// journal's, that names another account, app, box or region, or that |
| +// arrives while the claim grants no held write, is refused before root |
| +// selects, stages, publishes or writes a receipt. |
| +func TestHeldReleaseRootRefusesBeforeAnyEffect(t *testing.T) { |
| + ctx := context.Background() |
| + otherInc := strings.Repeat("c", 64) |
| + cases := []struct { |
| + name string |
| + action string |
| + state func(f *hrFixture) |
| + q func(q *heldReleaseRequest) |
| + }{ |
| + {"a stale incarnation", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Incarnation = otherInc }}, |
| + {"a stale stamp", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Stamp = 6 }}, |
| + {"a stale session", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Session = 2 }}, |
| + {"a stale generation", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Generation = "cur" }}, |
| + {"a stale op", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.MoveID, q.HoldOp = p1ID2, p1ID2 }}, |
| + {"a stale phase", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Phase = provision.LandProbing }}, |
| + {"a stale kind", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Kind = hostclaim.KindReverting }}, |
| + {"changed pins: another version", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Version = "v8" }}, |
| + {"changed pins: the record's", heldReleasePublish, func(f *hrFixture) { f.rec.Move.Inputs.Release = "v8" }, nil}, |
| + {"changed render", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.RenderHash = hrOther }}, |
| + {"a forged account", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Account = "zed" }}, |
| + {"a forged app", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.App = "other" }}, |
| + {"the wrong box", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Box = "ie-box" }}, |
| + {"the wrong region", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.Region = "ie" }}, |
| + {"another hold op", heldReleasePublish, nil, func(q *heldReleaseRequest) { q.HoldOp = hrRB }}, |
| + {"the row held by another op", heldReleasePublish, func(f *hrFixture) { f.row.HoldOp = p1ID2 }, nil}, |
| + {"no held row", heldReleasePublish, func(f *hrFixture) { f.row = nil }, nil}, |
| + {"the record's move is another", heldReleasePublish, func(f *hrFixture) { f.rec.Move.ID = p1ID2 }, nil}, |
| + {"the record is not at configured", heldReleasePublish, func(f *hrFixture) { f.rec.Move.LandingPhase = provision.LandLocal }, nil}, |
| + {"this box is not the executor", heldReleasePublish, func(f *hrFixture) { f.claim.Op.Exec = "ie-box" }, nil}, |
| + {"this box is declared lost", heldReleasePublish, func(f *hrFixture) { f.claim.Lost = []string{"uk-box"} }, nil}, |
| + {"a moving claim publishes nothing", heldReleasePublish, func(f *hrFixture) { |
| + f.claim.Op.Kind, f.claim.Op.Phase = hostclaim.KindMoving, "" |
| + }, func(q *heldReleaseRequest) { q.Kind, q.Phase = hostclaim.KindMoving, "" }}, |
| + {"a landing at local publishes nothing", heldReleasePublish, func(f *hrFixture) { |
| + f.claim.Op.Phase, f.rec.Move.LandingPhase = provision.LandLocal, provision.LandLocal |
| + }, func(q *heldReleaseRequest) { q.Phase = provision.LandLocal }}, |
| + {"no claim", heldReleasePublish, func(f *hrFixture) { f.claim.Host = "other.example.com" }, nil}, |
| + {"stage: the wrong box", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Box = "ie-box" }}, |
| + {"stage: a forged app", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.App = "other" }}, |
| + {"stage: a forged account", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Account = "zed" }}, |
| + {"stage: a stale session", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Session = 9 }}, |
| + {"stage: a stale incarnation", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.Incarnation = otherInc }}, |
| + {"stage: changed pins", heldReleaseStage, func(f *hrFixture) { f.rec.Move.Inputs.Release = "v8" }, nil}, |
| + {"stage: another target", heldReleaseStage, func(f *hrFixture) { f.claim.Op.Intent["target"] = "fr-box" }, nil}, |
| + {"stage: a render hash", heldReleaseStage, nil, func(q *heldReleaseRequest) { q.RenderHash = hrRendered }}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newHRFixture(t) |
| + if tc.state != nil { |
| + f.set(tc.state) |
| + } |
| + q := f.request(tc.action) |
| + if tc.q != nil { |
| + tc.q(&q) |
| + } |
| + _, err := f.root.Serve(ctx, hrPeer, q) |
| + if err == nil { |
| + t.Fatal("root accepted the request") |
| + } |
| + if errors.Is(err, errHeldReleaseNotYet) { |
| + t.Fatalf("refused as not yet: %v", err) |
| + } |
| + f.untouched(t) |
| + }) |
| + } |
| +} |
| + |
| +// TestHeldReleaseRootStages: a stage request stages and verifies the pinned |
| +// release into the shared version store and never touches live/<host>, and |
| +// selects nothing: a moving claim grants no held write. |
| +func TestHeldReleaseRootStages(t *testing.T) { |
| + ctx := context.Background() |
| + f := newHRFixture(t) |
| + f.set(func(f *hrFixture) { |
| + f.claim.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: p1ID, Exec: "ie-box", Participants: []string{"ie-box", "uk-box"}, |
| + Intent: map[string]string{"source": "ie-box", "target": "uk-box", "to": "uk"}} |
| + f.rec.Move.State, f.rec.Move.LandingPhase, f.rec.Move.Inputs = provision.MoveReady, "", nil |
| + f.row = nil |
| + }) |
| + resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleaseStage)) |
| + if err != nil || resp.Outcome != heldReleaseOutcomeStaged { |
| + t.Fatalf("stage: %+v %v", resp, err) |
| + } |
| + if _, err := os.Stat(release.VersionPath(f.opt, "bab", "v7", "app")); err != nil { |
| + t.Fatalf("v7 not staged: %v", err) |
| + } |
| + if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a stage published live/<host>: %v", err) |
| + } |
| + if n := f.sel.count(); n != 0 { |
| + t.Fatalf("a stage selected %d time(s)", n) |
| + } |
| + // A publish request against a moving claim is refused: the policy is |
| + // never widened. |
| + q := f.request(heldReleasePublish) |
| + if _, err := f.root.Serve(ctx, hrPeer, q); err == nil { |
| + t.Fatal("root published under a moving claim") |
| + } |
| + if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("live/<host> under a moving claim: %v", err) |
| + } |
| +} |
| + |
| +// TestHeldReleaseRootNotYetWithoutTheRenderJournal (platform-1e Q2): with |
| +// no render-hash journal, or one without the rendered step, root refuses |
| +// with the typed not-yet error at once. It selects nothing and holds no |
| +// lock, so the agent's retry finds the held-stage lock free. |
| +func TestHeldReleaseRootNotYetWithoutTheRenderJournal(t *testing.T) { |
| + ctx := context.Background() |
| + for _, tc := range []struct { |
| + name string |
| + prep func(t *testing.T, f *hrFixture) |
| + }{ |
| + {"no journal", func(t *testing.T, f *hrFixture) { |
| + f.data = t.TempDir() |
| + f.root.DataDir = f.data |
| + }}, |
| + {"no rendered step", func(t *testing.T, f *hrFixture) { |
| + f.data = t.TempDir() |
| + f.root.DataDir = f.data |
| + if err := moves.UpdateJournal(f.data, p1Host, p1ID, func(j *moves.Journal) error { |
| + j.Mark(landStepPreRender, `{"before":"absent","intended":["`+hrRendered+`"]}`) |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newHRFixture(t) |
| + tc.prep(t, f) |
| + start := time.Now() |
| + _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) |
| + if !errors.Is(err, errHeldReleaseNotYet) { |
| + t.Fatalf("publish without the journal: %v, want errHeldReleaseNotYet", err) |
| + } |
| + if d := time.Since(start); d > 5*time.Second { |
| + t.Fatalf("root waited %v for the journal", d) |
| + } |
| + f.untouched(t) |
| + if err := withHeldStageLock(f.data, p1Host, func() error { return nil }); err != nil { |
| + t.Fatalf("the held-stage lock after a not-yet: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestHeldReleaseRootReplayAndCrashRecovery: a replay after success is a |
| +// no-op that returns the same result (the receipt, keyed by session and |
| +// render hash); a crash between the publication and its receipt is |
| +// recovered by the replay, which publishes the same release again. |
| +func TestHeldReleaseRootReplayAndCrashRecovery(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("a replay after success", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + q := f.request(heldReleasePublish) |
| + first, err := f.root.Serve(ctx, hrPeer, q) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reads := f.claimReads |
| + q.RequestID = "req-2" |
| + again, err := f.root.Serve(ctx, hrPeer, q) |
| + if err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if again.Outcome != first.Outcome || !again.Replayed { |
| + t.Fatalf("replay = %+v, first = %+v", again, first) |
| + } |
| + if f.sel.count() != 1 || f.claimReads != reads { |
| + t.Fatalf("a replay selected %d time(s) and read the claim %d more time(s)", f.sel.count(), f.claimReads-reads) |
| + } |
| + }) |
| + |
| + t.Run("another session is not a replay", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 }) |
| + resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) |
| + if err != nil || resp.Replayed { |
| + t.Fatalf("a new session's request: %+v %v", resp, err) |
| + } |
| + if f.sel.count() != 2 { |
| + t.Fatalf("selections = %d, want 2", f.sel.count()) |
| + } |
| + }) |
| + |
| + t.Run("a stale request is not answered from an old receipt", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); err != nil { |
| + t.Fatal(err) |
| + } |
| + q := f.request(heldReleasePublish) |
| + q.Version = "v8" |
| + if _, err := f.root.Serve(ctx, hrPeer, q); err == nil { |
| + t.Fatal("another version was answered from the receipt") |
| + } |
| + }) |
| + |
| + t.Run("a crash between the publication and the receipt", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + crashed := false |
| + f.root.afterPublish = func(string) error { |
| + if !crashed { |
| + crashed = true |
| + return errCrash |
| + } |
| + return nil |
| + } |
| + if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); !errors.Is(err, errCrash) { |
| + t.Fatalf("first publish: %v, want the crash", err) |
| + } |
| + if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q after the crash", v) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.receipts, p1Host+".json")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a receipt before the crash point: %v", err) |
| + } |
| + resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) |
| + if err != nil || resp.Outcome != heldReleaseOutcomePublished || resp.Replayed { |
| + t.Fatalf("recovery: %+v %v", resp, err) |
| + } |
| + if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" || f.sel.count() != 2 { |
| + t.Fatalf("after recovery live = %q, selections %d", v, f.sel.count()) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.receipts, p1Host+".json")); err != nil { |
| + t.Fatalf("no receipt after recovery: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestHeldReleaseRequestShape: root refuses a request whose shape it cannot |
| +// trust before any read: the schema, the action, the names, the ids, the |
| +// incarnation, the stamp and the render hash. |
| +func TestHeldReleaseRequestShape(t *testing.T) { |
| + f := newHRFixture(t) |
| + for name, mutate := range map[string]func(q *heldReleaseRequest){ |
| + "schema": func(q *heldReleaseRequest) { q.Schema = "carlos.moves.held-stage/1" }, |
| + "action": func(q *heldReleaseRequest) { q.Action = "install" }, |
| + "no request id": func(q *heldReleaseRequest) { q.RequestID = "" }, |
| + "host": func(q *heldReleaseRequest) { q.Host = "../etc" }, |
| + "account": func(q *heldReleaseRequest) { q.Account = "Bab/x" }, |
| + "app": func(q *heldReleaseRequest) { q.App = "a/../b" }, |
| + "version": func(q *heldReleaseRequest) { q.Version = "../v7" }, |
| + "move id": func(q *heldReleaseRequest) { q.MoveID = "mv_x" }, |
| + "hold op": func(q *heldReleaseRequest) { q.HoldOp = "../" + p1ID }, |
| + "box": func(q *heldReleaseRequest) { q.Box = "" }, |
| + "region": func(q *heldReleaseRequest) { q.Region = "UK!" }, |
| + "incarnation": func(q *heldReleaseRequest) { q.Incarnation = "x" }, |
| + "stamp": func(q *heldReleaseRequest) { q.Stamp = 0 }, |
| + "kind": func(q *heldReleaseRequest) { q.Kind = hostclaim.KindServing }, |
| + "render hash": func(q *heldReleaseRequest) { q.RenderHash = "sha256:x" }, |
| + "no render hash": func(q *heldReleaseRequest) { q.RenderHash = "" }, |
| + "oversized phase": func(q *heldReleaseRequest) { q.Phase = strings.Repeat("p", 200) }, |
| + "oversized gen id": func(q *heldReleaseRequest) { q.Generation = strings.Repeat("g", 300) }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + q := f.request(heldReleasePublish) |
| + mutate(&q) |
| + reads := f.claimReads |
| + if _, err := f.root.Serve(context.Background(), hrPeer, q); !errors.Is(err, errHeldReleaseShape) { |
| + t.Fatalf("serve: %v, want errHeldReleaseShape", err) |
| + } |
| + if f.claimReads != reads { |
| + t.Fatal("a malformed request reached the claim") |
| + } |
| + }) |
| + } |
| + t.Run("root refuses a peer with no identity", func(t *testing.T) { |
| + if _, err := f.root.Serve(context.Background(), StopPeer{}, f.request(heldReleasePublish)); !errors.Is(err, ErrClaimStopIdentity) { |
| + t.Fatalf("serve: %v", err) |
| + } |
| + f.untouched(t) |
| + }) |
| + t.Run("the body is bounded and strict", func(t *testing.T) { |
| + raw, _ := json.Marshal(f.request(heldReleasePublish)) |
| + if _, err := decodeHeldReleaseRequest(strings.NewReader(strings.TrimSuffix(string(raw), "}") + `,"extra":1}`)); !errors.Is(err, errHeldReleaseShape) { |
| + t.Fatalf("unknown field: %v", err) |
| + } |
| + if _, err := decodeHeldReleaseRequest(strings.NewReader(string(raw) + `{}`)); !errors.Is(err, errHeldReleaseShape) { |
| + t.Fatalf("trailing data: %v", err) |
| + } |
| + if _, err := decodeHeldReleaseRequest(strings.NewReader(strings.Repeat(" ", heldReleaseRequestMax+1) + string(raw))); !errors.Is(err, errHeldReleaseShape) { |
| + t.Fatalf("oversized: %v", err) |
| + } |
| + if q, err := decodeHeldReleaseRequest(strings.NewReader(string(raw))); err != nil || q.Host != p1Host { |
| + t.Fatalf("a good body: %+v %v", q, err) |
| + } |
| + }) |
| +} |
| + |
| +// TestHeldReleaseRootSelectionMustBeTheRequests: the claim moving on between |
| +// root's read and its own selection (a new session admitted) is refused |
| +// before anything is published: the selection's tuple must be the |
| +// request's. A selection the claim refuses publishes nothing either. |
| +func TestHeldReleaseRootSelectionMustBeTheRequests(t *testing.T) { |
| + ctx := context.Background() |
| + t.Run("the claim moved on", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + works := f.root.Works |
| + f.root.Works = func(ctx context.Context, actor hostbox.Actor, host, action string) (hostwrite.Work, error) { |
| + f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 }) |
| + return works(ctx, actor, host, action) |
| + } |
| + if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("publish across a session change: %v, want hostbox.ErrChanged", err) |
| + } |
| + if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("published across a session change: %v", err) |
| + } |
| + if len(f.sel.ids) != 0 { |
| + t.Fatalf("wrote under %v", f.sel.ids) |
| + } |
| + }) |
| + t.Run("the selection is refused", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + f.sel.refuse = hostbox.ErrChanged |
| + if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("publish with the selection refused: %v", err) |
| + } |
| + if _, err := os.Lstat(release.LiveLink(f.opt, p1Host)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("published with the selection refused: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestHeldReleaseRouteIsMovesOnly: with CARLOS_MOVES_PHASE1 off the claim |
| +// root's handler is returned unchanged, so the route refuses as on main. |
| +func TestHeldReleaseRouteIsMovesOnly(t *testing.T) { |
| + h := ClaimRootWithLaunch{} |
| + got, err := WithProductionMovesHeldRelease(context.Background(), h, nil, func(string) string { return "" }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if r, ok := got.(ClaimRootWithLaunch); !ok || r.HeldRelease != nil { |
| + t.Fatalf("moves off wired %#v", got) |
| + } |
| +} |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| index c3707c4d5..a8e51995d 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -256,8 +256,10 @@ func MaterializeHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *Dig |
| // 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. |
| +// A stage this process cannot write is ErrStageBlocked. Under the claim the |
| +// sandboxed agent never calls this on a hardened box: it asks root, which |
| +// calls it inside root's own held-release selection (moves Task 7b, |
| +// internal/host movesheldrelease_root.go). |
| // |
| // pub and r are §4.1's evidence, as for MaterializeHeld: the stage |
| // (stageHeld) runs HeldAuthorized first and binds its digest, so a build no |
| @@ -338,8 +340,9 @@ func StageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRat |
| // A refusal of either D3 check is ErrHeldNotProven, and bind's is recorded |
| // in the move's held namespace as the publication records it; a build §4.1 |
| // does not authorize is ErrHeldNotAuthorized, before anything is |
| -// downloaded; a version store this process cannot write is ErrStageBlocked |
| -// (there is no root relay under the claim). A nil proof is refused. |
| +// downloaded; a version store this process cannot write is ErrStageBlocked, |
| +// which the agent answers by asking root to run this same stage (moves Task |
| +// 7b, internal/host movesheldrelease_root.go). A nil proof is refused. |
| func StageHeldVerified(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate) (*Manifest, error) { |
| if err := heldShape(rt, version); err != nil { |
| return nil, err |