diff --git a/pom.xml b/pom.xml index e76b9fc8..fe8cd11a 100644 --- a/pom.xml +++ b/pom.xml @@ -283,6 +283,11 @@ resourcegroupstaggingapi + + software.amazon.awssdk + rds + + software.amazon.awssdk diff --git a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java index bb439a5e..f463e4ce 100644 --- a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java +++ b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java @@ -25,8 +25,6 @@ public enum AuditResourceType { CLOUD_ACCOUNT("클라우드계정"), TARGETING_RULE("타겟팅규칙"), PLATFORM_CONFIG("플랫폼설정"), - S3_BUCKET("S3버킷"), - OBJECT_STORAGE_CONTAINER("오브젝트스토리지컨테이너"), UI("사용자인터페이스"); private final String description; diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCapabilityConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCapabilityConfig.java index ff4999df..a6a4206e 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCapabilityConfig.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCapabilityConfig.java @@ -87,4 +87,26 @@ public void initializeS3BucketCapabilities() { log.info("AWS S3 Bucket capabilities registered successfully - AWS|S3|BUCKET"); } + + @PostConstruct + public void initializeRdsCapabilities() { + log.info("Registering AWS RDS capabilities..."); + + CspCapability awsRdsCapability = CspCapability.builder() + .supportsStart(true) // RDS 인스턴스 시작 지원 (일부 엔진만 지원) + .supportsStop(true) // RDS 인스턴스 중지 지원 (일부 엔진만 지원) + .supportsTerminate(true) // RDS 인스턴스 삭제 지원 + .supportsTagging(true) // AWS RDS 인스턴스 태그 지원 + .supportsListByTag(true) // 태그 기반 목록 조회 지원 + .build(); + + capabilityRegistry.register( + CloudProvider.ProviderType.AWS, + "RDS", // 서비스 타입 + "DATABASE", // 리소스 타입 + awsRdsCapability + ); + + log.info("AWS RDS capabilities registered successfully - AWS|RDS|DATABASE"); + } } diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsRdsConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsRdsConfig.java new file mode 100644 index 00000000..c48194c8 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsRdsConfig.java @@ -0,0 +1,90 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.config; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.rds.RdsClient; + +/** + * AWS RDS 설정 클래스 + * + * 세션 자격증명 기반으로 RdsClient를 생성합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +public class AwsRdsConfig { + + /** + * 세션 자격증명으로 RDS Client를 생성합니다. + * + * @param session AWS 세션 자격증명 + * @param region AWS 리전 (RDS는 리전이 중요하므로 명시적 전달 권장, null이면 세션 리전 사용) + * @return RdsClient 인스턴스 + */ + public RdsClient createRdsClient(CloudSessionCredential session, String region) { + AwsSessionCredential awsSession = validateAndCastSession(session); + String targetRegion = region != null ? region : awsSession.getRegion(); + + return RdsClient.builder() + .credentialsProvider(StaticCredentialsProvider.create(toSdkCredentials(awsSession))) + .region(Region.of(resolveRegion(targetRegion))) + .build(); + } + + /** + * 리전 기본값 처리 + * + * @param region AWS 리전 (null이거나 빈 문자열이면 us-east-1 반환) + * @return 처리된 리전 문자열 + */ + private String resolveRegion(String region) { + return (region != null && !region.isBlank()) ? region : "us-east-1"; + } + + /** + * 세션 검증 및 AWS 세션으로 캐스팅 + * + * @param session 도메인 세션 자격증명 + * @return AWS 세션 자격증명 + * @throws BusinessException 세션이 유효하지 않거나 AWS 세션이 아닌 경우 + */ + private AwsSessionCredential validateAndCastSession(CloudSessionCredential session) { + if (session == null) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, "세션 자격증명이 필요합니다."); + } + if (!(session instanceof AwsSessionCredential awsSession)) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, + "AWS 세션 자격증명이 필요합니다. 제공된 타입: " + session.getClass().getSimpleName()); + } + if (!awsSession.isValid()) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, + "세션이 만료되었습니다. expiresAt: " + awsSession.getExpiresAt()); + } + return awsSession; + } + + /** + * 도메인 세션 객체를 AWS SDK 세션 객체로 변환 + * + * @param session AWS 도메인 세션 자격증명 + * @return AWS SDK 세션 자격증명 + */ + private AwsSessionCredentials toSdkCredentials(AwsSessionCredential session) { + return AwsSessionCredentials.create( + session.getAccessKeyId(), + session.getSecretAccessKey(), + session.getSessionToken() + ); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsS3Config.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsS3Config.java index e0251d09..e646e0eb 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsS3Config.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsS3Config.java @@ -2,15 +2,13 @@ import com.agenticcp.core.common.exception.BusinessException; import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential; -import com.agenticcp.core.domain.cloud.adapter.outbound.common.ThreadLocalCredentialCache; -import com.agenticcp.core.common.context.TenantContextHolder; import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -18,46 +16,23 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.sts.StsClient; -import java.net.URI; - /** * AWS S3 설정 클래스 * - * S3Client, ResourceGroupsTaggingApiClient, StsClient Bean을 생성합니다. - * ThreadLocal 기반 자격증명을 사용하여 멀티 테넌트 환경을 지원합니다. + * 세션 자격증명 기반으로 S3Client, ResourceGroupsTaggingApiClient를 생성합니다. + * StsClient Bean을 생성합니다. * * @author AgenticCP Team * @version 1.0.0 */ @Slf4j @Configuration +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) public class AwsS3Config { @Value("${aws.s3.region:us-east-1}") private String defaultRegion; - @Value("${aws.s3.endpoint:}") - private String endpoint; - - /** - * S3Client Bean을 생성합니다. - * ThreadLocal 기반 자격증명을 사용하여 동적 자격증명을 지원합니다. - * - * @return S3Client 인스턴스 - */ - @Bean - public S3Client s3Client() { - var builder = S3Client.builder() - .region(Region.of(resolveRegion(defaultRegion))) - .credentialsProvider(createThreadLocalCredentialsProvider()); - - // 커스텀 엔드포인트 설정 (LocalStack 등 테스트 환경용) - if (endpoint != null && !endpoint.isEmpty()) { - builder.endpointOverride(URI.create(endpoint)); - } - return builder.build(); - } - /** * 세션 자격증명으로 S3 Client를 생성합니다. * @@ -75,26 +50,6 @@ public S3Client createS3Client(CloudSessionCredential session, String region) { .build(); } - /** - * ResourceGroupsTaggingApiClient Bean을 생성합니다. - * 태그 기반 리소스 조회에 사용됩니다. - * ThreadLocal 기반 자격증명을 사용하여 동적 자격증명을 지원합니다. - * - * @return ResourceGroupsTaggingApiClient 인스턴스 - */ - @Bean - public ResourceGroupsTaggingApiClient resourceGroupsTaggingApiClient() { - var builder = ResourceGroupsTaggingApiClient.builder() - .region(Region.of(resolveRegion(defaultRegion))) - .credentialsProvider(createThreadLocalCredentialsProvider()); - - // 커스텀 엔드포인트 설정 (LocalStack 등 테스트 환경용) - if (endpoint != null && !endpoint.isEmpty()) { - builder.endpointOverride(URI.create(endpoint)); - } - return builder.build(); - } - /** * 세션 자격증명으로 ResourceGroupsTaggingApiClient를 생성합니다. * 태그 기반 리소스 조회에 사용됩니다. @@ -135,35 +90,6 @@ public StsClient stsClient() { private String resolveRegion(String region) { return (region != null && !region.isBlank()) ? region : "us-east-1"; } - - /** - * ThreadLocal 기반 자격증명 제공자 생성 - * - * 현재 스레드의 ThreadLocal 캐시에서 자격증명을 조회하여 제공합니다. - * 멀티 테넌트 환경에서 각 요청별로 다른 자격증명을 사용할 수 있도록 합니다. - */ - private AwsCredentialsProvider createThreadLocalCredentialsProvider() { - return () -> { - try { - // 현재 테넌트 키 조회 - String tenantKey = TenantContextHolder.getCurrentTenantKey(); - - if (tenantKey == null) { - log.warn("테넌트 컨텍스트가 설정되지 않음 - 기본 자격증명 사용"); - return null; // 기본 자격증명 사용 - } - - // ThreadLocal 캐시에서 자격증명 조회 - // 현재는 첫 번째 캐시된 자격증명을 반환 - // TODO: 실제로는 요청 컨텍스트에서 프로바이더 타입과 계정 스코프를 가져와야 함 - return ThreadLocalCredentialCache.getFirstCredentials(); - - } catch (Exception e) { - log.error("ThreadLocal 자격증명 조회 실패: {}", e.getMessage(), e); - return null; // 기본 자격증명 사용 - } - }; - } /** * 세션 검증 및 AWS 세션으로 캐스팅 diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsDiscoveryAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsDiscoveryAdapter.java new file mode 100644 index 00000000..ef87b2aa --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsDiscoveryAdapter.java @@ -0,0 +1,239 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.rds; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsRdsConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.CloudErrorTranslator; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsQuery; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.rds.RdsClient; +import software.amazon.awssdk.services.rds.model.*; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * AWS RDS 조회 어댑터 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class AwsRdsDiscoveryAdapter implements RdbmsDiscoveryPort, ProviderScoped { + + private final AwsRdsConfig awsRdsConfig; + private final AwsRdsMapper mapper; + + @Override + public CloudProvider.ProviderType getProviderType() { + return CloudProvider.ProviderType.AWS; + } + + /** + * + * @param session 세션 자격증명 + * @param operation RDS 클라이언트를 사용하는 작업 + * @param 반환 타입 + * @return 작업 결과 + */ + private T executeWithRdsClient(CloudSessionCredential session, String region, Function operation) { + RdsClient client = awsRdsConfig.createRdsClient(session, region); + + try { + return operation.apply(client); + } finally { + client.close(); + } + } + + @Override + public Page listRdbmsInstances(RdbmsQuery query, CloudSessionCredential session) { + log.debug("[AwsRdsDiscoveryAdapter] Listing RDBMS instances with query: {}", query); + + String region = query.getRegions() != null && !query.getRegions().isEmpty() + ? query.getRegions().iterator().next() + : null; + + return executeWithRdsClient(session, region, client -> { + try { + DescribeDbInstancesRequest request = buildDescribeRequest(query); + DescribeDbInstancesResponse response = client.describeDBInstances(request); + + List resources = response.dbInstances().stream() + .filter(dbInstance -> matchesQuery(dbInstance, query)) + .map(dbInstance -> mapper.toCloudResource( + dbInstance, + query.getProviderType(), + "RDS", // 기본 서비스 키 + region + )) + .collect(Collectors.toList()); + + // 페이징 처리 + Pageable pageable = PageRequest.of(query.getPage(), query.getSize()); + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), resources.size()); + List pagedResources = start < resources.size() + ? resources.subList(start, end) + : List.of(); + + log.debug("[AwsRdsDiscoveryAdapter] Found {} RDBMS instances", resources.size()); + return new PageImpl<>(pagedResources, pageable, resources.size()); + + } catch (Throwable t) { + log.error("[AwsRdsDiscoveryAdapter] Failed to list RDBMS instances", t); + throw CloudErrorTranslator.translate(t); + } + }); + } + + @Override + public Optional getRdbmsInstance(String instanceId, CloudSessionCredential session) { + log.debug("[AwsRdsDiscoveryAdapter] Getting RDBMS instance: {}", instanceId); + + return executeWithRdsClient(session, null, client -> { + try { + DescribeDbInstancesRequest request = buildDescribeRequestById(instanceId); + DescribeDbInstancesResponse response = client.describeDBInstances(request); + + Optional result = response.dbInstances().stream() + .findFirst() + .map(dbInstance -> { + String region = extractRegionFromAvailabilityZone(dbInstance.availabilityZone()); + return mapper.toCloudResource( + dbInstance, + CloudProvider.ProviderType.AWS, + "RDS", + region + ); + }); + + log.debug("[AwsRdsDiscoveryAdapter] Instance {} found: {}", instanceId, result.isPresent()); + return result; + + } catch (Throwable t) { + log.error("[AwsRdsDiscoveryAdapter] Failed to get RDBMS instance: {}", instanceId, t); + throw CloudErrorTranslator.translate(t); + } + }); + } + + @Override + public String getInstanceStatus(String instanceId, CloudSessionCredential session) { + log.debug("[AwsRdsDiscoveryAdapter] Getting status for RDBMS instance: {}", instanceId); + + return executeWithRdsClient(session, null, client -> { + try { + DescribeDbInstancesRequest request = buildDescribeRequestById(instanceId); + DescribeDbInstancesResponse response = client.describeDBInstances(request); + + String status = response.dbInstances().stream() + .findFirst() + .map(DBInstance::dbInstanceStatus) + .orElse("unknown"); + + log.debug("[AwsRdsDiscoveryAdapter] RDBMS instance {} status: {}", instanceId, status); + return status; + + } catch (Throwable t) { + log.error("[AwsRdsDiscoveryAdapter] Failed to get status for RDBMS instance: {}", instanceId, t); + throw CloudErrorTranslator.translate(t); + } + }); + } + + /** + * DBInstance가 쿼리 조건과 일치하는지 확인합니다. + */ + private boolean matchesQuery(DBInstance dbInstance, RdbmsQuery query) { + // instanceName 필터 + if (query.getInstanceName() != null) { + if (!dbInstance.dbInstanceIdentifier().equals(query.getInstanceName())) { + return false; + } + } + + // engine 필터 + if (query.getEngine() != null) { + if (!dbInstance.engine().equalsIgnoreCase(query.getEngine())) { + return false; + } + } + + // instanceSize 필터 + if (query.getInstanceSize() != null) { + if (!dbInstance.dbInstanceClass().equals(query.getInstanceSize())) { + return false; + } + } + + // status 필터 + if (query.getStatus() != null) { + if (!dbInstance.dbInstanceStatus().equalsIgnoreCase(query.getStatus())) { + return false; + } + } + + // tags 필터 (간단한 구현, 향후 개선 가능) + // TODO: 태그 필터링 로직 추가 + + return true; + } + + /** + * Availability Zone에서 리전을 추출합니다. + * 예: "us-east-1a" -> "us-east-1" + */ + private String extractRegionFromAvailabilityZone(String availabilityZone) { + if (availabilityZone == null || availabilityZone.isEmpty()) { + return null; + } + + if (availabilityZone.length() > 1) { + return availabilityZone.substring(0, availabilityZone.length() - 1); + } + + return availabilityZone; + } + + /** + * RdbmsQuery를 AWS DescribeDbInstancesRequest로 변환합니다. + */ + private DescribeDbInstancesRequest buildDescribeRequest(RdbmsQuery query) { + log.debug("[AwsRdsDiscoveryAdapter] Building DescribeDbInstancesRequest from query"); + + DescribeDbInstancesRequest.Builder builder = DescribeDbInstancesRequest.builder(); + + if (query.getInstanceName() != null) { + builder.dbInstanceIdentifier(query.getInstanceName()); + } + + return builder.build(); + } + + /** + * 인스턴스 ID로 AWS DescribeDbInstancesRequest를 생성합니다. + */ + private DescribeDbInstancesRequest buildDescribeRequestById(String instanceId) { + log.debug("[AwsRdsDiscoveryAdapter] Building DescribeDbInstancesRequest for instanceId: {}", instanceId); + + return DescribeDbInstancesRequest.builder() + .dbInstanceIdentifier(instanceId) + .build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsLifecycleAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsLifecycleAdapter.java new file mode 100644 index 00000000..869abb9f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsLifecycleAdapter.java @@ -0,0 +1,154 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.rds; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsRdsConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.CloudErrorTranslator; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.ResourceIdentity; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.rds.RdsClient; +import software.amazon.awssdk.services.rds.model.*; + +/** + * AWS RDS 생명주기 어댑터 + * + * RDBMS 인스턴스의 시작, 중지, 재시작 기능을 제공합니다. + * 모든 Lifecycle 작업은 세션 자격증명을 사용하여 요청별로 RDS 클라이언트를 생성합니다. + * + * RdbmsLifecyclePort를 구현하여 ResourceLifecyclePort의 start, stop, terminate와 + * RDBMS 특화 기능인 reboot를 제공합니다. + * + * @author AgenticCP Team + * @version 2.0.0 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class AwsRdsLifecycleAdapter implements RdbmsLifecyclePort, ProviderScoped { + + private final AwsRdsConfig awsRdsConfig; + + @Override + public CloudProvider.ProviderType getProviderType() { + return CloudProvider.ProviderType.AWS; + } + + // ==================== ResourceLifecyclePort 구현 (RdbmsLifecyclePort를 통해 상속) ==================== + + @Override + public void start(ResourceIdentity id, CloudSessionCredential session) { + String instanceId = id.getProviderResourceId(); + log.debug("[AwsRdsLifecycleAdapter] Starting RDBMS instance via ResourceLifecyclePort: {}", instanceId); + + RdsClient client = awsRdsConfig.createRdsClient(session, id.getRegion()); + + try { + log.warn("[AwsRdsLifecycleAdapter] RDS does not support startInstance operation. " + + "RDS instances cannot be stopped and started like EC2 instances. " + + "Use modifyDBInstance to change instance configuration instead."); + + throw new UnsupportedOperationException( + "AWS RDS does not support startInstance operation. " + + "RDS instances are always running when available. " + + "To resume a stopped instance, use modifyDBInstance or wait for automatic resume." + ); + + } catch (Throwable t) { + log.error("[AwsRdsLifecycleAdapter] Failed to start RDBMS instance: {}", instanceId, t); + throw CloudErrorTranslator.translate(t); + } finally { + client.close(); + } + } + + @Override + public void stop(ResourceIdentity id, CloudSessionCredential session) { + String instanceId = id.getProviderResourceId(); + log.debug("[AwsRdsLifecycleAdapter] Stopping RDBMS instance via ResourceLifecyclePort: {}", instanceId); + + RdsClient client = awsRdsConfig.createRdsClient(session, id.getRegion()); + + try { + StopDbInstanceRequest request = buildStopRequest(instanceId); + client.stopDBInstance(request); + + log.info("[AwsRdsLifecycleAdapter] Successfully stopped RDBMS instance: {}", instanceId); + + } catch (Throwable t) { + log.error("[AwsRdsLifecycleAdapter] Failed to stop RDBMS instance: {}", instanceId, t); + throw CloudErrorTranslator.translate(t); + } finally { + client.close(); + } + } + + @Override + public void terminate(ResourceIdentity id, CloudSessionCredential session) { + String instanceId = id.getProviderResourceId(); + log.debug("[AwsRdsLifecycleAdapter] Terminating RDBMS instance via ResourceLifecyclePort: {}", instanceId); + + // RDS의 경우 terminate는 delete와 동일하지만, 실제로는 RdbmsManagementPort의 deleteRdbms를 사용해야 합니다. + // 여기서는 UnsupportedOperationException을 던지거나, 실제 삭제 로직을 구현할 수 있습니다. + log.warn("[AwsRdsLifecycleAdapter] terminate() is called for RDS instance. " + + "RDS termination should be handled through RdbmsManagementPort.deleteRdbms() instead."); + + throw new UnsupportedOperationException( + "RDS 인스턴스 종료는 RdbmsManagementPort.deleteRdbms()를 통해 처리해야 합니다." + ); + } + + // ==================== RdbmsLifecyclePort 구현 (reboot 추가 기능) ==================== + + @Override + public void rebootInstance(String instanceId, CloudSessionCredential session) { + log.debug("[AwsRdsLifecycleAdapter] Rebooting RDBMS instance: {}", instanceId); + + RdsClient client = awsRdsConfig.createRdsClient(session, null); + + try { + // Command → AWS SDK Request 변환 (Adapter에서 직접 생성) + RebootDbInstanceRequest request = buildRebootRequest(instanceId); + RebootDbInstanceResponse response = client.rebootDBInstance(request); + + log.info("[AwsRdsLifecycleAdapter] Successfully rebooted RDBMS instance: {}", instanceId); + log.debug("[AwsRdsLifecycleAdapter] Reboot operation initiated for instance: {}", + response.dbInstance().dbInstanceIdentifier()); + + } catch (Throwable t) { + log.error("[AwsRdsLifecycleAdapter] Failed to reboot RDBMS instance: {}", instanceId, t); + throw CloudErrorTranslator.translate(t); + } finally { + client.close(); + } + } + + // ==================== Private Helper Methods ==================== + + /** + * 인스턴스 ID로 AWS StopDbInstanceRequest를 생성합니다. + */ + private StopDbInstanceRequest buildStopRequest(String instanceId) { + log.debug("[AwsRdsLifecycleAdapter] Building StopDbInstanceRequest for instanceId: {}", instanceId); + + return StopDbInstanceRequest.builder() + .dbInstanceIdentifier(instanceId) + .build(); + } + + /** + * 인스턴스 ID로 AWS RebootDbInstanceRequest를 생성합니다. + */ + private RebootDbInstanceRequest buildRebootRequest(String instanceId) { + log.debug("[AwsRdsLifecycleAdapter] Building RebootDbInstanceRequest for instanceId: {}", instanceId); + + return RebootDbInstanceRequest.builder() + .dbInstanceIdentifier(instanceId) + .build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapter.java new file mode 100644 index 00000000..2c873b90 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapter.java @@ -0,0 +1,394 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.rds; + +import com.agenticcp.core.common.crypto.EncryptionService; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsRdsConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.CloudErrorTranslator; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort; +import com.agenticcp.core.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.rds.RdsClient; +import software.amazon.awssdk.services.rds.model.*; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * AWS RDS 관리 어댑터 + * + * RDBMS 인스턴스의 생성, 수정, 삭제 기능을 제공합니다. + * 모든 Management 작업은 세션 자격증명을 사용하여 요청별로 RDS 클라이언트를 생성합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class AwsRdsManagementAdapter implements RdbmsManagementPort, ProviderScoped { + + private final AwsRdsConfig awsRdsConfig; + private final AwsRdsMapper mapper; + private final EncryptionService encryptionService; + + @Override + public CloudProvider.ProviderType getProviderType() { + return CloudProvider.ProviderType.AWS; + } + + @Override + public CloudResource createRdbms(RdbmsCreateCommand command) { + log.debug("[AwsRdsManagementAdapter] Creating RDBMS instance with command: instanceName={}, engine={}", + command.instanceName(), command.engine()); + + RdsClient client = awsRdsConfig.createRdsClient(command.session(), command.region()); + + try { + // Command → AWS SDK Request 변환 (Adapter에서 직접 생성) + CreateDbInstanceRequest request = buildCreateRequest(command); + CreateDbInstanceResponse response = client.createDBInstance(request); + + String instanceIdentifier = response.dbInstance().dbInstanceIdentifier(); + log.info("[AwsRdsManagementAdapter] RDBMS instance creation initiated: {}", instanceIdentifier); + + // 인스턴스가 available 상태가 될 때까지 대기 + DBInstance dbInstance = waitForInstanceAvailable(client, instanceIdentifier); + + // CloudResource로 변환 + CloudResource resource = mapper.toCloudResource( + dbInstance, + command.providerType(), + command.serviceKey(), + command.region() + ); + + log.info("[AwsRdsManagementAdapter] Successfully created RDBMS instance: {}", instanceIdentifier); + return resource; + + } catch (Throwable t) { + log.error("[AwsRdsManagementAdapter] Failed to create RDBMS instance", t); + throw CloudErrorTranslator.translate(t); + } finally { + client.close(); + } + } + + @Override + public CloudResource updateRdbms(RdbmsUpdateCommand command) { + log.debug("[AwsRdsManagementAdapter] Updating RDBMS instance: instanceId={}", + command.providerResourceId()); + + RdsClient client = awsRdsConfig.createRdsClient(command.session(), command.region()); + + try { + // Command → AWS SDK Request 변환 (Adapter에서 직접 생성) + ModifyDbInstanceRequest request = buildModifyRequest(command); + ModifyDbInstanceResponse response = client.modifyDBInstance(request); + + String instanceIdentifier = response.dbInstance().dbInstanceIdentifier(); + log.info("[AwsRdsManagementAdapter] RDBMS instance modification initiated: {}", instanceIdentifier); + + // 인스턴스가 available 상태가 될 때까지 대기 + DBInstance dbInstance = waitForInstanceAvailable(client, instanceIdentifier); + + // CloudResource로 변환 (providerType과 serviceKey는 command에서 추출 필요) + // TODO: command에 serviceKey 추가하거나 다른 방법으로 조회 + CloudResource resource = mapper.toCloudResource( + dbInstance, + command.providerType(), + "RDS", // 기본값, command에 serviceKey가 없을 경우 + command.region() + ); + + log.info("[AwsRdsManagementAdapter] Successfully updated RDBMS instance: {}", instanceIdentifier); + return resource; + + } catch (Throwable t) { + log.error("[AwsRdsManagementAdapter] Failed to update RDBMS instance: {}", + command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + client.close(); + } + } + + @Override + public void deleteRdbms(RdbmsDeleteCommand command) { + log.debug("[AwsRdsManagementAdapter] Deleting RDBMS instance: instanceId={}", + command.providerResourceId()); + + RdsClient client = awsRdsConfig.createRdsClient(command.session(), command.region()); + + try { + // Command → AWS SDK Request 변환 (Adapter에서 직접 생성) + DeleteDbInstanceRequest request = buildDeleteRequest(command); + DeleteDbInstanceResponse response = client.deleteDBInstance(request); + + String instanceIdentifier = response.dbInstance().dbInstanceIdentifier(); + log.info("[AwsRdsManagementAdapter] RDBMS instance deletion initiated: {}", instanceIdentifier); + + // 삭제는 비동기로 진행되므로 대기하지 않음 + // 필요시 별도로 상태 확인 가능 + + } catch (Throwable t) { + log.error("[AwsRdsManagementAdapter] Failed to delete RDBMS instance: {}", + command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + client.close(); + } + } + + /** + * RDS 인스턴스가 available 상태가 될 때까지 대기합니다. + * + * @param client RDS 클라이언트 + * @param instanceIdentifier 인스턴스 식별자 + * @return available 상태의 DBInstance + */ + private DBInstance waitForInstanceAvailable(RdsClient client, String instanceIdentifier) { + log.debug("[AwsRdsManagementAdapter] Waiting for instance to be available: {}", instanceIdentifier); + + int maxWaitMinutes = 30; // 최대 대기 시간 (분) + int pollIntervalSeconds = 30; // 폴링 간격 (초) + Instant startTime = Instant.now(); + + while (true) { + try { + DescribeDbInstancesRequest request = DescribeDbInstancesRequest.builder() + .dbInstanceIdentifier(instanceIdentifier) + .build(); + + DescribeDbInstancesResponse response = client.describeDBInstances(request); + + if (response.dbInstances().isEmpty()) { + throw new RuntimeException("DBInstance not found: " + instanceIdentifier); + } + + DBInstance dbInstance = response.dbInstances().get(0); + String status = dbInstance.dbInstanceStatus(); + + log.debug("[AwsRdsManagementAdapter] Instance status: {} (waiting for available)", status); + + if ("available".equalsIgnoreCase(status)) { + log.info("[AwsRdsManagementAdapter] Instance is now available: {}", instanceIdentifier); + return dbInstance; + } + + if ("failed".equalsIgnoreCase(status) || "deleted".equalsIgnoreCase(status)) { + throw new RuntimeException("Instance is in failed or deleted state: " + status); + } + + // 타임아웃 확인 + Duration elapsed = Duration.between(startTime, Instant.now()); + if (elapsed.toMinutes() > maxWaitMinutes) { + throw new RuntimeException("Timeout waiting for instance to be available: " + instanceIdentifier); + } + + // 대기 + Thread.sleep(pollIntervalSeconds * 1000L); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for instance to be available", e); + } catch (Throwable t) { + log.error("[AwsRdsManagementAdapter] Error while waiting for instance to be available", t); + throw CloudErrorTranslator.translate(t); + } + } + } + + // ==================== Command → AWS Request 변환 ==================== + + /** + * RdbmsCreateCommand를 AWS CreateDbInstanceRequest로 변환합니다. + * + * CSP 중립 필드 → AWS 특화 필드 매핑: + * - instanceName → dbInstanceIdentifier + * - instanceSize → dbInstanceClass + * - networkSecurityId → vpcSecurityGroupIds + * - zone → availabilityZone + * - highAvailability → multiAz + */ + private CreateDbInstanceRequest buildCreateRequest(RdbmsCreateCommand command) { + log.debug("[AwsRdsManagementAdapter] Building CreateDbInstanceRequest: instanceName={}, engine={}", + command.instanceName(), command.engine()); + + // adminPassword 복호화 + String decryptedPassword = null; + if (command.adminPassword() != null) { + try { + decryptedPassword = encryptionService.decrypt(command.adminPassword()); + log.debug("[AwsRdsManagementAdapter] adminPassword 복호화 완료"); + } catch (Exception e) { + log.error("[AwsRdsManagementAdapter] adminPassword 복호화 실패", e); + throw new BusinessException( + CloudErrorCode.DECRYPTION_FAILED, + "관리자 패스워드 복호화에 실패했습니다: " + e.getMessage() + ); + } + } + + CreateDbInstanceRequest.Builder builder = CreateDbInstanceRequest.builder() + .dbInstanceIdentifier(command.instanceName()) // instanceName → dbInstanceIdentifier + .engine(command.engine()) + .dbInstanceClass(command.instanceSize()) // instanceSize → dbInstanceClass + .allocatedStorage(command.allocatedStorage()) + .masterUsername(command.adminUsername()) + .masterUserPassword(decryptedPassword) // 복호화된 패스워드 사용 + .publiclyAccessible(command.publiclyAccessible() != null ? command.publiclyAccessible() : false); + + // engineVersion + if (command.engineVersion() != null) { + builder.engineVersion(command.engineVersion()); + } + + // dbName + if (command.dbName() != null) { + builder.dbName(command.dbName()); + } + + // networkSecurityId → vpcSecurityGroupIds + if (command.networkSecurityId() != null) { + builder.vpcSecurityGroupIds(command.networkSecurityId()); + } + + // subnetId → dbSubnetGroupName (providerSpecificConfig에서 추출) + String subnetGroupName = getSubnetGroupName(command); + if (subnetGroupName != null) { + builder.dbSubnetGroupName(subnetGroupName); + } + + // port + if (command.port() != null) { + builder.port(command.port()); + } + + // zone → availabilityZone + if (command.zone() != null) { + builder.availabilityZone(command.zone()); + } + + // highAvailability → multiAZ + if (command.highAvailability() != null) { + builder.multiAZ(command.highAvailability()); + } + + // tags + if (command.tags() != null && !command.tags().isEmpty()) { + builder.tags(convertTagsToAwsTags(command.tags())); + } + + return builder.build(); + } + + /** + * RdbmsUpdateCommand를 AWS ModifyDbInstanceRequest로 변환합니다. + */ + private ModifyDbInstanceRequest buildModifyRequest(RdbmsUpdateCommand command) { + log.debug("[AwsRdsManagementAdapter] Building ModifyDbInstanceRequest: instanceId={}", + command.providerResourceId()); + + ModifyDbInstanceRequest.Builder builder = ModifyDbInstanceRequest.builder() + .dbInstanceIdentifier(command.providerResourceId()); + + // instanceSize → dbInstanceClass + if (command.instanceSize() != null) { + builder.dbInstanceClass(command.instanceSize()); + } + + // allocatedStorage + if (command.allocatedStorage() != null) { + builder.allocatedStorage(command.allocatedStorage()); + } + + // adminPassword 복호화 + if (command.adminPassword() != null) { + try { + String decryptedPassword = encryptionService.decrypt(command.adminPassword()); + builder.masterUserPassword(decryptedPassword); + log.debug("[AwsRdsManagementAdapter] adminPassword 복호화 완료 (수정)"); + } catch (Exception e) { + log.error("[AwsRdsManagementAdapter] adminPassword 복호화 실패 (수정)", e); + throw new BusinessException( + CloudErrorCode.DECRYPTION_FAILED, + "관리자 패스워드 복호화에 실패했습니다: " + e.getMessage() + ); + } + } + + // applyImmediately + if (command.applyImmediately() != null) { + builder.applyImmediately(command.applyImmediately()); + } + + return builder.build(); + } + + /** + * RdbmsDeleteCommand를 AWS DeleteDbInstanceRequest로 변환합니다. + */ + private DeleteDbInstanceRequest buildDeleteRequest(RdbmsDeleteCommand command) { + log.debug("[AwsRdsManagementAdapter] Building DeleteDbInstanceRequest: instanceId={}", + command.providerResourceId()); + + DeleteDbInstanceRequest.Builder builder = DeleteDbInstanceRequest.builder() + .dbInstanceIdentifier(command.providerResourceId()); + + // skipSnapshot → skipFinalSnapshot + if (command.skipSnapshot() != null) { + builder.skipFinalSnapshot(command.skipSnapshot()); + } + + // snapshotName → finalDBSnapshotIdentifier + if (command.snapshotName() != null && !command.skipSnapshot()) { + builder.finalDBSnapshotIdentifier(command.snapshotName()); + } + + // deleteAutomatedBackups + if (command.deleteAutomatedBackups() != null) { + builder.deleteAutomatedBackups(command.deleteAutomatedBackups()); + } + + return builder.build(); + } + + /** + * providerSpecificConfig에서 AWS 특화 설정 추출 + */ + private String getSubnetGroupName(RdbmsCreateCommand command) { + if (command.providerSpecificConfig() != null) { + Object subnetGroupName = command.providerSpecificConfig().get("subnetGroupName"); + return subnetGroupName != null ? subnetGroupName.toString() : null; + } + return null; + } + + /** + * Map을 AWS Tag 리스트로 변환합니다. + */ + private List convertTagsToAwsTags(Map tags) { + if (tags == null || tags.isEmpty()) { + return List.of(); + } + + return tags.entrySet().stream() + .map(entry -> software.amazon.awssdk.services.rds.model.Tag.builder() + .key(entry.getKey()) + .value(entry.getValue()) + .build()) + .collect(java.util.stream.Collectors.toList()); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsMapper.java new file mode 100644 index 00000000..e8ec76a1 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsMapper.java @@ -0,0 +1,210 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.rds; + +import com.agenticcp.core.common.enums.Status; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudRegion; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.CloudService; +import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository; +import com.agenticcp.core.domain.cloud.repository.CloudRegionRepository; +import com.agenticcp.core.domain.cloud.repository.CloudServiceRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.rds.model.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * AWS RDS와 도메인 모델 간의 데이터 변환을 담당하는 매퍼 + * + * 이 매퍼는 AWS SDK의 DBInstance 객체를 우리 도메인의 CloudResource로 변환하는 역할을 합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +public class AwsRdsMapper { + + private final ObjectMapper objectMapper; + private final CloudProviderRepository cloudProviderRepository; + private final CloudServiceRepository cloudServiceRepository; + private final CloudRegionRepository cloudRegionRepository; + + public AwsRdsMapper( + ObjectMapper objectMapper, + CloudProviderRepository cloudProviderRepository, + CloudServiceRepository cloudServiceRepository, + CloudRegionRepository cloudRegionRepository) { + this.objectMapper = objectMapper; + this.cloudProviderRepository = cloudProviderRepository; + this.cloudServiceRepository = cloudServiceRepository; + this.cloudRegionRepository = cloudRegionRepository; + } + + /** + * AWS DBInstance를 CloudResource로 변환합니다. + * + * @param dbInstance AWS SDK DBInstance 객체 + * @param providerType 프로바이더 타입 + * @param serviceKey 서비스 키 (예: "RDS") + * @param region 리전 + * @return CloudResource 도메인 객체 + */ + public CloudResource toCloudResource(DBInstance dbInstance, CloudProvider.ProviderType providerType, + String serviceKey, String region) { + try { + log.debug("[AwsRdsMapper] Converting AWS DBInstance to CloudResource: {}", dbInstance.dbInstanceIdentifier()); + + CloudProvider provider = findProvider(providerType); + CloudService service = findService(providerType, serviceKey); + CloudRegion cloudRegion = findRegion(providerType, region); + + return CloudResource.builder() + .resourceId(dbInstance.dbInstanceIdentifier()) + .resourceName(dbInstance.dbInstanceIdentifier()) + .displayName(dbInstance.dbInstanceIdentifier()) + .provider(provider) + .region(cloudRegion) + .service(service) + .resourceType(CloudResource.ResourceType.DATABASE) + .lifecycleState(mapLifecycleState(dbInstance.dbInstanceStatus())) + .instanceType(dbInstance.dbInstanceClass()) + .instanceSize(dbInstance.dbInstanceClass()) + .storageGb((long) dbInstance.allocatedStorage()) + .ipAddress(dbInstance.endpoint() != null ? dbInstance.endpoint().address() : null) + .tags(convertTagsToMap(dbInstance.tagList())) + .configuration(convertConfigurationToJson(dbInstance)) + .status(mapStatus(dbInstance.dbInstanceStatus())) + .createdInCloud(dbInstance.instanceCreateTime() != null + ? LocalDateTime.ofInstant(dbInstance.instanceCreateTime(), ZoneId.systemDefault()) + : LocalDateTime.now()) + .lastSync(LocalDateTime.now()) + .build(); + + } catch (Exception e) { + log.error("[AwsRdsMapper] Failed to convert AWS DBInstance to CloudResource: {}", + dbInstance.dbInstanceIdentifier(), e); + throw new RuntimeException("Failed to convert AWS DBInstance to CloudResource", e); + } + } + + /** + * AWS DBInstance 상태를 도메인 LifecycleState로 매핑합니다. + */ + private CloudResource.LifecycleState mapLifecycleState(String awsStatus) { + if (awsStatus == null) { + return CloudResource.LifecycleState.UNKNOWN; + } + + return switch (awsStatus.toLowerCase()) { + case "available" -> CloudResource.LifecycleState.RUNNING; + case "creating" -> CloudResource.LifecycleState.PENDING; + case "deleting" -> CloudResource.LifecycleState.TERMINATING; + case "deleted" -> CloudResource.LifecycleState.TERMINATED; + case "modifying" -> CloudResource.LifecycleState.PENDING; + case "rebooting" -> CloudResource.LifecycleState.PENDING; + case "stopped" -> CloudResource.LifecycleState.STOPPED; + case "stopping" -> CloudResource.LifecycleState.STOPPING; + case "failed" -> CloudResource.LifecycleState.FAILED; + default -> CloudResource.LifecycleState.UNKNOWN; + }; + } + + /** + * AWS DBInstance 상태를 Status로 매핑합니다. + */ + private Status mapStatus(String awsStatus) { + if (awsStatus == null) { + return com.agenticcp.core.common.enums.Status.INACTIVE; + } + + return switch (awsStatus.toLowerCase()) { + case "available" -> com.agenticcp.core.common.enums.Status.ACTIVE; + case "creating", "modifying", "rebooting" -> com.agenticcp.core.common.enums.Status.PENDING; + case "deleting", "deleted" -> com.agenticcp.core.common.enums.Status.DELETED; + case "stopped", "stopping" -> com.agenticcp.core.common.enums.Status.INACTIVE; + case "failed" -> com.agenticcp.core.common.enums.Status.INACTIVE; + default -> com.agenticcp.core.common.enums.Status.INACTIVE; + }; + } + + /** + * AWS 태그 리스트를 Map으로 변환합니다. + */ + private Map convertTagsToMap(List awsTags) { + if (awsTags == null || awsTags.isEmpty()) { + return Map.of(); + } + + return awsTags.stream() + .collect(Collectors.toMap(Tag::key, Tag::value)); + } + + /** + * DBInstance의 설정을 JSON 문자열로 변환합니다. + */ + private String convertConfigurationToJson(DBInstance dbInstance) { + try { + Map config = new HashMap<>(); + config.put("engine", dbInstance.engine()); + config.put("engineVersion", dbInstance.engineVersion()); + config.put("dbInstanceClass", dbInstance.dbInstanceClass()); + config.put("allocatedStorage", dbInstance.allocatedStorage()); + config.put("storageType", dbInstance.storageType()); + config.put("multiAZ", dbInstance.multiAZ()); + config.put("publiclyAccessible", dbInstance.publiclyAccessible()); + config.put("availabilityZone", dbInstance.availabilityZone()); + config.put("preferredBackupWindow", dbInstance.preferredBackupWindow()); + config.put("preferredMaintenanceWindow", dbInstance.preferredMaintenanceWindow()); + config.put("backupRetentionPeriod", dbInstance.backupRetentionPeriod()); + + if (dbInstance.endpoint() != null) { + Map endpoint = new HashMap<>(); + endpoint.put("address", dbInstance.endpoint().address()); + endpoint.put("port", dbInstance.endpoint().port()); + config.put("endpoint", endpoint); + } + + return objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + log.warn("[AwsRdsMapper] Failed to convert DBInstance configuration to JSON", e); + return "{}"; + } + } + + /** + * CloudProvider 조회 + */ + private CloudProvider findProvider(CloudProvider.ProviderType providerType) { + return cloudProviderRepository.findFirstByProviderType(providerType) + .orElseThrow(() -> new IllegalStateException( + "CloudProvider not found for type: " + providerType)); + } + + /** + * CloudService 조회 + */ + private CloudService findService(CloudProvider.ProviderType providerType, String serviceKey) { + return cloudServiceRepository.findByProviderTypeAndServiceKey(providerType, serviceKey) + .orElse(null); // 서비스가 없어도 null 반환 (선택적) + } + + /** + * CloudRegion 조회 + */ + private CloudRegion findRegion(CloudProvider.ProviderType providerType, String region) { + if (region == null) { + return null; + } + return cloudRegionRepository.findByProviderTypeAndRegionKey(providerType, region) + .orElse(null); // 리전이 없어도 null 반환 (선택적) + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/common/CloudErrorTranslator.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/common/CloudErrorTranslator.java index 37fea052..ec537393 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/common/CloudErrorTranslator.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/common/CloudErrorTranslator.java @@ -7,6 +7,10 @@ public final class CloudErrorTranslator { private CloudErrorTranslator() {} public static RuntimeException translate(Throwable t) { + if (t instanceof BusinessException) { + return (BusinessException) t; + } + String msg = t.getMessage() == null ? "" : t.getMessage().toLowerCase(); if (msg.contains("rate") && msg.contains("limit")) { return new BusinessException(CloudErrorCode.API_RATE_LIMIT_EXCEEDED); diff --git a/src/main/java/com/agenticcp/core/domain/cloud/controller/ObjectStorageController.java b/src/main/java/com/agenticcp/core/domain/cloud/controller/ObjectStorageController.java index 59a17699..eb96ad0e 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/controller/ObjectStorageController.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/controller/ObjectStorageController.java @@ -43,7 +43,7 @@ public class ObjectStorageController { @PreAuthorize("hasAuthority('OBJECT_STORAGE_CONTAINER_CREATE') or hasRole('SUPER_ADMIN')") @AuditRequired( action = "CREATE_CONTAINER", - resourceType = AuditResourceType.OBJECT_STORAGE_CONTAINER, + resourceType = AuditResourceType.CLOUD_PROVIDER, description = "Object Storage Container 생성", severity = AuditSeverity.HIGH, includeRequestData = true, @@ -87,7 +87,7 @@ public ResponseEntity> createContainer( @PreAuthorize("hasAuthority('OBJECT_STORAGE_CONTAINER_UPDATE') or hasRole('SUPER_ADMIN')") @AuditRequired( action = "UPDATE_CONTAINER", - resourceType = AuditResourceType.OBJECT_STORAGE_CONTAINER, + resourceType = AuditResourceType.CLOUD_PROVIDER, description = "Object Storage Container 설정 업데이트", severity = AuditSeverity.HIGH, includeRequestData = true, @@ -134,7 +134,7 @@ public ResponseEntity> updateContainer( @PreAuthorize("hasAuthority('OBJECT_STORAGE_CONTAINER_READ') or hasRole('SUPER_ADMIN')") @AuditRequired( action = "LIST_CONTAINERS", - resourceType = AuditResourceType.OBJECT_STORAGE_CONTAINER, + resourceType = AuditResourceType.CLOUD_PROVIDER, description = "Object Storage Container 목록 조회", severity = AuditSeverity.LOW, includeRequestData = true, @@ -183,7 +183,7 @@ public ResponseEntity>> listContainers( @PreAuthorize("hasAuthority('OBJECT_STORAGE_CONTAINER_READ') or hasRole('SUPER_ADMIN')") @AuditRequired( action = "GET_CONTAINER", - resourceType = AuditResourceType.OBJECT_STORAGE_CONTAINER, + resourceType = AuditResourceType.CLOUD_PROVIDER, description = "Object Storage Container 상세 조회", severity = AuditSeverity.LOW, includeRequestData = true, @@ -222,7 +222,7 @@ public ResponseEntity> getContainer( @PreAuthorize("hasAuthority('OBJECT_STORAGE_CONTAINER_READ') or hasRole('SUPER_ADMIN')") @AuditRequired( action = "CHECK_CONTAINER_EXISTS", - resourceType = AuditResourceType.OBJECT_STORAGE_CONTAINER, + resourceType = AuditResourceType.CLOUD_PROVIDER, description = "Object Storage Container 존재 확인 (HEAD)", severity = AuditSeverity.LOW, includeRequestData = true, @@ -265,7 +265,7 @@ public ResponseEntity containerExists( @PreAuthorize("hasAuthority('OBJECT_STORAGE_CONTAINER_DELETE') or hasRole('SUPER_ADMIN')") @AuditRequired( action = "DELETE_CONTAINER", - resourceType = AuditResourceType.OBJECT_STORAGE_CONTAINER, + resourceType = AuditResourceType.CLOUD_PROVIDER, description = "Object Storage Container 삭제", severity = AuditSeverity.CRITICAL, includeRequestData = true, diff --git a/src/main/java/com/agenticcp/core/domain/cloud/controller/RdbmsController.java b/src/main/java/com/agenticcp/core/domain/cloud/controller/RdbmsController.java new file mode 100644 index 00000000..624c54e2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/controller/RdbmsController.java @@ -0,0 +1,478 @@ +package com.agenticcp.core.domain.cloud.controller; + +import com.agenticcp.core.common.audit.AuditRequired; +import com.agenticcp.core.common.dto.exception.ApiResponse; +import com.agenticcp.core.common.enums.AuditResourceType; +import com.agenticcp.core.common.enums.AuditSeverity; +import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsQueryRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsResponse; +import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.service.rdbms.RdbmsUseCaseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * RDBMS 인스턴스 관리를 위한 REST API 컨트롤러 + * + * 멀티 클라우드(AWS, GCP, Azure) RDBMS 인스턴스의 생성, 조회, 수정, 삭제, 생명주기 관리 등의 기능을 제공합니다. + * 헥사고날 아키텍처의 인터페이스 계층에 해당하며, 외부 클라이언트와의 통신을 담당합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/cloud/providers/{provider}/accounts/{accountScope}/rdbms/instances") +@RequiredArgsConstructor +@Tag(name = "RDBMS Management", description = "RDBMS 인스턴스 관리 API (멀티 클라우드 지원)") +public class RdbmsController { + + private final RdbmsUseCaseService rdbmsUseCaseService; + + // ==================== 인스턴스 조회 ==================== + + /** + * RDBMS 인스턴스 목록을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * @param accountScope 계정 스코프 + * @param request 조회 요청 (페이징, 필터링 포함) + * @return RdbmsResponse 페이지 + */ + @GetMapping + @AuditRequired( + action = "LIST_RDBMS_INSTANCES", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 목록 조회", + severity = AuditSeverity.LOW, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 목록 조회", description = "조건에 맞는 RDBMS 인스턴스 목록을 페이징하여 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity>> listRdbmsInstances( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Valid RdbmsQueryRequest request) { + + log.info("[RdbmsController] listRdbmsInstances - provider={}, accountScope={}, page={}, size={}", + provider, accountScope, request.getPage(), request.getSize()); + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + Page result = rdbmsUseCaseService.listRdbmsInstances(provider, accountScope, request); + + // CloudResource를 RdbmsResponse로 변환 + List responses = result.getContent().stream() + .map(RdbmsResponse::from) + .collect(Collectors.toList()); + + Page responsePage = new PageImpl<>( + responses, + result.getPageable(), + result.getTotalElements() + ); + + log.info("[RdbmsController] listRdbmsInstances - success provider={}, count={}", + provider, result.getTotalElements()); + return ResponseEntity.ok(ApiResponse.success(responsePage, "RDBMS 인스턴스 목록 조회에 성공했습니다.")); + } + + /** + * 특정 RDBMS 인스턴스를 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return RdbmsResponse 또는 404 + */ + @GetMapping("/{instanceId}") + @AuditRequired( + action = "GET_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 상세 조회", + severity = AuditSeverity.LOW, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 상세 조회", description = "특정 RDBMS 인스턴스의 상세 정보를 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> getRdbmsInstance( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId) { + + log.info("[RdbmsController] getRdbmsInstance - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + Optional result = rdbmsUseCaseService.getRdbmsInstance(provider, accountScope, instanceId); + + if (result.isPresent()) { + RdbmsResponse response = RdbmsResponse.from(result.get()); + log.info("[RdbmsController] getRdbmsInstance - success provider={}, instanceId={}", + provider, instanceId); + return ResponseEntity.ok(ApiResponse.success(response, "RDBMS 인스턴스 조회에 성공했습니다.")); + } else { + log.info("[RdbmsController] getRdbmsInstance - not found provider={}, instanceId={}", + provider, instanceId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + } + } + + // ==================== 인스턴스 생성 ==================== + + /** + * 새로운 RDBMS 인스턴스를 생성합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param request 생성 요청 정보 + * @return 생성된 RdbmsResponse + */ + @PostMapping + @AuditRequired( + action = "CREATE_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 생성", + severity = AuditSeverity.HIGH, + includeRequestData = true, + includeResponseData = true + ) + @Operation(summary = "RDBMS 인스턴스 생성", description = "새로운 RDBMS 인스턴스를 생성합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> createRdbms( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "생성 요청 정보") + @Valid @RequestBody RdbmsCreateRequest request) { + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + log.info("[RdbmsController] createRdbms - provider={}, accountScope={}, instanceName={}, engine={}", + provider, accountScope, request.getInstanceName(), request.getEngine()); + + CloudResource resource = rdbmsUseCaseService.createRdbms(request); + RdbmsResponse response = RdbmsResponse.from(resource); + + log.info("[RdbmsController] createRdbms - success provider={}, instanceId={}, resourceId={}", + provider, resource.getResourceId(), resource.getId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response, "RDBMS 인스턴스 생성에 성공했습니다.")); + } + + // ==================== 인스턴스 수정 ==================== + + /** + * RDBMS 인스턴스 정보를 수정합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @param request 수정 요청 정보 + * @return 수정된 RdbmsResponse + */ + @PutMapping("/{instanceId}") + @AuditRequired( + action = "UPDATE_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 수정", + severity = AuditSeverity.HIGH, + includeRequestData = true, + includeResponseData = true + ) + @Operation(summary = "RDBMS 인스턴스 수정", description = "RDBMS 인스턴스의 정보를 수정합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> updateRdbms( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId, + @Parameter(description = "수정 요청 정보") + @Valid @RequestBody RdbmsUpdateRequest request) { + + log.info("[RdbmsController] updateRdbms - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + request.setInstanceId(instanceId); + + CloudResource resource = rdbmsUseCaseService.updateRdbms(request); + RdbmsResponse response = RdbmsResponse.from(resource); + + log.info("[RdbmsController] updateRdbms - success provider={}, instanceId={}", provider, instanceId); + return ResponseEntity.ok(ApiResponse.success(response, "RDBMS 인스턴스 수정에 성공했습니다.")); + } + + // ==================== 인스턴스 삭제 ==================== + + /** + * RDBMS 인스턴스를 삭제합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @param request 삭제 요청 정보 + * @return 성공 응답 + */ + @DeleteMapping("/{instanceId}") + @AuditRequired( + action = "DELETE_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 삭제", + severity = AuditSeverity.CRITICAL, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 삭제", description = "RDBMS 인스턴스를 삭제합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> deleteRdbms( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId, + @Parameter(description = "삭제 요청 정보") + @RequestBody(required = false) RdbmsDeleteRequest request) { + + log.info("[RdbmsController] deleteRdbms - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + // 요청이 없으면 기본 삭제 요청 생성 + if (request == null) { + request = RdbmsDeleteRequest.basic(instanceId); + } + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + request.setInstanceId(instanceId); + + rdbmsUseCaseService.deleteRdbms(request); + log.info("[RdbmsController] deleteRdbms - success provider={}, instanceId={}", provider, instanceId); + return ResponseEntity.noContent().build(); + } + + // ==================== 인스턴스 생명주기 관리 ==================== + + /** + * RDBMS 인스턴스를 시작합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return 성공 응답 + */ + @PostMapping("/{instanceId}/start") + @AuditRequired( + action = "START_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 시작", + severity = AuditSeverity.MEDIUM, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 시작", description = "중지된 RDBMS 인스턴스를 시작합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "시작 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> startInstance( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId) { + + log.info("[RdbmsController] startInstance - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + rdbmsUseCaseService.startInstance(provider, accountScope, instanceId); + log.info("[RdbmsController] startInstance - success provider={}, instanceId={}", provider, instanceId); + return ResponseEntity.ok(ApiResponse.success(null, "RDBMS 인스턴스 시작에 성공했습니다.")); + } + + /** + * RDBMS 인스턴스를 중지합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return 성공 응답 + */ + @PostMapping("/{instanceId}/stop") + @AuditRequired( + action = "STOP_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 중지", + severity = AuditSeverity.MEDIUM, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 중지", description = "실행 중인 RDBMS 인스턴스를 중지합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "중지 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> stopInstance( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId) { + + log.info("[RdbmsController] stopInstance - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + rdbmsUseCaseService.stopInstance(provider, accountScope, instanceId); + log.info("[RdbmsController] stopInstance - success provider={}, instanceId={}", provider, instanceId); + return ResponseEntity.ok(ApiResponse.success(null, "RDBMS 인스턴스 중지에 성공했습니다.")); + } + + /** + * RDBMS 인스턴스를 재시작합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return 성공 응답 + */ + @PostMapping("/{instanceId}/reboot") + @AuditRequired( + action = "REBOOT_RDBMS_INSTANCE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 재시작", + severity = AuditSeverity.MEDIUM, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 재시작", description = "실행 중인 RDBMS 인스턴스를 재시작합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재시작 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> rebootInstance( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId) { + + log.info("[RdbmsController] rebootInstance - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + rdbmsUseCaseService.rebootInstance(provider, accountScope, instanceId); + log.info("[RdbmsController] rebootInstance - success provider={}, instanceId={}", provider, instanceId); + return ResponseEntity.ok(ApiResponse.success(null, "RDBMS 인스턴스 재시작에 성공했습니다.")); + } + + // ==================== 상태 확인 ==================== + + /** + * RDBMS 인스턴스의 현재 상태를 확인합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return 인스턴스 상태 + */ + @GetMapping("/{instanceId}/status") + @AuditRequired( + action = "GET_RDBMS_INSTANCE_STATUS", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "RDBMS 인스턴스 상태 확인", + severity = AuditSeverity.LOW, + includeRequestData = true, + includeResponseData = false + ) + @Operation(summary = "RDBMS 인스턴스 상태 확인", description = "RDBMS 인스턴스의 현재 상태를 확인합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "인스턴스 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> getInstanceStatus( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + @Parameter(description = "계정 스코프", required = true, example = "123456789012") + @PathVariable String accountScope, + @Parameter(description = "인스턴스 ID", required = true) + @PathVariable String instanceId) { + + log.info("[RdbmsController] getInstanceStatus - provider={}, accountScope={}, instanceId={}", + provider, accountScope, instanceId); + + String status = rdbmsUseCaseService.getInstanceStatus(provider, accountScope, instanceId); + log.info("[RdbmsController] getInstanceStatus - success provider={}, instanceId={}, status={}", + provider, instanceId, status); + return ResponseEntity.ok(ApiResponse.success(status, "RDBMS 인스턴스 상태 조회에 성공했습니다.")); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsCreateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsCreateRequest.java new file mode 100644 index 00000000..618a980f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsCreateRequest.java @@ -0,0 +1,173 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.jackson.Jacksonized; + +import java.util.Map; + +/** + * RDBMS 생성 요청 DTO (CSP 중립적) + * + * Controller에서 받는 요청 객체로, CSP 중립적인 필드만 포함합니다. + * CSP 특화 설정은 providerSpecificConfig에 포함됩니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RdbmsCreateRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private CloudProvider.ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 리전 (필수) + * 예: us-east-1, ap-northeast-2 + */ + @NotBlank(message = "리전은 필수입니다") + private String region; + + /** + * 인스턴스 이름 (CSP 중립적) + * - AWS: dbInstanceIdentifier + * - Azure: serverName + * - GCP: instanceId + */ + @NotBlank(message = "인스턴스 이름은 필수입니다") + @Size(min = 1, max = 100, message = "인스턴스 이름은 1-100자 사이여야 합니다") + private String instanceName; + + /** + * 데이터베이스 엔진 (필수) + * 예: mysql, postgresql, mariadb, oracle, sqlserver + */ + @NotBlank(message = "데이터베이스 엔진은 필수입니다") + private String engine; + + /** + * 엔진 버전 + * CSP별 버전 형식이 다를 수 있음 + */ + private String engineVersion; + + /** + * 인스턴스 크기 (CSP 중립적) + * - AWS: db.t3.micro, db.t3.small 등 + * - Azure: GP_Gen5_2, BC_Gen5_2 등 + * - GCP: db-custom-2-7680 등 + */ + @NotBlank(message = "인스턴스 크기는 필수입니다") + private String instanceSize; + + /** + * 할당된 스토리지 크기 (GB) + * 최소 20GB + */ + @Min(value = 20, message = "스토리지 크기는 최소 20GB 이상이어야 합니다") + private Integer allocatedStorage; + + /** + * 관리자 사용자명 (필수) + */ + @NotBlank(message = "관리자 사용자명은 필수입니다") + private String masterUsername; + + /** + * 관리자 패스워드 (필수) + * 최소 8자 이상 + */ + @NotBlank(message = "관리자 패스워드는 필수입니다") + @Size(min = 8, message = "패스워드는 최소 8자 이상이어야 합니다") + private String masterPassword; + + /** + * 초기 데이터베이스 이름 + */ + private String dbName; + + /** + * 네트워크 보안 그룹 ID (CSP 중립적) + * - AWS: securityGroupId + * - Azure: firewallRule + * - GCP: authorizedNetworks + */ + private String networkSecurityId; + + /** + * 서브넷 식별자 (선택적, 일부 CSP만 사용) + */ + private String subnetId; + + /** + * 데이터베이스 포트 + * 기본값: engine별로 다름 (MySQL: 3306, PostgreSQL: 5432 등) + */ + private Integer port; + + /** + * 가용 영역 (CSP 중립적) + * - AWS: availabilityZone (예: us-east-1a) + * - Azure: zone + * - GCP: zone + */ + private String zone; + + /** + * 고가용성 설정 + * - AWS: multiAz + * - Azure: highAvailability + * - GCP: highAvailability + */ + @Builder.Default + private Boolean highAvailability = false; + + /** + * 공개 접근 허용 여부 + */ + @Builder.Default + private Boolean publiclyAccessible = false; + + /** + * 태그 + */ + private Map tags; + + /** + * CSP별 특화 설정 + * + * AWS 예시: + * - subnetGroupName: "my-db-subnet-group" + * - parameterGroupName: "default.mysql8.0" + * + * Azure 예시: + * - resourceGroupName: "my-resource-group" + * - sku: { "name": "GP_Gen5_2", "tier": "GeneralPurpose", "capacity": 2 } + * + * GCP 예시: + * - databaseFlags: [{"name": "max_connections", "value": "100"}] + * - backupConfiguration: {"enabled": true, "startTime": "23:00"} + */ + private Map providerSpecificConfig; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsDeleteRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsDeleteRequest.java new file mode 100644 index 00000000..77445c6e --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsDeleteRequest.java @@ -0,0 +1,108 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.jackson.Jacksonized; + +import java.util.Map; + +/** + * RDBMS 삭제 요청 DTO (CSP 중립적) + * + * RDBMS 인스턴스의 삭제를 위한 요청 객체입니다. + * CSP 중립적인 필드를 사용하며, CSP 특화 삭제 옵션은 providerSpecificConfig에 포함됩니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RdbmsDeleteRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private CloudProvider.ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 삭제할 인스턴스 ID + */ + private String instanceId; + + /** + * 최종 스냅샷 건너뛰기 + * true인 경우 최종 스냅샷을 생성하지 않고 삭제합니다. + * 기본값: false (스냅샷 생성) + */ + @Builder.Default + private Boolean skipSnapshot = false; + + /** + * 최종 스냅샷 이름 (선택적) + * skipSnapshot이 false인 경우 사용됩니다. + */ + private String snapshotName; + + /** + * 자동 백업 삭제 여부 + * true인 경우 자동 백업도 함께 삭제합니다. + * 기본값: false (자동 백업 유지) + */ + @Builder.Default + private Boolean deleteAutomatedBackups = false; + + /** + * 삭제 이유 (감사 로그용) + */ + private String reason; + + /** + * CSP별 특화 삭제 옵션 + */ + private Map providerSpecificConfig; + + /** + * 기본 삭제 요청 생성 + * 최종 스냅샷을 생성하고 자동 백업은 유지합니다. + * + * @param instanceId 삭제할 인스턴스 ID + * @return 기본 삭제 요청 + */ + public static RdbmsDeleteRequest basic(String instanceId) { + return RdbmsDeleteRequest.builder() + .instanceId(instanceId) + .skipSnapshot(false) + .deleteAutomatedBackups(false) + .build(); + } + + /** + * 강제 삭제 요청 생성 + * 최종 스냅샷을 건너뛰고 자동 백업도 삭제합니다. + * + * @param instanceId 삭제할 인스턴스 ID + * @return 강제 삭제 요청 + */ + public static RdbmsDeleteRequest force(String instanceId) { + return RdbmsDeleteRequest.builder() + .instanceId(instanceId) + .skipSnapshot(true) + .deleteAutomatedBackups(true) + .build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsQueryRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsQueryRequest.java new file mode 100644 index 00000000..771ca0df --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsQueryRequest.java @@ -0,0 +1,96 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.jackson.Jacksonized; + +import java.util.Map; +import java.util.Set; + +/** + * RDBMS 조회 요청 DTO (CSP 중립적) + * + * RDBMS 인스턴스 목록 조회를 위한 요청 객체입니다. + * CSP 중립적인 필터링 조건을 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RdbmsQueryRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private CloudProvider.ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 조회할 리전 목록 + * null이면 모든 리전에서 조회 (일부 CSP만 지원) + */ + private Set regions; + + /** + * 인스턴스 이름으로 필터링 (CSP 중립적) + */ + private String instanceName; + + /** + * 엔진 타입으로 필터링 + * 예: mysql, postgresql, mariadb, oracle, sqlserver + */ + private String engine; + + /** + * 인스턴스 크기로 필터링 (CSP 중립적) + */ + private String instanceSize; + + /** + * 상태로 필터링 + * CSP별 상태 값이 다를 수 있음 + * - AWS: available, creating, deleting, modifying 등 + * - Azure: Ready, Creating, Deleting 등 + * - GCP: RUNNABLE, CREATING, DELETING 등 + */ + private String status; + + /** + * 태그로 필터링 + * 키-값 쌍으로 필터링 + */ + private Map tags; + + /** + * 페이지 번호 (0부터 시작) + */ + @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다") + @Builder.Default + private int page = 0; + + /** + * 페이지 크기 + */ + @Min(value = 1, message = "페이지 크기는 최소 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 최대 100 이하여야 합니다") + @Builder.Default + private int size = 20; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsResponse.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsResponse.java new file mode 100644 index 00000000..015e5ac0 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsResponse.java @@ -0,0 +1,197 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.extern.jackson.Jacksonized; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * RDBMS 응답 DTO (CSP 중립적) + * + * RDBMS 인스턴스 정보를 반환하는 응답 객체입니다. + * CloudResource 엔티티에서 변환됩니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RdbmsResponse { + + private Long id; + private String resourceId; + private String resourceName; + private CloudProvider.ProviderType providerType; + private String region; + private String engine; + private String engineVersion; + private String instanceSize; // CSP 중립적: AWS(db.t3.micro), Azure(GP_Gen5_2), GCP(db-custom-2-7680) + private Long storageGb; + private String status; + private String lifecycleState; + private String endpoint; // 데이터베이스 엔드포인트 주소 + private Integer port; // 데이터베이스 포트 + private Boolean highAvailability; // 고가용성 설정 여부 + private Boolean publiclyAccessible; // 공개 접근 허용 여부 + private Map tags; + private LocalDateTime createdAt; + private LocalDateTime lastSync; + + /** + * CloudResource → RdbmsResponse 변환 + * + * @param resource CloudResource 엔티티 + * @return RdbmsResponse DTO + */ + public static RdbmsResponse from(CloudResource resource) { + if (resource == null) { + return null; + } + + try { + // configuration JSON에서 추가 정보 추출 + Map config = parseConfiguration(resource.getConfiguration()); + + RdbmsResponse.RdbmsResponseBuilder builder = RdbmsResponse.builder() + .id(resource.getId()) + .resourceId(resource.getResourceId()) + .resourceName(resource.getResourceName()) + .providerType(resource.getProvider() != null + ? resource.getProvider().getProviderType() + : null) + .region(resource.getRegion() != null + ? resource.getRegion().getRegionKey() + : null) + .instanceSize(resource.getInstanceSize()) + .storageGb(resource.getStorageGb()) + .status(resource.getStatus() != null + ? resource.getStatus().name() + : null) + .lifecycleState(resource.getLifecycleState() != null + ? resource.getLifecycleState().name() + : null) + .tags(parseTags(resource.getTags())) + .createdAt(resource.getCreatedAt()) + .lastSync(resource.getLastSync()); + + // configuration에서 엔진 정보 추출 + if (config != null) { + builder.engine((String) config.get("engine")); + builder.engineVersion((String) config.get("engineVersion")); + + // endpoint 정보 추출 + Object endpointObj = config.get("endpoint"); + if (endpointObj instanceof Map) { + @SuppressWarnings("unchecked") + Map endpoint = (Map) endpointObj; + builder.endpoint((String) endpoint.get("address")); + Object portObj = endpoint.get("port"); + if (portObj instanceof Number) { + builder.port(((Number) portObj).intValue()); + } + } else if (resource.getIpAddress() != null) { + // endpoint가 없으면 ipAddress 사용 + builder.endpoint(resource.getIpAddress()); + } + + // highAvailability (multiAz) + Object multiAz = config.get("multiAz"); + if (multiAz instanceof Boolean) { + builder.highAvailability((Boolean) multiAz); + } + + // publiclyAccessible + Object publiclyAccessible = config.get("publiclyAccessible"); + if (publiclyAccessible instanceof Boolean) { + builder.publiclyAccessible((Boolean) publiclyAccessible); + } + } + + return builder.build(); + + } catch (Exception e) { + log.error("[RdbmsResponse] Failed to convert CloudResource to RdbmsResponse: {}", + resource.getResourceId(), e); + // 기본 정보만 반환 + return RdbmsResponse.builder() + .id(resource.getId()) + .resourceId(resource.getResourceId()) + .resourceName(resource.getResourceName()) + .providerType(resource.getProvider() != null + ? resource.getProvider().getProviderType() + : null) + .region(resource.getRegion() != null + ? resource.getRegion().getRegionKey() + : null) + .instanceSize(resource.getInstanceSize()) + .storageGb(resource.getStorageGb()) + .status(resource.getStatus() != null + ? resource.getStatus().name() + : null) + .lifecycleState(resource.getLifecycleState() != null + ? resource.getLifecycleState().name() + : null) + .tags(parseTags(resource.getTags())) + .createdAt(resource.getCreatedAt()) + .lastSync(resource.getLastSync()) + .build(); + } + } + + /** + * CloudResource 목록을 RdbmsResponse 목록으로 변환 + * + * @param resources CloudResource 목록 + * @return RdbmsResponse 목록 + */ + public static List fromList(List resources) { + if (resources == null) { + return List.of(); + } + return resources.stream() + .map(RdbmsResponse::from) + .collect(Collectors.toList()); + } + + /** + * configuration JSON 문자열을 Map으로 파싱 + */ + private static Map parseConfiguration(String configurationJson) { + if (configurationJson == null || configurationJson.isEmpty() || configurationJson.equals("{}")) { + return new HashMap<>(); + } + + try { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(configurationJson, Map.class); + } catch (JsonProcessingException e) { + log.warn("[RdbmsResponse] Failed to parse configuration JSON: {}", configurationJson, e); + return new HashMap<>(); + } + } + + /** + * 태그를 Map으로 변환 + */ + private static Map parseTags(Map tags) { + return tags != null ? tags : Map.of(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsUpdateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsUpdateRequest.java new file mode 100644 index 00000000..fbadc4a2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/RdbmsUpdateRequest.java @@ -0,0 +1,91 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.jackson.Jacksonized; + +import java.util.Map; + +/** + * RDBMS 수정 요청 DTO (CSP 중립적) + * + * RDBMS 인스턴스의 수정을 위한 요청 객체입니다. + * CSP 중립적인 필드를 사용하며, CSP 특화 설정은 providerSpecificConfig에 포함됩니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RdbmsUpdateRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private CloudProvider.ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 수정할 인스턴스 ID + */ + private String instanceId; + + /** + * 인스턴스 크기 변경 (CSP 중립적) + * - AWS: db.t3.micro → db.t3.small + * - Azure: GP_Gen5_2 → GP_Gen5_4 + * - GCP: db-custom-2-7680 → db-custom-4-15360 + */ + private String instanceSize; + + /** + * 스토리지 크기 변경 (GB) + * 최소 20GB, 증가만 가능 (일부 CSP는 감소 불가) + */ + @Min(value = 20, message = "스토리지 크기는 최소 20GB 이상이어야 합니다") + private Integer allocatedStorage; + + /** + * 관리자 패스워드 변경 (선택적) + * 최소 8자 이상 + */ + @Size(min = 8, message = "패스워드는 최소 8자 이상이어야 합니다") + private String masterPassword; + + /** + * 즉시 적용 여부 + * 일부 CSP만 지원 (AWS는 지원, 일부 CSP는 다음 유지보수 창에 적용) + */ + private Boolean applyImmediately; + + /** + * 추가할 태그 + */ + private Map tagsToAdd; + + /** + * 제거할 태그 + */ + private Map tagsToRemove; + + /** + * CSP별 특화 설정 + */ + private Map providerSpecificConfig; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java b/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java index 52139783..873df9d2 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java @@ -48,7 +48,11 @@ public enum CloudErrorCode implements BaseErrorCode { // 리소스 생성/동기화 관련 (4040-4049) RESOURCE_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 4040, "클라우드 리소스 생성 후 DB 저장에 실패하여 롤백되었습니다."), - RESOURCE_SYNC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 4041, "클라우드 리소스 동기화에 실패했습니다."); + RESOURCE_SYNC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 4041, "클라우드 리소스 동기화에 실패했습니다."), + + // 암호화/복호화 관련 (4050-4059) + ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 4050, "암호화에 실패했습니다."), + DECRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 4051, "복호화에 실패했습니다."); private final HttpStatus httpStatus; private final int codeNumber; diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsCreateCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsCreateCommand.java new file mode 100644 index 00000000..bcee546b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsCreateCommand.java @@ -0,0 +1,50 @@ +package com.agenticcp.core.domain.cloud.port.model.rdbms; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * RDBMS 생성 도메인 커맨드 (CSP 중립적) + * + * UseCase Service에서 Adapter로 전달되는 내부 명령 모델입니다. + * CSP 중립적인 필드를 사용하며, 각 CSP Adapter의 Mapper에서 CSP 특화 요청으로 변환합니다. + * + * 필드 매핑 예시: + * - instanceName: AWS(dbInstanceIdentifier), Azure(serverName), GCP(instanceId) + * - instanceSize: AWS(db.t3.micro), Azure(GP_Gen5_2), GCP(db-custom-2-7680) + * - networkSecurityId: AWS(securityGroupId), Azure(firewallRule), GCP(authorizedNetworks) + * - zone: AWS(availabilityZone), Azure(zone), GCP(zone) + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record RdbmsCreateCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String serviceKey, // "RDS", "AZURE_DATABASE", "CLOUD_SQL" + String resourceType, // "DATABASE" + String instanceName, // CSP 중립적: AWS(dbInstanceIdentifier), Azure(serverName), GCP(instanceId) + String engine, // "mysql", "postgresql", "mariadb", "oracle", "sqlserver" + String engineVersion, // CSP별 버전 형식 다를 수 있음 + String instanceSize, // CSP 중립적: AWS(db.t3.micro), Azure(GP_Gen5_2), GCP(db-custom-2-7680) + Integer allocatedStorage, // GB (모든 CSP 공통) + String adminUsername, // 관리자 사용자명 + String adminPassword, // 암호화 필요 + String dbName, // 초기 데이터베이스 이름 + String networkSecurityId, // CSP 중립적: AWS(securityGroupId), Azure(firewallRule), GCP(authorizedNetworks) + String subnetId, // 서브넷 식별자 (선택적, 일부 CSP만 사용) + Integer port, // 데이터베이스 포트 (기본값: engine별로 다름) + String zone, // CSP 중립적: AWS(availabilityZone), Azure(zone), GCP(zone) + Boolean highAvailability, // 고가용성 설정: AWS(multiAz), Azure(highAvailability), GCP(highAvailability) + Boolean publiclyAccessible, // 공개 접근 허용 여부 + Map tags, + String tenantKey, + Map providerSpecificConfig, // CSP별 특화 설정 + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsDeleteCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsDeleteCommand.java new file mode 100644 index 00000000..34ff3dd9 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsDeleteCommand.java @@ -0,0 +1,31 @@ +package com.agenticcp.core.domain.cloud.port.model.rdbms; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * RDBMS 삭제 도메인 커맨드 (CSP 중립적) + * + * UseCase Service에서 Adapter로 전달되는 내부 명령 모델입니다. + * CSP 중립적인 필드를 사용하며, 각 CSP Adapter의 Mapper에서 CSP 특화 요청으로 변환합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record RdbmsDeleteCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, + Boolean skipSnapshot, // 최종 스냅샷 건너뛰기 (AWS 특화, 다른 CSP는 providerSpecificConfig로) + String snapshotName, // 최종 스냅샷 이름 (선택적) + Boolean deleteAutomatedBackups, // 자동 백업 삭제 여부 + String tenantKey, + Map providerSpecificConfig, // CSP별 특화 삭제 옵션 + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsQuery.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsQuery.java new file mode 100644 index 00000000..384548ea --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsQuery.java @@ -0,0 +1,117 @@ +package com.agenticcp.core.domain.cloud.port.model.rdbms; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import lombok.Builder; +import lombok.Value; + +import java.util.Map; +import java.util.Set; + +/** + * RDBMS 조회 쿼리 (CSP 중립적) + * + * RDBMS 인스턴스 목록 조회 시 사용하는 조회 조건을 정의하는 모델입니다. + * CSP 중립적인 필드를 사용하며, 각 CSP Adapter에서 CSP별 필터링 로직으로 변환합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Value +@Builder +public class RdbmsQuery { + + /** + * 프로바이더 타입 + */ + CloudProvider.ProviderType providerType; + + /** + * 계정 범위 (AccountId, SubscriptionId, ProjectId) + */ + String accountScope; + + /** + * 조회할 리전 목록 (null이면 모든 리전) + */ + Set regions; + + /** + * 인스턴스 이름으로 필터링 (CSP 중립적) + */ + String instanceName; + + /** + * 엔진 타입으로 필터링 (mysql, postgresql, mariadb, oracle, sqlserver) + */ + String engine; + + /** + * 인스턴스 크기로 필터링 (CSP 중립적) + */ + String instanceSize; + + /** + * 상태로 필터링 (CSP별 상태 값 다를 수 있음) + */ + String status; + + /** + * 태그로 필터링 (키-값 쌍) + */ + Map tagsEquals; + + /** + * 페이징 - 페이지 번호 (0부터 시작) + */ + @Builder.Default + int page = 0; + + /** + * 페이징 - 페이지 크기 + */ + @Builder.Default + int size = 20; + + /** + * 모든 RDBMS 인스턴스 조회를 위한 기본 쿼리 생성 + */ + public static RdbmsQuery all(CloudProvider.ProviderType providerType, String accountScope) { + return RdbmsQuery.builder() + .providerType(providerType) + .accountScope(accountScope) + .build(); + } + + /** + * 특정 인스턴스 이름으로 조회하는 쿼리 생성 + */ + public static RdbmsQuery byInstanceName(CloudProvider.ProviderType providerType, String accountScope, String instanceName) { + return RdbmsQuery.builder() + .providerType(providerType) + .accountScope(accountScope) + .instanceName(instanceName) + .build(); + } + + /** + * 특정 엔진 타입으로 조회하는 쿼리 생성 + */ + public static RdbmsQuery byEngine(CloudProvider.ProviderType providerType, String accountScope, String engine) { + return RdbmsQuery.builder() + .providerType(providerType) + .accountScope(accountScope) + .engine(engine) + .build(); + } + + /** + * 특정 상태로 조회하는 쿼리 생성 + */ + public static RdbmsQuery byStatus(CloudProvider.ProviderType providerType, String accountScope, String status) { + return RdbmsQuery.builder() + .providerType(providerType) + .accountScope(accountScope) + .status(status) + .build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsUpdateCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsUpdateCommand.java new file mode 100644 index 00000000..ef34596e --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/rdbms/RdbmsUpdateCommand.java @@ -0,0 +1,33 @@ +package com.agenticcp.core.domain.cloud.port.model.rdbms; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * RDBMS 수정 도메인 커맨드 (CSP 중립적) + * + * UseCase Service에서 Adapter로 전달되는 내부 명령 모델입니다. + * CSP 중립적인 필드를 사용하며, 각 CSP Adapter의 Mapper에서 CSP 특화 요청으로 변환합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record RdbmsUpdateCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, // RDBMS 인스턴스 ID (CSP별 형식 다를 수 있음) + String instanceSize, // CSP 중립적: 인스턴스 크기 변경 + Integer allocatedStorage, // 스토리지 크기 변경 (GB) + String adminPassword, // 선택적: 관리자 패스워드 변경 + Boolean applyImmediately, // 즉시 적용 여부 (일부 CSP만 지원) + Map tags, + String tenantKey, + Map providerSpecificConfig, // CSP별 특화 설정 + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsDiscoveryPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsDiscoveryPort.java new file mode 100644 index 00000000..edd445af --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsDiscoveryPort.java @@ -0,0 +1,48 @@ +package com.agenticcp.core.domain.cloud.port.outbound.rdbms; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsQuery; +import org.springframework.data.domain.Page; + +import java.util.Optional; + +/** + * RDBMS 조회/탐색 책임 포트 + * + * RDBMS 인스턴스의 조회 기능을 제공합니다. + * 모든 Discovery 작업에서 세션 자격증명을 전달받아 사용합니다. + * JIT 세션 관리 패턴을 따릅니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface RdbmsDiscoveryPort { + + /** + * RDBMS 인스턴스 목록을 조회합니다. + * + * @param query 조회 조건 (페이징, 필터링 포함) + * @param session 세션 자격증명 + * @return CloudResource 페이지 (빈 페이지 가능, null 반환 금지) + */ + Page listRdbmsInstances(RdbmsQuery query, CloudSessionCredential session); + + /** + * 특정 RDBMS 인스턴스를 조회합니다. + * + * @param instanceId 인스턴스 ID + * @param session 세션 자격증명 + * @return CloudResource (존재하지 않으면 Optional.empty()) + */ + Optional getRdbmsInstance(String instanceId, CloudSessionCredential session); + + /** + * RDBMS 인스턴스의 현재 상태를 확인합니다. + * + * @param instanceId 인스턴스 ID + * @param session 세션 자격증명 + * @return 인스턴스 상태 + */ + String getInstanceStatus(String instanceId, CloudSessionCredential session); +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsLifecyclePort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsLifecyclePort.java new file mode 100644 index 00000000..3f760460 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsLifecyclePort.java @@ -0,0 +1,30 @@ +package com.agenticcp.core.domain.cloud.port.outbound.rdbms; + +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.outbound.ResourceLifecyclePort; + +/** + * RDBMS 생명주기 관리 책임 포트 + * + * RDBMS 인스턴스의 시작, 중지, 재시작 기능을 제공합니다. + * ResourceLifecyclePort를 확장하여 일반적인 리소스 생명주기 관리 기능을 상속받고, + * RDBMS 특화 기능인 reboot를 추가로 제공합니다. + * + * 모든 Lifecycle 작업은 세션 자격증명을 명시적으로 전달받습니다. + * + * @author AgenticCP Team + * @version 2.0.0 + */ +public interface RdbmsLifecyclePort extends ResourceLifecyclePort { + + /** + * RDBMS 인스턴스 재시작 + * + *

일반적인 리소스의 경우 stop 후 start를 순차적으로 호출하지만, + * RDBMS의 경우 reboot는 OS 레벨 재부팅으로 더 빠르고 안전한 재시작을 제공합니다.

+ * + * @param instanceId 인스턴스 ID + * @param session 세션 자격증명 + */ + void rebootInstance(String instanceId, CloudSessionCredential session); +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsManagementPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsManagementPort.java new file mode 100644 index 00000000..b72b58d3 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsManagementPort.java @@ -0,0 +1,41 @@ +package com.agenticcp.core.domain.cloud.port.outbound.rdbms; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand; + +/** + * RDBMS 관리 포트 - RDBMS 인스턴스의 CRUD 작업을 정의하는 계약 + * + * RDBMS 인스턴스의 생성, 수정, 삭제 기능을 제공합니다. + * 모든 Management 작업은 세션 자격증명을 명시적으로 전달받습니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface RdbmsManagementPort { + + /** + * RDBMS 인스턴스 생성 + * + * @param command 생성 명령 + * @return 생성된 RDBMS 인스턴스 + */ + CloudResource createRdbms(RdbmsCreateCommand command); + + /** + * RDBMS 인스턴스 수정 + * + * @param command 수정 명령 + * @return 수정된 RDBMS 인스턴스 + */ + CloudResource updateRdbms(RdbmsUpdateCommand command); + + /** + * RDBMS 인스턴스 삭제 + * + * @param command 삭제 명령 + */ + void deleteRdbms(RdbmsDeleteCommand command); +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsPortRouter.java b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsPortRouter.java new file mode 100644 index 00000000..4e0bd3cb --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsPortRouter.java @@ -0,0 +1,83 @@ +package com.agenticcp.core.domain.cloud.service.rdbms; + +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * RDBMS 포트 라우터 + * + * 다양한 클라우드 제공업체의 RDBMS 관리 포트를 관리하고, + * 요청된 제공업체 타입에 따라 적절한 포트를 선택하여 반환합니다. + * + * 헥사고날 아키텍처의 라우터 계층에 해당하며, 제공업체별 어댑터를 동적으로 선택합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +public class RdbmsPortRouter { + + private final Map managementPorts; + private final Map discoveryPorts; + private final Map lifecyclePorts; + + public RdbmsPortRouter( + List managementPortList, + List discoveryPortList, + List lifecyclePortList + ) { + this.managementPorts = buildPortMap(managementPortList); + this.discoveryPorts = buildPortMap(discoveryPortList); + this.lifecyclePorts = buildPortMap(lifecyclePortList); + + log.info("[RdbmsPortRouter] RDBMS router initialized: management={}, discovery={}, lifecycle={}", + managementPorts.keySet(), discoveryPorts.keySet(), lifecyclePorts.keySet()); + } + + private Map buildPortMap(List ports) { + Map map = new EnumMap<>(ProviderType.class); + ports.stream() + .filter(port -> port instanceof ProviderScoped) + .forEach(port -> { + ProviderType providerType = ((ProviderScoped) port).getProviderType(); + map.put(providerType, port); + log.debug("[RdbmsPortRouter] Registered {} port for provider {}", + port.getClass().getSimpleName(), providerType); + }); + return map; + } + + public RdbmsManagementPort management(ProviderType providerType) { + RdbmsManagementPort port = managementPorts.get(providerType); + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType); + } + return port; + } + + public RdbmsDiscoveryPort discovery(ProviderType providerType) { + RdbmsDiscoveryPort port = discoveryPorts.get(providerType); + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType); + } + return port; + } + + public RdbmsLifecyclePort lifecycle(ProviderType providerType) { + RdbmsLifecyclePort port = lifecyclePorts.get(providerType); + if (port == null) { + throw new IllegalArgumentException("생명주기 관리를 지원하지 않는 프로바이더입니다: " + providerType); + } + return port; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseService.java new file mode 100644 index 00000000..6e43a6c2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseService.java @@ -0,0 +1,631 @@ +package com.agenticcp.core.domain.cloud.service.rdbms; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.crypto.EncryptionService; +import com.agenticcp.core.common.logging.LogMaskingUtils; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsQueryRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsQuery; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand; +import com.agenticcp.core.domain.cloud.port.model.ResourceIdentity; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * RDBMS 유스케이스 서비스 + * + * 헥사고날 아키텍처의 애플리케이션 계층에서 RDBMS 인스턴스 관련 비즈니스 로직을 처리합니다. + * 포트 인터페이스를 통해서만 외부 시스템과 통신하며, 트랜잭션을 담당합니다. + * + * JIT 세션 관리 패턴을 따릅니다: + * - 모든 작업에서 Service 레벨에서 세션을 획득하여 Port에 전달 + * - getSession()을 통한 Redis 캐싱 활용 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RdbmsUseCaseService { + + /** + * RDBMS 리소스 타입 + */ + private static final String RESOURCE_TYPE = "DATABASE"; + + private final RdbmsPortRouter portRouter; + private final CapabilityGuard capabilityGuard; + private final AccountCredentialManagementPort credentialPort; + private final CloudResourceManagementHelper resourceHelper; + private final EncryptionService encryptionService; + + /** + * 세션 자격증명을 획득합니다. + * JIT 세션 관리 패턴을 따릅니다. + * - Redis 캐시 확인 → 없으면 발급 → 캐싱 → 반환 + * + * @param providerType 프로바이더 타입 + * @param accountScope 계정 스코프 + * @return CloudSessionCredential 세션 자격증명 + */ + private CloudSessionCredential getSession(ProviderType providerType, String accountScope) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + return credentialPort.getSession(tenantKey, accountScope, providerType); + } + + // ==================== 인스턴스 조회 ==================== + + /** + * RDBMS 인스턴스 목록을 조회합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param request 조회 요청 + * @return CloudResource 페이지 + */ + @Transactional(readOnly = true) + public Page listRdbmsInstances(ProviderType providerType, String accountScope, RdbmsQueryRequest request) { + log.debug("RDBMS 인스턴스 목록 조회 시작: provider={}, accountScope={}, request={}", + providerType, accountScope, request); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Query 변환 + RdbmsQuery query = toQuery(providerType, accountScope, request); + + Page result = portRouter.discovery(providerType).listRdbmsInstances(query, session); + + log.info("RDBMS 인스턴스 목록 조회 완료: provider={}, totalElements={}", providerType, result.getTotalElements()); + return result; + } + + /** + * 특정 RDBMS 인스턴스를 조회합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return CloudResource (존재하지 않으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getRdbmsInstance(ProviderType providerType, String accountScope, String instanceId) { + log.debug("RDBMS 인스턴스 조회 시작: provider={}, accountScope={}, instanceId={}", + providerType, accountScope, instanceId); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + Optional result = portRouter.discovery(providerType).getRdbmsInstance(instanceId, session); + + log.info("RDBMS 인스턴스 조회 완료: provider={}, instanceId={}, found={}", + providerType, instanceId, result.isPresent()); + return result; + } + + // ==================== 인스턴스 생성 ==================== + + /** + * 새로운 RDBMS 인스턴스를 생성합니다. + * CSP에서 인스턴스 생성 후 CloudResource 엔티티를 DB에 저장합니다. + * + * 보상 트랜잭션: DB 저장 실패 시 CSP에 생성된 인스턴스를 삭제하여 + * 데이터 정합성(Ghost Resource 방지)을 보장합니다. + * + * @param request 생성 요청 정보 (providerType, accountScope 포함) + * @return 생성된 CloudResource 엔티티 + * @throws BusinessException DB 저장 실패 및 보상 트랜잭션 실행 시 + */ + @Transactional + public CloudResource createRdbms(RdbmsCreateRequest request) { + ProviderType providerType = request.getProviderType(); + String accountScope = request.getAccountScope(); + + // 로깅용 마스킹된 요청 (원본 데이터는 변경하지 않음) + String maskedRequest = LogMaskingUtils.maskSensitiveData(request.toString()); + log.debug("RDBMS 인스턴스 생성 시작: provider={}, accountScope={}, request={}", + providerType, accountScope, maskedRequest); + + // Capability 검증 (CSP별 실제 서비스 키 사용) + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.CREATE); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Command 변환 + RdbmsCreateCommand command = toCreateCommand(request, session); + + // CSP에서 RDBMS 인스턴스 생성 + CloudResource resource = portRouter.management(providerType).createRdbms(command); + + // DB에 CloudResource 저장 (실패 시 보상 트랜잭션 실행) + CloudResource savedResource; + try { + // ResourceRegistrationRequest 생성 + Map attributes = new HashMap<>(); + attributes.put(ResourceRegistrationRequest.AttributeKeys.INSTANCE_SIZE, request.getInstanceSize()); + attributes.put(ResourceRegistrationRequest.AttributeKeys.STORAGE_GB, request.getAllocatedStorage()); + if (request.getEngine() != null) { + attributes.put("engine", request.getEngine()); + } + if (request.getEngineVersion() != null) { + attributes.put("engineVersion", request.getEngineVersion()); + } + + ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() + .resourceId(resource.getResourceId()) + .resourceName(resource.getResourceName() != null ? resource.getResourceName() : request.getInstanceName()) + .resourceType(CloudResource.ResourceType.DATABASE) + .tags(request.getTags() != null ? request.getTags() : resource.getTags()) + .attributes(attributes) + .build(); + + // DB에 CloudResource 저장 + savedResource = resourceHelper.registerResource( + providerType, + serviceKey, + registrationRequest + ); + + log.info("RDBMS 인스턴스 생성 완료: provider={}, instanceId={}, resourceId={}", + providerType, resource.getResourceId(), savedResource.getId()); + } catch (Exception e) { + log.error("[RdbmsUseCaseService] DB 저장 실패, 보상 트랜잭션 실행: instanceId={}, error={}", + resource.getResourceId(), e.getMessage()); + + // 보상 트랜잭션: CSP에 생성된 인스턴스 삭제 + executeCompensatingTransaction(providerType, accountScope, session, resource.getResourceId()); + + throw new BusinessException( + CloudErrorCode.RESOURCE_CREATION_FAILED, + "RDBMS 인스턴스 생성 후 DB 저장 실패로 인해 롤백되었습니다: " + resource.getResourceId() + ); + } + + return savedResource; + } + + /** + * 보상 트랜잭션: CSP에 생성된 RDBMS 인스턴스를 삭제합니다. + * Ghost Resource 방지를 위해 DB 저장 실패 시 호출됩니다. + * + * @param providerType 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param session 세션 자격증명 + * @param instanceId 삭제할 인스턴스 ID + */ + private void executeCompensatingTransaction( + ProviderType providerType, + String accountScope, + CloudSessionCredential session, + String instanceId + ) { + try { + log.warn("[RdbmsUseCaseService] 보상 트랜잭션 실행: CSP 인스턴스 삭제 시도 - instanceId={}", instanceId); + + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + RdbmsDeleteCommand deleteCommand = RdbmsDeleteCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(null) // region은 Adapter에서 처리 + .providerResourceId(instanceId) + .skipSnapshot(true) // 보상 트랜잭션이므로 스냅샷 생성하지 않음 + .deleteAutomatedBackups(false) + .tenantKey(tenantKey) + .session(session) + .build(); + + portRouter.management(providerType).deleteRdbms(deleteCommand); + log.info("[RdbmsUseCaseService] 보상 트랜잭션 완료: CSP 인스턴스 삭제 성공 - instanceId={}", instanceId); + } catch (Exception compensationError) { + // 보상 트랜잭션도 실패한 경우 - Ghost Resource 발생 + // 이 경우 별도의 모니터링/알림 시스템이나 배치 동기화로 처리 필요 + log.error("[RdbmsUseCaseService] 보상 트랜잭션 실패: Ghost Resource 발생 가능 - instanceId={}, error={}", + instanceId, compensationError.getMessage()); + } + } + + // ==================== 인스턴스 수정 ==================== + + /** + * RDBMS 인스턴스 정보를 수정합니다. + * + * @param request 수정 요청 정보 (providerType, accountScope 포함) + * @return 수정된 CloudResource 엔티티 + */ + @Transactional + public CloudResource updateRdbms(RdbmsUpdateRequest request) { + ProviderType providerType = request.getProviderType(); + String accountScope = request.getAccountScope(); + + // 로깅용 마스킹된 요청 (원본 데이터는 변경하지 않음) + String maskedRequest = LogMaskingUtils.maskSensitiveData(request.toString()); + log.debug("RDBMS 인스턴스 수정: provider={}, accountScope={}, request={}", + providerType, accountScope, maskedRequest); + + // Capability 검증 (CSP별 실제 서비스 키 사용) + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.UPDATE); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Command 변환 + RdbmsUpdateCommand command = toUpdateCommand(request, session); + + // RDBMS 인스턴스 수정 + CloudResource resource = portRouter.management(providerType).updateRdbms(command); + + // DB에 수정된 정보 반영 (instanceSize, allocatedStorage 등) + try { + // 수정된 리소스 정보를 DB에 동기화 + // Adapter에서 반환된 CloudResource의 정보를 DB에 업데이트 + if (request.getInstanceSize() != null || request.getAllocatedStorage() != null) { + // DB에서 기존 리소스 조회 후 업데이트하거나, + // Adapter에서 반환된 resource의 정보를 기반으로 DB 업데이트 + // 현재는 Adapter가 이미 CloudResource를 반환하므로, + // 생명주기 상태만 업데이트 (실제 속성 업데이트는 별도 동기화 작업에서 처리) + resourceHelper.updateLifecycleState( + request.getInstanceId(), + resource.getLifecycleState() != null ? resource.getLifecycleState() : LifecycleState.RUNNING + ); + } + } catch (Exception e) { + log.warn("[RdbmsUseCaseService] DB 업데이트 실패 (수정은 완료됨): instanceId={}, error={}", + request.getInstanceId(), e.getMessage()); + // DB 업데이트 실패해도 CSP 수정은 완료되었으므로 예외를 던지지 않음 + } + + log.info("RDBMS 인스턴스 수정 완료: provider={}, instanceId={}", providerType, request.getInstanceId()); + return resource; + } + + // ==================== 인스턴스 삭제 ==================== + + /** + * RDBMS 인스턴스를 삭제합니다. + * CSP에서 인스턴스 삭제 후 DB에서 소프트 삭제 처리합니다. + * + * @param request 삭제 요청 정보 (providerType, accountScope 포함) + */ + @Transactional + public void deleteRdbms(RdbmsDeleteRequest request) { + ProviderType providerType = request.getProviderType(); + String accountScope = request.getAccountScope(); + String instanceId = request.getInstanceId(); + + log.debug("RDBMS 인스턴스 삭제: provider={}, accountScope={}, request={}", + providerType, accountScope, request); + + // Capability 검증 (CSP별 실제 서비스 키 사용) + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.TERMINATE); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Command 변환 + RdbmsDeleteCommand command = toDeleteCommand(request, session); + + // CSP에서 RDBMS 인스턴스 삭제 + portRouter.management(providerType).deleteRdbms(command); + + // DB 소프트 삭제 + resourceHelper.softDeleteResource(instanceId); + + log.info("RDBMS 인스턴스 삭제 완료: provider={}, instanceId={}", providerType, instanceId); + } + + // ==================== 인스턴스 생명주기 관리 ==================== + + /** + * RDBMS 인스턴스를 시작합니다. + * CSP에서 인스턴스 시작 후 DB의 lifecycleState를 RUNNING으로 업데이트합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + */ + @Transactional + public void startInstance(ProviderType providerType, String accountScope, String instanceId) { + log.debug("RDBMS 인스턴스 시작: provider={}, accountScope={}, instanceId={}", + providerType, accountScope, instanceId); + + // Capability 검증 (CSP별 실제 서비스 키 사용) + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.START); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // ResourceIdentity 생성 (region 정보는 인스턴스 조회를 통해 얻거나 null로 설정) + String region = getRegionFromInstance(providerType, accountScope, instanceId, session); + ResourceIdentity resourceId = ResourceIdentity.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(region) + .providerResourceId(instanceId) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .build(); + + // CSP에서 RDBMS 인스턴스 시작 (RdbmsLifecyclePort는 ResourceLifecyclePort를 확장) + portRouter.lifecycle(providerType).start(resourceId, session); + + // DB 상태 업데이트: RUNNING + resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING); + + log.info("RDBMS 인스턴스 시작 완료: provider={}, instanceId={}", providerType, instanceId); + } + + /** + * RDBMS 인스턴스를 중지합니다. + * CSP에서 인스턴스 중지 후 DB의 lifecycleState를 STOPPED로 업데이트합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + */ + @Transactional + public void stopInstance(ProviderType providerType, String accountScope, String instanceId) { + log.debug("RDBMS 인스턴스 중지: provider={}, accountScope={}, instanceId={}", + providerType, accountScope, instanceId); + + // Capability 검증 (CSP별 실제 서비스 키 사용) + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.STOP); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // ResourceIdentity 생성 (region 정보는 인스턴스 조회를 통해 얻거나 null로 설정) + String region = getRegionFromInstance(providerType, accountScope, instanceId, session); + ResourceIdentity resourceId = ResourceIdentity.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(region) + .providerResourceId(instanceId) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .build(); + + // CSP에서 RDBMS 인스턴스 중지 (RdbmsLifecyclePort는 ResourceLifecyclePort를 확장) + portRouter.lifecycle(providerType).stop(resourceId, session); + + // DB 상태 업데이트: STOPPED + resourceHelper.updateLifecycleState(instanceId, LifecycleState.STOPPED); + + log.info("RDBMS 인스턴스 중지 완료: provider={}, instanceId={}", providerType, instanceId); + } + + /** + * RDBMS 인스턴스를 재시작합니다. + * CSP에서 인스턴스 재시작 후 DB의 lifecycleState를 RUNNING으로 유지합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + */ + @Transactional + public void rebootInstance(ProviderType providerType, String accountScope, String instanceId) { + log.debug("RDBMS 인스턴스 재시작: provider={}, accountScope={}, instanceId={}", + providerType, accountScope, instanceId); + + // Capability 검증 (START와 STOP이 모두 필요, CSP별 실제 서비스 키 사용) + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.STOP); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.START); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // CSP에서 RDBMS 인스턴스 재시작 + portRouter.lifecycle(providerType).rebootInstance(instanceId, session); + + // DB 상태 업데이트: 재시작 후 RUNNING 상태 유지 + resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING); + + log.info("RDBMS 인스턴스 재시작 완료: provider={}, instanceId={}", providerType, instanceId); + } + + // ==================== 상태 확인 ==================== + + /** + * RDBMS 인스턴스의 현재 상태를 확인합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param instanceId 인스턴스 ID + * @return 인스턴스 상태 + */ + @Transactional(readOnly = true) + public String getInstanceStatus(ProviderType providerType, String accountScope, String instanceId) { + log.debug("RDBMS 인스턴스 상태 확인: provider={}, accountScope={}, instanceId={}", + providerType, accountScope, instanceId); + + // 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + String status = portRouter.discovery(providerType).getInstanceStatus(instanceId, session); + + log.info("RDBMS 인스턴스 상태 확인 완료: provider={}, instanceId={}, status={}", + providerType, instanceId, status); + return status; + } + + // ==================== Helper Methods ==================== + + /** + * 인스턴스 ID로부터 region 정보를 조회합니다. + * 인스턴스가 존재하지 않으면 null을 반환합니다. + */ + private String getRegionFromInstance(ProviderType providerType, String accountScope, + String instanceId, CloudSessionCredential session) { + try { + Optional resource = portRouter.discovery(providerType) + .getRdbmsInstance(instanceId, session); + return resource + .map(r -> r.getRegion() != null ? r.getRegion().getRegionKey() : null) + .orElse(null); + } catch (Exception e) { + log.warn("Failed to get region for instance {}: {}", instanceId, e.getMessage()); + return null; + } + } + + /** + * 프로바이더 타입에 따른 서비스 키 반환 + * AWS: RDS, Azure: AzureDatabase, GCP: CloudSQL 등 + */ + private String getServiceKeyForProvider(ProviderType providerType) { + return switch (providerType) { + case AWS -> "RDS"; + case AZURE -> "AzureDatabase"; + case GCP -> "CloudSQL"; + default -> "RDBMS"; + }; + } + + // ==================== Command 변환 ==================== + + private RdbmsQuery toQuery(ProviderType providerType, String accountScope, RdbmsQueryRequest request) { + return RdbmsQuery.builder() + .providerType(providerType) + .accountScope(accountScope) + .regions(request.getRegions()) + .instanceName(request.getInstanceName()) + .engine(request.getEngine()) + .instanceSize(request.getInstanceSize()) + .status(request.getStatus()) + .tagsEquals(request.getTags()) + .page(request.getPage()) + .size(request.getSize()) + .build(); + } + + private RdbmsCreateCommand toCreateCommand(RdbmsCreateRequest request, CloudSessionCredential session) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + String serviceKey = getServiceKeyForProvider(request.getProviderType()); + + // adminPassword 암호화 + String encryptedPassword; + try { + encryptedPassword = encryptionService.encrypt(request.getMasterPassword()); + log.debug("RDBMS adminPassword 암호화 완료"); + } catch (Exception e) { + log.error("RDBMS adminPassword 암호화 실패", e); + throw new BusinessException( + CloudErrorCode.ENCRYPTION_FAILED, + "관리자 패스워드 암호화에 실패했습니다: " + e.getMessage() + ); + } + + return RdbmsCreateCommand.builder() + .providerType(request.getProviderType()) + .accountScope(request.getAccountScope()) + .region(request.getRegion()) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .instanceName(request.getInstanceName()) + .engine(request.getEngine()) + .engineVersion(request.getEngineVersion()) + .instanceSize(request.getInstanceSize()) + .allocatedStorage(request.getAllocatedStorage()) + .adminUsername(request.getMasterUsername()) + .adminPassword(encryptedPassword) // 암호화된 패스워드 전달 + .dbName(request.getDbName()) + .networkSecurityId(request.getNetworkSecurityId()) + .subnetId(request.getSubnetId()) + .port(request.getPort()) + .zone(request.getZone()) + .highAvailability(request.getHighAvailability()) + .publiclyAccessible(request.getPubliclyAccessible()) + .tags(request.getTags()) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + } + + private RdbmsUpdateCommand toUpdateCommand(RdbmsUpdateRequest request, CloudSessionCredential session) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // tagsToAdd를 tags로 사용 (tagsToRemove는 Adapter에서 처리하거나 별도 필드로 전달 필요) + // 현재는 tagsToAdd만 전달 (실제 구현에서는 Adapter에서 태그 추가/제거를 별도로 처리) + java.util.Map tags = request.getTagsToAdd() != null + ? new java.util.HashMap<>(request.getTagsToAdd()) + : new java.util.HashMap<>(); + + // adminPassword 암호화 (수정 시 선택적 - 패스워드 변경하지 않을 수도 있음) + String encryptedPassword = null; + if (request.getMasterPassword() != null) { + try { + encryptedPassword = encryptionService.encrypt(request.getMasterPassword()); + log.debug("RDBMS adminPassword 암호화 완료 (수정)"); + } catch (Exception e) { + log.error("RDBMS adminPassword 암호화 실패 (수정)", e); + throw new BusinessException( + CloudErrorCode.ENCRYPTION_FAILED, + "관리자 패스워드 암호화에 실패했습니다: " + e.getMessage() + ); + } + } + + return RdbmsUpdateCommand.builder() + .providerType(request.getProviderType()) + .accountScope(request.getAccountScope()) + .region(null) // region은 Adapter에서 처리 + .providerResourceId(request.getInstanceId()) + .instanceSize(request.getInstanceSize()) + .allocatedStorage(request.getAllocatedStorage()) + .adminPassword(encryptedPassword) // 암호화된 패스워드 전달 (null 가능) + .applyImmediately(request.getApplyImmediately()) + .tags(tags) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + } + + private RdbmsDeleteCommand toDeleteCommand(RdbmsDeleteRequest request, CloudSessionCredential session) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + return RdbmsDeleteCommand.builder() + .providerType(request.getProviderType()) + .accountScope(request.getAccountScope()) + .region(null) // region은 Adapter에서 처리 + .providerResourceId(request.getInstanceId()) + .skipSnapshot(request.getSkipSnapshot()) + .snapshotName(request.getSnapshotName()) + .deleteAutomatedBackups(request.getDeleteAutomatedBackups()) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + } + +} diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapterDecryptionTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapterDecryptionTest.java new file mode 100644 index 00000000..e376af55 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapterDecryptionTest.java @@ -0,0 +1,509 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.rds; + +import com.agenticcp.core.common.crypto.AesGcmEncryptionService; +import com.agenticcp.core.common.crypto.EncryptionService; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsRdsConfig; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.rds.RdsClient; +import software.amazon.awssdk.services.rds.model.*; + +import java.security.SecureRandom; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * AwsRdsManagementAdapter 복호화 로직 단위 테스트 + * + * adminPassword 복호화 로직을 검증합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AwsRdsManagementAdapter 복호화 테스트") +class AwsRdsManagementAdapterDecryptionTest { + + @Mock + private AwsRdsConfig awsRdsConfig; + + @Mock + private AwsRdsMapper mapper; + + @Mock + private RdsClient rdsClient; + + @Mock + private CloudSessionCredential session; + + private EncryptionService encryptionService; + private AwsRdsManagementAdapter adapter; + + @BeforeEach + void setUp() { + // 실제 EncryptionService 사용 (AES-GCM-256) + byte[] key = generateKey(32); + encryptionService = new AesGcmEncryptionService(key); + + // AwsRdsManagementAdapter 인스턴스 생성 (EncryptionService 주입) + adapter = new AwsRdsManagementAdapter(awsRdsConfig, mapper, encryptionService); + + // Mock 설정 - lenient()를 사용하여 각 테스트에서 다른 인자로 호출될 수 있음 + lenient().when(awsRdsConfig.createRdsClient(any(CloudSessionCredential.class), anyString())) + .thenReturn(rdsClient); + } + + /** + * AES 키 생성 헬퍼 메서드 + */ + private byte[] generateKey(int size) { + byte[] key = new byte[size]; + new SecureRandom().nextBytes(key); + return key; + } + + @Nested + @DisplayName("adminPassword 복호화 테스트 - 생성 시") + class CreateRdbmsDecryptionTest { + + @Test + @DisplayName("암호화된 adminPassword가 정상적으로 복호화되어 AWS SDK 요청에 전달됨") + void shouldDecryptPasswordWhenCreatingRdbms() throws Exception { + // given + String plainPassword = "MySecurePassword123!"; + String encryptedPassword = encryptionService.encrypt(plainPassword); + + RdbmsCreateCommand command = RdbmsCreateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .serviceKey("RDS") + .resourceType("DATABASE") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .adminUsername("admin") + .adminPassword(encryptedPassword) // 암호화된 패스워드 + .session(session) // session 추가 + .build(); + + // AWS SDK 응답 Mock + DBInstance dbInstance = DBInstance.builder() + .dbInstanceIdentifier("test-db") + .dbInstanceStatus("available") + .build(); + + CreateDbInstanceResponse response = CreateDbInstanceResponse.builder() + .dbInstance(dbInstance) + .build(); + + DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder() + .dbInstances(dbInstance) + .build(); + + given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class))) + .willReturn(response); + given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class))) + .willReturn(describeResponse); + given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any())) + .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateDbInstanceRequest.class); + + // when + adapter.createRdbms(command); + + // then + verify(rdsClient).createDBInstance(requestCaptor.capture()); + CreateDbInstanceRequest capturedRequest = requestCaptor.getValue(); + + // AWS SDK 요청에 전달된 패스워드가 복호화된 평문인지 확인 + String requestPassword = capturedRequest.masterUserPassword(); + assertThat(requestPassword).isNotNull(); + assertThat(requestPassword).isEqualTo(plainPassword); + assertThat(requestPassword).isNotEqualTo(encryptedPassword); + } + + @Test + @DisplayName("복호화 실패 시 DECRYPTION_FAILED 예외 발생") + void shouldThrowExceptionWhenDecryptionFails() { + // given + EncryptionService failingEncryptionService = mock(EncryptionService.class); + AwsRdsManagementAdapter adapterWithFailingDecryption = + new AwsRdsManagementAdapter(awsRdsConfig, mapper, failingEncryptionService); + + String invalidEncryptedPassword = "invalid-encrypted-password"; + RdbmsCreateCommand command = RdbmsCreateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .serviceKey("RDS") + .resourceType("DATABASE") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .adminUsername("admin") + .adminPassword(invalidEncryptedPassword) + .session(session) // session 추가 + .build(); + + given(awsRdsConfig.createRdsClient(any(CloudSessionCredential.class), anyString())) + .willReturn(rdsClient); + given(failingEncryptionService.decrypt(anyString())) + .willThrow(new RuntimeException("복호화 실패")); + + // when & then + assertThatThrownBy(() -> adapterWithFailingDecryption.createRdbms(command)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException be = (BusinessException) exception; + assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.DECRYPTION_FAILED); + assertThat(be.getMessage()).contains("관리자 패스워드 복호화에 실패했습니다"); + }); + } + + @Test + @DisplayName("adminPassword가 null이면 AWS SDK 요청에도 null이 전달됨") + void shouldPassNullWhenPasswordIsNull() throws Exception { + // given + RdbmsCreateCommand command = RdbmsCreateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .serviceKey("RDS") + .resourceType("DATABASE") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .adminUsername("admin") + .adminPassword(null) // null 패스워드 + .session(session) // session 추가 + .build(); + + // AWS SDK 응답 Mock + DBInstance dbInstance = DBInstance.builder() + .dbInstanceIdentifier("test-db") + .dbInstanceStatus("available") + .build(); + + CreateDbInstanceResponse response = CreateDbInstanceResponse.builder() + .dbInstance(dbInstance) + .build(); + + DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder() + .dbInstances(dbInstance) + .build(); + + given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class))) + .willReturn(response); + given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class))) + .willReturn(describeResponse); + given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any())) + .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateDbInstanceRequest.class); + + // when + adapter.createRdbms(command); + + // then + verify(rdsClient).createDBInstance(requestCaptor.capture()); + CreateDbInstanceRequest capturedRequest = requestCaptor.getValue(); + + // AWS SDK 요청에 전달된 패스워드가 null인지 확인 + String requestPassword = capturedRequest.masterUserPassword(); + assertThat(requestPassword).isNull(); + } + } + + @Nested + @DisplayName("adminPassword 복호화 테스트 - 수정 시") + class UpdateRdbmsDecryptionTest { + + @Test + @DisplayName("암호화된 adminPassword가 정상적으로 복호화되어 AWS SDK 요청에 전달됨") + void shouldDecryptPasswordWhenUpdatingRdbms() throws Exception { + // given + String plainPassword = "NewSecurePassword456!"; + String encryptedPassword = encryptionService.encrypt(plainPassword); + + RdbmsUpdateCommand command = RdbmsUpdateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .providerResourceId("db-instance-123") + .adminPassword(encryptedPassword) // 암호화된 패스워드 + .session(session) // session 추가 + .build(); + + // AWS SDK 응답 Mock + DBInstance dbInstance = DBInstance.builder() + .dbInstanceIdentifier("db-instance-123") + .dbInstanceStatus("available") + .build(); + + ModifyDbInstanceResponse response = ModifyDbInstanceResponse.builder() + .dbInstance(dbInstance) + .build(); + + DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder() + .dbInstances(dbInstance) + .build(); + + given(rdsClient.modifyDBInstance(any(ModifyDbInstanceRequest.class))) + .willReturn(response); + given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class))) + .willReturn(describeResponse); + given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any())) + .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ModifyDbInstanceRequest.class); + + // when + adapter.updateRdbms(command); + + // then + verify(rdsClient).modifyDBInstance(requestCaptor.capture()); + ModifyDbInstanceRequest capturedRequest = requestCaptor.getValue(); + + // AWS SDK 요청에 전달된 패스워드가 복호화된 평문인지 확인 + String requestPassword = capturedRequest.masterUserPassword(); + assertThat(requestPassword).isNotNull(); + assertThat(requestPassword).isEqualTo(plainPassword); + assertThat(requestPassword).isNotEqualTo(encryptedPassword); + } + + @Test + @DisplayName("복호화 실패 시 DECRYPTION_FAILED 예외 발생") + void shouldThrowExceptionWhenDecryptionFails() { + // given + EncryptionService failingEncryptionService = mock(EncryptionService.class); + AwsRdsManagementAdapter adapterWithFailingDecryption = + new AwsRdsManagementAdapter(awsRdsConfig, mapper, failingEncryptionService); + + String invalidEncryptedPassword = "invalid-encrypted-password"; + RdbmsUpdateCommand command = RdbmsUpdateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .providerResourceId("db-instance-123") + .adminPassword(invalidEncryptedPassword) + .session(session) // session 추가 + .build(); + + given(awsRdsConfig.createRdsClient(any(CloudSessionCredential.class), anyString())) + .willReturn(rdsClient); + given(failingEncryptionService.decrypt(anyString())) + .willThrow(new RuntimeException("복호화 실패")); + + // when & then + assertThatThrownBy(() -> adapterWithFailingDecryption.updateRdbms(command)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException be = (BusinessException) exception; + assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.DECRYPTION_FAILED); + assertThat(be.getMessage()).contains("관리자 패스워드 복호화에 실패했습니다"); + }); + } + + @Test + @DisplayName("adminPassword가 null이면 AWS SDK 요청에도 null이 전달됨") + void shouldPassNullWhenPasswordIsNull() throws Exception { + // given + RdbmsUpdateCommand command = RdbmsUpdateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .providerResourceId("db-instance-123") + .instanceSize("db.t3.small") + .adminPassword(null) // null 패스워드 + .session(session) // session 추가 + .build(); + + // AWS SDK 응답 Mock + DBInstance dbInstance = DBInstance.builder() + .dbInstanceIdentifier("db-instance-123") + .dbInstanceStatus("available") + .build(); + + ModifyDbInstanceResponse response = ModifyDbInstanceResponse.builder() + .dbInstance(dbInstance) + .build(); + + DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder() + .dbInstances(dbInstance) + .build(); + + given(rdsClient.modifyDBInstance(any(ModifyDbInstanceRequest.class))) + .willReturn(response); + given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class))) + .willReturn(describeResponse); + given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any())) + .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ModifyDbInstanceRequest.class); + + // when + adapter.updateRdbms(command); + + // then + verify(rdsClient).modifyDBInstance(requestCaptor.capture()); + ModifyDbInstanceRequest capturedRequest = requestCaptor.getValue(); + + // AWS SDK 요청에 전달된 패스워드가 null인지 확인 + String requestPassword = capturedRequest.masterUserPassword(); + assertThat(requestPassword).isNull(); + } + } + + @Nested + @DisplayName("복호화 라운드트립 테스트") + class DecryptionRoundTripTest { + + @Test + @DisplayName("암호화된 패스워드를 복호화하면 원본과 일치함") + void shouldDecryptToOriginalPassword() throws Exception { + // given + String plainPassword = "TestPassword123!@#"; + String encryptedPassword = encryptionService.encrypt(plainPassword); + + RdbmsCreateCommand command = RdbmsCreateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .serviceKey("RDS") + .resourceType("DATABASE") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .adminUsername("admin") + .adminPassword(encryptedPassword) + .session(session) // session 추가 + .build(); + + // AWS SDK 응답 Mock + DBInstance dbInstance = DBInstance.builder() + .dbInstanceIdentifier("test-db") + .dbInstanceStatus("available") + .build(); + + CreateDbInstanceResponse response = CreateDbInstanceResponse.builder() + .dbInstance(dbInstance) + .build(); + + DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder() + .dbInstances(dbInstance) + .build(); + + given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class))) + .willReturn(response); + given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class))) + .willReturn(describeResponse); + given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any())) + .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateDbInstanceRequest.class); + + // when + adapter.createRdbms(command); + + // then + verify(rdsClient).createDBInstance(requestCaptor.capture()); + CreateDbInstanceRequest capturedRequest = requestCaptor.getValue(); + + String decryptedPassword = capturedRequest.masterUserPassword(); + assertThat(decryptedPassword).isEqualTo(plainPassword); + } + + @Test + @DisplayName("다양한 특수문자를 포함한 패스워드도 정상 복호화됨") + void shouldHandleSpecialCharacters() throws Exception { + // given + String[] testPasswords = { + "Password123!@#$%^&*()", + "한글패스워드123!", + "P@ssw0rd with spaces", + "VeryLongPassword1234567890!@#$%^&*()_+-=[]{}|;:,.<>?", + "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + }; + + for (String plainPassword : testPasswords) { + String encryptedPassword = encryptionService.encrypt(plainPassword); + + RdbmsCreateCommand command = RdbmsCreateCommand.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .serviceKey("RDS") + .resourceType("DATABASE") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .adminUsername("admin") + .adminPassword(encryptedPassword) + .session(session) // session 추가 + .build(); + + // AWS SDK 응답 Mock + DBInstance dbInstance = DBInstance.builder() + .dbInstanceIdentifier("test-db") + .dbInstanceStatus("available") + .build(); + + CreateDbInstanceResponse response = CreateDbInstanceResponse.builder() + .dbInstance(dbInstance) + .build(); + + DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder() + .dbInstances(dbInstance) + .build(); + + given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class))) + .willReturn(response); + given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class))) + .willReturn(describeResponse); + given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any())) + .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(CreateDbInstanceRequest.class); + + // when + adapter.createRdbms(command); + + // then + verify(rdsClient, atLeastOnce()).createDBInstance(requestCaptor.capture()); + CreateDbInstanceRequest capturedRequest = requestCaptor.getValue(); + + String decryptedPassword = capturedRequest.masterUserPassword(); + assertThat(decryptedPassword).isEqualTo(plainPassword); + } + } + } +} diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceDbSyncTest.java new file mode 100644 index 00000000..d8f7c4db --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceDbSyncTest.java @@ -0,0 +1,408 @@ +package com.agenticcp.core.domain.cloud.service.rdbms; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.crypto.EncryptionService; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort; +import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RdbmsUseCaseService DB 동기화 로직 단위 테스트 + * CSP 작업 후 CloudResource 엔티티가 올바르게 DB에 저장/삭제되는지 검증합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RdbmsUseCaseService DB 동기화 테스트") +class RdbmsUseCaseServiceDbSyncTest { + + @Mock + private RdbmsPortRouter portRouter; + + @Mock + private RdbmsManagementPort managementPort; + + @Mock + private RdbmsDiscoveryPort discoveryPort; + + @Mock + private RdbmsLifecyclePort lifecyclePort; + + @Mock + private CapabilityGuard capabilityGuard; + + @Mock + private AccountCredentialManagementPort credentialPort; + + @Mock + private CloudResourceManagementHelper resourceHelper; + + @Mock + private EncryptionService encryptionService; + + private RdbmsUseCaseService rdbmsUseCaseService; + private CloudSessionCredential mockSession; + + private static final ProviderType PROVIDER_TYPE = ProviderType.AWS; + private static final String ACCOUNT_SCOPE = "123456789012"; + private static final String TENANT_KEY = "tenant-test"; + private static final String INSTANCE_ID = "test-instance-123"; + private static final String REGION = "us-east-1"; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantKey(TENANT_KEY); + + rdbmsUseCaseService = new RdbmsUseCaseService( + portRouter, + capabilityGuard, + credentialPort, + resourceHelper, + encryptionService + ); + + mockSession = mock(CloudSessionCredential.class); + + // 공통 Mock 설정 + lenient().when(portRouter.management(PROVIDER_TYPE)).thenReturn(managementPort); + lenient().when(portRouter.discovery(PROVIDER_TYPE)).thenReturn(discoveryPort); + lenient().when(portRouter.lifecycle(PROVIDER_TYPE)).thenReturn(lifecyclePort); + lenient().when(credentialPort.getSession(eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(PROVIDER_TYPE))) + .thenReturn(mockSession); + lenient().doNothing().when(capabilityGuard).ensureSupported(any(), anyString(), anyString(), any()); + lenient().when(encryptionService.encrypt(anyString())).thenReturn("encrypted-password"); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Nested + @DisplayName("인스턴스 생성 테스트") + class CreateRdbmsTest { + + @Test + @DisplayName("인스턴스 생성 성공 시 CloudResource가 DB에 저장된다") + void createRdbms_Success_SavesCloudResource() { + // Given + Map tags = Map.of("Environment", "test"); + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .region(REGION) + .instanceName("test-instance") + .engine("mysql") + .engineVersion("8.0") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword("password123") + .tags(tags) + .build(); + + CloudResource mockCreatedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + + when(managementPort.createRdbms(any())).thenReturn(mockCreatedInstance); + when(resourceHelper.registerResource(any(), any(), any())).thenReturn(mockCreatedInstance); + + // When + CloudResource result = rdbmsUseCaseService.createRdbms(request); + + // Then + assertThat(result).isNotNull(); + + verify(resourceHelper).registerResource( + eq(PROVIDER_TYPE), + eq("RDS"), + any(ResourceRegistrationRequest.class) + ); + } + + @Test + @DisplayName("DB 저장 실패 시 보상 트랜잭션이 실행되고 예외가 발생한다") + void createRdbms_DbSaveFails_CompensatingTransactionExecuted() { + // Given + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .region(REGION) + .instanceName("test-instance") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword("password123") + .build(); + + CloudResource mockCreatedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + + when(managementPort.createRdbms(any())).thenReturn(mockCreatedInstance); + // DB 저장 실패 + doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper) + .registerResource(any(), any(), any()); + doNothing().when(managementPort).deleteRdbms(any()); + + // When & Then + assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException be = (BusinessException) exception; + assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.RESOURCE_CREATION_FAILED); + }); + + // 보상 트랜잭션 실행 검증: CSP 인스턴스 삭제 호출됨 + verify(managementPort).deleteRdbms(any()); + } + + @Test + @DisplayName("보상 트랜잭션도 실패하면 Ghost Resource 경고 로그가 출력된다") + void createRdbms_CompensationFails_GhostResourceWarningLogged() { + // Given + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .region(REGION) + .instanceName("test-instance") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword("password123") + .build(); + + CloudResource mockCreatedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + + when(managementPort.createRdbms(any())).thenReturn(mockCreatedInstance); + // DB 저장 실패 + doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper) + .registerResource(any(), any(), any()); + // 보상 트랜잭션(CSP 삭제)도 실패 + doThrow(new RuntimeException("CSP 삭제 실패")).when(managementPort) + .deleteRdbms(any()); + + // When & Then + assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request)) + .isInstanceOf(BusinessException.class); + + // 보상 트랜잭션 시도 검증 + verify(managementPort).deleteRdbms(any()); + // Ghost Resource 발생 - 실제로는 모니터링/배치로 처리 필요 + } + } + + @Nested + @DisplayName("인스턴스 삭제 테스트") + class DeleteRdbmsTest { + + @Test + @DisplayName("인스턴스 삭제 시 소프트 삭제가 수행된다") + void deleteRdbms_Success_SoftDeletesResource() { + // Given + RdbmsDeleteRequest request = RdbmsDeleteRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .instanceId(INSTANCE_ID) + .skipSnapshot(false) + .build(); + + doNothing().when(managementPort).deleteRdbms(any()); + + // When + rdbmsUseCaseService.deleteRdbms(request); + + // Then + verify(managementPort).deleteRdbms(any()); + verify(resourceHelper).softDeleteResource(INSTANCE_ID); + } + + @Test + @DisplayName("DB에 리소스가 없어도 CSP 삭제는 성공한다") + void deleteRdbms_ResourceNotInDb_CspDeletionSucceeds() { + // Given + RdbmsDeleteRequest request = RdbmsDeleteRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .instanceId(INSTANCE_ID) + .skipSnapshot(true) + .build(); + + doNothing().when(managementPort).deleteRdbms(any()); + // Helper 내부에서 리소스가 없으면 로그만 출력하고 예외 발생 안함 + + // When + rdbmsUseCaseService.deleteRdbms(request); + + // Then + verify(managementPort).deleteRdbms(any()); // CSP 작업 성공 + verify(resourceHelper).softDeleteResource(INSTANCE_ID); + } + } + + @Nested + @DisplayName("인스턴스 수정 테스트") + class UpdateRdbmsTest { + + @Test + @DisplayName("인스턴스 수정 시 생명주기 상태가 DB에 업데이트된다") + void updateRdbms_Success_UpdatesLifecycleState() { + // Given + RdbmsUpdateRequest request = RdbmsUpdateRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .instanceId(INSTANCE_ID) + .instanceSize("db.t3.small") + .allocatedStorage(50) + .build(); + + CloudResource mockUpdatedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .lifecycleState(LifecycleState.RUNNING) + .build(); + + when(managementPort.updateRdbms(any())).thenReturn(mockUpdatedInstance); + + // When + CloudResource result = rdbmsUseCaseService.updateRdbms(request); + + // Then + assertThat(result).isNotNull(); + verify(managementPort).updateRdbms(any()); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + } + + @Test + @DisplayName("DB 업데이트 실패해도 CSP 수정은 완료된다") + void updateRdbms_DbUpdateFails_CspUpdateSucceeds() { + // Given + RdbmsUpdateRequest request = RdbmsUpdateRequest.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .instanceId(INSTANCE_ID) + .instanceSize("db.t3.small") + .build(); + + CloudResource mockUpdatedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .lifecycleState(LifecycleState.RUNNING) + .build(); + + when(managementPort.updateRdbms(any())).thenReturn(mockUpdatedInstance); + // DB 업데이트 실패 + doThrow(new RuntimeException("DB 업데이트 실패")).when(resourceHelper) + .updateLifecycleState(anyString(), any()); + + // When + CloudResource result = rdbmsUseCaseService.updateRdbms(request); + + // Then + assertThat(result).isNotNull(); + verify(managementPort).updateRdbms(any()); // CSP 수정은 성공 + verify(resourceHelper).updateLifecycleState(anyString(), any()); // DB 업데이트 시도 + } + } + + @Nested + @DisplayName("생명주기 관리 테스트") + class LifecycleManagementTest { + + @Test + @DisplayName("인스턴스 시작 시 생명주기 상태가 RUNNING으로 업데이트된다") + void startInstance_Success_UpdatesLifecycleStateToRunning() { + // Given + CloudResource mockInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + + when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)) + .thenReturn(java.util.Optional.of(mockInstance)); + doNothing().when(lifecyclePort).start(any(), eq(mockSession)); + + // When + rdbmsUseCaseService.startInstance(PROVIDER_TYPE, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + verify(lifecyclePort).start(any(), eq(mockSession)); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + } + + @Test + @DisplayName("인스턴스 중지 시 생명주기 상태가 STOPPED로 업데이트된다") + void stopInstance_Success_UpdatesLifecycleStateToStopped() { + // Given + CloudResource mockInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + + when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)) + .thenReturn(java.util.Optional.of(mockInstance)); + doNothing().when(lifecyclePort).stop(any(), eq(mockSession)); + + // When + rdbmsUseCaseService.stopInstance(PROVIDER_TYPE, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + verify(lifecyclePort).stop(any(), eq(mockSession)); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.STOPPED); + } + + @Test + @DisplayName("인스턴스 재시작 시 생명주기 상태가 RUNNING으로 유지된다") + void rebootInstance_Success_MaintainsRunningState() { + // Given + doNothing().when(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession); + + // When + rdbmsUseCaseService.rebootInstance(PROVIDER_TYPE, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + verify(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + } + } +} diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceEncryptionTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceEncryptionTest.java new file mode 100644 index 00000000..35cce9b1 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceEncryptionTest.java @@ -0,0 +1,399 @@ +package com.agenticcp.core.domain.cloud.service.rdbms; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.crypto.AesGcmEncryptionService; +import com.agenticcp.core.common.crypto.EncryptionService; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort; +import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.SecureRandom; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * RdbmsUseCaseService 암호화/복호화 로직 단위 테스트 + * + * adminPassword 암호화 로직을 검증합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RdbmsUseCaseService 암호화/복호화 테스트") +class RdbmsUseCaseServiceEncryptionTest { + + @Mock + private RdbmsPortRouter portRouter; + + @Mock + private CapabilityGuard capabilityGuard; + + @Mock + private AccountCredentialManagementPort credentialPort; + + @Mock + private CloudResourceManagementHelper resourceHelper; + + @Mock + private RdbmsManagementPort managementPort; + + private EncryptionService encryptionService; + private RdbmsUseCaseService rdbmsUseCaseService; + + private String tenantKey; + private CloudSessionCredential session; + private CloudResource mockResource; + + @BeforeEach + void setUp() { + tenantKey = "test-tenant"; + TenantContextHolder.setTenantKey(tenantKey); + + // 실제 EncryptionService 사용 (AES-GCM-256) + byte[] key = generateKey(32); + encryptionService = new AesGcmEncryptionService(key); + + // RdbmsUseCaseService 인스턴스 생성 (EncryptionService 주입) + rdbmsUseCaseService = new RdbmsUseCaseService( + portRouter, + capabilityGuard, + credentialPort, + resourceHelper, + encryptionService + ); + + // Mock 설정 + session = mock(CloudSessionCredential.class); + mockResource = mock(CloudResource.class); + + // lenient()를 사용하여 일부 테스트에서 사용되지 않을 수 있는 stubbing 허용 + lenient().when(credentialPort.getSession(eq(tenantKey), anyString(), any(CloudProvider.ProviderType.class))) + .thenReturn(session); + lenient().when(portRouter.management(any(CloudProvider.ProviderType.class))) + .thenReturn(managementPort); + lenient().when(mockResource.getResourceId()) + .thenReturn("test-instance-123"); + lenient().when(resourceHelper.registerResource(any(), any(), any())) + .thenReturn(mockResource); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + /** + * AES 키 생성 헬퍼 메서드 + */ + private byte[] generateKey(int size) { + byte[] key = new byte[size]; + new SecureRandom().nextBytes(key); + return key; + } + + @Nested + @DisplayName("adminPassword 암호화 테스트 - 생성 시") + class CreateRdbmsEncryptionTest { + + @Test + @DisplayName("adminPassword가 정상적으로 암호화되어 Command에 전달됨") + void shouldEncryptPasswordWhenCreatingRdbms() { + // given + String plainPassword = "MySecurePassword123!"; + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword(plainPassword) + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(managementPort.createRdbms(any(RdbmsCreateCommand.class))) + .willReturn(mockResource); + + // when + rdbmsUseCaseService.createRdbms(request); + + // then + verify(managementPort).createRdbms(argThat(command -> { + // Command에 전달된 adminPassword가 암호화되었는지 확인 + String encryptedPassword = command.adminPassword(); + assertThat(encryptedPassword).isNotNull(); + assertThat(encryptedPassword).isNotEqualTo(plainPassword); + + // 암호화된 값이 Base64 형식인지 확인 + assertThat(encryptedPassword).doesNotContain(plainPassword); + + // 복호화하여 원본과 일치하는지 확인 + String decrypted = encryptionService.decrypt(encryptedPassword); + assertThat(decrypted).isEqualTo(plainPassword); + + return true; + })); + } + + @Test + @DisplayName("암호화 실패 시 ENCRYPTION_FAILED 예외 발생") + void shouldThrowExceptionWhenEncryptionFails() { + // given + EncryptionService failingEncryptionService = mock(EncryptionService.class); + RdbmsUseCaseService serviceWithFailingEncryption = new RdbmsUseCaseService( + portRouter, + capabilityGuard, + credentialPort, + resourceHelper, + failingEncryptionService + ); + + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword("MySecurePassword123!") + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(failingEncryptionService.encrypt(anyString())) + .willThrow(new RuntimeException("암호화 실패")); + + // when & then + assertThatThrownBy(() -> serviceWithFailingEncryption.createRdbms(request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException be = (BusinessException) exception; + assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.ENCRYPTION_FAILED); + assertThat(be.getMessage()).contains("관리자 패스워드 암호화에 실패했습니다"); + }); + } + } + + @Nested + @DisplayName("adminPassword 암호화 테스트 - 수정 시") + class UpdateRdbmsEncryptionTest { + + @Test + @DisplayName("adminPassword가 제공되면 암호화되어 Command에 전달됨") + void shouldEncryptPasswordWhenUpdatingRdbmsWithPassword() { + // given + String plainPassword = "NewSecurePassword456!"; + RdbmsUpdateRequest request = RdbmsUpdateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .instanceId("db-instance-123") + .masterPassword(plainPassword) + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(managementPort.updateRdbms(any(RdbmsUpdateCommand.class))) + .willReturn(mockResource); + + // when + rdbmsUseCaseService.updateRdbms(request); + + // then + verify(managementPort).updateRdbms(argThat(command -> { + // Command에 전달된 adminPassword가 암호화되었는지 확인 + String encryptedPassword = command.adminPassword(); + assertThat(encryptedPassword).isNotNull(); + assertThat(encryptedPassword).isNotEqualTo(plainPassword); + + // 복호화하여 원본과 일치하는지 확인 + String decrypted = encryptionService.decrypt(encryptedPassword); + assertThat(decrypted).isEqualTo(plainPassword); + + return true; + })); + } + + @Test + @DisplayName("adminPassword가 null이면 Command에 null이 전달됨") + void shouldPassNullWhenPasswordNotProvided() { + // given + RdbmsUpdateRequest request = RdbmsUpdateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .instanceId("db-instance-123") + .instanceSize("db.t3.small") + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(managementPort.updateRdbms(any(RdbmsUpdateCommand.class))) + .willReturn(mockResource); + + // when + rdbmsUseCaseService.updateRdbms(request); + + // then + verify(managementPort).updateRdbms(argThat(command -> { + // Command에 전달된 adminPassword가 null인지 확인 + assertThat(command.adminPassword()).isNull(); + return true; + })); + } + + @Test + @DisplayName("암호화 실패 시 ENCRYPTION_FAILED 예외 발생") + void shouldThrowExceptionWhenEncryptionFails() { + // given + EncryptionService failingEncryptionService = mock(EncryptionService.class); + RdbmsUseCaseService serviceWithFailingEncryption = new RdbmsUseCaseService( + portRouter, + capabilityGuard, + credentialPort, + resourceHelper, + failingEncryptionService + ); + + RdbmsUpdateRequest request = RdbmsUpdateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .instanceId("db-instance-123") + .masterPassword("NewSecurePassword456!") + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(failingEncryptionService.encrypt(anyString())) + .willThrow(new RuntimeException("암호화 실패")); + + // when & then + assertThatThrownBy(() -> serviceWithFailingEncryption.updateRdbms(request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException be = (BusinessException) exception; + assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.ENCRYPTION_FAILED); + assertThat(be.getMessage()).contains("관리자 패스워드 암호화에 실패했습니다"); + }); + } + } + + @Nested + @DisplayName("암호화 라운드트립 테스트") + class EncryptionRoundTripTest { + + @Test + @DisplayName("암호화 후 복호화하면 원본과 일치함") + void shouldDecryptToOriginalPassword() { + // given + String plainPassword = "TestPassword123!@#"; + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword(plainPassword) + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(managementPort.createRdbms(any(RdbmsCreateCommand.class))) + .willReturn(mockResource); + + // when + rdbmsUseCaseService.createRdbms(request); + + // then + verify(managementPort).createRdbms(argThat(command -> { + String encryptedPassword = command.adminPassword(); + + // 암호화된 값이 원본과 다름 + assertThat(encryptedPassword).isNotEqualTo(plainPassword); + + // 복호화하면 원본과 일치 + String decrypted = encryptionService.decrypt(encryptedPassword); + assertThat(decrypted).isEqualTo(plainPassword); + + return true; + })); + } + + @Test + @DisplayName("다양한 특수문자를 포함한 패스워드도 정상 암호화/복호화됨") + void shouldHandleSpecialCharacters() { + // given + String[] testPasswords = { + "Password123!@#$%^&*()", + "한글패스워드123!", + "P@ssw0rd with spaces", + "VeryLongPassword1234567890!@#$%^&*()_+-=[]{}|;:,.<>?", + "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + }; + + ArgumentCaptor commandCaptor = + ArgumentCaptor.forClass(RdbmsCreateCommand.class); + + for (String plainPassword : testPasswords) { + RdbmsCreateRequest request = RdbmsCreateRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope("123456789012") + .region("us-east-1") + .instanceName("test-db") + .engine("mysql") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword(plainPassword) + .build(); + + doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + given(managementPort.createRdbms(any(RdbmsCreateCommand.class))) + .willReturn(mockResource); + + // when + rdbmsUseCaseService.createRdbms(request); + + // then - 각 호출마다 검증 + verify(managementPort).createRdbms(commandCaptor.capture()); + RdbmsCreateCommand capturedCommand = commandCaptor.getValue(); + + String encryptedPassword = capturedCommand.adminPassword(); + assertThat(encryptedPassword).isNotNull(); + assertThat(encryptedPassword).isNotEqualTo(plainPassword); + + // 복호화하여 원본과 일치하는지 확인 + String decrypted = encryptionService.decrypt(encryptedPassword); + assertThat(decrypted).isEqualTo(plainPassword); + + // 다음 반복을 위해 reset + reset(managementPort); + } + } + } +} diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceTest.java new file mode 100644 index 00000000..c88822c7 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceTest.java @@ -0,0 +1,623 @@ +package com.agenticcp.core.domain.cloud.service.rdbms; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.crypto.EncryptionService; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsQueryRequest; +import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState; +import com.agenticcp.core.domain.cloud.port.model.ResourceIdentity; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort; +import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort; +import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * RdbmsUseCaseService 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RdbmsUseCaseService 테스트") +class RdbmsUseCaseServiceTest { + + @Mock + private RdbmsPortRouter portRouter; + + @Mock + private CapabilityGuard capabilityGuard; + + @Mock + private AccountCredentialManagementPort credentialPort; + + @Mock + private CloudResourceManagementHelper resourceHelper; + + @Mock + private EncryptionService encryptionService; + + @Mock + private RdbmsManagementPort managementPort; + + @Mock + private RdbmsDiscoveryPort discoveryPort; + + @Mock + private RdbmsLifecyclePort lifecyclePort; + + @InjectMocks + private RdbmsUseCaseService rdbmsUseCaseService; + + private static final String TENANT_KEY = "test-tenant"; + private static final CloudProvider.ProviderType AWS = CloudProvider.ProviderType.AWS; + private static final String ACCOUNT_SCOPE = "123456789012"; + private static final String INSTANCE_ID = "test-instance-123"; + private static final String REGION = "us-east-1"; + + private CloudSessionCredential mockSession; + + @BeforeEach + void setUp() { + lenient().when(portRouter.management(AWS)).thenReturn(managementPort); + lenient().when(portRouter.discovery(AWS)).thenReturn(discoveryPort); + lenient().when(portRouter.lifecycle(AWS)).thenReturn(lifecyclePort); + + mockSession = AwsSessionCredential.builder() + .accessKeyId("AKIA_TEST") + .secretAccessKey("secret") + .sessionToken("token") + .region(REGION) + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + } + + @Nested + @DisplayName("RDBMS 인스턴스 목록 조회 테스트") + class ListRdbmsInstancesTest { + + private RdbmsQueryRequest query; + private Page expectedPage; + + @BeforeEach + void setUp() { + query = RdbmsQueryRequest.builder() + .providerType(AWS) + .accountScope(ACCOUNT_SCOPE) + .page(0) + .size(10) + .instanceName("test") + .engine("mysql") + .build(); + + CloudResource instance1 = CloudResource.builder() + .resourceId("instance-1") + .resourceName("test-instance-1") + .build(); + + CloudResource instance2 = CloudResource.builder() + .resourceId("instance-2") + .resourceName("test-instance-2") + .build(); + + expectedPage = new PageImpl<>(List.of(instance1, instance2), PageRequest.of(0, 10), 2); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 목록 조회") + void listRdbmsInstances_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(discoveryPort.listRdbmsInstances(any(), eq(mockSession))).thenReturn(expectedPage); + + // When + Page result = rdbmsUseCaseService.listRdbmsInstances(AWS, ACCOUNT_SCOPE, query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).getResourceName()).isEqualTo("test-instance-1"); + + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(discoveryPort).listRdbmsInstances(any(), eq(mockSession)); + } + } + + @Test + @DisplayName("빈 목록 조회") + void listRdbmsInstances_EmptyResult() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + when(discoveryPort.listRdbmsInstances(any(), eq(mockSession))).thenReturn(emptyPage); + + // When + Page result = rdbmsUseCaseService.listRdbmsInstances(AWS, ACCOUNT_SCOPE, query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getContent()).isEmpty(); + + verify(discoveryPort).listRdbmsInstances(any(), eq(mockSession)); + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 조회 테스트") + class GetRdbmsInstanceTest { + + private CloudResource expectedInstance; + + @BeforeEach + void setUp() { + expectedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .displayName("Test RDBMS Instance") + .build(); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 조회") + void getRdbmsInstance_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)) + .thenReturn(Optional.of(expectedInstance)); + + // When + Optional result = rdbmsUseCaseService.getRdbmsInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getResourceId()).isEqualTo(INSTANCE_ID); + assertThat(result.get().getResourceName()).isEqualTo("test-instance"); + + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession); + } + } + + @Test + @DisplayName("존재하지 않는 인스턴스 조회 시 Optional.empty() 반환") + void getRdbmsInstance_NotFound_ReturnsEmpty() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)).thenReturn(Optional.empty()); + + // When + Optional result = rdbmsUseCaseService.getRdbmsInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + assertThat(result).isEmpty(); + + verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession); + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 생성 테스트") + class CreateRdbmsTest { + + private RdbmsCreateRequest request; + private CloudResource expectedInstance; + + @BeforeEach + void setUp() { + Map tags = new HashMap<>(); + tags.put("Environment", "test"); + tags.put("Project", "agenticcp"); + + request = RdbmsCreateRequest.builder() + .providerType(AWS) + .accountScope(ACCOUNT_SCOPE) + .region(REGION) + .instanceName("test-instance") + .engine("mysql") + .engineVersion("8.0") + .instanceSize("db.t3.micro") + .allocatedStorage(20) + .masterUsername("admin") + .masterPassword("password123") + .dbName("testdb") + .tags(tags) + .build(); + + expectedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .displayName("Test RDBMS Instance") + .build(); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 생성") + void createRdbms_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(encryptionService.encrypt("password123")).thenReturn("encrypted-password"); + when(managementPort.createRdbms(any())).thenReturn(expectedInstance); + when(resourceHelper.registerResource(any(), any(), any())).thenReturn(expectedInstance); + + // When + CloudResource result = rdbmsUseCaseService.createRdbms(request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(INSTANCE_ID); + assertThat(result.getResourceName()).isEqualTo("test-instance"); + + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.CREATE); + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(encryptionService).encrypt("password123"); + verify(managementPort).createRdbms(any()); + verify(resourceHelper).registerResource(eq(AWS), eq("RDS"), any()); + } + } + + @Test + @DisplayName("Capability 검증 실패 시 예외 발생") + void createRdbms_CapabilityCheckFailed_ThrowsException() { + // Given + doThrow(new RuntimeException("Capability not supported")) + .when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + + // When & Then + assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Capability not supported"); + + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.CREATE); + verify(credentialPort, never()).getSession(any(), any(), any()); + verify(managementPort, never()).createRdbms(any()); + } + + @Test + @DisplayName("암호화 실패 시 예외 발생") + void createRdbms_EncryptionFailed_ThrowsException() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(encryptionService.encrypt("password123")) + .thenThrow(new RuntimeException("Encryption failed")); + + // When & Then + assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Encryption failed"); + + verify(encryptionService).encrypt("password123"); + verify(managementPort, never()).createRdbms(any()); + } + } + + @Test + @DisplayName("DB 저장 실패 시 보상 트랜잭션 실행") + void createRdbms_DbSaveFailed_ExecutesCompensatingTransaction() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(encryptionService.encrypt("password123")).thenReturn("encrypted-password"); + when(managementPort.createRdbms(any())).thenReturn(expectedInstance); + when(resourceHelper.registerResource(any(), any(), any())) + .thenThrow(new RuntimeException("DB save failed")); + doNothing().when(managementPort).deleteRdbms(any()); + + // When & Then + assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("RDBMS 인스턴스 생성 후 DB 저장 실패"); + + verify(managementPort).createRdbms(any()); + verify(resourceHelper).registerResource(any(), any(), any()); + verify(managementPort).deleteRdbms(any()); // 보상 트랜잭션 확인 + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 수정 테스트") + class UpdateRdbmsTest { + + private RdbmsUpdateRequest request; + private CloudResource expectedInstance; + + @BeforeEach + void setUp() { + Map tags = new HashMap<>(); + tags.put("Environment", "production"); + tags.put("Updated", "true"); + + request = RdbmsUpdateRequest.builder() + .providerType(AWS) + .accountScope(ACCOUNT_SCOPE) + .instanceId(INSTANCE_ID) + .instanceSize("db.t3.small") + .allocatedStorage(50) + .tagsToAdd(tags) + .build(); + + expectedInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .displayName("Updated Test RDBMS Instance") + .build(); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 수정") + void updateRdbms_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(managementPort.updateRdbms(any())).thenReturn(expectedInstance); + + // When + CloudResource result = rdbmsUseCaseService.updateRdbms(request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(INSTANCE_ID); + assertThat(result.getDisplayName()).isEqualTo("Updated Test RDBMS Instance"); + + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.UPDATE); + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(managementPort).updateRdbms(any()); + } + } + + @Test + @DisplayName("패스워드 변경 포함 수정") + void updateRdbms_WithPasswordChange_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + request.setMasterPassword("newpassword123"); + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(encryptionService.encrypt("newpassword123")).thenReturn("encrypted-new-password"); + when(managementPort.updateRdbms(any())).thenReturn(expectedInstance); + + // When + CloudResource result = rdbmsUseCaseService.updateRdbms(request); + + // Then + assertThat(result).isNotNull(); + verify(encryptionService).encrypt("newpassword123"); + verify(managementPort).updateRdbms(any()); + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 삭제 테스트") + class DeleteRdbmsTest { + + private RdbmsDeleteRequest request; + + @BeforeEach + void setUp() { + request = RdbmsDeleteRequest.builder() + .providerType(AWS) + .accountScope(ACCOUNT_SCOPE) + .instanceId(INSTANCE_ID) + .skipSnapshot(false) + .snapshotName("final-snapshot") + .deleteAutomatedBackups(false) + .build(); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 삭제") + void deleteRdbms_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + doNothing().when(managementPort).deleteRdbms(any()); + doNothing().when(resourceHelper).softDeleteResource(INSTANCE_ID); + + // When + rdbmsUseCaseService.deleteRdbms(request); + + // Then + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.TERMINATE); + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(managementPort).deleteRdbms(any()); + verify(resourceHelper).softDeleteResource(INSTANCE_ID); + } + } + + @Test + @DisplayName("Capability 검증 실패 시 예외 발생") + void deleteRdbms_CapabilityCheckFailed_ThrowsException() { + // Given + doThrow(new RuntimeException("Delete capability not supported")) + .when(capabilityGuard).ensureSupported(any(), any(), any(), any()); + + // When & Then + assertThatThrownBy(() -> rdbmsUseCaseService.deleteRdbms(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Delete capability not supported"); + + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.TERMINATE); + verify(credentialPort, never()).getSession(any(), any(), any()); + verify(managementPort, never()).deleteRdbms(any()); + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 시작 테스트") + class StartInstanceTest { + + private CloudResource mockInstance; + + @BeforeEach + void setUp() { + mockInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 시작") + void startInstance_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)) + .thenReturn(Optional.of(mockInstance)); + doNothing().when(lifecyclePort).start(any(ResourceIdentity.class), eq(mockSession)); + doNothing().when(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + + // When + rdbmsUseCaseService.startInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.START); + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession); + verify(lifecyclePort).start(any(ResourceIdentity.class), eq(mockSession)); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 중지 테스트") + class StopInstanceTest { + + private CloudResource mockInstance; + + @BeforeEach + void setUp() { + mockInstance = CloudResource.builder() + .resourceId(INSTANCE_ID) + .resourceName("test-instance") + .build(); + } + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 중지") + void stopInstance_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)) + .thenReturn(Optional.of(mockInstance)); + doNothing().when(lifecyclePort).stop(any(ResourceIdentity.class), eq(mockSession)); + doNothing().when(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.STOPPED); + + // When + rdbmsUseCaseService.stopInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.STOP); + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession); + verify(lifecyclePort).stop(any(ResourceIdentity.class), eq(mockSession)); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.STOPPED); + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 재시작 테스트") + class RebootInstanceTest { + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 재시작") + void rebootInstance_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + doNothing().when(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession); + doNothing().when(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + + // When + rdbmsUseCaseService.rebootInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.STOP); + verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.START); + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession); + verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING); + } + } + } + + @Nested + @DisplayName("RDBMS 인스턴스 상태 확인 테스트") + class GetInstanceStatusTest { + + @Test + @DisplayName("정상적인 RDBMS 인스턴스 상태 확인") + void getInstanceStatus_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession); + when(discoveryPort.getInstanceStatus(INSTANCE_ID, mockSession)).thenReturn("available"); + + // When + String result = rdbmsUseCaseService.getInstanceStatus(AWS, ACCOUNT_SCOPE, INSTANCE_ID); + + // Then + assertThat(result).isEqualTo("available"); + + verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS); + verify(discoveryPort).getInstanceStatus(INSTANCE_ID, mockSession); + } + } + } +}