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/ 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/. 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/ 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 // heldReleaseConcurrency bounds the held-release requests root serves at // once across hosts (fix round 2, P3-4); each host has one at a time. heldReleaseConcurrency = 3 ) 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") // errHeldReleaseReceipt is a publication root made whose receipt did not // persist (fix round 1, item 1): root never answers published without the // evidence a replay is answered from, so the agent asks again, and the // retry runs the whole path (the same render, the same selection checks) // and publishes the same release. errHeldReleaseReceipt = errors.New("moves: held release: published, but root's receipt did not persist") // errHeldReleaseBusy is root at its bound (fix round 2, P3-4): a request // for the host is already in flight, or heldReleaseConcurrency are. It // answers 503, which the agent reads as unavailable and retries. errHeldReleaseBusy = errors.New("moves: held release: root is busy with another request") ) // 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:"). 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 }