Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/superfly/client-signals/go v0.4.4
github.com/superfly/fly-go v0.11.3
github.com/superfly/fly-go v0.11.5
github.com/superfly/graphql v0.2.6
github.com/superfly/macaroon v0.3.2
github.com/superfly/tokenizer v0.0.3-0.20240826174224-a17a2e0a9dc0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -662,8 +662,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/superfly/client-signals/go v0.4.4 h1:btAouksYdwOkVCIqI/JI09bt17A6iIVuwVJGWndfmc8=
github.com/superfly/client-signals/go v0.4.4/go.mod h1:FTpkC1/boj2Lez8w98k9Zs9ihtvz8a1VeGXDtaq7asY=
github.com/superfly/fly-go v0.11.3 h1:EFSOoyVAjO6bP2GVXssItA0voBcvjf47/jOUW6+SCfo=
github.com/superfly/fly-go v0.11.3/go.mod h1:88plLE+2q2fBSanY75Hxy6uoUomXzA5Z1RY398okErQ=
github.com/superfly/fly-go v0.11.5 h1:wn1vP8Zz3J9x1OEJG3IxIw213eq+4tBuR4tngj7atVo=
github.com/superfly/fly-go v0.11.5/go.mod h1:88plLE+2q2fBSanY75Hxy6uoUomXzA5Z1RY398okErQ=
github.com/superfly/graphql v0.2.6 h1:zppbodNerWecoXEdjkhrqaNaSjGqobhXNlViHFuZzb4=
github.com/superfly/graphql v0.2.6/go.mod h1:CVfDl31srm8HnJ9udwLu6hFNUW/P6GUM2dKcG1YQ8jc=
github.com/superfly/macaroon v0.3.2 h1:vrPnUZUsd0DkSO6ievTESgGE1B3LJE3trXnDYZU1KTc=
Expand Down
30 changes: 30 additions & 0 deletions internal/build/imgsrc/flaps_mock_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions internal/command/deploy/mock_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ func (m *mockFlapsClient) FindLease(ctx context.Context, appName, machineID stri
return nil, fmt.Errorf("failed to find lease for %s", machineID)
}

func (m *mockFlapsClient) ForkManagedPostgresCluster(ctx context.Context, id string, req flaps.ForkManagedPostgresClusterRequest) (flaps.ManagedPostgresCluster, error) {
panic("not implemented")
}

func (m *mockFlapsClient) GenerateSecretKey(ctx context.Context, appName, name, typ string) (*fly.SetSecretKeyResp, error) {

return nil, fmt.Errorf("failed to generate app secret %s", name)
Expand Down Expand Up @@ -492,6 +496,10 @@ func (m *mockFlapsClient) ListManagedPostgresExtensions(ctx context.Context, id,
return nil, fmt.Errorf("not implemented")
}

func (m *mockFlapsClient) ListManagedPostgresPlans(ctx context.Context, orgSlug string) ([]flaps.ManagedPostgresPlan, error) {
return nil, fmt.Errorf("not implemented")
}

func (m *mockFlapsClient) ListAppSecrets(ctx context.Context, appName string, version *uint64, showSecrets bool) ([]fly.AppSecret, error) {
return nil, fmt.Errorf("failed to list app secrets")
}
Expand Down
66 changes: 66 additions & 0 deletions internal/command/mpg/fork.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package mpg

import (
"context"
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/superfly/flyctl/internal/command"
cmdv2 "github.com/superfly/flyctl/internal/command/mpg/v2"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/uiex/mpg"
)

func newFork() *cobra.Command {
const (
long = `Fork a Managed Postgres cluster into a new cluster from the source's current
data, leaving the source cluster unchanged. The fork is provisioned
asynchronously in the same organization and billed separately.

The fork uses the source cluster's plan unless --plan picks a different one,
including a smaller one.`
short = "Fork MPG cluster into a new cluster from its current data."
usage = "fork [CLUSTER_ID]"
)

cmd := command.New(usage, short, long, runFork,
command.RequireSession,
requireMacaroonToken,
)

cmd.Args = cobra.MaximumNArgs(1)

flag.Add(cmd,
flag.String{
Name: "name",
Shorthand: "n",
Description: "The name of the forked cluster (defaults to <source-name>-fork)",
},
flag.String{
Name: "plan",
Description: "The plan for the forked cluster: " + planNames() + " (defaults to the source cluster's plan)",
},
)

return cmd
}

func runFork(ctx context.Context) error {
clusterID := flag.FirstArg(ctx)
name := flag.GetString(ctx, "name")
// The API is the source of truth for plans and matches them
// case-insensitively, so --plan is passed through rather than checked
// against MPGPlans. Leaving it empty keeps the source cluster's plan.
plan := strings.TrimSpace(flag.GetString(ctx, "plan"))

cluster, _, err := ClusterFromArgOrSelect(ctx, clusterID, "")
if err != nil {
return err
}
if cluster.Version == mpg.VersionV1 {
return fmt.Errorf("forking is not supported for this cluster")
}

return cmdv2.RunFork(ctx, cluster.Id, name, plan)
}
143 changes: 143 additions & 0 deletions internal/command/mpg/fork_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package mpg

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/internal/flag/flagctx"
"github.com/superfly/flyctl/internal/flapsutil"
"github.com/superfly/flyctl/internal/mock"
mpgv1 "github.com/superfly/flyctl/internal/uiex/mpg/v1"
)

