From 39aa7755abf10cbfc39b3ebd9777a688b3b828b0 Mon Sep 17 00:00:00 2001 From: Philipp Winter Date: Mon, 13 Jul 2026 09:17:36 -0500 Subject: [PATCH] WIP: Add `--estimate` to select commands. This PR implement a cost preview for agents. The idea is to add an `--estimate` flag to select flyctl commands, allowing agents to learn what a command would cost before actually running it. An example: ``` flyctl m run alpine --vm-cpus 4 --vm-memory 512 --vm-cpu-kind performance --estimate ``` Under the hood, the `--estimate` flag makes flyctl talk to a new ui-ex endpoint to retrieve pricing information. --- internal/command/certificates/estimate.go | 33 ++++++ .../command/certificates/estimate_test.go | 50 +++++++++ internal/command/certificates/root.go | 36 +++++- internal/command/costestimate/costestimate.go | 104 ++++++++++++++++++ internal/command/ips/allocate.go | 23 ++++ internal/command/ips/estimate.go | 30 +++++ internal/command/ips/estimate_test.go | 50 +++++++++ internal/command/launch/plan/postgres_test.go | 4 + internal/command/machine/clone.go | 20 ++++ internal/command/machine/estimate.go | 80 ++++++++++++++ internal/command/machine/estimate_test.go | 65 +++++++++++ internal/command/machine/run.go | 41 +++++-- internal/command/machine/run_test.go | 55 +++++++++ internal/command/machine/update.go | 35 ++++-- internal/command/mpg/create.go | 7 ++ internal/command/mpg/estimate/estimate.go | 33 ++++++ .../command/mpg/estimate/estimate_test.go | 63 +++++++++++ internal/command/mpg/v1/run_create.go | 13 +++ internal/command/mpg/v2/run_create.go | 13 +++ internal/command/scale/count.go | 1 + internal/command/scale/count_machines.go | 53 +++++++++ internal/command/scale/estimate.go | 27 +++++ internal/command/scale/estimate_test.go | 51 +++++++++ internal/command/scale/machines.go | 42 +++++++ internal/command/scale/memory.go | 1 + internal/command/scale/vm.go | 4 + internal/command/volumes/create.go | 35 +++++- internal/command/volumes/destroy.go | 30 +++++ internal/command/volumes/estimate.go | 32 ++++++ internal/command/volumes/estimate_test.go | 78 +++++++++++++ internal/command/volumes/extend.go | 45 ++++++-- internal/command/volumes/fork.go | 30 +++++ internal/mock/uiex_client.go | 9 ++ internal/uiex/cost_estimates.go | 82 ++++++++++++++ internal/uiexutil/client.go | 3 + 35 files changed, 1247 insertions(+), 31 deletions(-) create mode 100644 internal/command/certificates/estimate.go create mode 100644 internal/command/certificates/estimate_test.go create mode 100644 internal/command/costestimate/costestimate.go create mode 100644 internal/command/ips/estimate.go create mode 100644 internal/command/ips/estimate_test.go create mode 100644 internal/command/machine/estimate.go create mode 100644 internal/command/machine/estimate_test.go create mode 100644 internal/command/machine/run_test.go create mode 100644 internal/command/mpg/estimate/estimate.go create mode 100644 internal/command/mpg/estimate/estimate_test.go create mode 100644 internal/command/scale/estimate.go create mode 100644 internal/command/scale/estimate_test.go create mode 100644 internal/command/volumes/estimate.go create mode 100644 internal/command/volumes/estimate_test.go create mode 100644 internal/uiex/cost_estimates.go diff --git a/internal/command/certificates/estimate.go b/internal/command/certificates/estimate.go new file mode 100644 index 0000000000..82de924c01 --- /dev/null +++ b/internal/command/certificates/estimate.go @@ -0,0 +1,33 @@ +package certificates + +import ( + "context" + + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/uiex" +) + +type certificateEstimateSpec struct { + Hostname string `json:"hostname"` +} + +func runCertificateEstimate(ctx context.Context, appName string, operation string, sourceCommand string, action string, hostname string) error { + change := uiex.CostEstimateChange{ + Kind: "certificate", + Action: action, + Ref: hostname, + Count: 1, + } + spec := certificateEstimateSpec{Hostname: hostname} + if action == "destroy" { + change.Current = spec + } else { + change.Desired = spec + } + + return costestimate.RunForApp(ctx, appName, costestimate.Input{ + Operation: operation, + Changes: []uiex.CostEstimateChange{change}, + SourceCommand: sourceCommand, + }) +} diff --git a/internal/command/certificates/estimate_test.go b/internal/command/certificates/estimate_test.go new file mode 100644 index 0000000000..308ab4407b --- /dev/null +++ b/internal/command/certificates/estimate_test.go @@ -0,0 +1,50 @@ +package certificates + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/mock" + "github.com/superfly/flyctl/internal/uiex" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +func TestRunCertificateEstimateBuildsRequest(t *testing.T) { + ios, _, out, _ := iostreams.Test() + ctx := iostreams.NewContext(context.Background(), ios) + ctx = flyutil.NewContextWithClient(ctx, &mock.Client{ + GetAppCompactFunc: func(ctx context.Context, appName string) (*fly.AppCompact, error) { + require.Equal(t, "test-app", appName) + return &fly.AppCompact{Organization: &fly.OrganizationBasic{Slug: "test-org"}}, nil + }, + }) + + var gotOrgSlug string + var gotReq uiex.CostEstimateRequest + ctx = uiexutil.NewContextWithClient(ctx, &mock.UiexClient{ + CreateCostEstimateFunc: func(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + gotOrgSlug = orgSlug + gotReq = in + + return &uiex.CostEstimateResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }, + }) + + err := runCertificateEstimate(ctx, "test-app", "certs.add", "fly certs add", "create", "*.example.com") + + require.NoError(t, err) + require.Equal(t, "test-org", gotOrgSlug) + require.Equal(t, "certs.add", gotReq.Operation) + require.Equal(t, "fly certs add", gotReq.Client.SourceCommand) + require.Len(t, gotReq.Changes, 1) + require.Equal(t, "certificate", gotReq.Changes[0].Kind) + require.Equal(t, "create", gotReq.Changes[0].Action) + require.Equal(t, "*.example.com", gotReq.Changes[0].Ref) + require.Equal(t, certificateEstimateSpec{Hostname: "*.example.com"}, gotReq.Changes[0].Desired) + require.JSONEq(t, `{"ok":true}`, out.String()) +} diff --git a/internal/command/certificates/root.go b/internal/command/certificates/root.go index 422a33eedd..97ca347ad4 100644 --- a/internal/command/certificates/root.go +++ b/internal/command/certificates/root.go @@ -76,6 +76,10 @@ as a parameter for the certificate.` flag.App(), flag.AppConfig(), flag.JSONOutput(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for adding the certificate and exit without creating anything", + }, ) cmd.Args = cobra.ExactArgs(1) cmd.Aliases = []string{"create"} @@ -107,10 +111,12 @@ ownership verification via DNS before the certificate becomes active.` Description: "Path to private key file (PEM format)", }, flag.JSONOutput(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for importing the certificate and exit without uploading anything", + }, ) cmd.Args = cobra.ExactArgs(1) - cmd.MarkFlagRequired("fullchain") - cmd.MarkFlagRequired("private-key") return cmd } @@ -120,8 +126,18 @@ func runCertificatesImport(ctx context.Context) error { appName := appconfig.NameFromContext(ctx) hostname := flag.FirstArg(ctx) + if flag.GetBool(ctx, "estimate") { + return runCertificateEstimate(ctx, appName, "certs.import", "fly certs import", "create", hostname) + } + fullchainPath := flag.GetString(ctx, "fullchain") privateKeyPath := flag.GetString(ctx, "private-key") + if fullchainPath == "" { + return fmt.Errorf("required flag \"fullchain\" not set") + } + if privateKeyPath == "" { + return fmt.Errorf("required flag \"private-key\" not set") + } fullchain, err := os.ReadFile(fullchainPath) if err != nil { @@ -217,6 +233,10 @@ Use --acme to stop ACME certificate issuance while keeping custom certificates.` Description: "Stop ACME certificate issuance, keeping custom certificates", Default: false, }, + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for removing the certificate and exit without deleting anything", + }, ) cmd.Args = cobra.ExactArgs(1) cmd.Aliases = []string{"delete"} @@ -360,6 +380,10 @@ func runCertificatesAdd(ctx context.Context) error { appName := appconfig.NameFromContext(ctx) hostname := flag.FirstArg(ctx) + if flag.GetBool(ctx, "estimate") { + return runCertificateEstimate(ctx, appName, "certs.add", "fly certs add", "create", hostname) + } + resp, err := flapsClient.CreateACMECertificate(ctx, appName, fly.CreateCertificateRequest{ Hostname: hostname, }) @@ -458,6 +482,14 @@ func runCertificatesRemove(ctx context.Context) error { return fmt.Errorf("cannot specify both --custom and --acme") } + if flag.GetBool(ctx, "estimate") { + if customOnly || acmeOnly { + return fmt.Errorf("cost estimates are only available for removing the full certificate, not --custom or --acme partial removal") + } + + return runCertificateEstimate(ctx, appName, "certs.remove", "fly certs remove", "destroy", hostname) + } + if !flag.GetYes(ctx) { var message string if customOnly { diff --git a/internal/command/costestimate/costestimate.go b/internal/command/costestimate/costestimate.go new file mode 100644 index 0000000000..7ca24c0d32 --- /dev/null +++ b/internal/command/costestimate/costestimate.go @@ -0,0 +1,104 @@ +package costestimate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/buildinfo" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/uiex" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +type Input struct { + Operation string + SourceCommand string + Changes []uiex.CostEstimateChange +} + +func RunForApp(ctx context.Context, appName string, input Input) error { + client := flyutil.ClientFromContext(ctx) + if client == nil { + return fmt.Errorf("can't estimate cost without an API client") + } + + app, err := client.GetAppCompact(ctx, appName) + if err != nil { + return fmt.Errorf("failed fetching app: %w", err) + } + + return RunForOrg(ctx, app.Organization, input) +} + +func RunForOrg(ctx context.Context, org *fly.OrganizationBasic, input Input) error { + if org == nil || org.Slug == "" { + return fmt.Errorf("can't estimate cost without an app organization") + } + + orgSlug, err := ResolveOrgSlug(ctx, org) + if err != nil { + return err + } + + return RunForOrgSlug(ctx, orgSlug, input) +} + +func RunForOrgSlug(ctx context.Context, orgSlug string, input Input) error { + req := uiex.CostEstimateRequest{ + SchemaVersion: 1, + Operation: input.Operation, + Currency: "USD", + Changes: input.Changes, + Client: &uiex.CostEstimateClient{ + Name: "flyctl", + Version: buildinfo.Version().String(), + SourceCommand: input.SourceCommand, + }, + } + + client := uiexutil.ClientFromContext(ctx) + if client == nil { + return fmt.Errorf("can't estimate cost without a ui-ex client") + } + + resp, err := client.CreateCostEstimate(ctx, orgSlug, req) + if err != nil { + return err + } + + var out bytes.Buffer + if err := json.Indent(&out, resp.Data, "", " "); err != nil { + return fmt.Errorf("failed to format cost estimate response: %w", err) + } + out.WriteByte('\n') + _, err = iostreams.FromContext(ctx).Out.Write(out.Bytes()) + return err +} + +func ResolveOrgSlug(ctx context.Context, org *fly.OrganizationBasic) (string, error) { + if org.Slug != "personal" { + return org.Slug, nil + } + if org.RawSlug != "" { + return org.RawSlug, nil + } + + client := flyutil.ClientFromContext(ctx) + if client == nil { + return "", fmt.Errorf("can't resolve personal organization slug without an API client") + } + + fullOrg, err := client.GetOrganizationBySlug(ctx, org.Slug) + if err != nil { + return "", fmt.Errorf("failed fetching org: %w", err) + } + if fullOrg.RawSlug == "" { + return "", fmt.Errorf("personal organization is missing raw slug") + } + + return fullOrg.RawSlug, nil +} diff --git a/internal/command/ips/allocate.go b/internal/command/ips/allocate.go index eee9d7e258..a46feb14a7 100644 --- a/internal/command/ips/allocate.go +++ b/internal/command/ips/allocate.go @@ -57,6 +57,10 @@ func newAllocatev4() *cobra.Command { flag.App(), flag.AppConfig(), flag.Region(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the IPv4 allocation and exit without allocating anything", + }, ) return cmd @@ -106,6 +110,10 @@ func newAllocateEgress() *cobra.Command { flag.AppConfig(), flag.Region(), flag.Yes(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for app-scoped egress IP allocation and exit without allocating anything", + }, ) return cmd @@ -115,6 +123,8 @@ func runAllocateIPAddressV4(ctx context.Context) error { addrType := "v4" if flag.GetBool(ctx, "shared") { addrType = "shared_v4" + } else if flag.GetBool(ctx, "estimate") { + return runAllocateIPAddress(ctx, addrType, nil, "") } else if !flag.GetBool(ctx, "yes") { msg := `Looks like you're accessing a paid feature. Dedicated IPv4 addresses now cost $2/mo. Are you ok with this? Alternatively, you could allocate a shared IPv4 address with the --shared flag.` @@ -153,6 +163,15 @@ func runAllocateIPAddress(ctx context.Context, addrType string, org *fly.Organiz appName := appconfig.NameFromContext(ctx) + if flag.GetBool(ctx, "estimate") { + switch addrType { + case "v4": + return runIPEstimate(ctx, appName, "ip.allocate-v4", "fly ips allocate-v4", ipEstimateSpec{Family: "v4", Type: "dedicated", Region: flag.GetRegion(ctx)}) + case "shared_v4", "v6", "private_v6": + return fmt.Errorf("cost estimates are only available for dedicated IPv4 and app-scoped egress IPv4 allocations") + } + } + if addrType == "shared_v4" { ip, err := client.AllocateSharedIPAddress(ctx, appName) if err != nil { @@ -191,6 +210,10 @@ func runAllocateEgressIPAddresses(ctx context.Context) (err error) { return fmt.Errorf("a region must be provided when allocating an app-scoped egress IP address") } + if flag.GetBool(ctx, "estimate") { + return runIPEstimate(ctx, appName, "ip.allocate-egress", "fly ips allocate-egress", ipEstimateSpec{Family: "v4", Type: "egress", Region: region}) + } + if !flag.GetBool(ctx, "yes") { msg := `You are allocating an egress IP address. This type of IPs are used when your machine accesses an external resource, and cannot be used to access your app. If you don't know what this is, you probably want to allocate an Anycast ingress IP using allocate-v4 or allocate-v6 instead. diff --git a/internal/command/ips/estimate.go b/internal/command/ips/estimate.go new file mode 100644 index 0000000000..30c020a381 --- /dev/null +++ b/internal/command/ips/estimate.go @@ -0,0 +1,30 @@ +package ips + +import ( + "context" + + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/uiex" +) + +type ipEstimateSpec struct { + Family string `json:"family"` + Type string `json:"type,omitempty"` + Region string `json:"region,omitempty"` +} + +func runIPEstimate(ctx context.Context, appName string, operation string, sourceCommand string, desired ipEstimateSpec) error { + return costestimate.RunForApp(ctx, appName, costestimate.Input{ + Operation: operation, + Changes: []uiex.CostEstimateChange{ + { + Kind: "ip", + Action: "allocate", + Ref: desired.Type, + Count: 1, + Desired: desired, + }, + }, + SourceCommand: sourceCommand, + }) +} diff --git a/internal/command/ips/estimate_test.go b/internal/command/ips/estimate_test.go new file mode 100644 index 0000000000..0baf5096a8 --- /dev/null +++ b/internal/command/ips/estimate_test.go @@ -0,0 +1,50 @@ +package ips + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/mock" + "github.com/superfly/flyctl/internal/uiex" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +func TestRunIPEstimateBuildsRequest(t *testing.T) { + ios, _, out, _ := iostreams.Test() + ctx := iostreams.NewContext(context.Background(), ios) + ctx = flyutil.NewContextWithClient(ctx, &mock.Client{ + GetAppCompactFunc: func(ctx context.Context, appName string) (*fly.AppCompact, error) { + require.Equal(t, "test-app", appName) + return &fly.AppCompact{Organization: &fly.OrganizationBasic{Slug: "test-org"}}, nil + }, + }) + + var gotOrgSlug string + var gotReq uiex.CostEstimateRequest + ctx = uiexutil.NewContextWithClient(ctx, &mock.UiexClient{ + CreateCostEstimateFunc: func(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + gotOrgSlug = orgSlug + gotReq = in + + return &uiex.CostEstimateResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }, + }) + + err := runIPEstimate(ctx, "test-app", "ip.allocate-egress", "fly ips allocate-egress", ipEstimateSpec{Family: "v4", Type: "egress", Region: "iad"}) + + require.NoError(t, err) + require.Equal(t, "test-org", gotOrgSlug) + require.Equal(t, "ip.allocate-egress", gotReq.Operation) + require.Equal(t, "fly ips allocate-egress", gotReq.Client.SourceCommand) + require.Len(t, gotReq.Changes, 1) + require.Equal(t, "ip", gotReq.Changes[0].Kind) + require.Equal(t, "allocate", gotReq.Changes[0].Action) + require.Equal(t, "egress", gotReq.Changes[0].Ref) + require.Equal(t, ipEstimateSpec{Family: "v4", Type: "egress", Region: "iad"}, gotReq.Changes[0].Desired) + require.JSONEq(t, `{"ok":true}`, out.String()) +} diff --git a/internal/command/launch/plan/postgres_test.go b/internal/command/launch/plan/postgres_test.go index e0e2a1a111..a871c7bfce 100644 --- a/internal/command/launch/plan/postgres_test.go +++ b/internal/command/launch/plan/postgres_test.go @@ -75,6 +75,10 @@ func (m *mockUIEXClient) CreateFlyManagedBuilder(ctx context.Context, orgSlug st return uiex.CreateFlyManagedBuilderResponse{}, nil } +func (m *mockUIEXClient) CreateCostEstimate(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + return &uiex.CostEstimateResponse{}, nil +} + func (m *mockUIEXClient) GetAllAppsCurrentReleaseTimestamps(ctx context.Context) (*map[string]time.Time, error) { return &map[string]time.Time{}, nil } diff --git a/internal/command/machine/clone.go b/internal/command/machine/clone.go index 72de357514..535392b31a 100644 --- a/internal/command/machine/clone.go +++ b/internal/command/machine/clone.go @@ -77,6 +77,11 @@ func newClone() *cobra.Command { Default: true, }, flag.Detach(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the cloned Machine and exit without creating anything", + Default: false, + }, flag.VMSizeFlags, ) @@ -180,6 +185,21 @@ func runMachineClone(ctx context.Context) (err error) { } } + if flag.GetBool(ctx, "estimate") { + app, err := estimateApp(ctx, appName) + if err != nil { + return err + } + + return runMachineChangeEstimate(ctx, app, machineEstimateInput{ + Operation: "machine.clone", + SourceCommand: "fly machine clone", + Action: "clone", + Desired: fly.LaunchMachineInput{Name: flag.GetString(ctx, "name"), Region: region, Config: targetConfig}, + RunningSeconds: 3600, + }) + } + for _, mnt := range source.Config.Mounts { var vol *fly.Volume if volID != "" { diff --git a/internal/command/machine/estimate.go b/internal/command/machine/estimate.go new file mode 100644 index 0000000000..c2e740d0b5 --- /dev/null +++ b/internal/command/machine/estimate.go @@ -0,0 +1,80 @@ +package machine + +import ( + "context" + "fmt" + + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/uiex" +) + +// runMachineEstimate prices the Machine that `machine run`/`machine create` +// would create and prints a JSON cost estimate. It runs after flyctl resolves +// the app and Machine config, but before any Machine API writes. +func runMachineEstimate(ctx context.Context, app *fly.AppCompact, input fly.LaunchMachineInput, isCreate bool) error { + operation := "machine.run" + sourceCommand := "fly machine run" + runningSeconds := 3600 + action := "create" + if isCreate { + operation = "machine.create" + sourceCommand = "fly machine create" + runningSeconds = 0 + } + + return runMachineChangeEstimate(ctx, app, machineEstimateInput{ + Operation: operation, + SourceCommand: sourceCommand, + Action: action, + Desired: input, + RunningSeconds: runningSeconds, + }) +} + +type machineEstimateInput struct { + Operation string + SourceCommand string + Action string + Current any + Desired any + RunningSeconds int +} + +func runMachineChangeEstimate(ctx context.Context, app *fly.AppCompact, input machineEstimateInput) error { + usage := map[string]any{} + if input.RunningSeconds > 0 { + usage["running_seconds"] = input.RunningSeconds + } + + return costestimate.RunForOrg(ctx, app.Organization, costestimate.Input{ + Operation: input.Operation, + Changes: []uiex.CostEstimateChange{ + { + Kind: "machine", + Action: input.Action, + Ref: "machine", + Count: 1, + Current: input.Current, + Desired: input.Desired, + Usage: usage, + }, + }, + SourceCommand: input.SourceCommand, + }) +} + +func estimateApp(ctx context.Context, appName string) (*fly.AppCompact, error) { + client := flyutil.ClientFromContext(ctx) + if client == nil { + return nil, fmt.Errorf("can't estimate cost without an API client") + } + + app, err := client.GetAppCompact(ctx, appName) + if err != nil { + return nil, fmt.Errorf("failed fetching app: %w", err) + } + + return app, nil +} diff --git a/internal/command/machine/estimate_test.go b/internal/command/machine/estimate_test.go new file mode 100644 index 0000000000..10d5840422 --- /dev/null +++ b/internal/command/machine/estimate_test.go @@ -0,0 +1,65 @@ +package machine + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/mock" + "github.com/superfly/flyctl/internal/uiex" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +func TestRunMachineChangeEstimateBuildsUpdateRequest(t *testing.T) { + ios, _, out, _ := iostreams.Test() + ctx := iostreams.NewContext(context.Background(), ios) + + var gotOrgSlug string + var gotReq uiex.CostEstimateRequest + ctx = uiexutil.NewContextWithClient(ctx, &mock.UiexClient{ + CreateCostEstimateFunc: func(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + gotOrgSlug = orgSlug + gotReq = in + + return &uiex.CostEstimateResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }, + }) + + app := &fly.AppCompact{ + Organization: &fly.OrganizationBasic{Slug: "test-org"}, + } + current := &fly.Machine{ + ID: "machine-id", + Region: "iad", + Config: &fly.MachineConfig{Guest: &fly.MachineGuest{CPUKind: "shared", CPUs: 1, MemoryMB: 256}}, + } + desired := fly.LaunchMachineInput{ + Name: "machine-name", + Region: "iad", + Config: &fly.MachineConfig{Guest: &fly.MachineGuest{CPUKind: "performance", CPUs: 2, MemoryMB: 4096}}, + } + + err := runMachineChangeEstimate(ctx, app, machineEstimateInput{ + Operation: "machine.update", + SourceCommand: "fly machine update", + Action: "update", + Current: current, + Desired: desired, + RunningSeconds: 3600, + }) + + require.NoError(t, err) + require.Equal(t, "test-org", gotOrgSlug) + require.Equal(t, "machine.update", gotReq.Operation) + require.Equal(t, "fly machine update", gotReq.Client.SourceCommand) + require.Len(t, gotReq.Changes, 1) + require.Equal(t, "machine", gotReq.Changes[0].Kind) + require.Equal(t, "update", gotReq.Changes[0].Action) + require.Equal(t, current, gotReq.Changes[0].Current) + require.Equal(t, desired, gotReq.Changes[0].Desired) + require.Equal(t, 3600, gotReq.Changes[0].Usage["running_seconds"]) + require.JSONEq(t, `{"ok":true}`, out.String()) +} diff --git a/internal/command/machine/run.go b/internal/command/machine/run.go index 1052c31ebb..38aa6985f6 100644 --- a/internal/command/machine/run.go +++ b/internal/command/machine/run.go @@ -200,6 +200,10 @@ var runOrCreateFlags = flag.Set{ Name: "use-zstd", Description: "Enable zstd compression for the image", }, + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the Machine and exit without creating anything", + }, } func soManyErrors(args ...any) error { @@ -441,6 +445,10 @@ func runMachineRun(ctx context.Context) error { input.SkipLaunch = (len(machineConf.Standbys) > 0 || isCreate) input.Config = machineConf + if flag.GetBool(ctx, "estimate") { + return runMachineEstimate(ctx, app, input, isCreate) + } + machine, err := flapsClient.Launch(ctx, app.Name, input) if err != nil { return fmt.Errorf("could not launch machine: %w", err) @@ -663,16 +671,35 @@ type determineMachineConfigInput struct { interact bool } +// resolveMachineGuest resolves the guest spec for a Machine, mutating +// machineConf in place: --machine-config (if given) is parsed into it first, +// then the individual --vm-* flags are applied on top. It is shared by +// determineMachineConfig and `--estimate` so an estimate always prices the +// guest the command would create. +func resolveMachineGuest(ctx context.Context, machineConf *fly.MachineConfig) error { + if mc := flag.GetString(ctx, "machine-config"); mc != "" { + if err := config.ParseConfig(machineConf, mc); err != nil { + return err + } + } + + guest, err := flag.GetMachineGuest(ctx, machineConf.Guest) + if err != nil { + return err + } + machineConf.Guest = guest + + return nil +} + func determineMachineConfig( ctx context.Context, input *determineMachineConfigInput, ) (*fly.MachineConfig, error) { machineConf := mach.CloneConfig(&input.initialMachineConf) - if mc := flag.GetString(ctx, "machine-config"); mc != "" { - if err := config.ParseConfig(machineConf, mc); err != nil { - return nil, err - } + if err := resolveMachineGuest(ctx, machineConf); err != nil { + return nil, err } // identify the container to use @@ -701,12 +728,6 @@ func determineMachineConfig( } } - var err error - machineConf.Guest, err = flag.GetMachineGuest(ctx, machineConf.Guest) - if err != nil { - return nil, err - } - if len(flag.GetStringArray(ctx, "kernel-arg")) != 0 { machineConf.Guest.KernelArgs = flag.GetStringArray(ctx, "kernel-arg") } diff --git a/internal/command/machine/run_test.go b/internal/command/machine/run_test.go new file mode 100644 index 0000000000..1183ad9456 --- /dev/null +++ b/internal/command/machine/run_test.go @@ -0,0 +1,55 @@ +package machine + +import ( + "context" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/flag" +) + +// guestFlagContext builds a context whose flag set contains the guest-related +// flags of `machine run`, parsed from args. +func guestFlagContext(t *testing.T, args ...string) context.Context { + t.Helper() + + cmd := &cobra.Command{} + flag.Add(cmd, flag.VMSizeFlags, flag.String{Name: "machine-config"}) + require.NoError(t, cmd.ParseFlags(args)) + + return flag.NewContext(context.Background(), cmd.Flags()) +} + +func TestResolveMachineGuest(t *testing.T) { + t.Run("defaults to shared-cpu-1x", func(t *testing.T) { + conf := &fly.MachineConfig{} + require.NoError(t, resolveMachineGuest(guestFlagContext(t), conf)) + assert.Equal(t, "shared", conf.Guest.CPUKind) + assert.Equal(t, 1, conf.Guest.CPUs) + }) + + t.Run("guest from --machine-config", func(t *testing.T) { + conf := &fly.MachineConfig{} + ctx := guestFlagContext(t, + "--machine-config", `{"guest":{"cpu_kind":"performance","cpus":2,"memory_mb":4096}}`, + ) + require.NoError(t, resolveMachineGuest(ctx, conf)) + assert.Equal(t, "performance", conf.Guest.CPUKind) + assert.Equal(t, 2, conf.Guest.CPUs) + assert.Equal(t, 4096, conf.Guest.MemoryMB) + }) + + t.Run("--vm-* flags override --machine-config", func(t *testing.T) { + conf := &fly.MachineConfig{} + ctx := guestFlagContext(t, + "--machine-config", `{"guest":{"cpu_kind":"performance","cpus":2,"memory_mb":4096}}`, + "--vm-memory", "8192", + ) + require.NoError(t, resolveMachineGuest(ctx, conf)) + assert.Equal(t, "performance", conf.Guest.CPUKind) + assert.Equal(t, 8192, conf.Guest.MemoryMB) + }) +} diff --git a/internal/command/machine/update.go b/internal/command/machine/update.go index c912e75745..46e89d5211 100644 --- a/internal/command/machine/update.go +++ b/internal/command/machine/update.go @@ -47,6 +47,11 @@ func newUpdate() *cobra.Command { Description: "Updates machine without waiting for health checks.", Default: false, }, + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the Machine update and exit without updating anything", + Default: false, + }, flag.String{ Name: "command", Shorthand: "C", @@ -98,13 +103,6 @@ func runUpdate(ctx context.Context) (err error) { return fmt.Errorf("the machine is on an unreachable host, try again later") } - // Acquire lease - machine, releaseLeaseFunc, err := mach.AcquireLease(ctx, appName, machine) - defer releaseLeaseFunc() - if err != nil { - return err - } - var imageOrPath string if image != "" { imageOrPath = image @@ -131,6 +129,22 @@ func runUpdate(ctx context.Context) (err error) { machineConf.Mounts[0].Path = mp } + if flag.GetBool(ctx, "estimate") { + app, err := estimateApp(ctx, appName) + if err != nil { + return err + } + + return runMachineChangeEstimate(ctx, app, machineEstimateInput{ + Operation: "machine.update", + SourceCommand: "fly machine update", + Action: "update", + Current: machine, + Desired: fly.LaunchMachineInput{Name: machine.Name, Region: machine.Region, Config: machineConf}, + RunningSeconds: 3600, + }) + } + // Prompt user to confirm changes if !autoConfirm { confirmed, err := mach.ConfirmConfigChanges(ctx, machine, *machineConf, "") @@ -149,6 +163,13 @@ func runUpdate(ctx context.Context) (err error) { return err } + // Acquire lease + machine, releaseLeaseFunc, err := mach.AcquireLease(ctx, appName, machine) + if err != nil { + return err + } + defer releaseLeaseFunc() + // Perform update input := &fly.LaunchMachineInput{ Name: machine.Name, diff --git a/internal/command/mpg/create.go b/internal/command/mpg/create.go index 28e36a7022..0c8d968246 100644 --- a/internal/command/mpg/create.go +++ b/internal/command/mpg/create.go @@ -65,6 +65,11 @@ func newCreate() *cobra.Command { Description: "The major version of Postgres to use for the Postgres cluster. Supported versions are 16 and 17.", Default: 16, }, + flag.Bool{ + Name: "estimate", + Description: "Estimate the cost of the Managed Postgres cluster without creating it", + Default: false, + }, ) return cmd @@ -159,6 +164,7 @@ func runCreate(ctx context.Context) error { PGMajorVersion: pgMajorVersion, StorageInGb: flag.GetInt(ctx, "volume-size"), PostGISEnabled: flag.GetBool(ctx, "enable-postgis-support"), + Estimate: flag.GetBool(ctx, "estimate"), } planDetails := MPGPlans[plan] @@ -178,6 +184,7 @@ func runCreate(ctx context.Context) error { VolumeSizeGB: flag.GetInt(ctx, "volume-size"), PostGISEnabled: flag.GetBool(ctx, "enable-postgis-support"), PGMajorVersion: pgMajorVersion, + Estimate: flag.GetBool(ctx, "estimate"), } planDetails := MPGPlans[plan] diff --git a/internal/command/mpg/estimate/estimate.go b/internal/command/mpg/estimate/estimate.go new file mode 100644 index 0000000000..cad2b5db06 --- /dev/null +++ b/internal/command/mpg/estimate/estimate.go @@ -0,0 +1,33 @@ +package estimate + +import ( + "context" + + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/uiex" +) + +type CreateInput struct { + Name string `json:"name,omitempty"` + Plan string `json:"plan"` + Region string `json:"region"` + StorageGB int `json:"storage_gb,omitempty"` + PGMajorVersion int `json:"pg_major_version,omitempty"` + PostGISEnabled bool `json:"postgis_enabled,omitempty"` +} + +func RunCreate(ctx context.Context, orgSlug string, input CreateInput) error { + return costestimate.RunForOrgSlug(ctx, orgSlug, costestimate.Input{ + Operation: "mpg.create", + Changes: []uiex.CostEstimateChange{ + { + Kind: "mpg", + Action: "create", + Ref: "postgres", + Count: 1, + Desired: input, + }, + }, + SourceCommand: "fly mpg create", + }) +} diff --git a/internal/command/mpg/estimate/estimate_test.go b/internal/command/mpg/estimate/estimate_test.go new file mode 100644 index 0000000000..0d6c5d68ed --- /dev/null +++ b/internal/command/mpg/estimate/estimate_test.go @@ -0,0 +1,63 @@ +package estimate + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/superfly/flyctl/internal/mock" + "github.com/superfly/flyctl/internal/uiex" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +func TestRunCreateBuildsCostEstimateRequest(t *testing.T) { + ios, _, out, _ := iostreams.Test() + ctx := iostreams.NewContext(context.Background(), ios) + + var gotOrgSlug string + var gotReq uiex.CostEstimateRequest + ctx = uiexutil.NewContextWithClient(ctx, &mock.UiexClient{ + CreateCostEstimateFunc: func(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + gotOrgSlug = orgSlug + gotReq = in + + return &uiex.CostEstimateResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }, + }) + + err := RunCreate(ctx, "personal-raw", CreateInput{ + Name: "my-db", + Plan: "starter", + Region: "iad", + StorageGB: 10, + PGMajorVersion: 17, + PostGISEnabled: true, + }) + + require.NoError(t, err) + require.Equal(t, "personal-raw", gotOrgSlug) + require.Equal(t, 1, gotReq.SchemaVersion) + require.Equal(t, "mpg.create", gotReq.Operation) + require.Equal(t, "USD", gotReq.Currency) + require.Len(t, gotReq.Changes, 1) + require.Equal(t, "mpg", gotReq.Changes[0].Kind) + require.Equal(t, "create", gotReq.Changes[0].Action) + require.Equal(t, "postgres", gotReq.Changes[0].Ref) + require.Equal(t, 1, gotReq.Changes[0].Count) + require.Equal(t, "flyctl", gotReq.Client.Name) + require.Equal(t, "fly mpg create", gotReq.Client.SourceCommand) + require.JSONEq(t, `{"ok":true}`, out.String()) + + desiredJSON, err := json.Marshal(gotReq.Changes[0].Desired) + require.NoError(t, err) + require.JSONEq(t, `{ + "name":"my-db", + "plan":"starter", + "region":"iad", + "storage_gb":10, + "pg_major_version":17, + "postgis_enabled":true + }`, string(desiredJSON)) +} diff --git a/internal/command/mpg/v1/run_create.go b/internal/command/mpg/v1/run_create.go index 5abd7b4cdc..445b0e8fa5 100644 --- a/internal/command/mpg/v1/run_create.go +++ b/internal/command/mpg/v1/run_create.go @@ -7,6 +7,7 @@ import ( "time" "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command/mpg/estimate" regionsv1 "github.com/superfly/flyctl/internal/command/mpg/v1/regions" "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/prompt" @@ -22,6 +23,7 @@ type CreateClusterParams struct { VolumeSizeGB int PostGISEnabled bool PGMajorVersion int + Estimate bool } type CreatePlanDisplay struct { @@ -88,6 +90,17 @@ func RunCreate(ctx context.Context, orgRawSlug string, params *CreateClusterPara PGMajorVersion: strconv.Itoa(params.PGMajorVersion), } + if params.Estimate { + return estimate.RunCreate(ctx, params.OrgSlug, estimate.CreateInput{ + Name: params.Name, + Plan: params.Plan, + Region: selectedRegion.Code, + StorageGB: params.VolumeSizeGB, + PGMajorVersion: params.PGMajorVersion, + PostGISEnabled: params.PostGISEnabled, + }) + } + response, err := mpgClient.CreateCluster(ctx, input) if err != nil { return fmt.Errorf("failed creating managed postgres cluster: %w", err) diff --git a/internal/command/mpg/v2/run_create.go b/internal/command/mpg/v2/run_create.go index 293f039bab..e231be5024 100644 --- a/internal/command/mpg/v2/run_create.go +++ b/internal/command/mpg/v2/run_create.go @@ -7,6 +7,7 @@ import ( "time" "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command/mpg/estimate" regionsv2 "github.com/superfly/flyctl/internal/command/mpg/v2/regions" "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/prompt" @@ -21,6 +22,7 @@ type CreateClusterParams struct { StorageInGb int PGMajorVersion int PostGISEnabled bool + Estimate bool } type CreatePlanDisplay struct { @@ -87,6 +89,17 @@ func RunCreate(ctx context.Context, orgRawSlug string, params *CreateClusterPara PGMajorVersion: strconv.Itoa(params.PGMajorVersion), } + if params.Estimate { + return estimate.RunCreate(ctx, params.OrgSlug, estimate.CreateInput{ + Name: params.Name, + Plan: params.Plan, + Region: selectedRegion.Code, + StorageGB: params.StorageInGb, + PGMajorVersion: params.PGMajorVersion, + PostGISEnabled: params.PostGISEnabled, + }) + } + response, err := mpgClient.CreateCluster(ctx, input) if err != nil { return fmt.Errorf("failed creating managed postgres cluster: %w", err) diff --git a/internal/command/scale/count.go b/internal/command/scale/count.go index 5aaeab5f68..6c62529834 100644 --- a/internal/command/scale/count.go +++ b/internal/command/scale/count.go @@ -38,6 +38,7 @@ For pricing, see https://fly.io/docs/about/pricing/` flag.String{Name: "region", Shorthand: "r", Description: "Comma separated list of regions to act on. Defaults to all regions where there is at least one machine running for the app", CompletionFn: completion.CompleteRegions}, flag.Bool{Name: "with-new-volumes", Description: "New machines each get a new volumes even if there are unattached volumes available"}, flag.String{Name: "from-snapshot", Description: "New volumes are restored from snapshot, use 'last' for most recent snapshot. The default is an empty volume"}, + flag.Bool{Name: "estimate", Description: "Print a JSON cost estimate for the scale count change and exit without changing anything"}, flag.VMSizeFlags, flag.Env(), ) diff --git a/internal/command/scale/count_machines.go b/internal/command/scale/count_machines.go index fd8116f1a6..3a3099be89 100644 --- a/internal/command/scale/count_machines.go +++ b/internal/command/scale/count_machines.go @@ -19,6 +19,7 @@ import ( "github.com/superfly/flyctl/internal/flyutil" mach "github.com/superfly/flyctl/internal/machine" "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/internal/uiex" "github.com/superfly/flyctl/iostreams" ) @@ -96,6 +97,14 @@ func runMachinesScaleCount(ctx context.Context, appName string, appConfig *appco return nil } + if flag.GetBool(ctx, "estimate") { + return runScaleEstimate(ctx, appName, scaleEstimateInput{ + Operation: "scale.count", + SourceCommand: "fly scale count", + Changes: scaleCountEstimateChanges(actions), + }) + } + fmt.Fprintf(io.Out, "App '%s' is going to be scaled according to this plan:\n", appName) for _, action := range actions { @@ -242,6 +251,50 @@ func destroyMachine(ctx context.Context, appName string, machine *fly.Machine) e return flapsClient.Destroy(ctx, appName, input, machine.LeaseNonce) } +func scaleCountEstimateChanges(actions []*planItem) []uiex.CostEstimateChange { + changes := []uiex.CostEstimateChange{} + + for _, action := range actions { + switch { + case action.Delta > 0: + changes = append(changes, uiex.CostEstimateChange{ + Kind: "machine", + Action: "create", + Ref: fmt.Sprintf("%s:%s", action.GroupName, action.Region), + Count: action.Delta, + Desired: action.LaunchMachineInput, + }) + + if action.CreateVolumeRequest != nil && action.CreateVolumeRequest.SizeGb != nil && action.VolumesDelta() > 0 { + changes = append(changes, uiex.CostEstimateChange{ + Kind: "volume", + Action: "create", + Ref: fmt.Sprintf("%s:%s:volume", action.GroupName, action.Region), + Count: action.VolumesDelta(), + Desired: scaleVolumeSpec{ + Region: action.CreateVolumeRequest.Region, + SizeGB: *action.CreateVolumeRequest.SizeGb, + }, + }) + } + + case action.Delta < 0: + for i := 0; i > action.Delta; i-- { + m := action.Machines[-i] + changes = append(changes, uiex.CostEstimateChange{ + Kind: "machine", + Action: "destroy", + Ref: m.ID, + Count: 1, + Current: m, + }) + } + } + } + + return changes +} + type planItem struct { GroupName string Region string diff --git a/internal/command/scale/estimate.go b/internal/command/scale/estimate.go new file mode 100644 index 0000000000..e2dc0c7932 --- /dev/null +++ b/internal/command/scale/estimate.go @@ -0,0 +1,27 @@ +package scale + +import ( + "context" + + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/uiex" +) + +type scaleEstimateInput struct { + Operation string + SourceCommand string + Changes []uiex.CostEstimateChange +} + +type scaleVolumeSpec struct { + Region string `json:"region,omitempty"` + SizeGB int `json:"size_gb"` +} + +func runScaleEstimate(ctx context.Context, appName string, input scaleEstimateInput) error { + return costestimate.RunForApp(ctx, appName, costestimate.Input{ + Operation: input.Operation, + Changes: input.Changes, + SourceCommand: input.SourceCommand, + }) +} diff --git a/internal/command/scale/estimate_test.go b/internal/command/scale/estimate_test.go new file mode 100644 index 0000000000..f3050b0d6d --- /dev/null +++ b/internal/command/scale/estimate_test.go @@ -0,0 +1,51 @@ +package scale + +import ( + "testing" + + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" +) + +func TestScaleCountEstimateChanges(t *testing.T) { + sizeGB := 10 + changes := scaleCountEstimateChanges([]*planItem{ + { + GroupName: "app", + Region: "iad", + Delta: 2, + LaunchMachineInput: &fly.LaunchMachineInput{ + Region: "iad", + Config: &fly.MachineConfig{Guest: &fly.MachineGuest{CPUKind: "shared", CPUs: 1, MemoryMB: 256}}, + }, + CreateVolumeRequest: &fly.CreateVolumeRequest{Region: "iad", SizeGb: &sizeGB}, + }, + { + GroupName: "worker", + Region: "ord", + Delta: -1, + Machines: []*fly.Machine{ + {ID: "machine-1", Region: "ord", Config: &fly.MachineConfig{Guest: &fly.MachineGuest{CPUKind: "shared", CPUs: 1, MemoryMB: 512}}}, + }, + }, + }) + + require.Len(t, changes, 3) + require.Equal(t, "machine", changes[0].Kind) + require.Equal(t, "create", changes[0].Action) + require.Equal(t, "app:iad", changes[0].Ref) + require.Equal(t, 2, changes[0].Count) + require.IsType(t, &fly.LaunchMachineInput{}, changes[0].Desired) + + require.Equal(t, "volume", changes[1].Kind) + require.Equal(t, "create", changes[1].Action) + require.Equal(t, "app:iad:volume", changes[1].Ref) + require.Equal(t, 2, changes[1].Count) + require.Equal(t, scaleVolumeSpec{Region: "iad", SizeGB: 10}, changes[1].Desired) + + require.Equal(t, "machine", changes[2].Kind) + require.Equal(t, "destroy", changes[2].Action) + require.Equal(t, "machine-1", changes[2].Ref) + require.Equal(t, 1, changes[2].Count) + require.IsType(t, &fly.Machine{}, changes[2].Current) +} diff --git a/internal/command/scale/machines.go b/internal/command/scale/machines.go index b6ef961f09..1dd36f0999 100644 --- a/internal/command/scale/machines.go +++ b/internal/command/scale/machines.go @@ -6,9 +6,12 @@ import ( "github.com/samber/lo" fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/helpers" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/appsecrets" + "github.com/superfly/flyctl/internal/flag" mach "github.com/superfly/flyctl/internal/machine" + "github.com/superfly/flyctl/internal/uiex" ) func v2ScaleVM(ctx context.Context, appName, group, sizeName string, memoryMB int) (*fly.VMSize, error) { @@ -36,6 +39,45 @@ func v2ScaleVM(ctx context.Context, appName, group, sizeName string, memoryMB in return nil, fmt.Errorf("No active machines in process group '%s', check `fly status` output", group) } + if flag.GetBool(ctx, "estimate") { + changes := make([]uiex.CostEstimateChange, 0, len(machines)) + operation := "scale.vm" + sourceCommand := "fly scale vm" + if sizeName == "" { + operation = "scale.memory" + sourceCommand = "fly scale memory" + } + + for _, machine := range machines { + desiredConfig := helpers.Clone(machine.Config) + if sizeName != "" { + desiredConfig.Guest.SetSize(sizeName) + } + if memoryMB > 0 { + desiredConfig.Guest.MemoryMB = memoryMB + } + + changes = append(changes, uiex.CostEstimateChange{ + Kind: "machine", + Action: "update", + Ref: machine.ID, + Count: 1, + Current: machine, + Desired: fly.LaunchMachineInput{ + Name: machine.Name, + Region: machine.Region, + Config: desiredConfig, + }, + }) + } + + return nil, runScaleEstimate(ctx, appName, scaleEstimateInput{ + Operation: operation, + SourceCommand: sourceCommand, + Changes: changes, + }) + } + machines, releaseFunc, err := mach.AcquireLeases(ctx, appName, machines) defer releaseFunc() if err != nil { diff --git a/internal/command/scale/memory.go b/internal/command/scale/memory.go index 45d209524e..8050593ce4 100644 --- a/internal/command/scale/memory.go +++ b/internal/command/scale/memory.go @@ -24,6 +24,7 @@ func newScaleMemory() *cobra.Command { flag.App(), flag.AppConfig(), flag.ProcessGroup("The process group to apply the VM size to"), + flag.Bool{Name: "estimate", Description: "Print a JSON cost estimate for the memory change and exit without changing anything"}, ) return cmd diff --git a/internal/command/scale/vm.go b/internal/command/scale/vm.go index 8a24193098..d377309fd6 100644 --- a/internal/command/scale/vm.go +++ b/internal/command/scale/vm.go @@ -40,6 +40,7 @@ For pricing, see https://fly.io/docs/about/pricing/` Aliases: []string{"memory"}, }, flag.ProcessGroup("The process group to apply the VM size to"), + flag.Bool{Name: "estimate", Description: "Print a JSON cost estimate for the VM size change and exit without changing anything"}, ) return cmd @@ -61,6 +62,9 @@ func scaleVertically(ctx context.Context, group, sizeName string, memoryMB int) if err != nil { return err } + if flag.GetBool(ctx, "estimate") { + return nil + } if group == "" { fmt.Fprintf(io.Out, "Scaled VM Type to '%s'\n", size.Name) diff --git a/internal/command/volumes/create.go b/internal/command/volumes/create.go index 01ba984df2..c4ed757fcc 100644 --- a/internal/command/volumes/create.go +++ b/internal/command/volumes/create.go @@ -16,6 +16,7 @@ import ( "github.com/superfly/flyctl/internal/future" "github.com/superfly/flyctl/internal/prompt" "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/internal/uiex" "github.com/superfly/flyctl/iostreams" ) @@ -84,6 +85,11 @@ func newCreate() *cobra.Command { Default: 1, Description: "The number of volumes to create", }, + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the volume creation and exit without creating anything", + Default: false, + }, flag.VMSizeFlags, ) @@ -112,10 +118,12 @@ func runCreate(ctx context.Context) error { return client.GetAppBasic(ctx, appName) }) - if confirm, err := confirmVolumeCreate(ctx, appName); err != nil { - return err - } else if !confirm { - return nil + if !flag.GetBool(ctx, "estimate") { + if confirm, err := confirmVolumeCreate(ctx, appName); err != nil { + return err + } else if !confirm { + return nil + } } app, err := appFuture.Get() @@ -166,6 +174,25 @@ func runCreate(ctx context.Context) error { input.AutoBackupEnabled = new(flag.GetBool(ctx, "scheduled-snapshots")) } + if flag.GetBool(ctx, "estimate") { + return runVolumeEstimate(ctx, appName, volumeEstimateInput{ + Operation: "volume.create", + SourceCommand: "fly volumes create", + Changes: []uiex.CostEstimateChange{ + { + Kind: "volume", + Action: "create", + Ref: volumeName, + Count: count, + Desired: volumeEstimateSpec{ + Region: region.Code, + SizeGB: *input.SizeGb, + }, + }, + }, + }) + } + out := iostreams.FromContext(ctx).Out for range count { volume, err := flapsClient.CreateVolume(ctx, appName, input) diff --git a/internal/command/volumes/destroy.go b/internal/command/volumes/destroy.go index 58d6d3da9c..2b9e31f700 100644 --- a/internal/command/volumes/destroy.go +++ b/internal/command/volumes/destroy.go @@ -12,6 +12,7 @@ import ( "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/internal/uiex" "github.com/superfly/flyctl/iostreams" ) @@ -33,6 +34,11 @@ func newDestroy() *cobra.Command { flag.Yes(), flag.App(), flag.AppConfig(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the volume destruction and exit without destroying anything", + Default: false, + }, ) return cmd @@ -72,6 +78,30 @@ func runDestroy(ctx context.Context) error { volIDs = append(volIDs, volume.ID) } + if flag.GetBool(ctx, "estimate") { + changes := make([]uiex.CostEstimateChange, 0, len(volIDs)) + for _, volID := range volIDs { + volume, err := flapsClient.GetVolume(ctx, appName, volID) + if err != nil { + return err + } + + changes = append(changes, uiex.CostEstimateChange{ + Kind: "volume", + Action: "destroy", + Ref: volID, + Count: 1, + Current: volumeSpec(volume), + }) + } + + return runVolumeEstimate(ctx, appName, volumeEstimateInput{ + Operation: "volume.destroy", + SourceCommand: "fly volumes destroy", + Changes: changes, + }) + } + for _, volID := range volIDs { if confirm, err := confirmVolumeDelete(ctx, appName, volID); err != nil { return err diff --git a/internal/command/volumes/estimate.go b/internal/command/volumes/estimate.go new file mode 100644 index 0000000000..7393417c98 --- /dev/null +++ b/internal/command/volumes/estimate.go @@ -0,0 +1,32 @@ +package volumes + +import ( + "context" + + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/uiex" +) + +type volumeEstimateSpec struct { + Region string `json:"region,omitempty"` + SizeGB int `json:"size_gb"` +} + +type volumeEstimateInput struct { + Operation string + SourceCommand string + Changes []uiex.CostEstimateChange +} + +func runVolumeEstimate(ctx context.Context, appName string, input volumeEstimateInput) error { + return costestimate.RunForApp(ctx, appName, costestimate.Input{ + Operation: input.Operation, + Changes: input.Changes, + SourceCommand: input.SourceCommand, + }) +} + +func volumeSpec(vol *fly.Volume) volumeEstimateSpec { + return volumeEstimateSpec{Region: vol.Region, SizeGB: vol.SizeGb} +} diff --git a/internal/command/volumes/estimate_test.go b/internal/command/volumes/estimate_test.go new file mode 100644 index 0000000000..b54644702a --- /dev/null +++ b/internal/command/volumes/estimate_test.go @@ -0,0 +1,78 @@ +package volumes + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command/costestimate" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/mock" + "github.com/superfly/flyctl/internal/uiex" + "github.com/superfly/flyctl/internal/uiexutil" + "github.com/superfly/flyctl/iostreams" +) + +func TestRunVolumeEstimateBuildsRequest(t *testing.T) { + ios, _, out, _ := iostreams.Test() + ctx := iostreams.NewContext(context.Background(), ios) + ctx = flyutil.NewContextWithClient(ctx, &mock.Client{ + GetAppCompactFunc: func(ctx context.Context, appName string) (*fly.AppCompact, error) { + require.Equal(t, "test-app", appName) + return &fly.AppCompact{Organization: &fly.OrganizationBasic{Slug: "test-org"}}, nil + }, + }) + + var gotOrgSlug string + var gotReq uiex.CostEstimateRequest + ctx = uiexutil.NewContextWithClient(ctx, &mock.UiexClient{ + CreateCostEstimateFunc: func(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + gotOrgSlug = orgSlug + gotReq = in + + return &uiex.CostEstimateResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }, + }) + + err := runVolumeEstimate(ctx, "test-app", volumeEstimateInput{ + Operation: "volume.extend", + SourceCommand: "fly volumes extend", + Changes: []uiex.CostEstimateChange{ + { + Kind: "volume", + Action: "extend", + Ref: "vol_123", + Count: 1, + Current: volumeEstimateSpec{Region: "iad", SizeGB: 10}, + Desired: volumeEstimateSpec{Region: "iad", SizeGB: 20}, + }, + }, + }) + + require.NoError(t, err) + require.Equal(t, "test-org", gotOrgSlug) + require.Equal(t, "volume.extend", gotReq.Operation) + require.Equal(t, "fly volumes extend", gotReq.Client.SourceCommand) + require.Len(t, gotReq.Changes, 1) + require.Equal(t, "volume", gotReq.Changes[0].Kind) + require.Equal(t, "extend", gotReq.Changes[0].Action) + require.Equal(t, volumeEstimateSpec{Region: "iad", SizeGB: 10}, gotReq.Changes[0].Current) + require.Equal(t, volumeEstimateSpec{Region: "iad", SizeGB: 20}, gotReq.Changes[0].Desired) + require.JSONEq(t, `{"ok":true}`, out.String()) +} + +func TestVolumeEstimateOrgSlugResolvesPersonalRawSlug(t *testing.T) { + ctx := flyutil.NewContextWithClient(context.Background(), &mock.Client{ + GetOrganizationBySlugFunc: func(ctx context.Context, slug string) (*fly.Organization, error) { + require.Equal(t, "personal", slug) + return &fly.Organization{RawSlug: "personal-raw"}, nil + }, + }) + + slug, err := costestimate.ResolveOrgSlug(ctx, &fly.OrganizationBasic{Slug: "personal"}) + + require.NoError(t, err) + require.Equal(t, "personal-raw", slug) +} diff --git a/internal/command/volumes/extend.go b/internal/command/volumes/extend.go index ec3ca6101c..dc7027cdc0 100644 --- a/internal/command/volumes/extend.go +++ b/internal/command/volumes/extend.go @@ -6,6 +6,7 @@ import ( "github.com/docker/go-units" "github.com/spf13/cobra" + fly "github.com/superfly/fly-go" "github.com/superfly/flyctl/helpers" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/command" @@ -14,6 +15,7 @@ import ( "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/internal/uiex" "github.com/superfly/flyctl/iostreams" ) @@ -42,6 +44,11 @@ func newExtend() *cobra.Command { Description: "Target volume size in gigabytes", }, flag.Yes(), + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the volume extension and exit without extending anything", + Default: false, + }, ) flag.Add(cmd, flag.JSONOutput()) @@ -76,20 +83,42 @@ func runExtend(ctx context.Context) error { return fmt.Errorf("Volume size must be specified") } - if sizeFlag[0] == '+' { - volume, err := flapsClient.GetVolume(ctx, appName, volID) + var volume *fly.Volume + if volID == "" { + volume, err = selectVolume(ctx, flapsClient, app) if err != nil { return err } - sizeGB += volume.SizeGb - } - - if volID == "" { - volume, err := selectVolume(ctx, flapsClient, app) + volID = volume.ID + } else { + volume, err = flapsClient.GetVolume(ctx, appName, volID) if err != nil { return err } - volID = volume.ID + } + + if sizeFlag[0] == '+' { + sizeGB += volume.SizeGb + } + + if flag.GetBool(ctx, "estimate") { + return runVolumeEstimate(ctx, appName, volumeEstimateInput{ + Operation: "volume.extend", + SourceCommand: "fly volumes extend", + Changes: []uiex.CostEstimateChange{ + { + Kind: "volume", + Action: "extend", + Ref: volID, + Count: 1, + Current: volumeSpec(volume), + Desired: volumeEstimateSpec{ + Region: volume.Region, + SizeGB: sizeGB, + }, + }, + }, + }) } volume, needsRestart, err := flapsClient.ExtendVolume(ctx, appName, volID, sizeGB) diff --git a/internal/command/volumes/fork.go b/internal/command/volumes/fork.go index 970ace75d7..4fc32f089e 100644 --- a/internal/command/volumes/fork.go +++ b/internal/command/volumes/fork.go @@ -13,6 +13,7 @@ import ( "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/internal/uiex" "github.com/superfly/flyctl/iostreams" ) @@ -55,6 +56,11 @@ func newFork() *cobra.Command { Shorthand: "r", Description: "The target region. By default, the new volume will be created in the source volume's region.", }, + flag.Bool{ + Name: "estimate", + Description: "Print a JSON cost estimate for the forked volume and exit without creating anything", + Default: false, + }, flag.VMSizeFlags, ) @@ -146,6 +152,30 @@ func runFork(ctx context.Context) error { Region: region, } + estimateRegion := region + if estimateRegion == "" { + estimateRegion = vol.Region + } + + if flag.GetBool(ctx, "estimate") { + return runVolumeEstimate(ctx, appName, volumeEstimateInput{ + Operation: "volume.fork", + SourceCommand: "fly volumes fork", + Changes: []uiex.CostEstimateChange{ + { + Kind: "volume", + Action: "fork", + Ref: vol.ID, + Count: 1, + Desired: volumeEstimateSpec{ + Region: estimateRegion, + SizeGB: vol.SizeGb, + }, + }, + }, + }) + } + volume, err := flapsClient.CreateVolume(ctx, appName, input) if err != nil { return fmt.Errorf("failed to fork volume: %w", err) diff --git a/internal/mock/uiex_client.go b/internal/mock/uiex_client.go index 8f7aa8b9e5..f92f3e8eed 100644 --- a/internal/mock/uiex_client.go +++ b/internal/mock/uiex_client.go @@ -22,6 +22,7 @@ type UiexClient struct { FinishBuildFunc func(ctx context.Context, in uiex.FinishBuildRequest) (*uiex.BuildResponse, error) EnsureDepotBuilderFunc func(ctx context.Context, in uiex.EnsureDepotBuilderRequest) (*uiex.EnsureDepotBuilderResponse, error) CreateFlyManagedBuilderFunc func(ctx context.Context, orgSlug string, region string) (uiex.CreateFlyManagedBuilderResponse, error) + CreateCostEstimateFunc func(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) GetAllAppsCurrentReleaseTimestampsFunc func(ctx context.Context) (*map[string]time.Time, error) ListReleasesFunc func(ctx context.Context, appName string, count int) ([]uiex.Release, error) GetCurrentReleaseFunc func(ctx context.Context, appName string) (*uiex.Release, error) @@ -93,6 +94,14 @@ func (m *UiexClient) CreateFlyManagedBuilder(ctx context.Context, orgSlug string return uiex.CreateFlyManagedBuilderResponse{}, nil } +func (m *UiexClient) CreateCostEstimate(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) { + if m.CreateCostEstimateFunc != nil { + return m.CreateCostEstimateFunc(ctx, orgSlug, in) + } + + return &uiex.CostEstimateResponse{}, nil +} + func (m *UiexClient) GetAllAppsCurrentReleaseTimestamps(ctx context.Context) (*map[string]time.Time, error) { if m.GetAllAppsCurrentReleaseTimestampsFunc != nil { return m.GetAllAppsCurrentReleaseTimestampsFunc(ctx) diff --git a/internal/uiex/cost_estimates.go b/internal/uiex/cost_estimates.go new file mode 100644 index 0000000000..734fee12cd --- /dev/null +++ b/internal/uiex/cost_estimates.go @@ -0,0 +1,82 @@ +package uiex + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superfly/flyctl/internal/config" +) + +type CostEstimateRequest struct { + SchemaVersion int `json:"schema_version"` + Operation string `json:"operation"` + Currency string `json:"currency,omitempty"` + Changes []CostEstimateChange `json:"changes"` + Client *CostEstimateClient `json:"client,omitempty"` +} + +type CostEstimateClient struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + SourceCommand string `json:"source_command,omitempty"` +} + +type CostEstimateChange struct { + Kind string `json:"kind"` + Action string `json:"action"` + Ref string `json:"ref,omitempty"` + Count int `json:"count,omitempty"` + Current any `json:"current,omitempty"` + Desired any `json:"desired,omitempty"` + Source any `json:"source,omitempty"` + Usage map[string]any `json:"usage,omitempty"` +} + +type CostEstimateResponse struct { + Data json.RawMessage `json:"data"` +} + +func (c *Client) CreateCostEstimate(ctx context.Context, orgSlug string, in CostEstimateRequest) (*CostEstimateResponse, error) { + cfg := config.FromContext(ctx) + url := fmt.Sprintf("%s/api/v1/organizations/%s/cost-estimates", c.baseUrl, orgSlug) + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(in); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+cfg.Tokens.GraphQL()) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to create cost estimate (status %d): %s", res.StatusCode, string(body)) + } + + var response CostEstimateResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to decode response, please try again: %w", err) + } + + return &response, nil +} diff --git a/internal/uiexutil/client.go b/internal/uiexutil/client.go index 35e3f6cb46..c024840d56 100644 --- a/internal/uiexutil/client.go +++ b/internal/uiexutil/client.go @@ -27,6 +27,9 @@ type Client interface { EnsureDepotBuilder(ctx context.Context, in uiex.EnsureDepotBuilderRequest) (*uiex.EnsureDepotBuilderResponse, error) CreateFlyManagedBuilder(ctx context.Context, orgSlug string, region string) (uiex.CreateFlyManagedBuilderResponse, error) + // Cost estimates + CreateCostEstimate(ctx context.Context, orgSlug string, in uiex.CostEstimateRequest) (*uiex.CostEstimateResponse, error) + // Releases GetAllAppsCurrentReleaseTimestamps(ctx context.Context) (*map[string]time.Time, error) ListReleases(ctx context.Context, appName string, count int) ([]uiex.Release, error)