carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package release is the release plane: content-addressed release manifests
2// and signed, CAS-guarded channel pointers in a deployment bucket.
3//
4// Ported from Eleven release.go (the signedManifest wrapper, key parsing,
5// keygen) with the trust split the plan calls for: release manifests are
6// content-addressed and immutable (their bucket path is claimed create-only,
7// so their integrity is their existence), while CHANNEL POINTERS are the
8// trust boundary — a pointer is what tells a fleet which build to run, so it
9// carries the Ed25519 signature and moves only via compare-and-swap.
10//
11// Bucket layout (deployment bucket):
12//
13// apps/<app>/releases/<version>/manifest.json content-addressed, immutable
14// apps/<app>/releases/<version>/<artifact files> binaries or static tree tar
15// apps/<app>/channels/<channel>.json SIGNED channel pointer, CAS via If-Match
16//
17// Keys: the private half is the CARLOS_RELEASE_KEY Ed25519 seed (env only —
18// secret manager -> shell variable -> process memory, never disk); the
19// public key is pinned in the deployment's host config
20// (CARLOS_RELEASE_PUBKEY), not baked into the binary.
21package release
22
23import (
24 "bytes"
25 "context"
26 "crypto/ed25519"
27 "crypto/rand"
28 "crypto/sha256"
29 "encoding/base64"
30 "encoding/hex"
31 "encoding/json"
32 "errors"
33 "fmt"
34 "os"
35 "regexp"
36 "strings"
37 "time"
38
39 "github.com/carlosframework/platform/internal/caps"
40 "github.com/carlosframework/platform/internal/controlname"
41)
42
43// Manifest describes one shipped release of one app. It is content-addressed
44// (immutable at its bucket path), so it needs no signature of its own: a
45// verified channel pointer names its version, and the manifest's artifact
46// sha256s gate every byte downloaded from it.
47type Manifest struct {
48 Schema int `json:"schema"` // 1
49 App string `json:"app"`
50 Version string `json:"version"` // git short sha
51 Kind string `json:"kind"` // "binary" | "static"
52 Commit string `json:"commit,omitempty"`
53 BuiltBy string `json:"built_by,omitempty"`
54 BuiltAt string `json:"built_at,omitempty"`
55 // Label and Notes are the human half of a release (release versioning
56 // spec, 2026-08-08): a short one-line intent ("fixing modals") and a
57 // changelog fragment, both written at ship time. ADDITIVE to schema 1
58 // exactly as Capabilities was — absent from every manifest written
59 // before the fields existed, omitted from marshal when empty, ignored
60 // by old binaries. Nothing on the adopt path reads either.
61 Label string `json:"label,omitempty"`
62 Notes string `json:"notes,omitempty"`
63 // Program is the Go main package path of the release's binaries
64 // (debug/buildinfo), and ProgramChange records an intended change of it
65 // (`carlos ship --program-change`). ADDITIVE to schema 1 like Label:
66 // omitted when empty, ignored by old binaries. Console release defences
67 // D2 (program.go).
68 Program string `json:"program,omitempty"`
69 ProgramChange bool `json:"program_change,omitempty"`
70 // Swap is this release's own swap demand (zero-downtime deploys spec
71 // §4.2). DemandHold says the build's boot must not share the instance's
72 // database with the build before it, so an awake instance moves onto it
73 // stop-then-start. Empty means the release demands nothing and the app's
74 // policy decides. ADDITIVE to schema 1 like Label: absent from every
75 // manifest written before it, omitted from marshal when empty, ignored by
76 // old binaries. A box reads it from the CANONICAL manifest only
77 // (CanonicalDemand), never from its staged copy, because a root older
78 // than this field re-marshalled its copies without it.
79 Swap string `json:"swap,omitempty"`
80 // Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a
81 // static tree tar.
82 Artifacts map[string]Artifact `json:"artifacts"`
83
84 // Capabilities is what this BUILD needs from the platform
85 // (internal/caps). ADDITIVE to schema 1, exactly as
86 // appconfig.Manifest.ExecDelivery was: a manifest written before this
87 // field existed has no "capabilities" key, parses with a nil pointer, and
88 // means no capabilities — which is today's behaviour byte for byte, and
89 // is most of every production bucket. A POINTER with omitempty, so a
90 // capability-free manifest also MARSHALS byte-identically to before (no
91 // "capabilities": {} appears), which is what keeps Ship's idempotent
92 // re-ship of an old release working.
93 //
94 // The reverse direction holds too: an OLD binary — one with no caps
95 // package at all — still parses a NEW manifest, because encoding/json
96 // ignores unknown top-level keys and nothing on the manifest read path
97 // uses DisallowUnknownFields. That is what makes the pinned deploy order
98 // (platform binaries first, capability-bearing releases second) a
99 // belt-and-braces rule rather than a load-bearing one.
100 //
101 // It lives in the release manifest and not the config manifest because it
102 // describes the unit the platform OWNS and it changes with the code: a
103 // rollback rolls the unit shape back with the binary, and the declaration
104 // rides the same trust chain as the bytes (signed pointer -> immutable
105 // manifest -> sha256). Spec §3a.
106 Capabilities *caps.Set `json:"capabilities,omitempty"`
107}
108
109// Artifact is one file of a release: its bucket file name and the sha256
110// every downloader must verify before staging it.
111type Artifact struct {
112 File string `json:"file"`
113 SHA256 string `json:"sha256"`
114 Size int64 `json:"size"`
115}
116
117// ChannelPointer says which version a channel currently serves. This is the
118// document the fleet trusts, so it ships wrapped in signedWrapper and only
119// moves via CAS.
120type ChannelPointer struct {
121 Schema int `json:"schema"`
122 // Account is the sqid of the account the pointer was signed for. App
123 // names are unique only WITHIN an account, so app+channel alone matched
124 // "blog/edge" in every account at once, and a byte copy of one account's
125 // pointer verified at another's key (tenant-isolation audit, discussion
126 // #220). Signed into every pointer written since, and checked against the
127 // key's account whenever present.
128 //
129 // ADDITIVE, and deliberately optional on read: every pointer written
130 // before the field existed has no "account" key and keeps verifying
131 // wherever it sits — see verifyPointer for what that leaves open. Old
132 // binaries ignore the key and verify exactly as before.
133 Account string `json:"account,omitempty"`
134 App string `json:"app"`
135 Channel string `json:"channel"`
136 Version string `json:"version"`
137 PromotedAt time.Time `json:"promoted_at"`
138 // History is the previous versions, newest first, max historyMax —
139 // rollback targets. Rollback swaps Version <-> History[0], so a second
140 // rollback returns to where you were (Eleven versions.go's
141 // current<->previous swap, generalised).
142 History []string `json:"history,omitempty"`
143 // Digest is the sha256 of Version's canonical manifest bytes
144 // (ManifestDigest), and HistoryDigests holds one per History entry in the
145 // same order, "" where the writer had none to copy (#253, spec §4.6).
146 // ADDITIVE and inside the signed payload: absent from every pointer
147 // written before them and from every pointer written while the digest
148 // marker is off, so those marshal byte-identically to before; old
149 // binaries ignore both keys and verify as they always have. A digest is
150 // either computed at promotion from the bytes the publisher shipped, or
151 // copied verbatim from the verified predecessor: never computed for an
152 // entry a writer inherits (inheritedDigests).
153 Digest string `json:"digest,omitempty"`
154 HistoryDigests []string `json:"history_digests,omitempty"`
155 // Audit is the promotion trail, newest first, max auditMax: how this
156 // channel came to point where it does, and who said so. ADDITIVE — a
157 // pointer written before the trail existed has no "audit" key, parses
158 // with none, and is not an error; that is most of the live fleet.
159 Audit []Promotion `json:"audit,omitempty"`
160 // FastTrack waives the supply-chain bake window for THIS version on THIS
161 // channel (2026-08-04 console bake controls spec §5). Root-key-only at
162 // mint AND at verification — a scoped key must never skip the window it
163 // bakes under. Set per write and never inherited: the next promotion
164 // starts clean. ADDITIVE: old pointers have no key; old binaries drop the
165 // field and keep baking, the safe direction.
166 FastTrack bool `json:"fast_track,omitempty"`
167}
168
169// Promotion is one entry in a channel pointer's audit trail. It rides inside
170// the SIGNED payload, so the trail is as trustworthy as the pointer itself —
171// an audit log that can be edited without breaking the signature is a log
172// that proves nothing.
173type Promotion struct {
174 Version string `json:"version"`
175 // From is the rung the version occupied before this promotion (highest,
176 // if several); empty when it was on none. To is the rung it moved onto,
177 // empty for a channel outside the ladder's vocabulary.
178 From string `json:"from,omitempty"`
179 To string `json:"to,omitempty"`
180 // Actor is who promoted — a console member's address, or the operator's
181 // user@host at the CLI. Empty when the caller did not say.
182 Actor string `json:"actor,omitempty"`
183 // Hotfix records that the ladder was bypassed. This is the whole reason
184 // the field exists: a bypass nobody can see is a broken gate.
185 Hotfix bool `json:"hotfix,omitempty"`
186 // FastTrack records that this write waived the bake window — same
187 // reasoning as Hotfix: a bypass nobody can see is a broken gate.
188 FastTrack bool `json:"fast_track,omitempty"`
189 // Retry records that this write only renewed an existing promotion of
190 // the same version, so a box's refusal of the earlier one no longer
191 // blocks it.
192 Retry bool `json:"retry,omitempty"`
193 At time.Time `json:"at"`
194}
195
196// historyMax bounds a pointer's rollback history; auditMax bounds its
197// promotion trail. The trail is longer because it answers questions about the
198// past ("when did this reach stable, and who signed it off") that rollback
199// targets do not — but it is still bounded: a signed pointer that grows
200// without limit eventually stops being fetchable.
201const (
202 historyMax = 10
203 auditMax = 20
204)
205
206// MaxLabelLen / MaxNotesLen bound the human half of a manifest. The label
207// is a table cell; the notes are a changelog fragment, not a novel.
208const (
209 MaxLabelLen = 120
210 MaxNotesLen = 16 << 10
211)
212
213// ValidateLabelNotes is the one home for the label/notes bounds and their
214// refusal text — called from validateManifest (Ship and every GetManifest
215// read) AND from the console API's handleAPIShip, which must reach the same
216// verdict before a single artifact byte crosses the wire rather than only
217// after release.Ship re-derives it. Keeping the messages here means a future
218// change to either limit, or its wording, has one call site to edit rather
219// than two copies that can drift.
220func ValidateLabelNotes(label, notes string) error {
221 if len(label) > MaxLabelLen || strings.ContainsAny(label, "\r\n") {
222 return fmt.Errorf("label: one line, at most %d characters", MaxLabelLen)
223 }
224 if len(notes) > MaxNotesLen {
225 return fmt.Errorf("notes: at most %d bytes", MaxNotesLen)
226 }
227 return nil
228}
229
230// Bucket keys, account-scoped: app names are unique WITHIN an account, so the
231// account sqid is part of every key and the key path itself carries the
232// app→account linkage (there is no separate linkage manifest). Channel names
233// may contain one "/" (canary/<branch>), which simply nests the pointer one
234// level deeper.
235//
236// acct is the account's sqid — "bab" for the built-in ops account. An empty
237// acct is INVALID and deliberately produces a broken key ("apps//kass/…")
238// rather than the legacy unqualified shape: silently rebuilding a legacy key
239// would make an unmigrated app look migrated, which is exactly the lie the
240// migration's verification gate exists to catch.
241func ManifestKey(acct, app, version string) string {
242 return appPrefix(acct, app) + "releases/" + version + "/manifest.json"
243}
244func ArtifactKey(acct, app, version, file string) string {
245 return appPrefix(acct, app) + "releases/" + version + "/" + file
246}
247func ChannelKey(acct, app, channel string) string {
248 return appPrefix(acct, app) + "channels/" + channel + ".json"
249}
250
251// ChannelsPrefix and ReleasesPrefix are the list prefixes behind "what
252// channels/releases does this app have" — the console and the CLI list them.
253func ChannelsPrefix(acct, app string) string { return appPrefix(acct, app) + "channels/" }
254func ReleasesPrefix(acct, app string) string { return appPrefix(acct, app) + "releases/" }
255
256// AppPrefix is everything an app owns in the bucket, under its account,
257// except its open ledgers (LedgerAppPrefix) and its door-key record
258// (internal/doorkey), which live outside it.
259func AppPrefix(acct, app string) string { return appPrefix(acct, app) }
260
261// LedgerAppPrefix is where an app's open ledgers live. internal/ledger builds
262// every ledger key under it, and the edge serves public ones from it
263// (internal/edge/status.go). It is defined here, not in internal/ledger,
264// because PurgeApp sweeps it and ledger imports release.
265func LedgerAppPrefix(acct, app string) string { return "ledgers/" + acct + "/" + app + "/" }
266
267func appPrefix(acct, app string) string { return "apps/" + acct + "/" + app + "/" }
268
269// The legacy, account-less key shapes. Since the read-fallback was retired
270// (qualified.go) they exist for the migration verb, which copies from here to
271// there, and for tests building a pre-accounts bucket. Nothing writes them,
272// and no runtime read resolves through them.
273//
274// The two LIST prefixes that used to live here (channels/, releases/) are
275// gone with the fallback that was their only caller. A dead builder naming a
276// shape nothing may resolve is an invitation to resolve it.
277func LegacyManifestKey(app, version string) string {
278 return legacyAppPrefix(app) + "releases/" + version + "/manifest.json"
279}
280func LegacyArtifactKey(app, version, file string) string {
281 return legacyAppPrefix(app) + "releases/" + version + "/" + file
282}
283func LegacyChannelKey(app, channel string) string {
284 return legacyAppPrefix(app) + "channels/" + channel + ".json"
285}
286
287// LegacyAppPrefix is everything an unmigrated app owns in the bucket — what
288// `accounts migrate -from legacy` lists and copies.
289func LegacyAppPrefix(app string) string { return legacyAppPrefix(app) }
290
291func legacyAppPrefix(app string) string { return "apps/" + app + "/" }
292
293func ValidApp(a string) bool { return controlname.ValidApp(a) }
294
295// channelSegRe is one channel path segment.
296var channelSegRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
297
298// ValidChannel accepts "stable", "beta", any other single name, or a
299// two-segment "canary/<branch>" — each segment directory-name safe (the
300// channel lands in a bucket key and in on-box state files).
301func ValidChannel(c string) bool {
302 parts := strings.Split(c, "/")
303 if len(parts) == 0 || len(parts) > 2 {
304 return false
305 }
306 for _, p := range parts {
307 if p == "" || p == "." || p == ".." || len(p) > 64 || !channelSegRe.MatchString(p) {
308 return false
309 }
310 }
311 return true
312}
313
314// fileRe keeps artifact file names usable as single path segments.
315var fileRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
316
317func validArtifactFile(f string) bool {
318 return f != "" && f != "." && f != ".." && len(f) <= 128 && fileRe.MatchString(f)
319}
320
321// validateManifest rejects a manifest that parsed but can't be acted on.
322func validateManifest(m *Manifest) error {
323 if m.Schema != 1 {
324 return fmt.Errorf("unsupported manifest schema %d", m.Schema)
325 }
326 if !ValidApp(m.App) {
327 return fmt.Errorf("invalid app %q", m.App)
328 }
329 if !ValidVersion(m.Version) || m.Version == "dev" {
330 return fmt.Errorf("invalid version %q", m.Version)
331 }
332 if m.Kind != "binary" && m.Kind != "static" {
333 return fmt.Errorf("invalid kind %q (binary | static)", m.Kind)
334 }
335 if len(m.Artifacts) == 0 {
336 return fmt.Errorf("manifest has no artifacts")
337 }
338 for k, a := range m.Artifacts {
339 if !validArtifactFile(a.File) {
340 return fmt.Errorf("artifact %s: invalid file name %q", k, a.File)
341 }
342 if raw, err := hex.DecodeString(a.SHA256); err != nil || len(raw) != sha256.Size {
343 return fmt.Errorf("artifact %s: sha256 must be %d hex chars", k, sha256.Size*2)
344 }
345 if a.Size < 0 {
346 return fmt.Errorf("artifact %s: negative size", k)
347 }
348 }
349 if err := ValidateLabelNotes(m.Label, m.Notes); err != nil {
350 return err
351 }
352 // LAST, and deliberately the same call on both sides of the trust chain:
353 // Ship runs validateManifest before writing a byte (a human is present to
354 // read the refusal), and GetManifest runs it on every read (so a box
355 // meeting a capability it does not understand fails ensureStaged and
356 // prints one line per pass without staging or pointing). One gate, shared
357 // by ship and adopt, exactly as appconfig.ValidExecDelivery is shared by
358 // set-time and sync-time. A nil Capabilities is valid and means none.
359 //
360 // Returned unwrapped on purpose: every caps refusal already opens with
361 // "capabilities: " (that prefix is pinned in Task 1's refusal table), and
362 // the plan's `fmt.Errorf("capabilities: %w", err)` would render it twice
363 // inside GetManifest's own "manifest %s/%s: %w" wrap. The operator-facing
364 // string is the point of these messages, so the prefix is stated once.
365 if err := caps.ValidCapabilities(m.App, m.Capabilities); err != nil {
366 return err
367 }
368 return nil
369}
370
371// ---- signing ----------------------------------------------------------------
372
373// signedWrapper is the wire format of a channel pointer: the signature
374// covers the exact base64 payload string, so verification never depends on
375// re-marshalling JSON — verify first, decode after, no JSON canonicalisation
376// to get wrong. (Ported from Eleven release.go's signedManifest.)
377type signedWrapper struct {
378 Payload string `json:"payload"`
379 Sig string `json:"sig"`
380 // Grant is ABSENT for a root-signed pointer — omitempty on a nil pointer,
381 // so the bytes a root key writes are byte-identical to the bytes it wrote
382 // before scoped keys existed (golden_test.go pins exactly that). When
383 // present, Sig above is by the SCOPED key and this is the root's
384 // signature over the statement authorising it.
385 //
386 // An old binary reading a new grant-bearing wrapper fails signature
387 // verification, because it checks Sig against the root key. That is the
388 // correct fail-closed direction and it sets the deploy order: binaries
389 // first, scoped keys second.
390 Grant *signedGrant `json:"grant,omitempty"`
391}
392
393// signPointer stamps PromotedAt (now) and wraps the pointer in the signed
394// wire format, attaching the signer's grant when it has one.
395//
396// The scope check lives here as well as at the top of the promote verbs: this
397// is the single place a pointer becomes signed bytes, so a caller that
398// forgets to ask cannot produce a scope-violating pointer even by accident.
399func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) {
400 if err := sg.usable(); err != nil {
401 return nil, err
402 }
403 p.Schema = 1
404 p.PromotedAt = time.Now().UTC().Truncate(time.Second)
405 // Optional on READ (old pointers), required on WRITE: nothing signed from
406 // here on may be an unbound pointer, or the binding is a convention.
407 if !ValidAccount(p.Account) {
408 return nil, fmt.Errorf("channel pointer %s/%s: invalid account %q", p.App, p.Channel, p.Account)
409 }
410 if err := validatePointer(p, p.Account, p.App, p.Channel); err != nil {
411 return nil, err
412 }
413 if err := sg.allows(p.Channel, p.PromotedAt); err != nil {
414 return nil, err
415 }
416 if p.FastTrack && sg.scope != nil {
417 return nil, fmt.Errorf("fast-track is root-key-only — key grant %q cannot waive the bake window", sg.scope.Name)
418 }
419 payload, err := json.MarshalIndent(p, "", " ")
420 if err != nil {
421 return nil, err
422 }
423 b64 := base64.StdEncoding.EncodeToString(payload)
424 sig := ed25519.Sign(sg.key, []byte(b64))
425 return json.MarshalIndent(signedWrapper{
426 Payload: b64,
427 Sig: base64.StdEncoding.EncodeToString(sig),
428 Grant: sg.grant,
429 }, "", " ")
430}
431
432// verifyPointer checks the signature against pub and only then decodes and
433// validates the payload — everything the caller acts on came from under the
434// signature. wantAcct/wantApp/wantChannel are matched against the SIGNED
435// payload, so a validly-signed pointer copied from another channel's key (a
436// cross-channel replay) is rejected, and so is one copied from another
437// account's key when it names its account.
438//
439// What the account check cannot close: a pointer signed BEFORE the field
440// existed names no account, and verifies under any account's key with that
441// app and channel name — refusing it would take every channel not promoted
442// since dark. The field is inside the signature, so it cannot be stripped
443// from a newer pointer without breaking it; the exposure is the fixed set of
444// account-less pointers already signed, which (having no expiry) stay
445// replayable until verification stops accepting them. Every promote,
446// rollback or fast-track re-signs a channel with its account, so that set
447// only shrinks. Sunset: once no live channel pointer lacks an account, make
448// the account required here; until then the copy still needs write access
449// to the victim's prefix, and resolves only to a version shipped there,
450// since the manifest is read under the key's own account.
451//
452// pub is the deployment's ROOT public key, and it is the only key a box is
453// ever configured with. A wrapper with no grant was signed by that key
454// directly — the shape the whole fleet is on. A wrapper WITH a grant was
455// signed by a scoped key, and the grant is the root's signature saying which
456// rungs that key may reach; so the chain is checked root -> grant -> pointer,
457// and then the pointer's own channel and promoted_at are checked against the
458// scope. Both scope checks read the SIGNED payload, never the caller's
459// arguments — a grant that is honoured on a channel the payload does not name
460// would be no scope at all.
461//
462// This is the single parse site for channel pointers, and it must stay that
463// way: every consumer (Channel, adoption, VersionRung, the console, the edge)
464// inherits grant handling by funnelling through here.
465func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantAcct, wantApp, wantChannel string) (*ChannelPointer, error) {
466 var sw signedWrapper
467 if err := json.Unmarshal(wrapper, &sw); err != nil {
468 return nil, fmt.Errorf("pointer wrapper: %v", err)
469 }
470 // signer is who must have signed the payload: the root itself, or the
471 // scoped key the root delegated to.
472 signer, scope := pub, (*Grant)(nil)
473 if sw.Grant != nil {
474 g, err := verifyGrant(pub, sw.Grant)
475 if err != nil {
476 return nil, err
477 }
478 gpub, err := ParsePublicKey(g.Pub, "key grant pub")
479 if err != nil {
480 return nil, err
481 }
482 signer, scope = gpub, g
483 }
484 sig, err := base64.StdEncoding.DecodeString(sw.Sig)
485 if err != nil {
486 return nil, fmt.Errorf("pointer sig: %v", err)
487 }
488 if len(signer) != ed25519.PublicKeySize {
489 return nil, fmt.Errorf("channel pointer: no usable release public key")
490 }
491 if !ed25519.Verify(signer, []byte(sw.Payload), sig) {
492 return nil, fmt.Errorf("channel pointer signature verification failed")
493 }
494 payload, err := base64.StdEncoding.DecodeString(sw.Payload)
495 if err != nil {
496 return nil, fmt.Errorf("pointer payload: %v", err)
497 }
498 var p ChannelPointer
499 if err := json.Unmarshal(payload, &p); err != nil {
500 return nil, fmt.Errorf("pointer payload: %v", err)
501 }
502 if err := validatePointer(&p, wantAcct, wantApp, wantChannel); err != nil {
503 return nil, err
504 }
505 if p.FastTrack && sw.Grant != nil {
506 return nil, fmt.Errorf("channel pointer %s/%s: fast_track is root-key-only — a scoped key cannot waive the bake window", p.App, p.Channel)
507 }
508 if scope != nil {
509 // promoted_at, not the wall clock: adoption re-verifies every pass, so
510 // comparing expiry against now would make an expired grant take a
511 // running route's channel resolution dark long after the promotion it
512 // was meant to gate (spec §5).
513 if err := scope.allows(p.Channel, p.PromotedAt); err != nil {
514 return nil, fmt.Errorf("channel pointer %s/%s: %w", p.App, p.Channel, err)
515 }
516 }
517 return &p, nil
518}
519
520func validatePointer(p *ChannelPointer, wantAcct, wantApp, wantChannel string) error {
521 if p.Schema != 1 {
522 return fmt.Errorf("unsupported pointer schema %d", p.Schema)
523 }
524 // Absent means a pointer from before the binding (see verifyPointer);
525 // present means it must be this key's account.
526 if p.Account != "" && p.Account != wantAcct {
527 return fmt.Errorf("pointer %s/%s was signed for account %s, expected %s", p.App, p.Channel, p.Account, wantAcct)
528 }
529 if p.App != wantApp || p.Channel != wantChannel {
530 return fmt.Errorf("pointer names %s/%s, expected %s/%s", p.App, p.Channel, wantApp, wantChannel)
531 }
532 if !ValidApp(p.App) || !ValidChannel(p.Channel) {
533 return fmt.Errorf("pointer has invalid app/channel %s/%s", p.App, p.Channel)
534 }
535 if !ValidVersion(p.Version) || p.Version == "dev" {
536 return fmt.Errorf("pointer has invalid version %q", p.Version)
537 }
538 if p.PromotedAt.IsZero() {
539 return fmt.Errorf("pointer has no promoted_at")
540 }
541 if len(p.History) > historyMax {
542 return fmt.Errorf("pointer history too long (%d > %d)", len(p.History), historyMax)
543 }
544 for _, v := range p.History {
545 if !ValidVersion(v) {
546 return fmt.Errorf("pointer history has invalid version %q", v)
547 }
548 }
549 // Digests (#253): absent, or well-formed and aligned with History. A
550 // short list is malformed, never "the rest have none": a writer that
551 // meant "none" writes "" at the position.
552 if p.Digest != "" && !validDigest(p.Digest) {
553 return fmt.Errorf("pointer has an invalid digest")
554 }
555 if len(p.HistoryDigests) != 0 && len(p.HistoryDigests) != len(p.History) {
556 return fmt.Errorf("pointer history_digests has %d entries for %d history entries", len(p.HistoryDigests), len(p.History))
557 }
558 for _, d := range p.HistoryDigests {
559 if d != "" && !validDigest(d) {
560 return fmt.Errorf("pointer history has an invalid digest")
561 }
562 }
563 // The audit trail is checked loosely and only for shape: an old pointer
564 // carries none, and a trail is a record of what happened, not an
565 // instruction — refusing a pointer the fleet needs over a malformed
566 // history entry would turn a bookkeeping bug into an outage.
567 if len(p.Audit) > auditMax {
568 return fmt.Errorf("pointer audit trail too long (%d > %d)", len(p.Audit), auditMax)
569 }
570 for _, a := range p.Audit {
571 if a.Version != "" && !ValidVersion(a.Version) {
572 return fmt.Errorf("pointer audit has invalid version %q", a.Version)
573 }
574 }
575 return nil
576}
577
578// ---- keys -------------------------------------------------------------------
579
580// GenerateKey mints a fresh signing pair: the base64 seed (the operator's
581// secret — password manager, then CARLOS_RELEASE_KEY) and the base64 public
582// key (pinned in host config as CARLOS_RELEASE_PUBKEY).
583func GenerateKey() (seedB64, pubB64 string, err error) {
584 pub, priv, err := ed25519.GenerateKey(rand.Reader)
585 if err != nil {
586 return "", "", err
587 }
588 return base64.StdEncoding.EncodeToString(priv.Seed()),
589 base64.StdEncoding.EncodeToString(pub), nil
590}
591
592// ParseSigningKey turns a base64 Ed25519 seed — from CARLOS_RELEASE_KEY —
593// into the signing key. src only names the origin in the error.
594//
595// A scoped key's seed carries the ScopedKeyPrefix tag, which is stripped
596// here: the bytes are an Ed25519 seed either way, and every root key already
597// in a secret store keeps parsing exactly as it did. Whether the tag was
598// there is a question for the caller (IsScopedSeed), which is the one that
599// knows whether a grant is required.
600func ParseSigningKey(b64, src string) (ed25519.PrivateKey, error) {
601 seed, err := base64.StdEncoding.DecodeString(
602 strings.TrimPrefix(strings.TrimSpace(b64), ScopedKeyPrefix))
603 if err != nil || len(seed) != ed25519.SeedSize {
604 return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", src, ed25519.SeedSize)
605 }
606 return ed25519.NewKeyFromSeed(seed), nil
607}
608
609// ParsePublicKey turns a base64 public key — from CARLOS_RELEASE_PUBKEY —
610// into the verification key.
611func ParsePublicKey(b64, src string) (ed25519.PublicKey, error) {
612 raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
613 if err != nil || len(raw) != ed25519.PublicKeySize {
614 return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 public key", src, ed25519.PublicKeySize)
615 }
616 return ed25519.PublicKey(raw), nil
617}
618
619// ---- top-level API ----------------------------------------------------------
620
621// Ship uploads a release: artifacts first, manifest LAST — the manifest is
622// the commit record, so a crashed ship leaves orphan artifact bytes, never a
623// manifest naming files that don't exist. Everything is create-only
624// (immutable): re-shipping identical bytes is an idempotent no-op, while
625// re-shipping DIFFERENT content under an existing version is refused — a
626// version id, once published, can never quietly mean something else.
627//
628// files is keyed by artifact map key ("linux-arm64", "site", ...). Ship
629// fills each Artifact's SHA256/Size from the bytes (and, when already set,
630// requires them to match); an empty Artifact.File defaults to the map key.
631func claimRawShip(ctx context.Context, s Store, acct string, m Manifest, files map[string][]byte) error {
632 if !ValidAccount(acct) {
633 return fmt.Errorf("ship: invalid account %q", acct)
634 }
635 m.Schema = 1
636 // Ship is where a person is present to read a refusal, so the one value
637 // a release may demand is enforced here. Reads stay permissive
638 // (NormalizeDemand): a value from a newer shipper must not stop a box
639 // staging the release, only stop an overlap.
640 if m.Swap != "" && m.Swap != DemandHold {
641 return fmt.Errorf("ship: swap %q: a release can demand %q or nothing", m.Swap, DemandHold)
642 }
643 for k, a := range m.Artifacts {
644 b, ok := files[k]
645 if !ok {
646 return fmt.Errorf("ship: manifest artifact %q has no file bytes", k)
647 }
648 sum := etagOf(b) // hex sha256
649 if a.SHA256 != "" && a.SHA256 != sum {
650 return fmt.Errorf("ship: artifact %q sha256 mismatch (manifest %s, bytes %s)", k, a.SHA256, sum)
651 }
652 a.SHA256 = sum
653 a.Size = int64(len(b))
654 if a.File == "" {
655 a.File = k
656 }
657 m.Artifacts[k] = a
658 }
659 for k := range files {
660 if _, ok := m.Artifacts[k]; !ok {
661 return fmt.Errorf("ship: file %q has no manifest artifact entry", k)
662 }
663 }
664 if err := validateManifest(&m); err != nil {
665 return fmt.Errorf("ship: %w", err)
666 }
667 for k, a := range m.Artifacts {
668 if err := putImmutable(ctx, s, ArtifactKey(acct, m.App, m.Version, a.File), files[k]); err != nil {
669 return fmt.Errorf("ship: artifact %s: %w", k, err)
670 }
671 }
672 raw, err := json.MarshalIndent(m, "", " ")
673 if err != nil {
674 return err
675 }
676 if err := putImmutable(ctx, s, ManifestKey(acct, m.App, m.Version), raw); err != nil {
677 return fmt.Errorf("ship: manifest: %w", err)
678 }
679 return nil
680}
681
682// putImmutable claims a content-addressed key create-only; a lost race is
683// fine iff the existing bytes are identical (idempotent re-ship), and an
684// error otherwise (an immutable path must never change meaning).
685//
686// Since issue #136, "immutable" means immutable-WHILE-PRESENT: a release
687// object's bytes never change under its key, but retention (prune.go) and
688// whole-app purge (trash.go) may delete it. Create-only writes still refuse
689// to overwrite, and a re-ship of a pruned version is a NEW release that
690// happens to reuse the name.
691func putImmutable(ctx context.Context, s Store, key string, body []byte) error {
692 _, err := s.Put(ctx, key, body, "")
693 if !errors.Is(err, ErrCASConflict) {
694 return err
695 }
696 cur, _, gerr := s.Get(ctx, key)
697 if gerr != nil {
698 return fmt.Errorf("%s exists but is unreadable: %w", key, gerr)
699 }
700 if !bytes.Equal(cur, body) {
701 return fmt.Errorf("%s already exists with different content — refusing to overwrite an immutable release object", key)
702 }
703 return nil
704}
705
706// GetManifest fetches and validates a release manifest, requiring it to name
707// the app/version its key claims. Reads the account-qualified key and only
708// that one. Not-found is os.ErrNotExist-wrapped, and names `accounts migrate
709// -from legacy` for a deployment whose objects never moved.
710func GetManifest(ctx context.Context, s Store, acct, app, version string) (*Manifest, error) {
711 m, _, err := GetManifestRaw(ctx, s, acct, app, version)
712 return m, err
713}
714
715// GetManifestRaw is GetManifest that also returns the exact bytes it read, so
716// a box can keep them (the staged sibling) rather than a re-marshal that
717// drops every field this binary does not know (spec §4.2).
718func GetManifestRaw(ctx context.Context, s Store, acct, app, version string) (*Manifest, []byte, error) {
719 raw, _, err := GetQualified(ctx, s, ManifestKey(acct, app, version))
720 if err != nil {
721 return nil, nil, err
722 }
723 var m Manifest
724 if err := json.Unmarshal(raw, &m); err != nil {
725 return nil, nil, fmt.Errorf("manifest %s/%s: %v", app, version, err)
726 }
727 if err := validateManifest(&m); err != nil {
728 return nil, nil, fmt.Errorf("manifest %s/%s: %w", app, version, err)
729 }
730 if m.App != app || m.Version != version {
731 return nil, nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(acct, app, version), m.App, m.Version)
732 }
733 return &m, raw, nil
734}
735
736// Promote points a channel at a shipped version with the deployment's root
737// key. It is Signer.Promote with RootSigner — the shape every caller that
738// holds the root key (the console, the migration verb, the tests) keeps using
739// unchanged.
740func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, rec ...Promotion) error {
741 return RootSigner(priv).Promote(ctx, s, acct, app, channel, version, rec...)
742}
743
744// Promote points a channel at a shipped version, signing the new pointer and
745// swapping it in via CAS (a concurrent promote surfaces as ErrCASConflict —
746// re-read and retry, never overwrite blind). The outgoing version is
747// prepended to History (capped at historyMax), which is what Rollback
748// consumes. An existing pointer must verify against the deployment's ROOT
749// public key before it is replaced: history from a document we can't trust
750// doesn't get carried forward.
751//
752// An optional rec appends an entry to the pointer's audit trail (who, from
753// which rung, hotfix or not). It is variadic because most callers — the
754// migration verb, the tests, anything moving a pointer mechanically — have no
755// promotion to record; a promotion a person made does.
756func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error {
757 return sg.promote(ctx, s, acct, app, channel, version, nil, "", rec...)
758}
759
760// promote is Promote with an optional authorize hook, called on a pipeline
761// read after the pointer read and before the conditional write. nil means no
762// hook and no extra reads.
763//
764// expectVersion, when set, is the version the pointer being replaced must
765// still name: the retry path's guarantee that it renews a promotion and never
766// changes the version. It is checked on the very pointer whose etag the write
767// is conditional on, so a promote that lands in between fails the write
768// rather than being overwritten. "" means no check.
769func (sg Signer) claimRawPromote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, expectVersion string, rec ...Promotion) error {
770 if !ValidAccount(acct) {
771 return fmt.Errorf("promote: invalid account %q", acct)
772 }
773 if !ValidApp(app) {
774 return fmt.Errorf("promote: invalid app %q", app)
775 }
776 if !ValidChannel(channel) {
777 return fmt.Errorf("promote: invalid channel %q", channel)
778 }
779 if !ValidVersion(version) || version == "dev" {
780 return fmt.Errorf("promote: invalid version %q", version)
781 }
782 // A scoped key learns it may not touch this channel HERE, before a single
783 // byte moves in either direction: no manifest read, no pointer read, and
784 // above all no write. The ladder refuses in the same style, and for the
785 // same reason — an operator finding out from a verifier three machines
786 // away has already been told the wrong thing about what happened.
787 if err := sg.allows(channel, time.Now().UTC()); err != nil {
788 return fmt.Errorf("promote: %w", err)
789 }
790 // A channel only ever points at a version that was actually shipped. The
791 // raw bytes are kept: they are what the publisher shipped, and the only
792 // bytes a head digest may be computed from (spec §4.6).
793 _, shippedRaw, err := GetManifestRaw(ctx, s, acct, app, version)
794 if err != nil {
795 return fmt.Errorf("promote: version %s is not shipped: %w", version, err)
796 }
797 pub := sg.verifyPub()
798 key := ChannelKey(acct, app, channel)
799 // The qualified key and nothing else, so the etag CAS holds against the
800 // object actually being written. An app still on legacy keys reads as a
801 // FIRST promote here and its history does not come forward — carrying
802 // history across the two key spaces is `accounts migrate`'s job, not a
803 // side effect of the next deploy.
804 //
805 // GetQualified for uniform plumbing, though the hint it attaches is
806 // DISCARDED below: an absent pointer is the legitimate first promotion of
807 // a fresh channel, and an app that has never been promoted here is not
808 // thereby unmigrated. PromoteOnLadder makes that distinction, where the
809 // whole app's prefix is in view.
810 cur, etag, err := GetQualified(ctx, s, key)
811 var old *ChannelPointer
812 switch {
813 case err == nil:
814 old, err = verifyPointer(pub, cur, acct, app, channel)
815 if err != nil {
816 return fmt.Errorf("promote: existing pointer at %s: %w", key, err)
817 }
818 case errors.Is(err, os.ErrNotExist):
819 old, etag = &ChannelPointer{}, "" // first promote: create-only Put
820 default:
821 return err
822 }
823 marker, merr := ReadDigestMarker(ctx, s, pub, acct)
824 if merr != nil {
825 return fmt.Errorf(TextMarkerUnreadable, "promote", acct, merr)
826 }
827 if version != old.Version {
828 // A promote onto a build the predecessor already lists is bound to
829 // that entry's signed digest (#253): without this it would sign
830 // whatever bytes the bucket holds under the name now, which is the
831 // replay the digests exist to stop. An approval-gated rollback runs
832 // as exactly this promote. The binding holds with the marker off
833 // too (Astra r1 #3): suspending emission stops digests being
834 // written, not the refusal of a history entry the spec says is
835 // "never a rollback target once its channel has any digest".
836 for i, v := range old.History {
837 if v != version {
838 continue
839 }
840 want := alignedHistoryDigests(old)[i]
841 switch {
842 case want != "":
843 if got := ManifestDigest(shippedRaw); got != want {
844 return fmt.Errorf("promote: %w: %s/%s hashes to %s, the pointer signed %s",
845 ErrManifestDigest, app, version, digestShort(got), digestShort(want))
846 }
847 case old.ChannelHasDigests():
848 return &ResignNeededError{App: app, Channel: channel, Version: version}
849 }
850 break
851 }
852 }
853 if expectVersion != "" && old.Version != expectVersion {
854 return fmt.Errorf("promote --retry renews a promotion: %s now names %s, not %s", channel, old.Version, expectVersion)
855 }
856 // A retry's whole effect is a new PromotedAt, so freshness is checked
857 // HERE, against the pointer whose etag guards the write, not against an
858 // earlier read: a concurrent retry that renewed the same version in
859 // between would otherwise be re-stamped with its own second-truncated
860 // time, and a box that refused that promotion would stay blocked behind
861 // a "successful" retry (final review F7).
862 if expectVersion != "" {
863 if err := waitPastPromotedAt(ctx, channel, old.PromotedAt); err != nil {
864 return err
865 }
866 }
867 if authorize != nil {
868 // Between the pointer read and the conditional write, so the write
869 // lands only if the pointer is still what was authorized against
870 // (spec §7.2, G1 and G2).
871 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
872 if perr != nil {
873 return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
874 }
875 if aerr := authorize(pipe); aerr != nil {
876 return &AuthorizationError{Err: aerr}
877 }
878 }
879 // The durable record of this promotion (spec §4.1), from the bytes the
880 // publisher shipped: after every check that can refuse the write (the
881 // retry guard, the authoritative authorize callback), and BEFORE the
882 // pointer moves, so a refused promotion leaves nothing behind and a
883 // published head never lacks one. Only a NEW head is recorded: a renewal
884 // (--retry) keeps the head it found, and signing that head's current
885 // bytes here would be the automatic backfill §4.1 forbids. A legacy head
886 // is authorized only by `carlos releases authorize`.
887 if old.Version != version {
888 actor := ""
889 if len(rec) > 0 {
890 actor = rec[0].Actor
891 }
892 if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: acct, App: app, Version: version,
893 ManifestDigest: ManifestDigest(shippedRaw), Channel: channel, Actor: actor}); err != nil {
894 return fmt.Errorf("promote: write the promotion record for %s: %w", version, err)
895 }
896 }
897 next := &ChannelPointer{Account: acct, App: app, Channel: channel, Version: version,
898 History: old.History, Audit: old.Audit}
899 if old.Version != "" && old.Version != version {
900 next.History = prependCapped(old.Version, old.History, historyMax)
901 }
902 if marker != nil {
903 // The head is newly promoted, so its digest is the shipped bytes'.
904 // A renewal of the same head (--retry) promotes nothing new and
905 // copies it, like every inherited entry.
906 next.Digest = ManifestDigest(shippedRaw)
907 if old.Version == version {
908 next.Digest = old.Digest
909 }
910 next.HistoryDigests = inheritedDigests(old, version)
911 }
912 if len(rec) > 0 {
913 e := rec[0]
914 e.Version = version // the entry describes THIS promotion, whatever it said
915 if e.At.IsZero() {
916 e.At = time.Now().UTC().Truncate(time.Second)
917 }
918 next.Audit = prependCapped(e, old.Audit, auditMax)
919 }
920 wrapper, err := signPointer(sg, next)
921 if err != nil {
922 return err
923 }
924 _, err = s.Put(ctx, key, wrapper, etag)
925 return err
926}
927
928// waitPastPromotedAt returns once the stamp signPointer would produce (now,
929// truncated to the second) is strictly after prev, waiting into the next
930// second if it must. The wait is bounded at 2 s: a prev further ahead than
931// that is clock skew, and it refuses rather than write a stamp that is not
932// newer.
933func waitPastPromotedAt(ctx context.Context, channel string, prev time.Time) error {
934 if time.Now().UTC().Truncate(time.Second).After(prev) {
935 return nil
936 }
937 t := time.NewTimer(min(time.Until(prev.Add(time.Second)), 2*time.Second))
938 select {
939 case <-ctx.Done():
940 t.Stop()
941 return ctx.Err()
942 case <-t.C:
943 }
944 if !time.Now().UTC().Truncate(time.Second).After(prev) {
945 return fmt.Errorf("promote --retry: %s was promoted at %s, ahead of this clock; nothing was written",
946 channel, prev.Format(time.RFC3339))
947 }
948 return nil
949}
950
951// PromoteOptions carries what the ladder needs to know about a promotion
952// beyond "which version, which channel".
953type PromoteOptions struct {
954 // Hotfix bypasses the ordering check entirely — the one sanctioned way
955 // past a gate, and recorded as such (spec §3: a bypass that is invisible
956 // is indistinguishable from a broken gate).
957 Hotfix bool
958 // Retry re-signs a pointer that already names this version, with a
959 // fresh PromotedAt: the way past a box's D1 refusal (refusal.go), which
960 // blocks one promotion, not a version. Never changes the version.
961 Retry bool
962 // Actor is who is promoting, recorded in the pointer's audit trail: a
963 // console member's address, or the operator's user@host at the CLI.
964 Actor string
965 // Authorize, when set, is called with the app's pipeline as read at each
966 // point a write could depend on it: the ladder's read, the repeat path,
967 // and between the pointer read and the conditional write. A non-nil
968 // error refuses the promotion as an *AuthorizationError. nil means
969 // today's behaviour exactly, with no extra reads.
970 Authorize func(p *Pipeline) error
971}
972
973// PromoteResult says what a ladder promotion actually did, so the caller can
974// tell the operator.
975type PromoteResult struct {
976 // From is the rung the version occupied before this promotion (the
977 // highest, if several); "" when it was on no rung.
978 From string
979 // To is the rung of the target channel; "" when the channel is not a rung
980 // at all, which only a hotfix can reach.
981 To string
982 // NoOp is a repeat: the channel already pointed at this version, so
983 // nothing was written and nothing failed.
984 NoOp bool
985 // Hotfix is whether the ordering check was bypassed.
986 Hotfix bool
987 // Tag is the semver tag stable promotion cut (or found already cut) for
988 // this version — "" off stable, and "" for versions that are not
989 // iterations. TagWarning says why a stable promotion carries no tag, or
990 // that cutting one failed; it is never an error, because the pointer
991 // has already moved and a promotion that happened must report success.
992 Tag string
993 TagWarning string
994}
995
996// stableTag fills res.Tag/TagWarning after version landed on (or was already
997// on) stable. Failures degrade to the warning: the promotion is real whether
998// or not its name-cutting worked, and a caller that turned this into an error
999// would tell the operator a deploy failed while the fleet moved to the build.
1000func stableTag(ctx context.Context, s Store, acct, app, version, actor string, res *PromoteResult) {
1001 if res.To != RungStable {
1002 return
1003 }
1004 tag, _, err := EnsureTag(ctx, s, acct, app, version, actor, time.Now().UTC())
1005 switch {
1006 case err != nil:
1007 res.TagWarning = "promoted, but cutting the tag failed: " + err.Error()
1008 case tag == nil:
1009 res.TagWarning = "no tag cut: " + version + " is not a <target>-<n> iteration"
1010 case tag.Release != version:
1011 // EnsureTag found an existing tag, but it names a DIFFERENT iteration
1012 // than the one just promoted (tag 0.5.0 -> 0.5.0-1, stable now serves
1013 // 0.5.0-2): the tag does not describe this promotion, so it is not
1014 // this promotion's tag.
1015 res.TagWarning = fmt.Sprintf("%s is already tagged at %s — this promotion carries no tag", tag.Version, tag.Release)
1016 default:
1017 res.Tag = tag.Version
1018 }
1019}
1020
1021// PromoteOnLadder is Promote with the channel ladder enforced: it works out
1022// which rung this version already occupies (from the app's existing channel
1023// pointers), checks the transition, and only then moves the pointer.
1024//
1025// Three things it deliberately does NOT do. It never refuses a canary — the
1026// fleet runs on canary/* channels and maturity has no say over them. It never
1027// fails a repeat: a channel that already points at this version is a no-op
1028// success, so running the same command twice is not an error. And it never
1029// touches a route — promotion moves a signed pointer and nothing else, so
1030// beta and stable are always wired to whatever their routes say they are.
1031//
1032// Plain Promote stays as it was: this is the verb's entry point, not a
1033// replacement, so nothing that legitimately points a channel outside the
1034// ladder (a migration, the console's canary flow) changes behaviour.
1035func PromoteOnLadder(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, opt PromoteOptions) (PromoteResult, error) {
1036 return RootSigner(priv).PromoteOnLadder(ctx, s, acct, app, channel, version, opt)
1037}
1038
1039// PromoteOnLadder as a scoped or root identity. A scoped key meets TWO gates
1040// here, and they answer different questions: the ladder asks whether this
1041// BUILD is mature enough for the rung, the grant asks whether this KEY may
1042// reach it at all. -hotfix bypasses the first and can never bypass the
1043// second — a bypass an operator can type is not a way to widen a delegation.
1044func (sg Signer) claimRawPromoteOnLadder(ctx context.Context, s Store, acct, app, channel, version string, opt PromoteOptions) (PromoteResult, error) {
1045 res := PromoteResult{Hotfix: opt.Hotfix}
1046 if !ValidAccount(acct) {
1047 return res, fmt.Errorf("promote: invalid account %q", acct)
1048 }
1049 if !ValidApp(app) {
1050 return res, fmt.Errorf("promote: invalid app %q", app)
1051 }
1052 if !ValidChannel(channel) {
1053 return res, fmt.Errorf("promote: invalid channel %q", channel)
1054 }
1055 if !ValidVersion(version) || version == "dev" {
1056 return res, fmt.Errorf("promote: invalid version %q", version)
1057 }
1058 // Before the no-op read, before the prefix listing, before anything: a key
1059 // that may not sign this channel is told so without touching the bucket.
1060 if err := sg.allows(channel, time.Now().UTC()); err != nil {
1061 return res, fmt.Errorf("promote: %w", err)
1062 }
1063 // The target, named: its RUNG when the channel is one of the frozen
1064 // rungs (canary/x reports "canary" — the rung, not the channel, which is
1065 // what the ladder's vocabulary means by "to"), else the channel's own
1066 // name. Without the fallback an app-defined channel reported "", which is
1067 // not merely a thin API answer: it is the To written into the Promotion
1068 // record in the pointer's own audit trail, the permanent record of where
1069 // a build went — and From on that same path already carries the channel
1070 // name (VersionStage answers in channel names), so a pipelined app's
1071 // trail read "edge -> " forever. Wire-additive in both places: a fuller
1072 // string exactly where an empty one used to be.
1073 if rung, ok := Rung(channel); ok {
1074 res.To = rung
1075 } else {
1076 res.To = channel
1077 }
1078 pub := sg.verifyPub()
1079
1080 // A repeat is a no-op, checked before anything else: re-running a command
1081 // must not fail, whichever rung the build has since climbed to.
1082 if cur, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil {
1083 if p, verr := verifyPointer(pub, cur, acct, app, channel); verr == nil && p.Version == version {
1084 if opt.Authorize != nil {
1085 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
1086 if perr != nil {
1087 return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
1088 }
1089 if aerr := opt.Authorize(pipe); aerr != nil {
1090 return res, &AuthorizationError{Err: aerr}
1091 }
1092 }
1093 if opt.Retry {
1094 res.From = res.To
1095 rec := Promotion{From: res.To, To: res.To, Actor: opt.Actor, Retry: true}
1096 // The version guard and the fresh-stamp wait both live in
1097 // sg.promote, on the pointer its write is conditional on.
1098 if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, version, rec); err != nil {
1099 return res, err
1100 }
1101 return res, nil
1102 }
1103 res.NoOp = true
1104 // The same fallback res.To takes above, for the same reason: the
1105 // build IS on this channel (that is what makes this a repeat), so
1106 // an app-defined channel names itself here too rather than
1107 // reporting "" for where the build already sits.
1108 res.From = res.To
1109 // A repeat still reports the tag — and cuts it if a previous
1110 // attempt's write failed, so re-running the same command heals a
1111 // stable release that ended up nameless.
1112 stableTag(ctx, s, acct, app, version, opt.Actor, &res)
1113 return res, nil
1114 }
1115 } else if !errors.Is(err, os.ErrNotExist) {
1116 return res, fmt.Errorf("promote: read %s: %w", ChannelKey(acct, app, channel), err)
1117 }
1118 // Reached only when the pointer is absent or names another version: a
1119 // retry renews a promotion, it never makes one.
1120 if opt.Retry {
1121 cur := "nothing"
1122 if c, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil {
1123 if p, verr := verifyPointer(pub, c, acct, app, channel); verr == nil {
1124 cur = p.Version
1125 }
1126 }
1127 return res, fmt.Errorf("promote --retry renews a promotion: %s names %s, not %s", channel, cur, version)
1128 }
1129
1130 // An app with NOTHING under its account-qualified prefix cannot be
1131 // promoted — Promote needs a shipped manifest there — so refuse now, while
1132 // the reason is still nameable. Left to run on, the ladder answers first
1133 // with "this build is on no rung yet", which is true (nothing qualified
1134 // means no rung) and entirely beside the point on a deployment that
1135 // upgraded from a pre-accounts binary and never migrated. Promote's own
1136 // read cannot say it either: an absent pointer is the legitimate first
1137 // promotion of a fresh channel, so the miss has to stay silent there.
1138 //
1139 // AppHasQualifiedObjects is the same test adopt applies to decide whether
1140 // a missing pointer deserves the advice — one rule, two callers.
1141 if has, err := AppHasQualifiedObjects(ctx, s, acct, app); err != nil {
1142 return res, fmt.Errorf("promote: list %s: %w", AppPrefix(acct, app), err)
1143 } else if !has {
1144 return res, fmt.Errorf("promote: nothing has ever been shipped to %s under account %s%s",
1145 app, acct, migrationHint(AppPrefix(acct, app)))
1146 }
1147
1148 // The pipeline decides the shape of everything below: which channels are
1149 // even in the running (VersionStage vs VersionRung) and how order is
1150 // judged (PipelinePromotionAllowed vs PromotionAllowed). A pipeline that
1151 // exists but cannot be read or verified refuses the promotion outright —
1152 // GetPipeline's error already means "not safely nil", and treating that
1153 // as "no pipeline" would silently drop back to the legacy ladder under
1154 // exactly the app that opted out of it.
1155 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
1156 if perr != nil {
1157 return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
1158 }
1159 if opt.Authorize != nil {
1160 if aerr := opt.Authorize(pipe); aerr != nil {
1161 return res, &AuthorizationError{Err: aerr}
1162 }
1163 }
1164 var from string
1165 var err error
1166 if pipe != nil {
1167 from, err = VersionStage(ctx, s, pub, acct, app, version, pipe)
1168 } else {
1169 from, err = VersionRung(ctx, s, pub, acct, app, version)
1170 }
1171 if err != nil {
1172 return res, fmt.Errorf("promote: %w", err)
1173 }
1174 res.From = from
1175 if !opt.Hotfix {
1176 // res.To already names a channel that is not a rung as itself (see
1177 // its fallback above) — "wip is not a rung" reads better in the
1178 // refusal than an empty target.
1179 to := res.To
1180 if pipe != nil {
1181 err = PipelinePromotionAllowed(pipe, from, channel)
1182 } else {
1183 err = PromotionAllowed(from, to)
1184 }
1185 if err != nil {
1186 return res, fmt.Errorf("promote %s %s -> %s: %w", app, version, channel, err)
1187 }
1188 }
1189 rec := Promotion{From: res.From, To: res.To, Actor: opt.Actor, Hotfix: opt.Hotfix}
1190 if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, "", rec); err != nil {
1191 return res, err
1192 }
1193 stableTag(ctx, s, acct, app, version, opt.Actor, &res)
1194 return res, nil
1195}
1196
1197// VersionRung reports the highest rung any of this app's channels currently
1198// points at version — "how mature is this build", answered from the pointers
1199// themselves rather than a side table that could disagree with them. "" means
1200// the version is on no rung (it may still be shipped, or on a channel outside
1201// the ladder's vocabulary).
1202//
1203// A channel whose pointer cannot be read or verified is skipped rather than
1204// fatal: an unreadable pointer can only LOWER the answer, which makes the
1205// ordering check stricter, never looser.
1206func VersionRung(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (string, error) {
1207 // Verification needs a real key: ed25519.Verify PANICS on a wrong-sized
1208 // one, and a read-only console runs with none.
1209 if len(pub) != ed25519.PublicKeySize {
1210 return "", fmt.Errorf("version rung: no usable release public key")
1211 }
1212 prefix := ChannelsPrefix(acct, app)
1213 keys, err := s.List(ctx, prefix)
1214 if err != nil {
1215 return "", fmt.Errorf("list channels of %s: %w", app, err)
1216 }
1217 best := ""
1218 for _, k := range keys {
1219 name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
1220 rung, ok := Rung(name)
1221 if !ok || RungIndex(rung) <= RungIndex(best) {
1222 continue // not a rung, or no higher than what we already found
1223 }
1224 p, err := Channel(ctx, s, pub, acct, app, name)
1225 if err != nil || p.Version != version {
1226 continue
1227 }
1228 best = rung
1229 }
1230 return best, nil
1231}
1232
1233// Channel resolves a channel's current pointer, verifying its signature —
1234// the one read path the fleet uses, so nothing unsigned ever reaches a box.
1235// Account-qualified only: an app whose pointer is still at the legacy key
1236// does not resolve here, it resolves after `accounts migrate`. Not-found is
1237// os.ErrNotExist-wrapped and says exactly that.
1238func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, channel string) (*ChannelPointer, error) {
1239 raw, _, err := GetQualified(ctx, s, ChannelKey(acct, app, channel))
1240 if err != nil {
1241 return nil, err
1242 }
1243 return verifyPointer(pub, raw, acct, app, channel)
1244}
1245
1246// Rollback swaps a channel back to History[0], pushing the outgoing version
1247// onto History — so a second rollback returns to where you were. Boxes apply
1248// it immediately per the bake rules: a box that has previously seen the
1249// target at this channel's head has a first-seen record older than the hold
1250// (that's how it got installed the first time), so evacuating a bad release
1251// never itself sits in a bake window (bake.go).
1252func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel string) error {
1253 return RootSigner(priv).Rollback(ctx, s, acct, app, channel)
1254}
1255
1256// Rollback as a scoped or root identity. A rollback signs a new pointer for
1257// the same channel, so it is bounded by the same grant: a key that cannot
1258// promote to stable cannot roll stable back either. Rolling back a channel
1259// outside the scope needs the root key, which is the same answer as moving it
1260// forward.
1261func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel string) error {
1262 return sg.RollbackAuthorized(ctx, s, acct, app, channel, nil)
1263}
1264
1265// RollbackAuthorized is Rollback with an optional authorize hook, called on a
1266// pipeline read after the pointer read and before the conditional write. nil
1267// means no hook and no extra reads.
1268func (sg Signer) claimRawRollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error {
1269 if !ValidAccount(acct) {
1270 return fmt.Errorf("rollback: invalid account %q", acct)
1271 }
1272 if !ValidApp(app) || !ValidChannel(channel) {
1273 return fmt.Errorf("rollback: invalid app/channel %s/%s", app, channel)
1274 }
1275 if err := sg.allows(channel, time.Now().UTC()); err != nil {
1276 return fmt.Errorf("rollback: %w", err)
1277 }
1278 pub := sg.verifyPub()
1279 key := ChannelKey(acct, app, channel)
1280 cur, etag, err := GetQualified(ctx, s, key)
1281 if err != nil {
1282 return fmt.Errorf("rollback: %w", err)
1283 }
1284 old, err := verifyPointer(pub, cur, acct, app, channel)
1285 if err != nil {
1286 return fmt.Errorf("rollback: existing pointer at %s: %w", key, err)
1287 }
1288 if len(old.History) == 0 {
1289 return fmt.Errorf("rollback: %s/%s has no previous version recorded", app, channel)
1290 }
1291 if authorize != nil {
1292 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
1293 if perr != nil {
1294 return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr)
1295 }
1296 if aerr := authorize(pipe); aerr != nil {
1297 return &AuthorizationError{Err: aerr}
1298 }
1299 }
1300 marker, merr := ReadDigestMarker(ctx, s, pub, acct)
1301 if merr != nil {
1302 return fmt.Errorf(TextMarkerUnreadable, "rollback", acct, merr)
1303 }
1304 hd := alignedHistoryDigests(old)
1305 // Marker or not (Astra r1 #3, spec §4.6): a history entry without a
1306 // digest is never a rollback target once its channel has any digest.
1307 // Only emission depends on the marker.
1308 if old.ChannelHasDigests() && hd[0] == "" {
1309 return &ResignNeededError{App: app, Channel: channel, Version: old.History[0]}
1310 }
1311 next := &ChannelPointer{
1312 Account: acct,
1313 App: app,
1314 Channel: channel,
1315 Version: old.History[0],
1316 History: prependCapped(old.Version, old.History[1:], historyMax),
1317 // A rollback is not a promotion and appends nothing, but it must not
1318 // erase how the channel got here either.
1319 Audit: old.Audit,
1320 }
1321 if marker != nil {
1322 // Versions and digests swap together (spec §4.6).
1323 next.Digest = hd[0]
1324 next.HistoryDigests = prependCapped(old.Digest, hd[1:], historyMax)
1325 }
1326 wrapper, err := signPointer(sg, next)
1327 if err != nil {
1328 return err
1329 }
1330 _, err = s.Put(ctx, key, wrapper, etag)
1331 return err
1332}
1333
1334// FastTrack re-signs a channel's current pointer with the bake-window
1335// waiver set — same version, same history, one new audit entry (the
1336// Rollback precedent: a write that is not a promotion stays out of the
1337// ladder). Root key only: the window defends subscribers against exactly
1338// the keys that are not the root, so this takes priv directly and never a
1339// Signer. A head already waived is a no-op success. Returns the version
1340// waived either way.
1341//
1342// signPointer re-stamps PromotedAt, which is correct here: the bake clock
1343// keys on the box's own first sighting of the VERSION (bake.go), which this
1344// write does not change, and the console suppresses its bakes-until
1345// estimate for a waived head.
1346func claimRawFastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, actor string) (string, error) {
1347 sg := RootSigner(priv)
1348 if !ValidAccount(acct) {
1349 return "", fmt.Errorf("fast-track: invalid account %q", acct)
1350 }
1351 if !ValidApp(app) || !ValidChannel(channel) {
1352 return "", fmt.Errorf("fast-track: invalid app/channel %s/%s", app, channel)
1353 }
1354 key := ChannelKey(acct, app, channel)
1355 cur, etag, err := GetQualified(ctx, s, key)
1356 if err != nil {
1357 return "", fmt.Errorf("fast-track: %w", err)
1358 }
1359 old, err := verifyPointer(sg.verifyPub(), cur, acct, app, channel)
1360 if err != nil {
1361 return "", fmt.Errorf("fast-track: existing pointer at %s: %w", key, err)
1362 }
1363 if old.FastTrack {
1364 return old.Version, nil
1365 }
1366 marker, merr := ReadDigestMarker(ctx, s, sg.verifyPub(), acct)
1367 if merr != nil {
1368 return "", fmt.Errorf(TextMarkerUnreadable, "fast-track", acct, merr)
1369 }
1370 rung, _ := Rung(channel)
1371 next := &ChannelPointer{
1372 Account: acct, App: app, Channel: channel, Version: old.Version,
1373 History: old.History, FastTrack: true,
1374 Audit: prependCapped(Promotion{
1375 Version: old.Version, From: rung, To: rung, Actor: actor,
1376 FastTrack: true, At: time.Now().UTC().Truncate(time.Second),
1377 }, old.Audit, auditMax),
1378 }
1379 if marker != nil {
1380 // Verbatim, never fetched (spec §4.6): the same head, the same history.
1381 next.Digest = old.Digest
1382 next.HistoryDigests = alignedHistoryDigests(old)
1383 if len(next.HistoryDigests) == 0 {
1384 next.HistoryDigests = nil
1385 }
1386 }
1387 wrapper, err := signPointer(sg, next)
1388 if err != nil {
1389 return "", err
1390 }
1391 if _, err := s.Put(ctx, key, wrapper, etag); err != nil {
1392 return "", err
1393 }
1394 return old.Version, nil
1395}
1396
1397// prependCapped puts v at the head of list, dropping the tail beyond max —
1398// the newest-first, bounded shape both the rollback history and the audit
1399// trail have.
1400func prependCapped[T any](v T, list []T, max int) []T {
1401 out := append([]T{v}, list...)
1402 if len(out) > max {
1403 out = out[:max]
1404 }
1405 return out
1406}
1407func FastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, actor string) (claimResult0 string, claimResult1 error) {
1408 claimResult1 = WithAppMutation(ctx, s, acct, app, "release.FastTrack", func(claimContext context.Context) error {
1409 claimResult0, claimResult1 = claimRawFastTrack(claimContext, s, priv, acct, app, channel, actor)
1410 return claimResult1
1411 })
1412 return
1413}
1414