Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
158 changes: 158 additions & 0 deletions cmd/nerdctl/compose/compose_up_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package compose

import (
"errors"
"fmt"
"io"
"path/filepath"
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions docs/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<SERVICE>.volumes[].type: image`
- Whole-image mounts are supported.
- `services.<SERVICE>.volumes[].image.subpath` is not yet supported.
109 changes: 99 additions & 10 deletions pkg/composer/serviceparser/serviceparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -434,19 +440,54 @@ 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 {
return nil, err
}

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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading