feat(cli): carlos instances enable; add shares provision's route conventions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+224
−3
cli.go+15 −3docs/superpowers/specs/2026-08-04-instance-provisioning-design.md+11 −0instances_cli.go+119 −0instances_cli_test.go+79 −0
diff --git a/cli.go b/cli.go| index a95989041..1828e3ff0 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -26,6 +26,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -91,10 +92,13 @@ func runAdd(args []string) { |
| if *socket == "" && *addr == "" && *kind == "instance" { |
| if *backing == registry.BackingUnit { |
| // The carlos-app@<host>.socket unit's path — enable that unit |
| - // alongside this route (or use -addr for a TCP-only app). |
| + // alongside this route (or use -addr for a TCP-only app). Not a |
| + // provision.DefaultSocket concern: that helper names the |
| + // exec-child convention, and a unit tenant's socket directory is |
| + // a different (systemd-owned) tree entirely. |
| *socket = filepath.Join("/run/carlos/apps", host+".sock") |
| } else { |
| - *socket = filepath.Join("/run/carlos", host+".sock") |
| + *socket = provision.DefaultSocket(host) |
| } |
| } |
| // No DB default for unit tenants: their data lives in the unit's |
| @@ -106,7 +110,7 @@ func runAdd(args []string) { |
| // database path from this flag; leave it unset and that tenant's data |
| // is replicated by nobody. |
| if *dbPath == "" && *kind == "instance" && *backing != registry.BackingUnit { |
| - *dbPath = filepath.Join(dataDir(), host+".db") |
| + *dbPath = provision.DefaultDBPath(dataDir(), host) |
| } |
| // A hibernating unit tenant stops and starts with systemd, not the |
| // agent — box-wide litestream (root) is the ONLY thing that can |
| @@ -156,6 +160,14 @@ func runRemove(args []string) { |
| } |
| func runInstances(args []string) { |
| + // "enable" is the one subcommand this verb grows (Task 8); every other |
| + // invocation is the bare listing form below, unchanged. Checked before |
| + // the flagset below is even built, since "enable" is a positional word, |
| + // not a flag, and must never reach fs.Parse. |
| + if len(args) > 0 && args[0] == "enable" { |
| + runInstancesEnable(args[1:]) |
| + return |
| + } |
| fs := flag.NewFlagSet("instances", flag.ExitOnError) |
| reg := fs.String("registry", defaultRegistry(), "registry database") |
| fs.Parse(args) |
diff --git a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md| index 5d42ef332..c19c5c7e0 100644 |
| --- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| @@ -330,6 +330,17 @@ follow-up, not this task): signup validates its own reserved slugs, calls |
| systemd-per-instance and generated litestream config retire per tito spec |
| §4 as instances migrate to activator management. |
| +**Follow-up (Task 7 review): app-token authority does not re-derive from |
| +membership.** An app token authorizes the instance-create/list/delete verbs |
| +for its own app regardless of who currently belongs to the account: a |
| +member who mints one and is later removed from the account keeps a token |
| +that still works, and only that ex-member (holding the token) can revoke |
| +it — there is no account-scoped listing of outstanding app tokens an |
| +operator can use to find and kill it after the fact. Follow-up is one of: |
| +an account-scoped token listing (so a remaining member can enumerate and |
| +revoke), or a gate-time membership re-check for the minter on every use |
| +(which trades a cheap gate for a lookup per call). Not built here. |
| + |
| ## 13. Testing |
| - `internal/provision`: record/manifest validation matrices, key parsing |
diff --git a/instances_cli.go b/instances_cli.go| new file mode 100644 |
| index 000000000..c3f84378b |
| --- /dev/null |
| +++ b/instances_cli.go |
| @@ -0,0 +1,119 @@ |
| +// The `carlos instances enable` verb: an operator's opt-in for the |
| +// per-app instance-provisioning feature (spec §3.1). Writes |
| +// apps/<sqid>/<app>/instances.json via provision.PutManifest, the |
| +// "carlos env set" pattern applied to the provisioning manifest — same |
| +// CAS-upsert discipline, not create-only, so re-running with a changed |
| +// value updates the manifest in place. Everything else instance-shaped |
| +// (create/list/delete of records themselves) is API-only per spec §9; no |
| +// CLI front for those is built here. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const instancesEnableUsage = `usage: carlos instances enable -app <a> -domain <d> [flags] |
| + |
| +flags: |
| + -app <a> app whose instances this enables (required) |
| + -domain <d> every instance host must be exactly one DNS label |
| + + "." + this domain (required) |
| + -pool <p> pool stamped into every record at create (default: |
| + "", the shared pool) |
| + -channel <c> release channel stamped into every record at |
| + create (default "stable"); the request may |
| + override channel, never pool |
| + -regions <r1,r2,...> allow-list for a record's region; empty (default) |
| + = any shape-valid region |
| + -seed "args..." argv of the app's first-boot init command, |
| + whitespace-split via strings.Fields — shell-word |
| + semantics (quoting, escaping) are deliberately NOT |
| + supported here; anything richer belongs in the app |
| + -account <a> account (sqid or name) the app belongs to |
| + (default "ops") |
| + -account-id <id> account sqid, matched as a sqid ONLY — supersedes |
| + -account |
| + |
| +Writing this manifest is the app's opt-in to instance provisioning: absence |
| +means the feature does not exist for the app, and the API refuses creates |
| +until this is set (spec §3.1). |
| +` |
| + |
| +func runInstancesEnable(args []string) { |
| + fs := flag.NewFlagSet("instances enable", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose instances this enables") |
| + domain := fs.String("domain", "", "instance host domain suffix") |
| + pool := fs.String("pool", "", "pool stamped into every record at create") |
| + channel := fs.String("channel", "stable", "release channel stamped into every record at create") |
| + regions := fs.String("regions", "", "comma-separated region allow-list; empty = any shape-valid region") |
| + seed := fs.String("seed", "", "argv of the app's first-boot init command, whitespace-split (strings.Fields)") |
| + _, _, acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" || *domain == "" { |
| + log.Fatal(instancesEnableUsage) |
| + } |
| + if !release.ValidApp(*app) { |
| + log.Fatalf("carlos instances enable: %q is not a valid app name", *app) |
| + } |
| + // A domain is dot-joined labels with no suffix of its own to check |
| + // against (unlike a host, which is validated against ITS domain by |
| + // provision.ValidHost) — ValidHostShape is exactly that shape check, |
| + // refusing a hostile domain before it is ever stamped into a manifest |
| + // every future host gets checked against. |
| + if !provision.ValidHostShape(*domain) { |
| + log.Fatalf("carlos instances enable: %q is not a valid domain", *domain) |
| + } |
| + if !provision.ValidPool(*pool) { |
| + log.Fatalf("carlos instances enable: %q is not a valid pool", *pool) |
| + } |
| + if !release.ValidChannel(*channel) { |
| + log.Fatalf("carlos instances enable: %q is not a valid channel", *channel) |
| + } |
| + var regionList []string |
| + if *regions != "" { |
| + for _, r := range strings.Split(*regions, ",") { |
| + r = strings.TrimSpace(r) |
| + if r == "" || !provision.ValidRegion(r) { |
| + log.Fatalf("carlos instances enable: %q is not a valid region", r) |
| + } |
| + regionList = append(regionList, r) |
| + } |
| + } |
| + m := provision.Manifest{ |
| + Schema: 1, |
| + App: *app, |
| + Domain: *domain, |
| + Pool: *pool, |
| + Channel: *channel, |
| + Regions: regionList, |
| + Seed: parseSeed(*seed), |
| + } |
| + if err := instancesEnable(context.Background(), openStore(), acctOf(), *app, m); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("enabled instances for %s under %s\n", *app, *domain) |
| +} |
| + |
| +// parseSeed splits -seed's raw value on whitespace (strings.Fields) into the |
| +// manifest's Seed argv. Shell-word semantics — quoting, escaping, anything |
| +// beyond a flat whitespace-separated argv — are deliberately NOT supported: |
| +// this flag is shell-word-free by contract, and anything richer belongs in |
| +// the app's own first-boot init command, not in this flag's parsing. |
| +func parseSeed(raw string) []string { |
| + return strings.Fields(raw) |
| +} |
| + |
| +// instancesEnable is the core `carlos instances enable` writes through — |
| +// separated from flag parsing/validation above so a test can call it |
| +// directly against a release.Store, the same shape provision_test.go's |
| +// TestManifestRoundTrip exercises PutManifest with. |
| +func instancesEnable(ctx context.Context, s release.Store, acct, app string, m provision.Manifest) error { |
| + return provision.PutManifest(ctx, s, acct, app, m) |
| +} |
diff --git a/instances_cli_test.go b/instances_cli_test.go| new file mode 100644 |
| index 000000000..1a45822e2 |
| --- /dev/null |
| +++ b/instances_cli_test.go |
| @@ -0,0 +1,79 @@ |
| +// Tests for the `carlos instances enable` core — flag parsing/validation and |
| +// printing live in instances_cli.go's runInstancesEnable, which os.Exit()s on |
| +// a bad invocation via log.Fatal and so isn't itself a good unit-test target |
| +// (no established env-verb argv test harness exists in this package — |
| +// appconfig_cli.go, the pattern this verb follows, has none either). What's |
| +// tested directly instead is instancesEnable, the core PutManifest wrapper, |
| +// and parseSeed, the -seed flag's whitespace-split helper. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestInstancesEnableWritesManifest(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + m := provision.Manifest{ |
| + Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Channel: "stable", |
| + Regions: []string{"ie"}, Seed: []string{"seed", "-v"}, |
| + } |
| + |
| + if err := instancesEnable(ctx, s, "bad", "tito", m); err != nil { |
| + t.Fatalf("instancesEnable: %v", err) |
| + } |
| + |
| + got, err := provision.GetManifest(ctx, s, "bad", "tito") |
| + if err != nil { |
| + t.Fatalf("GetManifest: %v", err) |
| + } |
| + if got.App != m.App || got.Domain != m.Domain || got.Pool != m.Pool || |
| + got.Channel != m.Channel || len(got.Regions) != 1 || got.Regions[0] != "ie" || |
| + len(got.Seed) != 2 || got.Seed[0] != "seed" || got.Seed[1] != "-v" { |
| + t.Errorf("GetManifest after instancesEnable 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 (the "carlos env set" pattern |
| + // spec §3.1 names). |
| + m.Pool = "other" |
| + if err := instancesEnable(ctx, s, "bad", "tito", m); err != nil { |
| + t.Fatalf("instancesEnable (update): %v", err) |
| + } |
| + got, err = provision.GetManifest(ctx, s, "bad", "tito") |
| + if err != nil { |
| + t.Fatalf("GetManifest after update: %v", err) |
| + } |
| + if got.Pool != "other" { |
| + t.Errorf("Pool after update = %q, want %q", got.Pool, "other") |
| + } |
| +} |
| + |
| +func TestParseSeed(t *testing.T) { |
| + cases := []struct { |
| + in string |
| + want []string |
| + }{ |
| + {"", nil}, |
| + {"seed", []string{"seed"}}, |
| + {"seed -v", []string{"seed", "-v"}}, |
| + {" seed -v ", []string{"seed", "-v"}}, |
| + } |
| + for _, c := range cases { |
| + got := parseSeed(c.in) |
| + if len(got) != len(c.want) { |
| + t.Errorf("parseSeed(%q) = %v, want %v", c.in, got, c.want) |
| + continue |
| + } |
| + for i := range got { |
| + if got[i] != c.want[i] { |
| + t.Errorf("parseSeed(%q) = %v, want %v", c.in, got, c.want) |
| + break |
| + } |
| + } |
| + } |
| +} |