mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
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:
committed by
Nicolas De loof
parent
eedf9131b4
commit
c8596c67fe
+74
-7
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user