Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'");
Expand All @@ -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 가 거부된다.
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading