diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/repository/ContainerImageRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/repository/ContainerImageRepository.java index 7035c4aa..8aa705db 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/repository/ContainerImageRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/repository/ContainerImageRepository.java @@ -4,7 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface ContainerImageRepository extends JpaRepository { boolean existsByImageNameAndImageVersion(String imageName, String imageVersion); + + Optional findByImageNameAndImageVersion(String imageName, String imageVersion); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AdminRequestApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AdminRequestApi.java index f396c9ad..b47f9144 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AdminRequestApi.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AdminRequestApi.java @@ -93,9 +93,10 @@ record ContainerListResponseDoc(int status, String message, List> startRestart(Long requestId, RestartPodRequestDTO dto); diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/MigrateRegisterRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/MigrateRegisterRequestDTO.java index d9fbbf5d..b049061c 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/MigrateRegisterRequestDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/MigrateRegisterRequestDTO.java @@ -20,22 +20,29 @@ public record MigrateRegisterRequestDTO( Boolean force, Boolean recreate, @JsonProperty("keep_changes") Boolean keepChanges, - @JsonProperty("access_blocked") Boolean accessBlocked + @JsonProperty("access_blocked") Boolean accessBlocked, + String image ) { /** 다른 노드로 옮기는 작업. */ public static MigrateRegisterRequestDTO move(Long requestId, String podName, String username, List nodes, Double minImprovementRatio, Boolean force) { - return new MigrateRegisterRequestDTO(requestId, podName, username, nodes, minImprovementRatio, force, null, null, null); + return new MigrateRegisterRequestDTO(requestId, podName, username, nodes, minImprovementRatio, force, null, null, null, null); } /** 현재 노드에서 다시 만드는 작업. keepChanges면 컨테이너 변경분을 그 노드에 구워 새 Pod에 이어 준다. */ public static MigrateRegisterRequestDTO restart(Long requestId, String podName, String username, boolean keepChanges) { - return new MigrateRegisterRequestDTO(requestId, podName, username, null, null, null, true, keepChanges, null); + return restart(requestId, podName, username, keepChanges, null); + } + + /** image(name:version)를 주면 새 Pod를 그 이미지로 만든다. 구운 변경분은 옛 이미지 위라 keepChanges와 함께 쓸 수 없다. */ + public static MigrateRegisterRequestDTO restart(Long requestId, String podName, String username, boolean keepChanges, + String image) { + return new MigrateRegisterRequestDTO(requestId, podName, username, null, null, null, true, keepChanges, null, image); } /** 접속이 막혀 있어야 하는 사용자의 작업이면 그 표시를 단다. 아니면 키를 뺀다. */ public MigrateRegisterRequestDTO accessBlocked(boolean blocked) { return new MigrateRegisterRequestDTO(requestId, podName, username, nodes, minImprovementRatio, force, recreate, - keepChanges, blocked ? Boolean.TRUE : null); + keepChanges, blocked ? Boolean.TRUE : null, image); } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RestartPodRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RestartPodRequestDTO.java index ae6f41dd..db9c2add 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RestartPodRequestDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RestartPodRequestDTO.java @@ -4,10 +4,12 @@ @Schema(description = "컨테이너 재시작 요청 DTO") public record RestartPodRequestDTO( - @Schema(description = "컨테이너 안에 설치한 패키지 등 변경분을 유지할지 (생략 시 true). false면 기본 이미지로 초기화", example = "true") - Boolean keepChanges + @Schema(description = "컨테이너 안에 설치한 패키지 등 변경분을 유지할지 (생략 시 true, imageId를 주면 false). false면 기본 이미지로 초기화", example = "true") + Boolean keepChanges, + @Schema(description = "관리자만: 이 이미지로 다시 만든다(생략하면 신청에 묶인 이미지). 기존 변경분은 이어 줄 수 없어 keepChanges를 true로 줄 수 없다", example = "3") + Long imageId ) { public boolean keepsChanges() { - return !Boolean.FALSE.equals(keepChanges); + return keepChanges == null ? imageId == null : keepChanges; } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/JobResultResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/JobResultResponseDTO.java index 8a982ed8..4bc25f4b 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/JobResultResponseDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/JobResultResponseDTO.java @@ -38,18 +38,27 @@ public record Result( @JsonProperty("old_pod_cleanup") String oldPodCleanup, // 생성 작업만: 계정에 넣은 보조 그룹과 그 gid. 승인 대기 그룹(gid 없이 보낸 그룹)은 작업이 발급한 gid 를 // 여기서 받아 채운다. - List groups + List groups, + // 마이그레이션 작업만: 이미지를 바꿔 다시 만들었다면 새 Pod가 쓴 이미지(name:version) + String image ) { /** 생성 작업 결과. */ public Result(Long uid, Long gid, String podName, String node, List ports) { - this(uid, gid, podName, node, ports, null, null, null, null, null, null, null); + this(uid, gid, podName, node, ports, null, null, null, null, null, null, null, null); } /** 그룹 결과가 없는 작업 결과(마이그레이션 등). */ public Result(Long uid, Long gid, String podName, String node, List ports, String status, String reason, String fromNode, String toNode, String oldPodName, String oldPodCleanup) { - this(uid, gid, podName, node, ports, status, reason, fromNode, toNode, oldPodName, oldPodCleanup, null); + this(uid, gid, podName, node, ports, status, reason, fromNode, toNode, oldPodName, oldPodCleanup, null, null); + } + + /** 이미지 없이 그룹 결과까지 있는 작업 결과(생성). */ + public Result(Long uid, Long gid, String podName, String node, List ports, + String status, String reason, String fromNode, String toNode, String oldPodName, + String oldPodCleanup, List groups) { + this(uid, gid, podName, node, ports, status, reason, fromNode, toNode, oldPodName, oldPodCleanup, groups, null); } public boolean isMigrated() { diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java index b4aef0b0..9767a0f9 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java @@ -175,6 +175,11 @@ public void prepareAsyncApproval(ContainerImage image, ResourceGroup resourceGro } } + /** 이미지를 바꿔 다시 만든 작업이 성공했을 때 신청의 이미지를 새 Pod가 쓰는 이미지로 맞춘다. */ + public void changeContainerImage(ContainerImage image) { + this.containerImage = image; + } + /** 등록해 둔 생성 작업이 성공했을 때 승인을 확정한다. 관리자가 고른 값은 이미 반영돼 있다. */ public void completeApproval() { transitionTo(Status.FULFILLED, "처리 중인 신청만 승인을 확정할 수 있습니다."); diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java index 88530525..d1408b40 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationService.java @@ -5,6 +5,8 @@ import DGU_AI_LAB.admin_be.domain.requests.job.JobResults; import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; import DGU_AI_LAB.admin_be.domain.alarm.service.UserGuideMailer; +import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; +import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; import DGU_AI_LAB.admin_be.domain.nodes.entity.Node; import DGU_AI_LAB.admin_be.domain.nodes.repository.NodeRepository; import DGU_AI_LAB.admin_be.domain.requests.dto.request.MigratePodRequestDTO; @@ -60,6 +62,7 @@ public class PodMigrationService { private final SuspensionGuard suspensionGuard; private final PodExternalPortRepository podExternalPortRepository; private final NodeRepository nodeRepository; + private final ContainerImageRepository containerImageRepository; private final JobClient jobClient; private final PlatformTransactionManager transactionManager; private final AlarmService alarmService; @@ -112,9 +115,24 @@ public void startMigration(Long requestId, MigratePodRequestDTO dto) { dto.nodes(), dto.minImprovementRatio(), dto.force())); } - /** 관리자가 현재 노드에서 다시 만든다(GPU 목록 갱신 등). */ + /** 관리자가 현재 노드에서 다시 만든다(GPU 목록 갱신 등). imageId를 주면 그 이미지로 바꿔 만든다. */ public void startRestart(Long requestId, RestartPodRequestDTO dto) { - start(requestId, req -> { }, req -> { }, req -> restartJob(req, dto)); + // 이미지 확인은 행 잠금·상태 검증 뒤(job 안)에서 한다 — 상태 오류(409)가 이미지 오류보다 먼저여야 한다. + start(requestId, req -> { }, req -> { }, req -> restartJob(req, dto, imageRefOf(dto))); + } + + /** 상태 전환과 같은 트랜잭션 안에서 확인해(거절하면 롤백) name:version으로 돌려준다. 이미지를 고르지 않았으면 null. */ + private String imageRefOf(RestartPodRequestDTO dto) { + if (dto == null || dto.imageId() == null) { + return null; + } + if (dto.keepsChanges()) { + throw new BusinessException("이미지를 바꾸면 컨테이너 변경분을 이어 줄 수 없습니다. keepChanges를 false로 주세요.", + ErrorCode.INVALID_INPUT_VALUE); + } + ContainerImage image = containerImageRepository.findById(dto.imageId()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + return image.getImageName() + ":" + image.getImageVersion(); } /** @@ -122,6 +140,10 @@ public void startRestart(Long requestId, RestartPodRequestDTO dto) { * 횟수는 실제로 시작할 수 있는 요청만 센다(상태 검증에 막힌 요청이 횟수를 쓰지 않게 전환 뒤에 센다). */ public void startOwnRestart(Long userId, Long requestId, RestartPodRequestDTO dto) { + // 이미지 선택은 관리자 권한이다. 조용히 무시하면 사용자는 이미지가 바뀐 줄 안다. + if (dto != null && dto.imageId() != null) { + throw new BusinessException(ErrorCode.FORBIDDEN_REQUEST); + } // start()는 먼저 끝난 작업 결과를 당겨 반영한다. 남의 신청에는 그것도 일어나지 않게 여기서 한 번 거른다 // (잠금 안에서 다시 확인한다). requireOwner(requestRepository.findById(requestId) @@ -132,8 +154,12 @@ public void startOwnRestart(Long userId, Long requestId, RestartPodRequestDTO dt } private static MigrateRegisterRequestDTO restartJob(Request req, RestartPodRequestDTO dto) { + return restartJob(req, dto, null); + } + + private static MigrateRegisterRequestDTO restartJob(Request req, RestartPodRequestDTO dto, String image) { return MigrateRegisterRequestDTO.restart(req.getRequestId(), req.getPodName(), req.getUbuntuUsername(), - dto == null || dto.keepsChanges()); + dto == null || dto.keepsChanges(), image); } private static void requireOwner(Request req, Long userId) { @@ -283,6 +309,7 @@ public void completeMigrationJob(Long requestId, JobResultResponseDTO.Result mad final boolean[] applied = {false}; final Long[] ownerId = {null}; final Request[] portsChangedRequest = {null}; + final boolean[] imageUnmatched = {false}; try { new TransactionTemplate(transactionManager).execute(status -> { Request req = requestRepository.findByIdForUpdate(requestId) @@ -294,6 +321,10 @@ public void completeMigrationJob(Long requestId, JobResultResponseDTO.Result mad if (made != null && made.isMigrated()) { Set oldPorts = portKeys(podExternalPortRepository.findByRequestRequestId(requestId)); req.assignPodInfo(made.podName(), made.node()); + if (made.image() != null) { + // 새 Pod는 이미 이 이미지로 떠 있다. 이미지가 그 사이 지워졌어도 반영은 이어 가고 알린다. + imageUnmatched[0] = !applyImage(req, made.image()); + } podExternalPortRepository.deleteByRequestRequestId(requestId); if (made.ports() != null) { for (CreatePodResponseDTO.PortInfo port : made.ports()) { @@ -335,11 +366,30 @@ public void completeMigrationJob(Long requestId, JobResultResponseDTO.Result mad if (portsChangedRequest[0] != null) { notifyPortsChanged(portsChangedRequest[0]); } + if (imageUnmatched[0]) { + if (alertDeduplicator.firstOccurrence("migration-image-unmatched:" + requestId + ":" + made.image())) { + alert(null, "notification.admin.migration.image-unmatched", requestId, made.image()); + } + } } else { log.info("Pod 마이그레이션 건너뜀: requestId={}, reason={}", requestId, made == null ? null : made.reason()); } } + /** name:version을 마지막 콜론에서 갈라 신청의 이미지를 바꾼다(이름에 레지스트리 포트의 콜론이 올 수 있다). 못 찾으면 false. */ + private boolean applyImage(Request req, String ref) { + int colon = ref.lastIndexOf(':'); + if (colon <= 0 || colon == ref.length() - 1) { + return false; + } + return containerImageRepository.findByImageNameAndImageVersion(ref.substring(0, colon), ref.substring(colon + 1)) + .map(image -> { + req.changeContainerImage(image); + return true; + }) + .orElse(false); + } + /** * 작업이 실패로 끝났다. 제어기가 새 Pod를 정리했고 기존 Pod는 그대로이므로 FULFILLED로 되돌리고 처리 기록(noti)으로 * 남긴다. 변경분을 굽느라 멈춘 기존 컨테이너를 다시 풀지 못한 실패는 "기존 컨테이너 유지"가 사실이 아니므로 문구만 달리한다. diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 5660c9e5..fb5782ab 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -561,6 +561,7 @@ notification.admin.approval.apply-failed=[승인 확인 필요] 생성 작업 notification.admin.approval.revert-failed=[보상 트랜잭션 실패] 요청 상태 복구 실패 - 수동 확인 필요: server={0}, requestId={1} notification.admin.migration.register-unconfirmed=[마이그레이션 확인 필요] 작업 등록 결과를 확인하지 못해 MIGRATING으로 두었습니다: requestId={0}, error={1} notification.admin.migration.result-missing=[마이그레이션 확인 필요] 작업은 성공했으나 결과 정보를 받지 못해 신청에 반영하지 못했습니다: requestId={0} +notification.admin.migration.image-unmatched=[마이그레이션 확인 필요] 이미지를 바꿔 다시 만들었지만 그 이미지를 DB에서 찾지 못해 신청의 이미지는 그대로입니다: requestId={0}, image={1} notification.admin.migration.apply-failed=[마이그레이션] 결과 DB 반영 실패 - Pod/포트 상태 수동 확인 필요: requestId={0} notification.admin.migration.old-pod-cleanup-failed=[마이그레이션] 새 Pod는 정상 반영됐지만 기존 Pod 정리 실패 - 수동 확인 필요: requestId={0}, oldPod={1}, oldNode={2} notification.admin.migration.job-failed=[마이그레이션 실패] 기존 컨테이너를 유지합니다: requestId={0}, error={1} diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationServiceTest.java index 0c15aacf..1aa68965 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationServiceTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/requests/service/PodMigrationServiceTest.java @@ -1,5 +1,7 @@ package DGU_AI_LAB.admin_be.domain.requests.service; +import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; +import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; import DGU_AI_LAB.admin_be.domain.warnings.service.SuspensionGuard; import DGU_AI_LAB.admin_be.support.Alerts; import DGU_AI_LAB.admin_be.global.alert.InMemoryAlertDeduplicator; @@ -55,6 +57,7 @@ class PodMigrationServiceTest { @Mock private RequestRepository requestRepository; @Mock private PodExternalPortRepository podExternalPortRepository; @Mock private NodeRepository nodeRepository; + @Mock private ContainerImageRepository containerImageRepository; @Mock private JobClient jobClient; @Mock private PlatformTransactionManager transactionManager; @Mock private TransactionStatus transactionStatus; @@ -72,7 +75,7 @@ class PodMigrationServiceTest { void setUp() { when(request.getJobId()).thenReturn(10L); // result()의 작업 번호와 같다 when(transactionManager.getTransaction(any())).thenReturn(transactionStatus); - service = new PodMigrationService(requestRepository, suspensionGuard, podExternalPortRepository, nodeRepository, jobClient, transactionManager, alarmService, userGuideMailer, + service = new PodMigrationService(requestRepository, suspensionGuard, podExternalPortRepository, nodeRepository, containerImageRepository, jobClient, transactionManager, alarmService, userGuideMailer, restartThrottle, new InMemoryAlertDeduplicator()); when(nodeRepository.findAllByResourceGroup(resourceGroup)).thenReturn(List.of(node("FARM2"), node("FARM7"))); when(request.getUbuntuUsername()).thenReturn("testuser"); @@ -170,7 +173,7 @@ void startRegistersJob() { @Test @DisplayName("관리자 재시작은 노드 목록 없이 현재 노드 재생성 작업을 등록하고 횟수를 세지 않는다") void adminRestartRegistersRecreateJob() { - service.startRestart(1L, new RestartPodRequestDTO(false)); + service.startRestart(1L, new RestartPodRequestDTO(false, null)); verify(request).beginMigration(); verify(jobClient).registerMigrate(MigrateRegisterRequestDTO.restart(1L, "ailab-testuser-old", "testuser", false)); @@ -543,7 +546,7 @@ void adminRestartOfSuspendedUserKeepsAccessBlocked() { when(user.getUserId()).thenReturn(7L); when(suspensionGuard.isSuspended(7L)).thenReturn(true); - service.startRestart(1L, new RestartPodRequestDTO(false)); + service.startRestart(1L, new RestartPodRequestDTO(false, null)); verify(jobClient).registerMigrate( MigrateRegisterRequestDTO.restart(1L, "ailab-testuser-old", "testuser", false).accessBlocked(true)); @@ -559,4 +562,113 @@ void completedMigrationReblocksNewPorts() { verify(suspensionGuard).reblockAfterPortsCreated(7L); } + + private static ContainerImage image(String name, String version) { + return ContainerImage.builder().imageName(name).imageVersion(version).cudaVersion("12.1").description("d").build(); + } + + private static JobResultResponseDTO.Result migratedTo(String image) { + return new JobResultResponseDTO.Result(null, null, "ailab-testuser-new", "farm2", List.of(), "migrated", null, + "farm2", "farm2", "ailab-testuser-old", null, null, image); + } + + @Test + @DisplayName("관리자가 이미지를 골라 재시작하면 변경분 없이 그 이미지로 다시 만드는 작업을 등록한다") + void adminRestartWithImageRegistersImage() { + when(containerImageRepository.findById(5L)).thenReturn(Optional.of(image("dguailab/decs", "2"))); + + service.startRestart(1L, new RestartPodRequestDTO(null, 5L)); + + verify(jobClient).registerMigrate( + MigrateRegisterRequestDTO.restart(1L, "ailab-testuser-old", "testuser", false, "dguailab/decs:2")); + } + + @Test + @DisplayName("이미지를 바꾸면서 변경분 유지를 요구하면 아무것도 바꾸지 않고 거절한다") + void restartWithImageAndKeepChangesIsRejected() { + assertThatThrownBy(() -> service.startRestart(1L, new RestartPodRequestDTO(true, 5L))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + verifyNoInteractions(jobClient); + } + + @Test + @DisplayName("이미 옮기는 중인 신청이면 이미지 검증보다 상태 오류가 먼저 난다") + void stateErrorComesBeforeImageValidation() { + doThrow(new BusinessException(ErrorCode.INVALID_REQUEST_STATUS)).when(request).beginMigration(); + + assertThatThrownBy(() -> service.startRestart(1L, new RestartPodRequestDTO(null, 9L))) + .extracting(e -> ((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.INVALID_REQUEST_STATUS); + verifyNoInteractions(containerImageRepository, jobClient); + } + + @Test + @DisplayName("없는 이미지를 고르면 작업을 등록하지 않고 404로 거절한다(트랜잭션 롤백)") + void restartWithUnknownImageIsNotFound() { + when(containerImageRepository.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.startRestart(1L, new RestartPodRequestDTO(null, 9L))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.RESOURCE_NOT_FOUND); + verifyNoInteractions(jobClient); + } + + @Test + @DisplayName("사용자 본인 재시작은 이미지를 고를 수 없다") + void ownRestartRejectsImage() { + assertThatThrownBy(() -> service.startOwnRestart(7L, 1L, new RestartPodRequestDTO(false, 5L))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN_REQUEST); + verify(request, never()).beginMigration(); + verifyNoInteractions(jobClient, restartThrottle); + } + + @Test + @DisplayName("이미지를 바꿔 다시 만든 작업이 성공하면 신청의 이미지를 새 이미지로 바꾼다") + void completeRestartWithImageChangesRequestImage() { + when(request.getStatus()).thenReturn(Status.MIGRATING); + ContainerImage next = image("dguailab/decs", "2"); + when(containerImageRepository.findByImageNameAndImageVersion("dguailab/decs", "2")).thenReturn(Optional.of(next)); + + service.completeMigrationJob(1L, migratedTo("dguailab/decs:2")); + + verify(request).changeContainerImage(next); + verify(request).endMigration(); + } + + @Test + @DisplayName("레지스트리 포트가 든 이미지 이름은 마지막 콜론에서 버전을 가른다") + void imageRefSplitsOnLastColon() { + when(request.getStatus()).thenReturn(Status.MIGRATING); + ContainerImage next = image("reg:5000/decs", "2"); + when(containerImageRepository.findByImageNameAndImageVersion("reg:5000/decs", "2")).thenReturn(Optional.of(next)); + + service.completeMigrationJob(1L, migratedTo("reg:5000/decs:2")); + + verify(request).changeContainerImage(next); + } + + @Test + @DisplayName("결과의 이미지를 DB에서 못 찾으면 옛 이미지를 두고 관리자에게 알린다") + void completeRestartWithUnknownImageKeepsOldImageAndAlerts() { + when(request.getStatus()).thenReturn(Status.MIGRATING); + when(containerImageRepository.findByImageNameAndImageVersion(anyString(), anyString())).thenReturn(Optional.empty()); + + service.completeMigrationJob(1L, migratedTo("gone/decs:9")); + + verify(request, never()).changeContainerImage(any()); + verify(request).endMigration(); + assertThat(Alerts.needsAction(alarmService)).filteredOn(a -> a.contains("migration.image-unmatched")).hasSize(1); + } + + @Test + @DisplayName("이미지를 바꾸지 않은 마이그레이션은 신청의 이미지를 건드리지 않는다") + void completeWithoutImageLeavesRequestImage() { + when(request.getStatus()).thenReturn(Status.MIGRATING); + + service.completeMigrationJob(1L, migrated(null)); + + verify(request, never()).changeContainerImage(any()); + verifyNoInteractions(containerImageRepository); + } }