func TestRunFork(t *testing.T) {
const clusterID = "cluster-123"

tests := []struct {
name string
clusterVersion int
args []string
wantErr string
wantFork bool
wantName string
wantPlan string
}{
{
name: "rejects v1 cluster before request",
clusterVersion: 1,
args: []string{clusterID, "--plan", "basic"},
wantErr: "forking is not supported for this cluster",
},
{
name: "sends no plan when --plan is omitted",
clusterVersion: 2,
args: []string{clusterID},
wantFork: true,
},
{
name: "passes name and plan through",
clusterVersion: 2,
args: []string{clusterID, "--name", "forked-db", "--plan", "starter"},
wantFork: true,
wantName: "forked-db",
wantPlan: "starter",
},
{
name: "accepts -n shorthand for name",
clusterVersion: 2,
args: []string{clusterID, "-n", "forked-db"},
wantFork: true,
wantName: "forked-db",
},
{
name: "trims plan whitespace",
clusterVersion: 2,
args: []string{clusterID, "--plan", " launch "},
wantFork: true,
wantPlan: "launch",
},
{
name: "blank plan keeps the source plan",
clusterVersion: 2,
args: []string{clusterID, "--plan", " "},
wantFork: true,
},
{
name: "does not validate plan against the local plan list",
clusterVersion: 2,
args: []string{clusterID, "--plan", "PERFORMANCE"},
wantFork: true,
wantPlan: "PERFORMANCE",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := setupTestContext()
cmd := newFork()
require.NoError(t, cmd.Flags().Parse(tt.args))
ctx = flagctx.NewContext(ctx, cmd.Flags())

forkCalled := false
v1Client := &mock.MpgV1Client{
GetManagedClusterByIdFunc: func(context.Context, string) (mpgv1.GetManagedClusterResponse, error) {
if tt.clusterVersion != 1 {
return mpgv1.GetManagedClusterResponse{}, errors.New("not found")
}

return mpgv1.GetManagedClusterResponse{Data: mpgv1.ManagedCluster{Id: clusterID}}, nil
},
}
publicClient := &mock.FlapsClient{
GetManagedPostgresClusterFunc: func(context.Context, string) (flaps.ManagedPostgresCluster, error) {
if tt.clusterVersion != 2 {
return flaps.ManagedPostgresCluster{}, flaps.ErrFlapsNotFound
}

return flaps.ManagedPostgresCluster{ID: clusterID, Name: "source-db"}, nil
},
ForkManagedPostgresClusterFunc: func(_ context.Context, gotClusterID string, req flaps.ForkManagedPostgresClusterRequest) (flaps.ManagedPostgresCluster, error) {
forkCalled = true
assert.Equal(t, clusterID, gotClusterID)
assert.Equal(t, tt.wantName, req.Name)
assert.Equal(t, tt.wantPlan, req.Plan)

return flaps.ManagedPostgresCluster{ID: "cluster-fork", Name: "source-db-fork", Plan: "basic"}, nil
},
}
ctx = mpgv1.NewContextWithClient(ctx, v1Client)
ctx = flapsutil.NewContextWithClient(ctx, publicClient)

err := runFork(ctx)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantFork, forkCalled)
})
}
}

func TestNewForkFlags(t *testing.T) {
cmd := newFork()

require.Error(t, cmd.Args(cmd, []string{"a", "b"}))
require.NoError(t, cmd.Args(cmd, []string{}))
require.NoError(t, cmd.Args(cmd, []string{"a"}))

name := cmd.Flags().Lookup("name")
require.NotNil(t, name)
assert.Equal(t, "n", name.Shorthand)
assert.Empty(t, name.DefValue)

plan := cmd.Flags().Lookup("plan")
require.NotNil(t, plan)
assert.Empty(t, plan.DefValue)
assert.Contains(t, plan.Usage, planNames())
assert.Contains(t, plan.Usage, "defaults to the source cluster's plan")
}
1 change: 1 addition & 0 deletions internal/command/mpg/mpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func New() *cobra.Command {
newDestroy(),
newBackup(),
newRestore(),
newFork(),
newDatabases(),
newUsers(),
)
Expand Down
38 changes: 38 additions & 0 deletions internal/command/mpg/v2/run_fork.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cmdv2

import (
"context"
"fmt"

"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/internal/flapsutil"
"github.com/superfly/flyctl/iostreams"
)

// RunFork forks clusterID into a new cluster. An empty name or plan is left
// out of the request so the API applies its defaults: the source's name with
// a -fork suffix, and the source cluster's plan.
func RunFork(ctx context.Context, clusterID, name, plan string) error {
out := iostreams.FromContext(ctx).Out

fmt.Fprintf(out, "Forking cluster %s...\n", clusterID)

request := flaps.ForkManagedPostgresClusterRequest{
Name: name,
Plan: plan,
}

response, err := flapsutil.ClientFromContext(ctx).ForkManagedPostgresCluster(ctx, clusterID, request)
if err != nil {
return fmt.Errorf("failed to fork cluster: %w", err)
}

fmt.Fprintf(out, "Fork initiated successfully!\n")
fmt.Fprintf(out, " Cluster ID: %s\n", response.ID)
fmt.Fprintf(out, " Cluster Name: %s\n", response.Name)
if response.Plan != "" {
fmt.Fprintf(out, " Plan: %s\n", response.Plan)
}

return nil
}
Loading
Loading