Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/command/mpg/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ func runList(ctx context.Context) error {
return nil
}

if !deleted {
for _, cluster := range clusters {
if cluster.Version != 2 {
printV2MigrationNotice(ctx)

break
}
}
}

if cfg.JSONOutput {
return render.JSON(out, clusters)
}
Expand Down
96 changes: 73 additions & 23 deletions internal/command/mpg/mpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ import (
"github.com/superfly/flyctl/internal/prompt"
"github.com/superfly/flyctl/internal/uiex/mpg"
mpgv1 "github.com/superfly/flyctl/internal/uiex/mpg/v1"
"github.com/superfly/flyctl/iostreams"
)

const dashboardURL = "https://fly.io/dashboard"

// Plain-text notice for help output; printV2MigrationNotice renders the styled version.
const v2MigrationNotice = "Managed Postgres v2 is here! Migrate eligible MPG v1 clusters to v2 from your cluster's page in the Fly.io dashboard (" + dashboardURL + ") — connection strings stay the same.\n"

func New() *cobra.Command {
const (
short = `Manage Managed Postgres clusters.`

long = short + "\n"
long = short + "\n\n" + v2MigrationNotice
)

cmd := command.New("mpg", short, long, nil)
Expand All @@ -50,6 +56,44 @@ func New() *cobra.Command {
return cmd
}

func printV2MigrationNotice(ctx context.Context) {
io := iostreams.FromContext(ctx)
colorize := io.ColorScheme()

dashboard := colorize.Cyan(io.CreateLink("the Fly.io dashboard", dashboardURL))

fmt.Fprintf(io.ErrOut, "\n%s\nMigrate eligible MPG v1 clusters to v2 from your cluster's page in %s — connection strings stay the same.\n\n",
colorize.Yellow("Managed Postgres v2 is here!"),
dashboard,
)
}

// Unknown eligibility (nil) still shows the link; only the dashboard can run
// the full eligibility checks.
func printV1MigrationLink(ctx context.Context, cluster *mpg.Cluster, orgSlug string) {
if cluster == nil || cluster.Version != mpg.VersionV1 {
return
}

if cluster.EligibleForV2Migration != nil && !*cluster.EligibleForV2Migration {
return
}

if cluster.Organization.Slug != "" {
orgSlug = cluster.Organization.Slug
}

io := iostreams.FromContext(ctx)
colorize := io.ColorScheme()

url := fmt.Sprintf("%s/%s/managed_postgres/%s/v2-migration", dashboardURL, orgSlug, cluster.Id)

fmt.Fprintf(io.ErrOut, "%s\n%s\n\n",
colorize.Yellow(fmt.Sprintf("Cluster %q is on MPG v1 — migrate it to v2 at:", cluster.Name)),
colorize.Cyan(io.CreateLinkURL(url)),
)
}

// ClusterFromArgOrSelect retrieves the cluster if the cluster ID is passed in
// otherwise it prompts the user to select a cluster from the available ones for
// the given organization.
Expand Down Expand Up @@ -81,19 +125,22 @@ func ClusterFromArgOrSelect(ctx context.Context, clusterID, orgSlug string) (*mp
}

cluster := &mpg.Cluster{
Id: c.Data.Id,
Name: c.Data.Name,
Region: c.Data.Region,
Status: c.Data.Status,
Plan: c.Data.Plan,
Disk: c.Data.Disk,
Replicas: c.Data.Replicas,
Organization: c.Data.Organization,
IpAssignments: c.Data.IpAssignments,
AttachedApps: c.Data.AttachedApps,
Version: version,
Id: c.Data.Id,
Name: c.Data.Name,
Region: c.Data.Region,
Status: c.Data.Status,
Plan: c.Data.Plan,
Disk: c.Data.Disk,
Replicas: c.Data.Replicas,
Organization: c.Data.Organization,
IpAssignments: c.Data.IpAssignments,
AttachedApps: c.Data.AttachedApps,
Version: version,
EligibleForV2Migration: c.Data.EligibleForV2Migration,
}

printV1MigrationLink(ctx, cluster, cluster.Organization.Slug)

return cluster, cluster.Organization.Slug, nil
}

Expand Down Expand Up @@ -131,6 +178,8 @@ func ClusterFromArgOrSelect(ctx context.Context, clusterID, orgSlug string) (*mp
return nil, orgSlug, err
}

printV1MigrationLink(ctx, clusters[index], orgSlug)

return clusters[index], orgSlug, nil
}

Expand Down Expand Up @@ -201,17 +250,18 @@ func clusterFromLegacyAPI(cluster mpgv1.ManagedCluster) *mpg.Cluster {
}

