Skip to content

Commit a0fda70

Browse files
committed
Add mpg detach command for separating MPG and apps
This mirrors the `mpg attach` feature we added earlier.
1 parent 49c4b5e commit a0fda70

File tree

7 files changed

+225
-0
lines changed

7 files changed

+225
-0
lines changed

internal/command/launch/plan/postgres_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ func (m *mockUIEXClient) CreateAttachment(ctx context.Context, clusterId string,
115115
return uiex.CreateAttachmentResponse{}, nil
116116
}
117117

118+
func (m *mockUIEXClient) DeleteAttachment(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) {
119+
return uiex.DeleteAttachmentResponse{}, nil
120+
}
121+
118122
func (m *mockUIEXClient) CreateBuild(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error) {
119123
return &uiex.BuildResponse{}, nil
120124
}

internal/command/mpg/detach.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package mpg
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/superfly/flyctl/internal/appconfig"
9+
"github.com/superfly/flyctl/internal/command"
10+
"github.com/superfly/flyctl/internal/flag"
11+
"github.com/superfly/flyctl/internal/flyutil"
12+
"github.com/superfly/flyctl/internal/uiexutil"
13+
"github.com/superfly/flyctl/iostreams"
14+
)
15+
16+
func newDetach() *cobra.Command {
17+
const (
18+
short = "Detach a managed Postgres cluster from an app"
19+
long = short + ". " +
20+
`This command will remove the attachment record linking the app to the cluster.
21+
Note: This does NOT remove any secrets from the app. Use 'fly secrets unset' to remove secrets.`
22+
usage = "detach <CLUSTER ID>"
23+
)
24+
25+
cmd := command.New(usage, short, long, runDetach,
26+
command.RequireSession,
27+
command.RequireAppName,
28+
)
29+
cmd.Args = cobra.MaximumNArgs(1)
30+
31+
flag.Add(cmd,
32+
flag.App(),
33+
flag.AppConfig(),
34+
flag.Yes(),
35+
)
36+
37+
return cmd
38+
}
39+
40+
func runDetach(ctx context.Context) error {
41+
// Check token compatibility early
42+
if err := validateMPGTokenCompatibility(ctx); err != nil {
43+
return err
44+
}
45+
46+
var (
47+
clusterId = flag.FirstArg(ctx)
48+
appName = appconfig.NameFromContext(ctx)
49+
client = flyutil.ClientFromContext(ctx)
50+
io = iostreams.FromContext(ctx)
51+
)
52+
53+
// Get app details to determine which org it belongs to
54+
app, err := client.GetAppBasic(ctx, appName)
55+
if err != nil {
56+
return fmt.Errorf("failed retrieving app %s: %w", appName, err)
57+
}
58+
59+
appOrgSlug := app.Organization.RawSlug
60+
if appOrgSlug != "" && clusterId == "" {
61+
fmt.Fprintf(io.Out, "Listing clusters in organization %s\n", appOrgSlug)
62+
}
63+
64+
// Get cluster details
65+
cluster, _, err := ClusterFromArgOrSelect(ctx, clusterId, appOrgSlug)
66+
if err != nil {
67+
return fmt.Errorf("failed retrieving cluster %s: %w", clusterId, err)
68+
}
69+
70+
clusterOrgSlug := cluster.Organization.Slug
71+
72+
// Verify that the app and cluster are in the same organization
73+
if appOrgSlug != clusterOrgSlug {
74+
return fmt.Errorf("app %s is in organization %s, but cluster %s is in organization %s. They must be in the same organization",
75+
appName, appOrgSlug, cluster.Id, clusterOrgSlug)
76+
}
77+
78+
uiexClient := uiexutil.ClientFromContext(ctx)
79+
80+
// Delete the attachment record
81+
_, err = uiexClient.DeleteAttachment(ctx, cluster.Id, appName)
82+
if err != nil {
83+
return fmt.Errorf("failed to detach: %w", err)
84+
}
85+
86+
fmt.Fprintf(io.Out, "\nPostgres cluster %s has been detached from %s\n", cluster.Id, appName)
87+
fmt.Fprintf(io.Out, "Note: This only removes the attachment record. Any secrets (like DATABASE_URL) are still set on the app.\n")
88+
fmt.Fprintf(io.Out, "Use 'fly secrets unset DATABASE_URL -a %s' to remove the connection string.\n", appName)
89+
90+
return nil
91+
}

internal/command/mpg/mpg.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func New() *cobra.Command {
7676
newProxy(),
7777
newConnect(),
7878
newAttach(),
79+
newDetach(),
7980
newStatus(),
8081
newList(),
8182
newCreate(),

internal/command/mpg/mpg_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,81 @@ func TestFormatAttachedApps(t *testing.T) {
15381538
}
15391539
}
15401540

1541+
// Test DeleteAttachment functionality
1542+
func TestDeleteAttachment(t *testing.T) {
1543+
ctx := setupTestContext()
1544+
1545+
clusterID := "test-cluster-123"
1546+
1547+
t.Run("successful attachment deletion", func(t *testing.T) {
1548+
mockUiex := &mock.UiexClient{
1549+
DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) {
1550+
assert.Equal(t, clusterID, clusterId)
1551+
assert.Equal(t, "test-app", appName)
1552+
return uiex.DeleteAttachmentResponse{
1553+
Data: struct {
1554+
Message string `json:"message"`
1555+
}{
1556+
Message: "Attachment deleted successfully",
1557+
},
1558+
}, nil
1559+
},
1560+
}
1561+
1562+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1563+
1564+
response, err := mockUiex.DeleteAttachment(ctx, clusterID, "test-app")
1565+
1566+
require.NoError(t, err)
1567+
assert.Equal(t, "Attachment deleted successfully", response.Data.Message)
1568+
})
1569+
1570+
t.Run("error - attachment not found", func(t *testing.T) {
1571+
mockUiex := &mock.UiexClient{
1572+
DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) {
1573+
return uiex.DeleteAttachmentResponse{}, fmt.Errorf("attachment not found for app '%s' on cluster %s", appName, clusterId)
1574+
},
1575+
}
1576+
1577+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1578+
1579+
_, err := mockUiex.DeleteAttachment(ctx, clusterID, "nonexistent-app")
1580+
1581+
assert.Error(t, err)
1582+
assert.Contains(t, err.Error(), "attachment not found")
1583+
})
1584+
1585+
t.Run("error - access denied", func(t *testing.T) {
1586+
mockUiex := &mock.UiexClient{
1587+
DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) {
1588+
return uiex.DeleteAttachmentResponse{}, fmt.Errorf("access denied: you don't have permission to detach from cluster %s", clusterId)
1589+
},
1590+
}
1591+
1592+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1593+
1594+
_, err := mockUiex.DeleteAttachment(ctx, clusterID, "test-app")
1595+
1596+
assert.Error(t, err)
1597+
assert.Contains(t, err.Error(), "access denied")
1598+
})
1599+
1600+
t.Run("error - cluster not found", func(t *testing.T) {
1601+
mockUiex := &mock.UiexClient{
1602+
DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) {
1603+
return uiex.DeleteAttachmentResponse{}, fmt.Errorf("cluster %s not found", clusterId)
1604+
},
1605+
}
1606+
1607+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1608+
1609+
_, err := mockUiex.DeleteAttachment(ctx, "nonexistent-cluster", "test-app")
1610+
1611+
assert.Error(t, err)
1612+
assert.Contains(t, err.Error(), "not found")
1613+
})
1614+
}
1615+
15411616
// Test the list command with attached apps
15421617
func TestListCommand_WithAttachedApps(t *testing.T) {
15431618
ctx := setupTestContext()

internal/mock/uiex_client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type UiexClient struct {
3232
CreateManagedClusterBackupFunc func(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error)
3333
RestoreManagedClusterBackupFunc func(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error)
3434
CreateAttachmentFunc func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error)
35+
DeleteAttachmentFunc func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error)
3536
CreateBuildFunc func(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error)
3637
FinishBuildFunc func(ctx context.Context, in uiex.FinishBuildRequest) (*uiex.BuildResponse, error)
3738
EnsureDepotBuilderFunc func(ctx context.Context, in uiex.EnsureDepotBuilderRequest) (*uiex.EnsureDepotBuilderResponse, error)
@@ -245,3 +246,10 @@ func (m *UiexClient) CreateAttachment(ctx context.Context, clusterId string, inp
245246
}
246247
return uiex.CreateAttachmentResponse{}, nil
247248
}
249+
250+
func (m *UiexClient) DeleteAttachment(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) {
251+
if m.DeleteAttachmentFunc != nil {
252+
return m.DeleteAttachmentFunc(ctx, clusterId, appName)
253+
}
254+
return uiex.DeleteAttachmentResponse{}, nil
255+
}

internal/uiex/managed_postgres.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,3 +944,48 @@ func (c *Client) CreateAttachment(ctx context.Context, clusterId string, input C
944944
return response, fmt.Errorf("failed to create attachment (status %d): %s", res.StatusCode, string(body))
945945
}
946946
}
947+
948+
type DeleteAttachmentResponse struct {
949+
Data struct {
950+
Message string `json:"message"`
951+
} `json:"data"`
952+
}
953+
954+
// DeleteAttachment removes a ManagedServiceAttachment record linking an app to a managed Postgres cluster
955+
func (c *Client) DeleteAttachment(ctx context.Context, clusterId string, appName string) (DeleteAttachmentResponse, error) {
956+
var response DeleteAttachmentResponse
957+
cfg := config.FromContext(ctx)
958+
url := fmt.Sprintf("%s/api/v1/postgres/%s/attachments/%s", c.baseUrl, clusterId, appName)
959+
960+
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
961+
if err != nil {
962+
return response, fmt.Errorf("failed to create request: %w", err)
963+
}
964+
965+
req.Header.Add("Authorization", "Bearer "+cfg.Tokens.GraphQL())
966+
967+
res, err := c.httpClient.Do(req)
968+
if err != nil {
969+
return response, err
970+
}
971+
defer res.Body.Close()
972+
973+
body, err := io.ReadAll(res.Body)
974+
if err != nil {
975+
return response, fmt.Errorf("failed to read response body: %w", err)
976+
}
977+
978+
switch res.StatusCode {
979+
case http.StatusOK:
980+
if err = json.Unmarshal(body, &response); err != nil {
981+
return response, fmt.Errorf("failed to decode response: %w", err)
982+
}
983+
return response, nil
984+
case http.StatusNotFound:
985+
return response, fmt.Errorf("attachment not found for app '%s' on cluster %s", appName, clusterId)
986+
case http.StatusForbidden:
987+
return response, fmt.Errorf("access denied: you don't have permission to detach from cluster %s", clusterId)
988+
default:
989+
return response, fmt.Errorf("failed to delete attachment (status %d): %s", res.StatusCode, string(body))
990+
}
991+
}

internal/uiexutil/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Client interface {
3131
CreateManagedClusterBackup(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error)
3232
RestoreManagedClusterBackup(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error)
3333
CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error)
34+
DeleteAttachment(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error)
3435

3536
// Builders
3637
CreateBuild(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error)

0 commit comments

Comments
 (0)