Skip to content

Commit 83bfe83

Browse files
authored
Add MPG backup list and restore commands (#4611)
* Add MPG backup list and restore commands This adds two new subcommands to `fly mpg`: - `fly mpg backup list <cluster-id>`: List backups for a cluster - Shows backups from the last 24 hours by default - Use `--all` flag to show all backups - Displays: ID, Start, Status, Type - Supports `--json` output (includes stop field) - `fly mpg restore <cluster-id> --backup-id <id>`: Restore from backup - Placeholder implementation (extracts cluster_id and backup_id) - Actual restore functionality to be implemented API changes: - Added `ListManagedClusterBackups` to uiex client - Endpoint: GET /api/v1/postgres/{cluster_id}/backups - Response includes: id, status, type, start, stop fields * Add MPG backup create command Adds `fly mpg backup create <cluster-id>` command to create backups for Managed Postgres clusters. Usage: - `fly mpg backup create <cluster-id>` - Create full backup (default) - `fly mpg backup create <cluster-id> --type incr` - Create incremental backup The command validates the backup type (must be "full" or "incr") and calls POST /api/v1/postgres/{cluster_id}/backups with the type in the request body. On success, displays: Backup queued successfully! ID: <backup-id> API changes: - Added CreateManagedClusterBackup to uiex client - Added CreateManagedClusterBackupInput and CreateManagedClusterBackupResponse types * Fix test mocks to implement new backup methods Updated mock uiex clients to implement the new backup-related methods added to the uiexutil.Client interface: - ListManagedClusterBackups - CreateManagedClusterBackup This fixes test compilation errors in: - internal/command/mpg/mpg_test.go - internal/command/launch/plan/postgres_test.go * Add test for mpg backup list with JSON output verification Added TestBackupList to verify the backup list command: - Captures output buffer to verify JSON response - Mocks uiex client to return 2 sample backups - Parses JSON output and asserts we get exactly 2 backups - Verifies backup IDs match expected values * Hook up restore command to API endpoint Implemented the restore functionality to call the backend API: API changes: - Added RestoreManagedClusterBackupInput and RestoreManagedClusterBackupResponse types - Added RestoreManagedClusterBackup method to uiex client - Endpoint: POST /api/v1/postgres/{cluster_id}/restore - Request body: {"backup_id": "..."} Command changes: - Updated restore command to call the API instead of placeholder - Displays "Restore initiated successfully!" with backup ID Test changes: - Updated mock clients to implement RestoreManagedClusterBackup method * gofmt to make linter happy * fix the failing tests
1 parent 10bb78d commit 83bfe83

File tree

8 files changed

+540
-8
lines changed

8 files changed

+540
-8
lines changed

internal/build/imgsrc/flyctl.config.lock

Whitespace-only changes.

internal/command/launch/plan/postgres_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ func (m *mockUIEXClient) DestroyCluster(ctx context.Context, orgSlug string, id
6262
return nil
6363
}
6464

65+
func (m *mockUIEXClient) ListManagedClusterBackups(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error) {
66+
return uiex.ListManagedClusterBackupsResponse{}, nil
67+
}
68+
69+
func (m *mockUIEXClient) CreateManagedClusterBackup(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error) {
70+
return uiex.CreateManagedClusterBackupResponse{}, nil
71+
}
72+
73+
func (m *mockUIEXClient) RestoreManagedClusterBackup(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error) {
74+
return uiex.RestoreManagedClusterBackupResponse{}, nil
75+
}
76+
6577
func (m *mockUIEXClient) CreateFlyManagedBuilder(ctx context.Context, orgSlug string, region string) (uiex.CreateFlyManagedBuilderResponse, error) {
6678
return uiex.CreateFlyManagedBuilderResponse{}, nil
6779
}

internal/command/mpg/backup.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package mpg
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/superfly/flyctl/internal/command"
10+
"github.com/superfly/flyctl/internal/config"
11+
"github.com/superfly/flyctl/internal/flag"
12+
"github.com/superfly/flyctl/internal/render"
13+
"github.com/superfly/flyctl/internal/uiex"
14+
"github.com/superfly/flyctl/internal/uiexutil"
15+
"github.com/superfly/flyctl/iostreams"
16+
)
17+
18+
func newBackup() *cobra.Command {
19+
const (
20+
short = "Backup commands"
21+
long = short + "\n"
22+
)
23+
24+
cmd := command.New("backup", short, long, nil)
25+
cmd.Aliases = []string{"backups"}
26+
27+
cmd.AddCommand(
28+
newBackupList(),
29+
newBackupCreate(),
30+
)
31+
32+
return cmd
33+
}
34+
35+
func newBackupList() *cobra.Command {
36+
const (
37+
long = `List backups for a Managed Postgres cluster.`
38+
short = "List MPG cluster backups."
39+
usage = "list [CLUSTER_ID]"
40+
)
41+
42+
cmd := command.New(usage, short, long, runBackupList,
43+
command.RequireSession,
44+
command.RequireUiex,
45+
)
46+
47+
cmd.Args = cobra.ExactArgs(1)
48+
cmd.Aliases = []string{"ls"}
49+
50+
flag.Add(cmd,
51+
flag.JSONOutput(),
52+
flag.Bool{
53+
Name: "all",
54+
Description: "Show all backups (default: last 24 hours)",
55+
Default: false,
56+
},
57+
)
58+
59+
return cmd
60+
}
61+
62+
func runBackupList(ctx context.Context) error {
63+
// Check token compatibility early
64+
if err := validateMPGTokenCompatibility(ctx); err != nil {
65+
return err
66+
}
67+
68+
cfg := config.FromContext(ctx)
69+
out := iostreams.FromContext(ctx).Out
70+
uiexClient := uiexutil.ClientFromContext(ctx)
71+
72+
clusterID := flag.FirstArg(ctx)
73+
if clusterID == "" {
74+
// Should not happen due to cobra.ExactArgs(1), but good practice
75+
return fmt.Errorf("cluster ID argument is required")
76+
}
77+
78+
backups, err := uiexClient.ListManagedClusterBackups(ctx, clusterID)
79+
if err != nil {
80+
return fmt.Errorf("failed to list backups for cluster %s: %w", clusterID, err)
81+
}
82+
83+
if len(backups.Data) == 0 {
84+
fmt.Fprintf(out, "No backups found for cluster %s\n", clusterID)
85+
return nil
86+
}
87+
88+
// Filter backups by time (default: last 24 hours)
89+
var filteredBackups []uiex.ManagedClusterBackup
90+
showAll := flag.GetBool(ctx, "all")
91+
92+
if showAll {
93+
filteredBackups = backups.Data
94+
} else {
95+
// Filter to last 24 hours
96+
cutoff := time.Now().Add(-24 * time.Hour)
97+
for _, backup := range backups.Data {
98+
startTime, err := time.Parse(time.RFC3339, backup.Start)
99+
if err != nil {
100+
// If we can't parse the time, include the backup
101+
filteredBackups = append(filteredBackups, backup)
102+
continue
103+
}
104+
if startTime.After(cutoff) {
105+
filteredBackups = append(filteredBackups, backup)
106+
}
107+
}
108+
}
109+
110+
if len(filteredBackups) == 0 {
111+
fmt.Fprintf(out, "No backups found for cluster %s in the last 24 hours (use --all to see all backups)\n", clusterID)
112+
return nil
113+
}
114+
115+
if cfg.JSONOutput {
116+
return render.JSON(out, filteredBackups)
117+
}
118+
119+
rows := make([][]string, 0, len(filteredBackups))
120+
for _, backup := range filteredBackups {
121+
rows = append(rows, []string{
122+
backup.Id,
123+
backup.Start,
124+
backup.Status,
125+
backup.Type,
126+
})
127+
}
128+
129+
return render.Table(out, "", rows, "ID", "Start", "Status", "Type")
130+
}
131+
132+
func newBackupCreate() *cobra.Command {
133+
const (
134+
long = `Create a backup for a Managed Postgres cluster.`
135+
short = "Create MPG cluster backup."
136+
usage = "create [CLUSTER_ID]"
137+
)
138+
139+
cmd := command.New(usage, short, long, runBackupCreate,
140+
command.RequireSession,
141+
command.RequireUiex,
142+
)
143+
144+
cmd.Args = cobra.ExactArgs(1)
145+
146+
flag.Add(cmd,
147+
flag.String{
148+
Name: "type",
149+
Description: "Backup type: full or incr",
150+
Default: "full",
151+
},
152+
)
153+
154+
return cmd
155+
}
156+
157+
func runBackupCreate(ctx context.Context) error {
158+
// Check token compatibility early
159+
if err := validateMPGTokenCompatibility(ctx); err != nil {
160+
return err
161+
}
162+
163+
out := iostreams.FromContext(ctx).Out
164+
uiexClient := uiexutil.ClientFromContext(ctx)
165+
166+
clusterID := flag.FirstArg(ctx)
167+
if clusterID == "" {
168+
// Should not happen due to cobra.ExactArgs(1), but good practice
169+
return fmt.Errorf("cluster ID argument is required")
170+
}
171+
172+
backupType := flag.GetString(ctx, "type")
173+
if backupType != "full" && backupType != "incr" {
174+
return fmt.Errorf("--type must be either 'full' or 'incr'")
175+
}
176+
177+
fmt.Fprintf(out, "Creating %s backup for cluster %s...\n", backupType, clusterID)
178+
179+
input := uiex.CreateManagedClusterBackupInput{
180+
Type: backupType,
181+
}
182+
183+
response, err := uiexClient.CreateManagedClusterBackup(ctx, clusterID, input)
184+
if err != nil {
185+
return fmt.Errorf("failed to create backup: %w", err)
186+
}
187+
188+
fmt.Fprintf(out, "Backup queued successfully!\n")
189+
fmt.Fprintf(out, " ID: %s\n", response.Data.Id)
190+
191+
return nil
192+
}

