carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev 5af29fe4416a76adbef5d1262655f2ed74f9b679 parent cb97234
7 files changed, +296 −0
  • apimode.go +30 −0
  • docs/site/cli.md +23 −0
  • internal/console/apirun.go +75 −0
  • internal/console/apirun_test.go +89 −0
  • internal/console/authcli.go +1 −0
  • main.go +5 −0
  • run.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"})
+}