package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "log" "mime/multipart" "net/http" "os" "strings" "time" "github.com/carlosframework/platform/internal/dbreads" "github.com/carlosframework/platform/internal/host" "github.com/carlosframework/platform/internal/hostenv" ) // carlos system: what is not an app (spec §0, the console-as-an-app // dividing line), and what is true of the DEPLOYMENT rather than of one app. // // Two families live here. The first is the platform's own binary: one // component today, the router — the substrate binary. `carlos system router // update` is the same verb scoped to it, so the command Paul named works and // a future split needs no new verb. The second is deployment-wide settings an // operator owns, which today is `antivirus`: whether object scanning is on at // all, a question whose answer should not require logging into a box to read // a unit file. // // They are one command because they answer the same shape of question — what // is this deployment doing — and they keep separate verbs because the // authority differs: the update family wants an ops account OWNER or an // address on CARLOS_SYSTEM_OPERATORS (local builds the owner's alone), antivirus // wants an address on CARLOS_STORE_OPERATORS. const systemComponentRouter = "router" func systemVerb(args []string) (verb string, rest []string, component string) { component = systemComponentRouter if len(args) > 0 && args[0] == systemComponentRouter { args = args[1:] } if len(args) == 0 { return "", nil, component } switch args[0] { // antivirus is a FAMILY, not a leaf: its own sub-verbs come back // untouched in rest for runSystemAntivirusVerb to read. // layout is a family too: migrate|revert|status stay in rest for // runSystemLayout. case "claims", "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "install-channel", "join", "host-env", "db-worker", "help", "-h", "--help": return args[0], args[1:], component } return "", args, component } func runSystem(args []string) { verb, rest, _ := systemVerb(args) switch verb { case "claims": host.RunSystemClaims(rest) case "files": host.RunSystemFiles(rest) case "guard-arm": host.RunSystemGuardArm(rest) case "guard-exec": host.RunSystemGuardExec(rest) case "journal-migrate": host.RunSystemJournalMigrate(rest) case "floor-allows-prev": // Root's floor gate for the system units; not for operators. host.RunSystemFloorAllowsPrev(rest) case "apply": host.RunSystemApply(rest) case "update": runSystemUpdate(rest) case "status": runSystemStatus(rest) case "rollback": runSystemRollback(rest) case "cancel-activation": runSystemCancelActivation(rest) case "antivirus": runSystemAntivirusVerb(rest) case "layout": runSystemLayout(rest) case "geoip": runSystemGeoipVerb(rest) case "seeds-floor": runSystemSeedsFloorVerb(rest) case "legacy-routes": runSystemLegacyRoutesVerb(rest) case "box": runSystemBoxVerb(rest) case "group": runSystemGroupVerb(rest) case "install-channel": // A joining box's own release (region HA spec 2 §5 step 3); root. host.RunSystemInstallChannel(rest) case "join": // Root's half of joining, behind carlos-system-join.service. host.RunSystemJoin(rest) case "host-env": // host.env from and into the deployment's parameters (§5 step 2). hostenv.RunSystemHostEnv(rest) case "db-worker": // The db-read worker (db region forward spec §8.3): systemd starts // it per connection to carlos-dbread.socket. Never run by hand. dbreads.RunWorker(rest) case "help", "-h", "--help": systemUsage() default: if msg := systemUnknownVerbMessage(args, rest); msg != "" { fmt.Fprintf(os.Stderr, "%s\n\n", msg) } systemUsage() os.Exit(2) } } // systemUnknownVerbMessage is the line above the usage when no verb matched. // It reads REST, not args, because systemVerb has already eaten the optional // component: `carlos system router console` is an unknown verb "console", // never an unknown verb "router". "" means say nothing at all — bare // `carlos system` is someone asking for the usage, not a typo to report. func systemUnknownVerbMessage(args, rest []string) string { switch { case len(rest) > 0: return fmt.Sprintf("system: unknown verb %q", rest[0]) case len(args) > 0: return "system: no verb given" default: return "" } } func systemUsage() { fmt.Fprint(os.Stderr, `usage: carlos system [flags] carlos system router [flags] carlos system antivirus [flags] carlos system geoip [flags] carlos system seeds-floor publish --min-release carlos system legacy-routes assign --name --to / carlos system box role carlos system box publish-record carlos system group role carlos system host-env --deployment --region [flags] The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): update promote a platform release to a channel; every box on it rolls itself status every box: platform, channel, running build, channel target, last apply outcome rollback point a channel back at the release before this one cancel-activation release a channel's activation reservation that was never published, so promote and rollback work again apply (root oneshot behind carlos-system-update.service; not for operators) layout migrate|revert|status: move every instance into its own directory and namespace, or back; see carlos system layout help seeds-floor publish --min-release the first release whose boxes can place a seed, such as v0.27.0 legacy-routes assign --name --to / `+systemLegacyRoutesVerb+` box role whether new instances may be placed on a box. A legacy or platform box keeps the instances it has and takes no new ones group role the same for every box a region's group has enrolled, and for every box it enrolls later box publish-record run on a box as the agent user, with systemd-run -p User=carlos: publish its AWS-signed identity record to the deployment bucket, once. It never replaces or deletes a record A box a region's group launches (region HA): host-env write build host.env from the deployment's parameters (root) host-env seed copy a box's host.env into the deployment's parameters install-channel install the release this box's channel names (root) join (root oneshot behind carlos-system-join.service; not for operators) Deployment-wide settings (CARLOS_STORE_OPERATORS): antivirus status is object scanning wired, switched on, and answering? Reports wiring and policy separately, because they fail separately. antivirus enable turn object scanning on for this deployment. Refused if no engine is wired: a record claiming enabled with nothing behind it would report health where no scan can run. antivirus disable turn it off. Takes effect on the next tick — no restart, which is the point of the switch living in a record rather than in the unit file. antivirus rotate-webhook-key deliver the current master-derived callback secret to every scanning-enabled app, one bounded batch at a time. Skips apps already on that key, restarts the rest, and is safe to run again. Platform GeoIP (ops account owners and CARLOS_SYSTEM_OPERATORS): geoip publish publish a MaxMind-format database (DB-IP Lite or GeoLite2) to every edge. -force skips the shrink check geoip status the published database, and whether every box strips forged headers and has it geoip off stop: edges drop the database and send no geo headers Enabling antivirus scans nothing by itself: a member still requests scanning for their app, and an operator still grants it per store. The platform is not an app: the console is updated with carlos deploy --app console. `) } // ---- the console's answers --------------------------------------------- // systemPointerRow is one channel in GET /api/cli/system/status's "pointers". // Error is the console saying it FOUND a pointer and could not verify it — // distinct from a null pointer, which means the channel has never been // promoted. Rendering the two the same way would hide the only one of them // that is an emergency. type systemPointerRow struct { Version string `json:"version"` PromotedAt time.Time `json:"promoted_at"` Spread string `json:"spread"` Source string `json:"source"` History int `json:"history"` Error string `json:"error"` } // systemStateRow is a box's last self-update outcome, as the box published it // in its heartbeat (internal/status.SystemState). type systemStateRow struct { Channel string `json:"channel"` Version string `json:"version"` PromotedAt time.Time `json:"promoted_at"` Outcome string `json:"outcome"` Reason string `json:"reason"` At time.Time `json:"at"` } type systemBoxRow struct { Label string `json:"label"` Region string `json:"region"` Platform string `json:"platform"` Channel string `json:"channel"` Release string `json:"release"` Target string `json:"target"` System *systemStateRow `json:"system"` Stale bool `json:"stale"` } // systemStatusResponse is the whole picture in one read. Partial says the box // list is INCOMPLETE — a fleet's heartbeats could not be read — so a short // list is never mistaken for a small fleet. type systemStatusResponse struct { Pointers map[string]*systemPointerRow `json:"pointers"` Boxes []systemBoxRow `json:"boxes"` Partial bool `json:"partial"` Bootstrap *systemBootstrapRow `json:"bootstrap"` } // systemBootstrapRow is bootstrap/carlos, the binary a new box boots, // against stable's signed linux-arm64 artifact (issue #316). nil when there // is nothing to compare, or the console predates the field. type systemBootstrapRow struct { Version string `json:"version"` // stable's version Key string `json:"key"` // stable's mirrored linux-arm64 artifact State string `json:"state"` // current, behind or unreadable Error string `json:"error"` } // ---- carlos system update ---------------------------------------------- func runSystemUpdate(args []string) { fs := flag.NewFlagSet("system update", flag.ExitOnError) consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") channel := fs.String("channel", "stable", "canary or stable") version := fs.String("version", "", "release tag to promote (default: the latest public release); with --file, the version the binary reports") file := fs.String("file", "", "a locally built binary instead of a public release (dev and pre-release; ops account owners only)") platform := fs.String("platform", "linux-arm64", "with --file: the binary's -") spread := fs.String("spread", "5m", "boxes jitter their restarts over this; 0 for a hotfix") yes := fs.Bool("yes", false, "do not ask") fs.Usage = flagUsage(fs, "") fs.Parse(args) if *file != "" && *version == "" { log.Fatal("--file needs --version: the console cannot run a foreign-arch binary to ask it") } sess := requireDeployAPISession(*consoleFlag) ctx := context.Background() // What this reaches, before it is done. st, body, err := sess.getJSON(ctx, "/api/cli/system/status") if err != nil { log.Fatalf("carlos system update: %v", err) } if st != http.StatusOK { log.Fatalf("carlos system update: %s", apiErrorFromBody(st, body)) } var cur systemStatusResponse _ = json.Unmarshal(body, &cur) var reach []string for _, b := range cur.Boxes { if b.Channel == *channel { reach = append(reach, fmt.Sprintf("%s (%s, running %s)", b.Label, orDash(b.Platform), orDash(b.Release))) } } what := "the latest public release" if *version != "" { what = *version } fmt.Fprintf(stdout, "promote %s to %s/%s — %d box(es) will roll over %s:\n", what, systemComponentRouter, *channel, len(reach), *spread) for _, r := range reach { fmt.Fprintf(stdout, " %s\n", r) } if len(reach) == 0 { mutedf(stdout, " (no box reports channel %s yet; boxes on an older build count as stable)\n", *channel) } if cur.Partial { fmt.Fprintf(stdout, " %s\n", colorFor(stdout).warn("some fleets could not be read; more boxes than these may roll")) } if msg := systemLadderWarning(cur.Pointers, *channel, *version); msg != "" { fmt.Fprintf(stdout, "%s\n", colorFor(stdout).warn(msg)) } if !*yes { if !interactiveTerminal() { log.Fatal("no terminal to confirm on; pass --yes") } if !promptYes(stdout, "go ahead? [y/N] ") { return } } in := map[string]any{"channel": *channel, "spread": *spread} if *version != "" { in["version"] = *version } if *file != "" { handle, err := sess.uploadSystemBinary(ctx, *file, *version, *platform) if err != nil { log.Fatalf("upload: %v", err) } in["upload"] = handle } st, body, err = sess.postJSON(ctx, "/api/cli/system/update", in) if err != nil { log.Fatalf("carlos system update: %v", err) } if st != http.StatusOK { // 409 is the one refusal that carries somewhere to go: the id of the // promote already running on this channel, which the operator can // watch instead of starting a second one. var busy struct { Error string `json:"error"` Job string `json:"job"` } if st == http.StatusConflict && json.Unmarshal(body, &busy) == nil && busy.Job != "" { log.Fatalf("carlos system update: %s (job %s)", busy.Error, busy.Job) } log.Fatalf("carlos system update: %s", apiErrorFromBody(st, body)) } var started struct { Job string `json:"job"` } _ = json.Unmarshal(body, &started) if err := sess.watchSystemJob(ctx, started.Job, stdout); err != nil { log.Fatal(err) } fmt.Fprintf(stdout, "%s boxes on %s roll themselves; watch with: carlos system status --channel %s\n", colorFor(stdout).ok("promoted"), *channel, *channel) } // systemLadderWarning is the canary → stable ladder, said out loud and not // enforced. A promote straight to stable is a legitimate thing to do — a // hotfix at three in the morning, a build that has already proved itself // somewhere else — so this is a line to read, never a refusal: the operator // is the one who knows whether the rung was skipped on purpose. // // It stays quiet unless it is certain. Without --version the console is // promoting "the latest public release", a tag only the server resolves, so // there is nothing here to compare against the canary pointer and a warning // would be a guess. Only stable has a rung below it worth naming. func systemLadderWarning(pointers map[string]*systemPointerRow, channel, version string) string { if channel != "stable" || version == "" { return "" } if canary := pointers["canary"]; canary != nil && canary.Version == version { return "" } return fmt.Sprintf("warning: %s has not been on canary; promoting straight to stable", version) } // watchSystemJob polls the job and prints each step once, as deploy's // watchVersion does for a route. The console runs the promote off the // request precisely so a dropped connection cannot abandon it, so this is a // view of work already under way, not the work itself. func (s *apiSession) watchSystemJob(ctx context.Context, id string, w io.Writer) error { printed := 0 deadline := time.Now().Add(15 * time.Minute) for { st, body, err := s.getJSON(ctx, "/api/cli/system/jobs/"+id) if err != nil { return fmt.Errorf("job %s: %v", id, err) } if st != http.StatusOK { return fmt.Errorf("job %s: %s", id, apiErrorFromBody(st, body)) } var j struct { Steps []string `json:"steps"` Done bool `json:"done"` Error string `json:"error"` } _ = json.Unmarshal(body, &j) for ; printed < len(j.Steps); printed++ { fmt.Fprintf(w, " %s\n", j.Steps[printed]) } if j.Done { if j.Error != "" { return fmt.Errorf("carlos system update: %s", j.Error) } return nil } if time.Now().After(deadline) { return errors.New("carlos system update: the console's job did not finish in 15 minutes") } time.Sleep(time.Second) } } // uploadSystemBinary parks a locally built binary on the console and hands // back the handle a promote names. The meta part goes FIRST so the console // can refuse a bad version or platform before buffering the bytes. func (s *apiSession) uploadSystemBinary(ctx context.Context, path, version, platform string) (string, error) { st, body, err := s.c.doMultipartMethod(ctx, http.MethodPut, "/api/cli/system/upload", func(mw *multipart.Writer) error { meta, err := mw.CreateFormField("meta") if err != nil { return err } if err := json.NewEncoder(meta).Encode(map[string]string{"version": version, "platform": platform}); err != nil { return err } return writeFilePart(mw, "file", path) }) if err != nil { return "", err } if st != http.StatusOK { return "", apiErrorFromBody(st, body) } var out struct { Upload string `json:"upload"` } if err := json.Unmarshal(body, &out); err != nil || out.Upload == "" { return "", errors.New("the console accepted the upload but named no handle") } return out.Upload, nil } // ---- carlos system status ---------------------------------------------- func runSystemStatus(args []string) { fs := flag.NewFlagSet("system status", flag.ExitOnError) consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") channel := fs.String("channel", "", "only boxes on this channel") fs.Usage = flagUsage(fs, "") fs.Parse(args) sess := requireDeployAPISession(*consoleFlag) st, body, err := sess.getJSON(context.Background(), "/api/cli/system/status") if err != nil { log.Fatalf("carlos system status: %v", err) } if st != http.StatusOK { log.Fatalf("carlos system status: %s", apiErrorFromBody(st, body)) } var resp systemStatusResponse if err := json.Unmarshal(body, &resp); err != nil { log.Fatalf("carlos system status: unreadable response: %v", err) } printSystemStatus(stdout, resp, *channel) } // printSystemStatus renders the pointers, then every box against them. It is // the whole verb minus the fetch, so the rendering is testable without a // console. func printSystemStatus(w io.Writer, resp systemStatusResponse, channel string) { c := colorFor(w) for _, ch := range []string{"canary", "stable"} { if channel != "" && ch != channel { continue } p := resp.Pointers[ch] switch { case p == nil: fmt.Fprintf(w, "%-7s (no pointer)\n", ch) case p.Error != "": // Never "(no pointer)": the console found one and could not // verify it, which is the opposite of never promoted. fmt.Fprintf(w, "%-7s %s\n", ch, c.bad("(pointer does not verify: "+p.Error+")")) default: // Built from what is actually there: a console that omits a // field must not make this print year 1 or a bare "spread". parts := []string{fmt.Sprintf("%-7s %s", ch, p.Version)} if !p.PromotedAt.IsZero() { parts = append(parts, "promoted "+p.PromotedAt.Format(time.RFC3339)) } if p.Spread != "" { parts = append(parts, "spread "+p.Spread) } if p.Source != "" { parts = append(parts, p.Source) } fmt.Fprintln(w, strings.Join(parts, " ")) } } // What a new box would boot (issue #316): quiet unless it is not stable's // build, and only where stable is shown. if b := resp.Bootstrap; b != nil && (channel == "" || channel == "stable") { switch b.State { case "behind": fmt.Fprintln(w, c.warn(fmt.Sprintf("bootstrap/carlos is not stable's build (%s), so a new box would boot a different one. Copy %s over it.", b.Version, b.Key))) case "unreadable": fmt.Fprintln(w, c.warn("bootstrap/carlos could not be read, so this can't say what a new box would boot: "+b.Error)) } } fmt.Fprintln(w) if resp.Partial { fmt.Fprintln(w, c.warn("fleet boxes could not all be read; rows may be missing")) } for _, b := range resp.Boxes { if channel != "" && b.Channel != channel { continue } state := "" switch { case b.Stale: state = c.bad("UNREACHABLE") case b.Platform == "": state = c.warn("pre-upgrade (no platform reported; counts as stable)") case b.Target == "": state = "no target" case b.Release == b.Target: state = c.ok("current") default: state = c.warn("behind → " + b.Target) } fmt.Fprintf(w, "%-20s %-12s %-7s %-10s %s", b.Label, orDash(b.Platform), orDash(b.Channel), orDash(b.Release), state) if b.System != nil && b.System.Outcome != "applied" && b.System.Outcome != "noted" { fmt.Fprintf(w, " %s %s: %s", c.bad(b.System.Outcome), b.System.Version, b.System.Reason) } fmt.Fprintln(w) } } // ---- carlos system rollback -------------------------------------------- func runSystemRollback(args []string) { fs := flag.NewFlagSet("system rollback", flag.ExitOnError) consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") channel := fs.String("channel", "", "canary or stable (required)") yes := fs.Bool("yes", false, "do not ask") fs.Usage = flagUsage(fs, "") fs.Parse(args) if *channel == "" { log.Fatal("--channel is required") } sess := requireDeployAPISession(*consoleFlag) if !*yes { if !interactiveTerminal() { log.Fatal("no terminal to confirm on; pass --yes") } if !promptYes(stdout, fmt.Sprintf("point %s back at the release before this one? every box on it rolls back [y/N] ", *channel)) { return } } st, body, err := sess.postJSON(context.Background(), "/api/cli/system/rollback", map[string]string{"channel": *channel}) if err != nil { log.Fatalf("carlos system rollback: %v", err) } if st != http.StatusOK { log.Fatalf("carlos system rollback: %s", apiErrorFromBody(st, body)) } var out struct { Version string `json:"version"` } _ = json.Unmarshal(body, &out) fmt.Fprintf(stdout, "%s %s → %s\n", colorFor(stdout).ok("rolled back"), *channel, out.Version) } // ---- carlos system antivirus ------------------------------------------- // runSystemAntivirusVerb reads the sub-verb off rest. systemVerb hands the // family through whole, so the shape of `carlos system antivirus ` is // decided in one place rather than smeared across the dispatch. func runSystemAntivirusVerb(rest []string) { if len(rest) == 0 { fmt.Fprint(os.Stderr, "system antivirus: no verb given\n\n") systemUsage() os.Exit(2) } switch rest[0] { case "status", "enable", "disable", "rotate-webhook-key": runSystemAntivirus(rest[1:], rest[0]) default: fmt.Fprintf(os.Stderr, "system antivirus: unknown verb %q\n\n", rest[0]) systemUsage() os.Exit(2) } } func runSystemAntivirus(args []string, verb string) { fs := flag.NewFlagSet("system antivirus "+verb, flag.ExitOnError) consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) sess := requireStoreAPISession(*consoleFlag) printUsing(stdout, sess) ctx := context.Background() var res apiSystemAntivirusReply var err error switch verb { case "status": res, err = sess.systemAntivirusStatus(ctx) case "enable": res, err = sess.systemAntivirusSet(ctx, true) case "disable": res, err = sess.systemAntivirusSet(ctx, false) case "rotate-webhook-key": warnf(stdout, "this rotation restarts every scanning-enabled app whose callback secret changes") for { rotated, rotateErr := sess.systemWebhookRotate(ctx) if rotateErr != nil { log.Fatalf("system antivirus %s: %v", verb, rotateErr) } fmt.Fprintf(stdout, "rotated callback secrets for %d apps; %d remain\n", rotated.Rotated, rotated.Remaining) if rotated.Remaining == 0 { break } if rotated.Rotated == 0 { time.Sleep(2 * time.Second) } } return } if err != nil { log.Fatalf("system antivirus %s: %v", verb, err) } printSystemAntivirus(stdout, res) } // printSystemAntivirus reports wiring and policy as the two separate facts // they are. An operator debugging "why is nothing scanning" needs to know // which half is missing, and a single "healthy" line would send half of them // to the wrong place. func printSystemAntivirus(w io.Writer, res apiSystemAntivirusReply) { c := colorFor(w) fmt.Fprintf(w, "%s\n", c.head("object scanning, deployment-wide")) fmt.Fprintf(w, "Apps with queued scans: %d\n", res.QueueAppsWithWork) if !res.Wired { warnf(w, "no engine is wired on this deployment — an operator sets CARLOS_CLAMD_ADDR") } else if res.EngineError != "" { warnf(w, "the engine is wired but not answering: %s", res.EngineError) } else { fmt.Fprintf(w, " engine: %s\n", res.EngineWired) if res.Signature != "" { fmt.Fprintf(w, " engine database now: %s\n", res.Signature) if age, ok := signatureAge(res.Signature, time.Now()); ok { max := time.Duration(res.MaxSignatureAgeSeconds) * time.Second if max > 0 && age > max { warnf(w, "those definitions are %d days old — update the engine's database", int(age.Hours()/24)) } } } } if res.RecordError != "" { warnf(w, "the scanning record could not be read: %s", res.RecordError) return } if !res.Enabled { mutedf(w, " scanning is OFF for this deployment — carlos system antivirus enable") if res.By != "" { mutedf(w, " turned off by %s at %s", res.By, res.At) } return } fmt.Fprintf(w, " scanning is ON, enabled by %s at %s\n", res.By, res.At) mutedf(w, " this alone scans nothing: a member requests scanning for their app,") mutedf(w, " and an operator grants it per store (carlos store scan grant)") } // ---- carlos system layout ---------------------------------------------- // runSystemLayout is `carlos system layout migrate|revert|status` // (isolated-exec design §8.2). migrate and revert record an intent through // the console's layout door and return at once: the boxes in scope // converge on their next serial move, and `carlos status --layout` is the // view of that. --force maps to the intent's Force field: an awake // instance is stopped for its move now instead of waiting out its grace. func runSystemLayout(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, systemLayoutUsage) os.Exit(2) } sub, rest := args[0], args[1:] switch sub { case "help", "-h", "--help": fmt.Fprint(os.Stderr, systemLayoutUsage) return case "status", "migrate", "revert": default: fmt.Fprintf(os.Stderr, "system layout: unknown verb %q\n\n%s", sub, systemLayoutUsage) os.Exit(2) } fs := flag.NewFlagSet("system layout "+sub, flag.ExitOnError) consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") box := fs.String("box", "", "one box's label; the default is every box") force := fs.Bool("force", false, "stop awake instances for the move now instead of waiting for them to sleep (migrate and revert)") yes := fs.Bool("yes", false, "do not ask") fs.Usage = flagUsage(fs, systemLayoutUsage) fs.Parse(rest) sess := requireDeployAPISession(*consoleFlag) ctx := context.Background() if sub == "status" { st, body, err := sess.getJSON(ctx, "/api/cli/system/layout") if err != nil { log.Fatalf("carlos system layout status: %v", err) } if st != http.StatusOK { log.Fatalf("carlos system layout status: %s", systemLayoutDoorError(st, body)) } var out systemLayoutResponse if err := json.Unmarshal(body, &out); err != nil { log.Fatalf("carlos system layout status: unreadable response: %v", err) } printSystemLayoutStatus(stdout, out) return } mode, what := "nested", "move every instance on %s into its own directory and namespace" if sub == "revert" { mode, what = "flat", "move every isolated instance on %s back to the shared directory" } scope := "every box" if *box != "" { scope = "box " + *box } if !*yes { if !interactiveTerminal() { log.Fatal("no terminal to confirm on; pass --yes") } how := "each one is stopped and moved while it is asleep, or after an hour awake" if *force { how = "awake instances are stopped for the move at once" } if !promptYes(stdout, fmt.Sprintf(what+"? %s [y/N] ", scope, how)) { return } } st, body, err := sess.postJSON(ctx, "/api/cli/system/layout", map[string]any{"mode": mode, "box": *box, "force": *force}) if err != nil { log.Fatalf("carlos system layout %s: %v", sub, err) } if st != http.StatusOK { log.Fatalf("carlos system layout %s: %s", sub, systemLayoutDoorError(st, body)) } fmt.Fprintf(stdout, "%s layout %s for %s; boxes converge on their next serial move. Watch with: carlos status --layout\n", colorFor(stdout).ok("recorded"), mode, scope) } // systemLayoutResponse is the layout door's answer (console // apiSystemLayoutResponse). Mode "none" means nothing was ever recorded. type systemLayoutResponse struct { Mode string `json:"mode"` Box string `json:"box"` ID string `json:"id"` RequestedAt string `json:"requested_at"` Force bool `json:"force"` } // systemLayoutDoorError is apiErrorFromBody with the one answer this door // adds: a bare 404 from a console that predates the door, which the // generic membership message would misdescribe. func systemLayoutDoorError(st int, body []byte) error { if st == http.StatusNotFound { var e struct { Error string `json:"error"` } if json.Unmarshal(body, &e) != nil || e.Error == "" { return errors.New("not found. this needs an owner of the ops account or an address on CARLOS_SYSTEM_OPERATORS, " + "or this console predates `carlos system layout` (deploy a console built from current main)") } } return apiErrorFromBody(st, body) } // printSystemLayoutStatus renders the recorded intent. It is the fleet's // INTENT, not what the boxes have done: that is `carlos status --layout`, // which the line points at. func printSystemLayoutStatus(w io.Writer, out systemLayoutResponse) { if out.Mode == "none" { fmt.Fprintln(w, "no layout intent recorded; boxes use the nested layout by default once their isolation is ready (CARLOS_LAYOUT_DEFAULT=flat in a box's host.env opts it out)") fmt.Fprintln(w, "to choose the layout yourself, record one: carlos system layout migrate [--box