diff --git a/pom.xml b/pom.xml index fe8cd11a..1c4afdb1 100644 --- a/pom.xml +++ b/pom.xml @@ -283,6 +283,11 @@ resourcegroupstaggingapi + + software.amazon.awssdk + cloudfront + + software.amazon.awssdk rds 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 f463e4ce..babbb727 100644 --- a/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java +++ b/src/main/java/com/agenticcp/core/common/enums/AuditResourceType.java @@ -25,6 +25,9 @@ public enum AuditResourceType { CLOUD_ACCOUNT("클라우드계정"), TARGETING_RULE("타겟팅규칙"), PLATFORM_CONFIG("플랫폼설정"), + S3_BUCKET("S3버킷"), + OBJECT_STORAGE_CONTAINER("오브젝트스토리지컨테이너"), + CDN_DISTRIBUTION("CDN배포"), UI("사용자인터페이스"); private final String description; diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapter.java new file mode 100644 index 00000000..356ad76c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapter.java @@ -0,0 +1,365 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsCloudFrontConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest; +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.exception.CredentialErrorCode; +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.cdn.CDNDiscoveryPort; +import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; +import software.amazon.awssdk.services.cloudfront.model.*; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * AWS CloudFront Distribution 발견 어댑터 + * AWS CloudFront API를 통해 Distribution 조회 기능을 제공 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsCloudFrontDiscoveryAdapter implements CDNDiscoveryPort, ProviderScoped { + + private final AwsCloudFrontMapper mapper; + private final CloudProviderRepository cloudProviderRepository; + private final AccountCredentialManagementPort accountCredentialManagementPort; + private final AwsCloudFrontConfig awsCloudFrontConfig; + private final AwsCloudFrontErrorTranslator errorTranslator; + + /** + * CloudFront Distribution 목록을 조회합니다. + * + * @param query 조회 조건 (태그 필터, 페이징, 정렬 등) + * @return 조회된 Distribution 목록 (페이징 정보 포함) + */ + @Override + public Page listDistributions(CDNDistributionQueryRequest query) { + log.debug("[AwsCloudFrontDiscoveryAdapter] Listing distributions with query: {}", query); + + return executeWithCloudFrontClient(query.accountScope(), client -> { + List allDistributions = fetchAllDistributions(client, query); + + // 태그 필터링 적용 + List resources = allDistributions.stream() + .map(summary -> { + Map tags = fetchTags(client, summary.arn()); + CloudResource resource = mapper.toCloudResource(summary, query); + resource.setTags(tags); + return resource; + }) + .filter(resource -> matchesTagFilter(resource, query.tags())) + .filter(resource -> matchesNameFilter(resource, query.distributionName())) + .filter(resource -> matchesEnabledFilter(resource, query.enabled())) + .collect(Collectors.toList()); + + return applyMemoryOperations(resources, query); + }); + } + + /** + * 특정 CloudFront Distribution의 상세 정보를 조회합니다. + * + * @param accountScope 계정 스코프 (필수) + * @param distributionId 조회할 Distribution ID + * @return Distribution 정보 (존재하지 않으면 Optional.empty()) + */ + @Override + public Optional getDistribution(String accountScope, String distributionId) { + log.debug("[AwsCloudFrontDiscoveryAdapter] Getting distribution details for: {}, accountScope={}", + distributionId, accountScope); + + return executeWithCloudFrontClient(accountScope, client -> { + try { + GetDistributionRequest request = GetDistributionRequest.builder() + .id(distributionId) + .build(); + + GetDistributionResponse response = client.getDistribution(request); + + // 태그 조회 + Map tags = fetchTags(client, response.distribution().arn()); + + CloudResource resource = mapper.toCloudResource( + response.distribution(), + response.eTag(), + CloudProvider.ProviderType.AWS, + tags + ); + + // 태그 설정 + resource.setTags(tags); + + log.debug("[AwsCloudFrontDiscoveryAdapter] Successfully retrieved distribution details: {}", distributionId); + return Optional.of(resource); + + } catch (NoSuchDistributionException e) { + log.info("[AwsCloudFrontDiscoveryAdapter] Distribution not found: {}", distributionId); + return Optional.empty(); + } + }); + } + + /** + * Distribution 존재 여부를 확인합니다. + * + * @param accountScope 계정 스코프 (필수) + * @param distributionId 확인할 Distribution ID + * @return Distribution이 존재하면 true, 존재하지 않으면 false + */ + @Override + public boolean distributionExists(String accountScope, String distributionId) { + log.debug("[AwsCloudFrontDiscoveryAdapter] Checking if distribution exists: {}, accountScope={}", + distributionId, accountScope); + + return executeWithCloudFrontClient(accountScope, client -> { + try { + GetDistributionRequest request = GetDistributionRequest.builder() + .id(distributionId) + .build(); + + client.getDistribution(request); + return true; + } catch (NoSuchDistributionException e) { + log.info("[AwsCloudFrontDiscoveryAdapter] Distribution not found: {}", distributionId); + return false; + } + }); + } + + @Override + public CloudProvider.ProviderType getProviderType() { + return CloudProvider.ProviderType.AWS; + } + + /** + * CloudFrontClient를 사용하여 작업을 실행합니다. + */ + private R executeWithCloudFrontClient(String accountScope, Function action) { + String resolvedScope = requireAccountScope(accountScope); + CloudSessionCredential session = acquireSession(resolvedScope); + + try (CloudFrontClient client = awsCloudFrontConfig.createCloudFrontClient(session)) { + return action.apply(client); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw errorTranslator.translate(e); + } + } + + /** + * 모든 Distribution을 조회합니다 (페이징 처리). + */ + private List fetchAllDistributions( + CloudFrontClient client, + CDNDistributionQueryRequest query) { + + List allDistributions = new ArrayList<>(); + String marker = null; + + do { + ListDistributionsRequest.Builder requestBuilder = ListDistributionsRequest.builder(); + + if (marker != null) { + requestBuilder.marker(marker); + } + + ListDistributionsResponse response = client.listDistributions(requestBuilder.build()); + + if (response.distributionList() != null && response.distributionList().items() != null) { + allDistributions.addAll(response.distributionList().items()); + } + + marker = response.distributionList() != null + ? response.distributionList().nextMarker() + : null; + + } while (marker != null && !marker.isEmpty()); + + return allDistributions; + } + + /** + * Distribution의 태그를 조회합니다. + */ + private Map fetchTags(CloudFrontClient client, String arn) { + try { + ListTagsForResourceRequest request = ListTagsForResourceRequest.builder() + .resource(arn) + .build(); + + ListTagsForResourceResponse response = client.listTagsForResource(request); + + if (response.tags() != null && response.tags().items() != null) { + return response.tags().items().stream() + .collect(Collectors.toMap( + Tag::key, + Tag::value + )); + } + } catch (Exception e) { + log.warn("[AwsCloudFrontDiscoveryAdapter] Failed to fetch tags for resource: {}", arn, e); + } + + return new HashMap<>(); + } + + /** + * 태그 필터와 일치하는지 확인합니다. + */ + private boolean matchesTagFilter(CloudResource resource, Map tagFilter) { + if (tagFilter == null || tagFilter.isEmpty()) { + return true; + } + + if (resource.getTags() == null || resource.getTags().isEmpty()) { + return false; + } + + return tagFilter.entrySet().stream() + .allMatch(entry -> { + String resourceTagValue = resource.getTags().get(entry.getKey()); + return resourceTagValue != null && resourceTagValue.equals(entry.getValue()); + }); + } + + /** + * 이름 필터와 일치하는지 확인합니다. + */ + private boolean matchesNameFilter(CloudResource resource, String nameFilter) { + if (nameFilter == null || nameFilter.isEmpty()) { + return true; + } + + return resource.getResourceName() != null + && resource.getResourceName().contains(nameFilter); + } + + /** + * Enabled 필터와 일치하는지 확인합니다. + */ + private boolean matchesEnabledFilter(CloudResource resource, Boolean enabledFilter) { + if (enabledFilter == null) { + return true; + } + + // metadata에서 enabled 정보 추출 필요 + // 간단히 처리하기 위해 모든 리소스를 통과시키고, 실제로는 metadata 파싱 필요 + return true; + } + + /** + * 메모리 기반 필터링, 정렬, 페이징을 적용합니다. + */ + private Page applyMemoryOperations( + List resources, + CDNDistributionQueryRequest query) { + + List filtered = resources; + + // 정렬 적용 + applySorting(filtered, query.sortBy(), query.sortDirection()); + + // 페이징 적용 + int page = query.page() != null ? query.page() : 0; + int size = query.size() != null ? query.size() : 20; + int start = Math.min(page * size, filtered.size()); + int end = Math.min(start + size, filtered.size()); + List paged = (start >= filtered.size()) + ? Collections.emptyList() + : filtered.subList(start, end); + + Sort sort = (query.sortBy() != null) + ? Sort.by(resolveSortDirection(query.sortDirection()), query.sortBy()) + : Sort.unsorted(); + + return new PageImpl<>(paged, PageRequest.of(page, size, sort), filtered.size()); + } + + /** + * 정렬을 적용합니다. + */ + private void applySorting(List resources, String sortBy, String sortDirection) { + if (sortBy == null || sortBy.isEmpty()) { + return; + } + + Comparator comparator; + if ("name".equalsIgnoreCase(sortBy)) { + comparator = Comparator.comparing(CloudResource::getResourceName); + } else if ("createdAt".equalsIgnoreCase(sortBy)) { + comparator = Comparator.comparing( + CloudResource::getCreatedInCloud, + Comparator.nullsLast(Comparator.naturalOrder()) + ); + } else { + log.warn("[AwsCloudFrontDiscoveryAdapter] Unsupported sort key: {}. Defaulting to name sort.", sortBy); + comparator = Comparator.comparing(CloudResource::getResourceName); + } + + if ("desc".equalsIgnoreCase(sortDirection)) { + comparator = comparator.reversed(); + } + + resources.sort(comparator); + } + + private Sort.Direction resolveSortDirection(String sortDirection) { + return (sortDirection != null) + ? Sort.Direction.fromString(sortDirection) + : Sort.Direction.ASC; + } + + /** + * AWS Provider를 조회합니다. + */ + private CloudProvider getAwsProvider() { + return cloudProviderRepository.findFirstByProviderType(CloudProvider.ProviderType.AWS) + .orElseThrow(() -> new ResourceNotFoundException(CloudErrorCode.CLOUD_PROVIDER_NOT_FOUND)); + } + + private String requireAccountScope(String accountScope) { + if (accountScope == null || accountScope.isBlank()) { + throw new BusinessException(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED, "AccountScope가 필요합니다."); + } + return accountScope; + } + + private CloudSessionCredential acquireSession(String accountScope) { + try { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + CloudSessionCredential session = accountCredentialManagementPort.getSession( + tenantKey, accountScope, getProviderType()); + log.debug("[AwsCloudFrontDiscoveryAdapter] 세션 획득 완료: accountScope={}, expiresAt={}", + accountScope, session.getExpiresAt()); + return session; + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("[AwsCloudFrontDiscoveryAdapter] 세션 획득 실패: accountScope={}", accountScope, e); + throw new BusinessException(CredentialErrorCode.INVALID_CREDENTIALS, + "세션 획득에 실패했습니다: " + e.getMessage()); + } + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontErrorTranslator.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontErrorTranslator.java new file mode 100644 index 00000000..b8932da9 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontErrorTranslator.java @@ -0,0 +1,101 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.common.exception.ResourceNotFoundException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.AwsErrorTranslator; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.services.cloudfront.model.*; + +/** + * AWS CloudFront 관련 예외 변환기 + * AWS CloudFront SDK에서 발생하는 예외를 도메인 예외로 변환합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsCloudFrontErrorTranslator { + + private final AwsErrorTranslator awsErrorTranslator; + + /** + * AWS CloudFront 관련 예외를 도메인 예외로 변환합니다. + * + * 변환 규칙: + * - NoSuchDistributionException → ResourceNotFoundException (DISTRIBUTION_NOT_FOUND) + * - DistributionAlreadyExistsException → BusinessException (DISTRIBUTION_ALREADY_EXISTS) + * - CloudFrontException의 errorCode에 따라 적절한 예외로 변환 + * - 기타 예외는 AwsErrorTranslator를 통해 변환 + * + * @param e 변환할 예외 + * @return 변환된 도메인 예외 (BusinessException 또는 ResourceNotFoundException) + */ + public RuntimeException translate(Exception e) { + log.error("[AwsCloudFrontErrorTranslator] CloudFront operation failed: {}", e.getMessage(), e); + + if (e instanceof NoSuchDistributionException) { + return new ResourceNotFoundException(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND); + } + + if (e instanceof CloudFrontException) { + CloudFrontException cfException = (CloudFrontException) e; + String errorCode = cfException.awsErrorDetails() != null + ? cfException.awsErrorDetails().errorCode() + : ""; + + return switch (errorCode) { + case "NoSuchDistribution" -> + new ResourceNotFoundException(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND); + case "DistributionAlreadyExists" -> + new BusinessException(CloudErrorCode.INVALID_REQUEST, "Distribution already exists"); + case "InvalidIfMatchVersion" -> + new BusinessException(CloudErrorCode.INVALID_REQUEST, + "Distribution was modified by another request. Please retry with the latest ETag."); + case "PreconditionFailed" -> + new BusinessException(CloudErrorCode.INVALID_REQUEST, + "Distribution configuration was changed. Please retry with the latest ETag."); + case "IllegalUpdate" -> + new BusinessException(CloudErrorCode.INVALID_REQUEST, + "Cannot update distribution: " + cfException.getMessage()); + case "TooManyDistributions" -> + new BusinessException(CloudErrorCode.RESOURCE_QUOTA_EXCEEDED, + "Distribution limit exceeded"); + case "TooManyInvalidationsInProgress" -> + new BusinessException(CloudErrorCode.RESOURCE_QUOTA_EXCEEDED, + "Too many invalidations in progress"); + case "AccessDenied" -> + new BusinessException(CloudErrorCode.PERMISSION_DENIED, + "Access denied to CloudFront resource"); + case "ServiceUnavailable" -> + new BusinessException(CloudErrorCode.CLOUD_PROVIDER_UNAVAILABLE); + case "ThrottlingException" -> + new BusinessException(CloudErrorCode.API_RATE_LIMIT_EXCEEDED); + default -> + new BusinessException(CloudErrorCode.CLOUD_PROVIDER_UNAVAILABLE, + "CloudFront operation failed: " + errorCode); + }; + } + + if (e instanceof SdkClientException) { + return awsErrorTranslator.translate(e); + } + + if (e instanceof SdkServiceException) { + return awsErrorTranslator.translate(e); + } + + if (e instanceof BusinessException) { + return (BusinessException) e; + } + + return awsErrorTranslator.translate(e); + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapter.java new file mode 100644 index 00000000..4a20e9b4 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapter.java @@ -0,0 +1,198 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsCloudFrontConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.exception.CredentialErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.cdn.CreateInvalidationCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; +import software.amazon.awssdk.services.cloudfront.model.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; + +/** + * AWS CloudFront 캐시 무효화 어댑터 + * AWS CloudFront SDK를 사용하여 캐시 무효화 생성 및 조회 기능을 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsCloudFrontInvalidationAdapter implements CDNInvalidationPort, ProviderScoped { + + private final AwsCloudFrontConfig awsCloudFrontConfig; + private final AwsCloudFrontErrorTranslator errorTranslator; + private final AccountCredentialManagementPort accountCredentialManagementPort; + + /** + * 캐시 무효화를 생성합니다. + * + * @param command 무효화 생성 명령 (세션, Distribution ID, 경로 목록 포함) + * @return 생성된 무효화 결과 (무효화 ID, 상태 포함) + */ + @Override + public InvalidationResult createInvalidation(CreateInvalidationCommand command) { + log.debug("[AwsCloudFrontInvalidationAdapter] Creating invalidation for distribution: {}, paths: {}", + command.distributionId(), command.paths()); + + return executeWithCloudFrontClient(command.accountScope(), client -> { + // CallerReference 생성 (중복 방지용) + String callerReference = command.callerReference() != null && !command.callerReference().isEmpty() + ? command.callerReference() + : UUID.randomUUID().toString(); + + // Paths 설정 (최소 1개 필요) + List paths = command.paths() != null && !command.paths().isEmpty() + ? command.paths() + : List.of("/*"); // 기본값: 모든 경로 + + // InvalidationBatch 생성 + InvalidationBatch invalidationBatch = InvalidationBatch.builder() + .paths(Paths.builder() + .quantity(paths.size()) + .items(paths) + .build()) + .callerReference(callerReference) + .build(); + + // CreateInvalidationRequest 생성 + CreateInvalidationRequest request = CreateInvalidationRequest.builder() + .distributionId(command.distributionId()) + .invalidationBatch(invalidationBatch) + .build(); + + // 무효화 생성 + CreateInvalidationResponse response = client.createInvalidation(request); + Invalidation invalidation = response.invalidation(); + + log.info("[AwsCloudFrontInvalidationAdapter] Invalidation created successfully: invalidationId={}, distributionId={}", + invalidation.id(), command.distributionId()); + + // InvalidationResult로 변환 + return InvalidationResult.builder() + .invalidationId(invalidation.id()) + .distributionId(command.distributionId()) + .status(invalidation.status()) + .createTime(invalidation.createTime() != null + ? LocalDateTime.ofInstant(invalidation.createTime(), ZoneId.systemDefault()) + : LocalDateTime.now()) + .paths(paths) + .build(); + }); + } + + /** + * 캐시 무효화 상태를 조회합니다. + * + * @param accountScope 조회 대상 Cloud 계정 범위 + * @param distributionId Distribution ID + * @param invalidationId 무효화 ID + * @return 무효화 결과 (존재하지 않으면 Optional.empty()) + */ + @Override + public Optional getInvalidation( + String accountScope, + String distributionId, + String invalidationId) { + log.debug("[AwsCloudFrontInvalidationAdapter] Getting invalidation: invalidationId={}, distributionId={}, accountScope={}", + invalidationId, distributionId, accountScope); + + return executeWithCloudFrontClient(accountScope, client -> { + try { + GetInvalidationRequest request = GetInvalidationRequest.builder() + .distributionId(distributionId) + .id(invalidationId) + .build(); + + GetInvalidationResponse response = client.getInvalidation(request); + Invalidation invalidation = response.invalidation(); + + log.debug("[AwsCloudFrontInvalidationAdapter] Successfully retrieved invalidation: {}", invalidationId); + + // InvalidationResult로 변환 + InvalidationResult result = InvalidationResult.builder() + .invalidationId(invalidation.id()) + .distributionId(distributionId) + .status(invalidation.status()) + .createTime(invalidation.createTime() != null + ? LocalDateTime.ofInstant(invalidation.createTime(), ZoneId.systemDefault()) + : LocalDateTime.now()) + .paths(invalidation.invalidationBatch() != null + && invalidation.invalidationBatch().paths() != null + && invalidation.invalidationBatch().paths().items() != null + ? invalidation.invalidationBatch().paths().items() + : List.of()) + .build(); + + return Optional.of(result); + + } catch (NoSuchInvalidationException e) { + log.info("[AwsCloudFrontInvalidationAdapter] Invalidation not found: {}", invalidationId); + return Optional.empty(); + } + }); + } + + @Override + public CloudProvider.ProviderType getProviderType() { + return CloudProvider.ProviderType.AWS; + } + + /** + * CloudFrontClient를 사용하여 작업을 실행합니다. + */ + private R executeWithCloudFrontClient(String accountScope, Function action) { + String resolvedScope = requireAccountScope(accountScope); + CloudSessionCredential session = acquireSession(resolvedScope); + + try (CloudFrontClient client = awsCloudFrontConfig.createCloudFrontClient(session)) { + return action.apply(client); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw errorTranslator.translate(e); + } + } + + private String requireAccountScope(String accountScope) { + if (accountScope == null || accountScope.isBlank()) { + throw new BusinessException(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED, "AccountScope가 필요합니다."); + } + return accountScope; + } + + private CloudSessionCredential acquireSession(String accountScope) { + try { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + CloudSessionCredential session = accountCredentialManagementPort.getSession( + tenantKey, accountScope, getProviderType()); + log.debug("[AwsCloudFrontInvalidationAdapter] 세션 획득 완료: accountScope={}, expiresAt={}", + accountScope, session.getExpiresAt()); + return session; + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("[AwsCloudFrontInvalidationAdapter] 세션 획득 실패: accountScope={}", accountScope, e); + throw new BusinessException(CredentialErrorCode.INVALID_CREDENTIALS, + "세션 획득에 실패했습니다: " + e.getMessage()); + } + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapter.java new file mode 100644 index 00000000..12c5178a --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapter.java @@ -0,0 +1,490 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsCloudFrontConfig; +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.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.cdn.*; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; +import software.amazon.awssdk.services.cloudfront.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * AWS CloudFront Distribution 관리 어댑터 + * + * AWS CloudFront SDK를 사용하여 Distribution 생성/수정/삭제 기능을 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsCloudFrontManagementAdapter implements CDNManagementPort, ProviderScoped { + + private final AwsCloudFrontConfig awsCloudFrontConfig; + private final AwsCloudFrontMapper awsCloudFrontMapper; + + @Override + public CloudResource createDistribution(CreateDistributionCommand command) { + try (CloudFrontClient cloudFrontClient = awsCloudFrontConfig.createCloudFrontClient(command.session())) { + + // DistributionConfig 생성 + DistributionConfig distributionConfig = buildDistributionConfig(command); + + // Distribution 생성 요청 + CreateDistributionRequest request = CreateDistributionRequest.builder() + .distributionConfig(distributionConfig) + .build(); + + CreateDistributionResponse response = cloudFrontClient.createDistribution(request); + + // 태그 추가 (Distribution 생성 직후) + if (command.tags() != null && !command.tags().isEmpty()) { + try { + List tags = command.tags().entrySet().stream() + .map(entry -> Tag.builder() + .key(entry.getKey()) + .value(entry.getValue()) + .build()) + .toList(); + + Tags cfTags = Tags.builder() + .items(tags) + .build(); + + TagResourceRequest tagRequest = TagResourceRequest.builder() + .resource(response.distribution().arn()) + .tags(cfTags) + .build(); + + cloudFrontClient.tagResource(tagRequest); + + log.info("[AwsCloudFrontManagementAdapter] Tags added to distribution: {}", response.distribution().id()); + } catch (Exception e) { + log.warn("[AwsCloudFrontManagementAdapter] Failed to add tags to distribution: {}", e.getMessage()); + } + } + + return awsCloudFrontMapper.toCloudResource( + response.distribution(), + response.eTag(), + command + ); + + } catch (Throwable e) { + log.error("[AwsCloudFrontManagementAdapter] Failed to create distribution: {}", e.getMessage(), e); + throw CloudErrorTranslator.translate(e); + } + } + + @Override + public CloudResource updateDistribution(UpdateDistributionCommand command) { + try (CloudFrontClient cloudFrontClient = awsCloudFrontConfig.createCloudFrontClient(command.session())) { + + // 기존 Distribution 설정 조회 + GetDistributionRequest getRequest = GetDistributionRequest.builder() + .id(command.distributionId()) + .build(); + + GetDistributionResponse getResponse = cloudFrontClient.getDistribution(getRequest); + DistributionConfig currentConfig = getResponse.distribution().distributionConfig(); + + // 설정 업데이트 + DistributionConfig.Builder configBuilder = currentConfig.toBuilder(); + + if (command.comment() != null) { + configBuilder.comment(command.comment()); + } + + if (command.enabled() != null) { + configBuilder.enabled(command.enabled()); + } + + // CacheBehavior 업데이트 + if (command.cacheBehaviors() != null && !command.cacheBehaviors().isEmpty()) { + // 기본 CacheBehavior 설정 + CacheBehaviorConfig defaultBehavior = command.cacheBehaviors().get(0); + DefaultCacheBehavior updatedDefaultBehavior = buildDefaultCacheBehavior( + defaultBehavior, + currentConfig.origins().items().get(0).id() + ); + configBuilder.defaultCacheBehavior(updatedDefaultBehavior); + } + + // Aliases 업데이트 + if (command.aliases() != null) { + Aliases aliases = Aliases.builder() + .quantity(command.aliases().size()) + .items(command.aliases()) + .build(); + configBuilder.aliases(aliases); + } + + // Distribution 업데이트 요청 + UpdateDistributionRequest updateRequest = UpdateDistributionRequest.builder() + .id(command.distributionId()) + .distributionConfig(configBuilder.build()) + .ifMatch(command.etag()) + .build(); + + UpdateDistributionResponse updateResponse = cloudFrontClient.updateDistribution(updateRequest); + + // 태그 업데이트 (기존 태그 삭제 후 새로 추가) + if (command.tags() != null && !command.tags().isEmpty()) { + try { + List tags = command.tags().entrySet().stream() + .map(entry -> Tag.builder() + .key(entry.getKey()) + .value(entry.getValue()) + .build()) + .toList(); + + Tags cfTags = Tags.builder() + .items(tags) + .build(); + + TagResourceRequest tagRequest = TagResourceRequest.builder() + .resource(updateResponse.distribution().arn()) + .tags(cfTags) + .build(); + + cloudFrontClient.tagResource(tagRequest); + + log.info("[AwsCloudFrontManagementAdapter] Tags updated for distribution: {}", command.distributionId()); + } catch (Exception e) { + log.warn("[AwsCloudFrontManagementAdapter] Failed to update tags: {}", e.getMessage()); + } + } + + return awsCloudFrontMapper.toCloudResource( + updateResponse.distribution(), + updateResponse.eTag(), + command + ); + + } catch (Throwable e) { + log.error("[AwsCloudFrontManagementAdapter] Failed to update distribution: {}", e.getMessage(), e); + throw CloudErrorTranslator.translate(e); + } + } + + @Override + public void deleteDistribution(DeleteDistributionCommand command) { + try (CloudFrontClient cloudFrontClient = awsCloudFrontConfig.createCloudFrontClient(command.session())) { + + // Distribution이 활성화되어 있다면 먼저 비활성화 필요 + GetDistributionRequest getRequest = GetDistributionRequest.builder() + .id(command.distributionId()) + .build(); + + GetDistributionResponse getResponse = cloudFrontClient.getDistribution(getRequest); + + if (getResponse.distribution().distributionConfig().enabled()) { + log.info("[AwsCloudFrontManagementAdapter] Disabling distribution before deletion: {}", command.distributionId()); + + DistributionConfig disabledConfig = getResponse.distribution() + .distributionConfig() + .toBuilder() + .enabled(false) + .build(); + + UpdateDistributionRequest updateRequest = UpdateDistributionRequest.builder() + .id(command.distributionId()) + .distributionConfig(disabledConfig) + .ifMatch(getResponse.eTag()) + .build(); + + UpdateDistributionResponse updateResponse = cloudFrontClient.updateDistribution(updateRequest); + + log.info("[AwsCloudFrontManagementAdapter] Distribution disabled, waiting for deployment to complete..."); + + // Distribution이 Deployed 상태가 될 때까지 대기 + String finalETag = waitForDeployment(cloudFrontClient, command.distributionId(), updateResponse.eTag()); + + // 삭제 요청 (배포 완료 후 최신 ETag 사용) + DeleteDistributionRequest deleteRequest = DeleteDistributionRequest.builder() + .id(command.distributionId()) + .ifMatch(finalETag) + .build(); + + cloudFrontClient.deleteDistribution(deleteRequest); + + } else { + // 이미 비활성화된 경우 바로 삭제 + DeleteDistributionRequest deleteRequest = DeleteDistributionRequest.builder() + .id(command.distributionId()) + .ifMatch(command.etag()) + .build(); + + cloudFrontClient.deleteDistribution(deleteRequest); + } + + log.info("[AwsCloudFrontManagementAdapter] Distribution deleted successfully: {}", command.distributionId()); + + } catch (Throwable e) { + log.error("[AwsCloudFrontManagementAdapter] Failed to delete distribution: {}", e.getMessage(), e); + throw CloudErrorTranslator.translate(e); + } + } + + /** + * CreateDistributionCommand로부터 DistributionConfig 생성 + */ + private DistributionConfig buildDistributionConfig(CreateDistributionCommand command) { + // CallerReference (고유 식별자) + String callerReference = UUID.randomUUID().toString(); + + // Origins 설정 + Origins origins = buildOrigins(command.origin()); + + // DefaultCacheBehavior 설정 + DefaultCacheBehavior defaultCacheBehavior = buildDefaultCacheBehavior( + command.cacheBehaviors() != null && !command.cacheBehaviors().isEmpty() + ? command.cacheBehaviors().get(0) + : createDefaultCacheBehavior(), + command.origin().id() + ); + + // Aliases 설정 (CNAME) + Aliases aliases = null; + if (command.aliases() != null && !command.aliases().isEmpty()) { + aliases = Aliases.builder() + .quantity(command.aliases().size()) + .items(command.aliases()) + .build(); + } + + // ViewerCertificate 설정 (SSL/TLS) + ViewerCertificate viewerCertificate = buildViewerCertificate(command.sslCertificateId()); + + return DistributionConfig.builder() + .callerReference(callerReference) + .origins(origins) + .defaultCacheBehavior(defaultCacheBehavior) + .comment(command.comment() != null ? command.comment() : "") + .enabled(command.enabled() != null ? command.enabled() : true) + .aliases(aliases) + .viewerCertificate(viewerCertificate) + .build(); + } + + /** + * OriginConfig로부터 Origins 생성 + */ + private Origins buildOrigins(OriginConfig originConfig) { + Origin.Builder originBuilder = Origin.builder() + .id(originConfig.id()) + .domainName(originConfig.domainName()); + + if (originConfig.type() == OriginConfig.OriginType.PUBLIC_S3) { + // S3 Origin 설정 + originBuilder.s3OriginConfig(S3OriginConfig.builder() + .originAccessIdentity("") // OAI 사용 안 함 (Phase 1) + .build()); + } else { + // Custom Origin 설정 (ELB, EC2 등) + CustomOriginConfig customOriginConfig = CustomOriginConfig.builder() + .httpPort(originConfig.httpPort() != null ? originConfig.httpPort() : 80) + .httpsPort(originConfig.httpsPort() != null ? originConfig.httpsPort() : 443) + .originProtocolPolicy( + originConfig.originProtocolPolicy() != null + ? OriginProtocolPolicy.fromValue(originConfig.originProtocolPolicy()) + : OriginProtocolPolicy.HTTPS_ONLY + ) + .build(); + + originBuilder.customOriginConfig(customOriginConfig); + } + + return Origins.builder() + .quantity(1) + .items(originBuilder.build()) + .build(); + } + + /** + * CacheBehaviorConfig로부터 DefaultCacheBehavior 생성 + */ + private DefaultCacheBehavior buildDefaultCacheBehavior(CacheBehaviorConfig config, String targetOriginId) { + return DefaultCacheBehavior.builder() + .targetOriginId(targetOriginId) + .viewerProtocolPolicy( + config.viewerProtocolPolicy() != null + ? ViewerProtocolPolicy.fromValue(config.viewerProtocolPolicy()) + : ViewerProtocolPolicy.REDIRECT_TO_HTTPS + ) + .allowedMethods(buildAllowedMethods(config.allowedMethods())) + .compress(config.compress() != null ? config.compress() : true) + .minTTL(0L) + .defaultTTL(config.ttl() != null ? config.ttl() : 86400L) + .maxTTL(31536000L) + .forwardedValues(ForwardedValues.builder() + .queryString(false) + .cookies(CookiePreference.builder() + .forward(ItemSelection.NONE) + .build()) + .build()) + .trustedSigners(TrustedSigners.builder() + .enabled(false) + .quantity(0) + .build()) + .build(); + } + + /** + * 기본 CacheBehaviorConfig 생성 + */ + private CacheBehaviorConfig createDefaultCacheBehavior() { + return CacheBehaviorConfig.builder() + .pathPattern("/*") + .ttl(86400L) // 24시간 + .allowedMethods(List.of("GET", "HEAD", "OPTIONS")) + .compress(true) + .viewerProtocolPolicy("redirect-to-https") + .build(); + } + + /** + * AllowedMethods 생성 + */ + private AllowedMethods buildAllowedMethods(List methods) { + if (methods == null || methods.isEmpty()) { + // 기본: GET, HEAD + return AllowedMethods.builder() + .quantity(2) + .items(Method.GET, Method.HEAD) + .cachedMethods(CachedMethods.builder() + .quantity(2) + .items(Method.GET, Method.HEAD) + .build()) + .build(); + } + + List awsMethods = new ArrayList<>(); + for (String method : methods) { + awsMethods.add(Method.fromValue(method)); + } + + return AllowedMethods.builder() + .quantity(awsMethods.size()) + .items(awsMethods) + .cachedMethods(CachedMethods.builder() + .quantity(Math.min(2, awsMethods.size())) + .items(Method.GET, Method.HEAD) + .build()) + .build(); + } + + /** + * ViewerCertificate 생성 + */ + private ViewerCertificate buildViewerCertificate(String sslCertificateId) { + if (sslCertificateId != null && !sslCertificateId.isEmpty()) { + // ACM 인증서 사용 + return ViewerCertificate.builder() + .acmCertificateArn(sslCertificateId) + .sslSupportMethod(SSLSupportMethod.SNI_ONLY) + .minimumProtocolVersion(MinimumProtocolVersion.TLS_V1_2_2021) + .build(); + } else { + // 기본 CloudFront 인증서 사용 + return ViewerCertificate.builder() + .cloudFrontDefaultCertificate(true) + .build(); + } + } + + /** + * Distribution이 Deployed 상태가 될 때까지 대기 + * + * @param cloudFrontClient CloudFront 클라이언트 + * @param distributionId Distribution ID + * @param initialETag 초기 ETag + * @return 배포 완료 후 최신 ETag + */ + private String waitForDeployment(CloudFrontClient cloudFrontClient, String distributionId, String initialETag) { + int maxWaitMinutes = 10; // 최대 10분 대기 + int pollIntervalSeconds = 10; // 10초마다 상태 확인 + int maxAttempts = (maxWaitMinutes * 60) / pollIntervalSeconds; + + String currentETag = initialETag; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + GetDistributionRequest getRequest = GetDistributionRequest.builder() + .id(distributionId) + .build(); + + GetDistributionResponse getResponse = cloudFrontClient.getDistribution(getRequest); + String status = getResponse.distribution().status(); + currentETag = getResponse.eTag(); + + log.debug("[AwsCloudFrontManagementAdapter] Distribution status check (attempt {}/{}): status={}, distributionId={}", + attempt, maxAttempts, status, distributionId); + + if ("Deployed".equals(status)) { + log.info("[AwsCloudFrontManagementAdapter] Distribution deployment completed: distributionId={}", distributionId); + return currentETag; + } + + // InProgress 상태면 계속 대기 + if ("InProgress".equals(status)) { + try { + TimeUnit.SECONDS.sleep(pollIntervalSeconds); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("[AwsCloudFrontManagementAdapter] Interrupted while waiting for deployment: {}", distributionId); + throw new RuntimeException("Deployment wait interrupted", e); + } + continue; + } + + // 기타 상태면 경고 로그만 남기고 계속 시도 + log.warn("[AwsCloudFrontManagementAdapter] Unexpected distribution status: status={}, distributionId={}", + status, distributionId); + + } catch (Exception e) { + log.warn("[AwsCloudFrontManagementAdapter] Error checking distribution status (attempt {}/{}): {}", + attempt, maxAttempts, e.getMessage()); + + // 마지막 시도가 아니면 계속 진행 + if (attempt < maxAttempts) { + try { + TimeUnit.SECONDS.sleep(pollIntervalSeconds); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Deployment wait interrupted", ie); + } + continue; + } + + // 마지막 시도에서도 실패하면 예외 발생 + throw new RuntimeException("Failed to wait for distribution deployment: " + e.getMessage(), e); + } + } + + // 타임아웃 발생 + log.error("[AwsCloudFrontManagementAdapter] Timeout waiting for distribution deployment: distributionId={}, maxWaitMinutes={}", + distributionId, maxWaitMinutes); + throw new RuntimeException( + String.format("Distribution deployment timeout: distributionId=%s, maxWaitMinutes=%d", + distributionId, maxWaitMinutes) + ); + } + + @Override + public ProviderType getProviderType() { + return ProviderType.AWS; + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontMapper.java new file mode 100644 index 00000000..05e2007c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontMapper.java @@ -0,0 +1,296 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest; +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.port.model.cdn.*; +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.cloudfront.model.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.Map; + +/** + * AWS CloudFront Distribution 응답을 CloudResource로 변환하는 매퍼 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +public class AwsCloudFrontMapper { + + private final ObjectMapper objectMapper; + private final CloudProviderRepository cloudProviderRepository; + private final CloudServiceRepository cloudServiceRepository; + private final CloudRegionRepository cloudRegionRepository; + + public AwsCloudFrontMapper( + ObjectMapper objectMapper, + CloudProviderRepository cloudProviderRepository, + CloudServiceRepository cloudServiceRepository, + CloudRegionRepository cloudRegionRepository) { + this.objectMapper = objectMapper; + this.cloudProviderRepository = cloudProviderRepository; + this.cloudServiceRepository = cloudServiceRepository; + this.cloudRegionRepository = cloudRegionRepository; + } + + /** + * Distribution을 CloudResource로 변환 (CreateDistributionCommand 사용) + */ + public CloudResource toCloudResource(Distribution distribution, String etag, CreateDistributionCommand command) { + return buildCloudResourceForCreate(distribution, etag, command); + } + + /** + * Distribution을 CloudResource로 변환 (UpdateDistributionCommand 사용) + */ + public CloudResource toCloudResource(Distribution distribution, String etag, UpdateDistributionCommand command) { + Map tags = command.tags() != null ? command.tags() : new HashMap<>(); + return buildCloudResource(distribution, etag, command.providerType(), "CloudFront", command.tenantKey(), tags); + } + + /** + * Distribution을 CloudResource로 변환 (CDNDistributionQueryRequest 사용) + */ + public CloudResource toCloudResource(DistributionSummary distributionSummary, CDNDistributionQueryRequest query) { + Map tags = query.tags() != null ? query.tags() : new HashMap<>(); + return buildCloudResourceFromSummary(distributionSummary, query.providerType(), "CloudFront", query.tenantKey(), tags); + } + + /** + * Distribution을 CloudResource로 변환 (조회용) + */ + public CloudResource toCloudResource(Distribution distribution, String etag, CloudProvider.ProviderType providerType, Map tags) { + return buildCloudResource(distribution, etag, providerType, "CloudFront", null, tags); + } + + /** + * Distribution 생성 시 사용하는 CloudResource 빌더 + */ + private CloudResource buildCloudResourceForCreate(Distribution distribution, String etag, CreateDistributionCommand command) { + String resourceId = distribution.id(); + String resourceName = command.distributionName() != null && !command.distributionName().isEmpty() + ? command.distributionName() + : distribution.id(); + + // 메타데이터 구성 + Map metadata = buildMetadata(distribution, etag, command.origin(), command.cacheBehaviors()); + + String metadataJson = null; + try { + metadataJson = objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[AwsCloudFrontMapper] Failed to serialize metadata: {}", e.getMessage()); + } + + // 엔티티 조회 + CloudProvider provider = cloudProviderRepository.findFirstByProviderType(command.providerType()) + .orElseThrow(() -> new IllegalStateException("CloudProvider not found for type: " + command.providerType())); + + CloudService service = cloudServiceRepository.findByProviderTypeAndServiceKey(command.providerType(), command.serviceKey()) + .orElseThrow(() -> new IllegalStateException("CloudService not found for providerType: " + command.providerType() + ", serviceKey: " + command.serviceKey())); + + // CloudFront는 글로벌 서비스이므로 region은 GLOBAL 또는 null + CloudRegion cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(command.providerType(), "GLOBAL") + .orElse(null); + + return CloudResource.builder() + .resourceId(resourceId) + .resourceName(resourceName) + .displayName(resourceName) + .provider(provider) + .service(service) + .region(cloudRegion) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .lifecycleState(mapStatusToLifecycleState(distribution.status())) + .tags(command.tags()) + .metadata(metadataJson) + .createdInCloud(LocalDateTime.now()) + .build(); + } + + /** + * Distribution 조회/수정 시 사용하는 CloudResource 빌더 + */ + private CloudResource buildCloudResource( + Distribution distribution, + String etag, + CloudProvider.ProviderType providerType, + String serviceKey, + String tenantKey, + Map tags) { + String resourceId = distribution.id(); + String resourceName = distribution.id(); + + // 메타데이터 구성 + Map metadata = buildMetadata(distribution, etag, null, null); + + String metadataJson = null; + try { + metadataJson = objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[AwsCloudFrontMapper] Failed to serialize metadata: {}", e.getMessage()); + } + + // 엔티티 조회 + CloudProvider provider = cloudProviderRepository.findFirstByProviderType(providerType) + .orElseThrow(() -> new IllegalStateException("CloudProvider not found for type: " + providerType)); + + CloudService service = cloudServiceRepository.findByProviderTypeAndServiceKey(providerType, serviceKey) + .orElseThrow(() -> new IllegalStateException("CloudService not found for providerType: " + providerType + ", serviceKey: " + serviceKey)); + + CloudRegion cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(providerType, "GLOBAL") + .orElse(null); + + return CloudResource.builder() + .resourceId(resourceId) + .resourceName(resourceName) + .displayName(resourceName) + .provider(provider) + .service(service) + .region(cloudRegion) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .lifecycleState(mapStatusToLifecycleState(distribution.status())) + .tags(tags != null ? tags : new HashMap<>()) + .metadata(metadataJson) + .lastModifiedInCloud(distribution.lastModifiedTime() != null + ? LocalDateTime.ofInstant(distribution.lastModifiedTime(), ZoneId.systemDefault()) + : LocalDateTime.now()) + .build(); + } + + /** + * DistributionSummary를 CloudResource로 변환 (목록 조회용) + */ + private CloudResource buildCloudResourceFromSummary( + DistributionSummary summary, + CloudProvider.ProviderType providerType, + String serviceKey, + String tenantKey, + Map tags) { + String resourceId = summary.id(); + String resourceName = summary.id(); + + // 간단한 메타데이터 (Summary는 상세 정보가 적음) + Map metadata = new HashMap<>(); + metadata.put("distributionId", summary.id()); + metadata.put("domainName", summary.domainName()); + metadata.put("status", summary.status()); + metadata.put("enabled", summary.enabled()); + metadata.put("comment", summary.comment()); + metadata.put("arn", summary.arn()); + + String metadataJson = null; + try { + metadataJson = objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[AwsCloudFrontMapper] Failed to serialize metadata: {}", e.getMessage()); + } + + // 엔티티 조회 + CloudProvider provider = cloudProviderRepository.findFirstByProviderType(providerType) + .orElseThrow(() -> new IllegalStateException("CloudProvider not found for type: " + providerType)); + + CloudService service = cloudServiceRepository.findByProviderTypeAndServiceKey(providerType, serviceKey) + .orElseThrow(() -> new IllegalStateException("CloudService not found for providerType: " + providerType + ", serviceKey: " + serviceKey)); + + CloudRegion cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(providerType, "GLOBAL") + .orElse(null); + + return CloudResource.builder() + .resourceId(resourceId) + .resourceName(resourceName) + .displayName(resourceName) + .provider(provider) + .service(service) + .region(cloudRegion) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .lifecycleState(mapStatusToLifecycleState(summary.status())) + .tags(tags != null ? tags : new HashMap<>()) + .metadata(metadataJson) + .lastModifiedInCloud(summary.lastModifiedTime() != null + ? LocalDateTime.ofInstant(summary.lastModifiedTime(), ZoneId.systemDefault()) + : LocalDateTime.now()) + .build(); + } + + /** + * Distribution 메타데이터 구성 + */ + private Map buildMetadata( + Distribution distribution, + String etag, + OriginConfig originConfig, + java.util.List cacheBehaviorConfigs) { + Map metadata = new HashMap<>(); + metadata.put("distributionId", distribution.id()); + metadata.put("arn", distribution.arn()); + metadata.put("domainName", distribution.domainName()); + metadata.put("status", distribution.status()); + metadata.put("etag", etag); + + DistributionConfig config = distribution.distributionConfig(); + if (config != null) { + metadata.put("enabled", config.enabled()); + metadata.put("comment", config.comment()); + + // Origin 정보 저장 + if (originConfig != null) { + metadata.put("origin", originConfig); + } else if (config.origins() != null && config.origins().items() != null && !config.origins().items().isEmpty()) { + Origin firstOrigin = config.origins().items().get(0); + Map originMap = new HashMap<>(); + originMap.put("id", firstOrigin.id()); + originMap.put("domainName", firstOrigin.domainName()); + metadata.put("origin", originMap); + } + + // CacheBehavior 정보 저장 + if (cacheBehaviorConfigs != null) { + metadata.put("cacheBehaviors", cacheBehaviorConfigs); + } + + // Aliases 저장 + if (config.aliases() != null && config.aliases().items() != null && !config.aliases().items().isEmpty()) { + metadata.put("aliases", config.aliases().items()); + } + + // SSL 인증서 정보 + if (config.viewerCertificate() != null) { + metadata.put("sslCertificateArn", config.viewerCertificate().acmCertificateArn()); + metadata.put("iamCertificateId", config.viewerCertificate().iamCertificateId()); + } + } + + return metadata; + } + + /** + * CloudFront Distribution 상태를 LifecycleState로 매핑 + */ + private CloudResource.LifecycleState mapStatusToLifecycleState(String status) { + if (status == null) { + return CloudResource.LifecycleState.UNKNOWN; + } + + return switch (status) { + case "InProgress" -> CloudResource.LifecycleState.PENDING; + case "Deployed" -> CloudResource.LifecycleState.RUNNING; + default -> CloudResource.LifecycleState.UNKNOWN; + }; + } + +} + 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 a6a4206e..1ce77e14 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 @@ -88,6 +88,26 @@ public void initializeS3BucketCapabilities() { log.info("AWS S3 Bucket capabilities registered successfully - AWS|S3|BUCKET"); } + @PostConstruct + public void initializeCloudFrontCapabilities() { + log.info("Registering AWS CloudFront capabilities..."); + + CspCapability awsCloudFrontCapability = CspCapability.builder() + .supportsStart(false) // CloudFront Distribution은 start/stop 개념이 없음 + .supportsStop(false) + .supportsTerminate(true) // Distribution 삭제 지원 + .supportsTagging(true) // AWS CloudFront Distribution 태그 지원 + .supportsListByTag(true) // 태그 기반 목록 조회 지원 + .build(); + capabilityRegistry.register( + CloudProvider.ProviderType.AWS, + "CloudFront", // 서비스 타입 + "CDN_DISTRIBUTION", // 리소스 타입 + awsCloudFrontCapability + ); + log.info("AWS CloudFront capabilities registered successfully - AWS|CloudFront|CDN_DISTRIBUTION"); + } + @PostConstruct public void initializeRdsCapabilities() { log.info("Registering AWS RDS capabilities..."); diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCloudFrontConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCloudFrontConfig.java new file mode 100644 index 00000000..79e5d658 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsCloudFrontConfig.java @@ -0,0 +1,125 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.config; + +import com.agenticcp.core.common.context.TenantContextHolder; +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.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.extern.slf4j.Slf4j; +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; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; + +/** + * AWS CloudFront 설정 클래스 + * + * CloudFrontClient Bean을 생성합니다. + * ThreadLocal 기반 자격증명을 사용하여 멀티 테넌트 환경을 지원합니다. + * CloudFront는 글로벌 서비스이므로 AWS_GLOBAL 리전을 사용합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Configuration +public class AwsCloudFrontConfig { + + /** + * CloudFrontClient Bean을 생성합니다. + * ThreadLocal 기반 자격증명을 사용하여 동적 자격증명을 지원합니다. + * CloudFront는 글로벌 서비스로 리전 독립적입니다. + * + * @return CloudFrontClient 인스턴스 + */ + @Bean + public CloudFrontClient cloudFrontClient() { + return CloudFrontClient.builder() + .region(Region.AWS_GLOBAL) // CloudFront는 글로벌 서비스 + .credentialsProvider(createThreadLocalCredentialsProvider()) + .build(); + } + + /** + * 세션 자격증명으로 CloudFront Client를 생성합니다. + * + * @param session AWS 세션 자격증명 + * @return CloudFrontClient 인스턴스 + */ + public CloudFrontClient createCloudFrontClient(CloudSessionCredential session) { + AwsSessionCredential awsSession = validateAndCastSession(session); + + return CloudFrontClient.builder() + .credentialsProvider(StaticCredentialsProvider.create(toSdkCredentials(awsSession))) + .region(Region.AWS_GLOBAL) // CloudFront는 글로벌 서비스 + .build(); + } + + /** + * ThreadLocal 기반 자격증명 제공자 생성 + * + * 현재 스레드의 ThreadLocal 캐시에서 자격증명을 조회하여 제공합니다. + * 멀티 테넌트 환경에서 각 요청별로 다른 자격증명을 사용할 수 있도록 합니다. + */ + private AwsCredentialsProvider createThreadLocalCredentialsProvider() { + return () -> { + try { + // 현재 테넌트 키 조회 + String tenantKey = TenantContextHolder.getCurrentTenantKey(); + + if (tenantKey == null) { + log.warn("테넌트 컨텍스트가 설정되지 않음 - 기본 자격증명 사용"); + return null; // 기본 자격증명 사용 + } + + // ThreadLocal 캐시에서 자격증명 조회 + return ThreadLocalCredentialCache.getFirstCredentials(); + + } catch (Exception e) { + log.error("ThreadLocal 자격증명 조회 실패: {}", e.getMessage(), e); + return null; // 기본 자격증명 사용 + } + }; + } + + /** + * 세션 검증 및 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/controller/CDNController.java b/src/main/java/com/agenticcp/core/domain/cloud/controller/CDNController.java new file mode 100644 index 00000000..1a684aa9 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/controller/CDNController.java @@ -0,0 +1,566 @@ +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.*; +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.cdn.*; +import com.agenticcp.core.domain.cloud.service.cdn.CDNUseCaseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Optional; + +/** + * CDN Distribution 관리 REST API 컨트롤러 + * + * AWS CloudFront Distribution의 생성, 조회, 수정, 삭제 및 캐시 무효화 기능을 제공합니다. + * 헥사고날 아키텍처의 인터페이스 계층에 해당하며, 외부 클라이언트와의 통신을 담당합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/cloud/providers/{provider}/accounts/{accountScope}/cloudfront/distributions") +@RequiredArgsConstructor +@Tag(name = "CDN Distribution Management", description = "CDN Distribution 관리 API (CloudFront 지원)") +public class CDNController { + + private static final String SERVICE_KEY = "CloudFront"; + private static final String RESOURCE_TYPE = "CDN_DISTRIBUTION"; + + private final CDNUseCaseService cdnUseCaseService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + // ==================== Distribution 생성 ==================== + + /** + * CDN Distribution을 생성합니다. + * + * @param provider 클라우드 프로바이더 타입 (AWS) + * @param accountScope 계정 스코프 + * @param request Distribution 생성 요청 + * @return 생성된 Distribution 리소스 + */ + @PostMapping + @PreAuthorize("hasAuthority('CDN_DISTRIBUTION_CREATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "CREATE_CDN_DISTRIBUTION", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN Distribution 생성", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.HIGH + ) + @Operation( + summary = "CDN Distribution 생성", + description = "새로운 CDN Distribution을 생성합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Distribution 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> createDistribution( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "CDN Distribution 생성 요청", required = true) + @Valid @RequestBody CDNDistributionCreateRequest request) { + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + log.info("[CDNController] createDistribution - provider={}, accountScope={}, distributionName={}", + provider, accountScope, request.getDistributionName()); + + // Request DTO를 Command로 변환 + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(request.getProviderType()) + .accountScope(request.getAccountScope()) + .serviceKey(SERVICE_KEY) + .resourceType(RESOURCE_TYPE) + .distributionName(request.getDistributionName()) + .comment(request.getComment()) + .enabled(request.getEnabled()) + .origin(request.getOrigin()) + .cacheBehaviors(request.getCacheBehaviors()) + .aliases(request.getAliases()) + .sslCertificateId(request.getSslCertificateId()) + .tags(request.getTags()) + .build(); + + CloudResource distribution = cdnUseCaseService.createDistribution(command); + + log.info("[CDNController] createDistribution - success resourceId={}", distribution.getResourceId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(distribution, "CDN Distribution 생성에 성공했습니다.")); + } + + // ==================== Distribution 조회 ==================== + + /** + * CDN Distribution 목록을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param distributionName Distribution 이름 필터 (선택) + * @param enabled 활성화 여부 필터 (선택) + * @param page 페이지 번호 (기본값: 0) + * @param size 페이지 크기 (기본값: 20) + * @param sortBy 정렬 기준 (기본값: name) + * @param sortDirection 정렬 방향 (기본값: asc) + * @return Distribution 리소스 목록 (페이징 정보 포함) + */ + @GetMapping + @PreAuthorize("hasAuthority('CDN_DISTRIBUTION_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "LIST_CDN_DISTRIBUTIONS", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN Distribution 목록 조회", + includeRequestData = true, + includeResponseData = false, + severity = AuditSeverity.LOW + ) + @Operation( + summary = "CDN Distribution 목록 조회", + description = "지정된 클라우드 프로바이더의 CDN Distribution 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Distribution 목록 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity>> listDistributions( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Distribution 이름 필터", example = "my-distribution") + @RequestParam(required = false) String distributionName, + + @Parameter(description = "활성화 여부 필터", example = "true") + @RequestParam(required = false) Boolean enabled, + + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") Integer page, + + @Parameter(description = "페이지 크기 (1-100)", example = "20") + @RequestParam(defaultValue = "20") Integer size, + + @Parameter(description = "정렬 기준", example = "name") + @RequestParam(defaultValue = "name") String sortBy, + + @Parameter(description = "정렬 방향 (asc, desc)", example = "asc") + @RequestParam(defaultValue = "asc") String sortDirection) { + + log.info("[CDNController] listDistributions - provider={}, accountScope={}, page={}, size={}", + provider, accountScope, page, size); + + // 페이징 파라미터 검증 + if (page < 0) { + throw new com.agenticcp.core.common.exception.BusinessException( + CloudErrorCode.INVALID_REQUEST, "페이지 번호는 0 이상이어야 합니다"); + } + if (size < 1 || size > 100) { + throw new com.agenticcp.core.common.exception.BusinessException( + CloudErrorCode.INVALID_REQUEST, "페이지 크기는 1 이상 100 이하여야 합니다"); + } + if (sortDirection != null && !sortDirection.equalsIgnoreCase("asc") && !sortDirection.equalsIgnoreCase("desc")) { + throw new com.agenticcp.core.common.exception.BusinessException( + CloudErrorCode.INVALID_REQUEST, "정렬 방향은 'asc' 또는 'desc'여야 합니다"); + } + + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(provider) + .accountScope(accountScope) + .distributionName(distributionName) + .enabled(enabled) + .page(page) + .size(size) + .sortBy(sortBy) + .sortDirection(sortDirection) + .build(); + + Page distributions = cdnUseCaseService.listDistributions(query); + + log.info("[CDNController] listDistributions - success count={}", distributions.getTotalElements()); + return ResponseEntity.ok(ApiResponse.success(distributions, "CDN Distribution 목록 조회에 성공했습니다.")); + } + + /** + * 특정 CDN Distribution을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @return Distribution 리소스 + */ + @GetMapping("/{distributionId}") + @PreAuthorize("hasAuthority('CDN_DISTRIBUTION_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "GET_CDN_DISTRIBUTION", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN Distribution 상세 조회", + includeRequestData = true, + includeResponseData = false, + severity = AuditSeverity.LOW + ) + @Operation( + summary = "CDN Distribution 상세 조회", + description = "특정 CDN Distribution의 상세 정보를 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Distribution 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Distribution을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> getDistribution( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Distribution ID", required = true, example = "E2QWRUHAPOMQZL") + @PathVariable String distributionId) { + + log.info("[CDNController] getDistribution - provider={}, accountScope={}, distributionId={}", + provider, accountScope, distributionId); + + Optional distribution = cdnUseCaseService.getDistribution(accountScope, distributionId, provider); + + if (distribution.isPresent()) { + log.info("[CDNController] getDistribution - success distributionId={}", distributionId); + return ResponseEntity.ok(ApiResponse.success(distribution.get(), "CDN Distribution 조회에 성공했습니다.")); + } else { + log.warn("[CDNController] getDistribution - not found distributionId={}", distributionId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + } + } + + // ==================== Distribution 수정 ==================== + + /** + * CDN Distribution을 수정합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @param request Distribution 수정 요청 + * @return 수정된 Distribution 리소스 + */ + @PutMapping("/{distributionId}") + @PreAuthorize("hasAuthority('CDN_DISTRIBUTION_UPDATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "UPDATE_CDN_DISTRIBUTION", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN Distribution 설정 업데이트", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.HIGH + ) + @Operation( + summary = "CDN Distribution 수정", + description = "CDN Distribution의 설정을 수정합니다. ETag 기반 동시성 제어가 적용됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Distribution 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Distribution을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "동시성 충돌 (ETag 불일치)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> updateDistribution( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Distribution ID", required = true, example = "E2QWRUHAPOMQZL") + @PathVariable String distributionId, + + @Parameter(description = "CDN Distribution 수정 요청", required = true) + @Valid @RequestBody CDNDistributionUpdateRequest request) { + + log.info("[CDNController] updateDistribution - provider={}, accountScope={}, distributionId={}", + provider, accountScope, distributionId); + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + // 현재 Distribution 조회하여 ETag 획득 + Optional currentDistribution = cdnUseCaseService.getDistribution( + accountScope, distributionId, provider); + + if (currentDistribution.isEmpty()) { + log.warn("[CDNController] updateDistribution - not found distributionId={}", distributionId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + } + + // metadata에서 ETag 추출 (간단한 예시, 실제로는 JSON 파싱 필요) + String etag = extractEtagFromMetadata(currentDistribution.get().getMetadata()); + + // Request DTO를 Command로 변환 + UpdateDistributionCommand command = UpdateDistributionCommand.builder() + .providerType(request.getProviderType()) + .accountScope(request.getAccountScope()) + .distributionId(distributionId) + .etag(etag) + .comment(request.getComment()) + .enabled(request.getEnabled()) + .cacheBehaviors(request.getCacheBehaviors()) + .aliases(request.getAliases()) + .sslCertificateId(request.getSslCertificateId()) + .tags(request.getTags()) + .build(); + + CloudResource distribution = cdnUseCaseService.updateDistribution(command); + + log.info("[CDNController] updateDistribution - success distributionId={}", distributionId); + return ResponseEntity.ok(ApiResponse.success(distribution, "CDN Distribution 수정에 성공했습니다.")); + } + + // ==================== Distribution 삭제 ==================== + + /** + * CDN Distribution을 삭제합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @return 삭제 결과 + */ + @DeleteMapping("/{distributionId}") + @PreAuthorize("hasAuthority('CDN_DISTRIBUTION_DELETE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "DELETE_CDN_DISTRIBUTION", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN Distribution 삭제", + includeRequestData = true, + includeResponseData = false, + severity = AuditSeverity.CRITICAL + ) + @Operation( + summary = "CDN Distribution 삭제", + description = "CDN Distribution을 삭제합니다. 삭제 전 자동으로 비활성화됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "Distribution 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Distribution을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity deleteDistribution( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Distribution ID", required = true, example = "E2QWRUHAPOMQZL") + @PathVariable String distributionId) { + + log.info("[CDNController] deleteDistribution - provider={}, accountScope={}, distributionId={}", + provider, accountScope, distributionId); + + // 현재 Distribution 조회하여 ETag 획득 + Optional currentDistribution = cdnUseCaseService.getDistribution( + accountScope, distributionId, provider); + + if (currentDistribution.isEmpty()) { + log.warn("[CDNController] deleteDistribution - not found distributionId={}", distributionId); + return ResponseEntity.notFound().build(); + } + + // metadata에서 ETag 추출 + String etag = extractEtagFromMetadata(currentDistribution.get().getMetadata()); + + // DeleteDistributionCommand 생성 + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(provider) + .accountScope(accountScope) + .distributionId(distributionId) + .etag(etag) + .build(); + + cdnUseCaseService.deleteDistribution(command); + + log.info("[CDNController] deleteDistribution - success distributionId={}", distributionId); + return ResponseEntity.noContent().build(); + } + + // ==================== 캐시 무효화 ==================== + + /** + * 캐시 무효화를 생성합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @param request 캐시 무효화 요청 + * @return 생성된 무효화 결과 + */ + @PostMapping("/{distributionId}/invalidations") + @PreAuthorize("hasAuthority('CDN_INVALIDATION_CREATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "CREATE_CDN_INVALIDATION", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN 캐시 무효화 생성", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.MEDIUM + ) + @Operation( + summary = "캐시 무효화 생성", + description = "CDN Distribution의 캐시를 무효화합니다." + ) + @ApiResponses({ + @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 = "404", description = "Distribution을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> createInvalidation( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Distribution ID", required = true, example = "E2QWRUHAPOMQZL") + @PathVariable String distributionId, + + @Parameter(description = "캐시 무효화 요청", required = true) + @Valid @RequestBody CDNInvalidationRequest request) { + + log.info("[CDNController] createInvalidation - provider={}, accountScope={}, distributionId={}, paths={}", + provider, accountScope, distributionId, request.getPaths()); + + // CreateInvalidationCommand 생성 + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(accountScope) + .distributionId(distributionId) + .paths(request.getPaths()) + .callerReference(request.getCallerReference()) + .build(); + + InvalidationResult result = cdnUseCaseService.createInvalidation(command); + CDNInvalidationResponse response = CDNInvalidationResponse.from(result); + + log.info("[CDNController] createInvalidation - success invalidationId={}", result.invalidationId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response, "캐시 무효화 생성에 성공했습니다.")); + } + + /** + * 캐시 무효화 상태를 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @param invalidationId 무효화 ID + * @return 무효화 결과 + */ + @GetMapping("/{distributionId}/invalidations/{invalidationId}") + @PreAuthorize("hasAuthority('CDN_INVALIDATION_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "GET_CDN_INVALIDATION", + resourceType = AuditResourceType.CDN_DISTRIBUTION, + description = "CDN 캐시 무효화 상태 조회", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.LOW + ) + @Operation( + summary = "캐시 무효화 상태 조회", + description = "특정 캐시 무효화의 상태를 조회합니다." + ) + @ApiResponses({ + @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 = "500", description = "서버 내부 오류") + }) + public ResponseEntity> getInvalidation( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Distribution ID", required = true, example = "E2QWRUHAPOMQZL") + @PathVariable String distributionId, + + @Parameter(description = "무효화 ID", required = true, example = "I2J3K4L5M6N7O") + @PathVariable String invalidationId) { + + log.info("[CDNController] getInvalidation - provider={}, accountScope={}, distributionId={}, invalidationId={}", + provider, accountScope, distributionId, invalidationId); + + Optional result = cdnUseCaseService.getInvalidation( + accountScope, distributionId, invalidationId, provider); + + if (result.isPresent()) { + CDNInvalidationResponse response = CDNInvalidationResponse.from(result.get()); + log.info("[CDNController] getInvalidation - success invalidationId={}", invalidationId); + return ResponseEntity.ok(ApiResponse.success(response, "캐시 무효화 상태 조회에 성공했습니다.")); + } else { + log.warn("[CDNController] getInvalidation - not found invalidationId={}", invalidationId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + } + } + + /** + * metadata에서 ETag를 추출합니다. + * + * @param metadata JSON 문자열 형태의 metadata + * @return ETag 값, 없으면 null + */ + private String extractEtagFromMetadata(String metadata) { + if (metadata == null || metadata.isEmpty()) { + return null; + } + + try { + @SuppressWarnings("unchecked") + Map metadataMap = objectMapper.readValue(metadata, Map.class); + Object etagObj = metadataMap.get("etag"); + if (etagObj != null) { + return etagObj.toString(); + } + } catch (JsonProcessingException e) { + log.warn("[CDNController] Failed to parse metadata JSON for ETag extraction: {}", e.getMessage()); + } + + return null; + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionCreateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionCreateRequest.java new file mode 100644 index 00000000..6052de3f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionCreateRequest.java @@ -0,0 +1,103 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.cdn.CacheBehaviorConfig; +import com.agenticcp.core.domain.cloud.port.model.cdn.OriginConfig; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * CDN Distribution 생성 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class CDNDistributionCreateRequest { + + /** + * 클라우드 프로바이더 타입 + * Controller에서 PathVariable로 주입됩니다. + */ + private CloudProvider.ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * Distribution 이름 (필수) + * 최대 128자, 영문자/숫자/하이픈/언더스코어만 허용 + */ + @NotBlank(message = "Distribution 이름은 필수입니다") + @jakarta.validation.constraints.Size(max = 128, message = "Distribution 이름은 최대 128자까지 가능합니다") + @jakarta.validation.constraints.Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "Distribution 이름은 영문자, 숫자, 하이픈(-), 언더스코어(_)만 사용할 수 있습니다") + private String distributionName; + + /** + * Distribution 설명 + * 최대 128자 + */ + @jakarta.validation.constraints.Size(max = 128, message = "설명은 최대 128자까지 가능합니다") + private String comment; + + /** + * Distribution 활성화 여부 + * 기본값: true + */ + private Boolean enabled; + + /** + * Origin 설정 (필수) + */ + @NotNull(message = "Origin 설정은 필수입니다") + @Valid + private OriginConfig origin; + + /** + * Cache Behavior 설정 목록 + * 첫 번째 항목이 Default Cache Behavior로 사용됩니다. + */ + @Valid + private List cacheBehaviors; + + /** + * CNAME 목록 (커스텀 도메인) + * 최대 100개, 각각 유효한 도메인 형식이어야 함 + */ + @jakarta.validation.constraints.Size(max = 100, message = "CNAME 목록은 최대 100개까지 가능합니다") + private List<@jakarta.validation.constraints.Pattern( + regexp = "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$", + message = "유효한 도메인 형식이어야 합니다" + ) String> aliases; + + /** + * SSL/TLS 인증서 ID + * CSP별로 다른 형식 사용 가능: + * - AWS: ACM ARN (arn:aws:acm:region:account:certificate/certificate-id) 또는 IAM Certificate ID + * - GCP: Certificate Manager 리소스 이름 + * - Azure: Key Vault 인증서 ID + */ + @jakarta.validation.constraints.Size(max = 512, message = "SSL 인증서 ID는 최대 512자까지 가능합니다") + private String sslCertificateId; + + /** + * 태그 + */ + private Map tags; +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionQueryRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionQueryRequest.java new file mode 100644 index 00000000..b4292cc5 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionQueryRequest.java @@ -0,0 +1,30 @@ +package com.agenticcp.core.domain.cloud.dto; + +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; + +/** + * CDN Distribution 목록 조회 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record CDNDistributionQueryRequest( + CloudProvider.ProviderType providerType, + String accountScope, + String distributionName, + Boolean enabled, + Map tags, + String tenantKey, + Integer page, + Integer size, + String sortBy, + String sortDirection, + CloudSessionCredential session +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionUpdateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionUpdateRequest.java new file mode 100644 index 00000000..fa7e76ec --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNDistributionUpdateRequest.java @@ -0,0 +1,82 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.cdn.CacheBehaviorConfig; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * CDN Distribution 수정 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CDNDistributionUpdateRequest { + + /** + * 클라우드 프로바이더 타입 + * Controller에서 PathVariable로 주입됩니다. + */ + private CloudProvider.ProviderType providerType; + + /** + * 계정 스코프 + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * Distribution 설명 + * 최대 128자 + */ + @Size(max = 128, message = "설명은 최대 128자까지 가능합니다") + private String comment; + + /** + * Distribution 활성화 여부 + */ + private Boolean enabled; + + /** + * Cache Behavior 설정 목록 + */ + @Valid + private List cacheBehaviors; + + /** + * CNAME 목록 (커스텀 도메인) + * 최대 100개, 각각 유효한 도메인 형식이어야 함 + */ + @Size(max = 100, message = "CNAME 목록은 최대 100개까지 가능합니다") + private List<@jakarta.validation.constraints.Pattern( + regexp = "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$", + message = "유효한 도메인 형식이어야 합니다" + ) String> aliases; + + /** + * SSL/TLS 인증서 ID + * CSP별로 다른 형식 사용 가능: + * - AWS: ACM ARN (arn:aws:acm:region:account:certificate/certificate-id) 또는 IAM Certificate ID + * - GCP: Certificate Manager 리소스 이름 + * - Azure: Key Vault 인증서 ID + */ + @jakarta.validation.constraints.Size(max = 512, message = "SSL 인증서 ID는 최대 512자까지 가능합니다") + private String sslCertificateId; + + /** + * 태그 + */ + private Map tags; +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationRequest.java new file mode 100644 index 00000000..0f3d7d59 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationRequest.java @@ -0,0 +1,45 @@ +package com.agenticcp.core.domain.cloud.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * CDN 캐시 무효화 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class CDNInvalidationRequest { + + /** + * 무효화할 경로 목록 (필수) + * 예: ["/images/*", "/css/*", "/index.html"] + * 모든 경로를 무효화하려면 ["/*"] 사용 + * 최대 3000개까지 가능 + */ + @NotEmpty(message = "무효화할 경로 목록은 필수입니다") + @jakarta.validation.constraints.Size(max = 3000, message = "무효화 경로는 최대 3000개까지 가능합니다") + private List<@NotBlank(message = "경로는 비어있을 수 없습니다") + @jakarta.validation.constraints.Pattern( + regexp = "^/.*|^\\*$", + message = "경로는 '/'로 시작하거나 '*'이어야 합니다" + ) String> paths; + + /** + * Caller Reference (고유 식별자, 중복 방지용) + * 미제공 시 자동 생성됩니다. + */ + private String callerReference; +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationResponse.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationResponse.java new file mode 100644 index 00000000..55b54184 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/CDNInvalidationResponse.java @@ -0,0 +1,70 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * CDN 캐시 무효화 응답 DTO + * + *

외부 API 클라이언트에게 반환되는 캐시 무효화 결과입니다.

+ *

포트 모델(InvalidationResult)을 DTO로 변환하여 인터페이스 계층과 도메인 계층을 분리합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CDNInvalidationResponse { + + /** + * 무효화 ID + */ + private String invalidationId; + + /** + * Distribution ID + */ + private String distributionId; + + /** + * 무효화 상태 + * - InProgress: 진행 중 + * - Completed: 완료 + */ + private String status; + + /** + * 생성 시간 + */ + private LocalDateTime createTime; + + /** + * 무효화된 경로 목록 + */ + private List paths; + + /** + * InvalidationResult에서 CDNInvalidationResponse로 변환 + * + * @param result 포트 모델 (도메인 계층) + * @return API 응답 DTO (인터페이스 계층) + */ + public static CDNInvalidationResponse from(InvalidationResult result) { + return CDNInvalidationResponse.builder() + .invalidationId(result.invalidationId()) + .distributionId(result.distributionId()) + .status(result.status()) + .createTime(result.createTime()) + .paths(result.paths()) + .build(); + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java index 2360bc37..f364a3e5 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java @@ -349,6 +349,7 @@ public enum ResourceType { LOAD_BALANCER, DATABASE, BUCKET, + CDN_DISTRIBUTION, FUNCTION, CONTAINER, CLUSTER, diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CacheBehaviorConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CacheBehaviorConfig.java new file mode 100644 index 00000000..ac3f2c5c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CacheBehaviorConfig.java @@ -0,0 +1,56 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.List; + +/** + * CDN 캐시 동작 설정 VO + * + * Distribution의 캐시 동작(Cache Behavior) 설정을 표현합니다. + * 경로 패턴별로 캐시 TTL, 허용 메서드, 압축 등을 설정할 수 있습니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record CacheBehaviorConfig( + @JsonProperty("pathPattern") + @Pattern( + regexp = "^/.*|^\\*$", + message = "경로 패턴은 '/'로 시작하거나 '*'이어야 합니다" + ) + String pathPattern, + + @JsonProperty("ttl") + @Min(value = 0, message = "TTL은 0 이상이어야 합니다") + Long ttl, // Time to Live (초) + + @JsonProperty("allowedMethods") + @Size(min = 1, message = "최소 하나 이상의 HTTP 메서드가 필요합니다") + List<@Pattern( + regexp = "^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)$", + message = "허용된 HTTP 메서드는 GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH 중 하나여야 합니다" + ) String> allowedMethods, // GET, POST, PUT, DELETE 등 + + @JsonProperty("compress") + Boolean compress, // Gzip 압축 여부 + + @JsonProperty("viewerProtocolPolicy") + @jakarta.validation.constraints.Size(max = 50, message = "Viewer Protocol Policy는 최대 50자까지 가능합니다") + String viewerProtocolPolicy, // CSP별로 다른 값 사용 가능 (예: AWS: allow-all/https-only/redirect-to-https, GCP: ALLOW_ALL/HTTPS_ONLY 등) + + @JsonProperty("cachePolicyId") + @jakarta.validation.constraints.Size(max = 255, message = "Cache Policy ID는 최대 255자까지 가능합니다") + String cachePolicyId, // CSP별 Managed Cache Policy ID (선택, AWS/GCP/Azure 각각 다른 형식) + + @JsonProperty("originRequestPolicyId") + @jakarta.validation.constraints.Size(max = 255, message = "Origin Request Policy ID는 최대 255자까지 가능합니다") + String originRequestPolicyId // CSP별 Managed Origin Request Policy ID (선택, AWS/GCP/Azure 각각 다른 형식) +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateDistributionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateDistributionCommand.java new file mode 100644 index 00000000..6237a352 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateDistributionCommand.java @@ -0,0 +1,34 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.List; +import java.util.Map; + +/** + * CDN Distribution 생성 명령 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record CreateDistributionCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String serviceKey, + String resourceType, + String distributionName, + String comment, + Boolean enabled, + OriginConfig origin, + List cacheBehaviors, + List aliases, + String sslCertificateId, + Map tags, + String tenantKey, + CloudSessionCredential session +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateInvalidationCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateInvalidationCommand.java new file mode 100644 index 00000000..d3d8eab9 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/CreateInvalidationCommand.java @@ -0,0 +1,23 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.List; + +/** + * 캐시 무효화 생성 명령 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record CreateInvalidationCommand( + String accountScope, + String distributionId, + List paths, // 무효화할 경로 목록 (예: /images/*, /css/*) + String callerReference, // 고유 식별자 (중복 방지용) + CloudSessionCredential session +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/DeleteDistributionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/DeleteDistributionCommand.java new file mode 100644 index 00000000..f9092810 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/DeleteDistributionCommand.java @@ -0,0 +1,22 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +/** + * CDN Distribution 삭제 명령 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record DeleteDistributionCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String distributionId, + String etag, // 동시성 제어용 + CloudSessionCredential session +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/InvalidationResult.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/InvalidationResult.java new file mode 100644 index 00000000..e48afeb1 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/InvalidationResult.java @@ -0,0 +1,23 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 캐시 무효화 결과 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record InvalidationResult( + String invalidationId, + String distributionId, + String status, // InProgress, Completed + LocalDateTime createTime, + List paths +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/OriginConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/OriginConfig.java new file mode 100644 index 00000000..8490dab8 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/OriginConfig.java @@ -0,0 +1,63 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; +import lombok.Builder; + +/** + * CDN Origin 설정 VO + * + * Distribution의 Origin 설정을 표현합니다. + * PUBLIC_S3, CUSTOM(ELB, EC2 등) Origin 타입을 지원합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record OriginConfig( + @JsonProperty("id") + @NotBlank(message = "Origin ID는 필수입니다") + @Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "Origin ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 사용할 수 있습니다") + String id, + + @JsonProperty("domainName") + @NotBlank(message = "Origin 도메인 이름은 필수입니다") + @Pattern( + regexp = "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$|^[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?$", + message = "유효한 도메인 형식이어야 합니다" + ) + String domainName, + + @JsonProperty("type") + @NotNull(message = "Origin 타입은 필수입니다") + OriginType type, + + // Custom Origin 설정 (ELB, EC2 등) + @JsonProperty("httpPort") + @Min(value = 1, message = "HTTP 포트는 1 이상이어야 합니다") + @Max(value = 65535, message = "HTTP 포트는 65535 이하여야 합니다") + Integer httpPort, // 기본 80 + + @JsonProperty("httpsPort") + @Min(value = 1, message = "HTTPS 포트는 1 이상이어야 합니다") + @Max(value = 65535, message = "HTTPS 포트는 65535 이하여야 합니다") + Integer httpsPort, // 기본 443 + + @JsonProperty("originProtocolPolicy") + @jakarta.validation.constraints.Size(max = 50, message = "Origin Protocol Policy는 최대 50자까지 가능합니다") + String originProtocolPolicy // CSP별로 다른 값 사용 가능 (예: AWS: http-only/https-only/match-viewer, GCP: HTTP_ONLY/HTTPS_ONLY 등) +) { + + /** + * Origin 타입 + */ + public enum OriginType { + PUBLIC_S3, // 퍼블릭 S3 버킷 + CUSTOM // 커스텀 Origin (ELB, EC2, 외부 웹서버 등) + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/UpdateDistributionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/UpdateDistributionCommand.java new file mode 100644 index 00000000..1cd894ff --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/cdn/UpdateDistributionCommand.java @@ -0,0 +1,32 @@ +package com.agenticcp.core.domain.cloud.port.model.cdn; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.List; +import java.util.Map; + +/** + * CDN Distribution 수정 명령 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record UpdateDistributionCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String distributionId, + String etag, // 동시성 제어용 + String comment, + Boolean enabled, + List cacheBehaviors, + List aliases, + String sslCertificateId, + Map tags, + String tenantKey, + CloudSessionCredential session +) { +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNDiscoveryPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNDiscoveryPort.java new file mode 100644 index 00000000..fd529a52 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNDiscoveryPort.java @@ -0,0 +1,45 @@ +package com.agenticcp.core.domain.cloud.port.outbound.cdn; + +import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import org.springframework.data.domain.Page; + +import java.util.Optional; + +/** + * CDN Distribution 조회 포트 + * + * CDN Distribution 조회 기능을 정의합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface CDNDiscoveryPort { + + /** + * CDN Distribution 목록을 조회합니다. + * + * @param query 조회 조건 (세션, 페이징, 필터링, 태그 포함) + * @return CloudResource 페이지 (빈 페이지 가능, null 반환 금지) + */ + Page listDistributions(CDNDistributionQueryRequest query); + + /** + * 특정 CDN Distribution을 조회합니다. + * + * @param distributionId Distribution ID + * @param accountScope 조회 대상 Cloud 계정 범위 + * @return CloudResource (존재하지 않으면 Optional.empty()) + */ + Optional getDistribution(String accountScope, String distributionId); + + /** + * Distribution 존재 여부를 확인합니다. + * + * @param accountScope 확인 대상 Cloud 계정 범위 + * @param distributionId Distribution ID + * @return 존재 여부 + */ + boolean distributionExists(String accountScope, String distributionId); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNInvalidationPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNInvalidationPort.java new file mode 100644 index 00000000..ec6e9bf9 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNInvalidationPort.java @@ -0,0 +1,40 @@ +package com.agenticcp.core.domain.cloud.port.outbound.cdn; + +import com.agenticcp.core.domain.cloud.port.model.cdn.CreateInvalidationCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult; + +import java.util.Optional; + +/** + * CDN 캐시 무효화 포트 + * + * CDN Distribution의 캐시 무효화 생성 및 조회 기능을 정의합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface CDNInvalidationPort { + + /** + * 캐시 무효화를 생성합니다. + * + * @param command 무효화 생성 명령 (세션, Distribution ID, 경로 목록 포함) + * @return 생성된 무효화 결과 (무효화 ID, 상태 포함) + */ + InvalidationResult createInvalidation(CreateInvalidationCommand command); + + /** + * 캐시 무효화 상태를 조회합니다. + * + * @param accountScope 조회 대상 Cloud 계정 범위 + * @param distributionId Distribution ID + * @param invalidationId 무효화 ID + * @return 무효화 결과 (존재하지 않으면 Optional.empty()) + */ + Optional getInvalidation( + String accountScope, + String distributionId, + String invalidationId + ); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNManagementPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNManagementPort.java new file mode 100644 index 00000000..6e3519f5 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/cdn/CDNManagementPort.java @@ -0,0 +1,43 @@ +package com.agenticcp.core.domain.cloud.port.outbound.cdn; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.cdn.CreateDistributionCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.DeleteDistributionCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.UpdateDistributionCommand; + +/** + * CDN Distribution 관리 포트 + * + * CDN Distribution의 생성, 수정, 삭제 기능을 정의합니다. + * CloudFront Distribution은 start/stop 개념이 없으므로 별도의 관리 포트로 분리합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface CDNManagementPort { + + /** + * CDN Distribution을 생성합니다. + * + * @param command 생성 명령 (세션, Origin, CacheBehavior, 태그 포함) + * @return 생성된 CloudResource + */ + CloudResource createDistribution(CreateDistributionCommand command); + + /** + * CDN Distribution 설정을 업데이트합니다. + * + * @param command 업데이트 명령 (세션, ETag, 설정 변경사항 포함) + * @return 업데이트된 CloudResource + */ + CloudResource updateDistribution(UpdateDistributionCommand command); + + /** + * CDN Distribution을 삭제합니다. + * Distribution은 삭제 전 반드시 비활성화(Disabled) 상태여야 합니다. + * + * @param command 삭제 명령 (세션, Distribution ID, ETag 포함) + */ + void deleteDistribution(DeleteDistributionCommand command); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNPortRouter.java b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNPortRouter.java new file mode 100644 index 00000000..07fd64c2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNPortRouter.java @@ -0,0 +1,153 @@ +package com.agenticcp.core.domain.cloud.service.cdn; + +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.outbound.cdn.CDNDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * CDN Distribution 포트 라우터 + * + * 헥사고날 아키텍처의 애플리케이션 계층에서 클라우드 프로바이더 타입에 따라 + * 적절한 CDN Distribution 포트 구현체를 선택하는 라우터입니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Component +@Slf4j +public class CDNPortRouter { + + private final Map managementPorts; + private final Map discoveryPorts; + private final Map invalidationPorts; + + /** + * 생성자 - Spring이 자동으로 어댑터들을 주입합니다. + * + * @param managementPortList CDN Distribution 관리 포트 구현체 목록 + * @param discoveryPortList CDN Distribution 발견 포트 구현체 목록 + * @param invalidationPortList CDN 캐시 무효화 포트 구현체 목록 + */ + public CDNPortRouter( + List managementPortList, + List discoveryPortList, + List invalidationPortList) { + + // Management 포트 맵 초기화 + this.managementPorts = managementPortList.stream() + .filter(port -> port instanceof ProviderScoped) + .collect(Collectors.toMap( + port -> ((ProviderScoped) port).getProviderType(), + port -> port, + (existing, replacement) -> { + log.warn("중복된 CDN Distribution Management 포트 발견: provider={}, existing={}, replacement={}", + ((ProviderScoped) existing).getProviderType(), + existing.getClass().getSimpleName(), + replacement.getClass().getSimpleName()); + return existing; // 기존 것을 유지 + } + )); + + // Discovery 포트 맵 초기화 + this.discoveryPorts = discoveryPortList.stream() + .filter(port -> port instanceof ProviderScoped) + .collect(Collectors.toMap( + port -> ((ProviderScoped) port).getProviderType(), + port -> port, + (existing, replacement) -> { + log.warn("중복된 CDN Distribution Discovery 포트 발견: provider={}, existing={}, replacement={}", + ((ProviderScoped) existing).getProviderType(), + existing.getClass().getSimpleName(), + replacement.getClass().getSimpleName()); + return existing; // 기존 것을 유지 + } + )); + + // Invalidation 포트 맵 초기화 + this.invalidationPorts = invalidationPortList.stream() + .filter(port -> port instanceof ProviderScoped) + .collect(Collectors.toMap( + port -> ((ProviderScoped) port).getProviderType(), + port -> port, + (existing, replacement) -> { + log.warn("중복된 CDN Invalidation 포트 발견: provider={}, existing={}, replacement={}", + ((ProviderScoped) existing).getProviderType(), + existing.getClass().getSimpleName(), + replacement.getClass().getSimpleName()); + return existing; // 기존 것을 유지 + } + )); + + log.info("[CDNPortRouter] CDN Distribution 포트 라우터 초기화 완료: managementPorts={}, discoveryPorts={}, invalidationPorts={}", + managementPorts.keySet(), discoveryPorts.keySet(), invalidationPorts.keySet()); + } + + /** + * 지정된 프로바이더 타입에 해당하는 CDN Distribution 관리 포트를 반환합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @return CDN Distribution 관리 포트 + * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우 + */ + public CDNManagementPort management(CloudProvider.ProviderType providerType) { + CDNManagementPort port = managementPorts.get(providerType); + if (port == null) { + log.error("[CDNPortRouter] 지원하지 않는 프로바이더 타입: {}, 지원되는 타입: {}", providerType, managementPorts.keySet()); + throw new IllegalArgumentException("지원하지 않는 프로바이더 타입입니다: " + providerType); + } + + log.debug("[CDNPortRouter] CDN Distribution Management 포트 선택: provider={}, port={}", + providerType, port.getClass().getSimpleName()); + + return port; + } + + /** + * 지정된 프로바이더 타입에 해당하는 CDN Distribution 발견 포트를 반환합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @return CDN Distribution 발견 포트 + * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우 + */ + public CDNDiscoveryPort discovery(CloudProvider.ProviderType providerType) { + CDNDiscoveryPort port = discoveryPorts.get(providerType); + if (port == null) { + log.error("[CDNPortRouter] 지원하지 않는 프로바이더 타입: {}, 지원되는 타입: {}", providerType, discoveryPorts.keySet()); + throw new IllegalArgumentException("지원하지 않는 프로바이더 타입입니다: " + providerType); + } + + log.debug("[CDNPortRouter] CDN Distribution Discovery 포트 선택: provider={}, port={}", + providerType, port.getClass().getSimpleName()); + + return port; + } + + /** + * 지정된 프로바이더 타입에 해당하는 CDN 캐시 무효화 포트를 반환합니다. + * + * @param providerType 클라우드 프로바이더 타입 + * @return CDN 캐시 무효화 포트 + * @throws IllegalArgumentException 지원하지 않는 프로바이더 타입인 경우 + */ + public CDNInvalidationPort invalidation(CloudProvider.ProviderType providerType) { + CDNInvalidationPort port = invalidationPorts.get(providerType); + if (port == null) { + log.error("[CDNPortRouter] 지원하지 않는 프로바이더 타입: {}, 지원되는 타입: {}", providerType, invalidationPorts.keySet()); + throw new IllegalArgumentException("지원하지 않는 프로바이더 타입입니다: " + providerType); + } + + log.debug("[CDNPortRouter] CDN Invalidation 포트 선택: provider={}, port={}", + providerType, port.getClass().getSimpleName()); + + return port; + } +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseService.java new file mode 100644 index 00000000..c227f7ed --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseService.java @@ -0,0 +1,416 @@ +package com.agenticcp.core.domain.cloud.service.cdn; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.exception.CredentialErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.cdn.*; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort; +import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository; +import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper; +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.Map; +import java.util.Optional; + +/** + * CDN Distribution 유스케이스 서비스 + * + * CDN Distribution의 생성, 조회, 수정, 삭제 및 캐시 무효화 기능을 제공합니다. + * CSP에서 리소스를 생성/수정/삭제한 후 CloudResource 엔티티를 DB에 저장/업데이트합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CDNUseCaseService { + + private static final String SERVICE_KEY = "CloudFront"; + private static final String RESOURCE_TYPE = "CDN_DISTRIBUTION"; + + private final CDNPortRouter cdnPortRouter; + private final CapabilityGuard capabilityGuard; + private final AccountCredentialManagementPort accountCredentialManagementPort; + private final CloudResourceManagementHelper resourceHelper; + private final CloudResourceRepository cloudResourceRepository; + + /** + * CDN Distribution을 생성합니다. + * CSP에서 Distribution 생성 후 CloudResource 엔티티를 DB에 저장합니다. + * + * 보상 트랜잭션: DB 저장 실패 시 CSP에 생성된 Distribution을 삭제하여 + * 데이터 정합성(Ghost Resource 방지)을 보장합니다. + * + * @param command 생성 명령 + * @return 생성된 Distribution CloudResource + * @throws BusinessException DB 저장 실패 및 보상 트랜잭션 실행 시 + */ + @Transactional + public CloudResource createDistribution(CreateDistributionCommand command) { + capabilityGuard.ensureSupported( + command.providerType(), + SERVICE_KEY, + RESOURCE_TYPE, + CapabilityGuard.Operation.TAGGING + ); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + String accountScope = command.accountScope(); + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = acquireSession(accountScope, command.providerType()); + + // Command에 세션 설정 + CreateDistributionCommand commandWithSession = CreateDistributionCommand.builder() + .providerType(command.providerType()) + .accountScope(command.accountScope()) + .serviceKey(command.serviceKey()) + .resourceType(command.resourceType()) + .distributionName(command.distributionName()) + .comment(command.comment()) + .enabled(command.enabled()) + .origin(command.origin()) + .cacheBehaviors(command.cacheBehaviors()) + .aliases(command.aliases()) + .sslCertificateId(command.sslCertificateId()) + .tags(command.tags()) + .tenantKey(tenantKey) + .session(session) + .build(); + + // CSP에서 Distribution 생성 + CDNManagementPort managementPort = cdnPortRouter.management(command.providerType()); + CloudResource distribution = managementPort.createDistribution(commandWithSession); + + // DB에 CloudResource 저장 (실패 시 보상 트랜잭션 실행) + try { + String resourceName = command.distributionName() != null + ? command.distributionName() + : distribution.getResourceId(); + + ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() + .resourceId(distribution.getResourceId()) + .resourceName(resourceName) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .tags(command.tags()) + .attributes(Map.of()) // Origin, CacheBehavior 등은 metadata에 저장됨 + .build(); + + // CloudResource 등록 + CloudResource savedResource = resourceHelper.registerResource( + command.providerType(), + SERVICE_KEY, + registrationRequest + ); + + // distribution 객체의 metadata를 DB에 저장된 리소스에 설정 + if (distribution.getMetadata() != null && !distribution.getMetadata().isEmpty()) { + savedResource.setMetadata(distribution.getMetadata()); + cloudResourceRepository.save(savedResource); + log.debug("[CDNUseCaseService] Distribution metadata 저장 완료: distributionId={}", + distribution.getResourceId()); + } + } catch (Exception e) { + log.error("[CDNUseCaseService] DB 저장 실패, 보상 트랜잭션 실행: distributionId={}, error={}", + distribution.getResourceId(), e.getMessage()); + + // 보상 트랜잭션: CSP에 생성된 Distribution 삭제 + executeCompensatingTransaction( + command.providerType(), + managementPort, + distribution.getResourceId(), + accountScope, + session + ); + + throw new BusinessException( + CloudErrorCode.RESOURCE_CREATION_FAILED, + "Distribution 생성 후 DB 저장 실패로 인해 롤백되었습니다: " + distribution.getResourceId() + ); + } + + return distribution; + } + + /** + * CDN Distribution을 조회합니다. + * + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @param providerType 프로바이더 타입 + * @return Distribution CloudResource (존재하지 않으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getDistribution(String accountScope, String distributionId, ProviderType providerType) { + validateAccountScope(accountScope); + + CDNDiscoveryPort discoveryPort = cdnPortRouter.discovery(providerType); + return discoveryPort.getDistribution(accountScope, distributionId); + } + + /** + * CDN Distribution 목록을 조회합니다. + * + * @param query 조회 쿼리 + * @return Distribution 목록 (페이징 정보 포함) + */ + @Transactional(readOnly = true) + public Page listDistributions(CDNDistributionQueryRequest query) { + validateAccountScope(query.accountScope()); + + // JIT 세션 획득 + CloudSessionCredential session = acquireSession(query.accountScope(), query.providerType()); + + // Query에 세션 설정 + CDNDistributionQueryRequest queryWithSession = CDNDistributionQueryRequest.builder() + .providerType(query.providerType()) + .accountScope(query.accountScope()) + .distributionName(query.distributionName()) + .enabled(query.enabled()) + .tags(query.tags()) + .tenantKey(query.tenantKey()) + .page(query.page()) + .size(query.size()) + .sortBy(query.sortBy()) + .sortDirection(query.sortDirection()) + .session(session) + .build(); + + CDNDiscoveryPort discoveryPort = cdnPortRouter.discovery(query.providerType()); + return discoveryPort.listDistributions(queryWithSession); + } + + /** + * CDN Distribution을 수정합니다. + * + * @param command 수정 명령 + * @return 수정된 Distribution CloudResource + */ + @Transactional + public CloudResource updateDistribution(UpdateDistributionCommand command) { + capabilityGuard.ensureSupported( + command.providerType(), + SERVICE_KEY, + RESOURCE_TYPE, + CapabilityGuard.Operation.TAGGING + ); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + String accountScope = command.accountScope(); + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = acquireSession(accountScope, command.providerType()); + + // Command에 세션 설정 + UpdateDistributionCommand commandWithSession = UpdateDistributionCommand.builder() + .providerType(command.providerType()) + .accountScope(command.accountScope()) + .distributionId(command.distributionId()) + .etag(command.etag()) + .comment(command.comment()) + .enabled(command.enabled()) + .cacheBehaviors(command.cacheBehaviors()) + .aliases(command.aliases()) + .sslCertificateId(command.sslCertificateId()) + .tags(command.tags()) + .tenantKey(tenantKey) + .session(session) + .build(); + + CDNManagementPort managementPort = cdnPortRouter.management(command.providerType()); + CloudResource distribution = managementPort.updateDistribution(commandWithSession); + + // DB 업데이트는 별도 동기화 작업으로 처리 (VPC 패턴과 동일) + // 여기서는 CSP 업데이트만 수행하고 CloudResource 반환 + + return distribution; + } + + /** + * CDN Distribution을 삭제합니다. + * + * @param command 삭제 명령 + */ + @Transactional + public void deleteDistribution(DeleteDistributionCommand command) { + capabilityGuard.ensureSupported( + command.providerType(), + SERVICE_KEY, + RESOURCE_TYPE, + CapabilityGuard.Operation.TERMINATE + ); + + // accountScope 검증 + String accountScope = command.accountScope(); + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = acquireSession(accountScope, command.providerType()); + + // Command에 세션 설정 + DeleteDistributionCommand commandWithSession = DeleteDistributionCommand.builder() + .providerType(command.providerType()) + .accountScope(command.accountScope()) + .distributionId(command.distributionId()) + .etag(command.etag()) + .session(session) + .build(); + + CDNManagementPort managementPort = cdnPortRouter.management(command.providerType()); + + // CSP에서 Distribution 삭제 + managementPort.deleteDistribution(commandWithSession); + + // DB 소프트 삭제 + resourceHelper.softDeleteResource(command.distributionId()); + } + + /** + * 캐시 무효화를 생성합니다. + * + * @param command 무효화 생성 명령 + * @return 생성된 무효화 결과 + */ + @Transactional + public InvalidationResult createInvalidation(CreateInvalidationCommand command) { + validateAccountScope(command.accountScope()); + + // JIT 세션 획득 + CloudSessionCredential session = acquireSession(command.accountScope(), ProviderType.AWS); + + // Command에 세션 설정 + CreateInvalidationCommand commandWithSession = CreateInvalidationCommand.builder() + .accountScope(command.accountScope()) + .distributionId(command.distributionId()) + .paths(command.paths()) + .callerReference(command.callerReference()) + .session(session) + .build(); + + CDNInvalidationPort invalidationPort = cdnPortRouter.invalidation(ProviderType.AWS); + return invalidationPort.createInvalidation(commandWithSession); + } + + /** + * 캐시 무효화 상태를 조회합니다. + * + * @param accountScope 계정 스코프 + * @param distributionId Distribution ID + * @param invalidationId 무효화 ID + * @param providerType 프로바이더 타입 + * @return 무효화 결과 (존재하지 않으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getInvalidation( + String accountScope, + String distributionId, + String invalidationId, + ProviderType providerType) { + validateAccountScope(accountScope); + + CDNInvalidationPort invalidationPort = cdnPortRouter.invalidation(providerType); + return invalidationPort.getInvalidation(accountScope, distributionId, invalidationId); + } + + /** + * 보상 트랜잭션: CSP에 생성된 Distribution을 삭제합니다. + * Ghost Resource 방지를 위해 DB 저장 실패 시 호출됩니다. + */ + private void executeCompensatingTransaction( + ProviderType providerType, + CDNManagementPort managementPort, + String distributionId, + String accountScope, + CloudSessionCredential session + ) { + try { + log.warn("[CDNUseCaseService] 보상 트랜잭션 실행: CSP Distribution 삭제 시도 - distributionId={}", distributionId); + + // ETag 조회를 위해 먼저 Distribution 조회 + CDNDiscoveryPort discoveryPort = cdnPortRouter.discovery(providerType); + Optional distributionOpt = discoveryPort.getDistribution(accountScope, distributionId); + + if (distributionOpt.isPresent()) { + // metadata에서 ETag 추출 (간단히 처리, 실제로는 파싱 필요) + String etag = "dummy-etag"; // 실제로는 metadata에서 추출 + + DeleteDistributionCommand deleteCommand = DeleteDistributionCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .distributionId(distributionId) + .etag(etag) + .session(session) + .build(); + + managementPort.deleteDistribution(deleteCommand); + log.info("[CDNUseCaseService] 보상 트랜잭션 완료: CSP Distribution 삭제 성공 - distributionId={}", distributionId); + } else { + log.warn("[CDNUseCaseService] 보상 트랜잭션: Distribution을 찾을 수 없음 - distributionId={}", distributionId); + } + } catch (Exception compensationError) { + // 보상 트랜잭션도 실패한 경우 - Ghost Resource 발생 + log.error("[CDNUseCaseService] 보상 트랜잭션 실패: Ghost Resource 발생 가능 - distributionId={}, error={}", + distributionId, compensationError.getMessage()); + } + } + + /** + * accountScope를 검증합니다. + */ + private void validateAccountScope(String accountScope) { + if (accountScope == null || accountScope.trim().isEmpty()) { + throw new BusinessException( + CloudErrorCode.ACCOUNT_SCOPE_REQUIRED, + "AccountScope가 필요합니다" + ); + } + } + + /** + * 세션을 획득합니다. + */ + private CloudSessionCredential acquireSession(String accountScope, ProviderType providerType) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + try { + CloudSessionCredential session = accountCredentialManagementPort.getSession( + tenantKey, accountScope, providerType); + log.debug("[CDNUseCaseService] 세션 획득 완료: expiresAt={}", session.getExpiresAt()); + return session; + } catch (BusinessException e) { + if (e.getErrorCode() == CredentialErrorCode.CREDENTIAL_NOT_FOUND) { + log.error("[CDNUseCaseService] 자격증명을 찾을 수 없습니다: tenantKey={}, accountScope={}", + tenantKey, accountScope); + throw new BusinessException( + CloudErrorCode.ACCOUNT_NOT_CONFIGURED, + "계정이 설정되지 않았습니다" + ); + } + throw e; + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapterTest.java new file mode 100644 index 00000000..e915d5f2 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontDiscoveryAdapterTest.java @@ -0,0 +1,546 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.common.context.TenantContextHolder; +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.aws.config.AwsCloudFrontConfig; +import com.agenticcp.core.domain.cloud.dto.CDNDistributionQueryRequest; +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.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; +import software.amazon.awssdk.services.cloudfront.model.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * AwsCloudFrontDiscoveryAdapter 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AwsCloudFrontDiscoveryAdapter 테스트") +class AwsCloudFrontDiscoveryAdapterTest { + + @Mock + private AwsCloudFrontMapper mapper; + + @Mock + private CloudProviderRepository cloudProviderRepository; + + @Mock + private AccountCredentialManagementPort accountCredentialManagementPort; + + @Mock + private AwsCloudFrontConfig awsCloudFrontConfig; + + @Mock + private AwsCloudFrontErrorTranslator errorTranslator; + + @Mock + private CloudFrontClient cloudFrontClient; + + @InjectMocks + private AwsCloudFrontDiscoveryAdapter adapter; + + private static final String TENANT_KEY = "test-tenant"; + private static final String ACCOUNT_SCOPE = "123456789012"; + private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL"; + private static final String DISTRIBUTION_ARN = "arn:aws:cloudfront::123456789012:distribution/E2QWRUHAPOMQZL"; + private static final String ETAG = "ETAG123456789"; + + private AwsSessionCredential session; + + @BeforeEach + void setUp() { + session = AwsSessionCredential.builder() + .accessKeyId("AKIA_TEST") + .secretAccessKey("secret") + .sessionToken("token") + .region("us-east-1") + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + // CloudFrontClient close()가 예외를 던지지 않도록 설정 + doNothing().when(cloudFrontClient).close(); + + // errorTranslator 기본 모킹: 예외가 발생할 경우를 대비 + lenient().when(errorTranslator.translate(any(Exception.class))) + .thenAnswer(invocation -> { + Exception e = invocation.getArgument(0); + return new BusinessException(CloudErrorCode.CLOUD_PROVIDER_UNAVAILABLE, e.getMessage()); + }); + + // 공통 Mock 설정: 모든 테스트에서 사용되는 기본 설정 + lenient().when(accountCredentialManagementPort.getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS))) + .thenReturn(session); + lenient().when(awsCloudFrontConfig.createCloudFrontClient(session)) + .thenReturn(cloudFrontClient); + } + + @Nested + @DisplayName("Distribution 목록 조회 테스트") + class ListDistributionsTest { + + @Test + @DisplayName("정상적인 Distribution 목록 조회") + void listDistributions_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + DistributionSummary summary1 = createMockDistributionSummary("E2QWRUHAPOMQZL", true); + DistributionSummary summary2 = createMockDistributionSummary("E2QWRUHAPOMQZL2", false); + + DistributionList distributionList = DistributionList.builder() + .items(summary1, summary2) + .isTruncated(false) + .nextMarker(null) + .build(); + + ListDistributionsResponse listResponse = ListDistributionsResponse.builder() + .distributionList(distributionList) + .build(); + + when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class))) + .thenReturn(listResponse); + + CloudResource resource1 = createMockCloudResource("E2QWRUHAPOMQZL", "dist-1"); + CloudResource resource2 = createMockCloudResource("E2QWRUHAPOMQZL2", "dist-2"); + + when(mapper.toCloudResource(eq(summary1), any(CDNDistributionQueryRequest.class))) + .thenReturn(resource1); + when(mapper.toCloudResource(eq(summary2), any(CDNDistributionQueryRequest.class))) + .thenReturn(resource2); + + when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse.builder() + .tags(Tags.builder() + .items(Collections.emptyList()) + .build()) + .build()); + + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .tenantKey(TENANT_KEY) + .page(0) + .size(20) + .sortBy("name") + .sortDirection("asc") + .build(); + + // When + Page result = adapter.listDistributions(query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(2); + + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS)); + verify(cloudFrontClient).listDistributions(any(ListDistributionsRequest.class)); + } + } + + @Test + @DisplayName("Distribution 이름 필터링") + void listDistributions_WithNameFilter_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + DistributionSummary summary1 = createMockDistributionSummary("E2QWRUHAPOMQZL", true); + DistributionSummary summary2 = createMockDistributionSummary("E2QWRUHAPOMQZL2", false); + + DistributionList distributionList = DistributionList.builder() + .items(summary1, summary2) + .isTruncated(false) + .build(); + + when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class))) + .thenReturn(ListDistributionsResponse.builder() + .distributionList(distributionList) + .build()); + + CloudResource resource1 = createMockCloudResource("E2QWRUHAPOMQZL", "test-distribution"); + CloudResource resource2 = createMockCloudResource("E2QWRUHAPOMQZL2", "other-distribution"); + + when(mapper.toCloudResource(any(DistributionSummary.class), any(CDNDistributionQueryRequest.class))) + .thenReturn(resource1, resource2); + + when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse.builder() + .tags(Tags.builder().items(Collections.emptyList()).build()) + .build()); + + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .tenantKey(TENANT_KEY) + .distributionName("test") + .page(0) + .size(20) + .build(); + + // When + Page result = adapter.listDistributions(query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getResourceName()).contains("test"); + } + } + + @Test + @DisplayName("태그 필터링") + void listDistributions_WithTagFilter_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + DistributionSummary summary = createMockDistributionSummary(DISTRIBUTION_ID, true); + DistributionList distributionList = DistributionList.builder() + .items(summary) + .isTruncated(false) + .build(); + + when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class))) + .thenReturn(ListDistributionsResponse.builder() + .distributionList(distributionList) + .build()); + + CloudResource resource = createMockCloudResource(DISTRIBUTION_ID, "test-distribution"); + resource.setTags(Map.of("Environment", "Test", "Project", "CDN")); + + when(mapper.toCloudResource(any(DistributionSummary.class), any(CDNDistributionQueryRequest.class))) + .thenReturn(resource); + + when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse.builder() + .tags(Tags.builder() + .items( + Tag.builder().key("Environment").value("Test").build(), + Tag.builder().key("Project").value("CDN").build() + ) + .build()) + .build()); + + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .tenantKey(TENANT_KEY) + .tags(Map.of("Environment", "Test")) + .page(0) + .size(20) + .build(); + + // When + Page result = adapter.listDistributions(query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + } + } + + @Test + @DisplayName("페이징 처리") + void listDistributions_WithPagination_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + List summaries = new ArrayList<>(); + for (int i = 0; i < 25; i++) { + summaries.add(createMockDistributionSummary("E" + i, true)); + } + + DistributionList distributionList = DistributionList.builder() + .items(summaries) + .isTruncated(false) + .build(); + + when(cloudFrontClient.listDistributions(any(ListDistributionsRequest.class))) + .thenReturn(ListDistributionsResponse.builder() + .distributionList(distributionList) + .build()); + + CloudResource mockResource = createMockCloudResource("E1", "dist-1"); + when(mapper.toCloudResource(any(DistributionSummary.class), any(CDNDistributionQueryRequest.class))) + .thenReturn(mockResource); + + when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse.builder() + .tags(Tags.builder().items(Collections.emptyList()).build()) + .build()); + + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .tenantKey(TENANT_KEY) + .page(0) + .size(10) + .build(); + + // When + Page result = adapter.listDistributions(query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSize()).isEqualTo(10); + assertThat(result.getTotalElements()).isEqualTo(25); + } + } + + @Test + @DisplayName("AccountScope 누락 시 예외 발생") + void listDistributions_WithoutAccountScope_ThrowsException() { + // Given + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(CloudProvider.ProviderType.AWS) + .accountScope(null) + .tenantKey(TENANT_KEY) + .build(); + + // When & Then + assertThatThrownBy(() -> adapter.listDistributions(query)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("AccountScope"); + } + } + + @Nested + @DisplayName("Distribution 단건 조회 테스트") + class GetDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 조회") + void getDistribution_Success() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(distribution) + .eTag(ETAG) + .build(); + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse); + + CloudResource mockResource = createMockCloudResource(DISTRIBUTION_ID, "test-distribution"); + when(mapper.toCloudResource( + eq(distribution), eq(ETAG), eq(CloudProvider.ProviderType.AWS), anyMap())) + .thenReturn(mockResource); + + when(cloudFrontClient.listTagsForResource(any(ListTagsForResourceRequest.class))) + .thenReturn(ListTagsForResourceResponse.builder() + .tags(Tags.builder() + .items( + Tag.builder().key("Environment").value("Test").build() + ) + .build()) + .build()); + + // When + Optional result = adapter.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getResourceId()).isEqualTo(DISTRIBUTION_ID); + + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS)); + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient).listTagsForResource(any(ListTagsForResourceRequest.class)); + } + } + + @Test + @DisplayName("Distribution이 존재하지 않을 때 Optional.empty 반환") + void getDistribution_NotFound_ReturnsEmpty() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenThrow(NoSuchDistributionException.builder() + .message("Distribution not found") + .build()); + + // When + Optional result = adapter.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID); + + // Then + assertThat(result).isEmpty(); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient, never()).listTagsForResource(any(ListTagsForResourceRequest.class)); + } + } + } + + @Nested + @DisplayName("Distribution 존재 여부 확인 테스트") + class DistributionExistsTest { + + @Test + @DisplayName("Distribution이 존재할 때 true 반환") + void distributionExists_Exists_ReturnsTrue() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(distribution) + .eTag(ETAG) + .build(); + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse); + + // When + boolean exists = adapter.distributionExists(ACCOUNT_SCOPE, DISTRIBUTION_ID); + + // Then + assertThat(exists).isTrue(); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + } + } + + @Test + @DisplayName("Distribution이 존재하지 않을 때 false 반환") + void distributionExists_NotExists_ReturnsFalse() { + try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) { + // Given + mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY); + // accountCredentialManagementPort와 awsCloudFrontConfig는 @BeforeEach에서 설정됨 + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenThrow(NoSuchDistributionException.builder() + .message("Distribution not found") + .build()); + + // When + boolean exists = adapter.distributionExists(ACCOUNT_SCOPE, DISTRIBUTION_ID); + + // Then + assertThat(exists).isFalse(); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + } + } + } + + @Nested + @DisplayName("ProviderScoped 테스트") + class ProviderScopedTest { + + @Test + @DisplayName("getProviderType은 AWS를 반환") + void getProviderType_ReturnsAWS() { + // When + CloudProvider.ProviderType result = adapter.getProviderType(); + + // Then + assertThat(result).isEqualTo(CloudProvider.ProviderType.AWS); + } + } + + /** + * Mock DistributionSummary 객체 생성 헬퍼 메서드 + */ + private DistributionSummary createMockDistributionSummary(String distributionId, boolean enabled) { + return DistributionSummary.builder() + .id(distributionId) + .arn("arn:aws:cloudfront::123456789012:distribution/" + distributionId) + .status(enabled ? "Deployed" : "InProgress") + .domainName("d1234567890.cloudfront.net") + .enabled(enabled) + .comment("Test distribution") + .lastModifiedTime(Instant.now()) + .build(); + } + + /** + * Mock Distribution 객체 생성 헬퍼 메서드 + */ + private Distribution createMockDistribution(String distributionId, boolean enabled) { + DistributionConfig config = DistributionConfig.builder() + .enabled(enabled) + .comment("Test distribution") + .callerReference("test-caller-ref") + .origins(Origins.builder() + .quantity(1) + .items(Origin.builder() + .id("origin-1") + .domainName("example.com") + .build()) + .build()) + .defaultCacheBehavior(DefaultCacheBehavior.builder() + .targetOriginId("origin-1") + .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS) + .build()) + .build(); + + return Distribution.builder() + .id(distributionId) + .arn(DISTRIBUTION_ARN) + .status(enabled ? "Deployed" : "InProgress") + .domainName("d1234567890.cloudfront.net") + .distributionConfig(config) + .lastModifiedTime(Instant.now()) + .build(); + } + + /** + * Mock CloudResource 객체 생성 헬퍼 메서드 + */ + private CloudResource createMockCloudResource(String resourceId, String resourceName) { + return CloudResource.builder() + .resourceId(resourceId) + .resourceName(resourceName) + .displayName(resourceName) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .lifecycleState(CloudResource.LifecycleState.RUNNING) + .createdInCloud(LocalDateTime.now()) + .build(); + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapterTest.java new file mode 100644 index 00000000..2040728c --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontInvalidationAdapterTest.java @@ -0,0 +1,465 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +import com.agenticcp.core.common.context.TenantContextHolder; +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.aws.config.AwsCloudFrontConfig; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.cdn.CreateInvalidationCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.InvalidationResult; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; +import software.amazon.awssdk.services.cloudfront.model.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * AwsCloudFrontInvalidationAdapter 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AwsCloudFrontInvalidationAdapter 테스트") +class AwsCloudFrontInvalidationAdapterTest { + + @Mock + private AwsCloudFrontConfig awsCloudFrontConfig; + + @Mock + private AwsCloudFrontErrorTranslator errorTranslator; + + @Mock + private AccountCredentialManagementPort accountCredentialManagementPort; + + @Mock + private CloudFrontClient cloudFrontClient; + + @InjectMocks + private AwsCloudFrontInvalidationAdapter adapter; + + private static final String TENANT_KEY = "test-tenant"; + private static final String ACCOUNT_SCOPE = "123456789012"; + private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL"; + private static final String INVALIDATION_ID = "I2J3K4L5M6N7O"; + + private AwsSessionCredential session; + + @BeforeEach + void setUp() { + session = AwsSessionCredential.builder() + .accessKeyId("AKIA_TEST") + .secretAccessKey("secret") + .sessionToken("token") + .region("us-east-1") + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + // CloudFrontClient close()가 예외를 던지지 않도록 설정 + doNothing().when(cloudFrontClient).close(); + + // errorTranslator 기본 모킹 + lenient().when(errorTranslator.translate(any(Exception.class))) + .thenAnswer(invocation -> { + Exception e = invocation.getArgument(0); + return new BusinessException(CloudErrorCode.CLOUD_PROVIDER_UNAVAILABLE, e.getMessage()); + }); + + // 공통 Mock 설정 + lenient().when(accountCredentialManagementPort.getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS))) + .thenReturn(session); + lenient().when(awsCloudFrontConfig.createCloudFrontClient(session)) + .thenReturn(cloudFrontClient); + } + + @Nested + @DisplayName("캐시 무효화 생성 테스트") + class CreateInvalidationTest { + + @Test + @DisplayName("정상적인 무효화 생성 (CallerReference 제공)") + void createInvalidation_Success_WithCallerReference() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .paths(List.of("/path1", "/path2")) + .callerReference("custom-reference-123") + .build(); + + Instant createTime = Instant.now(); + Invalidation invalidation = Invalidation.builder() + .id(INVALIDATION_ID) + .status("InProgress") + .createTime(createTime) + .invalidationBatch(InvalidationBatch.builder() + .paths(Paths.builder() + .quantity(2) + .items(List.of("/path1", "/path2")) + .build()) + .callerReference("custom-reference-123") + .build()) + .build(); + + CreateInvalidationResponse response = CreateInvalidationResponse.builder() + .invalidation(invalidation) + .location("https://cloudfront.amazonaws.com/invalidation/" + INVALIDATION_ID) + .build(); + + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + when(cloudFrontClient.createInvalidation(any(CreateInvalidationRequest.class))) + .thenReturn(response); + + // When + InvalidationResult result = adapter.createInvalidation(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.invalidationId()).isEqualTo(INVALIDATION_ID); + assertThat(result.distributionId()).isEqualTo(DISTRIBUTION_ID); + assertThat(result.status()).isEqualTo("InProgress"); + assertThat(result.paths()).containsExactly("/path1", "/path2"); + assertThat(result.createTime()).isNotNull(); + + verify(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class)); + } + } + + @Test + @DisplayName("정상적인 무효화 생성 (CallerReference 미제공, 자동 생성)") + void createInvalidation_Success_WithoutCallerReference() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .paths(List.of("/*")) + .callerReference(null) + .build(); + + Instant createTime = Instant.now(); + Invalidation invalidation = Invalidation.builder() + .id(INVALIDATION_ID) + .status("InProgress") + .createTime(createTime) + .invalidationBatch(InvalidationBatch.builder() + .paths(Paths.builder() + .quantity(1) + .items(List.of("/*")) + .build()) + .callerReference("auto-generated-reference") + .build()) + .build(); + + CreateInvalidationResponse response = CreateInvalidationResponse.builder() + .invalidation(invalidation) + .build(); + + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + when(cloudFrontClient.createInvalidation(any(CreateInvalidationRequest.class))) + .thenReturn(response); + + // When + InvalidationResult result = adapter.createInvalidation(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.invalidationId()).isEqualTo(INVALIDATION_ID); + assertThat(result.paths()).containsExactly("/*"); + + verify(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class)); + } + } + + @Test + @DisplayName("정상적인 무효화 생성 (경로 목록 비어있음, 기본값 사용)") + void createInvalidation_Success_WithEmptyPaths() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .paths(List.of()) + .callerReference("test-reference") + .build(); + + Instant createTime = Instant.now(); + Invalidation invalidation = Invalidation.builder() + .id(INVALIDATION_ID) + .status("InProgress") + .createTime(createTime) + .invalidationBatch(InvalidationBatch.builder() + .paths(Paths.builder() + .quantity(1) + .items(List.of("/*")) + .build()) + .callerReference("test-reference") + .build()) + .build(); + + CreateInvalidationResponse response = CreateInvalidationResponse.builder() + .invalidation(invalidation) + .build(); + + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + when(cloudFrontClient.createInvalidation(any(CreateInvalidationRequest.class))) + .thenReturn(response); + + // When + InvalidationResult result = adapter.createInvalidation(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.paths()).containsExactly("/*"); // 기본값 사용 + + verify(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class)); + } + } + + @Test + @DisplayName("무효화 생성 실패 (AccountScope 없음)") + void createInvalidation_WithoutAccountScope_ThrowsException() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(null) + .distributionId(DISTRIBUTION_ID) + .paths(List.of("/path1")) + .build(); + + // When & Then + assertThatThrownBy(() -> adapter.createInvalidation(command)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("AccountScope가 필요합니다"); + + verify(cloudFrontClient, never()).createInvalidation(any(CreateInvalidationRequest.class)); + } + + @Test + @DisplayName("무효화 생성 실패 (AWS SDK 예외)") + void createInvalidation_AwsSdkException_ThrowsBusinessException() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .paths(List.of("/path1")) + .build(); + + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + doThrow(CloudFrontException.builder() + .message("Distribution not found") + .statusCode(404) + .build()) + .when(cloudFrontClient).createInvalidation(any(CreateInvalidationRequest.class)); + + BusinessException translatedException = new BusinessException( + CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND, "Distribution not found"); + when(errorTranslator.translate(any(CloudFrontException.class))) + .thenReturn(translatedException); + + // When & Then + assertThatThrownBy(() -> adapter.createInvalidation(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("캐시 무효화 조회 테스트") + class GetInvalidationTest { + + @Test + @DisplayName("정상적인 무효화 조회") + void getInvalidation_Success() { + // Given + Instant createTime = Instant.now(); + Invalidation invalidation = Invalidation.builder() + .id(INVALIDATION_ID) + .status("Completed") + .createTime(createTime) + .invalidationBatch(InvalidationBatch.builder() + .paths(Paths.builder() + .quantity(2) + .items(List.of("/path1", "/path2")) + .build()) + .callerReference("test-reference") + .build()) + .build(); + + GetInvalidationResponse response = GetInvalidationResponse.builder() + .invalidation(invalidation) + .build(); + + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + when(cloudFrontClient.getInvalidation(any(GetInvalidationRequest.class))) + .thenReturn(response); + + // When + Optional result = adapter.getInvalidation( + ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID); + + // Then + assertThat(result).isPresent(); + InvalidationResult invalidationResult = result.get(); + assertThat(invalidationResult.invalidationId()).isEqualTo(INVALIDATION_ID); + assertThat(invalidationResult.distributionId()).isEqualTo(DISTRIBUTION_ID); + assertThat(invalidationResult.status()).isEqualTo("Completed"); + assertThat(invalidationResult.paths()).containsExactly("/path1", "/path2"); + assertThat(invalidationResult.createTime()).isNotNull(); + + verify(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class)); + } + } + + @Test + @DisplayName("무효화 조회 실패 (존재하지 않음)") + void getInvalidation_NotFound_ReturnsEmpty() { + // Given + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + when(cloudFrontClient.getInvalidation(any(GetInvalidationRequest.class))) + .thenThrow(NoSuchInvalidationException.builder() + .message("Invalidation not found") + .build()); + + // When + Optional result = adapter.getInvalidation( + ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID); + + // Then + assertThat(result).isEmpty(); + + verify(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class)); + } + } + + @Test + @DisplayName("무효화 조회 실패 (경로 정보 없음)") + void getInvalidation_WithoutPaths_ReturnsEmptyPaths() { + // Given + Instant createTime = Instant.now(); + Invalidation invalidation = Invalidation.builder() + .id(INVALIDATION_ID) + .status("InProgress") + .createTime(createTime) + .invalidationBatch(InvalidationBatch.builder() + .paths((Paths) null) // 경로 정보 없음 + .build()) + .build(); + + GetInvalidationResponse response = GetInvalidationResponse.builder() + .invalidation(invalidation) + .build(); + + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + when(cloudFrontClient.getInvalidation(any(GetInvalidationRequest.class))) + .thenReturn(response); + + // When + Optional result = adapter.getInvalidation( + ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().paths()).isEmpty(); // 빈 리스트 반환 + + verify(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class)); + } + } + + @Test + @DisplayName("무효화 조회 실패 (AccountScope 없음)") + void getInvalidation_WithoutAccountScope_ThrowsException() { + // When & Then + assertThatThrownBy(() -> adapter.getInvalidation(null, DISTRIBUTION_ID, INVALIDATION_ID)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("AccountScope가 필요합니다"); + + verify(cloudFrontClient, never()).getInvalidation(any(GetInvalidationRequest.class)); + } + + @Test + @DisplayName("무효화 조회 실패 (AWS SDK 예외)") + void getInvalidation_AwsSdkException_ThrowsBusinessException() { + // Given + try (MockedStatic mockedTenant = mockStatic(TenantContextHolder.class)) { + mockedTenant.when(TenantContextHolder::getCurrentTenantKeyOrThrow) + .thenReturn(TENANT_KEY); + + doThrow(CloudFrontException.builder() + .message("Access denied") + .statusCode(403) + .build()) + .when(cloudFrontClient).getInvalidation(any(GetInvalidationRequest.class)); + + BusinessException translatedException = new BusinessException( + CloudErrorCode.PERMISSION_DENIED, "Access denied"); + when(errorTranslator.translate(any(CloudFrontException.class))) + .thenReturn(translatedException); + + // When & Then + assertThatThrownBy(() -> adapter.getInvalidation( + ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.PERMISSION_DENIED); + } + } + } + + @Nested + @DisplayName("ProviderScoped 테스트") + class ProviderScopedTest { + + @Test + @DisplayName("getProviderType은 AWS를 반환") + void getProviderType_ReturnsAWS() { + // When + CloudProvider.ProviderType result = adapter.getProviderType(); + + // Then + assertThat(result).isEqualTo(CloudProvider.ProviderType.AWS); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapterTest.java new file mode 100644 index 00000000..2b7e4152 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/cloudfront/AwsCloudFrontManagementAdapterTest.java @@ -0,0 +1,634 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.cloudfront; + +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.aws.config.AwsCloudFrontConfig; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.cdn.*; +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.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.cloudfront.CloudFrontClient; +import software.amazon.awssdk.services.cloudfront.model.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +/** + * AwsCloudFrontManagementAdapter 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AwsCloudFrontManagementAdapter 테스트") +class AwsCloudFrontManagementAdapterTest { + + @Mock + private AwsCloudFrontConfig awsCloudFrontConfig; + + @Mock + private AwsCloudFrontMapper awsCloudFrontMapper; + + @Mock + private CloudFrontClient cloudFrontClient; + + @InjectMocks + private AwsCloudFrontManagementAdapter adapter; + + private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL"; + private static final String DISTRIBUTION_ARN = "arn:aws:cloudfront::123456789012:distribution/E2QWRUHAPOMQZL"; + private static final String ETAG = "ETAG123456789"; + private static final String ACCOUNT_SCOPE = "123456789012"; + private static final String TENANT_KEY = "test-tenant"; + + private AwsSessionCredential mockSession; + private OriginConfig originConfig; + private CacheBehaviorConfig cacheBehaviorConfig; + + @BeforeEach + void setUp() { + mockSession = AwsSessionCredential.builder() + .accessKeyId("AKIA_TEST") + .secretAccessKey("secret") + .sessionToken("token") + .region("us-east-1") + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + originConfig = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .httpPort(80) + .httpsPort(443) + .originProtocolPolicy("https-only") + .build(); + + cacheBehaviorConfig = CacheBehaviorConfig.builder() + .pathPattern("/*") + .ttl(86400L) + .allowedMethods(List.of("GET", "HEAD", "OPTIONS")) + .compress(true) + .viewerProtocolPolicy("redirect-to-https") + .build(); + } + + @Nested + @DisplayName("Distribution 생성 테스트") + class CreateDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 생성 (태그 없음)") + void createDistribution_Success_WithoutTags() { + // Given + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionName("test-distribution") + .comment("Test distribution") + .enabled(true) + .origin(originConfig) + .cacheBehaviors(List.of(cacheBehaviorConfig)) + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true); + CreateDistributionResponse createResponse = CreateDistributionResponse.builder() + .distribution(distribution) + .eTag(ETAG) + .location("https://cloudfront.amazonaws.com/distribution/" + DISTRIBUTION_ID) + .build(); + + when(cloudFrontClient.createDistribution(any(CreateDistributionRequest.class))) + .thenReturn(createResponse); + + CloudResource mockResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName("test-distribution") + .build(); + + when(awsCloudFrontMapper.toCloudResource(eq(distribution), eq(ETAG), eq(command))) + .thenReturn(mockResource); + + // When + CloudResource result = adapter.createDistribution(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID); + assertThat(result.getResourceName()).isEqualTo("test-distribution"); + + verify(awsCloudFrontConfig).createCloudFrontClient(mockSession); + verify(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class)); + verify(cloudFrontClient, never()).tagResource(any(TagResourceRequest.class)); + verify(awsCloudFrontMapper).toCloudResource(eq(distribution), eq(ETAG), eq(command)); + } + + @Test + @DisplayName("정상적인 Distribution 생성 (태그 포함)") + void createDistribution_Success_WithTags() { + // Given + Map tags = Map.of( + "Environment", "Test", + "Project", "CDN" + ); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionName("test-distribution") + .comment("Test distribution") + .enabled(true) + .origin(originConfig) + .cacheBehaviors(List.of(cacheBehaviorConfig)) + .tags(tags) + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true); + CreateDistributionResponse createResponse = CreateDistributionResponse.builder() + .distribution(distribution) + .eTag(ETAG) + .location("https://cloudfront.amazonaws.com/distribution/" + DISTRIBUTION_ID) + .build(); + + when(cloudFrontClient.createDistribution(any(CreateDistributionRequest.class))) + .thenReturn(createResponse); + when(cloudFrontClient.tagResource(any(TagResourceRequest.class))) + .thenReturn(TagResourceResponse.builder().build()); + + CloudResource mockResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName("test-distribution") + .build(); + + when(awsCloudFrontMapper.toCloudResource(eq(distribution), eq(ETAG), eq(command))) + .thenReturn(mockResource); + + // When + CloudResource result = adapter.createDistribution(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID); + + verify(awsCloudFrontConfig).createCloudFrontClient(mockSession); + verify(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class)); + verify(cloudFrontClient).tagResource(any(TagResourceRequest.class)); + verify(awsCloudFrontMapper).toCloudResource(eq(distribution), eq(ETAG), eq(command)); + } + + @Test + @DisplayName("Distribution 생성 실패 - CloudFrontException 발생") + void createDistribution_Failure_CloudFrontException() { + // Given + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionName("test-distribution") + .origin(originConfig) + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + doThrow(CloudFrontException.builder() + .message("Too many distributions") + .statusCode(400) + .build()) + .when(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class)); + + // When & Then + assertThatThrownBy(() -> adapter.createDistribution(command)) + .isInstanceOf(BusinessException.class); + + verify(awsCloudFrontConfig).createCloudFrontClient(mockSession); + verify(cloudFrontClient).createDistribution(any(CreateDistributionRequest.class)); + verify(awsCloudFrontMapper, never()).toCloudResource( + any(Distribution.class), anyString(), any(CreateDistributionCommand.class)); + } + + @Test + @DisplayName("Distribution 생성 후 태그 추가 실패 - 경고만 발생하고 계속 진행") + void createDistribution_TagFailure_Continues() { + // Given + Map tags = Map.of("Environment", "Test"); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionName("test-distribution") + .origin(originConfig) + .cacheBehaviors(List.of(cacheBehaviorConfig)) + .tags(tags) + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution distribution = createMockDistribution(DISTRIBUTION_ID, true); + CreateDistributionResponse createResponse = CreateDistributionResponse.builder() + .distribution(distribution) + .eTag(ETAG) + .build(); + + when(cloudFrontClient.createDistribution(any(CreateDistributionRequest.class))) + .thenReturn(createResponse); + when(cloudFrontClient.tagResource(any(TagResourceRequest.class))) + .thenThrow(CloudFrontException.builder() + .message("Access denied") + .statusCode(403) + .build()); + + CloudResource mockResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .build(); + + when(awsCloudFrontMapper.toCloudResource(eq(distribution), eq(ETAG), eq(command))) + .thenReturn(mockResource); + + // When + CloudResource result = adapter.createDistribution(command); + + // Then + assertThat(result).isNotNull(); + verify(cloudFrontClient).tagResource(any(TagResourceRequest.class)); + verify(awsCloudFrontMapper).toCloudResource(eq(distribution), eq(ETAG), eq(command)); + } + } + + @Nested + @DisplayName("Distribution 수정 테스트") + class UpdateDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 수정") + void updateDistribution_Success() { + // Given + UpdateDistributionCommand command = UpdateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .comment("Updated comment") + .enabled(false) + .cacheBehaviors(List.of(cacheBehaviorConfig)) + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution currentDistribution = createMockDistribution(DISTRIBUTION_ID, true); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(currentDistribution) + .eTag(ETAG) + .build(); + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse); + + Distribution updatedDistribution = createMockDistribution(DISTRIBUTION_ID, false); + UpdateDistributionResponse updateResponse = UpdateDistributionResponse.builder() + .distribution(updatedDistribution) + .eTag("ETAG_UPDATED") + .build(); + + when(cloudFrontClient.updateDistribution(any(UpdateDistributionRequest.class))) + .thenReturn(updateResponse); + + CloudResource mockResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .build(); + + when(awsCloudFrontMapper.toCloudResource(eq(updatedDistribution), eq("ETAG_UPDATED"), eq(command))) + .thenReturn(mockResource); + + // When + CloudResource result = adapter.updateDistribution(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID); + + verify(awsCloudFrontConfig).createCloudFrontClient(mockSession); + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class)); + verify(awsCloudFrontMapper).toCloudResource(eq(updatedDistribution), eq("ETAG_UPDATED"), eq(command)); + } + + @Test + @DisplayName("Distribution 수정 실패 - ETag 불일치 (PreconditionFailed)") + void updateDistribution_Failure_ETagMismatch() { + // Given + UpdateDistributionCommand command = UpdateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag("OLD_ETAG") + .comment("Updated comment") + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution currentDistribution = createMockDistribution(DISTRIBUTION_ID, true); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(currentDistribution) + .eTag("NEW_ETAG") + .build(); + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse); + + doThrow(CloudFrontException.builder() + .message("Precondition failed") + .statusCode(412) + .build()) + .when(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class)); + + // When & Then + assertThatThrownBy(() -> adapter.updateDistribution(command)) + .isInstanceOf(BusinessException.class); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class)); + } + + @Test + @DisplayName("Distribution 수정 실패 - Distribution 없음") + void updateDistribution_Failure_DistributionNotFound() { + // Given + UpdateDistributionCommand command = UpdateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .comment("Updated comment") + .tenantKey(TENANT_KEY) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + doThrow(NoSuchDistributionException.builder() + .message("Distribution not found") + .build()) + .when(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + + // When & Then + assertThatThrownBy(() -> adapter.updateDistribution(command)) + .isInstanceOf(BusinessException.class); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient, never()).updateDistribution(any(UpdateDistributionRequest.class)); + } + } + + @Nested + @DisplayName("Distribution 삭제 테스트") + class DeleteDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 삭제 (활성화된 경우 - 비활성화 후 삭제)") + void deleteDistribution_Success_Enabled() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution enabledDistribution = createMockDistribution(DISTRIBUTION_ID, true); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(enabledDistribution) + .eTag(ETAG) + .build(); + + // waitForDeployment에서도 getDistribution을 호출하므로 Deployed 상태 반환 + DistributionConfig deployedConfig = DistributionConfig.builder() + .enabled(false) + .comment("Test distribution") + .callerReference("test-caller-ref") + .origins(Origins.builder() + .quantity(1) + .items(Origin.builder() + .id("origin-1") + .domainName("example.com") + .build()) + .build()) + .defaultCacheBehavior(DefaultCacheBehavior.builder() + .targetOriginId("origin-1") + .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS) + .build()) + .build(); + + Distribution deployedDistribution = Distribution.builder() + .id(DISTRIBUTION_ID) + .arn(DISTRIBUTION_ARN) + .status("Deployed") // 배포 완료 상태 + .domainName("d1234567890.cloudfront.net") + .distributionConfig(deployedConfig) + .build(); + + GetDistributionResponse deployedResponse = GetDistributionResponse.builder() + .distribution(deployedDistribution) + .eTag("ETAG_DEPLOYED") + .build(); + + // 첫 번째 호출: deleteDistribution에서 상태 확인 (enabled=true, status=Deployed) + // 두 번째 호출: waitForDeployment에서 배포 상태 확인 (status=Deployed) + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse) // 첫 번째: 활성화된 상태 + .thenReturn(deployedResponse); // 두 번째: 배포 완료 상태 + + Distribution disabledDistribution = createMockDistribution(DISTRIBUTION_ID, false); + UpdateDistributionResponse updateResponse = UpdateDistributionResponse.builder() + .distribution(disabledDistribution) + .eTag("ETAG_DISABLED") + .build(); + + when(cloudFrontClient.updateDistribution(any(UpdateDistributionRequest.class))) + .thenReturn(updateResponse); + when(cloudFrontClient.deleteDistribution(any(DeleteDistributionRequest.class))) + .thenReturn(DeleteDistributionResponse.builder().build()); + + // When + adapter.deleteDistribution(command); + + // Then + verify(awsCloudFrontConfig).createCloudFrontClient(mockSession); + // getDistribution은 최소 2번 호출됨 (상태 확인 + 배포 완료 확인) + verify(cloudFrontClient, atLeast(2)).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient).updateDistribution(any(UpdateDistributionRequest.class)); + verify(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class)); + } + + @Test + @DisplayName("정상적인 Distribution 삭제 (비활성화된 경우 - 바로 삭제)") + void deleteDistribution_Success_Disabled() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution disabledDistribution = createMockDistribution(DISTRIBUTION_ID, false); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(disabledDistribution) + .eTag(ETAG) + .build(); + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse); + when(cloudFrontClient.deleteDistribution(any(DeleteDistributionRequest.class))) + .thenReturn(DeleteDistributionResponse.builder().build()); + + // When + adapter.deleteDistribution(command); + + // Then + verify(awsCloudFrontConfig).createCloudFrontClient(mockSession); + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient, never()).updateDistribution(any(UpdateDistributionRequest.class)); + verify(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class)); + } + + @Test + @DisplayName("Distribution 삭제 실패 - Distribution 없음") + void deleteDistribution_Failure_DistributionNotFound() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + doThrow(NoSuchDistributionException.builder() + .message("Distribution not found") + .build()) + .when(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + + // When & Then + assertThatThrownBy(() -> adapter.deleteDistribution(command)) + .isInstanceOf(BusinessException.class); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient, never()).deleteDistribution(any(DeleteDistributionRequest.class)); + } + + @Test + @DisplayName("Distribution 삭제 실패 - 삭제 중 예외 발생") + void deleteDistribution_Failure_DeleteException() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .session(mockSession) + .build(); + + when(awsCloudFrontConfig.createCloudFrontClient(mockSession)).thenReturn(cloudFrontClient); + + Distribution disabledDistribution = createMockDistribution(DISTRIBUTION_ID, false); + GetDistributionResponse getResponse = GetDistributionResponse.builder() + .distribution(disabledDistribution) + .eTag(ETAG) + .build(); + + when(cloudFrontClient.getDistribution(any(GetDistributionRequest.class))) + .thenReturn(getResponse); + doThrow(CloudFrontException.builder() + .message("Distribution is still deployed") + .statusCode(409) + .build()) + .when(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class)); + + // When & Then + assertThatThrownBy(() -> adapter.deleteDistribution(command)) + .isInstanceOf(BusinessException.class); + + verify(cloudFrontClient).getDistribution(any(GetDistributionRequest.class)); + verify(cloudFrontClient).deleteDistribution(any(DeleteDistributionRequest.class)); + } + } + + @Nested + @DisplayName("ProviderScoped 테스트") + class ProviderScopedTest { + + @Test + @DisplayName("getProviderType은 AWS를 반환") + void getProviderType_ReturnsAWS() { + // When + ProviderType result = adapter.getProviderType(); + + // Then + assertThat(result).isEqualTo(ProviderType.AWS); + } + } + + /** + * Mock Distribution 객체 생성 헬퍼 메서드 + */ + private Distribution createMockDistribution(String distributionId, boolean enabled) { + DistributionConfig config = DistributionConfig.builder() + .enabled(enabled) + .comment("Test distribution") + .callerReference("test-caller-ref") + .origins(Origins.builder() + .quantity(1) + .items(Origin.builder() + .id("origin-1") + .domainName("example.com") + .build()) + .build()) + .defaultCacheBehavior(DefaultCacheBehavior.builder() + .targetOriginId("origin-1") + .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS) + .build()) + .build(); + + return Distribution.builder() + .id(distributionId) + .arn(DISTRIBUTION_ARN) + .status(enabled ? "Deployed" : "InProgress") + .domainName("d1234567890.cloudfront.net") + .distributionConfig(config) + .build(); + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceDbSyncTest.java new file mode 100644 index 00000000..88cc1ed3 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceDbSyncTest.java @@ -0,0 +1,394 @@ +package com.agenticcp.core.domain.cloud.service.cdn; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +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.cdn.CreateDistributionCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.DeleteDistributionCommand; +import com.agenticcp.core.domain.cloud.port.model.cdn.OriginConfig; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort; +import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository; +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 java.util.Optional; + +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.*; + +/** + * CDNUseCaseService DB 동기화 로직 단위 테스트 + * CSP 작업 후 CloudResource 엔티티가 올바르게 DB에 저장/삭제되는지 검증합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CDNUseCaseService DB 동기화 테스트") +class CDNUseCaseServiceDbSyncTest { + + @Mock + private CDNPortRouter cdnPortRouter; + + @Mock + private CDNManagementPort managementPort; + + @Mock + private CDNDiscoveryPort discoveryPort; + + @Mock + private CapabilityGuard capabilityGuard; + + @Mock + private AccountCredentialManagementPort accountCredentialManagementPort; + + @Mock + private CloudResourceManagementHelper resourceHelper; + + @Mock + private CloudResourceRepository cloudResourceRepository; + + private CDNUseCaseService cdnUseCaseService; + 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 DISTRIBUTION_ID = "E1234567890ABC"; + private static final String DISTRIBUTION_NAME = "my-test-distribution"; + private static final String METADATA_JSON = "{\"origin\":{\"id\":\"origin-1\",\"domainName\":\"example.com\"}}"; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantKey(TENANT_KEY); + + cdnUseCaseService = new CDNUseCaseService( + cdnPortRouter, + capabilityGuard, + accountCredentialManagementPort, + resourceHelper, + cloudResourceRepository + ); + + mockSession = mock(CloudSessionCredential.class); + when(mockSession.getExpiresAt()).thenReturn(LocalDateTime.now().plusHours(1)); + + // 공통 Mock 설정 + lenient().when(cdnPortRouter.management(PROVIDER_TYPE)).thenReturn(managementPort); + lenient().when(cdnPortRouter.discovery(PROVIDER_TYPE)).thenReturn(discoveryPort); + lenient().when(accountCredentialManagementPort.getSession(eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(PROVIDER_TYPE))) + .thenReturn(mockSession); + lenient().doNothing().when(capabilityGuard).ensureSupported(any(), anyString(), anyString(), any()); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Nested + @DisplayName("Distribution 생성 테스트") + class CreateDistributionTest { + + @Test + @DisplayName("Distribution 생성 성공 시 CloudResource가 DB에 저장되고 metadata도 저장된다") + void createDistribution_Success_SavesCloudResourceWithMetadata() { + // Given + Map tags = Map.of("Environment", "test"); + OriginConfig origin = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .build(); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .distributionName(DISTRIBUTION_NAME) + .origin(origin) + .tags(tags) + .build(); + + CloudResource mockCreatedDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .metadata(METADATA_JSON) + .build(); + + CloudResource mockSavedResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .build(); + + when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution); + when(resourceHelper.registerResource( + eq(PROVIDER_TYPE), + eq("CloudFront"), + any(ResourceRegistrationRequest.class) + )).thenReturn(mockSavedResource); + when(cloudResourceRepository.save(any(CloudResource.class))).thenReturn(mockSavedResource); + + // When + CloudResource result = cdnUseCaseService.createDistribution(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID); + + verify(resourceHelper).registerResource( + eq(PROVIDER_TYPE), + eq("CloudFront"), + any(ResourceRegistrationRequest.class) + ); + // metadata 저장 검증 + verify(cloudResourceRepository).save(argThat(resource -> + resource.getMetadata() != null && resource.getMetadata().equals(METADATA_JSON) + )); + } + + @Test + @DisplayName("Distribution 이름이 없으면 Distribution ID가 리소스 이름으로 사용된다") + void createDistribution_NoDistributionName_UsesDistributionIdAsResourceName() { + // Given + OriginConfig origin = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .build(); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .distributionName(null) // Distribution 이름 없음 + .origin(origin) + .build(); + + CloudResource mockCreatedDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_ID) + .metadata(METADATA_JSON) + .build(); + + CloudResource mockSavedResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_ID) + .build(); + + when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution); + when(resourceHelper.registerResource(any(), any(), any())).thenReturn(mockSavedResource); + when(cloudResourceRepository.save(any(CloudResource.class))).thenReturn(mockSavedResource); + + // When + cdnUseCaseService.createDistribution(command); + + // Then + verify(resourceHelper).registerResource( + eq(PROVIDER_TYPE), + eq("CloudFront"), + any(ResourceRegistrationRequest.class) + ); + } + + @Test + @DisplayName("metadata가 없으면 metadata 저장은 스킵된다") + void createDistribution_NoMetadata_SkipsMetadataSave() { + // Given + OriginConfig origin = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .build(); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .distributionName(DISTRIBUTION_NAME) + .origin(origin) + .build(); + + CloudResource mockCreatedDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .metadata(null) // metadata 없음 + .build(); + + CloudResource mockSavedResource = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .build(); + + when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution); + when(resourceHelper.registerResource(any(), any(), any())).thenReturn(mockSavedResource); + + // When + cdnUseCaseService.createDistribution(command); + + // Then + verify(resourceHelper).registerResource(any(), any(), any()); + // metadata가 null이면 저장하지 않음 + verify(cloudResourceRepository, never()).save(any(CloudResource.class)); + } + + @Test + @DisplayName("DB 저장 실패 시 보상 트랜잭션이 실행되고 예외가 발생한다") + void createDistribution_DbSaveFails_CompensatingTransactionExecuted() { + // Given + OriginConfig origin = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .build(); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .distributionName(DISTRIBUTION_NAME) + .origin(origin) + .build(); + + CloudResource mockCreatedDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .metadata(METADATA_JSON) + .build(); + + when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution); + // DB 저장 실패 + doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper) + .registerResource(any(), any(), any()); + // 보상 트랜잭션을 위한 Distribution 조회 + when(discoveryPort.getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID))) + .thenReturn(Optional.of(mockCreatedDistribution)); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException be = (BusinessException) exception; + assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.RESOURCE_CREATION_FAILED); + }); + + // 보상 트랜잭션 실행 검증: CSP Distribution 삭제 호출됨 + verify(discoveryPort).getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID)); + verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + } + + @Test + @DisplayName("보상 트랜잭션도 실패하면 Ghost Resource 경고 로그가 출력된다") + void createDistribution_CompensationFails_GhostResourceWarningLogged() { + // Given + OriginConfig origin = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .build(); + + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .distributionName(DISTRIBUTION_NAME) + .origin(origin) + .build(); + + CloudResource mockCreatedDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .metadata(METADATA_JSON) + .build(); + + when(managementPort.createDistribution(any())).thenReturn(mockCreatedDistribution); + // DB 저장 실패 + doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper) + .registerResource(any(), any(), any()); + // 보상 트랜잭션을 위한 Distribution 조회 + when(discoveryPort.getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID))) + .thenReturn(Optional.of(mockCreatedDistribution)); + // 보상 트랜잭션(CSP 삭제)도 실패 + doThrow(new RuntimeException("CSP 삭제 실패")).when(managementPort) + .deleteDistribution(any(DeleteDistributionCommand.class)); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command)) + .isInstanceOf(BusinessException.class); + + // 보상 트랜잭션 시도 검증 + verify(discoveryPort).getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID)); + verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + // Ghost Resource 발생 - 실제로는 모니터링/배치로 처리 필요 + } + } + + @Nested + @DisplayName("Distribution 삭제 테스트") + class DeleteDistributionTest { + + @Test + @DisplayName("Distribution 삭제 시 소프트 삭제가 수행된다") + void deleteDistribution_Success_SoftDeletesResource() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag("dummy-etag") + .build(); + + doNothing().when(managementPort).deleteDistribution(any()); + + // When + cdnUseCaseService.deleteDistribution(command); + + // Then + verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + verify(resourceHelper).softDeleteResource(DISTRIBUTION_ID); + } + + @Test + @DisplayName("DB에 리소스가 없어도 CSP 삭제는 성공한다") + void deleteDistribution_ResourceNotInDb_CspDeletionSucceeds() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(PROVIDER_TYPE) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag("dummy-etag") + .build(); + + doNothing().when(managementPort).deleteDistribution(any()); + // Helper 내부에서 리소스가 없으면 로그만 출력하고 예외 발생 안함 + + // When + cdnUseCaseService.deleteDistribution(command); + + // Then + verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); // CSP 작업 성공 + verify(resourceHelper).softDeleteResource(DISTRIBUTION_ID); + } + } +} + diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceTest.java new file mode 100644 index 00000000..f17cbb40 --- /dev/null +++ b/src/test/java/com/agenticcp/core/domain/cloud/service/cdn/CDNUseCaseServiceTest.java @@ -0,0 +1,619 @@ +package com.agenticcp.core.domain.cloud.service.cdn; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.BusinessException; +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.CDNDistributionQueryRequest; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.exception.CredentialErrorCode; +import com.agenticcp.core.domain.cloud.port.model.cdn.*; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNInvalidationPort; +import com.agenticcp.core.domain.cloud.port.outbound.cdn.CDNManagementPort; +import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository; +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.InjectMocks; +import org.mockito.Mock; +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.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.*; + +/** + * CDNUseCaseService 단위 테스트 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CDNUseCaseService 단위 테스트") +class CDNUseCaseServiceTest { + + @Mock + private CDNPortRouter cdnPortRouter; + + @Mock + private CapabilityGuard capabilityGuard; + + @Mock + private AccountCredentialManagementPort accountCredentialManagementPort; + + @Mock + private CloudResourceManagementHelper resourceHelper; + + @Mock + private CDNManagementPort managementPort; + + @Mock + private CDNDiscoveryPort discoveryPort; + + @Mock + private CDNInvalidationPort invalidationPort; + + @Mock + private CloudResourceRepository cloudResourceRepository; + + @InjectMocks + private CDNUseCaseService cdnUseCaseService; + + private static final String TENANT_KEY = "test-tenant"; + private static final String ACCOUNT_SCOPE = "123456789012"; + private static final String DISTRIBUTION_ID = "E2QWRUHAPOMQZL"; + private static final String DISTRIBUTION_NAME = "test-distribution"; + private static final String ETAG = "ETAG123456789"; + private static final String INVALIDATION_ID = "I2J3K4L5M6N7O"; + + private AwsSessionCredential mockSession; + private CloudResource mockDistribution; + private OriginConfig originConfig; + private CacheBehaviorConfig cacheBehaviorConfig; + + @BeforeEach + void setUp() { + TenantContextHolder.setTenantKey(TENANT_KEY); + + mockSession = AwsSessionCredential.builder() + .accessKeyId("AKIA_TEST") + .secretAccessKey("secret") + .sessionToken("token") + .region("us-east-1") + .expiresAt(LocalDateTime.now().plusHours(1)) + .build(); + + mockDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .displayName(DISTRIBUTION_NAME) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .lifecycleState(CloudResource.LifecycleState.RUNNING) + .build(); + + originConfig = OriginConfig.builder() + .id("origin-1") + .domainName("example.com") + .type(OriginConfig.OriginType.CUSTOM) + .httpPort(80) + .httpsPort(443) + .originProtocolPolicy("https-only") + .build(); + + cacheBehaviorConfig = CacheBehaviorConfig.builder() + .pathPattern("/*") + .ttl(86400L) + .allowedMethods(List.of("GET", "HEAD", "OPTIONS")) + .compress(true) + .viewerProtocolPolicy("redirect-to-https") + .build(); + + // 기본 Mock 설정 + lenient().doNothing().when(capabilityGuard).ensureSupported( + any(ProviderType.class), + anyString(), + anyString(), + any(CapabilityGuard.Operation.class) + ); + + lenient().when(cdnPortRouter.management(ProviderType.AWS)).thenReturn(managementPort); + lenient().when(cdnPortRouter.discovery(ProviderType.AWS)).thenReturn(discoveryPort); + lenient().when(cdnPortRouter.invalidation(ProviderType.AWS)).thenReturn(invalidationPort); + + // 공통 Mock 설정: 모든 테스트에서 사용되는 기본 설정 + lenient().when(accountCredentialManagementPort.getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS))) + .thenReturn(mockSession); + lenient().when(cloudResourceRepository.save(any(CloudResource.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + @Nested + @DisplayName("Distribution 생성 테스트") + class CreateDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 생성") + void createDistribution_Success() { + // Given + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .resourceType("CDN_DISTRIBUTION") + .distributionName(DISTRIBUTION_NAME) + .comment("Test distribution") + .enabled(true) + .origin(originConfig) + .cacheBehaviors(List.of(cacheBehaviorConfig)) + .tags(Map.of("Environment", "Test")) + .build(); + + // accountCredentialManagementPort는 @BeforeEach에서 설정됨 + CloudResource distributionWithMetadata = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .displayName(DISTRIBUTION_NAME) + .resourceType(CloudResource.ResourceType.CDN_DISTRIBUTION) + .lifecycleState(CloudResource.LifecycleState.RUNNING) + .metadata("{\"origin\":{\"id\":\"origin-1\"}}") + .build(); + + when(managementPort.createDistribution(any(CreateDistributionCommand.class))) + .thenReturn(distributionWithMetadata); + when(resourceHelper.registerResource(any(ProviderType.class), anyString(), any(ResourceRegistrationRequest.class))) + .thenReturn(mockDistribution); + when(cloudResourceRepository.save(any(CloudResource.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + CloudResource result = cdnUseCaseService.createDistribution(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID); + assertThat(result.getResourceName()).isEqualTo(DISTRIBUTION_NAME); + + verify(capabilityGuard).ensureSupported( + eq(ProviderType.AWS), eq("CloudFront"), eq("CDN_DISTRIBUTION"), + eq(CapabilityGuard.Operation.TAGGING)); + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)); + verify(managementPort).createDistribution(any(CreateDistributionCommand.class)); + verify(resourceHelper).registerResource( + eq(ProviderType.AWS), eq("CloudFront"), any(ResourceRegistrationRequest.class)); + // metadata 저장 검증 + verify(cloudResourceRepository).save(argThat(resource -> + resource.getMetadata() != null && resource.getMetadata().equals("{\"origin\":{\"id\":\"origin-1\"}}") + )); + } + + @Test + @DisplayName("DB 저장 실패 시 보상 트랜잭션 실행") + void createDistribution_DbSaveFailure_ExecutesCompensation() { + // Given + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .resourceType("CDN_DISTRIBUTION") + .distributionName(DISTRIBUTION_NAME) + .origin(originConfig) + .build(); + + // accountCredentialManagementPort는 @BeforeEach에서 설정됨 + when(managementPort.createDistribution(any(CreateDistributionCommand.class))) + .thenReturn(mockDistribution); + when(resourceHelper.registerResource(any(), any(), any())) + .thenThrow(new RuntimeException("DB 저장 실패")); + + // Distribution 조회를 위한 Mock (보상 트랜잭션에서 사용) + when(discoveryPort.getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID))) + .thenReturn(Optional.of(mockDistribution)); + doNothing().when(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.RESOURCE_CREATION_FAILED); + + verify(managementPort).createDistribution(any(CreateDistributionCommand.class)); + verify(resourceHelper).registerResource(any(), any(), any()); + verify(discoveryPort).getDistribution(eq(ACCOUNT_SCOPE), eq(DISTRIBUTION_ID)); + verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + } + + @Test + @DisplayName("accountScope가 null일 때 예외 발생") + void createDistribution_NullAccountScope_ThrowsException() { + // Given + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(null) + .serviceKey("CloudFront") + .resourceType("CDN_DISTRIBUTION") + .distributionName(DISTRIBUTION_NAME) + .origin(originConfig) + .build(); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED); + + verify(accountCredentialManagementPort, never()).getSession(any(), any(), any()); + } + + @Test + @DisplayName("자격증명을 찾을 수 없을 때 ACCOUNT_NOT_CONFIGURED 예외 발생") + void createDistribution_CredentialNotFound_ThrowsException() { + // Given + CreateDistributionCommand command = CreateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .serviceKey("CloudFront") + .resourceType("CDN_DISTRIBUTION") + .distributionName(DISTRIBUTION_NAME) + .origin(originConfig) + .build(); + + when(accountCredentialManagementPort.getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS))) + .thenThrow(new BusinessException( + CredentialErrorCode.CREDENTIAL_NOT_FOUND, + "자격증명을 찾을 수 없습니다" + )); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.createDistribution(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_NOT_CONFIGURED); + } + } + + @Nested + @DisplayName("Distribution 조회 테스트") + class GetDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 조회") + void getDistribution_Success() { + // Given + when(discoveryPort.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID)) + .thenReturn(Optional.of(mockDistribution)); + + // When + Optional result = cdnUseCaseService.getDistribution( + ACCOUNT_SCOPE, DISTRIBUTION_ID, ProviderType.AWS); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getResourceId()).isEqualTo(DISTRIBUTION_ID); + + verify(discoveryPort).getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID); + } + + @Test + @DisplayName("Distribution이 존재하지 않을 때 Optional.empty 반환") + void getDistribution_NotFound_ReturnsEmpty() { + // Given + when(discoveryPort.getDistribution(ACCOUNT_SCOPE, DISTRIBUTION_ID)) + .thenReturn(Optional.empty()); + + // When + Optional result = cdnUseCaseService.getDistribution( + ACCOUNT_SCOPE, DISTRIBUTION_ID, ProviderType.AWS); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("accountScope가 null일 때 예외 발생") + void getDistribution_NullAccountScope_ThrowsException() { + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.getDistribution( + null, DISTRIBUTION_ID, ProviderType.AWS)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED); + } + } + + @Nested + @DisplayName("Distribution 목록 조회 테스트") + class ListDistributionsTest { + + @Test + @DisplayName("정상적인 Distribution 목록 조회") + void listDistributions_Success() { + // Given + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .tenantKey(TENANT_KEY) + .page(0) + .size(20) + .build(); + + Page expectedPage = new PageImpl<>( + List.of(mockDistribution), + PageRequest.of(0, 20), + 1 + ); + + // accountCredentialManagementPort는 @BeforeEach에서 설정됨 + when(discoveryPort.listDistributions(any(CDNDistributionQueryRequest.class))) + .thenReturn(expectedPage); + + // When + Page result = cdnUseCaseService.listDistributions(query); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()).hasSize(1); + + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)); + verify(discoveryPort).listDistributions(any(CDNDistributionQueryRequest.class)); + } + + @Test + @DisplayName("accountScope가 null일 때 예외 발생") + void listDistributions_NullAccountScope_ThrowsException() { + // Given + CDNDistributionQueryRequest query = CDNDistributionQueryRequest.builder() + .providerType(ProviderType.AWS) + .accountScope(null) + .build(); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.listDistributions(query)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED); + } + } + + @Nested + @DisplayName("Distribution 수정 테스트") + class UpdateDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 수정") + void updateDistribution_Success() { + // Given + UpdateDistributionCommand command = UpdateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .comment("Updated comment") + .enabled(false) + .cacheBehaviors(List.of(cacheBehaviorConfig)) + .tags(Map.of("Environment", "Production")) + .build(); + + CloudResource updatedDistribution = CloudResource.builder() + .resourceId(DISTRIBUTION_ID) + .resourceName(DISTRIBUTION_NAME) + .lifecycleState(CloudResource.LifecycleState.PENDING) + .build(); + + // accountCredentialManagementPort는 @BeforeEach에서 설정됨 + when(managementPort.updateDistribution(any(UpdateDistributionCommand.class))) + .thenReturn(updatedDistribution); + + // When + CloudResource result = cdnUseCaseService.updateDistribution(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getResourceId()).isEqualTo(DISTRIBUTION_ID); + + verify(capabilityGuard).ensureSupported( + eq(ProviderType.AWS), eq("CloudFront"), eq("CDN_DISTRIBUTION"), + eq(CapabilityGuard.Operation.TAGGING)); + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)); + verify(managementPort).updateDistribution(any(UpdateDistributionCommand.class)); + } + + @Test + @DisplayName("accountScope가 null일 때 예외 발생") + void updateDistribution_NullAccountScope_ThrowsException() { + // Given + UpdateDistributionCommand command = UpdateDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(null) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .build(); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.updateDistribution(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED); + } + } + + @Nested + @DisplayName("Distribution 삭제 테스트") + class DeleteDistributionTest { + + @Test + @DisplayName("정상적인 Distribution 삭제") + void deleteDistribution_Success() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .build(); + + // accountCredentialManagementPort는 @BeforeEach에서 설정됨 + doNothing().when(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + doNothing().when(resourceHelper).softDeleteResource(eq(DISTRIBUTION_ID)); + + // When + cdnUseCaseService.deleteDistribution(command); + + // Then + verify(capabilityGuard).ensureSupported( + eq(ProviderType.AWS), eq("CloudFront"), eq("CDN_DISTRIBUTION"), + eq(CapabilityGuard.Operation.TERMINATE)); + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)); + verify(managementPort).deleteDistribution(any(DeleteDistributionCommand.class)); + verify(resourceHelper).softDeleteResource(DISTRIBUTION_ID); + } + + @Test + @DisplayName("accountScope가 null일 때 예외 발생") + void deleteDistribution_NullAccountScope_ThrowsException() { + // Given + DeleteDistributionCommand command = DeleteDistributionCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(null) + .distributionId(DISTRIBUTION_ID) + .etag(ETAG) + .build(); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.deleteDistribution(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED); + } + } + + @Nested + @DisplayName("캐시 무효화 테스트") + class InvalidationTest { + + @Test + @DisplayName("정상적인 캐시 무효화 생성") + void createInvalidation_Success() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(ACCOUNT_SCOPE) + .distributionId(DISTRIBUTION_ID) + .paths(List.of("/*", "/images/*")) + .callerReference("test-ref") + .build(); + + InvalidationResult expectedResult = InvalidationResult.builder() + .invalidationId(INVALIDATION_ID) + .distributionId(DISTRIBUTION_ID) + .status("InProgress") + .createTime(LocalDateTime.now()) + .paths(List.of("/*", "/images/*")) + .build(); + + // accountCredentialManagementPort는 @BeforeEach에서 설정됨 + when(invalidationPort.createInvalidation(any(CreateInvalidationCommand.class))) + .thenReturn(expectedResult); + + // When + InvalidationResult result = cdnUseCaseService.createInvalidation(command); + + // Then + assertThat(result).isNotNull(); + assertThat(result.invalidationId()).isEqualTo(INVALIDATION_ID); + assertThat(result.distributionId()).isEqualTo(DISTRIBUTION_ID); + assertThat(result.status()).isEqualTo("InProgress"); + + verify(accountCredentialManagementPort).getSession( + eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(ProviderType.AWS)); + verify(invalidationPort).createInvalidation(any(CreateInvalidationCommand.class)); + } + + @Test + @DisplayName("정상적인 캐시 무효화 상태 조회") + void getInvalidation_Success() { + // Given + InvalidationResult expectedResult = InvalidationResult.builder() + .invalidationId(INVALIDATION_ID) + .distributionId(DISTRIBUTION_ID) + .status("Completed") + .createTime(LocalDateTime.now()) + .paths(List.of("/*")) + .build(); + + when(invalidationPort.getInvalidation(ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID)) + .thenReturn(Optional.of(expectedResult)); + + // When + Optional result = cdnUseCaseService.getInvalidation( + ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID, ProviderType.AWS); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().invalidationId()).isEqualTo(INVALIDATION_ID); + assertThat(result.get().status()).isEqualTo("Completed"); + + verify(invalidationPort).getInvalidation(ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID); + } + + @Test + @DisplayName("무효화가 존재하지 않을 때 Optional.empty 반환") + void getInvalidation_NotFound_ReturnsEmpty() { + // Given + when(invalidationPort.getInvalidation(ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID)) + .thenReturn(Optional.empty()); + + // When + Optional result = cdnUseCaseService.getInvalidation( + ACCOUNT_SCOPE, DISTRIBUTION_ID, INVALIDATION_ID, ProviderType.AWS); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("accountScope가 null일 때 예외 발생") + void createInvalidation_NullAccountScope_ThrowsException() { + // Given + CreateInvalidationCommand command = CreateInvalidationCommand.builder() + .accountScope(null) + .distributionId(DISTRIBUTION_ID) + .paths(List.of("/*")) + .build(); + + // When & Then + assertThatThrownBy(() -> cdnUseCaseService.createInvalidation(command)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(CloudErrorCode.ACCOUNT_SCOPE_REQUIRED); + } + } +} +