diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8269fc0fbe..e30099b76f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,7 @@ on: schedule: - cron: '21 */2 * * *' push: + pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -36,6 +37,8 @@ jobs: overwrite: true preflight: + # Only run preflight on master pushes, scheduled runs, or pull requests + if: ${{ github.event_name == 'schedule' || github.ref == 'refs/heads/master' || github.event_name == 'pull_request' }} needs: test_build uses: ./.github/workflows/preflight.yml secrets: inherit diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index 18883357d3..e6ac352074 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version-file: "go.mod" + go-version-file: 'go.mod' check-latest: true - name: Get go version id: go-version @@ -49,8 +49,8 @@ jobs: FLY_PREFLIGHT_TEST_ACCESS_TOKEN: ${{ secrets.FLYCTL_PREFLIGHT_CI_FLY_API_TOKEN }} FLY_PREFLIGHT_TEST_FLY_ORG: flyctl-ci-preflight FLY_PREFLIGHT_TEST_FLY_REGIONS: ${{ inputs.region }} - FLY_PREFLIGHT_TEST_NO_PRINT_HISTORY_ON_FAIL: "true" - FLY_FORCE_TRACE: "true" + FLY_PREFLIGHT_TEST_NO_PRINT_HISTORY_ON_FAIL: 'true' + FLY_FORCE_TRACE: 'true' run: | (test -e master-build/flyctl) && mv master-build/flyctl bin/flyctl chmod +x bin/flyctl diff --git a/.gitignore b/.gitignore index 4d62ab6f21..99ff234075 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ out # generated release meta release.json +.fly CLAUDE.md +.claude/settings.local.json diff --git a/internal/command/deploy/deploy_build.go b/internal/command/deploy/deploy_build.go index 1756025ee4..b212dae060 100644 --- a/internal/command/deploy/deploy_build.go +++ b/internal/command/deploy/deploy_build.go @@ -146,7 +146,12 @@ func determineImage(ctx context.Context, app *flaps.App, appConfig *appconfig.Co img, err = resolver.ResolveReference(ctx, io, opts) if err != nil { tracing.RecordError(span, err, "failed to resolve reference for prebuilt docker image") - return + img = &imgsrc.DeploymentImage{ + ID: imageRef, + Tag: imageRef, + } + terminal.Debugf("Failed to resolve reference for prebuilt docker image, using imageRef %s: %v\n", img.String(), err) + err = nil } span.AddEvent("using pre-built docker image") diff --git a/internal/command/extensions/core/core.go b/internal/command/extensions/core/core.go index 4b91cede7b..2f6d5db544 100644 --- a/internal/command/extensions/core/core.go +++ b/internal/command/extensions/core/core.go @@ -102,17 +102,17 @@ func ProvisionExtension(ctx context.Context, params ExtensionParams) (extension if override := params.OverrideName; override != nil { name = *override } else { - if name == "" { - name = flag.GetString(ctx, "name") - } + name = flag.GetString(ctx, "name") if name == "" { if provider.NameSuffix != "" && targetApp.Name != "" { name = targetApp.Name + "-" + provider.NameSuffix } - err = prompt.String(ctx, &name, "Choose a name, use the default, or leave blank to generate one:", name, false) - if err != nil { - return + if !flag.GetYes(ctx) { + err = prompt.String(ctx, &name, "Choose a name, use the default, or leave blank to generate one:", name, false) + if err != nil { + return + } } } } diff --git a/internal/command/launch/cmd.go b/internal/command/launch/cmd.go index 69f6207d22..e12eb05ee3 100644 --- a/internal/command/launch/cmd.go +++ b/internal/command/launch/cmd.go @@ -144,9 +144,20 @@ func New() (cmd *cobra.Command) { Name: "yaml", Description: "Generate configuration in YAML format", }, + // don't try to generate a name flag.Bool{ - Name: "no-create", - Description: "Do not create an app, only generate configuration files", + Name: "force-name", + Description: "Force app name supplied by --name", + Default: false, + Hidden: true, + }, + // like reuse-app, but non-legacy! + flag.Bool{ + Name: "no-create-app", + Description: "Do not create an app", + Default: false, + Hidden: true, + Aliases: []string{"no-create"}, }, flag.String{ Name: "auto-stop", @@ -337,6 +348,46 @@ func run(ctx context.Context) (err error) { return err } + planStep := plan.GetPlanStep(ctx) + + if launchManifest != nil && planStep != "generate" { + // we loaded a manifest... + cache = &planBuildCache{ + appConfig: launchManifest.Config, + sourceInfo: nil, + appNameValidated: true, + warnedNoCcHa: true, + } + } + + // For "generate" step, allow command-line flags to override manifest values. + // This is necessary because buildManifest() is skipped when loading a manifest from file. + // The "generate" step specifically needs this because it's called after propose/create steps, + // and the deployer wrapper needs to be able to override specific values without re-proposing. + if launchManifest != nil && planStep == "generate" { + // Override org if --org flag was provided + if orgRequested := flag.GetOrg(ctx); orgRequested != "" { + launchManifest.Plan.OrgSlug = orgRequested + } + + // Override app name if --app flag was provided + // This allows explicit override while preserving manifest value by default + if appRequested := flag.GetApp(ctx); appRequested != "" && flag.IsSpecified(ctx, "app") { + launchManifest.Plan.AppName = appRequested + } + + // Override region if --region flag was provided + if regionRequested := flag.GetRegion(ctx); regionRequested != "" && flag.IsSpecified(ctx, "region") { + launchManifest.Plan.RegionCode = regionRequested + } + + // Initialize PlanSource if nil (happens when loading from JSON because fields are unexported) + // This prevents nil pointer dereference in PlanSummary and other code that accesses PlanSource + if launchManifest.PlanSource == nil { + launchManifest.PlanSource = newDefaultPlanSource("from manifest") + } + } + // "--from" arg handling parentCtx := ctx ctx, parentConfig, err := setupFromTemplate(ctx) @@ -350,19 +401,20 @@ func run(ctx context.Context) (err error) { recoverableErrors := recoverableErrorBuilder{canEnterUi: canEnterUi} if launchManifest == nil { - launchManifest, cache, err = buildManifest(ctx, parentConfig, &recoverableErrors) if err != nil { var recoverableErr recoverableInUiError - if errors.As(err, &recoverableErr) && canEnterUi { - } else { + if !errors.As(err, &recoverableErr) || !canEnterUi { return err } } - if flag.GetBool(ctx, "manifest") { + manifestFlag := flag.GetBool(ctx, "manifest") + manifestPath := flag.GetString(ctx, "manifest-path") + + if manifestFlag { var jsonEncoder *json.Encoder - if manifestPath := flag.GetString(ctx, "manifest-path"); manifestPath != "" { + if manifestPath != "" { file, err := os.OpenFile(manifestPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { return err @@ -374,7 +426,8 @@ func run(ctx context.Context) (err error) { jsonEncoder = json.NewEncoder(io.Out) } jsonEncoder.SetIndent("", " ") - return jsonEncoder.Encode(launchManifest) + encodeErr := jsonEncoder.Encode(launchManifest) + return encodeErr } } @@ -419,7 +472,6 @@ func run(ctx context.Context) (err error) { family = state.sourceInfo.Family } - planStep := plan.GetPlanStep(ctx) if planStep == "" { colorize := io.ColorScheme() diff --git a/internal/command/launch/cmd_test.go b/internal/command/launch/cmd_test.go index 4634325dbf..cf3716c68c 100644 --- a/internal/command/launch/cmd_test.go +++ b/internal/command/launch/cmd_test.go @@ -105,6 +105,21 @@ func TestValidatePostgresFlags(t *testing.T) { } } +func TestNewDefaultPlanSource(t *testing.T) { + source := "test source" + planSource := newDefaultPlanSource(source) + + assert.NotNil(t, planSource) + assert.Equal(t, source, planSource.appNameSource) + assert.Equal(t, source, planSource.regionSource) + assert.Equal(t, source, planSource.orgSource) + assert.Equal(t, source, planSource.computeSource) + assert.Equal(t, source, planSource.postgresSource) + assert.Equal(t, source, planSource.redisSource) + assert.Equal(t, source, planSource.tigrisSource) + assert.Equal(t, source, planSource.sentrySource) +} + func TestParseMountOptions(t *testing.T) { tests := []struct { name string diff --git a/internal/command/launch/launch.go b/internal/command/launch/launch.go index 8c6f812194..35859e7053 100644 --- a/internal/command/launch/launch.go +++ b/internal/command/launch/launch.go @@ -110,6 +110,13 @@ func (state *launchState) Launch(ctx context.Context) error { } } + if planStep != "generate" { + // Override internal port if requested using --internal-port flag + if n := flag.GetInt(ctx, "internal-port"); n > 0 { + state.appConfig.SetInternalPort(n) + } + } + // if the user specified a command, set it in the app config if flag.GetString(ctx, "command") != "" { if state.appConfig.Processes == nil { @@ -276,17 +283,30 @@ func (state *launchState) updateComputeFromDeprecatedGuestFields(ctx context.Con return nil } +// isComputeValid checks if a compute configuration is valid and can be safely modified +func isComputeValid(c *appconfig.Compute) bool { + return c != nil && c.MachineGuest != nil +} + // updateConfig populates the appConfig with the plan's values func (state *launchState) updateConfig(ctx context.Context) { - state.appConfig.AppName = state.Plan.AppName - state.appConfig.PrimaryRegion = state.Plan.RegionCode - if state.env != nil { - state.appConfig.SetEnvVariables(state.env) + appConfig := state.appConfig + env := state.env + plan := state.Plan + + if plan == nil { + return } - state.appConfig.Compute = state.Plan.Compute + appConfig.AppName = plan.AppName + appConfig.PrimaryRegion = plan.RegionCode + if env != nil { + appConfig.SetEnvVariables(env) + } + + appConfig.Compute = plan.Compute - if state.Plan.HttpServicePort != 0 { + if plan.HttpServicePort != 0 { autostop := fly.MachineAutostopStop autostopFlag := flag.GetString(ctx, "auto-stop") @@ -296,8 +316,11 @@ func (state *launchState) updateConfig(ctx context.Context) { autostop = fly.MachineAutostopSuspend // if any compute has a GPU or more than 2GB of memory, set autostop to stop - for _, compute := range state.appConfig.Compute { - if compute.MachineGuest != nil && compute.MachineGuest.GPUKind != "" { + for _, compute := range appConfig.Compute { + if !isComputeValid(compute) { + continue + } + if compute.MachineGuest.GPUKind != "" { autostop = fly.MachineAutostopStop break } @@ -312,8 +335,8 @@ func (state *launchState) updateConfig(ctx context.Context) { } } - if state.appConfig.HTTPService == nil { - state.appConfig.HTTPService = &appconfig.HTTPService{ + if appConfig.HTTPService == nil { + appConfig.HTTPService = &appconfig.HTTPService{ ForceHTTPS: true, AutoStartMachines: fly.Pointer(true), AutoStopMachines: fly.Pointer(autostop), @@ -321,9 +344,35 @@ func (state *launchState) updateConfig(ctx context.Context) { Processes: []string{"app"}, } } - state.appConfig.HTTPService.InternalPort = state.Plan.HttpServicePort + appConfig.HTTPService.InternalPort = plan.HttpServicePort } else { - state.appConfig.HTTPService = nil + appConfig.HTTPService = nil + } + + // Apply plan-level compute overrides to all compute configurations + // Only set fields that haven't already been set (defensive against updateComputeFromDeprecatedGuestFields) + if plan.CPUKind != "" { + for i := range appConfig.Compute { + if isComputeValid(appConfig.Compute[i]) && appConfig.Compute[i].CPUKind == "" { + appConfig.Compute[i].CPUKind = plan.CPUKind + } + } + } + + if plan.CPUs != 0 { + for i := range appConfig.Compute { + if isComputeValid(appConfig.Compute[i]) && appConfig.Compute[i].CPUs == 0 { + appConfig.Compute[i].CPUs = plan.CPUs + } + } + } + + if plan.MemoryMB != 0 { + for i := range appConfig.Compute { + if isComputeValid(appConfig.Compute[i]) && appConfig.Compute[i].MemoryMB == 0 { + appConfig.Compute[i].MemoryMB = plan.MemoryMB + } + } } } diff --git a/internal/command/launch/launch_databases.go b/internal/command/launch/launch_databases.go index e4bce937ff..ea54adb87d 100644 --- a/internal/command/launch/launch_databases.go +++ b/internal/command/launch/launch_databases.go @@ -285,6 +285,18 @@ func (state *launchState) createManagedPostgres(ctx context.Context) error { retry.Delay(2*time.Second), retry.MaxDelay(30*time.Second), retry.DelayType(retry.BackOffDelay), + retry.OnRetry(func(n uint, err error) { + // Log network-related errors and periodic status updates + if containsNetworkError(err.Error()) { + s.Stop() + fmt.Fprintf(io.Out, "Retrying status check due to network issue: %v\n", err) + s = spinner.Run(io, colorize.Yellow("Provisioning your Managed Postgres cluster...")) + } else if n%10 == 0 && n > 0 { // Log every 10th attempt to show progress + s.Stop() + fmt.Fprintf(io.Out, "Still waiting for cluster to be ready (attempt %d)...\n", n+1) + s = spinner.Run(io, colorize.Yellow("Provisioning your Managed Postgres cluster...")) + } + }), ) // Stop the spinner diff --git a/internal/command/launch/launch_frameworks.go b/internal/command/launch/launch_frameworks.go index b2a1259149..1ebada4859 100644 --- a/internal/command/launch/launch_frameworks.go +++ b/internal/command/launch/launch_frameworks.go @@ -16,6 +16,7 @@ import ( "github.com/superfly/flyctl/helpers" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/appsecrets" + "github.com/superfly/flyctl/internal/command/launch/plan" "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" @@ -35,11 +36,13 @@ func (state *launchState) setupGitHubActions(ctx context.Context, appName string gh, err := exec.LookPath("gh") if err != nil { - io := iostreams.FromContext(ctx) - colorize := io.ColorScheme() - fmt.Fprintln(io.Out, "Run", colorize.Purple("`fly tokens create deploy -x 999999h`"), "to create a token and set it as the FLY_API_TOKEN secret in your GitHub repository settings") - fmt.Fprintln(io.Out, "See https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions") - fmt.Fprintln(io.Out) + if plan.GetPlanStep(ctx) == "" { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + fmt.Fprintln(io.Out, "Run", colorize.Purple("`fly tokens create deploy -x 999999h`"), "to create a token and set it as the FLY_API_TOKEN secret in your GitHub repository settings") + fmt.Fprintln(io.Out, "See https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions") + fmt.Fprintln(io.Out) + } } else { apiClient := flyutil.ClientFromContext(ctx) diff --git a/internal/command/launch/launch_test.go b/internal/command/launch/launch_test.go new file mode 100644 index 0000000000..8d5956e3a5 --- /dev/null +++ b/internal/command/launch/launch_test.go @@ -0,0 +1,55 @@ +package launch + +import ( + "testing" + + "github.com/stretchr/testify/assert" + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/appconfig" +) + +func TestIsComputeValid(t *testing.T) { + tests := []struct { + name string + compute *appconfig.Compute + expected bool + }{ + { + name: "nil compute", + compute: nil, + expected: false, + }, + { + name: "compute with nil MachineGuest", + compute: &appconfig.Compute{ + MachineGuest: nil, + }, + expected: false, + }, + { + name: "valid compute with MachineGuest", + compute: &appconfig.Compute{ + MachineGuest: &fly.MachineGuest{ + CPUKind: "shared", + CPUs: 1, + MemoryMB: 256, + }, + }, + expected: true, + }, + { + name: "valid compute with empty MachineGuest", + compute: &appconfig.Compute{ + MachineGuest: &fly.MachineGuest{}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isComputeValid(tt.compute) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/command/launch/plan/plan.go b/internal/command/launch/plan/plan.go index bdb8519f26..1030104d1a 100644 --- a/internal/command/launch/plan/plan.go +++ b/internal/command/launch/plan/plan.go @@ -14,7 +14,7 @@ type LaunchPlan struct { HighAvailability bool `json:"ha"` // Deprecated: The UI currently returns this instead of Compute, but new development should use Compute. - CPUKind string `json:"vm_cpukind,omitempty"` + CPUKind string `json:"vm_cpu_kind,omitempty"` // Deprecated: The UI currently returns this instead of Compute, but new development should use Compute. CPUs int `json:"vm_cpus,omitempty"` // Deprecated: The UI currently returns this instead of Compute, but new development should use Compute. @@ -42,8 +42,9 @@ type LaunchPlan struct { } type RuntimeStruct struct { - Language string `json:"language"` - Version string `json:"version"` + Language string `json:"language"` + Version string `json:"version"` + NoInstallRequired bool `json:"no_install_required"` } // Guest returns the guest described by the *raw* guest fields in a Plan. diff --git a/internal/command/launch/plan_builder.go b/internal/command/launch/plan_builder.go index 2d6106d978..1f0865aec6 100644 --- a/internal/command/launch/plan_builder.go +++ b/internal/command/launch/plan_builder.go @@ -106,8 +106,6 @@ func (r *recoverableErrorBuilder) build() string { } func buildManifest(ctx context.Context, parentConfig *appconfig.Config, recoverableErrors *recoverableErrorBuilder) (*LaunchManifest, *planBuildCache, error) { - io := iostreams.FromContext(ctx) - appConfig, copiedConfig, err := determineBaseAppConfig(ctx) if err != nil { return nil, nil, err @@ -135,12 +133,6 @@ func buildManifest(ctx context.Context, parentConfig *appconfig.Config, recovera if err := appConfig.SetMachinesPlatform(); err != nil { return nil, nil, fmt.Errorf("can not use configuration for Fly Launch, check fly.toml: %w", err) } - if flag.GetBool(ctx, "manifest") { - fmt.Fprintln(io.ErrOut, - "Warning: --manifest does not serialize an entire app configuration.\n"+ - "Creating a manifest from an existing fly.toml may be a lossy process!", - ) - } if service := appConfig.HTTPService; service != nil { httpServicePort = service.InternalPort } else { @@ -284,9 +276,12 @@ func buildManifest(ctx context.Context, parentConfig *appconfig.Config, recovera lp.Runtime = srcInfo.Runtime } + appConfig.AppName = lp.AppName + return &LaunchManifest{ Plan: lp, PlanSource: planSource, + Config: appConfig, }, buildCache, nil } @@ -436,6 +431,7 @@ func stateFromManifest(ctx context.Context, m LaunchManifest, optionalCache *pla LaunchManifest: LaunchManifest{ m.Plan, m.PlanSource, + appConfig, }, env: envVars, planBuildCache: planBuildCache{ @@ -456,7 +452,7 @@ func determineBaseAppConfig(ctx context.Context) (*appconfig.Config, bool, error if existingConfig != nil { colorize := io.ColorScheme() - if existingConfig.AppName != "" { + if existingConfig.AppName != "" && !flag.IsSpecified(ctx, "copy-config") { fmt.Fprintln(io.Out, "An existing fly.toml file was found for app", existingConfig.AppName) } else { fmt.Fprintln(io.Out, "An existing fly.toml file was found") @@ -558,6 +554,16 @@ func determineAppName(ctx context.Context, parentConfig *appconfig.Config, appCo appName := flag.GetString(ctx, "name") cause := "specified on the command line" + if flag.GetBool(ctx, "force-name") { + if appName == "" { + return "", "", flyerr.GenericErr{ + Err: "app name required when using --force-name", + Suggest: "Specify the app name with the --name flag", + } + } + return appName, cause, nil + } + if !flag.GetBool(ctx, "generate-name") { // --generate-name wasn't specified, so we try to get a name from the config file or directory name. if appName == "" { @@ -862,8 +868,9 @@ func determineCompute(ctx context.Context, config *appconfig.Config, srcInfo *sc func planValidateHighAvailability(ctx context.Context, p *plan.LaunchPlan, billable, print bool) bool { if !billable && p.HighAvailability { - // Silently turn off high availability if no payment method - // The billing check will handle informing the user about payment methods + if print { + fmt.Fprintln(iostreams.FromContext(ctx).ErrOut, "Warning: This organization has no payment method, turning off high availability") + } return false } return true diff --git a/internal/command/launch/plan_commands.go b/internal/command/launch/plan_commands.go index 9782a46217..5d1badf048 100644 --- a/internal/command/launch/plan_commands.go +++ b/internal/command/launch/plan_commands.go @@ -33,9 +33,11 @@ func NewPlan() *cobra.Command { func newPropose() *cobra.Command { const desc = "[experimental] propose a plan based on scanning the source code or Dockerfile" - cmd := command.New("propose", desc, desc, runPropose) + cmd := command.New("propose", desc, desc, runPropose, command.RequireSession) flag.Add(cmd, + flag.Region(), + flag.Org(), flag.String{ Name: "from", Description: "A github repo URL to use as a template for the new app", @@ -56,12 +58,33 @@ func newPropose() *cobra.Command { Name: "name", Description: `Name of the new app`, }, + flag.Bool{ + Name: "force-name", + Hidden: true, + }, + flag.Bool{ + Name: "copy-config", + Description: "Use the configuration file if present without prompting", + Default: false, + }, flag.String{ Name: "manifest-path", Description: "Path to write the manifest to", Default: "", Hidden: true, }, + flag.Bool{ + Name: "no-blank", + Description: "Don't allow a \"blank\" app (nothing could be detected)", + Default: true, + }, + flag.Compression(), + flag.CompressionLevel(), + flag.Int{ + Name: "internal-port", + Description: "Set internal_port for all services in the generated fly.toml", + Default: -1, + }, ) return cmd @@ -74,12 +97,20 @@ func newCreate() *cobra.Command { flag.Add(cmd, flag.String{ - Name: "manifest-path", + Name: "from-manifest", Shorthand: "p", + Aliases: []string{"manifest-path"}, Description: "Path to read the manifest from", Default: "", Hidden: true, }, + flag.Int{ + Name: "internal-port", + Description: "Set internal_port for all services in the generated fly.toml", + Default: -1, + }, + flag.Compression(), + flag.CompressionLevel(), ) return cmd @@ -92,8 +123,9 @@ func newPostgres() *cobra.Command { flag.Add(cmd, flag.String{ - Name: "manifest-path", + Name: "from-manifest", Shorthand: "p", + Aliases: []string{"manifest-path"}, Description: "Path to read the manifest from", Default: "", Hidden: true, @@ -110,8 +142,9 @@ func newRedis() *cobra.Command { flag.Add(cmd, flag.String{ - Name: "manifest-path", + Name: "from-manifest", Shorthand: "p", + Aliases: []string{"manifest-path"}, Description: "Path to read the manifest from", Default: "", Hidden: true, @@ -128,8 +161,9 @@ func newTigris() *cobra.Command { flag.Add(cmd, flag.String{ - Name: "manifest-path", + Name: "from-manifest", Shorthand: "p", + Aliases: []string{"manifest-path"}, Description: "Path to read the manifest from", Default: "", Hidden: true, @@ -141,7 +175,7 @@ func newTigris() *cobra.Command { func newGenerate() *cobra.Command { const desc = "[experimental] generate Dockerfile and other configuration files based on the plan" - cmd := command.New("generate", desc, desc, runGenerate) + cmd := command.New("generate", desc, desc, runGenerate, command.RequireSession) cmd.Args = cobra.ExactArgs(1) flag.Add(cmd, @@ -155,19 +189,21 @@ func newGenerate() *cobra.Command { Default: true, Hidden: true, }, - flag.Int{ - Name: "internal-port", - Description: "Set internal_port for all services in the generated fly.toml", - Default: -1, - Hidden: true, - }, flag.String{ - Name: "manifest-path", + Name: "from-manifest", Shorthand: "p", + Aliases: []string{"manifest-path"}, Description: "Path to read the manifest from", Default: "", Hidden: true, }, + flag.Compression(), + flag.CompressionLevel(), + flag.Int{ + Name: "internal-port", + Description: "Set internal_port for all services in the generated fly.toml", + Default: -1, + }, ) return cmd @@ -179,40 +215,40 @@ func RunPlan(ctx context.Context, step string) error { } func runPropose(ctx context.Context) error { - if flag.GetString(ctx, "manifest-path") == "" { - ctx = logger.NewContext(context.Background(), logger.New(os.Stderr, logger.FromContext(ctx).Level(), iostreams.IsTerminalWriter(os.Stdout))) + manifestPath := flag.GetString(ctx, "manifest-path") + + if manifestPath == "" { + ctx = logger.NewContext(ctx, logger.New(os.Stderr, logger.FromContext(ctx).Level(), iostreams.IsTerminalWriter(os.Stdout))) } - RunPlan(ctx, "propose") + err := RunPlan(ctx, "propose") + if err != nil { + return err + } return nil } func runCreate(ctx context.Context) error { - flag.SetString(ctx, "manifest-path", flag.FirstArg(ctx)) - RunPlan(ctx, "create") - return nil + flag.SetString(ctx, "from-manifest", flag.FirstArg(ctx)) + return RunPlan(ctx, "create") } func runPostgres(ctx context.Context) error { - flag.SetString(ctx, "manifest-path", flag.FirstArg(ctx)) - RunPlan(ctx, "postgres") - return nil + flag.SetString(ctx, "from-manifest", flag.FirstArg(ctx)) + return RunPlan(ctx, "postgres") } func runRedis(ctx context.Context) error { - flag.SetString(ctx, "manifest-path", flag.FirstArg(ctx)) - RunPlan(ctx, "redis") - return nil + flag.SetString(ctx, "from-manifest", flag.FirstArg(ctx)) + return RunPlan(ctx, "redis") } func runTigris(ctx context.Context) error { - flag.SetString(ctx, "manifest-path", flag.FirstArg(ctx)) - RunPlan(ctx, "tigris") - return nil + flag.SetString(ctx, "from-manifest", flag.FirstArg(ctx)) + return RunPlan(ctx, "tigris") } func runGenerate(ctx context.Context) error { - flag.SetString(ctx, "manifest-path", flag.FirstArg(ctx)) - RunPlan(ctx, "generate") - return nil + flag.SetString(ctx, "from-manifest", flag.FirstArg(ctx)) + return RunPlan(ctx, "generate") } diff --git a/internal/command/launch/state.go b/internal/command/launch/state.go index ca3e5599a4..464c7fa436 100644 --- a/internal/command/launch/state.go +++ b/internal/command/launch/state.go @@ -10,6 +10,7 @@ import ( "github.com/samber/lo" fly "github.com/superfly/fly-go" "github.com/superfly/flyctl/gql" + "github.com/superfly/flyctl/internal/appconfig" extensions_core "github.com/superfly/flyctl/internal/command/extensions/core" "github.com/superfly/flyctl/internal/command/launch/plan" "github.com/superfly/flyctl/internal/flag" @@ -30,9 +31,24 @@ type launchPlanSource struct { sentrySource string } +// newDefaultPlanSource creates a launchPlanSource with all fields set to the provided source description +func newDefaultPlanSource(source string) *launchPlanSource { + return &launchPlanSource{ + appNameSource: source, + regionSource: source, + orgSource: source, + computeSource: source, + postgresSource: source, + redisSource: source, + tigrisSource: source, + sentrySource: source, + } +} + type LaunchManifest struct { - Plan *plan.LaunchPlan - PlanSource *launchPlanSource + Plan *plan.LaunchPlan `json:"plan,omitempty"` + PlanSource *launchPlanSource `json:"plan_source,omitempty"` + Config *appconfig.Config `json:"config,omitempty"` } type launchState struct { diff --git a/internal/command/mpg/mpg.go b/internal/command/mpg/mpg.go index a1fc947cbd..c290da3759 100644 --- a/internal/command/mpg/mpg.go +++ b/internal/command/mpg/mpg.go @@ -40,11 +40,15 @@ type MPGService struct { } // NewMPGService creates a new MPGService with default dependencies -func NewMPGService(ctx context.Context) *MPGService { +func NewMPGService(ctx context.Context) (*MPGService, error) { + uiexClient := uiexutil.ClientFromContext(ctx) + if uiexClient == nil { + return nil, fmt.Errorf("uiex client not found in context") + } return &MPGService{ - uiexClient: uiexutil.ClientFromContext(ctx), + uiexClient: uiexClient, regionProvider: &DefaultRegionProvider{}, - } + }, nil } // NewMPGServiceWithDependencies creates a new MPGService with custom dependencies @@ -134,9 +138,20 @@ func ClusterFromArgOrSelect(ctx context.Context, clusterID, orgSlug string) (*ui } } +// ClusterFromFlagOrSelect retrieves the cluster ID from the --cluster flag. +// If the flag is not set, it prompts the user to select a cluster from the available ones for the given organization. +func ClusterFromFlagOrSelect(ctx context.Context, orgSlug string) (*uiex.ManagedCluster, error) { + clusterID := flag.GetMPGClusterID(ctx) + cluster, _, err := ClusterFromArgOrSelect(ctx, clusterID, orgSlug) + return cluster, err +} + // GetAvailableMPGRegions returns the list of regions available for Managed Postgres func GetAvailableMPGRegions(ctx context.Context, orgSlug string) ([]fly.Region, error) { - service := NewMPGService(ctx) + service, err := NewMPGService(ctx) + if err != nil { + return nil, err + } return service.GetAvailableMPGRegions(ctx, orgSlug) } @@ -148,6 +163,11 @@ func (s *MPGService) GetAvailableMPGRegions(ctx context.Context, orgSlug string) return nil, err } + // Check if uiexClient is initialized + if s.uiexClient == nil { + return nil, fmt.Errorf("uiex client is not initialized") + } + // Try to get available MPG regions from API mpgRegionsResponse, err := s.uiexClient.ListMPGRegions(ctx, orgSlug) if err != nil { @@ -159,7 +179,10 @@ func (s *MPGService) GetAvailableMPGRegions(ctx context.Context, orgSlug string) // IsValidMPGRegion checks if a region code is valid for Managed Postgres func IsValidMPGRegion(ctx context.Context, orgSlug string, regionCode string) (bool, error) { - service := NewMPGService(ctx) + service, err := NewMPGService(ctx) + if err != nil { + return false, err + } return service.IsValidMPGRegion(ctx, orgSlug, regionCode) } @@ -180,7 +203,10 @@ func (s *MPGService) IsValidMPGRegion(ctx context.Context, orgSlug string, regio // GetAvailableMPGRegionCodes returns just the region codes for error messages func GetAvailableMPGRegionCodes(ctx context.Context, orgSlug string) ([]string, error) { - service := NewMPGService(ctx) + service, err := NewMPGService(ctx) + if err != nil { + return nil, err + } return service.GetAvailableMPGRegionCodes(ctx, orgSlug) } diff --git a/internal/command/mpg/mpg_test.go b/internal/command/mpg/mpg_test.go index 4d3bf1fdda..77509e509f 100644 --- a/internal/command/mpg/mpg_test.go +++ b/internal/command/mpg/mpg_test.go @@ -57,6 +57,31 @@ func setupTestContext() context.Context { return ctx } +// Test NewMPGService returns error when uiex client is nil +func TestNewMPGService_NilClient(t *testing.T) { + ctx := context.Background() + + // Test with nil uiex client in context + service, err := NewMPGService(ctx) + assert.Error(t, err) + assert.Nil(t, service) + assert.Contains(t, err.Error(), "uiex client not found in context") +} + +// Test NewMPGService succeeds with valid client +func TestNewMPGService_ValidClient(t *testing.T) { + ctx := setupTestContext() + + mockUiex := &mock.UiexClient{} + ctx = uiexutil.NewContextWithClient(ctx, mockUiex) + + service, err := NewMPGService(ctx) + assert.NoError(t, err) + assert.NotNil(t, service) + assert.NotNil(t, service.uiexClient) + assert.NotNil(t, service.regionProvider) +} + // Test the actual filterMPGRegions function with real data func TestFilterMPGRegions_RealFunctionality(t *testing.T) { platformRegions := []fly.Region{ diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 8d6442d21d..bff959645e 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -320,6 +320,14 @@ func Org() String { } } +func MPGCluster() String { + return String{ + Name: "cluster", + Shorthand: "c", + Description: "The target cluster ID", + } +} + // Region returns a region string flag. func Region() String { return String{ diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 8ca555b469..4949555309 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -244,6 +244,8 @@ var errOrgSlugRequired = NonInteractiveError("org slug must be specified when no func Org(ctx context.Context) (*fly.Organization, error) { client := flyutil.ClientFromContext(ctx) + slug := config.FromContext(ctx).Organization + orgs, err := client.GetOrganizations(ctx) if err != nil { return nil, err @@ -251,7 +253,6 @@ func Org(ctx context.Context) (*fly.Organization, error) { sort.OrganizationsByTypeAndName(orgs) io := iostreams.FromContext(ctx) - slug := config.FromContext(ctx).Organization switch { case slug == "" && len(orgs) == 1 && orgs[0].Type == "PERSONAL": diff --git a/scanner/deno.go b/scanner/deno.go index 90a54f3302..a019945c17 100644 --- a/scanner/deno.go +++ b/scanner/deno.go @@ -1,26 +1,48 @@ package scanner +import ( + "fmt" + "path/filepath" + + "github.com/superfly/flyctl/internal/command/launch/plan" +) + func configureDeno(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { if !checksPass( sourceDir, // default config files: https://deno.land/manual@v1.35.2/getting_started/configuration_file fileExists("deno.json", "deno.jsonc"), // deno.land and denopkg.com imports - dirContains("*.ts", "\"https?://deno\\.land/.*\"", "\"https?://denopkg\\.com/.*\""), + dirContains("*.ts", `"https?://deno\.land/.*"`, `"https?://denopkg\.com/.*"`, `import "(.*)\\.tsx{0,}"`, `from "npm:.*"`, `from "jsr:.*"`, `Deno\.serve\(.*\)`, `Deno\.listen\(.*\)`), ) { return nil, nil } + // Check for common Deno entrypoint files + var entrypoint string + for _, path := range []string{"main.ts", "index.ts", "app.ts", "server.ts", "mod.ts"} { + if absFileExists(filepath.Join(sourceDir, path)) { + entrypoint = path + break + } + } + + // If no common entrypoint found, default to main.ts + if entrypoint == "" { + entrypoint = "main.ts" + } + s := &SourceInfo{ Files: templates("templates/deno"), Family: "Deno", Port: 8080, Processes: map[string]string{ - "app": "run --allow-net ./example.ts", + "app": fmt.Sprintf("run -A ./%s", entrypoint), }, Env: map[string]string{ "PORT": "8080", }, + Runtime: plan.RuntimeStruct{Language: "deno"}, } return s, nil diff --git a/scanner/django.go b/scanner/django.go index 62912f23c8..99c726e0b9 100644 --- a/scanner/django.go +++ b/scanner/django.go @@ -4,16 +4,15 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path" "path/filepath" - "regexp" "strings" "github.com/blang/semver" "github.com/logrusorgru/aurora" "github.com/mattn/go-zglob" "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/command/launch/plan" ) // setup django with a postgres database @@ -297,10 +296,14 @@ For detailed documentation, see https://fly.dev/docs/django/ cmd = []string{"gunicorn", "--bind", ":8000", "--workers", "2", "--worker-class", "uvicorn.workers.UvicornWorker", vars["asgiName"].(string) + ".asgi"} } else if vars["asgiFound"] == true && vars["hasDaphne"] == true { cmd = []string{"daphne", "-b", "0.0.0.0", "-p", "8000", vars["asgiName"].(string) + ".asgi"} - } else if vars["wsgiFound"] == true { + } else if vars["wsgiFound"] == true && vars["hasGunicorn"] == true { cmd = []string{"gunicorn", "--bind", ":8000", "--workers", "2", vars["wsgiName"].(string) + ".wsgi"} } else { - cmd = []string{"python", "manage.py", "runserver"} + // Warn if WSGI is found but gunicorn is not available + if vars["wsgiFound"] == true && vars["hasGunicorn"] != true { + fmt.Printf("%s\n", aurora.Yellow("WARNING: WSGI entrypoint detected but Gunicorn is not installed. Falling back to the Django development server, which is not suitable for production. Please add Gunicorn to your dependencies for production deployments.")) + } + cmd = []string{"python", "manage.py", "runserver", "0.0.0.0:8000"} } // Serialize the array to JSON @@ -341,36 +344,7 @@ For detailed documentation, see https://fly.dev/docs/django/ s.Files = templatesExecute("templates/django", vars) - return s, nil -} - -func extractPythonVersion() (string, bool, error) { - /* Example Output: - Python 3.11.2 - Python 3.12.0b4 - */ - pythonVersionOutput := "Python 3.12.0" // Fallback to 3.12 - - cmd := exec.Command("python3", "--version") - out, err := cmd.CombinedOutput() - if err == nil { - pythonVersionOutput = string(out) - } else { - cmd := exec.Command("python", "--version") - out, err := cmd.CombinedOutput() - if err == nil { - pythonVersionOutput = string(out) - } - } - - re := regexp.MustCompile(`Python ([0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z]+[0-9]+)?)`) - match := re.FindStringSubmatch(pythonVersionOutput) + s.Runtime = plan.RuntimeStruct{Language: "python", Version: pythonVersion} - if len(match) > 1 { - version := match[1] - nonNumericRegex := regexp.MustCompile(`[^0-9.]`) - pinned := nonNumericRegex.MatchString(version) - return version, pinned, nil - } - return "", false, fmt.Errorf("Could not find Python version") + return s, nil } diff --git a/scanner/elixir.go b/scanner/elixir.go index 711c72ca70..cdedf3dcc3 100644 --- a/scanner/elixir.go +++ b/scanner/elixir.go @@ -4,6 +4,7 @@ import ( "path/filepath" "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/command/launch/plan" ) func configureElixir(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { @@ -19,6 +20,7 @@ func configureElixir(sourceDir string, config *ScannerConfig) (*SourceInfo, erro Env: map[string]string{ "PORT": "8080", }, + Runtime: plan.RuntimeStruct{Language: "elixir"}, } return s, nil diff --git a/scanner/flask.go b/scanner/flask.go index 97f44b5c07..0e8aebac4f 100644 --- a/scanner/flask.go +++ b/scanner/flask.go @@ -1,6 +1,10 @@ package scanner -import "fmt" +import ( + "fmt" + + "github.com/superfly/flyctl/internal/command/launch/plan" +) func configureFlask(sourceDir string, _ *ScannerConfig) (*SourceInfo, error) { // require "Flask" to be in requirements.txt @@ -32,6 +36,7 @@ func configureFlask(sourceDir string, _ *ScannerConfig) (*SourceInfo, error) { Port: 8080, SkipDeploy: true, DeployDocs: `We have generated a simple Dockerfile for you. Modify it to fit your needs and run "fly deploy" to deploy your application.`, + Runtime: plan.RuntimeStruct{Language: "python"}, } return s, nil diff --git a/scanner/go.go b/scanner/go.go index 93be10ea76..51ed104758 100644 --- a/scanner/go.go +++ b/scanner/go.go @@ -2,9 +2,11 @@ package scanner import ( "fmt" + "os" + + "github.com/superfly/flyctl/internal/command/launch/plan" "github.com/superfly/flyctl/terminal" "golang.org/x/mod/modfile" - "os" ) func configureGo(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { @@ -12,18 +14,13 @@ func configureGo(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { return nil, nil } - s := &SourceInfo{ - Files: templates("templates/go"), - Family: "Go", - Port: 8080, - Env: map[string]string{ - "PORT": "8080", - }, - } + vars := make(map[string]interface{}) + + var skipDeploy bool if !absFileExists("go.sum") { - s.SkipDeploy = true - terminal.Warn("no go.sum file found, please adjust your Dockerfile to remove references to go.sum") + vars["skipGoSum"] = true + skipDeploy = true } gomod, parseErr := parseModfile() @@ -35,8 +32,18 @@ func configureGo(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { version = gomod.Go.Version } - s.BuildArgs = map[string]string{ - "GO_VERSION": version, + s := &SourceInfo{ + Files: templatesExecute("templates/go", vars), + Family: "Go", + Port: 8080, + Env: map[string]string{ + "PORT": "8080", + }, + Runtime: plan.RuntimeStruct{Language: "go", Version: version}, + BuildArgs: map[string]string{ + "GO_VERSION": version, + }, + SkipDeploy: skipDeploy, } return s, nil diff --git a/scanner/jsFramework.go b/scanner/jsFramework.go index ba759cab4a..d04aba8bd2 100644 --- a/scanner/jsFramework.go +++ b/scanner/jsFramework.go @@ -19,6 +19,44 @@ import ( var packageJson map[string]interface{} +// detectPortFromSource scans common entry point files for port definitions +// Returns the detected port or 0 if not found +func detectPortFromSource() int { + // Common entry point files to check + entryPoints := []string{"server.js", "index.js", "app.js", "src/server.js", "src/index.js", "src/app.js"} + + // Regex patterns to match port definitions + // Matches: process.env.PORT || "8080", process.env.PORT || 8080, const port = 8080 + portPatterns := []*regexp.Regexp{ + regexp.MustCompile(`process\.env\.PORT\s*\|\|\s*['"]?(\d{4,5})['"]?`), + regexp.MustCompile(`const\s+port\s*=\s*['"]?(\d{4,5})['"]?`), + regexp.MustCompile(`let\s+port\s*=\s*['"]?(\d{4,5})['"]?`), + regexp.MustCompile(`var\s+port\s*=\s*['"]?(\d{4,5})['"]?`), + regexp.MustCompile(`PORT\s*[:=]\s*['"]?(\d{4,5})['"]?`), // For Bun/Deno style + } + + for _, entryPoint := range entryPoints { + data, err := os.ReadFile(entryPoint) + if err != nil { + continue + } + + content := string(data) + for _, pattern := range portPatterns { + if matches := pattern.FindStringSubmatch(content); len(matches) > 1 { + if port, err := strconv.Atoi(matches[1]); err == nil { + // Common web server ports + if port >= 3000 && port <= 9999 { + return port + } + } + } + } + } + + return 0 +} + // Handle js frameworks separate from other node applications. Currently the requirements // for a framework is pretty low: to have a "start" script. Because we are actually // going to be running a js application to generate a Dockerfile there is one more @@ -202,9 +240,6 @@ func configureJsFramework(sourceDir string, config *ScannerConfig) (*SourceInfo, srcInfo.SkipDatabase = true } - // default to port 3000 - srcInfo.Port = 3000 - // etract port from EXPOSE statement in dockerfile dockerfile, err := os.ReadFile("Dockerfile") if err == nil { @@ -261,6 +296,17 @@ func configureJsFramework(sourceDir string, config *ScannerConfig) (*SourceInfo, srcInfo.Port = 80 } + // Try to detect port from source code if not found in Dockerfile + if srcInfo.Port == 0 { + if detectedPort := detectPortFromSource(); detectedPort != 0 { + srcInfo.Port = detectedPort + } + } + + if srcInfo.Port == 0 { + srcInfo.Port = 3000 + } + return srcInfo, nil } diff --git a/scanner/laravel.go b/scanner/laravel.go index b35fe79b6e..302c0deae7 100644 --- a/scanner/laravel.go +++ b/scanner/laravel.go @@ -64,9 +64,11 @@ func configureLaravel(sourceDir string, config *ScannerConfig) (*SourceInfo, err if err != nil || phpVersion == "" { // Fallback to 8.0, which has // the broadest compatibility - phpVersion = "8.0" + phpVersion = "8.1" } + s.Runtime = plan.RuntimeStruct{Language: "php", Version: phpVersion} + s.BuildArgs = map[string]string{ "PHP_VERSION": phpVersion, "NODE_VERSION": "18", diff --git a/scanner/lucky.go b/scanner/lucky.go index 77228d6dcd..e1dad34ab4 100644 --- a/scanner/lucky.go +++ b/scanner/lucky.go @@ -2,6 +2,7 @@ package scanner import ( "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/command/launch/plan" ) func configureLucky(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { @@ -39,6 +40,7 @@ func configureLucky(sourceDir string, config *ScannerConfig) (*SourceInfo, error UrlPrefix: "/", }, }, + Runtime: plan.RuntimeStruct{Language: "crystal"}, } return s, nil diff --git a/scanner/nextjs.go b/scanner/nextjs.go index af4001087e..e45e2834cd 100644 --- a/scanner/nextjs.go +++ b/scanner/nextjs.go @@ -22,5 +22,10 @@ func configureNextJs(sourceDir string, config *ScannerConfig) (*SourceInfo, erro "NEXT_PUBLIC_EXAMPLE": "Value goes here", } + // detect node.js version properly... + if nodeS, err := configureNode(sourceDir, config); err == nil && nodeS != nil { + s.Runtime = nodeS.Runtime + } + return s, nil } diff --git a/scanner/node.go b/scanner/node.go index bf7292eee9..a579996c1b 100644 --- a/scanner/node.go +++ b/scanner/node.go @@ -8,7 +8,10 @@ import ( "os/exec" "strings" + "golang.org/x/mod/semver" + "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/command/launch/plan" ) func configureNode(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { @@ -59,13 +62,18 @@ func configureNode(sourceDir string, config *ScannerConfig) (*SourceInfo, error) out, err := exec.Command("node", "-v").Output() if err == nil { - nodeVersion = strings.TrimSpace(string(out)) - if nodeVersion[:1] == "v" { - nodeVersion = nodeVersion[1:] + versionWithV := strings.TrimSpace(string(out)) + // Ensure version starts with 'v' for semver comparison + if !strings.HasPrefix(versionWithV, "v") { + versionWithV = "v" + versionWithV } - if nodeVersion < "16" { + // Check if node version is less than 16 using proper semver comparison + if semver.IsValid(versionWithV) && semver.Compare(versionWithV, "v16.0.0") < 0 { + nodeVersion = strings.TrimPrefix(versionWithV, "v") s.Notice += fmt.Sprintf("\n[WARNING] It looks like you have NodeJS v%s installed, but it has reached it's end of support. Using NodeJS v%s (LTS) to build your image instead.\n", nodeVersion, nodeLtsVersion) nodeVersion = nodeLtsVersion + } else { + nodeVersion = strings.TrimPrefix(versionWithV, "v") } } @@ -78,7 +86,16 @@ func configureNode(sourceDir string, config *ScannerConfig) (*SourceInfo, error) package_files := []string{"package.json"} _, err = os.Stat("yarn.lock") - vars["yarn"] = !os.IsNotExist(err) + // install yarn if there's a yarn.lock and if nodejs version is under 18 + yarnLockExists := !os.IsNotExist(err) + shouldInstallYarn := false + if yarnLockExists { + versionWithV := "v" + nodeVersion + if semver.IsValid(versionWithV) && semver.Compare(versionWithV, "v18.0.0") < 0 { + shouldInstallYarn = true + } + } + vars["yarn"] = shouldInstallYarn if os.IsNotExist(err) { vars["packager"] = "npm" @@ -171,5 +188,7 @@ Now: run 'fly deploy' to deploy your Node app. s.Env = env + s.Runtime = plan.RuntimeStruct{Language: "node", Version: nodeVersion} + return s, nil } diff --git a/scanner/nuxtjs.go b/scanner/nuxtjs.go index 087f5792a4..5170089eba 100644 --- a/scanner/nuxtjs.go +++ b/scanner/nuxtjs.go @@ -18,5 +18,10 @@ func configureNuxt(sourceDir string, config *ScannerConfig) (*SourceInfo, error) s.Files = templates("templates/nuxtjs") + // detect node.js version properly... + if nodeS, err := configureNode(sourceDir, config); err == nil && nodeS != nil { + s.Runtime = nodeS.Runtime + } + return s, nil } diff --git a/scanner/phoenix.go b/scanner/phoenix.go index 76bf1dd79e..f0a0f2baac 100644 --- a/scanner/phoenix.go +++ b/scanner/phoenix.go @@ -35,6 +35,7 @@ func configurePhoenix(sourceDir string, config *ScannerConfig) (*SourceInfo, err }, }, }, + Runtime: plan.RuntimeStruct{Language: "elixir"}, } // Detect if --copy-config and --now flags are set. If so, limited set of @@ -74,6 +75,44 @@ func configurePhoenix(sourceDir string, config *ScannerConfig) (*SourceInfo, err }, } + // This adds support on launch UI for repos with different .tool-versions + deployTrigger := os.Getenv("DEPLOY_TRIGGER") + if deployTrigger == "launch" && helpers.FileExists(filepath.Join(sourceDir, ".tool-versions")) { + // Check if asdf is installed + if _, err := exec.LookPath("asdf"); err != nil { + return nil, errors.New("We detected a .tool-versions file but 'asdf' is not installed or not in PATH. Please install asdf (https://asdf-vm.com/) or remove the .tool-versions file.") + } + + cmd := exec.Command("asdf", "install") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + return nil, errors.Wrap(err, "We identified .tool-versions but after running `asdf install` we ran into some errors. Please check that your `asdf install` builds successfully and try again.") + } + + // Check if mix is installed after asdf install + if _, err := exec.LookPath("mix"); err != nil { + return nil, errors.New("After running 'asdf install', the 'mix' command is not available. Please ensure Elixir is properly installed via asdf and in your PATH.") + } + + cmd = exec.Command("mix", "local.hex", "--force") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return nil, errors.Wrap(err, "After installing your elixir version with asdf we found an error while running `mix local.hex --force`. Please confirm that running this command works locally and try again.") + } + + cmd = exec.Command("mix", "local.rebar", "--force") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return nil, errors.Wrap(err, "After installing your elixir version with asdf we found an error while running `mix local.rebar --force`. Please confirm that running this command works locally and try again.") + } + } + // We found Phoenix, so check if the project compiles. cmd := exec.Command("mix", "do", "deps.get,", "compile") cmd.Stdout = os.Stdout diff --git a/scanner/python.go b/scanner/python.go index 047e496fdd..ad7a28058a 100644 --- a/scanner/python.go +++ b/scanner/python.go @@ -2,8 +2,12 @@ package scanner import ( "bufio" + "encoding/json" + "fmt" "os" + "os/exec" "path/filepath" + "regexp" "slices" "strings" "unicode" @@ -11,6 +15,7 @@ import ( "github.com/pkg/errors" "github.com/pelletier/go-toml/v2" + "github.com/superfly/flyctl/internal/command/launch/plan" "github.com/superfly/flyctl/terminal" ) @@ -51,6 +56,17 @@ type PyProjectToml struct { type Pipfile struct { Packages map[string]interface{} + Requires PipfileRequires `json:"requires" toml:"requires"` +} + +type PipfileRequires struct { + PythonVersion string `json:"python_version" toml:"python_version"` +} + +type PipfileLock struct { + Meta struct { + Requires PipfileRequires `json:"requires" toml:"requires"` + } `json:"_meta"` } type PyCfg struct { @@ -139,6 +155,9 @@ func intoSource(cfg PyCfg) (*SourceInfo, error) { return nil, nil } } + + runtime := plan.RuntimeStruct{Language: "python"} + vars[string(cfg.depStyle)] = true objectStorage := slices.Contains(cfg.deps, "boto3") || slices.Contains(cfg.deps, "boto") if app == "" { @@ -151,6 +170,7 @@ func intoSource(cfg PyCfg) (*SourceInfo, error) { Family: "FastAPI", Port: 8000, ObjectStorageDesired: objectStorage, + Runtime: runtime, }, nil } else if app == Flask { vars["flask"] = true @@ -159,6 +179,7 @@ func intoSource(cfg PyCfg) (*SourceInfo, error) { Family: "Flask", Port: 8080, ObjectStorageDesired: objectStorage, + Runtime: runtime, }, nil } else if app == Streamlit { vars["streamlit"] = true @@ -173,6 +194,7 @@ func intoSource(cfg PyCfg) (*SourceInfo, error) { Family: "Streamlit", Port: 8501, ObjectStorageDesired: objectStorage, + Runtime: runtime, }, nil } else { return nil, nil @@ -273,10 +295,12 @@ func configPipfile(sourceDir string, _ *ScannerConfig) (*SourceInfo, error) { dep := parsePyDep(dep) depList = append(depList, dep) } + pyVersion, _, err := extractPythonVersion() if err != nil { return nil, err } + appName := filepath.Base(sourceDir) cfg := PyCfg{pyVersion, appName, depList, Pipenv} return intoSource(cfg) @@ -339,6 +363,11 @@ func configurePython(sourceDir string, _ *ScannerConfig) (*SourceInfo, error) { return nil, nil } + pythonVersion, _, err := extractPythonVersion() + if err != nil { + return nil, err + } + s := &SourceInfo{ Files: templates("templates/python"), Builder: "paketobuildpacks/builder:base", @@ -349,7 +378,49 @@ func configurePython(sourceDir string, _ *ScannerConfig) (*SourceInfo, error) { }, SkipDeploy: true, DeployDocs: `We have generated a simple Procfile for you. Modify it to fit your needs and run "fly deploy" to deploy your application.`, + Runtime: plan.RuntimeStruct{Language: "python", Version: pythonVersion}, } return s, nil } + +func extractPythonVersion() (string, bool, error) { + var pipfileLock PipfileLock + contents, err := os.ReadFile("Pipfile.lock") + if err == nil { + if err := json.Unmarshal(contents, &pipfileLock); err == nil { + if pyVersion := pipfileLock.Meta.Requires.PythonVersion; pyVersion != "" { + return pyVersion, true, nil + } + } + } + + /* Example Output: + Python 3.11.2 + Python 3.12.0b4 + */ + pythonVersionOutput := "Python 3.12.0" // Fallback to 3.12 + + cmd := exec.Command("python3", "--version") + out, err := cmd.CombinedOutput() + if err == nil { + pythonVersionOutput = string(out) + } else { + cmd := exec.Command("python", "--version") + out, err := cmd.CombinedOutput() + if err == nil { + pythonVersionOutput = string(out) + } + } + + re := regexp.MustCompile(`Python ([0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z]+[0-9]+)?)`) + match := re.FindStringSubmatch(pythonVersionOutput) + + if len(match) > 1 { + version := match[1] + nonNumericRegex := regexp.MustCompile(`[^0-9.]`) + pinned := nonNumericRegex.MatchString(version) + return version, pinned, nil + } + return "", false, fmt.Errorf("Could not find Python version") +} diff --git a/scanner/rails.go b/scanner/rails.go index 5853d29014..c3c19e0030 100644 --- a/scanner/rails.go +++ b/scanner/rails.go @@ -20,7 +20,6 @@ import ( var healthcheck_channel = make(chan string) var bundle, ruby string -var binrails = filepath.Join(".", "bin", "rails") func configureRails(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { // `bundle init` will create a file with a commented out rails gem, @@ -129,19 +128,35 @@ func configureRails(sourceDir string, config *ScannerConfig) (*SourceInfo, error // mysql s.DatabaseDesired = DatabaseKindMySQL s.SkipDatabase = false - } else if !checksPass(sourceDir, fileExists("Dockerfile")) || checksPass(sourceDir, dirContains("Dockerfile", "libpq-dev", "postgres")) { - // postgresql + } else if checksPass(sourceDir, dirContains("Dockerfile", "libpq-dev", "postgres")) { + // postgresql (detected from existing Dockerfile) s.DatabaseDesired = DatabaseKindPostgres s.SkipDatabase = false } else if checksPass(sourceDir, dirContains("Dockerfile", "sqlite3")) { - // sqlite + // sqlite (detected from existing Dockerfile) s.DatabaseDesired = DatabaseKindSqlite s.SkipDatabase = true - s.ObjectStorageDesired = true + // Only request object storage if not skipping extensions (for litestream replication) + if os.Getenv("SKIP_EXTENSIONS") == "" { + s.ObjectStorageDesired = true + } + } else if checksPass(sourceDir, dirContains("config/database.yml", "adapter.*sqlite")) { + // sqlite (detected from database.yml) + s.DatabaseDesired = DatabaseKindSqlite + s.SkipDatabase = true + // Don't set ObjectStorageDesired here - let the normal object storage detection + // logic handle it by checking for aws-sdk-s3, active_storage, etc. + } else if checksPass(sourceDir, dirContains("config/database.yml", "adapter.*(mysql|trilogy)")) { + // mysql (detected from database.yml) + s.DatabaseDesired = DatabaseKindMySQL + s.SkipDatabase = false + } else if checksPass(sourceDir, dirContains("config/database.yml", "adapter.*postgres")) { + // postgresql (detected from database.yml) + s.DatabaseDesired = DatabaseKindPostgres + s.SkipDatabase = false } else { - // no database s.DatabaseDesired = DatabaseKindNone - s.SkipDatabase = true + s.SkipDatabase = false } // enable redis if there are any action cable / anycable channels @@ -226,6 +241,7 @@ func configureRails(sourceDir string, config *ScannerConfig) (*SourceInfo, error // if the app does not use Rails encrypted credentials. Rails v6 added // support for multi-environment credentials. Use the Rails searching // sequence for production credentials to determine the RAILS_MASTER_KEY. + binrails := filepath.Join("bin", "rails") masterKey, err := os.ReadFile("config/credentials/production.key") if err != nil { masterKey, err = os.ReadFile("config/master.key") @@ -290,22 +306,30 @@ Once ready: run 'fly deploy' to deploy your Rails app. } // fetch healthcheck route in a separate thread - go func() { - ruby, err := exec.LookPath("ruby") - if err != nil { + // Skip in tests to avoid file handle issues on Windows during cleanup + if config.SkipHealthcheck { + // Send empty value immediately to avoid blocking code that reads from the channel + go func() { healthcheck_channel <- "" - return - } + }() + } else { + go func() { + ruby, err := exec.LookPath("ruby") + if err != nil { + healthcheck_channel <- "" + return + } - out, err := exec.Command(ruby, binrails, "runner", - "puts Rails.application.routes.url_helpers.rails_health_check_path").Output() + out, err := exec.Command(ruby, binrails, "runner", + "puts Rails.application.routes.url_helpers.rails_health_check_path").Output() - if err == nil { - healthcheck_channel <- strings.TrimSpace(string(out)) - } else { - healthcheck_channel <- "" - } - }() + if err == nil { + healthcheck_channel <- strings.TrimSpace(string(out)) + } else { + healthcheck_channel <- "" + } + }() + } return s, nil } @@ -319,6 +343,8 @@ func RailsCallback(appName string, srcInfo *SourceInfo, plan *plan.LaunchPlan, f // If the generator fails but a Dockerfile exists, warn the user and proceed. Only fail if no // Dockerfile exists at the end of this process. + binrails := filepath.Join("bin", "rails") + // If bundle or ruby are not available, check if Dockerfile exists and skip generator if bundle == "" || ruby == "" { if _, err := os.Stat("Dockerfile"); err == nil { @@ -346,6 +372,7 @@ func RailsCallback(appName string, srcInfo *SourceInfo, plan *plan.LaunchPlan, f } } } + return nil } else { return errors.New("No Dockerfile found and bundle/ruby not available to generate one") @@ -468,8 +495,7 @@ func RailsCallback(appName string, srcInfo *SourceInfo, plan *plan.LaunchPlan, f } // base generate command - args := []string{binrails, "generate", "dockerfile", - "--label=fly_launch_runtime:rails"} + args := []string{binrails, "generate", "dockerfile", "--label=fly_launch_runtime:rails"} // skip prompt to replace files if Dockerfile already exists _, err = os.Stat("Dockerfile") @@ -481,9 +507,21 @@ func RailsCallback(appName string, srcInfo *SourceInfo, plan *plan.LaunchPlan, f } } - // add postgres - if plan.Postgres.Provider() != nil { + // Pass database flags based on what the app actually uses, not what's in the plan + // This ensures the Dockerfile is configured correctly even if the plan includes + // a different database (e.g., when migrating from SQLite to Postgres or when + // extensions are skipped in tests) + switch srcInfo.DatabaseDesired { + case DatabaseKindPostgres: args = append(args, "--postgresql", "--no-prepare") + case DatabaseKindMySQL: + args = append(args, "--mysql") + case DatabaseKindSqlite: + // No additional flags needed for SQLite here + case DatabaseKindNone: + // No database flags needed + default: + // Unknown database kind; no flags added } // add redis diff --git a/scanner/rails_dockerfile_test.go b/scanner/rails_dockerfile_test.go index e168c8e69a..f16d8fef6d 100644 --- a/scanner/rails_dockerfile_test.go +++ b/scanner/rails_dockerfile_test.go @@ -4,11 +4,24 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// drainHealthcheckChannel waits for the healthcheck goroutine to complete +// by reading from the channel with a timeout. This prevents file handle +// issues on Windows during test cleanup. +func drainHealthcheckChannel() { + select { + case <-healthcheck_channel: + // Goroutine completed and sent its result + case <-time.After(200 * time.Millisecond): + // Timeout - goroutine may still be running, but we've given it time + } +} + func TestRailsScannerWithExistingDockerfile(t *testing.T) { t.Run("uses existing Dockerfile when bundle install fails", func(t *testing.T) { dir := t.TempDir() @@ -40,7 +53,8 @@ CMD ["rails", "server"] require.NoError(t, err) // Run the scanner - it should detect the Rails app - si, err := configureRails(dir, &ScannerConfig{}) + si, err := configureRails(dir, &ScannerConfig{SkipHealthcheck: true}) + drainHealthcheckChannel() // Wait for goroutine to complete before cleanup // The scanner should succeed in detecting Rails require.NoError(t, err) @@ -80,7 +94,8 @@ CMD ["rails", "server"]` err = os.Chdir(dir) require.NoError(t, err) - si, err := configureRails(dir, &ScannerConfig{}) + si, err := configureRails(dir, &ScannerConfig{SkipHealthcheck: true}) + drainHealthcheckChannel() // Wait for goroutine to complete before cleanup require.NoError(t, err) require.NotNil(t, si) @@ -113,7 +128,8 @@ CMD ["rails", "server"]` err = os.Chdir(dir) require.NoError(t, err) - si, err := configureRails(dir, &ScannerConfig{}) + si, err := configureRails(dir, &ScannerConfig{SkipHealthcheck: true}) + drainHealthcheckChannel() // Wait for goroutine to complete before cleanup require.NoError(t, err) require.NotNil(t, si) @@ -144,7 +160,8 @@ CMD ["rails", "server"]` // If bundle is not found and no Dockerfile exists, it should fail // For now, we just verify that the scanner can detect Rails - si, err := configureRails(dir, &ScannerConfig{}) + si, err := configureRails(dir, &ScannerConfig{SkipHealthcheck: true}) + drainHealthcheckChannel() // Wait for goroutine to complete before cleanup // If bundle IS available locally, this will succeed // If bundle is NOT available and no Dockerfile exists, this should fail @@ -187,7 +204,8 @@ EXPOSE 3000` err = os.Chdir(dir) require.NoError(t, err) - si, err := configureRails(dir, &ScannerConfig{}) + si, err := configureRails(dir, &ScannerConfig{SkipHealthcheck: true}) + drainHealthcheckChannel() // Wait for goroutine to complete before cleanup require.NoError(t, err) require.NotNil(t, si) assert.Equal(t, "Rails", si.Family) diff --git a/scanner/redwood.go b/scanner/redwood.go index 0b67010629..0ac13967d9 100644 --- a/scanner/redwood.go +++ b/scanner/redwood.go @@ -30,5 +30,10 @@ func configureRedwood(sourceDir string, config *ScannerConfig) (*SourceInfo, err s.Notice = "\nThis deployment will run an SQLite on a single dedicated volume. The app can't scale beyond a single instance. Look into 'fly postgres' for a more robust production database that supports scaling up. \n" } + // detect node.js version properly... + if nodeS, err := configureNode(sourceDir, config); err == nil && nodeS != nil { + s.Runtime = nodeS.Runtime + } + return s, nil } diff --git a/scanner/ruby.go b/scanner/ruby.go index de2d11d885..d0a68a64e8 100644 --- a/scanner/ruby.go +++ b/scanner/ruby.go @@ -55,6 +55,7 @@ func extractRubyVersion(lockfilePath string, gemfilePath string, rubyVersionPath for i, name := range re.SubexpNames() { if len(m) > 0 && name == "version" { version = m[i] + break } } } @@ -66,14 +67,7 @@ func extractRubyVersion(lockfilePath string, gemfilePath string, rubyVersionPath return "", err } - re := regexp.MustCompile(`ruby \"(?P[\d.]+)\"`) - m := re.FindStringSubmatch(string(gemfileContents)) - - for i, name := range re.SubexpNames() { - if len(m) > 0 && name == "version" { - version = m[i] - } - } + version = extractGemfileRuby(gemfileContents) } if version == "" { @@ -84,13 +78,11 @@ func extractRubyVersion(lockfilePath string, gemfilePath string, rubyVersionPath return "", err } - version = string(versionString) + version = strings.TrimSpace(string(versionString)) } } if version == "" { - version = "3.3.5" - out, err := exec.Command("ruby", "-v").Output() if err == nil { @@ -101,6 +93,7 @@ func extractRubyVersion(lockfilePath string, gemfilePath string, rubyVersionPath for i, name := range re.SubexpNames() { if len(m) > 0 && name == "version" { version = m[i] + break } } } @@ -108,3 +101,16 @@ func extractRubyVersion(lockfilePath string, gemfilePath string, rubyVersionPath return version, nil } + +func extractGemfileRuby(contents []byte) string { + re := regexp.MustCompile(`ruby ["'](?P[\d.]+)["']`) + m := re.FindStringSubmatch(string(contents)) + + for i, name := range re.SubexpNames() { + if len(m) > 0 && name == "version" { + return m[i] + } + } + + return "" +} diff --git a/scanner/ruby_test.go b/scanner/ruby_test.go new file mode 100644 index 0000000000..760b335899 --- /dev/null +++ b/scanner/ruby_test.go @@ -0,0 +1,21 @@ +package scanner + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRubyVersionParsing(t *testing.T) { + v := extractGemfileRuby([]byte(` + source "https://rubygems.org" + + ruby '3.1.0' + `)) + + require.Equal(t, v, "3.1.0") + + v = extractGemfileRuby([]byte(`ruby "3.1.0"`)) + + require.Equal(t, v, "3.1.0") +} diff --git a/scanner/scanner.go b/scanner/scanner.go index 0549cfa49c..1071b74186 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -3,6 +3,7 @@ package scanner import ( "embed" "io/fs" + "os" "path/filepath" "strings" "text/template" @@ -100,9 +101,10 @@ type Static = appconfig.Static type Volume = appconfig.Mount type ScannerConfig struct { - Mode string - ExistingPort int - Colorize *iostreams.ColorScheme + Mode string + ExistingPort int + Colorize *iostreams.ColorScheme + SkipHealthcheck bool // Skip healthcheck goroutine (primarily for tests) } type GitHubActionsStruct struct { @@ -144,8 +146,11 @@ func Scan(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { if err != nil { return nil, err } + optOutGithubActions := os.Getenv("OPT_OUT_GITHUB_ACTIONS") if si != nil { - github_actions(sourceDir, &si.GitHubActions) + if optOutGithubActions == "" { + github_actions(sourceDir, &si.GitHubActions) + } return si, nil } } diff --git a/scanner/templates/deno/Dockerfile b/scanner/templates/deno/Dockerfile index 5bd38b3a56..4efb8eb3c8 100644 --- a/scanner/templates/deno/Dockerfile +++ b/scanner/templates/deno/Dockerfile @@ -4,15 +4,23 @@ ARG DENO_VERSION=2.4.5 ARG BIN_IMAGE=denoland/deno:bin-${DENO_VERSION} FROM ${BIN_IMAGE} AS bin -FROM frolvlad/alpine-glibc:alpine-3.22 +FROM gcr.io/distroless/cc as cc -RUN apk --no-cache add ca-certificates +FROM alpine:latest + +# Inspired by https://github.com/dojyorin/deno_docker_image/blob/master/src/alpine.dockerfile +COPY --from=cc --chown=root:root --chmod=755 /lib/*-linux-gnu/* /usr/local/lib/ +COPY --from=cc --chown=root:root --chmod=755 /lib/ld-linux-* /lib/ RUN addgroup --gid 1000 deno \ && adduser --uid 1000 --disabled-password deno --ingroup deno \ && mkdir /deno-dir/ \ - && chown deno:deno /deno-dir/ + && chown deno:deno /deno-dir/ \ + && mkdir /lib64 \ + && ln -s /usr/local/lib/ld-linux-* /lib64/ +ENV LD_LIBRARY_PATH="/usr/local/lib" +ENV DENO_USE_CGROUPS=1 ENV DENO_DIR /deno-dir/ ENV DENO_INSTALL_ROOT /usr/local @@ -24,4 +32,4 @@ WORKDIR /deno-dir COPY . . ENTRYPOINT ["/bin/deno"] -CMD ["run", "--allow-net", "https://deno.land/std/examples/echo_server.ts"] +CMD ["run", "-A", "./index.ts"] diff --git a/scanner/templates/go/Dockerfile b/scanner/templates/go/Dockerfile index fa878e19b1..ac09cf1cfb 100644 --- a/scanner/templates/go/Dockerfile +++ b/scanner/templates/go/Dockerfile @@ -2,7 +2,11 @@ ARG GO_VERSION=1 FROM golang:${GO_VERSION}-bookworm as builder WORKDIR /usr/src/app +{{ if .skipGoSum -}} +COPY go.mod ./ +{{ else -}} COPY go.mod go.sum ./ +{{ end -}} RUN go mod download && go mod verify COPY . . RUN go build -v -o /run-app . diff --git a/scanner/templates/node/Dockerfile b/scanner/templates/node/Dockerfile index 44eea37687..93e56f8f86 100644 --- a/scanner/templates/node/Dockerfile +++ b/scanner/templates/node/Dockerfile @@ -14,7 +14,7 @@ ENV NODE_ENV=production {{ if .yarn -}} ARG YARN_VERSION={{ .yarnVersion }} -RUN npm install -g yarn@$YARN_VERSION +RUN npm install --legacy-peer-deps -g yarn@$YARN_VERSION {{ end }} # Throw-away build stage to reduce size of final image diff --git a/scripts/delete_preflight_apps.sh b/scripts/delete_preflight_apps.sh index 5546d3c4a9..974f0704ca 100755 --- a/scripts/delete_preflight_apps.sh +++ b/scripts/delete_preflight_apps.sh @@ -13,5 +13,5 @@ do continue fi echo "Destroy $app" - flyctl apps destroy --yes "${app}" + flyctl apps destroy --yes "${app}" || true done diff --git a/test/preflight/fly_launch_test.go b/test/preflight/fly_launch_test.go index 1782004f78..a6403aa2e8 100644 --- a/test/preflight/fly_launch_test.go +++ b/test/preflight/fly_launch_test.go @@ -43,9 +43,10 @@ func TestFlyLaunchV2(t *testing.T) { "primary_region": f.PrimaryRegion(), "build": map[string]any{"image": "nginx"}, "vm": []any{map[string]any{ - "cpu_kind": "shared", - "cpus": int64(1), - "memory": "1gb", + "cpu_kind": "shared", + "cpus": int64(1), + "memory": "1gb", + "memory_mb": int64(1024), }}, "http_service": map[string]any{ "force_https": true, @@ -88,9 +89,10 @@ func TestFlyLaunchWithTOML(t *testing.T) { "status": map[string]any{"type": "tcp", "port": int64(5500)}, }, "vm": []any{map[string]any{ - "cpu_kind": "shared", - "cpus": int64(1), - "memory": "1gb", + "cpu_kind": "shared", + "cpus": int64(1), + "memory": "1gb", + "memory_mb": int64(1024), }}, } require.EqualValues(f, want, toml)