return &mpg.Cluster{
Id: cluster.Id,
Name: cluster.Name,
Region: cluster.Region,
Status: cluster.Status,
Plan: cluster.Plan,
Disk: cluster.Disk,
Replicas: cluster.Replicas,
Organization: cluster.Organization,
IpAssignments: cluster.IpAssignments,
AttachedApps: cluster.AttachedApps,
Version: version,
Id: cluster.Id,
Name: cluster.Name,
Region: cluster.Region,
Status: cluster.Status,
Plan: cluster.Plan,
Disk: cluster.Disk,
Replicas: cluster.Replicas,
Organization: cluster.Organization,
IpAssignments: cluster.IpAssignments,
AttachedApps: cluster.AttachedApps,
Version: version,
EligibleForV2Migration: cluster.EligibleForV2Migration,
}
}

Expand Down
95 changes: 95 additions & 0 deletions internal/command/mpg/notice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package mpg

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
fly "github.com/superfly/fly-go"
"github.com/superfly/flyctl/internal/uiex/mpg"
"github.com/superfly/flyctl/iostreams"
)

func TestPrintV2MigrationNotice(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ctx := iostreams.NewContext(context.Background(), ios)

printV2MigrationNotice(ctx)

assert.Contains(t, stderr.String(), "Managed Postgres v2 is here!")
assert.Contains(t, stderr.String(), dashboardURL)
assert.Empty(t, stdout.String())
}

func TestPrintV1MigrationLink(t *testing.T) {
t.Run("v1 cluster gets its migration link", func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
ctx := iostreams.NewContext(context.Background(), ios)

printV1MigrationLink(ctx, &mpg.Cluster{
Id: "abc123",
Name: "my-db",
Version: mpg.VersionV1,
Organization: fly.Organization{Slug: "acme"},
}, "")

assert.Contains(t, stderr.String(), `Cluster "my-db" is on MPG v1`)
assert.Contains(t, stderr.String(), "https://fly.io/dashboard/acme/managed_postgres/abc123/v2-migration")
})

t.Run("falls back to the provided org slug", func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
ctx := iostreams.NewContext(context.Background(), ios)

printV1MigrationLink(ctx, &mpg.Cluster{
Id: "abc123",
Name: "my-db",
Version: mpg.VersionV1,
}, "personal")

assert.Contains(t, stderr.String(), "https://fly.io/dashboard/personal/managed_postgres/abc123/v2-migration")
})

t.Run("v2 cluster prints nothing", func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
ctx := iostreams.NewContext(context.Background(), ios)

printV1MigrationLink(ctx, &mpg.Cluster{
Id: "abc123",
Name: "my-db",
Version: mpg.VersionV2,
}, "acme")

assert.Empty(t, stderr.String())
})

t.Run("ineligible v1 cluster prints nothing", func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
ctx := iostreams.NewContext(context.Background(), ios)
eligible := false

printV1MigrationLink(ctx, &mpg.Cluster{
Id: "abc123",
Name: "my-db",
Version: mpg.VersionV1,
EligibleForV2Migration: &eligible,
}, "acme")

assert.Empty(t, stderr.String())
})

t.Run("eligible v1 cluster gets its migration link", func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
ctx := iostreams.NewContext(context.Background(), ios)
eligible := true

printV1MigrationLink(ctx, &mpg.Cluster{
Id: "abc123",
Name: "my-db",
Version: mpg.VersionV1,
EligibleForV2Migration: &eligible,
}, "acme")

assert.Contains(t, stderr.String(), "https://fly.io/dashboard/acme/managed_postgres/abc123/v2-migration")
})
}
2 changes: 2 additions & 0 deletions internal/uiex/mpg/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Cluster struct {
IpAssignments ManagedClusterIpAssignments
AttachedApps []AttachedApp
Version Version

EligibleForV2Migration *bool // nil when the API did not report it
}

type ManagedClusterIpAssignments struct {
Expand Down
3 changes: 3 additions & 0 deletions internal/uiex/mpg/v1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ type ManagedCluster struct {
Organization fly.Organization `json:"organization"`
IpAssignments mpg.ManagedClusterIpAssignments `json:"ip_assignments"`
AttachedApps []mpg.AttachedApp `json:"attached_apps"`

// nil when the server did not return it (only the get-cluster endpoint does)
EligibleForV2Migration *bool `json:"eligible_for_v2_migration"`
}

type ListManagedClustersResponse struct {
Expand Down
11 changes: 11 additions & 0 deletions iostreams/iostreams.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,17 @@ func (s *IOStreams) CreateLink(text string, url string) string {
}
}

// CreateLinkURL renders url as a clickable hyperlink where supported. Unlike
// CreateLink it uses the URL itself as the link text, so unsupported
// terminals print the URL once rather than twice.
func (s *IOStreams) CreateLinkURL(url string) string {
if isTextClickable() {
return "\x1b]8;;" + url + "\x07" + url + "\x1b]8;;\x07"
}

return url
}

// writerWithFd implements a [terminal.FileWriter]
type writerWithFd struct {
io.Writer
Expand Down