Merge pull request #67 from carlosframework/issue-66-api-go-live
Console API + CLI: bring an app live on its platform domain with a login alone (#66)
14 files changed,
+1150
−43
README.md+6 −3apimode.go+133 −0cli.go+20 −7cmd/console/main.go+27 −2docs/superpowers/specs/2026-08-04-instance-provisioning-design.md+65 −0instances_cli.go+204 −25instances_e2e_test.go+142 −0internal/console/apiinstances.go+169 −0internal/console/apiinstancesenable_test.go+293 −0internal/console/authcli.go+4 −0internal/console/server.go+9 −0internal/provision/provision.go+31 −5internal/provision/provision_test.go+40 −0main.go+7 −1
diff --git a/README.md b/README.md| index d02050170..f9c453f57 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -292,9 +292,12 @@ publish an inflated instance count. |
| **Config surface:** |
| - `PLATFORM_APPS_DOMAIN` — the deployment's apps domain (flagship: |
| - `oncarlos.com`), read by `carlos agent`. **Unset = the whole feature is |
| - inert**: no minting, no manifests read, zero behavior change — the same |
| - litestream convention as everything else here. |
| + `oncarlos.com`), read by `carlos agent`, the edge, and the console (the |
| + member-facing `carlos instances enable` pins manifests to |
| + `<sqid>.<this domain>`; the console refuses a malformed value at boot). |
| + **Unset = the whole feature is inert**: no minting, no manifests read, |
| + the enable door answers 503 — the same litestream convention as |
| + everything else here. |
| - `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks |
| compare against, read by both the agent (checks) and the console |
| (guidance text), so the two can never name different addresses. Default: |
diff --git a/apimode.go b/apimode.go| index 09378fb82..ec697f2f3 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -786,6 +786,139 @@ func (s *apiSession) configSync(ctx context.Context, a apiAcct, app string) erro |
| return nil |
| } |
| +// deleteJSON runs one DELETE and hands back the status and raw body without |
| +// interpreting a non-200 as an error itself — getJSON's plumbing with the |
| +// method swapped, for the instance-record retire door (issue #66), the one |
| +// DELETE this client speaks. |
| +func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte, error) { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.c.base+path, nil) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + if s.c.token != "" { |
| + req.Header.Set("Authorization", "Bearer "+s.c.token) |
| + } |
| + res, err := s.c.httpc.Do(req) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + defer res.Body.Close() |
| + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| + if err != nil { |
| + return res.StatusCode, nil, err |
| + } |
| + return res.StatusCode, body, nil |
| +} |
| + |
| +// apiInstanceManifest is the instances-manifest door's response — the |
| +// manifest as the console now holds it, echoed so the CLI prints the domain |
| +// the SERVER pinned (the account's platform subdomain), never one this side |
| +// guessed. |
| +type apiInstanceManifest struct { |
| + App string `json:"app"` |
| + Domain string `json:"domain"` |
| + Pool string `json:"pool"` |
| + Channel string `json:"channel"` |
| + Regions []string `json:"regions"` |
| + Seed []string `json:"seed"` |
| +} |
| + |
| +// instancesEnable asks the console to write (or update) the app's instances |
| +// manifest under the account's own platform subdomain — the member-facing |
| +// half of `carlos instances enable` (issue #66). channel and seed are |
| +// pointers for the same reason apiConfigApply's ExecDelivery is: absent |
| +// means "preserve what the manifest carries", and seed's explicit empty |
| +// slice means "clear" — three requests the wire must keep apart. |
| +func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, channel *string, seed *[]string) (apiInstanceManifest, error) { |
| + in := struct { |
| + Channel *string `json:"channel,omitempty"` |
| + Seed *[]string `json:"seed,omitempty"` |
| + }{channel, seed} |
| + status, body, err := s.putJSON(ctx, apiPath(a, app, "/instances-manifest"), in) |
| + if err != nil { |
| + return apiInstanceManifest{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiInstanceManifest{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceManifest |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceManifest{}, fmt.Errorf("instances enable: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// apiInstanceRecord mirrors provision.Record's wire shape — redeclared here |
| +// rather than imported because this client renders records, never writes |
| +// them, and the fields it renders are the contract worth pinning. |
| +type apiInstanceRecord struct { |
| + Host string `json:"host"` |
| + Slug string `json:"slug"` |
| + Pool string `json:"pool"` |
| + Region string `json:"region"` |
| + Channel string `json:"channel"` |
| + CreatedAt string `json:"created_at"` |
| + CreatedBy string `json:"created_by"` |
| +} |
| + |
| +// instanceCreate asks the console to create one instance record — the |
| +// create-only write whose materialization (provision.Reconcile, on the box) |
| +// is what brings the host live. The console stamps pool from the manifest |
| +// and validates host against the manifest's domain; this side sends only |
| +// what a caller may choose. |
| +func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel string) (apiInstanceRecord, error) { |
| + in := struct { |
| + Host string `json:"host"` |
| + Region string `json:"region,omitempty"` |
| + Channel string `json:"channel,omitempty"` |
| + }{host, region, channel} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in) |
| + if err != nil { |
| + return apiInstanceRecord{}, err |
| + } |
| + if status != http.StatusCreated { |
| + return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, fmt.Errorf("instances create: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// instanceList reads the app's instance records through the console, |
| +// honest about the server's 500-record cap. |
| +func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app string) ([]apiInstanceRecord, bool, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances")) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, false, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Instances []apiInstanceRecord `json:"instances"` |
| + Truncated bool `json:"truncated"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, false, fmt.Errorf("instances list: unreadable response: %w", err) |
| + } |
| + return res.Instances, res.Truncated, nil |
| +} |
| + |
| +// instanceDelete retires one instance record. Idempotent server-side (the |
| +// caller's goal is "not there"), so a 200 is the only success shape. |
| +func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string) error { |
| + status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host))) |
| + if err != nil { |
| + return err |
| + } |
| + if status != http.StatusOK { |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
| + |
| // shipTimeout bounds ship's own context — generous on purpose. The console |
| // gives itself up to 10 minutes to read the request body and 2 minutes to |
| // store it (internal/console/apicli.go), well past the 30s absolute Timeout |
diff --git a/cli.go b/cli.go| index fc10c3a79..226a8e54d 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -181,13 +181,26 @@ 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 |
| + // The subcommands are positional words, not flags, and must never reach |
| + // fs.Parse: enable is the app's provisioning opt-in (instances_cli.go), |
| + // create/list/delete are the console-API record fronts (issue #66). |
| + // Every other invocation is the bare listing form below — THIS box's |
| + // registry routes, operator-side — unchanged. |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "enable": |
| + runInstancesEnable(args[1:]) |
| + return |
| + case "create": |
| + runInstancesCreate(args[1:]) |
| + return |
| + case "list": |
| + runInstancesList(args[1:]) |
| + return |
| + case "delete": |
| + runInstancesDelete(args[1:]) |
| + return |
| + } |
| } |
| fs := flag.NewFlagSet("instances", flag.ExitOnError) |
| reg := fs.String("registry", defaultRegistry(), "registry database") |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index c2686b9a2..c91aabf00 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -31,9 +31,12 @@ |
| // https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db), |
| // CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY, |
| // and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the |
| -// console; unset = read-only) and CARLOS_SECRETS_PUBKEY (SERVED to CLI |
| +// console; unset = read-only), CARLOS_SECRETS_PUBKEY (SERVED to CLI |
| // clients so `carlos secrets set` can seal with a login alone; never used |
| -// here). grant/revoke/owners need only CONSOLE_DB. |
| +// here) and PLATFORM_APPS_DOMAIN (the same variable the edge and agent |
| +// read; enables the CLI's member-facing `carlos instances enable`, whose |
| +// manifest domain is pinned to <sqid>.<this domain>). grant/revoke/owners |
| +// need only CONSOLE_DB. |
| package main |
| import ( |
| @@ -49,6 +52,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -227,6 +231,7 @@ func serve() { |
| Key: key, |
| EdgeIPs: edgeIPs(), |
| SecretsPub: secretsPub(), |
| + AppsDomain: appsDomain(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -302,6 +307,26 @@ func secretsPub() string { |
| return raw |
| } |
| +// appsDomain reads PLATFORM_APPS_DOMAIN — the same variable the edge and |
| +// the host agent read — for the CLI's member-facing `carlos instances |
| +// enable` door, which pins manifests to <sqid>.<this domain>. Garbage is |
| +// refused HERE, at boot, rather than stamped into a manifest every future |
| +// instance host gets validated against (the secretsPub precedent): the |
| +// suffix rides into bucket keys verbatim, so "*.oncarlos.com" copied off a |
| +// DNS record would produce records the reconcile pass skips — and protects |
| +// — forever. Unset is a legal deployment: the door answers 503 naming the |
| +// store-mode operator act. |
| +func appsDomain() string { |
| + d := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN"))) |
| + if d == "" { |
| + return "" |
| + } |
| + if !provision.ValidHostShape(d) { |
| + log.Fatalf("console: PLATFORM_APPS_DOMAIN %q is not a valid domain (dot-joined DNS labels, no wildcard, no leading dot)", d) |
| + } |
| + return d |
| +} |
| + |
| // edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| // agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| // guidance and the agent's DNS health checks can never name different |
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 cc612e744..d412fb808 100644 |
| --- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| @@ -357,6 +357,71 @@ 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. |
| +## 12a. Addendum (2026-08-06, issue #66): the member-facing enable door |
| + |
| +§3.1's "written by the operator, direct/store mode" left one bucket-only |
| +prerequisite between "shipped, promoted, claimed, configured over the |
| +console API" and "reachable": nothing a login credential holds could create |
| +an app's first route. Built to close that: |
| + |
| +- **`PUT /api/cli/apps/{account}/{app}/instances-manifest`** — the |
| + member-facing half of `carlos instances enable`. Gated by `apiAccountApp` |
| + directly (user tokens only — enabling is policy, a human act; an app |
| + token meets cliUser's 401, as at the token mint), budgeted on the |
| + address-keyed write limiter. The manifest's **domain is pinned |
| + server-side to `<sqid>.<PLATFORM_APPS_DOMAIN>`** — the account's own |
| + platform subdomain; nothing the request carries can name another. Custom |
| + domains stay operator/store-mode, and a standing manifest under any other |
| + domain is a 409, never an overwrite. Channel and seed follow the |
| + absent-vs-explicit split (absent preserves, explicit replaces, seed `[]` |
| + clears); pool and regions are operator policy the door never writes. |
| + The console learns the apps domain from `PLATFORM_APPS_DOMAIN` — the |
| + same variable the edge and agent read; unset = the door refuses (503) |
| + naming the store-mode operator act. |
| +- **`carlos instances enable` API mode** — credential-first resolution |
| + (the §1 pattern from 2026-08-03-cli-console-api-design.md); |
| + `-domain`/`-pool`/`-regions` are refused in signed-in mode rather than |
| + ignored. |
| +- **`carlos instances create / list / delete`** — the §9 follow-up slice, |
| + built: thin CLI fronts over the §7 record verbs, signed-in only |
| + (operators placing routes by hand keep `carlos add`). |
| + |
| +The go-live path this completes, for a platform-domain app: enable (manifest |
| +under `<sqid>.<domain>`) → create the record for `<app>.<sqid>.<domain>` |
| +(one label under the pin) → provision.Reconcile mints the real route at the |
| +apex host itself — domains.Reconcile's `byHost` guard then skips apex |
| +minting idempotently, and `wildcardParent` covers the host at exactly the |
| +per-account wildcard depth. No console route write anywhere: §11's "the |
| +agent's reconcile pass is the only route writer" stands untouched. |
| + |
| +Review outcomes (Opus, 2026-08-06), built: |
| + |
| +- **Sibling-apex refusal.** The platform subdomain is a namespace every |
| + app's apex shares, so a platform-pinned manifest makes sibling apexes |
| + valid host shapes for the wrong app — and a record there would mint a |
| + real route the domains pass defers to forever. The create door refuses a |
| + label naming another claimed app in the account (user and app tokens |
| + both); custom-domain manifests never enter apex space and are untouched. |
| +- **Guarded manifest write.** The enable door writes with |
| + `PutManifestIfMatch` against the etag it READ ("" = create-only), so an |
| + operator's store-mode manifest landing between check and write surfaces |
| + as a 409, never a silent re-home. `PutManifest`'s upsert stays the |
| + store-mode (operator-authority) semantics. |
| +- **Domain validated at both ends.** The console refuses a malformed |
| + `PLATFORM_APPS_DOMAIN` at boot (the secretsPub precedent) and the door |
| + re-checks the composed `<sqid>.<domain>` shape before stamping it. |
| + |
| +Recorded, not built: no per-app/per-account cap on records beyond the rate |
| +limiter (the operator opt-in used to be the admission control — a ceiling |
| +wants its own ruling); the enable door's read-modify-write would drop |
| +schema-1 fields added by a NEWER binary (upgrade the console before |
| +shipping such a field — same property the store-mode upsert has); a |
| +non-apex instance (e.g. `alice.<app>.<sqid>.<domain>`-style multi-tenant |
| +use under the account pin) still causes candidateApps/firstReadyUpstream to |
| +mint the app's apex onto that one customer's socket — pre-existing |
| +behaviour, now reachable by members, worth its own slice if platform-pinned |
| +multi-tenant apps become a real shape. |
| + |
| ## 13. Testing |
| - `internal/provision`: record/manifest validation matrices, key parsing |
diff --git a/instances_cli.go b/instances_cli.go| index c3f84378b..79e3f3bac 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -1,11 +1,19 @@ |
| -// 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. |
| +// The `carlos instances` verb family beyond the bare box-side listing: |
| +// |
| +// - `enable` — the app's opt-in to instance provisioning (spec §3.1). |
| +// Two modes, resolved credential-first like ship/promote (apimode.go): |
| +// store mode writes apps/<sqid>/<app>/instances.json via |
| +// provision.PutManifest with every field under the operator's hand; |
| +// API mode (issue #66) asks the console, which pins the manifest's |
| +// domain to the account's own platform subdomain — so a member can |
| +// bring an app live on <app>.<sqid>.<apps-domain> with a login alone, |
| +// and custom domains stay an operator act. |
| +// - `create` / `list` / `delete` — the CLI front over the console's |
| +// instance-record verbs (spec §9's "natural follow-up slice", built |
| +// here because the records were otherwise API-only: the one |
| +// day-to-day surface with no carlos command). API mode ONLY: a |
| +// record is a console-authorized write, and the box-side operator |
| +// equivalent has always been `carlos add`. |
| package main |
| import ( |
| @@ -14,24 +22,34 @@ import ( |
| "fmt" |
| "log" |
| "strings" |
| + "text/tabwriter" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -const instancesEnableUsage = `usage: carlos instances enable -app <a> -domain <d> [flags] |
| +const instancesEnableUsage = `usage: carlos instances enable -app <a> [flags] |
| + |
| +Signed-in mode (carlos auth login; no CARLOS_DEPLOYMENT_* env): the console |
| +writes the manifest, and pins its domain to your account's own platform |
| +subdomain (<sqid>.<apps-domain>). -domain, -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 -domain is required. |
| 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) |
| + + "." + this domain (store mode: required; |
| + signed-in mode: refused — the console pins it) |
| + -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 (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 |
| + create (store-mode default "stable"; signed-in |
| + mode: omitted = preserve what the manifest holds) |
| + -regions <r1,r2,...> allow-list for a record's region (store mode |
| + only); 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 |
| @@ -40,6 +58,8 @@ flags: |
| (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 instance provisioning: absence |
| means the feature does not exist for the app, and the API refuses creates |
| @@ -49,19 +69,77 @@ 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") |
| + domain := fs.String("domain", "", "instance host domain suffix (store mode only)") |
| + pool := fs.String("pool", "", "pool stamped into every record at create (store mode only)") |
| + // Default "" so signed-in mode can tell "unset" from "asked for": an |
| + // omitted -channel must preserve whatever the manifest already carries, |
| + // not silently reset it to stable. Store mode applies the documented |
| + // "stable" default below, so its behaviour is unchanged. |
| + channel := fs.String("channel", "", "release channel stamped into every record at create") |
| + regions := fs.String("regions", "", "comma-separated region allow-list (store mode only); 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) |
| + 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 == "" || *domain == "" { |
| + if *app == "" { |
| log.Fatal(instancesEnableUsage) |
| } |
| if !release.ValidApp(*app) { |
| log.Fatalf("carlos instances 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. The three operator fields are refused rather than |
| + // ignored — a flag that silently did nothing would read as policy |
| + // applied. |
| + for _, f := range []string{"domain", "pool", "regions"} { |
| + if set[f] { |
| + log.Fatalf("carlos instances enable: -%s is an operator, store-mode flag — the console pins the manifest to your account's platform subdomain. Set CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR to enable in store mode instead", f) |
| + } |
| + } |
| + var channelReq *string |
| + if set["channel"] { |
| + channelReq = channel |
| + } |
| + var seedReq *[]string |
| + if set["seed"] { |
| + // parseSeed("") is an empty (non-nil after this) argv: an explicit |
| + // -seed "" clears the manifest's seed, matching -channel's |
| + // explicit-vs-absent split. |
| + argv := parseSeed(*seed) |
| + if argv == nil { |
| + argv = []string{} |
| + } |
| + seedReq = &argv |
| + } |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq) |
| + if err != nil { |
| + log.Fatalf("instances enable: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "enabled instances for %s under %s\n", m.App, m.Domain) |
| + fmt.Fprintf(stdout, " first instance: carlos instances create -app %s -host <label>.%s\n", m.App, m.Domain) |
| + return |
| + } |
| + |
| + // Store mode — the operator path, unchanged: an ABSENT -channel takes |
| + // the documented "stable" default, while an explicitly empty one stays |
| + // the "not a valid channel" refusal it has always been (the flag's |
| + // default moved from "stable" to "" for the signed-in mode's |
| + // preserve-vs-replace split; set[] is what keeps the two cases apart). |
| + if *domain == "" { |
| + log.Fatal(instancesEnableUsage) |
| + } |
| + if *channel == "" && !set["channel"] { |
| + *channel = "stable" |
| + } |
| // 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, |
| @@ -110,10 +188,111 @@ 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 |
| +// instancesEnable is the core store-mode `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) |
| } |
| + |
| +const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> [-region <r>] [-channel <c>] |
| + carlos instances list -app <a> |
| + carlos instances delete -app <a> -host <h> |
| + |
| +Signed-in only (carlos auth login): instance records are console-authorized |
| +writes — the console checks the host against the app's manifest and the |
| +box's reconcile pass materializes the route. 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) |
| + |
| +Operators placing routes by hand on a box use carlos add, not this. |
| +` |
| + |
| +// requireAPISession resolves signed-in mode for the record verbs, refusing |
| +// direct mode by name: there IS no store-mode record write (the console is |
| +// the door, spec §9), and the operator equivalent is a different verb. |
| +func requireAPISession(verb, consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatalf("carlos instances %s works through a console, and this shell is in direct bucket mode\n"+ |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Instance records are\n"+ |
| + "console-authorized writes; unset the store env and run: carlos auth login -console <url>.\n"+ |
| + "To place a route by hand on THIS box, use carlos add instead", verb) |
| + } |
| + return sess |
| +} |
| + |
| +func runInstancesCreate(args []string) { |
| + fs := flag.NewFlagSet("instances create", flag.ExitOnError) |
| + app := fs.String("app", "", "app the instance belongs to") |
| + host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain") |
| + region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)") |
| + channel := fs.String("channel", "", "release channel the instance 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", "ops", "account (sqid or name) the app belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + if *app == "" || *host == "" { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + sess := requireAPISession("create", *consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel)) |
| + if err != nil { |
| + log.Fatalf("instances create: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "created instance %s (channel %s)\n", rec.Host, rec.Channel) |
| + fmt.Fprintf(stdout, " the host comes live on the next reconcile pass on its box\n") |
| +} |
| + |
| +func runInstancesList(args []string) { |
| + fs := flag.NewFlagSet("instances list", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose instance records to list") |
| + consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + sess := requireAPISession("list", *consoleFlag) |
| + recs, truncated, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app) |
| + if err != nil { |
| + log.Fatalf("instances list: %v", err) |
| + } |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tCREATED\tBY") |
| + for _, r := range recs { |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.CreatedAt, r.CreatedBy) |
| + } |
| + w.Flush() |
| + if truncated { |
| + fmt.Fprintln(stdout, "(truncated at 500 records)") |
| + } |
| +} |
| + |
| +func runInstancesDelete(args []string) { |
| + fs := flag.NewFlagSet("instances delete", flag.ExitOnError) |
| + app := fs.String("app", "", "app the instance belongs to") |
| + host := fs.String("host", "", "instance host whose record to retire") |
| + consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + if *app == "" || *host == "" { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + sess := requireAPISession("delete", *consoleFlag) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil { |
| + log.Fatalf("instances delete: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "deleted instance %s — its route retires on the next reconcile pass; parked data stays\n", *host) |
| +} |
diff --git a/instances_e2e_test.go b/instances_e2e_test.go| new file mode 100644 |
| index 000000000..b23d3d13d |
| --- /dev/null |
| +++ b/instances_e2e_test.go |
| @@ -0,0 +1,142 @@ |
| +// End-to-end acceptance test for issue #66: `carlos instances |
| +// enable/create/list/delete` working through the console API with `carlos |
| +// auth login` alone — the last step between "promoted" and "reachable" |
| +// without a box shell or bucket credentials. |
| +// |
| +// Follows TestE2ECLIThroughConsole's shape (auth_e2e_test.go): 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 |
| +// (these verbs print through cli.go's stdout var, so captureStdout reaches |
| +// them). |
| +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 TestE2EInstancesThroughConsole(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: the console pins the domain to the account's platform |
| + // subdomain; no -domain crosses the wire --------------------------------- |
| + const domain = "bab.oncarlos.test" |
| + buf := captureStdout(t) |
| + runInstancesEnable([]string{"-app", "titogo", "-account", "ops", "-channel", "beta"}) |
| + if want := "enabled instances for titogo under " + domain; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("enable stdout = %q, want %q", buf.String(), want) |
| + } |
| + m, err := provision.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo") |
| + if err != nil { |
| + t.Fatalf("manifest after enable: %v", err) |
| + } |
| + if m.Domain != domain || m.Channel != "beta" || m.Pool != "" { |
| + t.Fatalf("manifest = %+v, want %s on beta with the shared pool", m, domain) |
| + } |
| + |
| + // --- create: the app's own platform host, one label under the pin ------ |
| + host := "titogo." + domain |
| + buf.Reset() |
| + runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", host}) |
| + if want := "created instance " + host + " (channel beta)"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("create stdout = %q, want %q", buf.String(), want) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host) |
| + if err != nil { |
| + t.Fatalf("record after create: %v", err) |
| + } |
| + if rec.Channel != "beta" || rec.Slug != "titogo" || rec.CreatedBy != addr { |
| + t.Fatalf("record = %+v, want beta/titogo created by %s", rec, addr) |
| + } |
| + |
| + // --- list ---------------------------------------------------------------- |
| + buf.Reset() |
| + runInstancesList([]string{"-app", "titogo", "-account", "ops"}) |
| + if out := buf.String(); !strings.Contains(out, host) || !strings.Contains(out, "beta") { |
| + t.Fatalf("list stdout = %q, want the record's host and channel", out) |
| + } |
| + |
| + // --- delete: idempotent record retirement ------------------------------- |
| + buf.Reset() |
| + runInstancesDelete([]string{"-app", "titogo", "-account", "ops", "-host", host}) |
| + if want := "deleted instance " + host; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("delete stdout = %q, want %q", buf.String(), want) |
| + } |
| + if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host); err == nil { |
| + t.Fatalf("record survived delete") |
| + } |
| + |
| + // The whole flow ran through the console: nothing here may have touched |
| + // a local registry. |
| + checkNoRegistry() |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 34ed311ca..31c3a208c 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -240,6 +240,32 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| "invalid host — an instance host is one DNS label followed by ."+m.Domain) |
| return |
| } |
| + // The account's platform subdomain is a SHARED namespace: every app's |
| + // apex lives at <app>.<sqid>.<AppsDomain>, and a manifest pinned there |
| + // (the member-facing enable door) makes every sibling's apex a valid |
| + // host shape for THIS app. A record at a sibling's apex would mint a |
| + // real route there first, and the domains pass would then skip the |
| + // sibling's own apex mint forever (byHost) — one app quietly serving |
| + // another's public URL (review 2026-08-06 #1). Within-account only, but |
| + // reachable by an app token, so it is refused rather than trusted to |
| + // membership: a label naming another claimed app in the account belongs |
| + // to that app. Custom-domain manifests never enter apex space (ValidHost |
| + // pinned the suffix above), so this check cannot touch them. |
| + if s.cfg.AppsDomain != "" && m.Domain == acct.Sqid+"."+s.cfg.AppsDomain { |
| + if label := strings.TrimSuffix(host, "."+m.Domain); label != app { |
| + claimed, err := s.store.HasAppIn(acct.ID, label) |
| + if err != nil { |
| + log.Printf("console: api instances create sibling check %s/%s: %v", acct.Sqid, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not check the host — try again") |
| + return |
| + } |
| + if claimed { |
| + jsonError(w, http.StatusConflict, |
| + host+" is app "+label+"'s platform apex — create it as an instance of "+label+" instead") |
| + return |
| + } |
| + } |
| + } |
| region := strings.TrimSpace(req.Region) |
| if !provision.ValidRegion(region) || !instanceRegionAllowed(m.Regions, region) { |
| msg := "invalid region" |
| @@ -386,6 +412,149 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) |
| }) |
| } |
| +// instanceSeed bounds (issue #66). Seed becomes the argv the activator execs |
| +// the app's OWN binary with on first wake — the caller already controls that |
| +// binary via ship, so the bound here is manifest hygiene, not privilege: a |
| +// manifest is read forever, and an unbounded argv written once would be |
| +// permanent. |
| +const ( |
| + instanceSeedMaxArgs = 16 |
| + instanceSeedMaxArgLen = 256 |
| +) |
| + |
| +// handleAPIInstanceEnable is the member-facing half of `carlos instances |
| +// enable` (issue #66): the one bucket-only prerequisite that kept the |
| +// otherwise-API path from "promoted" to "reachable" needing a box shell. |
| +// |
| +// The security line it holds: the manifest's DOMAIN is pinned server-side to |
| +// <account-sqid>.<AppsDomain> — the account's own platform subdomain — and |
| +// nothing the request carries can name another. Custom domains (tito's |
| +// go.tito.io) stay an operator act in store mode, and an existing manifest |
| +// under any other domain is refused with a 409, never overwritten: this door |
| +// can hand an account keys only to hostnames that were already hers. |
| +// |
| +// Gated by apiAccountApp DIRECTLY, not the instance gate: enabling is |
| +// policy, a human act — an app token meets cliUser's 401 here, exactly as it |
| +// does at the token mint. Budgeted on the address-keyed write limiter for |
| +// the same reason. |
| +func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — the console is running without one") |
| + return |
| + } |
| + if s.cfg.AppsDomain == "" { |
| + // Membership is proven, so this may name the operator act that fixes |
| + // it. 503, not 409: the missing piece is deployment configuration |
| + // (PLATFORM_APPS_DOMAIN on the console), not anything about this app. |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "this console has no platform apps domain configured — an operator enables instances in store mode (carlos instances enable) or sets PLATFORM_APPS_DOMAIN on the console") |
| + return |
| + } |
| + // Channel and Seed are pointers because "absent" and "empty" are |
| + // different requests on the wire (the apiConfigApply precedent): an |
| + // omitted field preserves what the manifest already carries, an explicit |
| + // value replaces it — including seed's explicit [] meaning "clear". |
| + var req struct { |
| + Channel *string `json:"channel"` |
| + Seed *[]string `json:"seed"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if req.Channel != nil && !release.ValidChannel(*req.Channel) { |
| + jsonError(w, http.StatusBadRequest, "invalid channel name") |
| + return |
| + } |
| + if req.Seed != nil { |
| + if len(*req.Seed) > instanceSeedMaxArgs { |
| + jsonError(w, http.StatusBadRequest, "seed takes at most 16 arguments") |
| + return |
| + } |
| + for _, arg := range *req.Seed { |
| + if arg == "" || len(arg) > instanceSeedMaxArgLen { |
| + jsonError(w, http.StatusBadRequest, "each seed argument must be 1-256 bytes") |
| + return |
| + } |
| + } |
| + } |
| + domain := acct.Sqid + "." + s.cfg.AppsDomain |
| + // The domain is about to be stamped into a manifest every future host |
| + // gets checked against, and ValidHost only regex-checks the leading |
| + // label — the suffix rides into bucket keys verbatim. Store mode |
| + // validates its -domain for exactly this reason (instances_cli.go); a |
| + // misconfigured PLATFORM_APPS_DOMAIN ("*.oncarlos.com" copied off a DNS |
| + // record) must refuse here, not surface as records reconcile skips |
| + // forever. The console also refuses garbage at boot (cmd/console); this |
| + // guard covers embedders that build Config directly. |
| + if !provision.ValidHostShape(domain) { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "this console's PLATFORM_APPS_DOMAIN is not a valid domain — fix the console configuration") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + // Read WITH the etag and write guarded by it below: the domain-conflict |
| + // refusal is only honest if nothing can land between this read and the |
| + // write. PutManifest's upsert re-reads inside, so an operator's |
| + // store-mode manifest written into that window would be silently |
| + // re-homed — the exact overwrite the 409 exists to prevent. |
| + m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + m, etag = &provision.Manifest{Schema: 1, App: app, Domain: domain}, "" |
| + case err != nil: |
| + log.Printf("console: api instances enable read %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read instance settings") |
| + return |
| + case m.Domain != domain: |
| + // Never overwritten from here: a manifest naming any other domain |
| + // was written by an operator in store mode, and re-homing every |
| + // FUTURE host check under the platform domain would be this door |
| + // rewriting operator policy. Membership is proven, so naming the |
| + // standing domain leaks nothing the caller cannot already read. |
| + jsonError(w, http.StatusConflict, |
| + "instances for "+app+" are managed under "+m.Domain+ |
| + " — changing an app's instance domain is an operator act (carlos instances enable, store mode)") |
| + return |
| + } |
| + // Pool and Regions survive untouched on an update: they are operator |
| + // policy this door never speaks for, in either direction. (Recorded |
| + // debt: fields ADDED to schema 1 after this binary would be dropped by |
| + // this read-modify-write, the same property the store-mode upsert has — |
| + // an additive manifest field needs the console upgraded before members |
| + // enable through it.) |
| + if req.Channel != nil { |
| + m.Channel = *req.Channel |
| + } |
| + if req.Seed != nil { |
| + m.Seed = *req.Seed |
| + } |
| + if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, *m, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + jsonError(w, http.StatusConflict, |
| + "instance settings changed while this request was in flight — try again") |
| + return |
| + } |
| + log.Printf("console: api instances enable %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not enable instances — try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "instance-enable", "enabled instances under "+domain+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "app": app, "domain": m.Domain, "pool": m.Pool, "channel": m.Channel, |
| + "regions": m.Regions, "seed": m.Seed, |
| + }) |
| +} |
| + |
| // instanceRegionAllowed applies the manifest's region allow-list. An empty |
| // list means any shape-valid region (spec §3.1) — including the empty, |
| // unconstrained one, which is what a single-region deployment uses. |
diff --git a/internal/console/apiinstancesenable_test.go b/internal/console/apiinstancesenable_test.go| new file mode 100644 |
| index 000000000..82896501b |
| --- /dev/null |
| +++ b/internal/console/apiinstancesenable_test.go |
| @@ -0,0 +1,293 @@ |
| +package console |
| + |
| +// The member-facing instances-manifest door (issue #66): the one write that |
| +// used to need a box shell or bucket credentials between "promoted" and |
| +// "reachable". The properties that matter, in the order they'd hurt if |
| +// lost: the manifest's domain is pinned server-side to the account's own |
| +// platform subdomain (nothing the request carries can name another |
| +// account's — or anyone's custom — domain), an operator-written manifest |
| +// under a different domain is refused rather than overwritten, the refusals |
| +// before membership is proven are the family's byte-identical 404, an app |
| +// token cannot enable anything, and pool/regions — operator policy — ride |
| +// through updates untouched. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func enableURL(fx *consoleFixture, acct, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances-manifest" |
| +} |
| + |
| +// TestAPIInstanceEnableChain is issue #66's done-criteria as a test, on a |
| +// NON-ops account (the seam test's discipline — ops's sqid is hardcoded |
| +// known elsewhere, so an ops-only test would pass with the sqid agreement |
| +// broken): a member with a login credential and no bucket access enables |
| +// instances, creates the app's first record at its own platform host, and |
| +// the agent's reconcile pass — joined to the console by nothing but the |
| +// bucket — materializes the route that brings the host live. |
| +func TestAPIInstanceEnableChain(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-inst-enable") |
| + now := time.Now() |
| + const acctID int64 = 4242 |
| + sqid := accountSqid(acctID) |
| + if err := fx.st.CreateAccount(acctID, sqid, "Amadan", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(acctID, "hub", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Amadan", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + wantDomain := sqid + ".oncarlos.test" |
| + status, _, out := rawReq(t, http.MethodPut, enableURL(fx, sqid, "hub"), token, `{"channel":"beta"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + var res struct { |
| + App string `json:"app"` |
| + Domain string `json:"domain"` |
| + Channel string `json:"channel"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &res); err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.App != "hub" || res.Domain != wantDomain || res.Channel != "beta" { |
| + t.Fatalf("enable response = %+v, want hub under %s on beta", res, wantDomain) |
| + } |
| + m, err := provision.GetManifest(context.Background(), fx.rel, sqid, "hub") |
| + if err != nil { |
| + t.Fatalf("manifest after enable: %v", err) |
| + } |
| + if m.Domain != wantDomain || m.App != "hub" || m.Channel != "beta" || m.Pool != "" || len(m.Regions) != 0 { |
| + t.Fatalf("manifest = %+v, want domain %s, channel beta, empty pool/regions", m, wantDomain) |
| + } |
| + |
| + // The app's own platform host is one label under the pinned domain — |
| + // exactly what the existing create door now accepts. |
| + host := "hub." + wantDomain |
| + status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, sqid, "hub"), token, `{"host":"`+host+`"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + |
| + // The agent's half: an unscoped box (no region, no pools — the |
| + // single-box deployment shape) materializes the record as the route. |
| + dataDir := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + if err := provision.Reconcile(context.Background(), fx.rel, reg, |
| + provision.ReconcileConfig{DataDir: dataDir}); err != nil { |
| + t.Fatalf("reconcile: %v", err) |
| + } |
| + got, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatalf("the enabled app's record never materialized as a route") |
| + } |
| + want := registry.Route{ |
| + Host: host, Slug: "hub", Kind: "instance", |
| + Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host), |
| + App: "hub", Account: sqid, Channel: "beta", |
| + Hibernate: true, Provisioned: true, Status: "ready", |
| + CreatedAt: got.CreatedAt, |
| + } |
| + if *got != want { |
| + t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want) |
| + } |
| +} |
| + |
| +// TestAPIInstanceEnableUpdatePreserves: an update through this door touches |
| +// only what the request names. Pool and regions are operator policy the |
| +// door never speaks for; channel and seed follow the absent-vs-explicit |
| +// split (absent preserves, explicit replaces, seed's [] clears). |
| +func TestAPIInstanceEnableUpdatePreserves(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-enable-upd") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + domain := fx.sqid + ".oncarlos.test" |
| + // An operator already wrote a platform-domain manifest with policy in |
| + // it: same domain this door would pin, so updates are legal — but its |
| + // pool and regions must ride through untouched. |
| + enableInstances(t, fx, "titogo", provision.Manifest{ |
| + Schema: 1, App: "titogo", Domain: domain, |
| + Pool: "p1", Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed", "arg"}, |
| + }) |
| + |
| + u := enableURL(fx, fx.sqid, "titogo") |
| + if status, _, out := rawReq(t, http.MethodPut, u, token, `{"channel":"beta"}`); status != http.StatusOK { |
| + t.Fatalf("update channel = %d %s", status, out) |
| + } |
| + m, err := provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 || m.Regions[0] != "ie" || |
| + len(m.Seed) != 2 { |
| + t.Fatalf("after channel update, manifest = %+v: want beta with pool/regions/seed preserved", m) |
| + } |
| + |
| + if status, _, out := rawReq(t, http.MethodPut, u, token, `{"seed":[]}`); status != http.StatusOK { |
| + t.Fatalf("clear seed = %d %s", status, out) |
| + } |
| + m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(m.Seed) != 0 || m.Channel != "beta" || m.Pool != "p1" { |
| + t.Fatalf("after seed clear, manifest = %+v: want seed gone, everything else standing", m) |
| + } |
| + |
| + if status, _, out := rawReq(t, http.MethodPut, u, token, `{}`); status != http.StatusOK { |
| + t.Fatalf("empty update = %d %s", status, out) |
| + } |
| + m, err = provision.GetManifest(context.Background(), fx.rel, fx.sqid, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.Channel != "beta" || m.Pool != "p1" || len(m.Regions) != 1 { |
| + t.Fatalf("after empty update, manifest = %+v: want everything preserved", m) |
| + } |
| +} |
| + |
| +// TestAPIInstanceCreateSiblingApexRefused: the account's platform subdomain |
| +// is a namespace every app's apex shares, so a manifest pinned there |
| +// (exactly what the enable door writes) makes sibling apexes valid host |
| +// SHAPES for the wrong app. A record at a sibling's apex would mint a real |
| +// route the domains pass then defers to forever — one app serving another's |
| +// public URL — so the create door refuses a label that names another |
| +// claimed app in the account, for user and app tokens both. Unclaimed |
| +// labels and the app's own apex stay open. |
| +func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-sibling") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "marketing", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + domain := fx.sqid + ".oncarlos.test" |
| + |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"marketing.`+domain+`"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("sibling apex create = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "marketing") || !strings.Contains(out, "platform apex") { |
| + t.Fatalf("sibling refusal body = %s, want it to name the app and the apex", out) |
| + } |
| + |
| + // The app token meets the same wall: it authorizes titogo's records, |
| + // and a sibling's apex is not one of them. |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, |
| + `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict { |
| + t.Fatalf("app-token sibling apex create = %d %s, want 409", status, out) |
| + } |
| + |
| + // The app's own apex and an unclaimed label are exactly what the door |
| + // is for. |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"titogo.`+domain+`"}`); status != http.StatusCreated { |
| + t.Fatalf("own apex create = %d %s, want 201", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"acme.`+domain+`"}`); status != http.StatusCreated { |
| + t.Fatalf("unclaimed label create = %d %s, want 201", status, out) |
| + } |
| +} |
| + |
| +// TestAPIInstanceEnableRefusals: every way of not being allowed to enable, |
| +// and the order the answers keep secrets in. Before membership is proven the |
| +// answer is the family's byte-identical 404; an app token meets cliUser's |
| +// 401 (enabling is a human act); only after the gate do causes get names — |
| +// including the one refusal that guards somebody else's standing policy, |
| +// the foreign-domain 409. |
| +func TestAPIInstanceEnableRefusals(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-enable-ref") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + want := canonicalNotFound(t) |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-enable-eve") |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + for _, c := range []struct { |
| + what, acct, app, token string |
| + }{ |
| + {"non-member", "bab", "titogo", eve}, |
| + {"unknown account", "zzzz", "titogo", member}, |
| + {"unclaimed app", "bab", "ghost-app", member}, |
| + } { |
| + status, h, out := rawReq(t, http.MethodPut, enableURL(fx, c.acct, c.app), c.token, `{}`) |
| + assertNotFound(t, "enable: "+c.what, want, status, h, out) |
| + } |
| + |
| + // An app token cannot enable: CLITokenAddress answers only user tokens, |
| + // so the binding that authorizes record writes meets a plain 401 here. |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), appTok, `{}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token enable = %d %s, want 401", status, out) |
| + } |
| + |
| + // Cheap validation refusals, after the gate. |
| + for _, c := range []struct { |
| + what, body string |
| + }{ |
| + {"bad channel", `{"channel":"NOT a channel"}`}, |
| + {"too many seed args", `{"seed":[` + strings.Repeat(`"a",`, 16) + `"a"]}`}, |
| + {"empty seed arg", `{"seed":[""]}`}, |
| + {"oversized seed arg", `{"seed":["` + strings.Repeat("a", 257) + `"]}`}, |
| + } { |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, c.body); status != http.StatusBadRequest { |
| + t.Fatalf("enable %s = %d %s, want 400", c.what, status, out) |
| + } |
| + } |
| + |
| + // A standing manifest under any other domain — an operator's custom |
| + // domain — is never overwritten from here. |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{"channel":"beta"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("enable over custom domain = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "go.tito.io") || !strings.Contains(out, "operator act") { |
| + t.Fatalf("conflict body = %s, want it to name the standing domain and the operator act", out) |
| + } |
| + if m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo"); err != nil || m.Domain != "go.tito.io" { |
| + t.Fatalf("manifest after refused enable = %+v %v, want go.tito.io untouched", m, err) |
| + } |
| + |
| + // No PLATFORM_APPS_DOMAIN on this console: the member-facing door does |
| + // not exist, and says which operator act does. |
| + fx.srv.cfg.AppsDomain = "" |
| + status, _, out = rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("enable with no apps domain = %d %s, want 503", status, out) |
| + } |
| + if !strings.Contains(out, "PLATFORM_APPS_DOMAIN") { |
| + t.Fatalf("no-apps-domain body = %s, want it to name the missing configuration", out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 6fa89c19d..6826f0d50 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -244,6 +244,10 @@ func (s *Server) registerCLIRoutes() { |
| // registration order, and it deliberately sits under a DIFFERENT |
| // prefix from instances/{host} so a host can never be mistaken for the |
| // mint verb. |
| + // `instances-manifest` is the member-facing half of `carlos instances |
| + // enable` (issue #66): a literal sibling of instances/, never under it, |
| + // so a host named "instances-manifest" could still never reach it. |
| + s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/instances-manifest", s.handleAPIInstanceEnable) |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceList) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceCreate) |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 8f62e5810..dbbb7a068 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -155,6 +155,15 @@ type Config struct { |
| // or without it. Optional: "" omits the field from the API response |
| // and the CLI falls back to requiring the env var. |
| SecretsPub string |
| + // AppsDomain is the deployment's platform apps domain |
| + // (PLATFORM_APPS_DOMAIN, the same variable the edge and the host |
| + // agent read; flagship: "oncarlos.com"). The CLI's instances-manifest |
| + // door pins a member-written manifest's domain to |
| + // <account-sqid>.<AppsDomain> — never a domain the request names. "" |
| + // follows the litestream convention: the member-facing enable door |
| + // does not exist, and the handler refuses with a pointer at the |
| + // operator's store-mode `carlos instances enable`. |
| + AppsDomain string |
| } |
| // Server is the console's http.Handler. |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 2c3cb0838..6b149476f 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -155,18 +155,26 @@ func ValidPool(pool string) bool { return regionPoolRe.MatchString(pool) } |
| // truncated by json.Unmarshal (appconfig/store.go's precedent) — acting on |
| // a newer manifest would silently discard whatever the unknown fields meant. |
| func GetManifest(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) { |
| - raw, _, err := s.Get(ctx, ManifestKey(acct, app)) |
| + m, _, err := GetManifestETag(ctx, s, acct, app) |
| + return m, err |
| +} |
| + |
| +// GetManifestETag is GetManifest carrying the etag its bytes were read |
| +// under — for a caller that must write back GUARDED against what it read |
| +// (PutManifestIfMatch), not against whatever is there at write time. |
| +func GetManifestETag(ctx context.Context, s release.Store, acct, app string) (*Manifest, string, error) { |
| + raw, etag, err := s.Get(ctx, ManifestKey(acct, app)) |
| if err != nil { |
| - return nil, err |
| + return nil, "", err |
| } |
| var m Manifest |
| if err := json.Unmarshal(raw, &m); err != nil { |
| - return nil, fmt.Errorf("provision: manifest %s/%s: %w", acct, app, err) |
| + return nil, "", fmt.Errorf("provision: manifest %s/%s: %w", acct, app, err) |
| } |
| if m.Schema > 1 { |
| - return nil, fmt.Errorf("provision: manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema) |
| + return nil, "", fmt.Errorf("provision: manifest %s/%s: schema %d is newer than this binary understands — upgrade carlos", acct, app, m.Schema) |
| } |
| - return &m, nil |
| + return &m, etag, nil |
| } |
| // PutManifest publishes app's instances manifest — a CAS upsert (read |
| @@ -197,6 +205,24 @@ func PutManifest(ctx context.Context, s release.Store, acct, app string, m Manif |
| return nil |
| } |
| +// PutManifestIfMatch writes app's instances manifest guarded by the etag |
| +// the caller READ it under — "" meaning it read nothing, which makes the |
| +// write create-only. Unlike PutManifest's upsert (which re-reads inside and |
| +// retries, so the last writer always wins) a lost race here surfaces as |
| +// release.ErrCASConflict for the caller to answer: the console's |
| +// member-facing enable door uses this because its domain-conflict check is |
| +// only honest if nothing can slip in between the check and the write |
| +// (review 2026-08-06 — an operator's store-mode manifest landing in that |
| +// window must never be silently re-homed). |
| +func PutManifestIfMatch(ctx context.Context, s release.Store, acct, app string, m Manifest, etag string) error { |
| + body, err := json.Marshal(m) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, ManifestKey(acct, app), body, etag) |
| + return err |
| +} |
| + |
| // CreateRecord writes a new instance record, create-only: a lost race |
| // against a second signup for the same host comes back wrapping |
| // release.ErrCASConflict, never a silent overwrite of the winner's record. |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 30f94c15b..a42cbc7c4 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -335,3 +335,43 @@ func TestDefaults(t *testing.T) { |
| t.Errorf("DefaultDBPath(%q, %q) = %q, want /data/h.db", "/data/", "h", got) |
| } |
| } |
| + |
| +// TestPutManifestIfMatch pins the guarded write's two properties: etag "" |
| +// is CREATE-ONLY (a manifest landing between a caller's read-nothing and |
| +// its write surfaces as the conflict it is, never an overwrite), and a |
| +// stale etag — the caller's read racing somebody else's update — is refused |
| +// the same way. This is what makes the console enable door's domain check |
| +// honest: PutManifest's upsert re-reads inside and the last writer wins, |
| +// which is exactly the wrong property when the write was authorized by |
| +// what the FIRST read said. |
| +func TestPutManifestIfMatch(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"} |
| + |
| + if err := PutManifestIfMatch(ctx, s, "bad", "tito", m, ""); err != nil { |
| + t.Fatalf("create-only put: %v", err) |
| + } |
| + if err := PutManifestIfMatch(ctx, s, "bad", "tito", m, ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create-only put: err = %v, want ErrCASConflict", err) |
| + } |
| + |
| + got, etag, err := GetManifestETag(ctx, s, "bad", "tito") |
| + if err != nil || etag == "" { |
| + t.Fatalf("GetManifestETag: %+v %q %v, want a manifest with an etag", got, etag, err) |
| + } |
| + got.Channel = "beta" |
| + if err := PutManifestIfMatch(ctx, s, "bad", "tito", *got, etag); err != nil { |
| + t.Fatalf("guarded update: %v", err) |
| + } |
| + // The first read's etag is now stale; a write still guarded by it must |
| + // lose, not overwrite the update that beat it. |
| + got.Channel = "edge" |
| + if err := PutManifestIfMatch(ctx, s, "bad", "tito", *got, etag); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("stale-etag put: err = %v, want ErrCASConflict", err) |
| + } |
| + final, err := GetManifest(ctx, s, "bad", "tito") |
| + if err != nil || final.Channel != "beta" { |
| + t.Fatalf("manifest after stale write = %+v %v, want the guarded update's beta", final, err) |
| + } |
| +} |
diff --git a/main.go b/main.go| index fb8b01722..bcdc89bd5 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -82,7 +82,13 @@ commands: |
| version print the build id |
| edge run the mesh front door (router) |
| agent run edge + activator + release adoption on this host |
| - add | remove | instances manage routes |
| + add | remove | instances manage routes (bare "instances" lists THIS |
| + box's registry routes; "instances enable" |
| + opts an app into instance provisioning — |
| + signed-in mode pins it to the account's |
| + platform subdomain; "instances |
| + create|list|delete" manage instance records |
| + through the console API, signed-in only) |
| route -host <h> | -app <a> [-account <sqid|name|all>] |
| [-channel <c>] [-environment <name>] [-dry-run]: |
| change which release channel and/or named |