diff --git a/src/main/java/com/example/dvely/agent/application/service/PreviewBranchPushService.java b/src/main/java/com/example/dvely/agent/application/service/PreviewBranchPushService.java index 5354b61..f6da34e 100644 --- a/src/main/java/com/example/dvely/agent/application/service/PreviewBranchPushService.java +++ b/src/main/java/com/example/dvely/agent/application/service/PreviewBranchPushService.java @@ -34,6 +34,8 @@ public void push(String containerId, String repoFullName, boolean isNew, String taskId) { + // apk 는 이미 git 이 있거나 이미지가 alpine 이 아닐 수 있어 실패를 허용한다. 정말 git 이 + // 없으면 아래 strict 명령들이 대신 드러낸다. dockerService.exec(containerId, "apk add --no-cache git"); writeGitCredentials(containerId, username, userToken); dockerService.exec(containerId, "git config --global credential.helper 'store --file /tmp/.git-credentials'"); @@ -46,8 +48,8 @@ public void push(String containerId, if (!hasGit) { if (isNew) writeGitignore(containerId); - dockerService.exec(containerId, "cd /workspace/app && git init -b preview"); - dockerService.exec(containerId, "cd /workspace/app && git remote add origin " + remoteUrl); + execOrThrow(containerId, "cd /workspace/app && git init -b preview", "git init"); + execOrThrow(containerId, "cd /workspace/app && git remote add origin " + remoteUrl, "git remote add"); // 원격에 이미 preview 가 있으면 그 커밋을 부모로 삼는다. 저장소를 연결할 때 // preparePreviewBranch 가 기본 브랜치 HEAD 에서 preview 를 갈라두기 때문에, 갓 init 한 // 로컬 히스토리를 그대로 올리면 두 히스토리에 공통 조상이 없어 push 가 거부된다. @@ -57,15 +59,40 @@ public void push(String containerId, + "(git fetch origin preview 2>/dev/null " + "&& git reset --soft FETCH_HEAD) || true"); } else { - dockerService.exec(containerId, "cd /workspace/app && git remote set-url origin " + remoteUrl); - dockerService.exec(containerId, "cd /workspace/app && git checkout -B preview"); + execOrThrow(containerId, "cd /workspace/app && git remote set-url origin " + remoteUrl, "git remote set-url"); + execOrThrow(containerId, "cd /workspace/app && git checkout -B preview", "git checkout -B preview"); } - dockerService.exec(containerId, "cd /workspace/app && git add -A"); - dockerService.exec(containerId, + execOrThrow(containerId, "cd /workspace/app && git add -A", "git add"); + // 변경이 없으면 git diff --cached --quiet 가 0 으로 끝나 커밋을 건너뛴다. 변경이 있으면 + // 1 을 주고 커밋이 돌며, 그 커밋이 실패하면 전체가 0 이 아니다 — 그대로 실패로 본다. + execOrThrow(containerId, "cd /workspace/app && git diff --cached --quiet || git commit -m 'feat: apply Qeploy Agent task " - + taskId + "'"); - dockerService.exec(containerId, "cd /workspace/app && git push -u origin preview"); + + taskId + "'", "git commit"); + execOrThrow(containerId, "cd /workspace/app && git push -u origin preview", "git push"); + } + + /** + * 실패하면 던진다. + * + * push 가 실패해도 조용히 넘어가던 것이 이 메서드가 생긴 이유다. DockerContainerService#exec + * 은 종료 코드를 읽지 않아 인증 실패든 보호 브랜치든 그냥 문자열이 돌아왔고, 호출자는 + * 성공으로 알고 다음으로 갔다. 그 결과 감사 로그에는 PREVIEW_BRANCH_PUSHED 가 성공으로 남고, + * 사용자에게는 "작업물을 preview 브랜치에 올렸습니다 — 프리뷰가 만료돼도 코드는 남습니다"가 + * 표시된다. 실제로는 아무것도 올라가지 않았고, 컨테이너가 만료되면 작업물은 사라진다. + * + * 예외 메시지에는 명령 전문을 넣지 않는다. 이 클래스는 자격 증명을 다루고, 그 명령줄이 + * 로그나 사용자 화면으로 흘러가면 안 된다 — 어떤 단계였는지와 git 이 남긴 출력만 남긴다. + */ + private void execOrThrow(String containerId, String command, String step) { + DockerContainerService.ExecResult result = dockerService.execWithExitCode(containerId, command); + if (result.succeeded()) { + return; + } + String output = result.output() == null ? "" : result.output().trim(); + String tail = output.length() > 500 ? output.substring(output.length() - 500) : output; + throw new IllegalStateException( + "preview 브랜치에 올리지 못했습니다(" + step + ", exitCode=" + result.exitCode() + "): " + tail); } private void writeGitCredentials(String containerId, String username, String userToken) { diff --git a/src/test/java/com/example/dvely/agent/application/service/DeployAgentServiceTest.java b/src/test/java/com/example/dvely/agent/application/service/DeployAgentServiceTest.java index 9e4a927..efe0bd7 100644 --- a/src/test/java/com/example/dvely/agent/application/service/DeployAgentServiceTest.java +++ b/src/test/java/com/example/dvely/agent/application/service/DeployAgentServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -50,6 +51,10 @@ class DeployAgentServiceTest { @Test void deploysApprovedRequestAfterPushingRequestCommitToPreview() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -109,15 +114,15 @@ void deploysApprovedRequestAfterPushingRequestCommitToPreview() { ); assertThat(result.summary()).contains("승인된 변경 사항의 배포 요청", "배포 ID: 51"); - verify(dockerService).exec("container-1", "cd /workspace/app && git checkout -B preview"); - verify(dockerService).exec( + verify(dockerService).execWithExitCode("container-1", "cd /workspace/app && git checkout -B preview"); + verify(dockerService).execWithExitCode( "container-1", "cd /workspace/app && git diff --cached --quiet || " + "git commit -m 'feat: apply Qeploy Agent task task123'" ); - verify(dockerService).exec("container-1", "cd /workspace/app && git push -u origin preview"); - verify(dockerService, never()).exec(anyString(), contains("--force")); - verify(dockerService, never()).exec(anyString(), contains("origin main")); + verify(dockerService).execWithExitCode("container-1", "cd /workspace/app && git push -u origin preview"); + verify(dockerService, never()).execWithExitCode(anyString(), contains("--force")); + verify(dockerService, never()).execWithExitCode(anyString(), contains("origin main")); verify(deploymentFacade).deploy( 1L, 11L, @@ -137,6 +142,10 @@ void deploysApprovedRequestAfterPushingRequestCommitToPreview() { @Test void recordsRepositoryCreatedAndPreviewBranchPushedWhenANewRepositoryIsCreated() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -188,6 +197,10 @@ authCommandService, projectRepository, deploymentFacade, mock(InputWaitStore.cla @Test void autoBindRepositoryRetriesOnceAfterOptimisticLockingFailureAndSucceeds() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -230,6 +243,10 @@ authCommandService, projectRepository, deploymentFacade, mock(InputWaitStore.cla @Test void autoBindRepositoryReturnsReloadedProjectWithoutResavingWhenAlreadyBoundAfterRace() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -272,6 +289,10 @@ authCommandService, projectRepository, deploymentFacade, mock(InputWaitStore.cla @Test void autoBindRepositoryPropagatesWhenTheRetryAlsoHitsAnOptimisticLockingFailure() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -315,6 +336,10 @@ void autoBindRepositoryFailsFastWhenReloadAfterTheRaceFindsTheProjectGone() { // (F2 test above) — the project was deleted entirely between the failed first save and // the retry's reload. That must fail fast with a clear message, not NPE or loop. DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -353,6 +378,10 @@ authCommandService, projectRepository, deploymentFacade, mock(InputWaitStore.cla @Test void deployRetriesOnceAfterOptimisticLockingFailureAndSucceeds() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); @@ -385,6 +414,10 @@ authCommandService, projectRepository, deploymentFacade, mock(InputWaitStore.cla @Test void deployPropagatesWhenTheRetryAlsoHitsAnOptimisticLockingFailure() { DockerContainerService dockerService = mock(DockerContainerService.class); + // push 의 각 단계는 이제 종료 코드를 본다(PreviewBranchPushService#execOrThrow). + // 기본값을 주지 않으면 null 이 돌아와 NPE 가 난다. + lenient().when(dockerService.execWithExitCode(anyString(), anyString())) + .thenReturn(new DockerContainerService.ExecResult(0, "")); PreviewSessionService previewSessionService = mock(PreviewSessionService.class); GithubRepositoryPort githubRepositoryPort = mock(GithubRepositoryPort.class); UserRepository userRepository = mock(UserRepository.class); diff --git a/src/test/java/com/example/dvely/agent/application/service/PreviewBranchPushServiceTest.java b/src/test/java/com/example/dvely/agent/application/service/PreviewBranchPushServiceTest.java new file mode 100644 index 0000000..a199392 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/application/service/PreviewBranchPushServiceTest.java @@ -0,0 +1,124 @@ +package com.example.dvely.agent.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.dvely.agent.infrastructure.docker.DockerContainerService; +import com.example.dvely.agent.infrastructure.docker.DockerContainerService.ExecResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PreviewBranchPushServiceTest { + + private static final String CONTAINER_ID = "container-1"; + private static final String TOKEN = "ghu_supersecrettoken"; + + private DockerContainerService dockerService; + private PreviewBranchPushService service; + + @BeforeEach + void setUp() { + dockerService = mock(DockerContainerService.class); + service = new PreviewBranchPushService(dockerService); + // .git 이 없는 상태 → init 경로를 탄다. + lenient().when(dockerService.exec(eq(CONTAINER_ID), anyString())).thenReturn(""); + lenient().when(dockerService.exec(eq(CONTAINER_ID), contains("/.git ]"))).thenReturn("no"); + lenient().when(dockerService.execWithExitCode(eq(CONTAINER_ID), anyString())) + .thenReturn(new ExecResult(0, "")); + } + + /** + * 이 테스트가 이 클래스의 존재 이유다. + * + * push 가 실패해도 예외가 없으면 호출자는 성공으로 알고 다음으로 간다. 그러면 감사 로그에 + * PREVIEW_BRANCH_PUSHED 가 성공으로 남고, 사용자에게는 "작업물을 preview 브랜치에 + * 올렸습니다 — 프리뷰가 만료돼도 코드는 남습니다"가 표시된다. 실제로는 아무것도 올라가지 + * 않았고, 컨테이너가 만료되면 작업물은 사라진다. + */ + @Test + void aFailedPushIsNotReportedAsSuccess() { + when(dockerService.execWithExitCode(eq(CONTAINER_ID), contains("git push"))) + .thenReturn(new ExecResult(128, "remote: Permission to octo/app.git denied")); + + assertThatThrownBy(() -> push()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("preview 브랜치에 올리지 못했습니다") + .hasMessageContaining("git push"); + } + + /** + * 이 클래스는 자격 증명을 다룬다. 실패 메시지가 로그와 사용자 화면으로 흘러가므로 명령 전문을 + * 넣으면 토큰이 함께 샌다. + */ + @Test + void theFailureMessageNeverCarriesTheToken() { + when(dockerService.execWithExitCode(eq(CONTAINER_ID), contains("git push"))) + .thenReturn(new ExecResult(128, "fatal: Authentication failed")); + + assertThatThrownBy(() -> push()) + .isInstanceOf(IllegalStateException.class) + .satisfies(thrown -> assertThat(thrown.getMessage()).doesNotContain(TOKEN)); + } + + /** 푸시 전 단계가 깨졌는데 계속 진행하면, 올라가는 내용이 의도와 달라진다. */ + @Test + void aFailedStageStopsBeforeThePush() { + when(dockerService.execWithExitCode(eq(CONTAINER_ID), contains("git add"))) + .thenReturn(new ExecResult(1, "fatal: not a git repository")); + + assertThatThrownBy(() -> push()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("git add"); + + verify(dockerService, never()).execWithExitCode(eq(CONTAINER_ID), contains("git push")); + } + + /** + * 변경이 없으면 git diff --cached --quiet 가 0 으로 끝나 커밋을 건너뛴다. 그 경우까지 실패로 + * 보면 "고칠 것이 없었다"가 오류가 된다. + */ + @Test + void anEmptyCommitIsNotAFailure() { + when(dockerService.execWithExitCode(eq(CONTAINER_ID), contains("git commit"))) + .thenReturn(new ExecResult(0, "")); + + assertThatCode(this::push).doesNotThrowAnyException(); + } + + @Test + void aSucceededPushGoesThroughEveryStageInOrder() { + assertThatCode(this::push).doesNotThrowAnyException(); + + verify(dockerService).execWithExitCode(eq(CONTAINER_ID), contains("git init -b preview")); + verify(dockerService).execWithExitCode(eq(CONTAINER_ID), contains("git remote add origin")); + verify(dockerService).execWithExitCode(eq(CONTAINER_ID), contains("git add -A")); + verify(dockerService).execWithExitCode(eq(CONTAINER_ID), contains("git push -u origin preview")); + } + + /** + * apk 는 이미 git 이 깔려 있거나 이미지가 alpine 이 아닐 수 있다. 그것까지 실패로 보면 멀쩡히 + * 돌던 컨테이너에서 푸시가 막힌다 — 정말 git 이 없으면 뒤의 strict 단계가 드러낸다. + */ + @Test + void installingGitIsAllowedToFail() { + service = new PreviewBranchPushService(dockerService); + + assertThatCode(this::push).doesNotThrowAnyException(); + + verify(dockerService).exec(eq(CONTAINER_ID), contains("apk add")); + verify(dockerService, never()).execWithExitCode(eq(CONTAINER_ID), contains("apk add")); + } + + private void push() { + service.push(CONTAINER_ID, TOKEN, "octo", "octo/app", true, "task-1"); + } +}