carlos / platform Public

Add the carlos sidecars verb family

Paul Campbell pushed by paul@keymail.dev 7369fde378dd909be061d3c27c23384010e1ae44 parent 19ccbc5
5 files changed, +1019 −0
  • apimode.go +174 −0
  • main.go +4 −0
  • sidecars_cli.go +529 −0
  • sidecars_cli_test.go +149 −0
  • sidecars_e2e_test.go +163 −0
diff --git a/apimode.go b/apimode.go
index 92aa9e78c..5c0080a8c 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1951,6 +1951,180 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
return nil
}
+// apiSidecarManifest mirrors the console's sidecars-manifest response
+// (handleAPISidecarEnable's writeJSON, internal/console/apisidecars.go) —
+// read back after a write so the confirmation prints what the console
+// actually stored, not just what the caller asked for.
+type apiSidecarManifest struct {
+ App string `json:"app"`
+ Pool string `json:"pool"`
+ Channel string `json:"channel"`
+ Args []string `json:"args"`
+ Placement string `json:"placement"`
+ Regions []string `json:"regions"`
+ IdleSeconds int `json:"idle_seconds"`
+ MaxRunSeconds int `json:"max_run_seconds"`
+ Hibernate bool `json:"hibernate"`
+}
+
+// sidecarsEnable asks the console to write (or update) the app's sidecars
+// manifest — the member-facing half of `carlos sidecars enable`. Every
+// field is a POINTER for the same reason instancesEnable's channel/seed
+// are: absent means "preserve what the manifest already carries", and an
+// explicit empty slice on args means "clear" — three requests the wire
+// must keep apart (handleAPISidecarEnable's own doc, apisidecars.go).
+func (s *apiSession) sidecarsEnable(ctx context.Context, a apiAcct, app string,
+ channel *string, args *[]string, placement *string, idleSeconds, maxRunSeconds *int, hibernate *bool,
+) (apiSidecarManifest, error) {
+ in := struct {
+ Channel *string `json:"channel,omitempty"`
+ Args *[]string `json:"args,omitempty"`
+ Placement *string `json:"placement,omitempty"`
+ IdleSeconds *int `json:"idle_seconds,omitempty"`
+ MaxRunSeconds *int `json:"max_run_seconds,omitempty"`
+ Hibernate *bool `json:"hibernate,omitempty"`
+ }{channel, args, placement, idleSeconds, maxRunSeconds, hibernate}
+ status, body, err := s.putJSON(ctx, apiPath(a, app, "/sidecars-manifest"), in)
+ if err != nil {
+ return apiSidecarManifest{}, err
+ }
+ if status != http.StatusOK {
+ return apiSidecarManifest{}, apiErrorFromBody(status, body)
+ }
+ var out apiSidecarManifest
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiSidecarManifest{}, fmt.Errorf("sidecars enable: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// apiSidecarRecord mirrors provision.SidecarRecord's wire shape plus the
+// console's running-version join (sidecarListRecord, apisidecars.go) —
+// redeclared here rather than imported because this client renders
+// records, never writes them. RunningVersion is empty for a sidecar that
+// has never called /v1/ready, and MUST be rendered "unknown" rather than
+// the record's own Channel — a stamp that overclaims is worse than an
+// absent one (design §9.2, the honesty rule this branch was built around).
+type apiSidecarRecord struct {
+ Name string `json:"name"`
+ Host string `json:"host"`
+ App string `json:"app"`
+ Account string `json:"account"`
+ Pool string `json:"pool"`
+ Region string `json:"region"`
+ Channel string `json:"channel"`
+ Placement string `json:"placement"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by"`
+ // RunningVersion is the console's best-effort join from the fleet's own
+ // box snapshots (sidecarListRecord.RunningVersion) — see the doc above.
+ RunningVersion string `json:"running_version"`
+}
+
+// sidecarCreate asks the console to create one sidecar record — the
+// create-only write whose materialization (the sidecar reconcile pass, on
+// the box) is what spawns the process. name is the sidecar's short label;
+// region and channel are the caller's requests, both subject to the
+// manifest's policy and a fleet-placed app's own placement, which
+// overrides either (handleAPISidecarCreate, apisidecars.go).
+func (s *apiSession) sidecarCreate(ctx context.Context, a apiAcct, app, name, region, channel string) (apiSidecarRecord, error) {
+ in := struct {
+ Name string `json:"name"`
+ Region string `json:"region,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ }{name, region, channel}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/sidecars"), in)
+ if err != nil {
+ return apiSidecarRecord{}, err
+ }
+ if status != http.StatusCreated {
+ return apiSidecarRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiSidecarRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiSidecarRecord{}, fmt.Errorf("sidecars create: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// sidecarList reads the app's sidecar records through the console, honest
+// about the server's 500-record cap (sidecarListMax, apisidecars.go).
+func (s *apiSession) sidecarList(ctx context.Context, a apiAcct, app string) ([]apiSidecarRecord, bool, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/sidecars"))
+ if err != nil {
+ return nil, false, err
+ }
+ if status != http.StatusOK {
+ return nil, false, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Sidecars []apiSidecarRecord `json:"sidecars"`
+ Truncated bool `json:"truncated"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, false, fmt.Errorf("sidecars list: unreadable response: %w", err)
+ }
+ return res.Sidecars, res.Truncated, nil
+}
+
+// sidecarGet reads one sidecar record by name — what `carlos sidecars
+// logs` uses to resolve a short name into the synthetic host `carlos logs`
+// itself needs, without this client ever computing that host locally.
+func (s *apiSession) sidecarGet(ctx context.Context, a apiAcct, app, name string) (apiSidecarRecord, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/sidecars/"+url.PathEscape(name)))
+ if err != nil {
+ return apiSidecarRecord{}, err
+ }
+ if status != http.StatusOK {
+ return apiSidecarRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiSidecarRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiSidecarRecord{}, fmt.Errorf("sidecars get: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// sidecarDelete retires one sidecar's provisioning authorization.
+// Idempotent server-side (the caller's goal is "not there"), so a 200 is
+// the only success shape.
+func (s *apiSession) sidecarDelete(ctx context.Context, a apiAcct, app, name string) error {
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/sidecars/"+url.PathEscape(name)))
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
+// apiSidecarWakeResult mirrors handleAPISidecarWake's response body — only
+// the field this client renders (the sidecar's own host, in the confirmation
+// line) is decoded; "result"/"name" carry nothing this side doesn't already
+// know from the request it just made.
+type apiSidecarWakeResult struct {
+ Host string `json:"host"`
+}
+
+// sidecarWake asks every box serving this sidecar to wake it — pull-based,
+// design §7.4: nothing is pushed at a box, which is what lets it live in a
+// private subnet.
+func (s *apiSession) sidecarWake(ctx context.Context, a apiAcct, app, name string) (apiSidecarWakeResult, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/sidecars/"+url.PathEscape(name)+"/wake"), struct{}{})
+ if err != nil {
+ return apiSidecarWakeResult{}, err
+ }
+ if status != http.StatusOK {
+ return apiSidecarWakeResult{}, apiErrorFromBody(status, body)
+ }
+ var out apiSidecarWakeResult
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiSidecarWakeResult{}, fmt.Errorf("sidecars wake: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// apiDomainRecord mirrors the domains door's per-row wire shape
// (domainListItem, internal/console/apidomains.go) — DNSStatus and
// CertExpiresAt are the box-stamped reading columns and stay their zero
diff --git a/main.go b/main.go
index fd96f0e7b..d8c0df742 100644
--- a/main.go
+++ b/main.go
@@ -53,6 +53,8 @@ func main() {
runRoute(args)
case "instances":
runInstances(args)
+ case "sidecars":
+ runSidecars(args)
case "routes":
runRoutes(args)
case "domains":
@@ -160,6 +162,8 @@ Your apps — ship and run software on a CARLOS deployment
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete |
set-upstreams
+ sidecars a process nothing dials, one per enrolled member: enable |
+ create | ls | wake | rm | logs
routes an app's routes and what each is bound to: upstream,
channel, config environment, status
domains a customer's own hostname attached to an app's route:
diff --git a/sidecars_cli.go b/sidecars_cli.go
new file mode 100644
index 000000000..4ccab29c3
--- /dev/null
+++ b/sidecars_cli.go
@@ -0,0 +1,529 @@
+// The `carlos sidecars` verb family (Task 14, design: sidecar members): a
+// sidecar is a process the platform runs that serves no requests and that
+// nothing can dial, so its whole shape follows the instance family's
+// deliberately, minus the fields a sidecar has no use for. See
+// docs/superpowers/specs/2026-08-17-sidecar-members-design.md §11.
+//
+// - `enable` — the app's opt-in to sidecar provisioning. Two modes,
+// resolved credential-first exactly as instances enable's is
+// (instances_cli.go): store mode writes apps/<sqid>/<app>/sidecars.json
+// via provision.PutSidecarManifest with every field under the
+// operator's hand; API mode asks the console, which lets a member's own
+// args/placement/channel/idle/max-run/hibernate through untouched while
+// refusing -pool and -regions — those are operator, store-mode-only
+// policy fields (apisidecars.go's handleAPISidecarEnable).
+// - `create` / `ls` / `wake` / `rm` — the CLI front over the console's
+// sidecar record verbs. API mode ONLY: a record is a console-authorized
+// write, and there is deliberately NO box-side equivalent — a sidecar's
+// host is derived under the reserved .sidecar.internal suffix, and
+// `carlos add` refuses to mint one by hand (its reserved-suffix rail).
+// - `logs` delegates to the existing `carlos logs -host <h>` path for the
+// sidecar's own derived host: logs key on host, not on Kind (design
+// §9.3), so there is no separate sidecar-log machinery to keep in sync.
+//
+// Regions: `-regions` on `enable` takes PLAIN REGION CODES ONLY, never a
+// fleet placement target. A fleet-placed app's sidecars inherit the app's
+// own placement (set by `carlos apps place`), which overrides whatever the
+// manifest's Regions allow-list or a create request says; that allow-list is
+// only ever consulted for an UNPLACED app (Task 12 review finding). Offering
+// `-regions ie,fleet/eleven` here would be unsatisfiable and doubly
+// misleading, so this flag's help text points at `carlos apps place`
+// instead of pretending to accept fleet syntax.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const sidecarsEnableUsage = `usage: carlos sidecars enable -app <a> [flags]
+
+Signed-in mode (carlos auth login; no CARLOS_DEPLOYMENT_* env): the console
+writes the manifest under your own membership. -pool and -regions are
+operator, store-mode flags and are refused here.
+
+Store mode (CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR set): every field
+is the operator's call, and this write REPLACES the whole manifest — the
+same full-overwrite CAS upsert "carlos instances enable" uses, so a field
+you omit here resets to its own default, not to whatever a previous enable
+left it at.
+
+flags:
+ -app <a> app whose sidecars this enables (required)
+ -args "argv..." argv the activator execs the app's own binary
+ with, whitespace-split via strings.Fields — shell-
+ word semantics (quoting, escaping) are
+ deliberately NOT supported, same contract as
+ -seed on "carlos instances enable"
+ -placement <p> "any" (default) or "no-inbound" — no-inbound
+ mints only on boxes declaring CARLOS_INBOUND=none
+ (design §8)
+ -pool <p> pool stamped into every record at create (store
+ mode only; default: "", the shared pool)
+ -channel <c> release channel stamped into every record at
+ create (store-mode default "stable"; signed-in
+ mode: omitted = preserve what the manifest holds)
+ -regions <r1,r2,..> allow-list for a record's region, PLAIN REGION
+ CODES ONLY (store mode only); empty (default) =
+ any shape-valid region. Consulted only for an
+ UNPLACED app — a fleet-placed app's sidecars
+ inherit the app's own placement instead, set
+ with "carlos apps place", which overrides this
+ list (and anything a create request says)
+ entirely. There is no fleet syntax here.
+ -idle <dur> idle timeout before a hibernating sidecar sleeps
+ (default 5m; a Go duration — 5m, 1h — sent to
+ the wire as whole seconds, never a duration
+ string)
+ -max-run <dur> max run duration before the sweeper stops a
+ sidecar regardless of idle (default 1h; same
+ duration-to-seconds conversion as -idle)
+ -no-hibernate this app's sidecars never hibernate (default:
+ they do)
+ -account <a> account (sqid or name) the app belongs to
+ (default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY — supersedes
+ -account
+ -console <origin> console to enable through (signed-in mode only;
+ default: the only logged-in one)
+
+Writing this manifest is the app's opt-in to sidecar provisioning: absence
+means the feature does not exist for the app, and the API refuses creates
+until this is set (design §3.1, §11).
+`
+
+func runSidecarsEnable(args []string) {
+ fs := flag.NewFlagSet("sidecars enable", flag.ExitOnError)
+ app := fs.String("app", "", "app whose sidecars this enables")
+ argsFlag := fs.String("args", "", "argv the activator execs the app's own binary with, whitespace-split (strings.Fields)")
+ placement := fs.String("placement", "", `"any" (default) or "no-inbound"`)
+ pool := fs.String("pool", "", "pool stamped into every record at create (store mode only)")
+ channel := fs.String("channel", "", "release channel stamped into every record at create")
+ regions := fs.String("regions", "", "comma-separated region allow-list, plain region codes only (store mode only); empty = any shape-valid region — see carlos apps place for fleet placement")
+ idle := fs.String("idle", "", "idle timeout before a hibernating sidecar sleeps (default 5m; Go duration)")
+ maxRun := fs.String("max-run", "", "max run duration before the sweeper stops a sidecar (default 1h; Go duration)")
+ noHibernate := fs.Bool("no-hibernate", false, "this app's sidecars never hibernate (default: they do)")
+ consoleFlag := fs.String("console", "", "console to enable through (signed-in mode only; default: the only logged-in one)")
+ acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ fs.Parse(args)
+ if *app != "" && !release.ValidApp(*app) {
+ log.Fatalf("carlos sidecars enable: %q is not a valid app name", *app)
+ }
+ set := map[string]bool{}
+ fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ // Signed-in mode. -pool/-regions are refused rather than ignored —
+ // a flag that silently did nothing would read as policy applied.
+ for _, f := range []string{"pool", "regions"} {
+ if set[f] {
+ log.Fatalf("carlos sidecars enable: -%s is an operator, store-mode flag. Set CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR to enable in store mode instead", f)
+ }
+ }
+ var channelReq *string
+ if set["channel"] {
+ channelReq = channel
+ }
+ var argsReq *[]string
+ if set["args"] {
+ // parseArgv("") is an empty (non-nil after this) argv: an
+ // explicit -args "" clears the manifest's args, matching
+ // -channel's explicit-vs-absent split.
+ argv := parseArgv(*argsFlag)
+ if argv == nil {
+ argv = []string{}
+ }
+ argsReq = &argv
+ }
+ var placementReq *string
+ if set["placement"] {
+ placementReq = placement
+ }
+ var idleReq *int
+ if set["idle"] {
+ secs, err := parseDurationSeconds(*idle)
+ if err != nil {
+ log.Fatalf("carlos sidecars enable: -idle: %v", err)
+ }
+ idleReq = &secs
+ }
+ var maxRunReq *int
+ if set["max-run"] {
+ secs, err := parseDurationSeconds(*maxRun)
+ if err != nil {
+ log.Fatalf("carlos sidecars enable: -max-run: %v", err)
+ }
+ maxRunReq = &secs
+ }
+ var hibernateReq *bool
+ if *noHibernate {
+ no := false
+ hibernateReq = &no
+ }
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, sidecarsEnableUsage)
+ m, err := sess.sidecarsEnable(context.Background(), acct, app2,
+ channelReq, argsReq, placementReq, idleReq, maxRunReq, hibernateReq)
+ if err != nil {
+ log.Fatalf("sidecars enable: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s for %s (channel %s, placement %s)\n",
+ colorFor(stdout).ok("enabled sidecars"), m.App, m.Channel, m.Placement)
+ mutedf(stdout, " first sidecar: carlos sidecars create -app %s <name>", m.App)
+ return
+ }
+
+ // Store mode — the operator path: every field this manifest carries is
+ // set fresh on every call (PutSidecarManifest is a full-overwrite CAS
+ // upsert, not a patch), exactly as instances enable's own store-mode
+ // write is.
+ if *app == "" {
+ log.Fatal(sidecarsEnableUsage)
+ }
+ if *channel == "" && !set["channel"] {
+ *channel = "stable"
+ }
+ if !provision.ValidPool(*pool) {
+ log.Fatalf("carlos sidecars enable: %q is not a valid pool", *pool)
+ }
+ if !release.ValidChannel(*channel) {
+ log.Fatalf("carlos sidecars enable: %q is not a valid channel", *channel)
+ }
+ switch *placement {
+ case "", provision.PlacementAny, provision.PlacementNoInbound:
+ default:
+ log.Fatalf(`carlos sidecars enable: -placement %q: want "any" or "no-inbound"`, *placement)
+ }
+ var regionList []string
+ if *regions != "" {
+ for _, r := range strings.Split(*regions, ",") {
+ r = strings.TrimSpace(r)
+ if r == "" || !provision.ValidRegion(r) {
+ log.Fatalf("carlos sidecars enable: %q is not a valid region — -regions takes plain region codes only; for fleet placement use `carlos apps place`", r)
+ }
+ regionList = append(regionList, r)
+ }
+ }
+ var idleSecs int
+ if set["idle"] {
+ var err error
+ idleSecs, err = parseDurationSeconds(*idle)
+ if err != nil {
+ log.Fatalf("carlos sidecars enable: -idle: %v", err)
+ }
+ }
+ var maxRunSecs int
+ if set["max-run"] {
+ var err error
+ maxRunSecs, err = parseDurationSeconds(*maxRun)
+ if err != nil {
+ log.Fatalf("carlos sidecars enable: -max-run: %v", err)
+ }
+ }
+ m := provision.SidecarManifest{
+ Schema: 1, App: *app, Pool: *pool, Channel: *channel, Placement: *placement,
+ Regions: regionList, Args: parseArgv(*argsFlag),
+ IdleSeconds: idleSecs, MaxRunSeconds: maxRunSecs,
+ }
+ if *noHibernate {
+ no := false
+ m.Hibernate = &no
+ }
+ if err := sidecarsEnable(context.Background(), openStore(), acctOf(), *app, m); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("%s for %s (channel %s)\n", colorFor(os.Stdout).ok("enabled sidecars"), *app, *channel)
+}
+
+// parseArgv splits -args's raw value on whitespace (strings.Fields) into
+// the manifest's Args argv — instances enable's -seed contract
+// (instances_cli.go's parseSeed), carried over unchanged: quoting and
+// escaping are deliberately NOT supported, and anything richer belongs in
+// the app's own argv parsing.
+func parseArgv(raw string) []string {
+ return strings.Fields(raw)
+}
+
+// parseDurationSeconds parses a Go duration flag value (e.g. "5m", "1h")
+// into whole seconds for the wire, which carries idle/max-run timeouts as
+// plain integer seconds, never a duration string (design §4.1,
+// provision.SidecarManifest.IdleSeconds/MaxRunSeconds). A negative duration
+// is refused here rather than reaching the console — or, in store mode, the
+// bucket — as a nonsense wire value.
+func parseDurationSeconds(raw string) (int, error) {
+ d, err := time.ParseDuration(raw)
+ if err != nil {
+ return 0, fmt.Errorf("%q is not a valid duration (e.g. 5m, 1h): %w", raw, err)
+ }
+ if d < 0 {
+ return 0, fmt.Errorf("%q must not be negative", raw)
+ }
+ return int(d / time.Second), nil
+}
+
+// sidecarsEnable is the core store-mode `carlos sidecars enable` write —
+// separated from flag parsing/validation above so a test can call it
+// directly against a release.Store, instancesEnable's own precedent
+// (instances_cli.go).
+func sidecarsEnable(ctx context.Context, s release.Store, acct, app string, m provision.SidecarManifest) error {
+ return provision.PutSidecarManifest(ctx, s, acct, app, m)
+}
+
+const sidecarsRecordsUsage = `usage: carlos sidecars create -app <a> [-region <r>] [-channel <c>] <name>
+ carlos sidecars ls -app <a> [-long]
+ carlos sidecars wake -app <a> <name>
+ carlos sidecars rm -app <a> <name>
+ carlos sidecars logs -app <a> <name>
+
+Signed-in only (carlos auth login): sidecar records are console-authorized
+writes — the console checks the name and region against the app's manifest
+(and a fleet-placed app's own placement, which overrides both) and a box's
+sidecar reconcile pass turns the record into a running process. Common
+flags:
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+create-only flags:
+
+ -region <r> region code for the record, subject to the manifest's
+ allow-list — or overridden entirely by the app's own
+ placement, when it has one (carlos apps place)
+ -channel <c> release channel the sidecar follows (default: the
+ manifest's, then "stable")
+
+ls-only flags:
+
+ -long print the synthetic host (<name>.<app>.<sqid>.sidecar.internal)
+ instead of the short name
+
+There is no set-upstreams for a sidecar: it has no upstream set, and
+nothing dials it.
+`
+
+// requireSidecarsAPISession resolves signed-in mode for the sidecar record
+// verbs, refusing direct mode by NAME — sidecars' own advice, never
+// instances' (Task 12 review finding, binding on this task): `carlos add`
+// explicitly refuses to mint a sidecar host (its reserved-suffix rail), so
+// a refusal here that pointed at that verb would send an operator
+// somewhere that cannot help them. There is no box-side equivalent for a
+// sidecar record at all — this family is console-authorized only.
+func requireSidecarsAPISession(verb, consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatalf("carlos sidecars %s works through a console, and this shell is in direct bucket mode\n"+
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Sidecar records are console-authorized\n"+
+ "writes with no direct-bucket equivalent; unset the store env and run: carlos auth login -console <url>.\n"+
+ "There is no box-side way to mint a sidecar host by hand — carlos add refuses the reserved suffix", verb)
+ }
+ return sess
+}
+
+func runSidecarsCreate(args []string) {
+ fs := flag.NewFlagSet("sidecars create", flag.ExitOnError)
+ app := fs.String("app", "", "app the sidecar belongs to")
+ region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list, or the app's own placement)")
+ channel := fs.String("channel", "", "release channel the sidecar follows (default: the manifest's, then stable)")
+ consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if fs.NArg() != 1 || strings.TrimSpace(fs.Arg(0)) == "" {
+ log.Fatal(sidecarsRecordsUsage)
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ sess := requireSidecarsAPISession("create", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, sidecarsRecordsUsage)
+ rec, err := sess.sidecarCreate(context.Background(), acct, app2, name, strings.TrimSpace(*region), strings.TrimSpace(*channel))
+ if err != nil {
+ log.Fatalf("sidecars create: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s (channel %s)\n", colorFor(stdout).ok("created sidecar"), rec.Name, rec.Channel)
+ mutedf(stdout, " host: %s — it comes live on the next reconcile pass on its box", rec.Host)
+}
+
+// sidecarVersionCell renders `ls`'s VERSION column: the raw running version
+// when the sidecar has reported one via /v1/ready, or the literal
+// "unknown" when it never has — the honesty rule this branch was built
+// around (design §9.2, binding on this task): a stamp that overclaims is
+// worse than an absent one, so this must NEVER render the record's own
+// Channel (the desired version) as a stand-in for a version nobody has
+// confirmed is actually running.
+func sidecarVersionCell(running string) string {
+ if running == "" {
+ return "unknown"
+ }
+ return running
+}
+
+// sidecarPlacementCell renders a record's placement, defaulting the same
+// way provision.SidecarManifest.PlacementOrDefault does server-side: empty
+// means "any".
+func sidecarPlacementCell(placement string) string {
+ if placement == "" {
+ return provision.PlacementAny
+ }
+ return placement
+}
+
+func runSidecarsList(args []string) {
+ fs := flag.NewFlagSet("sidecars ls", flag.ExitOnError)
+ app := fs.String("app", "", "app whose sidecar records to list")
+ long := fs.Bool("long", false, "print the synthetic host instead of the short name")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ sess := requireSidecarsAPISession("ls", *consoleFlag)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, sidecarsRecordsUsage)
+ recs, truncated, err := sess.sidecarList(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatalf("sidecars ls: %v", err)
+ }
+ w := newTable(stdout)
+ idCol := "NAME"
+ if *long {
+ idCol = "HOST"
+ }
+ fmt.Fprintln(w, idCol+"\tREGION\tCHANNEL\tPOOL\tPLACEMENT\tVERSION\tCREATED\tBY")
+ for _, r := range recs {
+ id := r.Name
+ if *long {
+ id = r.Host
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ id, r.Region, r.Channel, r.Pool, sidecarPlacementCell(r.Placement),
+ sidecarVersionCell(r.RunningVersion), r.CreatedAt, r.CreatedBy)
+ }
+ w.Flush()
+ if truncated {
+ mutedf(stdout, "(truncated at 500 records)")
+ }
+}
+
+func runSidecarsWake(args []string) {
+ fs := flag.NewFlagSet("sidecars wake", flag.ExitOnError)
+ app := fs.String("app", "", "app the sidecar belongs to")
+ consoleFlag := fs.String("console", "", "console to wake through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if fs.NArg() != 1 || strings.TrimSpace(fs.Arg(0)) == "" {
+ log.Fatal(sidecarsRecordsUsage)
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ sess := requireSidecarsAPISession("wake", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, sidecarsRecordsUsage)
+ res, err := sess.sidecarWake(context.Background(), acct, app2, name)
+ if err != nil {
+ log.Fatalf("sidecars wake: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("requested wake for"), name)
+ mutedf(stdout, " host: %s — the box notices within seconds of the next serial move", res.Host)
+}
+
+func runSidecarsRm(args []string) {
+ fs := flag.NewFlagSet("sidecars rm", flag.ExitOnError)
+ app := fs.String("app", "", "app the sidecar belongs to")
+ consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if fs.NArg() != 1 || strings.TrimSpace(fs.Arg(0)) == "" {
+ log.Fatal(sidecarsRecordsUsage)
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ sess := requireSidecarsAPISession("rm", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, sidecarsRecordsUsage)
+ if err := sess.sidecarDelete(context.Background(), acct, app2, name); err != nil {
+ log.Fatalf("sidecars rm: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s — its process stops on the next reconcile pass; parked data stays\n",
+ colorFor(stdout).ok("deleted sidecar"), name)
+}
+
+// runSidecarsLogs delegates to the existing `carlos logs` path for the
+// sidecar's own derived host: logs key on host, not on Kind (design §9.3),
+// so there is no separate sidecar-log machinery to keep in sync. The name
+// is resolved to its host through sidecarGet rather than computed locally,
+// so this client never has to re-derive provision.SidecarHost's shape.
+func runSidecarsLogs(args []string) {
+ fs := flag.NewFlagSet("sidecars logs", flag.ExitOnError)
+ app := fs.String("app", "", "app the sidecar belongs to")
+ consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if fs.NArg() != 1 || strings.TrimSpace(fs.Arg(0)) == "" {
+ log.Fatal(sidecarsRecordsUsage)
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ sess := requireSidecarsAPISession("logs", *consoleFlag)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, sidecarsRecordsUsage)
+ rec, err := sess.sidecarGet(context.Background(), acct, app2, name)
+ if err != nil {
+ log.Fatalf("sidecars logs: %v", err)
+ }
+ logsArgs := []string{"-app", app2, "-host", rec.Host}
+ if *consoleFlag != "" {
+ logsArgs = append(logsArgs, "-console", *consoleFlag)
+ }
+ if *acctRaw != "" {
+ logsArgs = append(logsArgs, "-account", *acctRaw)
+ }
+ if *acctIDRaw != "" {
+ logsArgs = append(logsArgs, "-account-id", *acctIDRaw)
+ }
+ runLogs(logsArgs)
+}
+
+// runSidecars dispatches carlos sidecars' subcommand words BEFORE any flag
+// parsing — runInstances' own contract (cli.go): a subcommand word is not
+// a flag. Unlike instances, every word here is required; there is no bare
+// box-local listing form for a sidecar (it has no route to list).
+func runSidecars(args []string) {
+ if len(args) == 0 {
+ log.Fatal(sidecarsEnableUsage)
+ }
+ switch args[0] {
+ case "enable":
+ runSidecarsEnable(args[1:])
+ case "create":
+ runSidecarsCreate(args[1:])
+ case "ls":
+ runSidecarsList(args[1:])
+ case "wake":
+ runSidecarsWake(args[1:])
+ case "rm":
+ runSidecarsRm(args[1:])
+ case "logs":
+ runSidecarsLogs(args[1:])
+ default:
+ log.Fatalf("carlos sidecars: unknown subcommand %q\n%s", args[0], sidecarsEnableUsage)
+ }
+}
diff --git a/sidecars_cli_test.go b/sidecars_cli_test.go
new file mode 100644
index 000000000..073f54c38
--- /dev/null
+++ b/sidecars_cli_test.go
@@ -0,0 +1,149 @@
+// Tests for the `carlos sidecars` core — flag parsing/validation and
+// printing live in sidecars_cli.go's run* functions, which os.Exit() on a
+// bad invocation via log.Fatal and so aren't themselves a good unit-test
+// target (instances_cli_test.go's own doc comment: no argv test harness
+// exists in this package for os.Exit paths). What's tested directly
+// instead is sidecarsEnable, the core PutSidecarManifest wrapper;
+// parseArgv, the -args flag's whitespace-split helper; parseDurationSeconds,
+// the -idle/-max-run duration-to-seconds conversion; and the two small
+// render helpers `ls`'s VERSION and PLACEMENT columns go through.
+package main
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestSidecarsEnableWritesManifest(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ no := false
+ m := provision.SidecarManifest{
+ Schema: 1, App: "titogo", Pool: "tito", Channel: "stable",
+ Placement: provision.PlacementNoInbound, Regions: []string{"ie"},
+ Args: []string{"sidecar", "run"}, Hibernate: &no,
+ IdleSeconds: 300, MaxRunSeconds: 3600,
+ }
+
+ if err := sidecarsEnable(ctx, s, "bad", "titogo", m); err != nil {
+ t.Fatalf("sidecarsEnable: %v", err)
+ }
+
+ got, err := provision.GetSidecarManifest(ctx, s, "bad", "titogo")
+ if err != nil {
+ t.Fatalf("GetSidecarManifest: %v", err)
+ }
+ if got.App != m.App || got.Pool != m.Pool || got.Channel != m.Channel ||
+ got.Placement != m.Placement || len(got.Regions) != 1 || got.Regions[0] != "ie" ||
+ len(got.Args) != 2 || got.Args[0] != "sidecar" || got.Args[1] != "run" ||
+ got.Hibernate == nil || *got.Hibernate != false ||
+ got.IdleSeconds != 300 || got.MaxRunSeconds != 3600 {
+ t.Errorf("GetSidecarManifest after sidecarsEnable mismatch: got %+v, want %+v", got, m)
+ }
+
+ // Re-running with a changed pool must update the manifest in place — a
+ // CAS upsert, not a create-only write, exactly as instancesEnable's own
+ // re-run test pins (instances_cli_test.go).
+ m.Pool = "other"
+ if err := sidecarsEnable(ctx, s, "bad", "titogo", m); err != nil {
+ t.Fatalf("sidecarsEnable (update): %v", err)
+ }
+ got, err = provision.GetSidecarManifest(ctx, s, "bad", "titogo")
+ if err != nil {
+ t.Fatalf("GetSidecarManifest after update: %v", err)
+ }
+ if got.Pool != "other" {
+ t.Errorf("Pool after update = %q, want %q", got.Pool, "other")
+ }
+}
+
+func TestParseArgv(t *testing.T) {
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"", nil},
+ {"sidecar", []string{"sidecar"}},
+ {"sidecar run", []string{"sidecar", "run"}},
+ {" sidecar run ", []string{"sidecar", "run"}},
+ }
+ for _, c := range cases {
+ got := parseArgv(c.in)
+ if len(got) != len(c.want) {
+ t.Errorf("parseArgv(%q) = %v, want %v", c.in, got, c.want)
+ continue
+ }
+ for i := range got {
+ if got[i] != c.want[i] {
+ t.Errorf("parseArgv(%q) = %v, want %v", c.in, got, c.want)
+ break
+ }
+ }
+ }
+}
+
+// TestParseDurationSeconds pins the wire's own contract (design §4.1):
+// idle/max-run cross as whole SECONDS, never a duration string — "5m" must
+// become exactly 300, "1h" exactly 3600.
+func TestParseDurationSeconds(t *testing.T) {
+ cases := []struct {
+ in string
+ want int
+ wantErr bool
+ }{
+ {"5m", 300, false},
+ {"1h", 3600, false},
+ {"0s", 0, false},
+ {"30s", 30, false},
+ {"-5m", 0, true},
+ {"banana", 0, true},
+ }
+ for _, c := range cases {
+ got, err := parseDurationSeconds(c.in)
+ if c.wantErr {
+ if err == nil {
+ t.Errorf("parseDurationSeconds(%q) = %d, <nil>, want an error", c.in, got)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("parseDurationSeconds(%q) unexpected error: %v", c.in, err)
+ continue
+ }
+ if got != c.want {
+ t.Errorf("parseDurationSeconds(%q) = %d, want %d", c.in, got, c.want)
+ }
+ }
+}
+
+// TestSidecarVersionCell pins `ls`'s honesty rule directly (design §9.2,
+// binding on this task): a sidecar that has never reported a version must
+// render "unknown", never the record's own channel or anything else that
+// would read as a current, confirmed version.
+func TestSidecarVersionCell(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"", "unknown"},
+ {"abc1234", "abc1234"},
+ }
+ for _, c := range cases {
+ if got := sidecarVersionCell(c.in); got != c.want {
+ t.Errorf("sidecarVersionCell(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestSidecarPlacementCell(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"", provision.PlacementAny},
+ {provision.PlacementNoInbound, provision.PlacementNoInbound},
+ {provision.PlacementAny, provision.PlacementAny},
+ }
+ for _, c := range cases {
+ if got := sidecarPlacementCell(c.in); got != c.want {
+ t.Errorf("sidecarPlacementCell(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
diff --git a/sidecars_e2e_test.go b/sidecars_e2e_test.go
new file mode 100644
index 000000000..9c86d0452
--- /dev/null
+++ b/sidecars_e2e_test.go
@@ -0,0 +1,163 @@
+// End-to-end acceptance test for `carlos sidecars` (Task 14): the member
+// verbs — enable/create/ls/wake/rm — working through the console API with
+// `carlos auth login` alone, mirroring TestE2EInstancesThroughConsole's
+// shape (instances_e2e_test.go) exactly: a REAL console.Server over a
+// DirStore, a device-flow login landing a real bearer token in a temp
+// credential file, then — with NO store env set — the real run* functions
+// driven in-process. Ground truth is the DirStore's own manifest and
+// record objects, plus the `stdout` seam's confirmation lines.
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestE2ESidecarsThroughConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const addr = "paul@keymail.dev"
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // --- enable: member-facing manifest write, no domain/host to pin -------
+ buf := captureStdout(t)
+ runSidecarsEnable([]string{"-app", "titogo", "-account", "ops", "-channel", "beta",
+ "-args", "sidecar run", "-placement", "no-inbound"})
+ if want := "enabled sidecars for titogo"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("enable stdout = %q, want to contain %q", buf.String(), want)
+ }
+ m, err := provision.GetSidecarManifest(context.Background(), relStore, release.OpsAccount, "titogo")
+ if err != nil {
+ t.Fatalf("manifest after enable: %v", err)
+ }
+ if m.Channel != "beta" || m.Placement != "no-inbound" ||
+ len(m.Args) != 2 || m.Args[0] != "sidecar" || m.Args[1] != "run" {
+ t.Fatalf("manifest = %+v, want channel beta, placement no-inbound, args [sidecar run]", m)
+ }
+
+ // --- create: the derived synthetic host, one label under the account's
+ // sqid (release.OpsAccount == "bab" in this harness, instances_e2e_test.go's
+ // own pin) ---------------------------------------------------------------
+ const wantHost = "bot-acme.titogo.bab.sidecar.internal"
+ buf.Reset()
+ runSidecarsCreate([]string{"-app", "titogo", "-account", "ops", "bot-acme"})
+ if want := "created sidecar bot-acme (channel beta)"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("create stdout = %q, want %q", buf.String(), want)
+ }
+ rec, err := provision.GetSidecarRecord(context.Background(), relStore, release.OpsAccount, "titogo", "bot-acme")
+ if err != nil {
+ t.Fatalf("record after create: %v", err)
+ }
+ if rec.Channel != "beta" || rec.Name != "bot-acme" || rec.CreatedBy != addr || rec.Host != wantHost {
+ t.Fatalf("record = %+v, want beta/bot-acme created by %s at host %s", rec, addr, wantHost)
+ }
+
+ // --- ls: short name by default, honest "unknown" version since no box
+ // has ever reported this fresh record ------------------------------------
+ buf.Reset()
+ runSidecarsList([]string{"-app", "titogo", "-account", "ops"})
+ shortOut := buf.String()
+ if !strings.Contains(shortOut, "bot-acme") {
+ t.Fatalf("ls stdout = %q, want the short name bot-acme", shortOut)
+ }
+ if strings.Contains(shortOut, wantHost) {
+ t.Fatalf("ls stdout = %q, must NOT show the synthetic host without -long", shortOut)
+ }
+ if !strings.Contains(shortOut, "unknown") {
+ t.Fatalf("ls stdout = %q, want VERSION unknown — the honesty rule: a stamp that overclaims is worse than an absent one", shortOut)
+ }
+
+ // --- ls -long: the synthetic host, not the short name alone -------------
+ buf.Reset()
+ runSidecarsList([]string{"-app", "titogo", "-account", "ops", "-long"})
+ longOut := buf.String()
+ if !strings.Contains(longOut, wantHost) {
+ t.Fatalf("ls -long stdout = %q, want the synthetic host %q", longOut, wantHost)
+ }
+
+ // --- wake -----------------------------------------------------------
+ buf.Reset()
+ runSidecarsWake([]string{"-app", "titogo", "-account", "ops", "bot-acme"})
+ if want := "requested wake for bot-acme"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("wake stdout = %q, want %q", buf.String(), want)
+ }
+
+ // --- rm: idempotent record retirement ------------------------------
+ buf.Reset()
+ runSidecarsRm([]string{"-app", "titogo", "-account", "ops", "bot-acme"})
+ if want := "deleted sidecar bot-acme"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("rm stdout = %q, want %q", buf.String(), want)
+ }
+ if _, err := provision.GetSidecarRecord(context.Background(), relStore, release.OpsAccount, "titogo", "bot-acme"); err == nil {
+ t.Fatalf("record survived rm")
+ }
+
+ // The whole flow ran through the console: nothing here may have touched
+ // a local registry.
+ checkNoRegistry()
+}