diff --git a/docker_mounts.go b/docker_mounts.go index 7954b2b102..9609d92caa 100644 --- a/docker_mounts.go +++ b/docker_mounts.go @@ -1,6 +1,9 @@ package testcontainers import ( + "errors" + "path/filepath" + "github.com/docker/docker/api/types/mount" "github.com/testcontainers/testcontainers-go/log" @@ -11,6 +14,7 @@ var mountTypeMapping = map[MountType]mount.Type{ MountTypeVolume: mount.TypeVolume, MountTypeTmpfs: mount.TypeTmpfs, MountTypePipe: mount.TypeNamedPipe, + MountTypeImage: mount.TypeImage, } // Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments @@ -32,6 +36,12 @@ type TmpfsMounter interface { GetTmpfsOptions() *mount.TmpfsOptions } +// ImageMounter can optionally be implemented by mount sources +// to support advanced scenarios based on mount.ImageOptions +type ImageMounter interface { + ImageOptions() *mount.ImageOptions +} + // Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments type DockerBindMountSource struct { *mount.BindOptions @@ -85,6 +95,48 @@ func (s DockerTmpfsMountSource) GetTmpfsOptions() *mount.TmpfsOptions { return s.TmpfsOptions } +// DockerImageMountSource is a mount source for an image +type DockerImageMountSource struct { + // imageName is the image name + imageName string + + // subpath is the subpath to mount the image into + subpath string +} + +// NewDockerImageMountSource creates a new DockerImageMountSource +func NewDockerImageMountSource(imageName string, subpath string) DockerImageMountSource { + return DockerImageMountSource{ + imageName: imageName, + subpath: subpath, + } +} + +// Validate validates the source of the mount, ensuring that the subpath is a relative path +func (s DockerImageMountSource) Validate() error { + if !filepath.IsLocal(s.subpath) { + return errors.New("image mount source must be a local path") + } + return nil +} + +// ImageOptions returns the image options for the image mount +func (s DockerImageMountSource) ImageOptions() *mount.ImageOptions { + return &mount.ImageOptions{ + Subpath: s.subpath, + } +} + +// Source returns the image name for the image mount +func (s DockerImageMountSource) Source() string { + return s.imageName +} + +// Type returns the mount type for the image mount +func (s DockerImageMountSource) Type() MountType { + return MountTypeImage +} + // PrepareMounts maps the given []ContainerMount to the corresponding // []mount.Mount for further processing func (m ContainerMounts) PrepareMounts() []mount.Mount { @@ -118,6 +170,8 @@ func mapToDockerMounts(containerMounts ContainerMounts) []mount.Mount { containerMount.VolumeOptions = typedMounter.GetVolumeOptions() case TmpfsMounter: containerMount.TmpfsOptions = typedMounter.GetTmpfsOptions() + case ImageMounter: + containerMount.ImageOptions = typedMounter.ImageOptions() case BindMounter: log.Printf("Mount type %s is not supported by Testcontainers for Go", m.Source.Type()) default: diff --git a/docs/features/common_functional_options.md b/docs/features/common_functional_options.md index be65c53029..f3f3ca19a3 100644 --- a/docs/features/common_functional_options.md +++ b/docs/features/common_functional_options.md @@ -15,6 +15,23 @@ _Testcontainers for Go_ exposes an interface to perform this operation: `ImageSu Using the `WithImageSubstitutors` options, you could define your own substitutions to the container images. E.g. adding a prefix to the images so that they can be pulled from a Docker registry other than Docker Hub. This is the usual mechanism for using Docker image proxies, caches, etc. +#### WithImageMount + +- Not available until the next release of testcontainers-go :material-tag: main + +Since Docker v28, it's possible to mount an image to a container, passing the source image name, the relative subpath to mount in that image, and the mount point in the target container. + +This option validates that the subpath is a relative path, raising an error otherwise. + + +[Image Mount](../../modules/ollama/examples_test.go) inside_block:mountImage + + +In the code above, which mounts the directory in which Ollama models are stored, the `targetImage` is the name of the image containing the models (an Ollama image where the models are already pulled). + +!!!warning + Using this option fails the creation of the container if the underlying container runtime does not support the `image mount` feature. + #### WithEnv - Since testcontainers-go :material-tag: v0.29.0 diff --git a/docs/features/files_and_mounts.md b/docs/features/files_and_mounts.md index 118ee1ccbb..bae6caa6e1 100644 --- a/docs/features/files_and_mounts.md +++ b/docs/features/files_and_mounts.md @@ -20,6 +20,20 @@ It is possible to map a Docker volume into the container using the `Mounts` attr It is recommended to copy data from your local host machine to a test container using the file copy API described below, as it is much more portable. +## Mounting images + +Since Docker v28, it is possible to mount the file system of an image into a container using the `Mounts` attribute at the `ContainerRequest` struct. For that, use the `DockerImageMountSource` type, which allows you to specify the name of the image to be mounted, and the subpath inside the container where it should be mounted, or simply call the `ImageMount` function, which does exactly that: + + +[Image mounts](../../lifecycle_test.go) inside_block:imageMounts + + +!!!warning + If the subpath is not a relative path, the creation of the container will fail. + +!!!info + Mounting images fails the creation of the container if the underlying container runtime does not support the `image mount` feature, which is available since Docker v28. + ## Copying files to a container If you would like to copy a file to a container, you can do it in two different manners: diff --git a/lifecycle.go b/lifecycle.go index d938af2c8c..72363cccca 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -521,6 +521,20 @@ func (c ContainerLifecycleHooks) Terminated(ctx context.Context) func(container } func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req ContainerRequest, dockerInput *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) error { + var mountErrors []error + for _, m := range req.Mounts { + // validate only the mount sources that implement the Validator interface + if v, ok := m.Source.(Validator); ok { + if err := v.Validate(); err != nil { + mountErrors = append(mountErrors, err) + } + } + } + + if len(mountErrors) > 0 { + return errors.Join(mountErrors...) + } + // prepare mounts hostConfig.Mounts = mapToDockerMounts(req.Mounts) diff --git a/lifecycle_test.go b/lifecycle_test.go index bde9b96aef..02de785103 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -29,6 +29,32 @@ func TestPreCreateModifierHook(t *testing.T) { require.NoError(t, err) defer provider.Close() + t.Run("mount-errors", func(t *testing.T) { + // imageMounts { + req := ContainerRequest{ + // three mounts, one valid and two invalid + Mounts: ContainerMounts{ + { + Source: NewDockerImageMountSource("nginx:latest", "var/www/html"), + Target: "/var/www/valid", + }, + ImageMount("nginx:latest", "../var/www/html", "/var/www/invalid1"), + ImageMount("nginx:latest", "/var/www/html", "/var/www/invalid2"), + }, + } + // } + + err = provider.preCreateContainerHook(ctx, req, &container.Config{}, &container.HostConfig{}, &network.NetworkingConfig{}) + require.Error(t, err) + + var errs []error + var joinErr interface{ Unwrap() []error } + if errors.As(err, &joinErr) { + errs = joinErr.Unwrap() + } + require.Len(t, errs, 2) // one valid and two invalid mounts + }) + t.Run("No exposed ports", func(t *testing.T) { // reqWithModifiers { req := ContainerRequest{ diff --git a/modules/ollama/examples_test.go b/modules/ollama/examples_test.go index f14ac19aee..c5a12d491c 100644 --- a/modules/ollama/examples_test.go +++ b/modules/ollama/examples_test.go @@ -2,6 +2,7 @@ package ollama_test import ( "context" + "encoding/json" "fmt" "log" "net/http" @@ -243,3 +244,103 @@ func ExampleRun_withLocal() { // Intentionally not asserting the output, as we don't want to run this example in the tests. } + +func ExampleRun_withImageMount() { + cli, err := testcontainers.NewDockerClientWithOpts(context.Background()) + if err != nil { + log.Printf("failed to create docker client: %s", err) + return + } + + info, err := cli.Info(context.Background()) + if err != nil { + log.Printf("failed to get docker info: %s", err) + return + } + + // skip if the major version of the server is not v28 or greater + if info.ServerVersion < "28.0.0" { + log.Printf("skipping test because the server version is not v28 or greater") + return + } + + ctx := context.Background() + + ollamaContainer, err := tcollama.Run(ctx, "ollama/ollama:0.5.12") + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + defer func() { + if err := testcontainers.TerminateContainer(ollamaContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + + code, _, err := ollamaContainer.Exec(ctx, []string{"ollama", "pull", "all-minilm"}) + if err != nil { + log.Printf("failed to pull model %s: %s", "all-minilm", err) + return + } + + fmt.Println(code) + + targetImage := "testcontainers/ollama:tc-model-all-minilm" + + err = ollamaContainer.Commit(ctx, targetImage) + if err != nil { + log.Printf("failed to commit container: %s", err) + return + } + + // start a new fresh ollama container mounting the target image + // mountImage { + newOllamaContainer, err := tcollama.Run( + ctx, + "ollama/ollama:0.5.12", + testcontainers.WithImageMount(targetImage, "root/.ollama/models/", "/root/.ollama/models/"), + ) + // } + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + defer func() { + if err := testcontainers.TerminateContainer(newOllamaContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + + // perform an HTTP request to the ollama container to verify the model is available + + connectionStr, err := newOllamaContainer.ConnectionString(ctx) + if err != nil { + log.Printf("failed to get connection string: %s", err) + return + } + + resp, err := http.Get(connectionStr + "/api/tags") + if err != nil { + log.Printf("failed to get request: %s", err) + return + } + + fmt.Println(resp.StatusCode) + + type tagsResponse struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + + var tags tagsResponse + err = json.NewDecoder(resp.Body).Decode(&tags) + if err != nil { + log.Printf("failed to decode response: %s", err) + return + } + + fmt.Println(tags.Models[0].Name) + + // Intentionally not asserting the output, as we don't want to run this example in the tests. +} diff --git a/mounts.go b/mounts.go index a68e468b39..2e1d2c7e63 100644 --- a/mounts.go +++ b/mounts.go @@ -1,12 +1,16 @@ package testcontainers -import "errors" +import ( + "errors" + "path/filepath" +) const ( MountTypeBind MountType = iota // Deprecated: Use MountTypeVolume instead MountTypeVolume MountTypeTmpfs MountTypePipe + MountTypeImage ) var ( @@ -18,6 +22,7 @@ var ( _ ContainerMountSource = (*GenericBindMountSource)(nil) // Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments _ ContainerMountSource = (*GenericVolumeMountSource)(nil) _ ContainerMountSource = (*GenericTmpfsMountSource)(nil) + _ ContainerMountSource = (*GenericImageMountSource)(nil) ) type ( @@ -110,6 +115,15 @@ func VolumeMount(volumeName string, mountTarget ContainerMountTarget) ContainerM } } +// ImageMount returns a new ContainerMount with a GenericImageMountSource as source +// This is a convenience method to cover typical use cases. +func ImageMount(imageName string, subpath string, mountTarget ContainerMountTarget) ContainerMount { + return ContainerMount{ + Source: NewGenericImageMountSource(imageName, subpath), + Target: mountTarget, + } +} + // Mounts returns a ContainerMounts to support a more fluent API func Mounts(mounts ...ContainerMount) ContainerMounts { return mounts @@ -124,3 +138,38 @@ type ContainerMount struct { // ReadOnly determines if the mount should be read-only ReadOnly bool } + +// GenericImageMountSource implements ContainerMountSource and represents an image mount +type GenericImageMountSource struct { + // imageName refers to the name of the image to be mounted + // the same image might be mounted to multiple locations within a single container + imageName string + // subpath is the path within the image to be mounted + subpath string +} + +// NewGenericImageMountSource creates a new GenericImageMountSource +func NewGenericImageMountSource(imageName string, subpath string) GenericImageMountSource { + return GenericImageMountSource{ + imageName: imageName, + subpath: subpath, + } +} + +// Source returns the name of the image to be mounted +func (s GenericImageMountSource) Source() string { + return s.imageName +} + +// Type returns the type of the mount +func (GenericImageMountSource) Type() MountType { + return MountTypeImage +} + +// Validate validates the source of the mount +func (s GenericImageMountSource) Validate() error { + if !filepath.IsLocal(s.subpath) { + return errors.New("image mount source must be a local path") + } + return nil +} diff --git a/mounts_test.go b/mounts_test.go index b1ac51d305..d155618b12 100644 --- a/mounts_test.go +++ b/mounts_test.go @@ -42,6 +42,38 @@ func TestVolumeMount(t *testing.T) { } } +func TestImageMount(t *testing.T) { + t.Parallel() + + t.Run("valid-image-mount", func(t *testing.T) { + t.Parallel() + m := testcontainers.ImageMount("nginx:latest", "var/www/html", "/var/www/html") + // the source is a GenericImageMountSource, which does implement the Validator interface + if v, ok := m.Source.(testcontainers.Validator); ok { + require.NoError(t, v.Validate()) + } + + require.Equal(t, testcontainers.ContainerMount{ + Source: testcontainers.NewGenericImageMountSource("nginx:latest", "var/www/html"), + Target: "/var/www/html", + }, m) + }) + + t.Run("invalid-image-mount", func(t *testing.T) { + t.Parallel() + m := testcontainers.ImageMount("nginx:latest", "../var/www/html", "/var/www/invalid") + // the source is a GenericImageMountSource, which does implement the Validator interface + if v, ok := m.Source.(testcontainers.Validator); ok { + require.Error(t, v.Validate()) + } + + require.Equal(t, testcontainers.ContainerMount{ + Source: testcontainers.NewGenericImageMountSource("nginx:latest", "../var/www/html"), + Target: "/var/www/invalid", + }, m) + }) +} + func TestContainerMounts_PrepareMounts(t *testing.T) { volumeOptions := &mount.VolumeOptions{ Labels: testcontainers.GenericLabels(), @@ -160,6 +192,25 @@ func TestContainerMounts_PrepareMounts(t *testing.T) { }, }, }, + { + name: "Image mount", + mounts: testcontainers.ContainerMounts{ + { + Source: testcontainers.NewDockerImageMountSource("my-custom-image:latest", "data"), + Target: "/data", + }, + }, + want: []mount.Mount{ + { + Source: "my-custom-image:latest", + Type: mount.TypeImage, + Target: "/data", + ImageOptions: &mount.ImageOptions{ + Subpath: "data", + }, + }, + }, + }, } for _, tt := range tests { tt := tt diff --git a/options.go b/options.go index b9cc6ed9b0..9afbcd7e39 100644 --- a/options.go +++ b/options.go @@ -357,6 +357,25 @@ func WithWaitStrategyAndDeadline(deadline time.Duration, strategies ...wait.Stra } } +// WithImageMount mounts an image to a container, passing the source image name, +// the relative subpath to mount in that image, and the mount point in the target container. +// This option validates that the subpath is a relative path, raising an error otherwise. +func WithImageMount(source string, subpath string, target ContainerMountTarget) CustomizeRequestOption { + return func(req *GenericContainerRequest) error { + src := NewDockerImageMountSource(source, subpath) + + if err := src.Validate(); err != nil { + return fmt.Errorf("validate image mount source: %w", err) + } + + req.Mounts = append(req.Mounts, ContainerMount{ + Source: src, + Target: target, + }) + return nil + } +} + // WithEntrypoint completely replaces the entrypoint of a container func WithEntrypoint(entrypoint ...string) CustomizeRequestOption { return func(req *GenericContainerRequest) error { diff --git a/options_test.go b/options_test.go index 88690e0599..34ac401b3d 100644 --- a/options_test.go +++ b/options_test.go @@ -552,6 +552,63 @@ func TestWithDockerfile(t *testing.T) { require.Equal(t, map[string]*string{"ARG1": nil, "ARG2": nil}, req.BuildArgs) } +func TestWithImageMount(t *testing.T) { + cli, err := testcontainers.NewDockerClientWithOpts(context.Background()) + require.NoError(t, err) + + info, err := cli.Info(context.Background()) + require.NoError(t, err) + + // skip if the major version of the server is not v28 or greater + if info.ServerVersion < "28.0.0" { + t.Skipf("skipping test because the server version is not v28 or greater") + } + + t.Run("valid", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "alpine", + }, + } + + err := testcontainers.WithImageMount("alpine", "root/.ollama/models/", "/root/.ollama/models/")(&req) + require.NoError(t, err) + + require.Len(t, req.Mounts, 1) + + src := req.Mounts[0].Source + + require.Equal(t, testcontainers.NewDockerImageMountSource("alpine", "root/.ollama/models/"), src) + require.Equal(t, "alpine", src.Source()) + require.Equal(t, testcontainers.MountTypeImage, src.Type()) + + dst := req.Mounts[0].Target + require.Equal(t, testcontainers.ContainerMountTarget("/root/.ollama/models/"), dst) + }) + + t.Run("invalid", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "alpine", + }, + } + + err := testcontainers.WithImageMount("alpine", "/root/.ollama/models/", "/root/.ollama/models/")(&req) + require.Error(t, err) + }) + + t.Run("invalid-dots", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "alpine", + }, + } + + err := testcontainers.WithImageMount("alpine", "../root/.ollama/models/", "/root/.ollama/models/")(&req) + require.Error(t, err) + }) +} + func TestWithReuseByName_Succeeds(t *testing.T) { t.Parallel() req := &testcontainers.GenericContainerRequest{} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000000..a888586e83 --- /dev/null +++ b/validator.go @@ -0,0 +1,7 @@ +package testcontainers + +// Validator is an interface that can be implemented by types that need to validate their state. +type Validator interface { + // Validate validates the state of the type. + Validate() error +}