diff --git a/internal/command/launch/plan/postgres_test.go b/internal/command/launch/plan/postgres_test.go index 3b349faf26..14b8644d91 100644 --- a/internal/command/launch/plan/postgres_test.go +++ b/internal/command/launch/plan/postgres_test.go @@ -111,6 +111,14 @@ func (m *mockUIEXClient) RestoreManagedClusterBackup(ctx context.Context, cluste return uiex.RestoreManagedClusterBackupResponse{}, nil } +func (m *mockUIEXClient) CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + return uiex.CreateAttachmentResponse{}, nil +} + +func (m *mockUIEXClient) DeleteAttachment(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) { + return uiex.DeleteAttachmentResponse{}, nil +} + func (m *mockUIEXClient) CreateBuild(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error) { return &uiex.BuildResponse{}, nil } diff --git a/internal/command/mpg/attach.go b/internal/command/mpg/attach.go index 18c04d3db7..c01880131c 100644 --- a/internal/command/mpg/attach.go +++ b/internal/command/mpg/attach.go @@ -281,6 +281,15 @@ func runAttach(ctx context.Context) error { return err } + // Create attachment record to track the cluster-app relationship + attachInput := uiex.CreateAttachmentInput{ + AppName: appName, + } + if _, err := uiexClient.CreateAttachment(ctx, cluster.Id, attachInput); err != nil { + // Log warning but don't fail - the secret was set successfully + fmt.Fprintf(io.ErrOut, "Warning: failed to create attachment record: %v\n", err) + } + fmt.Fprintf(io.Out, "\nPostgres cluster %s is being attached to %s\n", cluster.Id, appName) fmt.Fprintf(io.Out, "The following secret was added to %s:\n %s=%s\n", appName, variableName, connectionUri) diff --git a/internal/command/mpg/detach.go b/internal/command/mpg/detach.go new file mode 100644 index 0000000000..d93ee22ff1 --- /dev/null +++ b/internal/command/mpg/detach.go @@ -0,0 +1,90 @@ +package mpg + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +func newDetach() *cobra.Command { + const ( + short = "Detach a managed Postgres cluster from an app" + long = short + ". " + + `This command will remove the attachment record linking the app to the cluster. +Note: This does NOT remove any secrets from the app. Use 'fly secrets unset' to remove secrets.` + usage = "detach " + ) + + cmd := command.New(usage, short, long, runDetach, + command.RequireSession, + command.RequireAppName, + ) + cmd.Args = cobra.MaximumNArgs(1) + + flag.Add(cmd, + flag.App(), + flag.AppConfig(), + ) + + return cmd +} + +func runDetach(ctx context.Context) error { + // Check token compatibility early + if err := validateMPGTokenCompatibility(ctx); err != nil { + return err + } + + var ( + clusterId = flag.FirstArg(ctx) + appName = appconfig.NameFromContext(ctx) + client = flyutil.ClientFromContext(ctx) + io = iostreams.FromContext(ctx) + ) + + // Get app details to determine which org it belongs to + app, err := client.GetAppBasic(ctx, appName) + if err != nil { + return fmt.Errorf("failed retrieving app %s: %w", appName, err) + } + + appOrgSlug := app.Organization.RawSlug + if appOrgSlug != "" && clusterId == "" { + fmt.Fprintf(io.Out, "Listing clusters in organization %s\n", appOrgSlug) + } + + // Get cluster details + cluster, _, err := ClusterFromArgOrSelect(ctx, clusterId, appOrgSlug) + if err != nil { + return fmt.Errorf("failed retrieving cluster %s: %w", clusterId, err) + } + + clusterOrgSlug := cluster.Organization.Slug + + // Verify that the app and cluster are in the same organization + if appOrgSlug != clusterOrgSlug { + return fmt.Errorf("app %s is in organization %s, but cluster %s is in organization %s. They must be in the same organization", + appName, appOrgSlug, cluster.Id, clusterOrgSlug) + } + + uiexClient := uiexutil.ClientFromContext(ctx) + + // Delete the attachment record + _, err = uiexClient.DeleteAttachment(ctx, cluster.Id, appName) + if err != nil { + return fmt.Errorf("failed to detach: %w", err) + } + + fmt.Fprintf(io.Out, "\nPostgres cluster %s has been detached from %s\n", cluster.Id, appName) + fmt.Fprintf(io.Out, "Note: This only removes the attachment record. Any secrets (like DATABASE_URL) are still set on the app.\n") + fmt.Fprintf(io.Out, "Use 'fly secrets unset DATABASE_URL -a %s' to remove the connection string.\n", appName) + + return nil +} diff --git a/internal/command/mpg/list.go b/internal/command/mpg/list.go index 79a9cd71c5..625a1183ef 100644 --- a/internal/command/mpg/list.go +++ b/internal/command/mpg/list.go @@ -3,10 +3,12 @@ package mpg import ( "context" "fmt" + "strings" "github.com/spf13/cobra" "github.com/superfly/flyctl/gql" + "github.com/superfly/flyctl/internal/uiex" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/internal/command" @@ -95,8 +97,22 @@ func runList(ctx context.Context) error { cluster.Region, cluster.Status, cluster.Plan, + formatAttachedApps(cluster.AttachedApps), }) } - return render.Table(out, "", rows, "ID", "Name", "Org", "Region", "Status", "Plan") + return render.Table(out, "", rows, "ID", "Name", "Org", "Region", "Status", "Plan", "Attached Apps") +} + +// formatAttachedApps formats the list of attached apps for display +func formatAttachedApps(apps []uiex.AttachedApp) string { + if len(apps) == 0 { + return "" + } + + names := make([]string, len(apps)) + for i, app := range apps { + names[i] = app.Name + } + return strings.Join(names, ", ") } diff --git a/internal/command/mpg/mpg.go b/internal/command/mpg/mpg.go index c290da3759..8fccabb1dd 100644 --- a/internal/command/mpg/mpg.go +++ b/internal/command/mpg/mpg.go @@ -76,6 +76,7 @@ func New() *cobra.Command { newProxy(), newConnect(), newAttach(), + newDetach(), newStatus(), newList(), newCreate(), diff --git a/internal/command/mpg/mpg_test.go b/internal/command/mpg/mpg_test.go index 77509e509f..7f35612b95 100644 --- a/internal/command/mpg/mpg_test.go +++ b/internal/command/mpg/mpg_test.go @@ -1216,6 +1216,262 @@ func TestCreateCommand_WithPGMajorVersion(t *testing.T) { } } +// Test CreateAttachment functionality +func TestCreateAttachment(t *testing.T) { + ctx := setupTestContext() + + clusterID := "test-cluster-123" + + t.Run("successful attachment creation", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + assert.Equal(t, clusterID, clusterId) + assert.Equal(t, "test-app", input.AppName) + return uiex.CreateAttachmentResponse{ + Data: struct { + Id int64 `json:"id"` + AppId int64 `json:"app_id"` + ManagedServiceId int64 `json:"managed_service_id"` + AttachedAt string `json:"attached_at"` + }{ + Id: 1, + AppId: 100, + ManagedServiceId: 200, + AttachedAt: "2025-01-15T10:00:00Z", + }, + }, nil + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + response, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{ + AppName: "test-app", + }) + + require.NoError(t, err) + assert.Equal(t, int64(1), response.Data.Id) + assert.Equal(t, int64(100), response.Data.AppId) + assert.Equal(t, int64(200), response.Data.ManagedServiceId) + assert.Equal(t, "2025-01-15T10:00:00Z", response.Data.AttachedAt) + }) + + t.Run("idempotent - returns existing attachment", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + // Simulating the idempotent case where attachment already exists + return uiex.CreateAttachmentResponse{ + Data: struct { + Id int64 `json:"id"` + AppId int64 `json:"app_id"` + ManagedServiceId int64 `json:"managed_service_id"` + AttachedAt string `json:"attached_at"` + }{ + Id: 42, // Existing attachment ID + AppId: 100, + ManagedServiceId: 200, + AttachedAt: "2025-01-14T09:00:00Z", // Earlier timestamp + }, + }, nil + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + response, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{ + AppName: "already-attached-app", + }) + + require.NoError(t, err) + assert.Equal(t, int64(42), response.Data.Id) + }) + + t.Run("error - cluster not found", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + return uiex.CreateAttachmentResponse{}, fmt.Errorf("cluster %s not found", clusterId) + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + _, err := mockUiex.CreateAttachment(ctx, "nonexistent-cluster", uiex.CreateAttachmentInput{ + AppName: "test-app", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("error - access denied", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + return uiex.CreateAttachmentResponse{}, fmt.Errorf("access denied: you don't have permission to attach cluster %s", clusterId) + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + _, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{ + AppName: "test-app", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "access denied") + }) + + t.Run("error - app not found", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + return uiex.CreateAttachmentResponse{}, fmt.Errorf("app not found") + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + _, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{ + AppName: "nonexistent-app", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +// Test attach command integration with CreateAttachment +func TestAttachCommand_CreatesAttachment(t *testing.T) { + ctx := setupTestContext() + + clusterID := "test-cluster-123" + appName := "test-app" + + expectedCluster := uiex.ManagedCluster{ + Id: clusterID, + Name: "test-cluster", + Region: "ord", + Status: "ready", + Organization: fly.Organization{ + Slug: "test-org", + }, + } + + connectionURI := "postgresql://user:pass@host:5432/db" + + // Track whether CreateAttachment was called + createAttachmentCalled := false + var capturedAppName string + + mockUiex := &mock.UiexClient{ + GetManagedClusterByIdFunc: func(ctx context.Context, id string) (uiex.GetManagedClusterResponse, error) { + assert.Equal(t, clusterID, id) + return uiex.GetManagedClusterResponse{ + Data: expectedCluster, + Credentials: uiex.GetManagedClusterCredentialsResponse{ + ConnectionUri: connectionURI, + User: "fly-user", + Password: "test-password", + DBName: "fly_db", + }, + }, nil + }, + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + createAttachmentCalled = true + capturedAppName = input.AppName + assert.Equal(t, clusterID, clusterId) + return uiex.CreateAttachmentResponse{ + Data: struct { + Id int64 `json:"id"` + AppId int64 `json:"app_id"` + ManagedServiceId int64 `json:"managed_service_id"` + AttachedAt string `json:"attached_at"` + }{ + Id: 1, + AppId: 100, + ManagedServiceId: 200, + AttachedAt: "2025-01-15T10:00:00Z", + }, + }, nil + }, + } + + ctx = uiexutil.NewContextWithClient(ctx, mockUiex) + + // Simulate the attach command flow: get cluster, then create attachment + response, err := mockUiex.GetManagedClusterById(ctx, clusterID) + require.NoError(t, err) + assert.Equal(t, expectedCluster.Id, response.Data.Id) + + // Create attachment (this simulates what runAttach does after setting secrets) + attachInput := uiex.CreateAttachmentInput{ + AppName: appName, + } + _, err = mockUiex.CreateAttachment(ctx, clusterID, attachInput) + require.NoError(t, err) + + // Verify CreateAttachment was called with correct app name + assert.True(t, createAttachmentCalled, "CreateAttachment should be called during attach") + assert.Equal(t, appName, capturedAppName, "App name should be passed to CreateAttachment") +} + +// Test that attach command handles CreateAttachment errors gracefully +func TestAttachCommand_HandlesAttachmentErrorGracefully(t *testing.T) { + ctx := setupTestContext() + + clusterID := "test-cluster-123" + appName := "test-app" + + expectedCluster := uiex.ManagedCluster{ + Id: clusterID, + Name: "test-cluster", + Region: "ord", + Status: "ready", + Organization: fly.Organization{ + Slug: "test-org", + }, + } + + connectionURI := "postgresql://user:pass@host:5432/db" + + mockUiex := &mock.UiexClient{ + GetManagedClusterByIdFunc: func(ctx context.Context, id string) (uiex.GetManagedClusterResponse, error) { + return uiex.GetManagedClusterResponse{ + Data: expectedCluster, + Credentials: uiex.GetManagedClusterCredentialsResponse{ + ConnectionUri: connectionURI, + User: "fly-user", + Password: "test-password", + DBName: "fly_db", + }, + }, nil + }, + CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + // Simulate a failure in creating attachment + return uiex.CreateAttachmentResponse{}, fmt.Errorf("failed to create attachment") + }, + } + + ctx = uiexutil.NewContextWithClient(ctx, mockUiex) + + // Get cluster - should succeed + response, err := mockUiex.GetManagedClusterById(ctx, clusterID) + require.NoError(t, err) + assert.Equal(t, expectedCluster.Id, response.Data.Id) + + // Create attachment - should fail but we handle it gracefully + attachInput := uiex.CreateAttachmentInput{ + AppName: appName, + } + _, err = mockUiex.CreateAttachment(ctx, clusterID, attachInput) + + // The error exists but in runAttach we just log a warning + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create attachment") + + // In the actual implementation, this is handled as a warning: + // fmt.Fprintf(io.ErrOut, "Warning: failed to create attachment record: %v\n", err) + // The attach command still succeeds because the secret was set +} + // Test invalid PG major version error message func TestInvalidPGMajorVersion_Error(t *testing.T) { invalidVersions := []int{15, 18, 14, 13, 19, 0, -1} @@ -1230,3 +1486,194 @@ func TestInvalidPGMajorVersion_Error(t *testing.T) { }) } } + +// Test formatAttachedApps function +func TestFormatAttachedApps(t *testing.T) { + tests := []struct { + name string + apps []uiex.AttachedApp + expected string + }{ + { + name: "no attached apps", + apps: []uiex.AttachedApp{}, + expected: "", + }, + { + name: "nil apps", + apps: nil, + expected: "", + }, + { + name: "single app", + apps: []uiex.AttachedApp{ + {Name: "my-web-app", Id: 1}, + }, + expected: "my-web-app", + }, + { + name: "two apps", + apps: []uiex.AttachedApp{ + {Name: "my-web-app", Id: 1}, + {Name: "my-api", Id: 2}, + }, + expected: "my-web-app, my-api", + }, + { + name: "three apps", + apps: []uiex.AttachedApp{ + {Name: "app-one", Id: 1}, + {Name: "app-two", Id: 2}, + {Name: "app-three", Id: 3}, + }, + expected: "app-one, app-two, app-three", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatAttachedApps(tt.apps) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test DeleteAttachment functionality +func TestDeleteAttachment(t *testing.T) { + ctx := setupTestContext() + + clusterID := "test-cluster-123" + + t.Run("successful attachment deletion", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) { + assert.Equal(t, clusterID, clusterId) + assert.Equal(t, "test-app", appName) + return uiex.DeleteAttachmentResponse{ + Data: struct { + Message string `json:"message"` + }{ + Message: "Attachment deleted successfully", + }, + }, nil + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + response, err := mockUiex.DeleteAttachment(ctx, clusterID, "test-app") + + require.NoError(t, err) + assert.Equal(t, "Attachment deleted successfully", response.Data.Message) + }) + + t.Run("error - attachment not found", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) { + return uiex.DeleteAttachmentResponse{}, fmt.Errorf("attachment not found for app '%s' on cluster %s", appName, clusterId) + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + _, err := mockUiex.DeleteAttachment(ctx, clusterID, "nonexistent-app") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "attachment not found") + }) + + t.Run("error - access denied", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) { + return uiex.DeleteAttachmentResponse{}, fmt.Errorf("access denied: you don't have permission to detach from cluster %s", clusterId) + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + _, err := mockUiex.DeleteAttachment(ctx, clusterID, "test-app") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "access denied") + }) + + t.Run("error - cluster not found", func(t *testing.T) { + mockUiex := &mock.UiexClient{ + DeleteAttachmentFunc: func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) { + return uiex.DeleteAttachmentResponse{}, fmt.Errorf("cluster %s not found", clusterId) + }, + } + + ctx := uiexutil.NewContextWithClient(ctx, mockUiex) + + _, err := mockUiex.DeleteAttachment(ctx, "nonexistent-cluster", "test-app") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +// Test the list command with attached apps +func TestListCommand_WithAttachedApps(t *testing.T) { + ctx := setupTestContext() + + expectedClusters := []uiex.ManagedCluster{ + { + Id: "cluster-1", + Name: "test-cluster-1", + Region: "ord", + Status: "ready", + Plan: "development", + Organization: fly.Organization{ + Slug: "test-org", + }, + AttachedApps: []uiex.AttachedApp{ + {Name: "web-app", Id: 100}, + {Name: "api-app", Id: 101}, + }, + }, + { + Id: "cluster-2", + Name: "test-cluster-2", + Region: "lax", + Status: "ready", + Plan: "production", + Organization: fly.Organization{ + Slug: "test-org", + }, + AttachedApps: []uiex.AttachedApp{}, // No attached apps + }, + } + + mockUiex := &mock.UiexClient{ + ListManagedClustersFunc: func(ctx context.Context, orgSlug string, deleted bool) (uiex.ListManagedClustersResponse, error) { + assert.Equal(t, "test-org", orgSlug) + return uiex.ListManagedClustersResponse{ + Data: expectedClusters, + }, nil + }, + } + + ctx = uiexutil.NewContextWithClient(ctx, mockUiex) + + // Test successful cluster listing with attached apps + clusters, err := mockUiex.ListManagedClusters(ctx, "test-org", false) + require.NoError(t, err) + assert.Len(t, clusters.Data, 2) + + // Verify first cluster has attached apps + assert.Len(t, clusters.Data[0].AttachedApps, 2) + assert.Equal(t, "web-app", clusters.Data[0].AttachedApps[0].Name) + assert.Equal(t, "api-app", clusters.Data[0].AttachedApps[1].Name) + + // Verify attached apps formatting for first cluster + formattedApps := formatAttachedApps(clusters.Data[0].AttachedApps) + assert.Equal(t, "web-app, api-app", formattedApps) + + // Verify second cluster has no attached apps + assert.Len(t, clusters.Data[1].AttachedApps, 0) + + // Verify attached apps formatting for second cluster (empty) + formattedApps = formatAttachedApps(clusters.Data[1].AttachedApps) + assert.Equal(t, "", formattedApps) +} diff --git a/internal/mock/uiex_client.go b/internal/mock/uiex_client.go index 800fe115cd..a6278f8c03 100644 --- a/internal/mock/uiex_client.go +++ b/internal/mock/uiex_client.go @@ -31,6 +31,8 @@ type UiexClient struct { ListManagedClusterBackupsFunc func(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error) CreateManagedClusterBackupFunc func(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error) RestoreManagedClusterBackupFunc func(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error) + CreateAttachmentFunc func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) + DeleteAttachmentFunc func(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) CreateBuildFunc func(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error) FinishBuildFunc func(ctx context.Context, in uiex.FinishBuildRequest) (*uiex.BuildResponse, error) EnsureDepotBuilderFunc func(ctx context.Context, in uiex.EnsureDepotBuilderRequest) (*uiex.EnsureDepotBuilderResponse, error) @@ -237,3 +239,17 @@ func (m *UiexClient) RestoreManagedClusterBackup(ctx context.Context, clusterID } return uiex.RestoreManagedClusterBackupResponse{}, nil } + +func (m *UiexClient) CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) { + if m.CreateAttachmentFunc != nil { + return m.CreateAttachmentFunc(ctx, clusterId, input) + } + return uiex.CreateAttachmentResponse{}, nil +} + +func (m *UiexClient) DeleteAttachment(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) { + if m.DeleteAttachmentFunc != nil { + return m.DeleteAttachmentFunc(ctx, clusterId, appName) + } + return uiex.DeleteAttachmentResponse{}, nil +} diff --git a/internal/uiex/managed_postgres.go b/internal/uiex/managed_postgres.go index 30f7a1afd2..cac543aaa3 100644 --- a/internal/uiex/managed_postgres.go +++ b/internal/uiex/managed_postgres.go @@ -54,6 +54,11 @@ type RestoreManagedClusterBackupResponse struct { Data ManagedCluster `json:"data"` } +type AttachedApp struct { + Name string `json:"name"` + Id int64 `json:"id"` +} + type ManagedCluster struct { Id string `json:"id"` Name string `json:"name"` @@ -64,6 +69,7 @@ type ManagedCluster struct { Replicas int `json:"replicas"` Organization fly.Organization `json:"organization"` IpAssignments ManagedClusterIpAssignments `json:"ip_assignments"` + AttachedApps []AttachedApp `json:"attached_apps"` } type ListManagedClustersResponse struct { @@ -880,3 +886,106 @@ func (c *Client) DestroyCluster(ctx context.Context, orgSlug string, id string) return fmt.Errorf("failed to destroy cluster (status %d): %s", res.StatusCode, string(body)) } } + +type CreateAttachmentInput struct { + AppName string `json:"app_name"` +} + +type CreateAttachmentResponse struct { + Data struct { + Id int64 `json:"id"` + AppId int64 `json:"app_id"` + ManagedServiceId int64 `json:"managed_service_id"` + AttachedAt string `json:"attached_at"` + } `json:"data"` +} + +// CreateAttachment creates a ManagedServiceAttachment record linking an app to a managed Postgres cluster +func (c *Client) CreateAttachment(ctx context.Context, clusterId string, input CreateAttachmentInput) (CreateAttachmentResponse, error) { + var response CreateAttachmentResponse + cfg := config.FromContext(ctx) + url := fmt.Sprintf("%s/api/v1/postgres/%s/attachments", c.baseUrl, clusterId) + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(input); err != nil { + return response, fmt.Errorf("failed to encode request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) + if err != nil { + return response, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+cfg.Tokens.GraphQL()) + req.Header.Add("Content-Type", "application/json") + + res, err := c.httpClient.Do(req) + if err != nil { + return response, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return response, fmt.Errorf("failed to read response body: %w", err) + } + + switch res.StatusCode { + case http.StatusOK, http.StatusCreated: + if err = json.Unmarshal(body, &response); err != nil { + return response, fmt.Errorf("failed to decode response: %w", err) + } + return response, nil + case http.StatusNotFound: + return response, fmt.Errorf("cluster %s not found", clusterId) + case http.StatusForbidden: + return response, fmt.Errorf("access denied: you don't have permission to attach cluster %s", clusterId) + default: + return response, fmt.Errorf("failed to create attachment (status %d): %s", res.StatusCode, string(body)) + } +} + +type DeleteAttachmentResponse struct { + Data struct { + Message string `json:"message"` + } `json:"data"` +} + +// DeleteAttachment removes a ManagedServiceAttachment record linking an app to a managed Postgres cluster +func (c *Client) DeleteAttachment(ctx context.Context, clusterId string, appName string) (DeleteAttachmentResponse, error) { + var response DeleteAttachmentResponse + cfg := config.FromContext(ctx) + url := fmt.Sprintf("%s/api/v1/postgres/%s/attachments/%s", c.baseUrl, clusterId, appName) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return response, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+cfg.Tokens.GraphQL()) + + res, err := c.httpClient.Do(req) + if err != nil { + return response, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return response, fmt.Errorf("failed to read response body: %w", err) + } + + switch res.StatusCode { + case http.StatusOK: + if err = json.Unmarshal(body, &response); err != nil { + return response, fmt.Errorf("failed to decode response: %w", err) + } + return response, nil + case http.StatusNotFound: + return response, fmt.Errorf("attachment not found for app '%s' on cluster %s", appName, clusterId) + case http.StatusForbidden: + return response, fmt.Errorf("access denied: you don't have permission to detach from cluster %s", clusterId) + default: + return response, fmt.Errorf("failed to delete attachment (status %d): %s", res.StatusCode, string(body)) + } +} diff --git a/internal/uiexutil/client.go b/internal/uiexutil/client.go index 68e58abbe8..a9e8384d35 100644 --- a/internal/uiexutil/client.go +++ b/internal/uiexutil/client.go @@ -30,6 +30,8 @@ type Client interface { ListManagedClusterBackups(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error) CreateManagedClusterBackup(ctx context.Context, clusterID string, input uiex.CreateManagedClusterBackupInput) (uiex.CreateManagedClusterBackupResponse, error) RestoreManagedClusterBackup(ctx context.Context, clusterID string, input uiex.RestoreManagedClusterBackupInput) (uiex.RestoreManagedClusterBackupResponse, error) + CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) + DeleteAttachment(ctx context.Context, clusterId string, appName string) (uiex.DeleteAttachmentResponse, error) // Builders CreateBuild(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error) diff --git a/test/preflight/fly_mpg_test.go b/test/preflight/fly_mpg_test.go new file mode 100644 index 0000000000..901c4fe404 --- /dev/null +++ b/test/preflight/fly_mpg_test.go @@ -0,0 +1,196 @@ +//go:build integration +// +build integration + +package preflight + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superfly/flyctl/test/preflight/testlib" +) + +// TestMPG_AttachCreatesAttachmentRecord tests that `fly mpg attach` creates +// a ManagedServiceAttachment record in addition to setting the DATABASE_URL secret. +// +// NOTE: This test requires the ui-ex and web changes to be deployed that add +// the POST /api/v1/postgres/:managed_postgres_hashid/attachments endpoint. +// Until those changes are deployed, this test will fail. +func TestMPG_AttachCreatesAttachmentRecord(t *testing.T) { + // Skip this test until the ui-ex/web changes are deployed + t.Skip("Requires ui-ex/web deployment of create_attachment endpoint") + + if testing.Short() { + t.Skip("skipping MPG test in short mode") + } + + f := testlib.NewTestEnvFromEnv(t) + + // Skip if using custom VM size (MPG doesn't support custom sizes) + if f.VMSize != "" { + t.Skip("MPG tests don't support custom VM sizes") + } + + // Create an MPG cluster + clusterName := f.CreateRandomAppName() + f.Cleanup(func() { + // Clean up the MPG cluster + f.FlyAllowExitFailure("mpg destroy %s --yes", clusterName) + }) + + f.Fly( + "mpg create --org %s --name %s --region %s --plan development --volume-size 10", + f.OrgSlug(), clusterName, f.PrimaryRegion(), + ) + + // Wait for cluster to be ready + assert.EventuallyWithT(t, func(t *assert.CollectT) { + result := f.FlyAllowExitFailure("mpg status %s --json", clusterName) + if result.ExitCode() != 0 { + t.Errorf("mpg status failed: %s", result.StdErr().String()) + return + } + // Check if cluster is ready + output := result.StdOut().String() + assert.Contains(t, output, `"status":"ready"`) + }, 5*time.Minute, 15*time.Second, "MPG cluster did not become ready") + + // Create an app to attach the cluster to + appName := f.CreateRandomAppName() + f.Fly("apps create %s --org %s --machines", appName, f.OrgSlug()) + + // Attach the MPG cluster to the app + result := f.Fly("mpg attach %s --app %s", clusterName, appName) + output := result.StdOut().String() + + // Verify the attach command succeeded and set the secret + require.Contains(f, output, "Postgres cluster") + require.Contains(f, output, "is being attached to") + require.Contains(f, output, "DATABASE_URL") + + // Verify the secret was set on the app + secretsResult := f.Fly("secrets list --app %s", appName) + require.Contains(f, secretsResult.StdOut().String(), "DATABASE_URL") +} + +// TestMPG_AttachWithCustomVariableName tests attaching with a custom variable name +func TestMPG_AttachWithCustomVariableName(t *testing.T) { + // Skip this test until the ui-ex/web changes are deployed + t.Skip("Requires ui-ex/web deployment of create_attachment endpoint") + + if testing.Short() { + t.Skip("skipping MPG test in short mode") + } + + f := testlib.NewTestEnvFromEnv(t) + + if f.VMSize != "" { + t.Skip("MPG tests don't support custom VM sizes") + } + + // Create an MPG cluster + clusterName := f.CreateRandomAppName() + f.Cleanup(func() { + f.FlyAllowExitFailure("mpg destroy %s --yes", clusterName) + }) + + f.Fly( + "mpg create --org %s --name %s --region %s --plan development --volume-size 10", + f.OrgSlug(), clusterName, f.PrimaryRegion(), + ) + + // Wait for cluster to be ready + assert.EventuallyWithT(t, func(t *assert.CollectT) { + result := f.FlyAllowExitFailure("mpg status %s --json", clusterName) + if result.ExitCode() != 0 { + return + } + assert.Contains(t, result.StdOut().String(), `"status":"ready"`) + }, 5*time.Minute, 15*time.Second) + + // Create an app + appName := f.CreateRandomAppName() + f.Fly("apps create %s --org %s --machines", appName, f.OrgSlug()) + + // Attach with custom variable name + customVarName := "POSTGRES_URL" + result := f.Fly("mpg attach %s --app %s --variable-name %s", clusterName, appName, customVarName) + output := result.StdOut().String() + + require.Contains(f, output, customVarName) + + // Verify the custom secret was set + secretsResult := f.Fly("secrets list --app %s", appName) + require.Contains(f, secretsResult.StdOut().String(), customVarName) +} + +// TestMPG_AttachFailsForDifferentOrg tests that attach fails when app and cluster +// are in different organizations +func TestMPG_AttachFailsForDifferentOrg(t *testing.T) { + // Skip this test until the ui-ex/web changes are deployed + t.Skip("Requires ui-ex/web deployment of create_attachment endpoint") + + if testing.Short() { + t.Skip("skipping MPG test in short mode") + } + + f := testlib.NewTestEnvFromEnv(t) + + if f.VMSize != "" { + t.Skip("MPG tests don't support custom VM sizes") + } + + // This test would require access to two different orgs, + // which is complex to set up in preflight tests. + // For now, we document the expected behavior. + t.Skip("requires access to multiple organizations") +} + +// TestMPG_AttachFailsWhenSecretExists tests that attach fails when the secret +// variable already exists on the app +func TestMPG_AttachFailsWhenSecretExists(t *testing.T) { + // Skip this test until the ui-ex/web changes are deployed + t.Skip("Requires ui-ex/web deployment of create_attachment endpoint") + + if testing.Short() { + t.Skip("skipping MPG test in short mode") + } + + f := testlib.NewTestEnvFromEnv(t) + + if f.VMSize != "" { + t.Skip("MPG tests don't support custom VM sizes") + } + + // Create an MPG cluster + clusterName := f.CreateRandomAppName() + f.Cleanup(func() { + f.FlyAllowExitFailure("mpg destroy %s --yes", clusterName) + }) + + f.Fly( + "mpg create --org %s --name %s --region %s --plan development --volume-size 10", + f.OrgSlug(), clusterName, f.PrimaryRegion(), + ) + + // Wait for cluster to be ready + assert.EventuallyWithT(t, func(t *assert.CollectT) { + result := f.FlyAllowExitFailure("mpg status %s --json", clusterName) + if result.ExitCode() != 0 { + return + } + assert.Contains(t, result.StdOut().String(), `"status":"ready"`) + }, 5*time.Minute, 15*time.Second) + + // Create an app and set DATABASE_URL secret + appName := f.CreateRandomAppName() + f.Fly("apps create %s --org %s --machines", appName, f.OrgSlug()) + f.Fly("secrets set DATABASE_URL=postgres://existing@localhost/db --app %s", appName) + + // Try to attach - should fail because DATABASE_URL already exists + result := f.FlyAllowExitFailure("mpg attach %s --app %s", clusterName, appName) + require.NotEqual(f, 0, result.ExitCode(), "attach should fail when secret exists") + require.Contains(f, result.StdErr().String(), "DATABASE_URL") +}