From 8363a427f16b1ff61907652329a0d2185d872e95 Mon Sep 17 00:00:00 2001 From: Chingis S Date: Wed, 19 Aug 2026 14:57:39 +0300 Subject: [PATCH] Build only the subdirectory a service asks for An image target that needs one subdirectory of the repository, such as an nginx service serving only the docroot, could express that only in its own Dockerfile, because --from and --to are pipeline-wide flags and cannot differ per service within one build. Take the copy paths the app build configuration now reports for each service and place them under the --from and --to roots. A service that reports no copy path builds the whole context exactly as before, and COPY_TO resolves the same way whichever Dockerfile wins, so a service-provided or author Dockerfile declaring ARG COPY_TO sees the same destination as the generated one. With this, a service that only needs part of the repository no longer has to ship a Dockerfile to say so. --- cmd/wodby/ci/build/build.go | 30 +++++++++++++++++++++++++----- cmd/wodby/ci/build/build_test.go | 24 ++++++++++++++++++++++++ pkg/types/build.go | 20 ++++++++++++-------- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/cmd/wodby/ci/build/build.go b/cmd/wodby/ci/build/build.go index 7aacad2..bc1452e 100644 --- a/cmd/wodby/ci/build/build.go +++ b/cmd/wodby/ci/build/build.go @@ -141,7 +141,9 @@ var Cmd = &cobra.Command{ for _, appServiceBuildConfig := range appServiceBuildConfigs { buildArgs := make(map[string]string) var redactValues []string - buildArgs["COPY_FROM"] = opts.from + // A service can narrow the build to a subdirectory of the context, so + // the flags give the roots and the service gives the path within them. + buildArgs["COPY_FROM"] = joinCopyPath(opts.from, appServiceBuildConfig.CopyFrom) buildArgs["WODBY_BASE_IMAGE"] = appServiceBuildConfig.Image buildFiles := newBuildFiles(context, appServiceBuildConfig.Name, opts.dockerfile) cacheFrom, cacheTo, err := resolveCacheOptions(config, appServiceBuildConfig.Name, opts) @@ -172,7 +174,7 @@ var Cmd = &cobra.Command{ return errors.WithStack(err) } dockerfile = string(d) - if err := addDockerfileBuildArgs(buildArgs, dockerfile, appServiceBuildConfig, opts.to, logger, &redactValues); err != nil { + if err := addDockerfileBuildArgs(buildArgs, dockerfile, appServiceBuildConfig, joinCopyPath(opts.to, appServiceBuildConfig.CopyTo), logger, &redactValues); err != nil { return errors.WithStack(err) } } else if fileExists(buildFiles.dockerfilePath) { @@ -183,7 +185,7 @@ var Cmd = &cobra.Command{ return errors.WithStack(err) } dockerfile = string(d) - if err := addDockerfileBuildArgs(buildArgs, dockerfile, appServiceBuildConfig, opts.to, logger, &redactValues); err != nil { + if err := addDockerfileBuildArgs(buildArgs, dockerfile, appServiceBuildConfig, joinCopyPath(opts.to, appServiceBuildConfig.CopyTo), logger, &redactValues); err != nil { return errors.WithStack(err) } } else { @@ -191,13 +193,13 @@ var Cmd = &cobra.Command{ dockerfileSource = dockerfileSourceService fmt.Println("Dockerfile provided by app service") dockerfile = *appServiceBuildConfig.Dockerfile - if err := addDockerfileBuildArgs(buildArgs, dockerfile, appServiceBuildConfig, opts.to, logger, &redactValues); err != nil { + if err := addDockerfileBuildArgs(buildArgs, dockerfile, appServiceBuildConfig, joinCopyPath(opts.to, appServiceBuildConfig.CopyTo), logger, &redactValues); err != nil { return errors.WithStack(err) } } else { dockerfileSource = dockerfileSourceDefault fmt.Println("No Dockerfile provided by app service, using the default") - buildArgs["COPY_TO"] = opts.to + buildArgs["COPY_TO"] = joinCopyPath(opts.to, appServiceBuildConfig.CopyTo) // Replace default image user in dockerfile template. defaultUser, err := dockerClient.GetImageDefaultUser(appServiceBuildConfig.Image) if err != nil { @@ -347,6 +349,24 @@ func dockerfileContentHash(dockerfile string) string { return "sha256:" + hex.EncodeToString(sum[:]) } +// joinCopyPath places a service's copy subdirectory under the root given by the +// --from/--to flags. An empty subdirectory leaves the root untouched, which is +// the default whole-context build. +func joinCopyPath(root string, subdir string) string { + if subdir == "" { + if root == "" { + return "." + } + + return root + } + if root == "" { + root = "." + } + + return path.Join(root, subdir) +} + func authoredDockerfile(source string) bool { return source == dockerfileSourceFlag || source == dockerfileSourceContext } diff --git a/cmd/wodby/ci/build/build_test.go b/cmd/wodby/ci/build/build_test.go index d225412..15c39b3 100644 --- a/cmd/wodby/ci/build/build_test.go +++ b/cmd/wodby/ci/build/build_test.go @@ -468,3 +468,27 @@ func TestDockerfileContentHash(t *testing.T) { t.Fatalf("dockerfileContentHash(\"\") = %q, want empty", dockerfileContentHash("")) } } + +func TestJoinCopyPath(t *testing.T) { + for _, tc := range []struct { + name string + root string + subdir string + want string + }{ + {name: "default root, no subdir", root: ".", subdir: "", want: "."}, + {name: "default root with subdir", root: ".", subdir: "web", want: "web"}, + {name: "explicit root with subdir", root: "static", subdir: "web", want: "static/web"}, + {name: "absolute destination with subdir", root: "/var/www/html", subdir: "web", want: "/var/www/html/web"}, + {name: "explicit root, no subdir", root: "static", subdir: "", want: "static"}, + {name: "empty root, no subdir", root: "", subdir: "", want: "."}, + {name: "empty root with subdir", root: "", subdir: "web", want: "web"}, + {name: "nested subdir", root: ".", subdir: "apps/web", want: "apps/web"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := joinCopyPath(tc.root, tc.subdir); got != tc.want { + t.Fatalf("joinCopyPath(%q, %q) = %q, want %q", tc.root, tc.subdir, got, tc.want) + } + }) + } +} diff --git a/pkg/types/build.go b/pkg/types/build.go index c6061e5..20ffc1d 100644 --- a/pkg/types/build.go +++ b/pkg/types/build.go @@ -25,14 +25,18 @@ type ( Services []*AppServiceBuildConfig `json:"services"` } AppServiceBuildConfig struct { - Name string `json:"name"` - Title string `json:"title"` - Image string `json:"image"` - Managed bool `json:"managed"` - Main bool `json:"main"` - Dockerfile *string `json:"dockerfile"` - Dockerignore *string `json:"dockerignore"` - Args []*AppServiceBuildArg `json:"args"` + Name string `json:"name"` + Title string `json:"title"` + Image string `json:"image"` + Managed bool `json:"managed"` + Main bool `json:"main"` + Dockerfile *string `json:"dockerfile"` + Dockerignore *string `json:"dockerignore"` + // CopyFrom and CopyTo narrow the build to a subdirectory, relative to the + // --from and --to paths. Empty means copy the whole context. + CopyFrom string `json:"copyFrom"` + CopyTo string `json:"copyTo"` + Args []*AppServiceBuildArg `json:"args"` } AppServiceBuildArg struct { Name string `json:"name"`