diff --git a/src/main/java/com/example/dvely/agent/infrastructure/docker/ContainerRole.java b/src/main/java/com/example/dvely/agent/infrastructure/docker/ContainerRole.java new file mode 100644 index 00000000..856323c2 --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/docker/ContainerRole.java @@ -0,0 +1,35 @@ +package com.example.dvely.agent.infrastructure.docker; + +/** + * 컨테이너를 무엇에 쓰는지. 생성 시점에 반드시 정한다. + * + *

둘은 같은 이미지를 쓰지만 수명도 노출면도 다르다. 그런데 한동안 같은 생성 경로를 구분 없이 + * 써서, 프리뷰 쪽 격리 정책을 손대면 배포 빌드까지 함께 흔들렸다 — 바꿀 때마다 배포 파이프라인 + * 전체를 다시 검증해야 한다는 뜻이고, 그래서 아무도 손대지 않게 된다.

+ * + *

기본값을 두지 않는 것이 요점이다. 새 호출부가 생기면 무엇인지 고르게 강제한다 — 기본값이 + * 있으면 고르지 않은 것과 고른 것이 구분되지 않고, 잘못 고른 쪽이 조용히 돈다.

+ */ +public enum ContainerRole { + + /** + * 사용자에게 보여줄 결과물을 서빙한다. 게이트웨이가 프록시할 수 있도록 포트를 게시하고, + * 세션이 살아 있는 동안 유지된다. + */ + PREVIEW, + + /** + * 저장소를 받아 빌드 산출물만 꺼내고 즉시 버린다. 아무것도 서빙하지 않으므로 포트를 + * 게시하지 않는다 — 게시해 봐야 아무도 연결하지 않고, 루프백이라도 열려 있는 면은 없는 편이 낫다. + */ + BUILD; + + public boolean publishesPort() { + return this == PREVIEW; + } + + /** 컨테이너 라벨에 넣는 값. 운영자가 `docker ps` 에서 둘을 갈라 볼 수 있어야 한다. */ + public String label() { + return name().toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java index c22bd11e..252514e6 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java @@ -46,6 +46,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -69,6 +70,7 @@ public class DockerContainerService { private static final String PROJECT_ID_LABEL = "qeploy.projectId"; private static final String CONVERSATION_ID_LABEL = "qeploy.conversationId"; private static final String TASK_ID_LABEL = "qeploy.taskId"; + private static final String ROLE_LABEL = "qeploy.role"; private static final String LEGACY_AGENT_LABEL = "dvely.agent"; // --- Preview container isolation policy (BI-194). Kept as plain constants rather than @@ -123,12 +125,13 @@ public DockerContainerService() { this.dockerClient = dockerClient; } - public String createAndStartContainer(Long userId, + public String createAndStartContainer(ContainerRole role, + Long userId, String previewSessionId, Long projectId, Long conversationId, String taskId) { - return createAndStartContainer(userId, previewSessionId, projectId, + return createAndStartContainer(role, userId, previewSessionId, projectId, conversationId, taskId, MEMORY_LIMIT_BYTES); } @@ -137,12 +140,14 @@ public String createAndStartContainer(Long userId, * {@link #JAVA_MEMORY_LIMIT_BYTES} 를 넘긴다. swap 은 메모리와 같게 둬(추가 swap 없음) OOM 이 * 느린 디스크 뒤로 숨지 않고 깨끗하게 kill 되도록 한다. */ - public String createAndStartContainer(Long userId, + public String createAndStartContainer(ContainerRole role, + Long userId, String previewSessionId, Long projectId, Long conversationId, String taskId, long memoryBytes) { + Objects.requireNonNull(role, "role"); pullImageIfNeeded(); ensurePreviewNetwork(); @@ -164,10 +169,16 @@ public String createAndStartContainer(Long userId, // same host". If preview containers ever move to a remote/multi-host Docker daemon, this // loopback bind must be revisited together with the gateway's proxy target — otherwise // the gateway simply can't reach the container at all. - portBindings.bind(exposedPort, Ports.Binding.bindIpAndPort(HOST_BIND_IP, 0)); + // 빌드 컨테이너는 아무것도 서빙하지 않는다 — 저장소를 받아 산출물만 꺼내고 버린다. + // 게시해 봐야 연결하는 쪽이 없고(getMappedPort 는 프리뷰 경로만 부른다), 루프백이라도 + // 열려 있는 면은 없는 편이 낫다. + if (role.publishesPort()) { + portBindings.bind(exposedPort, Ports.Binding.bindIpAndPort(HOST_BIND_IP, 0)); + } Map labels = new HashMap<>(); labels.put(AGENT_LABEL, "true"); + labels.put(ROLE_LABEL, role.label()); labels.put(USER_ID_LABEL, String.valueOf(userId)); putLabel(labels, PREVIEW_SESSION_ID_LABEL, previewSessionId); putLabel(labels, PROJECT_ID_LABEL, projectId); @@ -181,7 +192,7 @@ public String createAndStartContainer(Long userId, // disabled. Rootfs stays read-write (the agent writes project files into the container) // and no restart policy is set (a dead container surfaces via the status API instead). CreateContainerResponse container = dockerClient.createContainerCmd(IMAGE) - .withExposedPorts(exposedPort) + .withExposedPorts(role.publishesPort() ? List.of(exposedPort) : List.of()) .withHostConfig(HostConfig.newHostConfig() .withPortBindings(portBindings) .withMemory(memoryBytes) diff --git a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java index 7737ffdc..b3fe6463 100644 --- a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java +++ b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java @@ -13,6 +13,7 @@ import com.example.dvely.preview.infrastructure.persistence.entity.PreviewSessionEntity; import com.example.dvely.preview.infrastructure.persistence.repository.SpringDataPreviewSessionRepository; import com.example.dvely.preview.infrastructure.security.PreviewAccessCookies; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -67,6 +68,7 @@ public PreviewSessionInfo acquire(String taskId) { String accessToken = UUID.randomUUID().toString().replace("-", ""); long memoryBytes = runtimeConfigService.previewContainerMemoryBytes(task.projectId()); String containerId = dockerService.createAndStartContainer( + ContainerRole.PREVIEW, task.ownerUserId(), sessionId, task.projectId(), diff --git a/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java b/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java index 4b21942a..55ed9d29 100644 --- a/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java +++ b/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java @@ -11,6 +11,7 @@ import com.example.dvely.preview.infrastructure.persistence.repository.SpringDataPreviewSessionRepository; import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; @@ -131,6 +132,7 @@ public ProvisionOutcome provision(Long projectId, Long ownerUserId, boolean forc try { long memoryBytes = runtimeConfigService.previewContainerMemoryBytes(projectId); containerId = dockerService.createAndStartContainer( + ContainerRole.PREVIEW, ownerUserId, sessionId, projectId, null, null, memoryBytes); hostPort = dockerService.getMappedPort(containerId); } catch (RuntimeException exception) { diff --git a/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java b/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java index d47240fa..a478952f 100644 --- a/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java +++ b/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java @@ -5,6 +5,7 @@ import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; import com.example.dvely.provisioning.infrastructure.EcrImageRegistry.EcrAuth; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -73,6 +74,7 @@ private Path prepareContextTar(Long ownerUserId, Long projectId) { } String sessionId = "img-" + projectId + "-" + System.currentTimeMillis(); String containerId = dockerService.createAndStartContainer( + ContainerRole.BUILD, ownerUserId, sessionId, projectId, null, null); try { sourceClone.cloneInto(containerId, ownerUserId, sourceRepo); diff --git a/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java b/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java index 8e3d4b80..99068c26 100644 --- a/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java +++ b/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java @@ -9,6 +9,7 @@ import com.example.dvely.deployment.application.port.out.FrontendStaticHostingPort; import com.example.dvely.project.domain.repository.ProjectCloudConnectionSettingRepository; import com.example.dvely.provisioning.infrastructure.S3StaticSiteStore; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -45,6 +46,7 @@ public String publishToS3(PublishRequest request) { String sessionId = "site-build-" + request.projectId() + "-" + System.currentTimeMillis(); // 프론트 번들러(vite/webpack)는 메모리를 꽤 쓴다 — 1GiB 기본으로는 큰 앱이 OOM 날 수 있어 2GiB. String containerId = dockerService.createAndStartContainer( + ContainerRole.BUILD, request.ownerUserId(), sessionId, request.projectId(), null, null, DockerContainerService.JAVA_MEMORY_LIMIT_BYTES); try { diff --git a/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java b/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java index 1b1833c3..1cc693ba 100644 --- a/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java +++ b/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java @@ -4,6 +4,7 @@ import com.example.dvely.agent.infrastructure.docker.DockerContainerService.ExecResult; import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -51,6 +52,7 @@ public NativeArtifact build(Long ownerUserId, Long projectId) { String sessionId = "build-" + projectId + "-" + System.currentTimeMillis(); String containerId = dockerService.createAndStartContainer( + ContainerRole.BUILD, ownerUserId, sessionId, projectId, null, null, DockerContainerService.JAVA_MEMORY_LIMIT_BYTES); try { diff --git a/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java b/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java index 7df28dc6..308cd0e7 100644 --- a/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java +++ b/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java @@ -4,6 +4,7 @@ import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; import com.example.dvely.provisioning.infrastructure.EcrImageRegistry.EcrAuth; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.nio.file.Path; import java.util.List; import lombok.RequiredArgsConstructor; @@ -78,6 +79,7 @@ private Path prepareWebContextTar(Long ownerUserId, Long projectId, String front } String sessionId = "web-" + projectId + "-" + System.currentTimeMillis(); String containerId = dockerService.createAndStartContainer( + ContainerRole.BUILD, ownerUserId, sessionId, projectId, null, null); try { sourceClone.cloneInto(containerId, ownerUserId, repo); diff --git a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java index a8a6b156..588e35a6 100644 --- a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java +++ b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java @@ -71,7 +71,7 @@ void tearDown() { @Test void createAndStartContainerPublishesHostPortOnLoopbackOnly() { - containerId = service.createAndStartContainer( + containerId = service.createAndStartContainer(ContainerRole.PREVIEW, 999_000L, "it-session-" + System.nanoTime(), 1L, 1L, "it-task-" + System.nanoTime()); Ports.Binding[] bindings = inspectPortBindings(containerId); @@ -101,7 +101,7 @@ void createAndStartContainerPublishesHostPortOnLoopbackOnly() { // is HostIp. @Test void restartContainerKeepsHostPortOnLoopbackAfterReallocation() { - containerId = service.createAndStartContainer( + containerId = service.createAndStartContainer(ContainerRole.PREVIEW, 999_001L, "it-session-restart-" + System.nanoTime(), 1L, 1L, "it-task-restart-" + System.nanoTime()); int portBeforeRestart = service.getMappedPort(containerId); diff --git a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java index 1b7ba3f7..6c7cc611 100644 --- a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java +++ b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java @@ -161,7 +161,7 @@ void createAndStartContainerAppliesIsolationHostConfig() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); verify(createCommand).withHostConfig(hostConfigCaptor.capture()); @@ -177,6 +177,59 @@ void createAndStartContainerAppliesIsolationHostConfig() { assertThat(hostConfig.getNetworkMode()).isEqualTo("qeploy-preview"); } + /** + * 빌드 컨테이너는 아무것도 서빙하지 않는다 — 저장소를 받아 산출물만 꺼내고 버린다. + * 포트를 게시하면 연결하는 쪽도 없이 면만 늘어난다(getMappedPort 는 프리뷰 경로만 부른다). + * + *

역할 라벨은 운영자가 `docker ps` 에서 둘을 갈라 보기 위한 것이고, 그보다 중요하게는 + * 뒤따르는 격리 작업(#332)이 프리뷰만 골라 바꿀 수 있게 하는 기준이다.

+ */ + @Test + void buildContainerPublishesNoPortAndIsLabelledAsBuild() { + mockNetworkAlreadyExists(true); + CreateContainerCmd createCommand = mock(CreateContainerCmd.class, RETURNS_SELF); + CreateContainerResponse createResponse = mock(CreateContainerResponse.class); + when(dockerClient.createContainerCmd(anyString())).thenReturn(createCommand); + when(createCommand.exec()).thenReturn(createResponse); + when(createResponse.getId()).thenReturn("container-1"); + when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); + + service.createAndStartContainer(ContainerRole.BUILD, 1L, "session-1", 11L, null, null); + + ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); + verify(createCommand).withHostConfig(hostConfigCaptor.capture()); + assertThat(hostConfigCaptor.getValue().getPortBindings().getBindings()).isEmpty(); + + ArgumentCaptor> labelCaptor = ArgumentCaptor.captor(); + verify(createCommand).withLabels(labelCaptor.capture()); + assertThat(labelCaptor.getValue()).containsEntry("qeploy.role", "build"); + + // 격리 정책은 역할과 무관하게 그대로다 — 빌드도 사용자 저장소의 코드를 돌린다. + assertThat(hostConfigCaptor.getValue().getCapDrop()).containsExactly(Capability.ALL); + assertThat(hostConfigCaptor.getValue().getSecurityOpts()).containsExactly("no-new-privileges"); + } + + @Test + void previewContainerKeepsItsLoopbackPortAndIsLabelledAsPreview() { + mockNetworkAlreadyExists(true); + CreateContainerCmd createCommand = mock(CreateContainerCmd.class, RETURNS_SELF); + CreateContainerResponse createResponse = mock(CreateContainerResponse.class); + when(dockerClient.createContainerCmd(anyString())).thenReturn(createCommand); + when(createCommand.exec()).thenReturn(createResponse); + when(createResponse.getId()).thenReturn("container-1"); + when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); + + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); + + ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); + verify(createCommand).withHostConfig(hostConfigCaptor.capture()); + assertThat(hostConfigCaptor.getValue().getPortBindings().getBindings()).isNotEmpty(); + + ArgumentCaptor> labelCaptor = ArgumentCaptor.captor(); + verify(createCommand).withLabels(labelCaptor.capture()); + assertThat(labelCaptor.getValue()).containsEntry("qeploy.role", "preview"); + } + // Issue #76 (BI-081/G1): the host port binding itself must carry HostIp=127.0.0.1, not just // "some port binding exists" — an unset HostIp (the pre-fix Ports.Binding.bindPort(0)) is // exactly the bug this guards against, and would pass a looser "a binding was added" check. @@ -193,7 +246,7 @@ void createAndStartContainerBindsHostPortToLoopbackOnly() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); verify(createCommand).withHostConfig(hostConfigCaptor.capture()); @@ -216,7 +269,7 @@ void createAndStartContainerSkipsNetworkCreationWhenNetworkAlreadyExists() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); verify(dockerClient, never()).createNetworkCmd(); } @@ -234,7 +287,7 @@ void createAndStartContainerWarnsButProceedsWhenExistingNetworkIccMismatched() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - assertThatCode(() -> service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1")) + assertThatCode(() -> service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1")) .doesNotThrowAnyException(); verify(dockerClient).inspectNetworkCmd(); @@ -260,7 +313,7 @@ void createAndStartContainerCreatesNetworkWhenOnlySuperstringMatchExists() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); verify(dockerClient).createNetworkCmd(); verify(dockerClient, never()).inspectNetworkCmd(); @@ -282,7 +335,7 @@ void createAndStartContainerCreatesNetworkWithIccDisabledOption() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); @SuppressWarnings("unchecked") ArgumentCaptor> optionsCaptor = ArgumentCaptor.forClass(Map.class); @@ -307,7 +360,7 @@ void createAndStartContainerIgnoresConcurrentNetworkCreateConflict() { when(createResponse.getId()).thenReturn("container-1"); when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); - String containerId = service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + String containerId = service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); assertThat(containerId).isEqualTo("container-1"); verify(dockerClient).startContainerCmd("container-1"); @@ -326,7 +379,7 @@ void createAndStartContainerSkipsThePullWhenTheImageIsAlreadyLocal() { when(dockerClient.inspectImageCmd(anyString())).thenReturn(mock(InspectImageCmd.class)); mockContainerCreation(); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); verify(dockerClient, never()).pullImageCmd(anyString()); } @@ -343,7 +396,7 @@ void createAndStartContainerStillPullsWhenTheImageIsMissing() { when(inspect.exec()).thenThrow(new NotFoundException("no such image")); mockContainerCreation(); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); verify(dockerClient).pullImageCmd(anyString()); } @@ -360,7 +413,7 @@ void createAndStartContainerBoundsTheContainerLogSize() { mockNetworkAlreadyExists(true); mockContainerCreation(); - service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1"); ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); verify(createCommand).withHostConfig(hostConfigCaptor.capture()); diff --git a/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java b/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java index 43783f85..d5e1f467 100644 --- a/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java +++ b/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java @@ -25,6 +25,7 @@ import com.example.dvely.preview.infrastructure.persistence.repository.SpringDataPreviewSessionRepository; import com.example.dvely.preview.infrastructure.security.PreviewAccessCookies; import com.example.dvely.common.exception.NotFoundException; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; @@ -51,7 +52,7 @@ repository, dockerService, taskStore, properties(), gatewayUrlResolver(), access when(taskStore.get("task-1")).thenReturn(task()); when(repository.findByTaskIdAndStatus("task-1", PreviewSessionStatus.ACTIVE.name())) .thenReturn(Optional.empty()); - when(dockerService.createAndStartContainer(eq(1L), any(String.class), eq(11L), eq(21L), eq("task-1"), anyLong())) + when(dockerService.createAndStartContainer(eq(ContainerRole.PREVIEW), eq(1L), any(String.class), eq(11L), eq(21L), eq("task-1"), anyLong())) .thenReturn("container-1"); when(dockerService.getMappedPort("container-1")).thenReturn(32768); when(repository.save(any(PreviewSessionEntity.class))) @@ -144,6 +145,7 @@ void createsTaskScopedGatewaySession() { when(repository.findByTaskIdAndStatus("task-1", PreviewSessionStatus.ACTIVE.name())) .thenReturn(Optional.empty()); when(dockerService.createAndStartContainer( + eq(ContainerRole.PREVIEW), eq(1L), any(String.class), eq(11L), diff --git a/src/test/java/com/example/dvely/preview/application/service/ProjectPreviewServiceTest.java b/src/test/java/com/example/dvely/preview/application/service/ProjectPreviewServiceTest.java index f1a817c3..0ffcebf6 100644 --- a/src/test/java/com/example/dvely/preview/application/service/ProjectPreviewServiceTest.java +++ b/src/test/java/com/example/dvely/preview/application/service/ProjectPreviewServiceTest.java @@ -24,6 +24,7 @@ import com.example.dvely.preview.infrastructure.persistence.repository.SpringDataPreviewSessionRepository; import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; +import com.example.dvely.agent.infrastructure.docker.ContainerRole; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; @@ -144,7 +145,7 @@ void attachesToALiveSessionInsteadOfStartingASecondContainer() { assertThat(outcome.started()).isFalse(); assertThat(outcome.session().previewUrl()).isNotNull(); - verify(dockerService, never()).createAndStartContainer(any(), anyString(), any(), any(), any(), anyLong()); + verify(dockerService, never()).createAndStartContainer(any(), any(), anyString(), any(), any(), any(), anyLong()); verify(provisioner, never()).provision(anyString()); } @@ -168,7 +169,7 @@ void forceRebuildClosesTheLiveSessionAndStartsFresh() { .filter(s -> PreviewSessionStatus.PROVISIONING.name().equals(s.getStatus())) .toList(); // resolveConcurrentProvisioning — 새로 만든 세션 }); - when(dockerService.createAndStartContainer(eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) + when(dockerService.createAndStartContainer(eq(ContainerRole.PREVIEW), eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) .thenReturn("container-new"); when(dockerService.getMappedPort("container-new")).thenReturn(32772); @@ -179,7 +180,7 @@ void forceRebuildClosesTheLiveSessionAndStartsFresh() { verify(dockerService).removeContainer("container-old"); // 붙지 않고(attach 의 컨테이너 생존 확인조차 안 함) 새로 띄운다. verify(dockerService, never()).isContainerRunning(anyString()); - verify(dockerService).createAndStartContainer(eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong()); + verify(dockerService).createAndStartContainer(eq(ContainerRole.PREVIEW), eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong()); verify(provisioner).provision(anyString()); assertThat(outcome.started()).isTrue(); } @@ -193,7 +194,7 @@ void doesNotStartASecondProvisioningWhileOneIsAlreadyRunning() { ProvisionOutcome outcome = service.provision(PROJECT_ID, USER_ID, false); assertThat(outcome.started()).isTrue(); - verify(dockerService, never()).createAndStartContainer(any(), anyString(), any(), any(), any(), anyLong()); + verify(dockerService, never()).createAndStartContainer(any(), any(), anyString(), any(), any(), any(), anyLong()); verify(provisioner, never()).provision(anyString()); } @@ -201,7 +202,7 @@ void doesNotStartASecondProvisioningWhileOneIsAlreadyRunning() { void startsAProjectScopedSessionWithNoTaskAndHandsItToTheProvisioner() { when(repository.findFirstByProjectIdAndOwnerUserIdAndStatusInOrderByLastAccessedAtDesc( eq(PROJECT_ID), eq(USER_ID), any())).thenReturn(Optional.empty()); - when(dockerService.createAndStartContainer(eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) + when(dockerService.createAndStartContainer(eq(ContainerRole.PREVIEW), eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) .thenReturn("container-new"); when(dockerService.getMappedPort("container-new")).thenReturn(32770); when(repository.findByProjectIdAndOwnerUserIdAndStatusIn(eq(PROJECT_ID), eq(USER_ID), any())) @@ -225,7 +226,7 @@ void startsAProjectScopedSessionWithNoTaskAndHandsItToTheProvisioner() { void aDockerFailureIsReportedAsAnUnavailableEnvironmentInsteadOfAnOpaqueError() { when(repository.findFirstByProjectIdAndOwnerUserIdAndStatusInOrderByLastAccessedAtDesc( eq(PROJECT_ID), eq(USER_ID), any())).thenReturn(Optional.empty()); - when(dockerService.createAndStartContainer(eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) + when(dockerService.createAndStartContainer(eq(ContainerRole.PREVIEW), eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) .thenThrow(new RuntimeException("Cannot connect to the Docker daemon at unix:///var/run/docker.sock")); assertThatThrownBy(() -> service.provision(PROJECT_ID, USER_ID, false)) @@ -244,7 +245,7 @@ void refusesToProvisionAProjectWithNoConnectedRepository() { assertThatThrownBy(() -> service.provision(PROJECT_ID, USER_ID, false)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("저장소"); - verify(dockerService, never()).createAndStartContainer(any(), anyString(), any(), any(), any(), anyLong()); + verify(dockerService, never()).createAndStartContainer(any(), any(), anyString(), any(), any(), any(), anyLong()); } /** @@ -255,7 +256,7 @@ void refusesToProvisionAProjectWithNoConnectedRepository() { void aLosingConcurrentRequestCancelsItselfAndReleasesItsContainer() { when(repository.findFirstByProjectIdAndOwnerUserIdAndStatusInOrderByLastAccessedAtDesc( eq(PROJECT_ID), eq(USER_ID), any())).thenReturn(Optional.empty()); - when(dockerService.createAndStartContainer(eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) + when(dockerService.createAndStartContainer(eq(ContainerRole.PREVIEW), eq(USER_ID), anyString(), eq(PROJECT_ID), eq(null), eq(null), anyLong())) .thenReturn("container-late"); when(dockerService.getMappedPort("container-late")).thenReturn(32771); PreviewSessionEntity earlier = session(PreviewSessionStatus.PROVISIONING, "container-early", null);