Skip to content
54 changes: 54 additions & 0 deletions docker_mounts.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package testcontainers

import (
"errors"
"path/filepath"

"github.com/docker/docker/api/types/mount"

"github.com/testcontainers/testcontainers-go/log"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -85,6 +95,48 @@ func (s DockerTmpfsMountSource) GetTmpfsOptions() *mount.TmpfsOptions {
return s.TmpfsOptions
}

// DockerImageMountSource is a mount source for an image
type DockerImageMountSource struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about just ImageMountSource?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used this for keeping consistency with the existing types. As mentioned in the follow-ups section:

I do not like the code for mounting volumes and tmpfs, as it's hard to follow, and in my opinion there are too many abstractions. So I'd advocate for starting a discussion on how to do a hard refactor for that part.

So I'm open to start a rewrite of that layer

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd go with the simple naming too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed there must be a simpler abstraction for this seems there's multiple layers just adding complexity.

// 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 {
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions docs/features/common_functional_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://github.com/testcontainers/testcontainers-go"><span class="tc-version">:material-tag: main</span></a>

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.

<!--codeinclude-->
[Image Mount](../../modules/ollama/examples_test.go) inside_block:mountImage
<!--/codeinclude-->

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 <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.29.0"><span class="tc-version">:material-tag: v0.29.0</span></a>
Expand Down
14 changes: 14 additions & 0 deletions docs/features/files_and_mounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<!--codeinclude-->
[Image mounts](../../lifecycle_test.go) inside_block:imageMounts
<!--/codeinclude-->

!!!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:
Expand Down
14 changes: 14 additions & 0 deletions lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
26 changes: 26 additions & 0 deletions lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
101 changes: 101 additions & 0 deletions modules/ollama/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ollama_test

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
Expand Down Expand Up @@ -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.
}
51 changes: 50 additions & 1 deletion mounts.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Loading
Loading