Skip to content

Commit 64c0541

Browse files
authored
(#75) 백엔드 검증 루프 hardening과 Docker preflight 추가
* test: harden docker preflight for integration verification * test: address docker preflight review notes
1 parent 8b434a6 commit 64c0541

5 files changed

Lines changed: 299 additions & 0 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<a href="#badge">Badge</a> •
2828
<a href="#data-refresh">Data Refresh</a> •
2929
<a href="#faq">FAQ</a> •
30+
<a href="#development-verification">Development Verification</a> •
3031
<a href="#contributing">Contributing</a> •
3132
<a href="#roadmap">Roadmap</a> •
3233
<a href="#license">License</a> •
@@ -282,6 +283,20 @@ GitHub 프로필(`README.md`)에 동적 배지를 삽입해, 현재 티어와
282283

283284
---
284285

286+
<a id="development-verification"></a>
287+
## 🧪 Development Verification
288+
289+
로컬 변경 후 backend 검증은 아래 순서로 실행합니다.
290+
291+
- `./gradlew test jacocoTestCoverageVerification`: 단위 테스트와 커버리지 검증을 실행합니다. Docker가 없어도 동작합니다.
292+
- `./gradlew verifyDockerAvailable`: Testcontainers 기반 통합 테스트 전에 Docker CLI와 daemon 연결 가능 여부를 fail-fast로 확인합니다.
293+
- `./gradlew integrationTest`: `verifyDockerAvailable`를 먼저 실행한 뒤 `*IT` 통합 테스트를 수행합니다. Docker가 준비되지 않았으면 환경 문제로 즉시 실패합니다.
294+
295+
> [!TIP]
296+
> `verifyDockerAvailable`가 실패하면 먼저 `docker version`이 성공하는지 확인한 뒤 `./gradlew integrationTest`를 다시 실행하세요.
297+
298+
---
299+
285300
<a id="contributing"></a>
286301
## 🤝 Contributing
287302

build.gradle

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ tasks.named('processResources') {
7474
}
7575
}
7676

