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 "crypto/subtle"
6 "errors"
7 "fmt"
8 "log"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// The bounds are spec ruling 3: default 7 days (temporaryTTLDefault,
19// tempstore.go), at least an hour, at most 30 days, and at most 20 live
20// temporary apps in an account.
21const (
22 temporaryTTLMin = time.Hour
23 temporaryTTLMax = 30 * 24 * time.Hour
24 temporaryAppCap = 20
25)
26
27var errTTLBounds = errors.New("ttl_seconds must be between 1 hour (3600) and 30 days (2592000)")
28
29// temporaryTTL reads a request's ttl_seconds. Compared as seconds before any
30// multiplication, so no value can overflow a Duration on its way to being
31// refused. 0 is the default only where allowDefault says so (create).
32func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error) {
33 if seconds == 0 && allowDefault {
34 return temporaryTTLDefault, nil
35 }
36 if seconds < int64(temporaryTTLMin/time.Second) || seconds > int64(temporaryTTLMax/time.Second) {
37 return 0, errTTLBounds
38 }
39 return time.Duration(seconds) * time.Second, nil
40}
41
42// validTemporaryName is the name rule on top of release.ValidApp: in the tmp-
43// namespace, something after the prefix, and a DNS label, because the name
44// becomes the first label of the app's platform host.
45func validTemporaryName(name string) bool {
46 return release.IsTemporaryName(name) && name != release.TemporaryAppPrefix &&
47 release.ValidApp(name) && provision.ValidHostShape(name)
48}
49
50// temporaryCapError is the cap's refusal. It lists each live temporary app
51// with its expiry, so the person refused can see what to keep or delete.
52type temporaryCapError struct{ Live []AppLife }
53
54func (e *temporaryCapError) Error() string {
55 var b strings.Builder
56 fmt.Fprintf(&b, "this account already has %d temporary apps, the most it can hold. "+
57 "Make one permanent with carlos apps keep, or delete one with carlos apps delete. They are:", len(e.Live))
58 for _, l := range e.Live {
59 fmt.Fprintf(&b, "\n %s expires %s", l.App, time.Unix(l.ExpiresAt, 0).UTC().Format("2006-01-02 15:04 UTC"))
60 }
61 return b.String()
62}
63
64// temporaryCapCheck refuses when the account is at the cap. The CALLER holds
65// s.lockAccount(sqid), which is what makes count-then-insert exact (spec §3).
66func (s *Server) temporaryCapCheck(accountID int64) error {
67 live, err := s.store.LiveTemporaryApps(accountID)
68 if err != nil {
69 return err
70 }
71 if len(live) >= temporaryAppCap {
72 return &temporaryCapError{Live: live}
73 }
74 return nil
75}
76
77// accountAppHasRoutes is appHasRoutes (appsettings.go) scoped to one
78// account: any route in THIS console's registry whose account
79// (release.RouteAccount) is sqid and whose app is app, platform apex
80// included. A registry that cannot be opened or listed is an error, never
81// "no routes" (spec §3, astra 5.1). It is a second line behind the tmp-
82// namespace, not the proof: routes hand-added on other boxes are invisible
83// here.
84func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error) {
85 if s.cfg.Registry == "" {
86 return false, errors.New("console: no registry configured")
87 }
88 reg, err := openRegistry(s.cfg.Registry)
89 if err != nil {
90 return false, err
91 }
92 routes, err := reg.List()
93 if err != nil {
94 return false, err
95 }
96 for _, rt := range routes {
97 if rt.App == app && release.RouteAccount(rt) == sqid {
98 return true, nil
99 }
100 }
101 return false, nil
102}
103
104// handleAPICreateTemporaryApp is POST /api/cli/apps/{account}/new-temporary
105// (spec §3): a FRESH claim of a tmp- name, never an adoption, with the expiry
106// in the same insert. It shares claimApp's checks through claimAppWith and
107// takes no control_plane, place or build_probe: its strict decoder refuses
108// them by name. It takes /new's optional region and timezone fields, read
109// the same way, and echoes the stored region the way /new does.
110func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request) {
111 if !s.cfg.TemporaryApps {
112 http.NotFound(w, r)
113 return
114 }
115 p, acct, ok := s.apiPrincipalAccount(w, r)
116 if !ok {
117 return
118 }
119 // An app-scoped credential claiming a new name is account authority it
120 // was not given: the byte-identical 404, exactly as /new (apicli.go).
121 if p.Kind == PrincipalService && p.App != "" {
122 http.NotFound(w, r)
123 return
124 }
125 addr := auditActor(p, acct)
126 now := time.Now()
127 if !s.allowTemporaryCreate(p, now) {
128 jsonError(w, http.StatusTooManyRequests,
129 "app creation limit reached for today. Try again tomorrow, or deploy to an app you already have")
130 return
131 }
132 var req struct {
133 Name string `json:"name"`
134 TTLSeconds int64 `json:"ttl_seconds"`
135 Region string `json:"region"`
136 Timezone string `json:"timezone"`
137 }
138 if !readJSONStrictLimit(w, r, &req, 8<<10) {
139 return
140 }
141 name := normalizeAppName(req.Name)
142 if !validTemporaryName(name) {
143 jsonError(w, http.StatusBadRequest,
144 "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit")
145 return
146 }
147 ttl, err := temporaryTTL(req.TTLSeconds, true)
148 if err != nil {
149 jsonError(w, http.StatusBadRequest, err.Error())
150 return
151 }
152 if s.cfg.Releases == nil {
153 jsonError(w, http.StatusServiceUnavailable,
154 "this console has no deployment bucket, so it cannot create a temporary app")
155 return
156 }
157 // The same optional region fields /new takes, read the same way.
158 rq, ref := s.newAppRegionReq(r, req.Region, req.Timezone)
159 if ref != nil {
160 jsonError(w, ref.Status, ref.Msg)
161 return
162 }
163 defer s.lockAccount(acct.Sqid)()
164 if err := s.temporaryCapCheck(acct.ID); err != nil {
165 var capErr *temporaryCapError
166 if errors.As(err, &capErr) {
167 writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"})
168 return
169 }
170 log.Printf("console: temporary create %s: count: %v", acct.Sqid, err)
171 jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again")
172 return
173 }
174 // A temporary app is a running thing too (plans 3a); the account lock is
175 // already held.
176 if err := s.checkRunningThings(r.Context(), acct, 1); err != nil {
177 if isPlanCap(err) {
178 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
179 return
180 }
181 log.Printf("console: temporary create %s: plan cap: %v", acct.Sqid, err)
182 jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again")
183 return
184 }
185 switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); {
186 case err != nil:
187 log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err)
188 jsonError(w, http.StatusBadGateway, "could not check the routes for that name, so nothing was created. Try again")
189 return
190 case wired:
191 writeJSON(w, http.StatusConflict, map[string]any{
192 "error": "a route in this account already serves " + name + ", so it cannot be a temporary app. Choose another name",
193 "code": "name_taken",
194 })
195 return
196 }
197 outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl))
198 switch outcome {
199 case claimOK:
200 case claimInvalid:
201 jsonError(w, http.StatusBadRequest,
202 "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit")
203 return
204 case claimReserved:
205 writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is reserved", "code": "name_taken"})
206 return
207 case claimTaken:
208 writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is taken", "code": "name_taken"})
209 return
210 case claimApexHeld:
211 writeJSON(w, http.StatusConflict, map[string]any{
212 "error": "that name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held),
213 "code": "name_taken",
214 })
215 return
216 case claimStoreFailed:
217 jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again")
218 return
219 default:
220 jsonError(w, http.StatusInternalServerError, "could not create the app. Try again")
221 return
222 }
223 life, err := s.store.AppLife(acct.ID, name)
224 if err != nil || life == nil || !life.Temporary() {
225 log.Printf("console: temporary create %s/%s: read back expiry: %v %+v", acct.Sqid, name, err, life)
226 jsonError(w, http.StatusInternalServerError,
227 "the app was created but its expiry could not be read back. Check carlos apps list before you ship to it")
228 return
229 }
230 expires := time.Unix(life.ExpiresAt, 0).UTC()
231 s.audit(acct, name, addr, "create", "created temporary app "+name+", expiring "+expires.Format("2006-01-02 15:04 UTC")+" (cli)")
232 out := map[string]any{
233 "account": acct.Sqid,
234 "app": name,
235 "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
236 "expires_at": expires.Format(time.RFC3339),
237 }
238 s.echoCreatedRegion(r.Context(), acct, name, out)
239 writeJSON(w, http.StatusOK, out)
240}
241
242var (
243 errAppGone = errors.New("console: no such app")
244 errTemporaryInTrash = errors.New("console: the app is in the trash")
245 errNotTemporary = errors.New("console: the app is permanent")
246)
247
248// temporaryLive takes the app's lock, settles, and returns the row if it is
249// a live temporary app. The caller runs its update under the same lock.
250// Settling first is what makes a keep after a failed expiry step 4 find the
251// app in the Trash and refuse ("restore it first"), rather than succeed on a
252// row the bucket has already trashed (spec §5).
253func (s *Server) temporaryLive(ctx context.Context, acct *Account, app string, now time.Time) (*AppLife, error) {
254 if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil {
255 return nil, err
256 }
257 life, err := s.store.AppLife(acct.ID, app)
258 switch {
259 case err != nil:
260 return nil, err
261 case life == nil:
262 return nil, errAppGone
263 case life.Trashed() || life.Restoring:
264 return nil, errTemporaryInTrash
265 case !life.Temporary():
266 return nil, errNotTemporary
267 }
268 return life, nil
269}
270
271// keepApp is `carlos apps keep` and the Keep button: expires_at = 0.
272func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error {
273 defer s.lockApp(acct.Sqid, app)()
274 if _, err := s.temporaryLive(ctx, acct, app, now); err != nil {
275 return err
276 }
277 ok, err := s.store.KeepAppContext(ctx, acct.ID, app)
278 if err != nil {
279 return err
280 }
281 if !ok {
282 return errTemporaryInTrash
283 }
284 return nil
285}
286
287// setAppTTL is `carlos apps ttl`: expires_at = now + ttl, live temporary
288// apps only (spec §7). It never sets an expiry on a permanent row.
289func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error) {
290 defer s.lockApp(acct.Sqid, app)()
291 if _, err := s.temporaryLive(ctx, acct, app, now); err != nil {
292 return time.Time{}, err
293 }
294 exp := now.Add(ttl).Truncate(time.Second)
295 ok, err := s.store.SetAppExpiryContext(ctx, acct.ID, app, exp)
296 if err != nil {
297 return time.Time{}, err
298 }
299 if !ok {
300 return time.Time{}, errTemporaryInTrash
301 }
302 return exp, nil
303}
304
305// lifecycleRefusal maps keepApp/setAppTTL's errors onto the JSON answer.
306// It reports whether it wrote one.
307func lifecycleRefusal(w http.ResponseWriter, r *http.Request, app string, err error) bool {
308 switch {
309 case err == nil:
310 return false
311 case errors.Is(err, errAppGone):
312 http.NotFound(w, r)
313 case errors.Is(err, errTemporaryInTrash):
314 jsonError(w, http.StatusConflict, app+" is in the Trash. Restore it first with: carlos apps restore "+app)
315 case errors.Is(err, errNotTemporary):
316 jsonError(w, http.StatusConflict, app+" is permanent. It has no expiry to move or keep")
317 default:
318 log.Printf("console: lifecycle %s: %v", app, err)
319 jsonError(w, http.StatusInternalServerError, "could not change "+app+". Nothing was changed. Try again")
320 }
321 return true
322}
323
324func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request) {
325 addr, acct, app, ok := s.apiAccountApp(w, r)
326 if !ok {
327 return
328 }
329 p, _ := principalFrom(r.Context())
330 if !s.allowWrite(p, time.Now()) {
331 jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again")
332 return
333 }
334 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
335 defer cancel()
336 if lifecycleRefusal(w, r, app, s.keepApp(ctx, acct, app, time.Now())) {
337 return
338 }
339 s.audit(acct, app, addr, "keep", "made "+app+" permanent (cli)")
340 writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": nil})
341}
342
343func (s *Server) handleAPIAppTTL(w http.ResponseWriter, r *http.Request) {
344 addr, acct, app, ok := s.apiAccountApp(w, r)
345 if !ok {
346 return
347 }
348 p, _ := principalFrom(r.Context())
349 if !s.allowWrite(p, time.Now()) {
350 jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again")
351 return
352 }
353 var req struct {
354 TTLSeconds int64 `json:"ttl_seconds"`
355 }
356 if !readJSONStrictLimit(w, r, &req, 8<<10) {
357 return
358 }
359 ttl, err := temporaryTTL(req.TTLSeconds, false)
360 if err != nil {
361 jsonError(w, http.StatusBadRequest, err.Error())
362 return
363 }
364 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
365 defer cancel()
366 exp, err := s.setAppTTL(ctx, acct, app, ttl, time.Now())
367 if lifecycleRefusal(w, r, app, err) {
368 return
369 }
370 s.audit(acct, app, addr, "ttl", "set "+app+" to expire "+exp.UTC().Format("2006-01-02 15:04 UTC")+" (cli)")
371 writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": exp.UTC().Format(time.RFC3339)})
372}
373
374// handleAPIAppList is GET /api/cli/apps/{account}/list (spec §7): every app
375// the caller can see in the account, trashed included, with its lifecycle
376// times. New rather than a change to GET /api/cli/apps, whose "apps" field is
377// a []string that clients decode as such. An app-scoped service sees its own
378// app only, the filter handleAPIApps applies (apicli.go:532).
379func (s *Server) handleAPIAppList(w http.ResponseWriter, r *http.Request) {
380 p, acct, ok := s.apiPrincipalAccount(w, r)
381 if !ok {
382 return
383 }
384 lives, err := s.store.AppLivesIn(acct.ID)
385 if err != nil {
386 log.Printf("console: api list %s: %v", acct.Sqid, err)
387 jsonError(w, http.StatusInternalServerError, "could not load apps")
388 return
389 }
390 stamp := func(unix int64) any {
391 if unix == 0 {
392 return nil
393 }
394 return time.Unix(unix, 0).UTC().Format(time.RFC3339)
395 }
396 out := make([]map[string]any, 0, len(lives))
397 for _, l := range lives {
398 if p.Kind == PrincipalService && p.App != "" && l.App != p.App {
399 continue
400 }
401 entry := map[string]any{"app": l.App, "deleted_at": stamp(l.DeletedAt), "expires_at": stamp(l.ExpiresAt), "purge_after": nil}
402 if l.Trashed() {
403 entry["purge_after"] = effectivePurgeAt(l.DeletedAt, l.PurgeAfter).Format(time.RFC3339)
404 }
405 out = append(out, entry)
406 }
407 writeJSON(w, http.StatusOK, out)
408}
409
410// handleAppKeep is the app page's Keep button (spec §7): any member, CSRF,
411// no passkey, because keep only makes the app safer.
412func (s *Server) handleAppKeep(w http.ResponseWriter, r *http.Request) {
413 addr, raw, acct, _, app, ok := s.accountApp(w, r)
414 if !ok {
415 return
416 }
417 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
418 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
419 return
420 }
421 back := func(kind, msg string) {
422 s.setFlash(w, kind, msg)
423 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
424 }
425 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
426 defer cancel()
427 switch err := s.keepApp(ctx, acct, app, time.Now()); {
428 case err == nil:
429 case errors.Is(err, errNotTemporary):
430 back("ok", app+" is already permanent.")
431 return
432 case errors.Is(err, errTemporaryInTrash), errors.Is(err, errAppGone):
433 back("err", app+" is in the Trash. Restore it from the Trash page first.")
434 return
435 default:
436 log.Printf("console: keep %s/%s: %v", acct.Sqid, app, err)
437 back("err", "Could not keep "+app+". Nothing was changed. Try again.")
438 return
439 }
440 s.audit(acct, app, addr, "keep", "made "+app+" permanent")
441 back("ok", app+" is permanent now. It will not expire.")
442}
443