From 7e6e3f0d0cea3767bdf170571d0110f810118ed6 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Wed, 25 Dec 2024 12:58:38 -0500 Subject: [PATCH] add support for [deploy] seed_command The motivation here is to support fly launch (from either the CLI or web UI) getting an application completely up and running. In general, this not only involves creating of databases and running of migrations, but also seeding the database. The timing of the run of the seed command is after the first migration is run, and before any prerendering/SSG or deploy. This is awkward/impossible with the current flow. Seed support has been available in Rails for quite some time, and for the popular prisma ORM, the seed command can be determined from the `package.json` file: https://github.com/prisma/prisma-examples/blob/7a74fc64c82037f15b23e189a241bc643023f957/orm/nextjs-trpc/package.json#L36-L38 This implementation add [deploy] seed_command to fly.toml. Launch will insert that value (if present) into the ctx. . Deploy will only run that command if it is found in the context. The guarantees that the seed command is only run when deploy is called by Launch. At the moment, this is accomplished by launching new ephemeral machines for each command. Perhaps that could be optimized to reuse a single machine. Also perhaps, that could be generalized to support a series of post-build, pre-deploy commands. This depends on an unreleased change to dockerfile_node: https://github.com/fly-apps/dockerfile-node/commit/eebcf1e528ccc7cca0c4dd8729c3383024806b07 If is possible to test this change against dockerfile-node from github, using a Prisma ORM Fullstack example: https://github.com/prisma/prisma-examples?tab=readme-ov-file#prisma-orm This involves downloading the example, installing dockerfile-node, modifying the example to use PostgreSQL as the database, creating migrations, and finally, launching. ``` npx try-prisma@latest --template orm/sveltekit cd orm_sveltekit npm install --save-dev fly-apps/dockerfile-node sed -i.bak 's/sqlite/postgresql/;s/"file:.\/dev.db"/env("DATABASE_URL")/' prisma/schema.prisma psql -c "DROP DATABASE IF EXISTS testdb" export DATABASE_URL=postgres://$USER@localhost/testdb npx prisma migrate dev --name init ~/path/flyctl/bin/flyctl launch ``` --- internal/appconfig/config.go | 1 + internal/appconfig/context.go | 15 +++++++ .../deploy/machines_deploymachinesapp.go | 2 +- .../command/deploy/machines_releasecommand.go | 44 +++++++++++++------ internal/command/launch/launch.go | 4 ++ internal/command/launch/launch_frameworks.go | 9 ++++ scanner/scanner.go | 1 + 7 files changed, 62 insertions(+), 14 deletions(-) diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 9c3a3c6172..92fd8c5718 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -93,6 +93,7 @@ type Deploy struct { ReleaseCommand string `toml:"release_command,omitempty" json:"release_command,omitempty"` ReleaseCommandTimeout *fly.Duration `toml:"release_command_timeout,omitempty" json:"release_command_timeout,omitempty"` ReleaseCommandCompute *Compute `toml:"release_command_vm,omitempty" json:"release_command_vm,omitempty"` + SeedCommand string `toml:"seed_command,omitempty" json:"seed_command,omitempty"` } type File struct { diff --git a/internal/appconfig/context.go b/internal/appconfig/context.go index 78eaa0f4ad..dcfca056da 100644 --- a/internal/appconfig/context.go +++ b/internal/appconfig/context.go @@ -10,6 +10,7 @@ const ( _ contextKeyType = iota configContextKey nameContextKey + seedContextKey ) // WithConfig derives a context that carries cfg from ctx. @@ -39,3 +40,17 @@ func NameFromContext(ctx context.Context) string { return "" } + +// WithSeed derives a context that carries the given seed from ctx. +func WithSeedCommand(ctx context.Context, seedCommand string) context.Context { + return context.WithValue(ctx, seedContextKey, seedCommand) +} + +// SeedFromContext returns the seed ctx carries or an empty string. +func SeedCommandFromContext(ctx context.Context) string { + if seed, ok := ctx.Value(seedContextKey).(string); ok { + return seed + } + + return "" +} diff --git a/internal/command/deploy/machines_deploymachinesapp.go b/internal/command/deploy/machines_deploymachinesapp.go index 561fc52eda..9d67da833e 100644 --- a/internal/command/deploy/machines_deploymachinesapp.go +++ b/internal/command/deploy/machines_deploymachinesapp.go @@ -425,7 +425,7 @@ func (md *machineDeployment) deployMachinesApp(ctx context.Context) error { defer span.End() if !md.skipReleaseCommand { - if err := md.runReleaseCommand(ctx); err != nil { + if err := md.runReleaseCommands(ctx); err != nil { return fmt.Errorf("release command failed - aborting deployment. %w", err) } } diff --git a/internal/command/deploy/machines_releasecommand.go b/internal/command/deploy/machines_releasecommand.go index 4c8551689c..89a53733bb 100644 --- a/internal/command/deploy/machines_releasecommand.go +++ b/internal/command/deploy/machines_releasecommand.go @@ -27,22 +27,38 @@ import ( "golang.org/x/sync/errgroup" ) -func (md *machineDeployment) runReleaseCommand(ctx context.Context) (err error) { - ctx, span := tracing.GetTracer().Start(ctx, "run_release_cmd") +func (md *machineDeployment) runReleaseCommands(ctx context.Context) error { + err := md.runReleaseCommand(ctx, "release") + + if err == nil { + seedCommand := appconfig.SeedCommandFromContext(ctx) + + if seedCommand != "" { + md.appConfig.Deploy.ReleaseCommand = seedCommand + err = md.runReleaseCommand(ctx, "seed") + } + } + + return err +} + +func (md *machineDeployment) runReleaseCommand(ctx context.Context, commandType string) (err error) { + ctx, span := tracing.GetTracer().Start(ctx, "run_"+commandType+"_cmd") defer func() { if err != nil { - tracing.RecordError(span, err, "failed to run release_cmd") + tracing.RecordError(span, err, "failed to run "+commandType+"_cmd") } span.End() }() if md.appConfig.Deploy == nil || md.appConfig.Deploy.ReleaseCommand == "" { - span.AddEvent("no release command") + span.AddEvent("no " + commandType + " command") return nil } - fmt.Fprintf(md.io.ErrOut, "Running %s release_command: %s\n", + fmt.Fprintf(md.io.ErrOut, "Running %s %s_command: %s\n", md.colorize.Bold(md.app.Name), + commandType, md.appConfig.Deploy.ReleaseCommand, ) ctx, loggerCleanup := statuslogger.SingleLine(ctx, true) @@ -64,8 +80,8 @@ func (md *machineDeployment) runReleaseCommand(ctx context.Context) (err error) eg.Go(func() error { err := md.createOrUpdateReleaseCmdMachine(groupCtx) if err != nil { - tracing.RecordError(span, err, "failed to create release cmd machine") - return fmt.Errorf("error running release_command machine: %w", err) + tracing.RecordError(span, err, "failed to create "+commandType+" cmd machine") + return fmt.Errorf("error running %s_command machine: %w", commandType, err) } return nil }) @@ -117,24 +133,24 @@ func (md *machineDeployment) runReleaseCommand(ctx context.Context) (err error) fmt.Fprintln(md.io.ErrOut, "Starting machine") if err = releaseCmdMachine.Start(ctx); err != nil { - fmt.Fprintf(md.io.ErrOut, "error starting release_command machine: %v\n", err) + fmt.Fprintf(md.io.ErrOut, "error starting %s_command machine: %v\n", commandType, err) return } // FIXME: consolidate this wait stuff with deploy waits? Especially once we improve the outpu err = md.waitForReleaseCommandToFinish(ctx, releaseCmdMachine) if err != nil { - tracing.RecordError(span, err, "failed to wait for release cmd machine") + tracing.RecordError(span, err, "failed to wait for "+commandType+" cmd machine") return err } lastExitEvent, err := releaseCmdMachine.WaitForEventTypeAfterType(ctx, "exit", "start", md.releaseCmdTimeout, true) if err != nil { - return fmt.Errorf("error finding the release_command machine %s exit event: %w", releaseCmdMachine.Machine().ID, err) + return fmt.Errorf("error finding the %s_command machine %s exit event: %w", commandType, releaseCmdMachine.Machine().ID, err) } exitCode, err := lastExitEvent.Request.GetExitCode() if err != nil { - return fmt.Errorf("error get release_command machine %s exit code: %w", releaseCmdMachine.Machine().ID, err) + return fmt.Errorf("error get %s_command machine %s exit code: %w", commandType, releaseCmdMachine.Machine().ID, err) } if flag.GetBool(ctx, "verbose") { @@ -142,7 +158,7 @@ func (md *machineDeployment) runReleaseCommand(ctx context.Context) (err error) } if exitCode != 0 { - statuslogger.LogStatus(ctx, statuslogger.StatusFailure, "release_command failed") + statuslogger.LogStatus(ctx, statuslogger.StatusFailure, commandType+"_command failed") // Preemptive cleanup of the logger so that the logs have a clean place to write to loggerCleanup(false) @@ -163,7 +179,8 @@ func (md *machineDeployment) runReleaseCommand(ctx context.Context) (err error) } statuslogger.LogfStatus(ctx, statuslogger.StatusSuccess, - "release_command %s completed successfully", + "%s_command %s completed successfully", + commandType, md.colorize.Bold(releaseCmdMachine.Machine().ID), ) return nil @@ -332,5 +349,6 @@ func (md *machineDeployment) waitForReleaseCommandToFinish(ctx context.Context, err = suggestChangeWaitTimeout(err, "release-command-timeout") return fmt.Errorf("error waiting for release_command machine %s to finish running: %w", releaseCmdMachine.Machine().ID, err) } + md.releaseCommandMachine.RemoveMachines(ctx, []machine.LeasableMachine{releaseCmdMachine}) return nil } diff --git a/internal/command/launch/launch.go b/internal/command/launch/launch.go index c68a3c8786..46e9256ca3 100644 --- a/internal/command/launch/launch.go +++ b/internal/command/launch/launch.go @@ -129,6 +129,10 @@ func (state *launchState) Launch(ctx context.Context) error { } if state.sourceInfo != nil { + if state.appConfig.Deploy != nil && state.appConfig.Deploy.SeedCommand != "" { + ctx = appconfig.WithSeedCommand(ctx, state.appConfig.Deploy.SeedCommand) + } + if err := state.firstDeploy(ctx); err != nil { return err } diff --git a/internal/command/launch/launch_frameworks.go b/internal/command/launch/launch_frameworks.go index ec4a08f8e2..c7ce57f696 100644 --- a/internal/command/launch/launch_frameworks.go +++ b/internal/command/launch/launch_frameworks.go @@ -197,6 +197,10 @@ func (state *launchState) scannerRunCallback(ctx context.Context) error { state.sourceInfo.ReleaseCmd = cfg.Deploy.ReleaseCommand } + if state.sourceInfo.SeedCmd == "" && cfg.Deploy != nil { + state.sourceInfo.SeedCmd = cfg.Deploy.SeedCommand + } + if len(cfg.Env) > 0 { if len(state.sourceInfo.Env) == 0 { state.sourceInfo.Env = cfg.Env @@ -326,6 +330,11 @@ func (state *launchState) scannerSetAppconfig(ctx context.Context) error { appConfig.SetReleaseCommand(srcInfo.ReleaseCmd) } + if srcInfo.SeedCmd != "" { + // no V1 compatibility for this feature so bypass setters + appConfig.Deploy.SeedCommand = srcInfo.SeedCmd + } + if srcInfo.DockerCommand != "" { appConfig.SetDockerCommand(srcInfo.DockerCommand) } diff --git a/scanner/scanner.go b/scanner/scanner.go index 4c8881d531..fdf2016aff 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -51,6 +51,7 @@ type SourceInfo struct { BuildArgs map[string]string Builder string ReleaseCmd string + SeedCmd string DockerCommand string DockerEntrypoint string KillSignal string