carlos / platform Public

feat(cli): carlos instances enable; add shares provision's route conventions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d49550f9f44863cacd4a460325b02852d2d8b69a parent 3405d77
4 files changed, +224 −3
  • cli.go +15 −3
  • docs/superpowers/specs/2026-08-04-instance-provisioning-design.md +11 −0
  • instances_cli.go +119 −0
  • instances_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
+ }
+ }
+ }
+}