Skip to content
Draft
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
33 changes: 33 additions & 0 deletions internal/command/certificates/estimate.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
50 changes: 50 additions & 0 deletions internal/command/certificates/estimate_test.go
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 23 in internal/command/certificates/estimate_test.go

View workflow job for this annotation

GitHub Actions / lint

return with no blank line before (nlreturn)
},
})

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())
}
36 changes: 34 additions & 2 deletions internal/command/certificates/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions internal/command/costestimate/costestimate.go
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 79 in internal/command/costestimate/costestimate.go

View workflow job for this annotation

GitHub Actions / lint

return with no blank line before (nlreturn)
}

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
}
23 changes: 23 additions & 0 deletions internal/command/ips/allocate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions internal/command/ips/estimate.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
Loading
Loading