Skip to content
Draft
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
18 changes: 18 additions & 0 deletions docs/features/common_functional_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,24 @@ ctr, err = mymodule.Run(ctx, "docker.io/myservice:1.2.3", testcontainers.WithHos

To understand more about this feature, please read the [Exposing host ports to the container](/features/networking/#exposing-host-ports-to-the-container) documentation.

##### WithReadOnlyRootFilesystem

- Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.40.0"><span class="tc-version">:material-tag: v0.40.0</span></a>

If you need to run a container with a read-only root filesystem for enhanced security, you can use `testcontainers.WithReadOnlyRootFilesystem`. This is equivalent to using the `--read-only` flag with `docker run`:

```golang
ctr, err = mymodule.Run(ctx, "docker.io/myservice:1.2.3", testcontainers.WithReadOnlyRootFilesystem())
```

This option mounts the container's root filesystem as read-only, preventing any writes to the root filesystem. This is useful for security hardening and ensuring that your application doesn't write to unexpected locations. If your application needs to write temporary files, you can combine this with `WithTmpfs` to provide writable temporary directories:

```golang
ctr, err = mymodule.Run(ctx, "docker.io/myservice:1.2.3",
testcontainers.WithReadOnlyRootFilesystem(),
testcontainers.WithTmpfs(map[string]string{"/tmp": "rw,noexec,nosuid,size=100m"}))
```

##### WithConfigModifier

- Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a>
Expand Down
1 change: 1 addition & 0 deletions docs/features/common_functional_options_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The following options are exposed by the `testcontainers` package.
### Advanced Options

- [`WithHostPortAccess`](/features/creating_container/#withhostportaccess) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.31.0"><span class="tc-version">:material-tag: v0.31.0</span></a>
- [`WithReadOnlyRootFilesystem`](/features/creating_container/#withreadonlyrootfilesystem) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.40.0"><span class="tc-version">:material-tag: v0.40.0</span></a>
- [`WithConfigModifier`](/features/creating_container/#withconfigmodifier) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a>
- [`WithHostConfigModifier`](/features/creating_container/#withhostconfigmodifier) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a>
- [`WithEndpointSettingsModifier`](/features/creating_container/#withendpointsettingsmodifier) Since <a href="https://github.com/testcontainers/testcontainers-go/releases/tag/v0.20.0"><span class="tc-version">:material-tag: v0.20.0</span></a>
Expand Down
7 changes: 7 additions & 0 deletions examples/readonly/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module readonly-example

go 1.24

replace github.com/testcontainers/testcontainers-go => ../..

require github.com/testcontainers/testcontainers-go v0.0.0-00010101000000-000000000000
93 changes: 93 additions & 0 deletions examples/readonly/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"context"
"fmt"
"io"
"log"

"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

func main() {
ctx := context.Background()

// Example 1: Container with read-only root filesystem
fmt.Println("=== Example 1: Read-only root filesystem ===")

container, err := testcontainers.Run(ctx, "alpine:latest",
testcontainers.WithReadOnlyRootFilesystem(),
testcontainers.WithCmd("sh", "-c", "echo 'Attempting to write to root filesystem...' && echo 'test' > /test.txt && echo 'Write succeeded' || echo 'Write failed (expected)'"),
testcontainers.WithWaitStrategy(wait.ForExit()),
)
if err != nil {
log.Fatalf("Failed to start container: %v", err)
}
defer func() {
if err := testcontainers.TerminateContainer(container); err != nil {
log.Printf("Failed to terminate container: %v", err)
}
}()

// Get the logs
logs, err := container.Logs(ctx)
if err != nil {
log.Fatalf("Failed to get logs: %v", err)
}
defer logs.Close()

logBytes, err := io.ReadAll(logs)
if err != nil {
log.Fatalf("Failed to read logs: %v", err)
}

fmt.Printf("Container output:\n%s\n", string(logBytes))

// Example 2: Read-only root filesystem with tmpfs for writable areas
fmt.Println("=== Example 2: Read-only root filesystem with tmpfs ===")

container2, err := testcontainers.Run(ctx, "alpine:latest",
testcontainers.WithReadOnlyRootFilesystem(),
testcontainers.WithTmpfs(map[string]string{"/tmp": "rw,noexec,nosuid,size=100m"}),
testcontainers.WithCmd("sh", "-c", "echo 'Attempting to write to /tmp (tmpfs)...' && echo 'test' > /tmp/test.txt && echo 'Write to tmpfs succeeded' || echo 'Write to tmpfs failed'"),
testcontainers.WithWaitStrategy(wait.ForExit()),
)
if err != nil {
log.Fatalf("Failed to start container: %v", err)
}
defer func() {
if err := testcontainers.TerminateContainer(container2); err != nil {
log.Printf("Failed to terminate container: %v", err)
}
}()

// Get the logs
logs2, err := container2.Logs(ctx)
if err != nil {
log.Fatalf("Failed to get logs: %v", err)
}
defer logs2.Close()

logBytes2, err := io.ReadAll(logs2)
if err != nil {
log.Fatalf("Failed to read logs: %v", err)
}

fmt.Printf("Container output:\n%s\n", string(logBytes2))

// Verify the containers were configured correctly
inspect1, err := container.Inspect(ctx)
if err != nil {
log.Fatalf("Failed to inspect container: %v", err)
}

inspect2, err := container2.Inspect(ctx)
if err != nil {
log.Fatalf("Failed to inspect container: %v", err)
}

fmt.Printf("Container 1 ReadonlyRootfs: %t\n", inspect1.HostConfig.ReadonlyRootfs)
fmt.Printf("Container 2 ReadonlyRootfs: %t\n", inspect2.HostConfig.ReadonlyRootfs)
fmt.Printf("Container 2 Tmpfs mounts: %v\n", inspect2.HostConfig.Tmpfs)
}
21 changes: 21 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,3 +546,24 @@ func WithProvider(provider ProviderType) CustomizeRequestOption {
return nil
}
}

// WithReadOnlyRootFilesystem sets the container's root filesystem as read-only.
// This is equivalent to using the --read-only flag with docker run.
func WithReadOnlyRootFilesystem() CustomizeRequestOption {
return func(req *GenericContainerRequest) error {
if req.HostConfigModifier == nil {
req.HostConfigModifier = func(hostConfig *container.HostConfig) {
hostConfig.ReadonlyRootfs = true
}
} else {
// Wrap the existing modifier to also set ReadonlyRootfs
existingModifier := req.HostConfigModifier
req.HostConfigModifier = func(hostConfig *container.HostConfig) {
existingModifier(hostConfig)
hostConfig.ReadonlyRootfs = true
}
}

return nil
}
}
41 changes: 41 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/require"

"github.com/testcontainers/testcontainers-go"
Expand Down Expand Up @@ -933,3 +934,43 @@ func TestWithProvider(t *testing.T) {
require.Equal(t, testcontainers.ProviderPodman, req.ProviderType)
})
}

func TestWithReadOnlyRootFilesystem(t *testing.T) {
t.Run("sets ReadonlyRootfs to true when no existing HostConfigModifier", func(t *testing.T) {
req := &testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "alpine",
},
}

opt := testcontainers.WithReadOnlyRootFilesystem()
require.NoError(t, opt.Customize(req))
require.NotNil(t, req.HostConfigModifier)

// Test that the modifier sets ReadonlyRootfs to true
hostConfig := &container.HostConfig{}
req.HostConfigModifier(hostConfig)
require.True(t, hostConfig.ReadonlyRootfs)
})

t.Run("preserves existing HostConfigModifier and sets ReadonlyRootfs", func(t *testing.T) {
req := &testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "alpine",
HostConfigModifier: func(hc *container.HostConfig) {
hc.Privileged = true
},
},
}

opt := testcontainers.WithReadOnlyRootFilesystem()
require.NoError(t, opt.Customize(req))
require.NotNil(t, req.HostConfigModifier)

// Test that the modifier preserves existing settings and sets ReadonlyRootfs
hostConfig := &container.HostConfig{}
req.HostConfigModifier(hostConfig)
require.True(t, hostConfig.Privileged)
require.True(t, hostConfig.ReadonlyRootfs)
})
}
79 changes: 79 additions & 0 deletions readonly_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package testcontainers_test

