diff --git a/Agent/Tools/AWSTools.swift b/Agent/Tools/AWSTools.swift new file mode 100644 index 0000000..38529c6 --- /dev/null +++ b/Agent/Tools/AWSTools.swift @@ -0,0 +1,687 @@ +// AWSTools.swift +// CxIDE Agent — AWS integration tools for S3, CloudFront, Route 53, Lambda, and ACM +// +// 14 tools: aws_configure, aws_s3_list, aws_s3_upload, aws_s3_deploy, +// aws_s3_download, aws_s3_delete, aws_cloudfront_list, +// aws_cloudfront_create, aws_cloudfront_invalidate, +// aws_route53_zones, aws_route53_records, aws_route53_set, +// aws_lambda_list, aws_lambda_invoke + +import Foundation + +enum AWSTools { + static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) { + + // ── aws_configure ───────────────────────────────────────────── + server.registerTool( + "aws_configure", + description: "Configure AWS credentials and default region. Credentials are AES-256 encrypted locally.", + inputSchema: [ + "type": "object", + "required": ["action"], + "properties": [ + "action": ["type": "string", "description": "status, set, or set-region."], + "access_key_id": ["type": "string", "description": "AWS Access Key ID (for set)."], + "secret_access_key": ["type": "string", "description": "AWS Secret Access Key (for set)."], + "region": ["type": "string", "description": "AWS region (default: us-east-1)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let action = args["action"] as? String ?? "status" + let store = CredentialStore.shared + let wsDir = URL(fileURLWithPath: config.workspaceRoot) + try? store.load(from: wsDir) + + switch action.lowercased() { + case "status": + let aws = AWSService.shared + let hasKey = aws.accessKeyId != nil + let hasSecret = aws.secretAccessKey != nil + let region = aws.defaultRegion + var output = "AWS Configuration:\n" + output += " Access Key: \(hasKey ? "✅ Configured (\(aws.accessKeyId?.prefix(8) ?? "")...)" : "❌ Not set")\n" + output += " Secret Key: \(hasSecret ? "✅ Configured" : "❌ Not set")\n" + output += " Region: \(region)\n" + output += " Authenticated: \(aws.isAuthenticated ? "✅ Yes" : "❌ No")\n" + return ok(output) + + case "set": + guard let keyId = args["access_key_id"] as? String, !keyId.isEmpty, + let secret = args["secret_access_key"] as? String, !secret.isEmpty else { + return err("access_key_id and secret_access_key required") + } + let region = args["region"] as? String ?? "us-east-1" + AWSService.shared.setCredentials(accessKeyId: keyId, secretAccessKey: secret, region: region) + do { + try store.save(to: wsDir) + return ok("AWS credentials saved (encrypted). Region: \(region)") + } catch { + return err("Failed to save: \(error.localizedDescription)") + } + + case "set-region": + let region = args["region"] as? String ?? "us-east-1" + store.set("AWS_DEFAULT_REGION", value: region) + do { + try store.save(to: wsDir) + return ok("AWS region set to: \(region)") + } catch { + return err("Failed to save: \(error.localizedDescription)") + } + + default: + return err("Unknown action '\(action)'. Available: status, set, set-region") + } + } + + // ── aws_s3_list ─────────────────────────────────────────────── + server.registerTool( + "aws_s3_list", + description: "List S3 buckets or objects in a bucket.", + inputSchema: [ + "type": "object", + "properties": [ + "bucket": ["type": "string", "description": "Bucket name (omit to list all buckets)."], + "prefix": ["type": "string", "description": "Object key prefix filter."], + "region": ["type": "string", "description": "AWS region."], + ] as [String: Any], + ] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { args in + let s3 = AWSS3Service.shared + do { + if let bucket = args["bucket"] as? String, !bucket.isEmpty { + let prefix = args["prefix"] as? String + let region = args["region"] as? String + let objects = try await s3.listObjects(bucket: bucket, prefix: prefix, region: region) + if objects.isEmpty { + return ok("Bucket '\(bucket)' is empty\(prefix != nil ? " (prefix: \(prefix!))" : "")") + } + var output = "Objects in s3://\(bucket)\(prefix != nil ? "/\(prefix!)" : "") (\(objects.count)):\n\n" + output += String(format: "%-50s %10s %s\n", "Key", "Size", "Last Modified") + output += String(repeating: "-", count: 85) + "\n" + for obj in objects { + output += String(format: "%-50s %10s %s\n", + String(obj.key.prefix(50)), obj.formattedSize, obj.lastModified ?? "") + } + return ok(output) + } else { + let buckets = try await s3.listBuckets() + if buckets.isEmpty { + return ok("No S3 buckets found in this account.") + } + var output = "S3 Buckets (\(buckets.count)):\n\n" + for b in buckets { + output += " • \(b.name)" + if let date = b.creationDate { output += " (created: \(date))" } + output += "\n" + } + return ok(output) + } + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_s3_upload ───────────────────────────────────────────── + server.registerTool( + "aws_s3_upload", + description: "Upload a file to S3.", + inputSchema: [ + "type": "object", + "required": ["bucket", "file"], + "properties": [ + "bucket": ["type": "string", "description": "S3 bucket name."], + "file": ["type": "string", "description": "Local file path."], + "key": ["type": "string", "description": "S3 object key (default: filename)."], + "region": ["type": "string", "description": "AWS region."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let bucket = args["bucket"] as? String, + let filePath = args["file"] as? String, + let resolved = config.resolvePath(filePath) else { + return err("bucket and file required") + } + + let fileURL = URL(fileURLWithPath: resolved) + let key = args["key"] as? String ?? fileURL.lastPathComponent + let region = args["region"] as? String + + do { + let data = try Data(contentsOf: fileURL) + try await AWSS3Service.shared.putObject(bucket: bucket, key: key, data: data, region: region) + return ok("Uploaded \(fileURL.lastPathComponent) → s3://\(bucket)/\(key) (\(data.count) bytes)") + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_s3_deploy ───────────────────────────────────────────── + server.registerTool( + "aws_s3_deploy", + description: "Deploy a website directory to S3 with static hosting enabled. Creates the bucket if needed.", + inputSchema: [ + "type": "object", + "required": ["directory", "bucket"], + "properties": [ + "directory": ["type": "string", "description": "Local website directory."], + "bucket": ["type": "string", "description": "S3 bucket name (will be created if needed)."], + "region": ["type": "string", "description": "AWS region (default: us-east-1)."], + "cloudfront": ["type": "boolean", "description": "Also create a CloudFront distribution (default: false)."], + "domain": ["type": "string", "description": "Custom domain (requires Route 53 hosted zone)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let dirPath = args["directory"] as? String, + let resolved = config.resolvePath(dirPath), + let bucket = args["bucket"] as? String else { + return err("directory and bucket required") + } + + let region = args["region"] as? String + let useCF = args["cloudfront"] as? Bool ?? false + let domain = args["domain"] as? String + + do { + let result = try await AWSS3Service.shared.deployWebsite( + directory: URL(fileURLWithPath: resolved), + bucket: bucket, + region: region + ) + + var output = "S3 Deployment \(result.success ? "Succeeded" : "Completed with errors")\n\n" + output += " Bucket: s3://\(result.bucket)\n" + output += " Region: \(result.region)\n" + output += " Files uploaded: \(result.uploadedFiles.count)\n" + output += " Duration: \(String(format: "%.1f", result.duration))s\n" + + if let url = result.websiteURL { + output += " Website URL: \(url)\n" + } + + if !result.errors.isEmpty { + output += "\n Errors:\n" + for e in result.errors { output += " • \(e)\n" } + } + + // Create CloudFront distribution if requested + if useCF { + let cf = AWSCloudFrontService.shared + let aliases = domain != nil ? [domain!] : [] + let dist = try await cf.createDistributionForS3( + bucket: bucket, region: result.region, aliases: aliases + ) + output += "\n CloudFront Distribution:\n" + output += " ID: \(dist.id)\n" + output += " Domain: https://\(dist.domainName)\n" + output += " Status: \(dist.status) (may take 5-10 min)\n" + + // Point Route 53 if domain specified + if let domain = domain { + let r53 = AWSRoute53Service.shared + if let zone = try await r53.findHostedZone(domain: domain) { + try await r53.pointToCloudFront( + zoneId: zone.id, domain: domain, + distributionDomain: dist.domainName + ) + output += " Route 53: \(domain) → \(dist.domainName)\n" + } + } + } + + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_s3_download ─────────────────────────────────────────── + server.registerTool( + "aws_s3_download", + description: "Download an object from S3.", + inputSchema: [ + "type": "object", + "required": ["bucket", "key"], + "properties": [ + "bucket": ["type": "string", "description": "S3 bucket name."], + "key": ["type": "string", "description": "S3 object key."], + "output": ["type": "string", "description": "Local output path (default: current directory + filename)."], + "region": ["type": "string", "description": "AWS region."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let bucket = args["bucket"] as? String, + let key = args["key"] as? String else { + return err("bucket and key required") + } + + let region = args["region"] as? String + let outputPath: String + if let out = args["output"] as? String, let resolved = config.resolvePath(out) { + outputPath = resolved + } else { + let filename = (key as NSString).lastPathComponent + outputPath = config.workspaceRoot + "/" + filename + } + + do { + let data = try await AWSS3Service.shared.getObject(bucket: bucket, key: key, region: region) + try data.write(to: URL(fileURLWithPath: outputPath)) + return ok("Downloaded s3://\(bucket)/\(key) → \(outputPath) (\(data.count) bytes)") + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_s3_delete ───────────────────────────────────────────── + server.registerTool( + "aws_s3_delete", + description: "Delete an object from S3.", + inputSchema: [ + "type": "object", + "required": ["bucket", "key"], + "properties": [ + "bucket": ["type": "string", "description": "S3 bucket name."], + "key": ["type": "string", "description": "S3 object key to delete."], + "region": ["type": "string", "description": "AWS region."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let bucket = args["bucket"] as? String, + let key = args["key"] as? String else { + return err("bucket and key required") + } + do { + try await AWSS3Service.shared.deleteObject(bucket: bucket, key: key, region: args["region"] as? String) + return ok("Deleted s3://\(bucket)/\(key)") + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_cloudfront_list ─────────────────────────────────────── + server.registerTool( + "aws_cloudfront_list", + description: "List CloudFront CDN distributions.", + inputSchema: ["type": "object", "properties": [:] as [String: Any]] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { _ in + do { + let distributions = try await AWSCloudFrontService.shared.listDistributions() + if distributions.isEmpty { + return ok("No CloudFront distributions found.") + } + var output = "CloudFront Distributions (\(distributions.count)):\n\n" + for d in distributions { + output += " \(d.id) — \(d.domainName)\n" + output += " Status: \(d.status) | Enabled: \(d.enabled)\n" + if !d.aliases.isEmpty { + output += " Aliases: \(d.aliases.joined(separator: ", "))\n" + } + output += " Origins: \(d.origins.joined(separator: ", "))\n\n" + } + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_cloudfront_create ───────────────────────────────────── + server.registerTool( + "aws_cloudfront_create", + description: "Create a CloudFront distribution for an S3 website bucket.", + inputSchema: [ + "type": "object", + "required": ["bucket"], + "properties": [ + "bucket": ["type": "string", "description": "S3 bucket name (must have website hosting enabled)."], + "region": ["type": "string", "description": "S3 bucket region."], + "domains": ["type": "array", "items": ["type": "string"], "description": "Custom domain names (CNAMEs)."], + "certificate_arn": ["type": "string", "description": "ACM certificate ARN for HTTPS."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let bucket = args["bucket"] as? String else { + return err("bucket required") + } + let region = args["region"] as? String ?? AWSService.shared.defaultRegion + let domains = args["domains"] as? [String] ?? [] + let certArn = args["certificate_arn"] as? String + + do { + let dist = try await AWSCloudFrontService.shared.createDistributionForS3( + bucket: bucket, region: region, + aliases: domains, certificateArn: certArn + ) + var output = "CloudFront Distribution Created:\n\n" + output += " ID: \(dist.id)\n" + output += " Domain: https://\(dist.domainName)\n" + output += " Status: \(dist.status)\n" + if !domains.isEmpty { + output += " CNAMEs: \(domains.joined(separator: ", "))\n" + } + output += "\n Note: Distribution may take 5-10 minutes to deploy globally." + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_cloudfront_invalidate ───────────────────────────────── + server.registerTool( + "aws_cloudfront_invalidate", + description: "Invalidate CloudFront cache (clear cached content after updates).", + inputSchema: [ + "type": "object", + "required": ["distribution_id"], + "properties": [ + "distribution_id": ["type": "string", "description": "CloudFront distribution ID."], + "paths": ["type": "array", "items": ["type": "string"], "description": "Paths to invalidate (default: [/*])."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let distId = args["distribution_id"] as? String else { + return err("distribution_id required") + } + let paths = args["paths"] as? [String] ?? ["/*"] + do { + let invalidationId = try await AWSCloudFrontService.shared.createInvalidation( + distributionId: distId, paths: paths + ) + return ok("Cache invalidation created (ID: \(invalidationId))\nPaths: \(paths.joined(separator: ", "))") + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_route53_zones ───────────────────────────────────────── + server.registerTool( + "aws_route53_zones", + description: "List or create Route 53 hosted zones.", + inputSchema: [ + "type": "object", + "required": ["action"], + "properties": [ + "action": ["type": "string", "description": "list or create."], + "domain": ["type": "string", "description": "Domain name (for create)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let action = args["action"] as? String ?? "list" + let r53 = AWSRoute53Service.shared + do { + if action == "create" { + guard let domain = args["domain"] as? String, !domain.isEmpty else { + return err("domain required for create") + } + let zone = try await r53.createHostedZone(domain: domain) + var output = "Hosted Zone Created:\n\n" + output += " Zone ID: \(zone.id)\n" + output += " Domain: \(zone.domainName)\n" + if !zone.nameservers.isEmpty { + output += " Nameservers (set these at your registrar):\n" + for ns in zone.nameservers { + output += " • \(ns)\n" + } + } + return ok(output) + } else { + let zones = try await r53.listHostedZones() + if zones.isEmpty { + return ok("No Route 53 hosted zones found.") + } + var output = "Route 53 Hosted Zones (\(zones.count)):\n\n" + for z in zones { + output += " \(z.id) — \(z.domainName)\n" + output += " Records: \(z.recordCount) | Private: \(z.isPrivate)\n" + if let comment = z.comment { output += " Comment: \(comment)\n" } + output += "\n" + } + return ok(output) + } + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_route53_records ─────────────────────────────────────── + server.registerTool( + "aws_route53_records", + description: "List DNS records in a Route 53 hosted zone.", + inputSchema: [ + "type": "object", + "required": ["zone_id"], + "properties": [ + "zone_id": ["type": "string", "description": "Hosted zone ID."], + ] as [String: Any], + ] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { args in + guard let zoneId = args["zone_id"] as? String else { + return err("zone_id required") + } + do { + let records = try await AWSRoute53Service.shared.listRecordSets(zoneId: zoneId) + if records.isEmpty { + return ok("No records in zone \(zoneId)") + } + var output = "DNS Records (Zone: \(zoneId)):\n\n" + output += String(format: "%-8s %-35s %-10s %s\n", "Type", "Name", "TTL", "Value(s)") + output += String(repeating: "-", count: 90) + "\n" + for r in records { + let vals: String + if let alias = r.aliasTarget { + vals = "ALIAS → \(alias.dnsName)" + } else { + vals = r.values.joined(separator: ", ") + } + output += String(format: "%-8s %-35s %-10d %s\n", + r.type, String(r.name.prefix(35)), r.ttl, vals) + } + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_route53_set ─────────────────────────────────────────── + server.registerTool( + "aws_route53_set", + description: "Create or update a DNS record in Route 53.", + inputSchema: [ + "type": "object", + "required": ["zone_id", "name", "type", "values"], + "properties": [ + "zone_id": ["type": "string", "description": "Hosted zone ID."], + "name": ["type": "string", "description": "Record name (e.g., example.com or sub.example.com)."], + "type": ["type": "string", "description": "Record type: A, AAAA, CNAME, MX, TXT, NS."], + "values": ["type": "array", "items": ["type": "string"], "description": "Record values."], + "ttl": ["type": "integer", "description": "TTL in seconds (default: 300)."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let zoneId = args["zone_id"] as? String, + let name = args["name"] as? String, + let type = args["type"] as? String, + let values = args["values"] as? [String] else { + return err("zone_id, name, type, and values required") + } + let ttl = args["ttl"] as? Int ?? 300 + do { + try await AWSRoute53Service.shared.upsertRecord( + zoneId: zoneId, name: name, type: type, + values: values, ttl: ttl + ) + return ok("Set \(type) record: \(name) → \(values.joined(separator: ", ")) (TTL: \(ttl))") + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_lambda_list ─────────────────────────────────────────── + server.registerTool( + "aws_lambda_list", + description: "List Lambda functions or get function details.", + inputSchema: [ + "type": "object", + "properties": [ + "name": ["type": "string", "description": "Function name (omit to list all)."], + "region": ["type": "string", "description": "AWS region."], + ] as [String: Any], + ] as [String: Any], + annotations: ToolAnnotations(readOnlyHint: true) + ) { args in + let lambda = AWSLambdaService.shared + let region = args["region"] as? String + do { + if let name = args["name"] as? String, !name.isEmpty { + let f = try await lambda.getFunction(name: name, region: region) + var output = "Lambda Function: \(f.name)\n\n" + output += " ARN: \(f.arn)\n" + output += " Runtime: \(f.runtime)\n" + output += " Handler: \(f.handler)\n" + output += " Memory: \(f.memorySize) MB\n" + output += " Timeout: \(f.timeout)s\n" + output += " Code Size: \(f.formattedCodeSize)\n" + if let desc = f.description, !desc.isEmpty { output += " Description: \(desc)\n" } + if let mod = f.lastModified { output += " Last Modified: \(mod)\n" } + return ok(output) + } else { + let functions = try await lambda.listFunctions(region: region) + if functions.isEmpty { + return ok("No Lambda functions found.") + } + var output = "Lambda Functions (\(functions.count)):\n\n" + for f in functions { + output += " \(f.name) — \(f.runtime) | \(f.memorySize)MB | \(f.formattedCodeSize)\n" + } + return ok(output) + } + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_lambda_invoke ───────────────────────────────────────── + server.registerTool( + "aws_lambda_invoke", + description: "Invoke a Lambda function with optional JSON payload.", + inputSchema: [ + "type": "object", + "required": ["name"], + "properties": [ + "name": ["type": "string", "description": "Lambda function name."], + "payload": ["type": "object", "description": "JSON payload to send."], + "async": ["type": "boolean", "description": "Invoke asynchronously (default: false)."], + "region": ["type": "string", "description": "AWS region."], + ] as [String: Any], + ] as [String: Any] + ) { args in + guard let name = args["name"] as? String else { + return err("Function name required") + } + let payload = args["payload"] as? [String: Any] + let isAsync = args["async"] as? Bool ?? false + let region = args["region"] as? String + + do { + let result = try await AWSLambdaService.shared.invoke( + name: name, payload: payload, + async: isAsync, region: region + ) + var output = "Lambda Invocation Result:\n\n" + output += " Status: \(result.statusCode)\n" + if isAsync { + output += " Mode: Async (fire-and-forget)\n" + } else { + output += " Response:\n\(result.payload)\n" + } + return ok(output) + } catch { + return err(error.localizedDescription) + } + } + + // ── aws_ssl_certificate ─────────────────────────────────────── + server.registerTool( + "aws_ssl_certificate", + description: "Manage free SSL certificates via AWS Certificate Manager (ACM).", + inputSchema: [ + "type": "object", + "required": ["action"], + "properties": [ + "action": ["type": "string", "description": "list, request, or validate."], + "domain": ["type": "string", "description": "Domain for certificate (for request)."], + "arn": ["type": "string", "description": "Certificate ARN (for validate)."], + "alt_names": ["type": "array", "items": ["type": "string"], "description": "Subject alternative names."], + ] as [String: Any], + ] as [String: Any] + ) { args in + let action = args["action"] as? String ?? "list" + let cf = AWSCloudFrontService.shared + + do { + switch action.lowercased() { + case "list": + let certs = try await cf.listCertificates() + if certs.isEmpty { + return ok("No ACM certificates found.") + } + var output = "ACM Certificates (\(certs.count)):\n\n" + for c in certs { + output += " \(c.domainName) — \(c.status)\n" + output += " ARN: \(c.arn)\n\n" + } + return ok(output) + + case "request": + guard let domain = args["domain"] as? String, !domain.isEmpty else { + return err("domain required") + } + let altNames = args["alt_names"] as? [String] ?? [] + let arn = try await cf.requestCertificate(domain: domain, subjectAlternativeNames: altNames) + var output = "SSL Certificate Requested:\n\n" + output += " Domain: \(domain)\n" + output += " ARN: \(arn)\n" + output += " Status: PENDING_VALIDATION\n\n" + output += " Next: Use action='validate' with arn to get DNS validation records." + return ok(output) + + case "validate": + guard let arn = args["arn"] as? String, !arn.isEmpty else { + return err("arn required") + } + let records = try await cf.getCertificateValidation(arn: arn) + if records.isEmpty { + return ok("No validation records found (certificate may already be validated)") + } + var output = "Certificate Validation Records:\n\n" + output += "Add these CNAME records to your DNS:\n\n" + for r in records { + output += " Domain: \(r.domain)\n" + output += " Name: \(r.recordName)\n" + output += " Value: \(r.recordValue)\n" + output += " Type: \(r.recordType)\n\n" + } + return ok(output) + + default: + return err("Unknown action '\(action)'. Available: list, request, validate") + } + } catch { + return err(error.localizedDescription) + } + } + } + + // MARK: - Helpers + + private static func ok(_ text: String) -> [[String: Any]] { + [["type": "text", "text": text]] + } + + private static func err(_ message: String) -> [[String: Any]] { + [["type": "text", "text": "Error: \(message)"]] + } +} diff --git a/CxIDE.xcodeproj/project.pbxproj b/CxIDE.xcodeproj/project.pbxproj index 4a1839a..b3beafc 100644 --- a/CxIDE.xcodeproj/project.pbxproj +++ b/CxIDE.xcodeproj/project.pbxproj @@ -48,10 +48,17 @@ B10092 /* WebsiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10092 /* WebsiteService.swift */; }; B10093 /* WebsiteDeployService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10093 /* WebsiteDeployService.swift */; }; B10094 /* WebsiteTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10094 /* WebsiteTools.swift */; }; + B10095 /* AWSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10095 /* AWSService.swift */; }; + B10096 /* AWSS3Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10096 /* AWSS3Service.swift */; }; + B10097 /* AWSRoute53Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10097 /* AWSRoute53Service.swift */; }; + B10098 /* AWSCloudFrontService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10098 /* AWSCloudFrontService.swift */; }; + B10099 /* AWSLambdaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10099 /* AWSLambdaService.swift */; }; + B10100 /* AWSTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10100 /* AWSTools.swift */; }; B10080 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10080 /* Assets.xcassets */; }; B20001 /* CxIDETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20001 /* CxIDETests.swift */; }; B20002 /* EditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20002 /* EditorViewModelTests.swift */; }; B20003 /* TerminalSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20003 /* TerminalSessionTests.swift */; }; + B20004 /* AWSAndWebsiteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20004 /* AWSAndWebsiteTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -108,11 +115,20 @@ A10092 /* WebsiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteService.swift; sourceTree = ""; }; A10093 /* WebsiteDeployService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteDeployService.swift; sourceTree = ""; }; A10094 /* WebsiteTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteTools.swift; sourceTree = ""; }; + A10095 /* AWSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSService.swift; sourceTree = ""; }; + A10096 /* AWSS3Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSS3Service.swift; sourceTree = ""; }; + A10097 /* AWSRoute53Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSRoute53Service.swift; sourceTree = ""; }; + A10098 /* AWSCloudFrontService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSCloudFrontService.swift; sourceTree = ""; }; + A10099 /* AWSLambdaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSLambdaService.swift; sourceTree = ""; }; + A10100 /* AWSTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSTools.swift; sourceTree = ""; }; A10080 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A10081 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A10082 /* CxIDE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CxIDE.entitlements; sourceTree = ""; }; A20001 /* CxIDETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CxIDETests.swift; sourceTree = ""; }; - A20002 /* EditorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorViewModelTests.swift; sourceTree = ""; }; A20003 /* TerminalSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSessionTests.swift; sourceTree = ""; }; A90001 /* CxIDE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CxIDE.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A20002 /* EditorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorViewModelTests.swift; sourceTree = ""; }; + A20003 /* TerminalSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSessionTests.swift; sourceTree = ""; }; + A20004 /* AWSAndWebsiteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AWSAndWebsiteTests.swift; sourceTree = ""; }; + A90001 /* CxIDE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CxIDE.app; sourceTree = BUILT_PRODUCTS_DIR; }; A90002 /* CxIDETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CxIDETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -209,8 +225,11 @@ A10090 /* CredentialStore.swift */, A10091 /* GoDaddyService.swift */, A10092 /* WebsiteService.swift */, - A10093 /* WebsiteDeployService.swift */, - ); + A10093 /* WebsiteDeployService.swift */, A10095 /* AWSService.swift */, + A10096 /* AWSS3Service.swift */, + A10097 /* AWSRoute53Service.swift */, + A10098 /* AWSCloudFrontService.swift */, + A10099 /* AWSLambdaService.swift */, ); path = Services; sourceTree = ""; }; @@ -237,8 +256,7 @@ A10075 /* XcodeTools.swift */, A10076 /* DiagnosticsTools.swift */, A10077 /* AutoPilotTools.swift */, - A10094 /* WebsiteTools.swift */, - ); + A10094 /* WebsiteTools.swift */, A10100 /* AWSTools.swift */, ); path = Tools; sourceTree = ""; }; @@ -256,7 +274,10 @@ isa = PBXGroup; children = ( A20001 /* CxIDETests.swift */, - A20002 /* EditorViewModelTests.swift */, A20003 /* TerminalSessionTests.swift */, ); + A20002 /* EditorViewModelTests.swift */, + A20003 /* TerminalSessionTests.swift */, + A20004 /* AWSAndWebsiteTests.swift */, + ); path = CxIDETests; sourceTree = ""; }; @@ -390,8 +411,12 @@ B10091 /* GoDaddyService.swift in Sources */, B10092 /* WebsiteService.swift in Sources */, B10093 /* WebsiteDeployService.swift in Sources */, - B10094 /* WebsiteTools.swift in Sources */, - ); + B10094 /* WebsiteTools.swift in Sources */, B10095 /* AWSService.swift in Sources */, + B10096 /* AWSS3Service.swift in Sources */, + B10097 /* AWSRoute53Service.swift in Sources */, + B10098 /* AWSCloudFrontService.swift in Sources */, + B10099 /* AWSLambdaService.swift in Sources */, + B10100 /* AWSTools.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; SP0002 /* Sources */ = { @@ -399,7 +424,10 @@ buildActionMask = 2147483647; files = ( B20001 /* CxIDETests.swift in Sources */, - B20002 /* EditorViewModelTests.swift in Sources */, B20003 /* TerminalSessionTests.swift in Sources */, ); + B20002 /* EditorViewModelTests.swift in Sources */, + B20003 /* TerminalSessionTests.swift in Sources */, + B20004 /* AWSAndWebsiteTests.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/CxIDETests/AWSAndWebsiteTests.swift b/CxIDETests/AWSAndWebsiteTests.swift new file mode 100644 index 0000000..0bacad9 --- /dev/null +++ b/CxIDETests/AWSAndWebsiteTests.swift @@ -0,0 +1,692 @@ +import XCTest +import CryptoKit +@testable import CxIDE + +// MARK: - Credential Store Tests + +final class CredentialStoreTests: XCTestCase { + + private var store: CredentialStore! + private var tempDir: URL! + + override func setUpWithError() throws { + store = CredentialStore() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cxide-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + func testSetAndGet() { + store.set("TEST_KEY", value: "hello") + XCTAssertEqual(store.get("TEST_KEY"), "hello") + } + + func testGetMissing() { + XCTAssertNil(store.get("NONEXISTENT")) + } + + func testRemove() { + store.set("KEY", value: "val") + XCTAssertTrue(store.has("KEY")) + store.remove("KEY") + XCTAssertFalse(store.has("KEY")) + XCTAssertNil(store.get("KEY")) + } + + func testKeys() { + store.set("B_KEY", value: "1") + store.set("A_KEY", value: "2") + let keys = store.keys() + XCTAssertEqual(keys, ["A_KEY", "B_KEY"]) // sorted + } + + func testHas() { + XCTAssertFalse(store.has("FOO")) + store.set("FOO", value: "bar") + XCTAssertTrue(store.has("FOO")) + } + + func testSaveAndLoad() throws { + store.set("AWS_KEY", value: "AKIAEXAMPLE") + store.set("AWS_SECRET", value: "secretValue123") + try store.save(to: tempDir) + + let store2 = CredentialStore() + try store2.load(from: tempDir) + XCTAssertEqual(store2.get("AWS_KEY"), "AKIAEXAMPLE") + XCTAssertEqual(store2.get("AWS_SECRET"), "secretValue123") + } + + func testLoadFromEmptyDir() throws { + let emptyDir = tempDir.appendingPathComponent("empty") + try FileManager.default.createDirectory(at: emptyDir, withIntermediateDirectories: true) + try store.load(from: emptyDir) // Should not throw + XCTAssertTrue(store.keys().isEmpty) + } + + func testOverwriteValue() { + store.set("KEY", value: "first") + store.set("KEY", value: "second") + XCTAssertEqual(store.get("KEY"), "second") + } + + func testDeleteStore() throws { + store.set("DATA", value: "val") + try store.save(to: tempDir) + try store.deleteStore(in: tempDir) + XCTAssertTrue(store.keys().isEmpty) + } + + func testGoDaddyConvenience() { + store.setGoDaddyOTE(key: "oteKey", secret: "oteSecret") + XCTAssertEqual(store.godaddyOTEKey, "oteKey") + XCTAssertEqual(store.godaddyOTESecret, "oteSecret") + + store.setGoDaddyProduction(key: "prodKey", secret: "prodSecret") + XCTAssertEqual(store.godaddyProdKey, "prodKey") + XCTAssertEqual(store.godaddyProdSecret, "prodSecret") + } + + func testAWSConvenience() { + store.setAWS(accessKeyId: "AKIA123", secretAccessKey: "secret456", region: "eu-west-1") + XCTAssertEqual(store.awsAccessKeyId, "AKIA123") + XCTAssertEqual(store.awsSecretAccessKey, "secret456") + XCTAssertEqual(store.awsDefaultRegion, "eu-west-1") + } + + func testAWSDefaultRegion() { + XCTAssertEqual(store.awsDefaultRegion, "us-east-1") // default when not set + } + + func testCredentialErrorDescriptions() { + let e1 = CredentialStore.CredentialError.encryptionFailed + XCTAssertNotNil(e1.errorDescription) + XCTAssertTrue(e1.errorDescription!.contains("encrypt")) + + let e2 = CredentialStore.CredentialError.decryptionFailed + XCTAssertTrue(e2.errorDescription!.contains("decrypt")) + + let e3 = CredentialStore.CredentialError.invalidFormat + XCTAssertTrue(e3.errorDescription!.contains("Invalid")) + } + + func testEncryptedFilePermissions() throws { + store.set("KEY", value: "value") + try store.save(to: tempDir) + + let fileURL = tempDir.appendingPathComponent(".cxide-credentials.enc") + let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let perms = attrs[.posixPermissions] as? Int + XCTAssertEqual(perms, 0o600) + } +} + +// MARK: - AWS XML Parser Tests + +final class AWSXMLParserTests: XCTestCase { + + func testParseSimpleElement() throws { + let xml = "hello" + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + XCTAssertEqual(root.name, "Root") + XCTAssertEqual(root.child("Name")?.text, "hello") + } + + func testParseNestedElements() throws { + let xml = """ + + + bucket-a + bucket-b + + + """ + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + let buckets = root.allDescendants("Bucket") + XCTAssertEqual(buckets.count, 2) + XCTAssertEqual(buckets[0].child("Name")?.text, "bucket-a") + XCTAssertEqual(buckets[1].child("Name")?.text, "bucket-b") + } + + func testDescendantSearch() throws { + let xml = """ + deep + """ + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + XCTAssertEqual(root.descendant("D")?.text, "deep") + XCTAssertNil(root.descendant("Missing")) + } + + func testAllDescendants() throws { + let xml = """ + + 1 + 2 + 3 + + """ + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + let values = root.allDescendants("Value") + XCTAssertEqual(values.count, 3) + XCTAssertEqual(values.map(\.text), ["1", "2", "3"]) + } + + func testChildVsDescendant() throws { + let xml = "directnested" + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + // child only finds direct children + XCTAssertEqual(root.child("B")?.text, "direct") + // allChildren only finds direct children + XCTAssertEqual(root.allChildren("B").count, 1) + // allDescendants finds all + XCTAssertEqual(root.allDescendants("B").count, 2) + } + + func testEmptyElement() throws { + let xml = "" + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + XCTAssertEqual(root.child("Empty")?.text, "") + } + + func testInvalidXMLThrows() { + let bad = "" + XCTAssertThrowsError(try AWSXMLParser().parse(data: Data(bad.utf8))) + } + + func testParseS3ListBucketsResponse() throws { + let xml = """ + + + owner123 + + + my-website + 2026-01-15T10:30:00.000Z + + + my-backups + 2026-03-20T14:00:00.000Z + + + + """ + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + let buckets = root.allDescendants("Bucket") + XCTAssertEqual(buckets.count, 2) + XCTAssertEqual(buckets[0].child("Name")?.text, "my-website") + XCTAssertEqual(buckets[1].child("Name")?.text, "my-backups") + } + + func testParseS3ListObjectsResponse() throws { + let xml = """ + + + my-site + + index.html + 2048 + 2026-04-01T12:00:00.000Z + "abc123" + + + css/style.css + 512 + 2026-04-01T12:00:01.000Z + "def456" + + + """ + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + let contents = root.allDescendants("Contents") + XCTAssertEqual(contents.count, 2) + XCTAssertEqual(contents[0].child("Key")?.text, "index.html") + XCTAssertEqual(contents[0].child("Size")?.text, "2048") + XCTAssertEqual(contents[1].child("Key")?.text, "css/style.css") + } + + func testParseRoute53HostedZonesResponse() throws { + let xml = """ + + + + + /hostedzone/Z1234ABC + example.com. + 8 + My domainfalse + + + + """ + let root = try AWSXMLParser().parse(data: Data(xml.utf8)) + let zones = root.allDescendants("HostedZone") + XCTAssertEqual(zones.count, 1) + XCTAssertEqual(zones[0].child("Id")?.text, "/hostedzone/Z1234ABC") + XCTAssertEqual(zones[0].child("Name")?.text, "example.com.") + XCTAssertEqual(zones[0].descendant("Comment")?.text, "My domain") + } +} + +// MARK: - AWS Error Tests + +final class AWSErrorTests: XCTestCase { + + func testAllErrorsHaveDescriptions() { + let errors: [AWSError] = [ + .notAuthenticated, + .invalidURL("https://bad"), + .invalidResponse, + .apiError(statusCode: 403, body: "Forbidden"), + .bucketNotFound("my-bucket"), + .objectNotFound("file.txt"), + .bucketAlreadyExists("existing"), + .accessDenied("s3://bucket"), + .invalidXML("parse error"), + .deploymentFailed("timeout"), + .distributionNotFound("EXYZ123"), + .hostedZoneNotFound("example.com"), + .functionNotFound("myFunc"), + .timeout, + ] + for error in errors { + XCTAssertNotNil(error.errorDescription, "\(error) should have a description") + XCTAssertFalse(error.errorDescription!.isEmpty) + } + } + + func testNotAuthenticatedMessage() { + let err = AWSError.notAuthenticated + XCTAssertTrue(err.errorDescription!.contains("credential")) + } + + func testApiErrorIncludesStatusCode() { + let err = AWSError.apiError(statusCode: 500, body: "Internal Server Error") + XCTAssertTrue(err.errorDescription!.contains("500")) + XCTAssertTrue(err.errorDescription!.contains("Internal Server Error")) + } + + func testApiErrorTruncatesLongBody() { + let longBody = String(repeating: "x", count: 1000) + let err = AWSError.apiError(statusCode: 400, body: longBody) + // Should truncate to 500 chars + XCTAssertTrue(err.errorDescription!.count < 600) + } +} + +// MARK: - AWS Service Credential Tests + +final class AWSServiceTests: XCTestCase { + + func testDefaultRegion() { + let store = CredentialStore() + let aws = AWSService(credentialStore: store) + XCTAssertEqual(aws.defaultRegion, "us-east-1") + } + + func testSetAndGetCredentials() { + let store = CredentialStore() + let aws = AWSService(credentialStore: store) + aws.setCredentials(accessKeyId: "AKIA123", secretAccessKey: "secret", region: "eu-west-1") + XCTAssertEqual(aws.accessKeyId, "AKIA123") + XCTAssertEqual(aws.secretAccessKey, "secret") + XCTAssertEqual(aws.defaultRegion, "eu-west-1") + } + + func testIsAuthenticated() { + let store = CredentialStore() + let aws = AWSService(credentialStore: store) + XCTAssertFalse(aws.isAuthenticated) + aws.setCredentials(accessKeyId: "AKIA123", secretAccessKey: "secret") + XCTAssertTrue(aws.isAuthenticated) + } + + func testIsNotAuthenticatedWithEmpty() { + let store = CredentialStore() + let aws = AWSService(credentialStore: store) + store.set("AWS_ACCESS_KEY_ID", value: "") + store.set("AWS_SECRET_ACCESS_KEY", value: "") + XCTAssertFalse(aws.isAuthenticated) + } + + func testSessionToken() { + let store = CredentialStore() + let aws = AWSService(credentialStore: store) + XCTAssertNil(aws.sessionToken) + store.set("AWS_SESSION_TOKEN", value: "tok123") + XCTAssertEqual(aws.sessionToken, "tok123") + } + + func testRegionEnum() { + XCTAssertEqual(AWSService.Region.usEast1.rawValue, "us-east-1") + XCTAssertEqual(AWSService.Region.euCentral1.rawValue, "eu-central-1") + XCTAssertEqual(AWSService.Region.allCases.count, 10) + } +} + +// MARK: - SHA256/Data Hex Extension Tests + +final class HexStringTests: XCTestCase { + + func testSHA256Hex() { + let hash = SHA256.hash(data: Data("hello".utf8)) + XCTAssertEqual(hash.hexString.count, 64) // SHA256 = 32 bytes = 64 hex chars + XCTAssertEqual(hash.hexString, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") + } + + func testDataHex() { + let data = Data([0x00, 0xFF, 0xAB, 0x12]) + XCTAssertEqual(data.hexString, "00ffab12") + } + + func testEmptyDataHex() { + XCTAssertEqual(Data().hexString, "") + } +} + +// MARK: - S3 Model Tests + +final class S3ModelTests: XCTestCase { + + func testS3ObjectFormattedSize() { + XCTAssertEqual(S3Object(key: "a", size: 500, lastModified: nil, etag: nil).formattedSize, "500 B") + XCTAssertTrue(S3Object(key: "b", size: 2048, lastModified: nil, etag: nil).formattedSize.contains("KB")) + XCTAssertTrue(S3Object(key: "c", size: 5_242_880, lastModified: nil, etag: nil).formattedSize.contains("MB")) + XCTAssertTrue(S3Object(key: "d", size: 2_147_483_648, lastModified: nil, etag: nil).formattedSize.contains("GB")) + } + + func testS3DeployResult() { + let result = S3DeployResult( + success: true, bucket: "test-bucket", region: "us-east-1", + uploadedFiles: ["index.html", "style.css"], errors: [], + websiteURL: "http://test-bucket.s3-website-us-east-1.amazonaws.com", duration: 2.5 + ) + XCTAssertTrue(result.success) + XCTAssertEqual(result.uploadedFiles.count, 2) + XCTAssertTrue(result.errors.isEmpty) + XCTAssertNotNil(result.websiteURL) + } + + func testS3BucketModel() { + let bucket = S3Bucket(name: "my-site", creationDate: "2026-01-01T00:00:00Z") + XCTAssertEqual(bucket.name, "my-site") + XCTAssertEqual(bucket.creationDate, "2026-01-01T00:00:00Z") + } +} + +// MARK: - Route 53 Model Tests + +final class Route53ModelTests: XCTestCase { + + func testHostedZoneDomainName() { + let zone = Route53HostedZone(id: "Z123", name: "example.com.", recordCount: 5, comment: nil, isPrivate: false) + XCTAssertEqual(zone.domainName, "example.com") // trailing dot removed + } + + func testHostedZoneNoDot() { + let zone = Route53HostedZone(id: "Z123", name: "example.com", recordCount: 5, comment: nil, isPrivate: false) + XCTAssertEqual(zone.domainName, "example.com") + } + + func testRecordSetIsAlias() { + let alias = Route53AliasTarget(hostedZoneId: "Z2FD", dnsName: "d123.cloudfront.net", evaluateTargetHealth: false) + let record = Route53RecordSet(name: "example.com.", type: "A", ttl: 300, values: [], aliasTarget: alias) + XCTAssertTrue(record.isAlias) + + let normal = Route53RecordSet(name: "example.com.", type: "A", ttl: 300, values: ["1.2.3.4"], aliasTarget: nil) + XCTAssertFalse(normal.isAlias) + } +} + +// MARK: - CloudFront Model Tests + +final class CloudFrontModelTests: XCTestCase { + + func testDistribution() { + let dist = CloudFrontDistribution( + id: "E1ABC", domainName: "d123.cloudfront.net", + status: "Deployed", enabled: true, + aliases: ["example.com"], origins: ["my-bucket.s3.amazonaws.com"] + ) + XCTAssertEqual(dist.id, "E1ABC") + XCTAssertTrue(dist.enabled) + XCTAssertEqual(dist.aliases, ["example.com"]) + } + + func testACMCertificate() { + let cert = ACMCertificate(arn: "arn:aws:acm:us-east-1:123:cert/abc", domainName: "example.com", status: "ISSUED") + XCTAssertTrue(cert.arn.hasPrefix("arn:aws:acm")) + XCTAssertEqual(cert.status, "ISSUED") + } + + func testCertificateValidationRecord() { + let rec = CertificateValidationRecord( + domain: "example.com", + recordName: "_abc.example.com.", + recordValue: "_xyz.acm-validation.aws.", + recordType: "CNAME" + ) + XCTAssertEqual(rec.recordType, "CNAME") + XCTAssertTrue(rec.recordName.hasPrefix("_")) + } +} + +// MARK: - Lambda Model Tests + +final class LambdaModelTests: XCTestCase { + + func testRuntimeRawValues() { + XCTAssertEqual(LambdaRuntime.nodejs20.rawValue, "nodejs20.x") + XCTAssertEqual(LambdaRuntime.python312.rawValue, "python3.12") + XCTAssertEqual(LambdaRuntime.dotnet8.rawValue, "dotnet8") + XCTAssertEqual(LambdaRuntime.provided2023.rawValue, "provided.al2023") + } + + func testRuntimeDisplayNames() { + XCTAssertEqual(LambdaRuntime.nodejs20.displayName, "Node.js 20.x") + XCTAssertEqual(LambdaRuntime.python312.displayName, "Python 3.12") + XCTAssertEqual(LambdaRuntime.java21.displayName, "Java 21") + } + + func testAllRuntimes() { + XCTAssertEqual(LambdaRuntime.allCases.count, 10) + } + + func testLambdaFunctionFormattedCodeSize() { + let small = LambdaFunction(name: "f", arn: "", runtime: "nodejs20.x", handler: "index.handler", + memorySize: 128, timeout: 3, lastModified: nil, codeSize: 500, description: nil) + XCTAssertEqual(small.formattedCodeSize, "500 B") + + let medium = LambdaFunction(name: "f", arn: "", runtime: "nodejs20.x", handler: "index.handler", + memorySize: 128, timeout: 3, lastModified: nil, codeSize: 5120, description: nil) + XCTAssertTrue(medium.formattedCodeSize.contains("KB")) + + let large = LambdaFunction(name: "f", arn: "", runtime: "nodejs20.x", handler: "index.handler", + memorySize: 128, timeout: 3, lastModified: nil, codeSize: 10_485_760, description: nil) + XCTAssertTrue(large.formattedCodeSize.contains("MB")) + } + + func testInvokeResult() { + let result = LambdaInvokeResult(statusCode: 200, payload: "{\"message\":\"ok\"}", parsedPayload: ["message": "ok"]) + XCTAssertEqual(result.statusCode, 200) + XCTAssertTrue(result.payload.contains("ok")) + } +} + +// MARK: - Website Template Tests + +@MainActor +final class WebsiteTemplateTests: XCTestCase { + + func testAllTemplatesExist() { + XCTAssertEqual(WebsiteTemplate.allTemplates.count, 6) + } + + func testTemplateIDs() { + let ids = WebsiteTemplate.allTemplates.map(\.id) + XCTAssertTrue(ids.contains("blank")) + XCTAssertTrue(ids.contains("landing")) + XCTAssertTrue(ids.contains("portfolio")) + XCTAssertTrue(ids.contains("blog")) + XCTAssertTrue(ids.contains("docs")) + XCTAssertTrue(ids.contains("dashboard")) + } + + func testBlankTemplateGeneratesFiles() { + let files = WebsiteTemplate.blank.generateFiles("TestSite") + let paths = files.map(\.path) + XCTAssertTrue(paths.contains("index.html")) + XCTAssertTrue(paths.contains("css/style.css")) + XCTAssertTrue(paths.contains("js/main.js")) + } + + func testBlankTemplateContainsProjectName() { + let files = WebsiteTemplate.blank.generateFiles("My Cool Site") + let html = files.first { $0.path == "index.html" }?.content ?? "" + XCTAssertTrue(html.contains("My Cool Site")) + } + + func testLandingTemplateFiles() { + let files = WebsiteTemplate.landingPage.generateFiles("LandingSite") + let paths = files.map(\.path) + XCTAssertTrue(paths.contains("index.html")) + XCTAssertTrue(paths.contains("css/style.css")) + XCTAssertTrue(paths.contains("js/main.js")) + XCTAssertGreaterThan(files.count, 3) + } + + func testPortfolioTemplateFiles() { + let files = WebsiteTemplate.portfolio.generateFiles("Portfolio") + let paths = files.map(\.path) + XCTAssertTrue(paths.contains("index.html")) + XCTAssertTrue(paths.contains("projects.html")) + } + + func testBlogTemplateFiles() { + let files = WebsiteTemplate.blog.generateFiles("Blog") + let paths = files.map(\.path) + XCTAssertTrue(paths.contains("index.html")) + XCTAssertTrue(paths.contains("post.html")) + } + + func testDocsTemplateFiles() { + let files = WebsiteTemplate.documentation.generateFiles("Docs") + let paths = files.map(\.path) + XCTAssertTrue(paths.contains("index.html")) + } + + func testDashboardTemplateFiles() { + let files = WebsiteTemplate.dashboard.generateFiles("Dashboard") + let paths = files.map(\.path) + XCTAssertTrue(paths.contains("index.html")) + } + + func testAllTemplatesProduceValidHTML() { + for template in WebsiteTemplate.allTemplates { + let files = template.generateFiles("TestProject") + let html = files.first { $0.path == "index.html" }?.content ?? "" + XCTAssertTrue(html.contains(""), + "\(template.id) should have closing html tag") + } + } + + func testTemplateCategories() { + for template in WebsiteTemplate.allTemplates { + XCTAssertFalse(template.category.isEmpty, "\(template.id) should have a category") + XCTAssertFalse(template.description.isEmpty, "\(template.id) should have a description") + } + } + + func testWebsiteProjectInit() { + let dir = URL(fileURLWithPath: "/tmp/test") + let project = WebsiteProject(name: "site", directory: dir, template: "blank", createdAt: Date()) + XCTAssertEqual(project.name, "site") + XCTAssertEqual(project.template, "blank") + XCTAssertNil(project.deployedURL) + XCTAssertNil(project.domain) + } +} + +// MARK: - Website Deploy Provider Tests + +final class WebsiteDeployProviderTests: XCTestCase { + + func testAllProviders() { + XCTAssertEqual(WebsiteDeployService.Provider.allCases.count, 5) + } + + func testProviderDescriptions() { + for provider in WebsiteDeployService.Provider.allCases { + XCTAssertFalse(provider.description.isEmpty) + } + } + + func testProviderCLIRequirements() { + XCTAssertEqual(WebsiteDeployService.Provider.netlify.requiresCLI, "netlify") + XCTAssertEqual(WebsiteDeployService.Provider.surge.requiresCLI, "surge") + XCTAssertEqual(WebsiteDeployService.Provider.vercel.requiresCLI, "vercel") + XCTAssertEqual(WebsiteDeployService.Provider.githubPages.requiresCLI, "git") + XCTAssertEqual(WebsiteDeployService.Provider.rsync.requiresCLI, "rsync") + } + + func testDeployResultModel() { + let result = WebsiteDeployService.DeployResult( + success: true, url: "https://my-site.netlify.app", + output: "Published", provider: .netlify, duration: 3.5 + ) + XCTAssertTrue(result.success) + XCTAssertEqual(result.url, "https://my-site.netlify.app") + XCTAssertEqual(result.provider, .netlify) + } +} + +// MARK: - GoDaddy Model Tests + +final class GoDaddyModelTests: XCTestCase { + + func testEnvironments() { + XCTAssertTrue(GoDaddyService.Environment.ote.baseURL.contains("ote")) + XCTAssertTrue(GoDaddyService.Environment.production.baseURL.contains("api.godaddy.com")) + XCTAssertEqual(GoDaddyService.Environment.allCases.count, 2) + } + + func testDNSRecordTypes() { + XCTAssertEqual(DNSRecordType.A.rawValue, "A") + XCTAssertEqual(DNSRecordType.CNAME.rawValue, "CNAME") + XCTAssertEqual(DNSRecordType.MX.rawValue, "MX") + XCTAssertEqual(DNSRecordType.TXT.rawValue, "TXT") + } + + func testGoDaddyErrorDescriptions() { + let errors: [GoDaddyError] = [ + .notAuthenticated, .unauthorized, .forbidden, + .notFound("example.com"), .rateLimited, .invalidURL("bad"), + .invalidResponse, + ] + for error in errors { + XCTAssertNotNil(error.errorDescription, "\(error) should have description") + } + } +} + +// MARK: - AWSS3 Website Endpoint Tests + +final class AWSS3WebsiteTests: XCTestCase { + + func testWebsiteEndpoint() { + let s3 = AWSS3Service() + let url = s3.websiteEndpoint(bucket: "my-site", region: "us-east-1") + XCTAssertEqual(url, "http://my-site.s3-website-us-east-1.amazonaws.com") + } + + func testWebsiteEndpointOtherRegion() { + let s3 = AWSS3Service() + let url = s3.websiteEndpoint(bucket: "eu-site", region: "eu-west-1") + XCTAssertEqual(url, "http://eu-site.s3-website-eu-west-1.amazonaws.com") + } +} diff --git a/CxSwiftAgent/Sources/CxSwiftAgent/main.swift b/CxSwiftAgent/Sources/CxSwiftAgent/main.swift index 0d6426c..c39a669 100644 --- a/CxSwiftAgent/Sources/CxSwiftAgent/main.swift +++ b/CxSwiftAgent/Sources/CxSwiftAgent/main.swift @@ -86,6 +86,7 @@ XcodeTools.register(on: server, config: config, memory: memory) DiagnosticsTools.register(on: server, config: config, memory: memory) AutoPilotTools.register(on: server, config: config, memory: memory) WebsiteTools.register(on: server, config: config, memory: memory) +AWSTools.register(on: server, config: config, memory: memory) // Register workspace root as an MCP root server.registerRoot(uri: "file://\(config.workspaceRoot)", name: "workspace") diff --git a/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift b/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift index 5288577..5da3473 100644 --- a/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift +++ b/CxSwiftAgent/Sources/CxSwiftAgentMain/main.swift @@ -77,6 +77,7 @@ XcodeTools.register(on: server, config: config, memory: memory) DiagnosticsTools.register(on: server, config: config, memory: memory) AutoPilotTools.register(on: server, config: config, memory: memory) WebsiteTools.register(on: server, config: config, memory: memory) +AWSTools.register(on: server, config: config, memory: memory) // Register workspace root as an MCP root server.registerRoot(uri: "file://\(config.workspaceRoot)", name: "workspace") diff --git a/Services/AWSCloudFrontService.swift b/Services/AWSCloudFrontService.swift new file mode 100644 index 0000000..48d0456 --- /dev/null +++ b/Services/AWSCloudFrontService.swift @@ -0,0 +1,329 @@ +// 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 { "\($0)" }.joined() + aliasXML = """ + + \(aliases.count) + \(items) + + """ + } else { + aliasXML = """ + + 0 + + """ + } + + // SSL config + let viewerCertXML: String + if let arn = certificateArn, !aliases.isEmpty { + viewerCertXML = """ + + \(arn) + sni-only + TLSv1.2_2021 + + """ + } else { + viewerCertXML = """ + + true + TLSv1.2_2021 + + """ + } + + let callerRef = UUID().uuidString + let xml = """ + + + \(callerRef) + \(comment) + \(aliasXML) + index.html + + 1 + + + \(originId) + \(s3Origin) + + 80 + 443 + http-only + + + + + + \(originId) + redirect-to-https + + 2 + + GET + HEAD + + + true + 658327ea-f89d-4fab-a63d-7e88639e58f6 + + false + none + + 0 + 86400 + 31536000 + + + 1 + + + 404 + /index.html + 200 + 300 + + + + \(viewerCertXML) + true + PriceClass_100 + http2and3 + true + + """ + + 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 { "\($0)" }.joined() + + let xml = """ + + + \(callerRef) + + \(paths.count) + \(pathItems) + + + """ + + 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 +} diff --git a/Services/AWSLambdaService.swift b/Services/AWSLambdaService.swift new file mode 100644 index 0000000..2460057 --- /dev/null +++ b/Services/AWSLambdaService.swift @@ -0,0 +1,370 @@ +// AWSLambdaService.swift +// CxIDE — AWS Lambda serverless function management +// +// Create, deploy, invoke, and manage Lambda functions. +// Supports Node.js and Python runtimes with zip deployment. + +import Foundation + +// MARK: - Lambda Service + +final class AWSLambdaService: @unchecked Sendable { + static let shared = AWSLambdaService() + + private let aws: AWSService + + init(aws: AWSService = .shared) { + self.aws = aws + } + + // MARK: - Functions + + /// List Lambda functions + func listFunctions(region: String? = nil) async throws -> [LambdaFunction] { + let (data, _) = try await aws.request( + service: "lambda", + region: region, + method: "GET", + path: "/2015-03-31/functions" + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let functions = json["Functions"] as? [[String: Any]] else { + return [] + } + + return functions.compactMap { f -> LambdaFunction? in + guard let name = f["FunctionName"] as? String, + let arn = f["FunctionArn"] as? String else { return nil } + return LambdaFunction( + name: name, + arn: arn, + runtime: f["Runtime"] as? String ?? "unknown", + handler: f["Handler"] as? String ?? "", + memorySize: f["MemorySize"] as? Int ?? 128, + timeout: f["Timeout"] as? Int ?? 3, + lastModified: f["LastModified"] as? String, + codeSize: f["CodeSize"] as? Int ?? 0, + description: f["Description"] as? String + ) + } + } + + /// Get function details + func getFunction(name: String, region: String? = nil) async throws -> LambdaFunction { + let (data, _) = try await aws.request( + service: "lambda", + region: region, + method: "GET", + path: "/2015-03-31/functions/\(name)" + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let config = json["Configuration"] as? [String: Any], + let funcName = config["FunctionName"] as? String, + let arn = config["FunctionArn"] as? String else { + throw AWSError.functionNotFound(name) + } + + return LambdaFunction( + name: funcName, + arn: arn, + runtime: config["Runtime"] as? String ?? "unknown", + handler: config["Handler"] as? String ?? "", + memorySize: config["MemorySize"] as? Int ?? 128, + timeout: config["Timeout"] as? Int ?? 3, + lastModified: config["LastModified"] as? String, + codeSize: config["CodeSize"] as? Int ?? 0, + description: config["Description"] as? String + ) + } + + /// Create a new Lambda function from a zip file + func createFunction( + name: String, + runtime: LambdaRuntime, + handler: String, + roleArn: String, + zipData: Data, + description: String = "", + memorySize: Int = 128, + timeout: Int = 30, + environment: [String: String] = [:], + region: String? = nil + ) async throws -> LambdaFunction { + var requestBody: [String: Any] = [ + "FunctionName": name, + "Runtime": runtime.rawValue, + "Handler": handler, + "Role": roleArn, + "Code": ["ZipFile": zipData.base64EncodedString()], + "Description": description, + "MemorySize": memorySize, + "Timeout": timeout, + ] + + if !environment.isEmpty { + requestBody["Environment"] = ["Variables": environment] + } + + let bodyData = try JSONSerialization.data(withJSONObject: requestBody) + + let (data, _) = try await aws.request( + service: "lambda", + region: region, + method: "POST", + path: "/2015-03-31/functions", + body: bodyData, + contentType: "application/json" + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let funcName = json["FunctionName"] as? String, + let arn = json["FunctionArn"] as? String else { + throw AWSError.invalidXML("Failed to parse CreateFunction response") + } + + return LambdaFunction( + name: funcName, arn: arn, + runtime: runtime.rawValue, handler: handler, + memorySize: memorySize, timeout: timeout, + lastModified: nil, codeSize: zipData.count, + description: description + ) + } + + /// Update function code with a new zip + func updateFunctionCode(name: String, zipData: Data, region: String? = nil) async throws { + let requestBody: [String: Any] = [ + "ZipFile": zipData.base64EncodedString(), + ] + let bodyData = try JSONSerialization.data(withJSONObject: requestBody) + + _ = try await aws.request( + service: "lambda", + region: region, + method: "PUT", + path: "/2015-03-31/functions/\(name)/code", + body: bodyData, + contentType: "application/json" + ) + } + + /// Update function configuration + func updateFunctionConfig( + name: String, + memorySize: Int? = nil, + timeout: Int? = nil, + environment: [String: String]? = nil, + description: String? = nil, + region: String? = nil + ) async throws { + var requestBody: [String: Any] = [:] + if let mem = memorySize { requestBody["MemorySize"] = mem } + if let t = timeout { requestBody["Timeout"] = t } + if let env = environment { requestBody["Environment"] = ["Variables": env] } + if let desc = description { requestBody["Description"] = desc } + + let bodyData = try JSONSerialization.data(withJSONObject: requestBody) + + _ = try await aws.request( + service: "lambda", + region: region, + method: "PUT", + path: "/2015-03-31/functions/\(name)/configuration", + body: bodyData, + contentType: "application/json" + ) + } + + /// Delete a Lambda function + func deleteFunction(name: String, region: String? = nil) async throws { + _ = try await aws.request( + service: "lambda", + region: region, + method: "DELETE", + path: "/2015-03-31/functions/\(name)" + ) + } + + /// Invoke a Lambda function + func invoke( + name: String, + payload: [String: Any]? = nil, + async invocationType: Bool = false, + region: String? = nil + ) async throws -> LambdaInvokeResult { + let bodyData: Data? + if let payload = payload { + bodyData = try JSONSerialization.data(withJSONObject: payload) + } else { + bodyData = nil + } + + var headers: [String: String] = [:] + if invocationType { + headers["X-Amz-Invocation-Type"] = "Event" + } + + let (data, statusCode) = try await aws.request( + service: "lambda", + region: region, + method: "POST", + path: "/2015-03-31/functions/\(name)/invocations", + headers: headers, + body: bodyData, + contentType: "application/json" + ) + + let responsePayload = String(data: data, encoding: .utf8) ?? "" + let parsedPayload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + + return LambdaInvokeResult( + statusCode: statusCode, + payload: responsePayload, + parsedPayload: parsedPayload + ) + } + + // MARK: - Function URL (simplified API Gateway) + + /// Create a function URL for direct HTTP access + func createFunctionURL(name: String, authType: String = "NONE", region: String? = nil) async throws -> String { + let requestBody: [String: Any] = [ + "AuthType": authType, + "Cors": [ + "AllowOrigins": ["*"], + "AllowMethods": ["*"], + "AllowHeaders": ["*"], + ] as [String: Any], + ] + let bodyData = try JSONSerialization.data(withJSONObject: requestBody) + + let (data, _) = try await aws.request( + service: "lambda", + region: region, + method: "POST", + path: "/2021-10-31/functions/\(name)/url", + body: bodyData, + contentType: "application/json" + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let url = json["FunctionUrl"] as? String else { + throw AWSError.invalidXML("Failed to parse CreateFunctionUrlConfig response") + } + return url + } + + // MARK: - Deploy from Directory + + /// Create a zip from a directory and deploy as a Lambda function + func deployFromDirectory( + directory: URL, + functionName: String, + runtime: LambdaRuntime, + handler: String, + roleArn: String, + region: String? = nil + ) async throws -> LambdaFunction { + // Create a zip file from the directory + let zipData = try createZip(from: directory) + + // Try to update existing function first + do { + _ = try await getFunction(name: functionName, region: region) + try await updateFunctionCode(name: functionName, zipData: zipData, region: region) + return try await getFunction(name: functionName, region: region) + } catch { + // Function doesn't exist, create it + return try await createFunction( + name: functionName, + runtime: runtime, + handler: handler, + roleArn: roleArn, + zipData: zipData, + region: region + ) + } + } + + /// Create a zip archive from a directory using the zip CLI + private func createZip(from directory: URL) throws -> Data { + let tempZip = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".zip") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.arguments = ["-r", "-9", tempZip.path, "."] + process.currentDirectoryURL = directory + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw AWSError.deploymentFailed("Failed to create zip archive") + } + + let data = try Data(contentsOf: tempZip) + try? FileManager.default.removeItem(at: tempZip) + return data + } +} + +// MARK: - Lambda Models + +enum LambdaRuntime: String, CaseIterable, Sendable { + case nodejs20 = "nodejs20.x" + case nodejs18 = "nodejs18.x" + case python312 = "python3.12" + case python311 = "python3.11" + case python310 = "python3.10" + case java21 = "java21" + case java17 = "java17" + case dotnet8 = "dotnet8" + case ruby33 = "ruby3.3" + case provided2023 = "provided.al2023" + + var displayName: String { + switch self { + case .nodejs20: return "Node.js 20.x" + case .nodejs18: return "Node.js 18.x" + case .python312: return "Python 3.12" + case .python311: return "Python 3.11" + case .python310: return "Python 3.10" + case .java21: return "Java 21" + case .java17: return "Java 17" + case .dotnet8: return ".NET 8" + case .ruby33: return "Ruby 3.3" + case .provided2023: return "Custom (AL2023)" + } + } +} + +struct LambdaFunction: Sendable { + let name: String + let arn: String + let runtime: String + let handler: String + let memorySize: Int + let timeout: Int + let lastModified: String? + let codeSize: Int + let description: String? + + var formattedCodeSize: String { + if codeSize < 1024 { return "\(codeSize) B" } + else if codeSize < 1024 * 1024 { return String(format: "%.1f KB", Double(codeSize) / 1024) } + else { return String(format: "%.1f MB", Double(codeSize) / 1024 / 1024) } + } +} + +struct LambdaInvokeResult: Sendable { + let statusCode: Int + let payload: String + let parsedPayload: [String: Any]? +} diff --git a/Services/AWSRoute53Service.swift b/Services/AWSRoute53Service.swift new file mode 100644 index 0000000..b91b35d --- /dev/null +++ b/Services/AWSRoute53Service.swift @@ -0,0 +1,346 @@ +// 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 = """ + + + \(domain) + \(callerRef) + + \(comment) + + + """ + + 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 { "\($0)" } + .joined() + + let xml = """ + + + + + + UPSERT + + \(name) + \(type) + \(ttl) + \(resourceRecords) + + + + + + """ + + _ = 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 = """ + + + + + + UPSERT + + \(name) + \(type) + + \(targetHostedZoneId) + \(targetDNSName) + \(evaluateHealth) + + + + + + + """ + + _ = 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 { "\($0)" } + .joined() + + let xml = """ + + + + + + DELETE + + \(name) + \(type) + \(ttl) + \(resourceRecords) + + + + + + """ + + _ = 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 +} diff --git a/Services/AWSS3Service.swift b/Services/AWSS3Service.swift new file mode 100644 index 0000000..b63d322 --- /dev/null +++ b/Services/AWSS3Service.swift @@ -0,0 +1,363 @@ +// 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 = """ + + \(region) + + """ + 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 = """ + + + \(indexDoc) + + + \(errorDoc) + + + """ + + _ = 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 +} diff --git a/Services/AWSService.swift b/Services/AWSService.swift new file mode 100644 index 0000000..48c9e6c --- /dev/null +++ b/Services/AWSService.swift @@ -0,0 +1,506 @@ +// AWSService.swift +// CxIDE — Core AWS client with Signature V4 authentication +// +// Implements AWS SigV4 request signing and provides the shared HTTP client +// for all AWS service integrations. Credentials are loaded from the +// encrypted CredentialStore — never stored in source. + +import Foundation +import CryptoKit + +// MARK: - AWS Service (Core) + +final class AWSService: @unchecked Sendable { + static let shared = AWSService() + + private let credentialStore: CredentialStore + + init(credentialStore: CredentialStore = .shared) { + self.credentialStore = credentialStore + } + + // MARK: - Credential Access + + var accessKeyId: String? { credentialStore.get("AWS_ACCESS_KEY_ID") } + var secretAccessKey: String? { credentialStore.get("AWS_SECRET_ACCESS_KEY") } + var sessionToken: String? { credentialStore.get("AWS_SESSION_TOKEN") } + var defaultRegion: String { credentialStore.get("AWS_DEFAULT_REGION") ?? "us-east-1" } + + func setCredentials(accessKeyId: String, secretAccessKey: String, region: String = "us-east-1") { + credentialStore.set("AWS_ACCESS_KEY_ID", value: accessKeyId) + credentialStore.set("AWS_SECRET_ACCESS_KEY", value: secretAccessKey) + credentialStore.set("AWS_DEFAULT_REGION", value: region) + } + + var isAuthenticated: Bool { + guard let k = accessKeyId, let s = secretAccessKey else { return false } + return !k.isEmpty && !s.isEmpty + } + + // MARK: - AWS Regions + + enum Region: String, CaseIterable, Sendable { + case usEast1 = "us-east-1" + case usEast2 = "us-east-2" + case usWest1 = "us-west-1" + case usWest2 = "us-west-2" + case euWest1 = "eu-west-1" + case euWest2 = "eu-west-2" + case euCentral1 = "eu-central-1" + case apSoutheast1 = "ap-southeast-1" + case apSoutheast2 = "ap-southeast-2" + case apNortheast1 = "ap-northeast-1" + } + + // MARK: - Signed Request + + /// Execute an AWS API request with SigV4 authentication + func request( + service: String, + region: String? = nil, + method: String = "GET", + path: String = "/", + query: [String: String] = [:], + headers: [String: String] = [:], + body: Data? = nil, + contentType: String = "application/json" + ) async throws -> (data: Data, statusCode: Int) { + let region = region ?? defaultRegion + guard let accessKey = accessKeyId, let secretKey = secretAccessKey else { + throw AWSError.notAuthenticated + } + + let host = "\(service).\(region).amazonaws.com" + let endpoint: String + if service == "s3" { + endpoint = "https://s3.\(region).amazonaws.com" + } else { + endpoint = "https://\(host)" + } + + // Build URL with query parameters + var urlComponents = URLComponents(string: endpoint + path)! + if !query.isEmpty { + urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } + } + guard let url = urlComponents.url else { + throw AWSError.invalidURL(endpoint + path) + } + + // Timestamp + let now = Date() + let dateFormatter = DateFormatter() + dateFormatter.timeZone = TimeZone(identifier: "UTC") + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let amzDate = dateFormatter.string(from: now) + dateFormatter.dateFormat = "yyyyMMdd" + let dateStamp = dateFormatter.string(from: now) + + // Payload hash + let payload = body ?? Data() + let payloadHash = SHA256.hash(data: payload).hexString + + // Canonical query string (sorted) + let canonicalQueryString: String + if let queryItems = urlComponents.queryItems, !queryItems.isEmpty { + canonicalQueryString = queryItems + .sorted { $0.name < $1.name } + .map { "\(percentEncode($0.name))=\(percentEncode($0.value ?? ""))" } + .joined(separator: "&") + } else { + canonicalQueryString = "" + } + + // Build headers + var signHeaders: [String: String] = [ + "host": host, + "x-amz-date": amzDate, + "x-amz-content-sha256": payloadHash, + ] + if let token = sessionToken { + signHeaders["x-amz-security-token"] = token + } + for (k, v) in headers { + signHeaders[k.lowercased()] = v + } + + let signedHeaderNames = signHeaders.keys.sorted().joined(separator: ";") + let canonicalHeaders = signHeaders.keys.sorted() + .map { "\($0):\(signHeaders[$0]!.trimmingCharacters(in: .whitespaces))\n" } + .joined() + + // Canonical request + let canonicalRequest = [ + method, + uriEncode(path), + canonicalQueryString, + canonicalHeaders, + signedHeaderNames, + payloadHash, + ].joined(separator: "\n") + + let canonicalRequestHash = SHA256.hash(data: Data(canonicalRequest.utf8)).hexString + + // String to sign + let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + canonicalRequestHash, + ].joined(separator: "\n") + + // Signing key + let kDate = hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8)) + let kRegion = hmacSHA256(key: kDate, data: Data(region.utf8)) + let kService = hmacSHA256(key: kRegion, data: Data(service.utf8)) + let kSigning = hmacSHA256(key: kService, data: Data("aws4_request".utf8)) + + // Signature + let signature = hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)).hexString + + // Authorization header + let authorization = "AWS4-HMAC-SHA256 Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaderNames), Signature=\(signature)" + + // Build URLRequest + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = method + urlRequest.httpBody = body + urlRequest.timeoutInterval = 60 + urlRequest.setValue(authorization, forHTTPHeaderField: "Authorization") + urlRequest.setValue(amzDate, forHTTPHeaderField: "x-amz-date") + urlRequest.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256") + urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type") + if let token = sessionToken { + urlRequest.setValue(token, forHTTPHeaderField: "x-amz-security-token") + } + for (k, v) in headers { + urlRequest.setValue(v, forHTTPHeaderField: k) + } + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AWSError.invalidResponse + } + + if httpResponse.statusCode >= 400 { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + throw AWSError.apiError(statusCode: httpResponse.statusCode, body: errorBody) + } + + return (data, httpResponse.statusCode) + } + + // MARK: - S3 Request (different host pattern) + + /// Execute an S3 request (supports path-style and virtual-hosted-style) + func s3Request( + bucket: String? = nil, + key: String? = nil, + region: String? = nil, + method: String = "GET", + query: [String: String] = [:], + headers: [String: String] = [:], + body: Data? = nil, + contentType: String = "application/octet-stream" + ) async throws -> (data: Data, statusCode: Int) { + let region = region ?? defaultRegion + guard let accessKey = accessKeyId, let secretKey = secretAccessKey else { + throw AWSError.notAuthenticated + } + + // Build host and path (path-style addressing) + let host: String + let path: String + if let bucket = bucket { + host = "s3.\(region).amazonaws.com" + if let key = key { + path = "/\(bucket)/\(key)" + } else { + path = "/\(bucket)" + } + } else { + host = "s3.\(region).amazonaws.com" + path = "/" + } + + let endpoint = "https://\(host)" + + var urlComponents = URLComponents(string: endpoint + path)! + if !query.isEmpty { + urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } + } + guard let url = urlComponents.url else { + throw AWSError.invalidURL(endpoint + path) + } + + let now = Date() + let dateFormatter = DateFormatter() + dateFormatter.timeZone = TimeZone(identifier: "UTC") + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + let amzDate = dateFormatter.string(from: now) + dateFormatter.dateFormat = "yyyyMMdd" + let dateStamp = dateFormatter.string(from: now) + + let payload = body ?? Data() + let payloadHash = SHA256.hash(data: payload).hexString + + let canonicalQueryString: String + if let qi = urlComponents.queryItems, !qi.isEmpty { + canonicalQueryString = qi.sorted { $0.name < $1.name } + .map { "\(percentEncode($0.name))=\(percentEncode($0.value ?? ""))" } + .joined(separator: "&") + } else { + canonicalQueryString = "" + } + + var signHeaders: [String: String] = [ + "host": host, + "x-amz-date": amzDate, + "x-amz-content-sha256": payloadHash, + ] + if contentType != "application/octet-stream" || body != nil { + signHeaders["content-type"] = contentType + } + if let token = sessionToken { + signHeaders["x-amz-security-token"] = token + } + for (k, v) in headers { + signHeaders[k.lowercased()] = v + } + + let signedHeaderNames = signHeaders.keys.sorted().joined(separator: ";") + let canonicalHeaders = signHeaders.keys.sorted() + .map { "\($0):\(signHeaders[$0]!.trimmingCharacters(in: .whitespaces))\n" } + .joined() + + let canonicalRequest = [ + method, + uriEncode(path), + canonicalQueryString, + canonicalHeaders, + signedHeaderNames, + payloadHash, + ].joined(separator: "\n") + + let canonicalRequestHash = SHA256.hash(data: Data(canonicalRequest.utf8)).hexString + let credentialScope = "\(dateStamp)/\(region)/s3/aws4_request" + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + canonicalRequestHash, + ].joined(separator: "\n") + + let kDate = hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8)) + let kRegion = hmacSHA256(key: kDate, data: Data(region.utf8)) + let kService = hmacSHA256(key: kRegion, data: Data("s3".utf8)) + let kSigning = hmacSHA256(key: kService, data: Data("aws4_request".utf8)) + let signature = hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)).hexString + + let authorization = "AWS4-HMAC-SHA256 Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaderNames), Signature=\(signature)" + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = method + urlRequest.httpBody = body + urlRequest.timeoutInterval = 120 + urlRequest.setValue(authorization, forHTTPHeaderField: "Authorization") + urlRequest.setValue(amzDate, forHTTPHeaderField: "x-amz-date") + urlRequest.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256") + if contentType != "application/octet-stream" || body != nil { + urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + if let token = sessionToken { + urlRequest.setValue(token, forHTTPHeaderField: "x-amz-security-token") + } + for (k, v) in headers { + urlRequest.setValue(v, forHTTPHeaderField: k) + } + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw AWSError.invalidResponse + } + + if httpResponse.statusCode >= 400 { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + throw AWSError.apiError(statusCode: httpResponse.statusCode, body: errorBody) + } + + return (data, httpResponse.statusCode) + } + + // MARK: - SigV4 Helpers + + private func hmacSHA256(key: Data, data: Data) -> Data { + let hmac = HMAC.authenticationCode(for: data, using: SymmetricKey(data: key)) + return Data(hmac) + } + + private func percentEncode(_ string: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string + } + + private func uriEncode(_ path: String) -> String { + // Encode each path segment individually (preserving /) + path.split(separator: "/", omittingEmptySubsequences: false) + .map { percentEncode(String($0)) } + .joined(separator: "/") + } +} + +// MARK: - SHA256 Hex Extension + +extension SHA256Digest { + var hexString: String { + self.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - Data Hex Extension + +extension Data { + var hexString: String { + self.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - AWS Error + +enum AWSError: LocalizedError, Sendable { + case notAuthenticated + case invalidURL(String) + case invalidResponse + case apiError(statusCode: Int, body: String) + case bucketNotFound(String) + case objectNotFound(String) + case bucketAlreadyExists(String) + case accessDenied(String) + case invalidXML(String) + case deploymentFailed(String) + case distributionNotFound(String) + case hostedZoneNotFound(String) + case functionNotFound(String) + case timeout + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "AWS credentials not configured. Use credential_set to store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY." + case .invalidURL(let url): + return "Invalid AWS URL: \(url)" + case .invalidResponse: + return "Invalid response from AWS API" + case .apiError(let code, let body): + return "AWS API error (\(code)): \(body.prefix(500))" + case .bucketNotFound(let name): + return "S3 bucket not found: \(name)" + case .objectNotFound(let key): + return "S3 object not found: \(key)" + case .bucketAlreadyExists(let name): + return "S3 bucket already exists: \(name)" + case .accessDenied(let resource): + return "Access denied: \(resource)" + case .invalidXML(let detail): + return "Failed to parse AWS XML response: \(detail)" + case .deploymentFailed(let detail): + return "Deployment failed: \(detail)" + case .distributionNotFound(let id): + return "CloudFront distribution not found: \(id)" + case .hostedZoneNotFound(let name): + return "Route 53 hosted zone not found: \(name)" + case .functionNotFound(let name): + return "Lambda function not found: \(name)" + case .timeout: + return "AWS request timed out" + } + } +} + +// MARK: - Simple XML Parser + +/// Lightweight XML element for parsing AWS responses (no external dependencies) +final class AWSXMLElement { + let name: String + var text: String = "" + var children: [AWSXMLElement] = [] + var attributes: [String: String] = [:] + + init(name: String) { self.name = name } + + func child(_ name: String) -> AWSXMLElement? { + children.first { $0.name == name } + } + + func allChildren(_ name: String) -> [AWSXMLElement] { + children.filter { $0.name == name } + } + + /// Recursively find first descendant with given name + func descendant(_ name: String) -> AWSXMLElement? { + for c in children { + if c.name == name { return c } + if let found = c.descendant(name) { return found } + } + return nil + } + + func allDescendants(_ name: String) -> [AWSXMLElement] { + var result: [AWSXMLElement] = [] + for c in children { + if c.name == name { result.append(c) } + result.append(contentsOf: c.allDescendants(name)) + } + return result + } +} + +/// Minimal XML parser using Foundation's XMLParser +final class AWSXMLParser: NSObject, XMLParserDelegate { + private var root: AWSXMLElement? + private var stack: [AWSXMLElement] = [] + private var currentText = "" + + func parse(data: Data) throws -> AWSXMLElement { + let parser = XMLParser(data: data) + parser.delegate = self + guard parser.parse(), let root = root else { + throw AWSError.invalidXML("Failed to parse XML response") + } + return root + } + + func parserDidStartDocument(_ parser: XMLParser) { + stack = [] + root = nil + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, + namespaceURI: String?, qualifiedName: String?, + attributes: [String: String] = [:]) { + let element = AWSXMLElement(name: elementName) + element.attributes = attributes + stack.last?.children.append(element) + stack.append(element) + currentText = "" + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText += string + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, + namespaceURI: String?, qualifiedName: String?) { + if let current = stack.last { + current.text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + } + if stack.count == 1 { + root = stack.first + } + stack.removeLast() + currentText = stack.last.flatMap { _ in "" } ?? "" + } +} diff --git a/Services/AgentService.swift b/Services/AgentService.swift index 420a758..55c8d40 100644 --- a/Services/AgentService.swift +++ b/Services/AgentService.swift @@ -705,6 +705,7 @@ final class AgentService: ObservableObject { DiagnosticsTools.register(on: agentServer, config: agentConfig, memory: agentMemory) AutoPilotTools.register(on: agentServer, config: agentConfig, memory: agentMemory) WebsiteTools.register(on: agentServer, config: agentConfig, memory: agentMemory) + AWSTools.register(on: agentServer, config: agentConfig, memory: agentMemory) // Register workspace root agentServer.registerRoot(uri: "file://\(workspacePath)", name: "workspace") diff --git a/Services/CredentialStore.swift b/Services/CredentialStore.swift index 456a306..5b9a7ba 100644 --- a/Services/CredentialStore.swift +++ b/Services/CredentialStore.swift @@ -137,6 +137,18 @@ final class CredentialStore: @unchecked Sendable { set("GODADDY_PROD_SECRET", value: secret) } + // MARK: - Convenience: AWS + + var awsAccessKeyId: String? { self.get("AWS_ACCESS_KEY_ID") } + var awsSecretAccessKey: String? { self.get("AWS_SECRET_ACCESS_KEY") } + var awsDefaultRegion: String { self.get("AWS_DEFAULT_REGION") ?? "us-east-1" } + + func setAWS(accessKeyId: String, secretAccessKey: String, region: String = "us-east-1") { + set("AWS_ACCESS_KEY_ID", value: accessKeyId) + set("AWS_SECRET_ACCESS_KEY", value: secretAccessKey) + set("AWS_DEFAULT_REGION", value: region) + } + // MARK: - Errors enum CredentialError: LocalizedError {