carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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).
22package release
23
24import (
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.
37const 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.
51const LegacySource = "legacy"
52
53// ValidAccount reports whether a is usable as an account key segment.
54func 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.
84func 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.
95func 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.
118func 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.
155func 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.
174func 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.
200func 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