1af1db7ffc
- 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)
330 lines
12 KiB
Swift
330 lines
12 KiB
Swift
// AWSCloudFrontService.swift
|
|
// CxIDE — AWS CloudFront CDN integration
|
|
//
|
|
// Creates and manages CloudFront distributions for S3 static websites.
|
|
// Supports custom domains, SSL certificates (ACM), and cache invalidation.
|
|
|
|
import Foundation
|
|
|
|
// MARK: - CloudFront Service
|
|
|
|
final class AWSCloudFrontService: @unchecked Sendable {
|
|
static let shared = AWSCloudFrontService()
|
|
|
|
private let aws: AWSService
|
|
|
|
init(aws: AWSService = .shared) {
|
|
self.aws = aws
|
|
}
|
|
|
|
// CloudFront API is always us-east-1
|
|
private let cfRegion = "us-east-1"
|
|
|
|
// MARK: - Distributions
|
|
|
|
/// List all CloudFront distributions
|
|
func listDistributions() async throws -> [CloudFrontDistribution] {
|
|
let (data, _) = try await aws.request(
|
|
service: "cloudfront",
|
|
region: cfRegion,
|
|
method: "GET",
|
|
path: "/2020-05-31/distribution"
|
|
)
|
|
let xml = try AWSXMLParser().parse(data: data)
|
|
return xml.allDescendants("DistributionSummary").compactMap { el -> CloudFrontDistribution? in
|
|
guard let id = el.child("Id")?.text,
|
|
let domainName = el.child("DomainName")?.text,
|
|
let status = el.child("Status")?.text else { return nil }
|
|
let enabled = el.child("Enabled")?.text == "true"
|
|
let aliases = el.descendant("Aliases")?.allChildren("Items").flatMap { $0.allChildren("CNAME").map(\.text) } ?? []
|
|
let origins = el.descendant("Origins")?.allDescendants("DomainName").map(\.text) ?? []
|
|
return CloudFrontDistribution(
|
|
id: id, domainName: domainName, status: status,
|
|
enabled: enabled, aliases: aliases, origins: origins
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Create a distribution for an S3 website
|
|
func createDistributionForS3(
|
|
bucket: String,
|
|
region: String,
|
|
aliases: [String] = [],
|
|
certificateArn: String? = nil,
|
|
comment: String = "Created by CxIDE"
|
|
) async throws -> CloudFrontDistribution {
|
|
let s3Origin = "\(bucket).s3-website-\(region).amazonaws.com"
|
|
let originId = "S3-Website-\(bucket)"
|
|
|
|
// Build alias configuration
|
|
let aliasXML: String
|
|
if !aliases.isEmpty {
|
|
let items = aliases.map { "<CNAME>\($0)</CNAME>" }.joined()
|
|
aliasXML = """
|
|
<Aliases>
|
|
<Quantity>\(aliases.count)</Quantity>
|
|
<Items>\(items)</Items>
|
|
</Aliases>
|
|
"""
|
|
} else {
|
|
aliasXML = """
|
|
<Aliases>
|
|
<Quantity>0</Quantity>
|
|
</Aliases>
|
|
"""
|
|
}
|
|
|
|
// SSL config
|
|
let viewerCertXML: String
|
|
if let arn = certificateArn, !aliases.isEmpty {
|
|
viewerCertXML = """
|
|
<ViewerCertificate>
|
|
<ACMCertificateArn>\(arn)</ACMCertificateArn>
|
|
<SSLSupportMethod>sni-only</SSLSupportMethod>
|
|
<MinimumProtocolVersion>TLSv1.2_2021</MinimumProtocolVersion>
|
|
</ViewerCertificate>
|
|
"""
|
|
} else {
|
|
viewerCertXML = """
|
|
<ViewerCertificate>
|
|
<CloudFrontDefaultCertificate>true</CloudFrontDefaultCertificate>
|
|
<MinimumProtocolVersion>TLSv1.2_2021</MinimumProtocolVersion>
|
|
</ViewerCertificate>
|
|
"""
|
|
}
|
|
|
|
let callerRef = UUID().uuidString
|
|
let xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
<CallerReference>\(callerRef)</CallerReference>
|
|
<Comment>\(comment)</Comment>
|
|
\(aliasXML)
|
|
<DefaultRootObject>index.html</DefaultRootObject>
|
|
<Origins>
|
|
<Quantity>1</Quantity>
|
|
<Items>
|
|
<Origin>
|
|
<Id>\(originId)</Id>
|
|
<DomainName>\(s3Origin)</DomainName>
|
|
<CustomOriginConfig>
|
|
<HTTPPort>80</HTTPPort>
|
|
<HTTPSPort>443</HTTPSPort>
|
|
<OriginProtocolPolicy>http-only</OriginProtocolPolicy>
|
|
</CustomOriginConfig>
|
|
</Origin>
|
|
</Items>
|
|
</Origins>
|
|
<DefaultCacheBehavior>
|
|
<TargetOriginId>\(originId)</TargetOriginId>
|
|
<ViewerProtocolPolicy>redirect-to-https</ViewerProtocolPolicy>
|
|
<AllowedMethods>
|
|
<Quantity>2</Quantity>
|
|
<Items>
|
|
<Method>GET</Method>
|
|
<Method>HEAD</Method>
|
|
</Items>
|
|
</AllowedMethods>
|
|
<Compress>true</Compress>
|
|
<CachePolicyId>658327ea-f89d-4fab-a63d-7e88639e58f6</CachePolicyId>
|
|
<ForwardedValues>
|
|
<QueryString>false</QueryString>
|
|
<Cookies><Forward>none</Forward></Cookies>
|
|
</ForwardedValues>
|
|
<MinTTL>0</MinTTL>
|
|
<DefaultTTL>86400</DefaultTTL>
|
|
<MaxTTL>31536000</MaxTTL>
|
|
</DefaultCacheBehavior>
|
|
<CustomErrorResponses>
|
|
<Quantity>1</Quantity>
|
|
<Items>
|
|
<CustomErrorResponse>
|
|
<ErrorCode>404</ErrorCode>
|
|
<ResponsePagePath>/index.html</ResponsePagePath>
|
|
<ResponseCode>200</ResponseCode>
|
|
<ErrorCachingMinTTL>300</ErrorCachingMinTTL>
|
|
</CustomErrorResponse>
|
|
</Items>
|
|
</CustomErrorResponses>
|
|
\(viewerCertXML)
|
|
<Enabled>true</Enabled>
|
|
<PriceClass>PriceClass_100</PriceClass>
|
|
<HttpVersion>http2and3</HttpVersion>
|
|
<IsIPV6Enabled>true</IsIPV6Enabled>
|
|
</DistributionConfig>
|
|
"""
|
|
|
|
let (data, _) = try await aws.request(
|
|
service: "cloudfront",
|
|
region: cfRegion,
|
|
method: "POST",
|
|
path: "/2020-05-31/distribution",
|
|
body: Data(xml.utf8),
|
|
contentType: "application/xml"
|
|
)
|
|
|
|
let responseXml = try AWSXMLParser().parse(data: data)
|
|
guard let id = responseXml.descendant("Id")?.text,
|
|
let domainName = responseXml.descendant("DomainName")?.text else {
|
|
throw AWSError.invalidXML("Failed to parse CreateDistribution response")
|
|
}
|
|
|
|
return CloudFrontDistribution(
|
|
id: id, domainName: domainName, status: "InProgress",
|
|
enabled: true, aliases: aliases, origins: [s3Origin]
|
|
)
|
|
}
|
|
|
|
/// Create a cache invalidation (clear cached content)
|
|
func createInvalidation(distributionId: String, paths: [String] = ["/*"]) async throws -> String {
|
|
let callerRef = UUID().uuidString
|
|
let pathItems = paths.map { "<Path>\($0)</Path>" }.joined()
|
|
|
|
let xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
<CallerReference>\(callerRef)</CallerReference>
|
|
<Paths>
|
|
<Quantity>\(paths.count)</Quantity>
|
|
<Items>\(pathItems)</Items>
|
|
</Paths>
|
|
</InvalidationBatch>
|
|
"""
|
|
|
|
let (data, _) = try await aws.request(
|
|
service: "cloudfront",
|
|
region: cfRegion,
|
|
method: "POST",
|
|
path: "/2020-05-31/distribution/\(distributionId)/invalidation",
|
|
body: Data(xml.utf8),
|
|
contentType: "application/xml"
|
|
)
|
|
|
|
let responseXml = try AWSXMLParser().parse(data: data)
|
|
return responseXml.descendant("Id")?.text ?? "unknown"
|
|
}
|
|
|
|
/// Disable a distribution (must disable before deleting)
|
|
func disableDistribution(id: String) async throws {
|
|
// Get current config with ETag
|
|
let (getData, _) = try await aws.request(
|
|
service: "cloudfront",
|
|
region: cfRegion,
|
|
method: "GET",
|
|
path: "/2020-05-31/distribution/\(id)/config"
|
|
)
|
|
// Would need ETag from response headers — simplified for now
|
|
// Full implementation would parse ETag and send PUT with Enabled=false
|
|
_ = getData // Placeholder
|
|
}
|
|
|
|
// MARK: - ACM Certificates
|
|
|
|
/// List ACM certificates (us-east-1 only for CloudFront)
|
|
func listCertificates() async throws -> [ACMCertificate] {
|
|
let (data, _) = try await aws.request(
|
|
service: "acm",
|
|
region: cfRegion,
|
|
method: "POST",
|
|
headers: ["x-amz-target": "CertificateManager.ListCertificates"],
|
|
body: Data("{}".utf8),
|
|
contentType: "application/x-amz-json-1.1"
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let list = json["CertificateSummaryList"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return list.compactMap { item -> ACMCertificate? in
|
|
guard let arn = item["CertificateArn"] as? String,
|
|
let domain = item["DomainName"] as? String else { return nil }
|
|
let status = item["Status"] as? String ?? "UNKNOWN"
|
|
return ACMCertificate(arn: arn, domainName: domain, status: status)
|
|
}
|
|
}
|
|
|
|
/// Request a free SSL certificate from ACM
|
|
func requestCertificate(domain: String, subjectAlternativeNames: [String] = []) async throws -> String {
|
|
var requestBody: [String: Any] = [
|
|
"DomainName": domain,
|
|
"ValidationMethod": "DNS",
|
|
]
|
|
if !subjectAlternativeNames.isEmpty {
|
|
requestBody["SubjectAlternativeNames"] = subjectAlternativeNames
|
|
}
|
|
|
|
let bodyData = try JSONSerialization.data(withJSONObject: requestBody)
|
|
|
|
let (data, _) = try await aws.request(
|
|
service: "acm",
|
|
region: cfRegion,
|
|
method: "POST",
|
|
headers: ["x-amz-target": "CertificateManager.RequestCertificate"],
|
|
body: bodyData,
|
|
contentType: "application/x-amz-json-1.1"
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let arn = json["CertificateArn"] as? String else {
|
|
throw AWSError.invalidXML("Failed to parse RequestCertificate response")
|
|
}
|
|
return arn
|
|
}
|
|
|
|
/// Get certificate validation records (to add in DNS for verification)
|
|
func getCertificateValidation(arn: String) async throws -> [CertificateValidationRecord] {
|
|
let requestBody = try JSONSerialization.data(withJSONObject: [
|
|
"CertificateArn": arn,
|
|
])
|
|
|
|
let (data, _) = try await aws.request(
|
|
service: "acm",
|
|
region: cfRegion,
|
|
method: "POST",
|
|
headers: ["x-amz-target": "CertificateManager.DescribeCertificate"],
|
|
body: requestBody,
|
|
contentType: "application/x-amz-json-1.1"
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let cert = json["Certificate"] as? [String: Any],
|
|
let validations = cert["DomainValidationOptions"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return validations.compactMap { v -> CertificateValidationRecord? in
|
|
guard let domain = v["DomainName"] as? String,
|
|
let resourceRecord = v["ResourceRecord"] as? [String: Any],
|
|
let name = resourceRecord["Name"] as? String,
|
|
let value = resourceRecord["Value"] as? String,
|
|
let type = resourceRecord["Type"] as? String else { return nil }
|
|
return CertificateValidationRecord(domain: domain, recordName: name, recordValue: value, recordType: type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CloudFront Models
|
|
|
|
struct CloudFrontDistribution: Sendable {
|
|
let id: String
|
|
let domainName: String
|
|
let status: String
|
|
let enabled: Bool
|
|
let aliases: [String]
|
|
let origins: [String]
|
|
}
|
|
|
|
struct ACMCertificate: Sendable {
|
|
let arn: String
|
|
let domainName: String
|
|
let status: String // ISSUED, PENDING_VALIDATION, etc.
|
|
}
|
|
|
|
struct CertificateValidationRecord: Sendable {
|
|
let domain: String
|
|
let recordName: String
|
|
let recordValue: String
|
|
let recordType: String
|
|
}
|