Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2b685ba
build(deps): adopt compose-go jobs and the container-spec layering
ndeloof Sep 10, 2026
a7d4555
jobs: warn and ignore, reject active scheduled jobs on up, create and…
ndeloof Aug 19, 2026
329b2a3
run: execute jobs exactly like services
ndeloof Aug 19, 2026
3dc87c8
pre_start: inherit the whole container specification generically
ndeloof Aug 19, 2026
686193a
e2e: jobs scenarios — scheduled rejection, manual run, env_file, job …
ndeloof Aug 19, 2026
4bbc568
run: honor the explicit manual: false opt-out on jobs
ndeloof Aug 26, 2026
0804217
pre_start: consume the load-time-resolved hook specification
ndeloof Aug 31, 2026
051ccb9
publish, config --images: jobs are scanned and pinned like services
ndeloof Sep 11, 2026
d28ad4c
fix(compose): adapt relay/start tests to the container-spec layering
ndeloof Sep 18, 2026
f761e08
fix(run): stop double-warning unsupported attributes on the job fallback
ndeloof Sep 21, 2026
2bc29e3
fix(config): scan jobs in --images, --lock-image-digests, --resolve-i…
ndeloof Sep 21, 2026
c116397
fix(create, start): clear error when targeting a job by name
ndeloof Sep 21, 2026
8fc4918
fix(run): manual: false also blocks a job pulled in transitively
ndeloof Sep 21, 2026
06f51dd
fix(run): correlate jobTargetErr against the specific failing name
ndeloof Sep 21, 2026
2e5a687
fix(start): still refuse a job when COMPOSE_PROJECT_NAME masks the error
ndeloof Sep 21, 2026
ede4c8f
fix(compose): move the job-target refusal into projectOrName itself
ndeloof Sep 21, 2026
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
10 changes: 10 additions & 0 deletions cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ func (o *ProjectOptions) projectOrName(ctx context.Context, dockerCli command.Cl

p, _, err := o.ToProject(ctx, dockerCli, backend, services, skipUnsupportedAttributesWarning, cli.WithDiscardEnvFile, cli.WithoutEnvironmentResolution)
if err != nil {
// a service name among services can genuinely be a declared job:
// every caller of projectOrName treats it as run-only and has
// nothing to act on for it, so report that clearly instead of
// either the raw "no such service" below or, worse, silently
// falling back to the label-driven project next -- a job that
// was never run left no container behind for that fallback to
// find, so it would otherwise look like a successful no-op.
if jobErr, replaced := jobTargetErr(ctx, dockerCli, o, services, err); replaced {
return nil, "", jobErr
}
envProjectName := os.Getenv(ComposeProjectName)
if envProjectName != "" {
return nil, envProjectName, nil
Expand Down
30 changes: 27 additions & 3 deletions cmd/compose/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,9 @@ func TestFilterServices(t *testing.T) {
Links: []string{"bar"},
},
"bar": {
Name: "bar",
DependsOn: map[string]types.ServiceDependency{
Name: "bar", WorkloadSpec: types.WorkloadSpec{DependsOn: map[string]types.ServiceDependency{
"zot": {},
},
}},
},
"zot": {
Name: "zot",
Expand All @@ -53,3 +52,28 @@ func TestFilterServices(t *testing.T) {
_, err = p.GetService("zot")
assert.NilError(t, err)
}

// projectOrName backs every service-targeting command except run/create
// (down, stop, kill, pause, unpause, logs, rm, ps, events, start, ...): a
// job target must be refused the same way regardless of which of them is
// used, and the refusal must not be masked by the COMPOSE_PROJECT_NAME
// fallback below it.
func TestProjectOrNameRefusesJob(t *testing.T) {
opts := jobTargetErrFixture(t)

t.Run("a job target is refused with a clear error", func(t *testing.T) {
_, _, err := opts.projectOrName(t.Context(), nil, "migrate")
assert.Error(t, err, `job "migrate" can only be triggered with "docker compose run"`)
})

t.Run("COMPOSE_PROJECT_NAME must not mask the refusal behind a silent fallback", func(t *testing.T) {
t.Setenv("COMPOSE_PROJECT_NAME", "test")
_, _, err := opts.projectOrName(t.Context(), nil, "migrate")
assert.Error(t, err, `job "migrate" can only be triggered with "docker compose run"`)
})

t.Run("a real typo among several targets keeps its own error, not a same-invocation job's", func(t *testing.T) {
_, _, err := opts.projectOrName(t.Context(), nil, "typo", "migrate")
assert.ErrorContains(t, err, "no such service: typo")
})
}
102 changes: 92 additions & 10 deletions cmd/compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func imagesOnly(project *types.Project) *types.Project {
digests := types.Services{}
for name, config := range project.Services {
service := types.ServiceConfig{
Image: config.Image,
ContainerSpec: types.ContainerSpec{Image: config.Image},
}
for _, vol := range config.Volumes {
if vol.Type == types.VolumeTypeImage {
Expand All @@ -271,7 +271,22 @@ func imagesOnly(project *types.Project) *types.Project {
}
digests[name] = service
}
project = &types.Project{Services: digests}
var jobDigests types.Jobs
if len(project.Jobs) > 0 {
jobDigests = types.Jobs{}
}
for name, config := range project.Jobs {
job := types.JobConfig{
ContainerSpec: types.ContainerSpec{Image: config.Image},
}
for _, vol := range config.Volumes {
if vol.Type == types.VolumeTypeImage {
job.Volumes = append(job.Volumes, vol)
}
}
jobDigests[name] = job
}
project = &types.Project{Services: digests, Jobs: jobDigests}
return project
}

Expand Down Expand Up @@ -335,10 +350,10 @@ func warnModelHooksNotLockable(model map[string]any) {
}
}

// lockModel removes from model all attributes but service images and `type: image` volumes
// lockModel removes from model all attributes but service/job images and `type: image` volumes
func lockModel(model map[string]any) {
for key, e := range model {
if key != "services" {
if key != "services" && key != "jobs" {
delete(model, key)
continue
}
Expand Down Expand Up @@ -367,17 +382,33 @@ func lockModel(model map[string]any) {
}

func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error {
_, hasServices := model["services"].(map[string]any)
_, hasJobs := model["jobs"].(map[string]any)
if !hasServices && !hasJobs {
// both are optional at the top level of the compose model
return nil
}
if hasServices {
if err := resolveServiceImageDigests(ctx, dockerCli, model); err != nil {
return err
}
}
if hasJobs {
if err := resolveJobImageDigests(ctx, dockerCli, model); err != nil {
return err
}
}
return nil
}

func resolveServiceImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error {
// create a pseudo-project so we can rely on WithImagesResolved to resolve images,
// pre_start hook images and `type: image` volume sources, keyed by actual service
// names so sources referencing another service are detected as such and kept unresolved
p := &types.Project{
Services: types.Services{},
}
services, ok := model["services"].(map[string]any)
if !ok {
// services is optional at the top level of the compose model
return nil
}
services := model["services"].(map[string]any)
for name, s := range services {
service := s.(map[string]any)
config := types.ServiceConfig{}
Expand All @@ -386,7 +417,7 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s
}
for _, hook := range preStartHooks(service) {
image, _ := hook["image"].(string)
config.PreStart = append(config.PreStart, types.ServiceHook{Image: image})
config.PreStart = append(config.PreStart, types.PreStartHook{ContainerSpec: types.ContainerSpec{Image: image}})
}
for _, volume := range imageVolumes(service) {
source, _ := volume["source"].(string)
Expand Down Expand Up @@ -425,6 +456,50 @@ func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[s
return nil
}

// resolveJobImageDigests mirrors resolveServiceImageDigests for model["jobs"].
// WithImagesResolved only walks a project's Services, so jobs are dressed as
// services in their own pseudo-project -- keyed by job name, which can't
// collide with an actual service since the two are never mixed here -- then
// the resolved digests are folded back into the raw job models.
func resolveJobImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) error {
jobsAsServices := &types.Project{Services: types.Services{}}
jobs := model["jobs"].(map[string]any)
for name, j := range jobs {
job := j.(map[string]any)
config := types.ServiceConfig{}
if image, ok := job["image"].(string); ok {
config.Image = image
}
for _, volume := range imageVolumes(job) {
source, _ := volume["source"].(string)
config.Volumes = append(config.Volumes, types.ServiceVolumeConfig{
Type: types.VolumeTypeImage,
Source: source,
})
}
jobsAsServices.Services[name] = config
}

jobsAsServices, err := jobsAsServices.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()))
if err != nil {
return err
}

for name, j := range jobs {
job := j.(map[string]any)
config := jobsAsServices.Services[name]
if config.Image != "" {
job["image"] = config.Image
}
for i, volume := range imageVolumes(job) {
if source := config.Volumes[i].Source; source != "" {
volume["source"] = source
}
}
}
return nil
}

// preStartHooks returns the pre_start hook declarations of a service raw model
func preStartHooks(service map[string]any) []map[string]any {
hooks, ok := service["pre_start"].([]any)
Expand Down Expand Up @@ -651,6 +726,13 @@ func runConfigImages(ctx context.Context, dockerCli command.Cli, opts configOpti
_, _ = fmt.Fprintln(dockerCli.Out(), img)
}
}
for name, j := range project.Jobs {
job := types.ServiceConfig{Name: name, ContainerSpec: types.ContainerSpec{Image: j.Image}}
_, _ = fmt.Fprintln(dockerCli.Out(), api.GetImageNameOrDefault(job, project.Name))
for _, img := range api.GetDependentImages(job, project.Name) {
_, _ = fmt.Fprintln(dockerCli.Out(), img)
}
}
return nil
}

Expand Down
84 changes: 72 additions & 12 deletions cmd/compose/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,77 @@ func TestResolveImageDigestsWithoutServices(t *testing.T) {
assert.NilError(t, err)
}

// A job's image and `type: image` volume sources must resolve the same way a
// service's do -- jobs share the same build-and-publish surface, and
// resolveImageDigests originally only walked model["services"].
func TestResolveImageDigestsWithJobs(t *testing.T) {
const (
serviceDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
jobDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
jobVolDigest = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
)
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl)
cli.EXPECT().Client().Return(apiClient).AnyTimes()
cli.EXPECT().ConfigFile().Return(configfile.New("")).AnyTimes()

model := map[string]any{
"services": map[string]any{
"web": map[string]any{"image": "nginx:latest"},
},
"jobs": map[string]any{
"migrate": map[string]any{
"image": "migrate:latest",
"volumes": []any{
map[string]any{"type": "image", "source": "migrate-data:latest", "target": "/data"},
},
},
},
}

apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/nginx:latest", gomock.Any()).
Return(client.DistributionInspectResult{
DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: serviceDigest}},
}, nil)
apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/migrate:latest", gomock.Any()).
Return(client.DistributionInspectResult{
DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: jobDigest}},
}, nil)
apiClient.EXPECT().DistributionInspect(gomock.Any(), "docker.io/library/migrate-data:latest", gomock.Any()).
Return(client.DistributionInspectResult{
DistributionInspect: registry.DistributionInspect{Descriptor: ocispec.Descriptor{Digest: jobVolDigest}},
}, nil)

err := resolveImageDigests(t.Context(), cli, model)
assert.NilError(t, err)

service := model["services"].(map[string]any)["web"].(map[string]any)
assert.Equal(t, service["image"], "docker.io/library/nginx:latest@"+serviceDigest)

job := model["jobs"].(map[string]any)["migrate"].(map[string]any)
assert.Equal(t, job["image"], "docker.io/library/migrate:latest@"+jobDigest)
volumes := job["volumes"].([]any)
assert.Equal(t, volumes[0].(map[string]any)["source"], "docker.io/library/migrate-data:latest@"+jobVolDigest)
}

func TestImagesOnly(t *testing.T) {
project := &types.Project{
Name: "test",
Services: types.Services{
"test": types.ServiceConfig{
Name: "test",
Image: "docker.io/library/nginx@" + testDigest,
Command: types.ShellCommand{"echo", "hello"},
Name: "test",

// hooks can't be overridden element-wise on merge, so the lock must not carry them
PreStart: []types.ServiceHook{{Image: "docker.io/library/hookimage@" + testDigest}},
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
{Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"},
PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "docker.io/library/hookimage@" + testDigest}}}, ContainerSpec: types.ContainerSpec{
Image: "docker.io/library/nginx@" + testDigest,
Command: types.ShellCommand{"echo", "hello"},

Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
{Type: types.VolumeTypeBind, Source: "/host", Target: "/bind"},
},
},
},
},
Expand All @@ -154,9 +212,11 @@ func TestImagesOnly(t *testing.T) {
assert.DeepEqual(t, locked, &types.Project{
Services: types.Services{
"test": types.ServiceConfig{
Image: "docker.io/library/nginx@" + testDigest,
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
ContainerSpec: types.ContainerSpec{
Image: "docker.io/library/nginx@" + testDigest,
Volumes: []types.ServiceVolumeConfig{
{Type: types.VolumeTypeImage, Source: "docker.io/library/someimage@" + testDigest, Target: "/data"},
},
},
},
},
Expand All @@ -167,8 +227,8 @@ func TestWarnHooksNotLockable(t *testing.T) {
messages := captureWarnings(t, func() {
warnHooksNotLockable(&types.Project{
Services: types.Services{
"with-hook-image": types.ServiceConfig{PreStart: []types.ServiceHook{{Image: "alpine:latest"}}},
"inline-hook": types.ServiceConfig{PreStart: []types.ServiceHook{{Command: types.ShellCommand{"echo"}}}},
"with-hook-image": types.ServiceConfig{PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Image: "alpine:latest"}}}},
"inline-hook": types.ServiceConfig{PreStart: []types.PreStartHook{{ContainerSpec: types.ContainerSpec{Command: types.ShellCommand{"echo"}}}}},
"without-hook": types.ServiceConfig{},
},
})
Expand Down
21 changes: 18 additions & 3 deletions cmd/compose/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/docker/compose/v5/cmd/display"
"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/compose"
)
Expand Down Expand Up @@ -71,9 +72,18 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac
}
return nil
}),
RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services)
}),
RunE: func(cmd *cobra.Command, args []string) error {
err := p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
return runCreate(ctx, dockerCli, backendOptions, opts, buildOpts, project, services)
})(cmd, args)
if jobErr, replaced := jobTargetErr(cmd.Context(), dockerCli, p, args, err); replaced {
if display.Mode == display.ModeJSON {
return makeJSONError(jobErr)
}
return jobErr
}
return err
},
ValidArgsFunction: completeServiceNames(dockerCli, p),
}
flags := cmd.Flags()
Expand All @@ -98,6 +108,11 @@ func createCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac
}

func runCreate(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, createOpts createOptions, buildOpts buildOptions, project *types.Project, services []string) error {
// same contract as up: an active scheduled job is refused before any
// resource is created — silently not scheduling would break expectations
if err := rejectScheduledJobs(project); err != nil {
return err
}
if err := createOpts.Apply(project); err != nil {
return err
}
Expand Down
Loading
Loading