Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@
<artifactId>resourcegroupstaggingapi</artifactId>
</dependency>

<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>route53</artifactId>
</dependency>

<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>cloudfront</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<CloudResource> 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<CloudResource> 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<CloudResource> 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<CloudResource> 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;
}
}
Loading