carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net"
13 "net/http"
14 "regexp"
15 "time"
16
17 "github.com/carlosframework/platform/internal/controlname"
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/moves"
20 "github.com/carlosframework/platform/internal/provision"
21 "github.com/carlosframework/platform/internal/regionname"
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26// The held release under the host claim (moves after claim Plan 12, Task
27// 7b; task-7b-design.md with platform-1e's binding review of 2026-10-12).
28//
29// Under the claim the sandboxed agent cannot write /opt/carlos, so it can
30// neither stage T7's release nor publish live/<host> for the held row. It
31// asks root instead, over the root instance-control socket (claim Plan 11's
32// R10 transport: POST /claim/held-release, the peer authenticated as the
33// edge unit's main process), never through a file root would read by name
34// (#267). There are two actions:
35//
36// - stage: T7. Root stages and verifies the pinned release into the
37// shared version store (release.StageHeldVerified) and never touches
38// live/<host>. The agent then verifies the staged bytes itself; root's
39// answer is a hint.
40// - publish: the landing's configured step, a dirty return's configured
41// step and a probe's release. Root publishes live/<host> inside its own
42// held-release selection (release.MaterializeHeldUnder with the
43// held-stage lock), and writes a receipt keyed by the session and the
44// render hash, so a replay after success is a no-op with the same
45// answer.
46//
47// Every request field is an expectation root compares with authoritative
48// state it reads itself (the claim, the record, the registry row, the
49// render journal); none grants anything. The route is new, so an older root
50// answers 404 and never reads a stage as a publication, and the old file
51// relay (release.ServeHeldStageRequests) is never handed a request by this
52// path.
53
54const (
55 heldReleaseSchema = "carlos.moves.held-release/1"
56 heldReleaseStage = "stage"
57 heldReleasePublish = "publish"
58 heldReleaseOutcomeStaged = "staged"
59 heldReleaseOutcomePublished = "published"
60 heldReleasePath = "/claim/held-release"
61 // heldReleaseRequestMax bounds a request body: its fields fit many times
62 // over.
63 heldReleaseRequestMax = 4096
64 // heldReleaseDeadline bounds one call. A stage may download the build,
65 // as T7 does in the agent with the claim off.
66 heldReleaseDeadline = 15 * time.Minute
67 // heldReleaseConcurrency bounds the held-release requests root serves at
68 // once across hosts (fix round 2, P3-4); each host has one at a time.
69 heldReleaseConcurrency = 3
70)
71
72var (
73 errHeldReleaseShape = errors.New("moves: held release: the request is malformed")
74 errHeldReleaseUnavailable = errors.New("moves: held release: root serves no held-release requests")
75 // errHeldReleaseNotYet is root finding no render hash journalled for
76 // the publication (platform-1e Q2): it refuses at once, holding nothing,
77 // and the agent asks again on a later pass.
78 errHeldReleaseNotYet = errors.New("moves: held release: the held render's hash is not journalled yet")
79 errHeldReleaseUnsafe = errors.New("moves: held release: the render journal is not a regular file with one link and a bounded size")
80 // errHeldReleaseReceipt is a publication root made whose receipt did not
81 // persist (fix round 1, item 1): root never answers published without the
82 // evidence a replay is answered from, so the agent asks again, and the
83 // retry runs the whole path (the same render, the same selection checks)
84 // and publishes the same release.
85 errHeldReleaseReceipt = errors.New("moves: held release: published, but root's receipt did not persist")
86 // errHeldReleaseBusy is root at its bound (fix round 2, P3-4): a request
87 // for the host is already in flight, or heldReleaseConcurrency are. It
88 // answers 503, which the agent reads as unavailable and retries.
89 errHeldReleaseBusy = errors.New("moves: held release: root is busy with another request")
90)
91
92// heldReleaseRequest is one request. Kind, Phase, Incarnation, Stamp,
93// Session and Generation are the claim tuple the agent observed; MoveID is
94// the claim's op id; HoldOp the op its registry row is held by (the
95// rollback id for a return); RenderHash, on a publication, the held
96// render's journalled hash ("sha256:<hex>").
97type heldReleaseRequest struct {
98 Schema string `json:"schema"`
99 RequestID string `json:"request_id"`
100 Action string `json:"action"`
101 Host string `json:"host"`
102 Account string `json:"account"`
103 App string `json:"app"`
104 Version string `json:"version"`
105 MoveID string `json:"move_id"`
106 HoldOp string `json:"hold_op"`
107 Box string `json:"box"`
108 Region string `json:"region"`
109 Incarnation string `json:"incarnation"`
110 Stamp uint64 `json:"stamp"`
111 Session uint64 `json:"session"`
112 Generation string `json:"generation"`
113 Kind string `json:"kind"`
114 Phase string `json:"phase"`
115 RenderHash string `json:"render_hash,omitempty"`
116}
117
118type heldReleaseResponse struct {
119 Schema string `json:"schema"`
120 RequestID string `json:"request_id"`
121 Outcome string `json:"outcome"`
122 Replayed bool `json:"replayed,omitempty"`
123}
124
125var (
126 heldReleaseIDShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
127 heldReleaseBoxShape = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
128 heldReleaseHexShape = regexp.MustCompile(`^[0-9a-f]{64}$`)
129 heldReleaseRenderShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
130 heldReleaseTokenShape = regexp.MustCompile(`^[A-Za-z0-9._:@-]{0,128}$`)
131)
132
133// validate is the shape root trusts before any read: every name a path
134// component or a key could be built from, and the action's own fields.
135func (q heldReleaseRequest) validate() error {
136 bad := func(what string) error { return fmt.Errorf("%w: %s", errHeldReleaseShape, what) }
137 switch {
138 case q.Schema != heldReleaseSchema:
139 return bad("schema")
140 case q.Action != heldReleaseStage && q.Action != heldReleasePublish:
141 return bad("action")
142 case !heldReleaseIDShape.MatchString(q.RequestID):
143 return bad("request id")
144 case !controlname.ValidHostShape(q.Host):
145 return bad("host")
146 case !release.ValidAccount(q.Account):
147 return bad("account")
148 case !release.ValidApp(q.App):
149 return bad("app")
150 case !release.ValidVersion(q.Version):
151 return bad("version")
152 case !moves.ValidOpID(q.MoveID) || moves.OpIDKind(q.MoveID) != "move":
153 return bad("move id")
154 case !moves.ValidOpID(q.HoldOp):
155 return bad("hold op")
156 case !heldReleaseBoxShape.MatchString(q.Box):
157 return bad("box")
158 case q.Region != regionname.HomeRegion && (q.Region == "" || regionname.PlacementRegion(q.Region) != q.Region):
159 return bad("region")
160 case !heldReleaseHexShape.MatchString(q.Incarnation):
161 return bad("incarnation")
162 case q.Stamp == 0:
163 return bad("stamp")
164 case !heldReleaseTokenShape.MatchString(q.Generation) || len(q.Phase) > 64 || !heldReleaseTokenShape.MatchString(q.Phase):
165 return bad("generation or phase")
166 }
167 switch q.Kind {
168 case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting:
169 default:
170 return bad("kind")
171 }
172 if q.Action == heldReleasePublish && !heldReleaseRenderShape.MatchString(q.RenderHash) {
173 return bad("render hash")
174 }
175 if q.Action == heldReleaseStage && q.RenderHash != "" {
176 return bad("a stage names no render")
177 }
178 return nil
179}
180
181// decodeHeldReleaseRequest reads one request body: bounded, one JSON object,
182// no field the protocol does not carry.
183func decodeHeldReleaseRequest(r io.Reader) (heldReleaseRequest, error) {
184 body, err := io.ReadAll(io.LimitReader(r, heldReleaseRequestMax+1))
185 if err != nil || len(body) > heldReleaseRequestMax {
186 return heldReleaseRequest{}, fmt.Errorf("%w: unreadable or oversized", errHeldReleaseShape)
187 }
188 dec := json.NewDecoder(bytes.NewReader(body))
189 dec.DisallowUnknownFields()
190 var q heldReleaseRequest
191 if err := dec.Decode(&q); err != nil {
192 return heldReleaseRequest{}, fmt.Errorf("%w: %v", errHeldReleaseShape, err)
193 }
194 if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
195 return heldReleaseRequest{}, fmt.Errorf("%w: trailing data", errHeldReleaseShape)
196 }
197 return q, nil
198}
199
200func newHeldReleaseRequestID() string {
201 var b [16]byte
202 if _, err := rand.Read(b[:]); err != nil {
203 return fmt.Sprintf("t%d", time.Now().UnixNano())
204 }
205 return hex.EncodeToString(b[:])
206}
207
208// heldReleaseRelay is the agent's way to root.
209type heldReleaseRelay interface {
210 HeldRelease(context.Context, heldReleaseRequest) (heldReleaseResponse, error)
211}
212
213// heldReleaseRootClient is the agent's client of POST /claim/held-release
214// on the root instance-control socket.
215type heldReleaseRootClient struct {
216 Socket string
217 // Dial is the dialer; nil dials Socket as a unix socket.
218 Dial func(context.Context, string) (net.Conn, error)
219}
220
221func newHeldReleaseRootClient(socket string) *heldReleaseRootClient {
222 return &heldReleaseRootClient{Socket: socket}
223}
224
225// HeldRelease sends q and returns root's answer for exactly q. Root's
226// refusals come back typed: a stale or forged request wraps
227// release.ErrStaleHeldStage, a release D3 does not admit
228// release.ErrHeldNotProven, no render journal errHeldReleaseNotYet, and a
229// root with no such route (an older build, or one without its providers)
230// errHeldReleaseUnavailable.
231func (c *heldReleaseRootClient) HeldRelease(ctx context.Context, q heldReleaseRequest) (heldReleaseResponse, error) {
232 if c == nil || c.Socket == "" {
233 return heldReleaseResponse{}, errHeldReleaseUnavailable
234 }
235 body, err := json.Marshal(q)
236 if err != nil {
237 return heldReleaseResponse{}, err
238 }
239 ctx, cancel := context.WithTimeout(ctx, heldReleaseDeadline)
240 defer cancel()
241 dial := c.Dial
242 if dial == nil {
243 dial = func(ctx context.Context, path string) (net.Conn, error) {
244 return (&net.Dialer{}).DialContext(ctx, "unix", path)
245 }
246 }
247 transport := &http.Transport{DisableKeepAlives: true, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dial(ctx, c.Socket) }}
248 defer transport.CloseIdleConnections()
249 req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://control"+heldReleasePath, bytes.NewReader(body))
250 if err != nil {
251 return heldReleaseResponse{}, err
252 }
253 req.Header.Set("Content-Type", "application/json")
254 resp, err := (&http.Client{Transport: transport}).Do(req)
255 if err != nil {
256 return heldReleaseResponse{}, fmt.Errorf("moves: held release: %w", err)
257 }
258 defer resp.Body.Close()
259 defer io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
260 switch resp.StatusCode {
261 case http.StatusOK:
262 case http.StatusBadRequest:
263 return heldReleaseResponse{}, errHeldReleaseShape
264 case http.StatusForbidden:
265 return heldReleaseResponse{}, ErrClaimStopIdentity
266 case http.StatusConflict:
267 return heldReleaseResponse{}, staleHeldStage("root refused the held release")
268 case http.StatusUnprocessableEntity:
269 return heldReleaseResponse{}, fmt.Errorf("%w: root's proof gate refused it", release.ErrHeldNotProven)
270 case http.StatusTooEarly:
271 return heldReleaseResponse{}, errHeldReleaseNotYet
272 case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusServiceUnavailable:
273 return heldReleaseResponse{}, errHeldReleaseUnavailable
274 default:
275 return heldReleaseResponse{}, fmt.Errorf("moves: held release: root answered %d", resp.StatusCode)
276 }
277 dec := json.NewDecoder(io.LimitReader(resp.Body, heldReleaseRequestMax))
278 dec.DisallowUnknownFields()
279 var out heldReleaseResponse
280 if err := dec.Decode(&out); err != nil {
281 return heldReleaseResponse{}, fmt.Errorf("moves: held release: root's answer: %w", err)
282 }
283 want := heldReleaseOutcomeStaged
284 if q.Action == heldReleasePublish {
285 want = heldReleaseOutcomePublished
286 }
287 if out.Schema != heldReleaseSchema || out.RequestID != q.RequestID || out.Outcome != want {
288 return heldReleaseResponse{}, fmt.Errorf("moves: held release: root's answer is not for this request: %+v", out)
289 }
290 return out, nil
291}
292
293// heldReleaseVia is how the agent's held-release hooks reach root: the
294// relay (nil: no root path, Task 7's local behaviour), this box's
295// placement code, sent as its engine region, and the claim reader (nil:
296// hostclaim.Read over the hook's store; a test seam).
297type heldReleaseVia struct {
298 Relay heldReleaseRelay
299 Region string
300 Claims func(context.Context, string) (hostclaim.Observed, error)
301}
302
303func (v heldReleaseVia) claim(ctx context.Context, s release.Store, host string) (hostclaim.Observed, error) {
304 if v.Claims != nil {
305 return v.Claims(ctx, host)
306 }
307 return hostclaim.Read(ctx, s, host)
308}
309
310// request builds the agent's request for action from its own reads: the
311// claim tuple as it observes it now, moveID ("" takes the claim's op id)
312// and holdOp from its record or route, and on a publication the render hash
313// configured journalled under holdOp. None of it is authority: root reads
314// each again and compares.
315func (v heldReleaseVia) request(ctx context.Context, s release.Store, action, host, account, app, version, moveID, holdOp, box, dataDir string) (heldReleaseRequest, error) {
316 obs, err := v.claim(ctx, s, host)
317 if err != nil {
318 return heldReleaseRequest{}, err
319 }
320 if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil {
321 return heldReleaseRequest{}, staleHeldStage("the host has no claim")
322 }
323 c := obs.Claim
324 if moveID == "" {
325 moveID = c.Op.ID
326 }
327 q := heldReleaseRequest{Schema: heldReleaseSchema, RequestID: newHeldReleaseRequestID(), Action: action, Host: host, Account: account, App: app,
328 Version: version, MoveID: moveID, HoldOp: holdOp, Box: box, Region: regionname.PlacementRegion(v.Region),
329 Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp, Session: c.Holder.Session, Generation: c.Holder.Generation,
330 Kind: c.Op.Kind, Phase: c.Op.Phase}
331 if action == heldReleasePublish {
332 j, err := moves.ReadJournal(dataDir, host, holdOp)
333 if err != nil {
334 return heldReleaseRequest{}, err
335 }
336 if q.RenderHash = j.Steps[landStepRendered]; q.RenderHash == "" {
337 return heldReleaseRequest{}, errHeldReleaseNotYet
338 }
339 }
340 return q, nil
341}
342
343// heldReleaseHoldOp is the op rec's registry row is held by: the rollback
344// id while a rollback runs (ruling J3), else the move id.
345func heldReleaseHoldOp(rec provision.Record) string {
346 if m := rec.Move; m != nil && m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.ID != "" {
347 return m.Rollback.ID
348 }
349 if rec.Move == nil {
350 return ""
351 }
352 return rec.Move.ID
353}
354
355// stage asks root to stage T7's release for rec (the move's target, its
356// row held, if at all, by the move id).
357func (v heldReleaseVia) stage(ctx context.Context, s release.Store, rec provision.Record, box, version string) error {
358 q, err := v.request(ctx, s, heldReleaseStage, rec.Host, rec.Account, rec.App, version, rec.Move.ID, rec.Move.ID, box, "")
359 if err != nil {
360 return err
361 }
362 _, err = v.Relay.HeldRelease(ctx, q)
363 return err
364}
365
366// publish asks root to publish version for rt under holdOp's render
367// journal.
368func (v heldReleaseVia) publish(ctx context.Context, s release.Store, rt registry.Route, moveID, holdOp, box, dataDir, version string) error {
369 q, err := v.request(ctx, s, heldReleasePublish, rt.Host, release.RouteAccount(rt), rt.App, version, moveID, holdOp, box, dataDir)
370 if err != nil {
371 return err
372 }
373 _, err = v.Relay.HeldRelease(ctx, q)
374 return err
375}
376