Files
CxIDE/Services/AWSS3Service.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

364 lines
12 KiB
Swift

// AWSS3Service.swift
// CxIDE AWS S3 integration for static website hosting, file storage, and deployment
//
// Provides bucket management, object operations, and static website hosting configuration.
// Uses AWSService core for SigV4-authenticated requests.
import Foundation
// MARK: - S3 Service
final class AWSS3Service: @unchecked Sendable {
static let shared = AWSS3Service()
private let aws: AWSService
init(aws: AWSService = .shared) {
self.aws = aws
}
// MARK: - Bucket Operations
/// List all S3 buckets
func listBuckets() async throws -> [S3Bucket] {
let (data, _) = try await aws.s3Request(method: "GET")
let xml = try AWSXMLParser().parse(data: data)
let bucketElements = xml.allDescendants("Bucket")
return bucketElements.compactMap { el -> S3Bucket? in
guard let name = el.child("Name")?.text else { return nil }
let dateStr = el.child("CreationDate")?.text
return S3Bucket(name: name, creationDate: dateStr)
}
}
/// Create a new S3 bucket
func createBucket(name: String, region: String? = nil) async throws {
let region = region ?? aws.defaultRegion
var body: Data? = nil
// LocationConstraint needed for all regions except us-east-1
if region != "us-east-1" {
let xml = """
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<LocationConstraint>\(region)</LocationConstraint>
</CreateBucketConfiguration>
"""
body = Data(xml.utf8)
}
let (_, statusCode) = try await aws.s3Request(
bucket: name,
region: region,
method: "PUT",
body: body,
contentType: "application/xml"
)
if statusCode == 409 {
throw AWSError.bucketAlreadyExists(name)
}
}
/// Delete an S3 bucket (must be empty)
func deleteBucket(name: String, region: String? = nil) async throws {
_ = try await aws.s3Request(
bucket: name,
region: region,
method: "DELETE"
)
}
/// Get bucket location
func getBucketLocation(name: String) async throws -> String {
let (data, _) = try await aws.s3Request(
bucket: name,
query: ["location": ""],
contentType: "application/xml"
)
let xml = try AWSXMLParser().parse(data: data)
let location = xml.text
return location.isEmpty ? "us-east-1" : location
}
// MARK: - Object Operations
/// List objects in a bucket with optional prefix
func listObjects(bucket: String, prefix: String? = nil, maxKeys: Int = 1000, region: String? = nil) async throws -> [S3Object] {
var query: [String: String] = ["list-type": "2", "max-keys": "\(maxKeys)"]
if let prefix = prefix {
query["prefix"] = prefix
}
let (data, _) = try await aws.s3Request(
bucket: bucket,
region: region,
method: "GET",
query: query,
contentType: "application/xml"
)
let xml = try AWSXMLParser().parse(data: data)
let contents = xml.allDescendants("Contents")
return contents.compactMap { el -> S3Object? in
guard let key = el.child("Key")?.text else { return nil }
let size = Int(el.child("Size")?.text ?? "0") ?? 0
let lastModified = el.child("LastModified")?.text
let etag = el.child("ETag")?.text.replacingOccurrences(of: "\"", with: "")
return S3Object(key: key, size: size, lastModified: lastModified, etag: etag)
}
}
/// Upload a file/data to S3
func putObject(bucket: String, key: String, data: Data, contentType: String? = nil, region: String? = nil) async throws {
let mime = contentType ?? mimeType(for: key)
_ = try await aws.s3Request(
bucket: bucket,
key: key,
region: region,
method: "PUT",
body: data,
contentType: mime
)
}
/// Download an object from S3
func getObject(bucket: String, key: String, region: String? = nil) async throws -> Data {
let (data, _) = try await aws.s3Request(
bucket: bucket,
key: key,
region: region,
method: "GET"
)
return data
}
/// Delete an object from S3
func deleteObject(bucket: String, key: String, region: String? = nil) async throws {
_ = try await aws.s3Request(
bucket: bucket,
key: key,
region: region,
method: "DELETE"
)
}
/// Check if an object exists (HEAD request)
func objectExists(bucket: String, key: String, region: String? = nil) async throws -> Bool {
do {
_ = try await aws.s3Request(
bucket: bucket,
key: key,
region: region,
method: "HEAD"
)
return true
} catch AWSError.apiError(let code, _) where code == 404 {
return false
}
}
// MARK: - Static Website Hosting
/// Enable static website hosting on a bucket
func enableWebsiteHosting(bucket: String, indexDoc: String = "index.html", errorDoc: String = "error.html", region: String? = nil) async throws {
let xml = """
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<IndexDocument>
<Suffix>\(indexDoc)</Suffix>
</IndexDocument>
<ErrorDocument>
<Key>\(errorDoc)</Key>
</ErrorDocument>
</WebsiteConfiguration>
"""
_ = try await aws.s3Request(
bucket: bucket,
region: region,
method: "PUT",
query: ["website": ""],
body: Data(xml.utf8),
contentType: "application/xml"
)
}
/// Disable website hosting
func disableWebsiteHosting(bucket: String, region: String? = nil) async throws {
_ = try await aws.s3Request(
bucket: bucket,
region: region,
method: "DELETE",
query: ["website": ""]
)
}
/// Set public read bucket policy for website hosting
func setPublicReadPolicy(bucket: String, region: String? = nil) async throws {
let policy = """
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::\(bucket)/*"
}]
}
"""
_ = try await aws.s3Request(
bucket: bucket,
region: region,
method: "PUT",
query: ["policy": ""],
body: Data(policy.utf8),
contentType: "application/json"
)
}
/// Disable the "Block Public Access" settings for a bucket (required for website hosting)
func disableBlockPublicAccess(bucket: String, region: String? = nil) async throws {
_ = try await aws.s3Request(
bucket: bucket,
region: region,
method: "DELETE",
query: ["publicAccessBlock": ""]
)
}
/// Get the website endpoint URL for a bucket
func websiteEndpoint(bucket: String, region: String? = nil) -> String {
let region = region ?? aws.defaultRegion
return "http://\(bucket).s3-website-\(region).amazonaws.com"
}
// MARK: - Deploy Website
/// Deploy a local directory to S3 as a static website
func deployWebsite(
directory: URL,
bucket: String,
region: String? = nil,
enableHosting: Bool = true,
setPublicAccess: Bool = true
) async throws -> S3DeployResult {
let fm = FileManager.default
var uploadedFiles: [String] = []
var errors: [String] = []
let start = Date()
// Check if bucket exists, create if not
do {
_ = try await listObjects(bucket: bucket, maxKeys: 1, region: region)
} catch {
try await createBucket(name: bucket, region: region)
// Brief pause for bucket propagation
try await Task.sleep(nanoseconds: 2_000_000_000)
}
if setPublicAccess {
try await disableBlockPublicAccess(bucket: bucket, region: region)
try await setPublicReadPolicy(bucket: bucket, region: region)
}
if enableHosting {
try await enableWebsiteHosting(bucket: bucket, region: region)
}
// Upload all files recursively
let enumerator = fm.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey])
while let fileURL = enumerator?.nextObject() as? URL {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]),
resourceValues.isRegularFile == true else { continue }
// Skip hidden files
let relativePath = fileURL.path.replacingOccurrences(of: directory.path + "/", with: "")
if relativePath.hasPrefix(".") { continue }
do {
let fileData = try Data(contentsOf: fileURL)
try await putObject(bucket: bucket, key: relativePath, data: fileData, region: region)
uploadedFiles.append(relativePath)
} catch {
errors.append("\(relativePath): \(error.localizedDescription)")
}
}
let endpoint = enableHosting ? websiteEndpoint(bucket: bucket, region: region) : nil
let duration = Date().timeIntervalSince(start)
return S3DeployResult(
success: errors.isEmpty,
bucket: bucket,
region: region ?? aws.defaultRegion,
uploadedFiles: uploadedFiles,
errors: errors,
websiteURL: endpoint,
duration: duration
)
}
// MARK: - MIME Type
private func mimeType(for path: String) -> String {
let ext = (path as NSString).pathExtension.lowercased()
switch ext {
case "html", "htm": return "text/html"
case "css": return "text/css"
case "js": return "application/javascript"
case "json": return "application/json"
case "xml": return "application/xml"
case "txt": return "text/plain"
case "md": return "text/markdown"
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "svg": return "image/svg+xml"
case "ico": return "image/x-icon"
case "webp": return "image/webp"
case "woff": return "font/woff"
case "woff2": return "font/woff2"
case "ttf": return "font/ttf"
case "eot": return "application/vnd.ms-fontobject"
case "pdf": return "application/pdf"
case "zip": return "application/zip"
case "mp4": return "video/mp4"
case "webm": return "video/webm"
case "mp3": return "audio/mpeg"
case "wasm": return "application/wasm"
default: return "application/octet-stream"
}
}
}
// MARK: - S3 Models
struct S3Bucket: Sendable {
let name: String
let creationDate: String?
}
struct S3Object: Sendable {
let key: String
let size: Int
let lastModified: String?
let etag: String?
var formattedSize: String {
if size < 1024 { return "\(size) B" }
else if size < 1024 * 1024 { return String(format: "%.1f KB", Double(size) / 1024) }
else if size < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(size) / 1024 / 1024) }
else { return String(format: "%.1f GB", Double(size) / 1024 / 1024 / 1024) }
}
}
struct S3DeployResult: Sendable {
let success: Bool
let bucket: String
let region: String
let uploadedFiles: [String]
let errors: [String]
let websiteURL: String?
let duration: TimeInterval
}