Skip to content

Commit c1fea52

Browse files
Route MPGv2 create through the Machines API (#5168)
Update RunCreate to use CreateManagedPostgresCluster, GetManagedPostgresCluster, and GetManagedPostgresUserCredentials. Use mpgutil.AvailableRegions for region discovery. Return create errors without falling back to the private client because retrying a mutating request could provision a second cluster. Build the connection URI from the primary pooler endpoint with fly-user, fly-db, and url.UserPassword, validating the endpoint is populated before composing the URI.
1 parent cb23ac4 commit c1fea52

2 files changed

Lines changed: 685 additions & 30 deletions

File tree

internal/command/mpg/v2/run_create.go

Lines changed: 96 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ package cmdv2
33
import (
44
"context"
55
"fmt"
6+
"net/url"
67
"strconv"
78
"time"
89

9-
"github.com/superfly/fly-go"
10-
regionsv2 "github.com/superfly/flyctl/internal/command/mpg/v2/regions"
10+
"github.com/superfly/fly-go/flaps"
1111
"github.com/superfly/flyctl/internal/flag"
12+
"github.com/superfly/flyctl/internal/flapsutil"
13+
"github.com/superfly/flyctl/internal/mpgutil"
1214
"github.com/superfly/flyctl/internal/prompt"
13-
mpgv2 "github.com/superfly/flyctl/internal/uiex/mpg/v2"
1415
"github.com/superfly/flyctl/iostreams"
1516
)
1617

@@ -30,12 +31,35 @@ type CreatePlanDisplay struct {
3031
PricePerMo int
3132
}
3233

34+
// flyUser and flyDB are the fixed credentials used by the public
35+
// Machines API's create/credentials response. The public credentials
36+
// endpoint carries no database name, so the connection URI is composed
37+
// against the literal "fly-db" database.
38+
const (
39+
flyUser = "fly-user"
40+
flyDB = "fly-db"
41+
)
42+
43+
// RunCreate provisions a new Managed Postgres cluster via the public
44+
// Machines API (flaps) and prints the connection string once ready.
45+
//
46+
// Unlike the other migrated MPG commands, this one deliberately does NOT
47+
// fall back to the private client on a failed or ambiguous create call:
48+
// retrying a mutating create against a different backend risks
49+
// double-provisioning a cluster. Errors from CreateManagedPostgresCluster
50+
// are propagated to the caller as-is.
51+
//
52+
// orgRawSlug is accepted for API parity with the previous implementation;
53+
// region discovery is now driven by the non-org-scoped Machines API region
54+
// list (mpgutil.AvailableRegions).
3355
func RunCreate(ctx context.Context, orgRawSlug string, params *CreateClusterParams, planDisplay *CreatePlanDisplay) error {
56+
_ = orgRawSlug
57+
3458
io := iostreams.FromContext(ctx)
35-
mpgClient := mpgv2.ClientFromContext(ctx)
59+
flapsClient := flapsutil.ClientFromContext(ctx)
3660

37-
// Get available MPG regions from API
38-
mpgRegions, err := regionsv2.GetAvailableMPGRegions(ctx, orgRawSlug)
61+
// Get available MPG regions from the public Machines API region list.
62+
mpgRegions, err := mpgutil.AvailableRegions(ctx)
3963
if err != nil {
4064
return err
4165
}
@@ -46,19 +70,24 @@ func RunCreate(ctx context.Context, orgRawSlug string, params *CreateClusterPara
4670

4771
// Check if region was specified via flag
4872
regionCode := flag.GetString(ctx, "region")
49-
var selectedRegion *fly.Region
73+
var selectedRegionCode string
5074

5175
if regionCode != "" {
5276
// Find the specified region in the allowed regions
77+
var matched bool
5378
for _, region := range mpgRegions {
5479
if region.Code == regionCode {
55-
selectedRegion = &region
80+
selectedRegionCode = region.Code
81+
matched = true
5682

5783
break
5884
}
5985
}
60-
if selectedRegion == nil {
61-
availableCodes, _ := regionsv2.GetAvailableMPGRegionCodes(ctx, params.OrgSlug)
86+
if !matched {
87+
availableCodes := make([]string, len(mpgRegions))
88+
for i, region := range mpgRegions {
89+
availableCodes[i] = region.Code
90+
}
6291

6392
return fmt.Errorf("region %s is not available for Managed Postgres. Available regions: %v", regionCode, availableCodes)
6493
}
@@ -74,25 +103,25 @@ func RunCreate(ctx context.Context, orgRawSlug string, params *CreateClusterPara
74103
return err
75104
}
76105

77-
selectedRegion = &mpgRegions[selectedIndex]
106+
selectedRegionCode = mpgRegions[selectedIndex].Code
78107
}
79108

80-
input := mpgv2.CreateClusterInput{
109+
createReq := flaps.CreateManagedPostgresClusterRequest{
81110
Name: params.Name,
82-
Region: selectedRegion.Code,
111+
Region: selectedRegionCode,
83112
Plan: params.Plan,
84113
OrgSlug: params.OrgSlug,
85-
StorageInGb: params.StorageInGb,
86-
PostGISEnabled: params.PostGISEnabled,
114+
DiskSizeGB: params.StorageInGb,
87115
PGMajorVersion: strconv.Itoa(params.PGMajorVersion),
116+
PostGISEnabled: params.PostGISEnabled,
88117
}
89118

90-
response, err := mpgClient.CreateCluster(ctx, input)
119+
cluster, err := flapsClient.CreateManagedPostgresCluster(ctx, createReq)
91120
if err != nil {
92121
return fmt.Errorf("failed creating managed postgres cluster: %w", err)
93122
}
94123

95-
clusterID := response.Data.Id
124+
clusterID := cluster.ID
96125

97126
var connectionURI string
98127

@@ -108,40 +137,77 @@ func RunCreate(ctx context.Context, orgRawSlug string, params *CreateClusterPara
108137
fmt.Fprintf(io.Out, "You can cancel this wait with Ctrl+C - the cluster will continue provisioning in the background.\n")
109138
fmt.Fprintf(io.Out, "Once ready, you can connect to the database with: fly mpg connect %s\n\n", clusterID)
110139
for {
111-
res, err := mpgClient.GetClusterById(ctx, clusterID)
140+
res, err := flapsClient.GetManagedPostgresCluster(ctx, clusterID)
112141
if err != nil {
113142
return fmt.Errorf("failed checking cluster status: %w", err)
114143
}
115144

116-
cluster := res.Data
117-
credentials := res.Credentials
118-
119-
if cluster.Id == "" {
145+
if res.ID == "" {
120146
return fmt.Errorf("invalid cluster response: no cluster ID")
121147
}
122148

123-
if cluster.Status == "ready" {
124-
connectionURI = credentials.ConnectionUri
149+
switch res.Status {
150+
case flaps.ManagedPostgresStatusReady:
151+
creds, credErr := flapsClient.GetManagedPostgresUserCredentials(ctx, clusterID, flyUser)
152+
if credErr != nil {
153+
return fmt.Errorf("failed retrieving credentials for cluster %s: %w", clusterID, credErr)
154+
}
125155

126-
break
127-
}
156+
// The connection URI's username is the fixed "fly-user" the plan
157+
// requested credentials for, NOT whatever Username the credentials
158+
// response carries — invariant #6 keeps the URI user stable
159+
// regardless of any unexpected payload drift.
160+
uri, buildErr := buildConnectionURI(res.Endpoints.Primary.Pooler, flyUser, creds.Password, flyDB)
161+
if buildErr != nil {
162+
return fmt.Errorf("failed to build connection URI for cluster %s: %w", clusterID, buildErr)
163+
}
164+
connectionURI = uri
128165

129-
if cluster.Status == "error" {
166+
case flaps.ManagedPostgresStatusFailed, flaps.ManagedPostgresStatusError:
130167
return fmt.Errorf("cluster creation failed")
131168
}
132169

170+
if connectionURI != "" {
171+
break
172+
}
173+
133174
time.Sleep(5 * time.Second)
134175
}
135176

136177
fmt.Fprintf(io.Out, "\nManaged Postgres cluster created successfully!\n")
137178
fmt.Fprintf(io.Out, " ID: %s\n", clusterID)
138179
fmt.Fprintf(io.Out, " Name: %s\n", params.Name)
139180
fmt.Fprintf(io.Out, " Organization: %s\n", params.OrgSlug)
140-
fmt.Fprintf(io.Out, " Region: %s\n", response.Data.Region)
181+
fmt.Fprintf(io.Out, " Region: %s\n", cluster.Region)
141182
fmt.Fprintf(io.Out, " Plan: %s\n", params.Plan)
142-
fmt.Fprintf(io.Out, " Disk: %dGB\n", response.Data.Disk)
143-
fmt.Fprintf(io.Out, " PostGIS: %t\n", response.Data.PostGISEnabled)
183+
fmt.Fprintf(io.Out, " Disk: %dGB\n", cluster.DiskSizeGB)
184+
fmt.Fprintf(io.Out, " PostGIS: %t\n", cluster.PostGISEnabled)
144185
fmt.Fprintf(io.Out, " Connection string: %s\n", connectionURI)
145186

146187
return nil
147188
}
189+
190+
// buildConnectionURI composes a pooled (PgBouncer-style) connection URI for
191+
// the given endpoint + credentials. The password is URL-escaped via
192+
// url.UserPassword so that reserved characters in the password do not corrupt
193+
// the URI.
194+
//
195+
// The endpoint Host/Port are validated before composition: a cluster that
196+
// reports status "ready" can still carry a zero-valued pooler endpoint during
197+
// the propagation-lag race between readiness and endpoint population. Without
198+
// this check, the function would silently return a broken URI such as
199+
// "postgres://fly-user:secret@:0/fly-db" with a nil error.
200+
func buildConnectionURI(endpoint flaps.ManagedPostgresEndpoint, username, password, dbName string) (string, error) {
201+
if endpoint.Host == "" || endpoint.Port == 0 {
202+
return "", fmt.Errorf("cluster ready but pooler endpoint not yet available")
203+
}
204+
205+
u := &url.URL{
206+
Scheme: "postgres",
207+
User: url.UserPassword(username, password),
208+
Host: fmt.Sprintf("%s:%d", endpoint.Host, endpoint.Port),
209+
Path: "/" + dbName,
210+
}
211+
212+
return u.String(), nil
213+
}

0 commit comments

Comments
 (0)