diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eefe77a..0cf63c4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -75,6 +75,8 @@ jobs: host: ${{ secrets.EC2_HOST }} username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} + timeout: 60s + command_timeout: 15m script: | set -euo pipefail diff --git a/aws-test.tf b/aws-test.tf new file mode 100644 index 0000000..7083e08 --- /dev/null +++ b/aws-test.tf @@ -0,0 +1,128 @@ +# AWS VPC와 EC2 인스턴스 생성 테스트 +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +# AWS Provider 설정 +provider "aws" { + region = "ap-northeast-2" # 서울 리전 +} + +# VPC 생성 +resource "aws_vpc" "test_vpc" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "test-vpc" + } +} + +# 인터넷 게이트웨이 +resource "aws_internet_gateway" "test_igw" { + vpc_id = aws_vpc.test_vpc.id + + tags = { + Name = "test-igw" + } +} + +# 서브넷 +resource "aws_subnet" "test_subnet" { + vpc_id = aws_vpc.test_vpc.id + cidr_block = "10.0.1.0/24" + availability_zone = "ap-northeast-2a" + map_public_ip_on_launch = true + + tags = { + Name = "test-subnet" + } +} + +# 라우팅 테이블 +resource "aws_route_table" "test_rt" { + vpc_id = aws_vpc.test_vpc.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.test_igw.id + } + + tags = { + Name = "test-rt" + } +} + +# 라우팅 테이블 연결 +resource "aws_route_table_association" "test_rta" { + subnet_id = aws_subnet.test_subnet.id + route_table_id = aws_route_table.test_rt.id +} + +# 보안 그룹 +resource "aws_security_group" "test_sg" { + name = "test-security-group" + description = "Test security group for EC2" + vpc_id = aws_vpc.test_vpc.id + + # SSH 접속 허용 + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTP 접속 허용 + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # 모든 아웃바운드 트래픽 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "test-sg" + } +} + +# EC2 인스턴스 +resource "aws_instance" "test_instance" { + ami = "ami-0c9c942bd7bf113a2" # Amazon Linux 2023 AMI (서울 리전) + instance_type = "t2.micro" + subnet_id = aws_subnet.test_subnet.id + vpc_security_group_ids = [aws_security_group.test_sg.id] + + tags = { + Name = "test-instance" + } +} + +# 출력 +output "vpc_id" { + value = aws_vpc.test_vpc.id +} + +output "instance_id" { + value = aws_instance.test_instance.id +} + +output "instance_public_ip" { + value = aws_instance.test_instance.public_ip +} diff --git a/docs/AWS_SETUP.md b/docs/AWS_SETUP.md new file mode 100644 index 0000000..841cd08 --- /dev/null +++ b/docs/AWS_SETUP.md @@ -0,0 +1,85 @@ +# AWS 인증 설정 가이드 + +## 필수 환경 변수 + +Terraform으로 AWS 리소스를 생성하려면 다음 환경 변수들이 필요합니다: + +### 1. AWS Access Key 설정 +```bash +export AWS_ACCESS_KEY_ID="your-access-key-id" +export AWS_SECRET_ACCESS_KEY="your-secret-access-key" +``` + +### 2. AWS Region 설정 (선택사항) +```bash +export AWS_DEFAULT_REGION="ap-northeast-2" # 서울 리전 +``` + +### 3. AWS Profile 사용 (권장) +```bash +# AWS CLI로 프로필 설정 +aws configure --profile terraform-test + +# 환경 변수로 프로필 지정 +export AWS_PROFILE="terraform-test" +``` + +## AWS IAM 권한 + +다음 AWS 서비스에 대한 권한이 필요합니다: + +- **EC2**: 인스턴스, VPC, 서브넷, 보안 그룹 생성/삭제 +- **IAM**: 역할 및 정책 관리 (필요시) +- **VPC**: 가상 프라이빗 클라우드 관리 +- **CloudWatch**: 로그 및 모니터링 (선택사항) + +### 최소 IAM 정책 예시: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:*", + "vpc:*", + "iam:CreateRole", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:DeleteRole" + ], + "Resource": "*" + } + ] +} +``` + +## 테스트 방법 + +### 1. 환경 변수 설정 확인 +```bash +echo $AWS_ACCESS_KEY_ID +echo $AWS_SECRET_ACCESS_KEY +echo $AWS_DEFAULT_REGION +``` + +### 2. AWS CLI 테스트 +```bash +aws sts get-caller-identity +``` + +### 3. Terraform 테스트 +```bash +# 프로젝트 루트에서 +cd test-terraform +terraform init +terraform validate +terraform plan +``` + +## 주의사항 + +⚠️ **중요**: 실제 AWS 리소스가 생성되므로 비용이 발생할 수 있습니다. +- 테스트 후 반드시 `terraform destroy`로 리소스를 정리하세요 +- t2.micro 인스턴스는 AWS Free Tier에 포함될 수 있지만, 다른 리소스는 비용이 발생할 수 있습니다 +- 프로덕션 환경에서는 더 엄격한 IAM 정책을 사용하세요 diff --git a/src/main/java/com/blockcloud/config/SecurityConfig.java b/src/main/java/com/blockcloud/config/SecurityConfig.java index 2e71b3e..b533d9e 100644 --- a/src/main/java/com/blockcloud/config/SecurityConfig.java +++ b/src/main/java/com/blockcloud/config/SecurityConfig.java @@ -52,6 +52,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/signup/success", "/error", "/login/oauth2/code/google", + "/swagger-ui", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**" diff --git a/src/main/java/com/blockcloud/controller/TerraformController.java b/src/main/java/com/blockcloud/controller/TerraformController.java new file mode 100644 index 0000000..e7edf0c --- /dev/null +++ b/src/main/java/com/blockcloud/controller/TerraformController.java @@ -0,0 +1,152 @@ +package com.blockcloud.controller; + +import com.blockcloud.dto.RequestDto.TerraformApplyRequestDto; +import com.blockcloud.dto.RequestDto.TerraformDestroyRequestDto; +import com.blockcloud.dto.RequestDto.TerraformPlanRequestDto; +import com.blockcloud.dto.RequestDto.TerraformValidateRequestDto; +import com.blockcloud.dto.ResponseDto.DeploymentListResponseDto; +import com.blockcloud.dto.ResponseDto.DeploymentStatusResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformApplyResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformDestroyResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformPlanResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformValidateResponseDto; +import com.blockcloud.dto.common.ResponseDto; +import com.blockcloud.dto.oauth.CustomUserDetails; +import com.blockcloud.service.TerraformService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * Terraform 관련 API 요청을 처리하는 컨트롤러입니다. Terraform 코드 검증, 배포, 배포 상태 조회 기능을 제공합니다. + */ +@Tag(name = "Terraform API", description = "Terraform 코드 검증, 배포, 배포 상태 조회 관련 API") +@RestController +@RequestMapping("/api/projects/{projectId}/terraform") +@RequiredArgsConstructor +public class TerraformController { + + private final TerraformService terraformService; + + /** + * Terraform 코드를 검증합니다. + * + * @param projectId 프로젝트 ID + * @param requestDto Terraform 코드 검증 요청 + * @return 검증 결과가 담긴 응답 객체 + */ + @Operation( + summary = "Terraform 코드 검증", + description = "Terraform 코드의 문법과 구성을 검증합니다. 유효성 검사 결과와 에러/경고 메시지를 반환합니다." + ) + @PostMapping("/validate") + public ResponseDto validateTerraform( + @Parameter(description = "프로젝트 ID", required = true) @PathVariable Long projectId, + @Valid @RequestBody TerraformValidateRequestDto requestDto) { + return ResponseDto.ok(terraformService.validateTerraform(projectId, requestDto)); + } + + /** + * Terraform 코드의 변경 사항을 미리 확인합니다. + * + * @param projectId 프로젝트 ID + * @param requestDto Terraform plan 요청 + * @return plan 결과가 담긴 응답 객체 + */ + @Operation( + summary = "Terraform 코드 Plan", + description = "Terraform 코드를 실행했을 때 어떤 변경 사항이 발생할지 미리 확인합니다. 실제 배포는 하지 않습니다." + ) + @PostMapping("/plan") + public ResponseDto planTerraform( + @Parameter(description = "프로젝트 ID", required = true) @PathVariable Long projectId, + @Valid @RequestBody TerraformPlanRequestDto requestDto) { + return ResponseDto.ok(terraformService.planTerraform(projectId, requestDto)); + } + + /** + * Terraform 코드를 적용하여 배포를 시작합니다. + * + * @param projectId 프로젝트 ID + * @param requestDto Terraform 배포 요청 + * @param authentication 인증 정보 + * @return 배포 시작 정보가 담긴 응답 객체 + */ + @Operation( + summary = "Terraform 코드 배포", + description = "Terraform 코드를 실제 클라우드 환경에 적용하여 인프라를 배포합니다. 배포는 비동기로 실행되며, 배포 ID를 반환합니다." + ) + @PostMapping("/apply") + public ResponseDto applyTerraform( + @Parameter(description = "프로젝트 ID", required = true) @PathVariable Long projectId, + @Valid @RequestBody TerraformApplyRequestDto requestDto, + Authentication authentication) { + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return ResponseDto.ok(terraformService.applyTerraform(projectId, requestDto, userDetails.getUsername())); + } + + /** + * Terraform 코드를 실행하여 인프라를 삭제합니다. + * + * @param projectId 프로젝트 ID + * @param requestDto Terraform 삭제 요청 + * @param authentication 인증 정보 + * @return 삭제 시작 정보가 담긴 응답 객체 + */ + @Operation( + summary = "Terraform 인프라 삭제", + description = "Terraform 코드를 실행하여 생성된 인프라를 삭제합니다. 배포는 비동기로 실행되며, 삭제 ID를 반환합니다." + ) + @PostMapping("/destroy") + public ResponseDto destroyTerraform( + @Parameter(description = "프로젝트 ID", required = true) @PathVariable Long projectId, + @Valid @RequestBody TerraformDestroyRequestDto requestDto, + Authentication authentication) { + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return ResponseDto.ok(terraformService.destroyTerraform(projectId, requestDto, userDetails.getUsername())); + } + + /** + * 특정 배포의 상태를 조회합니다. + * + * @param projectId 프로젝트 ID + * @param deploymentId 배포 ID + * @return 배포 상태 정보가 담긴 응답 객체 + */ + @Operation( + summary = "배포 상태 조회", + description = "특정 배포의 현재 상태를 조회합니다. PENDING, RUNNING, SUCCESS, FAILED 상태와 상세 정보를 반환합니다." + ) + @GetMapping("/deployments/{deploymentId}") + public ResponseDto getDeploymentStatus( + @Parameter(description = "프로젝트 ID", required = true) @PathVariable Long projectId, + @Parameter(description = "배포 ID", required = true) @PathVariable Long deploymentId) { + return ResponseDto.ok(terraformService.getDeploymentStatus(projectId, deploymentId)); + } + + /** + * 프로젝트의 배포 이력을 조회합니다. + * + * @param projectId 프로젝트 ID + * @param lastId 마지막으로 조회된 배포 ID (첫 조회 시 null) + * @param size 한 번에 조회할 배포 수 (기본값: 10) + * @return 배포 이력 목록이 담긴 응답 객체 + */ + @Operation( + summary = "배포 이력 조회", + description = "프로젝트의 배포 이력을 무한스크롤 방식으로 조회합니다. `lastId`를 넘기면 해당 ID보다 작은 배포부터 조회하며, `size`로 가져올 개수를 지정할 수 있습니다." + ) + @GetMapping("/deployments") + public ResponseDto getDeploymentHistory( + @Parameter(description = "프로젝트 ID", required = true) @PathVariable Long projectId, + @Parameter(description = "마지막으로 조회한 배포 ID (첫 호출 시 생략 가능)") + @RequestParam(required = false) Long lastId, + @Parameter(description = "가져올 데이터 개수 (기본값 10)") + @RequestParam(defaultValue = "10") int size) { + return ResponseDto.ok(terraformService.getDeploymentHistory(projectId, lastId, size)); + } +} diff --git a/src/main/java/com/blockcloud/controller/TokenRestController.java b/src/main/java/com/blockcloud/controller/TokenRestController.java index fef1da7..bf9f4f7 100644 --- a/src/main/java/com/blockcloud/controller/TokenRestController.java +++ b/src/main/java/com/blockcloud/controller/TokenRestController.java @@ -63,7 +63,7 @@ public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse String role = jwtUtil.getRole(refresh); // Create new tokens - String newAccess = jwtUtil.createJwt("access", email, role, 24 * 60 * 60 * 1000L); // 1시간 + String newAccess = jwtUtil.createJwt("access", email, role, 30 * 60 * 1000L); // 30분 String refreshToken = jwtUtil.createJwt("refresh", email, role, 24 * 60 * 60 * 1000L); // Set refresh token in cookie diff --git a/src/main/java/com/blockcloud/domain/deployment/Deployment.java b/src/main/java/com/blockcloud/domain/deployment/Deployment.java new file mode 100644 index 0000000..13f1f5a --- /dev/null +++ b/src/main/java/com/blockcloud/domain/deployment/Deployment.java @@ -0,0 +1,55 @@ +package com.blockcloud.domain.deployment; + +import com.blockcloud.domain.global.BaseTimeEntity; +import com.blockcloud.domain.project.Project; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Deployment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private DeploymentStatus status; + + @Column(columnDefinition = "TEXT") + private String message; + + @Column(columnDefinition = "LONGTEXT") + private String terraformCode; + + @Column(columnDefinition = "LONGTEXT") + private String output; + + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + public void updateStatus(DeploymentStatus status, String message) { + this.status = status; + this.message = message; + if (status == DeploymentStatus.SUCCESS || status == DeploymentStatus.FAILED) { + this.completedAt = LocalDateTime.now(); + } + } + + public void setOutput(String output) { + this.output = output; + } +} diff --git a/src/main/java/com/blockcloud/domain/deployment/DeploymentRepository.java b/src/main/java/com/blockcloud/domain/deployment/DeploymentRepository.java new file mode 100644 index 0000000..ecc968f --- /dev/null +++ b/src/main/java/com/blockcloud/domain/deployment/DeploymentRepository.java @@ -0,0 +1,18 @@ +package com.blockcloud.domain.deployment; + +import com.blockcloud.domain.project.Project; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface DeploymentRepository extends JpaRepository { + + @Query("SELECT d FROM Deployment d WHERE d.project = :project ORDER BY d.createdAt DESC") + List findByProjectOrderByCreatedAtDesc(@Param("project") Project project, Pageable pageable); + + @Query("SELECT COUNT(d) > 0 FROM Deployment d WHERE d.project = :project AND d.createdAt > :lastId") + boolean existsByProjectAndCreatedAtAfter(@Param("project") Project project, @Param("lastId") Long lastId); +} diff --git a/src/main/java/com/blockcloud/domain/deployment/DeploymentStatus.java b/src/main/java/com/blockcloud/domain/deployment/DeploymentStatus.java new file mode 100644 index 0000000..4f5497a --- /dev/null +++ b/src/main/java/com/blockcloud/domain/deployment/DeploymentStatus.java @@ -0,0 +1,8 @@ +package com.blockcloud.domain.deployment; + +public enum DeploymentStatus { + PENDING, // 대기 중 + RUNNING, // 실행 중 + SUCCESS, // 성공 + FAILED // 실패 +} diff --git a/src/main/java/com/blockcloud/dto/RequestDto/TerraformApplyRequestDto.java b/src/main/java/com/blockcloud/dto/RequestDto/TerraformApplyRequestDto.java new file mode 100644 index 0000000..6926a02 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/RequestDto/TerraformApplyRequestDto.java @@ -0,0 +1,17 @@ +package com.blockcloud.dto.RequestDto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TerraformApplyRequestDto { + + @NotBlank(message = "Terraform 코드는 필수입니다.") + private String terraformCode; +} diff --git a/src/main/java/com/blockcloud/dto/RequestDto/TerraformDestroyRequestDto.java b/src/main/java/com/blockcloud/dto/RequestDto/TerraformDestroyRequestDto.java new file mode 100644 index 0000000..f081e37 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/RequestDto/TerraformDestroyRequestDto.java @@ -0,0 +1,17 @@ +package com.blockcloud.dto.RequestDto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TerraformDestroyRequestDto { + + @NotBlank(message = "Terraform 코드는 필수입니다.") + private String terraformCode; +} diff --git a/src/main/java/com/blockcloud/dto/RequestDto/TerraformPlanRequestDto.java b/src/main/java/com/blockcloud/dto/RequestDto/TerraformPlanRequestDto.java new file mode 100644 index 0000000..9f694b9 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/RequestDto/TerraformPlanRequestDto.java @@ -0,0 +1,17 @@ +package com.blockcloud.dto.RequestDto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TerraformPlanRequestDto { + + @NotBlank(message = "Terraform 코드는 필수입니다.") + private String terraformCode; +} diff --git a/src/main/java/com/blockcloud/dto/RequestDto/TerraformValidateRequestDto.java b/src/main/java/com/blockcloud/dto/RequestDto/TerraformValidateRequestDto.java new file mode 100644 index 0000000..77f7a61 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/RequestDto/TerraformValidateRequestDto.java @@ -0,0 +1,17 @@ +package com.blockcloud.dto.RequestDto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TerraformValidateRequestDto { + + @NotBlank(message = "Terraform 코드는 필수입니다.") + private String terraformCode; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/DeploymentListResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/DeploymentListResponseDto.java new file mode 100644 index 0000000..7f9f246 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/DeploymentListResponseDto.java @@ -0,0 +1,14 @@ +package com.blockcloud.dto.ResponseDto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class DeploymentListResponseDto { + + private List deployments; + private boolean hasNext; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/DeploymentStatusResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/DeploymentStatusResponseDto.java new file mode 100644 index 0000000..d04b747 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/DeploymentStatusResponseDto.java @@ -0,0 +1,18 @@ +package com.blockcloud.dto.ResponseDto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class DeploymentStatusResponseDto { + + private Long deploymentId; + private String status; // PENDING, RUNNING, SUCCESS, FAILED + private String message; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private String output; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/TerraformApplyResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformApplyResponseDto.java new file mode 100644 index 0000000..568832e --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformApplyResponseDto.java @@ -0,0 +1,18 @@ +package com.blockcloud.dto.ResponseDto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class TerraformApplyResponseDto { + + private Long deploymentId; + private String status; + private String message; + private LocalDateTime startedAt; + private String output; + private String errorMessage; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/TerraformDestroyResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformDestroyResponseDto.java new file mode 100644 index 0000000..de904ab --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformDestroyResponseDto.java @@ -0,0 +1,18 @@ +package com.blockcloud.dto.ResponseDto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class TerraformDestroyResponseDto { + + private Long deploymentId; + private String status; + private String message; + private LocalDateTime startedAt; + private String output; + private String errorMessage; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/TerraformPlanResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformPlanResponseDto.java new file mode 100644 index 0000000..7acd022 --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformPlanResponseDto.java @@ -0,0 +1,13 @@ +package com.blockcloud.dto.ResponseDto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TerraformPlanResponseDto { + + private boolean hasChanges; + private String planOutput; + private String errorMessage; +} diff --git a/src/main/java/com/blockcloud/dto/ResponseDto/TerraformValidateResponseDto.java b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformValidateResponseDto.java new file mode 100644 index 0000000..e02f9aa --- /dev/null +++ b/src/main/java/com/blockcloud/dto/ResponseDto/TerraformValidateResponseDto.java @@ -0,0 +1,15 @@ +package com.blockcloud.dto.ResponseDto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class TerraformValidateResponseDto { + + private boolean isValid; + private String output; + private String errorMessage; +} diff --git a/src/main/java/com/blockcloud/exception/error/ErrorCode.java b/src/main/java/com/blockcloud/exception/error/ErrorCode.java index 365dae5..a14bd8c 100644 --- a/src/main/java/com/blockcloud/exception/error/ErrorCode.java +++ b/src/main/java/com/blockcloud/exception/error/ErrorCode.java @@ -52,7 +52,13 @@ public enum ErrorCode { EXTERNAL_SERVER_ERROR(50200, HttpStatus.BAD_GATEWAY, "서버 외부 에러입니다."), // Project Errors - NOT_FOUND_PROJECT(40401, HttpStatus.NOT_FOUND, "존재하지 않는 프로젝트입니다."); + NOT_FOUND_PROJECT(40401, HttpStatus.NOT_FOUND, "존재하지 않는 프로젝트입니다."), + + // Deployment Errors + NOT_FOUND_DEPLOYMENT(40402, HttpStatus.NOT_FOUND, "존재하지 않는 배포입니다."), + DEPLOYMENT_ALREADY_RUNNING(40901, HttpStatus.CONFLICT, "이미 실행 중인 배포가 있습니다."), + TERRAFORM_VALIDATION_FAILED(40010, HttpStatus.BAD_REQUEST, "Terraform 코드 검증에 실패했습니다."), + TERRAFORM_APPLY_FAILED(50002, HttpStatus.INTERNAL_SERVER_ERROR, "Terraform 배포에 실패했습니다."); private final Integer code; private final HttpStatus httpStatus; diff --git a/src/main/java/com/blockcloud/exception/handler/OAuth2SuccessHandler.java b/src/main/java/com/blockcloud/exception/handler/OAuth2SuccessHandler.java index 38eb111..759bd3e 100644 --- a/src/main/java/com/blockcloud/exception/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/blockcloud/exception/handler/OAuth2SuccessHandler.java @@ -31,9 +31,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo User user = customOAuth2User.getUser(); ObjectMapper objectMapper = new ObjectMapper(); - try { - String accessToken = jwtUtil.createJwt("access", user.getEmail(), String.valueOf(user.getRole()), 60 * 1000L); - String refreshToken = jwtUtil.createJwt("refresh", user.getEmail(), String.valueOf(user.getRole()), 24 * 60 * 60 * 1000L); + try { + String accessToken = jwtUtil.createJwt("access", user.getEmail(), String.valueOf(user.getRole()), 30 * 60 * 1000L); + String refreshToken = jwtUtil.createJwt("refresh", user.getEmail(), String.valueOf(user.getRole()), 24 * 60 * 60 * 1000L); Cookie refreshCookie = cookieService.createCookie("refresh", refreshToken, 24 * 60 * 60 * 1000L); response.addCookie(refreshCookie); diff --git a/src/main/java/com/blockcloud/service/TerraformExecutor.java b/src/main/java/com/blockcloud/service/TerraformExecutor.java new file mode 100644 index 0000000..ac25f5d --- /dev/null +++ b/src/main/java/com/blockcloud/service/TerraformExecutor.java @@ -0,0 +1,214 @@ +package com.blockcloud.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Component +public class TerraformExecutor { + + private static final String BASE_DIR = "/tmp/terraform/"; + + /** + * Terraform 명령어를 실행합니다. + * + * @param terraformCode Terraform 코드 + * @param command 실행할 명령어 (init, validate, plan, apply 등) + * @return 실행 결과 + */ + public TerraformExecutionResult executeCommand(String terraformCode, String command) { + String projectId = UUID.randomUUID().toString(); + Path workDir = Paths.get(BASE_DIR, projectId); + + try { + // 작업 디렉토리 생성 + Files.createDirectories(workDir); + + // Terraform 파일 생성 + Path terraformFile = workDir.resolve("main.tf"); + Files.writeString(terraformFile, terraformCode); + + // Terraform init 실행 + ProcessBuilder initBuilder = new ProcessBuilder("terraform", "init", "-input=false") + .directory(workDir.toFile()); + Process initProcess = initBuilder.start(); + int initExitCode = initProcess.waitFor(); + + if (initExitCode != 0) { + String initError = new String(initProcess.getErrorStream().readAllBytes()); + return TerraformExecutionResult.builder() + .success(false) + .exitCode(initExitCode) + .output("") + .error("Terraform 초기화 실패: " + initError) + .build(); + } + + // 요청된 명령어 실행 + List cmd = new ArrayList<>(List.of("terraform")); + cmd.addAll(List.of(command.split(" "))); + + ProcessBuilder builder = new ProcessBuilder(cmd) + .directory(workDir.toFile()); + Process process = builder.start(); + + // 출력과 에러 스트림 읽기 + String output = new String(process.getInputStream().readAllBytes()); + String error = new String(process.getErrorStream().readAllBytes()); + int exitCode = process.waitFor(); + + // 작업 디렉토리 정리 + cleanupDirectory(workDir); + + return TerraformExecutionResult.builder() + .success(exitCode == 0) + .exitCode(exitCode) + .output(output) + .error(error) + .build(); + + } catch (Exception e) { + log.error("Terraform 명령어 실행 중 오류 발생: {}", e.getMessage(), e); + + // 작업 디렉토리 정리 + try { + cleanupDirectory(workDir); + } catch (Exception cleanupException) { + log.warn("작업 디렉토리 정리 중 오류: {}", cleanupException.getMessage()); + } + + return TerraformExecutionResult.builder() + .success(false) + .exitCode(-1) + .output("") + .error("Terraform 실행 중 오류 발생: " + e.getMessage()) + .build(); + } + } + + /** + * Terraform plan을 실행하여 변경 사항을 미리 확인합니다. + * + * @param terraformCode Terraform 코드 + * @return plan 실행 결과 + */ + public TerraformExecutionResult plan(String terraformCode) { + return executeCommand(terraformCode, "plan -detailed-exitcode"); + } + + /** + * Terraform validate를 실행하여 코드를 검증합니다. + * + * @param terraformCode Terraform 코드 + * @return validate 실행 결과 + */ + public TerraformExecutionResult validate(String terraformCode) { + return executeCommand(terraformCode, "validate"); + } + + /** + * Terraform apply를 실행하여 인프라를 배포합니다. + * + * @param terraformCode Terraform 코드 + * @return apply 실행 결과 + */ + public TerraformExecutionResult apply(String terraformCode) { + return executeCommand(terraformCode, "apply -auto-approve"); + } + + /** + * Terraform destroy를 실행하여 인프라를 삭제합니다. + * + * @param terraformCode Terraform 코드 + * @return destroy 실행 결과 + */ + public TerraformExecutionResult destroy(String terraformCode) { + return executeCommand(terraformCode, "destroy -auto-approve"); + } + + /** + * Terraform 전체 워크플로우를 실행합니다 (validate -> plan -> apply). + * + * @param terraformCode Terraform 코드 + * @return 최종 실행 결과 + */ + public TerraformExecutionResult runFullWorkflow(String terraformCode) { + // 1. Validate + TerraformExecutionResult validateResult = validate(terraformCode); + if (!validateResult.isSuccess()) { + return validateResult; + } + + // 2. Plan + TerraformExecutionResult planResult = plan(terraformCode); + if (!planResult.isSuccess()) { + return planResult; + } + + // 3. Apply + return apply(terraformCode); + } + + /** + * 작업 디렉토리를 정리합니다. + */ + private void cleanupDirectory(Path directory) { + try { + if (Files.exists(directory)) { + Files.walk(directory) + .sorted((a, b) -> b.compareTo(a)) // 하위 디렉토리부터 삭제 + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + log.warn("파일 삭제 실패: {}", path); + } + }); + } + } catch (IOException e) { + log.warn("디렉토리 정리 중 오류: {}", e.getMessage()); + } + } + + /** + * Terraform 실행 결과를 담는 내부 클래스 + */ + public static class TerraformExecutionResult { + private final boolean success; + private final int exitCode; + private final String output; + private final String error; + + @lombok.Builder + public TerraformExecutionResult(boolean success, int exitCode, String output, String error) { + this.success = success; + this.exitCode = exitCode; + this.output = output; + this.error = error; + } + + public boolean isSuccess() { + return success; + } + + public int getExitCode() { + return exitCode; + } + + public String getOutput() { + return output; + } + + public String getError() { + return error; + } + } +} diff --git a/src/main/java/com/blockcloud/service/TerraformService.java b/src/main/java/com/blockcloud/service/TerraformService.java new file mode 100644 index 0000000..5f75b88 --- /dev/null +++ b/src/main/java/com/blockcloud/service/TerraformService.java @@ -0,0 +1,263 @@ +package com.blockcloud.service; + +import com.blockcloud.domain.deployment.Deployment; +import com.blockcloud.domain.deployment.DeploymentRepository; +import com.blockcloud.domain.deployment.DeploymentStatus; +import com.blockcloud.domain.project.Project; +import com.blockcloud.domain.project.ProjectRepository; +import com.blockcloud.dto.RequestDto.TerraformApplyRequestDto; +import com.blockcloud.dto.RequestDto.TerraformDestroyRequestDto; +import com.blockcloud.dto.RequestDto.TerraformPlanRequestDto; +import com.blockcloud.dto.RequestDto.TerraformValidateRequestDto; +import com.blockcloud.dto.ResponseDto.DeploymentListResponseDto; +import com.blockcloud.dto.ResponseDto.DeploymentStatusResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformApplyResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformDestroyResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformPlanResponseDto; +import com.blockcloud.dto.ResponseDto.TerraformValidateResponseDto; +import com.blockcloud.exception.CommonException; +import com.blockcloud.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TerraformService { + + private final ProjectRepository projectRepository; + private final DeploymentRepository deploymentRepository; + private final TerraformExecutor terraformExecutor; + + /** + * Terraform 코드를 검증합니다. + */ + public TerraformValidateResponseDto validateTerraform(Long projectId, TerraformValidateRequestDto requestDto) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + TerraformExecutor.TerraformExecutionResult result = terraformExecutor.validate(requestDto.getTerraformCode()); + + return TerraformValidateResponseDto.builder() + .isValid(result.isSuccess()) + .output(result.getOutput()) + .errorMessage(result.getError()) + .build(); + } + + /** + * Terraform 코드의 변경 사항을 미리 확인합니다. + */ + public TerraformPlanResponseDto planTerraform(Long projectId, TerraformPlanRequestDto requestDto) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + TerraformExecutor.TerraformExecutionResult result = terraformExecutor.plan(requestDto.getTerraformCode()); + + // Terraform plan의 exit code: 0=성공, 1=에러, 2=변경사항 있음 + boolean hasChanges = result.getExitCode() == 2; + + return TerraformPlanResponseDto.builder() + .hasChanges(hasChanges) + .planOutput(result.getOutput()) + .errorMessage(result.getError()) + .build(); + } + + /** + * Terraform 코드를 적용하여 배포를 시작합니다. + */ + @Transactional + public TerraformApplyResponseDto applyTerraform(Long projectId, TerraformApplyRequestDto requestDto, String username) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + // 배포 이력 생성 + Deployment deployment = Deployment.builder() + .project(project) + .status(DeploymentStatus.PENDING) + .message("배포 대기 중") + .terraformCode(requestDto.getTerraformCode()) + .startedAt(LocalDateTime.now()) + .build(); + + Deployment savedDeployment = deploymentRepository.save(deployment); + + // 비동기로 배포 실행 + CompletableFuture.runAsync(() -> { + try { + executeTerraformApply(savedDeployment.getId(), projectId, requestDto.getTerraformCode()); + } catch (Exception e) { + log.error("Terraform apply failed for deployment {}: {}", savedDeployment.getId(), e.getMessage()); + updateDeploymentStatus(savedDeployment.getId(), DeploymentStatus.FAILED, "배포 실패: " + e.getMessage()); + } + }); + + return TerraformApplyResponseDto.builder() + .deploymentId(savedDeployment.getId()) + .status("PENDING") + .message("배포가 시작되었습니다.") + .startedAt(savedDeployment.getStartedAt()) + .build(); + } + + /** + * Terraform 코드를 실행하여 인프라를 삭제합니다. + */ + @Transactional + public TerraformDestroyResponseDto destroyTerraform(Long projectId, TerraformDestroyRequestDto requestDto, String username) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + // 배포 이력 생성 (삭제용) + Deployment deployment = Deployment.builder() + .project(project) + .status(DeploymentStatus.PENDING) + .message("인프라 삭제 대기 중") + .terraformCode(requestDto.getTerraformCode()) + .startedAt(LocalDateTime.now()) + .build(); + + Deployment savedDeployment = deploymentRepository.save(deployment); + + // 비동기로 삭제 실행 + CompletableFuture.runAsync(() -> { + try { + executeTerraformDestroy(savedDeployment.getId(), projectId, requestDto.getTerraformCode()); + } catch (Exception e) { + log.error("Terraform destroy failed for deployment {}: {}", savedDeployment.getId(), e.getMessage()); + updateDeploymentStatus(savedDeployment.getId(), DeploymentStatus.FAILED, "삭제 실패: " + e.getMessage()); + } + }); + + return TerraformDestroyResponseDto.builder() + .deploymentId(savedDeployment.getId()) + .status("PENDING") + .message("인프라 삭제가 시작되었습니다.") + .startedAt(savedDeployment.getStartedAt()) + .build(); + } + + /** + * 배포 상태를 조회합니다. + */ + public DeploymentStatusResponseDto getDeploymentStatus(Long projectId, Long deploymentId) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + Deployment deployment = deploymentRepository.findById(deploymentId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_DEPLOYMENT)); + + // 프로젝트에 속한 배포인지 확인 + if (!deployment.getProject().getId().equals(projectId)) { + throw new CommonException(ErrorCode.ACCESS_DENIED); + } + + return DeploymentStatusResponseDto.builder() + .deploymentId(deployment.getId()) + .status(deployment.getStatus().name()) + .message(deployment.getMessage()) + .startedAt(deployment.getStartedAt()) + .completedAt(deployment.getCompletedAt()) + .output(deployment.getOutput()) + .build(); + } + + /** + * 프로젝트의 배포 이력을 조회합니다. + */ + public DeploymentListResponseDto getDeploymentHistory(Long projectId, Long lastId, int size) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PROJECT)); + + PageRequest pageRequest = PageRequest.of(0, size + 1); + List deployments = deploymentRepository.findByProjectOrderByCreatedAtDesc(project, pageRequest); + + boolean hasNext = deployments.size() > size; + if (hasNext) { + deployments = deployments.subList(0, size); + } + + List deploymentDtos = deployments.stream() + .map(deployment -> DeploymentStatusResponseDto.builder() + .deploymentId(deployment.getId()) + .status(deployment.getStatus().name()) + .message(deployment.getMessage()) + .startedAt(deployment.getStartedAt()) + .completedAt(deployment.getCompletedAt()) + .output(deployment.getOutput()) + .build()) + .toList(); + + return DeploymentListResponseDto.builder() + .deployments(deploymentDtos) + .hasNext(hasNext) + .build(); + } + + // Private helper methods + + private void executeTerraformApply(Long deploymentId, Long projectId, String terraformCode) { + try { + // 상태를 RUNNING으로 업데이트 + updateDeploymentStatus(deploymentId, DeploymentStatus.RUNNING, "배포 실행 중"); + + // Terraform apply 실행 + TerraformExecutor.TerraformExecutionResult result = terraformExecutor.apply(terraformCode); + + if (result.isSuccess()) { + updateDeploymentStatus(deploymentId, DeploymentStatus.SUCCESS, "배포 성공"); + updateDeploymentOutput(deploymentId, result.getOutput()); + } else { + updateDeploymentStatus(deploymentId, DeploymentStatus.FAILED, "배포 실패: " + result.getError()); + updateDeploymentOutput(deploymentId, result.getError()); + } + + } catch (Exception e) { + updateDeploymentStatus(deploymentId, DeploymentStatus.FAILED, "배포 중 오류 발생: " + e.getMessage()); + } + } + + private void executeTerraformDestroy(Long deploymentId, Long projectId, String terraformCode) { + try { + // 상태를 RUNNING으로 업데이트 + updateDeploymentStatus(deploymentId, DeploymentStatus.RUNNING, "인프라 삭제 실행 중"); + + // Terraform destroy 실행 + TerraformExecutor.TerraformExecutionResult result = terraformExecutor.destroy(terraformCode); + + if (result.isSuccess()) { + updateDeploymentStatus(deploymentId, DeploymentStatus.SUCCESS, "인프라 삭제 성공"); + updateDeploymentOutput(deploymentId, result.getOutput()); + } else { + updateDeploymentStatus(deploymentId, DeploymentStatus.FAILED, "인프라 삭제 실패: " + result.getError()); + updateDeploymentOutput(deploymentId, result.getError()); + } + + } catch (Exception e) { + updateDeploymentStatus(deploymentId, DeploymentStatus.FAILED, "인프라 삭제 중 오류 발생: " + e.getMessage()); + } + } + + @Transactional + protected void updateDeploymentStatus(Long deploymentId, DeploymentStatus status, String message) { + Deployment deployment = deploymentRepository.findById(deploymentId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_DEPLOYMENT)); + deployment.updateStatus(status, message); + } + + @Transactional + protected void updateDeploymentOutput(Long deploymentId, String output) { + Deployment deployment = deploymentRepository.findById(deploymentId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_DEPLOYMENT)); + deployment.setOutput(output); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2eeec79..d5e9fc5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,5 +2,5 @@ spring: application: name: "BlockCloud Main Server API" profiles: - active: prod + active: dev include: secret diff --git a/test-terraform-api.sh b/test-terraform-api.sh new file mode 100755 index 0000000..317e15e --- /dev/null +++ b/test-terraform-api.sh @@ -0,0 +1,207 @@ +#!/bin/bash + +# Terraform API 테스트 스크립트 +# 사용법: ./test-terraform-api.sh [JWT_TOKEN] + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 기본 설정 +API_BASE="http://localhost:8080" +PROJECT_ID=1 + +# JWT 토큰 설정 +if [ -z "$1" ]; then + echo -e "${RED}JWT 토큰이 필요합니다.${NC}" + echo "사용법: $0 " + exit 1 +fi + +JWT_TOKEN=$1 + +# AWS VPC와 EC2를 생성하는 Terraform 코드 +TERRAFORM_CODE='terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" +} + +resource "aws_vpc" "test_vpc" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "test-vpc" + } +} + +resource "aws_internet_gateway" "test_igw" { + vpc_id = aws_vpc.test_vpc.id + + tags = { + Name = "test-igw" + } +} + +resource "aws_subnet" "test_subnet" { + vpc_id = aws_vpc.test_vpc.id + cidr_block = "10.0.1.0/24" + availability_zone = "ap-northeast-2a" + map_public_ip_on_launch = true + + tags = { + Name = "test-subnet" + } +} + +resource "aws_route_table" "test_rt" { + vpc_id = aws_vpc.test_vpc.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.test_igw.id + } + + tags = { + Name = "test-rt" + } +} + +resource "aws_route_table_association" "test_rta" { + subnet_id = aws_subnet.test_subnet.id + route_table_id = aws_route_table.test_rt.id +} + +resource "aws_security_group" "test_sg" { + name = "test-security-group" + description = "Test security group for EC2" + vpc_id = aws_vpc.test_vpc.id + + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "test-sg" + } +} + +resource "aws_instance" "test_instance" { + ami = "ami-0c9c942bd7bf113a2" + instance_type = "t2.micro" + subnet_id = aws_subnet.test_subnet.id + vpc_security_group_ids = [aws_security_group.test_sg.id] + + tags = { + Name = "test-instance" + } +} + +output "vpc_id" { + value = aws_vpc.test_vpc.id +} + +output "instance_id" { + value = aws_instance.test_instance.id +}' + +echo -e "${BLUE}=== Terraform API 테스트 시작 ===${NC}" +echo "API Base: $API_BASE" +echo "Project ID: $PROJECT_ID" +echo "" + +# 1. Validate 테스트 +echo -e "${YELLOW}1. Terraform Validate 테스트${NC}" +VALIDATE_RESPONSE=$(curl -s -X POST "$API_BASE/api/projects/$PROJECT_ID/terraform/validate" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"terraformCode\": \"$TERRAFORM_CODE\"}") + +echo "Response: $VALIDATE_RESPONSE" +echo "" + +# 2. Plan 테스트 +echo -e "${YELLOW}2. Terraform Plan 테스트${NC}" +PLAN_RESPONSE=$(curl -s -X POST "$API_BASE/api/projects/$PROJECT_ID/terraform/plan" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"terraformCode\": \"$TERRAFORM_CODE\"}") + +echo "Response: $PLAN_RESPONSE" +echo "" + +# 3. Apply 테스트 (실제 AWS 리소스 생성) +echo -e "${YELLOW}3. Terraform Apply 테스트${NC}" +echo -e "${RED}⚠️ 주의: 실제 AWS 리소스가 생성됩니다!${NC}" +read -p "계속하시겠습니까? (y/N): " -n 1 -r +echo + +if [[ $REPLY =~ ^[Yy]$ ]]; then + APPLY_RESPONSE=$(curl -s -X POST "$API_BASE/api/projects/$PROJECT_ID/terraform/apply" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"terraformCode\": \"$TERRAFORM_CODE\"}") + + echo "Response: $APPLY_RESPONSE" + echo "" + + # 배포 ID 추출 + DEPLOYMENT_ID=$(echo $APPLY_RESPONSE | grep -o '"deploymentId":[0-9]*' | cut -d':' -f2) + + if [ ! -z "$DEPLOYMENT_ID" ]; then + echo -e "${GREEN}배포 ID: $DEPLOYMENT_ID${NC}" + echo "" + + # 4. 배포 상태 확인 + echo -e "${YELLOW}4. 배포 상태 확인${NC}" + for i in {1..10}; do + STATUS_RESPONSE=$(curl -s -X GET "$API_BASE/api/projects/$PROJECT_ID/terraform/deployments/$DEPLOYMENT_ID" \ + -H "Authorization: Bearer $JWT_TOKEN") + + echo "Status Check $i: $STATUS_RESPONSE" + + # 성공 또는 실패 상태 확인 + if echo "$STATUS_RESPONSE" | grep -q '"status":"SUCCESS"'; then + echo -e "${GREEN}✅ 배포 성공!${NC}" + break + elif echo "$STATUS_RESPONSE" | grep -q '"status":"FAILED"'; then + echo -e "${RED}❌ 배포 실패!${NC}" + break + fi + + sleep 5 + done + fi +else + echo -e "${YELLOW}Apply 테스트를 건너뜁니다.${NC}" +fi + +echo "" +echo -e "${BLUE}=== 테스트 완료 ===${NC}" +echo "" +echo -e "${YELLOW}참고:${NC}" +echo "- 실제 AWS 리소스가 생성된 경우, 나중에 destroy API를 사용하여 정리하세요" +echo "- AWS 비용이 발생할 수 있으니 주의하세요"