Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions internal/command/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,9 +602,19 @@ func deployToMachines(
maxConcurrent = immediateMaxConcurrent
}

// img is nil for compose deploys that build no source image (every service
// uses a pre-built image); each container carries its own image reference.
var imageTag, imageBuilderID string
var imageBuildID int64
if img != nil {
imageTag = img.Tag
imageBuildID = img.BuildID
imageBuilderID = img.BuilderID
}

status.AppName = app.Name
status.OrgSlug = app.Organization.Slug
status.Image = img.Tag
status.Image = imageTag
status.Strategy = cfg.DeployStrategy()
if flag.GetString(ctx, "strategy") != "" {
status.Strategy = flag.GetString(ctx, "strategy")
Expand Down Expand Up @@ -650,7 +660,7 @@ func deployToMachines(

args := MachineDeploymentArgs{
App: app,
DeploymentImage: img.Tag,
DeploymentImage: imageTag,
Strategy: flag.GetString(ctx, "strategy"),
EnvFromFlags: flag.GetStringArray(ctx, "env"),
PrimaryRegionFlag: status.PrimaryRegion,
Expand All @@ -677,8 +687,8 @@ func deployToMachines(
VolumeInitialSize: flag.GetInt(ctx, "volume-initial-size"),
ProcessGroups: processGroups,
DeployRetries: deployRetries,
BuildID: img.BuildID,
BuilderID: img.BuilderID,
BuildID: imageBuildID,
BuilderID: imageBuilderID,
}

var path = flag.GetString(ctx, "export-manifest")
Expand Down
70 changes: 70 additions & 0 deletions internal/command/deploy/deploy_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/superfly/flyctl/internal/appconfig"
"github.com/superfly/flyctl/internal/build/imgsrc"
"github.com/superfly/flyctl/internal/cmdutil"
"github.com/superfly/flyctl/internal/containerconfig"
"github.com/superfly/flyctl/internal/env"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/flyutil"
Expand Down Expand Up @@ -167,6 +168,20 @@ func determineImage(ctx context.Context, app *flaps.App, appConfig *appconfig.Co
return
}

// Docker Compose: only build from source when a service declares `build:`.
usesCompose, composeBuild, err := composeBuildInfo(appConfig)
if err != nil {
tracing.RecordError(span, err, "failed to read compose build info")

return
}
if usesCompose && composeBuild == nil {
span.AddEvent("compose deploy with no build service; skipping source build")
terminal.Debug("compose deploy uses only pre-built images; skipping source build")

return nil, nil
}

build := appConfig.Build
if build == nil {
build = new(appconfig.Build)
Expand Down Expand Up @@ -241,6 +256,16 @@ func determineImage(ctx context.Context, app *flaps.App, appConfig *appconfig.Co
return
}

// A compose `build:` directive takes precedence over an auto-detected
// Dockerfile: route its context/dockerfile into the build options.
if composeBuild != nil {
if err = applyComposeBuild(&opts, appConfig, composeBuild); err != nil {
tracing.RecordError(span, err, "failed to apply compose build directive")

return
}
}

if opts.IgnorefilePath, err = resolveIgnorefilePath(ctx, appConfig); err != nil {
tracing.RecordError(span, err, "failed to resolveIgnorefilePath")

Expand Down Expand Up @@ -285,6 +310,51 @@ func determineImage(ctx context.Context, app *flaps.App, appConfig *appconfig.Co
return
}

func composeBuildInfo(appConfig *appconfig.Config) (usesCompose bool, cb *containerconfig.ComposeBuild, err error) {
if appConfig.Build == nil || appConfig.Build.Compose == nil {
return false, nil, nil
}

composePath := appConfig.DetectComposeFile()
if composePath == "" {
return true, nil, nil
}
if !filepath.IsAbs(composePath) {
composePath = filepath.Join(filepath.Dir(appConfig.ConfigFilePath()), composePath)
}

cb, err = containerconfig.ComposeBuildInfo(composePath)
if err != nil {
return true, nil, err
}

return true, cb, nil
}

func applyComposeBuild(opts *imgsrc.ImageOptions, appConfig *appconfig.Config, cb *containerconfig.ComposeBuild) error {
base := filepath.Dir(appConfig.ConfigFilePath())

ctxDir := opts.WorkingDir
if cb.Context != "" {
abs, err := filepath.Abs(filepath.Join(base, cb.Context))
if err != nil {
return err
}
ctxDir = abs
opts.WorkingDir = abs
}

if cb.Dockerfile != "" {
abs, err := filepath.Abs(filepath.Join(ctxDir, cb.Dockerfile))
if err != nil {
return err
}
opts.DockerfilePath = abs
}

return nil
}

// resolveDockerfilePath returns the absolute path to the Dockerfile
// if one was specified in the app config or a command line argument
func resolveDockerfilePath(ctx context.Context, appConfig *appconfig.Config) (path string, err error) {
Expand Down
112 changes: 112 additions & 0 deletions internal/command/deploy/deploy_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/superfly/flyctl/internal/appconfig"
"github.com/superfly/flyctl/internal/build/imgsrc"
"github.com/superfly/flyctl/internal/containerconfig"
"github.com/superfly/flyctl/internal/state"
)

Expand Down Expand Up @@ -37,3 +39,113 @@ func TestMultipleDockerfile(t *testing.T) {
err = multipleDockerfile(ctx, cfg)
assert.ErrorContains(t, err, "fly.production.toml")
}

// writeComposeApp writes a fly.toml with [build.compose] and a compose.yml into
// a temp dir, plus an optional stray Dockerfile, and returns the loaded config.
func writeComposeApp(t *testing.T, composeYML string, withDockerfile bool) *appconfig.Config {
t.Helper()
dir := t.TempDir()

require.NoError(t, os.WriteFile(filepath.Join(dir, "compose.yml"), []byte(composeYML), 0644))
if withDockerfile {
require.NoError(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0644))
}

flyToml := filepath.Join(dir, "fly.toml")
require.NoError(t, os.WriteFile(flyToml, []byte(`app = "compose-test"

[build.compose]
file = "compose.yml"
`), 0644))

cfg, err := appconfig.LoadConfig(flyToml)
require.NoError(t, err)

return cfg
}

func TestComposeBuildInfo_NoBuildService(t *testing.T) {
// Every service uses a pre-built image and a stray Dockerfile is present:
// the gate must report "uses compose, no build needed".
cfg := writeComposeApp(t, `services:
web:
image: nginx:latest
db:
image: postgres:14
`, true /* stray Dockerfile present */)

usesCompose, cb, err := composeBuildInfo(cfg)
require.NoError(t, err)
assert.True(t, usesCompose, "expected compose to be detected")
assert.Nil(t, cb, "expected no build directive when all services use images")
}

func TestComposeBuildInfo_WithBuildService(t *testing.T) {
cfg := writeComposeApp(t, `services:
app:
build:
context: ./src
dockerfile: Dockerfile.custom
cache:
image: redis:alpine
`, false)

usesCompose, cb, err := composeBuildInfo(cfg)
require.NoError(t, err)
assert.True(t, usesCompose)
require.NotNil(t, cb)
assert.Equal(t, "./src", cb.Context)
assert.Equal(t, "Dockerfile.custom", cb.Dockerfile)
}

func TestComposeBuildInfo_NotCompose(t *testing.T) {
// A plain Dockerfile app must be reported as not using compose.
dir := t.TempDir()
flyToml := filepath.Join(dir, "fly.toml")
require.NoError(t, os.WriteFile(flyToml, []byte(`app = "plain"`+"\n"), 0644))
cfg, err := appconfig.LoadConfig(flyToml)
require.NoError(t, err)

usesCompose, cb, err := composeBuildInfo(cfg)
require.NoError(t, err)
assert.False(t, usesCompose)
assert.Nil(t, cb)
}

func TestApplyComposeBuild_ContextAndDockerfile(t *testing.T) {
cfg := writeComposeApp(t, `services:
app:
build:
context: ./src
dockerfile: Dockerfile.custom
`, false)
base := filepath.Dir(cfg.ConfigFilePath())

opts := &imgsrc.ImageOptions{WorkingDir: base}
cb := &containerconfig.ComposeBuild{Context: "./src", Dockerfile: "Dockerfile.custom"}

require.NoError(t, applyComposeBuild(opts, cfg, cb))

wantWorkDir, _ := filepath.Abs(filepath.Join(base, "src"))
wantDockerfile, _ := filepath.Abs(filepath.Join(base, "src", "Dockerfile.custom"))
assert.Equal(t, wantWorkDir, opts.WorkingDir)
assert.Equal(t, wantDockerfile, opts.DockerfilePath)
}

func TestApplyComposeBuild_DockerfileOnly(t *testing.T) {
cfg := writeComposeApp(t, `services:
app:
build:
dockerfile: Dockerfile.custom
`, false)
base := filepath.Dir(cfg.ConfigFilePath())

opts := &imgsrc.ImageOptions{WorkingDir: base}
cb := &containerconfig.ComposeBuild{Dockerfile: "Dockerfile.custom"}

require.NoError(t, applyComposeBuild(opts, cfg, cb))

wantDockerfile, _ := filepath.Abs(filepath.Join(base, "Dockerfile.custom"))
assert.Equal(t, base, opts.WorkingDir, "working dir unchanged when no context")
assert.Equal(t, wantDockerfile, opts.DockerfilePath)
}
13 changes: 10 additions & 3 deletions internal/command/deploy/machines.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,6 @@ func NewMachineDeployment(ctx context.Context, args MachineDeploymentArgs) (_ Ma
ctx, span := tracing.GetTracer().Start(ctx, "new_machines_deployment")
defer span.End()

if !args.RestartOnly && args.DeploymentImage == "" {
return nil, fmt.Errorf("BUG: machines deployment created without specifying the image")
}
if args.RestartOnly && args.DeploymentImage != "" {
return nil, fmt.Errorf("BUG: restartOnly machines deployment created and specified an image")
}
Expand All @@ -183,6 +180,11 @@ func NewMachineDeployment(ctx context.Context, args MachineDeploymentArgs) (_ Ma
return nil, err
}

usesCompose := appConfig.Build != nil && appConfig.Build.Compose != nil
if !args.RestartOnly && args.DeploymentImage == "" && !usesCompose {
return nil, fmt.Errorf("BUG: machines deployment created without specifying the image")
}

// TODO: Blend extraInfo into ValidationError and remove this hack
if err, extraInfo := appConfig.ValidateGroups(ctx, lo.Keys(args.ProcessGroups)); err != nil {
fmt.Fprint(io.ErrOut, extraInfo)
Expand Down Expand Up @@ -668,6 +670,11 @@ func (md *machineDeployment) setImg(ctx context.Context) error {
if md.img != "" {
return nil
}
// A compose deploy where every service uses a pre-built image has no
// top-level image on purpose; each container carries its own reference.
if md.appConfig != nil && md.appConfig.Build != nil && md.appConfig.Build.Compose != nil {
return nil
}
latestImg, err := md.apiClient.LatestImage(ctx, md.app.Name)
if err == nil {
md.img = latestImg
Expand Down
42 changes: 42 additions & 0 deletions internal/containerconfig/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,48 @@ func parseComposeFile(composePath string) (*ComposeFile, error) {
return &compose, nil
}

type ComposeBuild struct {
Context string
Dockerfile string
}

func ComposeBuildInfo(composePath string) (*ComposeBuild, error) {
compose, err := parseComposeFile(composePath)
if err != nil {
return nil, err
}

for _, service := range compose.Services {
if service.Build == nil {
continue
}

return parseComposeBuild(service.Build), nil
}

return nil, nil
}

// parseComposeBuild converts the two compose `build:` forms into a ComposeBuild
func parseComposeBuild(build any) *ComposeBuild {
switch b := build.(type) {
case string:
return &ComposeBuild{Context: b}
case map[string]any:
cb := &ComposeBuild{}
if ctx, ok := b["context"].(string); ok {
cb.Context = ctx
}
if df, ok := b["dockerfile"].(string); ok {
cb.Dockerfile = df
}

return cb
default:
return &ComposeBuild{}
}
}

// parseDependsOn parses both short and long syntax depends_on
func parseDependsOn(dependsOn any) (ServiceDependencies, error) {
deps := ServiceDependencies{
Expand Down
Loading