Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

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.
- `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.

## [0.1.0] - 2026-05-23

### Added
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.2.0
21 changes: 21 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ 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.

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 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. 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:

```bash
Expand Down
283 changes: 281 additions & 2 deletions internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -524,7 +527,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 <list|info|get|image|create|delete|start|stop|restart|rebuild|deploy|pull|images|containers|services>")
_, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment <list|info|get|actions|action|exec|image|create|delete|start|stop|restart|rebuild|deploy|pull|images|containers|services>")
return 2
}

Expand All @@ -533,6 +536,12 @@ 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 "exec":
return runDeploymentExec(args[1:], stdout, stderr)
case "image":
return runDeploymentImage(args[1:], stdout, stderr)
case "create":
Expand Down Expand Up @@ -577,6 +586,200 @@ 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)
}

const deploymentExecUsage = "Usage: flatrun deployment exec NAME [SERVICE] -- COMMAND [ARGS...]"

func runDeploymentExec(args []string, stdout, stderr io.Writer) int {
opts := globalOptions{}
service := ""

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")
if code, ok := parseFlagSet(fs, interspersedFlags(head, globalValueFlags("service"))); !ok {
return code
}

positionals := fs.Args()
if len(positionals) == 0 {
_, _ = fmt.Fprintln(stderr, deploymentExecUsage)
return 2
}
name := positionals[0]
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
}

client, err := clientFromOptions(opts)
if err != nil {
_, _ = fmt.Fprintln(stderr, "Error:", err)
return 2
}

data, err := client.GetDeployment(context.Background(), name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is safer to use the context passed to the function rather than context.Background(), ensuring that cancellation signals (like SIGINT) are respected during the API call.

Suggested change
data, err := client.GetDeployment(context.Background(), name)
data, err := client.GetDeployment(ctx, 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{}

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 {
return code
}

positionals := fs.Args()
if len(positionals) != 1 {
_, _ = fmt.Fprintln(stderr, usage)
return 2
}
containerID := positionals[0]

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing a context to the API request allows for proper timeout and cancellation management, which is critical for long-running 'exec' commands.

Suggested change
func execContainer(client *flatrun.Client, containerID string, command []string, opts globalOptions, stdout, stderr io.Writer) int {
func execContainer(ctx context.Context, client *flatrun.Client, containerID string, command []string, opts globalOptions, stdout, stderr io.Writer) int {
data, err := client.ContainerExec(ctx, containerID, flatrun.ExecRequest{

data, err := client.ContainerExec(context.Background(), containerID, flatrun.ExecRequest{
Command: command[0],
Args: command[1:],
})
if err != nil {
if output := apiErrorOutput(err); output != "" {
_, _ = fmt.Fprintln(stderr, output)
}
_, _ = 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, found bool) {
for i, arg := range args {
if arg == "--" {
return args[:i], args[i+1:], true
}
}
return args, nil, false
}

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
}
}
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 != "" {
running = append(running, svc.Name)
containerID = svc.ContainerID
}
}
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 {
if len(args) == 0 {
_, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment image <set>")
Expand Down Expand Up @@ -930,7 +1133,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 <list|start|stop|restart|delete>")
_, _ = fmt.Fprintln(stderr, "Usage: flatrun container <list|start|stop|restart|exec|delete>")
return 2
}

Expand All @@ -939,6 +1142,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:
Expand Down Expand Up @@ -1114,6 +1319,80 @@ 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
}

// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If json.Unmarshal fails, the error is swallowed and an empty string is returned. While this prevents a crash, it might be helpful to log a debug message or handle the error if the API structure is expected to be stable.

Suggested change
if json.Unmarshal([]byte(apiErr.Body), &parsed) != nil {
if err := json.Unmarshal([]byte(apiErr.Body), &parsed); err != nil {
return ""
}

return ""
}
return strings.TrimRight(parsed.Output, "\n")
}

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"`
Expand Down
Loading
Loading