From 45cab74d8019524dfb4caf023630152d3b83d951 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Jun 2026 19:36:16 +0100 Subject: [PATCH 1/5] feat(deployment): Add quick action commands for running deployment tasks Operators previously had no way to run a command such as a database migration or cache rebuild on a deployment from the CLI. Two commands are added: one lists the quick actions defined on a deployment, and one runs a chosen action inside its service container and prints the command output. Actions remain subject to the deployment's protected-mode rules. This lets continuous deployment pipelines run tasks like migrations as an explicit step after shipping a release. --- CHANGELOG.md | 7 +++ VERSION | 2 +- docs/reference/commands.md | 9 ++++ internal/command/root.go | 75 ++++++++++++++++++++++++++++++++- internal/command/root_test.go | 65 ++++++++++++++++++++++++++++ internal/flatrun/client.go | 4 ++ internal/flatrun/client_test.go | 27 ++++++++++++ 7 files changed, 187 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee429e..1928f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the FlatRun CLI are documented in this file. +## [0.2.0] - 2026-06-15 + +### Added + +- `flatrun deployment actions NAME` lists the quick actions defined on a deployment. +- `flatrun deployment action NAME ACTION_ID` runs a quick action in its service container and prints the command output, enabling operator commands such as database migrations and cache rebuilds to run from CI. + ## [0.1.0] - 2026-05-23 ### Added diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 925c0ed..aee423d 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -66,6 +66,15 @@ flatrun deployment services my-api flatrun deployment containers my-api ``` +Run a quick action: + +```bash +flatrun deployment actions my-api +flatrun deployment action my-api migrate +``` + +`deployment actions` lists the quick actions defined on a deployment. `deployment action` runs one in its service container and prints the command output. Quick actions are configured on the deployment (id, command, and target service) and are subject to the deployment's protected-mode rules. This is how operator commands such as database migrations or cache rebuilds are run from CI. + Pull deployment images: ```bash diff --git a/internal/command/root.go b/internal/command/root.go index d0933bf..14885e0 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -524,7 +524,7 @@ func runHealth(args []string, stdout, stderr io.Writer) int { func runDeployment(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") + _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") return 2 } @@ -533,6 +533,10 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { return runDeploymentList(args[1:], stdout, stderr) case "info", "get": return runDeploymentInfo(args[0], args[1:], stdout, stderr) + case "actions": + return runDeploymentActions(args[1:], stdout, stderr) + case "action": + return runDeploymentAction(args[1:], stdout, stderr) case "image": return runDeploymentImage(args[1:], stdout, stderr) case "create": @@ -577,6 +581,31 @@ func runDeploymentInfo(command string, args []string, stdout, stderr io.Writer) }, args, stdout, stderr) } +func runDeploymentActions(args []string, stdout, stderr io.Writer) int { + return runClientCommand(clientCommand{ + name: "deployment actions", + usage: "Usage: flatrun deployment actions NAME", + positionals: 1, + run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { + return client.GetDeployment(ctx, args[0]) + }, + render: renderQuickActions, + }, args, stdout, stderr) +} + +func runDeploymentAction(args []string, stdout, stderr io.Writer) int { + return runClientCommand(clientCommand{ + name: "deployment action", + usage: "Usage: flatrun deployment action NAME ACTION_ID", + successMsg: "Action executed", + positionals: 2, + run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { + return client.ExecuteQuickAction(ctx, args[0], args[1]) + }, + render: renderQuickActionResult, + }, args, stdout, stderr) +} + func runDeploymentImage(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment image ") @@ -1114,6 +1143,50 @@ func renderDeploymentGet(stdout io.Writer, data []byte) error { return nil } +func renderQuickActions(stdout io.Writer, data []byte) error { + var response struct { + Deployment struct { + Metadata struct { + QuickActions []struct { + ID string `json:"id"` + Name string `json:"name"` + Service string `json:"service"` + Command string `json:"command"` + Description string `json:"description"` + } `json:"quick_actions"` + } `json:"metadata"` + } `json:"deployment"` + } + if err := json.Unmarshal(data, &response); err != nil { + return err + } + actions := response.Deployment.Metadata.QuickActions + tableRows := make([][]string, 0, len(actions)) + for _, action := range actions { + tableRows = append(tableRows, []string{action.ID, action.Name, action.Service, action.Command, action.Description}) + } + writeTable(stdout, []string{"ID", "NAME", "SERVICE", "COMMAND", "DESCRIPTION"}, tableRows) + return nil +} + +func renderQuickActionResult(stdout io.Writer, data []byte) error { + var response struct { + Message string `json:"message"` + ActionID string `json:"action_id"` + Output string `json:"output"` + } + if err := json.Unmarshal(data, &response); err != nil { + return err + } + if strings.TrimSpace(response.Output) != "" { + _, _ = fmt.Fprintln(stdout, strings.TrimRight(response.Output, "\n")) + } + if response.Message != "" { + _, _ = fmt.Fprintln(stdout, response.Message) + } + return nil +} + func renderImageList(stdout io.Writer, data []byte) error { var response struct { Images []imageListItem `json:"images"` diff --git a/internal/command/root_test.go b/internal/command/root_test.go index a8a6bc1..baff3da 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -376,6 +376,71 @@ func TestDeploymentDeleteCallsAPIWithConfirmation(t *testing.T) { } } +func TestDeploymentActionExecutesAndPrintsOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + if r.URL.Path != "/api/deployments/api/actions/migrate" { + t.Fatalf("path = %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"message":"Action executed successfully","action_id":"migrate","output":"Migrating: 2024_01_01_create\nMigrated: 2024_01_01_create"}`)) + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "action", "api", "migrate"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, want := range []string{"Migrated: 2024_01_01_create", "Action executed successfully"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout missing %q: %s", want, stdout.String()) + } + } +} + +func TestDeploymentActionRequiresActionID(t *testing.T) { + t.Setenv("FLATRUN_URL", "https://panel.example.com") + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "action", "api"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + +func TestDeploymentActionsListsQuickActions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/deployments/api" { + t.Fatalf("path = %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"deployment":{"name":"api","metadata":{"quick_actions":[{"id":"migrate","name":"Run migrations","service":"app","command":"php artisan migrate --force","description":"Apply pending migrations"}]}}}`)) + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "actions", "api"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, want := range []string{"ID", "migrate", "Run migrations", "app", "php artisan migrate --force"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("table missing %q: %s", want, stdout.String()) + } + } +} + func TestDeploymentListPrintsTableByDefault(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/deployments" { diff --git a/internal/flatrun/client.go b/internal/flatrun/client.go index 983cb07..79de910 100644 --- a/internal/flatrun/client.go +++ b/internal/flatrun/client.go @@ -109,6 +109,10 @@ func (c *Client) Manage(ctx context.Context, name string, operation string) ([]b return c.Do(ctx, http.MethodPost, "/deployments/"+url.PathEscape(name)+"/"+url.PathEscape(operation), nil) } +func (c *Client) ExecuteQuickAction(ctx context.Context, name, actionID string) ([]byte, error) { + return c.Do(ctx, http.MethodPost, "/deployments/"+url.PathEscape(name)+"/actions/"+url.PathEscape(actionID), nil) +} + func (c *Client) PullImages(ctx context.Context, name string, onlyLatest bool) ([]byte, error) { return c.Do(ctx, http.MethodPost, "/deployments/"+url.PathEscape(name)+"/pull", map[string]bool{ "only_latest": onlyLatest, diff --git a/internal/flatrun/client_test.go b/internal/flatrun/client_test.go index 48ae0f5..97ce372 100644 --- a/internal/flatrun/client_test.go +++ b/internal/flatrun/client_test.go @@ -50,6 +50,33 @@ func TestDeployUsesAPIBaseAndBearerToken(t *testing.T) { } } +func TestExecuteQuickActionPostsToActionsPath(t *testing.T) { + var captured *http.Request + + client := New("https://panel.example.com/api", "secret", time.Minute, false) + client.HTTP.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + captured = req + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"message":"ok"}`)), + }, nil + }) + + if _, err := client.ExecuteQuickAction(context.Background(), "my app", "migrate"); err != nil { + t.Fatalf("ExecuteQuickAction returned error: %v", err) + } + if captured.Method != http.MethodPost { + t.Fatalf("method = %s", captured.Method) + } + if captured.URL.String() != "https://panel.example.com/api/deployments/my%20app/actions/migrate" { + t.Fatalf("url = %s", captured.URL.String()) + } + if captured.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("authorization = %q", captured.Header.Get("Authorization")) + } +} + func TestDoTrimsTrailingAPIBaseSlash(t *testing.T) { var captured *http.Request From 5dc82bc1af622fc2f504b1e3ffd15e2e8b139f5d Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Jun 2026 20:38:24 +0100 Subject: [PATCH 2/5] feat(deployment): Add ad-hoc exec for deployments and containers Quick actions cover named, reusable commands, but there was no way to run a one-off command on a deployment without first defining it. Adds an exec command that runs an arbitrary command in a deployment's service container (selecting the service when there is more than one) and prints its output, plus an equivalent that targets a container by ID. Both run non-interactively and honor the deployment's protected-mode rules, so a pipeline can run something like a database migration as a single step. --- CHANGELOG.md | 2 + docs/reference/commands.md | 12 +++ internal/command/root.go | 161 +++++++++++++++++++++++++++++++- internal/command/root_test.go | 73 +++++++++++++++ internal/flatrun/client.go | 9 ++ internal/flatrun/client_test.go | 32 +++++++ 6 files changed, 287 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1928f19..d7b7937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the FlatRun CLI are documented in this file. - `flatrun deployment actions NAME` lists the quick actions defined on a deployment. - `flatrun deployment action NAME ACTION_ID` runs a quick action in its service container and prints the command output, enabling operator commands such as database migrations and cache rebuilds to run from CI. +- `flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]` runs an ad-hoc command in a deployment's service container and prints the output, for one-off operator commands that are not defined as quick actions. +- `flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]` runs an ad-hoc command in a container by ID. ## [0.1.0] - 2026-05-23 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index aee423d..472b0e8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -75,6 +75,18 @@ flatrun deployment action my-api migrate `deployment actions` lists the quick actions defined on a deployment. `deployment action` runs one in its service container and prints the command output. Quick actions are configured on the deployment (id, command, and target service) and are subject to the deployment's protected-mode rules. This is how operator commands such as database migrations or cache rebuilds are run from CI. +Run an ad-hoc command (any command the container image provides): + +```bash +flatrun deployment exec my-api -- npx prisma migrate deploy +flatrun deployment exec my-api -- bin/rails db:migrate +flatrun deployment exec my-api --service worker -- python manage.py migrate +flatrun deployment exec my-api -- php artisan migrate --force +flatrun container exec abc123 -- sh -c 'printenv | sort' +``` + +`deployment exec` runs a command in a deployment's service container and prints the output. Everything after `--` is the command and its arguments. `--service` selects the service when a deployment has more than one; without it the first running service is used. `container exec` targets a container directly by ID. Both run non-interactively and are subject to the deployment's protected-mode rules; use a quick action when you want a named, reusable command instead. + Pull deployment images: ```bash diff --git a/internal/command/root.go b/internal/command/root.go index 14885e0..0b6247b 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -524,7 +524,7 @@ func runHealth(args []string, stdout, stderr io.Writer) int { func runDeployment(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") + _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") return 2 } @@ -537,6 +537,8 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { return runDeploymentActions(args[1:], stdout, stderr) case "action": return runDeploymentAction(args[1:], stdout, stderr) + case "exec": + return runDeploymentExec(args[1:], stdout, stderr) case "image": return runDeploymentImage(args[1:], stdout, stderr) case "create": @@ -606,6 +608,146 @@ func runDeploymentAction(args []string, stdout, stderr io.Writer) int { }, args, stdout, stderr) } +func runDeploymentExec(args []string, stdout, stderr io.Writer) int { + opts := globalOptions{} + service := "" + + head, command := splitOnDoubleDash(args) + + fs := globalFlagSet("deployment exec", &opts, stderr, stderr) + fs.StringVar(&service, "service", "", "Service whose container runs the command") + if code, ok := parseFlagSet(fs, interspersedFlags(head, globalValueFlags("service"))); !ok { + return code + } + + positionals := fs.Args() + if len(positionals) == 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]") + return 2 + } + name := positionals[0] + if len(command) == 0 { + command = positionals[1:] + } + if len(command) == 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]") + return 2 + } + + client, err := clientFromOptions(opts) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 2 + } + + data, err := client.GetDeployment(context.Background(), name) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + containerID, err := serviceContainerID(data, service) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + + return execContainer(client, containerID, command, opts, stdout, stderr) +} + +func runContainerExec(args []string, stdout, stderr io.Writer) int { + opts := globalOptions{} + + head, command := splitOnDoubleDash(args) + + fs := globalFlagSet("container exec", &opts, stderr, stderr) + if code, ok := parseFlagSet(fs, interspersedFlags(head, globalValueFlags())); !ok { + return code + } + + positionals := fs.Args() + if len(positionals) == 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]") + return 2 + } + containerID := positionals[0] + if len(command) == 0 { + command = positionals[1:] + } + if len(command) == 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]") + return 2 + } + + client, err := clientFromOptions(opts) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 2 + } + + return execContainer(client, containerID, command, opts, stdout, stderr) +} + +func execContainer(client *flatrun.Client, containerID string, command []string, opts globalOptions, stdout, stderr io.Writer) int { + data, err := client.ContainerExec(context.Background(), containerID, flatrun.ExecRequest{ + Command: command[0], + Args: command[1:], + }) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + if opts.JSON { + printResponse(stdout, true, data, "") + return 0 + } + if err := renderExecOutput(stdout, data); err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + return 0 +} + +func splitOnDoubleDash(args []string) (head, command []string) { + for i, arg := range args { + if arg == "--" { + return args[:i], args[i+1:] + } + } + return args, nil +} + +func serviceContainerID(data []byte, service string) (string, error) { + var response struct { + Deployment struct { + Services []struct { + Name string `json:"name"` + ContainerID string `json:"container_id"` + } `json:"services"` + } `json:"deployment"` + } + if err := json.Unmarshal(data, &response); err != nil { + return "", err + } + services := response.Deployment.Services + if service != "" { + for _, svc := range services { + if svc.Name == service { + if svc.ContainerID == "" { + return "", fmt.Errorf("service %q has no running container", service) + } + return svc.ContainerID, nil + } + } + return "", fmt.Errorf("service %q not found in deployment", service) + } + for _, svc := range services { + if svc.ContainerID != "" { + return svc.ContainerID, nil + } + } + return "", errors.New("no running container found in deployment; specify --service") +} + func runDeploymentImage(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment image ") @@ -959,7 +1101,7 @@ func runImageDelete(args []string, stdout, stderr io.Writer) int { func runContainer(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun container ") + _, _ = fmt.Fprintln(stderr, "Usage: flatrun container ") return 2 } @@ -968,6 +1110,8 @@ func runContainer(args []string, stdout, stderr io.Writer) int { return runContainerList(args[1:], stdout, stderr) case "start", "stop", "restart": return runContainerSimple(args[0], args[1:], stdout, stderr) + case "exec": + return runContainerExec(args[1:], stdout, stderr) case "delete": return runContainerDelete(args[1:], stdout, stderr) default: @@ -1187,6 +1331,19 @@ func renderQuickActionResult(stdout io.Writer, data []byte) error { return nil } +func renderExecOutput(stdout io.Writer, data []byte) error { + var response struct { + Output string `json:"output"` + } + if err := json.Unmarshal(data, &response); err != nil { + return err + } + if strings.TrimSpace(response.Output) != "" { + _, _ = fmt.Fprintln(stdout, strings.TrimRight(response.Output, "\n")) + } + return nil +} + func renderImageList(stdout io.Writer, data []byte) error { var response struct { Images []imageListItem `json:"images"` diff --git a/internal/command/root_test.go b/internal/command/root_test.go index baff3da..1deed13 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -441,6 +441,79 @@ func TestDeploymentActionsListsQuickActions(t *testing.T) { } } +func TestContainerExecRunsCommandAndPrintsOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + if r.URL.Path != "/api/containers/abc123/exec" { + t.Fatalf("path = %s", r.URL.Path) + } + var body struct { + Command string `json:"command"` + Args []string `json:"args"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Command != "php" || strings.Join(body.Args, " ") != "artisan migrate --force" { + t.Fatalf("body = %+v", body) + } + _, _ = w.Write([]byte(`{"output":"Nothing to migrate."}`)) + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"container", "exec", "abc123", "--", "php", "artisan", "migrate", "--force"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Nothing to migrate.") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestDeploymentExecResolvesServiceContainer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/deployments/api": + _, _ = w.Write([]byte(`{"deployment":{"name":"api","services":[{"name":"app","container_id":"ctr-app"},{"name":"worker","container_id":"ctr-worker"}]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/containers/ctr-worker/exec": + _, _ = w.Write([]byte(`{"output":"OPTIMIZED"}`)) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "exec", "api", "--service", "worker", "--", "php", "artisan", "optimize"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "OPTIMIZED") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestDeploymentExecRequiresCommand(t *testing.T) { + t.Setenv("FLATRUN_URL", "https://panel.example.com") + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "exec", "api"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + func TestDeploymentListPrintsTableByDefault(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/deployments" { diff --git a/internal/flatrun/client.go b/internal/flatrun/client.go index 79de910..1a94ceb 100644 --- a/internal/flatrun/client.go +++ b/internal/flatrun/client.go @@ -189,6 +189,15 @@ func (c *Client) ContainerOperation(ctx context.Context, id, operation string) ( return c.Do(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/"+url.PathEscape(operation), nil) } +type ExecRequest struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` +} + +func (c *Client) ContainerExec(ctx context.Context, id string, req ExecRequest) ([]byte, error) { + return c.Do(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/exec", req) +} + func (c *Client) RemoveContainer(ctx context.Context, id string) ([]byte, error) { return c.Do(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id), nil) } diff --git a/internal/flatrun/client_test.go b/internal/flatrun/client_test.go index 97ce372..ef2b54f 100644 --- a/internal/flatrun/client_test.go +++ b/internal/flatrun/client_test.go @@ -77,6 +77,38 @@ func TestExecuteQuickActionPostsToActionsPath(t *testing.T) { } } +func TestContainerExecPostsCommand(t *testing.T) { + var captured *http.Request + var payload ExecRequest + + client := New("https://panel.example.com/api", "secret", time.Minute, false) + client.HTTP.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + captured = req + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"output":"ok"}`)), + }, nil + }) + + _, err := client.ContainerExec(context.Background(), "abc123", ExecRequest{Command: "php", Args: []string{"artisan", "migrate"}}) + if err != nil { + t.Fatalf("ContainerExec returned error: %v", err) + } + if captured.Method != http.MethodPost { + t.Fatalf("method = %s", captured.Method) + } + if captured.URL.String() != "https://panel.example.com/api/containers/abc123/exec" { + t.Fatalf("url = %s", captured.URL.String()) + } + if payload.Command != "php" || len(payload.Args) != 2 || payload.Args[0] != "artisan" { + t.Fatalf("payload = %+v", payload) + } +} + func TestDoTrimsTrailingAPIBaseSlash(t *testing.T) { var captured *http.Request From ff441fd9e11b7563bd46b7c5faea58e14d139319 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Jun 2026 22:02:38 +0100 Subject: [PATCH 3/5] fix(deployment): Show command output when exec or quick action fails A failed command previously surfaced only its exit status (for example "exit status 127"), hiding the container's own output and making failures hard to diagnose. The captured output is now printed alongside the error whenever an exec or quick action exits non-zero, so the underlying message (a missing binary, a migration error, a stack trace) is visible. The command still exits non-zero so pipelines continue to fail. --- CHANGELOG.md | 1 + internal/command/root.go | 23 +++++++++++++++++++++++ internal/command/root_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b7937..6d68b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to the FlatRun CLI are documented in this file. - `flatrun deployment action NAME ACTION_ID` runs a quick action in its service container and prints the command output, enabling operator commands such as database migrations and cache rebuilds to run from CI. - `flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]` runs an ad-hoc command in a deployment's service container and prints the output, for one-off operator commands that are not defined as quick actions. - `flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]` runs an ad-hoc command in a container by ID. +- Exec and quick action failures print the command's captured output alongside the error, so a non-zero exit shows what the container actually reported instead of only the exit status. The command still exits non-zero. ## [0.1.0] - 2026-05-23 diff --git a/internal/command/root.go b/internal/command/root.go index 0b6247b..79ee637 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -304,6 +304,9 @@ func runClientCommand(cmd clientCommand, args []string, stdout, stderr io.Writer } data, err := cmd.run(context.Background(), client, fs.Args()) if err != nil { + if output := apiErrorOutput(err); output != "" { + _, _ = fmt.Fprintln(stderr, output) + } _, _ = fmt.Fprintln(stderr, "Error:", err) return 1 } @@ -693,6 +696,9 @@ func execContainer(client *flatrun.Client, containerID string, command []string, Args: command[1:], }) if err != nil { + if output := apiErrorOutput(err); output != "" { + _, _ = fmt.Fprintln(stderr, output) + } _, _ = fmt.Fprintln(stderr, "Error:", err) return 1 } @@ -1331,6 +1337,23 @@ func renderQuickActionResult(stdout io.Writer, data []byte) error { return nil } +// apiErrorOutput extracts a command's captured output from a failed API +// response so a non-zero exit (e.g. a failed migration) shows what the +// container actually printed, not just the exit status. +func apiErrorOutput(err error) string { + var apiErr *flatrun.Error + if !errors.As(err, &apiErr) || apiErr.Body == "" { + return "" + } + var parsed struct { + Output string `json:"output"` + } + if json.Unmarshal([]byte(apiErr.Body), &parsed) != nil { + return "" + } + return strings.TrimRight(parsed.Output, "\n") +} + func renderExecOutput(stdout io.Writer, data []byte) error { var response struct { Output string `json:"output"` diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 1deed13..72714d7 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -475,6 +475,30 @@ func TestContainerExecRunsCommandAndPrintsOutput(t *testing.T) { } } +func TestContainerExecPrintsOutputWhenCommandFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"exit status 127","output":"sh: php: not found"}`)) + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"container", "exec", "abc123", "--", "php", "-v"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("code=%d", code) + } + if !strings.Contains(stderr.String(), "sh: php: not found") { + t.Fatalf("stderr missing command output: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), "exit status 127") { + t.Fatalf("stderr missing error: %s", stderr.String()) + } +} + func TestDeploymentExecResolvesServiceContainer(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { From 31d33ce7855a891645f8c9c2b3b64b979074cd86 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Jun 2026 22:08:55 +0100 Subject: [PATCH 4/5] fix(deployment): Require a service when exec is ambiguous Running exec on a deployment with more than one service silently picked the first one, so a command meant for the app could land in an unrelated container (for example a database) and fail confusingly. Exec now resolves the service automatically only when a deployment has a single running service; with more than one it lists them and stops until a service is named. The service can be given positionally or with --service, and the command to run must follow `--` so it is never mistaken for a service or flag. --- CHANGELOG.md | 2 +- docs/reference/commands.md | 6 +-- internal/command/root.go | 72 ++++++++++++++++++++++++----------- internal/command/root_test.go | 66 ++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d68b83..bfcc0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to the FlatRun CLI are documented in this file. - `flatrun deployment actions NAME` lists the quick actions defined on a deployment. - `flatrun deployment action NAME ACTION_ID` runs a quick action in its service container and prints the command output, enabling operator commands such as database migrations and cache rebuilds to run from CI. -- `flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]` runs an ad-hoc command in a deployment's service container and prints the output, for one-off operator commands that are not defined as quick actions. +- `flatrun deployment exec NAME [SERVICE] -- COMMAND [ARGS...]` runs an ad-hoc command in a deployment's service container and prints the output, for one-off operator commands that are not defined as quick actions. The service may be named positionally or with `--service`; a single-service deployment is resolved automatically, while a deployment with more than one service must be told which to use rather than guessing. - `flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]` runs an ad-hoc command in a container by ID. - Exec and quick action failures print the command's captured output alongside the error, so a non-zero exit shows what the container actually reported instead of only the exit status. The command still exits non-zero. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 472b0e8..6b97ee9 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -80,12 +80,12 @@ Run an ad-hoc command (any command the container image provides): ```bash flatrun deployment exec my-api -- npx prisma migrate deploy flatrun deployment exec my-api -- bin/rails db:migrate -flatrun deployment exec my-api --service worker -- python manage.py migrate -flatrun deployment exec my-api -- php artisan migrate --force +flatrun deployment exec my-api worker -- python manage.py migrate +flatrun deployment exec my-api --service worker -- php artisan migrate --force flatrun container exec abc123 -- sh -c 'printenv | sort' ``` -`deployment exec` runs a command in a deployment's service container and prints the output. Everything after `--` is the command and its arguments. `--service` selects the service when a deployment has more than one; without it the first running service is used. `container exec` targets a container directly by ID. Both run non-interactively and are subject to the deployment's protected-mode rules; use a quick action when you want a named, reusable command instead. +`deployment exec` runs a command in a deployment's service container and prints the output. The command and its arguments must follow `--`. Choose the service either as a positional argument (`exec NAME SERVICE -- ...`) or with `--service`; if a deployment has a single running service it is used automatically, and if it has more than one you must name it (otherwise the command reports the available services and stops). `container exec` targets a container directly by ID. Both run non-interactively and are subject to the deployment's protected-mode rules; on a non-zero exit they print the command's captured output and exit non-zero. Use a quick action when you want a named, reusable command instead. Pull deployment images: diff --git a/internal/command/root.go b/internal/command/root.go index 79ee637..50d50d0 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -611,11 +611,18 @@ func runDeploymentAction(args []string, stdout, stderr io.Writer) int { }, args, stdout, stderr) } +const deploymentExecUsage = "Usage: flatrun deployment exec NAME [SERVICE] -- COMMAND [ARGS...]" + func runDeploymentExec(args []string, stdout, stderr io.Writer) int { opts := globalOptions{} service := "" - head, command := splitOnDoubleDash(args) + head, command, hasSeparator := splitOnDoubleDash(args) + if !hasSeparator || len(command) == 0 { + _, _ = fmt.Fprintln(stderr, deploymentExecUsage) + _, _ = fmt.Fprintln(stderr, "The command to run must follow `--`.") + return 2 + } fs := globalFlagSet("deployment exec", &opts, stderr, stderr) fs.StringVar(&service, "service", "", "Service whose container runs the command") @@ -625,15 +632,20 @@ func runDeploymentExec(args []string, stdout, stderr io.Writer) int { positionals := fs.Args() if len(positionals) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]") + _, _ = fmt.Fprintln(stderr, deploymentExecUsage) return 2 } name := positionals[0] - if len(command) == 0 { - command = positionals[1:] - } - if len(command) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment exec NAME [--service SERVICE] -- COMMAND [ARGS...]") + switch { + case len(positionals) == 2: + if service != "" { + _, _ = fmt.Fprintln(stderr, "Error: service given both as an argument and --service") + return 2 + } + service = positionals[1] + case len(positionals) > 2: + _, _ = fmt.Fprintf(stderr, "Error: unexpected arguments before `--`: %s\n", strings.Join(positionals[1:], " ")) + _, _ = fmt.Fprintln(stderr, deploymentExecUsage) return 2 } @@ -660,7 +672,13 @@ func runDeploymentExec(args []string, stdout, stderr io.Writer) int { func runContainerExec(args []string, stdout, stderr io.Writer) int { opts := globalOptions{} - head, command := splitOnDoubleDash(args) + const usage = "Usage: flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]" + head, command, hasSeparator := splitOnDoubleDash(args) + if !hasSeparator || len(command) == 0 { + _, _ = fmt.Fprintln(stderr, usage) + _, _ = fmt.Fprintln(stderr, "The command to run must follow `--`.") + return 2 + } fs := globalFlagSet("container exec", &opts, stderr, stderr) if code, ok := parseFlagSet(fs, interspersedFlags(head, globalValueFlags())); !ok { @@ -668,18 +686,11 @@ func runContainerExec(args []string, stdout, stderr io.Writer) int { } positionals := fs.Args() - if len(positionals) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]") + if len(positionals) != 1 { + _, _ = fmt.Fprintln(stderr, usage) return 2 } containerID := positionals[0] - if len(command) == 0 { - command = positionals[1:] - } - if len(command) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun container exec CONTAINER_ID -- COMMAND [ARGS...]") - return 2 - } client, err := clientFromOptions(opts) if err != nil { @@ -713,13 +724,13 @@ func execContainer(client *flatrun.Client, containerID string, command []string, return 0 } -func splitOnDoubleDash(args []string) (head, command []string) { +func splitOnDoubleDash(args []string) (head, command []string, found bool) { for i, arg := range args { if arg == "--" { - return args[:i], args[i+1:] + return args[:i], args[i+1:], true } } - return args, nil + return args, nil, false } func serviceContainerID(data []byte, service string) (string, error) { @@ -744,14 +755,29 @@ func serviceContainerID(data []byte, service string) (string, error) { return svc.ContainerID, nil } } - return "", fmt.Errorf("service %q not found in deployment", service) + available := make([]string, 0, len(services)) + for _, svc := range services { + available = append(available, svc.Name) + } + return "", fmt.Errorf("service %q not found in deployment (services: %s)", service, strings.Join(available, ", ")) } + + running := make([]string, 0, len(services)) + containerID := "" for _, svc := range services { if svc.ContainerID != "" { - return svc.ContainerID, nil + running = append(running, svc.Name) + containerID = svc.ContainerID } } - return "", errors.New("no running container found in deployment; specify --service") + switch len(running) { + case 0: + return "", errors.New("no running container found in deployment") + case 1: + return containerID, nil + default: + return "", fmt.Errorf("deployment has multiple services (%s); choose one with: deployment exec NAME SERVICE -- COMMAND", strings.Join(running, ", ")) + } } func runDeploymentImage(args []string, stdout, stderr io.Writer) int { diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 72714d7..15a6bab 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -526,6 +526,72 @@ func TestDeploymentExecResolvesServiceContainer(t *testing.T) { } } +func TestDeploymentExecAcceptsServiceAsPositional(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/deployments/api": + _, _ = w.Write([]byte(`{"deployment":{"name":"api","services":[{"name":"app","container_id":"ctr-app"},{"name":"worker","container_id":"ctr-worker"}]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/containers/ctr-app/exec": + _, _ = w.Write([]byte(`{"output":"OK"}`)) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "exec", "api", "app", "--", "php", "artisan", "migrate"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "OK") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestDeploymentExecErrorsWhenServiceAmbiguous(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/deployments/api" { + _, _ = w.Write([]byte(`{"deployment":{"name":"api","services":[{"name":"app","container_id":"ctr-app"},{"name":"worker","container_id":"ctr-worker"}]}}`)) + return + } + t.Fatalf("unexpected request to %s; exec should not run when the service is ambiguous", r.URL.Path) + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"deployment", "exec", "api", "--", "php", "-v"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, want := range []string{"multiple services", "app", "worker"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr missing %q: %s", want, stderr.String()) + } + } +} + +func TestDeploymentExecRequiresDoubleDash(t *testing.T) { + t.Setenv("FLATRUN_URL", "https://panel.example.com") + t.Setenv("FLATRUN_TOKEN", "secret") + + var stdout bytes.Buffer + var stderr bytes.Buffer + // No `--` separator: the command is not clearly delimited, so this is rejected. + code := Run([]string{"deployment", "exec", "api", "php", "artisan", "migrate"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + func TestDeploymentExecRequiresCommand(t *testing.T) { t.Setenv("FLATRUN_URL", "https://panel.example.com") t.Setenv("FLATRUN_TOKEN", "secret") From 40e485a20de4358d3db2003ae7b25b31100f38b0 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 15 Jun 2026 22:17:48 +0100 Subject: [PATCH 5/5] docs: Document running commands on deployments in the README The README command overview did not mention the new ways to run commands on a deployment, so add quick action and exec usage alongside the existing deployment commands. --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 121c31e..729f4e9 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,18 @@ flatrun deployment delete my-api `deployment image set` updates the image for one compose service and writes the updated compose back to the deployment. Add `--deploy` when CI should immediately pull and run a deployment operation after the compose update. +Run commands inside a deployment, for tasks such as database migrations after a release: + +```bash +flatrun deployment actions my-api +flatrun deployment action my-api migrate +flatrun deployment exec my-api -- bin/rails db:migrate +flatrun deployment exec my-api worker -- php artisan queue:restart +flatrun container exec abc123 -- sh -c 'printenv | sort' +``` + +`deployment action` runs a quick action defined on the deployment; `deployment actions` lists them. `deployment exec` runs an ad-hoc command instead: the command follows `--`, and the service is chosen positionally or with `--service` (a single-service deployment is resolved automatically, a multi-service one must be named). Both run in the service container, honor the deployment's protected-mode rules, and surface the command's output (including on a non-zero exit). + Call any backend endpoint while a polished command is still pending: ```bash