| 1 | // The account-qualified key space, and the sentinel that names what came |
| 2 | // before it. |
| 3 | // |
| 4 | // App identity is account-scoped: every bucket key carries an account sqid |
| 5 | // (apps/<sqid>/<app>/…). It was not always so. While the data caught up with |
| 6 | // the code, every read path tried the qualified key and FELL BACK to the |
| 7 | // legacy unqualified apps/<app>/… shape, so there was no flag day. |
| 8 | // |
| 9 | // **The read-fallback was retired on 2026-08-03** (spec §2/§5), once every |
| 10 | // app with a route on either production deployment had been migrated and |
| 11 | // verified. Its job was done, and each read it still performed was another |
| 12 | // chance for a stale legacy object to shadow reality — the failure mode that |
| 13 | // nearly forced a rollback during the migration itself. The runtime now reads |
| 14 | // account-qualified keys and nothing else. |
| 15 | // |
| 16 | // What remains is the other half of the bargain. No code path ever deleted a |
| 17 | // legacy object, and a deployment upgrading straight from a pre-accounts |
| 18 | // binary still has all of its data at the legacy keys — so |
| 19 | // `carlos accounts migrate -from legacy` reads them EXPLICITLY, forever |
| 20 | // (GetMigrationSource), and a qualified miss names that command instead of |
| 21 | // 404ing mysteriously (GetQualified). |
| 22 | package release |
| 23 | |
| 24 | import ( |
| 25 | "context" |
| 26 | "errors" |
| 27 | "fmt" |
| 28 | "github.com/carlosframework/platform/internal/controlname" |
| 29 | "io" |
| 30 | "os" |
| 31 | "strings" |
| 32 | ) |
| 33 | |
| 34 | // OpsAccount is the built-in operator account's sqid — registry id 1 under |
| 35 | // the frozen encoder. It is the default account for CLI-added apps, and what |
| 36 | // a caller that genuinely has no account in hand passes. |
| 37 | const OpsAccount = "bab" |
| 38 | |
| 39 | // LegacySource names the account-less key space, so an operator can point at |
| 40 | // it. Before accounts existed every app lived at apps/<app>/…, and those keys |
| 41 | // carry no sqid — which left `accounts migrate` with no way to spell "move |
| 42 | // THIS into ops": the verb refuses a bare move into ops (ops is also its |
| 43 | // default source, and guessing the direction is not its job), and -from only |
| 44 | // took a sqid the legacy space does not have. Ops apps were stranded on legacy |
| 45 | // keys, and the read-fallback could never be retired. |
| 46 | // |
| 47 | // Usable as a sentinel because the frozen encoder's alphabet omits i, l and o, |
| 48 | // so Sqid() can never emit "legacy" for any id. TestLegacySourceIsNotASqid |
| 49 | // pins that, because the day the alphabet changes is the day this silently |
| 50 | // becomes a real account's name. |
| 51 | const LegacySource = "legacy" |
| 52 | |
| 53 | // ValidAccount reports whether a is usable as an account key segment. |
| 54 | func ValidAccount(a string) bool { return controlname.ValidAccount(a) } |
| 55 | |
| 56 | // GetQualified reads an account-qualified key. Not-found stays |
| 57 | // os.ErrNotExist-wrapped (every caller's "has this app ever shipped here" |
| 58 | // check keeps working) and gains the migration hint below. |
| 59 | // |
| 60 | // It is where every runtime read of an OBJECT goes — manifests, channel |
| 61 | // pointers, artifacts — so the sentence a miss needs is written once. Two |
| 62 | // kinds of read do not pass through it, and neither is a place the fallback |
| 63 | // could grow back: |
| 64 | // |
| 65 | // - LISTINGS (VersionRung, `carlos channels`, the console's channel and |
| 66 | // release views) call s.List on a prefix built by ChannelsPrefix or |
| 67 | // ReleasesPrefix. There is nothing to fall back to: a prefix either has |
| 68 | // objects under it or the app has none here, and both are answers. |
| 69 | // - Promote's read-modify-write of a channel pointer calls GetQualified but |
| 70 | // DISCARDS the hint, because an absent pointer is the legitimate first |
| 71 | // promotion of a fresh channel. PromoteOnLadder supplies the hint from |
| 72 | // the whole app's prefix instead, where the distinction is computable. |
| 73 | // |
| 74 | // What is true without qualification: no read anywhere in the runtime builds |
| 75 | // a legacy key. The tripwire tests are what keep that honest, and they guard |
| 76 | // the store rather than any one helper — the artifact downloads in |
| 77 | // adopt.ensureStaged and edge.ensureExtracted each get their own pass, |
| 78 | // because a tripwire only guards the paths a test routes through it. |
| 79 | // |
| 80 | // A store error that is not "not found" is returned untouched and never |
| 81 | // annotated: an unreachable bucket is not an unmigrated app, and pointing an |
| 82 | // operator at `accounts migrate` when the bucket is broken sends them at the |
| 83 | // wrong problem. |
| 84 | func GetQualified(ctx context.Context, s Store, key string) (body []byte, etag string, err error) { |
| 85 | body, etag, err = s.Get(ctx, key) |
| 86 | if err != nil && errors.Is(err, os.ErrNotExist) { |
| 87 | return nil, "", fmt.Errorf("%w%s", err, migrationHint(key)) |
| 88 | } |
| 89 | return body, etag, err |
| 90 | } |
| 91 | |
| 92 | // OpenQualified is GetQualified for a streaming read (OpenObject): the same |
| 93 | // key, the same hint on a miss. Staging reads a build this way (#269) so a |
| 94 | // large one never sits in memory whole. |
| 95 | func OpenQualified(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) { |
| 96 | rc, etag, err := OpenObject(ctx, s, key) |
| 97 | if err != nil && errors.Is(err, os.ErrNotExist) { |
| 98 | return nil, "", fmt.Errorf("%w%s", err, migrationHint(key)) |
| 99 | } |
| 100 | return rc, etag, err |
| 101 | } |
| 102 | |
| 103 | // migrationHint turns a miss on an account-qualified APP key into the exact |
| 104 | // command that fixes it, for the one deployment shape that produces a |
| 105 | // surprising 404: upgraded from a pre-accounts binary, objects still at |
| 106 | // apps/<app>/…, nothing migrated. Every runtime read funnels through |
| 107 | // GetQualified, so this sentence is written once and reaches all of them. |
| 108 | // |
| 109 | // It is deliberately unconditional rather than probing the legacy key first. |
| 110 | // Confirming the hint would mean the runtime reading a legacy key again — on |
| 111 | // the error path only, but "the runtime never reads a legacy key" is a |
| 112 | // property worth having outright rather than with an exception, and it is |
| 113 | // what the tripwire test can then pin. The wording carries the condition |
| 114 | // instead, so it stays honest for an app that simply has no such channel. |
| 115 | // |
| 116 | // "" for anything that is not an app key: a status snapshot or an account |
| 117 | // manifest must not advertise a command that cannot apply to it. |
| 118 | func migrationHint(key string) string { |
| 119 | rest, ok := strings.CutPrefix(key, "apps/") |
| 120 | if !ok { |
| 121 | return "" |
| 122 | } |
| 123 | acct, rest, ok := strings.Cut(rest, "/") |
| 124 | if !ok || !ValidAccount(acct) { |
| 125 | return "" |
| 126 | } |
| 127 | app, _, ok := strings.Cut(rest, "/") |
| 128 | if !ok || !ValidApp(app) { |
| 129 | return "" |
| 130 | } |
| 131 | return fmt.Sprintf(" — if this deployment predates account-scoped bucket keys its objects "+ |
| 132 | "are still at %s, which the runtime no longer reads; move them once with: "+ |
| 133 | "carlos accounts migrate --app %s --from legacy --to %s", |
| 134 | legacyAppPrefix(app), app, acct) |
| 135 | } |
| 136 | |
| 137 | // AppHasQualifiedObjects reports whether app has ANYTHING under its |
| 138 | // account-qualified prefix. It is the whole distinction between the two |
| 139 | // deployments a missing object can mean: |
| 140 | // |
| 141 | // - nothing there at all — this deployment upgraded from a pre-accounts |
| 142 | // binary and never migrated. `accounts migrate -from legacy` is the fix, |
| 143 | // and saying so is the point of the hint. |
| 144 | // - something there, but not this key — ordinary. A migrated app waiting on |
| 145 | // its first promotion to a channel is in exactly this state, and telling |
| 146 | // it to migrate would be wrong every hour of every day until somebody |
| 147 | // promotes. |
| 148 | // |
| 149 | // "Anything at all" rather than "any releases": an app that has shipped since |
| 150 | // accounts landed is not unmigrated however new the channel is. Both readings |
| 151 | // come from the qualified prefix; neither touches a legacy key. |
| 152 | // |
| 153 | // Callers that ask per channel should cache it per (account, app) — one |
| 154 | // listing answers for every channel an app has. |
| 155 | func AppHasQualifiedObjects(ctx context.Context, s Store, acct, app string) (bool, error) { |
| 156 | keys, err := s.List(ctx, AppPrefix(acct, app)) |
| 157 | return len(keys) > 0, err |
| 158 | } |
| 159 | |
| 160 | // GetMigrationSource reads key, and on NOT-FOUND reads legacy instead. It is |
| 161 | // the shape the retired read-fallback had, kept for the ONE caller whose |
| 162 | // access to the account-less key space is explicit and permanent: `carlos |
| 163 | // accounts migrate`, which must be able to move an app off those keys however |
| 164 | // long a deployment waits before upgrading. Nothing in a serving path may |
| 165 | // call it. |
| 166 | // |
| 167 | // It reports which key answered, so the verb's post-copy verification knows |
| 168 | // what it just compared against. |
| 169 | // |
| 170 | // A store error that is not "not found" is returned as-is and never retried |
| 171 | // against legacy. Masking a broken bucket as a fallback miss would make it |
| 172 | // look like an unmigrated app — and the migration would then happily "verify" |
| 173 | // an app it never actually read. |
| 174 | func GetMigrationSource(ctx context.Context, s Store, key, legacy string) (body []byte, etag string, usedLegacy bool, err error) { |
| 175 | body, etag, err = s.Get(ctx, key) |
| 176 | if err == nil { |
| 177 | return body, etag, false, nil |
| 178 | } |
| 179 | if !errors.Is(err, os.ErrNotExist) { |
| 180 | return nil, "", false, err |
| 181 | } |
| 182 | body, etag, err = s.Get(ctx, legacy) |
| 183 | if err != nil { |
| 184 | // Both absent: report the miss against the key the caller asked for, |
| 185 | // still os.ErrNotExist-wrapped so errors.Is keeps working. |
| 186 | return nil, "", false, err |
| 187 | } |
| 188 | return body, etag, true, nil |
| 189 | } |
| 190 | |
| 191 | // HostAccountLabel extracts the account sqid a PLATFORM host names in its |
| 192 | // own labels: <name>.<sqid>.<apps domain> (and the canary shape |
| 193 | // <canary>.<app>.<sqid>.<apps domain>) both put the sqid immediately |
| 194 | // before the apps domain. "" when the host is not under the apps domain, |
| 195 | // has no label pair before it, or the label isn't a valid account name — |
| 196 | // callers treat "" as "the host names nobody", never as an error. This is |
| 197 | // the one detail #344 turned on: a route row's account column is a moving |
| 198 | // part (the domains reconcile stamps it from bucket listings), but a |
| 199 | // platform host's own name is not. |
| 200 | func HostAccountLabel(host, appsDomain string) string { |
| 201 | appsDomain = strings.TrimSuffix(strings.TrimSpace(appsDomain), ".") |
| 202 | if appsDomain == "" { |
| 203 | return "" |
| 204 | } |
| 205 | rest, ok := strings.CutSuffix(strings.ToLower(host), "."+strings.ToLower(appsDomain)) |
| 206 | if !ok { |
| 207 | return "" |
| 208 | } |
| 209 | labels := strings.Split(rest, ".") |
| 210 | if len(labels) < 2 { |
| 211 | return "" |
| 212 | } |
| 213 | sq := labels[len(labels)-1] |
| 214 | if !ValidAccount(sq) { |
| 215 | return "" |
| 216 | } |
| 217 | return sq |
| 218 | } |
| 219 | |