diff --git a/config.go b/config.go index 91a333107d..52ad3b8400 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ import ( "github.com/testcontainers/testcontainers-go/internal/config" ) +// Deprecated: use [testcontainers.Config] instead // TestcontainersConfig represents the configuration for Testcontainers type TestcontainersConfig struct { Host string `properties:"docker.host,default="` // Deprecated: use Config.Host instead @@ -14,10 +15,15 @@ type TestcontainersConfig struct { Config config.Config } +// Deprecated: use [testcontainers.NewConfig] instead // ReadConfig reads from testcontainers properties file, storing the result in a singleton instance // of the TestcontainersConfig struct func ReadConfig() TestcontainersConfig { - cfg := config.Read() + cfg, err := NewConfig() + if err != nil { + return TestcontainersConfig{} + } + return TestcontainersConfig{ Host: cfg.Host, TLSVerify: cfg.TLSVerify, @@ -27,3 +33,11 @@ func ReadConfig() TestcontainersConfig { Config: cfg, } } + +// Config is a type alias for the internal config.Config type +type Config = config.Config + +// NewConfig reads the properties file and returns a new Config instance +func NewConfig() (Config, error) { + return config.Read() +} diff --git a/docker.go b/docker.go index 296fe6743c..ed3c2dc1a4 100644 --- a/docker.go +++ b/docker.go @@ -43,9 +43,9 @@ import ( var _ Container = (*DockerContainer)(nil) const ( - Bridge = "bridge" // Bridge network name (as well as driver) - Podman = "podman" - ReaperDefault = "reaper_default" // Default network name when bridge is not available + Bridge = "bridge" // Deprecated, it will removed in the next major release. Bridge network driver and name + Podman = "podman" // Deprecated: Podman is supported through the current Docker context + ReaperDefault = "reaper_default" // Deprecated: it will removed in the next major release. Default network name when bridge is not available packagePath = "github.com/testcontainers/testcontainers-go" ) @@ -1026,29 +1026,6 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque // defer the close of the Docker client connection the soonest defer p.Close() - var defaultNetwork string - defaultNetwork, err = p.ensureDefaultNetwork(ctx) - if err != nil { - return nil, fmt.Errorf("ensure default network: %w", err) - } - - // If default network is not bridge make sure it is attached to the request - // as container won't be attached to it automatically - // in case of Podman the bridge network is called 'podman' as 'bridge' would conflict - if defaultNetwork != p.defaultBridgeNetworkName { - isAttached := false - for _, net := range req.Networks { - if net == defaultNetwork { - isAttached = true - break - } - } - - if !isAttached { - req.Networks = append(req.Networks, defaultNetwork) - } - } - imageName := req.Image env := []string{} @@ -1452,6 +1429,7 @@ func (p *DockerProvider) RunContainer(ctx context.Context, req ContainerRequest) return c, nil } +// Deprecated: use [testcontainers.NewConfig] instead // Config provides the TestcontainersConfig read from $HOME/.testcontainers.properties or // the environment variables func (p *DockerProvider) Config() TestcontainersConfig { @@ -1522,10 +1500,6 @@ func (p *DockerProvider) CreateNetwork(ctx context.Context, req NetworkRequest) // defer the close of the Docker client connection the soonest defer p.Close() - if _, err = p.ensureDefaultNetwork(ctx); err != nil { - return nil, fmt.Errorf("ensure default network: %w", err) - } - if req.Labels == nil { req.Labels = make(map[string]string) } @@ -1593,13 +1567,7 @@ func (p *DockerProvider) GetNetwork(ctx context.Context, req NetworkRequest) (ne } func (p *DockerProvider) GetGatewayIP(ctx context.Context) (string, error) { - // Use a default network as defined in the DockerProvider - defaultNetwork, err := p.ensureDefaultNetwork(ctx) - if err != nil { - return "", fmt.Errorf("ensure default network: %w", err) - } - - nw, err := p.GetNetwork(ctx, NetworkRequest{Name: defaultNetwork}) + nw, err := p.GetNetwork(ctx, NetworkRequest{Name: Bridge}) if err != nil { return "", err } @@ -1618,58 +1586,6 @@ func (p *DockerProvider) GetGatewayIP(ctx context.Context) (string, error) { return ip, nil } -// ensureDefaultNetwork ensures that defaultNetwork is set and creates -// it if it does not exist, returning its value. -// It is safe to call this method concurrently. -func (p *DockerProvider) ensureDefaultNetwork(ctx context.Context) (string, error) { - p.mtx.Lock() - defer p.mtx.Unlock() - - if p.defaultNetwork != "" { - // Already set. - return p.defaultNetwork, nil - } - - networkResources, err := p.client.NetworkList(ctx, network.ListOptions{}) - if err != nil { - return "", fmt.Errorf("network list: %w", err) - } - - // TODO: remove once we have docker context support via #2810 - // Prefer the default bridge network if it exists. - // This makes the results stable as network list order is not guaranteed. - for _, net := range networkResources { - switch net.Name { - case p.defaultBridgeNetworkName: - p.defaultNetwork = p.defaultBridgeNetworkName - return p.defaultNetwork, nil - case ReaperDefault: - p.defaultNetwork = ReaperDefault - } - } - - if p.defaultNetwork != "" { - return p.defaultNetwork, nil - } - - // Create a bridge network for the container communications. - _, err = p.client.NetworkCreate(ctx, ReaperDefault, network.CreateOptions{ - Driver: Bridge, - Attachable: true, - Labels: GenericLabels(), - }) - // If the network already exists, we can ignore the error as that can - // happen if we are running multiple tests in parallel and we only - // need to ensure that the network exists. - if err != nil && !errdefs.IsConflict(err) { - return "", fmt.Errorf("network create: %w", err) - } - - p.defaultNetwork = ReaperDefault - - return p.defaultNetwork, nil -} - // ContainerFromType builds a Docker container struct from the response of the Docker API func (p *DockerProvider) ContainerFromType(ctx context.Context, response types.Container) (ctr *DockerContainer, err error) { exposedPorts := make([]string, len(response.Ports)) diff --git a/docker_auth.go b/docker_auth.go index 58b3ef2637..59b8aefad4 100644 --- a/docker_auth.go +++ b/docker_auth.go @@ -172,7 +172,7 @@ func configKey(cfg *dockercfg.Config) (string, error) { // getDockerAuthConfigs returns a map with the auth configs from the docker config file // using the registry as the key func getDockerAuthConfigs() (map[string]registry.AuthConfig, error) { - cfg, err := getDockerConfig() + cfg, err := core.DockerConfig() if err != nil { if errors.Is(err, os.ErrNotExist) { return map[string]registry.AuthConfig{}, nil @@ -258,25 +258,3 @@ func getDockerAuthConfigs() (map[string]registry.AuthConfig, error) { return cfgs, nil } - -// getDockerConfig returns the docker config file. It will internally check, in this particular order: -// 1. the DOCKER_AUTH_CONFIG environment variable, unmarshalling it into a dockercfg.Config -// 2. the DOCKER_CONFIG environment variable, as the path to the config file -// 3. else it will load the default config file, which is ~/.docker/config.json -func getDockerConfig() (*dockercfg.Config, error) { - if env := os.Getenv("DOCKER_AUTH_CONFIG"); env != "" { - var cfg dockercfg.Config - if err := json.Unmarshal([]byte(env), &cfg); err != nil { - return nil, fmt.Errorf("unmarshal DOCKER_AUTH_CONFIG: %w", err) - } - - return &cfg, nil - } - - cfg, err := dockercfg.LoadDefaultConfig() - if err != nil { - return nil, fmt.Errorf("load default config: %w", err) - } - - return &cfg, nil -} diff --git a/docker_auth_test.go b/docker_auth_test.go index 5d397d53c8..aa1822393f 100644 --- a/docker_auth_test.go +++ b/docker_auth_test.go @@ -26,76 +26,6 @@ const ( exampleRegistry = "https://example.com" ) -func Test_getDockerConfig(t *testing.T) { - expectedConfig := &dockercfg.Config{ - AuthConfigs: map[string]dockercfg.AuthConfig{ - core.IndexDockerIO: {}, - exampleRegistry: {}, - privateRegistry: {}, - }, - CredentialsStore: "desktop", - } - t.Run("HOME/valid", func(t *testing.T) { - testDockerConfigHome(t, "testdata") - - cfg, err := getDockerConfig() - require.NoError(t, err) - require.Equal(t, expectedConfig, cfg) - }) - - t.Run("HOME/not-found", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "not-found") - - cfg, err := getDockerConfig() - require.ErrorIs(t, err, os.ErrNotExist) - require.Nil(t, cfg) - }) - - t.Run("HOME/invalid-config", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "invalid-config") - - cfg, err := getDockerConfig() - require.ErrorContains(t, err, "json: cannot unmarshal array") - require.Nil(t, cfg) - }) - - t.Run("DOCKER_AUTH_CONFIG/valid", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "not-found") - t.Setenv("DOCKER_AUTH_CONFIG", dockerConfig) - - cfg, err := getDockerConfig() - require.NoError(t, err) - require.Equal(t, expectedConfig, cfg) - }) - - t.Run("DOCKER_AUTH_CONFIG/invalid-config", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "not-found") - t.Setenv("DOCKER_AUTH_CONFIG", `{"auths": []}`) - - cfg, err := getDockerConfig() - require.ErrorContains(t, err, "json: cannot unmarshal array") - require.Nil(t, cfg) - }) - - t.Run("DOCKER_CONFIG/valid", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "not-found") - t.Setenv("DOCKER_CONFIG", filepath.Join("testdata", ".docker")) - - cfg, err := getDockerConfig() - require.NoError(t, err) - require.Equal(t, expectedConfig, cfg) - }) - - t.Run("DOCKER_CONFIG/invalid-config", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "not-found") - t.Setenv("DOCKER_CONFIG", filepath.Join("testdata", "invalid-config", ".docker")) - - cfg, err := getDockerConfig() - require.ErrorContains(t, err, "json: cannot unmarshal array") - require.Nil(t, cfg) - }) -} - func TestDockerImageAuth(t *testing.T) { t.Run("retrieve auth with DOCKER_AUTH_CONFIG env var", func(t *testing.T) { username, password := "gopher", "secret" @@ -297,7 +227,6 @@ func prepareLocalRegistryWithAuth(t *testing.T) string { // } genContainerReq := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, } @@ -322,7 +251,6 @@ func prepareLocalRegistryWithAuth(t *testing.T) string { func prepareRedisImage(ctx context.Context, req ContainerRequest) (Container, error) { genContainerReq := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, } @@ -474,15 +402,6 @@ func Test_getDockerAuthConfigs(t *testing.T) { requireValidAuthConfig(t) }) - - t.Run("DOCKER_CONFIG/invalid-config", func(t *testing.T) { - testDockerConfigHome(t, "testdata", "not-found") - t.Setenv("DOCKER_CONFIG", filepath.Join("testdata", "invalid-config", ".docker")) - - cfg, err := getDockerConfig() - require.ErrorContains(t, err, "json: cannot unmarshal array") - require.Nil(t, cfg) - }) } // requireValidAuthConfig checks that the given authConfigs map contains the expected keys. diff --git a/docker_test.go b/docker_test.go index 3fa686632f..7e4c94b613 100644 --- a/docker_test.go +++ b/docker_test.go @@ -38,14 +38,6 @@ const ( daemonMaxVersion = "1.41" ) -var providerType = ProviderDocker - -func init() { - if strings.Contains(os.Getenv("DOCKER_HOST"), "podman.sock") { - providerType = ProviderPodman - } -} - 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") @@ -58,7 +50,6 @@ func TestContainerWithHostNetworkOptions(t *testing.T) { require.NoError(t, err) gcr := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, Files: []ContainerFile{ @@ -126,7 +117,6 @@ func TestContainerWithNetworkModeAndNetworkTogether(t *testing.T) { // } gcr := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxImage, Networks: []string{"new-network"}, @@ -157,7 +147,6 @@ func TestContainerWithHostNetwork(t *testing.T) { require.NoError(t, err) gcr := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, WaitingFor: wait.ForHTTP("/").WithPort(nginxHighPort), @@ -195,7 +184,6 @@ func TestContainerWithHostNetwork(t *testing.T) { func TestContainerReturnItsContainerID(t *testing.T) { ctx := context.Background() nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -222,7 +210,6 @@ func TestContainerTerminationResetsState(t *testing.T) { ctx := context.Background() nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -249,7 +236,6 @@ func TestContainerTerminationResetsState(t *testing.T) { func TestContainerStateAfterTermination(t *testing.T) { createContainerFn := func(ctx context.Context) (Container, error) { return GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -307,7 +293,6 @@ func TestContainerTerminationRemovesDockerImage(t *testing.T) { defer dockerClient.Close() ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -340,7 +325,6 @@ func TestContainerTerminationRemovesDockerImage(t *testing.T) { WaitingFor: wait.ForLog("Ready to accept connections"), } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -362,7 +346,6 @@ func TestContainerTerminationRemovesDockerImage(t *testing.T) { func TestTwoContainersExposingTheSamePort(t *testing.T) { ctx := context.Background() nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -376,7 +359,6 @@ func TestTwoContainersExposingTheSamePort(t *testing.T) { require.NoError(t, err) nginxB, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -412,7 +394,6 @@ func TestContainerCreation(t *testing.T) { ctx := context.Background() nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -450,7 +431,6 @@ func TestContainerCreationWithName(t *testing.T) { expectedName := "/" + creationName // inspect adds '/' in the beginning nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -458,7 +438,9 @@ func TestContainerCreationWithName(t *testing.T) { }, WaitingFor: wait.ForHTTP("/").WithPort(nginxDefaultPort), Name: creationName, - Networks: []string{"bridge"}, + // no network means it will be connected to the default bridge network + // of any given container runtime + // Networks: []string{}, }, Started: true, }) @@ -474,12 +456,7 @@ func TestContainerCreationWithName(t *testing.T) { require.NoError(t, err) require.Lenf(t, networks, 1, "Expected networks 1. Got '%d'.", len(networks)) network := networks[0] - switch providerType { - case ProviderDocker: - assert.Equalf(t, Bridge, network, "Expected network name '%s'. Got '%s'.", Bridge, network) - case ProviderPodman: - assert.Equalf(t, Podman, network, "Expected network name '%s'. Got '%s'.", Podman, network) - } + require.Equal(t, Bridge, network) endpoint, err := nginxC.PortEndpoint(ctx, nginxDefaultPort, "http") require.NoError(t, err) @@ -496,7 +473,6 @@ func TestContainerCreationAndWaitForListeningPortLongEnough(t *testing.T) { // delayed-nginx will wait 2s before opening port nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxDelayedImage, ExposedPorts: []string{ @@ -522,7 +498,6 @@ func TestContainerCreationTimesOut(t *testing.T) { ctx := context.Background() // delayed-nginx will wait 2s before opening port nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxDelayedImage, ExposedPorts: []string{ @@ -541,7 +516,6 @@ func TestContainerRespondsWithHttp200ForIndex(t *testing.T) { ctx := context.Background() nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -567,7 +541,6 @@ func TestContainerCreationTimesOutWithHttp(t *testing.T) { ctx := context.Background() // delayed-nginx will wait 2s before opening port nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxDelayedImage, ExposedPorts: []string{ @@ -593,7 +566,6 @@ func TestContainerCreationWaitsForLogContextTimeout(t *testing.T) { WaitingFor: wait.ForLog("test context timeout").WithStartupTimeout(1 * time.Second), } c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -613,7 +585,6 @@ func TestContainerCreationWaitsForLog(t *testing.T) { WaitingFor: wait.ForLog("port: 3306 MySQL Community Server - GPL"), } mysqlC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -640,7 +611,6 @@ func Test_BuildContainerFromDockerfileWithBuildArgs(t *testing.T) { // } genContainerReq := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, } @@ -685,7 +655,6 @@ func Test_BuildContainerFromDockerfileWithBuildLog(t *testing.T) { // } genContainerReq := GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, } @@ -702,7 +671,7 @@ func Test_BuildContainerFromDockerfileWithBuildLog(t *testing.T) { temp := strings.Split(string(out), "\n") require.NotEmpty(t, temp) - assert.Regexpf(t, `^Step\s*1/\d+\s*:\s*FROM alpine$`, temp[0], "Expected stdout first line to be %s. Got '%s'.", "Step 1/* : FROM alpine", temp[0]) + require.Regexp(t, `^(?i:Step)\s*1/\d+\s*:\s*FROM alpine$`, temp[0]) } func TestContainerCreationWaitsForLogAndPortContextTimeout(t *testing.T) { @@ -720,7 +689,6 @@ func TestContainerCreationWaitsForLogAndPortContextTimeout(t *testing.T) { ), } c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -738,7 +706,6 @@ func TestContainerCreationWaitingForHostPort(t *testing.T) { } // } nginx, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -754,7 +721,6 @@ func TestContainerCreationWaitingForHostPortWithoutBashThrowsAnError(t *testing. WaitingFor: wait.ForListeningPort(nginxDefaultPort), } nginx, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -780,7 +746,6 @@ func TestCMD(t *testing.T) { } c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -806,7 +771,6 @@ func TestEntrypoint(t *testing.T) { } c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -833,7 +797,6 @@ func TestWorkingDir(t *testing.T) { } c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -1037,7 +1000,6 @@ func TestContainerCreationWithVolumeAndFileWritingToIt(t *testing.T) { // Create the container that writes into the mounted volume. bashC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: "bash:5.2.26", Files: []ContainerFile{ @@ -1065,7 +1027,6 @@ func TestContainerWithTmpFs(t *testing.T) { } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -1116,7 +1077,6 @@ func TestContainerNonExistentImage(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: "postgres:12", WaitingFor: wait.ForLog("log"), @@ -1129,16 +1089,12 @@ func TestContainerNonExistentImage(t *testing.T) { } func TestContainerCustomPlatformImage(t *testing.T) { - if providerType == ProviderPodman { - t.Skip("Incompatible Docker API version for Podman") - } t.Run("error with a non-existent platform", func(t *testing.T) { t.Parallel() nonExistentPlatform := "windows/arm12" ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: "redis:latest", ImagePlatform: nonExistentPlatform, @@ -1154,7 +1110,6 @@ func TestContainerCustomPlatformImage(t *testing.T) { ctx := context.Background() c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: "mysql:8.0.36", ImagePlatform: "linux/amd64", @@ -1188,7 +1143,6 @@ func TestContainerWithCustomHostname(t *testing.T) { Hostname: hostname, } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -1249,7 +1203,6 @@ func TestDockerContainerCopyFileToContainer(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1272,7 +1225,6 @@ func TestDockerContainerCopyDirToContainer(t *testing.T) { ctx := context.Background() nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1455,7 +1407,6 @@ func TestDockerContainerCopyToContainer(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1483,7 +1434,6 @@ func TestDockerContainerCopyFileFromContainer(t *testing.T) { ctx := context.Background() nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1513,7 +1463,6 @@ func TestDockerContainerCopyEmptyFileFromContainer(t *testing.T) { ctx := context.Background() nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1540,9 +1489,6 @@ func TestDockerContainerCopyEmptyFileFromContainer(t *testing.T) { } 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") } @@ -1563,7 +1509,6 @@ func TestDockerContainerResources(t *testing.T) { } nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1592,16 +1537,11 @@ func TestDockerContainerResources(t *testing.T) { } func TestContainerCapAdd(t *testing.T) { - if providerType == ProviderPodman { - t.Skip("Rootless Podman does not support setting cap-add/cap-drop") - } - ctx := context.Background() expected := "IPC_LOCK" nginx, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{nginxDefaultPort}, @@ -1658,7 +1598,6 @@ func TestContainerWithUserID(t *testing.T) { WaitingFor: wait.ForExit(), } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -1682,7 +1621,6 @@ func TestContainerWithNoUserID(t *testing.T) { WaitingFor: wait.ForExit(), } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) @@ -1701,7 +1639,7 @@ func TestContainerWithNoUserID(t *testing.T) { func TestGetGatewayIP(t *testing.T) { // When using docker compose with DinD mode, and using host port or http wait strategy // It's need to invoke GetGatewayIP for get the host - provider, err := providerType.GetProvider(WithLogger(TestLogger(t))) + provider, err := ProviderDocker.GetProvider(WithLogger(TestLogger(t))) require.NoError(t, err) defer provider.Close() @@ -1718,7 +1656,6 @@ func TestGetGatewayIP(t *testing.T) { func TestNetworkModeWithContainerReference(t *testing.T) { ctx := context.Background() nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, }, @@ -1729,7 +1666,6 @@ func TestNetworkModeWithContainerReference(t *testing.T) { networkMode := fmt.Sprintf("container:%v", nginxA.GetContainerID()) nginxB, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, HostConfigModifier: func(hc *container.HostConfig) { @@ -1795,7 +1731,6 @@ func TestDockerProviderFindContainerByName(t *testing.T) { defer provider.Close() c1, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Name: "test", Image: "nginx:1.17.6", @@ -1813,7 +1748,6 @@ func TestDockerProviderFindContainerByName(t *testing.T) { c1Name := c1Inspect.Name c2, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Name: "test2", Image: "nginx:1.17.6", @@ -1848,7 +1782,6 @@ func TestImageBuiltFromDockerfile_KeepBuiltImage(t *testing.T) { cli := provider.Client() // Create container. c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ FromDockerfile: FromDockerfile{ Context: "testdata", diff --git a/docs/features/configuration.md b/docs/features/configuration.md index 8da214e977..c8aca919af 100644 --- a/docs/features/configuration.md +++ b/docs/features/configuration.md @@ -81,17 +81,19 @@ See [Docker environment variables](https://docs.docker.com/engine/reference/comm 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` +4. Read the host endpoint for the current Docker context in the Docker configuration file. E.g. `~/.docker/config.json`. -5. Read the **docker.host** property in the `~/.testcontainers.properties` file. E.g. `docker.host=tcp://my.docker.host:1234` +5. Assume the default Docker socket path as `/var/run/docker.sock`. -6. Read the rootless Docker socket path, checking in the following alternative locations: +6. Read the **docker.host** property in the `~/.testcontainers.properties` file. E.g. `docker.host=tcp://my.docker.host:1234` + +7. 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 library panics if none of the above are set, meaning that the Docker host was not detected. +8. The library panics if none of the above are set, meaning that the Docker host was not detected. ## Docker socket path detection diff --git a/docs/system_requirements/docker.md b/docs/system_requirements/docker.md index e791b748fb..ea99134be0 100644 --- a/docs/system_requirements/docker.md +++ b/docs/system_requirements/docker.md @@ -9,3 +9,71 @@ However, these are not actively tested in the main development workflow, so not 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/). + +## Using different container runtimes + +_Testcontainers for Go_ automatically detects the selected Docker context and use it to run the tests on that container runtime. You can check the selected context by running: + +```sh +docker context ls +NAME DESCRIPTION DOCKER ENDPOINT ERROR +colima colima unix:///Users/mdelapenya/.colima/default/docker.sock +default Current DOCKER_HOST based configuration unix:///var/run/docker.sock +desktop-linux * Docker Desktop unix:///Users/mdelapenya/.docker/run/docker.sock +orbstack OrbStack unix:///Users/mdelapenya/.orbstack/run/docker.sock +podman podman context unix:///var/folders/_j/nhbgdck523n3008dd3zlsm5m0000gn/T/podman/podman-machine-default-api.sock +tcd Testcontainers Desktop tcp://127.0.0.1:59908 +``` + +It is possible to use any container runtime to satisfy the system requirements instead of Docker, as long as it is 100% Docker-API compatible, and a Docker context is created for it. + +### Colima + +Colima creates its own Docker context when it is installed. This context is called `colima`. You can set this as the active context by running: + +```sh +docker context use colima +``` + +### Orbstack + +Orbstack creates its own Docker context when it is installed. This context is called `orbstack`. You can set this as the active context by running: + +```sh +docker context use orbstack +``` + +### Podman + +Podman does not create its own Docker context when it is installed so, after starting a `podman-machine` in **rootful mode**, please create the context with the following command: + +```sh +podman context create podman --description "podman context" --docker "host=unix:///var/folders/_j/nhbgdck523n3008dd3zlsm5m0000gn/T/podman/podman-machine-default-api.sock" +``` + +!!! note + The UNIX socket path could be different in your machine. You can find it by running `podman machine inspect`. + +Then you can set this as the active context by running: + +```sh +podman context use podman +``` + +### Rancher Desktop + +Rancher Desktop creates its own Docker context when it is installed. This context is called `rancher-desktop`. You can set this as the active context by running: + +```sh +docker context use rancher-desktop +``` + +### Testcontainers Desktop + +Testcontainers Desktop creates its own Docker context when it is installed. This context is called `tcd`. You can set this as the active context by running: + +```sh +docker context use tcd +``` + +Testcontainers Desktop allows you to switch between different container runtimes, such as Docker, Podman, and Colima, by just using its simple GUI. You can also run the containers in the cloud, using Docker's Testcontainers Cloud. diff --git a/docs/system_requirements/rancher.md b/docs/system_requirements/rancher.md deleted file mode 100644 index 581b3a9cca..0000000000 --- a/docs/system_requirements/rancher.md +++ /dev/null @@ -1,26 +0,0 @@ -# Using Rancher Desktop - -It is possible to use Rancher Desktop to satisfy the system requirements instead of Docker. - -**IMPORTANT**: Please ensure you are running an up-to-date version of Rancher Desktop. There were some key fixes made in earlier versions (especially around v1.6). It is highly unlikely you will be able to get Rancher Desktop working with testcontainers if you are on an old version. - -The instructions below are written on the assumption that: - -1. you wish to run Rancher Desktop without administrative permissions (i.e. without granting `sudo` access a.k.a *"Administrative Access"* setting tickbox in Rancher Desktop is *unticked*). -2. you are running Rancher Desktop on an Apple-silicon device a.k.a M-series processor. - -Steps are as follows: - -1. In Rancher Desktop change engine from `containerd` to `dockerd (moby)`. -2. In Rancher Desktop set `VZ mode` networking. -3. On macOS CLI (e.g. `Terminal` app), set the following environment variables: - -```sh -export DOCKER_HOST=unix://$HOME/.rd/docker.sock -export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock -export TESTCONTAINERS_HOST_OVERRIDE=$(rdctl shell ip a show vznat | awk '/inet / {sub("/.*",""); print $2}') -``` - -As always, remember that environment variables are not persisted unless you add them to the relevant file for your default shell e.g. `~/.zshrc`. - -Credit: Thank you to @pdrosos on GitHub. diff --git a/docs/system_requirements/using_colima.md b/docs/system_requirements/using_colima.md deleted file mode 100644 index 6cb853adde..0000000000 --- a/docs/system_requirements/using_colima.md +++ /dev/null @@ -1,47 +0,0 @@ -# Using Colima with Docker - -[Colima](https://github.com/abiosoft/colima) is a container runtime which -integrates with Docker's tooling and can be configured in various ways. - -As of Colima v0.4.0 it's recommended to set the active Docker context to use -Colima. After the context is set _Testcontainers for Go_ will automatically be -configured to use Colima. - -```bash -$ docker context ls -NAME DESCRIPTION DOCKER ENDPOINT KUBERNETES ENDPOINT ORCHESTRATOR -colima colima unix:///Users/foobar/.colima/default/docker.sock -default * Current DOCKER_HOST based configuration unix:///Users/foobar/.colima/docker.sock - -$ docker context use colima -colima -Current context is now "colima" - -$ docker context ls -NAME DESCRIPTION DOCKER ENDPOINT KUBERNETES ENDPOINT ORCHESTRATOR -colima * colima unix:///Users/foobar/.colima/default/docker.sock -default Current DOCKER_HOST based configuration unix:///var/run/docker.sock -``` - -If you're using an older version of Colima or have other applications that are -unaware of Docker context the following workaround is available: - -- Locate your Docker Socket, see: [Colima's FAQ - Docker Socket Location](https://github.com/abiosoft/colima/blob/main/docs/FAQ.md#docker-socket-location) - -- Create a symbolic link from the default Docker Socket to the expected location, and restart Colima with the `--network-address` flag. - -``` - sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock - colima stop - colima start --network-address -``` - -- Set the `DOCKER_HOST` environment variable to match the located Docker Socket - - * Example: `export DOCKER_HOST="unix://${HOME}/.colima/default/docker.sock"` - -- As of testcontainers-go v0.14.0 set `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` - to `/var/run/docker.sock` as the default value refers to your `DOCKER_HOST` - environment variable. - - * Example: `export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE="/var/run/docker.sock"` diff --git a/docs/system_requirements/using_podman.md b/docs/system_requirements/using_podman.md deleted file mode 100644 index 4143306901..0000000000 --- a/docs/system_requirements/using_podman.md +++ /dev/null @@ -1,81 +0,0 @@ -# Using Podman instead of Docker - -_Testcontainers for Go_ supports the use of Podman (rootless or rootful) instead of Docker. - -In most scenarios no special setup is required in _Testcontainers for Go_. -_Testcontainers for Go_ will automatically discover the socket based on the `DOCKER_HOST` environment variables. -Alternatively you can configure the host with a `.testcontainers.properties` file. -The discovered Docker host is taken into account when starting a reaper container. -The discovered socket is used to detect the use of Podman. - -By default _Testcontainers for Go_ takes advantage of the default network settings both Docker and Podman are applying to newly created containers. -It only intervenes in scenarios where a `ContainerRequest` specifies networks and does not include the default network of the current container provider. -Unfortunately the default network for Docker is called _bridge_ where the default network in Podman is called _podman_. - -In complex container network scenarios it may be required to explicitly make use of the `ProviderPodman` like so: - -```go - -package some_test - -import ( - "testing" - tc "github.com/testcontainers/testcontainers-go" -) - -func TestSomething(t *testing.T) { - req := tc.GenericContainerRequest{ - ProviderType: tc.ProviderPodman, - ContainerRequest: tc.ContainerRequest{ - Image: "nginx:alpine" - }, - } - - // ... -} -``` - -The `ProviderPodman` configures the `DockerProvider` with the correct default network for Podman to ensure complex network scenarios are working as with Docker. - -## Podman socket activation - -The reaper container needs to connect to the docker daemon to reap containers, so the podman socket service must be started: -```shell -> systemctl --user start podman.socket -``` - -## Fedora - -`DOCKER_HOST` environment variable must be set - -``` -> export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock -``` - -SELinux may require a custom policy be applied to allow the reaper container to connect to and write to a socket. Once you experience the se-linux error, you can run the following commands to create and install a custom policy. - -``` -> sudo ausearch -c 'app' --raw | audit2allow -M my-podman -> sudo semodule -i my-podman.pp -``` - -The resulting my-podman.te file should look something like this: -``` -module my-podman2 1.0; - -require { - type user_tmp_t; - type container_runtime_t; - type container_t; - class sock_file write; - class unix_stream_socket connectto; -} - -#============= container_t ============== -allow container_t container_runtime_t:unix_stream_socket connectto; -allow container_t user_tmp_t:sock_file write; - -``` - -**NOTE: It will take two rounds of installing a policy, then experiencing the next se-linux issue, install new policy, etc...** - diff --git a/from_dockerfile_test.go b/from_dockerfile_test.go index 75f80537d2..473c09ce21 100644 --- a/from_dockerfile_test.go +++ b/from_dockerfile_test.go @@ -94,7 +94,6 @@ func TestBuildImageFromDockerfile_BuildError(t *testing.T) { }, } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Started: true, }) diff --git a/generic.go b/generic.go index fd13a607de..69b26ce7df 100644 --- a/generic.go +++ b/generic.go @@ -78,7 +78,7 @@ func GenericContainer(ctx context.Context, req GenericContainerRequest) (Contain // TODO: Remove this debugging. if strings.Contains(err.Error(), "toomanyrequests") { // Debugging information for rate limiting. - cfg, err := getDockerConfig() + cfg, err := core.DockerConfig() if err == nil { fmt.Printf("XXX: too many requests: %+v", cfg) } diff --git a/generic_test.go b/generic_test.go index 7c0de2a246..b561211115 100644 --- a/generic_test.go +++ b/generic_test.go @@ -26,7 +26,6 @@ func TestGenericReusableContainer(t *testing.T) { reusableContainerName := reusableContainerName + "_" + time.Now().Format("20060102150405") n1, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{nginxDefaultPort}, @@ -83,7 +82,6 @@ func TestGenericReusableContainer(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { n2, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{nginxDefaultPort}, @@ -113,7 +111,6 @@ func TestGenericContainerShouldReturnRefOnError(t *testing.T) { defer cancel() c, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, WaitingFor: wait.ForLog("this string should not be present in the logs"), @@ -188,7 +185,6 @@ func TestHelperContainerStarterProcess(t *testing.T) { ctx := context.Background() nginxC, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxDelayedImage, ExposedPorts: []string{nginxDefaultPort}, diff --git a/internal/config/config.go b/internal/config/config.go index 85be6acd86..6394b9f7f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -11,11 +12,20 @@ import ( "github.com/magiconair/properties" ) -const ReaperDefaultImage = "testcontainers/ryuk:0.11.0" +const ( + // ReaperDefaultImage is the default image used for Ryuk, the resource reaper. + ReaperDefaultImage = "testcontainers/ryuk:0.11.0" + + // testcontainersProperties is the name of the properties file used to configure Testcontainers. + testcontainersProperties = ".testcontainers.properties" +) var ( tcConfig Config tcConfigOnce *sync.Once = new(sync.Once) + + // errEmptyValue is returned when the value is empty. Needed when parsing boolean values that are not set. + errEmptyValue = errors.New("empty value") ) // testcontainersConfig { @@ -89,14 +99,69 @@ type Config struct { // } +func applyEnvironmentConfiguration(config Config) (Config, error) { + ryukDisabledEnv := os.Getenv("TESTCONTAINERS_RYUK_DISABLED") + if value, err := parseBool(ryukDisabledEnv); err != nil { + if !errors.Is(err, errEmptyValue) { + return config, fmt.Errorf("invalid TESTCONTAINERS_RYUK_DISABLED environment variable: %s", ryukDisabledEnv) + } + } else { + config.RyukDisabled = value + } + + hubImageNamePrefix := os.Getenv("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX") + if hubImageNamePrefix != "" { + config.HubImageNamePrefix = hubImageNamePrefix + } + + ryukPrivilegedEnv := os.Getenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED") + if value, err := parseBool(ryukPrivilegedEnv); err != nil { + if !errors.Is(err, errEmptyValue) { + return config, fmt.Errorf("invalid TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED environment variable: %s", ryukPrivilegedEnv) + } + } else { + config.RyukPrivileged = value + } + + ryukVerboseEnv := readTestcontainersEnv("RYUK_VERBOSE") + if value, err := parseBool(ryukVerboseEnv); err != nil { + if !errors.Is(err, errEmptyValue) { + return config, fmt.Errorf("invalid RYUK_VERBOSE environment variable: %s", ryukVerboseEnv) + } + } else { + config.RyukVerbose = value + } + + ryukReconnectionTimeoutEnv := readTestcontainersEnv("RYUK_RECONNECTION_TIMEOUT") + if ryukReconnectionTimeoutEnv != "" { + if timeout, err := time.ParseDuration(ryukReconnectionTimeoutEnv); err != nil { + return config, fmt.Errorf("invalid RYUK_RECONNECTION_TIMEOUT environment variable: %w", err) + } else { + config.RyukReconnectionTimeout = timeout + } + } + + ryukConnectionTimeoutEnv := readTestcontainersEnv("RYUK_CONNECTION_TIMEOUT") + if ryukConnectionTimeoutEnv != "" { + if timeout, err := time.ParseDuration(ryukConnectionTimeoutEnv); err != nil { + return config, fmt.Errorf("invalid RYUK_CONNECTION_TIMEOUT environment variable: %w", err) + } else { + config.RyukConnectionTimeout = timeout + } + } + + return config, nil +} + // Read reads from testcontainers properties file, if it exists // it is possible that certain values get overridden when set as environment variables -func Read() Config { +func Read() (Config, error) { + var err error tcConfigOnce.Do(func() { - tcConfig = read() + tcConfig, err = read() }) - return tcConfig + return tcConfig, err } // Reset resets the singleton instance of the Config struct, @@ -107,66 +172,44 @@ func Reset() { tcConfigOnce = new(sync.Once) } -func read() Config { - config := Config{} - - applyEnvironmentConfiguration := func(config Config) Config { - ryukDisabledEnv := os.Getenv("TESTCONTAINERS_RYUK_DISABLED") - if parseBool(ryukDisabledEnv) { - config.RyukDisabled = ryukDisabledEnv == "true" - } - - hubImageNamePrefix := os.Getenv("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX") - if hubImageNamePrefix != "" { - config.HubImageNamePrefix = hubImageNamePrefix - } - - ryukPrivilegedEnv := os.Getenv("TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED") - if parseBool(ryukPrivilegedEnv) { - config.RyukPrivileged = ryukPrivilegedEnv == "true" - } - - ryukVerboseEnv := readTestcontainersEnv("RYUK_VERBOSE") - if parseBool(ryukVerboseEnv) { - config.RyukVerbose = ryukVerboseEnv == "true" - } - - ryukReconnectionTimeoutEnv := readTestcontainersEnv("RYUK_RECONNECTION_TIMEOUT") - if timeout, err := time.ParseDuration(ryukReconnectionTimeoutEnv); err == nil { - config.RyukReconnectionTimeout = timeout - } - - ryukConnectionTimeoutEnv := readTestcontainersEnv("RYUK_CONNECTION_TIMEOUT") - if timeout, err := time.ParseDuration(ryukConnectionTimeoutEnv); err == nil { - config.RyukConnectionTimeout = timeout - } - - return config - } +func read() (Config, error) { + var config 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) + tcProp := filepath.Join(home, testcontainersProperties) + // Init from a file, ignore if it doesn't exist, which is the case for most users. + // The properties library will return the default values for the struct. + props, err := properties.LoadFiles([]string{tcProp}, properties.UTF8, true) 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) + if err := props.Decode(&config); err != nil { + cfg, envErr := applyEnvironmentConfiguration(config) + if envErr != nil { + return cfg, envErr + } + return cfg, fmt.Errorf("invalid testcontainers properties file: %w", err) } return applyEnvironmentConfiguration(config) } -func parseBool(input string) bool { - _, err := strconv.ParseBool(input) - return err == nil +func parseBool(input string) (bool, error) { + if input == "" { + return false, errEmptyValue + } + + value, err := strconv.ParseBool(input) + if err != nil { + return false, fmt.Errorf("invalid boolean value: %w", err) + } + + return value, nil } // readTestcontainersEnv reads the environment variable with the given name. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 591fcff11c..26a01187e2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "dario.cat/mergo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,7 +32,7 @@ func resetTestEnv(t *testing.T) { func TestReadConfig(t *testing.T) { resetTestEnv(t) - t.Run("Config is read just once", func(t *testing.T) { + t.Run("config/read-once", func(t *testing.T) { t.Cleanup(Reset) t.Setenv("HOME", "") @@ -39,7 +40,8 @@ func TestReadConfig(t *testing.T) { t.Setenv("DOCKER_HOST", "") t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") - config := Read() + config, err := Read() + require.NoError(t, err) expected := Config{ RyukDisabled: true, @@ -50,7 +52,8 @@ func TestReadConfig(t *testing.T) { t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "false") - config = Read() + config, err = Read() + require.NoError(t, err) assert.Equal(t, expected, config) }) } @@ -60,18 +63,18 @@ func TestReadTCConfig(t *testing.T) { const defaultHubPrefix string = "registry.mycompany.com/mirror" - t.Run("HOME is not set", func(t *testing.T) { + t.Run("home/unset", func(t *testing.T) { t.Setenv("HOME", "") t.Setenv("USERPROFILE", "") // Windows support - config := read() - + config, err := read() + require.NoError(t, err) expected := Config{} assert.Equal(t, expected, config) }) - t.Run("HOME is not set - TESTCONTAINERS_ env is set", func(t *testing.T) { + t.Run("home/unset/testcontainers-env/set", func(t *testing.T) { t.Setenv("HOME", "") t.Setenv("USERPROFILE", "") // Windows support t.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true") @@ -80,7 +83,8 @@ func TestReadTCConfig(t *testing.T) { t.Setenv("RYUK_RECONNECTION_TIMEOUT", "13s") t.Setenv("RYUK_CONNECTION_TIMEOUT", "12s") - config := read() + config, err := read() + require.NoError(t, err) expected := Config{ HubImageNamePrefix: defaultHubPrefix, @@ -94,31 +98,44 @@ func TestReadTCConfig(t *testing.T) { assert.Equal(t, expected, config) }) - t.Run("HOME does not contain TC props file", func(t *testing.T) { + t.Run("home/set/no-testcontainers-props-file", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) t.Setenv("USERPROFILE", tmpDir) // Windows support - config := read() + config, err := read() + require.NoError(t, err) - expected := Config{} + // The time fields are set to the default values. + expected := Config{ + RyukReconnectionTimeout: 10 * time.Second, + RyukConnectionTimeout: time.Minute, + } assert.Equal(t, expected, config) }) - t.Run("HOME does not contain TC props file - DOCKER_HOST env is set", func(t *testing.T) { + t.Run("home/set/no-testcontainers-props-file/docker-host-env/set", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) t.Setenv("USERPROFILE", tmpDir) // Windows support t.Setenv("DOCKER_HOST", tcpDockerHost33293) - config := read() - expected := Config{} // the config does not read DOCKER_HOST, that's why it's empty + config, err := read() + require.NoError(t, err) + + // The time fields are set to the default values, + // and the config does not read DOCKER_HOST, + // that's why it's empty + expected := Config{ + RyukReconnectionTimeout: 10 * time.Second, + RyukConnectionTimeout: time.Minute, + } assert.Equal(t, expected, config) }) - t.Run("HOME does not contain TC props file - TESTCONTAINERS_ env is set", func(t *testing.T) { + t.Run("home/set/no-testcontainers-props-file/testcontainers-ev/set", func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) t.Setenv("USERPROFILE", tmpDir) // Windows support @@ -129,7 +146,9 @@ func TestReadTCConfig(t *testing.T) { t.Setenv("RYUK_RECONNECTION_TIMEOUT", "13s") t.Setenv("RYUK_CONNECTION_TIMEOUT", "12s") - config := read() + config, err := read() + require.NoError(t, err) + expected := Config{ HubImageNamePrefix: defaultHubPrefix, RyukDisabled: true, @@ -142,10 +161,10 @@ func TestReadTCConfig(t *testing.T) { assert.Equal(t, expected, config) }) - t.Run("HOME contains TC properties file", func(t *testing.T) { + t.Run("home/set/with-testcontainers-properties-file", func(t *testing.T) { defaultRyukConnectionTimeout := 60 * time.Second defaultRyukReconnectionTimeout := 10 * time.Second - defaultConfig := Config{ + defaultCfg := Config{ RyukConnectionTimeout: defaultRyukConnectionTimeout, RyukReconnectionTimeout: defaultRyukReconnectionTimeout, } @@ -155,365 +174,334 @@ func TestReadTCConfig(t *testing.T) { content string env map[string]string expected Config + wantErr bool }{ { - "Single Docker host with spaces", - "docker.host = " + tcpDockerHost33293, - map[string]string{}, - Config{ - Host: tcpDockerHost33293, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + name: "single-docker-host/with-spaces", + content: "docker.host = " + tcpDockerHost33293, + env: map[string]string{}, + expected: Config{ + Host: tcpDockerHost33293, }, }, { - "Multiple docker host entries, last one wins", - `docker.host = ` + tcpDockerHost33293 + ` + name: "multiple-docker-hosts/last-one-wins", + content: `docker.host = ` + tcpDockerHost33293 + ` docker.host = ` + tcpDockerHost4711 + ` `, - map[string]string{}, - Config{ - Host: tcpDockerHost4711, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + env: map[string]string{}, + expected: Config{ + Host: tcpDockerHost4711, }, }, { - "Multiple docker host entries, last one wins, with TLS", - `docker.host = ` + tcpDockerHost33293 + ` + name: "multiple-docker-hosts/last-one-wins/with-tls", + content: `docker.host = ` + tcpDockerHost33293 + ` docker.host = ` + tcpDockerHost4711 + ` docker.host = ` + tcpDockerHost1234 + ` docker.tls.verify = 1 `, - map[string]string{}, - Config{ - Host: tcpDockerHost1234, - TLSVerify: 1, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + env: map[string]string{}, + expected: Config{ + Host: tcpDockerHost1234, + TLSVerify: 1, }, }, { - "Empty file", - "", - map[string]string{}, - Config{ - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, - }, + name: "properties/empty", + content: "", + env: map[string]string{}, + expected: Config{}, + wantErr: false, }, { - "Non-valid properties are ignored", - `foo = bar + name: "non-valid-properties-are-ignored", + content: `foo = bar docker.host = ` + tcpDockerHost1234 + ` `, - map[string]string{}, - Config{ - Host: tcpDockerHost1234, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + env: map[string]string{}, + expected: Config{ + Host: tcpDockerHost1234, }, }, { - "Single Docker host without spaces", - "docker.host=" + tcpDockerHost33293, - map[string]string{}, - Config{ - Host: tcpDockerHost33293, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + name: "single-docker-host/without-spaces", + content: "docker.host=" + tcpDockerHost33293, + env: map[string]string{}, + expected: Config{ + Host: tcpDockerHost33293, }, }, { - "Comments are ignored", - `#docker.host=` + tcpDockerHost33293, - map[string]string{}, - defaultConfig, + name: "comments-are-ignored", + content: `#docker.host=` + tcpDockerHost33293, + env: map[string]string{}, + expected: defaultCfg, + wantErr: false, }, { - "Multiple docker host entries, last one wins, with TLS and cert path", - `#docker.host = ` + tcpDockerHost33293 + ` + name: "multiple-docker-hosts/last-one-wins/with-tls/cert-path", + content: `#docker.host = ` + tcpDockerHost33293 + ` docker.host = ` + tcpDockerHost4711 + ` docker.host = ` + tcpDockerHost1234 + ` docker.cert.path=/tmp/certs`, - map[string]string{}, - Config{ - Host: tcpDockerHost1234, - CertPath: "/tmp/certs", - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + env: map[string]string{}, + expected: Config{ + Host: tcpDockerHost1234, + CertPath: "/tmp/certs", }, }, { - "With Ryuk disabled using properties", - `ryuk.disabled=true`, - map[string]string{}, - Config{ - RyukDisabled: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + name: "using-properties/ryuk-disabled", + content: `ryuk.disabled=true`, + env: map[string]string{}, + expected: Config{ + RyukDisabled: true, }, }, { - "With Ryuk container privileged using properties", - `ryuk.container.privileged=true`, - map[string]string{}, - Config{ - RyukPrivileged: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + name: "properties/ryuk-container-privileged", + content: `ryuk.container.privileged=true`, + env: map[string]string{}, + expected: Config{ + RyukPrivileged: true, }, }, { - "With Ryuk container timeouts configured using properties", - `ryuk.connection.timeout=12s + name: "properties/ryuk-container-timeouts", + content: `ryuk.connection.timeout=12s ryuk.reconnection.timeout=13s`, - map[string]string{}, - Config{ + env: map[string]string{}, + expected: Config{ RyukReconnectionTimeout: 13 * time.Second, RyukConnectionTimeout: 12 * time.Second, }, }, { - "With Ryuk container timeouts configured using env vars", - ``, - map[string]string{ + name: "env-vars/ryuk-container-timeouts", + content: ``, + env: map[string]string{ "RYUK_RECONNECTION_TIMEOUT": "13s", "RYUK_CONNECTION_TIMEOUT": "12s", }, - Config{ + expected: Config{ RyukReconnectionTimeout: 13 * time.Second, RyukConnectionTimeout: 12 * time.Second, }, }, { - "With Ryuk container timeouts configured using env vars and properties. Env var wins", - `ryuk.connection.timeout=22s + name: "env-vars/ryuk-container-timeouts/env-var-wins", + content: `ryuk.connection.timeout=22s ryuk.reconnection.timeout=23s`, - map[string]string{ + env: map[string]string{ "RYUK_RECONNECTION_TIMEOUT": "13s", "RYUK_CONNECTION_TIMEOUT": "12s", }, - Config{ + expected: Config{ RyukReconnectionTimeout: 13 * time.Second, RyukConnectionTimeout: 12 * time.Second, }, }, { - "With Ryuk verbose configured using properties", - `ryuk.verbose=true`, - map[string]string{}, - Config{ - RyukVerbose: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + name: "properties/ryuk-verbose", + content: `ryuk.verbose=true`, + env: map[string]string{}, + expected: Config{ + RyukVerbose: true, }, }, { - "With Ryuk disabled using an env var", - ``, - map[string]string{ + name: "env-vars/ryuk-disabled", + content: ``, + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "true", }, - Config{ - RyukDisabled: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukDisabled: true, }, }, { - "With Ryuk container privileged using an env var", - ``, - map[string]string{ + name: "env-vars/ryuk-container-privileged", + content: ``, + env: map[string]string{ "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", }, - Config{ - RyukPrivileged: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukPrivileged: true, }, }, { - "With Ryuk disabled using an env var and properties. Env var wins (0)", - `ryuk.disabled=true`, - map[string]string{ + name: "env-vars/properties/ryuk-disabled/env-var-wins-0", + content: `ryuk.disabled=true`, + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "true", }, - Config{ - RyukDisabled: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukDisabled: true, }, }, { - "With Ryuk disabled using an env var and properties. Env var wins (1)", - `ryuk.disabled=false`, - map[string]string{ + name: "env-vars/properties/ryuk-disabled/env-var-wins-1", + content: `ryuk.disabled=false`, + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "true", }, - Config{ - RyukDisabled: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukDisabled: true, }, }, { - "With Ryuk disabled using an env var and properties. Env var wins (2)", - `ryuk.disabled=true`, - map[string]string{ + name: "env-vars/properties/ryuk-disabled/env-var-wins-2", + content: `ryuk.disabled=true`, + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "false", }, - defaultConfig, + expected: defaultCfg, + wantErr: false, }, { - "With Ryuk disabled using an env var and properties. Env var wins (3)", - `ryuk.disabled=false`, - map[string]string{ + name: "env-vars/properties/ryuk-disabled/env-var-wins-3", + content: `ryuk.disabled=false`, + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "false", }, - defaultConfig, + expected: defaultCfg, + wantErr: false, }, { - "With Ryuk verbose using an env var and properties. Env var wins (0)", - `ryuk.verbose=true`, - map[string]string{ + name: "env-vars/properties/ryuk-verbose/env-var-wins-0", + content: `ryuk.verbose=true`, + env: map[string]string{ "RYUK_VERBOSE": "true", }, - Config{ - RyukVerbose: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukVerbose: true, }, }, { - "With Ryuk verbose using an env var and properties. Env var wins (1)", - `ryuk.verbose=false`, - map[string]string{ + name: "env-vars/properties/ryuk-verbose/env-var-wins-1", + content: `ryuk.verbose=false`, + env: map[string]string{ "RYUK_VERBOSE": "true", }, - Config{ - RyukVerbose: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukVerbose: true, }, }, { - "With Ryuk verbose using an env var and properties. Env var wins (2)", - `ryuk.verbose=true`, - map[string]string{ + name: "env-vars/properties/ryuk-verbose/env-var-wins-2", + content: `ryuk.verbose=true`, + env: map[string]string{ "RYUK_VERBOSE": "false", }, - defaultConfig, + expected: defaultCfg, + wantErr: false, }, { - "With Ryuk verbose using an env var and properties. Env var wins (3)", - `ryuk.verbose=false`, - map[string]string{ + name: "env-vars/properties/ryuk-verbose/env-var-wins-3", + content: `ryuk.verbose=false`, + env: map[string]string{ "RYUK_VERBOSE": "false", }, - defaultConfig, + expected: defaultCfg, + wantErr: false, }, { - "With Ryuk container privileged using an env var and properties. Env var wins (0)", - `ryuk.container.privileged=true`, - map[string]string{ + name: "env-vars/properties/ryuk-container-privileged/env-var-wins-0", + content: `ryuk.container.privileged=true`, + env: map[string]string{ "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", }, - Config{ - RyukPrivileged: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukPrivileged: true, }, }, { - "With Ryuk container privileged using an env var and properties. Env var wins (1)", - `ryuk.container.privileged=false`, - map[string]string{ + name: "env-vars/properties/ryuk-container-privileged/env-var-wins-1", + content: `ryuk.container.privileged=false`, + env: map[string]string{ "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", }, - Config{ - RyukPrivileged: true, - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + RyukPrivileged: true, }, }, { - "With Ryuk container privileged using an env var and properties. Env var wins (2)", - `ryuk.container.privileged=true`, - map[string]string{ + name: "env-vars/properties/ryuk-container-privileged/env-var-wins-2", + content: `ryuk.container.privileged=true`, + env: map[string]string{ "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "false", }, - defaultConfig, + expected: defaultCfg, + wantErr: false, }, { - "With Ryuk container privileged using an env var and properties. Env var wins (3)", - `ryuk.container.privileged=false`, - map[string]string{ + name: "env-vars/properties/ryuk-container-privileged/env-var-wins-3", + content: `ryuk.container.privileged=false`, + env: map[string]string{ "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "false", }, - defaultConfig, + expected: defaultCfg, + wantErr: false, }, { - "With TLS verify using properties when value is wrong", - `ryuk.container.privileged=false + name: "properties/tls-verify/wrong-value", + content: `ryuk.container.privileged=false docker.tls.verify = ERROR`, - map[string]string{ + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "true", "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "true", }, - Config{ + expected: Config{ RyukDisabled: true, RyukPrivileged: true, }, + wantErr: 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{ + name: "env-vars/properties/ryuk-disabled/env-var-does-not-win/wrong-boolean", + content: `ryuk.disabled=false`, + env: map[string]string{ "TESTCONTAINERS_RYUK_DISABLED": "foo", }, - defaultConfig, + expected: defaultCfg, + wantErr: true, }, { - "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{ + name: "env-vars/properties/ryuk-container-privileged/env-var-does-not-win/wrong-boolean", + content: `ryuk.container.privileged=false`, + env: map[string]string{ "TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED": "foo", }, - defaultConfig, + expected: defaultCfg, + wantErr: true, }, { - "With Hub image name prefix set as a property", - `hub.image.name.prefix=` + defaultHubPrefix + `/props/`, - map[string]string{}, - Config{ - HubImageNamePrefix: defaultHubPrefix + "/props/", - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + name: "properties/hub-image-name-prefix", + content: `hub.image.name.prefix=` + defaultHubPrefix + `/props/`, + env: map[string]string{}, + expected: Config{ + HubImageNamePrefix: defaultHubPrefix + "/props/", }, }, { - "With Hub image name prefix set as env var", - ``, - map[string]string{ + name: "env-vars/hub-image-name-prefix", + content: ``, + env: map[string]string{ "TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX": defaultHubPrefix + "/env/", }, - Config{ - HubImageNamePrefix: defaultHubPrefix + "/env/", - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + HubImageNamePrefix: defaultHubPrefix + "/env/", }, }, { - "With Hub image name prefix set as env var and properties: Env var wins", - `hub.image.name.prefix=` + defaultHubPrefix + `/props/`, - map[string]string{ + name: "env-vars/properties/hub-image-name-prefix/env-var-wins", + content: `hub.image.name.prefix=` + defaultHubPrefix + `/props/`, + env: map[string]string{ "TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX": defaultHubPrefix + "/env/", }, - Config{ - HubImageNamePrefix: defaultHubPrefix + "/env/", - RyukConnectionTimeout: defaultRyukConnectionTimeout, - RyukReconnectionTimeout: defaultRyukReconnectionTimeout, + expected: Config{ + HubImageNamePrefix: defaultHubPrefix + "/env/", }, }, } @@ -528,10 +516,25 @@ func TestReadTCConfig(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, ".testcontainers.properties"), []byte(tt.content), 0o600) require.NoErrorf(t, err, "Failed to create the file") - // - config := read() + config, err := read() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // Merge the returned config, and the expected one, with the default config + // to avoid setting all the fields in the expected config. + // In the case of decoding errors in the properties file, the read config + // needs to be merged with the default config to avoid setting the fields + // that are not set in the properties file. + err = mergo.Merge(&config, defaultCfg) + require.NoError(t, err) + + err = mergo.Merge(&tt.expected, defaultCfg) + require.NoError(t, err) - assert.Equal(t, tt.expected, config, "Configuration doesn't not match") + require.Equal(t, tt.expected, config, "Configuration doesn't not match") }) } }) diff --git a/internal/core/client.go b/internal/core/client.go index 04a54bcbc5..3a9737db4f 100644 --- a/internal/core/client.go +++ b/internal/core/client.go @@ -2,6 +2,7 @@ package core import ( "context" + "fmt" "path/filepath" "github.com/docker/docker/client" @@ -12,7 +13,10 @@ import ( // 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() + tcConfig, err := config.Read() + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } dockerHost := MustExtractDockerHost(ctx) diff --git a/internal/core/docker_config.go b/internal/core/docker_config.go new file mode 100644 index 0000000000..2b352701d2 --- /dev/null +++ b/internal/core/docker_config.go @@ -0,0 +1,31 @@ +package core + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/cpuguy83/dockercfg" +) + +// DockerConfig returns the docker config file. It will internally check, in this particular order: +// 1. the DOCKER_AUTH_CONFIG environment variable, unmarshalling it into a dockercfg.Config +// 2. the DOCKER_CONFIG environment variable, as the path to the config file +// 3. else it will load the default config file, which is ~/.docker/config.json +func DockerConfig() (*dockercfg.Config, error) { + if env := os.Getenv("DOCKER_AUTH_CONFIG"); env != "" { + var cfg dockercfg.Config + if err := json.Unmarshal([]byte(env), &cfg); err != nil { + return nil, fmt.Errorf("unmarshal DOCKER_AUTH_CONFIG: %w", err) + } + + return &cfg, nil + } + + cfg, err := dockercfg.LoadDefaultConfig() + if err != nil { + return nil, fmt.Errorf("load default config: %w", err) + } + + return &cfg, nil +} diff --git a/internal/core/docker_config_test.go b/internal/core/docker_config_test.go new file mode 100644 index 0000000000..b8aba2b54f --- /dev/null +++ b/internal/core/docker_config_test.go @@ -0,0 +1,96 @@ +package core + +import ( + _ "embed" + "os" + "path/filepath" + "testing" + + "github.com/cpuguy83/dockercfg" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/.docker/config.json +var dockerConfig string + +func TestReadDockerConfig(t *testing.T) { + expectedConfig := &dockercfg.Config{ + AuthConfigs: map[string]dockercfg.AuthConfig{ + IndexDockerIO: {}, + "https://example.com": {}, + "https://my.private.registry": {}, + }, + CredentialsStore: "desktop", + } + t.Run("HOME/valid", func(t *testing.T) { + testDockerConfigHome(t, "testdata") + + cfg, err := DockerConfig() + require.NoError(t, err) + require.Equal(t, expectedConfig, cfg) + }) + + t.Run("HOME/not-found", func(t *testing.T) { + testDockerConfigHome(t, "testdata", "not-found") + + cfg, err := DockerConfig() + require.ErrorIs(t, err, os.ErrNotExist) + require.Nil(t, cfg) + }) + + t.Run("HOME/invalid-config", func(t *testing.T) { + testDockerConfigHome(t, "testdata", "invalid-config") + + cfg, err := DockerConfig() + require.ErrorContains(t, err, "json: cannot unmarshal array") + require.Nil(t, cfg) + }) + + t.Run("DOCKER_AUTH_CONFIG/valid", func(t *testing.T) { + testDockerConfigHome(t, "testdata", "not-found") + t.Setenv("DOCKER_AUTH_CONFIG", dockerConfig) + + cfg, err := DockerConfig() + require.NoError(t, err) + require.Equal(t, expectedConfig, cfg) + }) + + t.Run("DOCKER_AUTH_CONFIG/invalid-config", func(t *testing.T) { + testDockerConfigHome(t, "testdata", "not-found") + t.Setenv("DOCKER_AUTH_CONFIG", `{"auths": []}`) + + cfg, err := DockerConfig() + require.ErrorContains(t, err, "json: cannot unmarshal array") + require.Nil(t, cfg) + }) + + t.Run("DOCKER_CONFIG/valid", func(t *testing.T) { + testDockerConfigHome(t, "testdata", "not-found") + t.Setenv("DOCKER_CONFIG", filepath.Join("testdata", ".docker")) + + cfg, err := DockerConfig() + require.NoError(t, err) + require.Equal(t, expectedConfig, cfg) + }) + + t.Run("DOCKER_CONFIG/invalid-config", func(t *testing.T) { + testDockerConfigHome(t, "testdata", "not-found") + t.Setenv("DOCKER_CONFIG", filepath.Join("testdata", "invalid-config", ".docker")) + + cfg, err := DockerConfig() + require.ErrorContains(t, err, "json: cannot unmarshal array") + require.Nil(t, cfg) + }) +} + +// testDockerConfigHome sets the user's home directory to the given path +// and unsets the DOCKER_CONFIG and DOCKER_AUTH_CONFIG environment variables. +func testDockerConfigHome(t *testing.T, dirs ...string) { + t.Helper() + + dir := filepath.Join(dirs...) + t.Setenv("DOCKER_AUTH_CONFIG", "") + t.Setenv("DOCKER_CONFIG", "") + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Windows +} diff --git a/internal/core/docker_context.go b/internal/core/docker_context.go new file mode 100644 index 0000000000..bddde30d82 --- /dev/null +++ b/internal/core/docker_context.go @@ -0,0 +1,338 @@ +package core + +// The code in this file has been extracted from https://github.com/docker/cli, +// more especifically from https://github.com/docker/cli/blob/master/cli/context/store/metadatastore.go +// with the goal of not consuming the CLI package and all its dependencies. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "reflect" + "runtime" + + "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" +) + +const ( + // defaultContextName is the name reserved for the default context (config & env based) + defaultContextName = "default" + + // envOverrideContext is the name of the environment variable that can be + // used to override the context to use. If set, it overrides the context + // that's set in the CLI's configuration file, but takes no effect if the + // "DOCKER_HOST" env-var is set (which takes precedence. + envOverrideContext = "DOCKER_CONTEXT" + + // envOverrideConfigDir is the name of the environment variable that can be + // used to override the location of the client configuration files (~/.docker). + // + // It takes priority over the default. + envOverrideConfigDir = "DOCKER_CONFIG" + + // configFileName is the name of the client configuration file inside the + // config-directory. + configFileName = "config.json" + configFileDir = ".docker" + contextsDir = "contexts" + metadataDir = "meta" + metaFile = "meta.json" + + // DockerEndpoint is the name of the docker endpoint in a stored context + dockerEndpoint string = "docker" +) + +// dockerContext is a typed representation of what we put in Context metadata +type dockerContext struct { + Description string + AdditionalFields map[string]any +} + +type metadataStore struct { + root string + config contextConfig +} + +// typeGetter is a func used to determine the concrete type of a context or +// endpoint metadata by returning a pointer to an instance of the object +// eg: for a context of type DockerContext, the corresponding typeGetter should return new(DockerContext) +type typeGetter func() any + +// namedTypeGetter is a typeGetter associated with a name +type namedTypeGetter struct { + name string + typeGetter typeGetter +} + +// endpointTypeGetter returns a namedTypeGetter with the specified name and getter +func endpointTypeGetter(name string, getter typeGetter) namedTypeGetter { + return namedTypeGetter{ + name: name, + typeGetter: getter, + } +} + +// endpointMeta contains fields we expect to be common for most context endpoints +type endpointMeta struct { + Host string `json:",omitempty"` + SkipTLSVerify bool +} + +var defaultStoreEndpoints = []namedTypeGetter{ + endpointTypeGetter(dockerEndpoint, func() any { return &endpointMeta{} }), +} + +// contextConfig is used to configure the metadata marshaler of the context ContextStore +type contextConfig struct { + contextType typeGetter + endpointTypes map[string]typeGetter +} + +// newConfig creates a config object +func newConfig(contextType typeGetter, endpoints ...namedTypeGetter) contextConfig { + res := contextConfig{ + contextType: contextType, + endpointTypes: make(map[string]typeGetter), + } + + for _, e := range endpoints { + res.endpointTypes[e.name] = e.typeGetter + } + return res +} + +// metadata contains metadata about a context and its endpoints +type metadata struct { + Name string `json:",omitempty"` + Metadata any `json:",omitempty"` + Endpoints map[string]any `json:",omitempty"` +} + +func (s *metadataStore) contextDir(id contextdir) string { + return filepath.Join(s.root, string(id)) +} + +type untypedContextMetadata struct { + Metadata json.RawMessage `json:"metadata,omitempty"` + Endpoints map[string]json.RawMessage `json:"endpoints,omitempty"` + Name string `json:"name,omitempty"` +} + +// getByID returns the metadata for a context by its ID +func (s *metadataStore) getByID(id contextdir) (metadata, error) { + fileName := filepath.Join(s.contextDir(id), metaFile) + bytes, err := os.ReadFile(fileName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return metadata{}, errdefs.NotFound(fmt.Errorf("context not found: %w", err)) + } + return metadata{}, err + } + + var untyped untypedContextMetadata + r := metadata{ + Endpoints: make(map[string]any), + } + + if err := json.Unmarshal(bytes, &untyped); err != nil { + return metadata{}, fmt.Errorf("parsing %s (metadata): %w", fileName, err) + } + + r.Name = untyped.Name + if r.Metadata, err = parseTypedOrMap(untyped.Metadata, s.config.contextType); err != nil { + return metadata{}, fmt.Errorf("parsing %s (context type): %w", fileName, err) + } + + for k, v := range untyped.Endpoints { + if r.Endpoints[k], err = parseTypedOrMap(v, s.config.endpointTypes[k]); err != nil { + return metadata{}, fmt.Errorf("parsing %s (endpoint types): %w", fileName, err) + } + } + + return r, err +} + +// list returns a list of all Docker contexts +func (s *metadataStore) list() ([]metadata, error) { + ctxDirs, err := listRecursivelyMetadataDirs(s.root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + res := make([]metadata, 0, len(ctxDirs)) + for _, dir := range ctxDirs { + c, err := s.getByID(contextdir(dir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("read metadata: %w", err) + } + res = append(res, c) + } + + return res, nil +} + +// contextdir is a type used to represent a context directory +type contextdir string + +// isContextDir checks if the given path is a context directory, +// which means it contains a meta.json file. +func isContextDir(path string) bool { + s, err := os.Stat(filepath.Join(path, metaFile)) + if err != nil { + return false + } + + return !s.IsDir() +} + +// listRecursivelyMetadataDirs lists all directories that contain a meta.json file +func listRecursivelyMetadataDirs(root string) ([]string, error) { + fileEntries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + + var result []string + for _, fileEntry := range fileEntries { + if fileEntry.IsDir() { + if isContextDir(filepath.Join(root, fileEntry.Name())) { + result = append(result, fileEntry.Name()) + } + + subs, err := listRecursivelyMetadataDirs(filepath.Join(root, fileEntry.Name())) + if err != nil { + return nil, err + } + + for _, s := range subs { + result = append(result, filepath.Join(fileEntry.Name(), s)) + } + } + } + + return result, nil +} + +// parseTypedOrMap parses a JSON payload into a typed object or a map +func parseTypedOrMap(payload []byte, getter typeGetter) (any, error) { + if len(payload) == 0 || string(payload) == "null" { + return nil, nil + } + + if getter == nil { + var res map[string]any + if err := json.Unmarshal(payload, &res); err != nil { + return nil, err + } + return res, nil + } + + typed := getter() + if err := json.Unmarshal(payload, typed); err != nil { + return nil, err + } + + return reflect.ValueOf(typed).Elem().Interface(), nil +} + +// getHomeDir returns the home directory of the current user with the help of +// environment variables depending on the target operating system. +// Returned path should be used with "path/filepath" to form new paths. +// +// On non-Windows platforms, it falls back to nss lookups, if the home +// directory cannot be obtained from environment-variables. +// +// If linking statically with cgo enabled against glibc, ensure the +// osusergo build tag is used. +// +// If needing to do nss lookups, do not disable cgo or set osusergo. +// +// getHomeDir is a copy of [pkg/homedir.Get] to prevent adding docker/docker +// as dependency for consumers that only need to read the config-file. +// +// [pkg/homedir.Get]: https://pkg.go.dev/github.com/docker/docker@v26.1.4+incompatible/pkg/homedir#Get +func getHomeDir() string { + home, _ := os.UserHomeDir() + if home == "" && runtime.GOOS != "windows" { + if u, err := user.Current(); err == nil { + return u.HomeDir + } + } + return home +} + +// configurationDir returns the directory the configuration file is stored in +func configurationDir() string { + configDir := os.Getenv(envOverrideConfigDir) + if configDir == "" { + return filepath.Join(getHomeDir(), configFileDir) + } + + return configDir +} + +// GetDockerHostFromCurrentContext returns the Docker host from the current Docker context. +// For that, it traverses the directory structure of the Docker configuration directory, +// looking for the current context and its Docker endpoint. +func GetDockerHostFromCurrentContext() (string, error) { + metaRoot := filepath.Join(filepath.Join(configurationDir(), contextsDir), metadataDir) + + ms := &metadataStore{ + root: metaRoot, + config: newConfig(func() any { return &dockerContext{} }, defaultStoreEndpoints...), + } + + md, err := ms.list() + if err != nil { + return "", err + } + + currentContext := currentContext() + + for _, m := range md { + if m.Name == currentContext { + ep, ok := m.Endpoints[dockerEndpoint].(endpointMeta) + if ok { + return ep.Host, nil + } + } + } + + return "", errDockerSocketNotSetInDockerContext +} + +// currentContext returns the current context name, based on +// environment variables and the cli configuration file. It does not +// validate if the given context exists or if it's valid; errors may +// occur when trying to use it. +func currentContext() string { + cfg, err := DockerConfig() + if err != nil { + return defaultContextName + } + + if os.Getenv(client.EnvOverrideHost) != "" { + return defaultContextName + } + + if ctxName := os.Getenv(envOverrideContext); ctxName != "" { + return ctxName + } + + if cfg.CurrentContext != "" { + // We don't validate if this context exists: errors may occur when trying to use it. + return cfg.CurrentContext + } + + return defaultContextName +} diff --git a/internal/core/docker_context_test.go b/internal/core/docker_context_test.go new file mode 100644 index 0000000000..a294bd020f --- /dev/null +++ b/internal/core/docker_context_test.go @@ -0,0 +1,57 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// setupDockerContexts creates a temporary directory structure for testing the Docker context functions. +// It creates the following structure, where $i is the index of the context, starting from 1: +// - $HOME/.docker +// - config.json +// - contexts +// - meta +// - context$i +// - meta.json +// +// The config.json file contains the current context, and the meta.json files contain the metadata for each context. +// It generates the specified number of contexts, setting the current context to the one specified by currentContextIndex. +func setupDockerContexts(t *testing.T, currentContextIndex int, contextsCount int) { + t.Helper() + + configDir := filepath.Join(getHomeDir(), configFileDir) + + err := createTmpDir(configDir) + require.NoError(t, err) + + configJson := filepath.Join(configDir, "config.json") + + const baseContext = "context" + + configBytes := fmt.Sprintf(`{ + "currentContext": "%s%d" +}`, baseContext, currentContextIndex) + + err = os.WriteFile(configJson, []byte(configBytes), 0o644) + require.NoError(t, err) + + metaDir := filepath.Join(configDir, contextsDir, metadataDir) + + err = createTmpDir(metaDir) + require.NoError(t, err) + + // first index is 1 + for i := 1; i <= contextsCount; i++ { + contextDir := filepath.Join(metaDir, fmt.Sprintf("context%d", i)) + err = createTmpDir(contextDir) + require.NoError(t, err) + + context := fmt.Sprintf(`{"Name":"%s%d","Metadata":{"Description":"Testcontainers Go %d"},"Endpoints":{"docker":{"Host":"tcp://127.0.0.1:%d","SkipTLSVerify":false}}}`, baseContext, i, i, i) + err = os.WriteFile(filepath.Join(contextDir, "meta.json"), []byte(context), 0o644) + require.NoError(t, err) + } +} diff --git a/internal/core/docker_host.go b/internal/core/docker_host.go index 3088a3742b..d9a3aa1159 100644 --- a/internal/core/docker_host.go +++ b/internal/core/docker_host.go @@ -19,15 +19,17 @@ 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") + 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 Go context") + errDockerSocketNotSetInDockerContext = errors.New("socket not set in Docker 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 ( @@ -79,11 +81,12 @@ var dockerHostCheck = func(ctx context.Context, host string) error { // // 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, because the Docker host is not set, it panics. +// 3. Docker host from Go context. +// 4. Docker host from the current Docker context, without the unix schema. +// 5. Docker host from the default docker socket path, without the unix schema. +// 6. Docker host from the "docker.host" property in the ~/.testcontainers.properties file. +// 7. Rootless docker socket path. +// 8. Else, because the Docker host is not set, it panics. func MustExtractDockerHost(ctx context.Context) string { dockerHostOnce.Do(func() { cache, err := extractDockerHost(ctx) @@ -125,6 +128,9 @@ func extractDockerHost(ctx context.Context) (string, error) { testcontainersHostFromProperties, dockerHostFromEnv, dockerHostFromContext, + func(_ context.Context) (string, error) { + return GetDockerHostFromCurrentContext() + }, dockerSocketPath, dockerHostFromProperties, rootlessDockerSocketPath, @@ -152,7 +158,7 @@ func extractDockerHost(ctx context.Context) (string, error) { return "", errors.Join(errs...) } - return "", ErrSocketNotFound + return "", errSocketNotFound } // extractDockerSocket Extracts the docker socket from the different alternatives, without caching the result. @@ -169,33 +175,38 @@ func extractDockerSocket(ctx context.Context) string { return extractDockerSocketFromClient(ctx, cli) } +// parseDockerSocket satinitises the docker socket path, removing the TCP schema if present, +// or the unix schema if present, returning the path without the schema. +func parseDockerSocket(socket string) string { + // this use case will cover the case when the docker host is a tcp socket + if strings.HasPrefix(socket, TCPSchema) { + return DockerSocketPath + } + + if strings.HasPrefix(socket, DockerSocketSchema) { + return strings.Replace(socket, DockerSocketSchema, "", 1) + } + + return socket +} + // 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. // It panics if the Docker Info call errors, or the Docker host is not discovered. func extractDockerSocketFromClient(ctx context.Context, cli client.APIClient) string { - // check that the socket is not a tcp or unix socket - checkDockerSocketFn := func(socket string) string { - // this use case will cover the case when the docker host is a tcp socket - if strings.HasPrefix(socket, TCPSchema) { - return DockerSocketPath - } - - if strings.HasPrefix(socket, DockerSocketSchema) { - return strings.Replace(socket, DockerSocketSchema, "", 1) - } - - return socket - } - tcHost, err := testcontainersHostFromProperties(ctx) if err == nil { - return checkDockerSocketFn(tcHost) + if err = dockerHostCheck(ctx, tcHost); err == nil { + return parseDockerSocket(tcHost) + } } testcontainersDockerSocket, err := dockerSocketOverridePath() if err == nil { - return checkDockerSocketFn(testcontainersDockerSocket) + if err = dockerHostCheck(ctx, testcontainersDockerSocket); err == nil { + return parseDockerSocket(testcontainersDockerSocket) + } } info, err := cli.Info(ctx) @@ -217,18 +228,18 @@ func extractDockerSocketFromClient(ctx context.Context, cli client.APIClient) st panic(err) // Docker host is required to get the Docker socket } - return checkDockerSocketFn(dockerHost) + return parseDockerSocket(dockerHost) } // isHostNotSet returns true if the error is related to the Docker host // not being set, false otherwise. func isHostNotSet(err error) bool { switch { - case errors.Is(err, ErrTestcontainersHostNotSetInProperties), - errors.Is(err, ErrDockerHostNotSet), - errors.Is(err, ErrDockerSocketNotSetInContext), - errors.Is(err, ErrDockerSocketNotSetInProperties), - errors.Is(err, ErrSocketNotFoundInPath), + case errors.Is(err, errTestcontainersHostNotSetInProperties), + errors.Is(err, errDockerHostNotSet), + errors.Is(err, errDockerSocketNotSetInContext), + errors.Is(err, errDockerSocketNotSetInProperties), + errors.Is(err, errSocketNotFoundInPath), errors.Is(err, ErrXDGRuntimeDirNotSet), errors.Is(err, ErrRootlessDockerNotFoundHomeRunDir), errors.Is(err, ErrRootlessDockerNotFoundHomeDesktopDir), @@ -245,7 +256,7 @@ func dockerHostFromEnv(ctx context.Context) (string, error) { return dockerHostPath, nil } - return "", ErrDockerHostNotSet + return "", errDockerHostNotSet } // dockerHostFromContext returns the docker host from the Go context, if it's not empty @@ -259,18 +270,22 @@ func dockerHostFromContext(ctx context.Context) (string, error) { return parsed, nil } - return "", ErrDockerSocketNotSetInContext + 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() + cfg, err := config.Read() + if err != nil { + return "", fmt.Errorf("read config: %w", err) + } + socketPath := cfg.Host if socketPath != "" { return socketPath, nil } - return "", ErrDockerSocketNotSetInProperties + return "", errDockerSocketNotSetInProperties } // dockerSocketOverridePath returns the docker socket from the TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE environment variable, @@ -280,7 +295,7 @@ func dockerSocketOverridePath() (string, error) { return dockerHostPath, nil } - return "", ErrDockerSocketOverrideNotSet + return "", errDockerSocketOverrideNotSet } // dockerSocketPath returns the docker socket from the default docker socket path, if it's not empty @@ -290,12 +305,16 @@ func dockerSocketPath(ctx context.Context) (string, error) { return DockerSocketPathWithSchema, nil } - return "", ErrSocketNotFoundInPath + 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() + cfg, err := config.Read() + if err != nil { + return "", fmt.Errorf("read config: %w", err) + } + testcontainersHost := cfg.TestcontainersHost if testcontainersHost != "" { parsed, err := parseURL(testcontainersHost) @@ -306,7 +325,7 @@ func testcontainersHostFromProperties(ctx context.Context) (string, error) { return parsed, nil } - return "", ErrTestcontainersHostNotSetInProperties + return "", errTestcontainersHostNotSetInProperties } // InAContainer returns true if the code is running inside a container diff --git a/internal/core/docker_host_test.go b/internal/core/docker_host_test.go index 6faac45776..7d3a571de8 100644 --- a/internal/core/docker_host_test.go +++ b/internal/core/docker_host_test.go @@ -155,6 +155,21 @@ func TestExtractDockerHost(t *testing.T) { require.Equal(t, DockerSocketSchema+"/this/is/a/sample.sock", host) }) + t.Run("docker-context/docker-host", func(tt *testing.T) { + // do not mess with local .testcontainers.properties + tmpDir := tt.TempDir() + tt.Setenv("HOME", tmpDir) + tt.Setenv("USERPROFILE", tmpDir) // Windows support + setupDockerSocketNotFound(tt) + setupRootlessNotFound(tt) + setupTestcontainersProperties(tt, "") + setupDockerContexts(tt, 2, 3) // current context is context2 + + host, err := extractDockerHost(context.Background()) + require.NoError(t, err) + require.Equal(t, "tcp://127.0.0.1:2", host) // from context2 + }) + t.Run("Default Docker socket", func(t *testing.T) { setupRootlessNotFound(t) tmpSocket := setupDockerSocket(t) @@ -192,7 +207,7 @@ func TestExtractDockerHost(t *testing.T) { setupTestcontainersProperties(t, content) socket, err := testcontainersHostFromProperties(context.Background()) - require.ErrorIs(t, err, ErrTestcontainersHostNotSetInProperties) + require.ErrorIs(t, err, errTestcontainersHostNotSetInProperties) require.Empty(t, socket) }) @@ -212,7 +227,7 @@ func TestExtractDockerHost(t *testing.T) { t.Setenv("DOCKER_HOST", "") socket, err := dockerHostFromEnv(context.Background()) - require.ErrorIs(t, err, ErrDockerHostNotSet) + require.ErrorIs(t, err, errDockerHostNotSet) require.Empty(t, socket) }) @@ -236,7 +251,7 @@ func TestExtractDockerHost(t *testing.T) { os.Unsetenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") socket, err := dockerSocketOverridePath() - require.ErrorIs(t, err, ErrDockerSocketOverrideNotSet) + require.ErrorIs(t, err, errDockerSocketOverrideNotSet) require.Empty(t, socket) }) @@ -260,7 +275,7 @@ func TestExtractDockerHost(t *testing.T) { ctx := context.Background() socket, err := dockerHostFromContext(context.WithValue(ctx, DockerHostContextKey, "http://example.com/docker.sock")) - require.ErrorIs(t, err, ErrNoUnixSchema) + require.ErrorIs(t, err, errNoUnixSchema) require.Empty(t, socket) }) @@ -289,7 +304,7 @@ func TestExtractDockerHost(t *testing.T) { setupTestcontainersProperties(t, content) socket, err := dockerHostFromProperties(context.Background()) - require.ErrorIs(t, err, ErrDockerSocketNotSetInProperties) + require.ErrorIs(t, err, errDockerSocketNotSetInProperties) require.Empty(t, socket) }) @@ -297,9 +312,23 @@ func TestExtractDockerHost(t *testing.T) { setupDockerSocketNotFound(t) socket, err := dockerSocketPath(context.Background()) - require.ErrorIs(t, err, ErrSocketNotFoundInPath) + require.ErrorIs(t, err, errSocketNotFoundInPath) require.Empty(t, socket) }) + + t.Run("extract-from-docker-context/not-found", func(tt *testing.T) { + host, err := GetDockerHostFromCurrentContext() + require.ErrorIs(tt, err, errDockerSocketNotSetInDockerContext) + assert.Empty(tt, host) + }) + + t.Run("extract-from-docker-context/found", func(tt *testing.T) { + setupDockerContexts(tt, 2, 3) // current context is context2 + + host, err := GetDockerHostFromCurrentContext() + require.NoError(tt, err) + assert.Equal(tt, "tcp://127.0.0.1:2", host) + }) }) } diff --git a/internal/core/docker_rootless.go b/internal/core/docker_rootless.go index 70cdebf240..7f5082e975 100644 --- a/internal/core/docker_rootless.go +++ b/internal/core/docker_rootless.go @@ -93,7 +93,7 @@ func parseURL(s string) (string, error) { // return the original URL, as it is a valid TCP URL return s, nil default: - return "", ErrNoUnixSchema + return "", errNoUnixSchema } } diff --git a/internal/core/labels.go b/internal/core/labels.go index 0814924234..6739ea6ec1 100644 --- a/internal/core/labels.go +++ b/internal/core/labels.go @@ -42,7 +42,13 @@ func DefaultLabels(sessionID string) map[string]string { LabelSessionID: sessionID, } - if !config.Read().RyukDisabled { + cfg, err := config.Read() + if err != nil { + // do not apply labels from the config if it's not available + return labels + } + + if !cfg.RyukDisabled { labels[LabelReap] = "true" } diff --git a/internal/core/testdata/.docker/config.json b/internal/core/testdata/.docker/config.json new file mode 100644 index 0000000000..5ce110622d --- /dev/null +++ b/internal/core/testdata/.docker/config.json @@ -0,0 +1,8 @@ +{ + "auths": { + "https://index.docker.io/v1/": {}, + "https://example.com": {}, + "https://my.private.registry": {} + }, + "credsStore": "desktop" +} diff --git a/internal/core/testdata/invalid-config/.docker/config.json b/internal/core/testdata/invalid-config/.docker/config.json new file mode 100644 index 0000000000..f0f444f355 --- /dev/null +++ b/internal/core/testdata/invalid-config/.docker/config.json @@ -0,0 +1,3 @@ +{ + "auths": [] +} diff --git a/lifecycle.go b/lifecycle.go index 63446f715d..734b5db68f 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -522,7 +522,11 @@ func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req Contain // prepare mounts hostConfig.Mounts = mapToDockerMounts(req.Mounts) - endpointSettings := map[string]*network.EndpointSettings{} + endpointSettings := networkingConfig.EndpointsConfig + if endpointSettings == nil { + // sanity check for nil map + endpointSettings = make(map[string]*network.EndpointSettings) + } // #248: Docker allows only one network to be specified during container creation // If there is more than one network specified in the request container should be attached to them diff --git a/lifecycle_test.go b/lifecycle_test.go index 91102ccf82..d77fd952d0 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -900,7 +900,6 @@ func TestPrintContainerLogsOnError(t *testing.T) { } ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: req, Logger: &arrayOfLinesLogger, Started: true, diff --git a/logconsumer_test.go b/logconsumer_test.go index dae1ea0b5a..18efc0d31f 100644 --- a/logconsumer_test.go +++ b/logconsumer_test.go @@ -240,9 +240,6 @@ func TestContainerLogWithErrClosed(t *testing.T) { config.Reset() }) - if providerType == ProviderPodman { - t.Skip("Docker-in-Docker does not work with rootless Podman") - } // First spin up a docker-in-docker container, then spin up an inner container within that dind container // Logs are being read from the inner container via the dind container's tcp port, which can be briefly // closed to test behaviour in connection-closed situations. @@ -286,9 +283,12 @@ func TestContainerLogWithErrClosed(t *testing.T) { require.NoError(t, err) defer dockerClient.Close() + cfg, err := NewConfig() + require.NoError(t, err) + provider := &DockerProvider{ client: dockerClient, - config: config.Read(), + config: cfg, DockerProviderOptions: &DockerProviderOptions{ GenericProviderOptions: &GenericProviderOptions{ Logger: TestLogger(t), diff --git a/mkdocs.yml b/mkdocs.yml index 47044423dc..ae90f9a2b2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,9 +133,6 @@ nav: - system_requirements/ci/gitlab_ci.md - system_requirements/ci/tekton.md - system_requirements/ci/travis.md - - system_requirements/using_colima.md - - system_requirements/using_podman.md - - system_requirements/rancher.md - Contributing: contributing.md - Getting help: getting_help.md edit_uri: edit/main/docs/ diff --git a/modules/compose/compose_api.go b/modules/compose/compose_api.go index 45dd72c6e0..1efd0fc467 100644 --- a/modules/compose/compose_api.go +++ b/modules/compose/compose_api.go @@ -328,9 +328,14 @@ func (d *dockerCompose) Up(ctx context.Context, opts ...StackUpOption) (err erro return err } + cfg, err := testcontainers.NewConfig() + if err != nil { + return fmt.Errorf("new config: %w", err) + } + var termSignals []chan bool var reaper *testcontainers.Reaper - if !d.provider.Config().Config.RyukDisabled { + if !cfg.RyukDisabled { // NewReaper is deprecated: we need to find a way to create the reaper for compose // bypassing the deprecation. reaper, err = testcontainers.NewReaper(ctx, testcontainers.SessionID(), d.provider, "") diff --git a/modules/compose/compose_api_test.go b/modules/compose/compose_api_test.go index 808433f513..d0acf3c3e9 100644 --- a/modules/compose/compose_api_test.go +++ b/modules/compose/compose_api_test.go @@ -196,7 +196,9 @@ func TestDockerComposeAPI_TestcontainersLabelsArePresent(t *testing.T) { func TestDockerComposeAPI_WithReaper(t *testing.T) { config.Reset() // reset the config using the internal method to avoid the sync.Once - tcConfig := config.Read() + tcConfig, err := testcontainers.NewConfig() + require.NoError(t, err) + if tcConfig.RyukDisabled { t.Skip("Ryuk is disabled, skipping test") } @@ -225,7 +227,9 @@ func TestDockerComposeAPI_WithReaper(t *testing.T) { func TestDockerComposeAPI_WithoutReaper(t *testing.T) { config.Reset() // reset the config using the internal method to avoid the sync.Once - tcConfig := config.Read() + tcConfig, err := testcontainers.NewConfig() + require.NoError(t, err) + if !tcConfig.RyukDisabled { t.Skip("Ryuk is enabled, skipping test") } diff --git a/provider.go b/provider.go index 31714c0c14..f8b030107f 100644 --- a/provider.go +++ b/provider.go @@ -2,12 +2,8 @@ package testcontainers import ( "context" - "errors" "fmt" - "os" - "strings" - "github.com/testcontainers/testcontainers-go/internal/config" "github.com/testcontainers/testcontainers-go/internal/core" ) @@ -15,7 +11,7 @@ import ( const ( ProviderDefault ProviderType = iota // default will auto-detect provider from DOCKER_HOST environment variable ProviderDocker - ProviderPodman + ProviderPodman // Deprecated: Podman is supported through the current Docker context ) type ( @@ -39,7 +35,6 @@ type ( // DockerProviderOptions defines options applicable to DockerProvider DockerProviderOptions struct { - defaultBridgeNetworkName string *GenericProviderOptions } @@ -73,9 +68,10 @@ func Generic2DockerOptions(opts ...GenericProviderOption) []DockerProviderOption return converted } +// Deprecated: WithDefaultNetwork is deprecated and will be removed in the next major version func WithDefaultBridgeNetwork(bridgeNetworkName string) DockerProviderOption { return DockerProviderOptionFunc(func(opts *DockerProviderOptions) { - opts.defaultBridgeNetworkName = bridgeNetworkName + // NOOP }) } @@ -90,6 +86,8 @@ type ContainerProvider interface { ReuseOrCreateContainer(context.Context, ContainerRequest) (Container, error) // reuses a container if it exists or creates a container without starting RunContainer(context.Context, ContainerRequest) (Container, error) // create a container and start it Health(context.Context) error + + // Deprecated: use [testcontainers.NewConfig] instead Config() TestcontainersConfig } @@ -103,28 +101,11 @@ func (t ProviderType) GetProvider(opts ...GenericProviderOption) (GenericProvide o.ApplyGenericTo(opt) } - pt := t - if pt == ProviderDefault && strings.Contains(os.Getenv("DOCKER_HOST"), "podman.sock") { - pt = ProviderPodman - } - - switch pt { - case ProviderDefault, ProviderDocker: - providerOptions := append(Generic2DockerOptions(opts...), WithDefaultBridgeNetwork(Bridge)) - provider, err := NewDockerProvider(providerOptions...) - if err != nil { - return nil, fmt.Errorf("%w, failed to create Docker provider", err) - } - return provider, nil - case ProviderPodman: - providerOptions := append(Generic2DockerOptions(opts...), WithDefaultBridgeNetwork(Podman)) - provider, err := NewDockerProvider(providerOptions...) - if err != nil { - return nil, fmt.Errorf("%w, failed to create Docker provider", err) - } - return provider, nil + provider, err := NewDockerProvider(Generic2DockerOptions(opts...)...) + if err != nil { + return nil, fmt.Errorf("%w, failed to create Docker provider", err) } - return nil, errors.New("unknown provider") + return provider, nil } // NewDockerProvider creates a Docker provider with the EnvClient @@ -145,10 +126,15 @@ func NewDockerProvider(provOpts ...DockerProviderOption) (*DockerProvider, error return nil, err } + cfg, err := NewConfig() + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + return &DockerProvider{ DockerProviderOptions: o, host: core.MustExtractDockerHost(ctx), client: c, - config: config.Read(), + config: cfg, }, nil } diff --git a/provider_test.go b/provider_test.go deleted file mode 100644 index 94206e46bf..0000000000 --- a/provider_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package testcontainers - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/testcontainers/testcontainers-go/internal/core" -) - -func TestProviderTypeGetProviderAutodetect(t *testing.T) { - dockerHost := core.MustExtractDockerHost(context.Background()) - const podmanSocket = "unix://$XDG_RUNTIME_DIR/podman/podman.sock" - - tests := []struct { - name string - tr ProviderType - DockerHost string - want string - }{ - { - name: "default provider without podman.socket", - tr: ProviderDefault, - DockerHost: dockerHost, - want: Bridge, - }, - { - name: "default provider with podman.socket", - tr: ProviderDefault, - DockerHost: podmanSocket, - want: Podman, - }, - { - name: "docker provider without podman.socket", - tr: ProviderDocker, - DockerHost: dockerHost, - want: Bridge, - }, - { - // Explicitly setting Docker provider should not be overridden by auto-detect - name: "docker provider with podman.socket", - tr: ProviderDocker, - DockerHost: podmanSocket, - want: Bridge, - }, - { - name: "Podman provider without podman.socket", - tr: ProviderPodman, - DockerHost: dockerHost, - want: Podman, - }, - { - name: "Podman provider with podman.socket", - tr: ProviderPodman, - DockerHost: podmanSocket, - want: Podman, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.tr == ProviderPodman && core.IsWindows() { - t.Skip("Podman provider is not implemented for Windows") - } - - t.Setenv("DOCKER_HOST", tt.DockerHost) - - got, err := tt.tr.GetProvider() - require.NoErrorf(t, err, "ProviderType.GetProvider()") - provider, ok := got.(*DockerProvider) - require.Truef(t, ok, "ProviderType.GetProvider() = %T, want %T", got, &DockerProvider{}) - require.Equalf(t, tt.want, provider.defaultBridgeNetworkName, "ProviderType.GetProvider() = %v, want %v", provider.defaultBridgeNetworkName, tt.want) - }) - } -} diff --git a/reaper.go b/reaper.go index 1d97a36ffa..8112e70015 100644 --- a/reaper.go +++ b/reaper.go @@ -60,6 +60,8 @@ var ( // The ContainerProvider interface should usually satisfy this as well, so it is pluggable type ReaperProvider interface { RunContainer(ctx context.Context, req ContainerRequest) (Container, error) + + // Deprecated: use [testcontainers.NewConfig] instead Config() TestcontainersConfig } @@ -260,7 +262,12 @@ func (r *reaperSpawner) retryError(err error) error { // // Safe for concurrent calls. func (r *reaperSpawner) reaper(ctx context.Context, sessionID string, provider ReaperProvider) (*Reaper, error) { - if config.Read().RyukDisabled { + cfg, err := NewConfig() + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + if cfg.RyukDisabled { return nil, errReaperDisabled } @@ -376,6 +383,7 @@ func (r *reaperSpawner) newReaper(ctx context.Context, sessionID string, provide dockerHostMount := core.MustExtractDockerSocket(ctx) port := r.port() + // TODO: change deprecated usage of Config() once we have more consistent test for the config and the reaper. tcConfig := provider.Config().Config req := ContainerRequest{ Image: config.ReaperDefaultImage, @@ -387,7 +395,7 @@ func (r *reaperSpawner) newReaper(ctx context.Context, sessionID string, provide HostConfigModifier: func(hc *container.HostConfig) { hc.AutoRemove = true hc.Binds = []string{dockerHostMount + ":/var/run/docker.sock"} - hc.NetworkMode = Bridge + hc.NetworkMode = "bridge" }, Env: map[string]string{}, } @@ -406,16 +414,6 @@ func (r *reaperSpawner) newReaper(ctx context.Context, sessionID string, provide req.Labels[core.LabelRyuk] = "true" delete(req.Labels, core.LabelReap) - // Attach reaper container to a requested network if it is specified - if p, ok := provider.(*DockerProvider); ok { - defaultNetwork, err := p.ensureDefaultNetwork(ctx) - if err != nil { - return nil, fmt.Errorf("ensure default network: %w", err) - } - - req.Networks = append(req.Networks, defaultNetwork) - } - c, err := provider.RunContainer(ctx, req) defer func() { if err != nil { @@ -455,7 +453,12 @@ type Reaper struct { // It returns a channel that can be closed to terminate the connection. // Returns an error if config.RyukDisabled is true. func (r *Reaper) Connect() (chan bool, error) { - if config.Read().RyukDisabled { + cfg, err := config.Read() + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + if cfg.RyukDisabled { return nil, errReaperDisabled } diff --git a/reaper_test.go b/reaper_test.go index e9bc5ccb9f..1b5e9f17df 100644 --- a/reaper_test.go +++ b/reaper_test.go @@ -27,7 +27,7 @@ type mockReaperProvider struct { req ContainerRequest hostConfig *container.HostConfig endpointSettings map[string]*network.EndpointSettings - config TestcontainersConfig + config TestcontainersConfig // Deprecated: use [testcontainers.Config] instead } func newMockReaperProvider(cfg config.Config) *mockReaperProvider { @@ -62,6 +62,7 @@ func (m *mockReaperProvider) RunContainer(ctx context.Context, req ContainerRequ return nil, errExpected } +// Deprecated: use [testcontainers.NewConfig] instead func (m *mockReaperProvider) Config() TestcontainersConfig { return m.config } @@ -107,7 +108,6 @@ func testContainerStart(t *testing.T) { ctx := context.Background() ctr, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -171,7 +171,6 @@ func testContainerStop(t *testing.T) { ctx := context.Background() nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ @@ -203,7 +202,6 @@ func testContainerTerminate(t *testing.T) { ctx := context.Background() nginxA, err := GenericContainer(ctx, GenericContainerRequest{ - ProviderType: providerType, ContainerRequest: ContainerRequest{ Image: nginxAlpineImage, ExposedPorts: []string{ diff --git a/testdata/.docker/config.json b/testdata/.docker/config.json index af4b84ef1c..5ce110622d 100644 --- a/testdata/.docker/config.json +++ b/testdata/.docker/config.json @@ -5,4 +5,4 @@ "https://my.private.registry": {} }, "credsStore": "desktop" -} \ No newline at end of file +}