carlos run: the console door and the CLI verb
The door is `schedule run`'s sibling and shares its whole gate via apiScheduleApp — same principal proof, same byte-identical 404, same address-keyed write budget, same 503 when the console has no bucket. The only difference is what identifies the work: `schedule run` names a declared schedule and the box looks its path up, while this takes the path itself, because an ad-hoc run has no declaration to look anything up in. The path is validated at the door with the member's own words in the answer, and again on the box — a bucket writer is not this door. The verb reports the run as "requested", never "ran", following the rule schedule.go already set: nothing here waits for the app's handler, so any message claiming the work finished would be a claim the command cannot make. --wait changes what the terminal shows, not what was promised. cli.md gains a `carlos run` section — the internal/docsite gate fails without one, and the prose is where "this is not a shell" gets said to the person most likely to assume otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141TPozecoDNeapPJ94REuH
7 files changed,
+296
−0
apimode.go+30 −0docs/site/cli.md+23 −0internal/console/apirun.go+75 −0internal/console/apirun_test.go+89 −0internal/console/authcli.go+1 −0main.go+5 −0run.go+73 −0
CI passed — run details
diff --git a/apimode.go b/apimode.go| index 59556c81b..5253c4e31 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1155,6 +1155,36 @@ type apiScheduleRunResult struct { |
| Name string `json:"name"` |
| } |
| +// apiRunResult mirrors the console's ad-hoc run answer (apirun.go). It |
| +// echoes the PATH rather than a name: an ad-hoc run has no declaration, so |
| +// the path is the only thing identifying what was asked for. |
| +type apiRunResult struct { |
| + Requested bool `json:"requested"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Path string `json:"path"` |
| +} |
| + |
| +// runPath asks the console to record an ad-hoc run of path (handleAPIRun). |
| +// Unlike scheduleRun there is no name to be not-found: a 404 here is the |
| +// app, not the path, so it needs no special sentence. |
| +func (s *apiSession) runPath(ctx context.Context, a apiAcct, app, path string) (apiRunResult, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/run"), struct { |
| + Path string `json:"path"` |
| + }{Path: path}) |
| + if err != nil { |
| + return apiRunResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiRunResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiRunResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiRunResult{}, fmt.Errorf("run: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // scheduleRun asks the console to touch name's run signal |
| // (handleAPIScheduleRun) — TouchRun's etag movement, never its content, is |
| // the signal every box's converge pass acts on, restart-gen's own |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 9de517f37..9324d4d2f 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -174,6 +174,29 @@ stop within seconds and respawn on their next request, which for an idle app |
| may be a while. Nothing is stuck: the instance comes back with the next |
| request that needs it. Hibernating instances are left asleep. |
| +### carlos run |
| + |
| +Asks an app to POST one of its own endpoints once, on every instance, now. |
| + |
| +```sh |
| +carlos run --app hello --path /admin/backfill |
| +``` |
| + |
| +This is not a shell and not a remote command. The platform never chooses |
| +what runs — it delivers one POST to a path your app already serves, the same |
| +way a schedule's fire does, and whatever happens next is your handler's |
| +business. A path your app does not serve comes back as your app's own 404. |
| + |
| +Like `restart`, it reports the run as *requested*: the console writes one |
| +small object, and every instance picks it up within about fifteen seconds. A |
| +hibernating instance is woken for it. The output goes to the app's log rather |
| +than to your terminal, so `carlos logs --app hello -f` is where you watch it |
| +— or pass `--wait` and this command will follow the log for you. |
| + |
| +Reach for it when the work is a one-off: a backfill, a reindex, a cache |
| +warm. If you find yourself typing the same run every week, it wants to be a |
| +schedule instead. |
| + |
| ### carlos schedule |
| Gives an app a timetable. A schedule is a time and a path: at each fire the |
diff --git a/internal/console/apirun.go b/internal/console/apirun.go| new file mode 100644 |
| index 000000000..0f18309c8 |
| --- /dev/null |
| +++ b/internal/console/apirun.go |
| @@ -0,0 +1,75 @@ |
| +// The CLI's ad-hoc run door — `carlos run` (internal/schedule/runpath.go). |
| +// |
| +// It is `schedule run`'s sibling and shares its whole gate (apiScheduleApp: |
| +// same principal proof, same byte-identical 404, same address-keyed write |
| +// budget, same 503 when this console has no bucket). The one difference is |
| +// what identifies the work: `schedule run` names a DECLARED schedule and the |
| +// box looks its path up in schedules.json, while this door takes the path |
| +// itself, because an ad-hoc run has no declaration to look anything up in. |
| +// |
| +// The platform never interprets the path. A path the app does not serve is |
| +// a 404 from the APP, visible in `carlos logs` — not something this door can |
| +// or should predict, exactly as a declared schedule's path is not checked. |
| + |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +// runRequestMaxBody bounds the body: one short path and nothing else. The |
| +// door reads a fixed shape, so anything larger is a mistake or a probe. |
| +const runRequestMaxBody = 4 << 10 |
| + |
| +// handleAPIRun answers `carlos run`: write run-request.json, whose etag |
| +// movement AND content the boxes act on. Every instance of the app POSTs the |
| +// path once, within a tick (~15s), waking a hibernating instance for it the |
| +// same way a scheduled tick does. |
| +func (s *Server) handleAPIRun(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot request a run") |
| + if !ok { |
| + return |
| + } |
| + var body struct { |
| + Path string `json:"path"` |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(r.Body, runRequestMaxBody)) |
| + if err != nil { |
| + jsonError(w, http.StatusBadRequest, "could not read the request") |
| + return |
| + } |
| + if err := json.Unmarshal(raw, &body); err != nil { |
| + jsonError(w, http.StatusBadRequest, "body must be JSON: {\"path\":\"/your/endpoint\"}") |
| + return |
| + } |
| + // Validated HERE, with the member's own words in the answer, and again on |
| + // the box — a bucket writer is not this door. |
| + if err := schedule.ValidPath(body.Path); err != nil { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| + defer cancel() |
| + if err := schedule.TouchRunPath(ctx, s.cfg.Releases, acct.Sqid, app, body.Path, time.Now()); err != nil { |
| + // Logged, not returned: a store error carries bucket names, |
| + // endpoints and request ids. |
| + log.Printf("console: api run %s/%s %s: %v", acct.Sqid, app, body.Path, err) |
| + jsonError(w, http.StatusBadGateway, "could not request the run — nothing was fired; try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "run", "requested an ad-hoc run of "+body.Path+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "requested": true, |
| + "account": acct.Sqid, |
| + "app": app, |
| + "path": body.Path, |
| + }) |
| +} |
diff --git a/internal/console/apirun_test.go b/internal/console/apirun_test.go| new file mode 100644 |
| index 000000000..e28ddb87c |
| --- /dev/null |
| +++ b/internal/console/apirun_test.go |
| @@ -0,0 +1,89 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +func runRequestEtag(t *testing.T, fx *consoleFixture, app string) string { |
| + t.Helper() |
| + _, etag, err := fx.rel.Get(context.Background(), schedule.RunRequestKey(release.OpsAccount, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "" |
| + } |
| + t.Fatalf("read run request: %v", err) |
| + } |
| + return etag |
| +} |
| + |
| +// `carlos run` needs no declaration: the path travels in the request, and |
| +// an app that has never declared a schedule can still be asked to run one |
| +// of its own endpoints. |
| +func TestAPIRunWritesTheRequestWithoutADeclaration(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-run-adhoc") |
| + if got := runRequestEtag(t, fx, "titogo"); got != "" { |
| + t.Fatalf("run request exists before any run: %q", got) |
| + } |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/run", token, |
| + `{"path":"/admin/backfill"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("run = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + Requested bool `json:"requested"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Path string `json:"path"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatalf("run body %s: %v", out, err) |
| + } |
| + if !res.Requested || res.Account != "bab" || res.App != "titogo" || res.Path != "/admin/backfill" { |
| + t.Fatalf("run body = %+v, want requested for bab/titogo /admin/backfill", res) |
| + } |
| + if runRequestEtag(t, fx, "titogo") == "" { |
| + t.Fatal("no run request in the bucket after a 200 — no box will ever fire it") |
| + } |
| +} |
| + |
| +// A typo costs a round trip and nothing else: the path is validated at the |
| +// door, so no object is written and no box is asked to POST anywhere. |
| +func TestAPIRunRefusesABadPathAndWritesNothing(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-run-badpath") |
| + for _, bad := range []string{`{"path":"../etc/passwd"}`, `{"path":"http://evil/x"}`, `{"path":""}`, `{}`} { |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/run", token, bad) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("run %s = %d %s, want 400", bad, status, out) |
| + } |
| + } |
| + if got := runRequestEtag(t, fx, "titogo"); got != "" { |
| + t.Fatalf("a refused path still wrote the request: %q", got) |
| + } |
| +} |
| + |
| +// Two runs are two signals. An unmoved etag is a run every box ignores, so |
| +// asking for the same backfill twice must move it twice. |
| +func TestAPIRunMovesTheEtagEveryTime(t *testing.T) { |
| + fx, token := scheduleFixture(t, "code-run-twice") |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/run", token, |
| + `{"path":"/admin/backfill"}`); status != http.StatusOK { |
| + t.Fatalf("first run = %d %s", status, out) |
| + } |
| + first := runRequestEtag(t, fx, "titogo") |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/run", token, |
| + `{"path":"/admin/backfill"}`); status != http.StatusOK { |
| + t.Fatalf("second run = %d %s", status, out) |
| + } |
| + if second := runRequestEtag(t, fx, "titogo"); second == first { |
| + t.Fatalf("etag did not move on the second run: %q", first) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index cd8e3f795..feda0e11f 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -493,6 +493,7 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleSet) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/delete", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRemove) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/run", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRun) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/run", svcrole.Allow(svcrole.Operate), s.handleAPIRun) |
| // `carlos releases retention` (issue #136): a member's ambient-prune |
| // policy — read, set, clear, preview. Same identity gates as every |
| // door here; the safety list in release.PruneEligible is what keeps a |
diff --git a/main.go b/main.go| index 71aa9a3c6..6db6081f2 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -67,6 +67,8 @@ func main() { |
| runDeploy(args) |
| case "restart": |
| runRestart(args) |
| + case "run": |
| + runRun(args) |
| case "steering": |
| runSteering(args) |
| case "schedule": |
| @@ -166,6 +168,9 @@ Your apps — ship and run software on a CARLOS deployment |
| ("carlos channels" lists them; "carlos pipeline" orders them) |
| deploy ship + promote + wait until the URL serves the new version |
| restart cycle an app's instances — no new version, no config change |
| + run ask an app to POST one of its own endpoints once, on every |
| + instance, now — "carlos run --app <a> --path /admin/backfill"; |
| + never a shell, never a command the platform chooses |
| schedule recurring or one-shot work per app: ls | set | rm | run |
| store object storage for an app: create | status | grant | rotate |
| email tenant email sending: enable | domains add | status | test | |
diff --git a/run.go b/run.go| new file mode 100644 |
| index 000000000..18e0b30d2 |
| --- /dev/null |
| +++ b/run.go |
| @@ -0,0 +1,73 @@ |
| +// run.go is `carlos run` — one ad-hoc POST to a path the app already |
| +// serves, on every instance, now. |
| +// |
| +// It is deliberately NOT an exec: the platform never runs a command, never |
| +// opens a shell, and never interprets the path. The app chose what to expose |
| +// and what it does there; this verb asks it to do that thing once, out of |
| +// band, over the same delivery `carlos schedule run` uses (internal/schedule |
| +// runpath.go and its converge leg). |
| +// |
| +// The tense the output uses matters and follows schedule.go's rule: |
| +// "requested", never "ran". Nothing here waits for the app's handler, so a |
| +// message claiming the work finished would be a claim this command cannot |
| +// make. --wait changes what the terminal shows, not what was promised. |
| + |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "strings" |
| +) |
| + |
| +const runUsage = `usage: carlos run --app <a> --path </p> [--account <a>] [--account-id <id>] [--console <origin>] |
| + |
| +Ask an app to POST its own endpoint once, on every instance, now. |
| + |
| +The path is one the app already serves — the platform never interprets it, |
| +and never runs a command or a shell. A path the app does not serve is a 404 |
| +from the app, which shows up in "carlos logs". |
| + |
| +Delivery is the schedule runner's: every instance picks the request up |
| +within ~15s, and a hibernating instance is woken for it. Output goes to the |
| +app's log, not to this terminal. |
| + |
| + --path </p> the endpoint to POST, absolute ("/admin/backfill") |
| + --wait follow the app's logs until you stop it (ctrl-c)` |
| + |
| +func runRun(args []string) { |
| + fs := flag.NewFlagSet("run", flag.ExitOnError) |
| + app := appFlag(fs, "app to run the endpoint on") |
| + path := fs.String("path", "", "endpoint to POST, absolute") |
| + wait := fs.Bool("wait", false, "follow the app's logs after requesting") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to run through (default: the only logged-in one)") |
| + fs.Usage = flagUsage(fs, runUsage) |
| + fs.Parse(args) |
| + |
| + p := strings.TrimSpace(*path) |
| + if p == "" { |
| + log.Fatal(runUsage) |
| + } |
| + |
| + sess := requireScheduleAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName := resolvedApp(sess, acct, *app, runUsage) |
| + res, err := sess.runPath(context.Background(), acct, appName, p) |
| + if err != nil { |
| + log.Fatalf("run: %v", err) |
| + } |
| + // The CANONICAL account/app the console echoed back — the segment this |
| + // terminal sent may have been a name, not the sqid the console stores. |
| + fmt.Fprintf(stdout, "%s %s on %s/%s — every instance POSTs it within ~15s\n", |
| + colorFor(stdout).ok("run requested"), res.Path, res.Account, res.App) |
| + if !*wait { |
| + fmt.Fprintf(stdout, "watch it with carlos logs --app %s -f\n", res.App) |
| + return |
| + } |
| + fmt.Fprintf(stdout, "following %s — ctrl-c to stop\n", res.App) |
| + runLogs([]string{"--app", res.App, "-f"}) |
| +} |