Skip to content

Commit e3da867

Browse files
committed
Add mpg attach feature to track app attachments
Previously, `mpg attach` only created a secret and deployed. Now, it will create an explicit DB connection between the MPG and the app in question. It uses a new endpoint in `ui-ex`
1 parent 33800f8 commit e3da867

File tree

7 files changed

+532
-0
lines changed

7 files changed

+532
-0
lines changed

internal/command/launch/plan/postgres_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ func (m *mockUIEXClient) RestoreManagedClusterBackup(ctx context.Context, cluste
111111
return uiex.RestoreManagedClusterBackupResponse{}, nil
112112
}
113113

114+
func (m *mockUIEXClient) CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
115+
return uiex.CreateAttachmentResponse{}, nil
116+
}
117+
114118
func (m *mockUIEXClient) CreateBuild(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error) {
115119
return &uiex.BuildResponse{}, nil
116120
}

internal/command/mpg/attach.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,15 @@ func runAttach(ctx context.Context) error {
281281
return err
282282
}
283283

284+
// Create attachment record to track the cluster-app relationship
285+
attachInput := uiex.CreateAttachmentInput{
286+
AppName: appName,
287+
}
288+
if _, err := uiexClient.CreateAttachment(ctx, cluster.Id, attachInput); err != nil {
289+
// Log warning but don't fail - the secret was set successfully
290+
fmt.Fprintf(io.ErrOut, "Warning: failed to create attachment record: %v\n", err)
291+
}
292+
284293
fmt.Fprintf(io.Out, "\nPostgres cluster %s is being attached to %s\n", cluster.Id, appName)
285294
fmt.Fprintf(io.Out, "The following secret was added to %s:\n %s=%s\n", appName, variableName, connectionUri)
286295

internal/command/mpg/mpg_test.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,262 @@ func TestCreateCommand_WithPGMajorVersion(t *testing.T) {
12161216
}
12171217
}
12181218

