granny-devops 0.4.0__py3-none-any.whl

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.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1512 @@
1
+ import boto3
2
+ import os
3
+ import json
4
+ import time
5
+ import logging
6
+ import argparse
7
+ import requests
8
+
9
+ try:
10
+ from cloudflare import Cloudflare
11
+ except ImportError:
12
+ print("Cloudflare library not found. Please install it: pip install cloudflare")
13
+ exit(1)
14
+
15
+ from dotenv import load_dotenv
16
+ load_dotenv()
17
+ try:
18
+ from granny.credentials import load_secrets_into_env
19
+ load_secrets_into_env()
20
+ except Exception:
21
+ pass
22
+
23
+ """
24
+ This script sets up a CDN for static content using AWS S3, CloudFront, and Cloudflare.
25
+
26
+ SSL Configuration:
27
+ - When using --cloudflare-ssl (default): Cloudflare handles SSL termination and proxies to CloudFront.
28
+ CloudFront uses default certificate and NO aliases (Cloudflare points to CloudFront domain).
29
+ - When using --acm-ssl: AWS ACM certificate is created and CloudFront uses it with domain aliases.
30
+
31
+ SPA (Single Page Application) Support:
32
+ - Use --spa-mode to configure CloudFront for client-side routing (React Router, Vue Router, etc.)
33
+ - 404 errors will be redirected to your index.html (or custom path) with HTTP 200 status
34
+ - This allows SPAs to handle routing on the client side
35
+
36
+ Usage:
37
+ # Basic static site
38
+ python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --cloudflare-ssl
39
+
40
+ # Single Page Application
41
+ python setup_s3_website.py --domain lularge.com --subdomain app-dev --spa-mode
42
+
43
+ By default, it uses Cloudflare for SSL (controlled by the --cloudflare-ssl flag).
44
+ When --acm-ssl is used instead:
45
+ - An ACM certificate is created for the domain
46
+ - DNS validation is performed through Cloudflare
47
+ - CloudFront uses the ACM certificate and domain aliases
48
+ """
49
+
50
+ def parse_arguments():
51
+ """Parse command line arguments."""
52
+ parser = argparse.ArgumentParser(
53
+ description='Setup CDN with S3, CloudFront, and Cloudflare',
54
+ formatter_class=argparse.RawDescriptionHelpFormatter,
55
+ epilog="""
56
+ Examples:
57
+ # Basic create with Cloudflare SSL (default)
58
+ python setup_s3_website.py --domain lularge.com --subdomain cdn-staging
59
+
60
+ # Setup with ACM SSL instead of Cloudflare
61
+ python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --acm-ssl
62
+
63
+ # Use specific AWS profile
64
+ python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --aws-profile production
65
+
66
+ # Force recreate CloudFront distribution
67
+ python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --force
68
+
69
+ # Specify different region
70
+ python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --region eu-west-1
71
+
72
+ # Setup for Single Page Application (React, Vue, Angular, etc.)
73
+ python setup_s3_website.py --domain lularge.com --subdomain app-dev --spa-mode
74
+
75
+ # Custom SPA error handling
76
+ python setup_s3_website.py --domain lularge.com --subdomain app --spa-mode --spa-error-path /app.html --spa-error-code 200
77
+
78
+ Environment Variables:
79
+ CLOUDFLARE_API_TOKEN: Required - Your Cloudflare API token
80
+ AWS_PROFILE: Optional - AWS profile to use (can be overridden by --aws-profile)
81
+ """
82
+ )
83
+
84
+ parser.add_argument('--domain', required=True,
85
+ help='The root domain (e.g., lularge.com)')
86
+ parser.add_argument('--subdomain', required=True,
87
+ help='The subdomain for the CDN (e.g., cdn-staging)')
88
+ parser.add_argument('--region', default='us-east-1',
89
+ help='AWS region for S3 bucket (default: us-east-1)')
90
+ parser.add_argument('--aws-profile', default=None,
91
+ help='AWS profile to use for credentials (default: uses default profile or AWS_PROFILE env var)')
92
+
93
+ ssl_group = parser.add_mutually_exclusive_group()
94
+ ssl_group.add_argument('--cloudflare-ssl', action='store_true', default=True,
95
+ help='Use Cloudflare for SSL (default)')
96
+ ssl_group.add_argument('--acm-ssl', action='store_true',
97
+ help='Use AWS ACM for SSL instead of Cloudflare')
98
+
99
+ parser.add_argument('--test-timeout', type=int, default=60,
100
+ help='Timeout for CDN tests in seconds (default: 60)')
101
+ parser.add_argument('--skip-tests', action='store_true',
102
+ help='Skip the CDN functionality tests')
103
+ parser.add_argument('--force', action='store_true',
104
+ help='Force recreate CloudFront distribution even if it exists')
105
+
106
+ # SPA (Single Page Application) support
107
+ parser.add_argument('--spa-mode', action='store_true',
108
+ help='Configure CloudFront for Single Page Application routing (redirect 404s to index.html)')
109
+ parser.add_argument('--spa-error-path', default='/index.html',
110
+ help='Path to serve for 404 errors in SPA mode (default: /index.html)')
111
+ parser.add_argument('--spa-error-code', type=int, default=200,
112
+ help='HTTP response code for SPA 404 redirects (default: 200)')
113
+
114
+ return parser.parse_args()
115
+
116
+ # Parse command line arguments
117
+ args = parse_arguments()
118
+
119
+ # --- Configuration from command line ---
120
+ DOMAIN_NAME = args.domain
121
+ SUBDOMAIN = args.subdomain
122
+ BUCKET_NAME = f"{SUBDOMAIN}.{DOMAIN_NAME}"
123
+ CLOUDFLARE_ZONE_NAME = DOMAIN_NAME
124
+ AWS_REGION = args.region
125
+ AWS_PROFILE = args.aws_profile
126
+ USE_CLOUDFLARE_SSL = not args.acm_ssl # If --acm-ssl is specified, don't use Cloudflare SSL
127
+ TEST_TIMEOUT = args.test_timeout
128
+ SKIP_TESTS = args.skip_tests
129
+ FORCE_RECREATE = args.force
130
+ SPA_MODE = args.spa_mode
131
+ SPA_ERROR_PATH = args.spa_error_path
132
+ SPA_ERROR_CODE = args.spa_error_code
133
+
134
+ # --- Setup logging ---
135
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
136
+
137
+ # Log the configuration
138
+ logging.info("=== CDN Setup Configuration ===")
139
+ logging.info(f"Domain: {DOMAIN_NAME}")
140
+ logging.info(f"Subdomain: {SUBDOMAIN}")
141
+ logging.info(f"Full domain: {BUCKET_NAME}")
142
+ logging.info(f"AWS Region: {AWS_REGION}")
143
+ logging.info(f"AWS Profile: {AWS_PROFILE if AWS_PROFILE else 'default'}")
144
+ logging.info(f"SSL Method: {'Cloudflare' if USE_CLOUDFLARE_SSL else 'AWS ACM'}")
145
+ logging.info(f"Skip Tests: {SKIP_TESTS}")
146
+ logging.info(f"Force Recreate: {FORCE_RECREATE}")
147
+ logging.info(f"SPA Mode: {SPA_MODE}")
148
+ if SPA_MODE:
149
+ logging.info(f" SPA Error Path: {SPA_ERROR_PATH}")
150
+ logging.info(f" SPA Response Code: {SPA_ERROR_CODE}")
151
+ logging.info("===============================")
152
+
153
+ # --- Initialize AWS clients ---
154
+ def create_aws_clients(aws_profile=None, region=None):
155
+ """Create AWS clients with optional profile support.
156
+
157
+ Note: ACM client is ALWAYS created in us-east-1 because CloudFront
158
+ requires certificates to be in us-east-1 regardless of the S3 bucket region.
159
+ """
160
+ if aws_profile:
161
+ logging.info(f"Creating AWS clients using profile: {aws_profile}")
162
+ session = boto3.Session(profile_name=aws_profile)
163
+ else:
164
+ logging.info("Creating AWS clients using default credentials")
165
+ session = boto3.Session()
166
+
167
+ # S3 client uses the specified region for bucket operations
168
+ s3_region = region or 'us-east-1'
169
+
170
+ return {
171
+ 's3': session.client('s3', region_name=s3_region),
172
+ # ACM MUST be in us-east-1 for CloudFront - this is an AWS requirement
173
+ 'acm': session.client('acm', region_name='us-east-1'),
174
+ 'cloudfront': session.client('cloudfront'),
175
+ 'sts': session.client('sts')
176
+ }
177
+
178
+ # Create AWS clients with the specified profile
179
+ aws_clients = create_aws_clients(AWS_PROFILE, AWS_REGION)
180
+ s3_client = aws_clients['s3']
181
+ acm_client = aws_clients['acm']
182
+ cloudfront_client = aws_clients['cloudfront']
183
+ sts_client = aws_clients['sts']
184
+
185
+ def get_cloudflare_client():
186
+ """Gets Cloudflare API token from environment variable and returns a client."""
187
+ token = os.environ.get("CLOUDFLARE_API_TOKEN")
188
+ if not token:
189
+ logging.error("CLOUDFLARE_API_TOKEN environment variable not set.")
190
+ raise ValueError("CLOUDFLARE_API_TOKEN not found in environment variables.")
191
+ return Cloudflare(api_token=token)
192
+
193
+ def create_s3_bucket(bucket_name):
194
+ """Creates an S3 bucket if it doesn't exist."""
195
+ try:
196
+ logging.info(f"Checking if bucket '{bucket_name}' exists...")
197
+ s3_client.head_bucket(Bucket=bucket_name)
198
+ logging.info(f"Bucket '{bucket_name}' already exists.")
199
+ except s3_client.exceptions.ClientError as e:
200
+ if e.response['Error']['Code'] == '404':
201
+ logging.info(f"Bucket '{bucket_name}' does not exist. Creating...")
202
+ if AWS_REGION == 'us-east-1':
203
+ # For us-east-1, don't specify LocationConstraint
204
+ s3_client.create_bucket(Bucket=bucket_name)
205
+ else:
206
+ # For other regions, specify the LocationConstraint
207
+ s3_client.create_bucket(
208
+ Bucket=bucket_name,
209
+ CreateBucketConfiguration={'LocationConstraint': AWS_REGION}
210
+ )
211
+ logging.info(f"Bucket '{bucket_name}' created successfully.")
212
+ else:
213
+ logging.error(f"Error checking bucket: {e}")
214
+ raise
215
+
216
+ def create_test_content(bucket_name):
217
+ """Creates a simple test HTML file in the S3 bucket."""
218
+ logging.info(f"Creating test content in bucket '{bucket_name}'...")
219
+
220
+ test_html = f"""<!DOCTYPE html>
221
+ <html>
222
+ <head>
223
+ <meta charset="UTF-8">
224
+ <title>CDN Test Page</title>
225
+ <style>
226
+ body {{ font-family: Arial, sans-serif; margin: 40px; }}
227
+ .container {{ max-width: 600px; margin: 0 auto; text-align: center; }}
228
+ .success {{ color: #28a745; }}
229
+ .info {{ color: #17a2b8; background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <div class="container">
234
+ <h1 class="success">&#127881; CDN Setup Successful!</h1>
235
+ <p>Your CDN is working correctly.</p>
236
+ <div class="info">
237
+ <h3>Bucket Information</h3>
238
+ <p><strong>Bucket:</strong> {bucket_name}</p>
239
+ <p><strong>Region:</strong> {AWS_REGION}</p>
240
+ <p><strong>Served via:</strong> CloudFront + Cloudflare</p>
241
+ <p><strong>SSL:</strong> Cloudflare</p>
242
+ <p><strong>S3 Website Endpoint:</strong> {get_s3_website_endpoint(bucket_name, AWS_REGION)}</p>
243
+ </div>
244
+ <p><small>Generated by setup_s3_website.py</small></p>
245
+ </div>
246
+ </body>
247
+ </html>"""
248
+
249
+ error_html = f"""<!DOCTYPE html>
250
+ <html>
251
+ <head>
252
+ <meta charset="UTF-8">
253
+ <title>Page Not Found - CDN</title>
254
+ <style>
255
+ body {{ font-family: Arial, sans-serif; margin: 40px; }}
256
+ .container {{ max-width: 600px; margin: 0 auto; text-align: center; }}
257
+ .error {{ color: #dc3545; }}
258
+ .info {{ color: #6c757d; background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
259
+ </style>
260
+ </head>
261
+ <body>
262
+ <div class="container">
263
+ <h1 class="error">404 - Page Not Found</h1>
264
+ <p>The page you're looking for doesn't exist.</p>
265
+ <div class="info">
266
+ <p>This is a custom error page served from S3 static website hosting.</p>
267
+ <p><a href="/">← Back to Home</a></p>
268
+ </div>
269
+ <p><small>CDN: {bucket_name}</small></p>
270
+ </div>
271
+ </body>
272
+ </html>"""
273
+
274
+ try:
275
+ # Upload the test HTML file
276
+ s3_client.put_object(
277
+ Bucket=bucket_name,
278
+ Key='index-test.html',
279
+ Body=test_html.encode('utf-8'),
280
+ ContentType='text/html',
281
+ CacheControl='max-age=300'
282
+ )
283
+ logging.info("Test content (index-test.html) uploaded successfully.")
284
+
285
+ # Upload the error HTML file
286
+ s3_client.put_object(
287
+ Bucket=bucket_name,
288
+ Key='error.html',
289
+ Body=error_html.encode('utf-8'),
290
+ ContentType='text/html',
291
+ CacheControl='max-age=300'
292
+ )
293
+ logging.info("Error page (error.html) uploaded successfully.")
294
+
295
+ # Also create a simple test file for verification
296
+ test_text = f"CDN test file - {bucket_name} - Generated at {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}"
297
+ s3_client.put_object(
298
+ Bucket=bucket_name,
299
+ Key='test.txt',
300
+ Body=test_text.encode('utf-8'),
301
+ ContentType='text/plain',
302
+ CacheControl='max-age=300'
303
+ )
304
+ logging.info("Test file (test.txt) uploaded successfully.")
305
+
306
+ # If SPA mode is enabled, create a proper index.html file for SPA testing
307
+ if SPA_MODE:
308
+ spa_index_html = f"""<!DOCTYPE html>
309
+ <html>
310
+ <head>
311
+ <meta charset="UTF-8">
312
+ <title>SPA Test - {bucket_name}</title>
313
+ <style>
314
+ body {{ font-family: Arial, sans-serif; margin: 40px; }}
315
+ .container {{ max-width: 800px; margin: 0 auto; }}
316
+ .success {{ color: #28a745; }}
317
+ .info {{ color: #17a2b8; background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
318
+ .route {{ padding: 10px; margin: 10px 0; background: #e9ecef; border-radius: 3px; }}
319
+ a {{ color: #007bff; text-decoration: none; }}
320
+ a:hover {{ text-decoration: underline; }}
321
+ </style>
322
+ </head>
323
+ <body>
324
+ <div class="container">
325
+ <h1 class="success">&#127881; SPA-Enabled CDN Setup Successful!</h1>
326
+ <p>Your CDN is configured for Single Page Application routing.</p>
327
+ <div class="info">
328
+ <h3>Configuration</h3>
329
+ <p><strong>Bucket:</strong> {bucket_name}</p>
330
+ <p><strong>Region:</strong> {AWS_REGION}</p>
331
+ <p><strong>SPA Mode:</strong> Enabled</p>
332
+ <p><strong>Error Path:</strong> {SPA_ERROR_PATH}</p>
333
+ <p><strong>Error Code:</strong> {SPA_ERROR_CODE}</p>
334
+ </div>
335
+ <div class="info">
336
+ <h3>Test SPA Routing</h3>
337
+ <p>The following URLs should all serve this page (thanks to CloudFront custom error responses):</p>
338
+ <div class="route">
339
+ <a href="/">https://{bucket_name}/</a> - Root page
340
+ </div>
341
+ <div class="route">
342
+ <a href="/about">https://{bucket_name}/about</a> - Should serve this page
343
+ </div>
344
+ <div class="route">
345
+ <a href="/products/123">https://{bucket_name}/products/123</a> - Should serve this page
346
+ </div>
347
+ <div class="route">
348
+ <a href="/any/nested/route">https://{bucket_name}/any/nested/route</a> - Should serve this page
349
+ </div>
350
+ </div>
351
+ <p><small>Generated by setup_s3_website.py with --spa-mode</small></p>
352
+ </div>
353
+ </body>
354
+ </html>"""
355
+
356
+ s3_client.put_object(
357
+ Bucket=bucket_name,
358
+ Key='index.html',
359
+ Body=spa_index_html.encode('utf-8'),
360
+ ContentType='text/html',
361
+ CacheControl='max-age=300'
362
+ )
363
+ logging.info("SPA index.html uploaded successfully.")
364
+
365
+ except Exception as e:
366
+ logging.error(f"Error uploading test content: {e}")
367
+ raise
368
+
369
+ def find_existing_acm_certificate(domain_name):
370
+ """Find an existing ACM certificate that covers the domain."""
371
+ logging.info(f"Searching for existing ACM certificates for '{domain_name}'...")
372
+
373
+ try:
374
+ # List all certificates in the region
375
+ response = acm_client.list_certificates(
376
+ CertificateStatuses=['ISSUED'] # Only look for valid certificates
377
+ )
378
+
379
+ for cert_summary in response['CertificateSummaryList']:
380
+ cert_arn = cert_summary['CertificateArn']
381
+ cert_domain = cert_summary['DomainName']
382
+
383
+ # Get detailed certificate info to check Subject Alternative Names
384
+ cert_details = acm_client.describe_certificate(CertificateArn=cert_arn)
385
+ cert_info = cert_details['Certificate']
386
+
387
+ # Check if this certificate covers our domain
388
+ covered_domains = [cert_info['DomainName']]
389
+ if 'SubjectAlternativeNames' in cert_info:
390
+ covered_domains.extend(cert_info['SubjectAlternativeNames'])
391
+
392
+ for covered_domain in covered_domains:
393
+ # Check for exact match or wildcard match
394
+ if (covered_domain == domain_name or
395
+ (covered_domain.startswith('*.') and
396
+ domain_name.endswith(covered_domain[1:]))):
397
+
398
+ logging.info(f"Found existing certificate: {cert_arn}")
399
+ logging.info(f" Certificate domain: {cert_domain}")
400
+ logging.info(f" Covers domains: {covered_domains}")
401
+ logging.info(f" Status: {cert_info['Status']}")
402
+ return cert_arn
403
+
404
+ logging.info(f"No existing ACM certificate found for '{domain_name}'")
405
+ return None
406
+
407
+ except Exception as e:
408
+ logging.error(f"Error searching for existing certificates: {e}")
409
+ return None
410
+
411
+ def request_acm_certificate(domain_name):
412
+ """Requests an ACM certificate for the domain and returns its ARN and validation CNAME."""
413
+ logging.info(f"Requesting ACM certificate for '{domain_name}'...")
414
+ response = acm_client.request_certificate(
415
+ DomainName=domain_name,
416
+ ValidationMethod='DNS'
417
+ )
418
+ certificate_arn = response['CertificateArn']
419
+
420
+ logging.info(f"Waiting for certificate details to become available for {certificate_arn}...")
421
+ time.sleep(10) # Give some time for the validation options to be available
422
+
423
+ cert_description = acm_client.describe_certificate(CertificateArn=certificate_arn)
424
+ validation_options = cert_description['Certificate']['DomainValidationOptions'][0]
425
+
426
+ while 'ResourceRecord' not in validation_options:
427
+ logging.info("Waiting for DNS validation record to be created by AWS...")
428
+ time.sleep(10)
429
+ cert_description = acm_client.describe_certificate(CertificateArn=certificate_arn)
430
+ validation_options = cert_description['Certificate']['DomainValidationOptions'][0]
431
+
432
+ validation_cname = validation_options['ResourceRecord']
433
+ waiter = acm_client.get_waiter('certificate_validated')
434
+
435
+ logging.info(f"Certificate requested. ARN: {certificate_arn}")
436
+ logging.info(f"DNS validation record: {validation_cname['Name']} -> {validation_cname['Value']}")
437
+
438
+ return certificate_arn, validation_cname, waiter
439
+
440
+ def setup_dns_validation(cf_client, zone_name, validation_cname):
441
+ """Creates a CNAME record in Cloudflare for ACM validation."""
442
+ logging.info("Setting up DNS validation record in Cloudflare...")
443
+ try:
444
+ zones = cf_client.zones.list(name=zone_name)
445
+ if not zones.result:
446
+ logging.error(f"Zone '{zone_name}' not found in Cloudflare.")
447
+ raise Exception(f"Zone not found: {zone_name}")
448
+ zone_id = zones.result[0].id
449
+
450
+ dns_record = {
451
+ 'name': validation_cname['Name'],
452
+ 'type': validation_cname['Type'],
453
+ 'content': validation_cname['Value'].rstrip('.'),
454
+ 'proxied': False,
455
+ 'ttl': 60
456
+ }
457
+
458
+ cf_client.dns.records.create(zone_id=zone_id, **dns_record)
459
+ logging.info(f"DNS validation record created for {validation_cname['Name']}.")
460
+ return zone_id, dns_record
461
+ except Exception as e:
462
+ if "already exists" in str(e):
463
+ logging.warning(f"DNS record for {validation_cname['Name']} already exists.")
464
+ return None, None # Indicate that it already exists
465
+ logging.error(f"Cloudflare API error: {e}")
466
+ raise
467
+
468
+ def wait_for_cert_validation(waiter, certificate_arn):
469
+ """Waits for the ACM certificate to be validated."""
470
+ logging.info("Waiting for ACM certificate validation... This may take a few minutes.")
471
+ try:
472
+ waiter.wait(
473
+ CertificateArn=certificate_arn,
474
+ WaiterConfig={'Delay': 30, 'MaxAttempts': 20}
475
+ )
476
+ logging.info("Certificate has been validated successfully.")
477
+ except Exception as e:
478
+ logging.error(f"Certificate validation failed: {e}")
479
+ raise
480
+
481
+ def get_existing_oac(oac_name):
482
+ """Check if an OAC with the given name already exists."""
483
+ try:
484
+ response = cloudfront_client.list_origin_access_controls()
485
+ for oac in response['OriginAccessControlList']['Items']:
486
+ if oac['Name'] == oac_name:
487
+ logging.info(f"OAC '{oac_name}' already exists with ID: {oac['Id']}")
488
+ return oac['Id']
489
+ return None
490
+ except Exception as e:
491
+ logging.error(f"Error checking existing OACs: {e}")
492
+ return None
493
+
494
+ def create_cloudfront_oac():
495
+ """Creates a CloudFront Origin Access Control."""
496
+ oac_name = f"oac-{BUCKET_NAME}"
497
+ logging.info(f"Checking if OAC '{oac_name}' already exists...")
498
+
499
+ existing_oac_id = get_existing_oac(oac_name)
500
+ if existing_oac_id:
501
+ return existing_oac_id
502
+
503
+ logging.info("Creating CloudFront Origin Access Control (OAC)...")
504
+ response = cloudfront_client.create_origin_access_control(
505
+ OriginAccessControlConfig={
506
+ 'Name': oac_name,
507
+ 'Description': f"OAC for {BUCKET_NAME}",
508
+ 'SigningProtocol': 'sigv4',
509
+ 'SigningBehavior': 'always',
510
+ 'OriginAccessControlOriginType': 's3'
511
+ }
512
+ )
513
+ oac_id = response['OriginAccessControl']['Id']
514
+ logging.info(f"OAC created with ID: {oac_id}")
515
+ return oac_id
516
+
517
+ def get_existing_distribution(bucket_name):
518
+ """Check if a CloudFront distribution for the bucket already exists."""
519
+ try:
520
+ response = cloudfront_client.list_distributions()
521
+ if 'DistributionList' in response and 'Items' in response['DistributionList']:
522
+ for dist in response['DistributionList']['Items']:
523
+ # Check origins to see if any match our bucket
524
+ for origin in dist.get('Origins', {}).get('Items', []):
525
+ if bucket_name in origin.get('DomainName', ''):
526
+ logging.info(f"Found existing distribution for '{bucket_name}': {dist['Id']}")
527
+ return dist
528
+ return None
529
+ except Exception as e:
530
+ logging.error(f"Error checking existing distributions: {e}")
531
+ return None
532
+
533
+ def find_distribution_using_cname(domain_name):
534
+ """Find any CloudFront distribution that uses the specified domain as an alias."""
535
+ try:
536
+ logging.info(f"Searching for distributions using CNAME '{domain_name}'...")
537
+ response = cloudfront_client.list_distributions()
538
+ if 'DistributionList' in response and 'Items' in response['DistributionList']:
539
+ for dist in response['DistributionList']['Items']:
540
+ # Check aliases
541
+ aliases = dist.get('Aliases', {}).get('Items', [])
542
+ if domain_name in aliases:
543
+ logging.info(f"Found distribution using CNAME '{domain_name}': {dist['Id']}")
544
+ logging.info(f" Distribution status: {dist['Status']}")
545
+ logging.info(f" Distribution enabled: {dist['Enabled']}")
546
+ logging.info(f" All aliases: {aliases}")
547
+ return dist
548
+ return None
549
+ except Exception as e:
550
+ logging.error(f"Error searching for distributions using CNAME: {e}")
551
+ return None
552
+
553
+ def enable_s3_website_hosting(bucket_name):
554
+ """Enable static website hosting on the S3 bucket."""
555
+ logging.info(f"Enabling static website hosting for bucket '{bucket_name}'...")
556
+
557
+ try:
558
+ # First, disable block public access settings to allow public bucket policy
559
+ logging.info("Disabling block public access settings...")
560
+ s3_client.put_public_access_block(
561
+ Bucket=bucket_name,
562
+ PublicAccessBlockConfiguration={
563
+ 'BlockPublicAcls': False,
564
+ 'IgnorePublicAcls': False,
565
+ 'BlockPublicPolicy': False,
566
+ 'RestrictPublicBuckets': False
567
+ }
568
+ )
569
+ logging.info("Block public access settings disabled successfully.")
570
+
571
+ # Wait a moment for the settings to propagate
572
+ time.sleep(5)
573
+
574
+ # Configure the bucket for static website hosting
575
+ website_configuration = {
576
+ 'IndexDocument': {
577
+ 'Suffix': 'index.html'
578
+ },
579
+ 'ErrorDocument': {
580
+ 'Key': 'error.html'
581
+ }
582
+ }
583
+
584
+ s3_client.put_bucket_website(
585
+ Bucket=bucket_name,
586
+ WebsiteConfiguration=website_configuration
587
+ )
588
+ logging.info("S3 static website hosting enabled successfully.")
589
+
590
+ # Set bucket policy to allow public read access for website hosting
591
+ public_read_policy = {
592
+ "Version": "2012-10-17",
593
+ "Statement": [
594
+ {
595
+ "Sid": "PublicReadGetObject",
596
+ "Effect": "Allow",
597
+ "Principal": "*",
598
+ "Action": "s3:GetObject",
599
+ "Resource": f"arn:aws:s3:::{bucket_name}/*"
600
+ }
601
+ ]
602
+ }
603
+
604
+ s3_client.put_bucket_policy(
605
+ Bucket=bucket_name,
606
+ Policy=json.dumps(public_read_policy)
607
+ )
608
+ logging.info("S3 bucket policy updated for public read access.")
609
+
610
+ except Exception as e:
611
+ logging.error(f"Error enabling S3 website hosting: {e}")
612
+ raise
613
+
614
+ def get_s3_website_endpoint(bucket_name, region):
615
+ """Get the S3 static website endpoint for the bucket."""
616
+ if region == 'us-east-1':
617
+ return f"{bucket_name}.s3-website-us-east-1.amazonaws.com"
618
+ else:
619
+ return f"{bucket_name}.s3-website.{region}.amazonaws.com"
620
+
621
+ def update_cloudfront_distribution_origin(distribution_id, bucket_name, oac_id):
622
+ """Update an existing CloudFront distribution with the correct S3 website origin."""
623
+ logging.info(f"Updating CloudFront distribution {distribution_id} with S3 website origin...")
624
+
625
+ try:
626
+ # Get the current distribution configuration
627
+ response = cloudfront_client.get_distribution_config(Id=distribution_id)
628
+ distribution_config = response['DistributionConfig']
629
+ etag = response['ETag']
630
+
631
+ # Use the S3 website endpoint
632
+ s3_website_domain = get_s3_website_endpoint(bucket_name, AWS_REGION)
633
+
634
+ logging.info(f"Updating origin to use S3 website endpoint: {s3_website_domain}")
635
+
636
+ # Use the domain name (bucket_name) as the origin ID
637
+ origin_id = bucket_name
638
+
639
+ # Update the origin configuration
640
+ for origin in distribution_config.get('Origins', {}).get('Items', []):
641
+ if bucket_name in origin.get('DomainName', ''):
642
+ logging.info(f"Updating origin: {origin.get('DomainName')} -> {s3_website_domain}")
643
+ origin['Id'] = origin_id # Update the origin ID to use domain name
644
+ origin['DomainName'] = s3_website_domain
645
+
646
+ # Remove S3OriginConfig and OAC since we're using website endpoint
647
+ if 'S3OriginConfig' in origin:
648
+ del origin['S3OriginConfig']
649
+ if 'OriginAccessControlId' in origin:
650
+ del origin['OriginAccessControlId']
651
+
652
+ # Use CustomOriginConfig for S3 website endpoint
653
+ # OriginSslProtocols is required even for http-only
654
+ origin['CustomOriginConfig'] = {
655
+ 'HTTPPort': 80,
656
+ 'HTTPSPort': 443,
657
+ 'OriginProtocolPolicy': 'http-only',
658
+ 'OriginSslProtocols': {
659
+ 'Quantity': 1,
660
+ 'Items': ['TLSv1.2']
661
+ },
662
+ 'OriginReadTimeout': 30,
663
+ 'OriginKeepaliveTimeout': 5
664
+ }
665
+
666
+ logging.info(f"Origin configuration updated: {origin}")
667
+ break
668
+
669
+ # Update the default cache behavior to use the new origin ID
670
+ if 'DefaultCacheBehavior' in distribution_config:
671
+ distribution_config['DefaultCacheBehavior']['TargetOriginId'] = origin_id
672
+ logging.info(f"Updated default cache behavior to target origin ID: {origin_id}")
673
+ else:
674
+ logging.warning("DefaultCacheBehavior not found in distribution config, skipping target origin update")
675
+
676
+ # Ensure aliases are properly configured (always ensure aliases are configured)
677
+ aliases_items = distribution_config.get('Aliases', {}).get('Items', [])
678
+ if 'Aliases' not in distribution_config or not aliases_items:
679
+ logging.info(f"Adding missing alias: {bucket_name}")
680
+ distribution_config['Aliases'] = {
681
+ 'Quantity': 1,
682
+ 'Items': [bucket_name]
683
+ }
684
+ elif bucket_name not in aliases_items:
685
+ logging.info(f"Adding alias to existing list: {bucket_name}")
686
+ distribution_config['Aliases']['Items'].append(bucket_name)
687
+ distribution_config['Aliases']['Quantity'] = len(distribution_config['Aliases']['Items'])
688
+ else:
689
+ logging.info(f"Alias {bucket_name} already configured")
690
+
691
+ if USE_CLOUDFLARE_SSL:
692
+ logging.info("Using Cloudflare SSL - but aliases are still required for proper routing")
693
+
694
+ # Add custom error responses for SPA mode if updating existing distribution
695
+ if SPA_MODE:
696
+ existing_error_responses = distribution_config.get('CustomErrorResponses', {}).get('Items', [])
697
+ spa_error_exists = any(
698
+ err.get('ErrorCode') == 404 and err.get('ResponsePagePath') == SPA_ERROR_PATH
699
+ for err in existing_error_responses
700
+ )
701
+
702
+ if not spa_error_exists:
703
+ logging.info("Adding SPA mode error handling to existing distribution")
704
+ distribution_config['CustomErrorResponses'] = {
705
+ 'Quantity': 1,
706
+ 'Items': [{
707
+ 'ErrorCode': 404,
708
+ 'ResponsePagePath': SPA_ERROR_PATH,
709
+ 'ResponseCode': str(SPA_ERROR_CODE),
710
+ 'ErrorCachingMinTTL': 0
711
+ }]
712
+ }
713
+ else:
714
+ logging.info("SPA error handling already configured for distribution")
715
+
716
+ # Update the distribution
717
+ update_response = cloudfront_client.update_distribution(
718
+ Id=distribution_id,
719
+ DistributionConfig=distribution_config,
720
+ IfMatch=etag
721
+ )
722
+
723
+ logging.info("CloudFront distribution updated successfully.")
724
+ logging.info(f"Origin ID: {origin_id}")
725
+ logging.info("Note: It may take 5-15 minutes for changes to propagate globally.")
726
+ return update_response['Distribution']
727
+
728
+ except Exception as e:
729
+ logging.error(f"Error updating CloudFront distribution: {e}")
730
+ raise
731
+
732
+ def delete_cloudfront_distribution(distribution_id):
733
+ """Delete an existing CloudFront distribution."""
734
+ logging.info(f"Deleting CloudFront distribution {distribution_id}...")
735
+
736
+ try:
737
+ # First, get the distribution configuration
738
+ response = cloudfront_client.get_distribution_config(Id=distribution_id)
739
+ distribution_config = response['DistributionConfig']
740
+ etag = response['ETag']
741
+
742
+ # Disable the distribution first
743
+ if distribution_config['Enabled']:
744
+ logging.info("Disabling CloudFront distribution...")
745
+ distribution_config['Enabled'] = False
746
+
747
+ cloudfront_client.update_distribution(
748
+ Id=distribution_id,
749
+ DistributionConfig=distribution_config,
750
+ IfMatch=etag
751
+ )
752
+
753
+ # Wait for the distribution to be disabled
754
+ logging.info("Waiting for distribution to be disabled...")
755
+ waiter = cloudfront_client.get_waiter('distribution_deployed')
756
+ waiter.wait(Id=distribution_id, WaiterConfig={'Delay': 30, 'MaxAttempts': 20})
757
+
758
+ # Get the updated ETag after disabling
759
+ response = cloudfront_client.get_distribution_config(Id=distribution_id)
760
+ etag = response['ETag']
761
+
762
+ # Now delete the distribution
763
+ logging.info("Deleting CloudFront distribution...")
764
+ cloudfront_client.delete_distribution(Id=distribution_id, IfMatch=etag)
765
+ logging.info(f"CloudFront distribution {distribution_id} deleted successfully.")
766
+
767
+ # Wait a bit for the deletion to propagate and release CNAMEs
768
+ logging.info("Waiting 30 seconds for distribution deletion to propagate...")
769
+ time.sleep(30)
770
+
771
+ except Exception as e:
772
+ logging.error(f"Error deleting CloudFront distribution: {e}")
773
+ raise
774
+
775
+ def update_distribution_spa_mode(distribution_id, error_path='/index.html', response_code=200):
776
+ """Update an existing CloudFront distribution to handle SPA routing."""
777
+ logging.info(f"Updating CloudFront distribution {distribution_id} for SPA mode...")
778
+
779
+ try:
780
+ # Get the current distribution configuration
781
+ response = cloudfront_client.get_distribution_config(Id=distribution_id)
782
+ distribution_config = response['DistributionConfig']
783
+ etag = response['ETag']
784
+
785
+ # Check if SPA error handling already exists
786
+ existing_error_responses = distribution_config.get('CustomErrorResponses', {}).get('Items', [])
787
+ spa_error_exists = any(
788
+ err.get('ErrorCode') == 404 and err.get('ResponsePagePath') == error_path
789
+ for err in existing_error_responses
790
+ )
791
+
792
+ if spa_error_exists:
793
+ logging.info(f"SPA error handling already configured for distribution {distribution_id}")
794
+ return True
795
+
796
+ # Add or update custom error responses
797
+ new_error_response = {
798
+ 'ErrorCode': 404,
799
+ 'ResponsePagePath': error_path,
800
+ 'ResponseCode': str(response_code),
801
+ 'ErrorCachingMinTTL': 0
802
+ }
803
+
804
+ if existing_error_responses:
805
+ # Update existing 404 handler or add new one
806
+ found_404 = False
807
+ for err in existing_error_responses:
808
+ if err.get('ErrorCode') == 404:
809
+ err.update(new_error_response)
810
+ found_404 = True
811
+ break
812
+
813
+ if not found_404:
814
+ existing_error_responses.append(new_error_response)
815
+
816
+ distribution_config['CustomErrorResponses'] = {
817
+ 'Quantity': len(existing_error_responses),
818
+ 'Items': existing_error_responses
819
+ }
820
+ else:
821
+ # Create new custom error responses
822
+ distribution_config['CustomErrorResponses'] = {
823
+ 'Quantity': 1,
824
+ 'Items': [new_error_response]
825
+ }
826
+
827
+ # Update the distribution
828
+ logging.info(f"Adding SPA mode: 404 errors will return '{error_path}' with status {response_code}")
829
+ cloudfront_client.update_distribution(
830
+ Id=distribution_id,
831
+ DistributionConfig=distribution_config,
832
+ IfMatch=etag
833
+ )
834
+
835
+ logging.info("Distribution updated successfully for SPA mode.")
836
+ logging.info("Note: It may take 5-15 minutes for changes to propagate globally.")
837
+ return True
838
+
839
+ except Exception as e:
840
+ logging.error(f"Error updating distribution for SPA mode: {e}")
841
+ raise
842
+
843
+ def ensure_distribution_aliases(distribution_id, bucket_name):
844
+ """Ensure the CloudFront distribution has the correct aliases configured."""
845
+ logging.info(f"Checking aliases for CloudFront distribution {distribution_id}...")
846
+
847
+ try:
848
+ # Get the current distribution configuration
849
+ response = cloudfront_client.get_distribution_config(Id=distribution_id)
850
+ distribution_config = response['DistributionConfig']
851
+ etag = response['ETag']
852
+
853
+ # Check if aliases are properly configured
854
+ aliases_missing = False
855
+ current_aliases = distribution_config.get('Aliases', {}).get('Items', [])
856
+
857
+ if not current_aliases:
858
+ logging.info(f"No aliases found, adding: {bucket_name}")
859
+ aliases_missing = True
860
+ elif bucket_name not in current_aliases:
861
+ logging.info(f"Alias {bucket_name} missing from current aliases: {current_aliases}")
862
+ aliases_missing = True
863
+ else:
864
+ logging.info(f"Alias {bucket_name} already configured correctly")
865
+ return True
866
+
867
+ if aliases_missing:
868
+ # Add or update aliases
869
+ if bucket_name not in current_aliases:
870
+ current_aliases.append(bucket_name)
871
+
872
+ distribution_config['Aliases'] = {
873
+ 'Quantity': len(current_aliases),
874
+ 'Items': current_aliases
875
+ }
876
+
877
+ # Update the distribution
878
+ logging.info(f"Updating distribution aliases to: {current_aliases}")
879
+ cloudfront_client.update_distribution(
880
+ Id=distribution_id,
881
+ DistributionConfig=distribution_config,
882
+ IfMatch=etag
883
+ )
884
+
885
+ logging.info("Distribution aliases updated successfully.")
886
+ logging.info("Note: It may take 5-15 minutes for changes to propagate globally.")
887
+ return True
888
+
889
+ except Exception as e:
890
+ logging.error(f"Error updating distribution aliases: {e}")
891
+ raise
892
+
893
+ def create_cloudfront_distribution(bucket_name, certificate_arn, oac_id):
894
+ """Creates or updates a CloudFront distribution."""
895
+ logging.info(f"Checking if CloudFront distribution for '{bucket_name}' already exists...")
896
+
897
+ # First check for distributions using our bucket
898
+ existing_dist = get_existing_distribution(bucket_name)
899
+
900
+ # Also check for any distribution using our domain as CNAME (to handle conflicts)
901
+ cname_conflict_dist = find_distribution_using_cname(bucket_name)
902
+
903
+ # If there's a CNAME conflict with a different distribution, handle it
904
+ if cname_conflict_dist and (not existing_dist or cname_conflict_dist['Id'] != existing_dist['Id']):
905
+ logging.warning(f"CNAME conflict detected! Domain '{bucket_name}' is used by distribution {cname_conflict_dist['Id']}")
906
+ if FORCE_RECREATE:
907
+ logging.info("Force recreate enabled - deleting conflicting distribution...")
908
+ delete_cloudfront_distribution(cname_conflict_dist['Id'])
909
+ logging.info("Conflicting distribution deleted, proceeding with creation...")
910
+ else:
911
+ logging.error(f"Cannot proceed: Domain '{bucket_name}' is already used by distribution {cname_conflict_dist['Id']}")
912
+ logging.error("Options:")
913
+ logging.error(" 1. Use --force flag to delete the conflicting distribution")
914
+ logging.error(" 2. Choose a different subdomain")
915
+ logging.error(" 3. Manually delete the conflicting distribution first")
916
+ raise Exception(f"CNAME conflict: {bucket_name} already in use by distribution {cname_conflict_dist['Id']}")
917
+
918
+ if existing_dist:
919
+ if FORCE_RECREATE:
920
+ logging.info(f"Force recreate enabled - deleting existing distribution: {existing_dist['Id']}")
921
+ delete_cloudfront_distribution(existing_dist['Id'])
922
+ logging.info("Existing distribution deleted, creating new one...")
923
+ else:
924
+ logging.info(f"Found existing CloudFront distribution: {existing_dist['Id']}")
925
+
926
+ # Check if the origin is using the correct endpoint
927
+ current_origin = None
928
+ for origin in existing_dist.get('Origins', {}).get('Items', []):
929
+ if bucket_name in origin.get('DomainName', ''):
930
+ current_origin = origin
931
+ break
932
+
933
+ origin_needs_update = False
934
+ if current_origin:
935
+ current_domain = current_origin.get('DomainName', '')
936
+ expected_domain = get_s3_website_endpoint(bucket_name, AWS_REGION)
937
+
938
+ logging.info(f"Current origin domain: {current_domain}")
939
+ logging.info(f"Expected origin domain: {expected_domain}")
940
+
941
+ if current_domain != expected_domain or 'CustomOriginConfig' not in current_origin:
942
+ origin_needs_update = True
943
+ else:
944
+ logging.warning("Could not find matching origin in existing distribution.")
945
+ origin_needs_update = True
946
+
947
+ # Check if aliases are properly configured (always check now, regardless of SSL mode)
948
+ aliases_need_update = False
949
+ current_aliases = existing_dist.get('Aliases', {}).get('Items', [])
950
+ aliases_need_update = bucket_name not in current_aliases
951
+ logging.info(f"Current aliases: {current_aliases}")
952
+ logging.info(f"Aliases need update: {aliases_need_update}")
953
+ if USE_CLOUDFLARE_SSL:
954
+ logging.info("Using Cloudflare SSL - but still ensuring aliases are configured")
955
+
956
+ # Check if SPA mode needs to be configured
957
+ spa_needs_update = False
958
+ if SPA_MODE:
959
+ existing_error_responses = existing_dist.get('CustomErrorResponses', {}).get('Items', [])
960
+ spa_error_exists = any(
961
+ err.get('ErrorCode') == 404 and err.get('ResponsePagePath') == SPA_ERROR_PATH
962
+ for err in existing_error_responses
963
+ )
964
+ spa_needs_update = not spa_error_exists
965
+ logging.info(f"SPA mode configured: {not spa_needs_update}")
966
+
967
+ if origin_needs_update or spa_needs_update:
968
+ logging.info("Origin domain or SPA mode needs updating...")
969
+ return update_cloudfront_distribution_origin(existing_dist['Id'], bucket_name, oac_id)
970
+ elif aliases_need_update:
971
+ logging.info("Origin is correct but aliases need updating...")
972
+ ensure_distribution_aliases(existing_dist['Id'], bucket_name)
973
+ if spa_needs_update:
974
+ update_distribution_spa_mode(existing_dist['Id'], SPA_ERROR_PATH, SPA_ERROR_CODE)
975
+ return existing_dist
976
+ else:
977
+ logging.info("Origin domain and aliases are correct, using existing distribution.")
978
+ return existing_dist
979
+
980
+ logging.info(f"Creating new CloudFront distribution for '{bucket_name}'...")
981
+ caller_reference = f"dist-for-{bucket_name}-{int(time.time())}"
982
+
983
+ # Use the S3 website endpoint
984
+ s3_website_domain = get_s3_website_endpoint(bucket_name, AWS_REGION)
985
+
986
+ logging.info(f"Using S3 website endpoint: {s3_website_domain}")
987
+
988
+ # Create the origin configuration for S3 website endpoint
989
+ # Use the domain name (bucket_name) as the origin ID instead of generic naming
990
+ origin_id = bucket_name
991
+
992
+ origin_config = {
993
+ 'Id': origin_id,
994
+ 'DomainName': s3_website_domain,
995
+ 'CustomOriginConfig': {
996
+ 'HTTPPort': 80,
997
+ 'HTTPSPort': 443,
998
+ 'OriginProtocolPolicy': 'http-only',
999
+ 'OriginSslProtocols': {
1000
+ 'Quantity': 1,
1001
+ 'Items': ['TLSv1.2']
1002
+ },
1003
+ 'OriginReadTimeout': 30,
1004
+ 'OriginKeepaliveTimeout': 5
1005
+ }
1006
+ }
1007
+
1008
+ distribution_config = {
1009
+ 'CallerReference': caller_reference,
1010
+ 'Comment': f"Distribution for {bucket_name}",
1011
+ 'Enabled': True,
1012
+ 'DefaultRootObject': 'index.html',
1013
+ 'Origins': {
1014
+ 'Quantity': 1,
1015
+ 'Items': [origin_config]
1016
+ },
1017
+ 'DefaultCacheBehavior': {
1018
+ 'TargetOriginId': origin_id,
1019
+ 'ViewerProtocolPolicy': 'redirect-to-https',
1020
+ 'AllowedMethods': {
1021
+ 'Quantity': 2,
1022
+ 'Items': ['GET', 'HEAD'],
1023
+ 'CachedMethods': { 'Quantity': 2, 'Items': ['GET', 'HEAD']}
1024
+ },
1025
+ 'Compress': True,
1026
+ 'ForwardedValues': {
1027
+ 'QueryString': False,
1028
+ 'Cookies': {'Forward': 'none'}
1029
+ },
1030
+ 'TrustedSigners': {
1031
+ 'Enabled': False,
1032
+ 'Quantity': 0
1033
+ },
1034
+ 'MinTTL': 0,
1035
+ 'DefaultTTL': 86400,
1036
+ 'MaxTTL': 31536000,
1037
+ }
1038
+ }
1039
+
1040
+ # Add custom error responses for SPA mode
1041
+ if SPA_MODE:
1042
+ distribution_config['CustomErrorResponses'] = {
1043
+ 'Quantity': 1,
1044
+ 'Items': [{
1045
+ 'ErrorCode': 404,
1046
+ 'ResponsePagePath': SPA_ERROR_PATH,
1047
+ 'ResponseCode': str(SPA_ERROR_CODE),
1048
+ 'ErrorCachingMinTTL': 0 # Don't cache error responses
1049
+ }]
1050
+ }
1051
+ logging.info(f"Configured SPA mode: 404 errors will return '{SPA_ERROR_PATH}' with status {SPA_ERROR_CODE}")
1052
+
1053
+ # Add SSL certificate configuration and aliases
1054
+ # Always add aliases since CloudFront requires them for custom domains
1055
+ distribution_config['Aliases'] = {
1056
+ 'Quantity': 1,
1057
+ 'Items': [bucket_name]
1058
+ }
1059
+
1060
+ if not USE_CLOUDFLARE_SSL and certificate_arn:
1061
+ # Use ACM certificate for SSL when explicitly requested
1062
+ distribution_config['ViewerCertificate'] = {
1063
+ 'ACMCertificateArn': certificate_arn,
1064
+ 'SSLSupportMethod': 'sni-only',
1065
+ 'MinimumProtocolVersion': 'TLSv1.2_2021'
1066
+ }
1067
+ else:
1068
+ # For Cloudflare SSL mode, try to find existing certificate
1069
+ # If no certificate is provided, try to find an existing one
1070
+ if not certificate_arn:
1071
+ certificate_arn = find_existing_acm_certificate(bucket_name)
1072
+
1073
+ if certificate_arn:
1074
+ # Use ACM certificate (either existing or newly created)
1075
+ logging.info(f"Using ACM certificate for aliases: {certificate_arn}")
1076
+ distribution_config['ViewerCertificate'] = {
1077
+ 'ACMCertificateArn': certificate_arn,
1078
+ 'SSLSupportMethod': 'sni-only',
1079
+ 'MinimumProtocolVersion': 'TLSv1.2_2021'
1080
+ }
1081
+ else:
1082
+ # This should not happen anymore since we create certificates automatically
1083
+ logging.error(f"No ACM certificate available for '{bucket_name}' - this should not happen!")
1084
+ raise Exception("Certificate creation failed - cannot proceed without valid SSL certificate for aliases")
1085
+
1086
+ try:
1087
+ response = cloudfront_client.create_distribution(DistributionConfig=distribution_config)
1088
+ distribution = response['Distribution']
1089
+ logging.info(f"CloudFront distribution created. ID: {distribution['Id']}")
1090
+ logging.info(f"Domain Name: {distribution['DomainName']}")
1091
+ logging.info(f"Origin ID: {origin_id}")
1092
+ return distribution
1093
+ except Exception as e:
1094
+ error_str = str(e)
1095
+ if "CNAMEAlreadyExists" in error_str:
1096
+ logging.error(f"CNAME conflict error: {e}")
1097
+ logging.error(f"The domain '{bucket_name}' is still associated with another CloudFront distribution.")
1098
+ logging.error("This can happen when:")
1099
+ logging.error(" 1. A distribution was recently deleted but not fully propagated")
1100
+ logging.error(" 2. Another distribution is using the same domain")
1101
+ logging.error(" 3. DNS propagation delays")
1102
+ logging.error("\nTroubleshooting steps:")
1103
+ logging.error(" 1. Wait 5-10 minutes and try again")
1104
+ logging.error(" 2. Check for other distributions using this domain:")
1105
+ logging.error(f" create cloudfront list-distributions --query 'DistributionList.Items[?Aliases.Items[?contains(@, `{bucket_name}`)]]'")
1106
+ logging.error(" 3. If found, delete the conflicting distribution manually")
1107
+
1108
+ # Try to find the conflicting distribution and update the CNAME
1109
+ logging.info("Attempting to resolve CNAME conflict by updating the CNAME record...")
1110
+
1111
+ # Create the distribution without the alias
1112
+ logging.info("Creating distribution without the conflicting alias...")
1113
+ distribution_config['Aliases'] = {'Quantity': 0, 'Items': []}
1114
+
1115
+ try:
1116
+ response = cloudfront_client.create_distribution(DistributionConfig=distribution_config)
1117
+ distribution = response['Distribution']
1118
+ distribution_domain = distribution['DomainName']
1119
+ logging.info(f"CloudFront distribution created without alias. ID: {distribution['Id']}")
1120
+ logging.info(f"Domain Name: {distribution_domain}")
1121
+
1122
+ # Update the Cloudflare CNAME record to point to the new distribution
1123
+ logging.info(f"Updating Cloudflare CNAME record to point to the new distribution: {distribution_domain}")
1124
+ cf_client = get_cloudflare_client()
1125
+ zone_id = get_cloudflare_zone_id(cf_client, CLOUDFLARE_ZONE_NAME)
1126
+ create_cloudflare_cname_record(cf_client, zone_id, SUBDOMAIN, distribution_domain)
1127
+
1128
+ logging.info("CNAME record updated successfully to point to the new distribution.")
1129
+ return distribution
1130
+ except Exception as inner_e:
1131
+ logging.error(f"Error creating distribution without alias: {inner_e}")
1132
+ raise inner_e
1133
+ else:
1134
+ logging.error(f"Error creating CloudFront distribution: {e}")
1135
+ raise
1136
+
1137
+ def update_s3_bucket_policy(bucket_name, distribution_arn):
1138
+ """Updates S3 bucket policy to grant access to CloudFront."""
1139
+ logging.info(f"Updating bucket policy for '{bucket_name}'...")
1140
+ policy = {
1141
+ "Version": "2012-10-17",
1142
+ "Statement": [
1143
+ {
1144
+ "Sid": "AllowCloudFrontServicePrincipal",
1145
+ "Effect": "Allow",
1146
+ "Principal": {"Service": "cloudfront.amazonaws.com"},
1147
+ "Action": "s3:GetObject",
1148
+ "Resource": f"arn:create:s3:::{bucket_name}/*",
1149
+ "Condition": {"StringEquals": {"AWS:SourceArn": distribution_arn}}
1150
+ }
1151
+ ]
1152
+ }
1153
+ s3_client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(policy))
1154
+ logging.info("Bucket policy updated successfully.")
1155
+
1156
+ def get_cloudflare_zone_id(cf_client, zone_name):
1157
+ """Get the Cloudflare zone ID for the domain."""
1158
+ zones = cf_client.zones.list(name=zone_name)
1159
+ if not zones.result:
1160
+ logging.error(f"Zone '{zone_name}' not found in Cloudflare.")
1161
+ raise Exception(f"Zone not found: {zone_name}")
1162
+ return zones.result[0].id
1163
+
1164
+ def create_cloudflare_cname_record(cf_client, zone_id, cname_name, cname_content):
1165
+ """Creates or updates a CNAME record in Cloudflare pointing to CloudFront."""
1166
+ # Build the full domain name for the CNAME record
1167
+ full_domain_name = f"{cname_name}.{CLOUDFLARE_ZONE_NAME}"
1168
+ logging.info(f"Creating/updating CNAME record for '{full_domain_name}' -> '{cname_content}'...")
1169
+
1170
+ try:
1171
+ # Search for any existing records with the same name (A, AAAA, or CNAME)
1172
+ # Try both the subdomain and the full domain name
1173
+ existing_records = cf_client.dns.records.list(zone_id=zone_id, name=full_domain_name)
1174
+
1175
+ # If not found with FQDN, try with just the subdomain
1176
+ if not existing_records.result:
1177
+ logging.info(f"No records found for '{full_domain_name}', trying '{cname_name}'...")
1178
+ existing_records = cf_client.dns.records.list(zone_id=zone_id, name=cname_name)
1179
+
1180
+ dns_record = {
1181
+ 'name': cname_name, # Cloudflare expects just the subdomain when creating/updating
1182
+ 'type': 'CNAME',
1183
+ 'content': cname_content,
1184
+ 'proxied': False # Don't proxy - CloudFront handles SSL with ACM certificate
1185
+ }
1186
+
1187
+ cname_record_exists = False
1188
+ records_to_delete = []
1189
+
1190
+ if existing_records.result:
1191
+ logging.info(f"Found {len(existing_records.result)} existing record(s) for the domain")
1192
+ for record in existing_records.result:
1193
+ record_type = record.type
1194
+ record_id = record.id
1195
+ record_name = record.name
1196
+
1197
+ logging.info(f" Record name: '{record_name}', type: {record_type}, ID: {record_id}")
1198
+
1199
+ if record_type == 'CNAME':
1200
+ # Found existing CNAME record
1201
+ cname_record_exists = True
1202
+ current_content = record.content
1203
+ current_proxied = record.proxied
1204
+
1205
+ logging.info(f" Current CNAME content: '{current_content}'")
1206
+ logging.info(f" Target CNAME content: '{cname_content}'")
1207
+ logging.info(f" Current proxied: {current_proxied}")
1208
+ logging.info(" Target proxied: False")
1209
+
1210
+ # Check if we need to update it
1211
+ if current_content != cname_content or current_proxied:
1212
+ logging.info(f"Updating existing CNAME record '{record_name}' (ID: {record_id})")
1213
+ logging.info(f" Old target: {current_content} (proxied: {current_proxied})")
1214
+ logging.info(f" New target: {cname_content} (proxied: False)")
1215
+ cf_client.dns.records.update(zone_id=zone_id, dns_record_id=record_id, **dns_record)
1216
+ logging.info("Cloudflare CNAME record updated successfully.")
1217
+ else:
1218
+ logging.info(f"CNAME record for '{record_name}' is already pointing to the correct target.")
1219
+ elif record_type in ['A', 'AAAA']:
1220
+ # Found conflicting A or AAAA record - need to delete it first
1221
+ logging.info(f"Found conflicting {record_type} record for '{record_name}' (ID: {record_id})")
1222
+ logging.info(f" {record_type} record content: {record.content}")
1223
+ records_to_delete.append((record_id, record_type))
1224
+ else:
1225
+ logging.info(f"No existing records found for '{full_domain_name}' or '{cname_name}'")
1226
+
1227
+ # Delete conflicting A/AAAA records
1228
+ for record_id, record_type in records_to_delete:
1229
+ logging.info(f"Deleting conflicting {record_type} record (ID: {record_id})")
1230
+ cf_client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
1231
+ logging.info(f"Conflicting {record_type} record deleted successfully.")
1232
+
1233
+ # Create new CNAME record if none existed or we deleted conflicting records
1234
+ if not cname_record_exists or records_to_delete:
1235
+ logging.info(f"Creating new CNAME record for '{cname_name}'")
1236
+ cf_client.dns.records.create(zone_id=zone_id, **dns_record)
1237
+ logging.info("Cloudflare CNAME record created successfully.")
1238
+
1239
+ except Exception as e:
1240
+ logging.error(f"Error creating/updating Cloudflare CNAME record: {e}")
1241
+ raise
1242
+
1243
+ def cleanup_dns_validation(cf_client, zone_id, dns_record_to_delete):
1244
+ """Removes the DNS validation record from Cloudflare."""
1245
+ if not dns_record_to_delete:
1246
+ return
1247
+ logging.info(f"Cleaning up DNS validation record: {dns_record_to_delete['name']}")
1248
+ try:
1249
+ # We need the record ID to delete it.
1250
+ records = cf_client.dns.records.list(zone_id=zone_id, name=dns_record_to_delete['name'], type=dns_record_to_delete['type'])
1251
+ if records.result:
1252
+ record_id = records.result[0].id
1253
+ cf_client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
1254
+ logging.info("DNS validation record cleaned up successfully.")
1255
+ else:
1256
+ logging.warning("Could not find DNS validation record to cleanup.")
1257
+ except Exception as e:
1258
+ logging.error(f"Error cleaning up DNS record: {e}")
1259
+
1260
+ def test_cdn_functionality(domain, timeout=60):
1261
+ """Test the CDN functionality by fetching pages and validating responses."""
1262
+ logging.info("\n=== Testing CDN Functionality ===")
1263
+
1264
+ test_urls = [
1265
+ f"https://{domain}/index-test.html",
1266
+ f"https://{domain}/test.txt",
1267
+ f"https://{domain}/nonexistent-page.html" # Should return 404 with custom error page
1268
+ ]
1269
+
1270
+ # Add SPA routing tests if SPA mode is enabled
1271
+ if SPA_MODE:
1272
+ test_urls.extend([
1273
+ f"https://{domain}/", # Root should work
1274
+ f"https://{domain}/about", # SPA route should serve index.html
1275
+ f"https://{domain}/products/123", # Nested SPA route should serve index.html
1276
+ f"https://{domain}/any/nested/route" # Deep nested route should serve index.html
1277
+ ])
1278
+
1279
+ results = {}
1280
+
1281
+ for url in test_urls:
1282
+ logging.info(f"Testing URL: {url}")
1283
+
1284
+ try:
1285
+ # Make request with timeout
1286
+ response = requests.get(url, timeout=timeout, allow_redirects=True)
1287
+
1288
+ results[url] = {
1289
+ 'status_code': response.status_code,
1290
+ 'success': True,
1291
+ 'response_time': response.elapsed.total_seconds(),
1292
+ 'content_length': len(response.content),
1293
+ 'headers': dict(response.headers)
1294
+ }
1295
+
1296
+ # Validate specific responses
1297
+ if url.endswith('index-test.html'):
1298
+ # Main test page should return 200 and contain success message
1299
+ if response.status_code == 200:
1300
+ if 'CDN Setup Successful' in response.text:
1301
+ logging.info("✅ Test page test PASSED - Found success message")
1302
+ results[url]['validation'] = 'PASSED'
1303
+ else:
1304
+ logging.warning("⚠️ Test page test PARTIAL - Page loaded but missing expected content")
1305
+ results[url]['validation'] = 'PARTIAL'
1306
+ else:
1307
+ logging.error(f"❌ Test page test FAILED - Status: {response.status_code}")
1308
+ results[url]['validation'] = 'FAILED'
1309
+
1310
+ elif url.endswith('.txt'):
1311
+ # Text file should return 200 and contain expected content
1312
+ if response.status_code == 200:
1313
+ if 'CDN test file' in response.text:
1314
+ logging.info("✅ Text file test PASSED - Found expected content")
1315
+ results[url]['validation'] = 'PASSED'
1316
+ else:
1317
+ logging.warning("⚠️ Text file test PARTIAL - File loaded but unexpected content")
1318
+ results[url]['validation'] = 'PARTIAL'
1319
+ else:
1320
+ logging.error(f"❌ Text file test FAILED - Status: {response.status_code}")
1321
+ results[url]['validation'] = 'FAILED'
1322
+
1323
+ elif 'nonexistent' in url:
1324
+ # 404 page should return 404 and show custom error page (unless SPA mode)
1325
+ if SPA_MODE:
1326
+ # In SPA mode, this should return 200 with index.html content
1327
+ if response.status_code == 200:
1328
+ if 'SPA-Enabled CDN Setup Successful' in response.text:
1329
+ logging.info("✅ SPA 404 redirect test PASSED - Returned index.html with 200")
1330
+ results[url]['validation'] = 'PASSED'
1331
+ else:
1332
+ logging.warning("⚠️ SPA 404 redirect test PARTIAL - 200 returned but not index.html")
1333
+ results[url]['validation'] = 'PARTIAL'
1334
+ else:
1335
+ logging.error(f"❌ SPA 404 redirect test FAILED - Status: {response.status_code} (expected 200)")
1336
+ results[url]['validation'] = 'FAILED'
1337
+ else:
1338
+ # Normal mode - expect 404 with custom error page
1339
+ if response.status_code == 404:
1340
+ if 'Page Not Found' in response.text:
1341
+ logging.info("✅ 404 error page test PASSED - Custom error page displayed")
1342
+ results[url]['validation'] = 'PASSED'
1343
+ else:
1344
+ logging.warning("⚠️ 404 error page test PARTIAL - 404 returned but no custom error page")
1345
+ results[url]['validation'] = 'PARTIAL'
1346
+ else:
1347
+ logging.warning(f"⚠️ 404 error page test UNEXPECTED - Status: {response.status_code} (expected 404)")
1348
+ results[url]['validation'] = 'UNEXPECTED'
1349
+
1350
+ elif SPA_MODE and any(path in url for path in ['/about', '/products/', '/any/']):
1351
+ # SPA routing tests - these should all return 200 with index.html content
1352
+ if response.status_code == 200:
1353
+ if 'SPA-Enabled CDN Setup Successful' in response.text:
1354
+ logging.info(f"✅ SPA routing test PASSED - '{url}' served index.html")
1355
+ results[url]['validation'] = 'PASSED'
1356
+ else:
1357
+ logging.warning("⚠️ SPA routing test PARTIAL - 200 returned but not index.html")
1358
+ results[url]['validation'] = 'PARTIAL'
1359
+ else:
1360
+ logging.error(f"❌ SPA routing test FAILED - Status: {response.status_code} (expected 200)")
1361
+ results[url]['validation'] = 'FAILED'
1362
+
1363
+ # Log response details
1364
+ logging.info(f" Status: {response.status_code}")
1365
+ logging.info(f" Response time: {response.elapsed.total_seconds():.2f}s")
1366
+ logging.info(f" Content length: {len(response.content)} bytes")
1367
+
1368
+ # Check for CloudFront headers
1369
+ if 'X-Amz-Cf-Id' in response.headers:
1370
+ logging.info(f" ✅ CloudFront serving content (X-Amz-Cf-Id: {response.headers['X-Amz-Cf-Id'][:20]}...)")
1371
+ else:
1372
+ logging.warning(" ⚠️ CloudFront headers not detected")
1373
+
1374
+ except requests.exceptions.Timeout:
1375
+ logging.error(f"❌ TIMEOUT - Request to {url} timed out after {timeout}s")
1376
+ results[url] = {'success': False, 'error': 'timeout', 'validation': 'FAILED'}
1377
+
1378
+ except requests.exceptions.ConnectionError as e:
1379
+ logging.error(f"❌ CONNECTION ERROR - Could not connect to {url}: {e}")
1380
+ results[url] = {'success': False, 'error': 'connection_error', 'validation': 'FAILED'}
1381
+
1382
+ except requests.exceptions.RequestException as e:
1383
+ logging.error(f"❌ REQUEST ERROR - Error fetching {url}: {e}")
1384
+ results[url] = {'success': False, 'error': str(e), 'validation': 'FAILED'}
1385
+
1386
+ # Wait a bit between requests
1387
+ time.sleep(2)
1388
+
1389
+ # Summary
1390
+ logging.info("\n=== Test Results Summary ===")
1391
+ passed = 0
1392
+ total = len(test_urls)
1393
+
1394
+ for url, result in results.items():
1395
+ if result.get('success', False):
1396
+ validation = result.get('validation', 'UNKNOWN')
1397
+ if validation == 'PASSED':
1398
+ logging.info(f"✅ {url} - {validation}")
1399
+ passed += 1
1400
+ elif validation == 'PARTIAL':
1401
+ logging.warning(f"⚠️ {url} - {validation}")
1402
+ else:
1403
+ logging.error(f"❌ {url} - {validation}")
1404
+ else:
1405
+ logging.error(f"❌ {url} - FAILED ({result.get('error', 'unknown error')})")
1406
+
1407
+ success_rate = (passed / total) * 100
1408
+ logging.info(f"\nOverall Success Rate: {passed}/{total} ({success_rate:.1f}%)")
1409
+
1410
+ if success_rate >= 80:
1411
+ logging.info("🎉 CDN is working well!")
1412
+ return True
1413
+ elif success_rate >= 50:
1414
+ logging.warning("⚠️ CDN is partially working - some issues detected")
1415
+ return False
1416
+ else:
1417
+ logging.error("❌ CDN has significant issues")
1418
+ return False
1419
+
1420
+ def main():
1421
+ """Main function to orchestrate the create."""
1422
+ try:
1423
+ cf_client = get_cloudflare_client()
1424
+ zone_id = get_cloudflare_zone_id(cf_client, CLOUDFLARE_ZONE_NAME)
1425
+
1426
+ # 1. S3 Bucket
1427
+ create_s3_bucket(BUCKET_NAME)
1428
+
1429
+ # 2. Enable S3 Website Hosting
1430
+ enable_s3_website_hosting(BUCKET_NAME)
1431
+
1432
+ cert_arn = None
1433
+ if not USE_CLOUDFLARE_SSL:
1434
+ # 3. ACM Certificate (only if not using Cloudflare SSL)
1435
+ cert_arn, validation_cname, cert_waiter = request_acm_certificate(BUCKET_NAME)
1436
+
1437
+ # 4. DNS Validation
1438
+ _, validation_dns_record = setup_dns_validation(cf_client, CLOUDFLARE_ZONE_NAME, validation_cname)
1439
+
1440
+ # 5. Wait for Validation
1441
+ wait_for_cert_validation(cert_waiter, cert_arn)
1442
+
1443
+ # 6. Cleanup DNS validation record
1444
+ cleanup_dns_validation(cf_client, zone_id, validation_dns_record)
1445
+ else:
1446
+ logging.info("Using Cloudflare for SSL, but checking for existing ACM certificates...")
1447
+ # Even with Cloudflare SSL, we need ACM certificates for CloudFront aliases
1448
+ cert_arn = find_existing_acm_certificate(BUCKET_NAME)
1449
+ if cert_arn:
1450
+ logging.info(f"Found existing ACM certificate: {cert_arn}")
1451
+ else:
1452
+ logging.info("No existing ACM certificate found - creating wildcard certificate for aliases...")
1453
+ # Create wildcard certificate for the domain
1454
+ wildcard_domain = f"*.{CLOUDFLARE_ZONE_NAME}"
1455
+ logging.info(f"Creating ACM certificate for '{wildcard_domain}'...")
1456
+ cert_arn, validation_cname, cert_waiter = request_acm_certificate(wildcard_domain)
1457
+
1458
+ # DNS Validation
1459
+ _, validation_dns_record = setup_dns_validation(cf_client, CLOUDFLARE_ZONE_NAME, validation_cname)
1460
+
1461
+ # Wait for Validation
1462
+ wait_for_cert_validation(cert_waiter, cert_arn)
1463
+
1464
+ # Cleanup DNS validation record
1465
+ cleanup_dns_validation(cf_client, zone_id, validation_dns_record)
1466
+
1467
+ # 7. CloudFront Distribution (no OAC needed for S3 website endpoint)
1468
+ distribution = create_cloudfront_distribution(BUCKET_NAME, cert_arn, None)
1469
+ distribution_domain = distribution['DomainName']
1470
+
1471
+ # 8. Cloudflare CNAME to CloudFront
1472
+ create_cloudflare_cname_record(cf_client, zone_id, SUBDOMAIN, distribution_domain)
1473
+
1474
+ # 9. Create test content
1475
+ create_test_content(BUCKET_NAME)
1476
+
1477
+ logging.info("\n--- Setup Complete ---")
1478
+ logging.info(f"S3 Bucket: {BUCKET_NAME}")
1479
+ logging.info(f"S3 Website Endpoint: {get_s3_website_endpoint(BUCKET_NAME, AWS_REGION)}")
1480
+ logging.info(f"CloudFront Domain: {distribution_domain}")
1481
+ if USE_CLOUDFLARE_SSL:
1482
+ logging.info(f"Public URL: https://{BUCKET_NAME} (SSL via Cloudflare)")
1483
+ else:
1484
+ logging.info(f"Public URL: https://{BUCKET_NAME} (SSL via ACM)")
1485
+ logging.info("Test URLs:")
1486
+ logging.info(f" - https://{BUCKET_NAME}/index-test.html")
1487
+ logging.info(f" - https://{BUCKET_NAME}/test.txt")
1488
+ logging.info("--------------------")
1489
+
1490
+ # 10. Test CDN functionality (unless skipped)
1491
+ if not SKIP_TESTS:
1492
+ logging.info("\nWaiting 30 seconds for DNS/CDN propagation before testing...")
1493
+ time.sleep(30)
1494
+
1495
+ test_success = test_cdn_functionality(BUCKET_NAME, TEST_TIMEOUT)
1496
+
1497
+ if test_success:
1498
+ logging.info("\n🎉 CDN create completed successfully and is working!")
1499
+ return 0
1500
+ else:
1501
+ logging.warning("\n⚠️ CDN create completed but tests indicate some issues. Check the logs above.")
1502
+ return 1
1503
+ else:
1504
+ logging.info("\n✅ CDN create completed successfully (tests skipped)")
1505
+ return 0
1506
+
1507
+ except Exception as e:
1508
+ logging.error(f"An error occurred: {e}")
1509
+ return 1
1510
+
1511
+ if __name__ == "__main__":
1512
+ exit(main())