77+
tasks.register('verifyDockerAvailable', JavaExec) {
78+
group = 'verification'
79+
description = 'Fails fast when Docker is unavailable for Testcontainers-based integration tests.'
80+
mainClass = 'com.gitranker.api.testsupport.DockerPreflightMain'
81+
classpath = sourceSets.test.runtimeClasspath
82+
83+
dependsOn tasks.named('testClasses')
84+
}
85+
7786
// 통합 테스트: *IT.java만 실행 (Docker/Testcontainers 필요)
7887
// check 라이프사이클에 포함하지 않음 — Docker 없는 환경에서 build가 실패하지 않도록 의도적 제외
7988
// CI에서는 별도 단계로 명시적 실행: ./gradlew integrationTest
@@ -86,6 +95,8 @@ tasks.register('integrationTest', Test) {
8695
testClassesDirs = sourceSets.test.output.classesDirs
8796
classpath = sourceSets.test.runtimeClasspath
8897

98+
dependsOn tasks.named('verifyDockerAvailable')
99+
89100
// Docker 29+는 최소 API 1.44를 요구하지만 docker-java 3.4.0은 기본값 1.32로 요청함
90101
systemProperty 'api.version', '1.44'
91102
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package com.gitranker.api.testsupport;
2+
3+
import java.io.InputStream;
4+
import java.io.IOException;
5+
import java.io.UncheckedIOException;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.concurrent.CompletableFuture;
8+
import java.util.List;
9+
import java.util.Objects;
10+
11+
public final class DockerPreflightCheck {
12+
13+
private static final List<String> DOCKER_CONTEXT_COMMAND = List.of("docker", "context", "show");
14+
private static final List<String> DOCKER_VERSION_COMMAND =
15+
List.of("docker", "version", "--format", "{{.Server.APIVersion}}");
16+
17+
private final CommandRunner commandRunner;
18+
19+
public DockerPreflightCheck(CommandRunner commandRunner) {
20+
this.commandRunner = Objects.requireNonNull(commandRunner);
21+
}
22+
23+
public DockerPreflightResult run() {
24+
CommandResult contextResult = commandRunner.run(DOCKER_CONTEXT_COMMAND);
25+
String context = normalizedOrFallback(contextResult.stdout(), "unavailable");
26+
27+
CommandResult versionResult = commandRunner.run(DOCKER_VERSION_COMMAND);
28+
if (versionResult.isSuccess()) {
29+
String apiVersion = normalizedOrFallback(versionResult.stdout(), "unknown");
30+
return DockerPreflightResult.success(
31+
"Docker preflight passed. context=%s serverApiVersion=%s".formatted(context, apiVersion));
32+
}
33+
34+
return DockerPreflightResult.failure(buildFailureMessage(context, versionResult));
35+
}
36+
37+
private String buildFailureMessage(String context, CommandResult versionResult) {
38+
String diagnostic = versionResult.primaryDiagnostic();
39+
String cause = determineCause(versionResult);
40+
String firstStep = isDockerCliMissing(versionResult)
41+
? "- Install Docker Desktop, OrbStack, or another compatible Docker runtime."
42+
: "- Start Docker Desktop, OrbStack, or another compatible Docker runtime and confirm `docker version` succeeds.";
43+
44+
return """
45+
Docker preflight failed for Testcontainers integration tests.
46+
Cause: %s
47+
Context: %s
48+
Details: %s
49+
Next steps:
50+
%s
51+
- If you only need code-level verification, run `./gradlew test jacocoTestCoverageVerification`.
52+
- Re-run `./gradlew integrationTest` after Docker is available.
53+
""".formatted(cause, context, diagnostic, firstStep);
54+
}
55+
56+
private String determineCause(CommandResult versionResult) {
57+
if (isDockerCliMissing(versionResult)) {
58+
return "Docker CLI is not installed or not available on PATH.";
59+
}
60+
if (isDockerDaemonUnavailable(versionResult)) {
61+
return "Docker daemon is not reachable from the current shell.";
62+
}
63+
return "Docker returned a non-zero exit code before Testcontainers could start.";
64+
}
65+
66+
private boolean isDockerCliMissing(CommandResult versionResult) {
67+
String diagnostic = versionResult.primaryDiagnostic().toLowerCase();
68+
return versionResult.exitCode() == 127
69+
|| diagnostic.contains("command not found")
70+
|| diagnostic.contains("no such file or directory");
71+
}
72+
73+
private boolean isDockerDaemonUnavailable(CommandResult versionResult) {
74+
String diagnostic = versionResult.primaryDiagnostic().toLowerCase();
75+
return diagnostic.contains("cannot connect to the docker daemon")
76+
|| diagnostic.contains("is the docker daemon running")
77+
|| diagnostic.contains("error during connect");
78+
}
79+
80+
private String normalizedOrFallback(String value, String fallback) {
81+
String normalized = value == null ? "" : value.trim();
82+
return normalized.isEmpty() ? fallback : normalized;
83+
}
84+
}
85+
86+
@FunctionalInterface
87+
interface CommandRunner {
88+
89+
CommandResult run(List<String> command);
90+
}
91+
92+
record CommandResult(int exitCode, String stdout, String stderr) {
93+
94+
static CommandResult success(String stdout, String stderr) {
95+
return new CommandResult(0, stdout, stderr);
96+
}
97+
98+
static CommandResult failure(int exitCode, String stdout, String stderr) {
99+
if (exitCode == 0) {
100+
throw new IllegalArgumentException("Failure result must have non-zero exit code");
101+
}
102+
return new CommandResult(exitCode, stdout, stderr);
103+
}
104+
105+
boolean isSuccess() {
106+
return exitCode == 0;
107+
}
108+
109+
String primaryDiagnostic() {
110+
String stderrText = stderr == null ? "" : stderr.trim();
111+
if (!stderrText.isEmpty()) {
112+
return stderrText;
113+
}
114+
115+
String stdoutText = stdout == null ? "" : stdout.trim();
116+
if (!stdoutText.isEmpty()) {
117+
return stdoutText;
118+
}
119+
120+
return "No additional docker diagnostic output was produced.";
121+
}
122+
}
123+
124+
record DockerPreflightResult(boolean isSuccess, String message) {
125+
126+
static DockerPreflightResult success(String message) {
127+
return new DockerPreflightResult(true, message);
128+
}
129+
130+
static DockerPreflightResult failure(String message) {
131+
return new DockerPreflightResult(false, message);
132+
}
133+
}
134+
135+
final class ProcessCommandRunner implements CommandRunner {
136+
137+
@Override
138+
public CommandResult run(List<String> command) {
139+
ProcessBuilder processBuilder = new ProcessBuilder(command);
140+
processBuilder.redirectErrorStream(false);
141+
142+
try {
143+
Process process = processBuilder.start();
144+
CompletableFuture<String> stdoutFuture =
145+
CompletableFuture.supplyAsync(() -> readStream(process.getInputStream()));
146+
CompletableFuture<String> stderrFuture =
147+
CompletableFuture.supplyAsync(() -> readStream(process.getErrorStream()));
148+
int exitCode = process.waitFor();
149+
String stdout = stdoutFuture.join();
150+
String stderr = stderrFuture.join();
151+
return new CommandResult(exitCode, stdout, stderr);
152+
} catch (IOException exception) {
153+
return CommandResult.failure(127, "", exception.getMessage());
154+
} catch (InterruptedException exception) {
155+
Thread.currentThread().interrupt();
156+
return CommandResult.failure(130, "", "Interrupted while running command: " + String.join(" ", command));
157+
}
158+
}
159+
160+
private String readStream(InputStream inputStream) {
161+
try {
162+
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
163+
} catch (IOException exception) {
164+
throw new UncheckedIOException(exception);
165+
}
166+
}
167+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.gitranker.api.testsupport;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.util.List;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
10+
11+
class DockerPreflightCheckTest {
12+
13+
@Test
14+
@DisplayName("docker daemon이 reachable이면 성공 결과를 반환한다")
15+
void should_returnSuccess_when_dockerDaemonIsReachable() {
16+
DockerPreflightCheck check = new DockerPreflightCheck(command -> {
17+
if (command.equals(List.of("docker", "context", "show"))) {
18+
return CommandResult.success("orbstack\n", "");
19+
}
20+
if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) {
21+
return CommandResult.success("1.51\n", "");
22+
}
23+
throw new IllegalArgumentException("Unexpected command: " + command);
24+
});
25+
26+
DockerPreflightResult result = check.run();
27+
28+
assertThat(result.isSuccess()).isTrue();
29+
assertThat(result.message())
30+
.isEqualTo("Docker preflight passed. context=orbstack serverApiVersion=1.51");
31+
}
32+
33+
@Test
34+
@DisplayName("docker daemon에 연결할 수 없으면 환경 문제를 설명하는 실패 결과를 반환한다")
35+
void should_returnHelpfulFailure_when_dockerDaemonIsUnavailable() {
36+
DockerPreflightCheck check = new DockerPreflightCheck(command -> {
37+
if (command.equals(List.of("docker", "context", "show"))) {
38+
return CommandResult.success("orbstack\n", "");
39+
}
40+
if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) {
41+
return CommandResult.failure(
42+
1,
43+
"",
44+
"Cannot connect to the Docker daemon at unix:///tmp/docker.sock. Is the docker daemon running?");
45+
}
46+
throw new IllegalArgumentException("Unexpected command: " + command);
47+
});
48+
49+
DockerPreflightResult result = check.run();
50+
51+
assertThat(result.isSuccess()).isFalse();
52+
assertThat(result.message()).contains("Docker preflight failed for Testcontainers integration tests.");
53+
assertThat(result.message()).contains("Cause: Docker daemon is not reachable from the current shell.");
54+
assertThat(result.message()).contains("Context: orbstack");
55+
assertThat(result.message()).contains("docker version");
56+
assertThat(result.message()).contains("./gradlew test jacocoTestCoverageVerification");
57+
assertThat(result.message()).contains("./gradlew integrationTest");
58+
}
59+
60+
@Test
61+
@DisplayName("docker CLI가 없으면 설치 전제조건을 안내하는 실패 결과를 반환한다")
62+
void should_returnHelpfulFailure_when_dockerCliIsMissing() {
63+
DockerPreflightCheck check = new DockerPreflightCheck(command -> {
64+
if (command.equals(List.of("docker", "context", "show"))) {
65+
return CommandResult.failure(127, "", "docker: command not found");
66+
}
67+
if (command.equals(List.of("docker", "version", "--format", "{{.Server.APIVersion}}"))) {
68+
return CommandResult.failure(127, "", "docker: command not found");
69+
}
70+
throw new IllegalArgumentException("Unexpected command: " + command);
71+
});
72+
73+
DockerPreflightResult result = check.run();
74+
75+
assertThat(result.isSuccess()).isFalse();
76+
assertThat(result.message()).contains("Cause: Docker CLI is not installed or not available on PATH.");
77+
assertThat(result.message()).contains("Context: unavailable");
78+
assertThat(result.message()).contains("Install Docker Desktop, OrbStack, or another compatible Docker runtime.");
79+
}
80+
81+
@Test
82+
@DisplayName("failure result는 zero exit code를 허용하지 않는다")
83+
void should_rejectZeroExitCode_when_creatingFailureResult() {
84+
assertThatThrownBy(() -> CommandResult.failure(0, "", "unexpected"))
85+
.isInstanceOf(IllegalArgumentException.class)
86+
.hasMessage("Failure result must have non-zero exit code");
87+
}
88+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.gitranker.api.testsupport;
2+
3+
public final class DockerPreflightMain {
4+
5+
private DockerPreflightMain() {
6+
}
7+
8+
public static void main(String[] args) {
9+
DockerPreflightResult result = new DockerPreflightCheck(new ProcessCommandRunner()).run();
10+
if (result.isSuccess()) {
11+
System.out.println(result.message());
12+
return;
13+
}
14+
15+
System.err.println(result.message());
16+
System.exit(1);
17+
}
18+
}

0 commit comments

Comments
 (0)