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
30 changes: 30 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/command/endpoints_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 36 additions & 3 deletions internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions internal/command/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,43 @@ 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)
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())
}
}

func TestConfigureSetAndList(t *testing.T) {
t.Setenv("FLATRUN_CONFIG", filepath.Join(t.TempDir(), "config.json"))

Expand Down
5 changes: 5 additions & 0 deletions internal/command/shaped.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions internal/flatrun/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,65 @@ 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) {
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
}
tmp, err := os.CreateTemp(dir, ".flatrun-download-*")
if err != nil {
return nil, err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
written, err := io.Copy(tmp, resp.Body)
if 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, written)), 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)
Expand Down
Loading