build relies on types.ContainerSpec

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-04-17 17:59:03 +02:00
parent 24b2a3ae79
commit 21b093e10f
No known key found for this signature in database
GPG key ID: 9858809D6F8F6E7E
5 changed files with 94 additions and 64 deletions

View file

@ -51,7 +51,7 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti
func (s *composeService) build(ctx context.Context, project *types.Project, options api.BuildOptions, localImages map[string]api.ImageSummary) (map[string]string, error) {
imageIDs := map[string]string{}
serviceToBuild := types.Services{}
imagesToBuild := map[string]types.ContainerSpec{}
var policy types.DependencyOption = types.IgnoreDependencies
if options.Deps {
@ -63,7 +63,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
}
// Separate job names from service names and collect buildable jobs
serviceNames := collectBuildableJobs(options.Services, project, localImages, serviceToBuild)
serviceNames := collectBuildableJobs(options.Services, project, localImages, imagesToBuild)
// also include services used as additional_contexts with service: prefix
serviceNames = addBuildDependencies(serviceNames, project)
@ -89,14 +89,14 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
if localImagePresent && service.PullPolicy != types.PullPolicyBuild {
return nil
}
serviceToBuild[serviceName] = *service
imagesToBuild[serviceName] = service.ContainerSpec
return nil
}, policy)
if err != nil {
return imageIDs, err
}
if len(serviceToBuild) == 0 {
if len(imagesToBuild) == 0 {
return imageIDs, nil
}
@ -105,9 +105,9 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
return nil, err
}
if bake {
return s.doBuildBake(ctx, project, serviceToBuild, options)
return s.doBuildBake(ctx, project, imagesToBuild, options)
}
return s.doBuildClassic(ctx, project, serviceToBuild, options)
return s.doBuildClassic(ctx, project, imagesToBuild, options)
}
func (s *composeService) ensureImagesExists(ctx context.Context, project *types.Project, buildOpts *api.BuildOptions, quietPull bool) error {
@ -176,7 +176,7 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
// collectBuildableJobs separates job names from service names in the given list.
// Jobs that need building are added to serviceToBuild. Returns only the service names.
func collectBuildableJobs(names []string, project *types.Project, localImages map[string]api.ImageSummary, serviceToBuild map[string]types.ServiceConfig) []string {
func collectBuildableJobs(names []string, project *types.Project, localImages map[string]api.ImageSummary, imagesToBuild map[string]types.ContainerSpec) []string {
var serviceNames []string
for _, name := range names {
job, ok := project.Jobs[name]
@ -189,10 +189,7 @@ func collectBuildableJobs(names []string, project *types.Project, localImages ma
}
image := api.ImageNameOrDefault(job.Image, name, project.Name)
if _, present := localImages[image]; !present || job.PullPolicy == types.PullPolicyBuild {
serviceToBuild[name] = types.ServiceConfig{
Name: name,
ContainerSpec: job.ContainerSpec,
}
imagesToBuild[name] = job.ContainerSpec
}
}
return serviceNames
@ -284,9 +281,9 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ
//
// Finally, standard proxy variables based on the Docker client configuration are added, but will not overwrite
// any values if already present.
func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Project, service types.ServiceConfig, opts api.BuildOptions) types.MappingWithEquals {
func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Project, spec types.ContainerSpec, opts api.BuildOptions) types.MappingWithEquals {
result := make(types.MappingWithEquals).
OverrideBy(service.Build.Args).
OverrideBy(spec.Build.Args).
OverrideBy(opts.Args).
Resolve(envResolver(project.Environment))
@ -301,17 +298,17 @@ func resolveAndMergeBuildArgs(proxyConfig map[string]string, project *types.Proj
return result
}
func getImageBuildLabels(project *types.Project, service types.ServiceConfig) types.Labels {
func getImageBuildLabels(project *types.Project, name string, spec types.ContainerSpec) types.Labels {
ret := make(types.Labels)
if service.Build != nil {
for k, v := range service.Build.Labels {
if spec.Build != nil {
for k, v := range spec.Build.Labels {
ret.Add(k, v)
}
}
ret.Add(api.VersionLabel, api.ComposeVersion)
ret.Add(api.ProjectLabel, project.Name)
ret.Add(api.ServiceLabel, service.Name)
ret.Add(api.ServiceLabel, name)
return ret
}

View file

@ -115,7 +115,7 @@ type buildStatus struct {
Image string `json:"image.name"`
}
func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, serviceToBeBuild types.Services, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo
func (s *composeService) doBuildBake(ctx context.Context, project *types.Project, imagesToBuild map[string]types.ContainerSpec, options api.BuildOptions) (map[string]string, error) { //nolint:gocyclo
eg := errgroup.Group{}
ch := make(chan *client.SolveStatus)
displayMode := progressui.DisplayMode(options.Progress)
@ -143,31 +143,39 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
group bakeGroup
privileged bool
read []string
expectedImages = make(map[string]string, len(serviceToBeBuild)) // service name -> expected image
targets = make(map[string]string, len(serviceToBeBuild)) // service name -> build target
expectedImages = make(map[string]string, len(imagesToBuild)) // service name -> expected image
targets = make(map[string]string, len(imagesToBuild)) // service name -> build target
)
// produce a unique ID for service used as bake target
for serviceName := range project.Services {
t := strings.ReplaceAll(serviceName, ".", "_")
// produce a unique ID for each build target (services + jobs)
assignTarget := func(name string) {
t := strings.ReplaceAll(name, ".", "_")
for {
if _, ok := targets[serviceName]; !ok {
targets[serviceName] = t
break
if _, ok := targets[name]; !ok {
targets[name] = t
return
}
t += "_"
}
}
for serviceName := range project.Services {
assignTarget(serviceName)
}
for name := range imagesToBuild {
if _, ok := targets[name]; !ok {
assignTarget(name)
}
}
var secretsEnv []string
for serviceName, service := range project.Services {
if service.Build == nil {
continue
addBakeTarget := func(name string, spec types.ContainerSpec) {
if spec.Build == nil {
return
}
buildConfig := *service.Build
labels := getImageBuildLabels(project, service)
buildConfig := *spec.Build
labels := getImageBuildLabels(project, name, spec)
args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, service, options).ToMapping()
args := resolveAndMergeBuildArgs(s.getProxyConfig(), project, spec, options).ToMapping()
for k, v := range args {
args[k] = strings.ReplaceAll(v, "${", "$${")
}
@ -183,11 +191,11 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
var outputs []string
var call string
push := options.Push && service.Image != ""
push := options.Push && spec.Image != ""
switch {
case options.Check:
call = "lint"
case len(service.Build.Platforms) > 1:
case len(spec.Build.Platforms) > 1:
outputs = []string{fmt.Sprintf("type=image,push=%t", push)}
default:
if push {
@ -205,15 +213,15 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
}
}
image := api.GetImageNameOrDefault(service, project.Name)
image := api.ImageNameOrDefault(spec.Image, name, project.Name)
s.events.On(buildingEvent(image))
expectedImages[serviceName] = image
expectedImages[name] = image
pull := service.Build.Pull || options.Pull
noCache := service.Build.NoCache || options.NoCache
pull := spec.Build.Pull || options.Pull
noCache := spec.Build.NoCache || options.NoCache
target := targets[serviceName]
target := targets[name]
secrets, env := toBakeSecrets(project, buildConfig.Secrets)
secretsEnv = append(secretsEnv, env...)
@ -248,12 +256,22 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
}
}
// create a bake group with targets for services to build
for serviceName, service := range serviceToBeBuild {
if service.Build == nil {
for serviceName, service := range project.Services {
addBakeTarget(serviceName, service.ContainerSpec)
}
for name, spec := range imagesToBuild {
if _, ok := project.Services[name]; ok {
continue // already processed as a service
}
addBakeTarget(name, spec)
}
// create a bake group with targets to build
for name, spec := range imagesToBuild {
if spec.Build == nil {
continue
}
group.Targets = append(group.Targets, targets[serviceName])
group.Targets = append(group.Targets, targets[name])
}
cfg.Groups["default"] = group
@ -397,7 +415,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
}
results := map[string]string{}
for name := range serviceToBeBuild {
for name := range imagesToBuild {
image := expectedImages[name]
target := targets[name]
built, ok := md[target]

View file

@ -45,7 +45,7 @@ import (
"github.com/docker/compose/v5/pkg/api"
)
func (s *composeService) doBuildClassic(ctx context.Context, project *types.Project, serviceToBuild types.Services, options api.BuildOptions) (map[string]string, error) {
func (s *composeService) doBuildClassic(ctx context.Context, project *types.Project, imagesToBuild map[string]types.ContainerSpec, options api.BuildOptions) (map[string]string, error) {
imageIDs := map[string]string{}
// Not using bake, additional_context: service:xx is implemented by building images in dependency order
@ -82,14 +82,14 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj
err = InDependencyOrder(ctx, project, func(ctx context.Context, name string) error {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("builder", "classic"))
service, ok := serviceToBuild[name]
spec, ok := imagesToBuild[name]
if !ok {
return nil
}
image := api.GetImageNameOrDefault(service, project.Name)
image := api.ImageNameOrDefault(spec.Image, name, project.Name)
s.events.On(buildingEvent(image))
id, err := s.doBuildImage(ctx, project, service, options)
id, err := s.doBuildImage(ctx, project, name, spec, options)
if err != nil {
return err
}
@ -118,7 +118,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj
}
//nolint:gocyclo
func (s *composeService) doBuildImage(ctx context.Context, project *types.Project, service types.ServiceConfig, options api.BuildOptions) (string, error) {
func (s *composeService) doBuildImage(ctx context.Context, project *types.Project, name string, spec types.ContainerSpec, options api.BuildOptions) (string, error) {
var (
buildCtx io.ReadCloser
dockerfileCtx io.ReadCloser
@ -126,29 +126,29 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec
relDockerfile string
)
if len(service.Build.Platforms) > 1 {
if len(spec.Build.Platforms) > 1 {
return "", fmt.Errorf("the classic builder doesn't support multi-arch build, set DOCKER_BUILDKIT=1 to use BuildKit")
}
if service.Build.Privileged {
if spec.Build.Privileged {
return "", fmt.Errorf("the classic builder doesn't support privileged mode, set DOCKER_BUILDKIT=1 to use BuildKit")
}
if len(service.Build.AdditionalContexts) > 0 {
if len(spec.Build.AdditionalContexts) > 0 {
return "", fmt.Errorf("the classic builder doesn't support additional contexts, set DOCKER_BUILDKIT=1 to use BuildKit")
}
if len(service.Build.SSH) > 0 {
if len(spec.Build.SSH) > 0 {
return "", fmt.Errorf("the classic builder doesn't support SSH keys, set DOCKER_BUILDKIT=1 to use BuildKit")
}
if len(service.Build.Secrets) > 0 {
if len(spec.Build.Secrets) > 0 {
return "", fmt.Errorf("the classic builder doesn't support secrets, set DOCKER_BUILDKIT=1 to use BuildKit")
}
if service.Build.Labels == nil {
service.Build.Labels = make(map[string]string)
if spec.Build.Labels == nil {
spec.Build.Labels = make(map[string]string)
}
service.Build.Labels[api.ImageBuilderLabel] = "classic"
spec.Build.Labels[api.ImageBuilderLabel] = "classic"
dockerfileName := dockerFilePath(service.Build.Context, service.Build.Dockerfile)
specifiedContext := service.Build.Context
dockerfileName := dockerFilePath(spec.Build.Context, spec.Build.Dockerfile)
specifiedContext := spec.Build.Context
progBuff := s.stdout()
buildBuff := s.stdout()
@ -251,8 +251,8 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec
RegistryToken: authConfig.RegistryToken,
}
}
buildOpts := imageBuildOptions(s.getProxyConfig(), project, service, options)
imageName := api.GetImageNameOrDefault(service, project.Name)
buildOpts := imageBuildOptions(s.getProxyConfig(), project, spec, options)
imageName := api.ImageNameOrDefault(spec.Image, name, project.Name)
buildOpts.Tags = append(buildOpts.Tags, imageName)
buildOpts.Dockerfile = relDockerfile
buildOpts.AuthConfigs = authConfigs
@ -293,15 +293,15 @@ func (s *composeService) doBuildImage(ctx context.Context, project *types.Projec
return imageID, nil
}
func imageBuildOptions(proxyConfigs map[string]string, project *types.Project, service types.ServiceConfig, options api.BuildOptions) client.ImageBuildOptions {
config := service.Build
func imageBuildOptions(proxyConfigs map[string]string, project *types.Project, spec types.ContainerSpec, options api.BuildOptions) client.ImageBuildOptions {
config := spec.Build
return client.ImageBuildOptions{
Version: buildtypes.BuilderV1,
Tags: config.Tags,
NoCache: config.NoCache,
Remove: true,
PullParent: config.Pull,
BuildArgs: resolveAndMergeBuildArgs(proxyConfigs, project, service, options),
BuildArgs: resolveAndMergeBuildArgs(proxyConfigs, project, spec, options),
Labels: config.Labels,
NetworkMode: config.Network,
ExtraHosts: config.ExtraHosts.AsList(":"),

View file

@ -315,6 +315,14 @@ func TestLocalComposeRun(t *testing.T) {
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans")
})
t.Run("compose run job with build", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "--rm", "--build", "with-build")
lines := Lines(res.Stdout())
assert.Equal(t, lines[len(lines)-1], "built-job-marker", res.Stdout())
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/jobs.yaml", "down", "--remove-orphans", "--rmi=local")
})
t.Run("compose run unknown job or service", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/run-test/jobs.yaml", "run", "nonexistent")
res.Assert(t, icmd.Expected{

View file

@ -18,3 +18,10 @@ jobs:
with-command:
image: alpine
command: echo "default command"
with-build:
build:
dockerfile_inline: |
FROM alpine
RUN echo "built-job-marker" > /marker.txt
command: cat /marker.txt