diff --git a/cmd/compose/config.go b/cmd/compose/config.go index d39c34e54..f17d752d1 100644 --- a/cmd/compose/config.go +++ b/cmd/compose/config.go @@ -350,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 } @@ -382,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{} @@ -440,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) @@ -666,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 } diff --git a/cmd/compose/config_test.go b/cmd/compose/config_test.go index db557cb42..a30713b7f 100644 --- a/cmd/compose/config_test.go +++ b/cmd/compose/config_test.go @@ -130,6 +130,61 @@ 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",