carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "slices"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/carlosframework/platform/internal/controlname"
18 "github.com/carlosframework/platform/internal/hostwrite"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/safefile"
21)
22
23// ErrStageBlocked is MaterializeHeld reporting that this process cannot
24// write the version store (StageBlocked: EROFS, EPERM or EACCES). The
25// sandboxed agent gets it on every box, because carlos-edge.service writes
26// only /data and /run/carlos; its caller relays the stage to the root pass
27// (a held-stage request) instead of failing (moves design §7.3, T7).
28var ErrStageBlocked = errors.New("release: this process cannot write the version store, so the root stage pass stages this release")
29
30// ErrStaleHeldStage is a held-stage request its authority refused: the
31// operation it names is no longer the one the host claim, the registry row
32// or the source's report names, so the request is dropped and live/<host>
33// is never repointed for it.
34var ErrStaleHeldStage = errors.New("release: held stage request no longer matches its move")
35
36// ErrHeldClaimWired is MaterializeHeld refusing while CARLOS_HOST_CLAIM is
37// on: the live link would be written outside any claim selection. Plan 2
38// adds the held-row selection under the landing op (ruling F10, applied
39// here as it is to appconfig.RenderHeld).
40var ErrHeldClaimWired = errors.New("release: held rows are not staged while CARLOS_HOST_CLAIM is on")
41
42// ErrHeldNotProven is a held stage D3 refused (controller ruling D3-held):
43// this box's proof policy lists the app (or does not load), and the pinned
44// release has neither a verified proof for its bytes nor a place in this
45// box's healthy history, or its staged bytes are not the proven ones.
46// Returned before anything is published, so live/<host> is untouched.
47var ErrHeldNotProven = errors.New("release: the held release is not proven for this box")
48
49// ErrHeldNotAuthorized is a held stage of a build §4.1 does not authorize:
50// no promotion record, and no verified pointer of the app names it (settings
51// spec rev 11 §4.3a). The request is dropped, never retried, so it is
52// returned only when every pointer of the app was read and judged.
53var ErrHeldNotAuthorized = errors.New("release: the held release is not authorized: no promotion record and no signed pointer names it")
54
55// HeldAuthorized is §4.1 for a move's held stage. want is the digest staging
56// must bind: the record's, else a naming pointer entry's, preferring one that
57// signs a digest ("" only when every admitted naming entry comes from a
58// pointer whose channel carries no digests at all, today's pointer trust).
59//
60// Every pointer that verifies goes through the box's ratchet, whether or not
61// it names the version: §4.6 remembers a channel from the moment the box has
62// verified digests for it, so a digestless replay later is refused (ruling
63// R15). That observation never changes this answer; only a naming pointer's
64// ratchet outcome does.
65//
66// A pointer that is gone, fails verification, does not name the version, or
67// that the box's ratchet refuses is not evidence and is skipped. So is a
68// naming entry with no digest on a pointer whose channel carries digests: a
69// legacy build is never a target once its channel has any digest (§4.6,
70// ruling R16). A read that fails any other way, or a ratchet that cannot
71// tell about a naming pointer (the mark could not be persisted or read), is
72// returned as is: the answer is unknown, not no, so the root relay keeps the
73// request for the next pass.
74func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, acct, app, version string) (string, error) {
75 rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, version)
76 if err == nil {
77 return rec.ManifestDigest, nil
78 }
79 if !errors.Is(err, ErrNoPromotionRecord) {
80 return "", err
81 }
82 prefix := ChannelsPrefix(acct, app)
83 keys, err := s.List(ctx, prefix)
84 if err != nil {
85 return "", err
86 }
87 named := false // some admitted pointer of a digestless channel names the version
88 for _, k := range keys {
89 name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json")
90 if !ok || name == "" {
91 continue
92 }
93 raw, _, err := GetQualified(ctx, s, ChannelKey(acct, app, name))
94 if errors.Is(err, os.ErrNotExist) {
95 continue // gone since the list
96 }
97 if err != nil {
98 return "", fmt.Errorf("release: held stage of %s/%s at %s: reading channel %s: %w", acct, app, version, name, err)
99 }
100 p, err := verifyPointer(pub, raw, acct, app, name)
101 if err != nil {
102 continue // a pointer that does not verify is not evidence
103 }
104 // Observed first, so a verified pointer with digests marks its channel
105 // whatever it names (§4.6 "once verified"). On a pointer that does not
106 // name the version the outcome is dropped: a refusal or an unpersisted
107 // mark there is no evidence either way, and the ratchet's in-memory
108 // mark keeps that channel refusing digestless pointers regardless.
109 rerr := r.Admit(acct, p)
110 if p.Version != version && !slices.Contains(p.History, version) {
111 continue
112 }
113 // The box's ratchet and CARLOS_REQUIRE_POINTER_DIGEST apply here as
114 // they do to adoption (§4.6): a pointer the ratchet refuses is not
115 // evidence. A nil ratchet would admit everything, so it is refused.
116 if r == nil {
117 return "", fmt.Errorf("%w: no pointer digest ratchet", ErrHeldNotAuthorized)
118 }
119 if rerr != nil {
120 if errors.Is(rerr, ErrPointerWithoutDigests) && !errors.Is(rerr, errRatchetMarkUnreadable) {
121 continue // the ratchet's verdict: this pointer is refused
122 }
123 return "", fmt.Errorf("release: held stage of %s/%s at %s: channel %s: %w", acct, app, version, name, rerr)
124 }
125 if d := p.DigestOf(version); d != "" {
126 return d, nil
127 }
128 if p.ChannelHasDigests() {
129 continue // a digestless entry on a digested channel is not evidence
130 }
131 named = true
132 }
133 if named {
134 return "", nil
135 }
136 return "", fmt.Errorf("%w: %s/%s at %s", ErrHeldNotAuthorized, acct, app, version)
137}
138
139var (
140 errHeldNoGuard = errors.New("release: a held publication needs its authority")
141 errHeldNoProofGate = errors.New("release: a held stage needs its proof gate")
142)
143
144// materializeHeldStage is MaterializeHeld's staging step, ensureStagedDigest
145// bound to the digest HeldAuthorized returned (want "" is ensureStaged
146// exactly), as a variable so a test can make it fail the way a read-only
147// mount does.
148var materializeHeldStage = func(ctx context.Context, s Store, optDir, acct, app, ver, host, want string) (*Manifest, error) {
149 return ensureStagedDigest(ctx, s, optDir, acct, app, ver, host, want, stageCheck{})
150}
151
152// heldBeforeSwap runs inside the publication between bind and the link
153// swap. A test seam: the window a replaced staged file lands in.
154var heldBeforeSwap = func() {}
155
156// heldAfterSwap runs inside the publication between the link swap and the
157// second bind. A test seam: the window in which live/<host> names a version
158// whose bytes are not yet vouched for, which the rollback may undo.
159var heldAfterSwap = func() {}
160
161// ErrHeldNotLive is VerifyHeldLive finding live/<host> on another version,
162// or on none: the held release is not published on this box.
163var ErrHeldNotLive = errors.New("release: live/<host> does not name the held release")
164
165var errHeldNoLock = errors.New("release: a held reading needs the publication's lock")
166
167// VerifyHeldLive is T7's reading of a published held release (final review
168// finding 1). A link that names the version is no evidence by itself: the
169// publication's rollback can undo it, and the bytes it names or the box's
170// proof policy can change after it was written. So the reading passes only
171// when, now:
172//
173// - D3 admits the release under the policy in force (heldProofAdmit, the
174// publication's own admit, after BeginPass re-reads the policy), and
175// - under lock, live/<host> names version and bind accepts the bytes it
176// names (ProofGate.bindRecording, the publication's own check; no
177// refusal is recorded, since a reading changes nothing).
178//
179// lock must be the lock the publication's guard holds across the swap, the
180// re-hash and the rollback (internal/host withHeldStageLock), so a reading
181// never sees a link a failing publication is about to take back. A nil
182// lock or gate is refused. A link on another version, or none, is
183// ErrHeldNotLive; a refusal is ErrHeldNotProven.
184//
185// It writes nothing, so it does not consult CARLOS_HOST_CLAIM: under the
186// claim it is the configured step's reading of the release it published
187// under the held-release selection (moves Task 7, Paul's T7 ruling of
188// 2026-10-12).
189func VerifyHeldLive(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, lock func(read func() error) error) error {
190 if err := heldShape(rt, version); err != nil {
191 return err
192 }
193 return verifyHeldLive(ctx, s, optDir, rt, version, proof, lock)
194}
195
196// VerifyHeldLiveUnder is VerifyHeldLive for a held row under the host
197// claim (region HA Task 8c), as MaterializeHeldUnder is MaterializeHeld's:
198// the same reading under the same lock, with heldShape in place of the
199// CARLOS_HOST_CLAIM refusal. It writes nothing; the claim's selection
200// authorized the publication it reads.
201func VerifyHeldLiveUnder(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, lock func(read func() error) error) error {
202 if err := heldShape(rt, version); err != nil {
203 return err
204 }
205 return verifyHeldLive(ctx, s, optDir, rt, version, proof, lock)
206}
207
208// verifyHeldLive is VerifyHeldLive and VerifyHeldLiveUnder after their
209// shape checks.
210func verifyHeldLive(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate, lock func(read func() error) error) error {
211 if proof == nil {
212 return errHeldNoProofGate
213 }
214 if lock == nil {
215 return errHeldNoLock
216 }
217 // A peek, so a release not yet published is not admitted twice (the
218 // stage that follows admits it). Only the reading under lock passes.
219 if live, err := LiveVersion(optDir, rt.Host); err != nil {
220 return err
221 } else if live != version {
222 return ErrHeldNotLive
223 }
224 proof.BeginPass()
225 ptr, v, err := heldProofAdmit(ctx, s, optDir, rt, version, proof)
226 if err != nil {
227 return err
228 }
229 return lock(func() error {
230 live, err := LiveVersion(optDir, rt.Host)
231 if err != nil {
232 return err
233 }
234 if live != version {
235 return ErrHeldNotLive
236 }
237 if !proof.bindRecording(optDir, rt, ptr, v, live, proof.now(), func(Refusal) error { return nil }) {
238 return fmt.Errorf("%w: %s at %s: the bytes live/%s names are not the proven ones", ErrHeldNotProven, rt.Host, version, rt.Host)
239 }
240 return nil
241 })
242}
243
244// MaterializeHeld is StageHeld then PublishHeld: version of rt's app is
245// staged, and live/<host> is pointed at it inside guard. A held row runs
246// exactly the pinned release, so there is no channel resolution, no
247// bake-hold check and no restart. When this process cannot write optDir it
248// returns ErrStageBlocked wrapping the cause and has written nothing to
249// live/<host>.
250//
251// proof is D3's gate, applied as adoption applies it (ProofGate.admit via
252// heldProofAdmit, ProofGate.bind in publishHeld): admit before staging, so an unproven release is neither
253// downloaded nor relayed to a root pass, and bind before publishing. A
254// refusal is ErrHeldNotProven and live/<host> is untouched. A nil proof is
255// refused, as a nil guard is.
256//
257// pub and r are §4.1's evidence for the stage (StageHeld): a build no
258// signed promotion record and no ratchet-admitted pointer of the app
259// authorizes is ErrHeldNotAuthorized, before anything is downloaded.
260func MaterializeHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error) (*Manifest, error) {
261 return materializeHeldChecked(ctx, s, pub, r, optDir, rt, version, proof, guard)
262}
263
264func materializeHeldChecked(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error, pins ...PinSource) (*Manifest, error) {
265 if err := heldStageable(rt, version); err != nil {
266 return nil, err
267 }
268 return materializeHeld(ctx, s, pub, r, optDir, rt, version, proof, guard, pins...)
269}
270
271// MaterializeHeldUnder is MaterializeHeld for a held row under the host
272// claim (moves Phase 1 Plan 3 Task 5, ruling H13). As adoption does under
273// the claim, the version is staged outside the selection (the version store
274// is shared, and a download must not hold the hostguard flock), and the
275// host-specific publication, the bind, the live/<host> swap and its
276// rollback, runs inside work under the identity naming exactly version (the
277// held-release action's identity). guard, when not nil, runs inside work
278// around the publication (the caller's held-stage lock). D3's admit and bind
279// are MaterializeHeld's. A nil work is refused before anything is staged; it
280// does not consult CARLOS_HOST_CLAIM, since the selection is the authority.
281// A stage this process cannot write is ErrStageBlocked. Under the claim the
282// sandboxed agent never calls this on a hardened box: it asks root, which
283// calls it inside root's own held-release selection (moves Task 7b,
284// internal/host movesheldrelease_root.go).
285//
286// pub and r are §4.1's evidence, as for MaterializeHeld: the stage
287// (stageHeld) runs HeldAuthorized first and binds its digest, so a build no
288// signed promotion record and no ratchet-admitted pointer of the app
289// authorizes is ErrHeldNotAuthorized, before anything is downloaded. The
290// claim's selection authorizes the write, not the build.
291func MaterializeHeldUnder(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, work hostwrite.Work, guard func(publish func() error) error) (*Manifest, error) {
292 return MaterializeHeldUnderWithPins(ctx, s, pub, r, optDir, rt, version, proof, work, guard)
293}
294
295// MaterializeHeldUnderWithPins adds the root reservation check under the caller's
296// release exclusion. Agent callers retain the original entry point.
297func MaterializeHeldUnderWithPins(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, work hostwrite.Work, guard func(publish func() error) error, pins ...PinSource) (*Manifest, error) {
298 if err := heldShape(rt, version); err != nil {
299 return nil, err
300 }
301 if work == nil {
302 return nil, errHeldNoGuard
303 }
304 id := hostwrite.Identity{Release: version}
305 return materializeHeld(ctx, s, pub, r, optDir, rt, version, proof, func(publish func() error) error {
306 return work.With(ctx, id, func() error {
307 if guard == nil {
308 return publish()
309 }
310 return guard(publish)
311 })
312 }, pins...)
313}
314
315// materializeHeld is MaterializeHeld and MaterializeHeldUnder after their
316// admissions: D3's admit, the authorized stage (stageHeld), then publication.
317func materializeHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, guard func(publish func() error) error, pins ...PinSource) (*Manifest, error) {
318 if guard == nil {
319 return nil, errHeldNoGuard
320 }
321 if proof == nil {
322 return nil, errHeldNoProofGate
323 }
324 proof.BeginPass()
325 ptr, v, err := heldProofAdmit(ctx, s, optDir, rt, version, proof)
326 if err != nil {
327 return nil, err
328 }
329 m, err := stageHeld(ctx, s, pub, r, optDir, rt, version, pins...)
330 if err != nil {
331 return nil, err
332 }
333 if err := publishHeld(optDir, rt, version, proof, ptr, v, guard); err != nil {
334 return nil, err
335 }
336 return m, nil
337}
338
339// StageHeldVerified is T7 under the host claim (moves design §7.3 and §9.2
340// as amended on 2026-10-12, Paul's T7 ruling): the pinned version is staged
341// into the shared version store and verified, and live/<host> is never
342// touched. Its publication is the landing's, at configured, under the
343// held-release selection (MaterializeHeldUnder). As adoption does under the
344// claim, the stage runs outside any selection: the version store is shared
345// and a stage is no host's write. So, like MaterializeHeldUnder, it does not
346// consult CARLOS_HOST_CLAIM.
347//
348// Verified is everything MaterializeHeld checks short of the swap: D3's
349// admit before anything is staged (heldProofAdmit), §4.1's authorization
350// and the stage bound to its digest (stageHeld), then D3's bind on the
351// staged bytes, which hashes the version's own file and so needs no link.
352// A refusal of either D3 check is ErrHeldNotProven, and bind's is recorded
353// in the move's held namespace as the publication records it; a build §4.1
354// does not authorize is ErrHeldNotAuthorized, before anything is
355// downloaded; a version store this process cannot write is ErrStageBlocked,
356// which the agent answers by asking root to run this same stage (moves Task
357// 7b, internal/host movesheldrelease_root.go). A nil proof is refused.
358func StageHeldVerified(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate) (*Manifest, error) {
359 return StageHeldVerifiedWithPins(ctx, s, pub, r, optDir, rt, version, proof)
360}
361
362// StageHeldVerifiedWithPins adds the root reservation check under the caller's
363// release exclusion. Agent callers retain the original entry point.
364func StageHeldVerifiedWithPins(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, proof *ProofGate, pins ...PinSource) (*Manifest, error) {
365 if err := heldShape(rt, version); err != nil {
366 return nil, err
367 }
368 if proof == nil {
369 return nil, errHeldNoProofGate
370 }
371 proof.BeginPass()
372 ptr, v, err := heldProofAdmit(ctx, s, optDir, rt, version, proof)
373 if err != nil {
374 return nil, err
375 }
376 m, err := stageHeld(ctx, s, pub, r, optDir, rt, version, pins...)
377 if err != nil {
378 return nil, err
379 }
380 cur, err := LiveVersion(optDir, rt.Host)
381 if err != nil {
382 return nil, err
383 }
384 if !proof.bindRecording(optDir, rt, ptr, v, cur, proof.now(), heldRefusalWriter(proof.bakeDir, rt.HoldOp)) {
385 return nil, fmt.Errorf("%w: %s at %s: its staged bytes are not the proven ones", ErrHeldNotProven, rt.Host, version)
386 }
387 return m, nil
388}
389
390// stageHeld is every held stage's one staging step (StageHeld, and
391// materializeHeld for MaterializeHeld and MaterializeHeldUnder): §4.1 first
392// (HeldAuthorized, under pub and the box's ratchet r), then the stage bound
393// to the digest it returned, whose manifest is checked before any artifact
394// byte is written (ensureStagedDigest). Authorization lives here, below
395// every entry point, so no held path stages a build it has not authorized.
396func stageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string, pins ...PinSource) (*Manifest, error) {
397 if err := heldShape(rt, version); err != nil {
398 return nil, err
399 }
400 want, err := HeldAuthorized(ctx, s, pub, r, RouteAccount(rt), rt.App, version)
401 if err != nil {
402 return nil, err
403 }
404 var m *Manifest
405 if len(pins) == 0 {
406 m, err = materializeHeldStage(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host, want)
407 } else {
408 check := stageCheck{beforeWrite: func(path string) error {
409 for _, source := range pins {
410 if err := (passConfig{generationPins: source}).generationWriteCheck(ctx, optDir, path); err != nil {
411 return err
412 }
413 }
414 return nil
415 }}
416 m, err = ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host, want, check)
417 }
418 if err != nil {
419 if StageBlocked(err) {
420 return nil, fmt.Errorf("%w: %w", ErrStageBlocked, err)
421 }
422 return nil, err
423 }
424 return m, nil
425}
426
427// publishHeld is PublishHeld after admit. Everything runs inside guard,
428// after the authority's re-check and under whatever it holds (for the root
429// relay, the registry's holds frozen): bind hashes the staged bytes, the
430// link is swapped, and bind runs again on the bytes the link now names. The
431// guard's re-check reads the bucket and waits for the freeze, and the
432// staged file can be replaced meanwhile or after the first hash, so only a
433// hash taken after the swap vouches for what the link points at. When that
434// second bind refuses, the link goes back to the version it named before
435// (or away, when there was none) and the stage is ErrHeldNotProven.
436//
437// A binding refusal is recorded scoped to the move (rt.HoldOp, Refusal's
438// MoveOp) in the held namespace (HeldRefusalBase), never in adoption's; a
439// publication that succeeds retires its move's earlier refusal.
440func publishHeld(optDir string, rt registry.Route, version string, proof *ProofGate, ptr *ChannelPointer, v proofVerdict, guard func(publish func() error) error) error {
441 // The CARLOS_HOST_CLAIM refusal is the callers' (heldStageable): the
442 // claim path reaches here only inside its selection.
443 if err := heldShape(rt, version); err != nil {
444 return err
445 }
446 if guard == nil {
447 return errHeldNoGuard
448 }
449 record := heldRefusalWriter(proof.bakeDir, rt.HoldOp)
450 notProven := fmt.Errorf("%w: %s at %s: its staged bytes are not the proven ones", ErrHeldNotProven, rt.Host, version)
451 err := guard(func() error {
452 cur, err := LiveVersion(optDir, rt.Host)
453 if err != nil {
454 return err
455 }
456 if !proof.bindRecording(optDir, rt, ptr, v, cur, proof.now(), record) {
457 return notProven
458 }
459 heldBeforeSwap()
460 if err := PointLive(optDir, rt.Host, RouteAccount(rt), version, rt.App); err != nil {
461 if StageBlocked(err) {
462 return fmt.Errorf("%w: %w", ErrStageBlocked, err)
463 }
464 return err
465 }
466 heldAfterSwap()
467 if !proof.bindRecording(optDir, rt, ptr, v, cur, proof.now(), record) {
468 if rerr := restoreLive(optDir, rt, cur); rerr != nil {
469 return errors.Join(notProven, fmt.Errorf("putting live/%s back on %q: %w", rt.Host, cur, rerr))
470 }
471 return notProven
472 }
473 return nil
474 })
475 if err != nil {
476 return err
477 }
478 if proof.bakeDir != "" {
479 if err := RetireHeldRefusal(proof.bakeDir, rt.Host, rt.HoldOp); err != nil {
480 fmt.Printf(" %s: retiring move %s's held refusal: %v\n", rt.Host, rt.HoldOp, err)
481 }
482 }
483 return nil
484}
485
486// restoreLive puts live/<host> back on cur, or removes it when cur is "".
487func restoreLive(optDir string, rt registry.Route, cur string) error {
488 if cur == "" {
489 if err := os.Remove(LiveLink(optDir, rt.Host)); err != nil && !errors.Is(err, os.ErrNotExist) {
490 return err
491 }
492 return nil
493 }
494 return PointLive(optDir, rt.Host, RouteAccount(rt), cur, rt.App)
495}
496
497// Held refusals (final review finding 3) live in their own namespace,
498// HeldRefusalBase(bakeDir)/refusals/<host>.json, the same record shape as
499// adoption's refusals/<host>.json beside it, written and read with the same
500// functions (WriteRefusal, ReadRefusal, ListRefusals, ClearRefusal) over the
501// other base. Adoption never reads that directory and the held stage never
502// writes, reads or removes adoption's, so neither can clobber or delete the
503// other's record whatever the interleaving: adoption runs in the agent and
504// in root, and nothing serialises the two across processes. Minting
505// (internal/host proofroot.go) reads only adoption's records; status and
506// alerts read both (internal/host refusalwatch.go).
507//
508// Within the namespace one host has one record, the latest held stage's.
509// Retirement reads it and removes it only when it names the ended move; a
510// newer move's record replacing it in between would be removed too. That
511// race is between two moves of one host into one box, and costs a status
512// row: a held refusal blocks nothing, and the next held stage of the live
513// move writes it again.
514
515// HeldRefusalBase is the bake directory the held namespace is rooted at:
516// its records are at RefusalDir(HeldRefusalBase(bakeDir)).
517func HeldRefusalBase(bakeDir string) string { return filepath.Join(bakeDir, "moves-held") }
518
519// heldRefusalWriter is a held stage's refusal writer for bindRecording: the
520// record carries the move's op and goes to the held namespace.
521func heldRefusalWriter(bakeDir, op string) func(Refusal) error {
522 return func(r Refusal) error {
523 r.MoveOp = op
524 return WriteRefusal(HeldRefusalBase(bakeDir), r)
525 }
526}
527
528// RetireHeldRefusal removes host's held refusal when it is move op's, and
529// leaves another move's. Adoption's records are never read or touched.
530func RetireHeldRefusal(bakeDir, host, op string) error {
531 if op == "" {
532 return nil
533 }
534 base := HeldRefusalBase(bakeDir)
535 r, err := ReadRefusal(base, host)
536 if err != nil || r == nil || r.MoveOp != op {
537 return err
538 }
539 heldRetireBeforeClear(host)
540 return ClearRefusal(base, host)
541}
542
543// heldRetireBeforeClear runs between RetireHeldRefusal's read and its
544// removal. A test seam: the window a concurrent writer lands in.
545var heldRetireBeforeClear = func(host string) {}
546
547// ListHeldRefusals is every held refusal record, sorted by host. With no
548// held namespace yet it reads nothing.
549func ListHeldRefusals(bakeDir string) ([]Refusal, error) {
550 return ListRefusals(HeldRefusalBase(bakeDir))
551}
552
553// RetireHeldRefusals removes every held refusal whose move live says is
554// over, and returns the hosts it removed. With no held namespace it reads
555// nothing. Errors from individual removals are joined; the rest go ahead.
556func RetireHeldRefusals(bakeDir string, live func(Refusal) bool) ([]string, error) {
557 recs, err := ListHeldRefusals(bakeDir)
558 if err != nil {
559 return nil, err
560 }
561 var (
562 retired []string
563 errs []error
564 )
565 for _, r := range recs {
566 if r.MoveOp == "" || live(r) {
567 continue
568 }
569 if err := RetireHeldRefusal(bakeDir, r.Host, r.MoveOp); err != nil {
570 errs = append(errs, fmt.Errorf("%s: %w", r.Host, err))
571 continue
572 }
573 retired = append(retired, r.Host)
574 }
575 return retired, errors.Join(errs...)
576}
577
578// heldProofAdmit is D3's cheap check (ProofGate.admit, unchanged) for a
579// held stage. A held row runs a pinned release, not a channel head, so the
580// pointer it is asked about carries only the version: admit and bind read
581// nothing else from it but PromotedAt, which is zero here (the sha cache
582// key, and a binding refusal's PointerPromotedAt, which no real pointer
583// matches, so the record never blocks a later adoption).
584//
585// One difference from adoption, on the closed side: a policy that does not
586// load refuses every held route. Adoption closes only named-unit routes
587// then, but a held route is built from a move's record or a relayed
588// request, whose unit binding this path cannot vouch for.
589func heldProofAdmit(ctx context.Context, s Store, optDir string, rt registry.Route, version string, proof *ProofGate) (*ChannelPointer, proofVerdict, error) {
590 ptr := &ChannelPointer{Version: version}
591 if _, perr := proof.Requires(rt.App); perr {
592 _, err := proof.state()
593 fmt.Printf(" %s: held stage of %s: %v; not staged\n", rt.Host, version, err)
594 return nil, proofVerdict{}, fmt.Errorf("%w: %s at %s: %v", ErrHeldNotProven, rt.Host, version, err)
595 }
596 cur, _ := LiveVersion(optDir, rt.Host)
597 v, ok := proof.admit(ctx, s, optDir, rt, ptr, cur)
598 if !ok {
599 return nil, proofVerdict{}, fmt.Errorf("%w: %s at %s", ErrHeldNotProven, rt.Host, version)
600 }
601 return ptr, v, nil
602}
603
604// heldStageable is the admission StageHeld and PublishHeld share.
605func heldStageable(rt registry.Route, version string) error {
606 if hostwrite.Enabled(os.Getenv) {
607 return ErrHeldClaimWired
608 }
609 return heldShape(rt, version)
610}
611
612// heldShape is heldStageable without the CARLOS_HOST_CLAIM refusal, for the
613// claim path, which writes only inside its selection.
614func heldShape(rt registry.Route, version string) error {
615 if !controlname.ValidHostShape(rt.Host) || !ValidApp(rt.App) || !ValidVersion(version) {
616 return fmt.Errorf("release: held stage of %q/%q at %q: malformed", rt.Host, rt.App, version)
617 }
618 if rt.HoldOp == "" {
619 return fmt.Errorf("release: held stage of %q at %q names no move", rt.Host, version)
620 }
621 return nil
622}
623
624// HeldStageRequest is what the sandboxed agent leaves at
625// HeldStageDir(dataDir)/<Host>.json when MaterializeHeld returned
626// ErrStageBlocked: stage Version of Account/App for Host under move Op,
627// whose target is Box.
628type HeldStageRequest struct {
629 Account string `json:"account"`
630 App string `json:"app"`
631 Host string `json:"host"`
632 Version string `json:"version"`
633 Op string `json:"op"`
634 Box string `json:"box"`
635}
636
637// HeldStaged is what the root pass leaves at HeldStagedDir(dataDir)/<host>.json
638// after serving a request. It is a hint only: T7 trusts VerifyHeldLive.
639type HeldStaged struct {
640 Version string `json:"version"`
641 Op string `json:"op"`
642 At time.Time `json:"at"`
643}
644
645// HeldStageDir is where held-stage requests wait for the root pass.
646func HeldStageDir(dataDir string) string { return filepath.Join(dataDir, "moves", "held-stage") }
647
648// HeldStagedDir is where the root pass acknowledges the requests it served.
649func HeldStagedDir(dataDir string) string { return filepath.Join(dataDir, "moves", "held-staged") }
650
651// heldStageRequestMax bounds a request file: the six fields fit many times
652// over, and the directory is writable by every process that runs as the
653// agent's uid, flat tenants included.
654const heldStageRequestMax = 4096
655
656// ServeHeldStageRequestsWithPins protects the old relay on census boxes, including
657// after claims are disabled. The caller holds release exclusion through the pass.
658func ServeHeldStageRequestsWithPins(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir, dataDir string, proof *ProofGate, authorize func(req HeldStageRequest, then func() error) error, pins ...PinSource) error {
659 reqDir := HeldStageDir(dataDir)
660 dir, err := safefile.OpenDir(reqDir)
661 if errors.Is(err, os.ErrNotExist) {
662 return nil
663 }
664 if err != nil {
665 return err
666 }
667 defer dir.Close()
668 ents, err := dir.ReadDir(-1)
669 if err != nil {
670 return err
671 }
672 sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() })
673 for _, e := range ents {
674 name := e.Name()
675 if strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".json") {
676 continue // a writer's temp file, or not a request
677 }
678 if err := serveHeldStageRequest(ctx, s, pub, r, optDir, dataDir, reqDir, name, proof, authorize, pins...); err != nil {
679 return err
680 }
681 }
682 return nil
683}
684
685// serveHeldStageRequest serves one request file. It returns an error only
686// when this build cannot do the work at all.
687func serveHeldStageRequest(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir, dataDir, reqDir, name string, proof *ProofGate, authorize func(HeldStageRequest, func() error) error, pins ...PinSource) error {
688 drop := func(format string, args ...any) error {
689 fmt.Printf(" held stage request %q: "+format+"; removed\n", append([]any{name}, args...)...)
690 if err := safefile.Remove(filepath.Join(reqDir, name)); err != nil {
691 if errors.Is(err, errors.ErrUnsupported) {
692 return err
693 }
694 fmt.Printf(" held stage request %q: remove: %v\n", name, err)
695 }
696 return nil
697 }
698 f, err := safefile.OpenFile(filepath.Join(reqDir, name), os.O_RDONLY, 0)
699 if err != nil {
700 if errors.Is(err, errors.ErrUnsupported) {
701 return err
702 }
703 return drop("unreadable: %v", err)
704 }
705 body, err := io.ReadAll(io.LimitReader(f, heldStageRequestMax+1))
706 closeErr := f.Close()
707 if err != nil {
708 return drop("unreadable: %v", err)
709 }
710 if closeErr != nil {
711 return drop("unreadable: %v", closeErr)
712 }
713 if len(body) > heldStageRequestMax {
714 return drop("unreadable: request exceeds %d bytes", heldStageRequestMax)
715 }
716 var req HeldStageRequest
717 if err := json.Unmarshal(body, &req); err != nil {
718 return drop("not a request")
719 }
720 if why := heldStageRequestInvalid(req, strings.TrimSuffix(name, ".json")); why != "" {
721 return drop("refused: %s", why)
722 }
723 if err := authorize(req, nil); err != nil {
724 return drop("refused: %v", err)
725 }
726 rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App, HoldOp: req.Op}
727 guard := func(publish func() error) error { return authorize(req, publish) }
728 if _, err := materializeHeldChecked(ctx, s, pub, r, optDir, rt, req.Version, proof, guard, pins...); err != nil {
729 if errors.Is(err, ErrStaleHeldStage) {
730 return drop("refused at publication: %v", err)
731 }
732 if errors.Is(err, ErrHeldNotProven) || errors.Is(err, ErrHeldNotAuthorized) || errors.Is(err, ErrManifestDigest) {
733 return drop("refused: %v", err)
734 }
735 fmt.Printf(" %s: held stage of %s for %s: %v; kept for the next pass\n", req.Host, req.Version, req.Op, err)
736 return nil
737 }
738 raw, err := json.Marshal(HeldStaged{Version: req.Version, Op: req.Op, At: time.Now().UTC()})
739 if err == nil {
740 err = safefile.MkdirAll(HeldStagedDir(dataDir), 0o755)
741 }
742 if err == nil {
743 err = safefile.WriteFile(filepath.Join(HeldStagedDir(dataDir), req.Host+".json"), raw, 0o644)
744 }
745 if err != nil {
746 fmt.Printf(" %s: held stage of %s for %s: live, but the ack was not written: %v; kept for the next pass\n", req.Host, req.Version, req.Op, err)
747 return nil
748 }
749 fmt.Printf(" %s: held stage of %s for %s: live\n", req.Host, req.Version, req.Op)
750 if err := safefile.Remove(filepath.Join(reqDir, name)); err != nil {
751 fmt.Printf(" held stage request %q: served, but not removed: %v\n", name, err)
752 }
753 return nil
754}
755
756// heldStageRequestInvalid names the first field of req that cannot be
757// trusted as a path component or an identity, or returns "". host is the
758// request's own file name less ".json".
759func heldStageRequestInvalid(req HeldStageRequest, host string) string {
760 switch {
761 case req.Host != host:
762 return "its host is not its file name"
763 case !controlname.ValidHostShape(req.Host):
764 return "malformed host"
765 case !ValidAccount(req.Account):
766 return "malformed account"
767 case !ValidApp(req.App):
768 return "malformed app"
769 case !ValidVersion(req.Version):
770 return "malformed version"
771 case req.Op == "":
772 return "no move op"
773 case req.Box == "":
774 return "no box"
775 }
776 return ""
777}
778