| 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. |
| 51 | package console |
| 52 | |
| 53 | import ( |
| 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. |
| 74 | type 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. |
| 91 | type 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. |
| 107 | func (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. |
| 116 | func (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. |
| 316 | func (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). |
| 424 | func (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. |
| 482 | func (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. |
| 533 | func 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. |
| 542 | func (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. |
| 565 | func (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. |
| 587 | func 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. |
| 607 | func 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. |
| 616 | func pendingNote(pending bool) string { |
| 617 | if pending { |
| 618 | return " (waiting for DNS verification)" |
| 619 | } |
| 620 | return "" |
| 621 | } |
| 622 | |