diff --git a/.github/workflows/ci-rootless-docker.yml b/.github/workflows/ci-rootless-docker.yml new file mode 100644 index 0000000000..4944fa8302 --- /dev/null +++ b/.github/workflows/ci-rootless-docker.yml @@ -0,0 +1,72 @@ +name: Rootless Docker pipeline + +on: + push: + paths-ignore: + - 'mkdocs.yml' + - 'docs/**' + - 'README.md' + pull_request: + paths-ignore: + - 'mkdocs.yml' + - 'docs/**' + - 'README.md' + +concurrency: + group: "${{ github.workflow }}-${{ github.head_ref || github.sha }}" + cancel-in-progress: true + +jobs: + test-rootless-docker: + strategy: + matrix: + go-version: [1.19.x, 1.x] + platform: [ubuntu-latest] + runs-on: ${{ matrix.platform }} + env: + TESTCONTAINERS_RYUK_DISABLED: "false" + steps: + + - name: Setup rootless Docker + uses: ScribeMD/rootless-docker@0.2.2 + + - name: Remove Docket root socket + run: sudo rm -rf /var/run/docker.sock + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + - name: modVerify + run: go mod verify + + - name: modTidy + run: make tools-tidy + + - name: ensure compilation + env: + GOOS: linux + run: go build + + - name: gotestsum + # only run tests on linux, there are a number of things that won't allow the tests to run on anything else + # many (maybe, all?) images used can only be build on Linux, they don't have Windows in their manifest, and + # we can't put Windows Server in "Linux Mode" in Github actions + # another, host mode is only available on Linux, and we have tests around that, do we skip them? + if: ${{ matrix.platform == 'ubuntu-latest' }} + run: make test-unit + + - name: Run checker + run: | + ./scripts/check_environment.sh + + - name: Test Summary + uses: test-summary/action@4ee9ece4bca777a38f05c8fc578ac2007fe266f7 + with: + paths: "**/TEST-*.xml" + if: always() diff --git a/config.go b/config.go index 664f8c5ad5..829bcaa3e5 100644 --- a/config.go +++ b/config.go @@ -1,101 +1,38 @@ package testcontainers import ( - "fmt" - "os" - "path/filepath" - "strconv" - "sync" + "context" - "github.com/magiconair/properties" + "github.com/testcontainers/testcontainers-go/internal/config" ) -var tcConfig TestcontainersConfig -var tcConfigOnce *sync.Once = new(sync.Once) - // TestcontainersConfig represents the configuration for Testcontainers -// testcontainersConfig { type TestcontainersConfig struct { - Host string `properties:"docker.host,default="` - TLSVerify int `properties:"docker.tls.verify,default=0"` - CertPath string `properties:"docker.cert.path,default="` - RyukDisabled bool `properties:"ryuk.disabled,default=false"` - RyukPrivileged bool `properties:"ryuk.container.privileged,default=false"` + Host string `properties:"docker.host,default="` // Deprecated: use Config.Host instead + TLSVerify int `properties:"docker.tls.verify,default=0"` // Deprecated: use Config.TLSVerify instead + CertPath string `properties:"docker.cert.path,default="` // Deprecated: use Config.CertPath instead + RyukDisabled bool `properties:"ryuk.disabled,default=false"` // Deprecated: use Config.RyukDisabled instead + RyukPrivileged bool `properties:"ryuk.container.privileged,default=false"` // Deprecated: use Config.RyukPrivileged instead + Config config.Config } -// } - // ReadConfig reads from testcontainers properties file, storing the result in a singleton instance // of the TestcontainersConfig struct +// Deprecated use ReadConfigWithContext instead func ReadConfig() TestcontainersConfig { - tcConfigOnce.Do(func() { - tcConfig = readConfig() - - if tcConfig.RyukDisabled { - ryukDisabledMessage := ` -********************************************************************************************** -Ryuk has been disabled for the current execution. This can cause unexpected behavior in your environment. -More on this: https://golang.testcontainers.org/features/garbage_collector/ -**********************************************************************************************` - Logger.Printf(ryukDisabledMessage) - Logger.Printf("\n%+v", tcConfig) - } - }) - - return tcConfig -} - -// readConfig reads from testcontainers properties file, if it exists -// it is possible that certain values get overridden when set as environment variables -func readConfig() TestcontainersConfig { - config := TestcontainersConfig{} - - applyEnvironmentConfiguration := func(config TestcontainersConfig) TestcontainersConfig { - if dockerHostEnv := os.Getenv("DOCKER_HOST"); dockerHostEnv != "" { - config.Host = dockerHostEnv - } - if config.Host == "" { - config.Host = "unix:///var/run/docker.sock" - } - - ryukDisabledEnv := os.Getenv("TESTCONTAINERS_RYUK_DISABLED") - if parseBool(ryukDisabledEnv) { - config.RyukDisabled = ryukDisabledEnv == "true" - } - - ryukPrivilegedEnv := os.Getenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED") - if parseBool(ryukPrivilegedEnv) { - config.RyukPrivileged = ryukPrivilegedEnv == "true" - } - - return config - } - - home, err := os.UserHomeDir() - if err != nil { - return applyEnvironmentConfiguration(config) - } - - tcProp := filepath.Join(home, ".testcontainers.properties") - // init from a file - properties, err := properties.LoadFile(tcProp, properties.UTF8) - if err != nil { - return applyEnvironmentConfiguration(config) - } - - if err := properties.Decode(&config); err != nil { - fmt.Printf("invalid testcontainers properties file, returning an empty Testcontainers configuration: %v\n", err) - return applyEnvironmentConfiguration(config) - } - - fmt.Printf("Testcontainers properties file has been found: %s\n", tcProp) - - return applyEnvironmentConfiguration(config) + return ReadConfigWithContext(context.Background()) } -func parseBool(input string) bool { - if _, err := strconv.ParseBool(input); err == nil { - return true +// ReadConfigWithContext reads from testcontainers properties file, storing the result in a singleton instance +// of the TestcontainersConfig struct +func ReadConfigWithContext(ctx context.Context) TestcontainersConfig { + cfg := config.Read(ctx) + return TestcontainersConfig{ + Host: cfg.Host, + TLSVerify: cfg.TLSVerify, + CertPath: cfg.CertPath, + RyukDisabled: cfg.RyukDisabled, + RyukPrivileged: cfg.RyukPrivileged, + Config: cfg, } - return false } diff --git a/config_test.go b/config_test.go index 300dd66bca..82c258cdfe 100644 --- a/config_test.go +++ b/config_test.go @@ -1,19 +1,11 @@ package testcontainers import ( - "fmt" - "os" - "path/filepath" + "context" "testing" "github.com/stretchr/testify/assert" -) - -const ( - dockerSock = "unix:///var/run/docker.sock" - tcpDockerHost1234 = "tcp://127.0.0.1:1234" - tcpDockerHost33293 = "tcp://127.0.0.1:33293" - tcpDockerHost4711 = "tcp://127.0.0.1:4711" + "github.com/testcontainers/testcontainers-go/internal/config" ) // unset environment variables to avoid side effects @@ -30,398 +22,19 @@ func TestReadConfig(t *testing.T) { t.Setenv("HOME", "") t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") - config := ReadConfig() + cfg := ReadConfigWithContext(context.Background()) expected := TestcontainersConfig{ RyukDisabled: true, - Host: dockerSock, - } - - assert.Equal(t, expected, config) - - t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "false") - config = ReadConfig() - assert.Equal(t, expected, config) - }) -} - -func TestReadTCConfig(t *testing.T) { - resetTestEnv(t) - - t.Run("HOME is not set", func(t *testing.T) { - t.Setenv("HOME", "") - - config := readConfig() - - expected := TestcontainersConfig{} - expected.Host = dockerSock - - assert.Equal(t, expected, config) - }) - - t.Run("HOME is not set - TESTCONTAINERS_ env is set", func(t *testing.T) { - t.Setenv("HOME", "") - t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") - t.Setenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true") - - config := readConfig() - - expected := TestcontainersConfig{} - expected.RyukDisabled = true - expected.RyukPrivileged = true - expected.Host = dockerSock - - assert.Equal(t, expected, config) - }) - - t.Run("HOME does not contain TC props file", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", tmpDir) - - config := readConfig() - - expected := TestcontainersConfig{} - expected.Host = dockerSock - - assert.Equal(t, expected, config) - }) - - t.Run("HOME does not contain TC props file - DOCKER_HOST env is set", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", tmpDir) - t.Setenv("DOCKER_HOST", tcpDockerHost33293) - - config := readConfig() - expected := TestcontainersConfig{} - expected.Host = tcpDockerHost33293 - - assert.Equal(t, expected, config) - }) - - t.Run("HOME does not contain TC props file - TESTCONTAINERS_ env is set", func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", tmpDir) - t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") - t.Setenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true") - - config := readConfig() - expected := TestcontainersConfig{} - expected.RyukDisabled = true - expected.RyukPrivileged = true - expected.Host = dockerSock - - assert.Equal(t, expected, config) - }) - - t.Run("HOME contains TC properties file", func(t *testing.T) { - tests := []struct { - name string - content string - env map[string]string - expected TestcontainersConfig - }{ - { - "Single Docker host with spaces", - "docker.host = " + tcpDockerHost33293, - map[string]string{}, - TestcontainersConfig{ - Host: tcpDockerHost33293, - TLSVerify: 0, - CertPath: "", - }, - }, - { - "Multiple docker host entries, last one wins", - `docker.host = ` + tcpDockerHost33293 + ` - docker.host = ` + tcpDockerHost4711 + ` - `, - map[string]string{}, - TestcontainersConfig{ - Host: tcpDockerHost4711, - TLSVerify: 0, - CertPath: "", - }, - }, - { - "Multiple docker host entries, last one wins, with TLS", - `docker.host = ` + tcpDockerHost33293 + ` - docker.host = ` + tcpDockerHost4711 + ` - docker.host = ` + tcpDockerHost1234 + ` - docker.tls.verify = 1 - `, - map[string]string{}, - TestcontainersConfig{ - Host: tcpDockerHost1234, - TLSVerify: 1, - CertPath: "", - }, - }, - { - "Empty file", - "", - map[string]string{}, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - }, - }, - { - "Non-valid properties are ignored", - `foo = bar - docker.host = ` + tcpDockerHost1234 + ` - `, - map[string]string{}, - TestcontainersConfig{ - Host: tcpDockerHost1234, - TLSVerify: 0, - CertPath: "", - }, - }, - { - "Single Docker host without spaces", - "docker.host=" + tcpDockerHost33293, - map[string]string{}, - TestcontainersConfig{ - Host: tcpDockerHost33293, - TLSVerify: 0, - CertPath: "", - }, - }, - { - "Comments are ignored", - `#docker.host=` + tcpDockerHost33293, - map[string]string{}, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - }, - }, - { - "Multiple docker host entries, last one wins, with TLS and cert path", - `#docker.host = ` + tcpDockerHost33293 + ` - docker.host = ` + tcpDockerHost4711 + ` - docker.host = ` + tcpDockerHost1234 + ` - docker.cert.path=/tmp/certs`, - map[string]string{}, - TestcontainersConfig{ - Host: tcpDockerHost1234, - TLSVerify: 0, - CertPath: "/tmp/certs", - }, - }, - { - "With Ryuk disabled using properties", - `ryuk.disabled=true`, - map[string]string{}, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: true, - }, - }, - { - "With Ryuk container privileged using properties", - `ryuk.container.privileged=true`, - map[string]string{}, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: true, - }, - }, - { - "With Ryuk disabled using an env var", - ``, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: true, - }, - }, - { - "With Ryuk container privileged using an env var", - ``, - map[string]string{ - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: true, - }, - }, - { - "With Ryuk disabled using an env var and properties. Env var wins (0)", - `ryuk.disabled=true`, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: true, - }, - }, - { - "With Ryuk disabled using an env var and properties. Env var wins (1)", - `ryuk.disabled=false`, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: true, - }, - }, - { - "With Ryuk disabled using an env var and properties. Env var wins (2)", - `ryuk.disabled=true`, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "false", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: false, - }, - }, - { - "With Ryuk disabled using an env var and properties. Env var wins (3)", - `ryuk.disabled=false`, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "false", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: false, - }, - }, - { - "With Ryuk container privileged using an env var and properties. Env var wins (0)", - `ryuk.container.privileged=true`, - map[string]string{ - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: true, - }, - }, - { - "With Ryuk container privileged using an env var and properties. Env var wins (1)", - `ryuk.container.privileged=false`, - map[string]string{ - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: true, - }, - }, - { - "With Ryuk container privileged using an env var and properties. Env var wins (2)", - `ryuk.container.privileged=true`, - map[string]string{ - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "false", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: false, - }, - }, - { - "With Ryuk container privileged using an env var and properties. Env var wins (3)", - `ryuk.container.privileged=false`, - map[string]string{ - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "false", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: false, - }, - }, - { - "With TLS verify using properties when value is wrong", - `ryuk.container.privileged=false - docker.tls.verify = ERROR`, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "true", - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: true, - RyukPrivileged: true, - }, - }, - { - "With Ryuk disabled using an env var and properties. Env var does not win because it's not a boolean value", - `ryuk.disabled=false`, - map[string]string{ - "TESTCONTAINERS_RYUK_DISABLED": "foo", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukDisabled: false, - }, - }, - { - "With Ryuk container privileged using an env var and properties. Env var does not win because it's not a boolean value", - `ryuk.container.privileged=false`, - map[string]string{ - "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "foo", - }, - TestcontainersConfig{ - Host: dockerSock, - TLSVerify: 0, - CertPath: "", - RyukPrivileged: false, - }, + Config: config.Config{ + RyukDisabled: true, }, } - for _, tt := range tests { - t.Run(fmt.Sprintf(tt.name), func(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", tmpDir) - for k, v := range tt.env { - t.Setenv(k, v) - } - if err := os.WriteFile(filepath.Join(tmpDir, ".testcontainers.properties"), []byte(tt.content), 0o600); err != nil { - t.Errorf("Failed to create the file: %v", err) - return - } - config := readConfig() + assert.Equal(t, expected, cfg) - assert.Equal(t, tt.expected, config, "Configuration doesn't not match") - }) - } + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "false") + cfg = ReadConfig() + assert.Equal(t, expected, cfg) }) } diff --git a/container_test.go b/container_test.go index 9bdf9e0ad8..2106c8e16f 100644 --- a/container_test.go +++ b/container_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" "github.com/testcontainers/testcontainers-go/wait" ) @@ -359,6 +360,9 @@ func createTestContainer(t *testing.T, ctx context.Context) int { func TestBindMount(t *testing.T) { t.Parallel() + + dockerSocket := testcontainersdocker.ExtractDockerSocket(context.Background()) + type args struct { hostPath string mountTarget ContainerMountTarget @@ -369,9 +373,9 @@ func TestBindMount(t *testing.T) { want ContainerMount }{ { - name: "/var/run/docker.sock:/var/run/docker.sock", - args: args{hostPath: "/var/run/docker.sock", mountTarget: "/var/run/docker.sock"}, - want: ContainerMount{Source: GenericBindMountSource{HostPath: "/var/run/docker.sock"}, Target: "/var/run/docker.sock"}, + name: dockerSocket + ":" + dockerSocket, + args: args{hostPath: dockerSocket, mountTarget: "/var/run/docker.sock"}, + want: ContainerMount{Source: GenericBindMountSource{HostPath: dockerSocket}, Target: "/var/run/docker.sock"}, }, { name: "/var/lib/app/data:/data", diff --git a/docker.go b/docker.go index de8ae042fd..a9f2d583db 100644 --- a/docker.go +++ b/docker.go @@ -791,43 +791,7 @@ func (p *DockerProvider) SetClient(c client.APIClient) { var _ ContainerProvider = (*DockerProvider)(nil) func NewDockerClient() (cli *client.Client, err error) { - tcConfig := ReadConfig() - opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()} - - if tcConfig.Host != "" { - opts = append(opts, client.WithHost(tcConfig.Host)) - - // For further information, read https://docs.docker.com/engine/security/protect-access/. - if tcConfig.TLSVerify == 1 { - caCertPath := filepath.Join(tcConfig.CertPath, "ca.pem") - certPath := filepath.Join(tcConfig.CertPath, "cert.pem") - keyPath := filepath.Join(tcConfig.CertPath, "key.pem") - - opts = append(opts, client.WithTLSClientConfig(caCertPath, certPath, keyPath)) - } - } - - opts = append(opts, client.WithHTTPHeaders( - map[string]string{ - "x-tc-sid": testcontainerssession.String(), - }), - ) - - cli, err = client.NewClientWithOpts(opts...) - if err != nil { - return nil, err - } - - if _, err = cli.Ping(context.Background()); err != nil { - // Fallback to environment. - cli, err = testcontainersdocker.NewClient(context.Background()) - if err != nil { - return nil, err - } - } - defer cli.Close() - - return cli, nil + return testcontainersdocker.NewClient(context.Background()) } // BuildImage will build and image from context and Dockerfile, then return the tag @@ -940,7 +904,7 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque opt(&reaperOpts) } - tcConfig := p.Config() + tcConfig := p.Config().Config var termSignal chan bool // the reaper does not need to start a reaper for itself @@ -1149,7 +1113,7 @@ func (p *DockerProvider) ReuseOrCreateContainer(ctx context.Context, req Contain return p.CreateContainer(ctx, req) } - tcConfig := p.Config() + tcConfig := p.Config().Config var termSignal chan bool if !tcConfig.RyukDisabled { @@ -1304,7 +1268,7 @@ func (p *DockerProvider) CreateNetwork(ctx context.Context, req NetworkRequest) req.Labels = make(map[string]string) } - tcConfig := p.Config() + tcConfig := p.Config().Config nc := types.NetworkCreate{ Driver: req.Driver, diff --git a/docker_auth.go b/docker_auth.go index 10238c7365..c95f848c03 100644 --- a/docker_auth.go +++ b/docker_auth.go @@ -34,13 +34,13 @@ func DockerImageAuth(ctx context.Context, image string) (string, types.AuthConfi // It will use the docker daemon to get the default registry, returning "https://index.docker.io/v1/" if // it fails to get the information from the daemon func defaultRegistry(ctx context.Context) string { - p, err := NewDockerProvider() + client, err := testcontainersdocker.NewClient(ctx) if err != nil { return testcontainersdocker.IndexDockerIO } - defer p.Close() + defer client.Close() - info, err := p.client.Info(ctx) + info, err := client.Info(ctx) if err != nil { return testcontainersdocker.IndexDockerIO } diff --git a/docker_auth_test.go b/docker_auth_test.go index 2ad9b6200b..33bdcf40f8 100644 --- a/docker_auth_test.go +++ b/docker_auth_test.go @@ -22,6 +22,12 @@ var testDockerConfigDirPath = filepath.Join("testdata", ".docker") var indexDockerIO = testcontainersdocker.IndexDockerIO +var originalDockerAuthConfig string + +func init() { + originalDockerAuthConfig = os.Getenv("DOCKER_AUTH_CONFIG") +} + func TestGetDockerConfig(t *testing.T) { const expectedErrorMessage = "Expected to find %s in auth configs" @@ -76,6 +82,10 @@ func TestGetDockerConfig(t *testing.T) { }) t.Run("DOCKER_AUTH_CONFIG env var takes precedence", func(t *testing.T) { + t.Cleanup(func() { + os.Setenv("DOCKER_AUTH_CONFIG", originalDockerAuthConfig) + }) + t.Setenv("DOCKER_AUTH_CONFIG", `{ "auths": { "`+exampleAuth+`": {} @@ -101,6 +111,10 @@ func TestGetDockerConfig(t *testing.T) { }) t.Run("retrieve auth with DOCKER_AUTH_CONFIG env var", func(t *testing.T) { + t.Cleanup(func() { + os.Setenv("DOCKER_AUTH_CONFIG", originalDockerAuthConfig) + }) + base64 := "Z29waGVyOnNlY3JldA==" // gopher:secret t.Setenv("DOCKER_AUTH_CONFIG", `{ @@ -127,8 +141,9 @@ func TestBuildContainerFromDockerfile(t *testing.T) { FromDockerfile: FromDockerfile{ Context: "./testdata", }, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), + AlwaysPullImage: true, // make sure the authentication takes place + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), } redisC, err := prepareRedisImage(ctx, req, t) @@ -136,7 +151,29 @@ func TestBuildContainerFromDockerfile(t *testing.T) { terminateContainerOnEnd(t, ctx, redisC) } +// removeImageFromLocalCache removes the image from the local cache +func removeImageFromLocalCache(t *testing.T, image string) { + ctx := context.Background() + + testcontainersClient, err := testcontainersdocker.NewClient(ctx, client.WithVersion(daemonMaxVersion)) + if err != nil { + t.Log("could not create client to cleanup registry: ", err) + } + + _, err = testcontainersClient.ImageRemove(ctx, image, types.ImageRemoveOptions{ + Force: true, + PruneChildren: true, + }) + if err != nil { + t.Logf("could not remove image %s: %v", image, err) + } +} + func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) { + t.Cleanup(func() { + os.Setenv("DOCKER_AUTH_CONFIG", originalDockerAuthConfig) + }) + // using the same credentials as in the Docker Registry base64 := "dGVzdHVzZXI6dGVzdHBhc3N3b3Jk" // testuser:testpassword t.Setenv("DOCKER_AUTH_CONFIG", `{ @@ -147,22 +184,6 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) { }`) prepareLocalRegistryWithAuth(t) - defer func() { - ctx := context.Background() - testcontainersClient, err := client.NewClientWithOpts(client.WithVersion(daemonMaxVersion)) - if err != nil { - t.Log("could not create client to cleanup registry: ", err) - } - - _, err = testcontainersClient.ImageRemove(ctx, "localhost:5000/redis:5.0-alpine", types.ImageRemoveOptions{ - Force: true, - PruneChildren: true, - }) - if err != nil { - t.Log("could not remove image: ", err) - } - - }() ctx := context.Background() @@ -171,9 +192,9 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) { Context: "./testdata", Dockerfile: "auth.Dockerfile", }, - - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), + AlwaysPullImage: true, // make sure the authentication takes place + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), } redisC, err := prepareRedisImage(ctx, req, t) @@ -182,6 +203,10 @@ func TestBuildContainerFromDockerfileWithDockerAuthConfig(t *testing.T) { } func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *testing.T) { + t.Cleanup(func() { + os.Setenv("DOCKER_AUTH_CONFIG", originalDockerAuthConfig) + }) + // using different credentials than in the Docker Registry base64 := "Zm9vOmJhcg==" // foo:bar t.Setenv("DOCKER_AUTH_CONFIG", `{ @@ -200,8 +225,9 @@ func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *test Context: "./testdata", Dockerfile: "auth.Dockerfile", }, - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForLog("Ready to accept connections"), + AlwaysPullImage: true, // make sure the authentication takes place + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForLog("Ready to accept connections"), } redisC, err := prepareRedisImage(ctx, req, t) @@ -210,6 +236,11 @@ func TestBuildContainerFromDockerfileShouldFailWithWrongDockerAuthConfig(t *test } func TestCreateContainerFromPrivateRegistry(t *testing.T) { + t.Cleanup(func() { + os.Setenv("DOCKER_AUTH_CONFIG", originalDockerAuthConfig) + }) + os.Unsetenv("DOCKER_AUTH_CONFIG") + // using the same credentials as in the Docker Registry base64 := "dGVzdHVzZXI6dGVzdHBhc3N3b3Jk" // testuser:testpassword t.Setenv("DOCKER_AUTH_CONFIG", `{ @@ -276,6 +307,9 @@ func prepareLocalRegistryWithAuth(t *testing.T) { registryC, err := GenericContainer(ctx, genContainerReq) assert.NoError(t, err) + t.Cleanup(func() { + removeImageFromLocalCache(t, "localhost:5000/redis:5.0-alpine") + }) t.Cleanup(func() { assert.NoError(t, registryC.Terminate(context.Background())) }) diff --git a/docker_test.go b/docker_test.go index 857a1a912a..eb45e6ac6e 100644 --- a/docker_test.go +++ b/docker_test.go @@ -24,6 +24,7 @@ import ( "github.com/docker/go-units" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/internal/config" "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" "github.com/testcontainers/testcontainers-go/wait" ) @@ -127,6 +128,10 @@ func TestContainerAttachedToNewNetwork(t *testing.T) { // } func TestContainerWithHostNetworkOptions(t *testing.T) { + if os.Getenv("XDG_RUNTIME_DIR") != "" { + t.Skip("Skipping test that requires host network access when running in a container") + } + absPath, err := filepath.Abs("./testdata/nginx-highport.conf") if err != nil { t.Fatal(err) @@ -200,6 +205,10 @@ func TestContainerWithHostNetworkOptions_UseExposePortsFromImageConfigs(t *testi } func TestContainerWithNetworkModeAndNetworkTogether(t *testing.T) { + if os.Getenv("XDG_RUNTIME_DIR") != "" { + t.Skip("Skipping test that requires host network access when running in a container") + } + ctx := context.Background() gcr := GenericContainerRequest{ ProviderType: providerType, @@ -222,6 +231,10 @@ func TestContainerWithNetworkModeAndNetworkTogether(t *testing.T) { } func TestContainerWithHostNetworkOptionsAndWaitStrategy(t *testing.T) { + if os.Getenv("XDG_RUNTIME_DIR") != "" { + t.Skip("Skipping test that requires host network access when running in a container") + } + ctx := context.Background() absPath, err := filepath.Abs("./testdata/nginx-highport.conf") @@ -259,6 +272,10 @@ func TestContainerWithHostNetworkOptionsAndWaitStrategy(t *testing.T) { } func TestContainerWithHostNetworkAndEndpoint(t *testing.T) { + if os.Getenv("XDG_RUNTIME_DIR") != "" { + t.Skip("Skipping test that requires host network access when running in a container") + } + ctx := context.Background() absPath, err := filepath.Abs("./testdata/nginx-highport.conf") @@ -317,7 +334,7 @@ func TestContainerReturnItsContainerID(t *testing.T) { } func TestContainerStartsWithoutTheReaper(t *testing.T) { - tcConfig := readConfig() // read the config using the private method to avoid the sync.Once + tcConfig := config.Read(context.Background()) // read the config using the internal method to avoid the sync.Once if !tcConfig.RyukDisabled { t.Skip("Ryuk is enabled, skipping test") } @@ -356,7 +373,7 @@ func TestContainerStartsWithoutTheReaper(t *testing.T) { } func TestContainerStartsWithTheReaper(t *testing.T) { - tcConfig := readConfig() // read the config using the private method to avoid the sync.Once + tcConfig := config.Read(context.Background()) // read the config using the internal method to avoid the sync.Once if tcConfig.RyukDisabled { t.Skip("Ryuk is disabled, skipping test") } @@ -488,7 +505,7 @@ func TestContainerStateAfterTermination(t *testing.T) { } func TestContainerStopWithReaper(t *testing.T) { - tcConfig := readConfig() // read the config using the private method to avoid the sync.Once + tcConfig := config.Read(context.Background()) // read the config using the internal method to avoid the sync.Once if tcConfig.RyukDisabled { t.Skip("Ryuk is disabled, skipping test") } @@ -535,7 +552,7 @@ func TestContainerStopWithReaper(t *testing.T) { } func TestContainerTerminationWithReaper(t *testing.T) { - tcConfig := readConfig() // read the config using the private method to avoid the sync.Once + tcConfig := config.Read(context.Background()) // read the config using the internal method to avoid the sync.Once if tcConfig.RyukDisabled { t.Skip("Ryuk is disabled, skipping test") } @@ -574,7 +591,7 @@ func TestContainerTerminationWithReaper(t *testing.T) { } func TestContainerTerminationWithoutReaper(t *testing.T) { - tcConfig := readConfig() // read the config using the private method to avoid the sync.Once + tcConfig := config.Read(context.Background()) // read the config using the internal method to avoid the sync.Once if !tcConfig.RyukDisabled { t.Skip("Ryuk is enabled, skipping test") } @@ -1881,6 +1898,9 @@ func TestDockerContainerResources(t *testing.T) { if providerType == ProviderPodman { t.Skip("Rootless Podman does not support setting rlimit") } + if os.Getenv("XDG_RUNTIME_DIR") != "" { + t.Skip("Rootless Docker does not support setting rlimit") + } ctx := context.Background() diff --git a/docs/features/configuration.md b/docs/features/configuration.md index c5c650c331..aaa8d1c998 100644 --- a/docs/features/configuration.md +++ b/docs/features/configuration.md @@ -20,13 +20,24 @@ case with underscore separators, preceded by `TESTCONTAINERS_` - e.g. `ryuk.disa _Testcontainers for Go_ provides a struct type to represent the configuration: -[Supported properties](../../config.go) inside_block:testcontainersConfig +[Supported properties](../../internal/config/config.go) inside_block:testcontainersConfig -You can read it with the `ReadConfig()` function: +You can read it with the `ReadConfigWithContext(ctx)` function: ```go -cfg := testcontainers.ReadConfig() +cfg := testcontainers.ReadConfigWithContext(ctx) +``` + +For advanced users, the Docker host connection can be configured **via configuration** in `~/.testcontainers.properties`, but environment variables will take precedence. +Please see [Docker host detection](#docker-host-detection) for more information. + +The example below illustrates how to configure the Docker host connection via properties file: + +```properties +docker.host=tcp://my.docker.host:1234 # Equivalent to the DOCKER_HOST environment variable. +docker.tls.verify=1 # Equivalent to the DOCKER_TLS_VERIFY environment variable +docker.cert.path=/some/path # Equivalent to the DOCKER_CERT_PATH environment variable ``` ### Disabling Ryuk @@ -38,24 +49,48 @@ but does not allow starting privileged containers, you can turn off the Ryuk con !!!info For more information about Ryuk, see [Garbage Collector](garbage_collector.md). -## Customizing Docker host detection +## Docker host detection -Testcontainers will attempt to detect the Docker environment and configure everything to work automatically. +_Testcontainers for Go_ will attempt to detect the Docker environment and configure everything to work automatically. -However, sometimes customization is required. Testcontainers will respect the following **environment variables**: +However, sometimes customization is required. _Testcontainers for Go_ will respect the following order: -> **DOCKER_HOST** = unix:///var/run/docker.sock -> See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables) -> -> **TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE** -> Path to Docker's socket. Used by Ryuk, Docker Compose, and a few other containers that need to perform Docker actions. -> Example: `/var/run/docker-alt.sock` +1. Read the **tc.host** property in the `~/.testcontainers.properties` file. E.g. `tc.host=tcp://my.docker.host:1234` -For advanced users, the Docker host connection can be configured **via configuration** in `~/.testcontainers.properties`. -The example below illustrates usage: +2. Read the **DOCKER_HOST** environment variable. E.g. `DOCKER_HOST=unix:///var/run/docker.sock` +See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables) for more information. -```properties -docker.host=tcp://my.docker.host:1234 # Equivalent to the DOCKER_HOST environment variable. -docker.tls.verify=1 # Equivalent to the DOCKER_TLS_VERIFY environment variable -docker.cert.path=/some/path # Equivalent to the DOCKER_CERT_PATH environment variable -``` +3. Read the Go context for the **DOCKER_HOST** key. E.g. `ctx.Value("DOCKER_HOST")`. This is used internally for the library to pass the Docker host to the resource reaper. + +4. Read the default Docker socket path, without the unix schema. E.g. `/var/run/docker.sock` + +5. Read the **docker.host** property in the `~/.testcontainers.properties` file. E.g. `docker.host=tcp://my.docker.host:1234` + +6. Read the rootless Docker socket path, checking in the following alternative locations: + 1. `${XDG_RUNTIME_DIR}/.docker/run/docker.sock`. + 2. `${HOME}/.docker/run/docker.sock`. + 3. `${HOME}/.docker/desktop/docker.sock`. + 4. `/run/user/${UID}/docker.sock`, where `${UID}` is the user ID of the current user. + +7. The default Docker socket including schema will be returned if none of the above are set. + +## Docker socket path detection + +_Testcontainers for Go_ will attempt to detect the Docker socket path and configure everything to work automatically. + +However, sometimes customization is required. _Testcontainers for Go_ will respect the following order: + +1. Read the **tc.host** property in the `~/.testcontainers.properties` file. E.g. `tc.host=tcp://my.docker.host:1234` + +2. Read the **TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE** environment variable. +Path to Docker's socket. Used by Ryuk, Docker Compose, and a few other containers that need to perform Docker actions. + + Example: `/var/run/docker-alt.sock` + +3. If the Operative System retrieved by the Docker client is "Docker Desktop", return the default docker socket path for rootless docker. + +4. Get the current Docker Host from the existing strategies: see [Docker host detection](#docker-host-detection). + +5. If the socket contains the unix schema, the schema is removed (e.g. `unix:///var/run/docker.sock` -> `/var/run/docker.sock`) + +6. Else, the default location of the docker socket is used: `/var/run/docker.sock` diff --git a/docs/system_requirements/docker.md b/docs/system_requirements/docker.md index c7d014cdec..e791b748fb 100644 --- a/docs/system_requirements/docker.md +++ b/docs/system_requirements/docker.md @@ -5,6 +5,7 @@ During development, Testcontainers is actively tested against recent versions of These Docker environments are automatically detected and used by Testcontainers without any additional configuration being necessary. It is possible to configure Testcontainers to work for other Docker setups, such as a remote Docker host or Docker alternatives. -However, these are not actively tested in the main development workflow, so not all Testcontainers features might be available and additional manual configuration might be necessary. +However, these are not actively tested in the main development workflow, so not all Testcontainers features might be available and additional manual configuration might be necessary. Please see the [Docker host detection](../features/configuration.md#docker-host-detection) section for more information. + If you have further questions about configuration details for your setup or whether it supports running Testcontainers-based tests, please contact the Testcontainers team and other users from the Testcontainers community on [Slack](https://slack.testcontainers.org/). diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000..dc4c4d788c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + + "github.com/magiconair/properties" +) + +var tcConfig Config +var tcConfigOnce *sync.Once = new(sync.Once) + +// Config represents the configuration for Testcontainers +// testcontainersConfig { +type Config struct { + Host string `properties:"docker.host,default="` + TLSVerify int `properties:"docker.tls.verify,default=0"` + CertPath string `properties:"docker.cert.path,default="` + RyukDisabled bool `properties:"ryuk.disabled,default=false"` + RyukPrivileged bool `properties:"ryuk.container.privileged,default=false"` + TestcontainersHost string `properties:"tc.host,default="` +} + +// } + +// Read reads from testcontainers properties file, if it exists +// it is possible that certain values get overridden when set as environment variables +func Read(ctx context.Context) Config { + tcConfigOnce.Do(func() { + tcConfig = read(ctx) + + if tcConfig.RyukDisabled { + ryukDisabledMessage := ` +********************************************************************************************** +Ryuk has been disabled for the current execution. This can cause unexpected behavior in your environment. +More on this: https://golang.testcontainers.org/features/garbage_collector/ +**********************************************************************************************` + fmt.Println(ryukDisabledMessage) + } + }) + + return tcConfig +} + +// Reset resets the singleton instance of the Config struct, +// allowing to read the configuration again. +// Handy for testing, so do not use it in production code +// This function is not thread-safe +func Reset() { + tcConfigOnce = new(sync.Once) +} + +func read(ctx context.Context) Config { + config := Config{} + + applyEnvironmentConfiguration := func(config Config) Config { + ryukDisabledEnv := os.Getenv("TESTCONTAINERS_RYUK_DISABLED") + if parseBool(ryukDisabledEnv) { + config.RyukDisabled = ryukDisabledEnv == "true" + } + + ryukPrivilegedEnv := os.Getenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED") + if parseBool(ryukPrivilegedEnv) { + config.RyukPrivileged = ryukPrivilegedEnv == "true" + } + + return config + } + + home, err := os.UserHomeDir() + if err != nil { + return applyEnvironmentConfiguration(config) + } + + tcProp := filepath.Join(home, ".testcontainers.properties") + // init from a file + properties, err := properties.LoadFile(tcProp, properties.UTF8) + if err != nil { + return applyEnvironmentConfiguration(config) + } + + if err := properties.Decode(&config); err != nil { + fmt.Printf("invalid testcontainers properties file, returning an empty Testcontainers configuration: %v\n", err) + return applyEnvironmentConfiguration(config) + } + + fmt.Printf("Testcontainers properties file has been found: %s\n", tcProp) + + return applyEnvironmentConfiguration(config) +} + +func parseBool(input string) bool { + if _, err := strconv.ParseBool(input); err == nil { + return true + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000000..d579b4df24 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,411 @@ +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + tcpDockerHost1234 = "tcp://127.0.0.1:1234" + tcpDockerHost33293 = "tcp://127.0.0.1:33293" + tcpDockerHost4711 = "tcp://127.0.0.1:4711" +) + +// unset environment variables to avoid side effects +// execute this function before each test +func resetTestEnv(t *testing.T) { + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "") + t.Setenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "") +} + +func TestReadConfig(t *testing.T) { + resetTestEnv(t) + + t.Run("Config is read just once", func(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("DOCKER_HOST", "") + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + + config := Read(context.Background()) + + expected := Config{ + RyukDisabled: true, + Host: "", // docker socket is empty at the properties file + } + + assert.Equal(t, expected, config) + + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "false") + + config = Read(context.Background()) + assert.Equal(t, expected, config) + }) +} + +func TestReadTCConfig(t *testing.T) { + resetTestEnv(t) + + t.Run("HOME is not set", func(t *testing.T) { + t.Setenv("HOME", "") + + config := read(context.Background()) + + expected := Config{} + + assert.Equal(t, expected, config) + }) + + t.Run("HOME is not set - TESTCONTAINERS_ env is set", func(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + t.Setenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true") + + config := read(context.Background()) + + expected := Config{ + RyukDisabled: true, + RyukPrivileged: true, + Host: "", // docker socket is empty at the properties file + } + + assert.Equal(t, expected, config) + }) + + t.Run("HOME does not contain TC props file", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + config := read(context.Background()) + + expected := Config{} + + assert.Equal(t, expected, config) + }) + + t.Run("HOME does not contain TC props file - DOCKER_HOST env is set", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("DOCKER_HOST", tcpDockerHost33293) + + config := read(context.Background()) + expected := Config{} // the config does not read DOCKER_HOST, that's why it's empty + + assert.Equal(t, expected, config) + }) + + t.Run("HOME does not contain TC props file - TESTCONTAINERS_ env is set", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") + t.Setenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED", "true") + + config := read(context.Background()) + expected := Config{ + RyukDisabled: true, + RyukPrivileged: true, + } + + assert.Equal(t, expected, config) + }) + + t.Run("HOME contains TC properties file", func(t *testing.T) { + tests := []struct { + name string + content string + env map[string]string + expected Config + }{ + { + "Single Docker host with spaces", + "docker.host = " + tcpDockerHost33293, + map[string]string{}, + Config{ + Host: tcpDockerHost33293, + TLSVerify: 0, + CertPath: "", + }, + }, + { + "Multiple docker host entries, last one wins", + `docker.host = ` + tcpDockerHost33293 + ` + docker.host = ` + tcpDockerHost4711 + ` + `, + map[string]string{}, + Config{ + Host: tcpDockerHost4711, + TLSVerify: 0, + CertPath: "", + }, + }, + { + "Multiple docker host entries, last one wins, with TLS", + `docker.host = ` + tcpDockerHost33293 + ` + docker.host = ` + tcpDockerHost4711 + ` + docker.host = ` + tcpDockerHost1234 + ` + docker.tls.verify = 1 + `, + map[string]string{}, + Config{ + Host: tcpDockerHost1234, + TLSVerify: 1, + CertPath: "", + }, + }, + { + "Empty file", + "", + map[string]string{}, + Config{ + TLSVerify: 0, + CertPath: "", + }, + }, + { + "Non-valid properties are ignored", + `foo = bar + docker.host = ` + tcpDockerHost1234 + ` + `, + map[string]string{}, + Config{ + Host: tcpDockerHost1234, + TLSVerify: 0, + CertPath: "", + }, + }, + { + "Single Docker host without spaces", + "docker.host=" + tcpDockerHost33293, + map[string]string{}, + Config{ + Host: tcpDockerHost33293, + TLSVerify: 0, + CertPath: "", + }, + }, + { + "Comments are ignored", + `#docker.host=` + tcpDockerHost33293, + map[string]string{}, + Config{ + TLSVerify: 0, + CertPath: "", + }, + }, + { + "Multiple docker host entries, last one wins, with TLS and cert path", + `#docker.host = ` + tcpDockerHost33293 + ` + docker.host = ` + tcpDockerHost4711 + ` + docker.host = ` + tcpDockerHost1234 + ` + docker.cert.path=/tmp/certs`, + map[string]string{}, + Config{ + Host: tcpDockerHost1234, + TLSVerify: 0, + CertPath: "/tmp/certs", + }, + }, + { + "With Ryuk disabled using properties", + `ryuk.disabled=true`, + map[string]string{}, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: true, + }, + }, + { + "With Ryuk container privileged using properties", + `ryuk.container.privileged=true`, + map[string]string{}, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: true, + }, + }, + { + "With Ryuk disabled using an env var", + ``, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: true, + }, + }, + { + "With Ryuk container privileged using an env var", + ``, + map[string]string{ + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: true, + }, + }, + { + "With Ryuk disabled using an env var and properties. Env var wins (0)", + `ryuk.disabled=true`, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: true, + }, + }, + { + "With Ryuk disabled using an env var and properties. Env var wins (1)", + `ryuk.disabled=false`, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: true, + }, + }, + { + "With Ryuk disabled using an env var and properties. Env var wins (2)", + `ryuk.disabled=true`, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "false", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: false, + }, + }, + { + "With Ryuk disabled using an env var and properties. Env var wins (3)", + `ryuk.disabled=false`, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "false", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: false, + }, + }, + { + "With Ryuk container privileged using an env var and properties. Env var wins (0)", + `ryuk.container.privileged=true`, + map[string]string{ + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: true, + }, + }, + { + "With Ryuk container privileged using an env var and properties. Env var wins (1)", + `ryuk.container.privileged=false`, + map[string]string{ + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: true, + }, + }, + { + "With Ryuk container privileged using an env var and properties. Env var wins (2)", + `ryuk.container.privileged=true`, + map[string]string{ + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "false", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: false, + }, + }, + { + "With Ryuk container privileged using an env var and properties. Env var wins (3)", + `ryuk.container.privileged=false`, + map[string]string{ + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "false", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: false, + }, + }, + { + "With TLS verify using properties when value is wrong", + `ryuk.container.privileged=false + docker.tls.verify = ERROR`, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "true", + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: true, + RyukPrivileged: true, + }, + }, + { + "With Ryuk disabled using an env var and properties. Env var does not win because it's not a boolean value", + `ryuk.disabled=false`, + map[string]string{ + "TESTCONTAINERS_RYUK_DISABLED": "foo", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukDisabled: false, + }, + }, + { + "With Ryuk container privileged using an env var and properties. Env var does not win because it's not a boolean value", + `ryuk.container.privileged=false`, + map[string]string{ + "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "foo", + }, + Config{ + TLSVerify: 0, + CertPath: "", + RyukPrivileged: false, + }, + }, + } + for _, tt := range tests { + t.Run(fmt.Sprintf(tt.name), func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + for k, v := range tt.env { + t.Setenv(k, v) + } + if err := os.WriteFile(filepath.Join(tmpDir, ".testcontainers.properties"), []byte(tt.content), 0o600); err != nil { + t.Errorf("Failed to create the file: %v", err) + return + } + + // + config := read(context.Background()) + + assert.Equal(t, tt.expected, config, "Configuration doesn't not match") + }) + } + }) +} diff --git a/internal/testcontainersdocker/client.go b/internal/testcontainersdocker/client.go index c121246988..d37ef115a7 100644 --- a/internal/testcontainersdocker/client.go +++ b/internal/testcontainersdocker/client.go @@ -2,12 +2,61 @@ package testcontainersdocker import ( "context" + "path/filepath" "github.com/docker/docker/client" + "github.com/testcontainers/testcontainers-go/internal/config" + "github.com/testcontainers/testcontainers-go/internal/testcontainerssession" ) -// NewClient returns a new docker client with the default options +// NewClient returns a new docker client extracting the docker host from the different alternatives func NewClient(ctx context.Context, ops ...client.Opt) (*client.Client, error) { + tcConfig := config.Read(ctx) + + dockerHost := ExtractDockerHost(ctx) + + opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()} + if dockerHost != "" { + opts = append(opts, client.WithHost(dockerHost)) + + // for further information, read https://docs.docker.com/engine/security/protect-access/ + if tcConfig.TLSVerify == 1 { + cacertPath := filepath.Join(tcConfig.CertPath, "ca.pem") + certPath := filepath.Join(tcConfig.CertPath, "cert.pem") + keyPath := filepath.Join(tcConfig.CertPath, "key.pem") + + opts = append(opts, client.WithTLSClientConfig(cacertPath, certPath, keyPath)) + } + } + + opts = append(opts, client.WithHTTPHeaders( + map[string]string{ + "x-tc-sid": testcontainerssession.String(), + }), + ) + + // passed options have priority over the default ones + opts = append(opts, ops...) + + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return nil, err + } + + if _, err = cli.Ping(context.Background()); err != nil { + // Fallback to environment, including the original options + cli, err = defaultClient(context.Background(), ops...) + if err != nil { + return nil, err + } + } + defer cli.Close() + + return cli, nil +} + +// defaultClient returns a plain, new docker client with the default options +func defaultClient(ctx context.Context, ops ...client.Opt) (*client.Client, error) { if len(ops) == 0 { ops = []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()} } diff --git a/internal/testcontainersdocker/docker_host.go b/internal/testcontainersdocker/docker_host.go index 3014eb7eb9..6b9827f733 100644 --- a/internal/testcontainersdocker/docker_host.go +++ b/internal/testcontainersdocker/docker_host.go @@ -3,16 +3,38 @@ package testcontainersdocker import ( "context" "errors" - "net/url" + "fmt" "os" "os/exec" "strings" + "sync" + + "github.com/docker/docker/client" + "github.com/testcontainers/testcontainers-go/internal/config" ) type dockerHostContext string var DockerHostContextKey = dockerHostContext("docker_host") +var ( + ErrDockerHostNotSet = errors.New("DOCKER_HOST is not set") + ErrDockerSocketOverrideNotSet = errors.New("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is not set") + ErrDockerSocketNotSetInContext = errors.New("socket not set in context") + ErrDockerSocketNotSetInProperties = errors.New("socket not set in ~/.testcontainers.properties") + ErrNoUnixSchema = errors.New("URL schema is not unix") + ErrSocketNotFound = errors.New("socket not found") + ErrSocketNotFoundInPath = errors.New("docker socket not found in " + DockerSocketPath) + // ErrTestcontainersHostNotSetInProperties this error is specific to Testcontainers + ErrTestcontainersHostNotSetInProperties = errors.New("tc.host not set in ~/.testcontainers.properties") +) + +var dockerHostCache string +var dockerHostOnce sync.Once + +var dockerSocketPathCache string +var dockerSocketPathOnce sync.Once + // deprecated // see https://github.com/testcontainers/testcontainers-java/blob/main/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java#L46 func DefaultGatewayIP() (string, error) { @@ -29,33 +51,190 @@ func DefaultGatewayIP() (string, error) { return ip, nil } -// Extracts the docker host from the context, or returns the default value -func ExtractDockerHost(ctx context.Context) (dockerHostPath string) { - if dockerHostPath = os.Getenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"); dockerHostPath != "" { - return dockerHostPath +// ExtractDockerHost Extracts the docker host from the different alternatives, caching the result to avoid unnecessary +// calculations. Use this function to get the actual Docker host. This function does not consider Windows containers at the moment. +// The possible alternatives are: +// +// 1. Docker host from the "tc.host" property in the ~/.testcontainers.properties file. +// 2. DOCKER_HOST environment variable. +// 3. Docker host from context. +// 4. Docker host from the default docker socket path, without the unix schema. +// 5. Docker host from the "docker.host" property in the ~/.testcontainers.properties file. +// 6. Rootless docker socket path. +// 7. Else, the default Docker socket including schema will be returned. +func ExtractDockerHost(ctx context.Context) string { + dockerHostOnce.Do(func() { + dockerHostCache = extractDockerHost(ctx) + }) + + return dockerHostCache +} + +// ExtractDockerSocket Extracts the docker socket from the different alternatives, removing the socket schema and +// caching the result to avoid unnecessary calculations. Use this function to get the docker socket path, +// not the host (e.g. mounting the socket in a container). This function does not consider Windows containers at the moment. +// The possible alternatives are: +// +// 1. Docker host from the "tc.host" property in the ~/.testcontainers.properties file. +// 2. The TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE environment variable. +// 3. Using a Docker client, check if the Info().OperativeSystem is "Docker Desktop" and return the default docker socket path for rootless docker. +// 4. Else, Get the current Docker Host from the existing strategies: see ExtractDockerHost. +// 5. If the socket contains the unix schema, the schema is removed (e.g. unix:///var/run/docker.sock -> /var/run/docker.sock) +// 6. Else, the default location of the docker socket is used (/var/run/docker.sock) +func ExtractDockerSocket(ctx context.Context) string { + dockerSocketPathOnce.Do(func() { + dockerSocketPathCache = extractDockerSocket(ctx) + }) + + return dockerSocketPathCache +} + +// extractDockerHost Extracts the docker host from the different alternatives, without caching the result. +// This internal method is handy for testing purposes. +func extractDockerHost(ctx context.Context) string { + dockerHostFns := []func(context.Context) (string, error){ + testcontainersHostFromProperties, + dockerHostFromEnv, + dockerHostFromContext, + dockerSocketPath, + dockerHostFromProperties, + rootlessDockerSocketPath, + } + + outerErr := ErrSocketNotFound + for _, dockerHostFn := range dockerHostFns { + dockerHost, err := dockerHostFn(ctx) + if err != nil { + outerErr = fmt.Errorf("%w: %v", outerErr, err) + continue + } + + return dockerHost + } + + // We are not supporting Windows containers at the moment + return DockerSocketPathWithSchema +} + +// extractDockerHost Extracts the docker socket from the different alternatives, without caching the result. +// It will internally use the default Docker client, calling the internal method extractDockerSocketFromClient with it. +// This internal method is handy for testing purposes. +// If a Docker client cannot be created, the program will panic. +func extractDockerSocket(ctx context.Context) string { + cli, err := NewClient(ctx) + if err != nil { + panic(err) // a Docker client is required to get the Docker info + } + + return extractDockerSocketFromClient(ctx, cli) +} + +// extractDockerSocketFromClient Extracts the docker socket from the different alternatives, without caching the result, +// and receiving an instance of the Docker API client interface. +// This internal method is handy for testing purposes, passing a mock type simulating the desired behaviour. +func extractDockerSocketFromClient(ctx context.Context, cli client.APIClient) string { + tcHost, err := testcontainersHostFromProperties(ctx) + if err == nil { + return tcHost } - dockerHostPath = "/var/run/docker.sock" + testcontainersDockerSocket, err := dockerSocketOverridePath(ctx) + if err == nil { + return testcontainersDockerSocket + } - var hostRawURL string - if h, ok := ctx.Value(DockerHostContextKey).(string); !ok || h == "" { - return dockerHostPath - } else { - hostRawURL = h + info, err := cli.Info(ctx) + if err != nil { + panic(err) // Docker Info is required to get the Operating System } - var hostURL *url.URL - if u, err := url.Parse(hostRawURL); err != nil { - return dockerHostPath - } else { - hostURL = u + + // Because Docker Desktop runs in a VM, we need to use the default docker path for rootless docker + if info.OperatingSystem == "Docker Desktop" { + return "/var/run/docker.sock" } - switch hostURL.Scheme { - case "unix": - return hostURL.Path - default: - return dockerHostPath + dockerHost := extractDockerHost(ctx) + + if strings.HasPrefix(dockerHost, DockerSocketSchema) { + return strings.Replace(dockerHost, DockerSocketSchema, "", 1) } + + return dockerHost +} + +// dockerHostFromEnv returns the docker host from the DOCKER_HOST environment variable, if it's not empty +func dockerHostFromEnv(ctx context.Context) (string, error) { + if dockerHostPath := os.Getenv("DOCKER_HOST"); dockerHostPath != "" { + return dockerHostPath, nil + } + + return "", ErrDockerHostNotSet +} + +// dockerHostFromContext returns the docker host from the Go context, if it's not empty +func dockerHostFromContext(ctx context.Context) (string, error) { + if socketPath, ok := ctx.Value(DockerHostContextKey).(string); ok && socketPath != "" { + parsed, err := parseURL(socketPath) + if err != nil { + return "", err + } + + return parsed, nil + } + + return "", ErrDockerSocketNotSetInContext +} + +// dockerHostFromProperties returns the docker host from the ~/.testcontainers.properties file, if it's not empty +func dockerHostFromProperties(ctx context.Context) (string, error) { + cfg := config.Read(ctx) + socketPath := cfg.Host + if socketPath != "" { + parsed, err := parseURL(socketPath) + if err != nil { + return "", err + } + + return parsed, nil + } + + return "", ErrDockerSocketNotSetInProperties +} + +// dockerSocketOverridePath returns the docker socket from the TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE environment variable, +// if it's not empty +func dockerSocketOverridePath(ctx context.Context) (string, error) { + if dockerHostPath, exists := os.LookupEnv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"); exists { + return dockerHostPath, nil + } + + return "", ErrDockerSocketOverrideNotSet +} + +// dockerSocketPath returns the docker socket from the default docker socket path, if it's not empty +// and the socket exists +func dockerSocketPath(ctx context.Context) (string, error) { + if fileExists(DockerSocketPath) { + return DockerSocketPathWithSchema, nil + } + + return "", ErrSocketNotFoundInPath +} + +// testcontainersHostFromProperties returns the testcontainers host from the ~/.testcontainers.properties file, if it's not empty +func testcontainersHostFromProperties(ctx context.Context) (string, error) { + cfg := config.Read(ctx) + testcontainersHost := cfg.TestcontainersHost + if testcontainersHost != "" { + parsed, err := parseURL(testcontainersHost) + if err != nil { + return "", err + } + + return parsed, nil + } + + return "", ErrTestcontainersHostNotSetInProperties } // InAContainer returns true if the code is running inside a container diff --git a/internal/testcontainersdocker/docker_host_test.go b/internal/testcontainersdocker/docker_host_test.go index 06f0ca30d7..9da3eff303 100644 --- a/internal/testcontainersdocker/docker_host_test.go +++ b/internal/testcontainersdocker/docker_host_test.go @@ -6,46 +6,334 @@ import ( "path/filepath" "testing" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/internal/config" ) -func Test_ExtractDockerHost(t *testing.T) { - t.Run("Docker Host as environment variable", func(t *testing.T) { - t.Setenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/path/to/docker.sock") +var ( + originalDockerSocketPath string + originalDockerSocketPathWithSchema string +) + +var originalDockerSocketOverride string +var tmpSchema string + +func init() { + originalDockerSocketPath = DockerSocketPath + originalDockerSocketPathWithSchema = DockerSocketPathWithSchema + + originalDockerSocketOverride = os.Getenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") + + tmpSchema = DockerSocketSchema +} + +var resetSocketOverrideFn = func() { + os.Setenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", originalDockerSocketOverride) +} + +func TestExtractDockerHost(t *testing.T) { + setupDockerHostNotFound(t) + // do not mess with local .testcontainers.properties + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + t.Run("Docker Host as extracted just once", func(t *testing.T) { + expected := "/path/to/docker.sock" + t.Setenv("DOCKER_HOST", expected) host := ExtractDockerHost(context.Background()) - assert.Equal(t, "/path/to/docker.sock", host) + assert.Equal(t, expected, host) + + t.Setenv("DOCKER_HOST", "/path/to/another/docker.sock") + + host = ExtractDockerHost(context.Background()) + assert.Equal(t, expected, host) }) - t.Run("Default Docker Host", func(t *testing.T) { - host := ExtractDockerHost(context.Background()) + t.Run("Testcontainers Host is resolved first", func(t *testing.T) { + t.Setenv("DOCKER_HOST", "/path/to/docker.sock") + tmpHost := "tcp://127.0.0.1:12345" + content := "tc.host=" + tmpHost - assert.Equal(t, "/var/run/docker.sock", host) + config.Reset() + setupTestcontainersProperties(t, content) + + host := extractDockerHost(context.Background()) + + assert.Equal(t, tmpHost, host) + }) + + t.Run("Docker Host as environment variable", func(t *testing.T) { + t.Setenv("DOCKER_HOST", "/path/to/docker.sock") + host := extractDockerHost(context.Background()) + + assert.Equal(t, "/path/to/docker.sock", host) }) t.Run("Malformed Docker Host is passed in context", func(t *testing.T) { + setupDockerSocketNotFound(t) + setupRootlessNotFound(t) + ctx := context.Background() - host := ExtractDockerHost(context.WithValue(ctx, DockerHostContextKey, "path-to-docker-sock")) + host := extractDockerHost(context.WithValue(ctx, DockerHostContextKey, "path-to-docker-sock")) - assert.Equal(t, "/var/run/docker.sock", host) + assert.Equal(t, DockerSocketPathWithSchema, host) }) t.Run("Malformed Schema Docker Host is passed in context", func(t *testing.T) { + setupDockerSocketNotFound(t) + setupRootlessNotFound(t) ctx := context.Background() - host := ExtractDockerHost(context.WithValue(ctx, DockerHostContextKey, "http://path to docker sock")) + host := extractDockerHost(context.WithValue(ctx, DockerHostContextKey, "http://path to docker sock")) - assert.Equal(t, "/var/run/docker.sock", host) + assert.Equal(t, DockerSocketPathWithSchema, host) }) t.Run("Unix Docker Host is passed in context", func(t *testing.T) { ctx := context.Background() - host := ExtractDockerHost(context.WithValue(ctx, DockerHostContextKey, "unix:///this/is/a/sample.sock")) + host := extractDockerHost(context.WithValue(ctx, DockerHostContextKey, "unix:///this/is/a/sample.sock")) assert.Equal(t, "/this/is/a/sample.sock", host) }) + + t.Run("Default Docker socket", func(t *testing.T) { + setupRootlessNotFound(t) + tmpSocket := setupDockerSocket(t) + + host := extractDockerHost(context.Background()) + + assert.Equal(t, tmpSocket, host) + }) + + t.Run("Default Docker Host when empty", func(t *testing.T) { + setupDockerSocketNotFound(t) + setupRootlessNotFound(t) + host := extractDockerHost(context.Background()) + + assert.Equal(t, DockerSocketPathWithSchema, host) + }) + + t.Run("Extract Docker socket", func(t *testing.T) { + setupDockerHostNotFound(t) + t.Cleanup(resetSocketOverrideFn) + + t.Run("Testcontainers host is defined in properties", func(t *testing.T) { + tmpSocket := "tcp://127.0.0.1:12345" + content := "tc.host=" + tmpSocket + + config.Reset() + setupTestcontainersProperties(t, content) + defer config.Reset() + + socket, err := testcontainersHostFromProperties(context.Background()) + require.Nil(t, err) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("Testcontainers host is not defined in properties", func(t *testing.T) { + content := "ryuk.disabled=false" + + config.Reset() + setupTestcontainersProperties(t, content) + defer config.Reset() + + socket, err := testcontainersHostFromProperties(context.Background()) + require.ErrorIs(t, err, ErrTestcontainersHostNotSetInProperties) + assert.Empty(t, socket) + }) + + t.Run("DOCKER_HOST is set", func(t *testing.T) { + tmpDir := t.TempDir() + tmpSocket := filepath.Join(tmpDir, "docker.sock") + t.Setenv("DOCKER_HOST", tmpSocket) + err := createTmpDockerSocket(tmpDir) + require.Nil(t, err) + + socket, err := dockerHostFromEnv(context.Background()) + require.Nil(t, err) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("DOCKER_HOST is not set", func(t *testing.T) { + t.Setenv("DOCKER_HOST", "") + + socket, err := dockerHostFromEnv(context.Background()) + require.ErrorIs(t, err, ErrDockerHostNotSet) + assert.Empty(t, socket) + }) + + t.Run("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is set", func(t *testing.T) { + t.Cleanup(resetSocketOverrideFn) + + tmpDir := t.TempDir() + tmpSocket := filepath.Join(tmpDir, "docker.sock") + t.Setenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", tmpSocket) + err := createTmpDockerSocket(tmpDir) + require.Nil(t, err) + + socket, err := dockerSocketOverridePath(context.Background()) + require.Nil(t, err) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is not set", func(t *testing.T) { + t.Cleanup(resetSocketOverrideFn) + + os.Unsetenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") + + socket, err := dockerSocketOverridePath(context.Background()) + require.ErrorIs(t, err, ErrDockerSocketOverrideNotSet) + assert.Empty(t, socket) + }) + + t.Run("Context sets the Docker socket", func(t *testing.T) { + ctx := context.Background() + + socket, err := dockerHostFromContext(context.WithValue(ctx, DockerHostContextKey, "unix:///this/is/a/sample.sock")) + require.Nil(t, err) + assert.Equal(t, "/this/is/a/sample.sock", socket) + }) + + t.Run("Context sets a malformed Docker socket", func(t *testing.T) { + ctx := context.Background() + + socket, err := dockerHostFromContext(context.WithValue(ctx, DockerHostContextKey, "path-to-docker-sock")) + require.Error(t, err) + assert.Empty(t, socket) + }) + + t.Run("Context sets a malformed schema for the Docker socket", func(t *testing.T) { + ctx := context.Background() + + socket, err := dockerHostFromContext(context.WithValue(ctx, DockerHostContextKey, "http://example.com/docker.sock")) + require.ErrorIs(t, err, ErrNoUnixSchema) + assert.Empty(t, socket) + }) + + t.Run("Docker socket exists", func(t *testing.T) { + tmpSocket := setupDockerSocket(t) + + socket, err := dockerSocketPath(context.Background()) + require.Nil(t, err) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("Docker host is defined in properties", func(t *testing.T) { + tmpSocket := "/this/is/a/sample.sock" + content := "docker.host=unix://" + tmpSocket + + config.Reset() + setupTestcontainersProperties(t, content) + + socket, err := dockerHostFromProperties(context.Background()) + require.Nil(t, err) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("Docker host is not defined in properties", func(t *testing.T) { + content := "ryuk.disabled=false" + + config.Reset() + setupTestcontainersProperties(t, content) + + socket, err := dockerHostFromProperties(context.Background()) + require.ErrorIs(t, err, ErrDockerSocketNotSetInProperties) + assert.Empty(t, socket) + }) + + t.Run("Docker socket does not exist", func(t *testing.T) { + setupDockerSocketNotFound(t) + + socket, err := dockerSocketPath(context.Background()) + require.ErrorIs(t, err, ErrSocketNotFoundInPath) + assert.Empty(t, socket) + }) + }) +} + +// mockCli is a mock implementation of client.APIClient, which is handy for simulating +// different operating systems. +type mockCli struct { + client.APIClient + OS string +} + +// Info returns a mock implementation of types.Info, which is handy for detecting the operating system, +// which is used to determine the default docker socket path. +func (m mockCli) Info(ctx context.Context) (types.Info, error) { + return types.Info{ + OperatingSystem: m.OS, + }, nil +} + +func TestExtractDockerSocketFromClient(t *testing.T) { + setupDockerHostNotFound(t) + + t.Run("Docker socket from Testcontainers host defined in properties", func(t *testing.T) { + tmpSocket := "tcp://127.0.0.1:12345" + content := "tc.host=" + tmpSocket + + config.Reset() + setupTestcontainersProperties(t, content) + defer config.Reset() + + socket := extractDockerSocketFromClient(context.Background(), mockCli{OS: "foo"}) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("Docker socket from Testcontainers host takes precedence over TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", func(t *testing.T) { + tmpSocket := "tcp://127.0.0.1:12345" + content := "tc.host=" + tmpSocket + + config.Reset() + setupTestcontainersProperties(t, content) + t.Cleanup(config.Reset) + + t.Cleanup(resetSocketOverrideFn) + t.Setenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/path/to/docker.sock") + + socket := extractDockerSocketFromClient(context.Background(), mockCli{OS: "foo"}) + assert.Equal(t, tmpSocket, socket) + }) + + t.Run("Docker Socket as Testcontainers environment variable", func(t *testing.T) { + t.Cleanup(resetSocketOverrideFn) + + t.Setenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/path/to/docker.sock") + host := extractDockerSocketFromClient(context.Background(), mockCli{OS: "foo"}) + + assert.Equal(t, "/path/to/docker.sock", host) + }) + + t.Run("Unix Docker Socket is passed as DOCKER_HOST variable (Docker Desktop)", func(t *testing.T) { + t.Cleanup(resetSocketOverrideFn) + + ctx := context.Background() + os.Unsetenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") + t.Setenv("DOCKER_HOST", "unix:///this/is/a/sample.sock") + + socket := extractDockerSocketFromClient(ctx, mockCli{OS: "Docker Desktop"}) + + assert.Equal(t, "/var/run/docker.sock", socket) + }) + + t.Run("Unix Docker Socket is passed as DOCKER_HOST variable (Not Docker Desktop)", func(t *testing.T) { + t.Cleanup(resetSocketOverrideFn) + + ctx := context.Background() + os.Unsetenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") + t.Setenv("DOCKER_HOST", "unix:///this/is/a/sample.sock") + + socket := extractDockerSocketFromClient(ctx, mockCli{OS: "Ubuntu"}) + + assert.Equal(t, "/this/is/a/sample.sock", socket) + }) } func TestInAContainer(t *testing.T) { @@ -67,3 +355,80 @@ func TestInAContainer(t *testing.T) { assert.True(t, inAContainer(f)) }) } + +func createTmpDir(dir string) error { + err := os.MkdirAll(dir, 0755) + if err != nil { + return err + } + + return nil +} + +func createTmpDockerSocket(parent string) error { + socketPath := filepath.Join(parent, "docker.sock") + err := os.MkdirAll(filepath.Dir(socketPath), 0755) + if err != nil { + return err + } + + f, err := os.Create(socketPath) + if err != nil { + return err + } + f.Close() + return nil +} + +// setupDockerHostNotFound sets up the environment for the test case where the DOCKER_HOST environment variable is +// already set (e.g. rootless docker) therefore we need to unset it before the test +func setupDockerHostNotFound(t *testing.T) { + t.Setenv("DOCKER_HOST", "") +} + +func setupDockerSocket(t *testing.T) string { + t.Cleanup(func() { + DockerSocketPath = originalDockerSocketPath + DockerSocketPathWithSchema = originalDockerSocketPathWithSchema + }) + + tmpDir := t.TempDir() + tmpSocket := filepath.Join(tmpDir, "docker.sock") + err := createTmpDockerSocket(filepath.Dir(tmpSocket)) + require.Nil(t, err) + + DockerSocketPath = tmpSocket + DockerSocketPathWithSchema = tmpSchema + tmpSocket + + return tmpSchema + tmpSocket +} + +func setupDockerSocketNotFound(t *testing.T) { + t.Cleanup(func() { + DockerSocketPath = originalDockerSocketPath + DockerSocketPathWithSchema = originalDockerSocketPathWithSchema + }) + + tmpDir := t.TempDir() + tmpSocket := filepath.Join(tmpDir, "docker.sock") + + DockerSocketPath = tmpSocket +} + +func setupTestcontainersProperties(t *testing.T, content string) { + t.Cleanup(func() { + // reset the properties file after the test + config.Reset() + }) + + tmpDir := t.TempDir() + homeDir := filepath.Join(tmpDir, "home") + err := createTmpDir(homeDir) + require.Nil(t, err) + t.Setenv("HOME", homeDir) + + if err := os.WriteFile(filepath.Join(homeDir, ".testcontainers.properties"), []byte(content), 0o600); err != nil { + t.Errorf("Failed to create the file: %v", err) + return + } +} diff --git a/internal/testcontainersdocker/docker_rootless.go b/internal/testcontainersdocker/docker_rootless.go new file mode 100644 index 0000000000..43ff4637dd --- /dev/null +++ b/internal/testcontainersdocker/docker_rootless.go @@ -0,0 +1,141 @@ +package testcontainersdocker + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" +) + +var ( + ErrRootlessDockerNotFound = errors.New("rootless Docker not found") + ErrRootlessDockerNotFoundHomeDesktopDir = errors.New("checked path: ~/.docker/desktop/docker.sock") + ErrRootlessDockerNotFoundHomeRunDir = errors.New("checked path: ~/.docker/run/docker.sock") + ErrRootlessDockerNotFoundRunDir = errors.New("checked path: /run/user/${uid}/docker.sock") + ErrRootlessDockerNotFoundXDGRuntimeDir = errors.New("checked path: $XDG_RUNTIME_DIR") + ErrRootlessDockerNotSupportedWindows = errors.New("rootless Docker is not supported on Windows") + ErrXDGRuntimeDirNotSet = errors.New("XDG_RUNTIME_DIR is not set") +) + +// baseRunDir is the base directory for the "/run/user/${uid}" directory. +// It is a variable so it can be modified for testing. +var baseRunDir = "/run" + +// rootlessDockerSocketPath returns if the path to the rootless Docker socket exists. +// The rootless socket path is determined by the following order: +// +// 1. XDG_RUNTIME_DIR environment variable. +// 2. ~/.docker/run/docker.sock file. +// 3. ~/.docker/desktop/docker.sock file. +// 4. /run/user/${uid}/docker.sock file. +// 5. Else, return ErrRootlessDockerNotFound, wrapping secific errors for each of the above paths. +// +// It should include the Docker socket schema (unix://) in the returned path. +func rootlessDockerSocketPath(_ context.Context) (string, error) { + // adding a manner to test it on non-windows machines, setting the GOOS env var to windows + // This is needed because runtime.GOOS is a constant that returns the OS of the machine running the test + if os.Getenv("GOOS") == "windows" || runtime.GOOS == "windows" { + return "", ErrRootlessDockerNotSupportedWindows + } + + socketPathFns := []func() (string, error){ + rootlessSocketPathFromEnv, + rootlessSocketPathFromHomeRunDir, + rootlessSocketPathFromHomeDesktopDir, + rootlessSocketPathFromRunDir, + } + + outerErr := ErrRootlessDockerNotFound + for _, socketPathFn := range socketPathFns { + s, err := socketPathFn() + if err != nil { + outerErr = fmt.Errorf("%w: %v", outerErr, err) + continue + } + + return DockerSocketSchema + s, nil + } + + return "", outerErr +} + +func fileExists(f string) bool { + _, err := os.Stat(f) + return err == nil +} + +func parseURL(s string) (string, error) { + var hostURL *url.URL + if u, err := url.Parse(s); err != nil { + return "", err + } else { + hostURL = u + } + + switch hostURL.Scheme { + case "unix", "npipe": + return hostURL.Path, nil + case "tcp": + // return the original URL, as it is a valid TCP URL + return s, nil + default: + return "", ErrNoUnixSchema + } +} + +// rootlessSocketPathFromEnv returns the path to the rootless Docker socket from the XDG_RUNTIME_DIR environment variable. +// It should include the Docker socket schema (unix://) in the returned path. +func rootlessSocketPathFromEnv() (string, error) { + xdgRuntimeDir, exists := os.LookupEnv("XDG_RUNTIME_DIR") + if exists { + f := filepath.Join(xdgRuntimeDir, "docker.sock") + if fileExists(f) { + return f, nil + } + + return "", ErrRootlessDockerNotFoundXDGRuntimeDir + } + + return "", ErrXDGRuntimeDirNotSet +} + +// rootlessSocketPathFromHomeRunDir returns the path to the rootless Docker socket from the ~/.docker/run/docker.sock file. +func rootlessSocketPathFromHomeRunDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + f := filepath.Join(home, ".docker", "run", "docker.sock") + if fileExists(f) { + return f, nil + } + return "", ErrRootlessDockerNotFoundHomeRunDir +} + +// rootlessSocketPathFromHomeDesktopDir returns the path to the rootless Docker socket from the ~/.docker/desktop/docker.sock file. +func rootlessSocketPathFromHomeDesktopDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + f := filepath.Join(home, ".docker", "desktop", "docker.sock") + if fileExists(f) { + return f, nil + } + return "", ErrRootlessDockerNotFoundHomeDesktopDir +} + +// rootlessSocketPathFromRunDir returns the path to the rootless Docker socket from the /run/user//docker.sock file. +func rootlessSocketPathFromRunDir() (string, error) { + uid := os.Getuid() + f := filepath.Join(baseRunDir, "user", fmt.Sprintf("%d", uid), "docker.sock") + if fileExists(f) { + return f, nil + } + return "", ErrRootlessDockerNotFoundRunDir +} diff --git a/internal/testcontainersdocker/docker_rootless_test.go b/internal/testcontainersdocker/docker_rootless_test.go new file mode 100644 index 0000000000..f62935ddc6 --- /dev/null +++ b/internal/testcontainersdocker/docker_rootless_test.go @@ -0,0 +1,178 @@ +package testcontainersdocker + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var originalBaseRunDir string +var originalXDGRuntimeDir string +var originalHomeDir string + +func init() { + originalBaseRunDir = baseRunDir + originalXDGRuntimeDir = os.Getenv("XDG_RUNTIME_DIR") + originalHomeDir = os.Getenv("HOME") +} + +func TestFileExists(t *testing.T) { + type cases struct { + filepath string + expected bool + } + + tests := []cases{ + { + filepath: "testdata", + expected: true, + }, + { + filepath: "docker_rootless.go", + expected: true, + }, + { + filepath: "foobar.doc", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.filepath, func(t *testing.T) { + result := fileExists(test.filepath) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestRootlessDockerSocketPath(t *testing.T) { + restoreEnvFn := func() { + os.Setenv("HOME", originalHomeDir) + os.Setenv("XDG_RUNTIME_DIR", originalXDGRuntimeDir) + } + + t.Cleanup(func() { + restoreEnvFn() + }) + + t.Run("Rootless not supported on Windows", func(t *testing.T) { + t.Setenv("GOOS", "windows") + socketPath, err := rootlessDockerSocketPath(context.Background()) + require.ErrorIs(t, err, ErrRootlessDockerNotSupportedWindows) + assert.Empty(t, socketPath) + }) + + t.Run("XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR}/docker.sock", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", tmpDir) + err := createTmpDockerSocket(tmpDir) + require.Nil(t, err) + + socketPath, err := rootlessDockerSocketPath(context.Background()) + require.Nil(t, err) + assert.NotEmpty(t, socketPath) + }) + + t.Run("Home run dir: ~/.docker/run/docker.sock", func(t *testing.T) { + tmpDir := t.TempDir() + _ = os.Unsetenv("XDG_RUNTIME_DIR") + t.Cleanup(restoreEnvFn) + + runDir := filepath.Join(tmpDir, ".docker", "run") + err := createTmpDockerSocket(runDir) + require.Nil(t, err) + t.Setenv("HOME", tmpDir) + + socketPath, err := rootlessDockerSocketPath(context.Background()) + require.Nil(t, err) + assert.Equal(t, "unix://"+runDir+"/docker.sock", socketPath) + }) + + t.Run("Home desktop dir: ~/.docker/desktop/docker.sock", func(t *testing.T) { + tmpDir := t.TempDir() + _ = os.Unsetenv("XDG_RUNTIME_DIR") + t.Cleanup(restoreEnvFn) + + desktopDir := filepath.Join(tmpDir, ".docker", "desktop") + err := createTmpDockerSocket(desktopDir) + require.Nil(t, err) + t.Setenv("HOME", tmpDir) + + socketPath, err := rootlessDockerSocketPath(context.Background()) + require.Nil(t, err) + assert.Equal(t, "unix://"+desktopDir+"/docker.sock", socketPath) + }) + + t.Run("Run dir: /run/user/${uid}/docker.sock", func(t *testing.T) { + tmpDir := t.TempDir() + _ = os.Unsetenv("XDG_RUNTIME_DIR") + + homeDir := filepath.Join(tmpDir, "home") + err := createTmpDir(homeDir) + require.Nil(t, err) + t.Setenv("HOME", homeDir) + + baseRunDir = tmpDir + t.Cleanup(func() { + baseRunDir = originalBaseRunDir + restoreEnvFn() + }) + + uid := os.Getuid() + runDir := filepath.Join(tmpDir, "user", fmt.Sprintf("%d", uid)) + err = createTmpDockerSocket(runDir) + require.Nil(t, err) + + socketPath, err := rootlessDockerSocketPath(context.Background()) + require.Nil(t, err) + assert.Equal(t, "unix://"+runDir+"/docker.sock", socketPath) + }) + + t.Run("Rootless not found", func(t *testing.T) { + setupRootlessNotFound(t) + + socketPath, err := rootlessDockerSocketPath(context.Background()) + assert.ErrorIs(t, err, ErrRootlessDockerNotFound) + assert.Empty(t, socketPath) + + // the wrapped error includes all the locations that were checked + assert.ErrorContains(t, err, ErrRootlessDockerNotFoundXDGRuntimeDir.Error()) + assert.ErrorContains(t, err, ErrRootlessDockerNotFoundHomeRunDir.Error()) + assert.ErrorContains(t, err, ErrRootlessDockerNotFoundHomeDesktopDir.Error()) + assert.ErrorContains(t, err, ErrRootlessDockerNotFoundRunDir.Error()) + }) +} + +func setupRootlessNotFound(t *testing.T) { + t.Cleanup(func() { + baseRunDir = originalBaseRunDir + os.Setenv("XDG_RUNTIME_DIR", originalXDGRuntimeDir) + }) + + tmpDir := t.TempDir() + + xdgRuntimeDir := filepath.Join(tmpDir, "xdg-runtime-dir") + err := createTmpDir(xdgRuntimeDir) + require.Nil(t, err) + t.Setenv("XDG_RUNTIME_DIR", xdgRuntimeDir) + + homeDir := filepath.Join(tmpDir, "home") + err = createTmpDir(homeDir) + require.Nil(t, err) + t.Setenv("HOME", homeDir) + + homeRunDir := filepath.Join(homeDir, ".docker", "run") + err = createTmpDir(homeRunDir) + require.Nil(t, err) + + baseRunDir = tmpDir + uid := os.Getuid() + runDir := filepath.Join(tmpDir, "run", "user", fmt.Sprintf("%d", uid)) + err = createTmpDir(runDir) + require.Nil(t, err) +} diff --git a/internal/testcontainersdocker/docker_socket.go b/internal/testcontainersdocker/docker_socket.go new file mode 100644 index 0000000000..9f93c5fe1a --- /dev/null +++ b/internal/testcontainersdocker/docker_socket.go @@ -0,0 +1,10 @@ +package testcontainersdocker + +// DockerSocketSchema is the unix schema. +var DockerSocketSchema = "unix://" + +// DockerSocketPath is the path to the docker socket under unix systems. +var DockerSocketPath = "/var/run/docker.sock" + +// DockerSocketPathWithSchema is the path to the docker socket under unix systems with the unix schema. +var DockerSocketPathWithSchema = DockerSocketSchema + DockerSocketPath diff --git a/logconsumer_test.go b/logconsumer_test.go index 08f2a6ba60..da43790ad1 100644 --- a/logconsumer_test.go +++ b/logconsumer_test.go @@ -306,7 +306,7 @@ func TestContainerLogWithErrClosed(t *testing.T) { provider := &DockerProvider{ client: client, - config: ReadConfig(), + config: ReadConfigWithContext(ctx), DockerProviderOptions: &DockerProviderOptions{ GenericProviderOptions: &GenericProviderOptions{ Logger: TestLogger(t), diff --git a/modules/localstack/localstack.go b/modules/localstack/localstack.go index df1dce6369..8046b8e40b 100644 --- a/modules/localstack/localstack.go +++ b/modules/localstack/localstack.go @@ -64,9 +64,11 @@ func isVersion2(image string) bool { // - overrideReq: a function that can be used to override the default container request, usually used to set the image version, environment variables for localstack, etc. func RunContainer(ctx context.Context, opts ...testcontainers.ContainerCustomizer) (*LocalStackContainer, error) { // defaultContainerRequest { + dockerHost := testcontainersdocker.ExtractDockerSocket(ctx) + req := testcontainers.ContainerRequest{ Image: fmt.Sprintf("localstack/localstack:%s", defaultVersion), - Binds: []string{fmt.Sprintf("%s:/var/run/docker.sock", testcontainersdocker.ExtractDockerHost(ctx))}, + Binds: []string{fmt.Sprintf("%s:/var/run/docker.sock", dockerHost)}, WaitingFor: wait.ForHTTP("/_localstack/health").WithPort("4566/tcp").WithStartupTimeout(120 * time.Second), ExposedPorts: []string{fmt.Sprintf("%d/tcp", defaultPort)}, Env: map[string]string{}, diff --git a/provider.go b/provider.go index 8ee80c11b1..b5ccff1143 100644 --- a/provider.go +++ b/provider.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "strings" + + "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" ) // possible provider types @@ -141,11 +143,13 @@ func NewDockerProvider(provOpts ...DockerProviderOption) (*DockerProvider, error return nil, err } - tcConfig := ReadConfig() + tcConfig := ReadConfigWithContext(context.Background()) + + dockerHost := testcontainersdocker.ExtractDockerHost(context.Background()) p := &DockerProvider{ DockerProviderOptions: o, - host: tcConfig.Host, + host: dockerHost, client: c, config: tcConfig, } diff --git a/provider_test.go b/provider_test.go index 6700a40da3..143f26de2c 100644 --- a/provider_test.go +++ b/provider_test.go @@ -2,10 +2,12 @@ package testcontainers import ( "testing" + + "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" ) func TestProviderTypeGetProviderAutodetect(t *testing.T) { - const dockerSocket = "unix:///var/run/docker.sock" + var dockerSocket = testcontainersdocker.DockerSocketPathWithSchema const podmanSocket = "unix://$XDG_RUNTIME_DIR/podman/podman.sock" tests := []struct { diff --git a/reaper.go b/reaper.go index f7c22d8104..b72a9bec53 100644 --- a/reaper.go +++ b/reaper.go @@ -72,7 +72,7 @@ func reuseOrCreateReaper(ctx context.Context, sessionID string, provider ReaperP // newReaper creates a Reaper with a sessionID to identify containers and a provider to use // Should only be used internally and instead use reuseOrCreateReaper to prefer reusing an existing Reaper instance func newReaper(ctx context.Context, sessionID string, provider ReaperProvider, opts ...ContainerOption) (*Reaper, error) { - dockerHost := testcontainersdocker.ExtractDockerHost(ctx) + dockerHostMount := testcontainersdocker.ExtractDockerSocket(ctx) reaper := &Reaper{ Provider: provider, @@ -81,7 +81,7 @@ func newReaper(ctx context.Context, sessionID string, provider ReaperProvider, o listeningPort := nat.Port("8080/tcp") - tcConfig := provider.Config() + tcConfig := provider.Config().Config reaperOpts := containerOptions{} @@ -96,7 +96,7 @@ func newReaper(ctx context.Context, sessionID string, provider ReaperProvider, o TestcontainerLabelIsReaper: "true", testcontainersdocker.LabelReaper: "true", }, - Mounts: Mounts(BindMount(dockerHost, "/var/run/docker.sock")), + Mounts: Mounts(BindMount(dockerHostMount, "/var/run/docker.sock")), Privileged: tcConfig.RyukPrivileged, WaitingFor: wait.ForListeningPort(listeningPort), ReaperOptions: opts, diff --git a/reaper_test.go b/reaper_test.go index 36e9d4ebef..f392810370 100644 --- a/reaper_test.go +++ b/reaper_test.go @@ -10,6 +10,7 @@ import ( "github.com/docker/go-connections/nat" "github.com/stretchr/testify/assert" "github.com/testcontainers/testcontainers-go/internal" + "github.com/testcontainers/testcontainers-go/internal/config" "github.com/testcontainers/testcontainers-go/internal/testcontainersdocker" "github.com/testcontainers/testcontainers-go/wait" ) @@ -60,7 +61,7 @@ func createContainerRequest(customize func(ContainerRequest) ContainerRequest) C testcontainersdocker.LabelLang: "go", testcontainersdocker.LabelVersion: internal.Version, }, - Mounts: Mounts(BindMount("/var/run/docker.sock", "/var/run/docker.sock")), + Mounts: Mounts(BindMount(testcontainersdocker.ExtractDockerSocket(context.Background()), "/var/run/docker.sock")), WaitingFor: wait.ForListeningPort(nat.Port("8080/tcp")), ReaperOptions: []ContainerOption{ WithImageName("reaperImage"), @@ -94,17 +95,19 @@ func Test_NewReaper(t *testing.T) { return req }), config: TestcontainersConfig{ - RyukPrivileged: true, + Config: config.Config{ + RyukPrivileged: true, + }, }, }, { name: "docker-host in context", req: createContainerRequest(func(req ContainerRequest) ContainerRequest { - req.Mounts = Mounts(BindMount("/value/in/context.sock", "/var/run/docker.sock")) + req.Mounts = Mounts(BindMount(testcontainersdocker.ExtractDockerSocket(context.Background()), "/var/run/docker.sock")) return req }), config: TestcontainersConfig{}, - ctx: context.WithValue(context.TODO(), testcontainersdocker.DockerHostContextKey, "unix:///value/in/context.sock"), + ctx: context.WithValue(context.TODO(), testcontainersdocker.DockerHostContextKey, testcontainersdocker.DockerSocketPathWithSchema), }, }