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 "errors"
6 "fmt"
7 "net/http"
8 "strings"
9
10 "github.com/carlosframework/platform/internal/provision"
11)
12
13// Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every
14// check passes when plans are off (EffectiveLimits nil), so a deployment
15// without a catalog sees no change. A refusal is a *planCapError carrying
16// approved copy; JSON doors answer it 409 {"code":"cap"} and browser doors
17// flash it. Creation refusals have no grace: the cap refuses what is new and
18// never touches what already runs.
19
20// planCapError carries the CLI wording (Error) and, where a browser door can
21// meet it, the browser wording (Browser).
22type planCapError struct{ msg, browser string }
23
24func (e *planCapError) Error() string { return e.msg }
25
26// Browser is the sentence-case wording for a browser door.
27func (e *planCapError) Browser() string {
28 if e.browser != "" {
29 return e.browser
30 }
31 return e.msg
32}
33
34// capMessage formats approved copy by its review id (plancaps_copy.go).
35func capMessage(id string, args ...any) string {
36 format, ok := capCopy[id]
37 if !ok {
38 format = id
39 }
40 return fmt.Sprintf(format, args...)
41}
42
43// runningThings is the account's live apps (temporary and restoring
44// included) plus the instance records of those apps. Trashed apps and their
45// instances do not count. One key-only list of the account's prefix.
46func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) {
47 apps, err := s.store.LiveAppCount(acct.ID)
48 if err != nil {
49 return 0, err
50 }
51 live, err := s.store.AppsIn(acct.ID)
52 if err != nil {
53 return 0, err
54 }
55 restoring, err := s.store.RestoringAppsIn(acct.ID)
56 if err != nil {
57 return 0, err
58 }
59 liveSet := map[string]bool{}
60 for _, a := range append(live, restoring...) {
61 liveSet[a] = true
62 }
63 if s.cfg.Releases == nil {
64 return apps, nil
65 }
66 keys, err := s.cfg.Releases.List(ctx, "apps/"+acct.Sqid+"/")
67 if err != nil {
68 return 0, err
69 }
70 known := map[string]bool{acct.Sqid: true}
71 instances := 0
72 for _, k := range keys {
73 if _, app, _, ok := provision.ParseRecordKey(k, known); ok && liveSet[app] {
74 instances++
75 }
76 }
77 return apps + instances, nil
78}
79
80// checkRunningThings refuses when adding more running things would pass
81// the account's cap.
82func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error {
83 l, err := s.EffectiveLimits(acct)
84 if err != nil || l == nil {
85 return err
86 }
87 n, err := s.runningThings(ctx, acct)
88 if err != nil {
89 return err
90 }
91 if n+adding > l.RunningThings {
92 return &planCapError{capMessage("cli.cap.running_things", l.RunningThings), capMessage("browser.cap.running_things", l.RunningThings)}
93 }
94 return nil
95}
96
97// checkRestore is checkRunningThings for restoring app, with the restore
98// wording.
99func (s *Server) checkRestore(ctx context.Context, acct *Account, app string) error {
100 // Restoring brings back the app and its retained instance records
101 // (Astra plans-3a finding 1).
102 n, err := s.appInstanceCount(ctx, acct, app)
103 if err != nil {
104 return err
105 }
106 err = s.checkRunningThings(ctx, acct, 1+n)
107 if !isPlanCap(err) {
108 return err
109 }
110 l, _ := s.EffectiveLimits(acct)
111 return &planCapError{capMessage("cli.cap.restore", app, l.RunningThings), capMessage("browser.cap.restore", app, l.RunningThings)}
112}
113
114// checkSidecars refuses a new worker when app already has as many as the
115// plan allows.
116func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error {
117 l, err := s.EffectiveLimits(acct)
118 if err != nil || l == nil {
119 return err
120 }
121 if s.cfg.Releases == nil {
122 return nil
123 }
124 keys, err := s.cfg.Releases.List(ctx, provision.SidecarsPrefix(acct.Sqid, app))
125 if err != nil {
126 return err
127 }
128 n := 0
129 for _, k := range keys {
130 if strings.HasSuffix(k, ".json") {
131 n++
132 }
133 }
134 if n >= l.SidecarsPerApp {
135 return &planCapError{msg: capMessage("cli.cap.sidecars", app, l.SidecarsPerApp)}
136 }
137 return nil
138}
139
140// checkNewFreeAccount refuses a person a further account on a free plan once
141// they own the catalog's allowance of them (spec §8.1; creation only).
142func (s *Server) checkNewFreeAccount(addr string) error {
143 cat := s.cfg.PlanCatalog
144 if cat == nil || cat.FreeAccountsPerPerson <= 0 {
145 return nil
146 }
147 if p, ok := cat.Plans[cat.DefaultPlan]; ok && p.Paid {
148 return nil // new accounts start on a paid plan here: nothing to cap
149 }
150 accts, err := s.store.AccountsFor(addr)
151 if err != nil {
152 return err
153 }
154 free := 0
155 for _, a := range accts {
156 if a.Role != RoleOwner {
157 continue
158 }
159 plan := cat.DefaultPlan
160 row, err := s.store.AccountPlan(a.ID)
161 if err != nil {
162 return err
163 }
164 if row != nil {
165 plan = row.Plan
166 }
167 if p, ok := cat.Plans[plan]; !ok || !p.Paid {
168 free++
169 }
170 }
171 if free >= cat.FreeAccountsPerPerson {
172 return &planCapError{capMessage("cli.cap.free_account"), capMessage("browser.cap.free_account")}
173 }
174 return nil
175}
176
177// checkFleets refuses a fleet on a plan without fleets.
178func (s *Server) checkFleets(acct *Account) error {
179 l, err := s.EffectiveLimits(acct)
180 if err != nil || l == nil || l.Fleets {
181 return err
182 }
183 return &planCapError{msg: capMessage("cli.cap.fleets")}
184}
185
186// checkCustomDomains refuses a custom domain on a plan without them.
187// Platform hostnames are not custom domains; callers check those first.
188func (s *Server) checkCustomDomains(acct *Account) error {
189 l, err := s.EffectiveLimits(acct)
190 if err != nil || l == nil || l.CustomDomains {
191 return err
192 }
193 return &planCapError{capMessage("cli.cap.custom_domain"), capMessage("browser.cap.custom_domain")}
194}
195
196func isPlanCap(err error) bool {
197 var c *planCapError
198 return errors.As(err, &c)
199}
200
201// planLock takes the account lock around a capped door's count and write,
202// so two concurrent creates cannot both pass. With plans off it takes
203// nothing, leaving those deployments exactly as they were. Never call it
204// from a door that already holds lockAccount (/new-temporary, restore).
205func (s *Server) planLock(acct *Account) func() {
206 if s.cfg.PlanCatalog == nil {
207 return func() {}
208 }
209 return s.lockAccount(acct.Sqid)
210}
211
212// customDomain reports whether host is a customer's own domain, the kind a
213// free plan may not attach: an open host outside the deployment's apps
214// domain. The account's own <sqid>.<apps-domain> addresses and the
215// operator's platform zones are not custom domains.
216func (s *Server) customDomain(host, account string) bool {
217 if s.domainHostPolicy(host, account) != hostOpen {
218 return false
219 }
220 if domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); domain != "" && strings.HasSuffix(host, "."+domain) {
221 return false
222 }
223 return true
224}
225
226// customDomainRefusal is checkCustomDomains as a door refusal, for the
227// shared attach paths; nil when the attach may go ahead.
228func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal {
229 if !s.customDomain(host, acct.Sqid) {
230 return nil
231 }
232 if err := s.checkCustomDomains(acct); err != nil {
233 if isPlanCap(err) {
234 return &doorRefusal{http.StatusConflict, err.Error()}
235 }
236 return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"}
237 }
238 return nil
239}
240
241// capOrError is a door refusal as a JSON body, with "code":"cap" when it is
242// a plan cap (the only 409 customDomainRefusal returns).
243func capOrError(ref *doorRefusal) map[string]any {
244 body := map[string]any{"error": ref.Msg}
245 if ref.Status == http.StatusConflict {
246 body["code"] = "cap"
247 }
248 return body
249}
250
251// checkCreateApp is the running-things check for creating (or adopting) app.
252func (s *Server) checkCreateApp(ctx context.Context, acct *Account, app string) error {
253 // Adopting an app whose objects already exist brings its instance records
254 // with it (Astra plans-3a finding 3); a brand-new name has none.
255 n, err := s.appInstanceCount(ctx, acct, app)
256 if err != nil {
257 return err
258 }
259 return s.checkRunningThings(ctx, acct, 1+n)
260}
261
262// appInstanceCount counts app's instance records in the bucket, whether or
263// not the app has a live console row.
264func (s *Server) appInstanceCount(ctx context.Context, acct *Account, app string) (int, error) {
265 if s.cfg.Releases == nil {
266 return 0, nil
267 }
268 keys, err := s.cfg.Releases.List(ctx, provision.RecordsPrefix(acct.Sqid, app))
269 if err != nil {
270 return 0, err
271 }
272 known := map[string]bool{acct.Sqid: true}
273 n := 0
274 for _, k := range keys {
275 if _, a, _, ok := provision.ParseRecordKey(k, known); ok && a == app {
276 n++
277 }
278 }
279 return n, nil
280}
281
282// planPersonLock serialises a person's account creates when plans are on,
283// so concurrent requests cannot each see room for one more free account
284// (Astra plans-3a finding 2). The key cannot collide with an account sqid.
285func (s *Server) planPersonLock(addr string) func() {
286 if s.cfg.PlanCatalog == nil {
287 return func() {}
288 }
289 return s.acctLocks.lock("person:" + addr)
290}
291