diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee429e..bfcc0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 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..6b97ee9 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -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 diff --git a/internal/command/root.go b/internal/command/root.go index d0933bf..50d50d0 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 } @@ -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 ") + _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") return 2 } @@ -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": @@ -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) + 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 { + 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 ") @@ -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 ") + _, _ = fmt.Fprintln(stderr, "Usage: flatrun container ") return 2 } @@ -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: @@ -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 { + 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"` diff --git a/internal/command/root_test.go b/internal/command/root_test.go index a8a6bc1..15a6bab 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -376,6 +376,234 @@ 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 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 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 { + 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 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") + + 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 983cb07..1a94ceb 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, @@ -185,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 48ae0f5..ef2b54f 100644 --- a/internal/flatrun/client_test.go +++ b/internal/flatrun/client_test.go @@ -50,6 +50,65 @@ 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 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