Skip to content

Commit 1c15042

Browse files
mridangclaude
andcommitted
feat: share Docker containers across integration tests via SharedRuntimeContainer
Replace per-test container creation with a static pool keyed by generator name, reducing container starts from ~41 to 10. Each language container is started once, dependencies installed once, and a /work-snapshot preserves the setup state for test isolation without re-running setup commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2be29a8 commit 1c15042

40 files changed

Lines changed: 281 additions & 181 deletions

src/main/resources/templates/kotlin/test/BaseApiTest.mustache

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class BaseApiTest {
5151
return invokeApi(method, path, queryParams, headerParams, body, accepts, contentType, auth)
5252
}
5353

54-
suspend inline fun <reified T> callForResult(
54+
internal suspend inline fun <reified T> callForResult(
5555
method: String,
5656
path: String,
5757
queryParams: MutableMap<String, Any?> = mutableMapOf(),
@@ -70,7 +70,7 @@ class BaseApiTest {
7070
var capturedHeaders: Map<String, String> = emptyMap()
7171
var capturedBody: Any? = null
7272
73-
override fun sendRequest(
73+
override suspend fun sendRequest(
7474
method: String,
7575
url: String,
7676
headers: Map<String, String>,
@@ -304,7 +304,7 @@ class BaseApiTest {
304304
@DisplayName("skips JSON deserialization for text/plain response")
305305
fun skipsDeserializationForTextPlain() {
306306
val client = object : CapturingApiClient() {
307-
override fun sendRequest(
307+
override suspend fun sendRequest(
308308
method: String,
309309
url: String,
310310
headers: Map<String, String>,
@@ -330,7 +330,7 @@ class BaseApiTest {
330330
@DisplayName("deserializes vendor JSON MIME types like application/problem+json")
331331
fun deserializesVendorJsonMimeType() {
332332
val client = object : CapturingApiClient() {
333-
override fun sendRequest(
333+
override suspend fun sendRequest(
334334
method: String,
335335
url: String,
336336
headers: Map<String, String>,

src/spec/java/io/github/mridang/codegen/spec/AbstractIntegrationSpec.java

Lines changed: 2 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
package io.github.mridang.codegen.spec;
22

33
import java.nio.file.Path;
4-
import org.junit.jupiter.api.AfterEach;
54
import org.junit.jupiter.api.BeforeEach;
65
import org.junit.jupiter.api.TestInfo;
76
import org.slf4j.Logger;
87
import org.slf4j.LoggerFactory;
9-
import org.testcontainers.containers.BindMode;
108
import org.testcontainers.containers.GenericContainer;
11-
import org.testcontainers.containers.Network;
12-
import org.testcontainers.containers.output.Slf4jLogConsumer;
13-
14-
import javax.annotation.Nullable;
159

1610
/**
1711
* Base class for integration specs. Each generated SDK lives in
@@ -24,8 +18,6 @@ public abstract class AbstractIntegrationSpec implements LanguageSpec {
2418

2519
protected static final Logger logger = LoggerFactory.getLogger(AbstractIntegrationSpec.class);
2620

27-
@Nullable protected Network sharedNetwork;
28-
2921
protected Path tempOutputDir;
3022

3123
protected abstract String[] getBuildCommands();
@@ -36,20 +28,6 @@ void resolveOutputDir() {
3628
tempOutputDir = Path.of("src/spec/resources/generated/" + lang).toAbsolutePath();
3729
}
3830

39-
@BeforeEach
40-
void setupNetwork() {
41-
sharedNetwork = Network.newNetwork();
42-
logger.info("Created Docker network: {}", sharedNetwork.getId());
43-
}
44-
45-
@AfterEach
46-
void teardownNetwork() {
47-
if (sharedNetwork != null) {
48-
sharedNetwork.close();
49-
logger.info("Closed Docker network");
50-
}
51-
}
52-
5331
@BeforeEach
5432
void logTestContext(TestInfo testInfo) {
5533
logger.info("========================================");
@@ -60,83 +38,8 @@ void logTestContext(TestInfo testInfo) {
6038
}
6139

6240
protected ExecResult executeInRuntimeContainer(String[] commands) {
63-
try (GenericContainer<?> runtimeContainer =
64-
new GenericContainer<>(getRuntimeImage())
65-
.withNetwork(sharedNetwork)
66-
.withFileSystemBind(
67-
tempOutputDir.toAbsolutePath().toString(), "/app", BindMode.READ_WRITE)
68-
.withFileSystemBind("/var/run/docker.sock", "/var/run/docker.sock", BindMode.READ_WRITE)
69-
.withExtraHost("host.docker.internal", "host-gateway")
70-
.withEnv("TESTCONTAINERS_HOST_OVERRIDE", "host.docker.internal")
71-
.withEnv("TC_HOST", "host.docker.internal")
72-
.withEnv("DOCKER_HOST", "unix:///var/run/docker.sock")
73-
.withEnv("TESTCONTAINERS_RYUK_DISABLED", "true")
74-
.withEnv("HOST_APP_PATH", tempOutputDir.toAbsolutePath().toString())
75-
.withWorkingDirectory("/app")
76-
.withCommand("tail", "-f", "/dev/null")
77-
.withCreateContainerCmdModifier(cmd -> cmd.withUser("root"))
78-
.withLogConsumer(new Slf4jLogConsumer(logger).withPrefix(getGeneratorName()))) {
79-
80-
runtimeContainer.start();
81-
82-
logger.info("Runtime container started:");
83-
logger.info(" - Image: {}", getRuntimeImage());
84-
logger.info(" - Container ID: {}", runtimeContainer.getContainerId());
85-
86-
// Ensure Docker socket is accessible for testcontainers inside the container
87-
runtimeContainer.execInContainer("sh", "-c",
88-
"chmod 666 /var/run/docker.sock 2>/dev/null || true");
89-
90-
// Copy bind-mounted files to container-local storage to avoid file corruption
91-
// caused by heavy parallel I/O on macOS Docker bind mounts (VirtioFS/gRPC-FUSE).
92-
// Package managers (npm, composer, mvn) write thousands of small files during
93-
// install, and concurrent containers writing to bind mounts causes truncated or
94-
// corrupted files. Running in /work avoids this entirely.
95-
runtimeContainer.execInContainer("sh", "-c", "cp -a /app /work");
96-
97-
StringBuilder output = new StringBuilder();
98-
int exitCode = 0;
99-
100-
for (String command : commands) {
101-
logger.info("Executing: {}", command);
102-
103-
org.testcontainers.containers.Container.ExecResult result =
104-
runtimeContainer.execInContainer("sh", "-c", "cd /work && " + command);
105-
106-
output.append("=== ").append(command).append(" ===\n");
107-
output.append(result.getStdout());
108-
if (!result.getStderr().isEmpty()) {
109-
output.append("STDERR:\n").append(result.getStderr());
110-
}
111-
output.append("\n");
112-
113-
logger.info("Exit code: {}", result.getExitCode());
114-
if (!result.getStdout().isEmpty()) {
115-
logger.info("Output:\n{}", result.getStdout());
116-
}
117-
118-
if (result.getExitCode() != 0) {
119-
logger.error("Command failed with stderr:\n{}", result.getStderr());
120-
exitCode = result.getExitCode();
121-
break;
122-
}
123-
}
124-
125-
// Copy coverage.xml and JUnit XML reports back to the bind mount
126-
runtimeContainer.execInContainer("sh", "-c",
127-
"mkdir -p /app/.out/reports && "
128-
+ "cp /work/.out/coverage.xml /app/.out/coverage.xml 2>/dev/null || true; "
129-
+ "cp /work/.out/reports/*.xml /app/.out/reports/ 2>/dev/null || true");
130-
131-
// Fix permissions on the .out directory so subsequent runs can overwrite
132-
runtimeContainer.execInContainer("sh", "-c", "chmod -R 777 /app/.out 2>/dev/null || true");
133-
134-
return new ExecResult(exitCode, output.toString());
135-
} catch (Exception e) {
136-
logger.error("Failed to execute commands in runtime container", e);
137-
String message = e.getMessage();
138-
return new ExecResult(-1, message != null ? message : e.getClass().getName());
139-
}
41+
GenericContainer<?> container = SharedRuntimeContainer.getOrCreate(this, tempOutputDir);
42+
return SharedRuntimeContainer.execInContainer(container, tempOutputDir, commands);
14043
}
14144

14245
public static class ExecResult {

src/spec/java/io/github/mridang/codegen/spec/LanguageSpec.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.mridang.codegen.spec;
22

3+
import java.util.List;
34
import java.util.Map;
45
import org.testcontainers.utility.DockerImageName;
56

@@ -12,4 +13,8 @@ public interface LanguageSpec {
1213
default Map<String, Object> getCodegenProperties() {
1314
return Map.of();
1415
}
16+
17+
default List<String> getSetupCommands() {
18+
return List.of();
19+
}
1520
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package io.github.mridang.codegen.spec;
2+
3+
import java.nio.file.Path;
4+
import java.util.List;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.testcontainers.containers.BindMode;
9+
import org.testcontainers.containers.GenericContainer;
10+
import org.testcontainers.containers.Network;
11+
import org.testcontainers.containers.output.Slf4jLogConsumer;
12+
13+
/**
14+
* Maintains a static pool of long-lived Docker containers keyed by generator name. Each container
15+
* is started once (with dependencies pre-installed via {@link LanguageSpec#getSetupCommands()}), and
16+
* all test methods for that language reuse it. A JVM shutdown hook tears them all down.
17+
*/
18+
final class SharedRuntimeContainer {
19+
20+
private static final Logger logger = LoggerFactory.getLogger(SharedRuntimeContainer.class);
21+
22+
private static final ConcurrentHashMap<String, GenericContainer<?>> pool =
23+
new ConcurrentHashMap<>();
24+
25+
private static final Network sharedNetwork = Network.newNetwork();
26+
27+
static {
28+
Runtime.getRuntime()
29+
.addShutdownHook(
30+
new Thread(
31+
() -> {
32+
pool.forEach(
33+
(name, container) -> {
34+
try {
35+
container.stop();
36+
logger.info("Stopped shared container for {}", name);
37+
} catch (Exception e) {
38+
logger.warn("Failed to stop container for {}", name, e);
39+
}
40+
});
41+
pool.clear();
42+
try {
43+
sharedNetwork.close();
44+
logger.info("Closed shared Docker network");
45+
} catch (Exception e) {
46+
logger.warn("Failed to close shared network", e);
47+
}
48+
},
49+
"shared-runtime-container-cleanup"));
50+
}
51+
52+
private SharedRuntimeContainer() {}
53+
54+
static GenericContainer<?> getOrCreate(LanguageSpec spec, Path outputDir) {
55+
return pool.computeIfAbsent(
56+
spec.getGeneratorName(),
57+
name -> {
58+
logger.info("Creating shared container for {}", name);
59+
60+
GenericContainer<?> container =
61+
new GenericContainer<>(spec.getRuntimeImage())
62+
.withNetwork(sharedNetwork)
63+
.withFileSystemBind(
64+
outputDir.toAbsolutePath().toString(), "/app", BindMode.READ_WRITE)
65+
.withFileSystemBind(
66+
"/var/run/docker.sock", "/var/run/docker.sock", BindMode.READ_WRITE)
67+
.withExtraHost("host.docker.internal", "host-gateway")
68+
.withEnv("TESTCONTAINERS_HOST_OVERRIDE", "host.docker.internal")
69+
.withEnv("TC_HOST", "host.docker.internal")
70+
.withEnv("DOCKER_HOST", "unix:///var/run/docker.sock")
71+
.withEnv("TESTCONTAINERS_RYUK_DISABLED", "true")
72+
.withEnv("HOST_APP_PATH", outputDir.toAbsolutePath().toString())
73+
.withWorkingDirectory("/app")
74+
.withCommand("tail", "-f", "/dev/null")
75+
.withCreateContainerCmdModifier(cmd -> cmd.withUser("root"))
76+
.withLogConsumer(new Slf4jLogConsumer(logger).withPrefix(name));
77+
78+
container.start();
79+
80+
logger.info("Shared container started:");
81+
logger.info(" - Image: {}", spec.getRuntimeImage());
82+
logger.info(" - Container ID: {}", container.getContainerId());
83+
84+
try {
85+
container.execInContainer(
86+
"sh", "-c", "chmod 666 /var/run/docker.sock 2>/dev/null || true");
87+
88+
container.execInContainer("sh", "-c", "cp -a /app /work");
89+
90+
List<String> setupCommands = spec.getSetupCommands();
91+
for (String command : setupCommands) {
92+
logger.info("Running setup command for {}: {}", name, command);
93+
org.testcontainers.containers.Container.ExecResult result =
94+
container.execInContainer("sh", "-c", "cd /work && " + command);
95+
96+
if (!result.getStdout().isEmpty()) {
97+
logger.info("Setup output:\n{}", result.getStdout());
98+
}
99+
100+
if (result.getExitCode() != 0) {
101+
logger.error(
102+
"Setup command failed for {} with exit code {}: {}",
103+
name,
104+
result.getExitCode(),
105+
result.getStderr());
106+
throw new RuntimeException(
107+
"Setup command failed for "
108+
+ name
109+
+ ": "
110+
+ command
111+
+ "\n"
112+
+ result.getStderr());
113+
}
114+
}
115+
container.execInContainer("sh", "-c", "cp -a /work /work-snapshot");
116+
} catch (RuntimeException e) {
117+
throw e;
118+
} catch (Exception e) {
119+
throw new RuntimeException("Failed to set up shared container for " + name, e);
120+
}
121+
122+
return container;
123+
});
124+
}
125+
126+
static AbstractIntegrationSpec.ExecResult execInContainer(
127+
GenericContainer<?> container, Path outputDir, String[] commands) {
128+
try {
129+
container.execInContainer("sh", "-c", "rm -rf /work && cp -a /work-snapshot /work");
130+
131+
StringBuilder output = new StringBuilder();
132+
int exitCode = 0;
133+
134+
for (String command : commands) {
135+
logger.info("Executing: {}", command);
136+
137+
org.testcontainers.containers.Container.ExecResult result =
138+
container.execInContainer("sh", "-c", "cd /work && " + command);
139+
140+
output.append("=== ").append(command).append(" ===\n");
141+
output.append(result.getStdout());
142+
if (!result.getStderr().isEmpty()) {
143+
output.append("STDERR:\n").append(result.getStderr());
144+
}
145+
output.append("\n");
146+
147+
logger.info("Exit code: {}", result.getExitCode());
148+
if (!result.getStdout().isEmpty()) {
149+
logger.info("Output:\n{}", result.getStdout());
150+
}
151+
152+
if (result.getExitCode() != 0) {
153+
logger.error("Command failed with stderr:\n{}", result.getStderr());
154+
exitCode = result.getExitCode();
155+
break;
156+
}
157+
}
158+
159+
container.execInContainer(
160+
"sh",
161+
"-c",
162+
"mkdir -p /app/.out/reports && "
163+
+ "cp /work/.out/coverage.xml /app/.out/coverage.xml 2>/dev/null || true; "
164+
+ "cp /work/.out/reports/*.xml /app/.out/reports/ 2>/dev/null || true");
165+
166+
container.execInContainer("sh", "-c", "chmod -R 777 /app/.out 2>/dev/null || true");
167+
168+
return new AbstractIntegrationSpec.ExecResult(exitCode, output.toString());
169+
} catch (Exception e) {
170+
logger.error("Failed to execute commands in shared container", e);
171+
String message = e.getMessage();
172+
return new AbstractIntegrationSpec.ExecResult(
173+
-1, message != null ? message : e.getClass().getName());
174+
}
175+
}
176+
}

src/spec/java/io/github/mridang/codegen/spec/csharp/CSharpClientSpec.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ public class CSharpClientSpec extends AbstractClientSpec implements CSharpSpec {
1515
@Override
1616
protected String[] getBuildCommands() {
1717
return new String[] {
18-
"dotnet restore",
1918
"dotnet test --verbosity normal --logger \"junit;LogFilePath=.out/reports/junit.xml\""
2019
};
2120
}

src/spec/java/io/github/mridang/codegen/spec/csharp/CSharpSpec.java

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

33
import io.github.mridang.codegen.spec.DockerImageSpec;
44
import io.github.mridang.codegen.spec.LanguageSpec;
5+
import java.util.List;
56
import java.util.Map;
67
import org.junit.jupiter.api.Tag;
78
import org.testcontainers.utility.DockerImageName;
@@ -28,4 +29,9 @@ default String getDockerImage() {
2829
default Map<String, Object> getCodegenProperties() {
2930
return Map.of("packageName", "PetstoreClient", "sourceFolder", "src");
3031
}
32+
33+
@Override
34+
default List<String> getSetupCommands() {
35+
return List.of("dotnet restore");
36+
}
3137
}

src/spec/java/io/github/mridang/codegen/spec/dart/DartClientSpec.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ public class DartClientSpec extends AbstractClientSpec implements DartSpec {
1515
@Override
1616
protected String[] getBuildCommands() {
1717
return new String[] {
18-
"dart pub get",
1918
"mkdir -p .out/reports",
2019
"dart test --coverage=.out/coverage --reporter json | dart run junitreport:tojunit --output .out/reports/junit.xml",
2120
"dart run coverage:format_coverage --lcov --in=.out/coverage --out=.out/coverage.xml --report-on=lib"

0 commit comments

Comments
 (0)