package console import ( "context" "encoding/json" "errors" "io" "os" "strings" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // A short alias request finishes before the first renewal deadline. An // issued call that fails to finish remains in the durable effect journal. func (s *Server) attachDomainClaim(ctx context.Context, acct *Account, app string, incoming domains.Claim, now time.Time, prove func() bool) (bool, bool, bool, error) { if !s.cfg.HostClaim { return domains.AttachClaimProven(ctx, s.cfg.Releases, incoming, now, prove) } a := s.cfg.ClaimAuthority if a == nil || a.Control == nil || incoming.Route == "" { return false, false, false, hostops.ErrContract } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() target, err := hostclaim.Read(ctx, s.cfg.Releases, incoming.Route) if err != nil { return false, false, false, err } if target.Absent { // No claim object: either the route is not a provisioning record // at all (a registry-only static route, which never gets a host // claim) or the record predates claims and awaits backfill. // Neither may be aliased without the claim's incarnation binding. return false, false, false, s.claimAliasUnbacked(ctx, acct.Sqid, app, incoming.Route) } if target.Claim.Holder == nil || target.Claim.Holder.Account != acct.Sqid || target.Claim.Holder.App != app { return false, false, false, hostops.ErrPending } if target.Claim.Op.Kind != hostclaim.KindServing { // The route's own lifecycle operation (delete, park, placement, // move) is in flight. return false, false, false, hostops.ErrPending } appID, err := s.store.ClaimAppID(acct.ID, app) if err != nil { return false, false, false, err } admission, err := hostops.ReadAdmission(ctx, s.cfg.Releases, acct.Sqid, app, appID) if err != nil { return false, false, false, err } id, err := claimEffectID() if err != nil { return false, false, false, err } controller, err := claimEffectID() if err != nil { return false, false, false, err } owner := hostops.RegistrationOwner{Console: a.ConsoleID, Enrollment: a.Enrollment, Controller: controller} reg := hostops.Registration{Kind: "alias", Host: incoming.Route, Owner: owner, AppID: appID, Epoch: admission.Value.Epoch, Claim: hostops.SavedObservation(target), Incarnation: target.Claim.Holder.Incarnation, CreatedAt: target.Claim.Holder.CreatedAt} start, err := a.Control.RegisterStart(ctx, acct.Sqid, app, appID, id, reg, time.Now()) if err != nil { return false, false, false, err } // Exactly the observation saved before registration is consumed once. A // changed target refuses this request instead of silently rebasing it. ctx = start.Lifecycle(ctx) if _, err = hostclaim.Update(ctx, s.cfg.Releases, target, func(*hostclaim.Claim) error { return nil }); err != nil { return false, false, false, err } key := domains.ClaimKey(incoming.Host) raw, etag, err := s.cfg.Releases.Get(ctx, key) absent := errors.Is(err, os.ErrNotExist) if err != nil && !absent { return false, false, false, err } acquisition, err := claimEffectID() if err != nil { return false, false, false, err } if !absent { var current domains.Claim if err = json.Unmarshal(raw, ¤t); err != nil { return false, false, false, err } // Replacing another owner's marker belongs to that owner's retirement. if current.Account != acct.Sqid || current.App != app { return false, false, false, domains.ErrHostUnavailable } if current.Acquisition == "" || current.Route == "" || current.RecordIncarnation == "" || etag == "" { return false, false, false, hostops.ErrPending } if current.Route == incoming.Route && current.RecordIncarnation != target.Claim.Holder.Incarnation { return false, false, false, hostops.ErrPending } acquisition = current.Acquisition } incoming.Acquisition = acquisition incoming.RecordIncarnation = target.Claim.Holder.Incarnation incoming.OwnershipState = "owned" effects := claimStoreEffects{Runner: start.Runner(time.Now), OperationID: id} guarded := claimAliasStore{data: s.cfg.Releases, key: key, etag: etag, acquisition: acquisition, incarnation: incoming.RecordIncarnation, account: acct.Sqid, app: app, effects: effects} return domains.AttachClaimProven(ctx, guarded, incoming, now, prove) } // claimAliasStore admits exactly one marker Put (the observed ETag) as an // effect under the alias registration. Named private fields only. type claimAliasStore struct { data release.Store key, etag, acquisition, incarnation, account, app string effects claimStoreEffects } var ( _ release.Store = claimAliasStore{} _ release.Opener = claimAliasStore{} ) func (s claimAliasStore) Get(ctx context.Context, key string) ([]byte, string, error) { return s.data.Get(ctx, key) } func (s claimAliasStore) List(ctx context.Context, prefix string) ([]string, error) { return s.data.List(ctx, prefix) } func (s claimAliasStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { return s.data.ListDirs(ctx, prefix) } func (s claimAliasStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { return release.OpenObject(ctx, s.data, key) } func (s claimAliasStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { if key != s.key || etag != s.etag { return "", hostops.ErrPending } var marker domains.Claim if err := json.Unmarshal(raw, &marker); err != nil { return "", err } if marker.Account != s.account || marker.App != s.app { return "", hostops.ErrIdentity } // The acquisition is fixed; the referenced serving incarnation follows an // admitted repoint. marker.Acquisition = s.acquisition marker.RecordIncarnation = s.incarnation marker.OwnershipState = "owned" body, err := json.Marshal(marker) if err != nil { return "", err } return s.effects.run(ctx, key, "alias-ownership-put", map[string]string{"etag": etag, "body": string(body)}, func(ctx context.Context) (string, error) { return s.data.Put(ctx, key, body, etag) }) } func (s claimAliasStore) Delete(context.Context, string) error { return hostops.ErrContract } func (s *Server) detachDomainClaim(ctx context.Context, host, account, app string) (bool, error) { if !s.cfg.HostClaim { return domains.DetachClaim(ctx, s.cfg.Releases, host, account, app) } marker, err := domains.GetClaim(ctx, s.cfg.Releases, host) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } if marker == nil || marker.Account != account || marker.App != app { return false, nil } // Alias detach needs its own conditional effect operation; until then the // marker stays and the door answers an explicit refusal before effects. return false, errClaimAliasDetachUnsupported } // Explicit host-claim refusals of the alias doors. Each answers 409 before // any registration or effect, with its own message, so a refusal that is // intentionally unsupported is never confused with a missing gate. var ( // The alias target is a registry route with no provisioning record // (a static app's route): it never carries a host claim, so it has no // incarnation for the alias to bind. errClaimAliasStaticRoute = errors.Join(hostops.ErrContract, errors.New("this app's route is not a provisioned instance, so a hostname cannot be aliased to it on this console yet")) // The alias target has a provisioning record but no host claim yet: it // is waiting for the host-claim backfill to adopt it. errClaimAliasTargetUnclaimed = errors.Join(hostops.ErrPending, errors.New("this app's route has no host claim yet; attach again once the host-claim backfill has adopted it")) // Removing an alias marker needs its own conditional effect operation. errClaimAliasDetachUnsupported = errors.Join(hostops.ErrContract, errors.New("detaching a hostname alias is not supported on this console yet")) // Wildcard and catch-all capabilities are written only by the legacy // capability path. errClaimAliasCapabilitiesUnsupported = errors.Join(hostops.ErrContract, errors.New("wildcard and catch-all hostnames are not supported on this console yet")) ) func (s *Server) claimAliasUnbacked(ctx context.Context, account, app, route string) error { _, _, err := s.cfg.Releases.Get(ctx, provision.RecordKey(account, app, route)) switch { case errors.Is(err, os.ErrNotExist): return errClaimAliasStaticRoute case err != nil: return err default: return errClaimAliasTargetUnclaimed } } // claimDetachInProgressMsg: a first-route detach whose record delete is // durable but still running. The hostname stays claimed until it finishes. const claimDetachInProgressMsg = "this hostname's route is being removed; detach again shortly to release the hostname" // claimAliasRefusal is the 409 for an explicit host-claim alias refusal. // The message names no holder: every one is about this app's own route. func claimAliasRefusal(err error) (string, bool) { for _, known := range []error{errClaimAliasStaticRoute, errClaimAliasTargetUnclaimed, errClaimAliasDetachUnsupported, errClaimAliasCapabilitiesUnsupported} { if errors.Is(err, known) { return claimRefusalText(known), true } } return "", false } // claimRefusalText is the human half of a joined refusal (its last line). func claimRefusalText(err error) string { msg := err.Error() if i := strings.LastIndexByte(msg, '\n'); i >= 0 { return msg[i+1:] } return msg }