diff --git a/CHANGELOG.md b/CHANGELOG.md index e0978bc..41c057c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the FlatRun CLI are documented in this file. +## [0.4.0-beta.4] - 2026-08-20 + +### Added + +- Push a local directory to a deployment in one request, with optional destination cleanup +- File operations from the agent are available through the generated command catalogue + ## [0.3.0] - 2026-08-11 ### Added diff --git a/VERSION b/VERSION index 0d91a54..bfa2c56 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0-beta.4 diff --git a/internal/command/endpoints_gen.go b/internal/command/endpoints_gen.go index 440a4a4..852a7ba 100644 --- a/internal/command/endpoints_gen.go +++ b/internal/command/endpoints_gen.go @@ -103,6 +103,7 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "update", method: "PUT", path: "/deployments/:name", 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"}}, {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: "backups", method: "GET", path: "/deployments/:name/backups", args: []string{"name"}}, @@ -113,6 +114,7 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "compose-unmount", method: "POST", path: "/deployments/:name/compose/unmount", args: []string{"name"}}, {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: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}}, {family: "deployments", op: "domains", method: "GET", path: "/deployments/:name/domains", args: []string{"name"}}, {family: "deployments", op: "domains-create", method: "POST", path: "/deployments/:name/domains", args: []string{"name"}}, @@ -120,8 +122,10 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "domains-update", method: "PUT", path: "/deployments/:name/domains/:domainId", args: []string{"name", "domainId"}}, {family: "deployments", op: "env", method: "GET", path: "/deployments/:name/env", args: []string{"name"}}, {family: "deployments", op: "env-update", method: "PUT", path: "/deployments/:name/env", args: []string{"name"}}, + {family: "deployments", op: "extract", method: "POST", path: "/deployments/:name/extract", args: []string{"name"}}, {family: "deployments", op: "files", method: "GET", path: "/deployments/:name/files", args: []string{"name"}}, {family: "deployments", op: "files-info", method: "GET", path: "/deployments/:name/files-info", args: []string{"name"}}, + {family: "deployments", op: "files-push", method: "POST", path: "/deployments/:name/files-push", args: []string{"name"}}, {family: "deployments", op: "files-delete", method: "DELETE", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, {family: "deployments", op: "files-get", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, {family: "deployments", op: "files-create", method: "POST", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, @@ -135,6 +139,7 @@ var generatedEndpoints = []endpoint{ {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: "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"}}, {family: "deployments", op: "protected-mode", method: "PUT", path: "/deployments/:name/protected-mode", args: []string{"name"}}, {family: "deployments", op: "pull", method: "POST", path: "/deployments/:name/pull", args: []string{"name"}}, @@ -267,6 +272,9 @@ var generatedEndpoints = []endpoint{ {family: "storage-credentials", op: "delete", method: "DELETE", path: "/storage-credentials/:id", args: []string{"id"}}, {family: "storage-credentials", op: "update", method: "PUT", path: "/storage-credentials/:id", args: []string{"id"}}, {family: "subdomain", op: "generate", method: "GET", path: "/subdomain/generate", args: nil}, + {family: "system", op: "archive", method: "GET", path: "/system/archive", args: nil}, + {family: "system", op: "copy", method: "POST", path: "/system/copy", args: nil}, + {family: "system", op: "extract", method: "POST", path: "/system/extract", args: nil}, {family: "system", op: "files", method: "GET", path: "/system/files", args: nil}, {family: "system", op: "files-info", method: "GET", path: "/system/files-info", args: nil}, {family: "system", op: "files-delete", method: "DELETE", path: "/system/files/*path", args: []string{"path"}}, @@ -276,6 +284,7 @@ var generatedEndpoints = []endpoint{ {family: "system", op: "logs", method: "GET", path: "/system/logs", args: nil}, {family: "system", op: "logs-sources", method: "GET", path: "/system/logs/sources", args: nil}, {family: "system", op: "mkdir", method: "POST", path: "/system/mkdir/*path", args: []string{"path"}}, + {family: "system", op: "move", method: "POST", path: "/system/move", args: nil}, {family: "system", op: "permissions", method: "PUT", path: "/system/permissions/*path", args: []string{"path"}}, {family: "system", op: "services", method: "GET", path: "/system/services", args: nil}, {family: "system", op: "services-restart", method: "POST", path: "/system/services/:name/restart", args: []string{"name"}}, diff --git a/internal/command/root.go b/internal/command/root.go index ac75577..ebdb90e 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -593,6 +593,8 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { return runDeploymentExec(args[1:], stdout, stderr) case "image": return runDeploymentImage(args[1:], stdout, stderr) + case "files": + return runDeploymentFiles(args[1:], stdout, stderr) case "create": return runDeploymentCreate(args[1:], stdout, stderr) case "delete": @@ -610,6 +612,25 @@ 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]") + return 2 + } + deleteMissing := false + return runClientCommand(clientCommand{ + name: "deployment files push", + usage: "Usage: flatrun deployment files push DEPLOYMENT SOURCE DESTINATION [--delete]", + positionals: 3, + flags: func(fs *flag.FlagSet) { + fs.BoolVar(&deleteMissing, "delete", false, "Delete destination files missing from the source") + }, + run: func(ctx context.Context, client *flatrun.Client, values []string) ([]byte, error) { + return client.PushDeploymentFiles(ctx, values[0], values[1], values[2], deleteMissing) + }, + }, args[1:], stdout, stderr) +} + func runDeploymentList(args []string, stdout, stderr io.Writer) int { return runClientCommand(clientCommand{ name: "deployment list", diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 15a6bab..76562f8 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -1,8 +1,11 @@ package command import ( + "archive/tar" "bytes" + "compress/gzip" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -12,6 +15,88 @@ import ( "testing" ) +func TestDeploymentFilesPushSendsOneArchiveRequest(t *testing.T) { + source := t.TempDir() + if err := os.Mkdir(filepath.Join(source, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "index.html"), []byte("home"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "assets", "app.js"), []byte("app"), 0o644); err != nil { + t.Fatal(err) + } + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodPost || r.URL.Path != "/api/deployments/whilesmart/files-push" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + reader, err := r.MultipartReader() + if err != nil { + t.Error(err) + return + } + fields := map[string]string{} + files := map[string]string{} + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Error(err) + return + } + if part.FormName() != "archive" { + value, _ := io.ReadAll(part) + fields[part.FormName()] = string(value) + continue + } + gzipReader, err := gzip.NewReader(part) + if err != nil { + t.Error(err) + return + } + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Error(err) + return + } + if header.Typeflag == tar.TypeReg { + content, _ := io.ReadAll(tarReader) + files[header.Name] = string(content) + } + } + } + if fields["destination"] != "sites/" || fields["delete"] != "true" { + t.Errorf("fields = %#v", fields) + } + if files["index.html"] != "home" || files["assets/app.js"] != "app" { + t.Errorf("files = %#v", files) + } + _, _ = w.Write([]byte(`{"item":{"message":"Pushed 2 files","destination":"sites/","deleted":true,"files":2}}`)) + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + var stdout, stderr bytes.Buffer + code := Run([]string{"deployment", "files", "push", "whilesmart", source, "sites/", "--delete"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if requests != 1 { + t.Fatalf("requests = %d", requests) + } +} + 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 c60bd3d..b767ff4 100644 --- a/internal/command/shaped.go +++ b/internal/command/shaped.go @@ -28,6 +28,7 @@ var shapedCommands = []endpoint{ {family: "deployment", op: "action", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}}, {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: "image", op: "list", method: "GET", path: "/images"}, {family: "image", op: "pull", method: "POST", path: "/images/pull", args: []string{"image"}, flags: "--credential-id"}, diff --git a/internal/flatrun/client.go b/internal/flatrun/client.go index 43609b3..1ef4621 100644 --- a/internal/flatrun/client.go +++ b/internal/flatrun/client.go @@ -233,6 +233,10 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any) ([]by req.Header.Set("Authorization", "Bearer "+c.token) } + return c.doRequest(req) +} + +func (c *Client) doRequest(req *http.Request) ([]byte, error) { if c.Debug != nil { _, _ = fmt.Fprintf(c.Debug, "-> %s %s\n", req.Method, req.URL.String()) } diff --git a/internal/flatrun/files.go b/internal/flatrun/files.go new file mode 100644 index 0000000..f34dd8b --- /dev/null +++ b/internal/flatrun/files.go @@ -0,0 +1,137 @@ +package flatrun + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +func (c *Client) PushDeploymentFiles(ctx context.Context, deployment, source, destination string, deleteMissing bool) ([]byte, error) { + info, err := os.Stat(source) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("source must be a directory") + } + + reader, writer := io.Pipe() + multipartWriter := multipart.NewWriter(writer) + errCh := make(chan error, 1) + go func() { + errCh <- writePushBody(multipartWriter, writer, source, destination, deleteMissing) + }() + + apiBase := strings.TrimRight(c.baseURL, "/") + if !strings.HasSuffix(apiBase, "/api") { + apiBase += "/api" + } + path := "/deployments/" + url.PathEscape(deployment) + "/files-push" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, reader) + if err != nil { + _ = reader.Close() + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + data, requestErr := c.doRequest(req) + archiveErr := <-errCh + if requestErr != nil { + return nil, requestErr + } + if archiveErr != nil { + return nil, archiveErr + } + return data, nil +} + +func writePushBody(multipartWriter *multipart.Writer, pipe *io.PipeWriter, source, destination string, deleteMissing bool) error { + fail := func(err error) error { + _ = pipe.CloseWithError(err) + return err + } + if err := multipartWriter.WriteField("destination", destination); err != nil { + return fail(err) + } + if err := multipartWriter.WriteField("delete", strconv.FormatBool(deleteMissing)); err != nil { + return fail(err) + } + part, err := multipartWriter.CreateFormFile("archive", "content.tar.gz") + if err != nil { + return fail(err) + } + gzipWriter := gzip.NewWriter(part) + tarWriter := tar.NewWriter(gzipWriter) + if err := writeDirectoryArchive(tarWriter, source); err != nil { + return fail(err) + } + if err := tarWriter.Close(); err != nil { + return fail(err) + } + if err := gzipWriter.Close(); err != nil { + return fail(err) + } + if err := multipartWriter.Close(); err != nil { + return fail(err) + } + return pipe.Close() +} + +func writeDirectoryArchive(writer *tar.Writer, source string) error { + return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == source { + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symbolic links are not supported: %s", path) + } + info, err := entry.Info() + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = filepath.ToSlash(relative) + if entry.IsDir() { + header.Name += "/" + } + if err := writer.WriteHeader(header); err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + file, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(writer, file) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + return closeErr + }) +}