@stacksjs/ts-cloud 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +1 -1
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static Site Deployment with External DNS Provider Support
|
|
3
|
+
* Deploys static sites to AWS (S3 + CloudFront + ACM) with DNS managed by external providers (Porkbun, GoDaddy, etc.)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CloudFormationClient } from '../aws/cloudformation'
|
|
7
|
+
import { S3Client } from '../aws/s3'
|
|
8
|
+
import { CloudFrontClient } from '../aws/cloudfront'
|
|
9
|
+
import { ACMClient } from '../aws/acm'
|
|
10
|
+
import type { DnsProvider, DnsProviderConfig } from '../dns/types'
|
|
11
|
+
import { createDnsProvider } from '../dns'
|
|
12
|
+
import { UnifiedDnsValidator } from '../dns/validator'
|
|
13
|
+
|
|
14
|
+
export interface ExternalDnsStaticSiteConfig {
|
|
15
|
+
/** Site name used for resource naming */
|
|
16
|
+
siteName: string
|
|
17
|
+
/** AWS region for S3 bucket */
|
|
18
|
+
region?: string
|
|
19
|
+
/** Custom domain (e.g., bunpress.org) */
|
|
20
|
+
domain: string
|
|
21
|
+
/** S3 bucket name (auto-generated if not provided) */
|
|
22
|
+
bucket?: string
|
|
23
|
+
/** ACM certificate ARN (auto-created if not provided) */
|
|
24
|
+
certificateArn?: string
|
|
25
|
+
/** CloudFormation stack name */
|
|
26
|
+
stackName?: string
|
|
27
|
+
/** Default root object */
|
|
28
|
+
defaultRootObject?: string
|
|
29
|
+
/** Error document */
|
|
30
|
+
errorDocument?: string
|
|
31
|
+
/** Cache control for assets */
|
|
32
|
+
cacheControl?: string
|
|
33
|
+
/** Tags to apply to resources */
|
|
34
|
+
tags?: Record<string, string>
|
|
35
|
+
/** DNS provider configuration */
|
|
36
|
+
dnsProvider: DnsProviderConfig
|
|
37
|
+
/** Skip DNS verification and record creation (use when DNS is already configured) */
|
|
38
|
+
skipDnsVerification?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExternalDnsDeployResult {
|
|
42
|
+
success: boolean
|
|
43
|
+
stackId?: string
|
|
44
|
+
stackName: string
|
|
45
|
+
bucket: string
|
|
46
|
+
distributionId?: string
|
|
47
|
+
distributionDomain?: string
|
|
48
|
+
domain?: string
|
|
49
|
+
certificateArn?: string
|
|
50
|
+
message: string
|
|
51
|
+
filesUploaded?: number
|
|
52
|
+
filesSkipped?: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate CloudFormation template for static site infrastructure (without Route53)
|
|
57
|
+
*/
|
|
58
|
+
export function generateExternalDnsStaticSiteTemplate(config: {
|
|
59
|
+
bucketName: string
|
|
60
|
+
domain?: string
|
|
61
|
+
aliases?: string[]
|
|
62
|
+
certificateArn?: string
|
|
63
|
+
defaultRootObject?: string
|
|
64
|
+
errorDocument?: string
|
|
65
|
+
}): object {
|
|
66
|
+
const {
|
|
67
|
+
bucketName,
|
|
68
|
+
domain,
|
|
69
|
+
aliases,
|
|
70
|
+
certificateArn,
|
|
71
|
+
defaultRootObject = 'index.html',
|
|
72
|
+
errorDocument = '404.html',
|
|
73
|
+
} = config
|
|
74
|
+
|
|
75
|
+
const resources: Record<string, any> = {}
|
|
76
|
+
const outputs: Record<string, any> = {}
|
|
77
|
+
|
|
78
|
+
// S3 Bucket
|
|
79
|
+
resources.S3Bucket = {
|
|
80
|
+
Type: 'AWS::S3::Bucket',
|
|
81
|
+
Properties: {
|
|
82
|
+
BucketName: bucketName,
|
|
83
|
+
PublicAccessBlockConfiguration: {
|
|
84
|
+
BlockPublicAcls: true,
|
|
85
|
+
BlockPublicPolicy: false,
|
|
86
|
+
IgnorePublicAcls: true,
|
|
87
|
+
RestrictPublicBuckets: false,
|
|
88
|
+
},
|
|
89
|
+
WebsiteConfiguration: {
|
|
90
|
+
IndexDocument: defaultRootObject,
|
|
91
|
+
ErrorDocument: errorDocument,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
outputs.BucketName = {
|
|
97
|
+
Description: 'S3 Bucket Name',
|
|
98
|
+
Value: { Ref: 'S3Bucket' },
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
outputs.BucketArn = {
|
|
102
|
+
Description: 'S3 Bucket ARN',
|
|
103
|
+
Value: { 'Fn::GetAtt': ['S3Bucket', 'Arn'] },
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Origin Access Control
|
|
107
|
+
resources.CloudFrontOAC = {
|
|
108
|
+
Type: 'AWS::CloudFront::OriginAccessControl',
|
|
109
|
+
Properties: {
|
|
110
|
+
OriginAccessControlConfig: {
|
|
111
|
+
Name: `OAC-${bucketName}`,
|
|
112
|
+
Description: `OAC for ${bucketName}`,
|
|
113
|
+
OriginAccessControlOriginType: 's3',
|
|
114
|
+
SigningBehavior: 'always',
|
|
115
|
+
SigningProtocol: 'sigv4',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// CloudFront Function for URL rewriting
|
|
121
|
+
resources.UrlRewriteFunction = {
|
|
122
|
+
Type: 'AWS::CloudFront::Function',
|
|
123
|
+
Properties: {
|
|
124
|
+
Name: { 'Fn::Sub': '${AWS::StackName}-url-rewrite' },
|
|
125
|
+
AutoPublish: true,
|
|
126
|
+
FunctionConfig: {
|
|
127
|
+
Comment: 'Append .html extension to URLs without extensions',
|
|
128
|
+
Runtime: 'cloudfront-js-2.0',
|
|
129
|
+
},
|
|
130
|
+
FunctionCode: `function handler(event) {
|
|
131
|
+
var request = event.request;
|
|
132
|
+
var uri = request.uri;
|
|
133
|
+
|
|
134
|
+
// If URI ends with /, serve index.html
|
|
135
|
+
if (uri.endsWith('/')) {
|
|
136
|
+
request.uri = uri + 'index.html';
|
|
137
|
+
}
|
|
138
|
+
// If URI doesn't have an extension, append .html
|
|
139
|
+
else if (!uri.includes('.')) {
|
|
140
|
+
request.uri = uri + '.html';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return request;
|
|
144
|
+
}`,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// CloudFront Distribution
|
|
149
|
+
const distributionConfig: any = {
|
|
150
|
+
Enabled: true,
|
|
151
|
+
DefaultRootObject: defaultRootObject,
|
|
152
|
+
HttpVersion: 'http2and3',
|
|
153
|
+
IPV6Enabled: true,
|
|
154
|
+
PriceClass: 'PriceClass_100',
|
|
155
|
+
Origins: [
|
|
156
|
+
{
|
|
157
|
+
Id: `S3-${bucketName}`,
|
|
158
|
+
DomainName: { 'Fn::GetAtt': ['S3Bucket', 'RegionalDomainName'] },
|
|
159
|
+
S3OriginConfig: {
|
|
160
|
+
OriginAccessIdentity: '',
|
|
161
|
+
},
|
|
162
|
+
OriginAccessControlId: { 'Fn::GetAtt': ['CloudFrontOAC', 'Id'] },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
DefaultCacheBehavior: {
|
|
166
|
+
TargetOriginId: `S3-${bucketName}`,
|
|
167
|
+
ViewerProtocolPolicy: 'redirect-to-https',
|
|
168
|
+
AllowedMethods: ['GET', 'HEAD'],
|
|
169
|
+
CachedMethods: ['GET', 'HEAD'],
|
|
170
|
+
Compress: true,
|
|
171
|
+
CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // Managed-CachingOptimized
|
|
172
|
+
FunctionAssociations: [
|
|
173
|
+
{
|
|
174
|
+
EventType: 'viewer-request',
|
|
175
|
+
FunctionARN: { 'Fn::GetAtt': ['UrlRewriteFunction', 'FunctionARN'] },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
CustomErrorResponses: [
|
|
180
|
+
{
|
|
181
|
+
ErrorCode: 403,
|
|
182
|
+
ResponseCode: 200,
|
|
183
|
+
ResponsePagePath: `/${defaultRootObject}`,
|
|
184
|
+
ErrorCachingMinTTL: 300,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
ErrorCode: 404,
|
|
188
|
+
ResponseCode: 404,
|
|
189
|
+
ResponsePagePath: `/${errorDocument}`,
|
|
190
|
+
ErrorCachingMinTTL: 300,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Add custom domain configuration if provided
|
|
196
|
+
if (domain && certificateArn) {
|
|
197
|
+
// Use provided aliases or default to just the domain
|
|
198
|
+
distributionConfig.Aliases = aliases && aliases.length > 0 ? aliases : [domain]
|
|
199
|
+
distributionConfig.ViewerCertificate = {
|
|
200
|
+
AcmCertificateArn: certificateArn,
|
|
201
|
+
SslSupportMethod: 'sni-only',
|
|
202
|
+
MinimumProtocolVersion: 'TLSv1.2_2021',
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
distributionConfig.ViewerCertificate = {
|
|
207
|
+
CloudFrontDefaultCertificate: true,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
resources.CloudFrontDistribution = {
|
|
212
|
+
Type: 'AWS::CloudFront::Distribution',
|
|
213
|
+
DependsOn: ['S3Bucket', 'CloudFrontOAC', 'UrlRewriteFunction'],
|
|
214
|
+
Properties: {
|
|
215
|
+
DistributionConfig: distributionConfig,
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
outputs.DistributionId = {
|
|
220
|
+
Description: 'CloudFront Distribution ID',
|
|
221
|
+
Value: { Ref: 'CloudFrontDistribution' },
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
outputs.DistributionDomain = {
|
|
225
|
+
Description: 'CloudFront Distribution Domain',
|
|
226
|
+
Value: { 'Fn::GetAtt': ['CloudFrontDistribution', 'DomainName'] },
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// S3 Bucket Policy for CloudFront OAC
|
|
230
|
+
resources.S3BucketPolicy = {
|
|
231
|
+
Type: 'AWS::S3::BucketPolicy',
|
|
232
|
+
DependsOn: ['S3Bucket', 'CloudFrontDistribution'],
|
|
233
|
+
Properties: {
|
|
234
|
+
Bucket: { Ref: 'S3Bucket' },
|
|
235
|
+
PolicyDocument: {
|
|
236
|
+
Version: '2012-10-17',
|
|
237
|
+
Statement: [
|
|
238
|
+
{
|
|
239
|
+
Sid: 'AllowCloudFrontServicePrincipal',
|
|
240
|
+
Effect: 'Allow',
|
|
241
|
+
Principal: {
|
|
242
|
+
Service: 'cloudfront.amazonaws.com',
|
|
243
|
+
},
|
|
244
|
+
Action: 's3:GetObject',
|
|
245
|
+
Resource: { 'Fn::Sub': 'arn:aws:s3:::${S3Bucket}/*' },
|
|
246
|
+
Condition: {
|
|
247
|
+
StringEquals: {
|
|
248
|
+
'AWS:SourceArn': {
|
|
249
|
+
'Fn::Sub': 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}',
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// NOTE: No Route53 records - DNS will be managed by external provider
|
|
260
|
+
|
|
261
|
+
outputs.SiteUrl = {
|
|
262
|
+
Description: 'Site URL',
|
|
263
|
+
Value: domain ? `https://${domain}` : { 'Fn::Sub': 'https://${CloudFrontDistribution.DomainName}' },
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
AWSTemplateFormatVersion: '2010-09-09',
|
|
268
|
+
Description: `Static site infrastructure for ${domain || bucketName} (External DNS)`,
|
|
269
|
+
Resources: resources,
|
|
270
|
+
Outputs: outputs,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Deploy a static site to AWS with external DNS provider
|
|
276
|
+
*/
|
|
277
|
+
export async function deployStaticSiteWithExternalDns(
|
|
278
|
+
config: ExternalDnsStaticSiteConfig,
|
|
279
|
+
): Promise<ExternalDnsDeployResult> {
|
|
280
|
+
const region = config.region || 'us-east-1'
|
|
281
|
+
const cfRegion = 'us-east-1' // CloudFormation for global resources must be in us-east-1
|
|
282
|
+
const domain = config.domain
|
|
283
|
+
|
|
284
|
+
// Generate bucket name if not provided
|
|
285
|
+
const bucket = config.bucket || domain.replace(/\./g, '-')
|
|
286
|
+
|
|
287
|
+
// Generate stack name
|
|
288
|
+
const stackName = config.stackName || `${config.siteName}-static-site`
|
|
289
|
+
|
|
290
|
+
// Initialize clients
|
|
291
|
+
const cf = new CloudFormationClient(cfRegion)
|
|
292
|
+
const acm = new ACMClient('us-east-1') // ACM certs for CloudFront must be in us-east-1
|
|
293
|
+
|
|
294
|
+
// Create DNS provider (only if not skipping DNS verification)
|
|
295
|
+
let dnsProvider: DnsProvider | null = null
|
|
296
|
+
|
|
297
|
+
if (!config.skipDnsVerification) {
|
|
298
|
+
dnsProvider = createDnsProvider(config.dnsProvider)
|
|
299
|
+
|
|
300
|
+
// Verify DNS provider can manage this domain
|
|
301
|
+
console.log(`Verifying DNS provider can manage ${domain}...`)
|
|
302
|
+
const canManage = await dnsProvider.canManageDomain(domain)
|
|
303
|
+
if (!canManage) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
stackName,
|
|
307
|
+
bucket,
|
|
308
|
+
message: `DNS provider '${dnsProvider.name}' cannot manage domain ${domain}. Please check your API credentials and domain ownership.`,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
console.log(`DNS provider '${dnsProvider.name}' verified for ${domain}`)
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
console.log(`Skipping DNS verification for ${domain}`)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let certificateArn = config.certificateArn
|
|
318
|
+
|
|
319
|
+
// Determine if we're dealing with an apex domain (need www SAN)
|
|
320
|
+
const domainParts = domain.split('.')
|
|
321
|
+
const isApexDomain = domainParts.length === 2
|
|
322
|
+
const wwwDomain = isApexDomain ? `www.${domain}` : undefined
|
|
323
|
+
|
|
324
|
+
// Auto-create SSL certificate if not provided
|
|
325
|
+
if (!certificateArn) {
|
|
326
|
+
console.log(`Checking for existing SSL certificate for ${domain}...`)
|
|
327
|
+
|
|
328
|
+
// Check for existing certificate that covers both apex and www
|
|
329
|
+
const existingCert = await acm.findCertificateByDomain(domain)
|
|
330
|
+
let existingCertCoversWww = false
|
|
331
|
+
|
|
332
|
+
if (existingCert && existingCert.Status === 'ISSUED') {
|
|
333
|
+
// Check if the existing cert also covers www
|
|
334
|
+
if (wwwDomain && existingCert.SubjectAlternativeNames) {
|
|
335
|
+
existingCertCoversWww = existingCert.SubjectAlternativeNames.includes(wwwDomain)
|
|
336
|
+
|| existingCert.SubjectAlternativeNames.some(san => san === `*.${domain}`)
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
existingCertCoversWww = true // Not apex domain, no www needed
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (existingCertCoversWww) {
|
|
343
|
+
certificateArn = existingCert.CertificateArn
|
|
344
|
+
console.log(`Found existing certificate with www coverage: ${certificateArn}`)
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
console.log(`Existing certificate doesn't cover ${wwwDomain}, requesting new one...`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!certificateArn) {
|
|
352
|
+
if (!dnsProvider) {
|
|
353
|
+
return {
|
|
354
|
+
success: false,
|
|
355
|
+
stackName,
|
|
356
|
+
bucket,
|
|
357
|
+
message: `No DNS provider available to validate SSL certificate for ${domain}. Provide a certificateArn or enable DNS verification.`,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Request and validate new certificate using external DNS provider
|
|
362
|
+
// Always include www for apex domains
|
|
363
|
+
console.log(`Requesting new SSL certificate for ${domain}${wwwDomain ? ` (including ${wwwDomain})` : ''}...`)
|
|
364
|
+
const validator = new UnifiedDnsValidator(dnsProvider, 'us-east-1')
|
|
365
|
+
|
|
366
|
+
const certResult = await validator.findOrCreateCertificate({
|
|
367
|
+
domainName: domain,
|
|
368
|
+
subjectAlternativeNames: wwwDomain ? [wwwDomain] : undefined,
|
|
369
|
+
waitForValidation: true,
|
|
370
|
+
maxWaitMinutes: 10,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
if (certResult.status !== 'issued') {
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
stackName,
|
|
377
|
+
bucket,
|
|
378
|
+
message: `SSL certificate validation failed. Status: ${certResult.status}`,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
certificateArn = certResult.certificateArn
|
|
383
|
+
console.log(`Certificate issued: ${certificateArn}`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check if stack already exists
|
|
388
|
+
let stackExists = false
|
|
389
|
+
let existingBucketName: string | undefined
|
|
390
|
+
try {
|
|
391
|
+
const existingStacks = await cf.describeStacks({ stackName })
|
|
392
|
+
if (existingStacks.Stacks.length > 0) {
|
|
393
|
+
const stack = existingStacks.Stacks[0]
|
|
394
|
+
const stackStatus = stack.StackStatus
|
|
395
|
+
|
|
396
|
+
if (stackStatus === 'DELETE_IN_PROGRESS') {
|
|
397
|
+
console.log('Previous stack is still being deleted, waiting...')
|
|
398
|
+
await cf.waitForStack(stackName, 'stack-delete-complete')
|
|
399
|
+
stackExists = false
|
|
400
|
+
}
|
|
401
|
+
else if (stackStatus === 'DELETE_COMPLETE') {
|
|
402
|
+
stackExists = false
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
stackExists = true
|
|
406
|
+
const outputs = stack.Outputs || []
|
|
407
|
+
existingBucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (err: any) {
|
|
412
|
+
if (err.message?.includes('does not exist') || err.code === 'ValidationError') {
|
|
413
|
+
stackExists = false
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
throw err
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Determine final bucket name
|
|
421
|
+
let finalBucket = existingBucketName || bucket
|
|
422
|
+
if (!stackExists) {
|
|
423
|
+
const s3 = new S3Client(region)
|
|
424
|
+
const cloudfront = new CloudFrontClient()
|
|
425
|
+
|
|
426
|
+
// Check for existing CloudFront distributions with our domain FIRST
|
|
427
|
+
// before deciding to clean up any "orphaned" buckets
|
|
428
|
+
let hasExistingDistribution = false
|
|
429
|
+
if (domain) {
|
|
430
|
+
try {
|
|
431
|
+
console.log(`Checking for existing CloudFront distributions with alias ${domain}...`)
|
|
432
|
+
const distributions = await cloudfront.listDistributions()
|
|
433
|
+
for (const dist of distributions) {
|
|
434
|
+
let aliases: string[] = []
|
|
435
|
+
if (dist.Aliases?.Items) {
|
|
436
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
437
|
+
aliases = dist.Aliases.Items
|
|
438
|
+
}
|
|
439
|
+
else if (typeof dist.Aliases.Items === 'object') {
|
|
440
|
+
const cname = (dist.Aliases.Items as any).CNAME
|
|
441
|
+
if (typeof cname === 'string') {
|
|
442
|
+
aliases = [cname]
|
|
443
|
+
}
|
|
444
|
+
else if (Array.isArray(cname)) {
|
|
445
|
+
aliases = cname
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (aliases.includes(domain)) {
|
|
450
|
+
hasExistingDistribution = true
|
|
451
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
|
|
452
|
+
console.log(`Reusing existing infrastructure for updates...`)
|
|
453
|
+
|
|
454
|
+
// Get the origin bucket from the distribution
|
|
455
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
|
|
456
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items
|
|
457
|
+
let originBucket: string | undefined
|
|
458
|
+
|
|
459
|
+
if (originsData) {
|
|
460
|
+
// Handle AWS XML-to-JSON format: single item is { Origin: {...} }, multiple is { Origin: [...] } or [...]
|
|
461
|
+
let originList: any[] = []
|
|
462
|
+
if (Array.isArray(originsData)) {
|
|
463
|
+
originList = originsData
|
|
464
|
+
}
|
|
465
|
+
else if (originsData.Origin) {
|
|
466
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
originList = [originsData]
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const origin of originList) {
|
|
473
|
+
const domainName = origin.DomainName || ''
|
|
474
|
+
// Extract bucket name from S3 domain (e.g., "bucket-name.s3.us-east-1.amazonaws.com")
|
|
475
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
|
|
476
|
+
if (s3Match) {
|
|
477
|
+
originBucket = s3Match[1]
|
|
478
|
+
break
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (originBucket) {
|
|
484
|
+
console.log(`Using existing S3 bucket: ${originBucket}`)
|
|
485
|
+
// Return success with existing infrastructure info - skip CloudFormation
|
|
486
|
+
return {
|
|
487
|
+
success: true,
|
|
488
|
+
stackName: `existing-${dist.Id}`,
|
|
489
|
+
bucket: originBucket,
|
|
490
|
+
distributionId: dist.Id,
|
|
491
|
+
distributionDomain: dist.DomainName,
|
|
492
|
+
domain,
|
|
493
|
+
certificateArn,
|
|
494
|
+
message: 'Using existing CloudFront distribution',
|
|
495
|
+
_existingInfrastructure: true, // Flag to indicate we're reusing infrastructure
|
|
496
|
+
} as ExternalDnsDeployResult & { _existingInfrastructure: boolean }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// No distributions or error listing them
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Only clean up orphaned S3 buckets when no CloudFront distribution references them
|
|
507
|
+
if (!hasExistingDistribution) {
|
|
508
|
+
try {
|
|
509
|
+
const headResult = await s3.headBucket(bucket)
|
|
510
|
+
if (headResult.exists) {
|
|
511
|
+
console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`)
|
|
512
|
+
try {
|
|
513
|
+
const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket))
|
|
514
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
515
|
+
setTimeout(() => reject(new Error('Bucket cleanup timeout')), 30000),
|
|
516
|
+
)
|
|
517
|
+
await Promise.race([cleanupPromise, timeoutPromise])
|
|
518
|
+
console.log(`Deleted orphaned S3 bucket ${bucket}`)
|
|
519
|
+
}
|
|
520
|
+
catch (cleanupErr: any) {
|
|
521
|
+
console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`)
|
|
522
|
+
const suffix = Date.now().toString(36)
|
|
523
|
+
finalBucket = `${bucket}-${suffix}`
|
|
524
|
+
console.log(`Using alternative bucket name: ${finalBucket}`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// Bucket doesn't exist, good
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Generate CloudFormation template (without Route53)
|
|
535
|
+
// Build aliases list (include www for apex domains)
|
|
536
|
+
const aliases = wwwDomain ? [domain, wwwDomain] : [domain]
|
|
537
|
+
|
|
538
|
+
const template = generateExternalDnsStaticSiteTemplate({
|
|
539
|
+
bucketName: finalBucket,
|
|
540
|
+
domain,
|
|
541
|
+
aliases,
|
|
542
|
+
certificateArn,
|
|
543
|
+
defaultRootObject: config.defaultRootObject,
|
|
544
|
+
errorDocument: config.errorDocument,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
// Build tags
|
|
548
|
+
const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }))
|
|
549
|
+
tags.push({ Key: 'ManagedBy', Value: 'ts-cloud' })
|
|
550
|
+
tags.push({ Key: 'Application', Value: config.siteName })
|
|
551
|
+
if (dnsProvider) {
|
|
552
|
+
tags.push({ Key: 'DnsProvider', Value: dnsProvider.name })
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Create or update stack
|
|
556
|
+
let stackId: string
|
|
557
|
+
let isUpdate = false
|
|
558
|
+
|
|
559
|
+
if (stackExists) {
|
|
560
|
+
isUpdate = true
|
|
561
|
+
console.log(`Updating CloudFormation stack: ${stackName}`)
|
|
562
|
+
try {
|
|
563
|
+
const result = await cf.updateStack({
|
|
564
|
+
stackName,
|
|
565
|
+
templateBody: JSON.stringify(template),
|
|
566
|
+
capabilities: ['CAPABILITY_IAM'],
|
|
567
|
+
tags,
|
|
568
|
+
})
|
|
569
|
+
stackId = result.StackId
|
|
570
|
+
console.log(`Update initiated, stack ID: ${stackId}`)
|
|
571
|
+
}
|
|
572
|
+
catch (err: any) {
|
|
573
|
+
if (err.message?.includes('No updates are to be performed')) {
|
|
574
|
+
const stacks = await cf.describeStacks({ stackName })
|
|
575
|
+
stackId = stacks.Stacks[0].StackId
|
|
576
|
+
const outputs = stacks.Stacks[0]?.Outputs || []
|
|
577
|
+
const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
|
|
578
|
+
|
|
579
|
+
// Still need to ensure DNS records exist
|
|
580
|
+
const distributionDomain = getOutput('DistributionDomain')
|
|
581
|
+
if (distributionDomain) {
|
|
582
|
+
await ensureDnsRecords(dnsProvider, domain, distributionDomain)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
success: true,
|
|
587
|
+
stackId,
|
|
588
|
+
stackName,
|
|
589
|
+
bucket: getOutput('BucketName') || finalBucket,
|
|
590
|
+
distributionId: getOutput('DistributionId'),
|
|
591
|
+
distributionDomain,
|
|
592
|
+
domain,
|
|
593
|
+
certificateArn,
|
|
594
|
+
message: 'Static site infrastructure is already up to date',
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
throw err
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
console.log(`Creating CloudFormation stack: ${stackName}`)
|
|
604
|
+
console.log(`Bucket name: ${finalBucket}`)
|
|
605
|
+
console.log(`Domain: ${domain}`)
|
|
606
|
+
console.log(`Certificate ARN: ${certificateArn}`)
|
|
607
|
+
const result = await cf.createStack({
|
|
608
|
+
stackName,
|
|
609
|
+
templateBody: JSON.stringify(template),
|
|
610
|
+
capabilities: ['CAPABILITY_IAM'],
|
|
611
|
+
tags,
|
|
612
|
+
onFailure: 'DELETE',
|
|
613
|
+
})
|
|
614
|
+
stackId = result.StackId
|
|
615
|
+
console.log(`Create initiated, stack ID: ${stackId}`)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Wait for stack to complete
|
|
619
|
+
console.log(`Waiting for stack to reach ${isUpdate ? 'stack-update-complete' : 'stack-create-complete'}...`)
|
|
620
|
+
try {
|
|
621
|
+
await cf.waitForStack(stackName, isUpdate ? 'stack-update-complete' : 'stack-create-complete')
|
|
622
|
+
console.log('Stack operation completed successfully!')
|
|
623
|
+
}
|
|
624
|
+
catch (err: any) {
|
|
625
|
+
// Check for CloudFront account verification error
|
|
626
|
+
if (err.message?.includes('must be verified') || err.message?.includes('Access denied for operation')) {
|
|
627
|
+
console.log('CloudFront account verification required - checking for existing infrastructure...')
|
|
628
|
+
|
|
629
|
+
// Try to find an existing CloudFront distribution we can use
|
|
630
|
+
try {
|
|
631
|
+
const cloudfront = new CloudFrontClient()
|
|
632
|
+
const distributions = await cloudfront.listDistributions()
|
|
633
|
+
|
|
634
|
+
for (const dist of distributions) {
|
|
635
|
+
let aliases: string[] = []
|
|
636
|
+
if (dist.Aliases?.Items) {
|
|
637
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
638
|
+
aliases = dist.Aliases.Items
|
|
639
|
+
}
|
|
640
|
+
else if (typeof dist.Aliases.Items === 'object') {
|
|
641
|
+
const cname = (dist.Aliases.Items as any).CNAME
|
|
642
|
+
if (typeof cname === 'string') {
|
|
643
|
+
aliases = [cname]
|
|
644
|
+
}
|
|
645
|
+
else if (Array.isArray(cname)) {
|
|
646
|
+
aliases = cname
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (aliases.includes(domain)) {
|
|
652
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`)
|
|
653
|
+
console.log(`Using existing infrastructure despite account verification requirement...`)
|
|
654
|
+
|
|
655
|
+
// Get the origin bucket from the distribution
|
|
656
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id!)
|
|
657
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items
|
|
658
|
+
let originBucket: string | undefined
|
|
659
|
+
|
|
660
|
+
if (originsData) {
|
|
661
|
+
let originList: any[] = []
|
|
662
|
+
if (Array.isArray(originsData)) {
|
|
663
|
+
originList = originsData
|
|
664
|
+
}
|
|
665
|
+
else if (originsData.Origin) {
|
|
666
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin]
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
originList = [originsData]
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
for (const origin of originList) {
|
|
673
|
+
const domainName = origin.DomainName || ''
|
|
674
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/)
|
|
675
|
+
if (s3Match) {
|
|
676
|
+
originBucket = s3Match[1]
|
|
677
|
+
break
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (originBucket) {
|
|
683
|
+
console.log(`Using existing S3 bucket: ${originBucket}`)
|
|
684
|
+
|
|
685
|
+
// Also ensure DNS records exist
|
|
686
|
+
const distributionDomain = dist.DomainName
|
|
687
|
+
if (distributionDomain) {
|
|
688
|
+
await ensureDnsRecords(dnsProvider, domain, distributionDomain)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
success: true,
|
|
693
|
+
stackName: `existing-${dist.Id}`,
|
|
694
|
+
bucket: originBucket,
|
|
695
|
+
distributionId: dist.Id,
|
|
696
|
+
distributionDomain: dist.DomainName,
|
|
697
|
+
domain,
|
|
698
|
+
certificateArn,
|
|
699
|
+
message: 'Using existing CloudFront distribution (account verification pending for new distributions)',
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
// Couldn't find existing infrastructure
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// No existing infrastructure found, show verification message
|
|
710
|
+
const verificationMessage = `
|
|
711
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
712
|
+
│ AWS ACCOUNT VERIFICATION REQUIRED │
|
|
713
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
714
|
+
│ Your AWS account must be verified before you can create CloudFront │
|
|
715
|
+
│ distributions. This is a one-time requirement for new AWS accounts. │
|
|
716
|
+
│ │
|
|
717
|
+
│ To verify your account: │
|
|
718
|
+
│ │
|
|
719
|
+
│ 1. Go to: https://console.aws.amazon.com/support/home#/ │
|
|
720
|
+
│ 2. Click "Create case" │
|
|
721
|
+
│ 3. Select "Service limit increase" │
|
|
722
|
+
│ 4. For Service: Select "CloudFront" │
|
|
723
|
+
│ 5. For Request: "Please verify my account for CloudFront access" │
|
|
724
|
+
│ 6. Submit the case │
|
|
725
|
+
│ │
|
|
726
|
+
│ Verification typically takes 1-2 business days. │
|
|
727
|
+
│ After verification, re-run: bunx bunpress deploy │
|
|
728
|
+
└─────────────────────────────────────────────────────────────────────────────┘`
|
|
729
|
+
console.log(verificationMessage)
|
|
730
|
+
return {
|
|
731
|
+
success: false,
|
|
732
|
+
stackId,
|
|
733
|
+
stackName,
|
|
734
|
+
bucket: finalBucket,
|
|
735
|
+
message: 'CloudFront account verification required. Please contact AWS Support.',
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
success: false,
|
|
741
|
+
stackId,
|
|
742
|
+
stackName,
|
|
743
|
+
bucket: finalBucket,
|
|
744
|
+
message: `Stack deployment failed: ${err.message}`,
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Get stack outputs
|
|
749
|
+
const stacks = await cf.describeStacks({ stackName })
|
|
750
|
+
const outputs = stacks.Stacks[0]?.Outputs || []
|
|
751
|
+
const getOutput = (key: string) => outputs.find(o => o.OutputKey === key)?.OutputValue
|
|
752
|
+
|
|
753
|
+
const distributionDomain = getOutput('DistributionDomain')
|
|
754
|
+
|
|
755
|
+
// Create DNS records using external DNS provider
|
|
756
|
+
if (distributionDomain && dnsProvider) {
|
|
757
|
+
console.log(`Creating DNS records via ${dnsProvider.name}...`)
|
|
758
|
+
await ensureDnsRecords(dnsProvider, domain, distributionDomain)
|
|
759
|
+
}
|
|
760
|
+
else if (distributionDomain && !dnsProvider) {
|
|
761
|
+
console.log(`Skipping DNS record creation (DNS verification was skipped)`)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
success: true,
|
|
766
|
+
stackId,
|
|
767
|
+
stackName,
|
|
768
|
+
bucket: getOutput('BucketName') || finalBucket,
|
|
769
|
+
distributionId: getOutput('DistributionId'),
|
|
770
|
+
distributionDomain,
|
|
771
|
+
domain,
|
|
772
|
+
certificateArn,
|
|
773
|
+
message: 'Static site infrastructure deployed successfully with external DNS',
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Create or update DNS records pointing to CloudFront
|
|
779
|
+
*/
|
|
780
|
+
async function ensureDnsRecords(
|
|
781
|
+
dnsProvider: DnsProvider | null,
|
|
782
|
+
domain: string,
|
|
783
|
+
cloudfrontDomain: string,
|
|
784
|
+
): Promise<void> {
|
|
785
|
+
// Skip DNS record creation if no provider (DNS verification was skipped)
|
|
786
|
+
if (!dnsProvider) {
|
|
787
|
+
console.log(`Skipping DNS record creation (DNS verification was skipped)`)
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
// Create CNAME record pointing to CloudFront
|
|
791
|
+
// Note: For apex domains (e.g., bunpress.org), some DNS providers support ALIAS/ANAME records
|
|
792
|
+
// Porkbun supports ALIAS records for apex domains
|
|
793
|
+
|
|
794
|
+
// Check if this is an apex domain (no subdomain)
|
|
795
|
+
const parts = domain.split('.')
|
|
796
|
+
const isApexDomain = parts.length === 2
|
|
797
|
+
|
|
798
|
+
if (isApexDomain) {
|
|
799
|
+
// For apex domains, create an ALIAS record (Porkbun supports this)
|
|
800
|
+
// Porkbun uses 'ALIAS' type for apex domain CNAME-like behavior
|
|
801
|
+
console.log(`Creating ALIAS record for apex domain ${domain} -> ${cloudfrontDomain}`)
|
|
802
|
+
|
|
803
|
+
const result = await dnsProvider.upsertRecord(domain, {
|
|
804
|
+
name: domain,
|
|
805
|
+
type: 'ALIAS' as any, // Porkbun-specific type for apex domains
|
|
806
|
+
content: cloudfrontDomain,
|
|
807
|
+
ttl: 600,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
if (!result.success) {
|
|
811
|
+
// Fallback to A record using CloudFront IPs (not recommended but works)
|
|
812
|
+
console.log(`ALIAS record failed, trying CNAME with @ subdomain...`)
|
|
813
|
+
const cnameResult = await dnsProvider.upsertRecord(domain, {
|
|
814
|
+
name: domain,
|
|
815
|
+
type: 'CNAME',
|
|
816
|
+
content: cloudfrontDomain,
|
|
817
|
+
ttl: 600,
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
if (!cnameResult.success) {
|
|
821
|
+
console.warn(`Warning: Could not create DNS record: ${cnameResult.message}`)
|
|
822
|
+
console.warn(`Please manually create a CNAME or ALIAS record:`)
|
|
823
|
+
console.warn(` ${domain} -> ${cloudfrontDomain}`)
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
console.log(`Created CNAME record: ${domain} -> ${cloudfrontDomain}`)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
console.log(`Created ALIAS record: ${domain} -> ${cloudfrontDomain}`)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Also create CNAME for www subdomain
|
|
834
|
+
const wwwDomain = `www.${domain}`
|
|
835
|
+
console.log(`Creating CNAME record for ${wwwDomain} -> ${cloudfrontDomain}`)
|
|
836
|
+
const wwwResult = await dnsProvider.upsertRecord(domain, {
|
|
837
|
+
name: wwwDomain,
|
|
838
|
+
type: 'CNAME',
|
|
839
|
+
content: cloudfrontDomain,
|
|
840
|
+
ttl: 600,
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
if (!wwwResult.success) {
|
|
844
|
+
console.warn(`Warning: Could not create www DNS record: ${wwwResult.message}`)
|
|
845
|
+
console.warn(`Please manually create a CNAME record:`)
|
|
846
|
+
console.warn(` ${wwwDomain} -> ${cloudfrontDomain}`)
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
console.log(`Created CNAME record: ${wwwDomain} -> ${cloudfrontDomain}`)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
// For subdomains, use standard CNAME
|
|
854
|
+
console.log(`Creating CNAME record for ${domain} -> ${cloudfrontDomain}`)
|
|
855
|
+
|
|
856
|
+
const result = await dnsProvider.upsertRecord(domain, {
|
|
857
|
+
name: domain,
|
|
858
|
+
type: 'CNAME',
|
|
859
|
+
content: cloudfrontDomain,
|
|
860
|
+
ttl: 600,
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
if (!result.success) {
|
|
864
|
+
console.warn(`Warning: Could not create DNS record: ${result.message}`)
|
|
865
|
+
console.warn(`Please manually create a CNAME record:`)
|
|
866
|
+
console.warn(` ${domain} -> ${cloudfrontDomain}`)
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
console.log(`Created CNAME record: ${domain} -> ${cloudfrontDomain}`)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Full deployment with external DNS: infrastructure + files + cache invalidation
|
|
876
|
+
*/
|
|
877
|
+
export async function deployStaticSiteWithExternalDnsFull(config: ExternalDnsStaticSiteConfig & {
|
|
878
|
+
sourceDir: string
|
|
879
|
+
cleanBucket?: boolean
|
|
880
|
+
onProgress?: (stage: string, detail?: string) => void
|
|
881
|
+
}): Promise<ExternalDnsDeployResult> {
|
|
882
|
+
const { sourceDir, cleanBucket = false, onProgress, ...siteConfig } = config
|
|
883
|
+
|
|
884
|
+
// Step 1: Deploy infrastructure
|
|
885
|
+
onProgress?.('infrastructure', 'Deploying CloudFormation stack...')
|
|
886
|
+
const infraResult = await deployStaticSiteWithExternalDns(siteConfig)
|
|
887
|
+
|
|
888
|
+
if (!infraResult.success) {
|
|
889
|
+
return infraResult
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Step 2: Clean bucket before upload
|
|
893
|
+
if (cleanBucket) {
|
|
894
|
+
onProgress?.('clean', 'Cleaning old files from S3...')
|
|
895
|
+
try {
|
|
896
|
+
const s3 = new S3Client(siteConfig.region || 'us-east-1')
|
|
897
|
+
await s3.emptyBucket(infraResult.bucket)
|
|
898
|
+
}
|
|
899
|
+
catch (err: any) {
|
|
900
|
+
console.log(`Note: Could not clean bucket: ${err.message}`)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Step 3: Upload files
|
|
905
|
+
onProgress?.('upload', 'Uploading files to S3...')
|
|
906
|
+
const { uploadStaticFiles } = await import('./static-site')
|
|
907
|
+
const uploadResult = await uploadStaticFiles({
|
|
908
|
+
sourceDir,
|
|
909
|
+
bucket: infraResult.bucket,
|
|
910
|
+
region: siteConfig.region || 'us-east-1',
|
|
911
|
+
cacheControl: siteConfig.cacheControl,
|
|
912
|
+
onProgress: (uploaded, total, file) => {
|
|
913
|
+
onProgress?.('upload', `${uploaded}/${total}: ${file}`)
|
|
914
|
+
},
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
if (uploadResult.errors.length > 0) {
|
|
918
|
+
return {
|
|
919
|
+
...infraResult,
|
|
920
|
+
success: false,
|
|
921
|
+
message: `Upload errors: ${uploadResult.errors.join(', ')}`,
|
|
922
|
+
filesUploaded: uploadResult.uploaded,
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Step 4: Invalidate cache (only if files were uploaded)
|
|
927
|
+
if (infraResult.distributionId && uploadResult.uploaded > 0) {
|
|
928
|
+
onProgress?.('invalidate', 'Invalidating CloudFront cache...')
|
|
929
|
+
const { invalidateCache } = await import('./static-site')
|
|
930
|
+
await invalidateCache(infraResult.distributionId)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
onProgress?.('complete', 'Deployment complete!')
|
|
934
|
+
|
|
935
|
+
const message = uploadResult.skipped > 0
|
|
936
|
+
? `Deployed ${uploadResult.uploaded} files (${uploadResult.skipped} unchanged) with external DNS`
|
|
937
|
+
: `Deployed ${uploadResult.uploaded} files successfully with external DNS`
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
...infraResult,
|
|
941
|
+
filesUploaded: uploadResult.uploaded,
|
|
942
|
+
filesSkipped: uploadResult.skipped,
|
|
943
|
+
message,
|
|
944
|
+
}
|
|
945
|
+
}
|