internal/command/mpg/mpg.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ func New() *cobra.Command {
7676
newList(),
7777
newCreate(),
7878
newDestroy(),
79+
newBackup(),
80+
newRestore(),
7981
)
8082

8183
return cmd

internal/command/mpg/mpg_test.go

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mpg
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"testing"
78

@@ -21,14 +22,17 @@ import (
2122

2223
// MockUiexClient implements the uiexutil.Client interface for testing
2324
type MockUiexClient struct {
24-
ListMPGRegionsFunc func(ctx context.Context, orgSlug string) (uiex.ListMPGRegionsResponse, error)
25-
ListManagedClustersFunc func(ctx context.Context, orgSlug string) (uiex.ListManagedClustersResponse, error)
26-
GetManagedClusterFunc func(ctx context.Context, orgSlug string, id string) (uiex.GetManagedClusterResponse, error)
27-
GetManagedClusterByIdFunc func(ctx context.Context, id string) (uiex.GetManagedClusterResponse, error)
28-
CreateUserFunc func(ctx context.Context, id string, input uiex.CreateUserInput) (uiex.CreateUserResponse, error)
29-
CreateClusterFunc func(ctx context.Context, input uiex.CreateClusterInput) (uiex.CreateClusterResponse, error)
30-
DestroyClusterFunc func(ctx context.Context, orgSlug string, id string) error
31-
CreateFlyManagedBuilderFunc func(ctx context.Context, orgSlug string, region string) (uiex.CreateFlyManagedBuilderResponse, error)
25+
ListMPGRegionsFunc func(ctx context.Context, orgSlug string) (uiex.ListMPGRegionsResponse, error)
26+
ListManagedClustersFunc func(ctx context.Context, orgSlug string) (uiex.ListManagedClustersResponse, error)
27+
GetManagedClusterFunc func(ctx context.Context, orgSlug string, id string) (uiex.GetManagedClusterResponse, error)
28+
GetManagedClusterByIdFunc func(ctx context.Context, id string) (uiex.GetManagedClusterResponse, error)
29+
CreateUserFunc func(ctx context.Context, id string, input uiex.CreateUserInput) (uiex.CreateUserResponse, error)
30+
CreateClusterFunc func(ctx context.Context, input uiex.CreateClusterInput) (uiex.CreateClusterResponse, error)
31+
DestroyClusterFunc func(ctx context.Context, orgSlug string, id string) error
32+
ListManagedClusterBackupsFunc func(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error)
33+
CreateManagedClusterBackupFunc func(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error)
34+
RestoreManagedClusterBackupFunc func(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error)
35+
CreateFlyManagedBuilderFunc func(ctx context.Context, orgSlug string, region string) (uiex.CreateFlyManagedBuilderResponse, error)
3236
}
3337

3438
func (m *MockUiexClient) ListMPGRegions(ctx context.Context, orgSlug string) (uiex.ListMPGRegionsResponse, error) {
@@ -87,6 +91,27 @@ func (m *MockUiexClient) DestroyCluster(ctx context.Context, orgSlug string, id
8791
return nil
8892
}
8993

94+
func (m *MockUiexClient) ListManagedClusterBackups(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error) {
95+
if m.ListManagedClusterBackupsFunc != nil {
96+
return m.ListManagedClusterBackupsFunc(ctx, clusterID)
97+
}
98+
return uiex.ListManagedClusterBackupsResponse{}, nil
99+
}
100+
101+
func (m *MockUiexClient) CreateManagedClusterBackup(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error) {
102+
if m.CreateManagedClusterBackupFunc != nil {
103+
return m.CreateManagedClusterBackupFunc(ctx, clusterID, input)
104+
}
105+
return uiex.CreateManagedClusterBackupResponse{}, nil
106+
}
107+
108+
func (m *MockUiexClient) RestoreManagedClusterBackup(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error) {
109+
if m.RestoreManagedClusterBackupFunc != nil {
110+
return m.RestoreManagedClusterBackupFunc(ctx, clusterID, input)
111+
}
112+
return uiex.RestoreManagedClusterBackupResponse{}, nil
113+
}
114+
90115
// MockRegionProvider implements RegionProvider for testing
91116
type MockRegionProvider struct {
92117
GetPlatformRegionsFunc func(ctx context.Context) ([]fly.Region, error)
@@ -930,3 +955,68 @@ func TestMPGTokenValidation(t *testing.T) {
930955
assert.NoError(t, err, "MPG commands should accept contexts with macaroon tokens")
931956
})
932957
}
958+
959+
func TestBackupList(t *testing.T) {
960+
// Setup context with output capture
961+
ios, _, outBuf, _ := iostreams.Test()
962+
ctx := context.Background()
963+
ctx = iostreams.NewContext(ctx, ios)
964+
965+
// Add command context with a mock command
966+
cmd := &cobra.Command{}
967+
ctx = command_context.NewContext(ctx, cmd)
968+
969+
// Add macaroon tokens for MPG compatibility
970+
macaroonTokens := tokens.Parse("fm1r_macaroon_token")
971+
configWithMacaroonTokens := &config.Config{
972+
Tokens: macaroonTokens,
973+
JSONOutput: true, // Enable JSON output for easier verification
974+
}
975+
ctx = config.NewContext(ctx, configWithMacaroonTokens)
976+
977+
// Set the cluster ID as first arg
978+
flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError)
979+
flagSet.Bool("json", true, "JSON output")
980+
flagSet.Bool("all", true, "Show all backups")
981+
flagSet.Parse([]string{"test-cluster-123"})
982+
ctx = flagctx.NewContext(ctx, flagSet)
983+
984+
// Mock uiex client that returns some backups
985+
mockUiex := &MockUiexClient{
986+
ListManagedClusterBackupsFunc: func(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error) {
987+
require.Equal(t, "test-cluster-123", clusterID)
988+
return uiex.ListManagedClusterBackupsResponse{
989+
Data: []uiex.ManagedClusterBackup{
990+
{
991+
Id: "backup-1",
992+
Status: "completed",
993+
Type: "full",
994+
Start: "2025-10-14T10:00:00Z",
995+
Stop: "2025-10-14T10:30:00Z",
996+
},
997+
{
998+
Id: "backup-2",
999+
Status: "in_progress",
1000+
Type: "incr",
1001+
Start: "2025-10-14T12:00:00Z",
1002+
Stop: "",
1003+
},
1004+
},
1005+
}, nil
1006+
},
1007+
}
1008+
1009+
ctx = uiexutil.NewContextWithClient(ctx, mockUiex)
1010+
1011+
// Run the backup list command
1012+
err := runBackupList(ctx)
1013+
require.NoError(t, err)
1014+
1015+
// Parse the JSON output and verify we got 2 backups
1016+
var backups []uiex.ManagedClusterBackup
1017+
err = json.Unmarshal(outBuf.Bytes(), &backups)
1018+
require.NoError(t, err, "Should be able to parse JSON output")
1019+
require.Len(t, backups, 2, "Should return 2 backups")
1020+
assert.Equal(t, "backup-1", backups[0].Id)
1021+
assert.Equal(t, "backup-2", backups[1].Id)
1022+
}

0 commit comments

Comments
 (0)