From b630384282767821009a9de16c3e55242fa6b8cb Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 27 Sep 2026 16:57:25 +0100 Subject: [PATCH 1/3] feat: Complete migration operation coverage Operators can transfer files in both directions, attach databases, manage observability, and drive migration checkpoints without manual server access. --- docs/reference/commands.md | 30 ++++++++++++++++++++++++ internal/command/endpoints_gen.go | 14 +++++++++++ internal/command/root.go | 39 ++++++++++++++++++++++++++++--- internal/command/root_test.go | 36 ++++++++++++++++++++++++++++ internal/command/shaped.go | 5 ++++ internal/flatrun/files.go | 36 ++++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 3 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b86b9b4..8490b82 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -94,6 +94,36 @@ flatrun deployment pull my-api flatrun deployment pull my-api --only-latest ``` +Transfer deployment files: + +```bash +flatrun deployment files push my-api ./public public/ --delete +flatrun deployment files pull my-api public/index.html ./index.html +``` + +Attach a database to an existing deployment: + +```bash +flatrun deployment databases-attach my-api -f type=postgres -f mode=shared -f alias=primary +``` + +Track a migration and check DNS propagation: + +```bash +flatrun deployment migration-update my-api --data @migration.json +flatrun deployment migration-check-dns my-api +flatrun deployment migration my-api +``` + +Manage observability without server access: + +```bash +flatrun observability get +flatrun observability update --data @observability.json +flatrun observability alerts +flatrun observability alerts-update --data @alert-rules.json +``` + Run deployment operations: ```bash diff --git a/internal/command/endpoints_gen.go b/internal/command/endpoints_gen.go index e4a32fd..d82e3a3 100644 --- a/internal/command/endpoints_gen.go +++ b/internal/command/endpoints_gen.go @@ -3,6 +3,10 @@ package command var generatedEndpoints = []endpoint{ + {family: "access", op: "check", method: "GET", path: "/access/check", args: nil}, + {family: "access", op: "login", method: "GET", path: "/access/login", args: nil}, + {family: "access", op: "request", method: "POST", path: "/access/request", args: nil}, + {family: "access", op: "verify", method: "GET", path: "/access/verify", args: nil}, {family: "agent", op: "update", method: "GET", path: "/agent/update", args: nil}, {family: "agent", op: "update-create", method: "POST", path: "/agent/update", args: nil}, {family: "ai", op: "agents", method: "GET", path: "/ai/agents", args: nil}, @@ -116,6 +120,7 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "delete", method: "DELETE", path: "/deployments/:name", args: []string{"name"}}, {family: "deployments", op: "get", method: "GET", path: "/deployments/:name", args: []string{"name"}}, {family: "deployments", op: "update", method: "PUT", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "access-email-targets", method: "GET", path: "/deployments/:name/access/email-targets", args: []string{"name"}}, {family: "deployments", op: "actions", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}}, {family: "deployments", op: "ai-analyze", method: "POST", path: "/deployments/:name/ai/analyze", args: []string{"name"}}, {family: "deployments", op: "archive", method: "GET", path: "/deployments/:name/archive", args: []string{"name"}}, @@ -124,14 +129,19 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "autoscale-activate", method: "POST", path: "/deployments/:name/autoscale/activate", args: []string{"name"}}, {family: "deployments", op: "autoscale-compatibility", method: "GET", path: "/deployments/:name/autoscale/compatibility", args: []string{"name"}}, {family: "deployments", op: "autoscale-workload", method: "PUT", path: "/deployments/:name/autoscale/workload", args: []string{"name"}}, + {family: "deployments", op: "backup-cleanup", method: "POST", path: "/deployments/:name/backup-cleanup", args: []string{"name"}}, + {family: "deployments", op: "backup-cleanup-preview", method: "GET", path: "/deployments/:name/backup-cleanup-preview", args: []string{"name"}}, {family: "deployments", op: "backup-config", method: "GET", path: "/deployments/:name/backup-config", args: []string{"name"}}, {family: "deployments", op: "backup-config-update", method: "PUT", path: "/deployments/:name/backup-config", args: []string{"name"}}, + {family: "deployments", op: "backup-destinations", method: "GET", path: "/deployments/:name/backup-destinations", args: []string{"name"}}, + {family: "deployments", op: "backup-policy", method: "GET", path: "/deployments/:name/backup-policy", args: []string{"name"}}, {family: "deployments", op: "backups", method: "GET", path: "/deployments/:name/backups", args: []string{"name"}}, {family: "deployments", op: "backups-create", method: "POST", path: "/deployments/:name/backups", args: []string{"name"}}, {family: "deployments", op: "backups-jobs", method: "GET", path: "/deployments/:name/backups/jobs/:id", args: []string{"name", "id"}}, {family: "deployments", op: "backups-delete", method: "DELETE", path: "/deployments/:name/backups/:id", args: []string{"name", "id"}}, {family: "deployments", op: "backups-download", method: "GET", path: "/deployments/:name/backups/:id/download", args: []string{"name", "id"}}, {family: "deployments", op: "backups-restore", method: "POST", path: "/deployments/:name/backups/:id/restore", args: []string{"name", "id"}}, + {family: "deployments", op: "backups-retry-publication", method: "POST", path: "/deployments/:name/backups/:id/retry-publication", args: []string{"name", "id"}}, {family: "deployments", op: "certificates-renew", method: "POST", path: "/deployments/:name/certificates/renew", args: []string{"name"}}, {family: "deployments", op: "compose", method: "GET", path: "/deployments/:name/compose", args: []string{"name"}}, {family: "deployments", op: "compose-mount", method: "POST", path: "/deployments/:name/compose/mount", args: []string{"name"}}, @@ -139,6 +149,7 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "container-files", method: "GET", path: "/deployments/:name/container-files/:service", args: []string{"name", "service"}}, {family: "deployments", op: "container-files-materialize", method: "POST", path: "/deployments/:name/container-files/:service/materialize", args: []string{"name", "service"}}, {family: "deployments", op: "copy", method: "POST", path: "/deployments/:name/copy", args: []string{"name"}}, + {family: "deployments", op: "databases-attach", method: "POST", path: "/deployments/:name/databases/attach", args: []string{"name"}}, {family: "deployments", op: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}}, {family: "deployments", op: "diagnostics", method: "GET", path: "/deployments/:name/diagnostics", args: []string{"name"}}, {family: "deployments", op: "domains", method: "GET", path: "/deployments/:name/domains", args: []string{"name"}}, @@ -164,6 +175,9 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "logs-delete", method: "DELETE", path: "/deployments/:name/logs", args: []string{"name"}}, {family: "deployments", op: "logs", method: "GET", path: "/deployments/:name/logs", args: []string{"name"}}, {family: "deployments", op: "metadata", method: "PUT", path: "/deployments/:name/metadata", args: []string{"name"}}, + {family: "deployments", op: "migration", method: "GET", path: "/deployments/:name/migration", args: []string{"name"}}, + {family: "deployments", op: "migration-update", method: "PUT", path: "/deployments/:name/migration", args: []string{"name"}}, + {family: "deployments", op: "migration-check-dns", method: "POST", path: "/deployments/:name/migration/check-dns", args: []string{"name"}}, {family: "deployments", op: "mkdir", method: "POST", path: "/deployments/:name/mkdir/*path", args: []string{"name", "path"}}, {family: "deployments", op: "move", method: "POST", path: "/deployments/:name/move", args: []string{"name"}}, {family: "deployments", op: "permissions", method: "PUT", path: "/deployments/:name/permissions/*path", args: []string{"name", "path"}}, diff --git a/internal/command/root.go b/internal/command/root.go index 5fa6579..2adad57 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -218,7 +218,7 @@ func Run(args []string, stdout, stderr io.Writer) int { } } } - names = append(names, "profile", "configure", "health", "deployment", "image", "container", "api", "version", "update") + names = append(names, "profile", "configure", "health", "deployment", "image", "container", "observability", "api", "version", "update") seen := map[string]bool{} for _, name := range names { if seen[name] { @@ -307,6 +307,8 @@ func runLegacy(args []string, stdout, stderr io.Writer) int { return runImage(args[1:], stdout, stderr) case "container": return runContainer(args[1:], stdout, stderr) + case "observability": + return runObservability(args[1:], stdout, stderr) case "api": return runAPI(args[1:], stdout, stderr) default: @@ -336,6 +338,29 @@ func runLegacy(args []string, stdout, stderr io.Writer) int { } } +func runObservability(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun observability (get|update|alerts|alerts-update) [--data JSON]") + return 2 + } + method := "get" + path := "/plugin/observability/config" + switch args[0] { + case "get": + case "update": + method = "put" + case "alerts": + path = "/plugin/observability/alerts/rules" + case "alerts-update": + method = "put" + path = "/plugin/observability/alerts/rules" + default: + _, _ = fmt.Fprintf(stderr, "Unknown observability command: %s\n", args[0]) + return 2 + } + return runAPI(append([]string{method, path}, args[1:]...), stdout, stderr) +} + func usage(w io.Writer) { view := presentation.New(w) _, _ = fmt.Fprintln(w, view.Title("FlatRun CLI")) @@ -977,10 +1002,18 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { } func runDeploymentFiles(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || args[0] != "push" { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment files push DEPLOYMENT SOURCE DESTINATION [--delete]") + if len(args) == 0 || (args[0] != "push" && args[0] != "pull") { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment files (push|pull) DEPLOYMENT SOURCE DESTINATION") return 2 } + if args[0] == "pull" { + return runClientCommand(clientCommand{ + name: "deployment files pull", usage: "Usage: flatrun deployment files pull DEPLOYMENT SOURCE DESTINATION", positionals: 3, + run: func(ctx context.Context, client *flatrun.Client, values []string) ([]byte, error) { + return client.PullDeploymentFile(ctx, values[0], values[1], values[2]) + }, + }, args[1:], stdout, stderr) + } deleteMissing := false return runClientCommand(clientCommand{ name: "deployment files push", diff --git a/internal/command/root_test.go b/internal/command/root_test.go index fbf34d4..78c8943 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -133,6 +133,42 @@ func TestDeploymentFilesPushSendsOneArchiveRequest(t *testing.T) { } } +func TestDeploymentFilesPullWritesTheResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/deployments/shop/files/public/index.html" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte("home")) + })) + defer server.Close() + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + destination := filepath.Join(t.TempDir(), "site", "index.html") + var stdout, stderr bytes.Buffer + if code := Run([]string{"deployment", "files", "pull", "shop", "public/index.html", destination}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + content, err := os.ReadFile(destination) + if err != nil || string(content) != "home" { + t.Fatalf("downloaded content = %q err=%v", content, err) + } +} + +func TestObservabilityUpdateUsesPluginConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/api/plugin/observability/config" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"enabled":true}`)) + })) + defer server.Close() + t.Setenv("FLATRUN_URL", server.URL) + var stdout, stderr bytes.Buffer + if code := Run([]string{"observability", "update", "--data", `{"enabled":true}`}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + func TestConfigureSetAndList(t *testing.T) { t.Setenv("FLATRUN_CONFIG", filepath.Join(t.TempDir(), "config.json")) diff --git a/internal/command/shaped.go b/internal/command/shaped.go index b767ff4..997f463 100644 --- a/internal/command/shaped.go +++ b/internal/command/shaped.go @@ -29,6 +29,7 @@ var shapedCommands = []endpoint{ {family: "deployment", op: "exec", method: "POST", path: "/deployments/:name/exec", args: []string{"name"}, flags: "[SERVICE] -- COMMAND"}, {family: "deployment", op: "image set", method: "PUT", path: "/deployments/:name/compose", args: []string{"name", "service", "image"}, flags: "--deploy --operation"}, {family: "deployment", op: "files push", method: "POST", path: "/deployments/:name/files-push", args: []string{"name", "source", "destination"}, flags: "--delete"}, + {family: "deployment", op: "files pull", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name", "source", "destination"}}, {family: "image", op: "list", method: "GET", path: "/images"}, {family: "image", op: "pull", method: "POST", path: "/images/pull", args: []string{"image"}, flags: "--credential-id"}, @@ -40,6 +41,10 @@ var shapedCommands = []endpoint{ {family: "container", op: "restart", method: "POST", path: "/containers/:id/restart", args: []string{"id"}}, {family: "container", op: "exec", method: "POST", path: "/containers/:id/exec", args: []string{"id"}, flags: "-- COMMAND"}, {family: "container", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, + {family: "observability", op: "get", method: "GET", path: "/plugin/observability/config"}, + {family: "observability", op: "update", method: "PUT", path: "/plugin/observability/config", flags: "--data JSON"}, + {family: "observability", op: "alerts", method: "GET", path: "/plugin/observability/alerts/rules"}, + {family: "observability", op: "alerts-update", method: "PUT", path: "/plugin/observability/alerts/rules", flags: "--data JSON"}, } // catalogue is every command the CLI can run: the hand-shaped ones and the generated ones. The diff --git a/internal/flatrun/files.go b/internal/flatrun/files.go index f34dd8b..ea401ce 100644 --- a/internal/flatrun/files.go +++ b/internal/flatrun/files.go @@ -58,6 +58,42 @@ func (c *Client) PushDeploymentFiles(ctx context.Context, deployment, source, de return data, nil } +func (c *Client) PullDeploymentFile(ctx context.Context, deployment, source, destination string) ([]byte, error) { + data, err := c.Do(ctx, http.MethodGet, "/deployments/"+url.PathEscape(deployment)+"/files/"+escapeFilePath(source), nil) + if err != nil { + return nil, err + } + dir := filepath.Dir(destination) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + tmp, err := os.CreateTemp(dir, ".flatrun-download-*") + if err != nil { + return nil, err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return nil, err + } + if err := tmp.Close(); err != nil { + return nil, err + } + if err := os.Rename(tmpName, destination); err != nil { + return nil, err + } + return []byte(fmt.Sprintf(`{"source":%q,"destination":%q,"bytes":%d}`, source, destination, len(data))), nil +} + +func escapeFilePath(path string) string { + parts := strings.Split(strings.TrimPrefix(filepath.ToSlash(path), "/"), "/") + for i := range parts { + parts[i] = url.PathEscape(parts[i]) + } + return strings.Join(parts, "/") +} + func writePushBody(multipartWriter *multipart.Writer, pipe *io.PipeWriter, source, destination string, deleteMissing bool) error { fail := func(err error) error { _ = pipe.CloseWithError(err) From 805e9cea34df7bf607565d0f405720e3b2759f71 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 27 Sep 2026 18:23:56 +0100 Subject: [PATCH 2/3] fix(files): Stream deployment downloads Large deployment files now stream directly to a temporary file before being moved into place. Client memory use stays bounded. --- internal/flatrun/files.go | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/internal/flatrun/files.go b/internal/flatrun/files.go index ea401ce..9c73011 100644 --- a/internal/flatrun/files.go +++ b/internal/flatrun/files.go @@ -59,10 +59,32 @@ func (c *Client) PushDeploymentFiles(ctx context.Context, deployment, source, de } func (c *Client) PullDeploymentFile(ctx context.Context, deployment, source, destination string) ([]byte, error) { - data, err := c.Do(ctx, http.MethodGet, "/deployments/"+url.PathEscape(deployment)+"/files/"+escapeFilePath(source), nil) + apiBase := strings.TrimRight(c.baseURL, "/") + if !strings.HasSuffix(apiBase, "/api") { + apiBase += "/api" + } + path := "/deployments/" + url.PathEscape(deployment) + "/files/" + escapeFilePath(source) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil) if err != nil { return nil, err } + req.Header.Set("Accept", "application/octet-stream") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return nil, readErr + } + body := strings.TrimSpace(string(data)) + return nil, &Error{StatusCode: resp.StatusCode, Body: body, Message: errorMessage(body)} + } dir := filepath.Dir(destination) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err @@ -73,7 +95,8 @@ func (c *Client) PullDeploymentFile(ctx context.Context, deployment, source, des } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { + written, err := io.Copy(tmp, resp.Body) + if err != nil { _ = tmp.Close() return nil, err } @@ -83,7 +106,7 @@ func (c *Client) PullDeploymentFile(ctx context.Context, deployment, source, des if err := os.Rename(tmpName, destination); err != nil { return nil, err } - return []byte(fmt.Sprintf(`{"source":%q,"destination":%q,"bytes":%d}`, source, destination, len(data))), nil + return []byte(fmt.Sprintf(`{"source":%q,"destination":%q,"bytes":%d}`, source, destination, written)), nil } func escapeFilePath(path string) string { From 2b0195f1e6ea6a00b03033071e36600ff7783729 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 27 Sep 2026 22:47:07 +0100 Subject: [PATCH 3/3] test(observability): Authenticate update request The command test now supplies the token required by the same authenticated path used in production. --- internal/command/root_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 78c8943..e5e5ad6 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -163,6 +163,7 @@ func TestObservabilityUpdateUsesPluginConfig(t *testing.T) { })) defer server.Close() t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") var stdout, stderr bytes.Buffer if code := Run([]string{"observability", "update", "--data", `{"enabled":true}`}, &stdout, &stderr); code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr.String())