| 1 | // The `carlos fleets` verbs: mint an account-owned fleet, add/rotate/detach |
| 2 | // its boxes, and list what an account owns. A fleet is a named group of |
| 3 | // remote boxes (customer-fleets plan) that dial this console over a channel |
| 4 | // identified by "<fleet>/<label>" — console-tracked state |
| 5 | // (internal/registry's fleets/fleet_boxes tables), reached only through the |
| 6 | // console's API doors (internal/console/fleets.go). There is no |
| 7 | // bucket-direct fallback, the same posture `carlos apps create` takes: a |
| 8 | // fleet cannot exist without a console to claim its name and mint its |
| 9 | // boxes' tokens. |
| 10 | // |
| 11 | // `carlos apps place` — which repoints an EXISTING app onto a fleet, and |
| 12 | // apps create's --place flag, which does the same at create time — live in |
| 13 | // apps.go, not here: their subject is an app, and a fleet name is only ever |
| 14 | // data in their request body (mirroring where the console itself put the |
| 15 | // /place route — see fleets.go's package doc there). |
| 16 | package main |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "flag" |
| 21 | "fmt" |
| 22 | "log" |
| 23 | "os" |
| 24 | "strings" |
| 25 | "text/tabwriter" |
| 26 | |
| 27 | "github.com/carlosframework/platform/internal/registry" |
| 28 | ) |
| 29 | |
| 30 | const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|tag-box|detach-box|ls> [flags] |
| 31 | |
| 32 | create --bucket <bucket> [--account <sqid|name>] <name> |
| 33 | mint an account-owned fleet: a named group of remote boxes |
| 34 | that dial this console over a channel identified by |
| 35 | "<fleet>/<label>". <bucket> is the customer's OWN data |
| 36 | bucket, taken on trust that it already exists. The fleets |
| 37 | namespace is flat and global across every account — a name |
| 38 | once taken (by ANY account) cannot be reused, so the "that |
| 39 | name is taken" refusal on a retry does not necessarily mean |
| 40 | YOU took it. |
| 41 | |
| 42 | add-box [--account <sqid|name>] <fleet> <label> |
| 43 | register one box to a fleet and mint its bearer token. THE |
| 44 | TOKEN IS PRINTED EXACTLY ONCE, HERE — the console never |
| 45 | stores or re-shows the plaintext, so losing this output |
| 46 | means rotate-token, not a re-run of add-box (which just |
| 47 | 409s on the label). Put it in the box's credential store |
| 48 | right away. |
| 49 | ROTATION IS NOT REVOCATION: rotating a token later (below) |
| 50 | refuses the box's NEXT dial-in with the OLD token but does |
| 51 | NOT evict a channel it already holds — a box whose |
| 52 | credential may be COMPROMISED needs detach-box, not |
| 53 | rotate-token. |
| 54 | |
| 55 | rotate-token [--account <sqid|name>] <fleet> <label> |
| 56 | mint a FRESH token for a box that already exists, printed |
| 57 | exactly once, same as add-box. THIS REFUSES THE BOX'S NEXT |
| 58 | DIAL-IN with the old token, but does NOT evict a channel it |
| 59 | is already connected on — a box whose credential may be |
| 60 | COMPROMISED needs detach-box, not this. |
| 61 | |
| 62 | tag-box --tags <tags> [--account <sqid|name>] <fleet> <label> |
| 63 | replace a box's tags IN FULL: comma-separated segments of |
| 64 | 1-24 lowercase a-z/0-9/hyphen characters each (hyphens |
| 65 | interior only, never leading or trailing), max 8 tags. |
| 66 | --tags "" clears every tag. This is a full replace, not a |
| 67 | merge — the tags sent are exactly the tags that land. |
| 68 | |
| 69 | detach-box [--account <sqid|name>] <fleet> <label> |
| 70 | the hard revocation rotate-token is not: flips the box to |
| 71 | "detached", which refuses its next dial-in AND closes any |
| 72 | channel it already holds. This is what a compromised box |
| 73 | needs. |
| 74 | |
| 75 | ls [--account <sqid|name>] |
| 76 | list every fleet the account owns, with each box's label, |
| 77 | status and tags. |
| 78 | |
| 79 | --account <sqid|name> account the fleet belongs to (default: your only |
| 80 | account, or the configured/chosen one) — |
| 81 | resolved by the console against your own |
| 82 | memberships. |
| 83 | --account-id <sqid> account sqid, matched as a sqid ONLY — supersedes |
| 84 | --account |
| 85 | --console <origin> which logged-in console to use (only needed with |
| 86 | several) |
| 87 | |
| 88 | Every verb here is signed-in only (carlos auth login) — fleets are |
| 89 | console-tracked state, with no bucket-direct fallback. |
| 90 | ` |
| 91 | |
| 92 | // RunFleets dispatches the fleets sub-verbs. |
| 93 | func RunFleets(args []string) { |
| 94 | if len(args) == 0 { |
| 95 | fmt.Fprint(os.Stderr, fleetsUsage) |
| 96 | os.Exit(1) |
| 97 | } |
| 98 | switch args[0] { |
| 99 | case "create": |
| 100 | runFleetsCreate(args[1:]) |
| 101 | case "add-box": |
| 102 | runFleetsAddBox(args[1:]) |
| 103 | case "rotate-token": |
| 104 | runFleetsRotateToken(args[1:]) |
| 105 | case "tag-box": |
| 106 | runFleetsTagBox(args[1:]) |
| 107 | case "detach-box": |
| 108 | runFleetsDetachBox(args[1:]) |
| 109 | case "ls": |
| 110 | runFleetsList(args[1:]) |
| 111 | default: |
| 112 | fmt.Fprint(os.Stderr, fleetsUsage) |
| 113 | os.Exit(1) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // fleetsSignedInSession resolves API mode and fatals with a fleets-specific |
| 118 | // message when this shell is in direct bucket mode — every fleets verb is |
| 119 | // console-API-only, the same posture apps create takes (apps.go's |
| 120 | // runAppsCreate): a fleet is console-tracked state with nothing in the |
| 121 | // bucket to fall back to. |
| 122 | func fleetsSignedInSession(consoleFlag string) *apiSession { |
| 123 | sess, err := resolveMode(consoleFlag) |
| 124 | if err != nil { |
| 125 | log.Fatal(err) |
| 126 | } |
| 127 | if sess == nil { |
| 128 | log.Fatal("fleets works through a console, and this shell is in direct bucket mode\n" + |
| 129 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Fleets are console-tracked\n" + |
| 130 | "state with no bucket-direct fallback: unset the store env and run\n" + |
| 131 | "carlos auth login --console <url> instead.") |
| 132 | } |
| 133 | return sess |
| 134 | } |
| 135 | |
| 136 | // validFleetName and validBoxLabel are the client-side gates for the two |
| 137 | // name shapes every fleets verb below takes — checked BEFORE any API call, |
| 138 | // on the same "a shape the server is certain to refuse should cost nothing" |
| 139 | // principle apps.go's validAppName states. Both use |
| 140 | // registry.ValidTunnelName: a fleet name and a box label are the two halves |
| 141 | // of the "<fleet>/<label>" channel identity, and share its grammar exactly. |
| 142 | func validFleetName(name string) error { |
| 143 | if registry.ValidTunnelName(name) { |
| 144 | return nil |
| 145 | } |
| 146 | return fmt.Errorf("%q is not a usable fleet name — 1-32 characters of a-z, 0-9 and hyphen", name) |
| 147 | } |
| 148 | |
| 149 | func validBoxLabel(label string) error { |
| 150 | if registry.ValidTunnelName(label) { |
| 151 | return nil |
| 152 | } |
| 153 | return fmt.Errorf("%q is not a usable box label — 1-32 characters of a-z, 0-9 and hyphen", label) |
| 154 | } |
| 155 | |
| 156 | func runFleetsCreate(args []string) { |
| 157 | fs := flag.NewFlagSet("fleets create", flag.ExitOnError) |
| 158 | bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)") |
| 159 | acctRaw := fs.String("account", "", "account (sqid or name) to own the fleet (default: your only account, or the configured/chosen one)") |
| 160 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 161 | consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| 162 | fs.Usage = flagUsage(fs, "") |
| 163 | fs.Parse(args) |
| 164 | if fs.NArg() != 1 || strings.TrimSpace(*bucket) == "" { |
| 165 | log.Fatal("usage: carlos fleets create --bucket <bucket> [--account <sqid|name>] " + |
| 166 | "[--account-id <sqid>] [--console <origin>] <name>") |
| 167 | } |
| 168 | name := strings.TrimSpace(fs.Arg(0)) |
| 169 | if err := validFleetName(name); err != nil { |
| 170 | log.Fatalf("fleets create: %v", err) |
| 171 | } |
| 172 | bucketV := strings.TrimSpace(*bucket) |
| 173 | |
| 174 | sess := fleetsSignedInSession(*consoleFlag) |
| 175 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 176 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 177 | f, err := sess.createFleet(context.Background(), acct, name, bucketV) |
| 178 | if err != nil { |
| 179 | log.Fatalf("fleets create: %v", err) |
| 180 | } |
| 181 | fmt.Fprintf(stdout, "created fleet %s (bucket %s)\n", f.Name, f.Bucket) |
| 182 | fmt.Fprintf(stdout, " add a box: carlos fleets add-box %s <label>\n", f.Name) |
| 183 | } |
| 184 | |
| 185 | func runFleetsAddBox(args []string) { |
| 186 | fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError) |
| 187 | acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 188 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 189 | consoleFlag := consoleOption(fs, "console to add the box through (default: the only logged-in one)") |
| 190 | fs.Usage = flagUsage(fs, "") |
| 191 | fs.Parse(args) |
| 192 | if fs.NArg() != 2 { |
| 193 | log.Fatal("usage: carlos fleets add-box [--account <sqid|name>] [--account-id <sqid>] " + |
| 194 | "[--console <origin>] <fleet> <label>") |
| 195 | } |
| 196 | fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| 197 | if err := validFleetName(fleet); err != nil { |
| 198 | log.Fatalf("fleets add-box: %v", err) |
| 199 | } |
| 200 | if err := validBoxLabel(label); err != nil { |
| 201 | log.Fatalf("fleets add-box: %v", err) |
| 202 | } |
| 203 | |
| 204 | sess := fleetsSignedInSession(*consoleFlag) |
| 205 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 206 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 207 | token, err := sess.addFleetBox(context.Background(), acct, fleet, label) |
| 208 | if err != nil { |
| 209 | log.Fatalf("fleets add-box: %v", err) |
| 210 | } |
| 211 | printFleetToken(fleet, label, token, false) |
| 212 | } |
| 213 | |
| 214 | func runFleetsRotateToken(args []string) { |
| 215 | fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError) |
| 216 | acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 217 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 218 | consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)") |
| 219 | fs.Usage = flagUsage(fs, "") |
| 220 | fs.Parse(args) |
| 221 | if fs.NArg() != 2 { |
| 222 | log.Fatal("usage: carlos fleets rotate-token [--account <sqid|name>] [--account-id <sqid>] " + |
| 223 | "[--console <origin>] <fleet> <label>") |
| 224 | } |
| 225 | fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| 226 | if err := validFleetName(fleet); err != nil { |
| 227 | log.Fatalf("fleets rotate-token: %v", err) |
| 228 | } |
| 229 | if err := validBoxLabel(label); err != nil { |
| 230 | log.Fatalf("fleets rotate-token: %v", err) |
| 231 | } |
| 232 | |
| 233 | sess := fleetsSignedInSession(*consoleFlag) |
| 234 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 235 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 236 | token, err := sess.rotateFleetBoxToken(context.Background(), acct, fleet, label) |
| 237 | if err != nil { |
| 238 | log.Fatalf("fleets rotate-token: %v", err) |
| 239 | } |
| 240 | printFleetToken(fleet, label, token, true) |
| 241 | } |
| 242 | |
| 243 | // printFleetToken is the ONE place a fleet box token reaches stdout — for |
| 244 | // both add-box and rotate-token, so the two verbs can never drift apart on |
| 245 | // the warning that matters most (carried from Task 3's review, per this |
| 246 | // task's brief): rotating (or minting) a token refuses a box's NEXT dial-in |
| 247 | // with the credential just replaced, but does NOT evict a channel the box |
| 248 | // already holds — a COMPROMISED box needs `fleets detach-box`, not |
| 249 | // rotate-token. Printed exactly once, immediately after the token itself, |
| 250 | // on every call — never logged, never re-shown. |
| 251 | func printFleetToken(fleet, label, token string, rotated bool) { |
| 252 | fmt.Fprintf(stdout, "%s/%s token: %s\n", fleet, label, token) |
| 253 | fmt.Fprintln(stdout, " (shown once — the console does not store or re-show the plaintext)") |
| 254 | if rotated { |
| 255 | fmt.Fprintln(stdout, " ROTATION IS NOT REVOCATION: this refuses the box's NEXT dial-in with the OLD") |
| 256 | fmt.Fprintln(stdout, " token, but does NOT evict a channel it already holds — a COMPROMISED box needs") |
| 257 | fmt.Fprintln(stdout, " `carlos fleets detach-box`, not another rotate-token.") |
| 258 | return |
| 259 | } |
| 260 | fmt.Fprintln(stdout, " NOTE: rotating this token later (rotate-token) refuses the box's next dial-in but") |
| 261 | fmt.Fprintln(stdout, " does NOT evict a live session — a COMPROMISED box needs `fleets detach-box` instead.") |
| 262 | } |
| 263 | |
| 264 | // runFleetsTagBox implements `carlos fleets tag-box`: a full replace of a |
| 265 | // box's tags, checked client-side with registry.ValidBoxTags BEFORE the API |
| 266 | // call — the same "a shape the server is certain to refuse should cost |
| 267 | // nothing" principle validFleetName/validBoxLabel already state above. |
| 268 | // |
| 269 | // --tags is TRI-state, the exact hazard runAppsPlace's --target comment |
| 270 | // documents (apps.go): "" is a LEGAL, meaningful value here — it clears |
| 271 | // every tag — so a flag left unmentioned and one explicitly set to "" are |
| 272 | // not the same request, and the flag's own zero value cannot tell them |
| 273 | // apart. Without a positive check, a forgotten `--tags` would silently wipe |
| 274 | // a box's tags and print "cleared" as if that were asked for. fs.Visit |
| 275 | // reports only flags actually present on the command line, so — exactly |
| 276 | // like runAppsPlace's targetSet — that is what decides "not named" from |
| 277 | // "named, empty", not the value in *tags. |
| 278 | func runFleetsTagBox(args []string) { |
| 279 | fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError) |
| 280 | acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 281 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 282 | consoleFlag := consoleOption(fs, "console to tag through (default: the only logged-in one)") |
| 283 | tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`) |
| 284 | const tagBoxUsage = "usage: carlos fleets tag-box --tags <tags> [--account <sqid|name>] [--account-id <sqid>] " + |
| 285 | "[--console <origin>] <fleet> <label>" |
| 286 | fs.Usage = flagUsage(fs, tagBoxUsage) |
| 287 | fs.Parse(args) |
| 288 | if fs.NArg() != 2 { |
| 289 | log.Fatal(tagBoxUsage) |
| 290 | } |
| 291 | tagsSet := false |
| 292 | fs.Visit(func(f *flag.Flag) { |
| 293 | if f.Name == "tags" { |
| 294 | tagsSet = true |
| 295 | } |
| 296 | }) |
| 297 | if !tagsSet { |
| 298 | log.Fatal(tagBoxUsage) |
| 299 | } |
| 300 | fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| 301 | if err := validFleetName(fleet); err != nil { |
| 302 | log.Fatalf("fleets tag-box: %v", err) |
| 303 | } |
| 304 | if err := validBoxLabel(label); err != nil { |
| 305 | log.Fatalf("fleets tag-box: %v", err) |
| 306 | } |
| 307 | tagsV := strings.TrimSpace(*tags) |
| 308 | if !registry.ValidBoxTags(tagsV) { |
| 309 | log.Fatal("fleets tag-box: invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") |
| 310 | } |
| 311 | |
| 312 | sess := fleetsSignedInSession(*consoleFlag) |
| 313 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 314 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 315 | if err := sess.tagFleetBox(context.Background(), acct, fleet, label, tagsV); err != nil { |
| 316 | log.Fatalf("fleets tag-box: %v", err) |
| 317 | } |
| 318 | if tagsV == "" { |
| 319 | fmt.Fprintf(stdout, "cleared tags for %s/%s\n", fleet, label) |
| 320 | } else { |
| 321 | fmt.Fprintf(stdout, "tagged %s/%s [%s]\n", fleet, label, tagsV) |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | func runFleetsDetachBox(args []string) { |
| 326 | fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError) |
| 327 | acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 328 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 329 | consoleFlag := consoleOption(fs, "console to detach through (default: the only logged-in one)") |
| 330 | fs.Usage = flagUsage(fs, "") |
| 331 | fs.Parse(args) |
| 332 | if fs.NArg() != 2 { |
| 333 | log.Fatal("usage: carlos fleets detach-box [--account <sqid|name>] [--account-id <sqid>] " + |
| 334 | "[--console <origin>] <fleet> <label>") |
| 335 | } |
| 336 | fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1)) |
| 337 | if err := validFleetName(fleet); err != nil { |
| 338 | log.Fatalf("fleets detach-box: %v", err) |
| 339 | } |
| 340 | if err := validBoxLabel(label); err != nil { |
| 341 | log.Fatalf("fleets detach-box: %v", err) |
| 342 | } |
| 343 | |
| 344 | sess := fleetsSignedInSession(*consoleFlag) |
| 345 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 346 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 347 | if err := sess.detachFleetBox(context.Background(), acct, fleet, label); err != nil { |
| 348 | log.Fatalf("fleets detach-box: %v", err) |
| 349 | } |
| 350 | fmt.Fprintf(stdout, "detached %s/%s — its next dial-in is refused and any live channel is closed\n", fleet, label) |
| 351 | } |
| 352 | |
| 353 | func runFleetsList(args []string) { |
| 354 | fs := flag.NewFlagSet("fleets ls", flag.ExitOnError) |
| 355 | acctRaw := fs.String("account", "", "account (sqid or name) to list fleets for (default: your only account, or the configured/chosen one)") |
| 356 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 357 | consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 358 | fs.Usage = flagUsage(fs, "") |
| 359 | fs.Parse(args) |
| 360 | |
| 361 | sess := fleetsSignedInSession(*consoleFlag) |
| 362 | fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| 363 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 364 | fleets, err := sess.listFleets(context.Background(), acct) |
| 365 | if err != nil { |
| 366 | log.Fatalf("fleets ls: %v", err) |
| 367 | } |
| 368 | if len(fleets) == 0 { |
| 369 | fmt.Fprintln(stdout, "no fleets") |
| 370 | return |
| 371 | } |
| 372 | w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| 373 | fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS\tTAGS") |
| 374 | for _, f := range fleets { |
| 375 | if len(f.Boxes) == 0 { |
| 376 | fmt.Fprintf(w, "%s\t%s\t-\t-\t-\n", f.Name, f.Bucket) |
| 377 | continue |
| 378 | } |
| 379 | for _, b := range f.Boxes { |
| 380 | tags := b.Tags |
| 381 | if tags == "" { |
| 382 | tags = "-" |
| 383 | } |
| 384 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status, tags) |
| 385 | } |
| 386 | } |
| 387 | w.Flush() |
| 388 | } |
| 389 | |