1219+
// Test CreateAttachment functionality
1220+
func TestCreateAttachment(t *testing.T) {
1221+
ctx := setupTestContext()
1222+
1223+
clusterID := "test-cluster-123"
1224+
1225+
t.Run("successful attachment creation", func(t *testing.T) {
1226+
mockUiex := &mock.UiexClient{
1227+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1228+
assert.Equal(t, clusterID, clusterId)
1229+
assert.Equal(t, "test-app", input.AppName)
1230+
return uiex.CreateAttachmentResponse{
1231+
Data: struct {
1232+
Id int64 `json:"id"`
1233+
AppId int64 `json:"app_id"`
1234+
ManagedServiceId int64 `json:"managed_service_id"`
1235+
AttachedAt string `json:"attached_at"`
1236+
}{
1237+
Id: 1,
1238+
AppId: 100,
1239+
ManagedServiceId: 200,
1240+
AttachedAt: "2025-01-15T10:00:00Z",
1241+
},
1242+
}, nil
1243+
},
1244+
}
1245+
1246+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1247+
1248+
response, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{
1249+
AppName: "test-app",
1250+
})
1251+
1252+
require.NoError(t, err)
1253+
assert.Equal(t, int64(1), response.Data.Id)
1254+
assert.Equal(t, int64(100), response.Data.AppId)
1255+
assert.Equal(t, int64(200), response.Data.ManagedServiceId)
1256+
assert.Equal(t, "2025-01-15T10:00:00Z", response.Data.AttachedAt)
1257+
})
1258+
1259+
t.Run("idempotent - returns existing attachment", func(t *testing.T) {
1260+
mockUiex := &mock.UiexClient{
1261+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1262+
// Simulating the idempotent case where attachment already exists
1263+
return uiex.CreateAttachmentResponse{
1264+
Data: struct {
1265+
Id int64 `json:"id"`
1266+
AppId int64 `json:"app_id"`
1267+
ManagedServiceId int64 `json:"managed_service_id"`
1268+
AttachedAt string `json:"attached_at"`
1269+
}{
1270+
Id: 42, // Existing attachment ID
1271+
AppId: 100,
1272+
ManagedServiceId: 200,
1273+
AttachedAt: "2025-01-14T09:00:00Z", // Earlier timestamp
1274+
},
1275+
}, nil
1276+
},
1277+
}
1278+
1279+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1280+
1281+
response, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{
1282+
AppName: "already-attached-app",
1283+
})
1284+
1285+
require.NoError(t, err)
1286+
assert.Equal(t, int64(42), response.Data.Id)
1287+
})
1288+
1289+
t.Run("error - cluster not found", func(t *testing.T) {
1290+
mockUiex := &mock.UiexClient{
1291+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1292+
return uiex.CreateAttachmentResponse{}, fmt.Errorf("cluster %s not found", clusterId)
1293+
},
1294+
}
1295+
1296+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1297+
1298+
_, err := mockUiex.CreateAttachment(ctx, "nonexistent-cluster", uiex.CreateAttachmentInput{
1299+
AppName: "test-app",
1300+
})
1301+
1302+
assert.Error(t, err)
1303+
assert.Contains(t, err.Error(), "not found")
1304+
})
1305+
1306+
t.Run("error - access denied", func(t *testing.T) {
1307+
mockUiex := &mock.UiexClient{
1308+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1309+
return uiex.CreateAttachmentResponse{}, fmt.Errorf("access denied: you don't have permission to attach cluster %s", clusterId)
1310+
},
1311+
}
1312+
1313+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1314+
1315+
_, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{
1316+
AppName: "test-app",
1317+
})
1318+
1319+
assert.Error(t, err)
1320+
assert.Contains(t, err.Error(), "access denied")
1321+
})
1322+
1323+
t.Run("error - app not found", func(t *testing.T) {
1324+
mockUiex := &mock.UiexClient{
1325+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1326+
return uiex.CreateAttachmentResponse{}, fmt.Errorf("app not found")
1327+
},
1328+
}
1329+
1330+
ctx := uiexutil.NewContextWithClient(ctx, mockUiex)
1331+
1332+
_, err := mockUiex.CreateAttachment(ctx, clusterID, uiex.CreateAttachmentInput{
1333+
AppName: "nonexistent-app",
1334+
})
1335+
1336+
assert.Error(t, err)
1337+
assert.Contains(t, err.Error(), "not found")
1338+
})
1339+
}
1340+
1341+
// Test attach command integration with CreateAttachment
1342+
func TestAttachCommand_CreatesAttachment(t *testing.T) {
1343+
ctx := setupTestContext()
1344+
1345+
clusterID := "test-cluster-123"
1346+
appName := "test-app"
1347+
1348+
expectedCluster := uiex.ManagedCluster{
1349+
Id: clusterID,
1350+
Name: "test-cluster",
1351+
Region: "ord",
1352+
Status: "ready",
1353+
Organization: fly.Organization{
1354+
Slug: "test-org",
1355+
},
1356+
}
1357+
1358+
connectionURI := "postgresql://user:pass@host:5432/db"
1359+
1360+
// Track whether CreateAttachment was called
1361+
createAttachmentCalled := false
1362+
var capturedAppName string
1363+
1364+
mockUiex := &mock.UiexClient{
1365+
GetManagedClusterByIdFunc: func(ctx context.Context, id string) (uiex.GetManagedClusterResponse, error) {
1366+
assert.Equal(t, clusterID, id)
1367+
return uiex.GetManagedClusterResponse{
1368+
Data: expectedCluster,
1369+
Credentials: uiex.GetManagedClusterCredentialsResponse{
1370+
ConnectionUri: connectionURI,
1371+
User: "fly-user",
1372+
Password: "test-password",
1373+
DBName: "fly_db",
1374+
},
1375+
}, nil
1376+
},
1377+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1378+
createAttachmentCalled = true
1379+
capturedAppName = input.AppName
1380+
assert.Equal(t, clusterID, clusterId)
1381+
return uiex.CreateAttachmentResponse{
1382+
Data: struct {
1383+
Id int64 `json:"id"`
1384+
AppId int64 `json:"app_id"`
1385+
ManagedServiceId int64 `json:"managed_service_id"`
1386+
AttachedAt string `json:"attached_at"`
1387+
}{
1388+
Id: 1,
1389+
AppId: 100,
1390+
ManagedServiceId: 200,
1391+
AttachedAt: "2025-01-15T10:00:00Z",
1392+
},
1393+
}, nil
1394+
},
1395+
}
1396+
1397+
ctx = uiexutil.NewContextWithClient(ctx, mockUiex)
1398+
1399+
// Simulate the attach command flow: get cluster, then create attachment
1400+
response, err := mockUiex.GetManagedClusterById(ctx, clusterID)
1401+
require.NoError(t, err)
1402+
assert.Equal(t, expectedCluster.Id, response.Data.Id)
1403+
1404+
// Create attachment (this simulates what runAttach does after setting secrets)
1405+
attachInput := uiex.CreateAttachmentInput{
1406+
AppName: appName,
1407+
}
1408+
_, err = mockUiex.CreateAttachment(ctx, clusterID, attachInput)
1409+
require.NoError(t, err)
1410+
1411+
// Verify CreateAttachment was called with correct app name
1412+
assert.True(t, createAttachmentCalled, "CreateAttachment should be called during attach")
1413+
assert.Equal(t, appName, capturedAppName, "App name should be passed to CreateAttachment")
1414+
}
1415+
1416+
// Test that attach command handles CreateAttachment errors gracefully
1417+
func TestAttachCommand_HandlesAttachmentErrorGracefully(t *testing.T) {
1418+
ctx := setupTestContext()
1419+
1420+
clusterID := "test-cluster-123"
1421+
appName := "test-app"
1422+
1423+
expectedCluster := uiex.ManagedCluster{
1424+
Id: clusterID,
1425+
Name: "test-cluster",
1426+
Region: "ord",
1427+
Status: "ready",
1428+
Organization: fly.Organization{
1429+
Slug: "test-org",
1430+
},
1431+
}
1432+
1433+
connectionURI := "postgresql://user:pass@host:5432/db"
1434+
1435+
mockUiex := &mock.UiexClient{
1436+
GetManagedClusterByIdFunc: func(ctx context.Context, id string) (uiex.GetManagedClusterResponse, error) {
1437+
return uiex.GetManagedClusterResponse{
1438+
Data: expectedCluster,
1439+
Credentials: uiex.GetManagedClusterCredentialsResponse{
1440+
ConnectionUri: connectionURI,
1441+
User: "fly-user",
1442+
Password: "test-password",
1443+
DBName: "fly_db",
1444+
},
1445+
}, nil
1446+
},
1447+
CreateAttachmentFunc: func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
1448+
// Simulate a failure in creating attachment
1449+
return uiex.CreateAttachmentResponse{}, fmt.Errorf("failed to create attachment")
1450+
},
1451+
}
1452+
1453+
ctx = uiexutil.NewContextWithClient(ctx, mockUiex)
1454+
1455+
// Get cluster - should succeed
1456+
response, err := mockUiex.GetManagedClusterById(ctx, clusterID)
1457+
require.NoError(t, err)
1458+
assert.Equal(t, expectedCluster.Id, response.Data.Id)
1459+
1460+
// Create attachment - should fail but we handle it gracefully
1461+
attachInput := uiex.CreateAttachmentInput{
1462+
AppName: appName,
1463+
}
1464+
_, err = mockUiex.CreateAttachment(ctx, clusterID, attachInput)
1465+
1466+
// The error exists but in runAttach we just log a warning
1467+
assert.Error(t, err)
1468+
assert.Contains(t, err.Error(), "failed to create attachment")
1469+
1470+
// In the actual implementation, this is handled as a warning:
1471+
// fmt.Fprintf(io.ErrOut, "Warning: failed to create attachment record: %v\n", err)
1472+
// The attach command still succeeds because the secret was set
1473+
}
1474+
12191475
// Test invalid PG major version error message
12201476
func TestInvalidPGMajorVersion_Error(t *testing.T) {
12211477
invalidVersions := []int{15, 18, 14, 13, 19, 0, -1}

internal/mock/uiex_client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type UiexClient struct {
3131
ListManagedClusterBackupsFunc func(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error)
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)
34+
CreateAttachmentFunc func(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error)
3435
CreateBuildFunc func(ctx context.Context, in uiex.CreateBuildRequest) (*uiex.BuildResponse, error)
3536
FinishBuildFunc func(ctx context.Context, in uiex.FinishBuildRequest) (*uiex.BuildResponse, error)
3637
EnsureDepotBuilderFunc func(ctx context.Context, in uiex.EnsureDepotBuilderRequest) (*uiex.EnsureDepotBuilderResponse, error)
@@ -237,3 +238,10 @@ func (m *UiexClient) RestoreManagedClusterBackup(ctx context.Context, clusterID
237238
}
238239
return uiex.RestoreManagedClusterBackupResponse{}, nil
239240
}
241+
242+
func (m *UiexClient) CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error) {
243+
if m.CreateAttachmentFunc != nil {
244+
return m.CreateAttachmentFunc(ctx, clusterId, input)
245+
}
246+
return uiex.CreateAttachmentResponse{}, nil
247+
}

