// Package release is the release plane: content-addressed release manifests // and signed, CAS-guarded channel pointers in a deployment bucket. // // Ported from Eleven release.go (the signedManifest wrapper, key parsing, // keygen) with the trust split the plan calls for: release manifests are // content-addressed and immutable (their bucket path is claimed create-only, // so their integrity is their existence), while CHANNEL POINTERS are the // trust boundary — a pointer is what tells a fleet which build to run, so it // carries the Ed25519 signature and moves only via compare-and-swap. // // Bucket layout (deployment bucket): // // apps//releases//manifest.json content-addressed, immutable // apps//releases// binaries or static tree tar // apps//channels/.json SIGNED channel pointer, CAS via If-Match // // Keys: the private half is the CARLOS_RELEASE_KEY Ed25519 seed (env only — // secret manager -> shell variable -> process memory, never disk); the // public key is pinned in the deployment's host config // (CARLOS_RELEASE_PUBKEY), not baked into the binary. package release import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "regexp" "strings" "time" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/controlname" ) // Manifest describes one shipped release of one app. It is content-addressed // (immutable at its bucket path), so it needs no signature of its own: a // verified channel pointer names its version, and the manifest's artifact // sha256s gate every byte downloaded from it. type Manifest struct { Schema int `json:"schema"` // 1 App string `json:"app"` Version string `json:"version"` // git short sha Kind string `json:"kind"` // "binary" | "static" | "rootfs" Commit string `json:"commit,omitempty"` BuiltBy string `json:"built_by,omitempty"` BuiltAt string `json:"built_at,omitempty"` // Label and Notes are the human half of a release (release versioning // spec, 2026-08-08): a short one-line intent ("fixing modals") and a // changelog fragment, both written at ship time. ADDITIVE to schema 1 // exactly as Capabilities was — absent from every manifest written // before the fields existed, omitted from marshal when empty, ignored // by old binaries. Nothing on the adopt path reads either. Label string `json:"label,omitempty"` Notes string `json:"notes,omitempty"` // Program is the Go main package path of the release's binaries // (debug/buildinfo), and ProgramChange records an intended change of it // (`carlos ship --program-change`). ADDITIVE to schema 1 like Label: // omitted when empty, ignored by old binaries. Console release defences // D2 (program.go). Program string `json:"program,omitempty"` ProgramChange bool `json:"program_change,omitempty"` // Swap is this release's own swap demand (zero-downtime deploys spec // §4.2). DemandHold says the build's boot must not share the instance's // database with the build before it, so an awake instance moves onto it // stop-then-start. Empty means the release demands nothing and the app's // policy decides. ADDITIVE to schema 1 like Label: absent from every // manifest written before it, omitted from marshal when empty, ignored by // old binaries. A box reads it from the CANONICAL manifest only // (CanonicalDemand), never from its staged copy, because a root older // than this field re-marshalled its copies without it. Swap string `json:"swap,omitempty"` // Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a // static tree tar. Artifacts map[string]Artifact `json:"artifacts"` // Capabilities is what this BUILD needs from the platform // (internal/caps). ADDITIVE to schema 1, exactly as // appconfig.Manifest.ExecDelivery was: a manifest written before this // field existed has no "capabilities" key, parses with a nil pointer, and // means no capabilities — which is today's behaviour byte for byte, and // is most of every production bucket. A POINTER with omitempty, so a // capability-free manifest also MARSHALS byte-identically to before (no // "capabilities": {} appears), which is what keeps Ship's idempotent // re-ship of an old release working. // // The reverse direction holds too: an OLD binary — one with no caps // package at all — still parses a NEW manifest, because encoding/json // ignores unknown top-level keys and nothing on the manifest read path // uses DisallowUnknownFields. That is what makes the pinned deploy order // (platform binaries first, capability-bearing releases second) a // belt-and-braces rule rather than a load-bearing one. // // It lives in the release manifest and not the config manifest because it // describes the unit the platform OWNS and it changes with the code: a // rollback rolls the unit shape back with the binary, and the declaration // rides the same trust chain as the bytes (signed pointer -> immutable // manifest -> sha256). Spec §3a. Capabilities *caps.Set `json:"capabilities,omitempty"` // Processes, Hibernate and JIT are the rootfs kind's fields (rootfs apps // Plan 1, spec §5): valid only when Kind is "rootfs" and absent from every // binary and static manifest, which marshal byte for byte as before // (omitempty). Processes names what the release runs (web, workers, // release, console). Hibernate is the app's opt-in to sleeping when idle // (default always on). JIT lifts MemoryDenyWriteExecute for every process // of the release, for runtimes that compile at run time; Plan 2 reads it // from the root-staged manifest. Processes map[string]Process `json:"processes,omitempty"` Hibernate bool `json:"hibernate,omitempty"` JIT bool `json:"jit,omitempty"` } // Artifact is one file of a release: its bucket file name and the sha256 // every downloader must verify before staging it. type Artifact struct { File string `json:"file"` SHA256 string `json:"sha256"` Size int64 `json:"size"` } // ChannelPointer says which version a channel currently serves. This is the // document the fleet trusts, so it ships wrapped in signedWrapper and only // moves via CAS. type ChannelPointer struct { Schema int `json:"schema"` // Account is the sqid of the account the pointer was signed for. App // names are unique only WITHIN an account, so app+channel alone matched // "blog/edge" in every account at once, and a byte copy of one account's // pointer verified at another's key (tenant-isolation audit, discussion // #220). Signed into every pointer written since, and checked against the // key's account whenever present. // // ADDITIVE, and deliberately optional on read: every pointer written // before the field existed has no "account" key and keeps verifying // wherever it sits — see verifyPointer for what that leaves open. Old // binaries ignore the key and verify exactly as before. Account string `json:"account,omitempty"` App string `json:"app"` Channel string `json:"channel"` Version string `json:"version"` PromotedAt time.Time `json:"promoted_at"` // History is the previous versions, newest first, max historyMax — // rollback targets. Rollback swaps Version <-> History[0], so a second // rollback returns to where you were (Eleven versions.go's // current<->previous swap, generalised). History []string `json:"history,omitempty"` // Digest is the sha256 of Version's canonical manifest bytes // (ManifestDigest), and HistoryDigests holds one per History entry in the // same order, "" where the writer had none to copy (#253, spec §4.6). // ADDITIVE and inside the signed payload: absent from every pointer // written before them and from every pointer written while the digest // marker is off, so those marshal byte-identically to before; old // binaries ignore both keys and verify as they always have. A digest is // either computed at promotion from the bytes the publisher shipped, or // copied verbatim from the verified predecessor: never computed for an // entry a writer inherits (inheritedDigests). Digest string `json:"digest,omitempty"` HistoryDigests []string `json:"history_digests,omitempty"` // Audit is the promotion trail, newest first, max auditMax: how this // channel came to point where it does, and who said so. ADDITIVE — a // pointer written before the trail existed has no "audit" key, parses // with none, and is not an error; that is most of the live fleet. Audit []Promotion `json:"audit,omitempty"` // FastTrack waives the supply-chain bake window for THIS version on THIS // channel (2026-08-04 console bake controls spec §5). Root-key-only at // mint AND at verification — a scoped key must never skip the window it // bakes under. Set per write and never inherited: the next promotion // starts clean. ADDITIVE: old pointers have no key; old binaries drop the // field and keep baking, the safe direction. FastTrack bool `json:"fast_track,omitempty"` } // Promotion is one entry in a channel pointer's audit trail. It rides inside // the SIGNED payload, so the trail is as trustworthy as the pointer itself — // an audit log that can be edited without breaking the signature is a log // that proves nothing. type Promotion struct { Version string `json:"version"` // From is the rung the version occupied before this promotion (highest, // if several); empty when it was on none. To is the rung it moved onto, // empty for a channel outside the ladder's vocabulary. From string `json:"from,omitempty"` To string `json:"to,omitempty"` // Actor is who promoted — a console member's address, or the operator's // user@host at the CLI. Empty when the caller did not say. Actor string `json:"actor,omitempty"` // Hotfix records that the ladder was bypassed. This is the whole reason // the field exists: a bypass nobody can see is a broken gate. Hotfix bool `json:"hotfix,omitempty"` // FastTrack records that this write waived the bake window — same // reasoning as Hotfix: a bypass nobody can see is a broken gate. FastTrack bool `json:"fast_track,omitempty"` // Retry records that this write only renewed an existing promotion of // the same version, so a box's refusal of the earlier one no longer // blocks it. Retry bool `json:"retry,omitempty"` At time.Time `json:"at"` } // historyMax bounds a pointer's rollback history; auditMax bounds its // promotion trail. The trail is longer because it answers questions about the // past ("when did this reach stable, and who signed it off") that rollback // targets do not — but it is still bounded: a signed pointer that grows // without limit eventually stops being fetchable. const ( historyMax = 10 auditMax = 20 ) // MaxLabelLen / MaxNotesLen bound the human half of a manifest. The label // is a table cell; the notes are a changelog fragment, not a novel. const ( MaxLabelLen = 120 MaxNotesLen = 16 << 10 ) // ValidateLabelNotes is the one home for the label/notes bounds and their // refusal text — called from validateManifest (Ship and every GetManifest // read) AND from the console API's handleAPIShip, which must reach the same // verdict before a single artifact byte crosses the wire rather than only // after release.Ship re-derives it. Keeping the messages here means a future // change to either limit, or its wording, has one call site to edit rather // than two copies that can drift. func ValidateLabelNotes(label, notes string) error { if len(label) > MaxLabelLen || strings.ContainsAny(label, "\r\n") { return fmt.Errorf("label: one line, at most %d characters", MaxLabelLen) } if len(notes) > MaxNotesLen { return fmt.Errorf("notes: at most %d bytes", MaxNotesLen) } return nil } // Bucket keys, account-scoped: app names are unique WITHIN an account, so the // account sqid is part of every key and the key path itself carries the // app→account linkage (there is no separate linkage manifest). Channel names // may contain one "/" (canary/), which simply nests the pointer one // level deeper. // // acct is the account's sqid — "bab" for the built-in ops account. An empty // acct is INVALID and deliberately produces a broken key ("apps//kass/…") // rather than the legacy unqualified shape: silently rebuilding a legacy key // would make an unmigrated app look migrated, which is exactly the lie the // migration's verification gate exists to catch. func ManifestKey(acct, app, version string) string { return appPrefix(acct, app) + "releases/" + version + "/manifest.json" } func ArtifactKey(acct, app, version, file string) string { return appPrefix(acct, app) + "releases/" + version + "/" + file } func ChannelKey(acct, app, channel string) string { return appPrefix(acct, app) + "channels/" + channel + ".json" } // ChannelsPrefix and ReleasesPrefix are the list prefixes behind "what // channels/releases does this app have" — the console and the CLI list them. func ChannelsPrefix(acct, app string) string { return appPrefix(acct, app) + "channels/" } func ReleasesPrefix(acct, app string) string { return appPrefix(acct, app) + "releases/" } // AppPrefix is everything an app owns in the bucket, under its account, // except its open ledgers (LedgerAppPrefix) and its door-key record // (internal/doorkey), which live outside it. func AppPrefix(acct, app string) string { return appPrefix(acct, app) } // LedgerAppPrefix is where an app's open ledgers live. internal/ledger builds // every ledger key under it, and the edge serves public ones from it // (internal/edge/status.go). It is defined here, not in internal/ledger, // because PurgeApp sweeps it and ledger imports release. func LedgerAppPrefix(acct, app string) string { return "ledgers/" + acct + "/" + app + "/" } func appPrefix(acct, app string) string { return "apps/" + acct + "/" + app + "/" } // The legacy, account-less key shapes. Since the read-fallback was retired // (qualified.go) they exist for the migration verb, which copies from here to // there, and for tests building a pre-accounts bucket. Nothing writes them, // and no runtime read resolves through them. // // The two LIST prefixes that used to live here (channels/, releases/) are // gone with the fallback that was their only caller. A dead builder naming a // shape nothing may resolve is an invitation to resolve it. func LegacyManifestKey(app, version string) string { return legacyAppPrefix(app) + "releases/" + version + "/manifest.json" } func LegacyArtifactKey(app, version, file string) string { return legacyAppPrefix(app) + "releases/" + version + "/" + file } func LegacyChannelKey(app, channel string) string { return legacyAppPrefix(app) + "channels/" + channel + ".json" } // LegacyAppPrefix is everything an unmigrated app owns in the bucket — what // `accounts migrate -from legacy` lists and copies. func LegacyAppPrefix(app string) string { return legacyAppPrefix(app) } func legacyAppPrefix(app string) string { return "apps/" + app + "/" } func ValidApp(a string) bool { return controlname.ValidApp(a) } // channelSegRe is one channel path segment. var channelSegRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) // ValidChannel accepts "stable", "beta", any other single name, or a // two-segment "canary/" — each segment directory-name safe (the // channel lands in a bucket key and in on-box state files). func ValidChannel(c string) bool { parts := strings.Split(c, "/") if len(parts) == 0 || len(parts) > 2 { return false } for _, p := range parts { if p == "" || p == "." || p == ".." || len(p) > 64 || !channelSegRe.MatchString(p) { return false } } return true } // fileRe keeps artifact file names usable as single path segments. var fileRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) func validArtifactFile(f string) bool { return f != "" && f != "." && f != ".." && len(f) <= 128 && fileRe.MatchString(f) } // validateManifest rejects a manifest that parsed but can't be acted on. func validateManifest(m *Manifest) error { if m.Schema != 1 { return fmt.Errorf("unsupported manifest schema %d", m.Schema) } if !ValidApp(m.App) { return fmt.Errorf("invalid app %q", m.App) } if !ValidVersion(m.Version) || m.Version == "dev" { return fmt.Errorf("invalid version %q", m.Version) } switch m.Kind { case "binary", "static", KindRootfs: default: return fmt.Errorf("invalid kind %q (binary | static | rootfs)", m.Kind) } if err := validateRootfsShape(m); err != nil { return err } if len(m.Artifacts) == 0 { return fmt.Errorf("manifest has no artifacts") } for k, a := range m.Artifacts { if !validArtifactFile(a.File) { return fmt.Errorf("artifact %s: invalid file name %q", k, a.File) } if raw, err := hex.DecodeString(a.SHA256); err != nil || len(raw) != sha256.Size { return fmt.Errorf("artifact %s: sha256 must be %d hex chars", k, sha256.Size*2) } if a.Size < 0 { return fmt.Errorf("artifact %s: negative size", k) } } if err := ValidateLabelNotes(m.Label, m.Notes); err != nil { return err } // LAST, and deliberately the same call on both sides of the trust chain: // Ship runs validateManifest before writing a byte (a human is present to // read the refusal), and GetManifest runs it on every read (so a box // meeting a capability it does not understand fails ensureStaged and // prints one line per pass without staging or pointing). One gate, shared // by ship and adopt, exactly as appconfig.ValidExecDelivery is shared by // set-time and sync-time. A nil Capabilities is valid and means none. // // Returned unwrapped on purpose: every caps refusal already opens with // "capabilities: " (that prefix is pinned in Task 1's refusal table), and // the plan's `fmt.Errorf("capabilities: %w", err)` would render it twice // inside GetManifest's own "manifest %s/%s: %w" wrap. The operator-facing // string is the point of these messages, so the prefix is stated once. if err := caps.ValidCapabilities(m.App, m.Capabilities); err != nil { return err } return nil } // ---- signing ---------------------------------------------------------------- // signedWrapper is the wire format of a channel pointer: the signature // covers the exact base64 payload string, so verification never depends on // re-marshalling JSON — verify first, decode after, no JSON canonicalisation // to get wrong. (Ported from Eleven release.go's signedManifest.) type signedWrapper struct { Payload string `json:"payload"` Sig string `json:"sig"` // Grant is ABSENT for a root-signed pointer — omitempty on a nil pointer, // so the bytes a root key writes are byte-identical to the bytes it wrote // before scoped keys existed (golden_test.go pins exactly that). When // present, Sig above is by the SCOPED key and this is the root's // signature over the statement authorising it. // // An old binary reading a new grant-bearing wrapper fails signature // verification, because it checks Sig against the root key. That is the // correct fail-closed direction and it sets the deploy order: binaries // first, scoped keys second. Grant *signedGrant `json:"grant,omitempty"` } // signPointer stamps PromotedAt (now) and wraps the pointer in the signed // wire format, attaching the signer's grant when it has one. // // The scope check lives here as well as at the top of the promote verbs: this // is the single place a pointer becomes signed bytes, so a caller that // forgets to ask cannot produce a scope-violating pointer even by accident. func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) { if err := sg.usable(); err != nil { return nil, err } p.Schema = 1 p.PromotedAt = time.Now().UTC().Truncate(time.Second) // Optional on READ (old pointers), required on WRITE: nothing signed from // here on may be an unbound pointer, or the binding is a convention. if !ValidAccount(p.Account) { return nil, fmt.Errorf("channel pointer %s/%s: invalid account %q", p.App, p.Channel, p.Account) } if err := validatePointer(p, p.Account, p.App, p.Channel); err != nil { return nil, err } if err := sg.allows(p.Channel, p.PromotedAt); err != nil { return nil, err } if p.FastTrack && sg.scope != nil { return nil, fmt.Errorf("fast-track is root-key-only — key grant %q cannot waive the bake window", sg.scope.Name) } payload, err := json.MarshalIndent(p, "", " ") if err != nil { return nil, err } b64 := base64.StdEncoding.EncodeToString(payload) sig := ed25519.Sign(sg.key, []byte(b64)) return json.MarshalIndent(signedWrapper{ Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig), Grant: sg.grant, }, "", " ") } // verifyPointer checks the signature against pub and only then decodes and // validates the payload — everything the caller acts on came from under the // signature. wantAcct/wantApp/wantChannel are matched against the SIGNED // payload, so a validly-signed pointer copied from another channel's key (a // cross-channel replay) is rejected, and so is one copied from another // account's key when it names its account. // // What the account check cannot close: a pointer signed BEFORE the field // existed names no account, and verifies under any account's key with that // app and channel name — refusing it would take every channel not promoted // since dark. The field is inside the signature, so it cannot be stripped // from a newer pointer without breaking it; the exposure is the fixed set of // account-less pointers already signed, which (having no expiry) stay // replayable until verification stops accepting them. Every promote, // rollback or fast-track re-signs a channel with its account, so that set // only shrinks. Sunset: once no live channel pointer lacks an account, make // the account required here; until then the copy still needs write access // to the victim's prefix, and resolves only to a version shipped there, // since the manifest is read under the key's own account. // // pub is the deployment's ROOT public key, and it is the only key a box is // ever configured with. A wrapper with no grant was signed by that key // directly — the shape the whole fleet is on. A wrapper WITH a grant was // signed by a scoped key, and the grant is the root's signature saying which // rungs that key may reach; so the chain is checked root -> grant -> pointer, // and then the pointer's own channel and promoted_at are checked against the // scope. Both scope checks read the SIGNED payload, never the caller's // arguments — a grant that is honoured on a channel the payload does not name // would be no scope at all. // // This is the single parse site for channel pointers, and it must stay that // way: every consumer (Channel, adoption, VersionRung, the console, the edge) // inherits grant handling by funnelling through here. func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantAcct, wantApp, wantChannel string) (*ChannelPointer, error) { var sw signedWrapper if err := json.Unmarshal(wrapper, &sw); err != nil { return nil, fmt.Errorf("pointer wrapper: %v", err) } // signer is who must have signed the payload: the root itself, or the // scoped key the root delegated to. signer, scope := pub, (*Grant)(nil) if sw.Grant != nil { g, err := verifyGrant(pub, sw.Grant) if err != nil { return nil, err } gpub, err := ParsePublicKey(g.Pub, "key grant pub") if err != nil { return nil, err } signer, scope = gpub, g } sig, err := base64.StdEncoding.DecodeString(sw.Sig) if err != nil { return nil, fmt.Errorf("pointer sig: %v", err) } if len(signer) != ed25519.PublicKeySize { return nil, fmt.Errorf("channel pointer: no usable release public key") } if !ed25519.Verify(signer, []byte(sw.Payload), sig) { return nil, fmt.Errorf("channel pointer signature verification failed") } payload, err := base64.StdEncoding.DecodeString(sw.Payload) if err != nil { return nil, fmt.Errorf("pointer payload: %v", err) } var p ChannelPointer if err := json.Unmarshal(payload, &p); err != nil { return nil, fmt.Errorf("pointer payload: %v", err) } if err := validatePointer(&p, wantAcct, wantApp, wantChannel); err != nil { return nil, err } if p.FastTrack && sw.Grant != nil { 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) } if scope != nil { // promoted_at, not the wall clock: adoption re-verifies every pass, so // comparing expiry against now would make an expired grant take a // running route's channel resolution dark long after the promotion it // was meant to gate (spec §5). if err := scope.allows(p.Channel, p.PromotedAt); err != nil { return nil, fmt.Errorf("channel pointer %s/%s: %w", p.App, p.Channel, err) } } return &p, nil } func validatePointer(p *ChannelPointer, wantAcct, wantApp, wantChannel string) error { if p.Schema != 1 { return fmt.Errorf("unsupported pointer schema %d", p.Schema) } // Absent means a pointer from before the binding (see verifyPointer); // present means it must be this key's account. if p.Account != "" && p.Account != wantAcct { return fmt.Errorf("pointer %s/%s was signed for account %s, expected %s", p.App, p.Channel, p.Account, wantAcct) } if p.App != wantApp || p.Channel != wantChannel { return fmt.Errorf("pointer names %s/%s, expected %s/%s", p.App, p.Channel, wantApp, wantChannel) } if !ValidApp(p.App) || !ValidChannel(p.Channel) { return fmt.Errorf("pointer has invalid app/channel %s/%s", p.App, p.Channel) } if !ValidVersion(p.Version) || p.Version == "dev" { return fmt.Errorf("pointer has invalid version %q", p.Version) } if p.PromotedAt.IsZero() { return fmt.Errorf("pointer has no promoted_at") } if len(p.History) > historyMax { return fmt.Errorf("pointer history too long (%d > %d)", len(p.History), historyMax) } for _, v := range p.History { if !ValidVersion(v) { return fmt.Errorf("pointer history has invalid version %q", v) } } // Digests (#253): absent, or well-formed and aligned with History. A // short list is malformed, never "the rest have none": a writer that // meant "none" writes "" at the position. if p.Digest != "" && !validDigest(p.Digest) { return fmt.Errorf("pointer has an invalid digest") } if len(p.HistoryDigests) != 0 && len(p.HistoryDigests) != len(p.History) { return fmt.Errorf("pointer history_digests has %d entries for %d history entries", len(p.HistoryDigests), len(p.History)) } for _, d := range p.HistoryDigests { if d != "" && !validDigest(d) { return fmt.Errorf("pointer history has an invalid digest") } } // The audit trail is checked loosely and only for shape: an old pointer // carries none, and a trail is a record of what happened, not an // instruction — refusing a pointer the fleet needs over a malformed // history entry would turn a bookkeeping bug into an outage. if len(p.Audit) > auditMax { return fmt.Errorf("pointer audit trail too long (%d > %d)", len(p.Audit), auditMax) } for _, a := range p.Audit { if a.Version != "" && !ValidVersion(a.Version) { return fmt.Errorf("pointer audit has invalid version %q", a.Version) } } return nil } // ---- keys ------------------------------------------------------------------- // GenerateKey mints a fresh signing pair: the base64 seed (the operator's // secret — password manager, then CARLOS_RELEASE_KEY) and the base64 public // key (pinned in host config as CARLOS_RELEASE_PUBKEY). func GenerateKey() (seedB64, pubB64 string, err error) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return "", "", err } return base64.StdEncoding.EncodeToString(priv.Seed()), base64.StdEncoding.EncodeToString(pub), nil } // ParseSigningKey turns a base64 Ed25519 seed — from CARLOS_RELEASE_KEY — // into the signing key. src only names the origin in the error. // // A scoped key's seed carries the ScopedKeyPrefix tag, which is stripped // here: the bytes are an Ed25519 seed either way, and every root key already // in a secret store keeps parsing exactly as it did. Whether the tag was // there is a question for the caller (IsScopedSeed), which is the one that // knows whether a grant is required. func ParseSigningKey(b64, src string) (ed25519.PrivateKey, error) { seed, err := base64.StdEncoding.DecodeString( strings.TrimPrefix(strings.TrimSpace(b64), ScopedKeyPrefix)) if err != nil || len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", src, ed25519.SeedSize) } return ed25519.NewKeyFromSeed(seed), nil } // ParsePublicKey turns a base64 public key — from CARLOS_RELEASE_PUBKEY — // into the verification key. func ParsePublicKey(b64, src string) (ed25519.PublicKey, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil || len(raw) != ed25519.PublicKeySize { return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 public key", src, ed25519.PublicKeySize) } return ed25519.PublicKey(raw), nil } // ---- top-level API ---------------------------------------------------------- // Ship uploads a release: artifacts first, manifest LAST — the manifest is // the commit record, so a crashed ship leaves orphan artifact bytes, never a // manifest naming files that don't exist. Everything is create-only // (immutable): re-shipping identical bytes is an idempotent no-op, while // re-shipping DIFFERENT content under an existing version is refused — a // version id, once published, can never quietly mean something else. // // files is keyed by artifact map key ("linux-arm64", "site", ...). Ship // fills each Artifact's SHA256/Size from the bytes (and, when already set, // requires them to match); an empty Artifact.File defaults to the map key. func claimRawShip(ctx context.Context, s Store, acct string, m Manifest, files map[string][]byte) error { if !ValidAccount(acct) { return fmt.Errorf("ship: invalid account %q", acct) } m.Schema = 1 // Ship is where a person is present to read a refusal, so the one value // a release may demand is enforced here. Reads stay permissive // (NormalizeDemand): a value from a newer shipper must not stop a box // staging the release, only stop an overlap. if m.Swap != "" && m.Swap != DemandHold { return fmt.Errorf("ship: swap %q: a release can demand %q or nothing", m.Swap, DemandHold) } for k, a := range m.Artifacts { b, ok := files[k] if !ok { return fmt.Errorf("ship: manifest artifact %q has no file bytes", k) } sum := etagOf(b) // hex sha256 if a.SHA256 != "" && a.SHA256 != sum { return fmt.Errorf("ship: artifact %q sha256 mismatch (manifest %s, bytes %s)", k, a.SHA256, sum) } a.SHA256 = sum a.Size = int64(len(b)) if a.File == "" { a.File = k } m.Artifacts[k] = a } for k := range files { if _, ok := m.Artifacts[k]; !ok { return fmt.Errorf("ship: file %q has no manifest artifact entry", k) } } if err := validateManifest(&m); err != nil { return fmt.Errorf("ship: %w", err) } // The kind is checked, and for the app's first ship fixed, before the // first artifact byte, so a ship that loses the first-kind race writes // nothing (rootfs_ship.go). if err := establishShipKind(ctx, s, acct, &m); err != nil { return err } for k, a := range m.Artifacts { if err := putImmutable(ctx, s, ArtifactKey(acct, m.App, m.Version, a.File), files[k]); err != nil { return fmt.Errorf("ship: artifact %s: %w", k, err) } } raw, err := json.MarshalIndent(m, "", " ") if err != nil { return err } if err := putImmutable(ctx, s, ManifestKey(acct, m.App, m.Version), raw); err != nil { return fmt.Errorf("ship: manifest: %w", err) } return nil } // putImmutable claims a content-addressed key create-only; a lost race is // fine iff the existing bytes are identical (idempotent re-ship), and an // error otherwise (an immutable path must never change meaning). // // Since issue #136, "immutable" means immutable-WHILE-PRESENT: a release // object's bytes never change under its key, but retention (prune.go) and // whole-app purge (trash.go) may delete it. Create-only writes still refuse // to overwrite, and a re-ship of a pruned version is a NEW release that // happens to reuse the name. func putImmutable(ctx context.Context, s Store, key string, body []byte) error { _, err := s.Put(ctx, key, body, "") if !errors.Is(err, ErrCASConflict) { return err } cur, _, gerr := s.Get(ctx, key) if gerr != nil { return fmt.Errorf("%s exists but is unreadable: %w", key, gerr) } if !bytes.Equal(cur, body) { return fmt.Errorf("%s already exists with different content — refusing to overwrite an immutable release object", key) } return nil } // GetManifest fetches and validates a release manifest, requiring it to name // the app/version its key claims. Reads the account-qualified key and only // that one. Not-found is os.ErrNotExist-wrapped, and names `accounts migrate // -from legacy` for a deployment whose objects never moved. func GetManifest(ctx context.Context, s Store, acct, app, version string) (*Manifest, error) { m, _, err := GetManifestRaw(ctx, s, acct, app, version) return m, err } // GetManifestRaw is GetManifest that also returns the exact bytes it read, so // a box can keep them (the staged sibling) rather than a re-marshal that // drops every field this binary does not know (spec §4.2). func GetManifestRaw(ctx context.Context, s Store, acct, app, version string) (*Manifest, []byte, error) { raw, _, err := GetQualified(ctx, s, ManifestKey(acct, app, version)) if err != nil { return nil, nil, err } var m Manifest if err := json.Unmarshal(raw, &m); err != nil { return nil, nil, fmt.Errorf("manifest %s/%s: %v", app, version, err) } if err := validateManifest(&m); err != nil { return nil, nil, fmt.Errorf("manifest %s/%s: %w", app, version, err) } if m.App != app || m.Version != version { return nil, nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(acct, app, version), m.App, m.Version) } return &m, raw, nil } // Promote points a channel at a shipped version with the deployment's root // key. It is Signer.Promote with RootSigner — the shape every caller that // holds the root key (the console, the migration verb, the tests) keeps using // unchanged. func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, rec ...Promotion) error { return RootSigner(priv).Promote(ctx, s, acct, app, channel, version, rec...) } // Promote points a channel at a shipped version, signing the new pointer and // swapping it in via CAS (a concurrent promote surfaces as ErrCASConflict — // re-read and retry, never overwrite blind). The outgoing version is // prepended to History (capped at historyMax), which is what Rollback // consumes. An existing pointer must verify against the deployment's ROOT // public key before it is replaced: history from a document we can't trust // doesn't get carried forward. // // An optional rec appends an entry to the pointer's audit trail (who, from // which rung, hotfix or not). It is variadic because most callers — the // migration verb, the tests, anything moving a pointer mechanically — have no // promotion to record; a promotion a person made does. func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error { return sg.promote(ctx, s, acct, app, channel, version, nil, "", rec...) } // promote is Promote with an optional authorize hook, called on a pipeline // read after the pointer read and before the conditional write. nil means no // hook and no extra reads. // // expectVersion, when set, is the version the pointer being replaced must // still name: the retry path's guarantee that it renews a promotion and never // changes the version. It is checked on the very pointer whose etag the write // is conditional on, so a promote that lands in between fails the write // rather than being overwritten. "" means no check. func (sg Signer) claimRawPromote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, expectVersion string, rec ...Promotion) error { if !ValidAccount(acct) { return fmt.Errorf("promote: invalid account %q", acct) } if !ValidApp(app) { return fmt.Errorf("promote: invalid app %q", app) } if !ValidChannel(channel) { return fmt.Errorf("promote: invalid channel %q", channel) } if !ValidVersion(version) || version == "dev" { return fmt.Errorf("promote: invalid version %q", version) } // A scoped key learns it may not touch this channel HERE, before a single // byte moves in either direction: no manifest read, no pointer read, and // above all no write. The ladder refuses in the same style, and for the // same reason — an operator finding out from a verifier three machines // away has already been told the wrong thing about what happened. if err := sg.allows(channel, time.Now().UTC()); err != nil { return fmt.Errorf("promote: %w", err) } // A channel only ever points at a version that was actually shipped. The // raw bytes are kept: they are what the publisher shipped, and the only // bytes a head digest may be computed from (spec §4.6). _, shippedRaw, err := GetManifestRaw(ctx, s, acct, app, version) if err != nil { return fmt.Errorf("promote: version %s is not shipped: %w", version, err) } pub := sg.verifyPub() key := ChannelKey(acct, app, channel) // The qualified key and nothing else, so the etag CAS holds against the // object actually being written. An app still on legacy keys reads as a // FIRST promote here and its history does not come forward — carrying // history across the two key spaces is `accounts migrate`'s job, not a // side effect of the next deploy. // // GetQualified for uniform plumbing, though the hint it attaches is // DISCARDED below: an absent pointer is the legitimate first promotion of // a fresh channel, and an app that has never been promoted here is not // thereby unmigrated. PromoteOnLadder makes that distinction, where the // whole app's prefix is in view. cur, etag, err := GetQualified(ctx, s, key) var old *ChannelPointer switch { case err == nil: old, err = verifyPointer(pub, cur, acct, app, channel) if err != nil { return fmt.Errorf("promote: existing pointer at %s: %w", key, err) } case errors.Is(err, os.ErrNotExist): old, etag = &ChannelPointer{}, "" // first promote: create-only Put default: return err } marker, merr := ReadDigestMarker(ctx, s, pub, acct) if merr != nil { return fmt.Errorf(TextMarkerUnreadable, "promote", acct, merr) } if version != old.Version { // A promote onto a build the predecessor already lists is bound to // that entry's signed digest (#253): without this it would sign // whatever bytes the bucket holds under the name now, which is the // replay the digests exist to stop. An approval-gated rollback runs // as exactly this promote. The binding holds with the marker off // too (Astra r1 #3): suspending emission stops digests being // written, not the refusal of a history entry the spec says is // "never a rollback target once its channel has any digest". for i, v := range old.History { if v != version { continue } want := alignedHistoryDigests(old)[i] switch { case want != "": if got := ManifestDigest(shippedRaw); got != want { return fmt.Errorf("promote: %w: %s/%s hashes to %s, the pointer signed %s", ErrManifestDigest, app, version, digestShort(got), digestShort(want)) } case old.ChannelHasDigests(): return &ResignNeededError{App: app, Channel: channel, Version: version} } break } } if expectVersion != "" && old.Version != expectVersion { return fmt.Errorf("promote --retry renews a promotion: %s now names %s, not %s", channel, old.Version, expectVersion) } // A retry's whole effect is a new PromotedAt, so freshness is checked // HERE, against the pointer whose etag guards the write, not against an // earlier read: a concurrent retry that renewed the same version in // between would otherwise be re-stamped with its own second-truncated // time, and a box that refused that promotion would stay blocked behind // a "successful" retry (final review F7). if expectVersion != "" { if err := waitPastPromotedAt(ctx, channel, old.PromotedAt); err != nil { return err } } if authorize != nil { // Between the pointer read and the conditional write, so the write // lands only if the pointer is still what was authorized against // (spec §7.2, G1 and G2). pipe, perr := GetPipeline(ctx, s, pub, acct, app) if perr != nil { return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) } if aerr := authorize(pipe); aerr != nil { return &AuthorizationError{Err: aerr} } } // The durable record of this promotion (spec §4.1), from the bytes the // publisher shipped: after every check that can refuse the write (the // retry guard, the authoritative authorize callback), and BEFORE the // pointer moves, so a refused promotion leaves nothing behind and a // published head never lacks one. Only a NEW head is recorded: a renewal // (--retry) keeps the head it found, and signing that head's current // bytes here would be the automatic backfill §4.1 forbids. A legacy head // is authorized only by `carlos releases authorize`. if old.Version != version { actor := "" if len(rec) > 0 { actor = rec[0].Actor } if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: acct, App: app, Version: version, ManifestDigest: ManifestDigest(shippedRaw), Channel: channel, Actor: actor}); err != nil { return fmt.Errorf("promote: write the promotion record for %s: %w", version, err) } } next := &ChannelPointer{Account: acct, App: app, Channel: channel, Version: version, History: old.History, Audit: old.Audit} if old.Version != "" && old.Version != version { next.History = prependCapped(old.Version, old.History, historyMax) } if marker != nil { // The head is newly promoted, so its digest is the shipped bytes'. // A renewal of the same head (--retry) promotes nothing new and // copies it, like every inherited entry. next.Digest = ManifestDigest(shippedRaw) if old.Version == version { next.Digest = old.Digest } next.HistoryDigests = inheritedDigests(old, version) } if len(rec) > 0 { e := rec[0] e.Version = version // the entry describes THIS promotion, whatever it said if e.At.IsZero() { e.At = time.Now().UTC().Truncate(time.Second) } next.Audit = prependCapped(e, old.Audit, auditMax) } wrapper, err := signPointer(sg, next) if err != nil { return err } _, err = s.Put(ctx, key, wrapper, etag) return err } // waitPastPromotedAt returns once the stamp signPointer would produce (now, // truncated to the second) is strictly after prev, waiting into the next // second if it must. The wait is bounded at 2 s: a prev further ahead than // that is clock skew, and it refuses rather than write a stamp that is not // newer. func waitPastPromotedAt(ctx context.Context, channel string, prev time.Time) error { if time.Now().UTC().Truncate(time.Second).After(prev) { return nil } t := time.NewTimer(min(time.Until(prev.Add(time.Second)), 2*time.Second)) select { case <-ctx.Done(): t.Stop() return ctx.Err() case <-t.C: } if !time.Now().UTC().Truncate(time.Second).After(prev) { return fmt.Errorf("promote --retry: %s was promoted at %s, ahead of this clock; nothing was written", channel, prev.Format(time.RFC3339)) } return nil } // PromoteOptions carries what the ladder needs to know about a promotion // beyond "which version, which channel". type PromoteOptions struct { // Hotfix bypasses the ordering check entirely — the one sanctioned way // past a gate, and recorded as such (spec §3: a bypass that is invisible // is indistinguishable from a broken gate). Hotfix bool // Retry re-signs a pointer that already names this version, with a // fresh PromotedAt: the way past a box's D1 refusal (refusal.go), which // blocks one promotion, not a version. Never changes the version. Retry bool // Actor is who is promoting, recorded in the pointer's audit trail: a // console member's address, or the operator's user@host at the CLI. Actor string // Authorize, when set, is called with the app's pipeline as read at each // point a write could depend on it: the ladder's read, the repeat path, // and between the pointer read and the conditional write. A non-nil // error refuses the promotion as an *AuthorizationError. nil means // today's behaviour exactly, with no extra reads. Authorize func(p *Pipeline) error } // PromoteResult says what a ladder promotion actually did, so the caller can // tell the operator. type PromoteResult struct { // From is the rung the version occupied before this promotion (the // highest, if several); "" when it was on no rung. From string // To is the rung of the target channel; "" when the channel is not a rung // at all, which only a hotfix can reach. To string // NoOp is a repeat: the channel already pointed at this version, so // nothing was written and nothing failed. NoOp bool // Hotfix is whether the ordering check was bypassed. Hotfix bool // Tag is the semver tag stable promotion cut (or found already cut) for // this version — "" off stable, and "" for versions that are not // iterations. TagWarning says why a stable promotion carries no tag, or // that cutting one failed; it is never an error, because the pointer // has already moved and a promotion that happened must report success. Tag string TagWarning string } // stableTag fills res.Tag/TagWarning after version landed on (or was already // on) stable. Failures degrade to the warning: the promotion is real whether // or not its name-cutting worked, and a caller that turned this into an error // would tell the operator a deploy failed while the fleet moved to the build. func stableTag(ctx context.Context, s Store, acct, app, version, actor string, res *PromoteResult) { if res.To != RungStable { return } tag, _, err := EnsureTag(ctx, s, acct, app, version, actor, time.Now().UTC()) switch { case err != nil: res.TagWarning = "promoted, but cutting the tag failed: " + err.Error() case tag == nil: res.TagWarning = "no tag cut: " + version + " is not a - iteration" case tag.Release != version: // EnsureTag found an existing tag, but it names a DIFFERENT iteration // than the one just promoted (tag 0.5.0 -> 0.5.0-1, stable now serves // 0.5.0-2): the tag does not describe this promotion, so it is not // this promotion's tag. res.TagWarning = fmt.Sprintf("%s is already tagged at %s — this promotion carries no tag", tag.Version, tag.Release) default: res.Tag = tag.Version } } // PromoteOnLadder is Promote with the channel ladder enforced: it works out // which rung this version already occupies (from the app's existing channel // pointers), checks the transition, and only then moves the pointer. // // Three things it deliberately does NOT do. It never refuses a canary — the // fleet runs on canary/* channels and maturity has no say over them. It never // fails a repeat: a channel that already points at this version is a no-op // success, so running the same command twice is not an error. And it never // touches a route — promotion moves a signed pointer and nothing else, so // beta and stable are always wired to whatever their routes say they are. // // Plain Promote stays as it was: this is the verb's entry point, not a // replacement, so nothing that legitimately points a channel outside the // ladder (a migration, the console's canary flow) changes behaviour. func PromoteOnLadder(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, opt PromoteOptions) (PromoteResult, error) { return RootSigner(priv).PromoteOnLadder(ctx, s, acct, app, channel, version, opt) } // PromoteOnLadder as a scoped or root identity. A scoped key meets TWO gates // here, and they answer different questions: the ladder asks whether this // BUILD is mature enough for the rung, the grant asks whether this KEY may // reach it at all. -hotfix bypasses the first and can never bypass the // second — a bypass an operator can type is not a way to widen a delegation. func (sg Signer) claimRawPromoteOnLadder(ctx context.Context, s Store, acct, app, channel, version string, opt PromoteOptions) (PromoteResult, error) { res := PromoteResult{Hotfix: opt.Hotfix} if !ValidAccount(acct) { return res, fmt.Errorf("promote: invalid account %q", acct) } if !ValidApp(app) { return res, fmt.Errorf("promote: invalid app %q", app) } if !ValidChannel(channel) { return res, fmt.Errorf("promote: invalid channel %q", channel) } if !ValidVersion(version) || version == "dev" { return res, fmt.Errorf("promote: invalid version %q", version) } // Before the no-op read, before the prefix listing, before anything: a key // that may not sign this channel is told so without touching the bucket. if err := sg.allows(channel, time.Now().UTC()); err != nil { return res, fmt.Errorf("promote: %w", err) } // The target, named: its RUNG when the channel is one of the frozen // rungs (canary/x reports "canary" — the rung, not the channel, which is // what the ladder's vocabulary means by "to"), else the channel's own // name. Without the fallback an app-defined channel reported "", which is // not merely a thin API answer: it is the To written into the Promotion // record in the pointer's own audit trail, the permanent record of where // a build went — and From on that same path already carries the channel // name (VersionStage answers in channel names), so a pipelined app's // trail read "edge -> " forever. Wire-additive in both places: a fuller // string exactly where an empty one used to be. if rung, ok := Rung(channel); ok { res.To = rung } else { res.To = channel } pub := sg.verifyPub() // A repeat is a no-op, checked before anything else: re-running a command // must not fail, whichever rung the build has since climbed to. if cur, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil { if p, verr := verifyPointer(pub, cur, acct, app, channel); verr == nil && p.Version == version { if opt.Authorize != nil { pipe, perr := GetPipeline(ctx, s, pub, acct, app) if perr != nil { return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) } if aerr := opt.Authorize(pipe); aerr != nil { return res, &AuthorizationError{Err: aerr} } } if opt.Retry { res.From = res.To rec := Promotion{From: res.To, To: res.To, Actor: opt.Actor, Retry: true} // The version guard and the fresh-stamp wait both live in // sg.promote, on the pointer its write is conditional on. if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, version, rec); err != nil { return res, err } return res, nil } res.NoOp = true // The same fallback res.To takes above, for the same reason: the // build IS on this channel (that is what makes this a repeat), so // an app-defined channel names itself here too rather than // reporting "" for where the build already sits. res.From = res.To // A repeat still reports the tag — and cuts it if a previous // attempt's write failed, so re-running the same command heals a // stable release that ended up nameless. stableTag(ctx, s, acct, app, version, opt.Actor, &res) return res, nil } } else if !errors.Is(err, os.ErrNotExist) { return res, fmt.Errorf("promote: read %s: %w", ChannelKey(acct, app, channel), err) } // Reached only when the pointer is absent or names another version: a // retry renews a promotion, it never makes one. if opt.Retry { cur := "nothing" if c, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil { if p, verr := verifyPointer(pub, c, acct, app, channel); verr == nil { cur = p.Version } } return res, fmt.Errorf("promote --retry renews a promotion: %s names %s, not %s", channel, cur, version) } // An app with NOTHING under its account-qualified prefix cannot be // promoted — Promote needs a shipped manifest there — so refuse now, while // the reason is still nameable. Left to run on, the ladder answers first // with "this build is on no rung yet", which is true (nothing qualified // means no rung) and entirely beside the point on a deployment that // upgraded from a pre-accounts binary and never migrated. Promote's own // read cannot say it either: an absent pointer is the legitimate first // promotion of a fresh channel, so the miss has to stay silent there. // // AppHasQualifiedObjects is the same test adopt applies to decide whether // a missing pointer deserves the advice — one rule, two callers. if has, err := AppHasQualifiedObjects(ctx, s, acct, app); err != nil { return res, fmt.Errorf("promote: list %s: %w", AppPrefix(acct, app), err) } else if !has { return res, fmt.Errorf("promote: nothing has ever been shipped to %s under account %s%s", app, acct, migrationHint(AppPrefix(acct, app))) } // The pipeline decides the shape of everything below: which channels are // even in the running (VersionStage vs VersionRung) and how order is // judged (PipelinePromotionAllowed vs PromotionAllowed). A pipeline that // exists but cannot be read or verified refuses the promotion outright — // GetPipeline's error already means "not safely nil", and treating that // as "no pipeline" would silently drop back to the legacy ladder under // exactly the app that opted out of it. pipe, perr := GetPipeline(ctx, s, pub, acct, app) if perr != nil { return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) } if opt.Authorize != nil { if aerr := opt.Authorize(pipe); aerr != nil { return res, &AuthorizationError{Err: aerr} } } var from string var err error if pipe != nil { from, err = VersionStage(ctx, s, pub, acct, app, version, pipe) } else { from, err = VersionRung(ctx, s, pub, acct, app, version) } if err != nil { return res, fmt.Errorf("promote: %w", err) } res.From = from if !opt.Hotfix { // res.To already names a channel that is not a rung as itself (see // its fallback above) — "wip is not a rung" reads better in the // refusal than an empty target. to := res.To if pipe != nil { err = PipelinePromotionAllowed(pipe, from, channel) } else { err = PromotionAllowed(from, to) } if err != nil { return res, fmt.Errorf("promote %s %s -> %s: %w", app, version, channel, err) } } rec := Promotion{From: res.From, To: res.To, Actor: opt.Actor, Hotfix: opt.Hotfix} if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, "", rec); err != nil { return res, err } stableTag(ctx, s, acct, app, version, opt.Actor, &res) return res, nil } // VersionRung reports the highest rung any of this app's channels currently // points at version — "how mature is this build", answered from the pointers // themselves rather than a side table that could disagree with them. "" means // the version is on no rung (it may still be shipped, or on a channel outside // the ladder's vocabulary). // // A channel whose pointer cannot be read or verified is skipped rather than // fatal: an unreadable pointer can only LOWER the answer, which makes the // ordering check stricter, never looser. func VersionRung(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (string, error) { // Verification needs a real key: ed25519.Verify PANICS on a wrong-sized // one, and a read-only console runs with none. if len(pub) != ed25519.PublicKeySize { return "", fmt.Errorf("version rung: no usable release public key") } prefix := ChannelsPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return "", fmt.Errorf("list channels of %s: %w", app, err) } best := "" for _, k := range keys { name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") rung, ok := Rung(name) if !ok || RungIndex(rung) <= RungIndex(best) { continue // not a rung, or no higher than what we already found } p, err := Channel(ctx, s, pub, acct, app, name) if err != nil || p.Version != version { continue } best = rung } return best, nil } // Channel resolves a channel's current pointer, verifying its signature — // the one read path the fleet uses, so nothing unsigned ever reaches a box. // Account-qualified only: an app whose pointer is still at the legacy key // does not resolve here, it resolves after `accounts migrate`. Not-found is // os.ErrNotExist-wrapped and says exactly that. func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, channel string) (*ChannelPointer, error) { raw, _, err := GetQualified(ctx, s, ChannelKey(acct, app, channel)) if err != nil { return nil, err } return verifyPointer(pub, raw, acct, app, channel) } // Rollback swaps a channel back to History[0], pushing the outgoing version // onto History — so a second rollback returns to where you were. Boxes apply // it immediately per the bake rules: a box that has previously seen the // target at this channel's head has a first-seen record older than the hold // (that's how it got installed the first time), so evacuating a bad release // never itself sits in a bake window (bake.go). func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel string) error { return RootSigner(priv).Rollback(ctx, s, acct, app, channel) } // Rollback as a scoped or root identity. A rollback signs a new pointer for // the same channel, so it is bounded by the same grant: a key that cannot // promote to stable cannot roll stable back either. Rolling back a channel // outside the scope needs the root key, which is the same answer as moving it // forward. func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel string) error { return sg.RollbackAuthorized(ctx, s, acct, app, channel, nil) } // RollbackAuthorized is Rollback with an optional authorize hook, called on a // pipeline read after the pointer read and before the conditional write. nil // means no hook and no extra reads. func (sg Signer) claimRawRollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error { if !ValidAccount(acct) { return fmt.Errorf("rollback: invalid account %q", acct) } if !ValidApp(app) || !ValidChannel(channel) { return fmt.Errorf("rollback: invalid app/channel %s/%s", app, channel) } if err := sg.allows(channel, time.Now().UTC()); err != nil { return fmt.Errorf("rollback: %w", err) } pub := sg.verifyPub() key := ChannelKey(acct, app, channel) cur, etag, err := GetQualified(ctx, s, key) if err != nil { return fmt.Errorf("rollback: %w", err) } old, err := verifyPointer(pub, cur, acct, app, channel) if err != nil { return fmt.Errorf("rollback: existing pointer at %s: %w", key, err) } if len(old.History) == 0 { return fmt.Errorf("rollback: %s/%s has no previous version recorded", app, channel) } if authorize != nil { pipe, perr := GetPipeline(ctx, s, pub, acct, app) if perr != nil { return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr) } if aerr := authorize(pipe); aerr != nil { return &AuthorizationError{Err: aerr} } } marker, merr := ReadDigestMarker(ctx, s, pub, acct) if merr != nil { return fmt.Errorf(TextMarkerUnreadable, "rollback", acct, merr) } hd := alignedHistoryDigests(old) // Marker or not (Astra r1 #3, spec §4.6): a history entry without a // digest is never a rollback target once its channel has any digest. // Only emission depends on the marker. if old.ChannelHasDigests() && hd[0] == "" { return &ResignNeededError{App: app, Channel: channel, Version: old.History[0]} } next := &ChannelPointer{ Account: acct, App: app, Channel: channel, Version: old.History[0], History: prependCapped(old.Version, old.History[1:], historyMax), // A rollback is not a promotion and appends nothing, but it must not // erase how the channel got here either. Audit: old.Audit, } if marker != nil { // Versions and digests swap together (spec §4.6). next.Digest = hd[0] next.HistoryDigests = prependCapped(old.Digest, hd[1:], historyMax) } wrapper, err := signPointer(sg, next) if err != nil { return err } _, err = s.Put(ctx, key, wrapper, etag) return err } // FastTrack re-signs a channel's current pointer with the bake-window // waiver set — same version, same history, one new audit entry (the // Rollback precedent: a write that is not a promotion stays out of the // ladder). Root key only: the window defends subscribers against exactly // the keys that are not the root, so this takes priv directly and never a // Signer. A head already waived is a no-op success. Returns the version // waived either way. // // signPointer re-stamps PromotedAt, which is correct here: the bake clock // keys on the box's own first sighting of the VERSION (bake.go), which this // write does not change, and the console suppresses its bakes-until // estimate for a waived head. func claimRawFastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, actor string) (string, error) { sg := RootSigner(priv) if !ValidAccount(acct) { return "", fmt.Errorf("fast-track: invalid account %q", acct) } if !ValidApp(app) || !ValidChannel(channel) { return "", fmt.Errorf("fast-track: invalid app/channel %s/%s", app, channel) } key := ChannelKey(acct, app, channel) cur, etag, err := GetQualified(ctx, s, key) if err != nil { return "", fmt.Errorf("fast-track: %w", err) } old, err := verifyPointer(sg.verifyPub(), cur, acct, app, channel) if err != nil { return "", fmt.Errorf("fast-track: existing pointer at %s: %w", key, err) } if old.FastTrack { return old.Version, nil } marker, merr := ReadDigestMarker(ctx, s, sg.verifyPub(), acct) if merr != nil { return "", fmt.Errorf(TextMarkerUnreadable, "fast-track", acct, merr) } rung, _ := Rung(channel) next := &ChannelPointer{ Account: acct, App: app, Channel: channel, Version: old.Version, History: old.History, FastTrack: true, Audit: prependCapped(Promotion{ Version: old.Version, From: rung, To: rung, Actor: actor, FastTrack: true, At: time.Now().UTC().Truncate(time.Second), }, old.Audit, auditMax), } if marker != nil { // Verbatim, never fetched (spec §4.6): the same head, the same history. next.Digest = old.Digest next.HistoryDigests = alignedHistoryDigests(old) if len(next.HistoryDigests) == 0 { next.HistoryDigests = nil } } wrapper, err := signPointer(sg, next) if err != nil { return "", err } if _, err := s.Put(ctx, key, wrapper, etag); err != nil { return "", err } return old.Version, nil } // prependCapped puts v at the head of list, dropping the tail beyond max — // the newest-first, bounded shape both the rollback history and the audit // trail have. func prependCapped[T any](v T, list []T, max int) []T { out := append([]T{v}, list...) if len(out) > max { out = out[:max] } return out } func FastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, actor string) (claimResult0 string, claimResult1 error) { claimResult1 = WithAppMutation(ctx, s, acct, app, "release.FastTrack", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawFastTrack(claimContext, s, priv, acct, app, channel, actor) return claimResult1 }) return }