| 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. |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 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). |
| 24 | func 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". |
| 34 | func 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. |
| 49 | const ( |
| 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 | |
| 57 | 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 |
| 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. |
| 62 | func 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 | |
| 83 | const temporaryLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" |
| 84 | |
| 85 | // randomTemporaryLabel is six random lowercase letters and digits (spec §7). |
| 86 | func 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. |
| 102 | func 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. |
| 124 | func 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). |
| 153 | func 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. |
| 179 | func 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. |
| 194 | func 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. |
| 210 | func 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. |
| 225 | func 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 | |
| 235 | func 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 | |
| 264 | func 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 | |
| 282 | func 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 | |