// temporary.go: the CLI side of temporary apps (temporary static sites spec // §7) and the one relative-time format every lifecycle line prints. package main import ( "context" "crypto/rand" "errors" "flag" "fmt" "log" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // restoreHint is what `carlos apps delete` says about getting the app back. // carlos add refuses every tmp- name (appRail, cli.go), kept ones included, // so a tmp- app is told what actually happens: its instance records are kept // through the trash, and hosts mint its routes again once it is restored // (internal/provision/reconcile.go:903). func restoreHint(app string) string { if release.IsTemporaryName(app) { return "restoring brings back its releases and channels, and hosts bring its routes back from its instance records" } return "restoring brings back its releases and channels, but no routes. carlos add serves it again" } // inDuration says how far away d is, in the largest whole unit: "in 3d", // "in 5h", "in 12m". Floors, so it never promises more time than there is. // Anything already due is "soon". func inDuration(d time.Duration) string { switch { case d <= 0: return "soon" case d >= 24*time.Hour: return fmt.Sprintf("in %dd", int(d/(24*time.Hour))) case d >= time.Hour: return fmt.Sprintf("in %dh", int(d/time.Hour)) default: return fmt.Sprintf("in %dm", max(int(d/time.Minute), 1)) } } // The bounds match the console's (temporary static sites spec ruling 3). // Checked here too so a typo costs no create budget. const ( ttlFloor = time.Hour ttlCeiling = 30 * 24 * time.Hour ttlDefault = 7 * 24 * time.Hour // temporaryNameAttempts bounds retries of a GENERATED name on "taken". temporaryNameAttempts = 5 ) var 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 // parseTTL reads --ttl and `carlos apps ttl`'s argument: Go durations plus // whole days ("3d"). Days are bounded before they are multiplied, so no // input overflows. func parseTTL(s string) (time.Duration, error) { s = strings.ToLower(strings.TrimSpace(s)) var d time.Duration if n, ok := strings.CutSuffix(s, "d"); ok { days, err := strconv.Atoi(n) if err != nil || days < 0 || days > int(ttlCeiling/(24*time.Hour)) { return 0, errTTL } d = time.Duration(days) * 24 * time.Hour } else { var err error if d, err = time.ParseDuration(s); err != nil { return 0, errTTL } } if d < ttlFloor || d > ttlCeiling { return 0, errTTL } return d, nil } const temporaryLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" // randomTemporaryLabel is six random lowercase letters and digits (spec §7). func randomTemporaryLabel() string { b := make([]byte, 6) if _, err := rand.Read(b); err != nil { log.Fatalf("deploy: no randomness for a temporary app name: %v", err) } for i := range b { b[i] = temporaryLabelAlphabet[int(b[i])%len(temporaryLabelAlphabet)] } return string(b) } // temporaryAppName is spec §7's naming: no --app gives tmp- plus a random // label; --app acme-docs gives tmp-acme-docs; an --app already starting tmp- // is used as given. The result must be a valid app name and a DNS label (it // becomes the first label of the platform host), so a trailing hyphen or a // name past 32 characters is refused here, before any request. func temporaryAppName(app string, random func() string) (string, error) { name := strings.ToLower(strings.TrimSpace(app)) switch { case name == "": name = release.TemporaryAppPrefix + random() case !release.IsTemporaryName(name): name = release.TemporaryAppPrefix + name } if name == release.TemporaryAppPrefix || !release.ValidApp(name) || strings.HasSuffix(name, "-") { return "", fmt.Errorf("%q cannot name a temporary app: it would be %s, and a temporary app's name is tmp- followed by "+ "lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit", app, name) // C11.2 } return name, nil } // temporaryDeployFlags applies spec §7's rules to deploy's flags: --ttl alone // implies --temporary, and --host, --kind binary and a missing site // directory are refused locally, before anything is created. // // ttlSet is whether --ttl was GIVEN (flagWasSet), not whether its value is // blank: `--ttl ""` is a TTL the caller asked for and got wrong, and must be // refused, never read as "no --ttl" and turned into an ordinary deploy. func temporaryDeployFlags(temporary, ttlSet bool, ttlRaw, host string, kindSet bool, kind string, nargs int) (bool, time.Duration, error) { if ttlSet { temporary = true } if !temporary { return false, 0, nil } ttl := ttlDefault if ttlSet { d, err := parseTTL(ttlRaw) if err != nil { return false, 0, fmt.Errorf("--ttl %q: %w", ttlRaw, err) } ttl = d } switch { case host != "": return false, 0, errors.New("--temporary cannot be used with --host. A temporary app is served only at its own platform address") // C11.3 case kindSet && kind != "static": return false, 0, errors.New("--temporary deploys a static site, so it cannot be used with --kind binary") // C11.4 case nargs == 0: return false, 0, errors.New("--temporary needs the site directory to deploy, for example: carlos deploy --temporary ./dist") // C11.5 } return true, ttl, nil } // createTemporary creates the app through /new-temporary, retrying a // GENERATED name that is taken. A name the caller chose is tried once. // region and timezone go to the console as /new's are (createApp). func createTemporary(ctx context.Context, sess *apiSession, acct apiAcct, appFlag string, ttl time.Duration, region, timezone string, random func() string) (apiCreateTemporaryResult, error) { attempts := 1 if strings.TrimSpace(appFlag) == "" { attempts = temporaryNameAttempts } for i := 0; i < attempts; i++ { name, err := temporaryAppName(appFlag, random) if err != nil { return apiCreateTemporaryResult{}, err } res, err := sess.createTemporaryApp(ctx, acct, name, ttl, region, timezone) if errors.Is(err, errTemporaryNameTaken) && attempts > 1 { continue } return res, err } // The approved text alone (review round 1, finding 3): the per-attempt // console reason already had its say on every retry along the way, and // appending the LAST one here again would be unapproved text stapled // onto C11.6's copy. return apiCreateTemporaryResult{}, fmt.Errorf("%d generated names in a row were taken. Try again, or name the app with --app", attempts) // C11.6 } // printTemporaryExpiry is spec §7's "carlos deploy to an existing temporary // app: expiry unchanged, printed". Best effort: a console without /list says // nothing rather than something it cannot back up. func printTemporaryExpiry(ctx context.Context, sess *apiSession, acct apiAcct, app string) { lives, err := sess.appList(ctx, acct) if err != nil { return } for _, l := range lives { if l.App == app && l.ExpiresAt != nil && l.DeletedAt == nil { mutedf(stdout, "%s is temporary. It moves to the Trash %s, at %s. This deploy does not change that", // C11.7 app, inDuration(time.Until(*l.ExpiresAt)), l.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")) } } } // appLifeLine is one line of `carlos apps list` (spec §7): name, live or // trashed, and "expires in 3d", "purged in 2d" or nothing. func appLifeLine(l apiAppLife, now time.Time) string { state, note := "live", "" // C11.8 switch { case l.DeletedAt != nil: state = "trashed" // C11.8 if l.PurgeAfter != nil { note = "purged " + inDuration(l.PurgeAfter.Sub(now)) // C11.8 } case l.ExpiresAt != nil: note = "expires " + inDuration(l.ExpiresAt.Sub(now)) // C11.8 } return strings.TrimRight(fmt.Sprintf("%-32s %-7s %s", l.App, state, note), " ") } // requireSignedInApps refuses direct mode by name for the apps verbs that // only the console can answer. func requireSignedInApps(verb, consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatalf("carlos apps %s works through a console, and this shell is in direct bucket mode "+ "(CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Temporary apps are recorded by the console, "+ "which direct mode never reads. Unset the store variables and run: carlos auth login --console ", verb) // C11.9 } return sess } // appsLifecycleFlags is the flag set the three verbs share. Not accountFlags: // that also registers --registry for direct mode, which these never use. func appsLifecycleFlags(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) { fs = flag.NewFlagSet(name, flag.ExitOnError) app = appFlag(fs, "app name") acctRaw = fs.String("account", "", "account (sqid or name) the app is in (default: your only account, or the configured/chosen one)") acctIDRaw = fs.String("account-id", "", "account sqid, matched as a sqid only; supersedes --account") consoleFlag = consoleOption(fs, "console to use (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") return fs, app, acctRaw, acctIDRaw, consoleFlag } func runAppsTTL(args []string) { fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps ttl") // parseInterspersed, not fs.Parse: the usage line puts the positional TTL // before any flag, but also shows flags after it ("--app // [--account ...]") — plain fs.Parse stops at the first non-flag word, so // a flag placed after the TTL is left unparsed and miscounted as a second // positional (review round 1, finding 2). pos, err := parseInterspersed(fs, args) if err != nil { log.Fatal(err) } if strings.TrimSpace(*app) == "" || len(pos) != 1 { log.Fatal("usage: carlos apps ttl --app <1h to 30d, e.g. 5d> [--account ] [--console ]") // C11.10 } ttl, err := parseTTL(pos[0]) if err != nil { log.Fatalf("apps ttl: %v", err) } sess := requireSignedInApps("ttl", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) exp, err := sess.appTTL(context.Background(), acct, *app, ttl) if err != nil { log.Fatalf("apps ttl: %v", err) } fmt.Fprintf(stdout, "%s %s moves to the Trash %s, at %s\n", colorFor(stdout).ok("expiry set"), // C11.11 *app, inDuration(time.Until(exp)), exp.UTC().Format("2006-01-02 15:04 UTC")) } func runAppsKeep(args []string) { fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps keep") pos, err := parseInterspersed(fs, args) if err != nil { log.Fatal(err) } if strings.TrimSpace(*app) == "" || len(pos) != 0 { log.Fatal("usage: carlos apps keep --app [--account ] [--console ]") // C11.12 } sess := requireSignedInApps("keep", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) if err := sess.appKeep(context.Background(), acct, *app); err != nil { log.Fatalf("apps keep: %v", err) } fmt.Fprintf(stdout, "%s %s is permanent now. It keeps its name\n", colorFor(stdout).ok("kept"), *app) // C11.13 } func runAppsList(args []string) { fs, _, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps list") pos, err := parseInterspersed(fs, args) if err != nil { log.Fatal(err) } if len(pos) != 0 { log.Fatal("usage: carlos apps list [--account ] [--console ]") // C11.14 } sess := requireSignedInApps("list", *consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) lives, err := sess.appList(context.Background(), acct) if err != nil { log.Fatalf("apps list: %v", err) } if len(lives) == 0 { mutedf(stdout, "no apps in this account") // C11.15 return } now := time.Now() for _, l := range lives { fmt.Fprintln(stdout, appLifeLine(l, now)) } }