diff --git a/internal/command/launch/describe_plan.go b/internal/command/launch/describe_plan.go index b613880d95..3b4795fb85 100644 --- a/internal/command/launch/describe_plan.go +++ b/internal/command/launch/describe_plan.go @@ -63,7 +63,7 @@ func describeRedisPlan(ctx context.Context, p plan.RedisPlan) (string, error) { } func describeUpstashRedisPlan(ctx context.Context, p *plan.UpstashRedisPlan) (string, error) { - plan, err := redis.DeterminePlan(ctx) + plan, err := redis.DeterminePlan(ctx, "") if err != nil { return "", fmt.Errorf("redis plan not found: %w", err) } diff --git a/internal/command/launch/launch_databases.go b/internal/command/launch/launch_databases.go index b4b99e60fc..1bbd6f1a01 100644 --- a/internal/command/launch/launch_databases.go +++ b/internal/command/launch/launch_databases.go @@ -496,7 +496,14 @@ func (state *launchState) createUpstashRedis(ctx context.Context) error { } } - db, err := redis.Create(ctx, org, dbName, ®ion, len(readReplicaRegions) == 0, redisPlan.Eviction, &readReplicaRegions) + // Get the default plan (pay-as-you-go) + plan, err := redis.DeterminePlan(ctx, "") + if err != nil { + return err + } + + // Launch uses defaults: no auto-upgrade (not available for pay-as-you-go anyway), no prodpack + db, err := redis.Create(ctx, org, dbName, ®ion, plan, len(readReplicaRegions) == 0, redisPlan.Eviction, false, false, &readReplicaRegions) if err != nil { return err } diff --git a/internal/command/redis/create.go b/internal/command/redis/create.go index f3e7f554f5..535df144a5 100644 --- a/internal/command/redis/create.go +++ b/internal/command/redis/create.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "github.com/spf13/cobra" @@ -24,6 +25,28 @@ const ( redisPlanPayAsYouGo = "ekQ85Yjkw155ohQ5ALYq0M" ) +// Legacy plans that are no longer available for new databases +// but existing databases can remain on them +// These match the normalized display names (lowercase, spaces replaced with underscores) +var legacyPlans = []string{ + "pro_2k", // "Pro 2k" + "pro_10k", // "Pro 10k" + "starter", // "Starter" + "standard", // "Standard" +} + +func isLegacyPlan(planName string) bool { + normalized := strings.ToLower(strings.ReplaceAll(planName, " ", "_")) + return slices.Contains(legacyPlans, normalized) +} + +// isFixedPlan checks if a plan is one of the new fixed plans (not pay-as-you-go) +// Auto-upgrade is only available for fixed plans +func isFixedPlan(planName string) bool { + return strings.HasPrefix(strings.ToLower(planName), "flyio_fixed_") || + strings.HasPrefix(strings.ToLower(planName), "fixed ") +} + func newCreate() (cmd *cobra.Command) { const ( long = `Create an Upstash Redis database` @@ -43,6 +66,10 @@ func newCreate() (cmd *cobra.Command) { Shorthand: "n", Description: "The name of your Redis database", }, + flag.String{ + Name: "plan", + Description: "The plan for your Redis database (default: pay-as-you-go)", + }, flag.Bool{ Name: "no-replicas", Description: "Don't prompt for selecting replica regions", @@ -55,6 +82,14 @@ func newCreate() (cmd *cobra.Command) { Name: "disable-eviction", Description: "Disallow writes when the max data size limit has been reached", }, + flag.Bool{ + Name: "enable-auto-upgrade", + Description: "Automatically upgrade to a higher plan when hitting resource limits", + }, + flag.Bool{ + Name: "enable-prodpack", + Description: "Enable ProdPack add-on for additional features ($200/mo)", + }, ) return cmd @@ -63,6 +98,12 @@ func newCreate() (cmd *cobra.Command) { func runCreate(ctx context.Context) (err error) { io := iostreams.FromContext(ctx) + // Validate --plan flag early before prompting for other options + planName := flag.GetString(ctx, "plan") + if planName != "" && isLegacyPlan(planName) { + return fmt.Errorf("plan %q is no longer available for new databases. Please choose a current plan", planName) + } + // pre-fetch platform regions for later use prompt.PlatformRegions(ctx) @@ -106,11 +147,49 @@ func runCreate(ctx context.Context) (err error) { return } } - _, err = Create(ctx, org, name, primaryRegion, flag.GetBool(ctx, "no-replicas"), enableEviction, nil) + + // Determine plan (already validated above if --plan was specified) + plan, err := DeterminePlan(ctx, planName) + if err != nil { + return err + } + + // Check if the selected plan is a fixed plan + planIsFixed := isFixedPlan(plan.DisplayName) + + // Prompt for auto-upgrade option (fixed plans only) + var enableAutoUpgrade bool + if planIsFixed { + if flag.IsSpecified(ctx, "enable-auto-upgrade") { + enableAutoUpgrade = flag.GetBool(ctx, "enable-auto-upgrade") + } else { + fmt.Fprintf(io.Out, "\nAuto-upgrade automatically switches to a higher plan when you hit resource limits.\nThis setting can be changed later.\n\n") + enableAutoUpgrade, err = prompt.Confirm(ctx, "Would you like to enable auto-upgrade?") + if err != nil { + return + } + } + } else if flag.IsSpecified(ctx, "enable-auto-upgrade") && flag.GetBool(ctx, "enable-auto-upgrade") { + fmt.Fprintf(io.Out, "\nNote: Auto-upgrade is only available for fixed plans, not pay-as-you-go.\n") + } + + // prompt for prodpack option (pay-as-you-go and fixed plans) + var enableProdpack bool + if flag.IsSpecified(ctx, "enable-prodpack") { + enableProdpack = flag.GetBool(ctx, "enable-prodpack") + } else { + fmt.Fprintf(io.Out, "\nProdPack adds enhanced features for production workloads at $200/mo.\nThis setting can be changed later.\n\n") + enableProdpack, err = prompt.Confirm(ctx, "Would you like to enable ProdPack?") + if err != nil { + return + } + } + + _, err = Create(ctx, org, name, primaryRegion, plan, flag.GetBool(ctx, "no-replicas"), enableEviction, enableAutoUpgrade, enableProdpack, nil) return err } -func Create(ctx context.Context, org *fly.Organization, name string, region *fly.Region, disallowReplicas bool, enableEviction bool, readRegions *[]fly.Region) (addOn *gql.AddOn, err error) { +func Create(ctx context.Context, org *fly.Organization, name string, region *fly.Region, plan *gql.ListAddOnPlansAddOnPlansAddOnPlanConnectionNodesAddOnPlan, disallowReplicas bool, enableEviction bool, enableAutoUpgrade bool, enableProdpack bool, readRegions *[]fly.Region) (addOn *gql.AddOn, err error) { var ( io = iostreams.FromContext(ctx) colorize = io.ColorScheme() @@ -146,11 +225,6 @@ func Create(ctx context.Context, org *fly.Organization, name string, region *fly } } - plan, err := DeterminePlan(ctx) - if err != nil { - return nil, err - } - s := spinner.Run(io, "Launching...") params := RedisConfiguration{ @@ -159,6 +233,8 @@ func Create(ctx context.Context, org *fly.Organization, name string, region *fly PrimaryRegion: region, ReadRegions: *readRegions, Eviction: enableEviction, + AutoUpgrade: enableAutoUpgrade, + ProdPack: enableProdpack, } addOn, err = ProvisionDatabase(ctx, org, params) @@ -181,6 +257,8 @@ type RedisConfiguration struct { PrimaryRegion *fly.Region ReadRegions []fly.Region Eviction bool + AutoUpgrade bool + ProdPack bool } func ProvisionDatabase(ctx context.Context, org *fly.Organization, config RedisConfiguration) (addOn *gql.AddOn, err error) { @@ -197,6 +275,12 @@ func ProvisionDatabase(ctx context.Context, org *fly.Organization, config RedisC if config.Eviction { options["eviction"] = true } + if config.AutoUpgrade { + options["auto_upgrade"] = true + } + if config.ProdPack { + options["prod_pack"] = true + } input := gql.CreateAddOnInput{ OrganizationId: org.ID, @@ -216,21 +300,33 @@ func ProvisionDatabase(ctx context.Context, org *fly.Organization, config RedisC return &response.CreateAddOn.AddOn, nil } -func DeterminePlan(ctx context.Context) (*gql.ListAddOnPlansAddOnPlansAddOnPlanConnectionNodesAddOnPlan, error) { +func DeterminePlan(ctx context.Context, planName string) (*gql.ListAddOnPlansAddOnPlansAddOnPlanConnectionNodesAddOnPlan, error) { client := flyutil.ClientFromContext(ctx) - planId := redisPlanPayAsYouGo - - // Now that we have the Plan ID, look up the actual plan - allAddons, err := gql.ListAddOnPlans(ctx, client.GenqClient(), gql.AddOnTypeUpstashRedis) + // Fetch all available plans + allPlans, err := gql.ListAddOnPlans(ctx, client.GenqClient(), gql.AddOnTypeUpstashRedis) if err != nil { return nil, err } - for _, addon := range allAddons.AddOnPlans.Nodes { - if addon.Id == planId { - return &addon, nil + // If a specific plan is requested, use it if it's not a legacy plan + if planName != "" { + if isLegacyPlan(planName) { + return nil, fmt.Errorf("plan %q is no longer available for new databases. Please choose a current plan", planName) + } + for _, plan := range allPlans.AddOnPlans.Nodes { + if plan.DisplayName == planName || plan.Id == planName { + return &plan, nil + } + } + return nil, fmt.Errorf("plan %q not found", planName) + } + + // Default to pay-as-you-go plan + for _, plan := range allPlans.AddOnPlans.Nodes { + if plan.Id == redisPlanPayAsYouGo { + return &plan, nil } } - return nil, errors.New("plan not found") + return nil, errors.New("default plan not found") } diff --git a/internal/command/redis/plans.go b/internal/command/redis/plans.go index 6b86aa1cbf..3794ad1a2b 100644 --- a/internal/command/redis/plans.go +++ b/internal/command/redis/plans.go @@ -36,25 +36,39 @@ func runPlans(ctx context.Context) (err error) { ) result, err := gql.ListAddOnPlans(ctx, client, gql.AddOnTypeUpstashRedis) + if err != nil { + return err + } var rows [][]string - fmt.Fprintf(out, "\nRedis databases run on Fly.io, fully managed by Upstash.com. \nOther limits, besides memory, apply to most plans. Learn more at https://fly.io/docs/reference/redis\n\n") + fmt.Fprintf(out, "\nRedis databases run on Fly.io, fully managed by Upstash.com.\nOther limits, besides memory, apply to most plans. Learn more at https://fly.io/docs/reference/redis\n\n") for _, plan := range result.AddOnPlans.Nodes { + // Filter out legacy plans - only show plans available for new databases + if isLegacyPlan(plan.DisplayName) { + continue + } + + // Format price + var price string + if plan.PricePerMonth == 0 { + price = "Free" + } else { + price = fmt.Sprintf("$%d/mo", plan.PricePerMonth/100) + } row := []string{ plan.DisplayName, + plan.MaxDataSize, + price, plan.Description, } - var price string - - row = append(row, price) rows = append(rows, row) } - _ = render.Table(out, "", rows, "Name", "Description") + _ = render.Table(out, "", rows, "Name", "Max Data Size", "Price", "Description") return } diff --git a/internal/command/redis/status.go b/internal/command/redis/status.go index 5d12eeacde..9109ced9bd 100644 --- a/internal/command/redis/status.go +++ b/internal/command/redis/status.go @@ -64,6 +64,18 @@ func runStatus(ctx context.Context) (err error) { evictionStatus = "Enabled" } + autoUpgradeStatus := "Disabled" + + if options["auto_upgrade"] != nil && options["auto_upgrade"].(bool) { + autoUpgradeStatus = "Enabled" + } + + prodPackStatus := "Disabled" + + if options["prod_pack"] != nil && options["prod_pack"].(bool) { + prodPackStatus = "Enabled" + } + obj := [][]string{ { addOn.Id, @@ -72,11 +84,13 @@ func runStatus(ctx context.Context) (err error) { addOn.PrimaryRegion, readRegions, evictionStatus, + autoUpgradeStatus, + prodPackStatus, addOn.PublicUrl, }, } - var cols = []string{"ID", "Name", "Plan", "Primary Region", "Read Regions", "Eviction", "Private URL"} + var cols = []string{"ID", "Name", "Plan", "Primary Region", "Read Regions", "Eviction", "Auto-Upgrade", "ProdPack", "Private URL"} if err = render.VerticalTable(io.Out, "Redis", obj, cols...); err != nil { return diff --git a/internal/command/redis/update.go b/internal/command/redis/update.go index 7444108aaf..3b2bd5d32f 100644 --- a/internal/command/redis/update.go +++ b/internal/command/redis/update.go @@ -49,6 +49,9 @@ func runUpdate(ctx context.Context) (err error) { addOn := response.AddOn + // Check if current plan is a legacy plan + currentPlanIsLegacy := isLegacyPlan(addOn.AddOnPlan.DisplayName) + excludedRegions, err := GetExcludedRegions(ctx) if err != nil { return err @@ -63,16 +66,26 @@ func runUpdate(ctx context.Context) (err error) { var index int var promptOptions []string var promptDefault string + var filteredPlans []gql.ListAddOnPlansAddOnPlansAddOnPlanConnectionNodesAddOnPlan result, err := gql.ListAddOnPlans(ctx, client, gql.AddOnTypeUpstashRedis) if err != nil { return } + // Filter plans based on current plan type for _, plan := range result.AddOnPlans.Nodes { - promptOptions = append(promptOptions, fmt.Sprintf("%s: %s", plan.DisplayName, plan.Description)) - if addOn.AddOnPlan.Id == plan.Id { - promptDefault = fmt.Sprintf("%s: %s", plan.DisplayName, plan.Description) + isLegacy := isLegacyPlan(plan.DisplayName) + + // Include plan if: + // 1. It's not a legacy plan (always include new plans), OR + // 2. It's the current plan (so user can stay on their legacy plan) + if !isLegacy || addOn.AddOnPlan.Id == plan.Id { + filteredPlans = append(filteredPlans, plan) + promptOptions = append(promptOptions, fmt.Sprintf("%s: %s", plan.DisplayName, plan.Description)) + if addOn.AddOnPlan.Id == plan.Id { + promptDefault = fmt.Sprintf("%s: %s", plan.DisplayName, plan.Description) + } } } @@ -82,11 +95,9 @@ func runUpdate(ctx context.Context) (err error) { return fmt.Errorf("failed to select a plan: %w", err) } - // type Options struct { - // Eviction bool - // } - - // options := &Options{} + selectedPlan := filteredPlans[index] + selectedPlanIsLegacy := isLegacyPlan(selectedPlan.DisplayName) + selectedPlanIsFixed := isFixedPlan(selectedPlan.DisplayName) options, _ := addOn.Options.(map[string]interface{}) @@ -99,29 +110,74 @@ func runUpdate(ctx context.Context) (err error) { if metadata == nil { metadata = make(map[string]interface{}) } - if err != nil { - return - } + // Eviction prompt (always available) if options["eviction"] != nil && options["eviction"].(bool) { - if disableEviction, err := prompt.Confirm(ctx, " Would you like to disable eviction?"); disableEviction || err != nil { + if disableEviction, err := prompt.Confirm(ctx, "Would you like to disable eviction?"); disableEviction || err != nil { options["eviction"] = false } } else { - options["eviction"], err = prompt.Confirm(ctx, " Would you like to enable eviction?") + options["eviction"], err = prompt.Confirm(ctx, "Would you like to enable eviction?") } if err != nil { return } + // Auto-upgrade only available for fixed plans (not pay-as-you-go or legacy) + if selectedPlanIsFixed { + currentAutoUpgrade := false + if options["auto_upgrade"] != nil { + currentAutoUpgrade, _ = options["auto_upgrade"].(bool) + } + + if currentAutoUpgrade { + if disableAutoUpgrade, err := prompt.Confirm(ctx, "Would you like to disable auto-upgrade?"); disableAutoUpgrade || err != nil { + options["auto_upgrade"] = false + } + } else { + options["auto_upgrade"], err = prompt.Confirm(ctx, "Would you like to enable auto-upgrade?") + } + + if err != nil { + return + } + } else if !selectedPlanIsLegacy { + // Pay-as-you-go plan - auto-upgrade not available but we should clear it if it was set + if options["auto_upgrade"] != nil { + delete(options, "auto_upgrade") + } + } + + // ProdPack available for both pay-as-you-go and fixed plans (but not legacy) + if !selectedPlanIsLegacy { + currentProdPack := false + if options["prod_pack"] != nil { + currentProdPack, _ = options["prod_pack"].(bool) + } + + if currentProdPack { + if disableProdPack, err := prompt.Confirm(ctx, "Would you like to disable ProdPack?"); disableProdPack || err != nil { + options["prod_pack"] = false + } + } else { + options["prod_pack"], err = prompt.Confirm(ctx, "Would you like to enable ProdPack ($200/mo)?") + } + + if err != nil { + return + } + } else if currentPlanIsLegacy { + fmt.Fprintf(out, "\nNote: Auto-upgrade and ProdPack are not available for legacy plans.\nTo access these features, please upgrade to a current plan.\n\n") + } + readRegionCodes := []string{} for _, region := range *readRegions { readRegionCodes = append(readRegionCodes, region.Code) } - _, err = gql.UpdateAddOn(ctx, client, addOn.Id, result.AddOnPlans.Nodes[index].Id, readRegionCodes, options, metadata) + _, err = gql.UpdateAddOn(ctx, client, addOn.Id, selectedPlan.Id, readRegionCodes, options, metadata) if err != nil { return diff --git a/test/preflight/testlib/helpers.go b/test/preflight/testlib/helpers.go index fd9ca78243..3931644f7b 100644 --- a/test/preflight/testlib/helpers.go +++ b/test/preflight/testlib/helpers.go @@ -31,12 +31,12 @@ import ( const defaultRegion = "cdg" type platformRegion struct { - Code string `json:"code"` - Name string `json:"name"` - GatewayAvailable bool `json:"gateway_available"` - RequiresPaidPlan bool `json:"requires_paid_plan"` - Deprecated bool `json:"deprecated"` - Capacity int `json:"capacity"` + Code string `json:"code"` + Name string `json:"name"` + GatewayAvailable bool `json:"gateway_available"` + RequiresPaidPlan bool `json:"requires_paid_plan"` + Deprecated bool `json:"deprecated"` + Capacity int `json:"capacity"` } // getBestRegions fetches platform regions and returns the top N regions