internal/uiex/managed_postgres.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,3 +880,61 @@ func (c *Client) DestroyCluster(ctx context.Context, orgSlug string, id string)
880880
return fmt.Errorf("failed to destroy cluster (status %d): %s", res.StatusCode, string(body))
881881
}
882882
}
883+
884+
type CreateAttachmentInput struct {
885+
AppName string `json:"app_name"`
886+
}
887+
888+
type CreateAttachmentResponse struct {
889+
Data struct {
890+
Id int64 `json:"id"`
891+
AppId int64 `json:"app_id"`
892+
ManagedServiceId int64 `json:"managed_service_id"`
893+
AttachedAt string `json:"attached_at"`
894+
} `json:"data"`
895+
}
896+
897+
// CreateAttachment creates a ManagedServiceAttachment record linking an app to a managed Postgres cluster
898+
func (c *Client) CreateAttachment(ctx context.Context, clusterId string, input CreateAttachmentInput) (CreateAttachmentResponse, error) {
899+
var response CreateAttachmentResponse
900+
cfg := config.FromContext(ctx)
901+
url := fmt.Sprintf("%s/api/v1/postgres/%s/attachments", c.baseUrl, clusterId)
902+
903+
var buf bytes.Buffer
904+
if err := json.NewEncoder(&buf).Encode(input); err != nil {
905+
return response, fmt.Errorf("failed to encode request body: %w", err)
906+
}
907+
908+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
909+
if err != nil {
910+
return response, fmt.Errorf("failed to create request: %w", err)
911+
}
912+
913+
req.Header.Add("Authorization", "Bearer "+cfg.Tokens.GraphQL())
914+
req.Header.Add("Content-Type", "application/json")
915+
916+
res, err := c.httpClient.Do(req)
917+
if err != nil {
918+
return response, err
919+
}
920+
defer res.Body.Close()
921+
922+
body, err := io.ReadAll(res.Body)
923+
if err != nil {
924+
return response, fmt.Errorf("failed to read response body: %w", err)
925+
}
926+
927+
switch res.StatusCode {
928+
case http.StatusOK, http.StatusCreated:
929+
if err = json.Unmarshal(body, &response); err != nil {
930+
return response, fmt.Errorf("failed to decode response: %w", err)
931+
}
932+
return response, nil
933+
case http.StatusNotFound:
934+
return response, fmt.Errorf("cluster %s not found", clusterId)
935+
case http.StatusForbidden:
936+
return response, fmt.Errorf("access denied: you don't have permission to attach cluster %s", clusterId)
937+
default:
938+
return response, fmt.Errorf("failed to create attachment (status %d): %s", res.StatusCode, string(body))
939+
}
940+
}

internal/uiexutil/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Client interface {
3030
ListManagedClusterBackups(ctx context.Context, clusterID string) (uiex.ListManagedClusterBackupsResponse, error)
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)
33+
CreateAttachment(ctx context.Context, clusterId string, input uiex.CreateAttachmentInput) (uiex.CreateAttachmentResponse, error)
3334

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

0 commit comments

Comments
 (0)