Skip to content

Commit 224da6d

Browse files
authored
feat: support for mounting images (#3044)
* feat: support for mounting images * chore: use errors.New * fix: lint * chore: do not pollute the interface * chore: do not expose internal state of image mounts * chore: run ImageMount tests only for Docker v28+
1 parent 92b9255 commit 224da6d

File tree

11 files changed

+410
-1
lines changed

11 files changed

+410
-1
lines changed

docker_mounts.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package testcontainers
22

33
import (
4+
"errors"
5+
"path/filepath"
6+
47
"github.com/docker/docker/api/types/mount"
58

69
"github.com/testcontainers/testcontainers-go/log"
@@ -11,6 +14,7 @@ var mountTypeMapping = map[MountType]mount.Type{
1114
MountTypeVolume: mount.TypeVolume,
1215
MountTypeTmpfs: mount.TypeTmpfs,
1316
MountTypePipe: mount.TypeNamedPipe,
17+
MountTypeImage: mount.TypeImage,
1418
}
1519

1620
// 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 {
3236
GetTmpfsOptions() *mount.TmpfsOptions
3337
}
3438

39+
// ImageMounter can optionally be implemented by mount sources
40+
// to support advanced scenarios based on mount.ImageOptions
41+
type ImageMounter interface {
42+
ImageOptions() *mount.ImageOptions
43+
}
44+
3545
// Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
3646
type DockerBindMountSource struct {
3747
*mount.BindOptions
@@ -85,6 +95,48 @@ func (s DockerTmpfsMountSource) GetTmpfsOptions() *mount.TmpfsOptions {
8595
return s.TmpfsOptions
8696
}
8797

98+
// DockerImageMountSource is a mount source for an image
99+
type DockerImageMountSource struct {
100+
// imageName is the image name
101+
imageName string
102+
103+
// subpath is the subpath to mount the image into
104+
subpath string
105+
}
106+
107+
// NewDockerImageMountSource creates a new DockerImageMountSource
108+
func NewDockerImageMountSource(imageName string, subpath string) DockerImageMountSource {
109+
return DockerImageMountSource{
110+
imageName: imageName,
111+
subpath: subpath,
112+
}
113+
}
114+
115+
// Validate validates the source of the mount, ensuring that the subpath is a relative path
116+
func (s DockerImageMountSource) Validate() error {
117+
if !filepath.IsLocal(s.subpath) {
118+
return errors.New("image mount source must be a local path")
119+
}
120+
return nil
121+
}
122+
123+
// ImageOptions returns the image options for the image mount
124+
func (s DockerImageMountSource) ImageOptions() *mount.ImageOptions {
125+
return &mount.ImageOptions{
126+
Subpath: s.subpath,
127+
}
128+
}
129+
130+
// Source returns the image name for the image mount
131+
func (s DockerImageMountSource) Source() string {
132+
return s.imageName
133+
}
134+
135+
// Type returns the mount type for the image mount
136+
func (s DockerImageMountSource) Type() MountType {
137+
return MountTypeImage
138+
}
139+
88140
// PrepareMounts maps the given []ContainerMount to the corresponding
89141
// []mount.Mount for further processing
90142
func (m ContainerMounts) PrepareMounts() []mount.Mount {
@@ -118,6 +170,8 @@ func mapToDockerMounts(containerMounts ContainerMounts) []mount.Mount {
118170
containerMount.VolumeOptions = typedMounter.GetVolumeOptions()
119171
case TmpfsMounter:
120172
containerMount.TmpfsOptions = typedMounter.GetTmpfsOptions()
173+
case ImageMounter:
174+
containerMount.ImageOptions = typedMounter.ImageOptions()
121175
case BindMounter:
122176
log.Printf("Mount type %s is not supported by Testcontainers for Go", m.Source.Type())
123177
default:

docs/features/common_functional_options.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@ _Testcontainers for Go_ exposes an interface to perform this operation: `ImageSu
1515

1616
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.
1717

18+
#### WithImageMount
19+
20+
- 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>
21+
22+
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.
23+
24+
This option validates that the subpath is a relative path, raising an error otherwise.
25+
26+
<!--codeinclude-->
27+
[Image Mount](../../modules/ollama/examples_test.go) inside_block:mountImage
28+
<!--/codeinclude-->
29+
30+
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).
31+
32+
!!!warning
33+
Using this option fails the creation of the container if the underlying container runtime does not support the `image mount` feature.
34+
1835
#### WithEnv
1936

2037
- 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>

docs/features/files_and_mounts.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ It is possible to map a Docker volume into the container using the `Mounts` attr
2020
It is recommended to copy data from your local host machine to a test container using the file copy API
2121
described below, as it is much more portable.
2222

23+
## Mounting images
24+
25+
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:
26+
27+
<!--codeinclude-->
28+
[Image mounts](../../lifecycle_test.go) inside_block:imageMounts
29+
<!--/codeinclude-->
30+
31+
!!!warning
32+
If the subpath is not a relative path, the creation of the container will fail.
33+
34+
!!!info
35+
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.
36+
2337
## Copying files to a container
2438

2539
If you would like to copy a file to a container, you can do it in two different manners:

lifecycle.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,20 @@ func (c ContainerLifecycleHooks) Terminated(ctx context.Context) func(container
521521
}
522522

523523
func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req ContainerRequest, dockerInput *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) error {
524+
var mountErrors []error
525+
for _, m := range req.Mounts {
526+
// validate only the mount sources that implement the Validator interface
527+
if v, ok := m.Source.(Validator); ok {
528+
if err := v.Validate(); err != nil {
529+
mountErrors = append(mountErrors, err)
530+
}
531+
}
532+
}
533+
534+
if len(mountErrors) > 0 {
535+
return errors.Join(mountErrors...)
536+
}
537+
524538
// prepare mounts
525539
hostConfig.Mounts = mapToDockerMounts(req.Mounts)
526540

lifecycle_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@ func TestPreCreateModifierHook(t *testing.T) {
2929
require.NoError(t, err)
3030
defer provider.Close()
3131

32+
t.Run("mount-errors", func(t *testing.T) {
33+
// imageMounts {
34+
req := ContainerRequest{
35+
// three mounts, one valid and two invalid
36+
Mounts: ContainerMounts{
37+
{
38+
Source: NewDockerImageMountSource("nginx:latest", "var/www/html"),
39+
Target: "/var/www/valid",
40+
},
41+
ImageMount("nginx:latest", "../var/www/html", "/var/www/invalid1"),
42+
ImageMount("nginx:latest", "/var/www/html", "/var/www/invalid2"),
43+
},
44+
}
45+
// }
46+
47+
err = provider.preCreateContainerHook(ctx, req, &container.Config{}, &container.HostConfig{}, &network.NetworkingConfig{})
48+
require.Error(t, err)
49+
50+
var errs []error
51+
var joinErr interface{ Unwrap() []error }
52+
if errors.As(err, &joinErr) {
53+
errs = joinErr.Unwrap()
54+
}
55+
require.Len(t, errs, 2) // one valid and two invalid mounts
56+
})
57+
3258
t.Run("No exposed ports", func(t *testing.T) {
3359
// reqWithModifiers {
3460
req := ContainerRequest{

modules/ollama/examples_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ollama_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"log"
78
"net/http"
@@ -243,3 +244,103 @@ func ExampleRun_withLocal() {
243244

244245
// Intentionally not asserting the output, as we don't want to run this example in the tests.
245246
}
247+
248+
func ExampleRun_withImageMount() {
249+
cli, err := testcontainers.NewDockerClientWithOpts(context.Background())
250+
if err != nil {
251+
log.Printf("failed to create docker client: %s", err)
252+
return
253+
}
254+
255+
info, err := cli.Info(context.Background())
256+
if err != nil {
257+
log.Printf("failed to get docker info: %s", err)
258+
return
259+
}
260+
261+
// skip if the major version of the server is not v28 or greater
262+
if info.ServerVersion < "28.0.0" {
263+
log.Printf("skipping test because the server version is not v28 or greater")
264+
return
265+
}
266+
267+
ctx := context.Background()
268+
269+
ollamaContainer, err := tcollama.Run(ctx, "ollama/ollama:0.5.12")
270+
if err != nil {
271+
log.Printf("failed to start container: %s", err)
272+
return
273+
}
274+
defer func() {
275+
if err := testcontainers.TerminateContainer(ollamaContainer); err != nil {
276+
log.Printf("failed to terminate container: %s", err)
277+
}
278+
}()
279+
280+
code, _, err := ollamaContainer.Exec(ctx, []string{"ollama", "pull", "all-minilm"})
281+
if err != nil {
282+
log.Printf("failed to pull model %s: %s", "all-minilm", err)
283+
return
284+
}
285+
286+
fmt.Println(code)
287+
288+
targetImage := "testcontainers/ollama:tc-model-all-minilm"
289+
290+
err = ollamaContainer.Commit(ctx, targetImage)
291+
if err != nil {
292+
log.Printf("failed to commit container: %s", err)
293+
return
294+
}
295+
296+
// start a new fresh ollama container mounting the target image
297+
// mountImage {
298+
newOllamaContainer, err := tcollama.Run(
299+
ctx,
300+
"ollama/ollama:0.5.12",
301+
testcontainers.WithImageMount(targetImage, "root/.ollama/models/", "/root/.ollama/models/"),
302+
)
303+
// }
304+
if err != nil {
305+
log.Printf("failed to start container: %s", err)
306+
return
307+
}
308+
defer func() {
309+
if err := testcontainers.TerminateContainer(newOllamaContainer); err != nil {
310+
log.Printf("failed to terminate container: %s", err)
311+
}
312+
}()
313+
314+
// perform an HTTP request to the ollama container to verify the model is available
315+
316+
connectionStr, err := newOllamaContainer.ConnectionString(ctx)
317+
if err != nil {
318+
log.Printf("failed to get connection string: %s", err)
319+
return
320+
}
321+
322+
resp, err := http.Get(connectionStr + "/api/tags")
323+
if err != nil {
324+
log.Printf("failed to get request: %s", err)
325+
return
326+
}
327+
328+
fmt.Println(resp.StatusCode)
329+
330+
type tagsResponse struct {
331+
Models []struct {
332+
Name string `json:"name"`
333+
} `json:"models"`
334+
}
335+
336+
var tags tagsResponse
337+
err = json.NewDecoder(resp.Body).Decode(&tags)
338+
if err != nil {
339+
log.Printf("failed to decode response: %s", err)
340+
return
341+
}
342+
343+
fmt.Println(tags.Models[0].Name)
344+
345+
// Intentionally not asserting the output, as we don't want to run this example in the tests.
346+
}

mounts.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package testcontainers
22

3-
import "errors"
3+
import (
4+
"errors"
5+
"path/filepath"
6+
)
47

58
const (
69
MountTypeBind MountType = iota // Deprecated: Use MountTypeVolume instead
710
MountTypeVolume
811
MountTypeTmpfs
912
MountTypePipe
13+
MountTypeImage
1014
)
1115

1216
var (
@@ -18,6 +22,7 @@ var (
1822
_ ContainerMountSource = (*GenericBindMountSource)(nil) // Deprecated: use Files or HostConfigModifier in the ContainerRequest, or copy files container APIs to make containers portable across Docker environments
1923
_ ContainerMountSource = (*GenericVolumeMountSource)(nil)
2024
_ ContainerMountSource = (*GenericTmpfsMountSource)(nil)
25+
_ ContainerMountSource = (*GenericImageMountSource)(nil)
2126
)
2227

2328
type (
@@ -110,6 +115,15 @@ func VolumeMount(volumeName string, mountTarget ContainerMountTarget) ContainerM
110115
}
111116
}
112117

118+
// ImageMount returns a new ContainerMount with a GenericImageMountSource as source
119+
// This is a convenience method to cover typical use cases.
120+
func ImageMount(imageName string, subpath string, mountTarget ContainerMountTarget) ContainerMount {
121+
return ContainerMount{
122+
Source: NewGenericImageMountSource(imageName, subpath),
123+
Target: mountTarget,
124+
}
125+
}
126+
113127
// Mounts returns a ContainerMounts to support a more fluent API
114128
func Mounts(mounts ...ContainerMount) ContainerMounts {
115129
return mounts
@@ -124,3 +138,38 @@ type ContainerMount struct {
124138
// ReadOnly determines if the mount should be read-only
125139
ReadOnly bool
126140
}
141+
142+
// GenericImageMountSource implements ContainerMountSource and represents an image mount
143+
type GenericImageMountSource struct {
144+
// imageName refers to the name of the image to be mounted
145+
// the same image might be mounted to multiple locations within a single container
146+
imageName string
147+
// subpath is the path within the image to be mounted
148+
subpath string
149+
}
150+
151+
// NewGenericImageMountSource creates a new GenericImageMountSource
152+
func NewGenericImageMountSource(imageName string, subpath string) GenericImageMountSource {
153+
return GenericImageMountSource{
154+
imageName: imageName,
155+
subpath: subpath,
156+
}
157+
}
158+
159+
// Source returns the name of the image to be mounted
160+
func (s GenericImageMountSource) Source() string {
161+
return s.imageName
162+
}
163+
164+
// Type returns the type of the mount
165+
func (GenericImageMountSource) Type() MountType {
166+
return MountTypeImage
167+
}
168+
169+
// Validate validates the source of the mount
170+
func (s GenericImageMountSource) Validate() error {
171+
if !filepath.IsLocal(s.subpath) {
172+
return errors.New("image mount source must be a local path")
173+
}
174+
return nil
175+
}

0 commit comments

Comments
 (0)