fix(config): scan jobs in --images, --lock-image-digests, --resolve-image-digests

Only the interpolated path (imagesOnly) and pkg/compose/publish.go
walked project.Jobs the same way they walk project.Services. Three
sibling paths in config.go were missed:

- runConfigImages (--images) only printed service images.
- lockModel (--no-interpolate --lock-image-digests) dropped the
  top-level "jobs" key entirely along with every other non-services
  key, instead of filtering it down to image/type:image-volumes like
  services.
- resolveImageDigests (--no-interpolate --resolve-image-digests) only
  built its pseudo-project from model["services"], so job images and
  type: image volume sources never resolved -- confirmed by a new test
  that failed against the first attempt at this fix (job image stayed
  unpinned) until dressed as its own pseudo-project the same way
  generateImageDigestsOverride in publish.go already does, since
  WithImagesResolved only walks a project's Services, never its Jobs.

The extra complexity that last one picked up is split into
resolveServiceImageDigests (services, unchanged from before this
commit) and resolveJobImageDigests (new), called from a small
dispatcher.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-21 16:35:20 +02:00
committed by Nicolas De loof
parent eedf9131b4
commit c8596c67fe
2 changed files with 129 additions and 7 deletions
+74 -7
View File
@@ -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
}
+55
View File
@@ -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",