// The account-qualified key space, and the sentinel that names what came // before it. // // App identity is account-scoped: every bucket key carries an account sqid // (apps///…). It was not always so. While the data caught up with // the code, every read path tried the qualified key and FELL BACK to the // legacy unqualified apps//… shape, so there was no flag day. // // **The read-fallback was retired on 2026-08-03** (spec §2/§5), once every // app with a route on either production deployment had been migrated and // verified. Its job was done, and each read it still performed was another // chance for a stale legacy object to shadow reality — the failure mode that // nearly forced a rollback during the migration itself. The runtime now reads // account-qualified keys and nothing else. // // What remains is the other half of the bargain. No code path ever deleted a // legacy object, and a deployment upgrading straight from a pre-accounts // binary still has all of its data at the legacy keys — so // `carlos accounts migrate -from legacy` reads them EXPLICITLY, forever // (GetMigrationSource), and a qualified miss names that command instead of // 404ing mysteriously (GetQualified). package release import ( "context" "errors" "fmt" "github.com/carlosframework/platform/internal/controlname" "io" "os" "strings" ) // OpsAccount is the built-in operator account's sqid — registry id 1 under // the frozen encoder. It is the default account for CLI-added apps, and what // a caller that genuinely has no account in hand passes. const OpsAccount = "bab" // LegacySource names the account-less key space, so an operator can point at // it. Before accounts existed every app lived at apps//…, and those keys // carry no sqid — which left `accounts migrate` with no way to spell "move // THIS into ops": the verb refuses a bare move into ops (ops is also its // default source, and guessing the direction is not its job), and -from only // took a sqid the legacy space does not have. Ops apps were stranded on legacy // keys, and the read-fallback could never be retired. // // Usable as a sentinel because the frozen encoder's alphabet omits i, l and o, // so Sqid() can never emit "legacy" for any id. TestLegacySourceIsNotASqid // pins that, because the day the alphabet changes is the day this silently // becomes a real account's name. const LegacySource = "legacy" // ValidAccount reports whether a is usable as an account key segment. func ValidAccount(a string) bool { return controlname.ValidAccount(a) } // GetQualified reads an account-qualified key. Not-found stays // os.ErrNotExist-wrapped (every caller's "has this app ever shipped here" // check keeps working) and gains the migration hint below. // // It is where every runtime read of an OBJECT goes — manifests, channel // pointers, artifacts — so the sentence a miss needs is written once. Two // kinds of read do not pass through it, and neither is a place the fallback // could grow back: // // - LISTINGS (VersionRung, `carlos channels`, the console's channel and // release views) call s.List on a prefix built by ChannelsPrefix or // ReleasesPrefix. There is nothing to fall back to: a prefix either has // objects under it or the app has none here, and both are answers. // - Promote's read-modify-write of a channel pointer calls GetQualified but // DISCARDS the hint, because an absent pointer is the legitimate first // promotion of a fresh channel. PromoteOnLadder supplies the hint from // the whole app's prefix instead, where the distinction is computable. // // What is true without qualification: no read anywhere in the runtime builds // a legacy key. The tripwire tests are what keep that honest, and they guard // the store rather than any one helper — the artifact downloads in // adopt.ensureStaged and edge.ensureExtracted each get their own pass, // because a tripwire only guards the paths a test routes through it. // // A store error that is not "not found" is returned untouched and never // annotated: an unreachable bucket is not an unmigrated app, and pointing an // operator at `accounts migrate` when the bucket is broken sends them at the // wrong problem. func GetQualified(ctx context.Context, s Store, key string) (body []byte, etag string, err error) { body, etag, err = s.Get(ctx, key) if err != nil && errors.Is(err, os.ErrNotExist) { return nil, "", fmt.Errorf("%w%s", err, migrationHint(key)) } return body, etag, err } // OpenQualified is GetQualified for a streaming read (OpenObject): the same // key, the same hint on a miss. Staging reads a build this way (#269) so a // large one never sits in memory whole. func OpenQualified(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) { rc, etag, err := OpenObject(ctx, s, key) if err != nil && errors.Is(err, os.ErrNotExist) { return nil, "", fmt.Errorf("%w%s", err, migrationHint(key)) } return rc, etag, err } // migrationHint turns a miss on an account-qualified APP key into the exact // command that fixes it, for the one deployment shape that produces a // surprising 404: upgraded from a pre-accounts binary, objects still at // apps//…, nothing migrated. Every runtime read funnels through // GetQualified, so this sentence is written once and reaches all of them. // // It is deliberately unconditional rather than probing the legacy key first. // Confirming the hint would mean the runtime reading a legacy key again — on // the error path only, but "the runtime never reads a legacy key" is a // property worth having outright rather than with an exception, and it is // what the tripwire test can then pin. The wording carries the condition // instead, so it stays honest for an app that simply has no such channel. // // "" for anything that is not an app key: a status snapshot or an account // manifest must not advertise a command that cannot apply to it. func migrationHint(key string) string { rest, ok := strings.CutPrefix(key, "apps/") if !ok { return "" } acct, rest, ok := strings.Cut(rest, "/") if !ok || !ValidAccount(acct) { return "" } app, _, ok := strings.Cut(rest, "/") if !ok || !ValidApp(app) { return "" } return fmt.Sprintf(" — if this deployment predates account-scoped bucket keys its objects "+ "are still at %s, which the runtime no longer reads; move them once with: "+ "carlos accounts migrate --app %s --from legacy --to %s", legacyAppPrefix(app), app, acct) } // AppHasQualifiedObjects reports whether app has ANYTHING under its // account-qualified prefix. It is the whole distinction between the two // deployments a missing object can mean: // // - nothing there at all — this deployment upgraded from a pre-accounts // binary and never migrated. `accounts migrate -from legacy` is the fix, // and saying so is the point of the hint. // - something there, but not this key — ordinary. A migrated app waiting on // its first promotion to a channel is in exactly this state, and telling // it to migrate would be wrong every hour of every day until somebody // promotes. // // "Anything at all" rather than "any releases": an app that has shipped since // accounts landed is not unmigrated however new the channel is. Both readings // come from the qualified prefix; neither touches a legacy key. // // Callers that ask per channel should cache it per (account, app) — one // listing answers for every channel an app has. func AppHasQualifiedObjects(ctx context.Context, s Store, acct, app string) (bool, error) { keys, err := s.List(ctx, AppPrefix(acct, app)) return len(keys) > 0, err } // GetMigrationSource reads key, and on NOT-FOUND reads legacy instead. It is // the shape the retired read-fallback had, kept for the ONE caller whose // access to the account-less key space is explicit and permanent: `carlos // accounts migrate`, which must be able to move an app off those keys however // long a deployment waits before upgrading. Nothing in a serving path may // call it. // // It reports which key answered, so the verb's post-copy verification knows // what it just compared against. // // A store error that is not "not found" is returned as-is and never retried // against legacy. Masking a broken bucket as a fallback miss would make it // look like an unmigrated app — and the migration would then happily "verify" // an app it never actually read. func GetMigrationSource(ctx context.Context, s Store, key, legacy string) (body []byte, etag string, usedLegacy bool, err error) { body, etag, err = s.Get(ctx, key) if err == nil { return body, etag, false, nil } if !errors.Is(err, os.ErrNotExist) { return nil, "", false, err } body, etag, err = s.Get(ctx, legacy) if err != nil { // Both absent: report the miss against the key the caller asked for, // still os.ErrNotExist-wrapped so errors.Is keeps working. return nil, "", false, err } return body, etag, true, nil } // HostAccountLabel extracts the account sqid a PLATFORM host names in its // own labels: .. (and the canary shape // ...) both put the sqid immediately // before the apps domain. "" when the host is not under the apps domain, // has no label pair before it, or the label isn't a valid account name — // callers treat "" as "the host names nobody", never as an error. This is // the one detail #344 turned on: a route row's account column is a moving // part (the domains reconcile stamps it from bucket listings), but a // platform host's own name is not. func HostAccountLabel(host, appsDomain string) string { appsDomain = strings.TrimSuffix(strings.TrimSpace(appsDomain), ".") if appsDomain == "" { return "" } rest, ok := strings.CutSuffix(strings.ToLower(host), "."+strings.ToLower(appsDomain)) if !ok { return "" } labels := strings.Split(rest, ".") if len(labels) < 2 { return "" } sq := labels[len(labels)-1] if !ValidAccount(sq) { return "" } return sq }