carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// temporary.go: the CLI side of temporary apps (temporary static sites spec
2// §7) and the one relative-time format every lifecycle line prints.
3package main
4
5import (
6 "context"
7 "crypto/rand"
8 "errors"
9 "flag"
10 "fmt"
11 "log"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// restoreHint is what `carlos apps delete` says about getting the app back.
20// carlos add refuses every tmp- name (appRail, cli.go), kept ones included,
21// so a tmp- app is told what actually happens: its instance records are kept
22// through the trash, and hosts mint its routes again once it is restored
23// (internal/provision/reconcile.go:903).
24func restoreHint(app string) string {
25 if release.IsTemporaryName(app) {
26 return "restoring brings back its releases and channels, and hosts bring its routes back from its instance records"
27 }
28 return "restoring brings back its releases and channels, but no routes. carlos add <host> serves it again"
29}
30
31// inDuration says how far away d is, in the largest whole unit: "in 3d",
32// "in 5h", "in 12m". Floors, so it never promises more time than there is.
33// Anything already due is "soon".
34func inDuration(d time.Duration) string {
35 switch {
36 case d <= 0:
37 return "soon"
38 case d >= 24*time.Hour:
39 return fmt.Sprintf("in %dd", int(d/(24*time.Hour)))
40 case d >= time.Hour:
41 return fmt.Sprintf("in %dh", int(d/time.Hour))
42 default:
43 return fmt.Sprintf("in %dm", max(int(d/time.Minute), 1))
44 }
45}
46
47// The bounds match the console's (temporary static sites spec ruling 3).
48// Checked here too so a typo costs no create budget.
49const (
50 ttlFloor = time.Hour
51 ttlCeiling = 30 * 24 * time.Hour
52 ttlDefault = 7 * 24 * time.Hour
53 // temporaryNameAttempts bounds retries of a GENERATED name on "taken".
54 temporaryNameAttempts = 5
55)
56
57var errTTL = errors.New("a temporary app lives from 1h to 30d. Give a whole number of days like 3d, or a duration like 36h or 90m") // C11.1
58
59// parseTTL reads --ttl and `carlos apps ttl`'s argument: Go durations plus
60// whole days ("3d"). Days are bounded before they are multiplied, so no
61// input overflows.
62func parseTTL(s string) (time.Duration, error) {
63 s = strings.ToLower(strings.TrimSpace(s))
64 var d time.Duration
65 if n, ok := strings.CutSuffix(s, "d"); ok {
66 days, err := strconv.Atoi(n)
67 if err != nil || days < 0 || days > int(ttlCeiling/(24*time.Hour)) {
68 return 0, errTTL
69 }
70 d = time.Duration(days) * 24 * time.Hour
71 } else {
72 var err error
73 if d, err = time.ParseDuration(s); err != nil {
74 return 0, errTTL
75 }
76 }
77 if d < ttlFloor || d > ttlCeiling {
78 return 0, errTTL
79 }
80 return d, nil
81}
82
83const temporaryLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
84
85// randomTemporaryLabel is six random lowercase letters and digits (spec §7).
86func randomTemporaryLabel() string {
87 b := make([]byte, 6)
88 if _, err := rand.Read(b); err != nil {
89 log.Fatalf("deploy: no randomness for a temporary app name: %v", err)
90 }
91 for i := range b {
92 b[i] = temporaryLabelAlphabet[int(b[i])%len(temporaryLabelAlphabet)]
93 }
94 return string(b)
95}
96
97// temporaryAppName is spec §7's naming: no --app gives tmp- plus a random
98// label; --app acme-docs gives tmp-acme-docs; an --app already starting tmp-
99// is used as given. The result must be a valid app name and a DNS label (it
100// becomes the first label of the platform host), so a trailing hyphen or a
101// name past 32 characters is refused here, before any request.
102func temporaryAppName(app string, random func() string) (string, error) {
103 name := strings.ToLower(strings.TrimSpace(app))
104 switch {
105 case name == "":
106 name = release.TemporaryAppPrefix + random()
107 case !release.IsTemporaryName(name):
108 name = release.TemporaryAppPrefix + name
109 }
110 if name == release.TemporaryAppPrefix || !release.ValidApp(name) || strings.HasSuffix(name, "-") {
111 return "", fmt.Errorf("%q cannot name a temporary app: it would be %s, and a temporary app's name is tmp- followed by "+
112 "lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit", app, name) // C11.2
113 }
114 return name, nil
115}
116
117// temporaryDeployFlags applies spec §7's rules to deploy's flags: --ttl alone
118// implies --temporary, and --host, --kind binary and a missing site
119// directory are refused locally, before anything is created.
120//
121// ttlSet is whether --ttl was GIVEN (flagWasSet), not whether its value is
122// blank: `--ttl ""` is a TTL the caller asked for and got wrong, and must be
123// refused, never read as "no --ttl" and turned into an ordinary deploy.
124func temporaryDeployFlags(temporary, ttlSet bool, ttlRaw, host string, kindSet bool, kind string, nargs int) (bool, time.Duration, error) {
125 if ttlSet {
126 temporary = true
127 }
128 if !temporary {
129 return false, 0, nil
130 }
131 ttl := ttlDefault
132 if ttlSet {
133 d, err := parseTTL(ttlRaw)
134 if err != nil {
135 return false, 0, fmt.Errorf("--ttl %q: %w", ttlRaw, err)
136 }
137 ttl = d
138 }
139 switch {
140 case host != "":
141 return false, 0, errors.New("--temporary cannot be used with --host. A temporary app is served only at its own platform address") // C11.3
142 case kindSet && kind != "static":
143 return false, 0, errors.New("--temporary deploys a static site, so it cannot be used with --kind binary") // C11.4
144 case nargs == 0:
145 return false, 0, errors.New("--temporary needs the site directory to deploy, for example: carlos deploy --temporary ./dist") // C11.5
146 }
147 return true, ttl, nil
148}
149
150// createTemporary creates the app through /new-temporary, retrying a
151// GENERATED name that is taken. A name the caller chose is tried once.
152// region and timezone go to the console as /new's are (createApp).
153func createTemporary(ctx context.Context, sess *apiSession, acct apiAcct, appFlag string, ttl time.Duration, region, timezone string, random func() string) (apiCreateTemporaryResult, error) {
154 attempts := 1
155 if strings.TrimSpace(appFlag) == "" {
156 attempts = temporaryNameAttempts
157 }
158 for i := 0; i < attempts; i++ {
159 name, err := temporaryAppName(appFlag, random)
160 if err != nil {
161 return apiCreateTemporaryResult{}, err
162 }
163 res, err := sess.createTemporaryApp(ctx, acct, name, ttl, region, timezone)
164 if errors.Is(err, errTemporaryNameTaken) && attempts > 1 {
165 continue
166 }
167 return res, err
168 }
169 // The approved text alone (review round 1, finding 3): the per-attempt
170 // console reason already had its say on every retry along the way, and
171 // appending the LAST one here again would be unapproved text stapled
172 // onto C11.6's copy.
173 return apiCreateTemporaryResult{}, fmt.Errorf("%d generated names in a row were taken. Try again, or name the app with --app", attempts) // C11.6
174}
175
176// printTemporaryExpiry is spec §7's "carlos deploy to an existing temporary
177// app: expiry unchanged, printed". Best effort: a console without /list says
178// nothing rather than something it cannot back up.
179func printTemporaryExpiry(ctx context.Context, sess *apiSession, acct apiAcct, app string) {
180 lives, err := sess.appList(ctx, acct)
181 if err != nil {
182 return
183 }
184 for _, l := range lives {
185 if l.App == app && l.ExpiresAt != nil && l.DeletedAt == nil {
186 mutedf(stdout, "%s is temporary. It moves to the Trash %s, at %s. This deploy does not change that", // C11.7
187 app, inDuration(time.Until(*l.ExpiresAt)), l.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC"))
188 }
189 }
190}
191
192// appLifeLine is one line of `carlos apps list` (spec §7): name, live or
193// trashed, and "expires in 3d", "purged in 2d" or nothing.
194func appLifeLine(l apiAppLife, now time.Time) string {
195 state, note := "live", "" // C11.8
196 switch {
197 case l.DeletedAt != nil:
198 state = "trashed" // C11.8
199 if l.PurgeAfter != nil {
200 note = "purged " + inDuration(l.PurgeAfter.Sub(now)) // C11.8
201 }
202 case l.ExpiresAt != nil:
203 note = "expires " + inDuration(l.ExpiresAt.Sub(now)) // C11.8
204 }
205 return strings.TrimRight(fmt.Sprintf("%-32s %-7s %s", l.App, state, note), " ")
206}
207
208// requireSignedInApps refuses direct mode by name for the apps verbs that
209// only the console can answer.
210func requireSignedInApps(verb, consoleFlag string) *apiSession {
211 sess, err := resolveMode(consoleFlag)
212 if err != nil {
213 log.Fatal(err)
214 }
215 if sess == nil {
216 log.Fatalf("carlos apps %s works through a console, and this shell is in direct bucket mode "+
217 "(CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Temporary apps are recorded by the console, "+
218 "which direct mode never reads. Unset the store variables and run: carlos auth login --console <url>", verb) // C11.9
219 }
220 return sess
221}
222
223// appsLifecycleFlags is the flag set the three verbs share. Not accountFlags:
224// that also registers --registry for direct mode, which these never use.
225func appsLifecycleFlags(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) {
226 fs = flag.NewFlagSet(name, flag.ExitOnError)
227 app = appFlag(fs, "app name")
228 acctRaw = fs.String("account", "", "account (sqid or name) the app is in (default: your only account, or the configured/chosen one)")
229 acctIDRaw = fs.String("account-id", "", "account sqid, matched as a sqid only; supersedes --account")
230 consoleFlag = consoleOption(fs, "console to use (default: the only logged-in one)")
231 fs.Usage = flagUsage(fs, "")
232 return fs, app, acctRaw, acctIDRaw, consoleFlag
233}
234
235func runAppsTTL(args []string) {
236 fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps ttl")
237 // parseInterspersed, not fs.Parse: the usage line puts the positional TTL
238 // before any flag, but also shows flags after it ("--app <name> <ttl>
239 // [--account ...]") — plain fs.Parse stops at the first non-flag word, so
240 // a flag placed after the TTL is left unparsed and miscounted as a second
241 // positional (review round 1, finding 2).
242 pos, err := parseInterspersed(fs, args)
243 if err != nil {
244 log.Fatal(err)
245 }
246 if strings.TrimSpace(*app) == "" || len(pos) != 1 {
247 log.Fatal("usage: carlos apps ttl --app <name> <1h to 30d, e.g. 5d> [--account <sqid|name>] [--console <origin>]") // C11.10
248 }
249 ttl, err := parseTTL(pos[0])
250 if err != nil {
251 log.Fatalf("apps ttl: %v", err)
252 }
253 sess := requireSignedInApps("ttl", *consoleFlag)
254 printUsing(stdout, sess)
255 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
256 exp, err := sess.appTTL(context.Background(), acct, *app, ttl)
257 if err != nil {
258 log.Fatalf("apps ttl: %v", err)
259 }
260 fmt.Fprintf(stdout, "%s %s moves to the Trash %s, at %s\n", colorFor(stdout).ok("expiry set"), // C11.11
261 *app, inDuration(time.Until(exp)), exp.UTC().Format("2006-01-02 15:04 UTC"))
262}
263
264func runAppsKeep(args []string) {
265 fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps keep")
266 pos, err := parseInterspersed(fs, args)
267 if err != nil {
268 log.Fatal(err)
269 }
270 if strings.TrimSpace(*app) == "" || len(pos) != 0 {
271 log.Fatal("usage: carlos apps keep --app <name> [--account <sqid|name>] [--console <origin>]") // C11.12
272 }
273 sess := requireSignedInApps("keep", *consoleFlag)
274 printUsing(stdout, sess)
275 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
276 if err := sess.appKeep(context.Background(), acct, *app); err != nil {
277 log.Fatalf("apps keep: %v", err)
278 }
279 fmt.Fprintf(stdout, "%s %s is permanent now. It keeps its name\n", colorFor(stdout).ok("kept"), *app) // C11.13
280}
281
282func runAppsList(args []string) {
283 fs, _, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps list")
284 pos, err := parseInterspersed(fs, args)
285 if err != nil {
286 log.Fatal(err)
287 }
288 if len(pos) != 0 {
289 log.Fatal("usage: carlos apps list [--account <sqid|name>] [--console <origin>]") // C11.14
290 }
291 sess := requireSignedInApps("list", *consoleFlag)
292 printUsing(stdout, sess)
293 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
294 lives, err := sess.appList(context.Background(), acct)
295 if err != nil {
296 log.Fatalf("apps list: %v", err)
297 }
298 if len(lives) == 0 {
299 mutedf(stdout, "no apps in this account") // C11.15
300 return
301 }
302 now := time.Now()
303 for _, l := range lives {
304 fmt.Fprintln(stdout, appLifeLine(l, now))
305 }
306}
307