carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "log"
7 "strings"
8 "time"
9
10 "github.com/carlosframework/platform/internal/domains"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// MaterializeAccounts teaches the console about accounts it did not mint.
15//
16// The bucket is the control plane, and the console is NOT the only minter:
17// `carlos accounts create` writes accounts/<sqid>/account.json and a registry
18// row without ever opening this database — deliberately, because the carlos
19// binary has no business inside console state. The consequence, before this,
20// was a console that would refuse `member -add … <sqid>` for an account that
21// demonstrably exists on the very box that created it. The agent's reconcile
22// pass (internal/domains §1) already materializes exactly these manifests into
23// the registry; this is the same pass for the console's own tables, reading
24// the same objects through the same key parser.
25//
26// The id in the manifest is TAKEN AS GIVEN. Nothing here ever mints or
27// reassigns one: the id is what the sqid encodes, the sqid is in every
28// platform domain and every bucket key, and inventing one would re-home an
29// account's whole world. A manifest that disagrees with itself — path sqid,
30// body sqid and Sqid(id) must all be the same string — is corruption, not a
31// new account, and is skipped loudly.
32//
33// INSERT-only: an account the console already knows keeps its row, name and
34// members. Returns how many were newly materialized.
35//
36// Best-effort by design. A nil store (no CARLOS_DEPLOYMENT_BUCKET/DIR on this
37// box) and a failed listing are both "nothing to catch up on", not errors: an
38// unreachable bucket must never be what stops an operator removing a
39// membership, which is the emergency this whole verb family exists for.
40func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int, error) {
41 if s == nil {
42 return 0, nil
43 }
44 keys, err := s.List(ctx, "accounts/")
45 if err != nil {
46 return 0, err
47 }
48 n := 0
49 for _, key := range keys {
50 sqid, ok := domains.AccountManifestSqid(key)
51 if !ok {
52 continue
53 }
54 // The UNFILTERED lookup, deliberately: this pass is the one place in
55 // the console that must tell "never seen" from "seen, and deleted".
56 // Every other door asks AccountBySqid and gets nothing for a marked
57 // row; here, a marked row is the console's MEMORY of a tombstone, and
58 // forgetting it is a takeover (see the switch below).
59 a, deleted, err := st.AccountAnyBySqid(sqid)
60 if err != nil {
61 return n, err
62 }
63 // Read UNCONDITIONALLY now, including for accounts we already know.
64 // This is the accepted cost of reaping: tombstone state lives in the
65 // manifest BODY, so the old "known, stop here before the GET"
66 // short-circuit would make a deletion performed elsewhere invisible
67 // here forever. One small GET per account, at startup.
68 body, _, err := s.Get(ctx, key)
69 if err != nil {
70 log.Printf("console: skipping %s: read failed: %v", key, err)
71 continue
72 }
73 var m accountManifest
74 if err := json.Unmarshal(body, &m); err != nil || m.ID <= 0 || m.Sqid == "" {
75 log.Printf("console: skipping %s: malformed account manifest: %v", key, err)
76 continue
77 }
78 if m.Sqid != sqid || domains.Sqid(m.ID) != sqid {
79 log.Printf("console: skipping %s: sqid %q/%q is not Sqid(%d)=%q — that is corruption, "+
80 "not a new account", key, sqid, m.Sqid, m.ID, domains.Sqid(m.ID))
81 continue
82 }
83 // A live account holding a reserved sqid (#285). The allocator never
84 // mints one, so this fires only if the list grows over an existing
85 // account or a manifest was written by hand. The account is left alone,
86 // as the ruling says; this line is what makes it visible on every
87 // console start instead of surfacing as broken DNS.
88 if !m.Deleted() && domains.IsReservedSqid(m.Sqid) {
89 log.Printf("console: account %s (id %d) has a reserved sqid; its hostnames under the apps domain "+
90 "collide with platform infrastructure (#285)", m.Sqid, m.ID)
91 }
92 // Everything the row and the manifest can say to each other. Only the
93 // last case — never seen here, and live — falls through to birth.
94 switch {
95 case deleted && m.Deleted():
96 continue // both sides agree the account is retired; nothing to do
97
98 case deleted && !m.Deleted():
99 // A marked row is tombstone MEMORY, and memory does not argue with
100 // a rewritten manifest. Re-materializing here — let alone
101 // re-adopting the owner the manifest now names — would hand the
102 // account, its sqid, its platform hostnames and its whole apps
103 // prefix to whoever holds bucket write access, with no signing key
104 // involved. The manifest is not write-once (see materializeOwner),
105 // so "reap, rewrite, re-birth" would be a complete account
106 // takeover available to anyone who can PUT one object.
107 //
108 // An account id is spent once. This is corruption, not an
109 // un-deletion, and it is logged as such.
110 log.Printf("console: manifest %s is live again but the account was deleted here — "+
111 "refusing to resurrect it; an account id is spent once", key)
112 continue
113
114 case a != nil && m.Deleted():
115 // The control plane has ruled; converge on it. This is what makes a
116 // deletion performed anywhere — another box, `carlos accounts
117 // delete`, an operator with only bucket access — become real here
118 // without hand-run SQL (the Kass gap).
119 if err := st.ReapAccount(a.ID, time.Now()); err != nil {
120 log.Printf("console: reap deleted account %s: %v", sqid, err)
121 } else {
122 log.Printf("console: reaped account %s (%s) — its manifest is tombstoned", sqid, a.Name)
123 }
124 continue
125
126 case a != nil:
127 // Known and live: settled. INSERT-only, and — the security
128 // boundary — owner adoption is never revisited for an account the
129 // console has seen before.
130 continue
131
132 case m.Deleted():
133 // Never seen here and already retired: it stays unseen. Without
134 // this, deletion would be a lie that lasts until the next console
135 // start on a fresh box — the manifest would be read, no row found,
136 // and the account re-created together with the owner membership it
137 // names, which after an ownership transfer is not even the person
138 // who deleted it.
139 continue
140 }
141
142 // FIRST-EVER materialization from here down. Nothing above may reach
143 // EnsureAccount or materializeOwner: birth happens once per account id,
144 // for as long as this database lives.
145 created := m.CreatedAt
146 if created.IsZero() {
147 created = time.Now()
148 }
149 added, err := st.EnsureAccount(m.ID, m.Sqid, m.Name, created)
150 if err != nil {
151 return n, err
152 }
153 if !added {
154 // The sqid was free but the id was not: some other row already
155 // holds it. Loud, because two accounts cannot share an id and one
156 // of these two is wrong.
157 log.Printf("console: did not materialize %s: account id %d is already taken here", key, m.ID)
158 continue
159 }
160 // The manifest's owner, when it names one, becomes the account's first
161 // membership. This is what makes `carlos accounts create -owner <addr>`
162 // (and a logged-in CLI's create) reach the console at all: without it
163 // the account materializes with no members, and an account with no
164 // members is one nobody can open — the exact failure this pass used to
165 // leave behind. Only on the FIRST materialization, and only for an
166 // address the sign-in form would accept: a typo'd owner is a grant
167 // nobody could ever claim, and inventing it would just be a stranded
168 // row with a confusing name on it.
169 if m.Owner != "" {
170 if !ValidAddress(m.Owner) {
171 log.Printf("console: account %s names owner %q, which is not a keymail address — "+
172 "nobody could sign in as it, so no membership was created. Fix it with: "+
173 "console member --add <address> %s --role owner", m.Sqid, m.Owner, m.Sqid)
174 } else if err := st.AddMember(m.ID, strings.ToLower(m.Owner), RoleOwner, created); err != nil {
175 // Loud but not fatal: the account itself materialized, and an
176 // operator can still add the owner box-side. Failing the whole
177 // pass here would hide every other account behind one bad row.
178 log.Printf("console: account %s materialized but its owner %s could not be recorded: %v — "+
179 "add them with: console member --add %s %s --role owner", m.Sqid, m.Owner, err, m.Owner, m.Sqid)
180 }
181 }
182 log.Printf("console: materialized account %s (%s) from the bucket", m.Sqid, m.Name)
183 n++
184 // ONLY here — on the pass that created the row, never on a later one.
185 materializeOwner(st, key, m, created)
186 }
187 return n, nil
188}
189
190// materializeOwner turns the manifest's `owner` into the account's first owner
191// membership — the console half of the fix for `carlos accounts create` making
192// unreachable accounts (see Store.AdoptManifestOwner for the production failure
193// this comes from).
194//
195// IT RUNS EXACTLY ONCE PER ACCOUNT: on the pass that first creates the console's
196// row, and never again. That is the whole security argument for the field, and
197// it is deliberately structural rather than a check inside this function.
198//
199// The reason is that the manifest is NOT write-once. MintAccount claims the
200// object with a create-only Put, but that is a convention of one code path, not
201// a property of the object: release.Store.Put takes its ifMatch from the caller,
202// the deployment bucket has no Object Lock, and the host IAM role holds
203// unconditional s3:PutObject across the bucket with nothing excluding
204// accounts/*/account.json. Anyone who can write to the bucket can rewrite a live
205// account's manifest.
206//
207// If adoption happened on any pass, that rewrite would BE an authority grant:
208// name yourself owner of a vacant account, wait for the next console start or
209// `console member` invocation — both run this pass — and you own a live account
210// and everything deployed in it. That is a real widening of the trust model, not
211// something bucket write access already implied: forging a channel pointer needs
212// CARLOS_RELEASE_KEY, which most bucket-writers do not hold, and this path needs
213// no key at all. AdoptManifestOwner's transactional vacancy check does not cover
214// it either — it is TOCTOU-safe, but the account was LEGITIMATELY vacant and it
215// is the manifest that changed afterwards.
216//
217// Adopting only at birth is safe precisely because `-owner` is now REQUIRED on
218// `carlos accounts create`: manifests are born carrying their owner, so there is
219// no legitimate case of one arriving later.
220//
221// THE TRADE-OFF, stated plainly: an owner added to a manifest AFTER the account
222// first materialized is deliberately NOT adopted, however well-meant the edit.
223// Editing the bucket is not how you grant console authority; `console member
224// -add <address> <sqid> -role owner` is, and it is box-side for exactly this
225// reason. The same applies to an account that materialized ownerless from a
226// pre-`owner` manifest — it stays ownerless until an operator says otherwise.
227//
228// Identity and authority stay SEPARATE decisions, which is why this runs after
229// the account row rather than inside EnsureAccount: a manifest with an owner
230// keymail could never authenticate still yields a perfectly good account. One
231// unusable string must not hide an account from the console entirely.
232//
233// Best-effort and never fatal, like the rest of this pass: an account that
234// materialized but could not be given its owner is recoverable with `console
235// member -add`, whereas a console that refuses to boot is not.
236func materializeOwner(st *Store, key string, m accountManifest, created time.Time) {
237 owner := domains.NormalizeAddress(m.Owner)
238 if owner == "" {
239 return // ownerless by design: every manifest written before the field existed
240 }
241 if !domains.ValidAddress(owner) {
242 log.Printf("console: %s names owner %q, which is not a keymail address — "+
243 "materializing the account without it; give it one with `console member --add <address> %s --role owner`",
244 key, m.Owner, m.Sqid)
245 return
246 }
247 adopted, err := st.AdoptManifestOwner(m.ID, owner, created)
248 if err != nil {
249 log.Printf("console: could not adopt %s as owner of %s: %v", owner, m.Sqid, err)
250 return
251 }
252 if adopted {
253 log.Printf("console: %s is the owner of %s (%s), from the account manifest", owner, m.Sqid, m.Name)
254 }
255}
256
257// MaterializeApps converges the console's app rows onto the bucket, which is
258// the control plane for trash state exactly as it is for account identity.
259//
260// Trash and purge both happen where this console is not: `carlos` running
261// headless on a box, a second console, the auto-purge pass sweeping cooled-off
262// markers. Each writes the bucket; none opens this database. So the console
263// catches up the same way it catches up on accounts — by reading the objects.
264//
265// Four cases, decided per row:
266//
267// (a) live row + valid marker → mark it trashed (trashed elsewhere)
268// (b) marked row + no marker + keys → un-mark it (restored elsewhere)
269// (c) marked row + no marker + no keys → DELETE the row (purged elsewhere);
270// the row IS the name reservation, so this is where the name frees
271// (d) live row + no marker + no keys → NOTHING.
272//
273// Case (d) is the guard the whole design turns on: an app claimed but never
274// shipped has no objects, which makes it byte-for-byte indistinguishable from
275// a purged one in the bucket. The only thing that tells them apart is the row's
276// own deleted_at — so convergence may only ever destroy a row that was ALREADY
277// trashed here. PurgeAppRow enforces the same rule in SQL.
278//
279// Row-driven, not bucket-driven: a purged app leaves no keys to list, so the
280// listing alone can never raise case (c). Keys under an apps/<sqid>/ prefix
281// whose sqid this console does not know contribute nothing to anything —
282// another deployment's tenants are not this console's business.
283//
284// Case (c) is the only one that reasons from absence, and it is held back
285// entirely when the apps/ listing comes back EMPTY — see the tripwire below,
286// which is what stops a mistyped bucket name from releasing every held name.
287//
288// Best-effort like the rest of this file: a nil store or a failed listing is
289// "nothing to catch up on", never a reason to stop the console.
290func MaterializeApps(ctx context.Context, s release.Store, st *Store, claimEnabled ...bool) error {
291 if s == nil {
292 return nil
293 }
294 rows, err := st.AppRows()
295 if err != nil {
296 return err
297 }
298 if len(rows) == 0 {
299 return nil
300 }
301 keys, err := s.List(ctx, "apps/")
302 if err != nil {
303 return err
304 }
305 // THE EMPTY-BUCKET TRIPWIRE. Case (c) reasons from ABSENCE, and absence is
306 // exactly what a mistyped CARLOS_DEPLOYMENT_BUCKET produces: a listing that
307 // succeeds and returns nothing. Without this, one wrong environment
308 // variable purges every trashed row on the first pass and frees every held
309 // name — unrecoverably, because this pass is row-driven and a marker with
310 // no row is invisible to it forever.
311 //
312 // So a completely empty apps/ listing is treated as no evidence rather
313 // than as evidence of destruction: cases (a), (b) and (d) act on PRESENCE
314 // and are unaffected, and only the purges are held back.
315 //
316 // The accepted corner: a deployment whose every app really was purged
317 // keeps its trashed names reserved until anything ships again. A
318 // reservation outliving its need is a small annoyance; names released
319 // because the console was pointed at the wrong bucket are gone.
320 //
321 // Trash markers do not count as evidence (bucketEvidence, settle.go):
322 // they are the one thing the temporary-app pass writes into a bucket.
323 purgesHeld := !bucketEvidence(keys)
324 if purgesHeld {
325 trashed := 0
326 for _, r := range rows {
327 if r.Deleted {
328 trashed++
329 }
330 }
331 if trashed == 0 {
332 purgesHeld = false // nothing to hold back; stay quiet
333 } else {
334 log.Printf("console: the apps/ listing is empty but %d trashed row(s) exist here — "+
335 "refusing to release their names; is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?", trashed)
336 }
337 }
338 // One listing, indexed by app prefix. The prefixes come from the ROWS, so
339 // an unknown account's keys simply never find a home here.
340 type bucketState struct{ anyKeys, marker bool }
341 state := make(map[string]*bucketState, len(rows))
342 for _, r := range rows {
343 state["apps/"+r.Sqid+"/"+r.App+"/"] = &bucketState{}
344 }
345 for _, key := range keys {
346 parts := strings.SplitN(key, "/", 4)
347 if len(parts) < 4 || parts[0] != "apps" {
348 continue // not an app-scoped key: apps/<sqid>/<app>/<something>
349 }
350 bs := state["apps/"+parts[1]+"/"+parts[2]+"/"]
351 if bs == nil {
352 continue
353 }
354 // Any object under the prefix counts, including a bare directory
355 // placeholder: convergence fails toward KEEPING the row.
356 bs.anyKeys = true
357 if key == release.AppTombstoneKey(parts[1], parts[2]) {
358 bs.marker = true
359 }
360 }
361 for _, r := range rows {
362 if len(claimEnabled) > 0 && claimEnabled[0] {
363 handled, claimErr := materializeClaimApp(ctx, s, st, r.AccountID, r.Sqid, r.App, time.Now())
364 if claimErr != nil {
365 log.Printf("console: hold app materialization %s/%s: %v", r.Sqid, r.App, claimErr)
366 }
367 if handled {
368 continue
369 }
370 }
371 bs := state["apps/"+r.Sqid+"/"+r.App+"/"]
372 switch {
373 case bs.marker:
374 // Re-read rather than trusting the listing: it is one GET, and a
375 // marker is an instruction to hide an app. ReadAppTombstone
376 // VALIDATES — a body that disagrees with its path, or carries no
377 // timestamp, is not something to act on either way.
378 tomb, err := release.ReadAppTombstone(ctx, s, r.Sqid, r.App)
379 if err != nil {
380 log.Printf("console: leaving %s/%s alone: %v", r.Sqid, r.App, err)
381 continue
382 }
383 if tomb == nil {
384 continue // restored between the listing and the read
385 }
386 if r.Deleted {
387 // A restore that crashed before deleting the marker: release
388 // its cap slot; the app stays in the Trash (spec §6).
389 if r.Restoring {
390 if _, err := st.SetRestoringContext(ctx, r.AccountID, r.App, false); err != nil {
391 log.Printf("console: clear restoring on %s/%s: %v", r.Sqid, r.App, err)
392 }
393 }
394 continue
395 }
396 // The clock comes from the MARKER. TrashAppRow also sets the
397 // 3-day window when the row is temporary (spec §5 "Materialize").
398 if err := st.TrashAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil {
399 log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err)
400 continue
401 }
402 log.Printf("console: %s/%s is in the trash in the bucket — marking it trashed here", r.Sqid, r.App)
403
404 case !r.Deleted:
405 continue // (d), and the ordinary live app: no marker, nothing to do
406
407 case bs.anyKeys:
408 // Restored elsewhere (a headless restore), or a console restore
409 // that failed its last step. A temporary row gets restore step 2
410 // here too (spec §6).
411 if err := st.FinishRestoreRow(r.AccountID, r.App, time.Now()); err != nil {
412 log.Printf("console: restore %s/%s: %v", r.Sqid, r.App, err)
413 continue
414 }
415 log.Printf("console: %s/%s has no trash marker and still has objects — restoring it here", r.Sqid, r.App)
416
417 default:
418 // Purged: marker gone, objects gone, and the row was already
419 // trashed here — so this frees the name rather than destroying
420 // anything. PurgeAppRow is guarded to refuse a live row even if
421 // this reasoning is ever wrong.
422 if purgesHeld {
423 continue // an empty listing is no evidence; see the tripwire above
424 }
425 if err := st.PurgeAppRow(r.AccountID, r.App); err != nil {
426 log.Printf("console: purge %s/%s: %v", r.Sqid, r.App, err)
427 continue
428 }
429 log.Printf("console: %s/%s is gone from the bucket — releasing the name here", r.Sqid, r.App)
430 }
431 }
432 return nil
433}
434
435// materializeQuietly is the call site every entry point actually wants: catch
436// up if we can, log and carry on if we cannot. Separated from
437// MaterializeAccounts so the failure policy lives in ONE place rather than
438// being re-decided by the server, the member verb and whatever comes next.
439func materializeQuietly(ctx context.Context, s release.Store, st *Store, claimEnabled ...bool) {
440 if _, err := MaterializeAccounts(ctx, s, st); err != nil {
441 log.Printf("console: could not materialize accounts from the bucket: %v", err)
442 }
443 // After accounts, always: a reaped account's app rows are gone with it, so
444 // this pass then has nothing to say about them.
445 if err := MaterializeApps(ctx, s, st, claimEnabled...); err != nil {
446 log.Printf("console: could not converge app rows with the bucket: %v", err)
447 }
448}
449