Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,66 @@
DeepWebIDE
api.deep
# Sprinf Boot + Java 17 + Gradle
This project provides a bakend implementation for DeepDirect using Spring Boot, JWT security, WebSocket (STOMP), Redis pub/sub, and MySQL.

## Tech Stack
- **Language** : Java 17
- **Library & Framework** : Spring Boot 3.4.7, Spring Security, Spring Web, Spring WebSocket, Spring Data JPA, Spring Mail
- **Build Tool** : Gradle
- **Database** : MySQL
- **ORM** : JPA(Hibernate)
- **Cache** / Message Broker : Redis(Lettuce, pub/sub)
- **Authentication** : JWT, OAuth2 (GitHub), Spring Security
- **Dev Tools** : Lombok, Swagger, Actuator
- **Message Protocol** : WebSocket + STOMP
- **Third-Party API** : Coolsms API
- **Monitoring** : Sentry
- **Cloud & Storage** : AWS EC2, S3, Route53
- **Deploy** : Docker + Nginx, GitHub Action (CI/CD)

---

## Environment Variables (.env)
These environment variables are required for local development and production deployments.
Sensitive values should never be exposed publicly--make sure they are securely managed through a `.env` file or a secure environment configuration system.


```env
# ✅ Redis
SPRING_REDIS_HOST=localhost
SPRING_REDIS_PORT=6379
SPRING_REDIS_PASSWORD=your_redis_password

# ✅ JWT
JWT_SECRET=dEVzVDFAM1NlQ3JFdCEyI2tFeTQzMjE=
JWT_ACCESS_TOKEN_EXPIRATION=86400000 # 24 hours (ms)
JWT_REFRESH_TOKEN_EXPIRATION=1209600000 # 14 days (ms)

# ✅ Gmail SMTP
SPRING_MAIL_USERNAME=your_email@gmail.com
SPRING_MAIL_PASSWORD=your_gmail_app_password

# ✅ Coolsms API
COOLSMS_KEY=your_coolsms_key
COOLSMS_SECRET=your_coolsms_secret
COOLSMS_NUMBER=010xxxxxxxx

# ✅ AWS
AWS_ACCESS_KEY=your_aws_access_key
AWS_SECRET_KEY=your_aws_secret_key
AWS_REGION=ap-northeast-2
AWS_S3_BUCKET=my-app-bucket

# ✅ GitHub OAuth
GIT_CLIENT_ID=your_github_client_id
GIT_CLIENT_SECRET=your_github_client_secret

# ✅ Sentry (Monitoring)
SENTRY_DSN=https://xxxxxxx.ingest.us.sentry.io/xxxxxxxxxx
SENTRY_ENVIRONMENT=development
SENTRY_RELEASE=0
SENTRY_TRACES_SAMPLE_RATE=0.1
SENTRY_DEBUG=false

# ✅ Sandbox API
SANDBOX_URL=http://localhost:9090
```

Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ private void sendSuccessResponse(HttpServletResponse response, SignInResponse da
window.opener.postMessage({
type: 'GITHUB_LOGIN_SUCCESS',
response: %s
}, 'http://localhost:5173');
}, 'https://www.deepdirect.site');
window.close();
}
</script>
Expand All @@ -70,7 +70,7 @@ private void sendErrorResponse(HttpServletResponse response, String errorMessage
window.opener.postMessage({
type: 'GITHUB_LOGIN_ERROR',
error: '%s'
}, 'http://localhost:5173');
}, 'https://www.deepdirect.site');
window.close();
}
</script>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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;
Expand All @@ -15,6 +17,7 @@
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;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package com.deepdirect.deepwebide_be.repository.domain;

import jakarta.persistence.*;
import lombok.Getter;

