@@ -3,14 +3,15 @@ package cmdv2
33import (
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).
3355func 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 , "\n Managed 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