Skip to content

Commit 50c778f

Browse files
authored
Add a rootless Docker strategy (#2985)
Closes #2943, #1770
1 parent c137996 commit 50c778f

File tree

13 files changed

+167
-21
lines changed

13 files changed

+167
-21
lines changed

.github/workflows/ci-rootless.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI-Docker-Rootless
2+
3+
on:
4+
pull_request: {}
5+
push: { branches: [ master ] }
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-18.04
10+
steps:
11+
- uses: actions/checkout@v2
12+
- name: debug
13+
run: id -u; whoami
14+
- name: uninstall rootful Docker
15+
run: sudo apt-get -q -y --purge remove moby-engine moby-buildx && sudo rm -rf /var/run/docker.sock
16+
- name: install rootless Docker
17+
run: curl -fsSL https://get.docker.com/rootless | sh
18+
- name: start rootless Docker
19+
run: PATH=$HOME/bin:$PATH XDG_RUNTIME_DIR=/tmp/docker-$(id -u) dockerd-rootless.sh --experimental --storage-driver vfs &
20+
- name: Build with Gradle
21+
run: XDG_RUNTIME_DIR=/tmp/docker-$(id -u) ./gradlew --no-daemon --scan testcontainers:test
22+
- name: aggregate test reports with ciMate
23+
if: always()
24+
continue-on-error: true
25+
env:
26+
CIMATE_PROJECT_ID: 2348n4vl
27+
CIMATE_CI_KEY: "CI / rootless Docker"
28+
run: |
29+
wget -q https://get.cimate.io/release/linux/cimate
30+
chmod +x cimate
31+
./cimate "**/TEST-*.xml"

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ jobs:
5656
run: docker image prune -af
5757
- name: Build and test with Gradle (${{matrix.gradle_args}})
5858
run: |
59-
./gradlew --no-daemon --continue --scan --info ${{matrix.gradle_args}}
59+
./gradlew --no-daemon --continue --scan ${{matrix.gradle_args}}
6060
- name: Aggregate test reports with ciMate
6161
if: always()
6262
continue-on-error: true

core/src/main/java/org/testcontainers/DockerClientFactory.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import lombok.SneakyThrows;
1919
import lombok.Synchronized;
2020
import lombok.extern.slf4j.Slf4j;
21+
import org.apache.commons.lang.StringUtils;
2122
import org.testcontainers.dockerclient.DockerClientProviderStrategy;
2223
import org.testcontainers.dockerclient.DockerMachineClientProviderStrategy;
2324
import org.testcontainers.images.TimeLimitedLoggedPullImageResultCallback;
@@ -29,6 +30,7 @@
2930
import java.io.ByteArrayOutputStream;
3031
import java.io.IOException;
3132
import java.io.InputStream;
33+
import java.net.URI;
3234
import java.util.ArrayList;
3335
import java.util.List;
3436
import java.util.Map;
@@ -129,6 +131,19 @@ private DockerClientProviderStrategy getOrInitializeStrategy() {
129131
return strategy;
130132
}
131133

134+
@UnstableAPI
135+
public String getDockerUnixSocketPath() {
136+
String dockerSocketOverride = System.getenv("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE");
137+
if (!StringUtils.isBlank(dockerSocketOverride)) {
138+
return dockerSocketOverride;
139+
}
140+
141+
URI dockerHost = getOrInitializeStrategy().getTransportConfig().getDockerHost();
142+
return "unix".equals(dockerHost.getScheme())
143+
? dockerHost.getRawPath()
144+
: "/var/run/docker.sock";
145+
}
146+
132147
/**
133148
*
134149
* @return a new initialized Docker client

core/src/main/java/org/testcontainers/containers/DockerComposeContainer.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@
5959
import static com.google.common.base.Strings.isNullOrEmpty;
6060
import static java.util.stream.Collectors.joining;
6161
import static java.util.stream.Collectors.toList;
62-
import static org.testcontainers.containers.BindMode.READ_ONLY;
6362
import static org.testcontainers.containers.BindMode.READ_WRITE;
6463

6564
/**
@@ -580,7 +579,6 @@ interface DockerCompose {
580579
*/
581580
class ContainerisedDockerCompose extends GenericContainer<ContainerisedDockerCompose> implements DockerCompose {
582581

583-
private static final String DOCKER_SOCKET_PATH = "/var/run/docker.sock";
584582
public static final char UNIX_PATH_SEPERATOR = ':';
585583

586584
public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
@@ -601,24 +599,18 @@ public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
601599
final String composeFileEnvVariableValue = Joiner.on(UNIX_PATH_SEPERATOR).join(absoluteDockerComposeFiles); // we always need the UNIX path separator
602600
logger().debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue);
603601
addEnv(ENV_COMPOSE_FILE, composeFileEnvVariableValue);
604-
addFileSystemBind(pwd, containerPwd, READ_ONLY);
602+
addFileSystemBind(pwd, containerPwd, READ_WRITE);
605603

606604
// Ensure that compose can access docker. Since the container is assumed to be running on the same machine
607605
// as the docker daemon, just mapping the docker control socket is OK.
608606
// As there seems to be a problem with mapping to the /var/run directory in certain environments (e.g. CircleCI)
609607
// we map the socket file outside of /var/run, as just /docker.sock
610-
addFileSystemBind(getDockerSocketHostPath(), "/docker.sock", READ_WRITE);
608+
addFileSystemBind("/" + DockerClientFactory.instance().getDockerUnixSocketPath(), "/docker.sock", READ_WRITE);
611609
addEnv("DOCKER_HOST", "unix:///docker.sock");
612610
setStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy());
613611
setWorkingDirectory(containerPwd);
614612
}
615613