import (
"context"
"io"
"testing"

"github.com/stretchr/testify/require"

"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

func TestWithReadOnlyRootFilesystem_Integration(t *testing.T) {
ctx := context.Background()

// Test that a container with read-only root filesystem cannot write to the root filesystem
container, err := testcontainers.Run(ctx, "alpine:latest",
testcontainers.WithReadOnlyRootFilesystem(),
testcontainers.WithCmd("sh", "-c", "echo 'test' > /test.txt && echo 'success' || echo 'failed'"),
testcontainers.WithWaitStrategy(wait.ForExit()),
)
require.NoError(t, err)
defer func() {
require.NoError(t, testcontainers.TerminateContainer(container))
}()

// Get the logs to verify the write operation failed
logs, err := container.Logs(ctx)
require.NoError(t, err)
defer logs.Close()

logBytes, err := io.ReadAll(logs)
require.NoError(t, err)
logContent := string(logBytes)

// The write operation should fail because the root filesystem is read-only
require.Contains(t, logContent, "failed")
require.NotContains(t, logContent, "success")

// Verify the container was actually configured with read-only root filesystem
inspect, err := container.Inspect(ctx)
require.NoError(t, err)
require.True(t, inspect.HostConfig.ReadonlyRootfs)
}

func TestWithReadOnlyRootFilesystem_WithTmpfs_Integration(t *testing.T) {
ctx := context.Background()

// Test that a container with read-only root filesystem can still write to tmpfs mounts
container, err := testcontainers.Run(ctx, "alpine:latest",
testcontainers.WithReadOnlyRootFilesystem(),
testcontainers.WithTmpfs(map[string]string{"/tmp": "rw,noexec,nosuid,size=100m"}),
testcontainers.WithCmd("sh", "-c", "echo 'test' > /tmp/test.txt && echo 'success' || echo 'failed'"),
testcontainers.WithWaitStrategy(wait.ForExit()),
)
require.NoError(t, err)
defer func() {
require.NoError(t, testcontainers.TerminateContainer(container))
}()

// Get the logs to verify the write operation succeeded in tmpfs
logs, err := container.Logs(ctx)
require.NoError(t, err)
defer logs.Close()

logBytes, err := io.ReadAll(logs)
require.NoError(t, err)
logContent := string(logBytes)

// The write operation should succeed because /tmp is mounted as tmpfs
require.Contains(t, logContent, "success")
require.NotContains(t, logContent, "failed")

// Verify the container was configured with read-only root filesystem
inspect, err := container.Inspect(ctx)
require.NoError(t, err)
require.True(t, inspect.HostConfig.ReadonlyRootfs)
}
Loading