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)
364 lines
12 KiB
Swift
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
|
|
}
|