Skip to content

Commit f286ff0

Browse files
authored
deployer (#4654)
Adds launch plan subcommands and refactors launch flow to support deployer integration. Includes scanner improvements, framework detection fixes, and enhanced managed Postgres functionality. Launch Plan Commands - Support for manifest-based configuration via --from-manifest flag - Added --force-name and --no-create-app flags for programmatic app creation - Improved flag precedence handling for --app, --region, and --org in generate step - Fixed compute override application across all configurations with nil checks Scanner Improvements - Deno: Fixed malformed regex, better main.ts fallback detection, upgraded default version to 2.0.4 - Rails: Fixed SQLite Dockerfile generation, improved ruby version detection, fixed binrails edge case in multi-test runs - Python/Django: Added WSGI/Gunicorn warnings, improved Python version detection from Pipfile - JS Frameworks: Fixed port detection (now respects specified ports instead of always defaulting to 3000) - Go: Better handling when go.sum is missing - PHP: Added composer.json scanning for version detection Managed Postgres (MPG) - Improved retry logic with network error handling and progress logging during provisioning Configuration - Fixed extension name prompting when using --yes flag - Better handling of internal port overrides - Fixed compute validation with isComputeValid helper
1 parent bdbb230 commit f286ff0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+828
-206
lines changed

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ on:
33
schedule:
44
- cron: '21 */2 * * *'
55
push:
6+
pull_request:
67

78
concurrency:
89
group: ${{ github.workflow }}-${{ github.ref }}
@@ -36,6 +37,8 @@ jobs:
3637
overwrite: true
3738

3839
preflight:
40+
# Only run preflight on master pushes, scheduled runs, or pull requests
41+
if: ${{ github.event_name == 'schedule' || github.ref == 'refs/heads/master' || github.event_name == 'pull_request' }}
3942
needs: test_build
4043
uses: ./.github/workflows/preflight.yml
4144
secrets: inherit

.github/workflows/preflight.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- uses: actions/checkout@v6
2525
- uses: actions/setup-go@v6
2626
with:
27-
go-version-file: "go.mod"
27+
go-version-file: 'go.mod'
2828
check-latest: true
2929
- name: Get go version
3030
id: go-version
@@ -49,8 +49,8 @@ jobs:
4949
FLY_PREFLIGHT_TEST_ACCESS_TOKEN: ${{ secrets.FLYCTL_PREFLIGHT_CI_FLY_API_TOKEN }}
5050
FLY_PREFLIGHT_TEST_FLY_ORG: flyctl-ci-preflight
5151
FLY_PREFLIGHT_TEST_FLY_REGIONS: ${{ inputs.region }}
52-
FLY_PREFLIGHT_TEST_NO_PRINT_HISTORY_ON_FAIL: "true"
53-
FLY_FORCE_TRACE: "true"
52+
FLY_PREFLIGHT_TEST_NO_PRINT_HISTORY_ON_FAIL: 'true'
53+
FLY_FORCE_TRACE: 'true'
5454
run: |
5555
(test -e master-build/flyctl) && mv master-build/flyctl bin/flyctl
5656
chmod +x bin/flyctl

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,6 @@ out
4242
# generated release meta
4343
release.json
4444

45+
.fly
4546
CLAUDE.md
47+
.claude/settings.local.json

internal/command/deploy/deploy_build.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ func determineImage(ctx context.Context, app *flaps.App, appConfig *appconfig.Co
146146
img, err = resolver.ResolveReference(ctx, io, opts)
147147
if err != nil {
148148
tracing.RecordError(span, err, "failed to resolve reference for prebuilt docker image")
149-
return
149+
img = &imgsrc.DeploymentImage{
150+
ID: imageRef,
151+
Tag: imageRef,
152+
}
153+
terminal.Debugf("Failed to resolve reference for prebuilt docker image, using imageRef %s: %v\n", img.String(), err)
154+
err = nil
150155
}
151156

152157
span.AddEvent("using pre-built docker image")

internal/command/extensions/core/core.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,17 @@ func ProvisionExtension(ctx context.Context, params ExtensionParams) (extension
102102
if override := params.OverrideName; override != nil {
103103
name = *override
104104
} else {
105-
if name == "" {
106-
name = flag.GetString(ctx, "name")
107-
}
105+
name = flag.GetString(ctx, "name")
108106

109107
if name == "" {
110108
if provider.NameSuffix != "" && targetApp.Name != "" {
111109
name = targetApp.Name + "-" + provider.NameSuffix
112110
}
113-
err = prompt.String(ctx, &name, "Choose a name, use the default, or leave blank to generate one:", name, false)
114-
if err != nil {
115-
return
111+
if !flag.GetYes(ctx) {
112+
err = prompt.String(ctx, &name, "Choose a name, use the default, or leave blank to generate one:", name, false)
113+
if err != nil {
114+
return
115+
}
116116
}
117117
}
118118
}

internal/command/launch/cmd.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,20 @@ func New() (cmd *cobra.Command) {
144144
Name: "yaml",
145145
Description: "Generate configuration in YAML format",
146146
},
147+
// don't try to generate a name
147148
flag.Bool{
148-
Name: "no-create",
149-
Description: "Do not create an app, only generate configuration files",
149+
Name: "force-name",
150+
Description: "Force app name supplied by --name",
151+
Default: false,
152+
Hidden: true,
153+
},
154+
// like reuse-app, but non-legacy!
155+
flag.Bool{
156+
Name: "no-create-app",
157+
Description: "Do not create an app",
158+
Default: false,
159+
Hidden: true,
160+
Aliases: []string{"no-create"},
150161
},
151162
flag.String{
152163
Name: "auto-stop",
@@ -337,6 +348,46 @@ func run(ctx context.Context) (err error) {
337348
return err
338349
}
339350

351+
planStep := plan.GetPlanStep(ctx)
352+
353+
if launchManifest != nil && planStep != "generate" {
354+
// we loaded a manifest...
355+
cache = &planBuildCache{
356+
appConfig: launchManifest.Config,
357+
sourceInfo: nil,
358+
appNameValidated: true,
359+
warnedNoCcHa: true,
360+
}
361+
}
362+
363+
// For "generate" step, allow command-line flags to override manifest values.
364+
// This is necessary because buildManifest() is skipped when loading a manifest from file.
365+
// The "generate" step specifically needs this because it's called after propose/create steps,
366+
// and the deployer wrapper needs to be able to override specific values without re-proposing.
367+
if launchManifest != nil && planStep == "generate" {
368+
// Override org if --org flag was provided
369+
if orgRequested := flag.GetOrg(ctx); orgRequested != "" {
370+
launchManifest.Plan.OrgSlug = orgRequested
371+
}
372+
373+
// Override app name if --app flag was provided
374+
// This allows explicit override while preserving manifest value by default
375+
if appRequested := flag.GetApp(ctx); appRequested != "" && flag.IsSpecified(ctx, "app") {
376+
launchManifest.Plan.AppName = appRequested
377+
}
378+
379+
// Override region if --region flag was provided
380+
if regionRequested := flag.GetRegion(ctx); regionRequested != "" && flag.IsSpecified(ctx, "region") {
381+
launchManifest.Plan.RegionCode = regionRequested
382+
}
383+
384+
// Initialize PlanSource if nil (happens when loading from JSON because fields are unexported)
385+
// This prevents nil pointer dereference in PlanSummary and other code that accesses PlanSource
386+
if launchManifest.PlanSource == nil {
387+
launchManifest.PlanSource = newDefaultPlanSource("from manifest")
388+
}
389+
}
390+
340391
// "--from" arg handling
341392
parentCtx := ctx
342393
ctx, parentConfig, err := setupFromTemplate(ctx)
@@ -350,19 +401,20 @@ func run(ctx context.Context) (err error) {
350401
recoverableErrors := recoverableErrorBuilder{canEnterUi: canEnterUi}
351402

352403
if launchManifest == nil {
353-
354404
launchManifest, cache, err = buildManifest(ctx, parentConfig, &recoverableErrors)
355405
if err != nil {
356406
var recoverableErr recoverableInUiError
357-
if errors.As(err, &recoverableErr) && canEnterUi {
358-
} else {
407+
if !errors.As(err, &recoverableErr) || !canEnterUi {
359408
return err
360409
}
361410
}
362411

363-
if flag.GetBool(ctx, "manifest") {
412+
manifestFlag := flag.GetBool(ctx, "manifest")
413+
manifestPath := flag.GetString(ctx, "manifest-path")
414+
415+
if manifestFlag {
364416
var jsonEncoder *json.Encoder
365-
if manifestPath := flag.GetString(ctx, "manifest-path"); manifestPath != "" {
417+
if manifestPath != "" {
366418
file, err := os.OpenFile(manifestPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
367419
if err != nil {
368420
return err
@@ -374,7 +426,8 @@ func run(ctx context.Context) (err error) {
374426
jsonEncoder = json.NewEncoder(io.Out)
375427
}
376428
jsonEncoder.SetIndent("", " ")
377-
return jsonEncoder.Encode(launchManifest)
429+
encodeErr := jsonEncoder.Encode(launchManifest)
430+
return encodeErr
378431
}
379432
}
380433

@@ -419,7 +472,6 @@ func run(ctx context.Context) (err error) {
419472
family = state.sourceInfo.Family
420473
}
421474

422-
planStep := plan.GetPlanStep(ctx)
423475
if planStep == "" {
424476
colorize := io.ColorScheme()
425477

internal/command/launch/cmd_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ func TestValidatePostgresFlags(t *testing.T) {
105105
}
106106
}
107107

108+
func TestNewDefaultPlanSource(t *testing.T) {
109+
source := "test source"
110+
planSource := newDefaultPlanSource(source)
111+
112+
assert.NotNil(t, planSource)
113+
assert.Equal(t, source, planSource.appNameSource)
114+
assert.Equal(t, source, planSource.regionSource)
115+
assert.Equal(t, source, planSource.orgSource)
116+
assert.Equal(t, source, planSource.computeSource)
117+
assert.Equal(t, source, planSource.postgresSource)
118+
assert.Equal(t, source, planSource.redisSource)
119+
assert.Equal(t, source, planSource.tigrisSource)
120+
assert.Equal(t, source, planSource.sentrySource)
121+
}
122+
108123
func TestParseMountOptions(t *testing.T) {
109124
tests := []struct {
110125
name string

internal/command/launch/launch.go

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ func (state *launchState) Launch(ctx context.Context) error {
110110
}
111111
}
112112

113+
if planStep != "generate" {
114+
// Override internal port if requested using --internal-port flag
115+
if n := flag.GetInt(ctx, "internal-port"); n > 0 {
116+
state.appConfig.SetInternalPort(n)
117+
}
118+
}
119+
113120
// if the user specified a command, set it in the app config
114121
if flag.GetString(ctx, "command") != "" {
115122
if state.appConfig.Processes == nil {
@@ -276,17 +283,30 @@ func (state *launchState) updateComputeFromDeprecatedGuestFields(ctx context.Con
276283
return nil
277284
}
278285

286+
// isComputeValid checks if a compute configuration is valid and can be safely modified
287+
func isComputeValid(c *appconfig.Compute) bool {
288+
return c != nil && c.MachineGuest != nil
289+
}
290+
279291
// updateConfig populates the appConfig with the plan's values
280292
func (state *launchState) updateConfig(ctx context.Context) {
281-
state.appConfig.AppName = state.Plan.AppName
282-
state.appConfig.PrimaryRegion = state.Plan.RegionCode
283-
if state.env != nil {
284-
state.appConfig.SetEnvVariables(state.env)
293+
appConfig := state.appConfig
294+
env := state.env
295+
plan := state.Plan
296+
297+
if plan == nil {
298+
return
285299
}
286300

287-
state.appConfig.Compute = state.Plan.Compute
301+
appConfig.AppName = plan.AppName
302+
appConfig.PrimaryRegion = plan.RegionCode
303+
if env != nil {
304+
appConfig.SetEnvVariables(env)
305+
}
306+
307+
appConfig.Compute = plan.Compute
288308

289-
if state.Plan.HttpServicePort != 0 {
309+
if plan.HttpServicePort != 0 {
290310
autostop := fly.MachineAutostopStop
291311
autostopFlag := flag.GetString(ctx, "auto-stop")
292312

@@ -296,8 +316,11 @@ func (state *launchState) updateConfig(ctx context.Context) {
296316
autostop = fly.MachineAutostopSuspend
297317

298318
// if any compute has a GPU or more than 2GB of memory, set autostop to stop
299-
for _, compute := range state.appConfig.Compute {
300-
if compute.MachineGuest != nil && compute.MachineGuest.GPUKind != "" {
319+
for _, compute := range appConfig.Compute {
320+
if !isComputeValid(compute) {
321+
continue
322+
}
323+
if compute.MachineGuest.GPUKind != "" {
301324
autostop = fly.MachineAutostopStop
302325
break
303326
}
@@ -312,18 +335,44 @@ func (state *launchState) updateConfig(ctx context.Context) {
312335
}
313336
}
314337

315-
if state.appConfig.HTTPService == nil {
316-
state.appConfig.HTTPService = &appconfig.HTTPService{
338+
if appConfig.HTTPService == nil {
339+
appConfig.HTTPService = &appconfig.HTTPService{
317340
ForceHTTPS: true,
318341
AutoStartMachines: fly.Pointer(true),
319342
AutoStopMachines: fly.Pointer(autostop),
320343
MinMachinesRunning: fly.Pointer(0),
321344
Processes: []string{"app"},
322345
}
323346
}
324-
state.appConfig.HTTPService.InternalPort = state.Plan.HttpServicePort
347+
appConfig.HTTPService.InternalPort = plan.HttpServicePort
325348
} else {
326-
state.appConfig.HTTPService = nil
349+
appConfig.HTTPService = nil
350+
}
351+
352+
// Apply plan-level compute overrides to all compute configurations
353+
// Only set fields that haven't already been set (defensive against updateComputeFromDeprecatedGuestFields)
354+
if plan.CPUKind != "" {
355+
for i := range appConfig.Compute {
356+
if isComputeValid(appConfig.Compute[i]) && appConfig.Compute[i].CPUKind == "" {
357+
appConfig.Compute[i].CPUKind = plan.CPUKind
358+
}
359+
}
360+
}
361+
362+
if plan.CPUs != 0 {
363+
for i := range appConfig.Compute {
364+
if isComputeValid(appConfig.Compute[i]) && appConfig.Compute[i].CPUs == 0 {
365+
appConfig.Compute[i].CPUs = plan.CPUs
366+
}
367+
}
368+
}
369+
370+
if plan.MemoryMB != 0 {
371+
for i := range appConfig.Compute {
372+
if isComputeValid(appConfig.Compute[i]) && appConfig.Compute[i].MemoryMB == 0 {
373+
appConfig.Compute[i].MemoryMB = plan.MemoryMB
374+
}
375+
}
327376
}
328377
}
329378

internal/command/launch/launch_databases.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,18 @@ func (state *launchState) createManagedPostgres(ctx context.Context) error {
285285
retry.Delay(2*time.Second),
286286
retry.MaxDelay(30*time.Second),
287287
retry.DelayType(retry.BackOffDelay),
288+
retry.OnRetry(func(n uint, err error) {
289+
// Log network-related errors and periodic status updates
290+
if containsNetworkError(err.Error()) {
291+
s.Stop()
292+
fmt.Fprintf(io.Out, "Retrying status check due to network issue: %v\n", err)
293+
s = spinner.Run(io, colorize.Yellow("Provisioning your Managed Postgres cluster..."))
294+
} else if n%10 == 0 && n > 0 { // Log every 10th attempt to show progress
295+
s.Stop()
296+
fmt.Fprintf(io.Out, "Still waiting for cluster to be ready (attempt %d)...\n", n+1)
297+
s = spinner.Run(io, colorize.Yellow("Provisioning your Managed Postgres cluster..."))
298+
}
299+
}),
288300
)
289301

290302
// Stop the spinner

internal/command/launch/launch_frameworks.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/superfly/flyctl/helpers"
1717
"github.com/superfly/flyctl/internal/appconfig"
1818
"github.com/superfly/flyctl/internal/appsecrets"
19+
"github.com/superfly/flyctl/internal/command/launch/plan"
1920
"github.com/superfly/flyctl/internal/flag"
2021
"github.com/superfly/flyctl/internal/flapsutil"
2122
"github.com/superfly/flyctl/internal/flyutil"
@@ -35,11 +36,13 @@ func (state *launchState) setupGitHubActions(ctx context.Context, appName string
3536
gh, err := exec.LookPath("gh")
3637

3738
if err != nil {
38-
io := iostreams.FromContext(ctx)
39-
colorize := io.ColorScheme()
40-
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")
41-
fmt.Fprintln(io.Out, "See https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions")
42-
fmt.Fprintln(io.Out)
39+
if plan.GetPlanStep(ctx) == "" {
40+
io := iostreams.FromContext(ctx)
41+
colorize := io.ColorScheme()
42+
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")
43+
fmt.Fprintln(io.Out, "See https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions")
44+
fmt.Fprintln(io.Out)
45+
}
4346
} else {
4447
apiClient := flyutil.ClientFromContext(ctx)
4548

0 commit comments

Comments
 (0)