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