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 "errors"
7 "io"
8 "os"
9 "strings"
10 "time"
11
12 "github.com/carlosframework/platform/internal/domains"
13 "github.com/carlosframework/platform/internal/hostclaim"
14 "github.com/carlosframework/platform/internal/hostops"
15 "github.com/carlosframework/platform/internal/provision"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// A short alias request finishes before the first renewal deadline. An
20// issued call that fails to finish remains in the durable effect journal.
21func (s *Server) attachDomainClaim(ctx context.Context, acct *Account, app string, incoming domains.Claim, now time.Time, prove func() bool) (bool, bool, bool, error) {
22 if !s.cfg.HostClaim {
23 return domains.AttachClaimProven(ctx, s.cfg.Releases, incoming, now, prove)
24 }
25 a := s.cfg.ClaimAuthority
26 if a == nil || a.Control == nil || incoming.Route == "" {
27 return false, false, false, hostops.ErrContract
28 }
29 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
30 defer cancel()
31 target, err := hostclaim.Read(ctx, s.cfg.Releases, incoming.Route)
32 if err != nil {
33 return false, false, false, err
34 }
35 if target.Absent {
36 // No claim object: either the route is not a provisioning record
37 // at all (a registry-only static route, which never gets a host
38 // claim) or the record predates claims and awaits backfill.
39 // Neither may be aliased without the claim's incarnation binding.
40 return false, false, false, s.claimAliasUnbacked(ctx, acct.Sqid, app, incoming.Route)
41 }
42 if target.Claim.Holder == nil || target.Claim.Holder.Account != acct.Sqid || target.Claim.Holder.App != app {
43 return false, false, false, hostops.ErrPending
44 }
45 if target.Claim.Op.Kind != hostclaim.KindServing {
46 // The route's own lifecycle operation (delete, park, placement,
47 // move) is in flight.
48 return false, false, false, hostops.ErrPending
49 }
50 appID, err := s.store.ClaimAppID(acct.ID, app)
51 if err != nil {
52 return false, false, false, err
53 }
54 admission, err := hostops.ReadAdmission(ctx, s.cfg.Releases, acct.Sqid, app, appID)
55 if err != nil {
56 return false, false, false, err
57 }
58 id, err := claimEffectID()
59 if err != nil {
60 return false, false, false, err
61 }
62 controller, err := claimEffectID()
63 if err != nil {
64 return false, false, false, err
65 }
66 owner := hostops.RegistrationOwner{Console: a.ConsoleID, Enrollment: a.Enrollment, Controller: controller}
67 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}
68 start, err := a.Control.RegisterStart(ctx, acct.Sqid, app, appID, id, reg, time.Now())
69 if err != nil {
70 return false, false, false, err
71 }
72 // Exactly the observation saved before registration is consumed once. A
73 // changed target refuses this request instead of silently rebasing it.
74 ctx = start.Lifecycle(ctx)
75 if _, err = hostclaim.Update(ctx, s.cfg.Releases, target, func(*hostclaim.Claim) error { return nil }); err != nil {
76 return false, false, false, err
77 }
78 key := domains.ClaimKey(incoming.Host)
79 raw, etag, err := s.cfg.Releases.Get(ctx, key)
80 absent := errors.Is(err, os.ErrNotExist)
81 if err != nil && !absent {
82 return false, false, false, err
83 }
84 acquisition, err := claimEffectID()
85 if err != nil {
86 return false, false, false, err
87 }
88 if !absent {
89 var current domains.Claim
90 if err = json.Unmarshal(raw, &current); err != nil {
91 return false, false, false, err
92 }
93 // Replacing another owner's marker belongs to that owner's retirement.
94 if current.Account != acct.Sqid || current.App != app {
95 return false, false, false, domains.ErrHostUnavailable
96 }
97 if current.Acquisition == "" || current.Route == "" || current.RecordIncarnation == "" || etag == "" {
98 return false, false, false, hostops.ErrPending
99 }
100 if current.Route == incoming.Route && current.RecordIncarnation != target.Claim.Holder.Incarnation {
101 return false, false, false, hostops.ErrPending
102 }
103 acquisition = current.Acquisition
104 }
105 incoming.Acquisition = acquisition
106 incoming.RecordIncarnation = target.Claim.Holder.Incarnation
107 incoming.OwnershipState = "owned"
108 effects := claimStoreEffects{Runner: start.Runner(time.Now), OperationID: id}
109 guarded := claimAliasStore{data: s.cfg.Releases, key: key, etag: etag, acquisition: acquisition, incarnation: incoming.RecordIncarnation, account: acct.Sqid, app: app, effects: effects}
110 return domains.AttachClaimProven(ctx, guarded, incoming, now, prove)
111}
112
113// claimAliasStore admits exactly one marker Put (the observed ETag) as an
114// effect under the alias registration. Named private fields only.
115type claimAliasStore struct {
116 data release.Store
117 key, etag, acquisition, incarnation, account, app string
118 effects claimStoreEffects
119}
120
121var (
122 _ release.Store = claimAliasStore{}
123 _ release.Opener = claimAliasStore{}
124)
125
126func (s claimAliasStore) Get(ctx context.Context, key string) ([]byte, string, error) {
127 return s.data.Get(ctx, key)
128}
129func (s claimAliasStore) List(ctx context.Context, prefix string) ([]string, error) {
130 return s.data.List(ctx, prefix)
131}
132func (s claimAliasStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
133 return s.data.ListDirs(ctx, prefix)
134}
135func (s claimAliasStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
136 return release.OpenObject(ctx, s.data, key)
137}
138func (s claimAliasStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
139 if key != s.key || etag != s.etag {
140 return "", hostops.ErrPending
141 }
142 var marker domains.Claim
143 if err := json.Unmarshal(raw, &marker); err != nil {
144 return "", err
145 }
146 if marker.Account != s.account || marker.App != s.app {
147 return "", hostops.ErrIdentity
148 }
149 // The acquisition is fixed; the referenced serving incarnation follows an
150 // admitted repoint.
151 marker.Acquisition = s.acquisition
152 marker.RecordIncarnation = s.incarnation
153 marker.OwnershipState = "owned"
154 body, err := json.Marshal(marker)
155 if err != nil {
156 return "", err
157 }
158 return s.effects.run(ctx, key, "alias-ownership-put", map[string]string{"etag": etag, "body": string(body)}, func(ctx context.Context) (string, error) {
159 return s.data.Put(ctx, key, body, etag)
160 })
161}
162func (s claimAliasStore) Delete(context.Context, string) error { return hostops.ErrContract }
163
164func (s *Server) detachDomainClaim(ctx context.Context, host, account, app string) (bool, error) {
165 if !s.cfg.HostClaim {
166 return domains.DetachClaim(ctx, s.cfg.Releases, host, account, app)
167 }
168 marker, err := domains.GetClaim(ctx, s.cfg.Releases, host)
169 if errors.Is(err, os.ErrNotExist) {
170 return false, nil
171 }
172 if err != nil {
173 return false, err
174 }
175 if marker == nil || marker.Account != account || marker.App != app {
176 return false, nil
177 }
178 // Alias detach needs its own conditional effect operation; until then the
179 // marker stays and the door answers an explicit refusal before effects.
180 return false, errClaimAliasDetachUnsupported
181}
182
183// Explicit host-claim refusals of the alias doors. Each answers 409 before
184// any registration or effect, with its own message, so a refusal that is
185// intentionally unsupported is never confused with a missing gate.
186var (
187 // The alias target is a registry route with no provisioning record
188 // (a static app's route): it never carries a host claim, so it has no
189 // incarnation for the alias to bind.
190 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"))
191 // The alias target has a provisioning record but no host claim yet: it
192 // is waiting for the host-claim backfill to adopt it.
193 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"))
194 // Removing an alias marker needs its own conditional effect operation.
195 errClaimAliasDetachUnsupported = errors.Join(hostops.ErrContract, errors.New("detaching a hostname alias is not supported on this console yet"))
196 // Wildcard and catch-all capabilities are written only by the legacy
197 // capability path.
198 errClaimAliasCapabilitiesUnsupported = errors.Join(hostops.ErrContract, errors.New("wildcard and catch-all hostnames are not supported on this console yet"))
199)
200
201func (s *Server) claimAliasUnbacked(ctx context.Context, account, app, route string) error {
202 _, _, err := s.cfg.Releases.Get(ctx, provision.RecordKey(account, app, route))
203 switch {
204 case errors.Is(err, os.ErrNotExist):
205 return errClaimAliasStaticRoute
206 case err != nil:
207 return err
208 default:
209 return errClaimAliasTargetUnclaimed
210 }
211}
212
213// claimDetachInProgressMsg: a first-route detach whose record delete is
214// durable but still running. The hostname stays claimed until it finishes.
215const claimDetachInProgressMsg = "this hostname's route is being removed; detach again shortly to release the hostname"
216
217// claimAliasRefusal is the 409 for an explicit host-claim alias refusal.
218// The message names no holder: every one is about this app's own route.
219func claimAliasRefusal(err error) (string, bool) {
220 for _, known := range []error{errClaimAliasStaticRoute, errClaimAliasTargetUnclaimed, errClaimAliasDetachUnsupported, errClaimAliasCapabilitiesUnsupported} {
221 if errors.Is(err, known) {
222 return claimRefusalText(known), true
223 }
224 }
225 return "", false
226}
227
228// claimRefusalText is the human half of a joined refusal (its last line).
229func claimRefusalText(err error) string {
230 msg := err.Error()
231 if i := strings.LastIndexByte(msg, '\n'); i >= 0 {
232 return msg[i+1:]
233 }
234 return msg
235}
236