@Getter
@Entity
@Table(name = "port_registry")
public class PortRegistry {
Expand All @@ -19,7 +17,13 @@ public class PortRegistry {
@OneToOne
private Repository repository;

public PortRegistry() {} // JPA 기본 생성자
// 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;
Expand All @@ -31,4 +35,8 @@ public void release() {
this.repository = null;
}

public Integer getPort() {
return this.port;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.deepdirect.deepwebide_be.repository.domain.Repository;
import org.springframework.data.jpa.repository.JpaRepository;

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

public interface PortRegistryRepository extends JpaRepository<PortRegistry, Long> {
Expand All @@ -14,8 +13,4 @@ public interface PortRegistryRepository extends JpaRepository<PortRegistry, Long

Optional<PortRegistry> findByRepository(Repository repository);
Optional<PortRegistry> findFirstByStatus(PortStatus status);

Optional<PortRegistry> findByPort(Integer port);
List<PortRegistry> findAllByStatus(PortStatus status);

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,4 @@ public interface RunningContainerRepository extends JpaRepository<RunningContain

@Query("SELECT COUNT(rc) FROM RunningContainer rc WHERE rc.status = 'RUNNING'")
long countRunningContainers();

List<RunningContainer> findAllByStatusAndCreatedAtBefore(String status, LocalDateTime dateTime);

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
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.time.LocalDateTime;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
Expand All @@ -53,26 +53,33 @@ public class RepositoryRunService {
@Value("${sandbox.api.base-url}")
private String sandboxBaseUrl;

// === 컨테이너 실행 ===
@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); // 랜덤 할당
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)
Expand All @@ -82,11 +89,9 @@ public RepositoryExecuteResponse executeRepository(Long repositoryId, Long userI

SandboxExecutionResponse result = sandboxService.requestExecution(request);

// 8. 실행 중인 컨테이너 정보 저장
saveRunningContainer(repositoryId, uuid, "sandbox-" + uuid, port, framework, s3Url);

// **컨테이너 10분 후 자동 만료 비동기 스케줄**
scheduleAutoStopAndRelease(uuid, port);

log.info("Repository execution completed - repositoryId: {}, uuid: {}, port: {}", repositoryId, uuid, port);

return RepositoryExecuteResponse.builder()
Expand All @@ -112,46 +117,6 @@ public RepositoryExecuteResponse executeRepository(Long repositoryId, Long userI
}
}

// === 컨테이너 만료 비동기 스케줄 ===
@Async
public void scheduleAutoStopAndRelease(String uuid, Integer port) {
try {
Thread.sleep(600_000); // 10분 대기

boolean stopped = sandboxService.stopContainer(uuid);
if (stopped) {
RunningContainer container = runningContainerRepository.findByUuid(uuid).orElse(null);
if (container != null) {
container.stop();
runningContainerRepository.save(container);

PortRegistry portReg = portRegistryRepository.findByPort(port).orElse(null);
if (portReg != null) {
portReg.release();
portRegistryRepository.save(portReg);
}
}
log.info("컨테이너 {}가 만료되어 중지 및 포트 {} 반납 완료", uuid, port);
}
} catch (Exception e) {
log.error("컨테이너 만료 자동 중지 실패 - uuid: {}", uuid, e);
}
}

// === 만료 컨테이너 1분마다 백업성 스케줄 ===
@Scheduled(fixedDelay = 60_000)
public void autoCleanupExpiredContainers() {
LocalDateTime now = LocalDateTime.now();
List<RunningContainer> expired = runningContainerRepository
.findAllByStatusAndCreatedAtBefore("RUNNING", now.minusMinutes(10));
for (RunningContainer container : expired) {
log.info("자동정리: 10분 초과 컨테이너 발견 {}", container.getUuid());
stopRepository(container.getRepositoryId(), null);
}
}



/**
* 기존 실행 중인 컨테이너 중지
*/
Expand Down Expand Up @@ -193,7 +158,8 @@ public void saveRunningContainer(Long repositoryId, String uuid, String containe
Integer port, String framework, String s3Url) {
try {
RunningContainer container = RunningContainer.builder()
.repositoryId(repositoryId).uuid(uuid)
.repositoryId(repositoryId)
.uuid(uuid)
.containerName(containerName)
.port(port)
.status("RUNNING")
Expand All @@ -217,16 +183,9 @@ public void saveRunningContainer(Long repositoryId, String uuid, String containe
@Transactional
public boolean stopRepository(Long repositoryId, Long userId) {
try {
// userId가 null이 아니면 권한 체크, null이면 skip
if (userId != null) {
repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId)
.orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND));
} else {
// 그냥 존재하는지 체크 (없으면 그냥 진행)
if (!repositoryRepository.findById(repositoryId).isPresent()) {
log.warn("stopRepository: repositoryId={} not found, but will cleanup container/port anyway.", repositoryId);
}
}
// 권한 체크
repositoryRepository.findByIdAndMemberOrOwner(repositoryId, userId)
.orElseThrow(() -> new GlobalException(ErrorCode.REPOSITORY_NOT_FOUND));

Optional<RunningContainer> containerOpt = runningContainerRepository.findByRepositoryId(repositoryId);

Expand All @@ -243,18 +202,10 @@ public boolean stopRepository(Long repositoryId, Long userId) {
if (success) {
container.stop();
runningContainerRepository.save(container);

// 포트 반납도 여기서!
PortRegistry portReg = portRegistryRepository.findByPort(container.getPort()).orElse(null);
if (portReg != null) {
portReg.release();
portRegistryRepository.save(portReg);
}

log.info("Successfully stopped repository: {} (uuid: {})", repositoryId, container.getUuid());
}

return true;
return success;

} catch (Exception e) {
log.error("Failed to stop repository: {}", repositoryId, e);
Expand Down Expand Up @@ -313,7 +264,6 @@ private String convertTypeToFramework(RepositoryType type) {
};
}

// === 랜덤 포트 할당 방식으로 수정 ===
private Integer allocateOrGetPort(Repository repo) {
return portRegistryRepository.findByRepository(repo)
.map(PortRegistry::getPort)
Expand All @@ -323,18 +273,16 @@ private Integer allocateOrGetPort(Repository repo) {
@Transactional
public PortRegistry allocateNewPortForRepository(Repository repo) {
log.debug("Allocating new port for repository: {}", repo.getId());
List<PortRegistry> availablePorts = portRegistryRepository.findAllByStatus(PortStatus.AVAILABLE);

if (availablePorts.isEmpty()) {
throw new GlobalException(ErrorCode.NO_AVAILABLE_PORT);
}
// 1. 사용 가능한 포트 목록 조회 (status == AVAILABLE)
PortRegistry available = portRegistryRepository.findFirstByStatus(PortStatus.AVAILABLE)
.orElseThrow(() -> new GlobalException(ErrorCode.NO_AVAILABLE_PORT));

Collections.shuffle(availablePorts);
PortRegistry selected = availablePorts.get(0);
selected.assignToRepository(repo);
PortRegistry savedRegistry = portRegistryRepository.save(selected);
// 2. 해당 포트 할당/저장
available.assignToRepository(repo);
PortRegistry savedRegistry = portRegistryRepository.save(available);

log.info("Port {} allocated to repository {}", selected.getPort(), repo.getId());
log.info("Port {} allocated to repository {}", available.getPort(), repo.getId());
return savedRegistry;
}

Expand Down