616-
private String getDockerSocketHostPath() {
617-
return SystemUtils.IS_OS_WINDOWS
618-
? "/" + DOCKER_SOCKET_PATH
619-
: DOCKER_SOCKET_PATH;
620-
}
621-
622614
@Override
623615
public void invoke() {
624616
super.start();
@@ -681,16 +673,26 @@ public DockerCompose withEnv(Map<String, String> env) {
681673
return this;
682674
}
683675

676+
@VisibleForTesting
677+
static boolean executableExists() {
678+
return CommandLine.executableExists(COMPOSE_EXECUTABLE);
679+
}
680+
684681
@Override
685682
public void invoke() {
686683
// bail out early
687-
if (!CommandLine.executableExists(COMPOSE_EXECUTABLE)) {
684+
if (!executableExists()) {
688685
throw new ContainerLaunchException("Local Docker Compose not found. Is " + COMPOSE_EXECUTABLE + " on the PATH?");
689686
}
690687

691688
final Map<String, String> environment = Maps.newHashMap(env);
692689
environment.put(ENV_PROJECT_NAME, identifier);
693690

691+
String dockerHost = System.getenv("DOCKER_HOST");
692+
if (dockerHost == null) {
693+
dockerHost = "unix://" + DockerClientFactory.instance().getDockerUnixSocketPath();
694+
}
695+
environment.put("DOCKER_HOST", dockerHost);
694696

695697
final Stream<String> absoluteDockerComposeFilePaths = composeFiles.stream()
696698
.map(File::getAbsolutePath)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.testcontainers.dockerclient;
2+
3+
import com.sun.jna.Library;
4+
import com.sun.jna.Native;
5+
import org.apache.commons.lang.SystemUtils;
6+
7+
import java.net.URI;
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
10+
import java.nio.file.Paths;
11+
12+
/**
13+
*
14+
* @deprecated this class is used by the SPI and should not be used directly
15+
*/
16+
@Deprecated
17+
public final class RootlessDockerClientProviderStrategy extends DockerClientProviderStrategy {
18+
19+
public static final int PRIORITY = UnixSocketClientProviderStrategy.PRIORITY + 1;
20+
21+
private Path getSocketPath() {
22+
String xdgRuntimeDir = System.getenv("XDG_RUNTIME_DIR");
23+
if (xdgRuntimeDir == null) {
24+
xdgRuntimeDir = "/run/user/" + LibC.INSTANCE.getuid();
25+
}
26+
return Paths.get(xdgRuntimeDir).resolve("docker.sock");
27+
}
28+
29+
@Override
30+
public TransportConfig getTransportConfig() throws InvalidConfigurationException {
31+
return TransportConfig.builder()
32+
.dockerHost(URI.create("unix://" + getSocketPath().toString()))
33+
.build();
34+
}
35+
36+
@Override
37+
protected boolean isApplicable() {
38+
return SystemUtils.IS_OS_LINUX && Files.exists(getSocketPath());
39+
}
40+
41+
@Override
42+
public String getDescription() {
43+
return "Rootless Docker accessed via Unix socket (" + getSocketPath() + ")";
44+
}
45+
46+
@Override
47+
protected int getPriority() {
48+
return PRIORITY;
49+
}
50+
51+
private interface LibC extends Library {
52+
53+
LibC INSTANCE = Native.loadLibrary("c", LibC.class);
54+
55+
int getuid();
56+
}
57+
58+
}

core/src/main/java/org/testcontainers/dockerclient/UnixSocketClientProviderStrategy.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public TransportConfig getTransportConfig() throws InvalidConfigurationException
4141

4242
@Override
4343
protected boolean isApplicable() {
44-
return SystemUtils.IS_OS_LINUX;
44+
return SystemUtils.IS_OS_LINUX || SystemUtils.IS_OS_MAC;
4545
}
4646

4747
@Override

core/src/main/java/org/testcontainers/utility/ResourceReaper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public static String start(String hostIpAddress, DockerClient client) {
7676
DockerClientFactory.instance().checkAndPullImage(client, ryukImage);
7777

7878
List<Bind> binds = new ArrayList<>();
79-
binds.add(new Bind("//var/run/docker.sock", new Volume("/var/run/docker.sock")));
79+
binds.add(new Bind("/" + DockerClientFactory.instance().getDockerUnixSocketPath(), new Volume("/var/run/docker.sock")));
8080

8181
String ryukContainerId = client.createContainerCmd(ryukImage)
8282
.withHostConfig(new HostConfig().withAutoRemove(true))

core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrate
22
org.testcontainers.dockerclient.UnixSocketClientProviderStrategy
33
org.testcontainers.dockerclient.DockerMachineClientProviderStrategy
44
org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy
5+
org.testcontainers.dockerclient.RootlessDockerClientProviderStrategy

core/src/test/java/org/testcontainers/junit/DockerComposeOverridesTest.java renamed to core/src/test/java/org/testcontainers/containers/DockerComposeOverridesTest.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
package org.testcontainers.junit;
1+
package org.testcontainers.containers;
22

33
import com.google.common.util.concurrent.Uninterruptibles;
4+
import org.assertj.core.api.Assumptions;
5+
import org.junit.Before;
46
import org.junit.Test;
57
import org.junit.runner.RunWith;
68
import org.junit.runners.Parameterized;
@@ -48,6 +50,15 @@ public static Iterable<Object[]> data() {
4850
});
4951
}
5052

53+
@Before
54+
public void setUp() {
55+
if (localMode) {
56+
Assumptions.assumeThat(LocalDockerCompose.executableExists())
57+
.as("docker-compose executable exists")
58+
.isTrue();
59+
}
60+
}
61+
5162
@Test
5263
public void test() {
5364
try (DockerComposeContainer compose =

core/src/test/java/org/testcontainers/containers/GenericContainerTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import com.github.dockerjava.api.DockerClient;
44
import com.github.dockerjava.api.command.InspectContainerResponse.ContainerState;
5+
import com.github.dockerjava.api.model.Info;
56
import lombok.RequiredArgsConstructor;
67
import lombok.SneakyThrows;
78
import lombok.experimental.FieldDefaults;
89
import lombok.extern.slf4j.Slf4j;
910
import org.apache.commons.io.FileUtils;
11+
import org.assertj.core.api.Assumptions;
1012
import org.junit.Test;
1113
import org.rnorth.ducttape.unreliables.Unreliables;
14+
import org.testcontainers.DockerClientFactory;
1215
import org.testcontainers.containers.startupcheck.StartupCheckStrategy;
1316
import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy;
1417

@@ -21,6 +24,9 @@ public class GenericContainerTest {
2124

2225
@Test
2326
public void shouldReportOOMAfterWait() {
27+
Info info = DockerClientFactory.instance().client().infoCmd().exec();
28+
// Poor man's rootless Docker detection :D
29+
Assumptions.assumeThat(info.getDriver()).doesNotContain("vfs");
2430
try (
2531
GenericContainer container = new GenericContainer<>()
2632
.withStartupCheckStrategy(new NoopStartupCheckStrategy())

core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.testcontainers.dockerclient;
22

33
import com.github.dockerjava.api.DockerClient;
4+
import org.assertj.core.api.Assumptions;
45
import org.junit.Test;
56
import org.testcontainers.DockerClientFactory;
67

@@ -16,6 +17,10 @@ public class DockerClientConfigUtilsTest {
1617

1718
@Test
1819
public void getDockerHostIpAddressShouldReturnLocalhostWhenUnixSocket() {
20+
Assumptions.assumeThat(DockerClientConfigUtils.IN_A_CONTAINER)
21+
.as("in a container")
22+
.isFalse();
23+
1924
String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress(client, URI.create("unix:///var/run/docker.sock"));
2025
assertEquals("localhost", actual);
2126
}

core/src/test/resources/auth-config/docker-credential-fake

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
1-
#!/bin/bash
1+
#!/bin/sh
22

3-
if [[ $1 != "get" ]]; then
3+
if [ $1 != "get" ]; then
44
exit 1
55
fi
66

77
read inputLine
88

9-
if [[ $inputLine == "registry2.example.com" ]]; then
9+
if [ "$inputLine" = "registry2.example.com" ]; then
1010
echo Fake credentials not found on credentials store \'$inputLine\' 1>&2
1111
exit 1
1212
fi
13-
if [[ $inputLine == "https://not.a.real.registry/url" ]]; then
13+
if [ "$inputLine" = "https://not.a.real.registry/url" ]; then
1414
echo Fake credentials not found on credentials store \'$inputLine\' 1>&2
1515
exit 1
1616
fi
1717

18-
if [[ $inputLine == "registry.example.com" ]]; then
18+
if [ "$inputLine" = "registry.example.com" ]; then
1919
echo '{' \
2020
' "ServerURL": "url",' \
2121
' "Username": "username",' \
2222
' "Secret": "secret"' \
2323
'}'
2424
exit 0
2525
fi
26-
if [[ $inputLine == "registrytoken.example.com" ]]; then
26+
if [ "$inputLine" = "registrytoken.example.com" ]; then
2727
echo '{' \
2828
' "ServerURL": "url",' \
2929
' "Username": "<token>",' \

docs/features/configuration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,20 @@ but does not allow starting privileged containers, you can turn off the Ryuk con
7272

7373
> **pull.pause.timeout = 30**
7474
> By default Testcontainers will abort the pull of an image if the pull appears stalled (no data transferred) for longer than this duration (in seconds).
75+
76+
## Customizing Docker host detection
77+
78+
Testcontainers will attempt to detect the Docker environment and configure everything.
79+
80+
However, sometimes a customization is required. For that, you can provide the following environment variables:
81+
82+
> **DOCKER_HOST** = unix:///var/run/docker.sock
83+
> See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables)
84+
>
85+
> **TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE**
86+
> Path to Docker's socket. Used by Ryuk, Docker Compose, and a few other containers that need to perform Docker actions.
87+
> Example: `/var/run/docker-alt.sock`
88+
>
89+
> **TESTCONTAINERS_HOST_OVERRIDE**
90+
> Docker's host on which ports are exposed.
91+
> Example: `docker.svc.local`

0 commit comments

Comments
 (0)