diff --git a/cmd/nerdctl/compose/compose_up_linux_test.go b/cmd/nerdctl/compose/compose_up_linux_test.go index 13a07eabafb..582eb7d2488 100644 --- a/cmd/nerdctl/compose/compose_up_linux_test.go +++ b/cmd/nerdctl/compose/compose_up_linux_test.go @@ -17,6 +17,7 @@ package compose import ( + "errors" "fmt" "io" "path/filepath" @@ -1245,6 +1246,163 @@ services: testCase.Run(t) } +func TestComposeImageVolume(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = nerdtest.Private + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + containerName := data.Identifier("image-volume") + composeYAML := fmt.Sprintf(` +services: + app: + image: %s + container_name: %s + command: ["sleep", "infinity"] + network_mode: none + volumes: + - type: image + source: %s + target: /website +`, testutil.CommonImage, containerName, testutil.NginxAlpineImage) + composePath := data.Temp().Path("compose.yaml") + data.Temp().Save(composeYAML, "compose.yaml") + + helpers.Anyhow("rmi", "-f", testutil.NginxAlpineImage) + helpers.Command("image", "inspect", testutil.NginxAlpineImage).Run(&test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + }) + helpers.Ensure("compose", "-f", composePath, "up", "-d") + helpers.Ensure("image", "inspect", testutil.NginxAlpineImage) + helpers.Command("inspect", "--format", "{{json .Mounts}}", containerName).Run(&test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.Contains(`"Type":"image"`), + }) + data.Labels().Set("containerName", containerName) + } + + testCase.SubTests = []*test.Case{ + { + Description: "source image files are visible", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("containerName"), "test", "-s", "/website/usr/share/nginx/html/index.html") + }, + Expected: test.Expects(0, nil, nil), + }, + { + Description: "image mount is read only", + NoParallel: true, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("containerName"), "touch", "/website/should-not-exist") + }, + Expected: test.Expects(expect.ExitCodeGenericFail, []error{errors.New("Read-only file system")}, nil), + }, + } + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-f", data.Temp().Path("compose.yaml"), "down", "--volumes", "--remove-orphans") + helpers.Anyhow("rm", "-f", data.Identifier("image-volume")) + helpers.Anyhow("rmi", "-f", testutil.NginxAlpineImage) + } + + testCase.Run(t) +} + +func TestComposeImageVolumeServiceSource(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = nerdtest.Private + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + projectName := data.Identifier("image-service-source") + containerName := data.Identifier("image-service-source-app") + composeYAML := fmt.Sprintf(` +services: + source: + image: %s + profiles: [image-source] + app: + image: %s + container_name: %s + command: ["sleep", "infinity"] + network_mode: none + volumes: + - type: image + source: source + target: /website +`, testutil.NginxAlpineImage, testutil.CommonImage, containerName) + composePath := data.Temp().Save(composeYAML, "compose.yaml") + + helpers.Anyhow("rmi", "-f", testutil.NginxAlpineImage) + helpers.Command("image", "inspect", testutil.NginxAlpineImage).Run(&test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + }) + helpers.Ensure("compose", "-p", projectName, "-f", composePath, "up", "-d") + helpers.Ensure("image", "inspect", testutil.NginxAlpineImage) + helpers.Command("inspect", "--format", "{{json .Mounts}}", containerName).Run(&test.Expected{ + ExitCode: expect.ExitCodeSuccess, + Output: expect.All( + expect.Contains(`"Type":"image"`), + expect.Contains(fmt.Sprintf(`"Source":"%s"`, testutil.NginxAlpineImage)), + ), + }) + data.Labels().Set("composeYAML", composePath) + data.Labels().Set("containerName", containerName) + data.Labels().Set("projectName", projectName) + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("exec", data.Labels().Get("containerName"), "test", "-s", "/website/usr/share/nginx/html/index.html") + } + testCase.Expected = test.Expects(expect.ExitCodeSuccess, nil, nil) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("compose", "-p", data.Identifier("image-service-source"), "-f", data.Temp().Path("compose.yaml"), "down", "--volumes", "--remove-orphans") + helpers.Anyhow("rm", "-f", data.Identifier("image-service-source-app")) + helpers.Anyhow("rmi", "-f", testutil.NginxAlpineImage) + } + + testCase.Run(t) +} + +func TestComposeImageVolumeValidationDoesNotCreateNetwork(t *testing.T) { + testCase := nerdtest.Setup() + testCase.Require = require.All( + nerdtest.Private, + require.Not(nerdtest.Docker), + ) + testCase.NoParallel = true + + testCase.Setup = func(data test.Data, helpers test.Helpers) { + composeYAML := fmt.Sprintf(` +services: + app: + image: %s + volumes: + - type: image + target: /website +`, testutil.CommonImage) + data.Temp().Save(composeYAML, "compose.yaml") + } + + testCase.Command = func(data test.Data, helpers test.Helpers) test.TestableCommand { + projectName := data.Identifier("invalid-image-volume") + helpers.Command("compose", "-p", projectName, "-f", data.Temp().Path("compose.yaml"), "up", "-d").Run(&test.Expected{ + ExitCode: expect.ExitCodeGenericFail, + Errors: []error{errors.New("image volume source is missing")}, + }) + return helpers.Command("network", "inspect", projectName+"_default") + } + testCase.Expected = test.Expects(expect.ExitCodeGenericFail, nil, nil) + + testCase.Cleanup = func(data test.Data, helpers test.Helpers) { + projectName := data.Identifier("invalid-image-volume") + helpers.Anyhow("network", "rm", projectName+"_default") + helpers.Anyhow("compose", "-p", projectName, "-f", data.Temp().Path("compose.yaml"), "down", "--volumes", "--remove-orphans") + } + + testCase.Run(t) +} + func TestComposeTmpfsVolume(t *testing.T) { testCase := nerdtest.Setup() diff --git a/docs/compose.md b/docs/compose.md index 02e59be61e9..68c06cdb084 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -42,3 +42,7 @@ which was derived from [Docker Compose file version 3 specification](https://doc - `uid`, `gid`: Cannot be specified. The default value is not propagated from `USER` instruction of Dockerfile. The file owner corresponds to the original file on the host. - `mode`: Cannot be specified. The file is mounted as read-only, with permission bits that correspond to the original file on the host. + +#### `services..volumes[].type: image` +- Whole-image mounts are supported. +- `services..volumes[].image.subpath` is not yet supported. diff --git a/pkg/composer/serviceparser/serviceparser.go b/pkg/composer/serviceparser/serviceparser.go index ad5c32c3e3b..28231e24c97 100644 --- a/pkg/composer/serviceparser/serviceparser.go +++ b/pkg/composer/serviceparser/serviceparser.go @@ -217,12 +217,18 @@ type Build struct { // TODO: call BuildKit API directly without executing `nerdctl build` } +type ImageMountSource struct { + Source string + Platform string +} + type Service struct { - Image string - PullMode string - Containers []Container // length = replicas - Build *Build - Unparsed *types.ServiceConfig + Image string + PullMode string + Containers []Container // length = replicas + Build *Build + Unparsed *types.ServiceConfig + ImageMountSources []ImageMountSource } func getReplicas(svc types.ServiceConfig) (int, error) { @@ -434,8 +440,42 @@ func getNetworks(project *types.Project, svc types.ServiceConfig) ([]networkName return fullNames, nil } +func resolveImageVolumeSources(project *types.Project, svc *types.ServiceConfig) []ImageMountSource { + services := project.AllServices() + svc.Volumes = append([]types.ServiceVolumeConfig(nil), svc.Volumes...) + imageMountSources := make([]ImageMountSource, 0, len(svc.Volumes)) + for i := range svc.Volumes { + volume := &svc.Volumes[i] + if volume.Type != types.VolumeTypeImage { + continue + } + + platform := svc.Platform + if referencedService, ok := services[volume.Source]; ok { + if referencedService.Image != "" { + volume.Source = referencedService.Image + } else { + serviceName := referencedService.Name + if serviceName == "" { + serviceName = volume.Source + } + volume.Source = DefaultImageName(project.Name, serviceName) + } + if referencedService.Platform != "" { + platform = referencedService.Platform + } + } + imageMountSources = append(imageMountSources, ImageMountSource{ + Source: volume.Source, + Platform: platform, + }) + } + return imageMountSources +} + func Parse(project *types.Project, svc types.ServiceConfig) (*Service, error) { warnUnknownFields(svc) + imageMountSources := resolveImageVolumeSources(project, &svc) replicas, err := getReplicas(svc) if err != nil { @@ -443,10 +483,11 @@ func Parse(project *types.Project, svc types.ServiceConfig) (*Service, error) { } parsed := &Service{ - Image: svc.Image, - PullMode: "missing", - Containers: make([]Container, replicas), - Unparsed: &svc, + Image: svc.Image, + ImageMountSources: imageMountSources, + PullMode: "missing", + Containers: make([]Container, replicas), + Unparsed: &svc, } if svc.Build == nil { @@ -719,13 +760,22 @@ func newContainer(project *types.Project, parsed *Service, i int) (*Container, e } for _, v := range svc.Volumes { + if v.Type == types.VolumeTypeImage { + mount, err := serviceVolumeConfigToImageMount(v) + if err != nil { + return nil, err + } + c.RunArgs = append(c.RunArgs, "--mount="+mount) + continue + } + vStr, mkdir, err := serviceVolumeConfigToFlagV(v, project) if err != nil { return nil, err } switch v.Type { - case "tmpfs": + case types.VolumeTypeTmpfs: c.RunArgs = append(c.RunArgs, "--tmpfs="+vStr) default: c.RunArgs = append(c.RunArgs, "-v="+vStr) @@ -841,6 +891,45 @@ func servicePortConfigToFlagP(c types.ServicePortConfig) (string, error) { return s, nil } +func serviceVolumeConfigToImageMount(c types.ServiceVolumeConfig) (string, error) { + if c.Source == "" { + return "", errors.New("image volume source is missing") + } + if strings.Contains(c.Source, ",") { + return "", errors.New("image volume source must not contain commas") + } + if c.Target == "" { + return "", errors.New("volume target is missing") + } + if !filepath.IsAbs(c.Target) { + return "", fmt.Errorf("volume target must be an absolute path, got %q", c.Target) + } + if strings.Contains(c.Target, ",") { + return "", errors.New("volume target must not contain commas") + } + if c.Bind != nil { + return "", errors.New("image volume does not support bind options") + } + if c.Volume != nil { + return "", errors.New("image volume does not support volume options") + } + if c.Tmpfs != nil { + return "", errors.New("image volume does not support tmpfs options") + } + if c.Consistency != "" { + return "", errors.New("image volume does not support consistency options") + } + if c.Image != nil && c.Image.SubPath != "" { + return "", errors.New("image.subpath is not yet supported") + } + + mount := fmt.Sprintf("type=%s,source=%s,target=%s", types.VolumeTypeImage, c.Source, c.Target) + if c.ReadOnly { + mount += ",readonly" + } + return mount, nil +} + func serviceVolumeConfigToFlagV(c types.ServiceVolumeConfig, project *types.Project) (flagV string, mkdir []string, err error) { if unknown := reflectutil.UnknownNonEmptyFields(&c, "Type", diff --git a/pkg/composer/serviceparser/serviceparser_test.go b/pkg/composer/serviceparser/serviceparser_test.go index 8b567512097..c4c0ae375fe 100644 --- a/pkg/composer/serviceparser/serviceparser_test.go +++ b/pkg/composer/serviceparser/serviceparser_test.go @@ -450,6 +450,302 @@ services: } } +func TestServiceVolumeConfigToImageMount(t *testing.T) { + t.Parallel() + + target := "/website" + if runtime.GOOS == "windows" { + target = `C:\website` + } + + testCases := []struct { + name string + volume types.ServiceVolumeConfig + want string + wantErr string + }{ + { + name: "whole image", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + }, + want: fmt.Sprintf("type=image,source=nginx:alpine,target=%s", target), + }, + { + name: "explicit read only", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + ReadOnly: true, + }, + want: fmt.Sprintf("type=image,source=nginx:alpine,target=%s,readonly", target), + }, + { + name: "missing source", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Target: target, + }, + wantErr: "image volume source is missing", + }, + { + name: "missing target", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + }, + wantErr: "volume target is missing", + }, + { + name: "relative target", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: "website", + }, + wantErr: `volume target must be an absolute path, got "website"`, + }, + { + name: "image subpath", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + Image: &types.ServiceVolumeImage{SubPath: "usr/share/nginx/html"}, + }, + wantErr: "image.subpath is not yet supported", + }, + { + name: "bind options", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + Bind: &types.ServiceVolumeBind{}, + }, + wantErr: "image volume does not support bind options", + }, + { + name: "volume options", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + Volume: &types.ServiceVolumeVolume{}, + }, + wantErr: "image volume does not support volume options", + }, + { + name: "tmpfs options", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + Tmpfs: &types.ServiceVolumeTmpfs{}, + }, + wantErr: "image volume does not support tmpfs options", + }, + { + name: "consistency options", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target, + Consistency: "cached", + }, + wantErr: "image volume does not support consistency options", + }, + { + name: "source containing comma", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine,type=bind,source=/", + Target: target, + }, + wantErr: "image volume source must not contain commas", + }, + { + name: "target containing comma", + volume: types.ServiceVolumeConfig{ + Type: types.VolumeTypeImage, + Source: "nginx:alpine", + Target: target + ",type=bind,source=/,target=/host", + }, + wantErr: "volume target must not contain commas", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := serviceVolumeConfigToImageMount(tc.volume) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + assert.NilError(t, err) + assert.Equal(t, got, tc.want) + }) + } +} + +func TestParseImageVolume(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + + const dockerComposeYAML = ` +services: + foo: + image: alpine + volumes: + - type: image + source: nginx:alpine + target: /website +` + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) + assert.NilError(t, err) + + fooSvc, err := project.GetService("foo") + assert.NilError(t, err) + + foo, err := Parse(project, fooSvc) + assert.NilError(t, err) + assert.Equal(t, len(foo.Containers), 1) + assert.Assert(t, in(foo.Containers[0].RunArgs, "--mount=type=image,source=nginx:alpine,target=/website")) + assert.Assert(t, !in(foo.Containers[0].RunArgs, "-v=nginx:alpine:/website")) +} + +func TestParseImageVolumeServiceSource(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + + testCases := []struct { + name string + referencedService types.ServiceConfig + disabled bool + wantSource string + wantPlatform string + }{ + { + name: "explicit image", + referencedService: types.ServiceConfig{ + Name: "builder", + Image: "nginx:alpine", + Platform: "linux/amd64", + }, + wantSource: "nginx:alpine", + wantPlatform: "linux/amd64", + }, + { + name: "disabled build service", + referencedService: types.ServiceConfig{ + Name: "builder", + Build: &types.BuildConfig{}, + Platform: "linux/amd64", + }, + disabled: true, + wantSource: DefaultImageName("project", "builder"), + wantPlatform: "linux/amd64", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + app := types.ServiceConfig{ + Name: "app", + Image: "alpine", + Platform: "linux/arm64", + Volumes: []types.ServiceVolumeConfig{{ + Type: types.VolumeTypeImage, + Source: "builder", + Target: "/website", + }}, + } + project := &types.Project{ + Name: "project", + Services: types.Services{"app": app}, + } + if tc.disabled { + project.DisabledServices = types.Services{"builder": tc.referencedService} + } else { + project.Services["builder"] = tc.referencedService + } + + parsed, err := Parse(project, app) + assert.NilError(t, err) + assert.Equal(t, parsed.Unparsed.Volumes[0].Source, tc.wantSource) + assert.DeepEqual(t, parsed.ImageMountSources, []ImageMountSource{{ + Source: tc.wantSource, + Platform: tc.wantPlatform, + }}) + assert.Assert(t, in(parsed.Containers[0].RunArgs, + fmt.Sprintf("--mount=type=image,source=%s,target=/website", tc.wantSource))) + assert.Equal(t, project.Services["app"].Volumes[0].Source, "builder") + }) + } +} + +func TestParseImageVolumePreservesOtherVolumeTypes(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("test is not compatible with windows") + } + + const dockerComposeYAML = ` +services: + foo: + image: alpine + volumes: + - type: image + source: nginx:alpine + target: /website + - type: bind + source: /host + target: /bind + - type: volume + source: named + target: /named + - type: volume + target: /anonymous + - type: tmpfs + target: /tmpfs +volumes: + named: +` + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + + project, err := testutil.LoadProject(comp.YAMLFullPath(), comp.ProjectName(), nil) + assert.NilError(t, err) + + fooSvc, err := project.GetService("foo") + assert.NilError(t, err) + + foo, err := Parse(project, fooSvc) + assert.NilError(t, err) + assert.Equal(t, len(foo.Containers), 1) + runArgs := foo.Containers[0].RunArgs + assert.Assert(t, in(runArgs, "--mount=type=image,source=nginx:alpine,target=/website")) + assert.Assert(t, in(runArgs, "-v=/host:/bind")) + assert.Assert(t, in(runArgs, fmt.Sprintf("-v=%s_named:/named", project.Name))) + assert.Assert(t, in(runArgs, "-v=/anonymous")) + assert.Assert(t, in(runArgs, "--tmpfs=/tmpfs")) +} + func TestTmpfsVolumeLongSyntax(t *testing.T) { t.Parallel() diff --git a/pkg/composer/up.go b/pkg/composer/up.go index ec9155331bb..5ef9de44756 100644 --- a/pkg/composer/up.go +++ b/pkg/composer/up.go @@ -57,18 +57,6 @@ func (opts UpOptions) recreateStrategy() string { } func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) error { - for shortName := range c.project.Networks { - if err := c.upNetwork(ctx, shortName); err != nil { - return err - } - } - - for shortName := range c.project.Volumes { - if err := c.upVolume(ctx, shortName); err != nil { - return err - } - } - for shortName, secret := range c.project.Secrets { obj := types.FileObjectConfig(secret) if err := validateFileObjectConfig(obj, shortName, "service", c.project); err != nil { @@ -104,6 +92,18 @@ func (c *Composer) Up(ctx context.Context, uo UpOptions, services []string) erro return err } + for shortName := range c.project.Networks { + if err := c.upNetwork(ctx, shortName); err != nil { + return err + } + } + + for shortName := range c.project.Volumes { + if err := c.upVolume(ctx, shortName); err != nil { + return err + } + } + // remove orphan containers before the service has be started // FYI: https://github.com/docker/compose/blob/v2.3.4/pkg/compose/create.go#L91-L112 orphans, err := c.getOrphanContainers(ctx, parsedServices) diff --git a/pkg/composer/up_service.go b/pkg/composer/up_service.go index fb9b1ed9fbb..2ab76545e1a 100644 --- a/pkg/composer/up_service.go +++ b/pkg/composer/up_service.go @@ -103,14 +103,25 @@ func (c *Composer) upServices(ctx context.Context, parsedServices []*servicepars } func (c *Composer) ensureServiceImage(ctx context.Context, ps *serviceparser.Service, allowBuild, forceBuild bool, bo BuildOptions, quiet bool, pullModeArg string) error { + pullMode := ps.PullMode + if pullModeArg != "" { + pullMode = pullModeArg + } + if ps.Build != nil && allowBuild { if ps.Build.Force || forceBuild { - return c.buildServiceImage(ctx, ps.Image, ps.Build, ps.Unparsed.Platform, bo) + if err := c.buildServiceImage(ctx, ps.Image, ps.Build, ps.Unparsed.Platform, bo); err != nil { + return err + } + return c.ensureImageMountSources(ctx, ps, pullMode, quiet) } if ok, err := c.ImageExists(ctx, ps.Image); err != nil { return err } else if !ok { - return c.buildServiceImage(ctx, ps.Image, ps.Build, ps.Unparsed.Platform, bo) + if err := c.buildServiceImage(ctx, ps.Image, ps.Build, ps.Unparsed.Platform, bo); err != nil { + return err + } + return c.ensureImageMountSources(ctx, ps, pullMode, quiet) } // even when c.ImageExists returns true, we need to call c.EnsureImage // because ps.PullMode can be "always". So no return here. @@ -118,10 +129,26 @@ func (c *Composer) ensureServiceImage(ctx context.Context, ps *serviceparser.Ser } log.G(ctx).Infof("Ensuring image %s", ps.Image) - if pullModeArg != "" { - return c.EnsureImage(ctx, ps.Image, pullModeArg, ps.Unparsed.Platform, ps, quiet) + if err := c.EnsureImage(ctx, ps.Image, pullMode, ps.Unparsed.Platform, ps, quiet); err != nil { + return err } - return c.EnsureImage(ctx, ps.Image, ps.PullMode, ps.Unparsed.Platform, ps, quiet) + return c.ensureImageMountSources(ctx, ps, pullMode, quiet) +} + +func (c *Composer) ensureImageMountSources(ctx context.Context, ps *serviceparser.Service, pullMode string, quiet bool) error { + seen := make(map[serviceparser.ImageMountSource]struct{}) + for _, source := range ps.ImageMountSources { + if _, ok := seen[source]; ok { + continue + } + seen[source] = struct{}{} + + log.G(ctx).Infof("Ensuring image mount source %s", source.Source) + if err := c.EnsureImage(ctx, source.Source, pullMode, source.Platform, ps, quiet); err != nil { + return fmt.Errorf("failed to ensure image %q for image volume: %w", source.Source, err) + } + } + return nil } // upServiceContainer must be called after ensureServiceImage diff --git a/pkg/composer/up_service_test.go b/pkg/composer/up_service_test.go new file mode 100644 index 00000000000..e270fe46c69 --- /dev/null +++ b/pkg/composer/up_service_test.go @@ -0,0 +1,89 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package composer + +import ( + "context" + "errors" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/composer/serviceparser" +) + +func TestEnsureImageMountSources(t *testing.T) { + t.Parallel() + + type ensureCall struct { + Image string + PullMode string + Platform string + Quiet bool + } + var calls []ensureCall + composer := &Composer{Options: Options{ + EnsureImage: func(_ context.Context, imageName, pullMode, platform string, _ *serviceparser.Service, quiet bool) error { + calls = append(calls, ensureCall{ + Image: imageName, + PullMode: pullMode, + Platform: platform, + Quiet: quiet, + }) + return nil + }, + }} + service := &serviceparser.Service{ + Unparsed: &types.ServiceConfig{}, + ImageMountSources: []serviceparser.ImageMountSource{ + {Source: "nginx:alpine", Platform: "linux/amd64"}, + {Source: "nginx:alpine", Platform: "linux/amd64"}, + {Source: "nginx:alpine", Platform: "linux/arm64"}, + {Source: "caddy:alpine", Platform: "linux/arm64"}, + }, + } + + err := composer.ensureImageMountSources(context.Background(), service, types.PullPolicyAlways, true) + assert.NilError(t, err) + assert.DeepEqual(t, calls, []ensureCall{ + {Image: "nginx:alpine", PullMode: types.PullPolicyAlways, Platform: "linux/amd64", Quiet: true}, + {Image: "nginx:alpine", PullMode: types.PullPolicyAlways, Platform: "linux/arm64", Quiet: true}, + {Image: "caddy:alpine", PullMode: types.PullPolicyAlways, Platform: "linux/arm64", Quiet: true}, + }) +} + +func TestEnsureImageMountSourcesError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("pull failed") + composer := &Composer{Options: Options{ + EnsureImage: func(context.Context, string, string, string, *serviceparser.Service, bool) error { + return sentinel + }, + }} + service := &serviceparser.Service{ + Unparsed: &types.ServiceConfig{}, + ImageMountSources: []serviceparser.ImageMountSource{ + {Source: "nginx:alpine"}, + }, + } + + err := composer.ensureImageMountSources(context.Background(), service, types.PullPolicyMissing, false) + assert.ErrorIs(t, err, sentinel) + assert.ErrorContains(t, err, `failed to ensure image "nginx:alpine" for image volume`) +}