diff --git a/pom.xml b/pom.xml index 1c4afdb1..ed6c4784 100644 --- a/pom.xml +++ b/pom.xml @@ -283,6 +283,11 @@ resourcegroupstaggingapi + + software.amazon.awssdk + route53 + + software.amazon.awssdk cloudfront 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 1ce77e14..232f8920 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,28 @@ public void initializeS3BucketCapabilities() { log.info("AWS S3 Bucket capabilities registered successfully - AWS|S3|BUCKET"); } + @PostConstruct + public void initializeDnsCapabilities() { + log.info("Registering AWS DNS capabilities..."); + + CspCapability awsDnsCapability = CspCapability.builder() + .supportsStart(false) // DNS 호스팅 존은 start/stop 개념이 없음 + .supportsStop(false) + .supportsTerminate(true) // DNS 호스팅 존 삭제 지원 + .supportsTagging(true) // AWS Route53 호스팅 존 태그 지원 + .supportsListByTag(true) // 태그 기반 목록 조회 지원 + .build(); + + capabilityRegistry.register( + CloudProvider.ProviderType.AWS, + "ROUTE53", // 서비스 키 + "DNS_ZONE", // 리소스 타입 + awsDnsCapability + ); + + log.info("AWS DNS capabilities registered successfully - AWS|ROUTE53|DNS_ZONE"); + } + @PostConstruct public void initializeCloudFrontCapabilities() { log.info("Registering AWS CloudFront capabilities..."); diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsDnsConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsDnsConfig.java new file mode 100644 index 00000000..87e41301 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsDnsConfig.java @@ -0,0 +1,84 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.config; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.route53.Route53Client; + +/** + * AWS Route53 설정 클래스 + * + * Route53Client 생성을 담당합니다. + * Route53은 글로벌 서비스이므로 리전이 중요하지 않지만, + * 일관성을 위해 세션의 리전을 사용합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +public class AwsDnsConfig { + + /** + * 세션 자격증명으로 Route53 Client를 생성합니다. + * + * Route53은 글로벌 서비스이므로 리전이 중요하지 않지만, + * 일관성을 위해 세션의 리전을 사용합니다. + * + * @param session AWS 세션 자격증명 + * @param region AWS 리전 (Route53은 글로벌 서비스이므로 무시될 수 있음) + * @return Route53Client 인스턴스 + */ + public Route53Client createRoute53Client(CloudSessionCredential session, String region) { + AwsSessionCredential awsSession = validateAndCastSession(session); + + return Route53Client.builder() + .credentialsProvider(StaticCredentialsProvider.create(toSdkCredentials(awsSession))) + .region(Region.AWS_GLOBAL) // Route53은 글로벌 서비스 + .build(); + } + + /** + * 세션 검증 및 AWS 세션으로 캐스팅 + * + * @param session 도메인 세션 자격증명 + * @return AWS 세션 자격증명 + * @throws BusinessException 세션이 유효하지 않거나 AWS 세션이 아닌 경우 + */ + private AwsSessionCredential validateAndCastSession(CloudSessionCredential session) { + if (session == null) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, "세션 자격증명이 필요합니다."); + } + if (!(session instanceof AwsSessionCredential awsSession)) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, + "AWS 세션 자격증명이 필요합니다. 제공된 타입: " + session.getClass().getSimpleName()); + } + if (!awsSession.isValid()) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, + "세션이 만료되었습니다. expiresAt: " + awsSession.getExpiresAt()); + } + return awsSession; + } + + /** + * 도메인 세션 객체를 AWS SDK 세션 객체로 변환 + * + * @param session AWS 도메인 세션 자격증명 + * @return AWS SDK 세션 자격증명 + */ + private AwsSessionCredentials toSdkCredentials(AwsSessionCredential session) { + return AwsSessionCredentials.create( + session.getAccessKeyId(), + session.getSecretAccessKey(), + session.getSessionToken() + ); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsDiscoveryAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsDiscoveryAdapter.java new file mode 100644 index 00000000..9421fe38 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsDiscoveryAdapter.java @@ -0,0 +1,244 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.dns; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsDnsConfig; +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.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsQuery; +import com.agenticcp.core.domain.cloud.port.outbound.dns.DnsDiscoveryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.route53.Route53Client; +import software.amazon.awssdk.services.route53.model.GetHostedZoneRequest; +import software.amazon.awssdk.services.route53.model.GetHostedZoneResponse; +import software.amazon.awssdk.services.route53.model.HostedZone; +import software.amazon.awssdk.services.route53.model.ListHostedZonesRequest; +import software.amazon.awssdk.services.route53.model.ListHostedZonesResponse; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * AWS Route53 DNS 발견 어댑터 + * + * AWS SDK를 사용하여 Route53 호스팅 존 조회 기능을 제공하는 어댑터 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class AwsDnsDiscoveryAdapter implements DnsDiscoveryPort, ProviderScoped { + + private final AwsDnsConfig dnsConfig; + private final AwsDnsMapper mapper; + + @Override + public Page listHostedZones(DnsQuery query, CloudSessionCredential session) { + Route53Client route53Client = null; + try { + log.debug("[AwsDnsDiscoveryAdapter] Listing hosted zones: providerType={}, accountScope={}", + query.providerType(), query.accountScope()); + + route53Client = dnsConfig.createRoute53Client(session, null); + + // AWS Route53 ListHostedZones 호출 + ListHostedZonesRequest.Builder requestBuilder = ListHostedZonesRequest.builder(); + + // Route53은 제한적인 필터링만 지원 + // zoneName으로 시작하는 존만 조회 (prefix 기반) + if (query.zoneName() != null && !query.zoneName().isEmpty()) { + // Route53은 dnsname 필터를 지원하지 않으므로, + // 모든 존을 조회한 후 메모리에서 필터링 + // 하지만 일단 모든 존을 조회 + } + + ListHostedZonesResponse response = route53Client.listHostedZones(requestBuilder.build()); + + // HostedZone 목록을 CloudResource로 변환 + List resources = response.hostedZones().stream() + .map(zone -> { + // 메모리에서 필터링 + if (query.zoneName() != null && !query.zoneName().isEmpty()) { + String zoneName = extractZoneName(zone.name()); + if (!zoneName.equals(query.zoneName()) && !zoneName.startsWith(query.zoneName())) { + return null; + } + } + + // zoneType 필터링 (PUBLIC/PRIVATE) + if (query.zoneType() != null && !query.zoneType().isEmpty()) { + boolean isPrivate = zone.config() != null && Boolean.TRUE.equals(zone.config().privateZone()); + String zoneType = isPrivate ? "PRIVATE" : "PUBLIC"; + if (!zoneType.equals(query.zoneType())) { + return null; + } + } + + // VPC ID 필터링 (Private Zone인 경우) + if (query.vpcId() != null && !query.vpcId().isEmpty()) { + // Route53 ListHostedZones는 VPC 정보를 포함하지 않으므로 + // GetHostedZone으로 상세 정보를 조회해야 함 + // 성능상의 이유로 여기서는 필터링하지 않고 모든 존을 반환 + // 필요시 GetHostedZone으로 상세 조회 후 필터링 + } + + return mapper.toCloudResource(zone, query); + }) + .filter(resource -> resource != null) + .collect(Collectors.toList()); + + // 페이징 처리 + int page = query.page(); + int size = query.size(); + int total = resources.size(); + int start = page * size; + int end = Math.min(start + size, total); + + List pagedResources = start < total + ? resources.subList(start, end) + : List.of(); + + log.info("[AwsDnsDiscoveryAdapter] Listed hosted zones: total={}, page={}, size={}, returned={}", + total, page, size, pagedResources.size()); + + return new PageImpl<>(pagedResources, PageRequest.of(page, size), total); + } catch (Throwable t) { + log.error("[AwsDnsDiscoveryAdapter] Failed to list hosted zones", t); + throw CloudErrorTranslator.translate(t); + } finally { + if (route53Client != null) { + route53Client.close(); + } + } + } + + @Override + public Optional getHostedZone(String zoneId, CloudSessionCredential session) { + Route53Client route53Client = null; + try { + log.debug("[AwsDnsDiscoveryAdapter] Getting hosted zone: zoneId={}", zoneId); + + route53Client = dnsConfig.createRoute53Client(session, null); + + GetHostedZoneRequest request = GetHostedZoneRequest.builder() + .id(extractFullZoneId(zoneId)) + .build(); + + GetHostedZoneResponse response = route53Client.getHostedZone(request); + + if (response.hostedZone() == null) { + log.warn("[AwsDnsDiscoveryAdapter] Hosted zone not found: zoneId={}", zoneId); + return Optional.empty(); + } + + // GetDnsCommand를 생성하여 매퍼에 전달 + // 여기서는 간단히 null을 전달하고 매퍼에서 처리 + CloudResource resource = mapper.toCloudResource(response.hostedZone(), + com.agenticcp.core.domain.cloud.port.model.dns.GetDnsCommand.builder() + .providerType(ProviderType.AWS) + .accountScope(null) + .region(null) + .providerResourceId(zoneId) + .serviceKey("ROUTE53") + .resourceType("DNS_ZONE") + .session(session) + .build()); + + log.info("[AwsDnsDiscoveryAdapter] Got hosted zone: zoneId={}, zoneName={}", + zoneId, extractZoneName(response.hostedZone().name())); + + return Optional.of(resource); + } catch (Throwable t) { + log.error("[AwsDnsDiscoveryAdapter] Failed to get hosted zone: zoneId={}", zoneId, t); + + // Route53에서 NotFoundException이 발생하면 Optional.empty() 반환 + if (t.getMessage() != null && + (t.getMessage().contains("NoSuchHostedZone") || + t.getMessage().contains("404"))) { + return Optional.empty(); + } + + throw CloudErrorTranslator.translate(t); + } finally { + if (route53Client != null) { + route53Client.close(); + } + } + } + + @Override + public String getZoneStatus(String zoneId, CloudSessionCredential session) { + Route53Client route53Client = null; + try { + log.debug("[AwsDnsDiscoveryAdapter] Getting zone status: zoneId={}", zoneId); + + route53Client = dnsConfig.createRoute53Client(session, null); + + GetHostedZoneRequest request = GetHostedZoneRequest.builder() + .id(extractFullZoneId(zoneId)) + .build(); + + GetHostedZoneResponse response = route53Client.getHostedZone(request); + + if (response.hostedZone() == null) { + return "NOT_FOUND"; + } + + // Route53 HostedZone은 항상 활성 상태 + return "ACTIVE"; + } catch (Throwable t) { + log.error("[AwsDnsDiscoveryAdapter] Failed to get zone status: zoneId={}", zoneId, t); + + if (t.getMessage() != null && + (t.getMessage().contains("NoSuchHostedZone") || + t.getMessage().contains("404"))) { + return "NOT_FOUND"; + } + + throw CloudErrorTranslator.translate(t); + } finally { + if (route53Client != null) { + route53Client.close(); + } + } + } + + @Override + public ProviderType getProviderType() { + return ProviderType.AWS; + } + + /** + * Zone name에서 trailing dot 제거 + */ + private String extractZoneName(String zoneName) { + if (zoneName == null) { + return null; + } + return zoneName.endsWith(".") ? zoneName.substring(0, zoneName.length() - 1) : zoneName; + } + + /** + * Zone ID를 Route53 API 형식으로 변환 + */ + private String extractFullZoneId(String zoneId) { + if (zoneId == null) { + return null; + } + if (zoneId.startsWith("/hostedzone/")) { + return zoneId; + } + return "/hostedzone/" + zoneId; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsManagementAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsManagementAdapter.java new file mode 100644 index 00000000..bd929694 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsManagementAdapter.java @@ -0,0 +1,165 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.dns; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsDnsConfig; +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.dns.DnsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsUpdateCommand; +import com.agenticcp.core.domain.cloud.port.outbound.dns.DnsManagementPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.route53.Route53Client; +import software.amazon.awssdk.services.route53.model.*; + +/** + * AWS Route53 DNS 관리 어댑터 + * + * AWS SDK를 사용하여 Route53 호스팅 존 관리 기능을 제공하는 어댑터 + * Usecase에서 전달받은 세션 자격증명만을 사용하여 AWS SDK를 호출합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "aws.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class AwsDnsManagementAdapter implements DnsManagementPort, ProviderScoped { + + private final AwsDnsConfig dnsConfig; + private final AwsDnsMapper mapper; + + @Override + public CloudResource createHostedZone(DnsCreateCommand command) { + Route53Client route53Client = null; + try { + log.debug("[AwsDnsManagementAdapter] Creating hosted zone: zoneName={}, zoneType={}", + command.zoneName(), command.zoneType()); + + route53Client = dnsConfig.createRoute53Client(command.session(), command.region()); + + CreateHostedZoneRequest request = mapper.toCreateRequest(command); + CreateHostedZoneResponse response = route53Client.createHostedZone(request); + + log.info("[AwsDnsManagementAdapter] Hosted zone created successfully: zoneId={}, zoneName={}", + response.hostedZone().id(), command.zoneName()); + + return mapper.toCloudResource(response, command); + } catch (Throwable t) { + log.error("[AwsDnsManagementAdapter] Failed to create hosted zone: zoneName={}", + command.zoneName(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (route53Client != null) { + route53Client.close(); + } + } + } + + @Override + public CloudResource updateHostedZone(DnsUpdateCommand command) { + Route53Client route53Client = null; + try { + log.debug("[AwsDnsManagementAdapter] Updating hosted zone: zoneId={}", + command.providerResourceId()); + + route53Client = dnsConfig.createRoute53Client(command.session(), command.region()); + + // Route53은 호스팅 존의 comment만 업데이트 가능 + // 태그는 별도 API로 관리해야 함 + if (command.comment() != null) { + UpdateHostedZoneCommentRequest updateRequest = UpdateHostedZoneCommentRequest.builder() + .id(extractFullZoneId(command.providerResourceId())) + .comment(command.comment()) + .build(); + + route53Client.updateHostedZoneComment(updateRequest); + log.debug("[AwsDnsManagementAdapter] Hosted zone comment updated: zoneId={}", + command.providerResourceId()); + } + + // 업데이트된 호스팅 존 정보 조회 + GetHostedZoneRequest getRequest = GetHostedZoneRequest.builder() + .id(extractFullZoneId(command.providerResourceId())) + .build(); + + GetHostedZoneResponse getResponse = route53Client.getHostedZone(getRequest); + + log.info("[AwsDnsManagementAdapter] Hosted zone updated successfully: zoneId={}", + command.providerResourceId()); + + return mapper.toCloudResource(getResponse.hostedZone(), command); + } catch (Throwable t) { + log.error("[AwsDnsManagementAdapter] Failed to update hosted zone: zoneId={}", + command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (route53Client != null) { + route53Client.close(); + } + } + } + + @Override + public void deleteHostedZone(DnsDeleteCommand command) { + Route53Client route53Client = null; + try { + log.debug("[AwsDnsManagementAdapter] Deleting hosted zone: zoneId={}, forceDelete={}", + command.providerResourceId(), command.forceDelete()); + + route53Client = dnsConfig.createRoute53Client(command.session(), command.region()); + + DeleteHostedZoneRequest deleteRequest = DeleteHostedZoneRequest.builder() + .id(extractFullZoneId(command.providerResourceId())) + .build(); + + route53Client.deleteHostedZone(deleteRequest); + + log.info("[AwsDnsManagementAdapter] Hosted zone deleted successfully: zoneId={}", + command.providerResourceId()); + } catch (Throwable t) { + log.error("[AwsDnsManagementAdapter] Failed to delete hosted zone: zoneId={}", + command.providerResourceId(), t); + + // Route53은 레코드가 있으면 삭제할 수 없음 + // forceDelete 옵션은 Route53에서 지원하지 않으므로 에러 메시지만 확인 + if (t.getMessage() != null && t.getMessage().contains("HostedZoneNotEmpty")) { + log.warn("[AwsDnsManagementAdapter] Hosted zone contains records and cannot be deleted: zoneId={}", + command.providerResourceId()); + } + + throw CloudErrorTranslator.translate(t); + } finally { + if (route53Client != null) { + route53Client.close(); + } + } + } + + @Override + public ProviderType getProviderType() { + return ProviderType.AWS; + } + + /** + * Zone ID를 Route53 API 형식으로 변환 + * Route53 API는 /hostedzone/Z1234567890 형식을 요구하지만, + * 우리는 Z1234567890만 저장하므로 필요시 /hostedzone/ 접두사 추가 + */ + private String extractFullZoneId(String zoneId) { + if (zoneId == null) { + return null; + } + // 이미 /hostedzone/ 접두사가 있으면 그대로 반환 + if (zoneId.startsWith("/hostedzone/")) { + return zoneId; + } + // 없으면 추가 + return "/hostedzone/" + zoneId; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsMapper.java new file mode 100644 index 00000000..d78b001d --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/dns/AwsDnsMapper.java @@ -0,0 +1,301 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.dns; + +import com.agenticcp.core.common.exception.BusinessException; +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.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsQuery; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsUpdateCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.GetDnsCommand; +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.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.route53.model.CreateHostedZoneRequest; +import software.amazon.awssdk.services.route53.model.CreateHostedZoneResponse; +import software.amazon.awssdk.services.route53.model.HostedZone; +import software.amazon.awssdk.services.route53.model.HostedZoneConfig; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * AWS Route53 DNS 매퍼 + * + * AWS SDK HostedZone 객체를 CloudResource로 변환하고, + * DnsCreateCommand를 AWS SDK 요청으로 변환합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsDnsMapper { + + private final ObjectMapper objectMapper; + private final CloudProviderRepository cloudProviderRepository; + private final CloudServiceRepository cloudServiceRepository; + private final CloudRegionRepository cloudRegionRepository; + + /** + * AWS HostedZone → CloudResource 변환 + * + * @param hostedZone AWS HostedZone 객체 + * @param command DnsCreateCommand (생성 시) 또는 GetDnsCommand (조회 시) + * @return CloudResource 엔티티 + */ + public CloudResource toCloudResource(HostedZone hostedZone, DnsCreateCommand command) { + return buildCloudResource(hostedZone, command.providerType(), command.serviceKey(), + command.region(), command.tags(), null); + } + + /** + * AWS HostedZone → CloudResource 변환 (GetDnsCommand 사용) + */ + public CloudResource toCloudResource(HostedZone hostedZone, GetDnsCommand command) { + return buildCloudResource(hostedZone, command.providerType(), command.serviceKey(), + command.region(), null, null); + } + + /** + * AWS HostedZone → CloudResource 변환 (DnsQuery 사용) + */ + public CloudResource toCloudResource(HostedZone hostedZone, DnsQuery query) { + return buildCloudResource(hostedZone, query.providerType(), "ROUTE53", + null, query.tagsEquals(), null); + } + + /** + * AWS HostedZone → CloudResource 변환 (DnsUpdateCommand 사용) + */ + public CloudResource toCloudResource(HostedZone hostedZone, DnsUpdateCommand command) { + return buildCloudResource(hostedZone, command.providerType(), "ROUTE53", + command.region(), command.tags(), null); + } + + /** + * CreateHostedZoneResponse → CloudResource 변환 + */ + public CloudResource toCloudResource(CreateHostedZoneResponse response, DnsCreateCommand command) { + HostedZone hostedZone = response.hostedZone(); + List nameServers = response.delegationSet() != null && response.delegationSet().nameServers() != null + ? response.delegationSet().nameServers() + : List.of(); + + return buildCloudResource(hostedZone, command.providerType(), command.serviceKey(), + command.region(), command.tags(), nameServers); + } + + /** + * DnsCreateCommand → CreateHostedZoneRequest 변환 + * CSP 중립적 Command를 AWS SDK 요청으로 변환 + */ + public CreateHostedZoneRequest toCreateRequest(DnsCreateCommand command) { + String zoneName = command.zoneName(); + // Route53은 zone name 끝에 trailing dot(.)을 요구하지 않지만, 일관성을 위해 추가 + if (!zoneName.endsWith(".")) { + zoneName = zoneName + "."; + } + + CreateHostedZoneRequest.Builder builder = CreateHostedZoneRequest.builder() + .name(zoneName) + .callerReference(UUID.randomUUID().toString()); // Route53 요구사항 + + // Private Zone인 경우 VPC 설정 + if ("PRIVATE".equals(command.zoneType())) { + HostedZoneConfig.Builder configBuilder = HostedZoneConfig.builder() + .privateZone(true); + + if (command.comment() != null) { + configBuilder.comment(command.comment()); + } + + builder.hostedZoneConfig(configBuilder.build()); + + // VPC ID가 제공된 경우 VPC 설정 + if (command.vpcId() != null && !command.vpcId().isEmpty()) { + // Route53의 VPC는 Consumer를 통해 설정 + String vpcRegion = command.region() != null && !command.region().isEmpty() + ? command.region() + : "us-east-1"; // 기본값 + builder.vpc(vpcBuilder -> vpcBuilder + .vpcId(command.vpcId()) + .vpcRegion(vpcRegion)); + } + } else { + // Public Zone + HostedZoneConfig.Builder configBuilder = HostedZoneConfig.builder() + .privateZone(false); + + if (command.comment() != null) { + configBuilder.comment(command.comment()); + } + + builder.hostedZoneConfig(configBuilder.build()); + } + + return builder.build(); + } + + /** + * CloudResource 빌드 (공통 로직) + */ + private CloudResource buildCloudResource( + HostedZone hostedZone, + CloudProvider.ProviderType providerType, + String serviceKey, + String region, + Map tags, + List nameServers) { + + try { + log.debug("[AwsDnsMapper] Converting AWS HostedZone to CloudResource: {}", hostedZone.id()); + + // Zone name에서 trailing dot 제거 (CSP 중립적) + String zoneName = extractZoneName(hostedZone.name()); + String resourceId = extractZoneId(hostedZone.id()); + + // 엔티티 조회 + 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)); + + // Route53은 글로벌 서비스이므로 region은 optional + CloudRegion cloudRegion = null; + if (region != null && !region.isEmpty()) { + cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(providerType, region) + .orElse(null); + if (cloudRegion == null) { + log.warn("[AwsDnsMapper] CloudRegion not found for providerType: {}, regionKey: {}", providerType, region); + } + } + + // Configuration JSON 구성 + String configurationJson = buildConfigurationJson(hostedZone, nameServers); + + // Metadata JSON 구성 + String metadataJson = buildMetadataJson(hostedZone, nameServers); + + // LifecycleState 매핑 + CloudResource.LifecycleState lifecycleState = mapLifecycleState(hostedZone); + + // CreatedInCloud 시간 변환 + // HostedZone에는 생성 시간 정보가 없으므로 현재 시간 사용 + LocalDateTime createdInCloud = LocalDateTime.now(); + + return CloudResource.builder() + .resourceId(resourceId) + .resourceName(zoneName) + .displayName(zoneName) + .provider(provider) + .service(service) + .region(cloudRegion) + .resourceType(CloudResource.ResourceType.DNS_ZONE) + .lifecycleState(lifecycleState) + .tags(tags != null ? tags : new HashMap<>()) + .configuration(configurationJson) + .metadata(metadataJson) + .createdInCloud(createdInCloud) + .lastSync(LocalDateTime.now()) + .build(); + + } catch (Exception e) { + log.error("[AwsDnsMapper] Failed to convert AWS HostedZone to CloudResource: {}", hostedZone.id(), e); + throw new BusinessException(CloudErrorCode.MAPPING_FAILED, + "DNS 호스팅 존을 CloudResource로 변환하는 중 오류가 발생했습니다: " + e.getMessage()); + } + } + + /** + * Zone name에서 trailing dot 제거 + */ + private String extractZoneName(String zoneName) { + if (zoneName == null) { + return null; + } + // Route53은 zone name 끝에 trailing dot(.)을 포함 + return zoneName.endsWith(".") ? zoneName.substring(0, zoneName.length() - 1) : zoneName; + } + + /** + * Zone ID에서 호스팅 존 ID만 추출 + * Route53의 zone ID 형식: /hostedzone/Z1234567890 + */ + private String extractZoneId(String zoneId) { + if (zoneId == null) { + return null; + } + // /hostedzone/Z1234567890 형식에서 Z1234567890만 추출 + if (zoneId.startsWith("/hostedzone/")) { + return zoneId.substring("/hostedzone/".length()); + } + return zoneId; + } + + /** + * Configuration JSON 구성 + */ + private String buildConfigurationJson(HostedZone hostedZone, List nameServers) { + try { + Map config = new HashMap<>(); + config.put("zoneName", extractZoneName(hostedZone.name())); + config.put("zoneId", extractZoneId(hostedZone.id())); + config.put("isPrivateZone", hostedZone.config() != null && Boolean.TRUE.equals(hostedZone.config().privateZone())); + config.put("comment", hostedZone.config() != null ? hostedZone.config().comment() : null); + + if (nameServers != null && !nameServers.isEmpty()) { + config.put("nameServers", nameServers); + } + + return objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + log.warn("[AwsDnsMapper] Failed to serialize configuration: {}", e.getMessage()); + return "{}"; + } + } + + /** + * Metadata JSON 구성 + */ + private String buildMetadataJson(HostedZone hostedZone, List nameServers) { + try { + Map metadata = new HashMap<>(); + metadata.put("zoneName", extractZoneName(hostedZone.name())); + metadata.put("zoneId", extractZoneId(hostedZone.id())); + metadata.put("isPrivateZone", hostedZone.config() != null && Boolean.TRUE.equals(hostedZone.config().privateZone())); + metadata.put("comment", hostedZone.config() != null ? hostedZone.config().comment() : null); + metadata.put("recordSetCount", hostedZone.resourceRecordSetCount() != null ? hostedZone.resourceRecordSetCount() : 0); + + if (nameServers != null && !nameServers.isEmpty()) { + metadata.put("nameServers", nameServers); + } + + return objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[AwsDnsMapper] Failed to serialize metadata: {}", e.getMessage()); + return "{}"; + } + } + + /** + * LifecycleState 매핑 + */ + private CloudResource.LifecycleState mapLifecycleState(HostedZone hostedZone) { + // Route53 HostedZone은 항상 활성 상태이므로 RUNNING으로 매핑 + // 삭제 중인 경우는 별도로 처리 필요 (현재는 RUNNING으로 처리) + return CloudResource.LifecycleState.RUNNING; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/controller/DnsController.java b/src/main/java/com/agenticcp/core/domain/cloud/controller/DnsController.java new file mode 100644 index 00000000..819c7f78 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/controller/DnsController.java @@ -0,0 +1,343 @@ +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.DnsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.DnsDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.DnsQueryRequest; +import com.agenticcp.core.domain.cloud.dto.DnsResponse; +import com.agenticcp.core.domain.cloud.dto.DnsUpdateRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.service.dns.DnsUseCaseService; +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.Optional; + +/** + * DNS 관리 REST API 컨트롤러 + * + * 멀티 클라우드(AWS Route53, Azure DNS, GCP Cloud DNS) DNS 호스팅 존의 생성, 조회, 수정, 삭제 기능을 제공합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/cloud/providers/{provider}/accounts/{accountScope}/dns/zones") +@RequiredArgsConstructor +@Tag(name = "DNS Management", description = "DNS 호스팅 존 관리 API (멀티 클라우드 지원)") +public class DnsController { + + private final DnsUseCaseService dnsUseCaseService; + + // ==================== DNS 호스팅 존 생성 ==================== + + /** + * DNS 호스팅 존을 생성합니다. + * + * @param provider 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * @param accountScope 계정 스코프 + * @param request DNS 호스팅 존 생성 요청 + * @return 생성된 DNS 호스팅 존 리소스 + */ + @PostMapping + @PreAuthorize("hasAuthority('DNS_CREATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "CREATE_DNS_ZONE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "DNS 호스팅 존 생성", + severity = AuditSeverity.HIGH, + includeRequestData = true, + includeResponseData = true + ) + @Operation( + summary = "DNS 호스팅 존 생성", + description = "새로운 DNS 호스팅 존을 생성합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "DNS 호스팅 존 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> createHostedZone( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "DNS 호스팅 존 생성 요청", required = true) + @Valid @RequestBody DnsCreateRequest request) { + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + log.info("[DnsController] createHostedZone - provider={}, accountScope={}, zoneName={}", + provider, accountScope, request.getZoneName()); + + CloudResource resource = dnsUseCaseService.createHostedZone(request); + DnsResponse response = DnsResponse.from(resource); + + log.info("[DnsController] createHostedZone - success resourceId={}", resource.getResourceId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response, "DNS 호스팅 존 생성에 성공했습니다.")); + } + + // ==================== DNS 호스팅 존 조회 ==================== + + /** + * DNS 호스팅 존 목록을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param request 조회 요청 파라미터 + * @return DNS 호스팅 존 리소스 목록 + */ + @GetMapping + @PreAuthorize("hasAuthority('DNS_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "LIST_DNS_ZONES", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "DNS 호스팅 존 목록 조회", + severity = AuditSeverity.LOW, + includeRequestData = true, + includeResponseData = false + ) + @Operation( + summary = "DNS 호스팅 존 목록 조회", + description = "지정된 클라우드 프로바이더의 DNS 호스팅 존 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "DNS 호스팅 존 목록 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity>> listHostedZones( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Valid DnsQueryRequest request) { + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + log.info("[DnsController] listHostedZones - provider={}, accountScope={}", + provider, accountScope); + + Page resources = dnsUseCaseService.listHostedZones(request); + Page responses = resources.map(DnsResponse::from); + + log.info("[DnsController] listHostedZones - success count={}", responses.getTotalElements()); + return ResponseEntity.ok(ApiResponse.success(responses, "DNS 호스팅 존 목록 조회에 성공했습니다.")); + } + + /** + * 특정 DNS 호스팅 존을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param zoneId 호스팅 존 ID + * @param region 리전 + * @return DNS 호스팅 존 리소스 + */ + @GetMapping("/{zoneId}") + @PreAuthorize("hasAuthority('DNS_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "GET_DNS_ZONE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "DNS 호스팅 존 조회", + severity = AuditSeverity.LOW, + includeRequestData = true, + includeResponseData = false + ) + @Operation( + summary = "DNS 호스팅 존 상세 조회", + description = "특정 DNS 호스팅 존의 상세 정보를 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "DNS 호스팅 존 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "DNS 호스팅 존을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> getHostedZone( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "호스팅 존 ID", required = true, example = "/hostedzone/Z1234567890") + @PathVariable String zoneId, + + @Parameter(description = "리전", required = true, example = "us-east-1") + @RequestParam(required = false) String region) { + + log.info("[DnsController] getHostedZone - provider={}, accountScope={}, zoneId={}, region={}", + provider, accountScope, zoneId, region); + + Optional resource = dnsUseCaseService.getHostedZone(provider, accountScope, region, zoneId); + + if (resource.isPresent()) { + DnsResponse response = DnsResponse.from(resource.get()); + log.info("[DnsController] getHostedZone - success zoneId={}", zoneId); + return ResponseEntity.ok(ApiResponse.success(response, "DNS 호스팅 존 조회에 성공했습니다.")); + } else { + log.warn("[DnsController] getHostedZone - not found zoneId={}", zoneId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + } + } + + // ==================== DNS 호스팅 존 수정 ==================== + + /** + * DNS 호스팅 존을 수정합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param zoneId 호스팅 존 ID + * @param region 리전 + * @param request DNS 호스팅 존 수정 요청 + * @return 수정된 DNS 호스팅 존 리소스 + */ + @PutMapping("/{zoneId}") + @PreAuthorize("hasAuthority('DNS_UPDATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "UPDATE_DNS_ZONE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "DNS 호스팅 존 수정", + severity = AuditSeverity.HIGH, + includeRequestData = true, + includeResponseData = true + ) + @Operation( + summary = "DNS 호스팅 존 수정", + description = "DNS 호스팅 존의 정보를 수정합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "DNS 호스팅 존 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "DNS 호스팅 존을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> updateHostedZone( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "호스팅 존 ID", required = true, example = "/hostedzone/Z1234567890") + @PathVariable String zoneId, + + @Parameter(description = "리전", required = true, example = "us-east-1") + @RequestParam(required = false) String region, + + @Parameter(description = "DNS 호스팅 존 수정 요청", required = true) + @Valid @RequestBody DnsUpdateRequest request) { + + log.info("[DnsController] updateHostedZone - provider={}, accountScope={}, zoneId={}, region={}", + provider, accountScope, zoneId, region); + + CloudResource resource = dnsUseCaseService.updateHostedZone(provider, accountScope, region, zoneId, request); + DnsResponse response = DnsResponse.from(resource); + + log.info("[DnsController] updateHostedZone - success zoneId={}", zoneId); + return ResponseEntity.ok(ApiResponse.success(response, "DNS 호스팅 존 수정에 성공했습니다.")); + } + + // ==================== DNS 호스팅 존 삭제 ==================== + + /** + * DNS 호스팅 존을 삭제합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param zoneId 호스팅 존 ID + * @param region 리전 + * @param request 삭제 요청 (선택적) + * @return 삭제 결과 + */ + @DeleteMapping("/{zoneId}") + @PreAuthorize("hasAuthority('DNS_DELETE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "DELETE_DNS_ZONE", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "DNS 호스팅 존 삭제", + severity = AuditSeverity.CRITICAL, + includeRequestData = true, + includeResponseData = false + ) + @Operation( + summary = "DNS 호스팅 존 삭제", + description = "DNS 호스팅 존을 삭제합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "DNS 호스팅 존 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "DNS 호스팅 존을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity deleteHostedZone( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "호스팅 존 ID", required = true, example = "/hostedzone/Z1234567890") + @PathVariable String zoneId, + + @Parameter(description = "리전", example = "us-east-1") + @RequestParam(required = false) String region, + + @Parameter(description = "삭제 요청 (선택적)") + @RequestBody(required = false) DnsDeleteRequest request) { + + log.info("[DnsController] deleteHostedZone - provider={}, accountScope={}, zoneId={}, region={}", + provider, accountScope, zoneId, region); + + // Request가 없으면 기본값으로 생성 + if (request == null) { + request = DnsDeleteRequest.builder() + .providerType(provider) + .accountScope(accountScope) + .region(region) + .zoneId(zoneId) + .forceDelete(false) + .build(); + } else { + // PathVariable 값 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + request.setZoneId(zoneId); + if (region != null) { + request.setRegion(region); + } + } + + dnsUseCaseService.deleteHostedZone(request); + + log.info("[DnsController] deleteHostedZone - success zoneId={}", zoneId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsCreateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsCreateRequest.java new file mode 100644 index 00000000..9fd86f04 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsCreateRequest.java @@ -0,0 +1,99 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * DNS 호스팅 존 생성 요청 DTO + * + * Controller에서 받는 요청 객체로, CSP 중립적인 필드만 포함합니다. + * CSP 특화 설정은 providerSpecificConfig에 포함됩니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsCreateRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 리전 (필수) + * 예: us-east-1, ap-northeast-2 + * Route53은 글로벌 서비스이지만 일관성을 위해 리전 파라미터는 유지 + */ + @NotBlank(message = "리전은 필수입니다") + private String region; + + /** + * 호스팅 존 이름 (필수) + * CSP 중립적: example.com + */ + @NotBlank(message = "호스팅 존 이름은 필수입니다") + @Pattern(regexp = "^[a-z0-9]([a-z0-9\\-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9\\-]{0,61}[a-z0-9])?)*$", + message = "유효한 도메인 이름 형식이 아닙니다") + private String zoneName; + + /** + * 존 타입 (필수) + * CSP 중립적: "PUBLIC", "PRIVATE" + */ + @NotBlank(message = "존 타입은 필수입니다") + @Pattern(regexp = "PUBLIC|PRIVATE", message = "PUBLIC 또는 PRIVATE이어야 합니다") + private String zoneType; + + /** + * VPC ID (Private Zone인 경우 선택적) + */ + private String vpcId; + + /** + * 호스팅 존 설명 + */ + private String comment; + + /** + * 태그 + */ + private Map tags; + + /** + * CSP별 특화 설정 + * + * AWS 예시: + * - delegationSetId: "N1234567890" (재사용 가능한 위임 집합 ID) + * + * Azure 예시: + * - resourceGroupName: "my-resource-group" + * - zoneType: "Public" | "Private" + * - registrationVirtualNetworkIds: ["vnet-id-1", "vnet-id-2"] + * + * GCP 예시: + * - description: "Managed zone description" + * - dnsName: "example.com." + * - visibility: "public" | "private" + * - privateVisibilityConfig: { networks: [...] } + */ + private Map providerSpecificConfig; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsDeleteRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsDeleteRequest.java new file mode 100644 index 00000000..820acf6c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsDeleteRequest.java @@ -0,0 +1,75 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * DNS 호스팅 존 삭제 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsDeleteRequest { + + /** + * 클라우드 프로바이더 타입 + * Controller에서 PathVariable로 주입됩니다. + */ + @NotNull + private ProviderType providerType; + + /** + * 계정 스코프 + * Controller에서 PathVariable로 주입됩니다. + */ + @NotBlank + private String accountScope; + + /** + * 리전 + */ + private String region; + + /** + * 삭제할 호스팅 존 ID + */ + @NotBlank(message = "호스팅 존 ID는 필수입니다") + private String zoneId; + + /** + * 레코드가 있어도 강제 삭제 여부 + */ + @Builder.Default + private Boolean forceDelete = false; + + /** + * 삭제 이유 (감사 로그용) + */ + private String reason; + + /** + * CSP별 특화 삭제 옵션 + */ + private Map providerSpecificConfig; + + /** + * 기본 삭제 요청 생성 + */ + public static DnsDeleteRequest basic(String zoneId) { + return DnsDeleteRequest.builder() + .zoneId(zoneId) + .forceDelete(false) + .build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsQueryRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsQueryRequest.java new file mode 100644 index 00000000..f751f66c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsQueryRequest.java @@ -0,0 +1,82 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; +import java.util.Set; + +/** + * DNS 호스팅 존 조회 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsQueryRequest { + + /** + * 클라우드 프로바이더 타입 + * Controller에서 PathVariable로 주입됩니다. + */ + @NotNull + private ProviderType providerType; + + /** + * 계정 스코프 + * Controller에서 PathVariable로 주입됩니다. + */ + @NotBlank + private String accountScope; + + /** + * 조회할 리전 목록 + * Route53은 글로벌 서비스이므로 무시될 수 있음 + */ + private Set regions; + + /** + * 호스팅 존 이름으로 필터링 + */ + private String zoneName; + + /** + * "PUBLIC", "PRIVATE"로 필터링 + */ + private String zoneType; + + /** + * Private Zone인 경우 VPC ID로 필터링 + */ + private String vpcId; + + /** + * 태그로 필터링 + */ + private Map tags; + + /** + * 페이지 번호 (0부터 시작) + */ + @Min(0) + @Builder.Default + private int page = 0; + + /** + * 페이지 크기 + */ + @Min(1) + @Max(100) + @Builder.Default + private int size = 20; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsResponse.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsResponse.java new file mode 100644 index 00000000..d543aacf --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsResponse.java @@ -0,0 +1,87 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * DNS 호스팅 존 응답 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsResponse { + + private Long id; + private String resourceId; + private String resourceName; // zone name (example.com) + private ProviderType providerType; + private String region; + private String zoneType; // "PUBLIC", "PRIVATE" + private String vpcId; // Private Zone인 경우 VPC ID + private String nameServers; // 네임서버 목록 (JSON 배열 문자열) + private String status; + private String lifecycleState; + private String comment; // 호스팅 존 설명 + private Map tags; + private LocalDateTime createdAt; + private LocalDateTime lastSync; + + /** + * CloudResource → DnsResponse 변환 + */ + public static DnsResponse from(CloudResource resource) { + ObjectMapper objectMapper = new ObjectMapper(); + + // configuration에서 추가 정보 추출 + Map config = parseConfiguration(resource.getConfiguration(), objectMapper); + String zoneType = extractString(config, "zoneType"); + String vpcId = extractString(config, "vpcId"); + String comment = extractString(config, "comment"); + + return DnsResponse.builder() + .id(resource.getId()) + .resourceId(resource.getResourceId()) + .resourceName(resource.getResourceName()) + .providerType(resource.getProvider().getProviderType()) + .region(resource.getRegion() != null ? resource.getRegion().getRegionKey() : null) + .zoneType(zoneType) + .vpcId(vpcId) + .status(resource.getStatus() != null ? resource.getStatus().name() : null) + .lifecycleState(resource.getLifecycleState() != null ? resource.getLifecycleState().name() : null) + .comment(comment) + .tags(resource.getTags() != null ? resource.getTags() : new HashMap<>()) + .createdAt(resource.getCreatedAt()) + .lastSync(resource.getLastSync()) + .build(); + } + + private static Map parseConfiguration(String configJson, ObjectMapper objectMapper) { + if (configJson == null || configJson.trim().isEmpty()) { + return new HashMap<>(); + } + try { + return objectMapper.readValue(configJson, new TypeReference>() {}); + } catch (Exception e) { + return new HashMap<>(); + } + } + + private static String extractString(Map map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsUpdateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsUpdateRequest.java new file mode 100644 index 00000000..561a70cd --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/DnsUpdateRequest.java @@ -0,0 +1,42 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * DNS 호스팅 존 수정 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DnsUpdateRequest { + + /** + * 호스팅 존 설명 변경 + */ + private String comment; + + /** + * 추가할 태그 + */ + private Map tagsToAdd; + + /** + * 제거할 태그 + */ + private Map tagsToRemove; + + /** + * CSP별 특화 설정 + */ + private Map providerSpecificConfig; +} 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 f364a3e5..11afa67f 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 @@ -360,7 +360,8 @@ public enum ResourceType { CONFIG_MAP, SECRET, PERSISTENT_VOLUME, - PERSISTENT_VOLUME_CLAIM + PERSISTENT_VOLUME_CLAIM, + DNS_ZONE } public enum LifecycleState { diff --git a/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java b/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java index 873df9d2..e6a89190 100644 --- a/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java +++ b/src/main/java/com/agenticcp/core/domain/cloud/exception/CloudErrorCode.java @@ -45,6 +45,7 @@ public enum CloudErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, 4037, "유효하지 않은 클라우드 API 요청입니다."), CLOUD_NETWORK_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 4038, "클라우드 네트워크 통신에 실패했습니다."), CLOUD_CONFIGURATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 4039, "클라우드 설정이 잘못되었습니다."), + CLOUD_PROVIDER_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, 4042, "지원하지 않는 클라우드 프로바이더입니다."), // 리소스 생성/동기화 관련 (4040-4049) RESOURCE_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 4040, "클라우드 리소스 생성 후 DB 저장에 실패하여 롤백되었습니다."), diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsCreateCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsCreateCommand.java new file mode 100644 index 00000000..2a850a8a --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsCreateCommand.java @@ -0,0 +1,32 @@ +package com.agenticcp.core.domain.cloud.port.model.dns; + +import java.util.Map; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; + +import lombok.Builder; + +/** + * DNS 호스팅 존 생성 도메인 커맨드 + * + * UseCase Service에서 Adapter로 전달되는 내부 명령 모델입니다. + * 각 CSP Adapter의 Mapper에서 CSP 특화 요청으로 변환합니다. + */ +@Builder +public record DnsCreateCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String serviceKey, + String resourceType, + String zoneName, + String zoneType, + String vpcId, + String comment, + Map tags, + String tenantKey, + Map providerSpecificConfig, + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsDeleteCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsDeleteCommand.java new file mode 100644 index 00000000..6315dbb0 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsDeleteCommand.java @@ -0,0 +1,24 @@ +package com.agenticcp.core.domain.cloud.port.model.dns; + +import java.util.Map; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; + +import lombok.Builder; + +/** + * DNS 호스팅 존 삭제 도메인 커맨드 + */ +@Builder +public record DnsDeleteCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, + Boolean forceDelete, + String tenantKey, + Map providerSpecificConfig, + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsQuery.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsQuery.java new file mode 100644 index 00000000..b76a1ad2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsQuery.java @@ -0,0 +1,25 @@ +package com.agenticcp.core.domain.cloud.port.model.dns; + +import java.util.Map; +import java.util.Set; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; + +import lombok.Builder; + +/** + * DNS 호스팅 존 조회 쿼리 + */ +@Builder +public record DnsQuery( + CloudProvider.ProviderType providerType, + String accountScope, + Set regions, + String zoneName, + String zoneType, + String vpcId, + Map tagsEquals, + int page, + int size +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsUpdateCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsUpdateCommand.java new file mode 100644 index 00000000..66367c8b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/DnsUpdateCommand.java @@ -0,0 +1,25 @@ +package com.agenticcp.core.domain.cloud.port.model.dns; + +import java.util.Map; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; + +import lombok.Builder; + +/** + * DNS 호스팅 존 수정 도메인 커맨드 + */ +@Builder +public record DnsUpdateCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, + String comment, + Map tags, + String tenantKey, + Map providerSpecificConfig, + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/GetDnsCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/GetDnsCommand.java new file mode 100644 index 00000000..67c796d2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/dns/GetDnsCommand.java @@ -0,0 +1,18 @@ +package com.agenticcp.core.domain.cloud.port.model.dns; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; + +import lombok.Builder; + +@Builder +public record GetDnsCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, + String serviceKey, + String resourceType, + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/dns/DnsDiscoveryPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/dns/DnsDiscoveryPort.java new file mode 100644 index 00000000..f5884b7e --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/dns/DnsDiscoveryPort.java @@ -0,0 +1,29 @@ +package com.agenticcp.core.domain.cloud.port.outbound.dns; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsQuery; +import java.util.Optional; +import org.springframework.data.domain.Page; + +/** + * DNS 호스팅 존 조회 포트 + */ +public interface DnsDiscoveryPort { + + /** + * DNS 호스팅 존 목록 조회 + */ + Page listHostedZones(DnsQuery query, CloudSessionCredential session); + + /** + * 특정 DNS 호스팅 존 조회 + */ + Optional getHostedZone(String zoneId, CloudSessionCredential session); + + /** + * DNS 호스팅 존 상태 조회 + */ + String getZoneStatus(String zoneId, CloudSessionCredential session); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/dns/DnsManagementPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/dns/DnsManagementPort.java new file mode 100644 index 00000000..b491c1cf --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/dns/DnsManagementPort.java @@ -0,0 +1,30 @@ +package com.agenticcp.core.domain.cloud.port.outbound.dns; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsUpdateCommand; + +/** + * DNS 호스팅 존 관리 포트 + * + * 각 CSP별 Adapter(AWS Route53, Azure DNS, GCP Cloud DNS 등)가 이 인터페이스를 구현합니다. + */ +public interface DnsManagementPort { + + /** + * DNS 호스팅 존 생성 + */ + CloudResource createHostedZone(DnsCreateCommand command); + + /** + * DNS 호스팅 존 수정 + */ + CloudResource updateHostedZone(DnsUpdateCommand command); + + /** + * DNS 호스팅 존 삭제 + */ + void deleteHostedZone(DnsDeleteCommand command); +} + diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/dns/DnsPortRouter.java b/src/main/java/com/agenticcp/core/domain/cloud/service/dns/DnsPortRouter.java new file mode 100644 index 00000000..c4343990 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/dns/DnsPortRouter.java @@ -0,0 +1,56 @@ +package com.agenticcp.core.domain.cloud.service.dns; + +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.port.outbound.dns.DnsDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.dns.DnsManagementPort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class DnsPortRouter { + + private final Map managementPorts; + private final Map discoveryPorts; + + public DnsPortRouter( + List dnsManagementPorts, + List dnsDiscoveryPorts) { + this.managementPorts = dnsManagementPorts.stream() + .filter(port -> port instanceof ProviderScoped) + .collect(Collectors.toMap( + port -> ((ProviderScoped) port).getProviderType(), + Function.identity() + )); + this.discoveryPorts = dnsDiscoveryPorts.stream() + .filter(port -> port instanceof ProviderScoped) + .collect(Collectors.toMap( + port -> ((ProviderScoped) port).getProviderType(), + Function.identity() + )); + } + + public DnsManagementPort management(ProviderType provider) { + DnsManagementPort port = managementPorts.get(provider); + + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + provider); + } + + return port; + } + + public DnsDiscoveryPort discovery(ProviderType provider) { + DnsDiscoveryPort port = discoveryPorts.get(provider); + + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + provider); + } + + return port; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/dns/DnsUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/dns/DnsUseCaseService.java new file mode 100644 index 00000000..60a1ad1c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/dns/DnsUseCaseService.java @@ -0,0 +1,351 @@ +package com.agenticcp.core.domain.cloud.service.dns; + +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.DnsCreateRequest; +import com.agenticcp.core.domain.cloud.dto.DnsDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.DnsQueryRequest; +import com.agenticcp.core.domain.cloud.dto.DnsUpdateRequest; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest.AttributeKeys; +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.dns.DnsCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsQuery; +import com.agenticcp.core.domain.cloud.port.model.dns.DnsUpdateCommand; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DnsUseCaseService { + + private static final String RESOURCE_TYPE = "DNS_ZONE"; + + private final DnsPortRouter portRouter; + private final CapabilityGuard capabilityGuard; + private final AccountCredentialManagementPort credentialPort; + private final CloudResourceManagementHelper resourceHelper; + + /** + * DNS 호스팅 존 생성 + */ + @Transactional + public CloudResource createHostedZone(DnsCreateRequest request) { + log.info("[DnsUseCaseService] createHostedZone - provider={}, accountScope={}, zoneName={}", + request.getProviderType(), request.getAccountScope(), request.getZoneName()); + + ProviderType providerType = request.getProviderType(); + String accountScope = request.getAccountScope(); + + // Capability 검증 + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.CREATE); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Command 변환 + DnsCreateCommand command = DnsCreateCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(request.getRegion()) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .zoneName(request.getZoneName()) + .zoneType(request.getZoneType()) + .vpcId(request.getVpcId()) + .comment(request.getComment()) + .tags(request.getTags()) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + + // 어댑터 호출 + CloudResource resource = portRouter.management(providerType).createHostedZone(command); + + // DB에 CloudResource 저장 + try { + String resourceName = request.getZoneName() != null ? request.getZoneName() : resource.getResourceId(); + ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() + .resourceId(resource.getResourceId()) + .resourceName(resourceName) + .resourceType(CloudResource.ResourceType.NETWORK) // DNS는 네트워크 리소스로 분류 + .tags(request.getTags()) + .attributes(Map.of( + AttributeKeys.CONFIGURATION, buildConfigurationJson(request) + )) + .build(); + + CloudResource savedResource = resourceHelper.registerResource( + providerType, + serviceKey, + registrationRequest + ); + + log.info("[DnsUseCaseService] createHostedZone - success resourceId={}", savedResource.getResourceId()); + return savedResource; + } catch (Exception e) { + log.error("[DnsUseCaseService] DB 저장 실패, 보상 트랜잭션 실행: zoneId={}, error={}", + resource.getResourceId(), e.getMessage()); + + // 보상 트랜잭션: CSP에 생성된 호스팅 존 삭제 + executeCompensatingTransaction(providerType, resource.getResourceId(), accountScope, + request.getRegion(), session); + + throw new BusinessException( + CloudErrorCode.RESOURCE_CREATION_FAILED, + "DNS 호스팅 존 생성 후 DB 저장 실패로 인해 롤백되었습니다: " + resource.getResourceId() + ); + } + } + + /** + * DNS 호스팅 존 목록 조회 + */ + @Transactional(readOnly = true) + public Page listHostedZones(DnsQueryRequest request) { + log.info("[DnsUseCaseService] listHostedZones - provider={}, accountScope={}", + request.getProviderType(), request.getAccountScope()); + + ProviderType providerType = request.getProviderType(); + String accountScope = request.getAccountScope(); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Query 변환 + DnsQuery query = DnsQuery.builder() + .providerType(providerType) + .accountScope(accountScope) + .regions(request.getRegions()) + .zoneName(request.getZoneName()) + .zoneType(request.getZoneType()) + .vpcId(request.getVpcId()) + .tagsEquals(request.getTags()) + .page(request.getPage()) + .size(request.getSize()) + .build(); + + return portRouter.discovery(providerType).listHostedZones(query, session); + } + + /** + * 특정 DNS 호스팅 존 조회 + */ + @Transactional(readOnly = true) + public Optional getHostedZone(ProviderType providerType, String accountScope, + String region, String zoneId) { + log.info("[DnsUseCaseService] getHostedZone - provider={}, accountScope={}, zoneId={}", + providerType, accountScope, zoneId); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + return portRouter.discovery(providerType).getHostedZone(zoneId, session); + } + + /** + * DNS 호스팅 존 수정 + */ + @Transactional + public CloudResource updateHostedZone(ProviderType providerType, String accountScope, + String region, String zoneId, DnsUpdateRequest request) { + log.info("[DnsUseCaseService] updateHostedZone - provider={}, accountScope={}, zoneId={}", + providerType, accountScope, zoneId); + + // Capability 검증 + String serviceKey = getServiceKeyForProvider(providerType); + capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.UPDATE); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Command 변환 + DnsUpdateCommand command = DnsUpdateCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(region) + .providerResourceId(zoneId) + .comment(request.getComment()) + .tags(request.getTagsToAdd()) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + + return portRouter.management(providerType).updateHostedZone(command); + } + + /** + * DNS 호스팅 존 삭제 + */ + @Transactional + public void deleteHostedZone(DnsDeleteRequest request) { + log.info("[DnsUseCaseService] deleteHostedZone - provider={}, accountScope={}, zoneId={}", + request.getProviderType(), request.getAccountScope(), request.getZoneId()); + + ProviderType providerType = request.getProviderType(); + String accountScope = request.getAccountScope(); + + // tenantKey 획득 + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // accountScope 검증 + validateAccountScope(accountScope); + + // JIT 세션 획득 + CloudSessionCredential session = getSession(providerType, accountScope); + + // Command 변환 + DnsDeleteCommand command = DnsDeleteCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(request.getRegion()) + .providerResourceId(request.getZoneId()) + .forceDelete(request.getForceDelete()) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + + // CSP에서 호스팅 존 삭제 + portRouter.management(providerType).deleteHostedZone(command); + + // DB 소프트 삭제 + resourceHelper.softDeleteResource(request.getZoneId()); + + log.info("[DnsUseCaseService] deleteHostedZone - success zoneId={}", request.getZoneId()); + } + + // ==================== Private Helper Methods ==================== + + private CloudSessionCredential getSession(ProviderType providerType, String accountScope) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + log.debug("세션 획득 시작: tenantKey={}, accountScope={}, providerType={}", + tenantKey, accountScope, providerType); + + try { + CloudSessionCredential session = credentialPort.getSession(tenantKey, accountScope, providerType); + log.info("세션 획득 완료: expiresAt={}", session.getExpiresAt()); + return session; + } catch (BusinessException e) { + if (e.getErrorCode() == CredentialErrorCode.CREDENTIAL_NOT_FOUND) { + log.error("자격증명을 찾을 수 없습니다: tenantKey={}, accountScope={}", + tenantKey, accountScope); + throw new BusinessException( + CloudErrorCode.ACCOUNT_NOT_CONFIGURED, + "계정이 설정되지 않았습니다" + ); + } + throw e; + } + } + + private String getServiceKeyForProvider(ProviderType providerType) { + return switch (providerType) { + case AWS -> "ROUTE53"; + case AZURE -> "AZURE_DNS"; + case GCP -> "CLOUD_DNS"; + default -> throw new BusinessException(CloudErrorCode.CLOUD_PROVIDER_NOT_SUPPORTED, + "지원하지 않는 프로바이더입니다: " + providerType); + }; + } + + private void validateAccountScope(String accountScope) { + if (accountScope == null || accountScope.trim().isEmpty()) { + throw new BusinessException( + CloudErrorCode.ACCOUNT_SCOPE_REQUIRED, + "AccountScope가 필요합니다" + ); + } + } + + private String buildConfigurationJson(DnsCreateRequest request) { + // 간단한 JSON 문자열 생성 (실제로는 JSON 라이브러리 사용 권장) + StringBuilder sb = new StringBuilder(); + sb.append("{"); + sb.append("\"zoneType\":\"").append(request.getZoneType()).append("\""); + if (request.getVpcId() != null) { + sb.append(",\"vpcId\":\"").append(request.getVpcId()).append("\""); + } + if (request.getComment() != null) { + sb.append(",\"comment\":\"").append(request.getComment()).append("\""); + } + sb.append("}"); + return sb.toString(); + } + + /** + * 보상 트랜잭션: CSP에 생성된 호스팅 존을 삭제합니다. + */ + private void executeCompensatingTransaction( + ProviderType providerType, + String zoneId, + String accountScope, + String region, + CloudSessionCredential session + ) { + try { + log.warn("[DnsUseCaseService] 보상 트랜잭션 실행: CSP 호스팅 존 삭제 시도 - zoneId={}", zoneId); + + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + DnsDeleteCommand deleteCommand = DnsDeleteCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(region) + .providerResourceId(zoneId) + .forceDelete(false) + .tenantKey(tenantKey) + .session(session) + .build(); + + portRouter.management(providerType).deleteHostedZone(deleteCommand); + log.info("[DnsUseCaseService] 보상 트랜잭션 완료: CSP 호스팅 존 삭제 성공 - zoneId={}", zoneId); + } catch (Exception compensationError) { + log.error("[DnsUseCaseService] 보상 트랜잭션 실패: Ghost Resource 발생 가능 - zoneId={}, error={}", + zoneId, compensationError.getMessage()); + } + } +}