carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Attach and detach a custom domain — the core both doors share (#82).
2// Spec: docs/superpowers/specs/2026-08-12-domain-attach-design.md §3-§4.
3//
4// The attach splits on one question the console can answer: does the app
5// already have a ready route? Yes → an ALIAS claim naming that route, and
6// nothing else is written (the `route_aliases` row is derived box-side;
7// minting a second route row is the #129 socket-race / #148 bare-alias
8// class this design exists to avoid). No → a FIRST-ROUTE attach: the same
9// claim with an empty route, plus a declared static route record in #137's
10// vocabulary, which the provisioning reconciler materializes as the app's
11// real primary route.
12//
13// The invariant: the write order is CLAIM THEN RECORD. The claim's
14// create-only write IS the fleet-wide uniqueness check (#151's
15// domains.AttachClaim — the bucket key is the cross-account gate), so it
16// must be decided before anything else about the host exists. That ordering
17// also picks the failure worth having — a half-failed attach leaves at worst
18// a claim with no record (inert: no route is materialized from a
19// first-route claim, and detach or a retry clears it), never a live route
20// whose host is unclaimed. A record write that fails ambiguously therefore
21// unwinds both best-effort and refuses, while a record ALREADY at the key
22// (which, being account+app scoped, can only be this app's own) makes the
23// attach idempotent rather than a permanent 500. The reverse write order
24// would let two accounts each declare a record for one host and the loser
25// find out from the fleet.
26//
27// The fleet-wide deployment-route refusal lives in here for the same reason,
28// and with one extra rule: it sits IMMEDIATELY BEFORE each AttachClaim, below
29// every gate that could refuse for another reason. Byte-identical refusals
30// are only half of the neutrality — ORDER is the other half. A check placed
31// above attachChannel, say, is read apart from the ordinary "held by someone
32// else" 409 by a prober who deliberately sends a malformed channel: a
33// hostname a live box serves comes back 409, anything else comes back the
34// channel refusal, and the deployment's whole non-alias route set enumerates
35// one hostname at a time. So the two calls below are deliberately duplicated
36// rather than hoisted to the top of this function, and anything added to
37// either branch later belongs ABOVE them, never between them and the write.
38// (Task 5 review, rounds 1 and 2; the CLI door's own direct-AttachClaim path
39// carries the same guard under the same rule — apidomains.go.)
40//
41// Both refusals go through claimRefusal with domains.ErrHostUnavailable
42// rather than constructing a doorRefusal here, so they ARE the existing
43// refusal path rather than a second one that merely matches it today.
44//
45// audit + bumpSerial live in here rather than in the doors, so both doors
46// get them, in the right place, for free: strictly after the write they
47// announce — and gated on an ACTUAL write (#151's changed-gating rule): a
48// re-attach that stored nothing new must not send every box in the fleet
49// converging onto a serial that names no change, nor bury the tenant's
50// audit log under no-ops.
51package console
52
53import (
54 "context"
55 "errors"
56 "log"
57 "net/http"
58 "os"
59 "sort"
60 "strings"
61 "time"
62
63 "github.com/carlosframework/platform/internal/domains"
64 "github.com/carlosframework/platform/internal/hostops"
65 "github.com/carlosframework/platform/internal/provision"
66 "github.com/carlosframework/platform/internal/release"
67)
68
69// doorRefusal is a refusal the calling door translates into its own shape —
70// a rendered message for the browser, a JSON error (or, for 404, the
71// family's byte-identical http.NotFound) for the CLI API. Msg is
72// member-facing and NEVER names another account: the 409 below is one
73// string whoever holds the host, including the caller.
74type doorRefusal struct {
75 Status int
76 Msg string
77}
78
79// attachResult is what the doors report back. FirstRoute distinguishes the
80// two mechanisms — true means a declared record was written (or found
81// already standing) and a real route is materializing, which is the
82// difference between "your domain serves the app the moment DNS points" and
83// "a new route is being created". Created is domains.AttachClaim's own
84// answer: this call brought the claim into existence (false on a re-attach,
85// which the claim writer treats as a converge or a no-op, never a refusal).
86//
87// Pending is the #215 answer: the claim is stored but its writer has not yet
88// proved control of the hostname, so nothing serves it, and on the
89// first-route branch no record was declared. Verify is the TXT record that
90// proves it; re-attaching once it is published turns the claim verified.
91type attachResult struct {
92 OperationID string
93 OperationPhase string
94 Claim domains.Claim
95 Created bool
96 FirstRoute bool // a declared record backs this attach
97 Channel string // channel the first-route record follows ("" for alias)
98 Pending bool
99 Verify dnsRecord
100}
101
102// attachDomain claims host for acct's app and, when the app has no route to
103// alias, declares the static route that becomes its first one. kindFlag and
104// channelFlag may both be "" — the kind is then inferred (alias if there is
105// a route, static otherwise) and the channel is the one the host's own
106// existing route follows, or failing that the app's sole shipped channel.
107func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, messages ...string) (attachResult, *doorRefusal) {
108 return s.attachDomainAs(ctx, acct, app, host, kindFlag, channelFlag, principal, false, messages...)
109}
110
111// attachDomainAs is attachDomain with the platform door (#209) named.
112// platform true lets a hostPlatform name through and nothing else; the
113// CALLER has already established that principal is a platform operator
114// (handleAPIDomainAttach refuses the flag otherwise). The browser form never
115// passes it.
116func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, platform bool, messages ...string) (attachResult, *doorRefusal) {
117 message := ""
118 if len(messages) != 0 {
119 message = messages[0]
120 }
121 if ref := s.bucketRefusal(); ref != nil {
122 return attachResult{}, ref
123 }
124 if msg, status := s.temporaryRefusal(acct, app, "attach a domain to it"); msg != "" {
125 return attachResult{}, &doorRefusal{Status: status, Msg: msg}
126 }
127 // Policy first: shape and platform space are decided before any write,
128 // so a refused host never reaches a bucket key. Both checks are #151's
129 // exported validation surface (internal/domains + domainHostDenied), the
130 // same pair the CLI API door runs — one validation spot, two doors.
131 host, ok := domains.NormalizeAliasHost(host)
132 if !ok {
133 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
134 "not a valid hostname — attach a full hostname like tickets.example.com"}
135 }
136 if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !platform) {
137 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
138 "that hostname is platform-managed"}
139 }
140 // Plans 3a: custom domains need a plan that includes them.
141 if ref := s.customDomainRefusal(acct, host); ref != nil {
142 return attachResult{}, ref
143 }
144 // The reserved sidecar suffix, refused at the same policy stage and with
145 // the same status as the two checks above: this door must never mint a
146 // claim (let alone a route record) under a name provision.SidecarSuffix
147 // reserves. Defence in depth behind Task 3's structural refusal to SERVE
148 // a Kind=="sidecar" row (edge.go's Reload) — this closes the other
149 // direction, a member attaching an unrelated app's domain under it.
150 if strings.HasSuffix(host, provision.SidecarSuffix) {
151 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
152 "that hostname is reserved for sidecar processes"}
153 }
154 route, selfChannel, err := s.readyRouteHost(acct.Sqid, app, host)
155 if err != nil {
156 log.Printf("console: attach %s: registry: %v", host, err)
157 return attachResult{}, &doorRefusal{http.StatusInternalServerError,
158 "could not read this deployment's routes — try again in a moment"}
159 }
160 claim := domains.Claim{
161 Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: route,
162 CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal,
163 }
164
165 // The claim write asks for proof of control (#215) through prove: a TXT
166 // lookup, made only when the answer can change what is written (see
167 // domains.AttachClaimProven), so a re-attach of a verified claim costs no
168 // DNS round trip.
169 prove := s.domainProver(ctx, host, acct.Sqid)
170 verify := s.domainChallenge(host, acct.Sqid)
171
172 // --- alias attach: the app already has a route to serve from ---------
173 if route != "" {
174 if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
175 return attachResult{}, ref
176 }
177 created, changed, pending, err := s.attachDomainClaim(ctx, acct, app, claim, time.Now(), prove)
178 if err != nil {
179 return attachResult{}, claimRefusal(host, err)
180 }
181 if changed {
182 s.auditActivity(acct, app, principal, Activity{
183 Action: "domain-attach", TargetType: "instance", Target: route, Message: message,
184 Detail: host + " → " + route + platformNote(platform) + pendingNote(pending),
185 })
186 // A pending claim is invisible to every box, so writing one
187 // gives the fleet nothing to converge on.
188 if !pending {
189 s.bumpSerial(ctx)
190 }
191 }
192 return attachResult{Claim: claim, Created: created, Pending: pending, Verify: verify}, nil
193 }
194
195 if s.directAppsDomainHost(host) {
196 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, hostUnavailableMsg}
197 }
198 if s.firstRouteOutsideNamespace(host, acct.Sqid) {
199 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
200 "that hostname is platform-managed"}
201 }
202
203 // --- first-route attach: this host becomes the app's primary --------
204 if kindFlag != "" && kindFlag != "static" {
205 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
206 "an app with no running route can only attach a static domain — deploy first, then attach"}
207 }
208 channel, ref := s.attachChannel(ctx, acct.Sqid, app, channelFlag, selfChannel)
209 if ref != nil {
210 return attachResult{}, ref
211 }
212 // Plans 3a: a first route starts a running thing, so it meets the cap,
213 // above heldElsewhere per the ordering rule.
214 defer s.planLock(acct)()
215 if err := s.checkRunningThings(ctx, acct, 1); err != nil {
216 if isPlanCap(err) {
217 return attachResult{}, &doorRefusal{http.StatusConflict, err.Error()}
218 }
219 return attachResult{}, &doorRefusal{http.StatusBadGateway, "could not create the instance — try again"}
220 }
221 if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
222 return attachResult{}, ref
223 }
224 if s.cfg.HostClaim {
225 return s.attachClaimFirstRoute(ctx, acct, app, host, channel, principal)
226 }
227 created, changed, pending, err := s.attachDomainClaim(ctx, acct, app, claim, time.Now(), prove)
228 if err != nil {
229 return attachResult{}, claimRefusal(host, err)
230 }
231 if pending {
232 // No record until the claim is proven. The record is what makes the
233 // host a real route, served and certified on whatever box adopts it,
234 // so declaring it now would serve an unproven name, which is the
235 // takeover #215 closes. Re-attaching once the TXT record is
236 // published lands here verified and declares it then.
237 if changed {
238 s.auditActivity(acct, app, principal, Activity{
239 Action: "domain-attach", TargetType: "instance", Target: host, Message: message,
240 Detail: host + " (first route, " + channel + ")" + pendingNote(true),
241 })
242 }
243 return attachResult{Claim: claim, Created: created, Channel: channel, Pending: true, Verify: verify}, nil
244 }
245 rec := provision.Record{
246 Schema: 1, Host: host, App: app, Account: acct.Sqid,
247 Channel: channel, Kind: "static",
248 CreatedAt: time.Now().UTC(), CreatedBy: principal,
249 }
250 if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
251 // A record already at this key is NOT a failure to undo. The key is
252 // account+app scoped, so a create that lost the race lost it to this
253 // app's own record — a retry after a half-failed attach, or a record
254 // written for a host whose claim had been released. GetRecord
255 // re-reads it and refuses a body that disagrees with its own path,
256 // so a successful read is proof the record describes this app and
257 // account; the attach is then idempotent, the claim stays (this
258 // app's, whether this call minted it or an earlier one did), and the
259 // truth reported back is the record's channel, not the one this call
260 // would have written. audit + serial follow the write-gating rule:
261 // only when the CLAIM half actually wrote — a fully-converged
262 // re-attach (claim untouched, record standing) stores nothing and
263 // announces nothing.
264 if errors.Is(err, release.ErrCASConflict) {
265 existing, gerr := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
266 if gerr == nil && attachWrote(*existing) {
267 if changed {
268 s.auditActivity(acct, app, principal, Activity{
269 Action: "domain-attach", TargetType: "instance", Target: host, Message: message,
270 Detail: host + " (first route, " + existing.Channel + ", record already present)",
271 })
272 s.bumpSerial(ctx)
273 }
274 return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel, Verify: verify}, nil
275 }
276 // Unreadable (malformed, or a body that lies about its
277 // identity) or written by a door other than this one. Not ours
278 // to delete and not safe to write over, so the claim goes back
279 // (only if this call minted it — a pre-existing claim is not
280 // this failure's to unwind) and the member is told what stands
281 // in the way — the fact of a record, and nothing about who
282 // wrote it.
283 log.Printf("console: attach %s: existing record is not this door's to adopt (read: %v)", host, gerr)
284 s.rollbackAttach(ctx, acct, app, host, false, created)
285 return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
286 "a route record already exists for this host — detach it or pick another hostname"}
287 }
288 // Anything else is AMBIGUOUS: a timeout or a dropped connection can
289 // come back as an error for a write that landed. Unwind both, in
290 // detach's order (record, then claim), so the failure cannot leave a
291 // record behind a released claim — a route materializing for a host
292 // anyone may now claim.
293 log.Printf("console: attach %s: record write: %v", host, err)
294 s.rollbackAttach(ctx, acct, app, host, true, created)
295 return attachResult{}, &doorRefusal{http.StatusInternalServerError,
296 "could not declare the route for this domain — nothing was attached, try again"}
297 }
298 // The record wrote (a real change by definition), so this announces
299 // regardless of whether the claim half was the no-op of a retry.
300 s.auditActivity(acct, app, principal, Activity{
301 Action: "domain-attach", TargetType: "instance", Target: host, Message: message,
302 Detail: host + " (first route, " + channel + ")",
303 })
304 s.bumpSerial(ctx)
305 return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel, Verify: verify}, nil
306}
307
308// detachDomain releases host's claim and, when the claim minted the app's
309// own route (empty Route — the first-route case), removes the declared
310// record first so the reconciler's removal phase drops the route.
311//
312// Every non-answer is one refusal: absent, another account's, or this
313// account's other app's all return the same 404, which the doors render as
314// http.NotFound — a detach must not be the probe that confirms a host is
315// claimed, or by whom.
316func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal {
317 if ref := s.bucketRefusal(); ref != nil {
318 return ref
319 }
320 // The browser twin of handleAPIDomainDetach's control-plane refusal, and
321 // it has to be here rather than in the door above it: this core is what
322 // deletes the provision record, so a guard the browser path did not
323 // share would leave the same route retirable by the same mechanism
324 // through a form post. Refused outright for a flagged app, for the
325 // reasons the API door's comment gives, and BEFORE the claim is read —
326 // its own caller (handleAppDomainDetach) has already spent the session,
327 // CSRF and owner gates, so a non-member never reaches this sentence.
328 if msg, status := s.controlPlaneRefusal(acct, app, "detached"); msg != "" {
329 return &doorRefusal{status, msg}
330 }
331 notAttached := &doorRefusal{http.StatusNotFound, "not attached to this app"}
332 // A host that is not even a hostname cannot be a claim key: refuse it
333 // here rather than handing it to a store as a key segment.
334 host, ok := domains.NormalizeAliasHost(host)
335 if !ok {
336 return notAttached
337 }
338 // A platform hostname's claim is the operator's to release (#209).
339 // Decided from the name alone, before the claim is read, so it says
340 // nothing about who holds it. Reserved names are left alone: nobody can
341 // claim one now, and a member holding one from before it was reserved
342 // must still be able to let it go.
343 if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.platformOperator(principal) {
344 return &doorRefusal{http.StatusForbidden, platformOperatorMsg}
345 }
346 claim, err := domains.GetClaim(ctx, s.cfg.Releases, host)
347 if err != nil {
348 if errors.Is(err, os.ErrNotExist) {
349 return notAttached
350 }
351 log.Printf("console: detach %s: claim read: %v", host, err)
352 return &doorRefusal{http.StatusInternalServerError,
353 "could not read this domain's claim — try again in a moment"}
354 }
355 if claim.Account != acct.Sqid || claim.App != app {
356 return notAttached
357 }
358 if claim.Route == "" {
359 // First-route: the record goes before the claim, the mirror of
360 // attach. A record left behind by a released claim would keep a
361 // route alive for a host anyone may now claim.
362 if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, "domain-detach", claim.RecordIncarnation); err != nil {
363 // The record's delete operation is durable and still running
364 // (or another operation holds the app): the hostname stays
365 // claimed until a later detach finds the record gone.
366 if accepted, ok := claimRetireAcceptedOf(err); ok {
367 s.audit(acct, app, principal, "domain-detach", "removal of the route for "+host+" accepted, operation "+accepted.operation)
368 s.bumpSerial(ctx)
369 return &doorRefusal{http.StatusConflict, claimDetachInProgressMsg}
370 }
371 if errors.Is(err, hostops.ErrPending) {
372 return &doorRefusal{http.StatusConflict, claimRetireBusyMsg}
373 }
374 log.Printf("console: detach %s: record delete: %v", host, err)
375 return &doorRefusal{http.StatusInternalServerError,
376 "could not remove this domain's route — it is still attached, try again"}
377 }
378 }
379 // DetachClaim re-checks ownership before deleting (its own neutrality);
380 // the read above already proved it, so removed=false here can only mean
381 // a race this call lost — in which case nothing was written and nothing
382 // announces.
383 removed, err := s.detachDomainClaim(ctx, host, acct.Sqid, app)
384 if msg, ok := claimAliasRefusal(err); ok {
385 return &doorRefusal{http.StatusConflict, msg}
386 }
387 if errors.Is(err, hostops.ErrContract) {
388 return &doorRefusal{http.StatusConflict, err.Error()}
389 }
390 if err != nil {
391 log.Printf("console: detach %s: claim delete: %v", host, err)
392 return &doorRefusal{http.StatusInternalServerError,
393 "could not release this domain's claim — try again in a moment"}
394 }
395 if removed {
396 s.audit(acct, app, principal, "domain-detach", host)
397 s.bumpSerial(ctx)
398 }
399 return nil
400}
401
402// readyRouteHost answers "does this app already have a route to alias?" with
403// the host of the first one, or "" for none. The selection is
404// domains.firstReadyUpstream's, minus the upstream copy: only the HOST is
405// needed here, because nothing about the primary's upstream is copied —
406// the alias resolves to it at request time, box-side.
407//
408// An empty Registry path is "no routes", not an error: a console configured
409// without a registry (and every test that wants the first-route branch) has
410// nothing to alias, and a missing DB must not be reported as a route.
411//
412// attaching is the host being attached. It is never an alias candidate — a
413// claim whose route names its own host is a self-alias, which is not a thing
414// the box side can resolve — and its ready row is instead reported back as
415// selfChannel, the channel that row already follows. That case is real: an
416// operator-added custom-domain route (story.carlosframework.com, spec §7)
417// that no claim covers yet. Skipping it as an alias lands the attach on the
418// first-route branch, where the declared record describes the row that
419// already exists and #137's adopt-on-match transfers ownership with no box
420// command — the backfill, arriving through the member's own door — and
421// carrying its channel forward is what keeps that adoption from being a
422// silent repoint (provision.convergeOwned writes the record's channel onto
423// the row it adopts).
424func (s *Server) readyRouteHost(sqid, app, attaching string) (alias, selfChannel string, err error) {
425 if s.cfg.Registry == "" {
426 return "", "", nil
427 }
428 reg, err := openRegistry(s.cfg.Registry)
429 if err != nil {
430 return "", "", err
431 }
432 routes, err := reg.List() // host order
433 if err != nil {
434 return "", "", err
435 }
436 for _, rt := range routes {
437 if rt.App != app || release.RouteAccount(rt) != sqid {
438 continue
439 }
440 // A platform mint is not a source to alias — aliasing a mint is
441 // self-reference, the same rule firstReadyUpstream carries — and a
442 // route that is not ready has nothing to serve yet.
443 if rt.Platform || rt.Status != "ready" {
444 continue
445 }
446 // Nor a sidecar, which is not a source to alias for a blunter
447 // reason: nothing can dial one. This says explicitly what
448 // firstReadyUpstream gets for free from the clause this function
449 // drops — it requires an upstream (Socket/Addr, or a channel for a
450 // static), and a sidecar is exactly the row that has neither. Every
451 // other test here passes a sidecar: it is the app's, it is the
452 // account's, it is not Platform, and it is ready, because ready is
453 // what makes it runnable. Aliasing onto one would mint a claim the
454 // box refuses for good (ReconcileAliases' own c.Route check), so the
455 // member's domain would never serve and nothing would say why.
456 if rt.IsSidecar() {
457 continue
458 }
459 if rt.Host == attaching {
460 selfChannel = rt.Channel
461 continue
462 }
463 if alias == "" {
464 alias = rt.Host
465 }
466 }
467 return alias, selfChannel, nil
468}
469
470// attachChannel resolves the channel a first-route record follows: the flag
471// when given, then the channel the host's own existing route already
472// follows, then the app's sole shipped channel. Nothing shipped and several
473// channels are both refusals rather than guesses — a record pointed at the
474// wrong channel serves the wrong build from a domain the member just
475// published.
476//
477// selfChannel (the backfill case above) takes precedence over the listing
478// for two reasons: the record is about to be adopted onto that live row, so
479// naming any other channel would REPOINT a running route the member did not
480// ask to move, and an app with two channels would otherwise be refused as
481// ambiguous for a domain whose channel was never in question.
482func (s *Server) attachChannel(ctx context.Context, sqid, app, channelFlag, selfChannel string) (string, *doorRefusal) {
483 // Shape only, never existence: a channel a member is about to ship is a
484 // legitimate target, but a malformed one becomes a bucket key segment
485 // and a route pinned to nothing (release.ValidChannel is the same rail
486 // the instance-create door puts on its own channel field).
487 if channelFlag != "" {
488 if !release.ValidChannel(channelFlag) {
489 return "", &doorRefusal{http.StatusUnprocessableEntity, "invalid channel name"}
490 }
491 return channelFlag, nil
492 }
493 if selfChannel != "" && release.ValidChannel(selfChannel) {
494 return selfChannel, nil
495 }
496 prefix := release.ChannelsPrefix(sqid, app)
497 keys, err := s.cfg.Releases.List(ctx, prefix)
498 if err != nil {
499 log.Printf("console: attach: list channels %s/%s: %v", sqid, app, err)
500 return "", &doorRefusal{http.StatusInternalServerError,
501 "could not list this app's channels — try again in a moment"}
502 }
503 var names []string
504 for _, k := range keys {
505 name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json")
506 if !ok || name == "" {
507 continue
508 }
509 names = append(names, name)
510 }
511 sort.Strings(names)
512 switch len(names) {
513 case 0:
514 return "", &doorRefusal{http.StatusUnprocessableEntity,
515 "nothing shipped yet — carlos ship first"}
516 case 1:
517 return names[0], nil
518 }
519 return "", &doorRefusal{http.StatusUnprocessableEntity,
520 "this app follows several channels (" + strings.Join(names, ", ") +
521 ") — say which one this domain serves"}
522}
523
524// attachWrote reports whether rec is a record THIS door could have written:
525// the fixed shape of a first-route attach — kind static, no slug, and the
526// shared pool and unconstrained region (§3). It is the guard on the
527// idempotent conflict branch, and it is narrow on purpose. The record key
528// is host-scoped within an app, and an app with a custom manifest Domain
529// can have `carlos instances create` write an INSTANCE record at the very
530// same key; adopting one of those as "an attach that already happened"
531// would hand detach the authority to retire a tenant instance this door
532// never created. Anything that fails this test is left alone and refused.
533func attachWrote(rec provision.Record) bool {
534 return rec.Kind == "static" && rec.Slug == "" && rec.Region == "" && rec.Pool == ""
535}
536
537// bucketRefusal is the "this console cannot answer at all" check both verbs
538// open with: a deployment with no bucket has nowhere to put a claim or a
539// record. Same 503 and the same sentence the instance API's gate uses
540// (apiinstances.go), so one deployment shape produces one answer wherever a
541// member meets it.
542func (s *Server) bucketRefusal() *doorRefusal {
543 if s.cfg.Releases == nil {
544 return &doorRefusal{http.StatusServiceUnavailable,
545 "no deployment bucket on this host — the console is running without one"}
546 }
547 return nil
548}
549
550// rollbackAttach unwinds a half-written first-route attach, best effort:
551// the bucket that just refused a write may refuse these too, and a member
552// waiting on a refusal must not also wait on a retry loop. dropRecord asks
553// for the record to go first — set when the record write's outcome is
554// UNKNOWN (a timeout can report failure for an object that landed), and
555// clear when the record is known to be someone else's business. dropClaim
556// is whether the claim is this failure's to release at all: only when THIS
557// call created it — deleting a claim an earlier attach minted would strand
558// a host the member still legitimately holds. DetachClaim is idempotent and
559// owner-checked, so a no-op costs one read.
560//
561// A rollback that itself fails leaves a claim with no record: inert (nothing
562// materializes from a claim alone), visible to its own owner in the app's
563// domain list, and clearable with detach. That is the failure this ordering
564// was chosen to have.
565func (s *Server) rollbackAttach(ctx context.Context, acct *Account, app, host string, dropRecord, dropClaim bool) {
566 if s.cfg.HostClaim {
567 return
568 }
569 if dropRecord {
570 if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, "attach-rollback", ""); err != nil {
571 log.Printf("console: attach %s: record rollback: %v", host, err)
572 }
573 }
574 if !dropClaim {
575 return
576 }
577 if _, err := s.detachDomainClaim(ctx, host, acct.Sqid, app); err != nil {
578 log.Printf("console: attach %s: claim rollback: %v (claim is stranded — detach clears it)", host, err)
579 }
580}
581
582// claimRefusal maps a lost claim write onto the door's answer. The 409 is
583// ONE string whoever holds the host: domains.ErrHostUnavailable already
584// refuses to name the holder, and the text here is its message verbatim —
585// the same bytes the CLI API door answers, so the two doors cannot be
586// diffed against each other to learn anything either one refuses to say.
587func claimRefusal(host string, err error) *doorRefusal {
588 if msg, ok := claimAliasRefusal(err); ok {
589 return &doorRefusal{http.StatusConflict, msg}
590 }
591 if errors.Is(err, hostops.ErrPending) {
592 return &doorRefusal{http.StatusConflict, claimRetireBusyMsg}
593 }
594 if errors.Is(err, hostops.ErrContract) {
595 return &doorRefusal{http.StatusConflict, err.Error()}
596 }
597 if errors.Is(err, domains.ErrHostUnavailable) {
598 return &doorRefusal{http.StatusConflict, hostUnavailableMsg}
599 }
600 log.Printf("console: attach %s: claim write: %v", host, err)
601 return &doorRefusal{http.StatusInternalServerError,
602 "could not claim this domain — try again in a moment"}
603}
604
605// platformNote marks an audit row written through the platform door (#209),
606// so the holding account's activity log says the operator placed the claim.
607func platformNote(platform bool) string {
608 if platform {
609 return " (platform operator)"
610 }
611 return ""
612}
613
614// pendingNote marks an audit row for a claim written pending (#215), so the
615// account's activity log does not read as though the hostname went live.
616func pendingNote(pending bool) string {
617 if pending {
618 return " (waiting for DNS verification)"
619 }
620 return ""
621}
622