Files
CxIDE/Services/AWSRoute53Service.swift
T
cx-git-agent 1af1db7ffc Add comprehensive AWS integration (S3, Route53, CloudFront, Lambda, ACM) with 72 new tests
- AWSService: SigV4 request signing, XML parser, credential management
- AWSS3Service: bucket/object CRUD, website hosting, deploy pipeline
- AWSRoute53Service: hosted zones, DNS records, S3/CloudFront aliases
- AWSCloudFrontService: distributions, cache invalidation, ACM certs
- AWSLambdaService: functions, invoke, zip deploy
- AWSTools: 15 MCP agent tools for all AWS operations
- CredentialStore: AWS credential convenience properties
- AWSAndWebsiteTests: 72 tests covering models, XML parsing, errors,
  credentials, templates, deploy providers (165 total tests passing)
2026-04-21 19:40:51 -05:00

347 lines
12 KiB
Swift

// AWSRoute53Service.swift
// CxIDE AWS Route 53 DNS management
//
// Provides hosted zone management, DNS record CRUD, and domain routing
// for websites deployed to S3, CloudFront, or other AWS services.
import Foundation
// MARK: - Route 53 Service
final class AWSRoute53Service: @unchecked Sendable {
static let shared = AWSRoute53Service()
private let aws: AWSService
init(aws: AWSService = .shared) {
self.aws = aws
}
// Route 53 uses us-east-1 globally
private let route53Region = "us-east-1"
// MARK: - Hosted Zones
/// List all hosted zones
func listHostedZones() async throws -> [Route53HostedZone] {
let (data, _) = try await aws.request(
service: "route53",
region: route53Region,
method: "GET",
path: "/2013-04-01/hostedzone"
)
let xml = try AWSXMLParser().parse(data: data)
return xml.allDescendants("HostedZone").compactMap { el -> Route53HostedZone? in
guard let id = el.child("Id")?.text,
let name = el.child("Name")?.text else { return nil }
let count = Int(el.child("ResourceRecordSetCount")?.text ?? "0") ?? 0
let comment = el.descendant("Comment")?.text
let isPrivate = el.descendant("PrivateZone")?.text == "true"
// ID comes as "/hostedzone/XXXXX", extract just the ID
let cleanId = id.replacingOccurrences(of: "/hostedzone/", with: "")
return Route53HostedZone(
id: cleanId, name: name, recordCount: count,
comment: comment, isPrivate: isPrivate
)
}
}
/// Create a hosted zone for a domain
func createHostedZone(domain: String, comment: String = "Created by CxIDE") async throws -> Route53HostedZone {
let callerRef = UUID().uuidString
let xml = """
<?xml version="1.0" encoding="UTF-8"?>
<CreateHostedZoneRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
<Name>\(domain)</Name>
<CallerReference>\(callerRef)</CallerReference>
<HostedZoneConfig>
<Comment>\(comment)</Comment>
</HostedZoneConfig>
</CreateHostedZoneRequest>
"""
let (data, _) = try await aws.request(
service: "route53",
region: route53Region,
method: "POST",
path: "/2013-04-01/hostedzone",
body: Data(xml.utf8),
contentType: "application/xml"
)
let responseXml = try AWSXMLParser().parse(data: data)
guard let zone = responseXml.descendant("HostedZone"),
let id = zone.child("Id")?.text,
let name = zone.child("Name")?.text else {
throw AWSError.invalidXML("Failed to parse CreateHostedZone response")
}
let cleanId = id.replacingOccurrences(of: "/hostedzone/", with: "")
// Extract nameservers
let nsRecords = responseXml.allDescendants("Nameserver").map { $0.text }
return Route53HostedZone(
id: cleanId, name: name, recordCount: 2,
comment: comment, isPrivate: false, nameservers: nsRecords
)
}
/// Delete a hosted zone
func deleteHostedZone(id: String) async throws {
_ = try await aws.request(
service: "route53",
region: route53Region,
method: "DELETE",
path: "/2013-04-01/hostedzone/\(id)"
)
}
// MARK: - DNS Records
/// List DNS records in a hosted zone
func listRecordSets(zoneId: String) async throws -> [Route53RecordSet] {
let (data, _) = try await aws.request(
service: "route53",
region: route53Region,
method: "GET",
path: "/2013-04-01/hostedzone/\(zoneId)/rrset"
)
let xml = try AWSXMLParser().parse(data: data)
return xml.allDescendants("ResourceRecordSet").compactMap { el -> Route53RecordSet? in
guard let name = el.child("Name")?.text,
let typeStr = el.child("Type")?.text else { return nil }
let ttl = Int(el.child("TTL")?.text ?? "300") ?? 300
let values = el.allDescendants("Value").map { $0.text }
// Alias record
let aliasTarget = el.child("AliasTarget")
let alias: Route53AliasTarget?
if let at = aliasTarget,
let hostedZone = at.child("HostedZoneId")?.text,
let dnsName = at.child("DNSName")?.text {
alias = Route53AliasTarget(
hostedZoneId: hostedZone,
dnsName: dnsName,
evaluateTargetHealth: at.child("EvaluateTargetHealth")?.text == "true"
)
} else {
alias = nil
}
return Route53RecordSet(
name: name, type: typeStr, ttl: ttl,
values: values, aliasTarget: alias
)
}
}
/// Create or update DNS records (UPSERT)
func upsertRecord(
zoneId: String,
name: String,
type: String,
values: [String],
ttl: Int = 300
) async throws {
let resourceRecords = values
.map { "<ResourceRecord><Value>\($0)</Value></ResourceRecord>" }
.joined()
let xml = """
<?xml version="1.0" encoding="UTF-8"?>
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
<ChangeBatch>
<Changes>
<Change>
<Action>UPSERT</Action>
<ResourceRecordSet>
<Name>\(name)</Name>
<Type>\(type)</Type>
<TTL>\(ttl)</TTL>
<ResourceRecords>\(resourceRecords)</ResourceRecords>
</ResourceRecordSet>
</Change>
</Changes>
</ChangeBatch>
</ChangeResourceRecordSetsRequest>
"""
_ = try await aws.request(
service: "route53",
region: route53Region,
method: "POST",
path: "/2013-04-01/hostedzone/\(zoneId)/rrset",
body: Data(xml.utf8),
contentType: "application/xml"
)
}
/// Create an alias record (for S3, CloudFront, etc.)
func upsertAliasRecord(
zoneId: String,
name: String,
type: String,
targetHostedZoneId: String,
targetDNSName: String,
evaluateHealth: Bool = false
) async throws {
let xml = """
<?xml version="1.0" encoding="UTF-8"?>
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
<ChangeBatch>
<Changes>
<Change>
<Action>UPSERT</Action>
<ResourceRecordSet>
<Name>\(name)</Name>
<Type>\(type)</Type>
<AliasTarget>
<HostedZoneId>\(targetHostedZoneId)</HostedZoneId>
<DNSName>\(targetDNSName)</DNSName>
<EvaluateTargetHealth>\(evaluateHealth)</EvaluateTargetHealth>
</AliasTarget>
</ResourceRecordSet>
</Change>
</Changes>
</ChangeBatch>
</ChangeResourceRecordSetsRequest>
"""
_ = try await aws.request(
service: "route53",
region: route53Region,
method: "POST",
path: "/2013-04-01/hostedzone/\(zoneId)/rrset",
body: Data(xml.utf8),
contentType: "application/xml"
)
}
/// Delete a DNS record
func deleteRecord(
zoneId: String,
name: String,
type: String,
values: [String],
ttl: Int = 300
) async throws {
let resourceRecords = values
.map { "<ResourceRecord><Value>\($0)</Value></ResourceRecord>" }
.joined()
let xml = """
<?xml version="1.0" encoding="UTF-8"?>
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
<ChangeBatch>
<Changes>
<Change>
<Action>DELETE</Action>
<ResourceRecordSet>
<Name>\(name)</Name>
<Type>\(type)</Type>
<TTL>\(ttl)</TTL>
<ResourceRecords>\(resourceRecords)</ResourceRecords>
</ResourceRecordSet>
</Change>
</Changes>
</ChangeBatch>
</ChangeResourceRecordSetsRequest>
"""
_ = try await aws.request(
service: "route53",
region: route53Region,
method: "POST",
path: "/2013-04-01/hostedzone/\(zoneId)/rrset",
body: Data(xml.utf8),
contentType: "application/xml"
)
}
// MARK: - Convenience: Find hosted zone by domain
/// Find a hosted zone by domain name
func findHostedZone(domain: String) async throws -> Route53HostedZone? {
let zones = try await listHostedZones()
let normalized = domain.hasSuffix(".") ? domain : "\(domain)."
return zones.first { $0.name == normalized }
}
// MARK: - Convenience: Point domain to S3 Website
/// Configure Route 53 to point a domain to an S3 website bucket
func pointToS3Website(zoneId: String, domain: String, bucket: String, region: String) async throws {
// S3 website hosted zone IDs per region
let s3HostedZoneId = s3WebsiteHostedZoneId(region: region)
try await upsertAliasRecord(
zoneId: zoneId,
name: domain,
type: "A",
targetHostedZoneId: s3HostedZoneId,
targetDNSName: "s3-website-\(region).amazonaws.com"
)
}
/// Configure Route 53 to point a domain to a CloudFront distribution
func pointToCloudFront(zoneId: String, domain: String, distributionDomain: String) async throws {
try await upsertAliasRecord(
zoneId: zoneId,
name: domain,
type: "A",
targetHostedZoneId: "Z2FDTNDATAQYW2", // CloudFront global hosted zone ID
targetDNSName: distributionDomain
)
}
// S3 website endpoint hosted zone IDs by region
private func s3WebsiteHostedZoneId(region: String) -> String {
let mapping: [String: String] = [
"us-east-1": "Z3AQBSTGFYJSTF",
"us-east-2": "Z2O1EMRO9K5GLX",
"us-west-1": "Z2F56UZL2M1ACD",
"us-west-2": "Z3BJ6K6RIION7M",
"eu-west-1": "Z1BKCTXD74EZPE",
"eu-west-2": "Z3GKZC51ZF0DB4",
"eu-central-1": "Z21DNDUVLTQW6Q",
"ap-southeast-1": "Z3O0J2DXBE1FTB",
"ap-southeast-2": "Z1WCIBER0Y3ULH",
"ap-northeast-1": "Z2M4EHUR26P7ZW",
]
return mapping[region] ?? mapping["us-east-1"]!
}
}
// MARK: - Route 53 Models
struct Route53HostedZone: Sendable {
let id: String
let name: String
let recordCount: Int
let comment: String?
let isPrivate: Bool
var nameservers: [String] = []
var domainName: String {
// Remove trailing dot
name.hasSuffix(".") ? String(name.dropLast()) : name
}
}
struct Route53RecordSet: Sendable {
let name: String
let type: String
let ttl: Int
let values: [String]
let aliasTarget: Route53AliasTarget?
var isAlias: Bool { aliasTarget != nil }
}
struct Route53AliasTarget: Sendable {
let hostedZoneId: String
let dnsName: String
let evaluateTargetHealth: Bool
}