carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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).
16package main
17
18import (
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
30const 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
88Every verb here is signed-in only (carlos auth login) — fleets are
89console-tracked state, with no bucket-direct fallback.
90`
91
92// RunFleets dispatches the fleets sub-verbs.
93func 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.
122func 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.
142func 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
149func 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
156func 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
185func 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
214func 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.
251func 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.
278func 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
325func 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
353func 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