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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ out/
application.yml
/.gradle
.DS_Store

### Local notes ###
status.md
Binary file added docs/component.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sequence.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package DGU_AI_LAB.admin_be.domain.alarm.service;

import DGU_AI_LAB.admin_be.domain.alarm.dto.SlackMessageDto;
import DGU_AI_LAB.admin_be.domain.pod.PodPortUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
import DGU_AI_LAB.admin_be.domain.pod.entity.PodExternalPort;
import DGU_AI_LAB.admin_be.domain.pod.repository.PodExternalPortRepository;
import DGU_AI_LAB.admin_be.domain.requests.entity.ChangeRequest;
import DGU_AI_LAB.admin_be.domain.requests.entity.Request;
import DGU_AI_LAB.admin_be.domain.users.entity.User;
import DGU_AI_LAB.admin_be.global.util.MessageUtils;
Expand Down Expand Up @@ -153,6 +154,9 @@ public void sendContainerCreatedEmail(Request request, String sshPort, String ju
jupyterPort = toFarmPublicPort(jupyterPort);
}

List<PodExternalPort> allPorts = podExternalPortRepository.findByRequestRequestId(request.getRequestId());
String extraPorts = PodPortUtils.formatExtraPortSummary(allPorts);

String subject = messageUtils.get("email.container.created.subject", serverName);
String body = messageUtils.get("email.container.created.body",
user.getName(), // {0}
Expand All @@ -161,7 +165,8 @@ public void sendContainerCreatedEmail(Request request, String sshPort, String ju
sshPort, // {3}
jupyterPort, // {4}
resolveHostIp(serverName), // {5}
request.getUbuntuPassword()); // {6}
request.getUbuntuPassword(), // {6}
extraPorts); // {7}

