| 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. |
| 21 | package release |
| 22 | |
| 23 | import ( |
| 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. |
| 47 | type 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. |
| 111 | type 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. |
| 120 | type 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. |
| 173 | type 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. |
| 201 | const ( |
| 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. |
| 208 | const ( |
| 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. |
| 220 | func 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. |
| 241 | func ManifestKey(acct, app, version string) string { |
| 242 | return appPrefix(acct, app) + "releases/" + version + "/manifest.json" |
| 243 | } |
| 244 | func ArtifactKey(acct, app, version, file string) string { |
| 245 | return appPrefix(acct, app) + "releases/" + version + "/" + file |
| 246 | } |
| 247 | func 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. |
| 253 | func ChannelsPrefix(acct, app string) string { return appPrefix(acct, app) + "channels/" } |
| 254 | func 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. |
| 259 | func 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. |
| 265 | func LedgerAppPrefix(acct, app string) string { return "ledgers/" + acct + "/" + app + "/" } |
| 266 | |
| 267 | func 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. |
| 277 | func LegacyManifestKey(app, version string) string { |
| 278 | return legacyAppPrefix(app) + "releases/" + version + "/manifest.json" |
| 279 | } |
| 280 | func LegacyArtifactKey(app, version, file string) string { |
| 281 | return legacyAppPrefix(app) + "releases/" + version + "/" + file |
| 282 | } |
| 283 | func 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. |
| 289 | func LegacyAppPrefix(app string) string { return legacyAppPrefix(app) } |
| 290 | |
| 291 | func legacyAppPrefix(app string) string { return "apps/" + app + "/" } |
| 292 | |
| 293 | func ValidApp(a string) bool { return controlname.ValidApp(a) } |
| 294 | |
| 295 | // channelSegRe is one channel path segment. |
| 296 | var 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). |
| 301 | func 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. |
| 315 | var fileRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) |
| 316 | |
| 317 | func 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. |
| 322 | func 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.) |
| 377 | type 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. |
| 399 | func 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. |
| 465 | func 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 | |
| 520 | func 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). |
| 583 | func 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. |
| 600 | func 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. |
| 611 | func 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. |
| 631 | func 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. |
| 691 | func 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. |
| 710 | func 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). |
| 718 | func 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. |
| 740 | func 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. |
| 756 | func (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. |
| 769 | func (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. |
| 933 | func 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". |
| 953 | type 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. |
| 975 | type 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. |
| 1000 | func 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. |
| 1035 | func 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. |
| 1044 | func (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. |
| 1206 | func 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. |
| 1238 | func 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). |
| 1252 | func 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. |
| 1261 | func (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. |
| 1268 | func (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. |
| 1346 | func 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. |
| 1400 | func 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 | } |
| 1407 | func 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 | |