diff --git a/build.gradle b/build.gradle index 73a8b49e..54fa208e 100644 --- a/build.gradle +++ b/build.gradle @@ -50,8 +50,11 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' // AWS S3 (파일 업로드 및 다운로드를 위한 라이브러리) + // AWS SDK v2 - S3 + presigned URL + implementation 'software.amazon.awssdk:s3:2.31.78' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.673' + // 이메일 발송 기능 implementation 'org.springframework.boot:spring-boot-starter-mail' @@ -72,7 +75,11 @@ dependencies { implementation 'io.sentry:sentry-spring-boot-starter:8.17.0' implementation 'io.sentry:sentry-logback:8.17.0' + // Apache Commons IO (파일 처리 등 유틸리티) + implementation 'commons-io:commons-io:2.16.1' + // 테스트 관련 라이브러리 + implementation 'org.springframework:spring-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/controller/ChatReadOffsetController.java b/src/main/java/com/deepdirect/deepwebide_be/chat/controller/ChatReadOffsetController.java new file mode 100644 index 00000000..850710e3 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/controller/ChatReadOffsetController.java @@ -0,0 +1,29 @@ +package com.deepdirect.deepwebide_be.chat.controller; + +import com.deepdirect.deepwebide_be.chat.dto.request.ChatReadOffsetRequest; +import com.deepdirect.deepwebide_be.chat.service.ChatReadOffsetService; +import com.deepdirect.deepwebide_be.global.dto.ApiResponseDto; +import com.deepdirect.deepwebide_be.global.security.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/repositories/{repositoryId}/chat") +public class ChatReadOffsetController { + + private final ChatReadOffsetService chatReadOffsetService; + + @PatchMapping("/read-offset") + public ResponseEntity> saveReadOffset( + @PathVariable Long repositoryId, + @Valid @RequestBody ChatReadOffsetRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + chatReadOffsetService.saveOffset(repositoryId, userDetails.getId(), request.getLastReadMessageId()); + return ResponseEntity.ok(ApiResponseDto.of(200, "읽음 처리 완료", null)); + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatReadOffset.java b/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatReadOffset.java index b83a9fcf..3d57846a 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatReadOffset.java +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/domain/ChatReadOffset.java @@ -4,6 +4,7 @@ import com.deepdirect.deepwebide_be.repository.domain.Repository; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,10 +27,11 @@ public class ChatReadOffset { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(name = "last_read_message_id", nullable = false) - private Long lastReadMessageId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_read_message_id", nullable = false) + private ChatMessage lastReadMessage; - @Column(name = "updated_at") + @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; @PrePersist @@ -37,4 +39,26 @@ public class ChatReadOffset { public void setUpdatedAt() { this.updatedAt = LocalDateTime.now(); } + + + @Builder + private ChatReadOffset(Repository repository, User user, ChatMessage lastReadMessage) { + this.repository = repository; + this.user = user; + this.lastReadMessage = lastReadMessage; + this.updatedAt = LocalDateTime.now(); + } + + public static ChatReadOffset of(Repository repository, User user, ChatMessage message) { + return ChatReadOffset.builder() + .repository(repository) + .user(user) + .lastReadMessage(message) + .build(); + } + + public void update(ChatMessage newMessage) { + this.lastReadMessage = newMessage; + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatReadOffsetRequest.java b/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatReadOffsetRequest.java new file mode 100644 index 00000000..c2860601 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/dto/request/ChatReadOffsetRequest.java @@ -0,0 +1,22 @@ +package com.deepdirect.deepwebide_be.chat.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Schema(description = "채팅 읽음 위치 저장 요청") +public class ChatReadOffsetRequest { + + @NotNull + @Schema(description = "마지막으로 읽은 메시지 ID", example = "152") + private Long lastReadMessageId; + + @Builder + public ChatReadOffsetRequest(Long lastReadMessageId) { + this.lastReadMessageId = lastReadMessageId; + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/repository/ChatReadOffsetRepository.java b/src/main/java/com/deepdirect/deepwebide_be/chat/repository/ChatReadOffsetRepository.java new file mode 100644 index 00000000..9239fd97 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/repository/ChatReadOffsetRepository.java @@ -0,0 +1,11 @@ +package com.deepdirect.deepwebide_be.chat.repository; + +import com.deepdirect.deepwebide_be.chat.domain.ChatReadOffset; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ChatReadOffsetRepository extends JpaRepository { + + Optional findByRepositoryIdAndUserId(Long repositoryId, Long userId); +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatReadOffsetService.java b/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatReadOffsetService.java new file mode 100644 index 00000000..d6e41e89 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/chat/service/ChatReadOffsetService.java @@ -0,0 +1,50 @@ +package com.deepdirect.deepwebide_be.chat.service; + +import com.deepdirect.deepwebide_be.chat.domain.ChatMessage; +import com.deepdirect.deepwebide_be.chat.domain.ChatReadOffset; +import com.deepdirect.deepwebide_be.chat.repository.ChatMessageRepository; +import com.deepdirect.deepwebide_be.chat.repository.ChatReadOffsetRepository; +import com.deepdirect.deepwebide_be.global.exception.ErrorCode; +import com.deepdirect.deepwebide_be.global.exception.GlobalException; +import com.deepdirect.deepwebide_be.member.domain.User; +import com.deepdirect.deepwebide_be.member.repository.UserRepository; +import com.deepdirect.deepwebide_be.repository.domain.Repository; +import com.deepdirect.deepwebide_be.repository.repository.RepositoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ChatReadOffsetService { + + private final ChatReadOffsetRepository chatReadOffsetRepository; + private final RepositoryRepository repositoryRepository; + private final UserRepository userRepository; + private final ChatMessageRepository chatMessageRepository; + + @Transactional + public void saveOffset(Long repositoryId, Long userId, Long messageId) { + Repository repository = repositoryRepository.findById(repositoryId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); + + ChatMessage message = chatMessageRepository.findById(messageId) + .orElseThrow(() -> new GlobalException(ErrorCode.CHAT_MESSAGE_NOT_FOUND)); + + chatReadOffsetRepository.findByRepositoryIdAndUserId(repositoryId, userId) + .ifPresentOrElse( + offset -> { + if (message.getId() > offset.getLastReadMessage().getId()) { + offset.update(message); + } + }, + () -> { + ChatReadOffset newOffset = ChatReadOffset.of(repository, user, message); + chatReadOffsetRepository.save(newOffset); + } + ); + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java b/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java index f92846cc..3ac53a93 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java +++ b/src/main/java/com/deepdirect/deepwebide_be/global/exception/ErrorCode.java @@ -1,7 +1,6 @@ package com.deepdirect.deepwebide_be.global.exception; import lombok.Getter; -import org.springframework.boot.actuate.autoconfigure.observation.ObservationProperties; import org.springframework.http.HttpStatus; @Getter @@ -38,6 +37,10 @@ public enum ErrorCode { HISTORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 히스토리를 찾을 수 없습니다."), MISSING_WS_PARAMS(HttpStatus.BAD_REQUEST, "WebSocket 연결에 필요한 Param이 누락되었습니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST,"검색 키워드를 입력해주세요."), + REPOSITORY_EXECUTION_FAILED(HttpStatus.BAD_REQUEST, "레포지토리를 찾을 수 없습니다."), + UNSUPPORTED_REPOSITORY_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 레포지토리 타입입니다."), + REPOSITORY_FILES_NOT_FOUND(HttpStatus.BAD_REQUEST, "레포지토리 파일을 찾을 수 없습니다."), + FILE_TREE_CONVERSION_FAILED(HttpStatus.BAD_REQUEST, "파일 트리 변환에 실패했습니다."), // 401 UNAUTHORIZED UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증되지 않았습니다."), @@ -57,6 +60,7 @@ public enum ErrorCode { VERIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "인증 요청 기록이 없습니다."), REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않은 레포지토리 입니다."), ENTRY_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "입장 코드를 찾을 수 없습니다."), + CHAT_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "채팅 메시지를 찾을 수 없습니다."), // 409 CONFLICT diff --git a/src/main/java/com/deepdirect/deepwebide_be/global/exception/GlobalExceptionHandler.java b/src/main/java/com/deepdirect/deepwebide_be/global/exception/GlobalExceptionHandler.java index 58881b2a..69f63b45 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/deepdirect/deepwebide_be/global/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.deepdirect.deepwebide_be.global.exception; import com.deepdirect.deepwebide_be.global.dto.ApiResponseDto; +import com.deepdirect.deepwebide_be.sandbox.exception.SandboxException; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; @@ -75,6 +76,13 @@ public ResponseEntity> handleOtherExceptions(Exception ex, Htt .body(ApiResponseDto.error(500, responseMessage)); } + @ExceptionHandler(SandboxException.class) + public ResponseEntity> handleSandboxException(SandboxException e) { + log.error("Sandbox execution error", e); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponseDto.of(503, "코드 실행 서비스가 일시적으로 사용할 수 없습니다.", null)); + } + /** Sentry에 예외 정보 전송 */ private SentryId sendToSentry(Exception ex, String errorType, SentryLevel level, HttpServletRequest request) { if (!Sentry.isEnabled()) { diff --git a/src/main/java/com/deepdirect/deepwebide_be/global/security/SecurityConfiguration.java b/src/main/java/com/deepdirect/deepwebide_be/global/security/SecurityConfiguration.java index 6682d386..59bac50f 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/global/security/SecurityConfiguration.java +++ b/src/main/java/com/deepdirect/deepwebide_be/global/security/SecurityConfiguration.java @@ -11,7 +11,6 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; @@ -91,9 +90,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti new AntPathRequestMatcher("/redis-test"), new AntPathRequestMatcher("/test/**"), // Sentry 테스트용 new AntPathRequestMatcher("/ws/**"), - new AntPathRequestMatcher("/test/**"), // Sentry 테스트용 new AntPathRequestMatcher("/login/oauth2/**"), - new AntPathRequestMatcher("/oauth2/**") + new AntPathRequestMatcher("/oauth2/**"), + new AntPathRequestMatcher("/api/projects/**") ).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/auth/signout")).authenticated() .anyRequest().authenticated() diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/controller/RepositoryRunController.java b/src/main/java/com/deepdirect/deepwebide_be/repository/controller/RepositoryRunController.java new file mode 100644 index 00000000..7f27f54e --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/controller/RepositoryRunController.java @@ -0,0 +1,93 @@ +package com.deepdirect.deepwebide_be.repository.controller; + +import com.deepdirect.deepwebide_be.global.dto.ApiResponseDto; +import com.deepdirect.deepwebide_be.global.exception.ErrorCode; +import com.deepdirect.deepwebide_be.global.exception.GlobalException; +import com.deepdirect.deepwebide_be.global.security.CustomUserDetails; +import com.deepdirect.deepwebide_be.repository.dto.response.RepositoryExecuteResponse; +import com.deepdirect.deepwebide_be.repository.dto.response.RepositoryStatusResponse; +import com.deepdirect.deepwebide_be.repository.dto.response.RepositoryStopResponse; +import com.deepdirect.deepwebide_be.repository.repository.RepositoryRepository; +import com.deepdirect.deepwebide_be.repository.service.RepositoryRunService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.util.Map; + + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/repositories") +@Tag(name = "RUN", description = "레포지토리 실행, 로그 반환 등 기능 API") +public class RepositoryRunController { + + private final RepositoryRunService repositoryRunService; + private final RepositoryRepository repositoryRepository; + + @PostMapping("/{repositoryId}/execute") + @Operation(summary = "레포지토리 실행", description = "레포지토리를 실행하고 실행 결과를 반환합니다.") + public ResponseEntity> executeRepository( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long repositoryId + ) { + RepositoryExecuteResponse resp = repositoryRunService.executeRepository(repositoryId, userDetails.getId()); + return ResponseEntity.ok(ApiResponseDto.of(200, "레포지토리 실행 요청 완료", resp)); + } + + /** + * 레포지토리 중지 + */ + @DeleteMapping("/{repositoryId}/stop") + @Operation(summary = "레포지토리 중지", description = "레포지토리를 중지하고 중지 결과를 반환합니다.") + public ResponseEntity> stopRepository( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long repositoryId + ) { + boolean success = repositoryRunService.stopRepository(repositoryId, userDetails.getId()); + + RepositoryStopResponse result = RepositoryStopResponse.builder() + .repositoryId(repositoryId) + .stopped(success) + .message(success ? "레포지토리가 중지되었습니다." : "중지할 컨테이너가 없습니다.") + .build(); + + return ResponseEntity.ok(ApiResponseDto.of(200, "레포지토리 중지 요청 완료", result)); + } + + /** + * 레포지토리 실행 상태 조회 + */ + @GetMapping("/{repositoryId}/status") + @Operation(summary = "레포지토리 상태 조회", description = "레포지토리의 실행 상태를 조회합니다.") + public ResponseEntity> getRepositoryStatus( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long repositoryId + ) { + RepositoryStatusResponse status = repositoryRunService.getRepositoryStatus(repositoryId, userDetails.getId()); + return ResponseEntity.ok(ApiResponseDto.of(200, "레포지토리 상태 조회 완료", status)); + } + + /** + * 레포지토리 실행 로그 조회 + */ + @GetMapping("/{repositoryId}/logs") + @Operation(summary = "레포지토리 로그 조회", description = "레포지토리의 실행 로그를 조회합니다.") + public ResponseEntity>> getRepositoryLogs( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long repositoryId, + @RequestParam(defaultValue = "50") int lines, + @RequestParam(defaultValue = "5m") String since) { + + Map logs = repositoryRunService.getRepositoryLogs(repositoryId, userDetails.getId(), lines, since); + return ResponseEntity.ok(ApiResponseDto.of(200, "레포지토리 로그 조회 완료", logs)); + } +} + diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/domain/PortRegistry.java b/src/main/java/com/deepdirect/deepwebide_be/repository/domain/PortRegistry.java index 28a94ac3..807b07e3 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/repository/domain/PortRegistry.java +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/domain/PortRegistry.java @@ -17,12 +17,26 @@ public class PortRegistry { @OneToOne private Repository repository; - public void setStatus(PortStatus status) { - this.status = status; +// public void setStatus(PortStatus status) { +// this.status = status; +// } +// +// public void setRepository(Repository repository) { +// this.repository = repository; +// } + + public void assignToRepository(Repository repository) { + this.status = PortStatus.IN_USE; + this.repository = repository; } - public void setRepository(Repository repository) { - this.repository = repository; + public void release() { + this.status = PortStatus.AVAILABLE; + this.repository = null; + } + + public Integer getPort() { + return this.port; } } diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/domain/RunningContainer.java b/src/main/java/com/deepdirect/deepwebide_be/repository/domain/RunningContainer.java new file mode 100644 index 00000000..fba3d2aa --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/domain/RunningContainer.java @@ -0,0 +1,77 @@ +package com.deepdirect.deepwebide_be.repository.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "running_containers") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RunningContainer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private Long repositoryId; + + @Column(nullable = false) + private String uuid; + + @Column(nullable = false) + private String containerName; + + @Column(nullable = false) + private Integer port; + + @Column(nullable = false) + private String status; + + @Column(nullable = false) + private String framework; + + @Column + private String s3Url; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column + private LocalDateTime stoppedAt; + + // PrePersist로 createdAt 자동 설정 + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } + + // 상태 업데이트 메서드 + public void stop() { + this.status = "STOPPED"; + this.stoppedAt = LocalDateTime.now(); + } + + public void updateStatus(String status) { + this.status = status; + } + + // Builder 패턴을 위한 커스텀 빌더 클래스 + public static class RunningContainerBuilder { + private LocalDateTime createdAt = LocalDateTime.now(); // 기본값 설정 + + public RunningContainerBuilder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryExecuteResponse.java b/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryExecuteResponse.java new file mode 100644 index 00000000..909c582f --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryExecuteResponse.java @@ -0,0 +1,39 @@ +package com.deepdirect.deepwebide_be.repository.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "레포지토리 실행 결과 응답 DTO") +public class RepositoryExecuteResponse { + + @Schema(description = "레포지토리 UUID", example = "123e4567-e89b-12d3-a456-426614174000") + private String uuid; + + @Schema(description = "S3 URL", example = "https://s3.amazonaws.com/bucket/123e4567-e89b-12d3-a456-426614174000.zip") + private String s3Url; + + @Schema(description = "레포지토리 포트 번호", example = "8080") + private Integer port; + + @Schema(description = "실행 결과 메시지", example = "레포지토리 실행이 성공적으로 완료되었습니다.") + private String message; + + // 추가된 필드들 + @Schema(description = "실행 ID", example = "exec-123456") + private String executionId; + + @Schema(description = "실행 상태", example = "RUNNING", allowableValues = {"PENDING", "RUNNING", "SUCCESS", "FAILED"}) + private String status; + + @Schema(description = "실행 출력", example = "Application started successfully on port 8080") + private String output; + + @Schema(description = "실행 에러", example = "") + private String error; + + @Schema(description = "실행 시간 (밀리초)", example = "5000") + private Long executionTime; +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryStatusResponse.java b/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryStatusResponse.java new file mode 100644 index 00000000..7496c044 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryStatusResponse.java @@ -0,0 +1,38 @@ +package com.deepdirect.deepwebide_be.repository.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Map; + +@Getter +@Builder +@Schema(description = "레포지토리 실행 상태 응답 DTO") +public class RepositoryStatusResponse { + + @Schema(description = "레포지토리 ID", example = "1") + private Long repositoryId; + + @Schema(description = "실행 중인 컨테이너 UUID", example = "sandbox-1a2b3c4d") + private String uuid; + + @Schema(description = "도커 컨테이너 이름", example = "sandbox-1a2b3c4d") + private String containerName; + + @Schema(description = "할당된 실행 포트", example = "43210") + private Integer port; + + @Schema(description = "레포지토리 프레임워크", example = "spring") + private String framework; + + @Schema(description = "컨테이너 생성 시각", example = "2025-07-30T19:00:00") + private LocalDateTime createdAt; + + @Schema(description = "DB에 기록된 상태 (예: RUNNING, STOPPED)", example = "RUNNING") + private String dbStatus; + + @Schema(description = "샌드박스 서버에서 반환한 상태 정보", example = "{\"state\": \"healthy\", \"uptime\": \"5m\"}") + private Map sandboxStatus; +} \ No newline at end of file diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryStopResponse.java b/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryStopResponse.java new file mode 100644 index 00000000..ad765023 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/dto/response/RepositoryStopResponse.java @@ -0,0 +1,20 @@ +package com.deepdirect.deepwebide_be.repository.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "레포지토리 중지 응답 DTO") +public class RepositoryStopResponse { + + @Schema(description = "레포지토리 ID", example = "1") + private Long repositoryId; + + @Schema(description = "중지 성공 여부", example = "true") + private boolean stopped; + + @Schema(description = "중지 메시지", example = "레포지토리가 중지되었습니다.") + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/repository/PortRegistryRepository.java b/src/main/java/com/deepdirect/deepwebide_be/repository/repository/PortRegistryRepository.java index dcca9b65..f2b8c769 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/repository/repository/PortRegistryRepository.java +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/repository/PortRegistryRepository.java @@ -2,10 +2,15 @@ import com.deepdirect.deepwebide_be.repository.domain.PortRegistry; import com.deepdirect.deepwebide_be.repository.domain.PortStatus; +import com.deepdirect.deepwebide_be.repository.domain.Repository; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface PortRegistryRepository extends JpaRepository { + Optional findFirstByStatusOrderByPortAsc(PortStatus status); + + Optional findByRepository(Repository repository); + Optional findFirstByStatus(PortStatus status); } diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/repository/RunningContainerRepository.java b/src/main/java/com/deepdirect/deepwebide_be/repository/repository/RunningContainerRepository.java new file mode 100644 index 00000000..3705099a --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/repository/RunningContainerRepository.java @@ -0,0 +1,32 @@ +package com.deepdirect.deepwebide_be.repository.repository; + +import com.deepdirect.deepwebide_be.repository.domain.RunningContainer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface RunningContainerRepository extends JpaRepository { + + Optional findByRepositoryId(Long repositoryId); + + @Modifying + @Query("DELETE FROM RunningContainer rc WHERE rc.repositoryId = :repositoryId") + void deleteByRepositoryId(@Param("repositoryId") Long repositoryId); + + List findByStatus(String status); + + Optional findByUuid(String uuid); + + @Query("SELECT rc FROM RunningContainer rc WHERE rc.status = 'RUNNING' AND rc.createdAt < :before") + List findStaleContainers(@Param("before") LocalDateTime before); + + @Query("SELECT COUNT(rc) FROM RunningContainer rc WHERE rc.status = 'RUNNING'") + long countRunningContainers(); +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryRunService.java b/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryRunService.java new file mode 100644 index 00000000..d551690b --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryRunService.java @@ -0,0 +1,568 @@ +package com.deepdirect.deepwebide_be.repository.service; + +import com.deepdirect.deepwebide_be.file.domain.FileContent; +import com.deepdirect.deepwebide_be.file.domain.FileNode; +import com.deepdirect.deepwebide_be.file.repository.FileContentRepository; +import com.deepdirect.deepwebide_be.file.repository.FileNodeRepository; +import com.deepdirect.deepwebide_be.global.exception.ErrorCode; +import com.deepdirect.deepwebide_be.global.exception.GlobalException; +import com.deepdirect.deepwebide_be.repository.domain.*; +import com.deepdirect.deepwebide_be.repository.dto.response.RepositoryExecuteResponse; +import com.deepdirect.deepwebide_be.repository.dto.response.RepositoryStatusResponse; +import com.deepdirect.deepwebide_be.repository.repository.PortRegistryRepository; +import com.deepdirect.deepwebide_be.repository.repository.RepositoryRepository; +import com.deepdirect.deepwebide_be.repository.repository.RunningContainerRepository; +import com.deepdirect.deepwebide_be.sandbox.dto.request.SandboxExecutionRequest; +import com.deepdirect.deepwebide_be.sandbox.dto.response.SandboxExecutionResponse; +import com.deepdirect.deepwebide_be.sandbox.service.S3Service; +import com.deepdirect.deepwebide_be.sandbox.service.SandboxService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RepositoryRunService { + + private final RepositoryRepository repositoryRepository; + private final S3Service s3Service; + private final SandboxService sandboxService; + private final PortRegistryRepository portRegistryRepository; + private final FileNodeRepository fileNodeRepository; + private final FileContentRepository fileContentRepository; + private final RunningContainerRepository runningContainerRepository; + private final String sandboxBaseUrl = "http://localhost:9090"; + private final RestTemplate restTemplate; + + @Transactional + public RepositoryExecuteResponse executeRepository(Long repositoryId, Long userId) { + log.info("Starting repository execution - repositoryId: {}, userId: {}", repositoryId, userId); + + File zipFile = null; + try { + // 1. 기존 실행 중인 컨테이너 중지 요청 + stopExistingContainer(repositoryId); + + // 2. 권한 체크 & 레포지토리 조회 + Repository repo = repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); + + // 3. framework, port 등 정보 추출 + String framework = convertTypeToFramework(repo.getRepositoryType()); + Integer port = allocateOrGetPort(repo); + + // 4. 새로운 UUID 생성 + String uuid = UUID.randomUUID().toString(); + + // 5. 파일트리를 zip으로 변환 + zipFile = fileTreeToZip(repositoryId, uuid); + + // 6. S3 업로드 + String s3Url = uploadToS3(zipFile, uuid); + + // 7. 샌드박스 실행 요청 + SandboxExecutionRequest request = SandboxExecutionRequest.builder() + .uuid(uuid) + .url(s3Url) + .framework(framework) + .port(port) + .build(); + + SandboxExecutionResponse result = sandboxService.requestExecution(request); + + // 8. 실행 중인 컨테이너 정보 저장 + saveRunningContainer(repositoryId, uuid, "sandbox-" + uuid, port, framework, s3Url); + + log.info("Repository execution completed - repositoryId: {}, uuid: {}, port: {}", repositoryId, uuid, port); + + return RepositoryExecuteResponse.builder() + .uuid(uuid) + .s3Url(s3Url) + .port(port) + .message(result.getMessage()) + .executionId(result.getExecutionId()) + .status(result.getStatus()) + .output(result.getOutput()) + .error(result.getError()) + .executionTime(result.getExecutionTime()) + .build(); + + } catch (GlobalException e) { + log.error("Repository execution failed - repositoryId: {}, userId: {}", repositoryId, userId, e); + throw e; + } catch (Exception e) { + log.error("Repository execution failed - repositoryId: {}, userId: {}", repositoryId, userId, e); + throw new GlobalException(ErrorCode.REPOSITORY_EXECUTION_FAILED); + } finally { + cleanupTempFile(zipFile); + } + } + + /** + * 기존 실행 중인 컨테이너 중지 + */ + private void stopExistingContainer(Long repositoryId) { + runningContainerRepository.findByRepositoryId(repositoryId) + .ifPresent(container -> { + try { + log.info("Stopping existing container for repository {}: {}", repositoryId, container.getUuid()); + + // 샌드박스 서버에 중지 요청 + boolean success = sandboxService.stopContainer(container.getUuid()); + + if (success) { + // 상태 업데이트 + container.stop(); + runningContainerRepository.save(container); + + log.info("Successfully stopped existing container: {}", container.getUuid()); + } else { + log.warn("Failed to stop existing container: {}", container.getUuid()); + } + + // 성공 여부와 관계없이 DB에서 제거 (새로운 컨테이너가 실행될 예정이므로) + runningContainerRepository.deleteByRepositoryId(repositoryId); + + } catch (Exception e) { + log.error("Error while stopping existing container: {}", container.getUuid(), e); + // 에러가 발생해도 DB에서는 제거 + runningContainerRepository.deleteByRepositoryId(repositoryId); + } + }); + } + + /** + * 실행 중인 컨테이너 정보 저장 + */ + @Transactional + public void saveRunningContainer(Long repositoryId, String uuid, String containerName, + Integer port, String framework, String s3Url) { + try { + RunningContainer container = RunningContainer.builder() + .repositoryId(repositoryId) + .uuid(uuid) + .containerName(containerName) + .port(port) + .status("RUNNING") + .framework(framework) + .s3Url(s3Url) + .build(); + + runningContainerRepository.save(container); + log.info("Saved running container info - repositoryId: {}, uuid: {}, port: {}", + repositoryId, uuid, port); + + } catch (Exception e) { + log.error("Failed to save running container info - repositoryId: {}, uuid: {}", + repositoryId, uuid, e); + } + } + + /** + * 레포지토리 중지 (수동) + */ + @Transactional + public boolean stopRepository(Long repositoryId, Long userId) { + try { + // 권한 체크 + repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); + + Optional containerOpt = runningContainerRepository.findByRepositoryId(repositoryId); + + if (containerOpt.isEmpty()) { + log.info("No running container found for repository: {}", repositoryId); + return false; + } + + RunningContainer container = containerOpt.get(); + + // 샌드박스 서버에 중지 요청 + boolean success = sandboxService.stopContainer(container.getUuid()); + + if (success) { + container.stop(); + runningContainerRepository.save(container); + log.info("Successfully stopped repository: {} (uuid: {})", repositoryId, container.getUuid()); + } + + return success; + + } catch (Exception e) { + log.error("Failed to stop repository: {}", repositoryId, e); + return false; + } + } + + /** + * 레포지토리 상태 조회 + */ + public RepositoryStatusResponse getRepositoryStatus(Long repositoryId, Long userId) { + try { + repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); + + Optional containerOpt = runningContainerRepository.findByRepositoryId(repositoryId); + + if (containerOpt.isEmpty()) { + return RepositoryStatusResponse.builder() + .repositoryId(repositoryId) + .dbStatus("NOT_RUNNING") + .sandboxStatus(Map.of("message", "No running container found")) + .build(); + } + + RunningContainer container = containerOpt.get(); + Map sandboxStatus = sandboxService.getContainerStatus(container.getUuid()); + + return RepositoryStatusResponse.builder() + .repositoryId(repositoryId) + .uuid(container.getUuid()) + .containerName(container.getContainerName()) + .port(container.getPort()) + .framework(container.getFramework()) + .createdAt(container.getCreatedAt()) + .dbStatus(container.getStatus()) + .sandboxStatus(sandboxStatus) + .build(); + + } catch (Exception e) { + log.error("Failed to get repository status: {}", repositoryId, e); + return RepositoryStatusResponse.builder() + .repositoryId(repositoryId) + .dbStatus("ERROR") + .sandboxStatus(Map.of("error", e.getMessage())) + .build(); + } + } + + private String convertTypeToFramework(RepositoryType type) { + return switch (type) { + case SPRING_BOOT -> "spring"; + case REACT -> "react"; + case FAST_API -> "fastapi"; + default -> throw new GlobalException(ErrorCode.UNSUPPORTED_REPOSITORY_TYPE); + }; + } + + private Integer allocateOrGetPort(Repository repo) { + return portRegistryRepository.findByRepository(repo) + .map(PortRegistry::getPort) + .orElseGet(() -> allocateNewPortForRepository(repo).getPort()); + } + + @Transactional + public PortRegistry allocateNewPortForRepository(Repository repo) { + log.debug("Allocating new port for repository: {}", repo.getId()); + + // 1. 사용 가능한 포트 목록 조회 (status == AVAILABLE) + PortRegistry available = portRegistryRepository.findFirstByStatus(PortStatus.AVAILABLE) + .orElseThrow(() -> new GlobalException(ErrorCode.NO_AVAILABLE_PORT)); + + // 2. 해당 포트 할당/저장 + available.assignToRepository(repo); + PortRegistry savedRegistry = portRegistryRepository.save(available); + + log.info("Port {} allocated to repository {}", available.getPort(), repo.getId()); + return savedRegistry; + } + + private String uploadToS3(File zipFile, String uuid) throws IOException { + log.debug("Uploading zip file to S3 - uuid: {}", uuid); + + try (FileInputStream fis = new FileInputStream(zipFile)) { + String s3Url = s3Service.upload( + new MockMultipartFile("file", zipFile.getName(), "application/zip", fis), + uuid + ); + log.debug("S3 upload completed - url: {}", s3Url); + return s3Url; + } + } + + public File fileTreeToZip(Long repositoryId, String uuid) { + Path tempDir = null; + Path zipPath = null; + + try { + log.debug("Converting file tree to zip - repositoryId: {}, uuid: {}", repositoryId, uuid); + + // 1. 임시 디렉토리 생성 + tempDir = Files.createTempDirectory("repo-" + uuid); + + // 2. 파일트리/내용 복원 + List nodes = fileNodeRepository.findAllByRepositoryId(repositoryId); + if (nodes.isEmpty()) { + log.warn("No files found for repository: {}", repositoryId); + throw new GlobalException(ErrorCode.REPOSITORY_FILES_NOT_FOUND); + } + + restoreFileTree(nodes, tempDir); + + // 3. zip 압축 + zipPath = createZipFromDirectory(tempDir); + + log.debug("File tree conversion completed - zipPath: {}", zipPath); + return zipPath.toFile(); + + } catch (Exception e) { + log.error("Failed to convert file tree to zip - repositoryId: {}", repositoryId, e); + // 실패 시 생성된 임시 파일들 정리 + cleanupTempResources(tempDir, zipPath); + throw new GlobalException(ErrorCode.FILE_TREE_CONVERSION_FAILED); + } finally { + // 임시 디렉토리 정리 (zip 파일은 유지) + cleanupTempDirectory(tempDir); + } + } + + private void restoreFileTree(List nodes, Path tempDir) throws IOException { + log.debug("Restoring file tree - nodes count: {}", nodes.size()); + + // 폴더 먼저 생성 + nodes.stream() + .filter(FileNode::isFolder) + .forEach(node -> { + try { + Path nodePath = tempDir.resolve(node.getPath()); + Files.createDirectories(nodePath); + log.trace("Created directory: {}", nodePath); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create directory: " + node.getPath(), e); + } + }); + + // 파일 생성 + for (FileNode node : nodes) { + if (!node.isFolder()) { + Path nodePath = tempDir.resolve(node.getPath()); + + // 부모 디렉토리 먼저 생성 + if (nodePath.getParent() != null) { + Files.createDirectories(nodePath.getParent()); + } + + // 파일 내용 작성 + FileContent content = fileContentRepository.findByFileNode(node) + .orElseThrow(() -> new GlobalException(ErrorCode.FILE_CONTENT_NOT_FOUND)); + + Files.write(nodePath, content.getContent()); + log.trace("Created file: {} (size: {} bytes)", nodePath, content.getContent().length); + } + } + } + + private Path createZipFromDirectory(Path tempDir) throws IOException { + Path zipPath = Paths.get(tempDir.toString() + ".zip"); + log.debug("Creating zip file: {}", zipPath); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) { + Files.walk(tempDir) + .filter(Files::isRegularFile) + .forEach(path -> addToZip(zos, tempDir, path)); + } + + long zipSize = Files.size(zipPath); + log.debug("Zip file created successfully - size: {} bytes", zipSize); + + return zipPath; + } + + private void addToZip(ZipOutputStream zos, Path baseDir, Path filePath) { + try { + String entryName = baseDir.relativize(filePath).toString().replace('\\', '/'); + ZipEntry entry = new ZipEntry(entryName); + zos.putNextEntry(entry); + zos.write(Files.readAllBytes(filePath)); + zos.closeEntry(); + log.trace("Added to zip: {}", entryName); + } catch (IOException e) { + throw new UncheckedIOException("Failed to add file to zip: " + filePath, e); + } + } + + private void cleanupTempFile(File file) { + if (file != null && file.exists()) { + try { + Files.deleteIfExists(file.toPath()); + log.debug("Cleaned up temp file: {}", file.getPath()); + } catch (IOException e) { + log.warn("Failed to cleanup temp file: {}", file.getPath(), e); + } + } + } + + private void cleanupTempDirectory(Path tempDir) { + if (tempDir != null && Files.exists(tempDir)) { + try { + FileUtils.deleteDirectory(tempDir.toFile()); + log.debug("Cleaned up temp directory: {}", tempDir); + } catch (IOException e) { + log.warn("Failed to cleanup temp directory: {}", tempDir, e); + } + } + } + + private void cleanupTempResources(Path tempDir, Path zipPath) { + cleanupTempDirectory(tempDir); + if (zipPath != null) { + try { + Files.deleteIfExists(zipPath); + log.debug("Cleaned up temp zip file: {}", zipPath); + } catch (IOException e) { + log.warn("Failed to cleanup zip file: {}", zipPath, e); + } + } + } + + /** + * 레포지토리 로그 조회 + */ + public Map getRepositoryLogs(Long repositoryId, Long userId, int lines, String since) { + try { + log.info("Getting repository logs - repositoryId: {}, userId: {}", repositoryId, userId); + + repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND)); + + Optional containerOpt = runningContainerRepository.findByRepositoryId(repositoryId); + + if (containerOpt.isEmpty()) { + return Map.of( + "port", null, + "logs", "실행 중인 컨테이너가 없습니다." + ); + } + + RunningContainer container = containerOpt.get(); + log.info("Found container - uuid: {}, dbStatus: {}", container.getUuid(), container.getStatus()); + + String url = String.format("%s/api/sandbox/logs/%s?lines=%d&since=%s", + sandboxBaseUrl, container.getUuid(), lines, since); + + try { + Map response = restTemplate.getForObject(url, Map.class); + + if (response != null) { + String status = (String) response.get("status"); + + // 컨테이너를 찾을 수 없는 경우 DB 상태 업데이트 + if ("CONTAINER_NOT_FOUND".equals(status)) { + log.warn("Container {} not found, updating DB status", container.getUuid()); + + container.stop(); + runningContainerRepository.save(container); + + return Map.of( + "port", null, + "logs", "컨테이너가 존재하지 않아 중지되었습니다." + ); + } + + // 정상 응답 - 포트와 로그만 반환 + String logs = extractLogs(response); + return Map.of( + "port", container.getPort(), + "logs", logs + ); + } + + return Map.of( + "port", container.getPort(), + "logs", "로그를 가져올 수 없습니다." + ); + + } catch (Exception httpEx) { + log.error("HTTP request failed - url: {}", url, httpEx); + + // HTTP 오류 시에도 컨테이너 상태 확인 + if (httpEx.getMessage().contains("404") || httpEx.getMessage().contains("Not Found")) { + container.stop(); + runningContainerRepository.save(container); + } + + return Map.of( + "port", container.getPort(), + "logs", "로그 조회 중 오류가 발생했습니다: " + httpEx.getMessage() + ); + } + + } catch (Exception e) { + log.error("Failed to get repository logs: {}", repositoryId, e); + return Map.of( + "port", null, + "logs", "오류가 발생했습니다: " + e.getMessage() + ); + } + } + + private String extractLogs(Map response) { + StringBuilder combinedLogs = new StringBuilder(); + + String stdout = (String) response.get("stdout"); + String stderr = (String) response.get("stderr"); + + if (stderr != null && !stderr.trim().isEmpty()) { + combinedLogs.append(stderr.trim()); + } + + if (stdout != null && !stdout.trim().isEmpty()) { + if (combinedLogs.length() > 0) { + combinedLogs.append("\n"); + } + combinedLogs.append(stdout.trim()); + } + + String result = combinedLogs.toString(); + return result.isEmpty() ? "로그가 없습니다." : result; + } + + private Map createContainerInfo(RunningContainer container, String actualStatus) { + return Map.of( + "uuid", container.getUuid(), + "containerName", container.getContainerName(), + "port", container.getPort(), + "framework", container.getFramework(), + "createdAt", container.getCreatedAt(), + "dbStatus", actualStatus + ); + } + + private Map createNoContainerResponse(Long repositoryId) { + return Map.of( + "repositoryId", repositoryId, + "status", "NO_CONTAINER", + "message", "실행 중인 컨테이너 정보가 없습니다.", + "stdout", "", + "stderr", "", + "logs", "" + ); + } + + private Map createErrorResponse(Long repositoryId, String errorMessage) { + return Map.of( + "repositoryId", repositoryId, + "status", "ERROR", + "error", errorMessage, + "stdout", "", + "stderr", "", + "logs", "" + ); + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryService.java b/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryService.java index 1c03d8cd..4f19747e 100644 --- a/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryService.java +++ b/src/main/java/com/deepdirect/deepwebide_be/repository/service/RepositoryService.java @@ -59,8 +59,7 @@ public RepositoryCreateResponse createRepository(RepositoryCreateRequest request .orElseThrow(() -> new GlobalException( ErrorCode.NO_AVAILABLE_PORT )); - availablePort.setStatus(PortStatus.IN_USE); - availablePort.setRepository(savedRepository); + availablePort.assignToRepository(savedRepository); portRegistryRepository.save(availablePort); RepositoryMember ownerMember = RepositoryMember.builder() diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/config/RestTemplateConfig.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/config/RestTemplateConfig.java new file mode 100644 index 00000000..86a71101 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/config/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package com.deepdirect.deepwebide_be.sandbox.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(10)) // 연결 시도 10초 + .readTimeout(Duration.ofMinutes(10)) // 응답 대기 10분 + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/controller/SandboxController.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/controller/SandboxController.java new file mode 100644 index 00000000..42fcf601 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/controller/SandboxController.java @@ -0,0 +1,78 @@ +package com.deepdirect.deepwebide_be.sandbox.controller; + +import com.deepdirect.deepwebide_be.global.dto.ApiResponseDto; +import com.deepdirect.deepwebide_be.sandbox.dto.request.SandboxExecutionRequest; +import com.deepdirect.deepwebide_be.sandbox.dto.response.SandboxExecutionResponse; +import com.deepdirect.deepwebide_be.sandbox.service.S3Service; +import com.deepdirect.deepwebide_be.sandbox.service.SandboxService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; +import java.util.UUID; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/projects") +@Tag(name = "SANDBOX", description = "샌드박스 실행 API") +public class SandboxController { + + private final S3Service s3Service; + private final SandboxService sandboxService; + + @PostMapping("/execute") + @Operation(summary = "프로젝트 업로드 및 실행", description = "zip 파일을 업로드하고 샌드박스에서 실행합니다.") + public ResponseEntity> uploadAndRun( + @Parameter(description = "업로드할 zip 파일", required = true) + @RequestParam("file") MultipartFile file, + + @Parameter(description = "프레임워크 타입", example = "spring", required = true) + @RequestParam("framework") String framework, + + @Parameter(description = "포트 번호", example = "8080", required = true) + @RequestParam("port") Integer port + ) { + try { + log.info("Starting project execution - framework: {}, port: {}", framework, port); + + // 1. UUID 생성 + String uuid = UUID.randomUUID().toString(); + + // 2. S3에 파일 업로드 + String s3Url = s3Service.upload(file, uuid); + log.debug("File uploaded to S3 - url: {}", s3Url); + + // 3. 샌드박스 실행 요청 생성 + SandboxExecutionRequest request = SandboxExecutionRequest.builder() + .uuid(uuid) + .url(s3Url) + .framework(framework) + .port(port) + .build(); + + // 4. 샌드박스 서비스 호출 + SandboxExecutionResponse response = sandboxService.requestExecution(request); + + log.info("Project execution completed - uuid: {}, status: {}", uuid, response.getStatus()); + + return ResponseEntity.ok( + ApiResponseDto.of(200, "프로젝트 실행 요청이 완료되었습니다.", response) + ); + + } catch (Exception e) { + log.error("Project execution failed - framework: {}, port: {}", framework, port, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponseDto.of(500, "실행 실패: " + e.getMessage(), null)); + } + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/controller/SandboxLogController.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/controller/SandboxLogController.java new file mode 100644 index 00000000..d9ff10fd --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/controller/SandboxLogController.java @@ -0,0 +1,32 @@ +package com.deepdirect.deepwebide_be.sandbox.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; +import org.springframework.web.client.RestTemplate; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/projects") +public class SandboxLogController { + + private final RestTemplate restTemplate = new RestTemplate(); + + @GetMapping("/logs/{uuid}") + public ResponseEntity getContainerLogs(@PathVariable String uuid) { + String containerId = "sandbox-" + uuid; + String sandboxUrl = "http://localhost:9090/api/sandbox/logs/" + containerId; + + try { + ResponseEntity response = restTemplate.getForEntity(sandboxUrl, String.class); + return ResponseEntity.ok(response.getBody()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("샌드박스 로그 조회 실패: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/dto/request/SandboxExecutionRequest.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/dto/request/SandboxExecutionRequest.java new file mode 100644 index 00000000..82f64380 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/dto/request/SandboxExecutionRequest.java @@ -0,0 +1,17 @@ +package com.deepdirect.deepwebide_be.sandbox.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SandboxExecutionRequest { + private String uuid; + private String url; + private String framework; + private Integer port; +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/dto/response/SandboxExecutionResponse.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/dto/response/SandboxExecutionResponse.java new file mode 100644 index 00000000..64752972 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/dto/response/SandboxExecutionResponse.java @@ -0,0 +1,17 @@ +package com.deepdirect.deepwebide_be.sandbox.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SandboxExecutionResponse { + private String executionId; + private String status; + private String output; + private String error; + private Long executionTime; + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/exception/SandboxException.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/exception/SandboxException.java new file mode 100644 index 00000000..b98c3f5b --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/exception/SandboxException.java @@ -0,0 +1,11 @@ +package com.deepdirect.deepwebide_be.sandbox.exception; + +public class SandboxException extends RuntimeException { + public SandboxException(String message) { + super(message); + } + + public SandboxException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/service/S3Service.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/service/S3Service.java new file mode 100644 index 00000000..4854059d --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/service/S3Service.java @@ -0,0 +1,30 @@ +package com.deepdirect.deepwebide_be.sandbox.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public String upload(MultipartFile file, String uuid) throws IOException { + String key = "sandbox-projects/" + uuid + ".zip"; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + + amazonS3.putObject(bucket, key, file.getInputStream(), metadata); + return amazonS3.getUrl(bucket, key).toString(); + } +} + diff --git a/src/main/java/com/deepdirect/deepwebide_be/sandbox/service/SandboxService.java b/src/main/java/com/deepdirect/deepwebide_be/sandbox/service/SandboxService.java new file mode 100644 index 00000000..6cd94052 --- /dev/null +++ b/src/main/java/com/deepdirect/deepwebide_be/sandbox/service/SandboxService.java @@ -0,0 +1,109 @@ +package com.deepdirect.deepwebide_be.sandbox.service; + +import com.deepdirect.deepwebide_be.sandbox.dto.request.SandboxExecutionRequest; +import com.deepdirect.deepwebide_be.sandbox.dto.response.SandboxExecutionResponse; +import com.deepdirect.deepwebide_be.sandbox.exception.SandboxException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SandboxService { + + private final RestTemplate restTemplate; + + @Value("${sandbox.api.base-url:http://localhost:9090}") + private String sandboxBaseUrl; + + public SandboxExecutionResponse requestExecution(SandboxExecutionRequest request) { + try { + HttpHeaders headers = createHeaders(); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + String url = sandboxBaseUrl + "/api/sandbox/run"; + log.info("Requesting sandbox execution: {} with request: {}", url, request); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + httpEntity, + SandboxExecutionResponse.class + ); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("Sandbox execution completed successfully"); + return response.getBody(); + } else { + throw new SandboxException("Sandbox execution failed with status: " + response.getStatusCode()); + } + + } catch (RestClientException e) { + log.error("Failed to communicate with sandbox service", e); + throw new SandboxException("Sandbox service communication error", e); + } + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add("User-Agent", "DeepWebIDE-Backend"); + return headers; + } + + + /** + * 컨테이너 중지 요청 + */ + public boolean stopContainer(String uuid) { + try { + String url = sandboxBaseUrl + "/api/sandbox/stop/" + uuid; + + log.info("Sending stop request to sandbox server - uuid: {}, url: {}", uuid, url); + + // DELETE 요청으로 컨테이너 중지 + restTemplate.delete(url); + + log.info("Successfully sent stop request for container: {}", uuid); + return true; + + } catch (RestClientException e) { + log.error("Failed to send stop request for container: {}", uuid, e); + return false; + } catch (Exception e) { + log.error("Unexpected error while stopping container: {}", uuid, e); + return false; + } + } + + /** + * 컨테이너 상태 조회 + */ + public Map getContainerStatus(String uuid) { + try { + String url = sandboxBaseUrl + "/api/sandbox/status/" + uuid; + + log.debug("Checking container status - uuid: {}, url: {}", uuid, url); + + Map response = restTemplate.getForObject(url, Map.class); + + log.debug("Container status response: {}", response); + return response; + + } catch (RestClientException e) { + log.error("Failed to get container status: {}", uuid, e); + return Map.of( + "uuid", uuid, + "status", "ERROR", + "error", e.getMessage() + ); + } + } +} \ No newline at end of file