diff --git a/internal/command/mpg/list.go b/internal/command/mpg/list.go index 703c30431b..1a222d69dd 100644 --- a/internal/command/mpg/list.go +++ b/internal/command/mpg/list.go @@ -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) } diff --git a/internal/command/mpg/mpg.go b/internal/command/mpg/mpg.go index 1ffa9f3c65..524bd31d93 100644 --- a/internal/command/mpg/mpg.go +++ b/internal/command/mpg/mpg.go @@ -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) @@ -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. @@ -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 } @@ -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 } @@ -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, } } diff --git a/internal/command/mpg/notice_test.go b/internal/command/mpg/notice_test.go new file mode 100644 index 0000000000..401d7f3211 --- /dev/null +++ b/internal/command/mpg/notice_test.go @@ -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") + }) +} diff --git a/internal/uiex/mpg/types.go b/internal/uiex/mpg/types.go index 4eee701640..eabf5da0ea 100644 --- a/internal/uiex/mpg/types.go +++ b/internal/uiex/mpg/types.go @@ -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 { diff --git a/internal/uiex/mpg/v1/client.go b/internal/uiex/mpg/v1/client.go index c435f03070..c23e5e8111 100644 --- a/internal/uiex/mpg/v1/client.go +++ b/internal/uiex/mpg/v1/client.go @@ -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 { diff --git a/iostreams/iostreams.go b/iostreams/iostreams.go index a85b5e62bb..da5fb9c4df 100644 --- a/iostreams/iostreams.go +++ b/iostreams/iostreams.go @@ -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