sendMailAlert(user.getEmail(), subject, body);
sendMonitoringLog(user.getName(), user.getEmail(), subject);
Expand Down Expand Up @@ -198,17 +203,22 @@ private String resolveHostIp(String serverName) {
* [관리자 수동 삭제 안내 메일] 관리자가 계정 삭제 시 사용자에게 발송.
*/
public void sendContainerDeletedEmail(Request request) {
List<PodExternalPort> ports = podExternalPortRepository.findByRequestRequestId(request.getRequestId());
sendContainerDeletedEmail(request, ports);
}

public void sendContainerDeletedEmail(Request request, List<PodExternalPort> ports) {
User user = request.getUser();
String serverName = request.getResourceGroup().getServerName();
List<PodExternalPort> ports = podExternalPortRepository.findByRequestRequestId(request.getRequestId());
String podName = request.getPodName() != null ? request.getPodName() : "미배정";

String subject = messageUtils.get("email.container.deleted.subject", serverName);
String body = messageUtils.get("email.container.deleted.body",
user.getName(), // {0}
serverName, // {1}
request.getUbuntuUsername(), // {2}
request.getPodName(), // {3}
formatPortSummary(ports), // {4}
podName, // {3}
PodPortUtils.formatPortSummary(ports), // {4}
LocalDate.now().toString()); // {5}

sendMailAlert(user.getEmail(), subject, body);
Expand All @@ -222,26 +232,67 @@ public void sendContainerExtendedEmail(Request request, LocalDateTime oldExpires
User user = request.getUser();
String serverName = request.getResourceGroup().getServerName();
List<PodExternalPort> ports = podExternalPortRepository.findByRequestRequestId(request.getRequestId());
String oldDate = oldExpiresAt != null ? oldExpiresAt.toLocalDate().toString() : "이전 기록 없음";
String podName = request.getPodName() != null ? request.getPodName() : "미배정";

String subject = messageUtils.get("email.container.extended.subject", serverName);
String body = messageUtils.get("email.container.extended.body",
user.getName(), // {0}
serverName, // {1}
request.getUbuntuUsername(), // {2}
newExpiresAt.toLocalDate().toString(), // {3}
oldExpiresAt.toLocalDate().toString(), // {4}
request.getPodName(), // {5}
formatPortSummary(ports)); // {6}
oldDate, // {4}
podName, // {5}
PodPortUtils.formatPortSummary(ports)); // {6}

sendMailAlert(user.getEmail(), subject, body);
sendMonitoringLog(user.getName(), user.getEmail(), subject);
}

public void sendRequestRejectedEmail(Request request, String adminComment) {
User user = request.getUser();
String serverName = request.getResourceGroup().getServerName();

String subject = messageUtils.get("email.request.rejected.subject", serverName);
String body = messageUtils.get("email.request.rejected.body",
user.getName(), // {0}
serverName, // {1}
adminComment); // {2}

sendMailAlert(user.getEmail(), subject, body);
sendMonitoringLog(user.getName(), user.getEmail(), subject);
}

private String formatPortSummary(List<PodExternalPort> ports) {
if (ports == null || ports.isEmpty()) return "없음";
return ports.stream()
.map(p -> p.getUsagePurpose() + "(" + p.getExternalPort() + ")")
.collect(Collectors.joining(", "));
public void sendModificationRejectedEmail(ChangeRequest changeRequest, String adminComment) {
User user = changeRequest.getRequestedBy();
String changeType = changeRequest.getChangeType().name();

String subject = messageUtils.get("email.modification.rejected.subject", changeType);
String body = messageUtils.get("email.modification.rejected.body",
user.getName(), // {0}
changeType, // {1}
adminComment); // {2}

sendMailAlert(user.getEmail(), subject, body);
sendMonitoringLog(user.getName(), user.getEmail(), subject);
}

/**
* [변경 요청 승인 안내 메일] EXPIRES_AT 외 타입(VOLUME_SIZE/GROUP/RESOURCE_GROUP/CONTAINER_IMAGE/PORT) 공통.
* EXPIRES_AT은 sendContainerExtendedEmail로 별도의 상세 메일을 보낸다.
*/
public void sendModificationApprovedEmail(ChangeRequest changeRequest, String adminComment) {
User user = changeRequest.getRequestedBy();
String changeType = changeRequest.getChangeType().name();

String subject = messageUtils.get("email.modification.approved.subject", changeType);
String body = messageUtils.get("email.modification.approved.body",
user.getName(), // {0}
changeType, // {1}
adminComment); // {2}

sendMailAlert(user.getEmail(), subject, body);
sendMonitoringLog(user.getName(), user.getEmail(), subject);
}

public void sendAdminSlackNotification(String serverName, String message) {
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/DGU_AI_LAB/admin_be/domain/pod/PodPortUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package DGU_AI_LAB.admin_be.domain.pod;

import DGU_AI_LAB.admin_be.domain.pod.entity.PodExternalPort;

import java.util.List;
import java.util.stream.Collectors;

public class PodPortUtils {

private PodPortUtils() {}

/** 전체 포트 목록을 "용도(포트번호), ..." 형식으로 반환. 없으면 "없음". */
public static String formatPortSummary(List<PodExternalPort> ports) {
if (ports == null || ports.isEmpty()) return "없음";
return ports.stream()
.map(p -> p.getUsagePurpose() + "(" + p.getExternalPort() + ")")
.collect(Collectors.joining(", "));
}

/** ssh/jupyter를 제외한 추가 포트만 포맷. 없으면 "없음". */
public static String formatExtraPortSummary(List<PodExternalPort> ports) {
if (ports == null) return "없음";
List<PodExternalPort> extraPorts = ports.stream()
.filter(p -> !"ssh".equalsIgnoreCase(p.getUsagePurpose())
&& !"jupyter".equalsIgnoreCase(p.getUsagePurpose()))
.collect(Collectors.toList());
return formatPortSummary(extraPorts);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@

import DGU_AI_LAB.admin_be.domain.requests.entity.ChangeRequest;
import DGU_AI_LAB.admin_be.domain.requests.entity.Status;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface ChangeRequestRepository extends JpaRepository<ChangeRequest, Long> {
List<ChangeRequest> findAllByStatus(Status status);
List<ChangeRequest> findAllByRequestedBy_UserIdAndStatus(Long userId, Status status);
List<ChangeRequest> findAllByRequestedBy_UserId(Long userId);

/**
* 변경 요청 승인 시 상태 확인 → 반영 사이의 race condition을 막기 위한 행 잠금 조회.
* RequestRepository.findByIdForUpdate와 동일한 목적.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM ChangeRequest c WHERE c.changeRequestId = :changeRequestId")
Optional<ChangeRequest> findByIdForUpdate(@Param("changeRequestId") Long changeRequestId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import DGU_AI_LAB.admin_be.domain.requests.entity.Request;
import DGU_AI_LAB.admin_be.domain.requests.entity.Status;
import DGU_AI_LAB.admin_be.domain.users.entity.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
Expand All @@ -29,6 +31,15 @@ public interface RequestRepository extends JpaRepository<Request, Long> {
@Query("SELECT r.ubuntuUsername FROM Request r WHERE r.status = :status")
List<String> findUbuntuUsernamesByStatus(@Param("status") Status status);

/**
* 승인 처리 중 상태 확인 → 변경 사이의 race condition을 막기 위한 행 잠금 조회.
* 동시에 같은 Request를 승인하려는 두 번째 트랜잭션은 첫 트랜잭션 커밋(짧은 상태-확인 트랜잭션) 때까지 대기한 뒤
* PROCESSING 상태를 보고 INVALID_REQUEST_STATUS로 실패한다.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Request r WHERE r.requestId = :requestId")
Optional<Request> findByIdForUpdate(@Param("requestId") Long requestId);

@Query("SELECT DISTINCT r FROM Request r " +
"JOIN FETCH r.user " +
"LEFT JOIN FETCH r.resourceGroup " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import DGU_AI_LAB.admin_be.domain.groups.repository.GroupRepository;
import DGU_AI_LAB.admin_be.domain.pod.entity.PodExternalPort;
import DGU_AI_LAB.admin_be.domain.pod.repository.PodExternalPortRepository;
import DGU_AI_LAB.admin_be.domain.portRequests.service.PortRequestService;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.ApproveModificationDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.ApproveRequestDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.PortRequestDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.RejectModificationDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.RejectRequestDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.UserCreationRequestDTO;
Expand Down Expand Up @@ -43,6 +45,7 @@
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

Expand All @@ -63,6 +66,7 @@ public class AdminRequestCommandService {
private final PodExternalPortRepository podExternalPortRepository;
private final PodService podService;
private final UbuntuAccountService ubuntuAccountService;
private final PortRequestService portRequestService;
private final ObjectMapper objectMapper;

private final @Qualifier("configWebClient") WebClient userCreationWebClient;
Expand All @@ -76,7 +80,9 @@ public SaveRequestResponseDTO approveRequest(ApproveRequestDTO dto) {
final UserCreationRequestDTO[] creationDtoRef = {null};
final String[] usernameRef = {null};
tx.execute(status -> {
Request req = requestRepository.findById(dto.requestId())
// 행 잠금 조회: 동시에 같은 요청을 승인 시도하는 두 번째 트랜잭션은 여기서 대기하다가
// 첫 트랜잭션 커밋 후 PROCESSING 상태를 보고 아래에서 실패한다 (중복 승인/중복 provisioning 방지)
Request req = requestRepository.findByIdForUpdate(dto.requestId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND));
if (req.getStatus() != Status.PENDING) {
throw new BusinessException(ErrorCode.INVALID_REQUEST_STATUS);
Expand Down Expand Up @@ -219,6 +225,11 @@ public SaveRequestResponseDTO rejectRequest(RejectRequestDTO dto) {
throw new BusinessException(ErrorCode.INVALID_REQUEST_STATUS);
}
request.reject(dto.adminComment());
try {
alarmService.sendRequestRejectedEmail(request, dto.adminComment());
} catch (Exception e) {
log.warn("거절 안내 메일 발송 실패: requestId={}", dto.requestId(), e);
}
return SaveRequestResponseDTO.fromEntity(request);
}

Expand All @@ -236,11 +247,18 @@ public void rejectModification(Long adminId, RejectModificationDTO dto) {
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

changeRequest.deny(admin, dto.adminComment());
try {
alarmService.sendModificationRejectedEmail(changeRequest, dto.adminComment());
} catch (Exception e) {
log.warn("변경 요청 거절 메일 발송 실패: changeRequestId={}", dto.changeRequestId(), e);
}
}

@Transactional
public void approveModification(Long adminId, ApproveModificationDTO dto) {
ChangeRequest changeRequest = changeRequestRepository.findById(dto.changeRequestId())
// 행 잠금 조회: 동시에 같은 변경 요청을 승인 시도하는 두 번째 트랜잭션은 첫 트랜잭션 커밋까지 대기하다가
// FULFILLED 상태를 보고 실패한다 (PORT 등 부수 효과의 중복 실행 방지)
ChangeRequest changeRequest = changeRequestRepository.findByIdForUpdate(dto.changeRequestId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND));

if (changeRequest.getStatus() != Status.PENDING) {
Expand Down Expand Up @@ -272,7 +290,8 @@ public void approveModification(Long adminId, ApproveModificationDTO dto) {
case GROUP:
// 그룹 변경은 복잡하기 때문에, 엔티티가 아닌 서비스 레이어에서 처리합니다.
originalRequest.getRequestGroups().clear();
Set<Long> newGroupIds = objectMapper.readValue(changeRequest.getNewValue(), Set.class);
Set<Long> newGroupIds = objectMapper.readValue(changeRequest.getNewValue(),
objectMapper.getTypeFactory().constructCollectionType(Set.class, Long.class));
Set<Group> newGroups = newGroupIds.stream()
.map(gid -> groupRepository.findByUbuntuGid(gid)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)))
Expand All @@ -294,6 +313,18 @@ public void approveModification(Long adminId, ApproveModificationDTO dto) {
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND));
originalRequest.updateContainerImage(newContainerImage);
break;
case PORT:
List<PortRequestDTO> newPorts = objectMapper.readValue(changeRequest.getNewValue(),
objectMapper.getTypeFactory().constructCollectionType(List.class, PortRequestDTO.class));
for (PortRequestDTO portRequestDTO : newPorts) {
portRequestService.createPortRequest(
originalRequest,
originalRequest.getResourceGroup(),
portRequestDTO.internalPort(),
portRequestDTO.usagePurpose()
);
}
break;
default:
throw new BusinessException(ErrorCode.UNSUPPORTED_CHANGE_TYPE);
}
Expand All @@ -311,6 +342,13 @@ public void approveModification(Long adminId, ApproveModificationDTO dto) {
} catch (Exception e) {
log.warn("기간 연장 안내 메일 발송 실패: changeRequestId={}", dto.changeRequestId(), e);
}
} else {
try {
alarmService.sendModificationApprovedEmail(changeRequest, dto.adminComment());
log.info("사용자 '{}'에게 변경 요청 승인 안내 메일을 발송했습니다.", originalRequest.getUser().getName());
} catch (Exception e) {
log.warn("변경 요청 승인 안내 메일 발송 실패: changeRequestId={}", dto.changeRequestId(), e);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package DGU_AI_LAB.admin_be.domain.requests.service;

import DGU_AI_LAB.admin_be.domain.pod.PodPortUtils;
import DGU_AI_LAB.admin_be.domain.pod.entity.PodExternalPort;
import DGU_AI_LAB.admin_be.domain.pod.repository.PodExternalPortRepository;
import DGU_AI_LAB.admin_be.domain.requests.entity.Request;
Expand All @@ -15,7 +16,6 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
Expand Down Expand Up @@ -43,10 +43,7 @@ public void deleteExpiredRequest(Long requestId) {
String expiresAt = request.getExpiresAt() != null ? request.getExpiresAt().toLocalDate().toString() : "";

List<PodExternalPort> ports = podExternalPortRepository.findByRequestRequestId(requestId);
String portSummary = ports.isEmpty() ? "없음"
: ports.stream()
.map(p -> p.getUsagePurpose() + "(" + p.getExternalPort() + ")")
.collect(Collectors.joining(", "));
String portSummary = PodPortUtils.formatPortSummary(ports);

try {
podService.deletePod(request.getPodName());
Expand Down
Loading