diff --git a/build.gradle b/build.gradle index 991b8ed9..d14cdd92 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,8 @@ dependencies { // smtp implementation 'org.springframework.boot:spring-boot-starter-mail' + // kubernetes + implementation 'io.fabric8:kubernetes-client:6.10.0' } tasks.named('test') { diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/AlarmController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/AlarmController.java new file mode 100644 index 00000000..d3e1ddc3 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/AlarmController.java @@ -0,0 +1,42 @@ +package DGU_AI_LAB.admin_be.domain.alarm.controller; + +import DGU_AI_LAB.admin_be.domain.alarm.controller.docs.AlarmApi; +import DGU_AI_LAB.admin_be.domain.alarm.dto.request.CombinedAlertRequestDTO; +import DGU_AI_LAB.admin_be.domain.alarm.dto.request.EmailRequestDTO; +import DGU_AI_LAB.admin_be.domain.alarm.dto.request.SlackDMRequestDTO; +import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/alert") +public class AlarmController implements AlarmApi { + + private final AlarmService alarmService; + + @PostMapping("/dm") + public ResponseEntity sendSlackDMAlert(@RequestBody @Valid SlackDMRequestDTO request) { + alarmService.sendDMAlert(request.username(), request.email(), request.message()); + return ResponseEntity.ok("Alert sent to Slack DM"); + } + + @PostMapping("/email") + public ResponseEntity sendEmailAlert(@RequestBody @Valid EmailRequestDTO request) { + alarmService.sendMailAlert(request.to(), request.subject(), request.body()); + return ResponseEntity.ok("Alert sent to Email"); + } + + @PostMapping + public ResponseEntity sendAllAlerts(@RequestBody @Valid CombinedAlertRequestDTO request) { + alarmService.sendAllAlerts( + request.username(), + request.email(), + request.subject(), + request.message() + ); + return ResponseEntity.ok("Alert sent to Slack DM and Email"); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/docs/AlarmApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/docs/AlarmApi.java new file mode 100644 index 00000000..bb8b40c9 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/docs/AlarmApi.java @@ -0,0 +1,57 @@ +package DGU_AI_LAB.admin_be.domain.alarm.controller.docs; + +import DGU_AI_LAB.admin_be.domain.alarm.dto.request.CombinedAlertRequestDTO; +import DGU_AI_LAB.admin_be.domain.alarm.dto.request.EmailRequestDTO; +import DGU_AI_LAB.admin_be.domain.alarm.dto.request.SlackDMRequestDTO; +import DGU_AI_LAB.admin_be.global.common.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Slack 및 E-mail", description = "Slack 및 Email 알림 API") +public interface AlarmApi { + + @Operation( + summary = "Slack DM 알림 전송", + description = "Slack 사용자에게 개인 DM으로 알림 메시지를 전송합니다. 이름이 중복될 경우 이메일이 일치하는 사용자에게 전송됩니다." + ) + @ApiResponse( + responseCode = "200", description = "Slack DM 전송 성공", + content = @Content(schema = @Schema(implementation = SuccessResponse.class)) + ) + ResponseEntity sendSlackDMAlert( + @RequestBody(description = "Slack DM 알림 요청 DTO", required = true) + @Valid SlackDMRequestDTO request + ); + + @Operation( + summary = "Email 알림 전송", + description = "Email로 알림 메시지를 전송합니다." + ) + @ApiResponse( + responseCode = "200", description = "Email 전송 성공", + content = @Content(schema = @Schema(implementation = SuccessResponse.class)) + ) + ResponseEntity sendEmailAlert( + @RequestBody(description = "이메일 알림 요청 DTO", required = true) + @Valid EmailRequestDTO request + ); + + @Operation( + summary = "Slack DM + Email 통합 알림 전송", + description = "Slack DM과 Email을 동시에 전송합니다." + ) + @ApiResponse( + responseCode = "200", description = "Slack + Email 알림 전송 성공", + content = @Content(schema = @Schema(implementation = SuccessResponse.class)) + ) + ResponseEntity sendAllAlerts( + @RequestBody(description = "통합 알림 요청 DTO", required = true) + @Valid CombinedAlertRequestDTO request + ); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/CombinedAlertRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/CombinedAlertRequestDTO.java new file mode 100644 index 00000000..196885c5 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/CombinedAlertRequestDTO.java @@ -0,0 +1,21 @@ +package DGU_AI_LAB.admin_be.domain.alarm.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record CombinedAlertRequestDTO( + @NotBlank + String username, + + @NotBlank + @Email + String email, + + @NotBlank + String subject, + + @NotBlank + String message +) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/EmailRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/EmailRequestDTO.java new file mode 100644 index 00000000..8b37dcc8 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/EmailRequestDTO.java @@ -0,0 +1,18 @@ +package DGU_AI_LAB.admin_be.domain.alarm.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record EmailRequestDTO( + @NotBlank + @Email + String to, + + @NotBlank + String subject, + + @NotBlank + String body +) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/SlackDMRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/SlackDMRequestDTO.java new file mode 100644 index 00000000..c15c7055 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/SlackDMRequestDTO.java @@ -0,0 +1,18 @@ +package DGU_AI_LAB.admin_be.domain.alarm.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record SlackDMRequestDTO( + @NotBlank + String username, + + @NotBlank + @Email + String email, + + @NotBlank + String message +) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java new file mode 100644 index 00000000..0b860a8d --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java @@ -0,0 +1,171 @@ +package DGU_AI_LAB.admin_be.domain.alarm.service; + +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +// import java.util.Map; +import DGU_AI_LAB.admin_be.error.ErrorCode; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class AlarmService { + + @Value("${slack-webhook-url.monitoring}") + private String defaultWebhookUrl; + + @Value("${slack.bot-token}") + private String botToken; + private final RestTemplate restTemplate = new RestTemplate(); + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String from; + + public void sendSlackAlert(String message) { + sendSlackAlert(message, null); + } + + public void sendSlackAlert(String message, String webhookUrl) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map payload = Map.of("text", message); + HttpEntity> request = new HttpEntity<>(payload, headers); + + String urlToUse = (webhookUrl != null && !webhookUrl.isEmpty()) ? webhookUrl : defaultWebhookUrl; + + ResponseEntity response = restTemplate.postForEntity(urlToUse, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.debug("Slack 알림 전송 실패: {}", response.getStatusCode()); + } else { + log.debug("Slack 알림 전송 성공"); + } + } + public void sendMailAlert(String to, String subject, String body) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + mailSender.send(message); + System.out.printf("메일 전송 성공: 수신자=%s, 제목=%s%n", to, subject); + } + + // slack dm 전송 + public void sendDMAlert(String username, String email, String message) { + String userId = getSlackUser(username, email, botToken); + if (userId == null) { + throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); + } + + String channelId = openDMChannel(userId, botToken); + if (channelId == null) { + throw new BusinessException(ErrorCode.SLACK_DM_CHANNEL_FAILED); + } + + try { + sendMessageToSlackChannel(channelId, message, botToken); + } catch (Exception e) { + throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); + } + } + + // 이름이 일치하는 사용자에게 dm 전송 + // 이름이 같은 사용자가 있는 경우 email이 일치하는 사용자에게 dm 전송 + private String getSlackUser(String username, String email, String token) { + String url = "https://slack.com/api/users.list"; + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); + if (!Boolean.TRUE.equals(response.getBody().get("ok"))) { + throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); + } + + List> members = (List>) response.getBody().get("members"); + + // 이름이 일치하는 사용자 목록 필터링 + List> matchedUsers = members.stream() + .filter(user -> { + Map profile = (Map) user.get("profile"); + String displayName = (String) profile.get("display_name"); + String realName = (String) profile.get("real_name"); + String name = (String) user.get("name"); + + return username.equals(name) || username.equals(displayName) || username.equals(realName); + }) + .collect(Collectors.toList()); + + if (matchedUsers.isEmpty()) { + throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); + } + + if (matchedUsers.size() == 1) { + return (String) matchedUsers.get(0).get("id"); + } + + Map selectedUser = matchedUsers.stream() + .filter(user -> { + Map profile = (Map) user.get("profile"); + String userEmail = (String) profile.get("email"); + return userEmail != null && userEmail.equalsIgnoreCase(email); + }) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.SLACK_USER_EMAIL_NOT_MATCH)); + + return (String) selectedUser.get("id"); + } + + private String openDMChannel(String userId, String token) { + String url = "https://slack.com/api/conversations.open"; + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = Map.of("users", userId); + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); + if (Boolean.TRUE.equals(response.getBody().get("ok"))) { + Map channel = (Map) response.getBody().get("channel"); + return (String) channel.get("id"); + } + return null; + } + + private void sendMessageToSlackChannel(String channelId, String message, String token) { + String url = "https://slack.com/api/chat.postMessage"; + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = Map.of( + "channel", channelId, + "text", message + ); + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); + if (!Boolean.TRUE.equals(response.getBody().get("ok"))) { + throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); + } + } + + public void sendAllAlerts(String username, String email, String subject, String message) { + sendDMAlert(username, email, message); + sendMailAlert(email, subject, message); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/ContainerImageController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/ContainerImageController.java new file mode 100644 index 00000000..cb22161c --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/ContainerImageController.java @@ -0,0 +1,38 @@ +package DGU_AI_LAB.admin_be.domain.containerImage.controller; + +import DGU_AI_LAB.admin_be.domain.containerImage.controller.docs.ContainerImageApi; +import DGU_AI_LAB.admin_be.domain.containerImage.dto.request.ContainerImageCreateRequest; +import DGU_AI_LAB.admin_be.domain.containerImage.dto.response.ContainerImageResponseDTO; +import DGU_AI_LAB.admin_be.domain.containerImage.service.ContainerImageService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/images") +public class ContainerImageController implements ContainerImageApi { + + private final ContainerImageService containerImageService; + + @PostMapping + public ResponseEntity createImage( + @RequestBody @Valid ContainerImageCreateRequest request + ) { + ContainerImageResponseDTO createdImage = containerImageService.createImage(request); + return ResponseEntity.ok(createdImage); + } + + @GetMapping("/{id}") + public ResponseEntity getImageById(@PathVariable Long id) { + return ResponseEntity.ok(containerImageService.getImageById(id)); + } + + @GetMapping + public ResponseEntity> getAllImages() { + return ResponseEntity.ok(containerImageService.getAllImages()); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/docs/ContainerImageApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/docs/ContainerImageApi.java new file mode 100644 index 00000000..95b1c3ba --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/docs/ContainerImageApi.java @@ -0,0 +1,39 @@ +package DGU_AI_LAB.admin_be.domain.containerImage.controller.docs; + +import DGU_AI_LAB.admin_be.domain.containerImage.dto.request.ContainerImageCreateRequest; +import DGU_AI_LAB.admin_be.domain.containerImage.dto.response.ContainerImageResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@Tag(name = "이미지 관리", description = "컨테이너 이미지 생성 및 조회 API") +public interface ContainerImageApi { + + @Operation(summary = "이미지 생성", description = "새로운 컨테이너 이미지를 등록합니다.") + @ApiResponse(responseCode = "200", description = "이미지 생성 성공", + content = @Content(schema = @Schema(implementation = ContainerImageResponseDTO.class))) + ResponseEntity createImage( + @RequestBody @Valid ContainerImageCreateRequest request + ); + + @Operation(summary = "단일 이미지 조회", description = "이미지 ID로 단일 이미지를 조회합니다.") + @ApiResponse(responseCode = "200", description = "이미지 조회 성공", + content = @Content(schema = @Schema(implementation = ContainerImageResponseDTO.class))) + ResponseEntity getImageById( + @PathVariable Long id + ); + + @Operation(summary = "전체 이미지 목록 조회", description = "등록된 모든 이미지를 조회합니다.") + @ApiResponse(responseCode = "200", description = "이미지 목록 조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ContainerImageResponseDTO.class)))) + ResponseEntity> getAllImages(); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/dto/request/ContainerImageCreateRequest.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/dto/request/ContainerImageCreateRequest.java new file mode 100644 index 00000000..76432c9c --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/dto/request/ContainerImageCreateRequest.java @@ -0,0 +1,10 @@ +package DGU_AI_LAB.admin_be.domain.containerImage.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ContainerImageCreateRequest( + @NotNull String imageName, + @NotNull String imageVersion, + @NotNull String cudaVersion, + String description +) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/dto/response/ContainerImageResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/dto/response/ContainerImageResponseDTO.java new file mode 100644 index 00000000..8a7de6db --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/dto/response/ContainerImageResponseDTO.java @@ -0,0 +1,27 @@ +package DGU_AI_LAB.admin_be.domain.containerImage.dto.response; + +import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record ContainerImageResponseDTO( + Long imageId, + String imageName, + String imageVersion, + String cudaVersion, + String description, + LocalDateTime createdAt +) { + public static ContainerImageResponseDTO fromEntity(ContainerImage image) { + return ContainerImageResponseDTO.builder() + .imageId(image.getImageId()) + .imageName(image.getImageName()) + .imageVersion(image.getImageVersion()) + .cudaVersion(image.getCudaVersion()) + .description(image.getDescription()) + .createdAt(image.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/entity/ContainerImage.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/entity/ContainerImage.java index 2d932cda..a9e97707 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/entity/ContainerImage.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/entity/ContainerImage.java @@ -1,22 +1,30 @@ package DGU_AI_LAB.admin_be.domain.containerImage.entity; +import DGU_AI_LAB.admin_be.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @Entity @Table(name = "container_image") -@IdClass(ContainerImageId.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -public class ContainerImage { - +public class ContainerImage extends BaseTimeEntity { @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long imageId; + @Column(name = "image_name", length = 100, nullable = false) private String imageName; - @Id @Column(name = "image_version", length = 100, nullable = false) private String imageVersion; + + @Column(name = "cuda_version", length = 100, nullable = false) + private String cudaVersion; + + @Column(name = "description", length = 500, nullable = false) + private String description; } 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 254d4934..a539a4b3 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,10 +4,9 @@ import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImageId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; - import java.util.Optional; @Repository -public interface ContainerImageRepository extends JpaRepository { +public interface ContainerImageRepository extends JpaRepository { Optional findByImageNameAndImageVersion(String imageName, String imageVersion); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/service/ContainerImageService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/service/ContainerImageService.java new file mode 100644 index 00000000..09202eb6 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/service/ContainerImageService.java @@ -0,0 +1,48 @@ +package DGU_AI_LAB.admin_be.domain.containerImage.service; + +import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; +import DGU_AI_LAB.admin_be.domain.containerImage.dto.request.ContainerImageCreateRequest; +import DGU_AI_LAB.admin_be.domain.containerImage.dto.response.ContainerImageResponseDTO; +import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ContainerImageService { + + private final ContainerImageRepository imageRepository; + + @Transactional + public ContainerImageResponseDTO createImage(ContainerImageCreateRequest request) { + ContainerImage image = ContainerImage.builder() + .imageName(request.imageName()) + .imageVersion(request.imageVersion()) + .cudaVersion(request.cudaVersion()) + .description(request.description()) + .build(); + + ContainerImage saved = imageRepository.save(image); + return ContainerImageResponseDTO.fromEntity(saved); + } + + @Transactional(readOnly = true) + public ContainerImageResponseDTO getImageById(Long id) { + ContainerImage image = imageRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND)); + return ContainerImageResponseDTO.fromEntity(image); + } + + @Transactional(readOnly = true) + public List getAllImages() { + return imageRepository.findAll().stream() + .map(ContainerImageResponseDTO::fromEntity) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardService.java index 2bc3dfca..1911077c 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/dashboard/service/DashboardService.java @@ -45,7 +45,7 @@ public List getUserServers(Long userId, Status status) { if (resourceGroup != null) { resourceGroupName = resourceGroup.getDescription(); - List nodesInGroup = nodeRepository.findByRsgroupId(resourceGroup.getRsgroupId()); + List nodesInGroup = nodeRepository.findAllByResourceGroup(resourceGroup); if (!nodesInGroup.isEmpty()) { Node representativeNode = nodesInGroup.get(0); cpuCoreCount = representativeNode.getCpuCoreCount(); diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/dto/response/GpuTypeResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/dto/response/GpuTypeResponseDTO.java index de96abf1..c6d6a69d 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/dto/response/GpuTypeResponseDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/dto/response/GpuTypeResponseDTO.java @@ -1,5 +1,6 @@ package DGU_AI_LAB.admin_be.domain.gpus.dto.response; +import DGU_AI_LAB.admin_be.domain.gpus.repository.GpuRepository; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -13,6 +14,8 @@ public record GpuTypeResponseDTO( @Schema(description = "GPU RAM 크기 (GB)", example = "24") Integer ramGb, + String description, + @Schema(description = "GPU가 속한 리소스 그룹명 (같은 스펙 묶음)", example = "RTX 3090") String resourceGroupName, @@ -40,4 +43,13 @@ public static GpuTypeResponseDTO fromQueryResult(Object[] queryResult) { .isAvailable(true) // 현재는 항상 true로 가정 .build(); } + + public static GpuTypeResponseDTO fromSummary(GpuRepository.GpuSummary s) { + return GpuTypeResponseDTO.builder() + .gpuModel(s.getGpuModel()) + .ramGb(s.getRamGb()) + .description(s.getDescription()) + .availableNodes(s.getNodeCount()) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/repository/GpuRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/repository/GpuRepository.java index cdccb977..89a5c9ee 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/repository/GpuRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/gpus/repository/GpuRepository.java @@ -10,12 +10,38 @@ @Repository public interface GpuRepository extends JpaRepository { - @Query("SELECT g.gpuModel, g.ramGb, rg.description, COUNT(DISTINCT n.nodeId) " + - "FROM Gpu g JOIN g.node n JOIN ResourceGroup rg ON n.rsgroupId = rg.rsgroupId " + - "GROUP BY g.gpuModel, g.ramGb, rg.description") - List findGpuSummary(); - @Query("SELECT DISTINCT n.cpuCoreCount, n.memorySizeGB " + - "FROM Gpu g JOIN g.node n " + - "WHERE g.gpuModel = :gpuModel") - List findNodeSpecsByGpuModel(String gpuModel); + // GPU 요약 프로젝션 + interface GpuSummary { + String getGpuModel(); + Integer getRamGb(); + String getDescription(); + Long getNodeCount(); + } + + @Query(""" + SELECT g.gpuModel AS gpuModel, + g.ramGb AS ramGb, + rg.description AS description, + COUNT(DISTINCT n.nodeId) AS nodeCount + FROM Gpu g + JOIN g.node n + JOIN n.resourceGroup rg + GROUP BY g.gpuModel, g.ramGb, rg.description + """) + List findGpuSummary(); + + // GPU 모델별 노드 사양 조회 + interface NodeSpec { + Integer getCpuCoreCount(); + Integer getMemorySizeGB(); + } + + @Query(""" + SELECT DISTINCT n.cpuCoreCount AS cpuCoreCount, + n.memorySizeGB AS memorySizeGB + FROM Gpu g + JOIN g.node n + WHERE g.gpuModel = :gpuModel + """) + List findNodeSpecsByGpuModel(String gpuModel); } \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/entity/Node.java b/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/entity/Node.java index 1a7d1fd8..2380dcb0 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/entity/Node.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/entity/Node.java @@ -1,11 +1,13 @@ package DGU_AI_LAB.admin_be.domain.nodes.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import DGU_AI_LAB.admin_be.domain.gpus.entity.Gpu; +import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; +import jakarta.persistence.*; import lombok.*; +import java.util.LinkedHashSet; +import java.util.Set; + @Entity @Table(name = "nodes") @Getter @@ -22,12 +24,21 @@ public class Node { @Column(name = "node_id", length = 100) private String nodeId; - @Column(name = "rsgroup_id", nullable = false) - private Integer rsgroupId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "rsgroup_id", nullable = false) + private ResourceGroup resourceGroup; @Column(name = "memory_size_GB", nullable = false) private Integer memorySizeGB; @Column(name = "CPU_core_count", nullable = false) private Integer cpuCoreCount; + + @OneToMany(mappedBy = "node", fetch = FetchType.LAZY) + @Builder.Default + private Set gpus = new LinkedHashSet<>(); + + public int getNumberGpu() { + return gpus == null ? 0 : gpus.size(); + } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/repository/NodeRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/repository/NodeRepository.java index ba745f99..c411ac62 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/repository/NodeRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/nodes/repository/NodeRepository.java @@ -1,6 +1,7 @@ package DGU_AI_LAB.admin_be.domain.nodes.repository; import DGU_AI_LAB.admin_be.domain.nodes.entity.Node; +import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,5 +9,5 @@ @Repository public interface NodeRepository extends JpaRepository { - List findByRsgroupId(Integer rsgroupId); + List findAllByResourceGroup(ResourceGroup resourceGroup); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/pod/controller/PodController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/pod/controller/PodController.java new file mode 100644 index 00000000..eee233db --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/pod/controller/PodController.java @@ -0,0 +1,51 @@ +package DGU_AI_LAB.admin_be.domain.pod.controller; + +import DGU_AI_LAB.admin_be.domain.pod.controller.docs.PodApi; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.*; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import DGU_AI_LAB.admin_be.domain.pod.dto.response.PodResponseDTO; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/pods") +public class PodController implements PodApi { + + private final KubernetesClient client; + + // 전체 pod 목록 조회 + @GetMapping + public List getPods() { + return client.pods() + .inNamespace("default") + .list() + .getItems() + .stream() + .map(pod -> pod.getMetadata().getName()) + .collect(Collectors.toList()); + } + + // 단일 pod 정보 조회 + @GetMapping("/{podName}") + public PodResponseDTO getPodDetail(@PathVariable String podName) { + Pod pod = client.pods() + .inNamespace("default") + .withName(podName) + .get(); + + if (pod == null) { + throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND); + } + + return PodResponseDTO.fromEntity(pod); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/pod/controller/docs/PodApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/pod/controller/docs/PodApi.java new file mode 100644 index 00000000..bb168cc4 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/pod/controller/docs/PodApi.java @@ -0,0 +1,52 @@ +package DGU_AI_LAB.admin_be.domain.pod.controller.docs; + +import DGU_AI_LAB.admin_be.domain.pod.dto.response.PodResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Kubernetes Pods", description = "쿠버네티스 Pod 조회 API") +public interface PodApi { + + @Operation( + summary = "전체 Pod 목록 조회" + ) + @ApiResponse( + responseCode = "200", + description = "조회 성공" + ) + @ApiResponse( + responseCode = "500", + description = "서버 오류", + content = @Content + ) + List getPods(); + + @Operation( + summary = "단일 pod 정보 조회" + ) + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = PodResponseDTO.class)) + ) + @ApiResponse( + responseCode = "404", + description = "해당 이름의 Pod를 찾을 수 없음", + content = @Content + ) + @ApiResponse( + responseCode = "500", + description = "서버 오류", + content = @Content + ) + PodResponseDTO getPodDetail( + @Parameter(description = "조회할 Pod의 이름") + String podName + ); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/pod/dto/response/PodResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/pod/dto/response/PodResponseDTO.java new file mode 100644 index 00000000..f3eaa4e8 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/pod/dto/response/PodResponseDTO.java @@ -0,0 +1,71 @@ +package DGU_AI_LAB.admin_be.domain.pod.dto.response; + +import io.fabric8.kubernetes.api.model.Pod; +import lombok.Builder; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Builder +public record PodResponseDTO( + String name, + String namespace, + String status, + String creationTimestamp, + Map labels, + Map annotations, + List containers, + List volumes, + String hostIP, + String nodeName +) { + + @Builder + public record ContainerDTO( + String name, + String image + ) { + } + + @Builder + public record VolumeDTO( + String name + ) { + } + + public static PodResponseDTO fromEntity(Pod pod) { + List volumes = pod.getSpec() != null && pod.getSpec().getVolumes() != null + ? pod.getSpec().getVolumes().stream() + .map(volume -> VolumeDTO.builder() + .name(volume.getName()) + .build()) + .collect(Collectors.toList()) + : List.of(); + + List containers = pod.getSpec() != null && pod.getSpec().getContainers() != null + ? pod.getSpec().getContainers().stream() + .map(container -> ContainerDTO.builder() + .name(container.getName()) + .image(container.getImage()) + .build()) + .collect(Collectors.toList()) + : List.of(); + + String hostIP = pod.getStatus() != null ? pod.getStatus().getHostIP() : null; + String nodeName = pod.getSpec() != null ? pod.getSpec().getNodeName() : null; + + return PodResponseDTO.builder() + .name(pod.getMetadata().getName()) + .namespace(pod.getMetadata().getNamespace()) + .status(pod.getStatus() != null ? pod.getStatus().getPhase() : "Unknown") + .creationTimestamp(pod.getMetadata().getCreationTimestamp()) + .labels(pod.getMetadata().getLabels()) + .annotations(pod.getMetadata().getAnnotations()) + .containers(containers) + .volumes(volumes) + .hostIP(hostIP) + .nodeName(nodeName) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AcceptInfoController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AcceptInfoController.java new file mode 100644 index 00000000..9bb85eda --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AcceptInfoController.java @@ -0,0 +1,24 @@ +package DGU_AI_LAB.admin_be.domain.requests.controller; + +import DGU_AI_LAB.admin_be.domain.requests.controller.docs.AcceptInfoApi; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.AcceptInfoResponseDTO; +import DGU_AI_LAB.admin_be.domain.requests.service.RequestService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/acceptinfo") +public class AcceptInfoController implements AcceptInfoApi { + private final RequestService requestService; + + @GetMapping("/{username}") + public ResponseEntity getAcceptInfo(@PathVariable String username) { + AcceptInfoResponseDTO response = requestService.getAcceptInfo(username); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AdminRequestController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AdminRequestController.java index 28748112..db3c3a8e 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AdminRequestController.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/AdminRequestController.java @@ -1,9 +1,13 @@ package DGU_AI_LAB.admin_be.domain.requests.controller; -import DGU_AI_LAB.admin_be.domain.requests.dto.request.ApproveModificationDTO; +import DGU_AI_LAB.admin_be.domain.requests.controller.docs.AdminRequestApi; +import DGU_AI_LAB.admin_be.domain.requests.dto.request.ApproveRequestDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.request.RejectRequestDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.ContainerInfoDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.ResourceUsageDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; import DGU_AI_LAB.admin_be.domain.requests.service.RequestService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -13,15 +17,15 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/admin/requests") -public class AdminRequestController { +public class AdminRequestController implements AdminRequestApi { private final RequestService requestService; - @PostMapping("/modify/approve") + /*@PostMapping("/modify/approve") public ResponseEntity approveModification(@RequestBody ApproveModificationDTO dto) { requestService.approveModification(dto); return ResponseEntity.ok().build(); - } + }*/ @GetMapping("/usage") public ResponseEntity> getAllResourceUsage() { @@ -32,4 +36,20 @@ public ResponseEntity> getAllResourceUsage() { public ResponseEntity> getAllActiveContainers() { return ResponseEntity.ok(requestService.getActiveContainers()); } + + @GetMapping + public ResponseEntity> getAllRequests() { + List requests = requestService.getAllRequests(); + return ResponseEntity.ok(requests); + } + + @PatchMapping("/approval") + public ResponseEntity approve(@RequestBody @Valid ApproveRequestDTO dto) { + return ResponseEntity.ok(requestService.approveRequest(dto)); + } + + @PatchMapping("/reject") + public ResponseEntity reject(@RequestBody @Valid RejectRequestDTO dto) { + return ResponseEntity.ok(requestService.rejectRequest(dto)); + } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java index 77eb631a..b914e269 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/RequestController.java @@ -1,9 +1,11 @@ package DGU_AI_LAB.admin_be.domain.requests.controller; -import DGU_AI_LAB.admin_be.domain.requests.dto.request.ModifyRequestDTO; +import DGU_AI_LAB.admin_be.domain.requests.controller.docs.RequestApi; +import DGU_AI_LAB.admin_be.domain.requests.dto.request.SaveRequestRequestDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; import DGU_AI_LAB.admin_be.domain.requests.service.RequestService; import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -14,10 +16,19 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/requests") -public class RequestController { +public class RequestController implements RequestApi { private final RequestService requestService; + @PostMapping + public ResponseEntity createRequest( + @AuthenticationPrincipal(expression = "userId") Long userId, + @RequestBody @Valid SaveRequestRequestDTO dto + ) { + SaveRequestResponseDTO body = requestService.createRequest(userId, dto); + return ResponseEntity.ok(body); + } + @GetMapping("/my") public ResponseEntity> getMyRequests( @AuthenticationPrincipal CustomUserDetails user @@ -25,10 +36,19 @@ public ResponseEntity> getMyRequests( return ResponseEntity.ok(requestService.getRequestsByUserId(user.getUserId())); } - @PostMapping("/modify") + @GetMapping("/check-username") + public ResponseEntity checkUbuntuUsername(@RequestParam String username) { + boolean available = requestService.isUbuntuUsernameAvailable(username); + return ResponseEntity.ok().body( + java.util.Map.of( + "available", available + ) + ); + } + + /*@PostMapping("/modify") public ResponseEntity requestModification(@RequestBody ModifyRequestDTO dto) { requestService.requestModification(dto); return ResponseEntity.ok().build(); - } - + }*/ } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AcceptInfoApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AcceptInfoApi.java new file mode 100644 index 00000000..e9b168b5 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AcceptInfoApi.java @@ -0,0 +1,33 @@ +package DGU_AI_LAB.admin_be.domain.requests.controller.docs; + +import DGU_AI_LAB.admin_be.domain.requests.dto.response.AcceptInfoResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Config Server용 승인 정보 관리", description = "Ubuntu username별 승인 정보 조회 API") +public interface AcceptInfoApi { + + @Operation( + summary = "사용자 승인 정보 조회", + description = "Ubuntu username으로 승인된 서버 사용 신청 정보를 조회합니다." + ) + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = AcceptInfoResponseDTO.class)) + ) + @ApiResponse( + responseCode = "404", + description = "해당 username의 승인 정보를 찾을 수 없음", + content = @Content + ) + ResponseEntity getAcceptInfo( + @Parameter(description = "Ubuntu username") + String username + ); +} \ No newline at end of file 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 new file mode 100644 index 00000000..05b0eaf4 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/AdminRequestApi.java @@ -0,0 +1,57 @@ +package DGU_AI_LAB.admin_be.domain.requests.controller.docs; + +import DGU_AI_LAB.admin_be.domain.requests.dto.request.ApproveRequestDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.request.RejectRequestDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.*; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "관리자용 서버 사용 신청 관리", description = "관리자용 서버 사용 신청 관리 API") +public interface AdminRequestApi { + + @Operation( + summary = "모든 신청 목록 조회", + description = "상태에 상관없이 모든 사용 신청을 조회합니다." + ) + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = SaveRequestResponseDTO.class))) + ) + ResponseEntity> getAllRequests(); + + @Operation( + summary = "신청 승인", + description = "해당 신청의 상태를 FULFILLED로 변경하고 approvedAt을 현재 시각으로 설정합니다. expiresAt, volumeSizeGiB, imageId, rsgroupId를 갱신합니다." + ) + @ApiResponse( + responseCode = "200", description = "승인 성공", + content = @Content(schema = @Schema(implementation = SaveRequestResponseDTO.class)) + ) + ResponseEntity approve( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "승인 DTO", required = true + ) + ApproveRequestDTO dto + ); + + @Operation( + summary = "신청 거절", + description = "해당 신청의 상태를 DENIED로 변경하고 관리 코멘트를 기록합니다." + ) + @ApiResponse( + responseCode = "200", description = "거절 성공", + content = @Content(schema = @Schema(implementation = SaveRequestResponseDTO.class)) + ) + ResponseEntity reject( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "거절 DTO", required = true + ) + RejectRequestDTO dto + ); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java new file mode 100644 index 00000000..b13d8a76 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/controller/docs/RequestApi.java @@ -0,0 +1,49 @@ +package DGU_AI_LAB.admin_be.domain.requests.controller.docs; + +import DGU_AI_LAB.admin_be.domain.requests.dto.request.SaveRequestRequestDTO; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; +import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.*; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "서버 사용 신청", description = "서버 사용 신청 API") +public interface RequestApi { + + @Operation( + summary = "서버 사용 신청 생성", + description = "로그인된 사용자의 인증 정보를 바탕으로 서버 사용 신청을 생성합니다." + ) + @ApiResponse( + responseCode = "200", + description = "신청 생성 성공", + content = @Content(schema = @Schema(implementation = SaveRequestResponseDTO.class)) + ) + ResponseEntity createRequest( + @Parameter(hidden = true, description = "인증된 사용자 ID") + Long userId, + @RequestBody(description = "서버 사용 신청 DTO", required = true) + @Valid SaveRequestRequestDTO dto + ); + + @Operation( + summary = "내 신청 목록 조회", + description = "로그인된 사용자의 모든 신청 내역을 조회합니다." + ) + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = SaveRequestResponseDTO.class))) + ) + ResponseEntity> getMyRequests( + @Parameter(hidden = true, description = "인증된 사용자") + CustomUserDetails user + ); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveModificationDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveModificationDTO.java index 1080a4ee..b5e3c1e0 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveModificationDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveModificationDTO.java @@ -5,7 +5,7 @@ public record ApproveModificationDTO( Long requestId ) { - public void applyTo(Request request) { + /*public void applyTo(Request request) { request.applyModification(); - } + }*/ } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveRequestDTO.java index 1f925e58..505cd2a6 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveRequestDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ApproveRequestDTO.java @@ -1,14 +1,12 @@ package DGU_AI_LAB.admin_be.domain.requests.dto.request; -import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; -import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import java.time.LocalDateTime; public record ApproveRequestDTO( Long requestId, - String imageName, - String imageVersion -) { - public void applyTo(Request request, ContainerImage image) { - request.approve(image, request.getVolumeSizeByte(), request.getCudaVersion()); - } -} + Long imageId, + Integer resourceGroupId, + Long volumeSizeGiB, + LocalDateTime expiresAt, + String adminComment +) {} diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ModifyRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ModifyRequestDTO.java index 4da1d3e2..33cfa063 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ModifyRequestDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/ModifyRequestDTO.java @@ -10,7 +10,7 @@ public record ModifyRequestDTO( LocalDateTime requestedExpiresAt, String reason ) { - public void applyTo(Request request) { + /*public void applyTo(Request request) { request.requestModification(requestedVolumeSizeByte, requestedExpiresAt, reason); - } + }*/ } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/PvcRequest.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/PvcRequest.java new file mode 100644 index 00000000..652ec8f5 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/PvcRequest.java @@ -0,0 +1,6 @@ +package DGU_AI_LAB.admin_be.domain.requests.dto.request; + +public record PvcRequest ( + String username, + Long storage +) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RejectRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RejectRequestDTO.java index f07e8c4e..d86dc9ed 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RejectRequestDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/RejectRequestDTO.java @@ -1,12 +1,6 @@ package DGU_AI_LAB.admin_be.domain.requests.dto.request; -import DGU_AI_LAB.admin_be.domain.requests.entity.Request; - public record RejectRequestDTO( Long requestId, - String comment -) { - public void applyTo(Request request) { - request.reject(comment); - } -} + String adminComment +) {} diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/SaveRequestRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/SaveRequestRequestDTO.java index 9a01efa6..d58ab209 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/SaveRequestRequestDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/request/SaveRequestRequestDTO.java @@ -6,23 +6,36 @@ import DGU_AI_LAB.admin_be.domain.requests.entity.Status; import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; import DGU_AI_LAB.admin_be.domain.users.entity.User; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDateTime; +import java.util.Map; import java.util.Set; @Builder public record SaveRequestRequestDTO( - Long userId, + @Schema(description = "자원 그룹 id", example = "1") Integer resourceGroupId, - String imageName, - String imageVersion, + + @Schema(description = "이미지 id", example = "1") + Long imageId, + String ubuntuUsername, - Long ubuntuUid, - Long volumeSizeByte, - String cudaVersion, + String ubuntuPassword, + + @Schema(description = "볼륨 사이즈", example = "20") + Long volumeSizeGiB, + String usagePurpose, - String formAnswers, + + @Schema(description = "폼 응답", example = "{\"question\": \"answer\"}") + Map formAnswers, + LocalDateTime expiresAt, Set ubuntuGids ) { @@ -30,28 +43,30 @@ public Request toEntity( User user, ResourceGroup resourceGroup, ContainerImage image, - Set groups + Set groups, + String ubuntuPassword ) { + String formAnswersJson; + try { + formAnswersJson = new ObjectMapper().writeValueAsString(formAnswers); + } catch (JsonProcessingException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + // 1) 본체 먼저 생성 Request req = Request.builder() .user(user) .resourceGroup(resourceGroup) .containerImage(image) .ubuntuUsername(ubuntuUsername) - .ubuntuUid(ubuntuUid) - .volumeSizeByte(volumeSizeByte) - .cudaVersion(cudaVersion) + .ubuntuPassword(ubuntuPassword) + .volumeSizeGiB(volumeSizeGiB) .usagePurpose(usagePurpose) - .formAnswers(formAnswers) + .formAnswers(formAnswersJson) .expiresAt(expiresAt) .status(Status.PENDING) .build(); - // 2) 그룹 연결은 addGroup()으로 — 중간 엔티티(request_groups) 생성 - if (groups != null && !groups.isEmpty()) { - groups.forEach(req::addGroup); - } - return req; } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/AcceptInfoResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/AcceptInfoResponseDTO.java new file mode 100644 index 00000000..a4fda9dd --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/AcceptInfoResponseDTO.java @@ -0,0 +1,57 @@ +package DGU_AI_LAB.admin_be.domain.requests.dto.response; + +import DGU_AI_LAB.admin_be.domain.nodes.entity.Node; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import lombok.Builder; +import java.util.List; + +@Builder +public record AcceptInfoResponseDTO( + String username, + String image, + Long uid, + List gid, + Long volume_size, + Boolean gpu_required, + String gpu_group, + String server_type, + List gpu_nodes +) { + @Builder + public record NodeDTO( + String node_name, + String cpu_limit, + String memory_limit, + Integer num_gpu + ) {} + + public static AcceptInfoResponseDTO fromEntity(Request request, List nodes) { + var image = request.getContainerImage(); + var group = request.getResourceGroup(); + + List nodeDTOList = nodes.stream() + .map(node -> NodeDTO.builder() + .node_name(node.getNodeId()) + .cpu_limit(node.getCpuCoreCount() * 1000 + "m") + .memory_limit(node.getMemorySizeGB() * 1024 + "Mi") + .num_gpu(node.getNumberGpu()) + .build() + ).toList(); + + return AcceptInfoResponseDTO.builder() + .username(request.getUbuntuUsername()) + .image(image.getImageName() + ":" + image.getImageVersion()) + .uid(request.getUbuntuUid().getIdValue()) + .gid( + request.getRequestGroups().stream() + .map(rg -> rg.getGroup().getUbuntuGid()) + .toList() + ) + .volume_size(request.getVolumeSizeGiB()) + .gpu_required(true) + .gpu_group(group.getDescription()) + .server_type(group.getServerName()) + .gpu_nodes(nodeDTOList) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ContainerInfoDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ContainerInfoDTO.java index 827cf359..43f1f638 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ContainerInfoDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ContainerInfoDTO.java @@ -23,7 +23,7 @@ public static ContainerInfoDTO fromEntity(Request request) { .userId(request.getUser().getUserId()) .userName(request.getUser().getName()) .ubuntuUsername(request.getUbuntuUsername()) - .ubuntuUid(request.getUbuntuUid()) + //.ubuntuUid(request.getUbuntuUid()) .ubuntuGids( request.getRequestGroups().stream() .map(rg -> rg.getGroup().getUbuntuGid()) diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ResourceUsageDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ResourceUsageDTO.java index 4615a2e4..e3965e48 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ResourceUsageDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/ResourceUsageDTO.java @@ -15,7 +15,7 @@ public static ResourceUsageDTO fromEntity(Request request) { .userId(request.getUser().getUserId()) .userName(request.getUser().getName()) .resourceGroupId(request.getResourceGroup().getRsgroupId()) - .volumeSizeByte(request.getVolumeSizeByte()) + .volumeSizeByte(request.getVolumeSizeGiB()) .build(); } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java index d9dc0ab9..b1cf14d5 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/SaveRequestResponseDTO.java @@ -2,9 +2,11 @@ import DGU_AI_LAB.admin_be.domain.requests.entity.Request; import DGU_AI_LAB.admin_be.domain.requests.entity.Status; +import com.fasterxml.jackson.annotation.JsonRawValue; import lombok.Builder; import java.time.LocalDateTime; +import java.util.List; @Builder public record SaveRequestResponseDTO( @@ -14,10 +16,10 @@ public record SaveRequestResponseDTO( String imageVersion, String ubuntuUsername, Long ubuntuUid, + List ubuntuGids, Long volumeSizeByte, - String cudaVersion, String usagePurpose, - String formAnswers, + @JsonRawValue String formAnswers, LocalDateTime expiresAt, Status status, LocalDateTime approvedAt, @@ -30,15 +32,21 @@ public static SaveRequestResponseDTO fromEntity(Request request) { .imageName(request.getContainerImage().getImageName()) .imageVersion(request.getContainerImage().getImageVersion()) .ubuntuUsername(request.getUbuntuUsername()) - .ubuntuUid(request.getUbuntuUid()) - .volumeSizeByte(request.getVolumeSizeByte()) - .cudaVersion(request.getCudaVersion()) + .ubuntuUid(request.getUbuntuUid() != null + ? request.getUbuntuUid().getIdValue() + : null) + .ubuntuGids( + request.getRequestGroups().stream() + .map(rg -> rg.getGroup().getUbuntuGid()) + .toList() + ) + .volumeSizeByte(request.getVolumeSizeGiB()) .usagePurpose(request.getUsagePurpose()) .formAnswers(request.getFormAnswers()) .expiresAt(request.getExpiresAt()) .status(request.getStatus()) .approvedAt(request.getApprovedAt()) - .comment(request.getComment()) + .comment(request.getAdminComment()) .build(); } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/UserServerResponseDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/UserServerResponseDTO.java index 7c9d1808..ad55a06b 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/UserServerResponseDTO.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/dto/response/UserServerResponseDTO.java @@ -16,12 +16,11 @@ public record UserServerResponseDTO( String serverAddress, @Schema(description = "서버 사용 만료일", example = "2025-12-31T23:59:59") LocalDateTime expiresAt, - @Schema(description = "할당된 볼륨 크기 (GB)", example = "100") - Long volumeSizeGB, - @Schema(description = "사용된 CUDA 버전", example = "12.0") - String cudaVersion, + @Schema(description = "할당된 볼륨 크기 (GiB)", example = "100") + Long volumeSizeGiB, @Schema(description = "할당된 CPU 코어 수", example = "8") Integer cpuCoreCount, + // TODO: 용도가 무엇인지 모르겠으나 GiB로 통일하는 것이 좋아보임. @Schema(description = "할당된 메모리 크기 (GB)", example = "32") Integer memoryGB, @Schema(description = "할당된 리소스 그룹명 (GPU 스펙 묶음)", example = "RTX 3090 D6 24GB 그룹") @@ -34,8 +33,7 @@ public static UserServerResponseDTO fromEntity(Request request, String serverAdd .requestId(request.getRequestId()) .serverAddress(serverAddress) .expiresAt(request.getExpiresAt()) - .volumeSizeGB(request.getVolumeSizeByte() / (1024L * 1024 * 1024)) - .cudaVersion(request.getCudaVersion()) + .volumeSizeGiB(request.getVolumeSizeGiB() / (1024L * 1024 * 1024)) .cpuCoreCount(cpuCoreCount) .memoryGB(memoryGB) .resourceGroupName(resourceGroupName) 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 376647aa..6d69df2b 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 @@ -3,7 +3,10 @@ import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; import DGU_AI_LAB.admin_be.domain.groups.entity.Group; import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; +import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; import DGU_AI_LAB.admin_be.domain.users.entity.User; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; import DGU_AI_LAB.admin_be.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -23,21 +26,18 @@ public class Request extends BaseTimeEntity { @Column(name = "request_id") private Long requestId; - @Column(name = "ubuntu_username", nullable = false, length = 100) + @Column(name = "ubuntu_username", nullable = false, length = 100, unique = true) private String ubuntuUsername; - @Column(name = "ubuntu_uid", nullable = false) - private Long ubuntuUid; + @Column(name = "ubuntu_password", nullable = false) + private String ubuntuPassword; + + @Column(name = "volume_size_GiB", nullable = false) + private Long volumeSizeGiB; @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; - @Column(name = "volume_size_byte", nullable = false) - private Long volumeSizeByte; - - @Column(name = "cuda_version", nullable = false, length = 100) - private String cudaVersion; - @Column(name = "usage_purpose", nullable = false, length = 1000) private String usagePurpose; @@ -58,34 +58,23 @@ public class Request extends BaseTimeEntity { /** * 거절 사유 등, status에 대한 설명 */ - @Column(name = "comment", length = 300) - private String comment; - - /** - * 사용자가 볼륨 수정 요청 시 담아두는 값 - */ - @Column(name = "requested_volume_size_byte") - private Long requestedVolumeSizeByte; - - /** - * 사용자가 만료일 수정 요청 시 담아두는 값 - */ - @Column(name = "requested_expires_at") - private LocalDateTime requestedExpiresAt; + @Column(name = "admin_comment", length = 300) + private String adminComment; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ubuntuUid", referencedColumnName = "id_value", nullable = true) + private UsedId ubuntuUid; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "rsgroup_id", nullable = false) private ResourceGroup resourceGroup; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumns({ - @JoinColumn(name = "image_name", referencedColumnName = "image_name", nullable = false), - @JoinColumn(name = "image_version", referencedColumnName = "image_version", nullable = false) - }) + @JoinColumn(name = "image_id", nullable = false) private ContainerImage containerImage; @OneToMany(mappedBy = "request", cascade = CascadeType.ALL, orphanRemoval = true) @@ -95,57 +84,72 @@ public class Request extends BaseTimeEntity { // ==== 비즈니스 로직 ==== public void updateStatus(Status status, String comment, LocalDateTime approvedAt) { this.status = status; - this.comment = comment; + this.adminComment = comment; this.approvedAt = approvedAt; } - public void approve(ContainerImage image, Long volumeSizeByte, String cudaVersion) { + public void approve(ContainerImage image, ResourceGroup resourceGroup, Long volumeSizeGiB, LocalDateTime expiresAt, String adminComment) { this.containerImage = image; - this.volumeSizeByte = volumeSizeByte; - this.cudaVersion = cudaVersion; + this.resourceGroup = resourceGroup; + this.volumeSizeGiB = volumeSizeGiB; + this.expiresAt = expiresAt; this.status = Status.FULFILLED; this.approvedAt = LocalDateTime.now(); - this.comment = null; + + if (adminComment != null && !adminComment.isBlank()) { + this.adminComment = adminComment; + } } public void reject(String comment) { this.status = Status.DENIED; - this.approvedAt = LocalDateTime.now(); - this.comment = comment; + this.adminComment = comment; } - public void requestModification(Long newVolumeSizeByte, LocalDateTime newExpiresAt, String reason) { - this.requestedVolumeSizeByte = newVolumeSizeByte; + /*public void requestModification(Long newVolumeSizeByte, LocalDateTime newExpiresAt, String reason) { + this.requestedVolumeSizeGi = newVolumeSizeByte; this.requestedExpiresAt = newExpiresAt; - this.comment = "변경 요청: " + reason; - } + this.adminComment = "변경 요청: " + reason; + }*/ - public void applyModification() { - if (this.requestedVolumeSizeByte != null) { - this.volumeSizeByte = this.requestedVolumeSizeByte; + /*public void applyModification() { + if (this.requestedVolumeSizeGi != null) { + this.volumeSizeGiB = this.requestedVolumeSizeGi; } if (this.requestedExpiresAt != null) { this.expiresAt = this.requestedExpiresAt; } - this.requestedVolumeSizeByte = null; + this.requestedVolumeSizeGi = null; this.requestedExpiresAt = null; - this.comment = "변경 완료됨"; - } + this.adminComment = "변경 완료됨"; + }*/ - public void rejectModification(String reason) { - this.requestedVolumeSizeByte = null; + /*public void rejectModification(String reason) { + this.requestedVolumeSizeGi = null; this.requestedExpiresAt = null; - this.comment = "변경 요청 거절: " + reason; + this.adminComment = "변경 요청 거절: " + reason; + }*/ + + public void assignUbuntuUid(UsedId uid) { + this.ubuntuUid = uid; } public void addGroup(Group group) { + Long rid = this.getRequestId(); + if (rid == null) { + throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND); + } + RequestGroup rg = RequestGroup.builder() + .id(new RequestGroupId(rid, group.getUbuntuGid())) .request(this) .group(group) .build(); + this.requestGroups.add(rg); } + public void removeGroup(Long ubuntuGid) { this.requestGroups.removeIf(rg -> rg.getGroup().getUbuntuGid().equals(ubuntuGid)); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroup.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroup.java index a4df620a..078a90e8 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroup.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroup.java @@ -8,6 +8,7 @@ @Entity @Table(name = "request_groups") +@Access(AccessType.FIELD) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroupId.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroupId.java index a9a49847..68637640 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroupId.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/RequestGroupId.java @@ -1,5 +1,7 @@ package DGU_AI_LAB.admin_be.domain.requests.entity; +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import lombok.*; @@ -7,10 +9,10 @@ import java.io.Serializable; @Embeddable +@Access(AccessType.FIELD) @Getter @NoArgsConstructor @AllArgsConstructor -@Builder @EqualsAndHashCode public class RequestGroupId implements Serializable { diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java index fac15c4f..5c190cc8 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java @@ -7,11 +7,17 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface RequestRepository extends JpaRepository { List findAllByUser(User user); + Optional findByUbuntuUsername(String username); List findAllByUser_UserId(Long userId); List findAllByStatus(Status status); + Optional findByUbuntuUsernameAndUbuntuPassword(String username, String passwordBase64); List findByUserUserIdAndStatus(Long userId, Status status); + Optional findTopByUbuntuUsernameAndUbuntuUidIsNotNullOrderByApprovedAtDesc(String ubuntuUsername); + + boolean existsByUbuntuUsername(String ubuntuUsername); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestService.java index 627ffab0..f6d0137a 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/service/RequestService.java @@ -4,7 +4,10 @@ import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; import DGU_AI_LAB.admin_be.domain.groups.entity.Group; import DGU_AI_LAB.admin_be.domain.groups.repository.GroupRepository; +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.*; +import DGU_AI_LAB.admin_be.domain.requests.dto.response.AcceptInfoResponseDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.ContainerInfoDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.ResourceUsageDTO; import DGU_AI_LAB.admin_be.domain.requests.dto.response.SaveRequestResponseDTO; @@ -13,18 +16,31 @@ import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; import DGU_AI_LAB.admin_be.domain.resourceGroups.repository.ResourceGroupRepository; +import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; +import DGU_AI_LAB.admin_be.domain.usedIds.repository.UsedIdRepository; +import DGU_AI_LAB.admin_be.domain.usedIds.service.IdAllocationService; import DGU_AI_LAB.admin_be.domain.users.entity.User; import DGU_AI_LAB.admin_be.domain.users.repository.UserRepository; import DGU_AI_LAB.admin_be.error.ErrorCode; import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import DGU_AI_LAB.admin_be.global.util.PasswordUtil; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; import java.util.HashSet; import java.util.List; import java.util.Set; +@Slf4j @Service @RequiredArgsConstructor public class RequestService { @@ -34,32 +50,53 @@ public class RequestService { private final ContainerImageRepository containerImageRepository; private final GroupRepository groupRepository; private final ResourceGroupRepository resourceGroupRepository; + private final UsedIdRepository usedIdRepository; + private final PasswordEncoder passwordEncoder; + private final RestTemplate restTemplate; + private final IdAllocationService idAllocationService; + private final NodeRepository nodeRepository; + + @Value("${pvc.base-url}") + private String pvcBaseUrl; /** 신청 생성 */ @Transactional - public SaveRequestResponseDTO createRequest(SaveRequestRequestDTO dto) { - User user = userRepository.findById(dto.userId()) + public SaveRequestResponseDTO createRequest(Long userId, SaveRequestRequestDTO dto) { + User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); ResourceGroup rg = resourceGroupRepository.findById(dto.resourceGroupId()) .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); - ContainerImage img = containerImageRepository.findByImageNameAndImageVersion( - dto.imageName(), dto.imageVersion() - ).orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + ContainerImage img = containerImageRepository.findById(dto.imageId()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); - Set groups = dto.ubuntuGids() == null || dto.ubuntuGids().isEmpty() - ? Set.of() - : new HashSet<>(groupRepository.findAllByUbuntuGidIn(dto.ubuntuGids())); + String ubuntuPassword = PasswordUtil.encodePassword(dto.ubuntuPassword()); - if (groups.size() != (dto.ubuntuGids() == null ? 0 : dto.ubuntuGids().size())) { - throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND); - } + Request req = dto.toEntity( + user, + rg, + img, + java.util.Collections.emptySet(), + ubuntuPassword + ); - Request saved = requestRepository.save(dto.toEntity(user, rg, img, groups)); - return SaveRequestResponseDTO.fromEntity(saved); - } + req = requestRepository.save(req); + requestRepository.flush(); + + if (dto.ubuntuGids() != null && !dto.ubuntuGids().isEmpty()) { + Set found = new java.util.HashSet<>(groupRepository.findAllByUbuntuGidIn(dto.ubuntuGids())); + if (found.size() != dto.ubuntuGids().size()) { + throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND); + } + + for (Group g : found) { + req.addGroup(g); + } + } + return SaveRequestResponseDTO.fromEntity(req); + } /** 신청 승인 */ @Transactional @@ -71,11 +108,56 @@ public SaveRequestResponseDTO approveRequest(ApproveRequestDTO dto) { throw new BusinessException(ErrorCode.INVALID_REQUEST_STATUS); } - ContainerImage containerImage = containerImageRepository.findByImageNameAndImageVersion( - dto.imageName(), dto.imageVersion() - ).orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + // UID 할당 + var allocation = idAllocationService.allocateFor(request); + + request.assignUbuntuUid(allocation.getUid()); + + boolean alreadyLinked = request.getRequestGroups().stream() + .anyMatch(rg -> rg.getGroup().getUbuntuGid() + .equals(allocation.getPrimaryGroup().getUbuntuGid())); + if (!alreadyLinked) { + request.addGroup(allocation.getPrimaryGroup()); + } + + ContainerImage image = containerImageRepository.findById(dto.imageId()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + + ResourceGroup rg = resourceGroupRepository.findById(dto.resourceGroupId()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); + + request.approve( + image, + rg, + dto.volumeSizeGiB(), + dto.expiresAt(), + dto.adminComment() + ); + + requestRepository.flush(); + + // pvc post + String url = pvcBaseUrl + "/pvc"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + PvcRequest body = new PvcRequest( + request.getUbuntuUsername(), + request.getVolumeSizeGiB() + ); + + HttpEntity entity = new HttpEntity<>(body, headers); + + try { + ResponseEntity res = restTemplate.postForEntity(url, entity, Void.class); + if (!res.getStatusCode().is2xxSuccessful()) { + throw new BusinessException(ErrorCode.EXTERNAL_API_FAILED); + } + } catch (Exception ex) { + throw new BusinessException(ErrorCode.EXTERNAL_API_FAILED); + } - dto.applyTo(request, containerImage); return SaveRequestResponseDTO.fromEntity(request); } @@ -89,10 +171,20 @@ public SaveRequestResponseDTO rejectRequest(RejectRequestDTO dto) { throw new BusinessException(ErrorCode.INVALID_REQUEST_STATUS); } - dto.applyTo(request); + request.reject( + dto.adminComment() + ); return SaveRequestResponseDTO.fromEntity(request); } + /** 모든 신청 목록 (관리자용) */ + @Transactional(readOnly = true) + public List getAllRequests() { + return requestRepository.findAll().stream() + .map(SaveRequestResponseDTO::fromEntity) + .toList(); + } + /** 내 신청 목록 */ @Transactional(readOnly = true) public List getRequestsByUserId(Long userId) { @@ -104,8 +196,25 @@ public List getRequestsByUserId(Long userId) { .toList(); } + /** ubuntu username 중복 검사 */ + @Transactional(readOnly = true) + public boolean isUbuntuUsernameAvailable(String username) { + return !requestRepository.existsByUbuntuUsername(username); + } + + /** config server용 acceptinfo */ + public AcceptInfoResponseDTO getAcceptInfo(String username) { + Request request = requestRepository.findByUbuntuUsername(username) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_APPROVAL_NOT_FOUND)); + + ResourceGroup group = request.getResourceGroup(); + List nodes = nodeRepository.findAllByResourceGroup(group); + + return AcceptInfoResponseDTO.fromEntity(request, nodes); + } + /** 변경 요청 */ - @Transactional + /*@Transactional public void requestModification(ModifyRequestDTO dto) { Request request = requestRepository.findById(dto.requestId()) .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); @@ -115,10 +224,10 @@ public void requestModification(ModifyRequestDTO dto) { } dto.applyTo(request); - } + }*/ /** 변경 승인 */ - @Transactional + /*@Transactional public void approveModification(ApproveModificationDTO dto) { Request request = requestRepository.findById(dto.requestId()) .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND)); @@ -128,7 +237,7 @@ public void approveModification(ApproveModificationDTO dto) { } dto.applyTo(request); - } + }*/ /** 승인 완료 자원 사용량 */ @Transactional(readOnly = true) diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/entity/ResourceGroup.java b/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/entity/ResourceGroup.java index 833ee470..c9161e05 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/entity/ResourceGroup.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/entity/ResourceGroup.java @@ -21,4 +21,7 @@ public class ResourceGroup { @Column(name = "description", length = 500) private String description; + + @Column(name = "server_name", length = 300) + private String serverName; } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/service/ResourceGroupService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/service/ResourceGroupService.java index 9a3650a4..2d9283e1 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/service/ResourceGroupService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/resourceGroups/service/ResourceGroupService.java @@ -27,16 +27,24 @@ public class ResourceGroupService { public List getGpuTypeResources() { log.info("[getGpuTypeResources] GPU 기종별 리소스 정보 조회 시작"); - List gpuSummaries = gpuRepository.findGpuSummary(); + List gpuSummaries = gpuRepository.findGpuSummary(); + + for (GpuRepository.GpuSummary summary : gpuSummaries) { + System.out.println(summary.getGpuModel()); + System.out.println(summary.getRamGb()); + System.out.println(summary.getDescription()); + System.out.println(summary.getNodeCount()); + } if (gpuSummaries.isEmpty()) { log.warn("[getGpuTypeResources] 조회된 GPU 기종별 리소스가 없습니다."); throw new BusinessException(ErrorCode.NO_AVAILABLE_RESOURCES); } - List response = gpuSummaries.stream() - .map(GpuTypeResponseDTO::fromQueryResult) - .collect(Collectors.toList()); + var summaries = gpuRepository.findGpuSummary(); // List + var response = summaries.stream() + .map(GpuTypeResponseDTO::fromSummary) + .toList(); log.info("[getGpuTypeResources] GPU 기종별 리소스 정보 조회 완료. {}개 기종", response.size()); return response; diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java index 63adad04..a61b7e7a 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java @@ -17,27 +17,4 @@ public class UsedId { @Id @Column(name = "id_value", nullable = false) private Long idValue; - - @Column(name = "is_used", nullable = false) - private Boolean isUsed; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @PrePersist - void onCreate() { - if (createdAt == null) createdAt = LocalDateTime.now(); - if (isUsed == null) isUsed = true; - } - - public static UsedId allocate(Long id) { - return UsedId.builder() - .idValue(id) - .isUsed(true) - .build(); - } - - public void release() { - this.isUsed = false; - } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/repository/UsedIdRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/repository/UsedIdRepository.java index a8a67561..6fe93fbb 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/repository/UsedIdRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/repository/UsedIdRepository.java @@ -2,7 +2,11 @@ import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface UsedIdRepository extends JpaRepository { -} +import java.util.Optional; +public interface UsedIdRepository extends JpaRepository { + @Query("SELECT MAX(u.idValue) FROM UsedId u") + Optional findMaxIdValue(); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/service/IdAllocationService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/service/IdAllocationService.java new file mode 100644 index 00000000..c8ccd465 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/service/IdAllocationService.java @@ -0,0 +1,85 @@ +package DGU_AI_LAB.admin_be.domain.usedIds.service; + +import DGU_AI_LAB.admin_be.domain.groups.entity.Group; +import DGU_AI_LAB.admin_be.domain.groups.repository.GroupRepository; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; +import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; +import DGU_AI_LAB.admin_be.domain.usedIds.repository.UsedIdRepository; +import DGU_AI_LAB.admin_be.error.ErrorCode; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class IdAllocationService { + + private static final long UID_BASE = 10_000L; + private static final int MAX_RETRY = 5; + + private final UsedIdRepository usedIdRepository; + private final GroupRepository groupRepository; + private final RequestRepository requestRepository; + + @Getter + @AllArgsConstructor + public static class AllocationResult { + private final UsedId uid; + private final Group primaryGroup; + } + + @Transactional + public AllocationResult allocateFor(Request request) { + final String username = request.getUbuntuUsername(); + + UsedId uid = findReusableUidByUbuntuUsername(username) + .orElseGet(this::allocateNewUid); + + Group primaryGroup = groupRepository.findById(uid.getIdValue()) + .orElseGet(() -> createGroupWithSameId(username, uid.getIdValue())); + + return new AllocationResult(uid, primaryGroup); + } + + private Optional findReusableUidByUbuntuUsername(String ubuntuUsername) { + return requestRepository + .findTopByUbuntuUsernameAndUbuntuUidIsNotNullOrderByApprovedAtDesc(ubuntuUsername) + .map(Request::getUbuntuUid); + } + + // 새 UID 생성 + private UsedId allocateNewUid() { + for (int i = 0; i < MAX_RETRY; i++) { + long currentMax = usedIdRepository.findMaxIdValue().orElse(UID_BASE - 1L); + long candidate = Math.max(UID_BASE, currentMax + 1); + try { + return usedIdRepository.saveAndFlush(UsedId.builder().idValue(candidate).build()); + } catch (DataIntegrityViolationException ignore) {} + } + throw new BusinessException(ErrorCode.UID_ALLOCATION_FAILED); + } + + private Group createGroupWithSameId(String username, long uidValue) { + Optional existing = groupRepository.findById(uidValue); + if (existing.isPresent()) return existing.get(); + + UsedId gidUsedId = usedIdRepository.findById(uidValue) + .orElseGet(() -> usedIdRepository.saveAndFlush( + UsedId.builder().idValue(uidValue).build() + )); + + Group group = Group.builder() + .groupName(username) + .usedId(gidUsedId) + .build(); + + return groupRepository.saveAndFlush(group); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/AuthController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/AuthController.java index 72b3e291..a668bbf8 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/AuthController.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/AuthController.java @@ -1,10 +1,13 @@ package DGU_AI_LAB.admin_be.domain.users.controller; import DGU_AI_LAB.admin_be.domain.users.controller.docs.AuthApi; +import DGU_AI_LAB.admin_be.domain.users.dto.request.UserAuthRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.UserLoginRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.UserRegisterRequestDTO; +import DGU_AI_LAB.admin_be.domain.users.dto.response.UserAuthResponseDTO; import DGU_AI_LAB.admin_be.domain.users.dto.response.UserTokenResponseDTO; import DGU_AI_LAB.admin_be.domain.users.service.UserLoginService; +import DGU_AI_LAB.admin_be.domain.users.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -19,6 +22,7 @@ public class AuthController implements AuthApi { private final UserLoginService userLoginService; + private final UserService userService; /** * 3) 회원가입 @@ -38,4 +42,9 @@ public ResponseEntity login(@RequestBody @Valid UserLoginR return ResponseEntity.ok(userLoginService.login(request)); } + /** ssh 로그인 */ + @PostMapping("/users") + public ResponseEntity userAuth(@RequestBody @Valid UserAuthRequestDTO dto) { + return ResponseEntity.ok(userService.userAuth(dto)); + } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AuthApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AuthApi.java index 77ea3c08..4657cca5 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AuthApi.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/controller/docs/AuthApi.java @@ -1,7 +1,9 @@ package DGU_AI_LAB.admin_be.domain.users.controller.docs; +import DGU_AI_LAB.admin_be.domain.users.dto.request.UserAuthRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.UserLoginRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.UserRegisterRequestDTO; +import DGU_AI_LAB.admin_be.domain.users.dto.response.UserAuthResponseDTO; import DGU_AI_LAB.admin_be.domain.users.dto.response.UserTokenResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -45,4 +47,26 @@ public interface AuthApi { ) @ApiResponse(responseCode = "200", description = "로그인 성공 및 토큰 발급") ResponseEntity login(UserLoginRequestDTO request); + + @Operation( + summary = "SSH 로그인", + description = "사용자가 입력한 username과 password를 request에 있는 정보와 비교합니다.", + requestBody = @RequestBody( + required = true, + description = "SSH 로그인 인증 정보", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserAuthRequestDTO.class) + ) + ) + ) + @ApiResponse( + responseCode = "200", + description = "인증 결과 반환", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserAuthResponseDTO.class) + ) + ) + ResponseEntity userAuth(UserAuthRequestDTO request); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/dto/request/ApprovalAuthRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/dto/request/ApprovalAuthRequestDTO.java deleted file mode 100644 index d6ef157b..00000000 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/dto/request/ApprovalAuthRequestDTO.java +++ /dev/null @@ -1,8 +0,0 @@ -package DGU_AI_LAB.admin_be.domain.users.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public record ApprovalAuthRequestDTO( - @NotBlank String username, - @NotBlank String passwordBase64 -) {} diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserService.java index 6b473135..296072aa 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserService.java @@ -1,11 +1,14 @@ package DGU_AI_LAB.admin_be.domain.users.service; import DGU_AI_LAB.admin_be.domain.groups.repository.GroupRepository; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; +import DGU_AI_LAB.admin_be.domain.users.dto.request.UserAuthRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.PasswordUpdateRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.PhoneUpdateRequestDTO; -import DGU_AI_LAB.admin_be.domain.users.dto.request.UserCreateRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.request.UserUpdateRequestDTO; import DGU_AI_LAB.admin_be.domain.users.dto.response.MyInfoResponseDTO; +import DGU_AI_LAB.admin_be.domain.users.dto.response.UserAuthResponseDTO; import DGU_AI_LAB.admin_be.domain.users.dto.response.UserResponseDTO; import DGU_AI_LAB.admin_be.domain.users.dto.response.UserSummaryDTO; import DGU_AI_LAB.admin_be.domain.users.entity.User; @@ -13,6 +16,8 @@ import DGU_AI_LAB.admin_be.error.ErrorCode; import DGU_AI_LAB.admin_be.error.exception.BusinessException; import DGU_AI_LAB.admin_be.error.exception.EntityNotFoundException; +import DGU_AI_LAB.admin_be.error.exception.UnauthorizedException; +import DGU_AI_LAB.admin_be.global.util.PasswordUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; @@ -28,6 +33,8 @@ public class UserService { private final UserRepository userRepository; + private final GroupRepository groupRepository; + private final RequestRepository requestRepository; private final PasswordEncoder passwordEncoder; private static final long UID_BASE = 10000; // TODO: 이부분 시스템에 맞추어서 수정하기 @@ -90,6 +97,22 @@ public MyInfoResponseDTO getMyInfo(Long userId) { return MyInfoResponseDTO.fromEntity(user); } + /** ssh 로그인 */ + @Transactional + public UserAuthResponseDTO userAuth(UserAuthRequestDTO dto) { + String encodedPassword = PasswordUtil.encodePassword(dto.passwordBase64()); + + Request request = requestRepository.findByUbuntuUsernameAndUbuntuPassword(dto.username(), encodedPassword) + .orElseThrow(() -> new UnauthorizedException(ErrorCode.USER_NOT_FOUND)); + + + if (!encodedPassword.equals(request.getUbuntuPassword())) { + throw new UnauthorizedException(ErrorCode.INVALID_LOGIN_INFO); + } + + return new UserAuthResponseDTO(true, request.getUbuntuUsername()); + } + /** * 사용자 비밀번호 변경 */ @@ -130,4 +153,4 @@ public UserResponseDTO updatePhone(Long userId, PhoneUpdateRequestDTO request) { log.info("[updatePhone] userId={} 연락처 변경 완료", userId); return UserResponseDTO.fromEntity(user); } -} \ No newline at end of file +} diff --git a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java index bdabbb05..74177a01 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java +++ b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java @@ -63,6 +63,17 @@ public enum ErrorCode { FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패하였습니다."), JSON_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "JSON 파싱에 실패하였습니다."), + /** + * 502 Bad Gateway + */ + SLACK_DM_CHANNEL_FAILED(HttpStatus.BAD_GATEWAY, "Slack DM 채널 열기를 실패하였습니다."), + EXTERNAL_API_FAILED(HttpStatus.BAD_GATEWAY, "외부 API 호출에 실패했습니다."), + + /** + * 503 Service Unavailable + */ + SLACK_SEND_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "Slack 메시지 전송에 실패하였습니다."), + /** * User Error */ @@ -74,12 +85,15 @@ public enum ErrorCode { INVALID_LOGIN_INFO(HttpStatus.BAD_REQUEST, "잘못된 로그인 입력값입니다."), INVALID_AUTH_CODE(HttpStatus.BAD_REQUEST, "올바르지 않은 인증 코드입니다."), GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "지정된 그룹을 찾을 수 없습니다."), - UID_ALLOCATION_FAILED(HttpStatus.BAD_REQUEST, "UID를 할당하는 데 실패했습니다."), // 이 부분 고민 + UID_ALLOCATION_FAILED(HttpStatus.BAD_REQUEST, "UID를 할당에 실패했습니다."), + + SLACK_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "Slack 사용자를 찾을 수 없습니다."), + SLACK_USER_EMAIL_NOT_MATCH(HttpStatus.NOT_FOUND, "이메일이 일치하는 Slack 사용자를 찾을 수 없습니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "현재 비밀번호가 일치하지 않습니다."), PASSWORD_CHANGE_SAME_AS_OLD(HttpStatus.BAD_REQUEST, "새 비밀번호가 현재 비밀번호와 동일합니다."), - /** * Approval Error */ diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/config/KubernetesConfig.java b/src/main/java/DGU_AI_LAB/admin_be/global/config/KubernetesConfig.java index 0610ee20..766873c6 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/global/config/KubernetesConfig.java +++ b/src/main/java/DGU_AI_LAB/admin_be/global/config/KubernetesConfig.java @@ -1,14 +1,14 @@ -//package DGU_AI_LAB.admin_be.global.config; -// -//import io.fabric8.kubernetes.client.KubernetesClient; -//import io.fabric8.kubernetes.client.KubernetesClientBuilder; -//import org.springframework.context.annotation.Bean; -//import org.springframework.context.annotation.Configuration; -// -//@Configuration -//public class KubernetesConfig { -// @Bean -// public KubernetesClient kubernetesClient() { -// return new KubernetesClientBuilder().build(); -// } -//} \ No newline at end of file +package DGU_AI_LAB.admin_be.global.config; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class KubernetesConfig { + @Bean + public KubernetesClient kubernetesClient() { + return new KubernetesClientBuilder().build(); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/config/RestTemplateConfig.java b/src/main/java/DGU_AI_LAB/admin_be/global/config/RestTemplateConfig.java new file mode 100644 index 00000000..f92551dc --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/global/config/RestTemplateConfig.java @@ -0,0 +1,32 @@ +package DGU_AI_LAB.admin_be.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate( + RestTemplateBuilder builder, + @Value("${pvc.timeout-seconds:5}") int timeoutSeconds + ) { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofSeconds(timeoutSeconds)); + + return builder + .requestFactory(() -> requestFactory) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/util/PasswordUtil.java b/src/main/java/DGU_AI_LAB/admin_be/global/util/PasswordUtil.java index bf40eb26..aed21092 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/global/util/PasswordUtil.java +++ b/src/main/java/DGU_AI_LAB/admin_be/global/util/PasswordUtil.java @@ -9,8 +9,8 @@ private PasswordUtil() {} public static String encodePassword(String passwordBase64) { try { - // sha256 해싱 - MessageDigest digest = MessageDigest.getInstance("SHA-256"); + // sha512 해싱 + MessageDigest digest = MessageDigest.getInstance("SHA-512"); byte[] hash = digest.digest(passwordBase64.getBytes(StandardCharsets.UTF_8)); // byte[] -> hex