belt 0.4.0 → 0.4.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/lib/belt/cli/deploy_command.rb +27 -0
- data/lib/belt/cli/dns_command.rb +510 -241
- data/lib/belt/cli/environment_command.rb +5 -5
- data/lib/belt/cli/frontend_deploy_command.rb +6 -0
- data/lib/belt/cli/frontend_env_command.rb +13 -4
- data/lib/belt/cli/logs_command.rb +11 -0
- data/lib/belt/cli/server_command.rb +5 -0
- data/lib/belt/cli/setup_command.rb +1 -1
- data/lib/belt/cli/tables_command.rb +114 -4
- data/lib/belt/version.rb +1 -1
- metadata +1 -1
data/lib/belt/cli/dns_command.rb
CHANGED
|
@@ -31,6 +31,8 @@ module Belt
|
|
|
31
31
|
new.show(args)
|
|
32
32
|
when 'doctor'
|
|
33
33
|
new.doctor(args)
|
|
34
|
+
when 'sync-validation'
|
|
35
|
+
new.sync_validation(args)
|
|
34
36
|
when '--help', '-h', 'help'
|
|
35
37
|
puts help
|
|
36
38
|
else
|
|
@@ -49,13 +51,17 @@ module Belt
|
|
|
49
51
|
add <env> Add an environment's NS records to dns/terraform.tfvars
|
|
50
52
|
remove <env> Remove an environment's NS records from dns/terraform.tfvars
|
|
51
53
|
show Show root zone name servers (for registrar configuration)
|
|
52
|
-
doctor Diagnose DNS configuration
|
|
54
|
+
doctor Diagnose DNS configuration for all environments
|
|
55
|
+
sync-validation Sync ACM validation CNAMEs to root zone (for apex domains)
|
|
53
56
|
help Show this help
|
|
54
57
|
|
|
55
58
|
Options for generate:
|
|
56
59
|
--aws-profile NAME AWS profile to use for DNS infrastructure
|
|
57
60
|
Sets belt.rb config and derives state bucket from account ID
|
|
58
61
|
|
|
62
|
+
Options for doctor:
|
|
63
|
+
--env ENV Check a specific environment only
|
|
64
|
+
|
|
59
65
|
Examples:
|
|
60
66
|
belt dns deploy # Deploy the root zone
|
|
61
67
|
belt dns generate # Scaffold infrastructure/dns (prompts for profile)
|
|
@@ -64,6 +70,7 @@ module Belt
|
|
|
64
70
|
belt dns remove staging # Remove staging's NS delegation
|
|
65
71
|
belt dns show # Show root name servers to configure at registrar
|
|
66
72
|
belt dns doctor # Check DNS health for all environments
|
|
73
|
+
belt dns doctor --env prod # Check DNS health for prod only
|
|
67
74
|
|
|
68
75
|
The dns directory manages your root domain and delegates subdomains to
|
|
69
76
|
per-environment hosted zones. Each environment (dev, staging, prod) gets
|
|
@@ -80,6 +87,11 @@ module Belt
|
|
|
80
87
|
1. belt destroy environment dev # Destroy the environment
|
|
81
88
|
2. belt dns remove dev # Remove DNS delegation
|
|
82
89
|
3. belt dns deploy # Apply the change
|
|
90
|
+
|
|
91
|
+
Special handling for prod (apex domain):
|
|
92
|
+
Prod environments use the apex domain (e.g., example.com, not prod.example.com).
|
|
93
|
+
ACM certificates for apex domains need validation CNAMEs in the root zone.
|
|
94
|
+
`belt deploy prod` handles this automatically when infrastructure/dns exists.
|
|
83
95
|
HELP
|
|
84
96
|
end
|
|
85
97
|
|
|
@@ -90,6 +102,150 @@ module Belt
|
|
|
90
102
|
@state_bucket = nil # Resolved during generate with profile context
|
|
91
103
|
end
|
|
92
104
|
|
|
105
|
+
# --- Doctor ---
|
|
106
|
+
def doctor(args = [])
|
|
107
|
+
require 'open3'
|
|
108
|
+
|
|
109
|
+
# Parse --env flag
|
|
110
|
+
env_filter = nil
|
|
111
|
+
env_index = args.index('--env')
|
|
112
|
+
if env_index
|
|
113
|
+
env_filter = args[env_index + 1]
|
|
114
|
+
args.delete_at(env_index + 1)
|
|
115
|
+
args.delete_at(env_index)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Load DNS config for shared account credentials
|
|
119
|
+
dns_config = load_dns_config_if_exists
|
|
120
|
+
|
|
121
|
+
# Read domain from tfvars
|
|
122
|
+
domain = read_domain_from_tfvars
|
|
123
|
+
unless domain
|
|
124
|
+
puts 'No domain configured.'
|
|
125
|
+
puts "\nSet up DNS first:"
|
|
126
|
+
puts ' belt dns generate'
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
puts "DNS Health: #{domain}"
|
|
131
|
+
puts '═' * 60
|
|
132
|
+
puts ''
|
|
133
|
+
|
|
134
|
+
# Check root zone
|
|
135
|
+
root_zone_ok = check_root_zone(domain, dns_config)
|
|
136
|
+
puts ''
|
|
137
|
+
|
|
138
|
+
# Get list of environments to check
|
|
139
|
+
environments = discover_environments(env_filter)
|
|
140
|
+
if environments.empty?
|
|
141
|
+
puts 'No environments found to check.'
|
|
142
|
+
puts "\nDeploy an environment first:"
|
|
143
|
+
puts ' belt deploy dev'
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Check each environment
|
|
148
|
+
all_ok = root_zone_ok
|
|
149
|
+
environments.each do |env_name|
|
|
150
|
+
env_ok = check_environment(env_name, domain, dns_config)
|
|
151
|
+
all_ok &&= env_ok
|
|
152
|
+
puts ''
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Summary
|
|
156
|
+
puts '═' * 60
|
|
157
|
+
if all_ok
|
|
158
|
+
puts '✓ All DNS checks passed'
|
|
159
|
+
else
|
|
160
|
+
puts '⚠ Some DNS issues detected — see details above'
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- Sync Validation ---
|
|
165
|
+
# Syncs ACM validation CNAMEs from an environment to the root zone.
|
|
166
|
+
# Primarily used for prod (apex domain) where the env's zone isn't authoritative.
|
|
167
|
+
def sync_validation(args = [])
|
|
168
|
+
require 'open3'
|
|
169
|
+
|
|
170
|
+
env_name = args.shift
|
|
171
|
+
if env_name.nil? || env_name.start_with?('-')
|
|
172
|
+
puts 'Usage: belt dns sync-validation <env>'
|
|
173
|
+
puts "\nThis syncs ACM certificate validation CNAMEs from the environment's"
|
|
174
|
+
puts 'zone to the root zone. Needed when the environment uses the apex domain'
|
|
175
|
+
puts '(e.g., prod → example.com) because ACM validates against the authoritative'
|
|
176
|
+
puts "zone, which is the root zone, not the environment's zone."
|
|
177
|
+
puts "\nExample:"
|
|
178
|
+
puts ' belt dns sync-validation prod'
|
|
179
|
+
exit 1
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
unless Dir.exist?(DNS_DIR)
|
|
183
|
+
puts 'No infrastructure/dns directory found.'
|
|
184
|
+
puts "\nCreate it first:"
|
|
185
|
+
puts ' belt dns generate'
|
|
186
|
+
exit 1
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
env_dir = "infrastructure/#{env_name}"
|
|
190
|
+
unless Dir.exist?(env_dir)
|
|
191
|
+
puts "Environment #{env_name} not found at #{env_dir}/"
|
|
192
|
+
exit 1
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
sync_acm_validation_to_root_zone!(env_name)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Public API for deploy_command to call
|
|
199
|
+
def self.sync_acm_validation_if_needed(env_name)
|
|
200
|
+
# Only sync if DNS is configured
|
|
201
|
+
return unless Dir.exist?(DNS_DIR)
|
|
202
|
+
|
|
203
|
+
cmd = new(quiet: true)
|
|
204
|
+
cmd.sync_acm_validation_to_root_zone!(env_name)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Core logic: sync ACM validation CNAMEs to root zone
|
|
208
|
+
def sync_acm_validation_to_root_zone!(env_name)
|
|
209
|
+
require 'open3'
|
|
210
|
+
|
|
211
|
+
env_config = EnvironmentConfig.load(env_name)
|
|
212
|
+
dns_config = load_dns_config_if_exists
|
|
213
|
+
|
|
214
|
+
# Get domain from tfvars
|
|
215
|
+
domain = read_domain_from_tfvars
|
|
216
|
+
return unless domain
|
|
217
|
+
|
|
218
|
+
# Determine if this is an apex environment
|
|
219
|
+
is_apex = apex_environment?(env_name, domain)
|
|
220
|
+
unless is_apex
|
|
221
|
+
puts " ℹ #{env_name} uses subdomain (#{env_name}.#{domain}) — no root zone sync needed" unless @quiet
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
puts " 🔄 Syncing ACM validation for #{env_name} (apex domain) to root zone..." unless @quiet
|
|
226
|
+
|
|
227
|
+
# Get pending ACM validation records from the environment
|
|
228
|
+
validation_records = fetch_pending_acm_validation(env_name, env_config)
|
|
229
|
+
if validation_records.nil? || validation_records.empty?
|
|
230
|
+
puts ' No pending ACM validation records found' unless @quiet
|
|
231
|
+
return
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Get root zone ID
|
|
235
|
+
root_zone_id = fetch_root_zone_id(dns_config)
|
|
236
|
+
unless root_zone_id
|
|
237
|
+
puts ' ⚠ Could not find root zone ID — run `belt dns deploy` first' unless @quiet
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Create/update the validation CNAMEs in the root zone
|
|
242
|
+
validation_records.each do |record|
|
|
243
|
+
create_validation_cname_in_root_zone(root_zone_id, record, dns_config)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
puts ' ✓ ACM validation CNAMEs synced to root zone' unless @quiet
|
|
247
|
+
end
|
|
248
|
+
|
|
93
249
|
# --- Generate ---
|
|
94
250
|
def generate(args = [])
|
|
95
251
|
# Parse --aws-profile flag
|
|
@@ -347,334 +503,447 @@ module Belt
|
|
|
347
503
|
end
|
|
348
504
|
end
|
|
349
505
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
506
|
+
private
|
|
507
|
+
|
|
508
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
509
|
+
# Doctor helpers
|
|
510
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
511
|
+
|
|
512
|
+
def check_root_zone(domain, dns_config)
|
|
353
513
|
require 'open3'
|
|
354
|
-
require_relative 'terraform_command'
|
|
355
514
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if args.include?('--env')
|
|
359
|
-
idx = args.index('--env')
|
|
360
|
-
env_filter = args[idx + 1]
|
|
361
|
-
end
|
|
515
|
+
puts 'Root Zone (shared account)'
|
|
516
|
+
puts '-' * 40
|
|
362
517
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
unless domain
|
|
366
|
-
puts 'Could not determine domain.'
|
|
367
|
-
puts "\nEnsure infrastructure/dns/terraform.tfvars has:"
|
|
368
|
-
puts ' domain = "example.com"'
|
|
369
|
-
exit 1
|
|
370
|
-
end
|
|
518
|
+
env = {}
|
|
519
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config&.aws_profile?
|
|
371
520
|
|
|
372
|
-
|
|
373
|
-
puts '═' * 64
|
|
374
|
-
puts ''
|
|
521
|
+
all_ok = true
|
|
375
522
|
|
|
376
|
-
# Check root zone
|
|
377
|
-
|
|
378
|
-
|
|
523
|
+
# Check if root zone exists
|
|
524
|
+
zone_id = fetch_root_zone_id(dns_config)
|
|
525
|
+
if zone_id
|
|
526
|
+
puts " ✓ Zone ID: #{zone_id}"
|
|
379
527
|
else
|
|
380
|
-
puts 'Root
|
|
381
|
-
puts '
|
|
382
|
-
|
|
383
|
-
puts ' Run: belt dns generate'
|
|
384
|
-
puts ''
|
|
528
|
+
puts ' ✗ Root zone not found'
|
|
529
|
+
puts ' Run: belt dns deploy'
|
|
530
|
+
return false
|
|
385
531
|
end
|
|
386
532
|
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
533
|
+
# Get NS records from root zone
|
|
534
|
+
ns_output, ns_status = Open3.capture2e(
|
|
535
|
+
env,
|
|
536
|
+
'aws', 'route53', 'list-resource-record-sets',
|
|
537
|
+
'--hosted-zone-id', zone_id,
|
|
538
|
+
'--query', "ResourceRecordSets[?Type=='NS' && Name=='#{domain}.'].ResourceRecords[].Value",
|
|
539
|
+
'--output', 'json'
|
|
540
|
+
)
|
|
393
541
|
|
|
394
|
-
|
|
395
|
-
|
|
542
|
+
if ns_status.success?
|
|
543
|
+
ns_records = begin
|
|
544
|
+
JSON.parse(ns_output)
|
|
545
|
+
rescue StandardError
|
|
546
|
+
[]
|
|
547
|
+
end
|
|
548
|
+
if ns_records.any?
|
|
549
|
+
puts " ✓ NS records configured (#{ns_records.size} servers)"
|
|
550
|
+
else
|
|
551
|
+
puts ' ⚠ No NS records found'
|
|
552
|
+
end
|
|
396
553
|
end
|
|
397
|
-
end
|
|
398
554
|
|
|
399
|
-
|
|
555
|
+
# Check delegated environments
|
|
556
|
+
delegated = fetch_delegated_environments(dns_config)
|
|
557
|
+
if delegated.any?
|
|
558
|
+
puts " ✓ Delegated: #{delegated.join(', ')}"
|
|
559
|
+
else
|
|
560
|
+
puts ' ⚠ No environments delegated yet'
|
|
561
|
+
puts ' Run: belt dns add <env>'
|
|
562
|
+
end
|
|
400
563
|
|
|
401
|
-
|
|
402
|
-
JSON.parse(output)
|
|
403
|
-
rescue JSON::ParserError
|
|
404
|
-
nil
|
|
564
|
+
all_ok
|
|
405
565
|
end
|
|
406
566
|
|
|
407
|
-
def
|
|
408
|
-
|
|
409
|
-
puts '────────────────────────────────────────'
|
|
567
|
+
def check_environment(env_name, domain, dns_config)
|
|
568
|
+
require 'open3'
|
|
410
569
|
|
|
411
|
-
|
|
570
|
+
env_config = EnvironmentConfig.load(env_name)
|
|
412
571
|
env = {}
|
|
413
|
-
env['AWS_PROFILE'] =
|
|
572
|
+
env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
|
|
414
573
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
unless status.success?
|
|
418
|
-
puts ' ⚠ Cannot read terraform state'
|
|
419
|
-
puts ' Run: belt dns deploy'
|
|
420
|
-
return
|
|
421
|
-
end
|
|
574
|
+
is_apex = apex_environment?(env_name, domain)
|
|
575
|
+
env_domain = is_apex ? domain : "#{env_name}.#{domain}"
|
|
422
576
|
|
|
423
|
-
|
|
424
|
-
|
|
577
|
+
puts "#{env_name} (#{env_domain})#{' [apex]' if is_apex}"
|
|
578
|
+
puts '-' * 40
|
|
425
579
|
|
|
426
|
-
|
|
427
|
-
puts ' ⚠ Failed to parse terraform outputs'
|
|
428
|
-
return
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
zone_id = outputs.dig('root_zone_id', 'value')
|
|
432
|
-
name_servers = outputs.dig('root_name_servers', 'value') || []
|
|
433
|
-
delegated = outputs.dig('delegated_environments', 'value') || []
|
|
580
|
+
all_ok = true
|
|
434
581
|
|
|
582
|
+
# Check zone exists
|
|
583
|
+
zone_id = fetch_env_zone_id(env_name, env_config)
|
|
435
584
|
if zone_id
|
|
436
|
-
|
|
437
|
-
puts " ✓ Zone ID: #{zone_id}"
|
|
438
|
-
else
|
|
439
|
-
puts " ✗ Zone #{zone_id} not found in AWS (state stale?)"
|
|
440
|
-
end
|
|
585
|
+
puts " ✓ Zone ID: #{zone_id}"
|
|
441
586
|
else
|
|
442
|
-
puts '
|
|
587
|
+
puts ' ✗ Hosted zone not found'
|
|
588
|
+
puts " Run: belt deploy #{env_name}"
|
|
589
|
+
return false
|
|
443
590
|
end
|
|
444
591
|
|
|
445
|
-
|
|
446
|
-
|
|
592
|
+
# Check NS delegation in root zone
|
|
593
|
+
delegated = fetch_delegated_environments(dns_config)
|
|
594
|
+
if delegated.include?(env_name)
|
|
595
|
+
puts ' ✓ NS delegation in root zone'
|
|
596
|
+
else
|
|
597
|
+
puts ' ⚠ Not delegated in root zone'
|
|
598
|
+
puts " Run: belt dns add #{env_name} && belt dns deploy"
|
|
599
|
+
all_ok = false
|
|
600
|
+
end
|
|
447
601
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
602
|
+
# Check ACM certificate
|
|
603
|
+
cert_status, cert_domain = fetch_acm_cert_status(env_name, env_config)
|
|
604
|
+
case cert_status
|
|
605
|
+
when 'ISSUED'
|
|
606
|
+
puts " ✓ ACM certificate: ISSUED (#{cert_domain})"
|
|
607
|
+
when 'PENDING_VALIDATION'
|
|
608
|
+
puts " ⚠ ACM certificate: PENDING_VALIDATION (#{cert_domain})"
|
|
609
|
+
if is_apex
|
|
610
|
+
# Check if validation CNAME is in root zone
|
|
611
|
+
validation_in_root = check_acm_validation_in_root(env_name, env_config, dns_config)
|
|
612
|
+
if validation_in_root
|
|
613
|
+
puts ' ✓ Validation CNAME in root zone — waiting for DNS propagation'
|
|
614
|
+
else
|
|
615
|
+
puts ' ✗ Validation CNAME NOT in root zone'
|
|
616
|
+
puts ' Apex domains need validation CNAMEs in the root zone.'
|
|
617
|
+
puts " Run: belt dns sync-validation #{env_name} && belt dns deploy"
|
|
618
|
+
end
|
|
456
619
|
else
|
|
457
|
-
puts '
|
|
620
|
+
puts ' Validation CNAME should be in environment zone — waiting for DNS propagation'
|
|
458
621
|
end
|
|
622
|
+
all_ok = false
|
|
623
|
+
when 'FAILED'
|
|
624
|
+
puts " ✗ ACM certificate: FAILED (#{cert_domain})"
|
|
625
|
+
all_ok = false
|
|
626
|
+
when nil
|
|
627
|
+
puts ' ⚠ ACM certificate not found'
|
|
628
|
+
all_ok = false
|
|
459
629
|
else
|
|
460
|
-
puts
|
|
630
|
+
puts " ⚠ ACM certificate: #{cert_status} (#{cert_domain})"
|
|
461
631
|
end
|
|
462
632
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
633
|
+
# Check API Gateway custom domain
|
|
634
|
+
api_domain_status = fetch_api_gateway_domain_status(env_name, env_config, domain)
|
|
635
|
+
case api_domain_status
|
|
636
|
+
when :available
|
|
637
|
+
puts ' ✓ API Gateway custom domain: available'
|
|
638
|
+
when :pending
|
|
639
|
+
puts ' ⚠ API Gateway custom domain: pending (waiting for cert)'
|
|
640
|
+
when :not_found
|
|
641
|
+
puts ' ⚠ API Gateway custom domain: not configured'
|
|
642
|
+
all_ok = false
|
|
467
643
|
end
|
|
468
644
|
|
|
469
|
-
|
|
470
|
-
|
|
645
|
+
all_ok
|
|
646
|
+
end
|
|
471
647
|
|
|
472
|
-
|
|
648
|
+
def apex_environment?(env_name, _domain)
|
|
649
|
+
# Convention: 'prod' or 'production' uses the apex domain
|
|
650
|
+
%w[prod production].include?(env_name)
|
|
473
651
|
end
|
|
474
652
|
|
|
475
|
-
def
|
|
476
|
-
|
|
477
|
-
output, status = Open3.capture2e(
|
|
478
|
-
env,
|
|
479
|
-
'aws', 'route53', 'list-resource-record-sets',
|
|
480
|
-
'--hosted-zone-id', zone_id,
|
|
481
|
-
'--query', "ResourceRecordSets[?Type=='A'].Name",
|
|
482
|
-
'--output', 'json'
|
|
483
|
-
)
|
|
484
|
-
return unless status.success?
|
|
653
|
+
def discover_environments(filter = nil)
|
|
654
|
+
return [filter] if filter && Dir.exist?("infrastructure/#{filter}")
|
|
485
655
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
656
|
+
env_dirs = Dir.glob('infrastructure/*').select do |path|
|
|
657
|
+
next false unless File.directory?(path)
|
|
658
|
+
|
|
659
|
+
env_name = File.basename(path)
|
|
660
|
+
next false if %w[modules dns].include?(env_name)
|
|
661
|
+
next false unless File.exist?(File.join(path, 'main.tf'))
|
|
662
|
+
|
|
663
|
+
true
|
|
490
664
|
end
|
|
665
|
+
env_dirs.map { |path| File.basename(path) }.sort
|
|
666
|
+
end
|
|
491
667
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
668
|
+
def read_domain_from_tfvars
|
|
669
|
+
tfvars_path = "#{DNS_DIR}/terraform.tfvars"
|
|
670
|
+
return nil unless File.exist?(tfvars_path)
|
|
495
671
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
672
|
+
content = File.read(tfvars_path)
|
|
673
|
+
match = content.match(/domain\s*=\s*"([^"]+)"/)
|
|
674
|
+
match[1] if match
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def load_dns_config_if_exists
|
|
678
|
+
return nil unless Dir.exist?(DNS_DIR)
|
|
679
|
+
|
|
680
|
+
load_dns_config
|
|
681
|
+
rescue StandardError
|
|
682
|
+
nil
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def fetch_root_zone_id(dns_config)
|
|
686
|
+
require 'open3'
|
|
687
|
+
|
|
688
|
+
return nil unless Dir.exist?(DNS_DIR)
|
|
689
|
+
|
|
690
|
+
env = {}
|
|
691
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config&.aws_profile?
|
|
692
|
+
|
|
693
|
+
output, status = Dir.chdir(DNS_DIR) do
|
|
694
|
+
Open3.capture2e(env, 'terraform', 'output', '-raw', 'root_zone_id')
|
|
506
695
|
end
|
|
696
|
+
|
|
697
|
+
status.success? ? output.strip : nil
|
|
507
698
|
end
|
|
508
699
|
|
|
509
|
-
def
|
|
510
|
-
|
|
511
|
-
return unless Dir.exist?(env_dir)
|
|
700
|
+
def fetch_delegated_environments(dns_config)
|
|
701
|
+
require 'open3'
|
|
512
702
|
|
|
513
|
-
|
|
514
|
-
tfvars_path = File.join(env_dir, 'terraform.tfvars')
|
|
515
|
-
env_domain = nil
|
|
516
|
-
is_prod = env_name == 'prod'
|
|
703
|
+
return [] unless Dir.exist?(DNS_DIR)
|
|
517
704
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
configured_domain = domain_match ? domain_match[1] : nil
|
|
525
|
-
actual_env = env_match ? env_match[1] : env_name
|
|
526
|
-
parent = parent_match ? parent_match[1] : nil
|
|
527
|
-
is_prod = actual_env == 'prod'
|
|
528
|
-
is_nested = parent && !parent.empty?
|
|
529
|
-
|
|
530
|
-
if configured_domain
|
|
531
|
-
env_domain = if is_nested
|
|
532
|
-
"#{actual_env}.#{parent}.#{configured_domain}"
|
|
533
|
-
elsif is_prod
|
|
534
|
-
configured_domain
|
|
535
|
-
else
|
|
536
|
-
"#{actual_env}.#{configured_domain}"
|
|
537
|
-
end
|
|
538
|
-
end
|
|
705
|
+
env = {}
|
|
706
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config&.aws_profile?
|
|
707
|
+
|
|
708
|
+
output, status = Dir.chdir(DNS_DIR) do
|
|
709
|
+
Open3.capture2e(env, 'terraform', 'output', '-json', 'delegated_environments')
|
|
539
710
|
end
|
|
540
711
|
|
|
541
|
-
|
|
542
|
-
"#{env_name} (#{env_domain || domain}) [apex]"
|
|
543
|
-
else
|
|
544
|
-
"#{env_name} (#{env_domain || "#{env_name}.#{domain}"})"
|
|
545
|
-
end
|
|
546
|
-
puts label
|
|
547
|
-
puts '────────────────────────────────────────'
|
|
712
|
+
return [] unless status.success?
|
|
548
713
|
|
|
549
|
-
|
|
550
|
-
|
|
714
|
+
begin
|
|
715
|
+
JSON.parse(output)
|
|
551
716
|
rescue StandardError
|
|
552
|
-
|
|
717
|
+
[]
|
|
553
718
|
end
|
|
719
|
+
end
|
|
554
720
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
721
|
+
def fetch_env_zone_id(env_name, env_config)
|
|
722
|
+
require 'open3'
|
|
723
|
+
|
|
724
|
+
env_dir = "infrastructure/#{env_name}"
|
|
725
|
+
return nil unless Dir.exist?(env_dir)
|
|
560
726
|
|
|
561
727
|
env = {}
|
|
562
728
|
env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
|
|
563
729
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
output, status = Open3.capture2e(env, 'terraform', 'output', '-json')
|
|
567
|
-
unless status.success?
|
|
568
|
-
puts ' ⚠ Cannot read terraform state (not deployed?)'
|
|
569
|
-
puts ''
|
|
570
|
-
return
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
outputs = parse_json(output)
|
|
574
|
-
unless outputs
|
|
575
|
-
puts ' ⚠ Failed to parse terraform outputs'
|
|
576
|
-
puts ''
|
|
577
|
-
return
|
|
578
|
-
end
|
|
579
|
-
|
|
580
|
-
report_environment_status(outputs, env_name, env_dir, env, domain, env_domain, is_prod)
|
|
730
|
+
output, status = Dir.chdir(env_dir) do
|
|
731
|
+
Open3.capture2e(env, 'terraform', 'output', '-raw', 'zone_id')
|
|
581
732
|
end
|
|
582
733
|
|
|
583
|
-
|
|
734
|
+
status.success? && !output.strip.empty? ? output.strip : nil
|
|
584
735
|
end
|
|
585
736
|
|
|
586
|
-
def
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
737
|
+
def fetch_acm_cert_status(env_name, env_config)
|
|
738
|
+
require 'open3'
|
|
739
|
+
|
|
740
|
+
env_dir = "infrastructure/#{env_name}"
|
|
741
|
+
return [nil, nil] unless Dir.exist?(env_dir)
|
|
742
|
+
|
|
743
|
+
env = {}
|
|
744
|
+
env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
|
|
745
|
+
|
|
746
|
+
# Get cert ARN from terraform
|
|
747
|
+
arn_output, arn_status = Dir.chdir(env_dir) do
|
|
748
|
+
Open3.capture2e(env, 'terraform', 'output', '-raw', 'certificate_arn')
|
|
593
749
|
end
|
|
594
750
|
|
|
595
|
-
|
|
596
|
-
|
|
751
|
+
return [nil, nil] unless arn_status.success? && !arn_output.strip.empty?
|
|
752
|
+
|
|
753
|
+
cert_arn = arn_output.strip
|
|
754
|
+
|
|
755
|
+
# Get cert details from ACM
|
|
756
|
+
cert_output, cert_status = Open3.capture2e(
|
|
757
|
+
env,
|
|
758
|
+
'aws', 'acm', 'describe-certificate',
|
|
759
|
+
'--certificate-arn', cert_arn,
|
|
760
|
+
'--output', 'json'
|
|
761
|
+
)
|
|
597
762
|
|
|
598
|
-
|
|
599
|
-
check_delegation(env_name, domain, name_servers) unless is_prod
|
|
763
|
+
return [nil, nil] unless cert_status.success?
|
|
600
764
|
|
|
601
|
-
|
|
602
|
-
|
|
765
|
+
cert_data = begin
|
|
766
|
+
JSON.parse(cert_output)
|
|
767
|
+
rescue StandardError
|
|
768
|
+
nil
|
|
769
|
+
end
|
|
770
|
+
return [nil, nil] unless cert_data
|
|
771
|
+
|
|
772
|
+
status = cert_data.dig('Certificate', 'Status')
|
|
773
|
+
domain = cert_data.dig('Certificate', 'DomainName')
|
|
774
|
+
[status, domain]
|
|
603
775
|
end
|
|
604
776
|
|
|
605
|
-
def
|
|
606
|
-
|
|
777
|
+
def fetch_api_gateway_domain_status(env_name, env_config, domain)
|
|
778
|
+
require 'open3'
|
|
779
|
+
|
|
780
|
+
is_apex = apex_environment?(env_name, domain)
|
|
781
|
+
api_domain = is_apex ? "api.#{domain}" : "api.#{env_name}.#{domain}"
|
|
782
|
+
|
|
783
|
+
env = {}
|
|
784
|
+
env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
|
|
785
|
+
|
|
607
786
|
output, status = Open3.capture2e(
|
|
608
|
-
|
|
609
|
-
'
|
|
787
|
+
env,
|
|
788
|
+
'aws', 'apigateway', 'get-domain-name',
|
|
789
|
+
'--domain-name', api_domain,
|
|
790
|
+
'--output', 'json'
|
|
610
791
|
)
|
|
611
|
-
return unless status.success?
|
|
612
792
|
|
|
613
|
-
|
|
614
|
-
return unless cert_data
|
|
793
|
+
return :not_found unless status.success?
|
|
615
794
|
|
|
616
|
-
|
|
617
|
-
|
|
795
|
+
data = begin
|
|
796
|
+
JSON.parse(output)
|
|
797
|
+
rescue StandardError
|
|
798
|
+
nil
|
|
799
|
+
end
|
|
800
|
+
return :not_found unless data
|
|
618
801
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
when 'PENDING_VALIDATION'
|
|
623
|
-
puts " ⚠ ACM certificate: PENDING_VALIDATION (#{cert_domain})"
|
|
624
|
-
puts ' Validation CNAME may need to be in root zone for apex domains'
|
|
802
|
+
# Check if it has an endpoint
|
|
803
|
+
if data['regionalDomainName'] || data['distributionDomainName']
|
|
804
|
+
:available
|
|
625
805
|
else
|
|
626
|
-
|
|
806
|
+
:pending
|
|
627
807
|
end
|
|
628
808
|
end
|
|
629
809
|
|
|
630
|
-
def
|
|
631
|
-
|
|
810
|
+
def check_acm_validation_in_root(env_name, env_config, dns_config)
|
|
811
|
+
require 'open3'
|
|
812
|
+
|
|
813
|
+
validation_records = fetch_pending_acm_validation(env_name, env_config)
|
|
814
|
+
return false if validation_records.nil? || validation_records.empty?
|
|
815
|
+
|
|
816
|
+
root_zone_id = fetch_root_zone_id(dns_config)
|
|
817
|
+
return false unless root_zone_id
|
|
632
818
|
|
|
633
|
-
dns_config = load_dns_config
|
|
634
819
|
env = {}
|
|
635
|
-
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config
|
|
820
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config&.aws_profile?
|
|
636
821
|
|
|
637
|
-
#
|
|
638
|
-
|
|
639
|
-
|
|
822
|
+
# Check if the validation CNAME exists in the root zone
|
|
823
|
+
validation_records.all? do |record|
|
|
824
|
+
output, status = Open3.capture2e(
|
|
825
|
+
env,
|
|
826
|
+
'aws', 'route53', 'list-resource-record-sets',
|
|
827
|
+
'--hosted-zone-id', root_zone_id,
|
|
828
|
+
'--query', "ResourceRecordSets[?Name=='#{record[:name]}' && Type=='CNAME']",
|
|
829
|
+
'--output', 'json'
|
|
830
|
+
)
|
|
640
831
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
832
|
+
next false unless status.success?
|
|
833
|
+
|
|
834
|
+
records = begin
|
|
835
|
+
JSON.parse(output)
|
|
836
|
+
rescue StandardError
|
|
837
|
+
[]
|
|
838
|
+
end
|
|
839
|
+
records.any?
|
|
647
840
|
end
|
|
648
841
|
end
|
|
649
842
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
843
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
844
|
+
# ACM validation sync helpers
|
|
845
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
846
|
+
|
|
847
|
+
def fetch_pending_acm_validation(env_name, env_config)
|
|
848
|
+
require 'open3'
|
|
849
|
+
|
|
850
|
+
env_dir = "infrastructure/#{env_name}"
|
|
851
|
+
return nil unless Dir.exist?(env_dir)
|
|
852
|
+
|
|
853
|
+
env = {}
|
|
854
|
+
env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
|
|
855
|
+
|
|
856
|
+
# Get cert ARN from terraform
|
|
857
|
+
arn_output, arn_status = Dir.chdir(env_dir) do
|
|
858
|
+
Open3.capture2e(env, 'terraform', 'output', '-raw', 'certificate_arn')
|
|
659
859
|
end
|
|
660
|
-
end
|
|
661
860
|
|
|
662
|
-
|
|
663
|
-
tfvars_path = "#{DNS_DIR}/terraform.tfvars"
|
|
664
|
-
return nil unless File.exist?(tfvars_path)
|
|
861
|
+
return nil unless arn_status.success? && !arn_output.strip.empty?
|
|
665
862
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
863
|
+
cert_arn = arn_output.strip
|
|
864
|
+
|
|
865
|
+
# Get cert details from ACM
|
|
866
|
+
cert_output, cert_status = Open3.capture2e(
|
|
867
|
+
env,
|
|
868
|
+
'aws', 'acm', 'describe-certificate',
|
|
869
|
+
'--certificate-arn', cert_arn,
|
|
870
|
+
'--output', 'json'
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
return nil unless cert_status.success?
|
|
874
|
+
|
|
875
|
+
cert_data = begin
|
|
876
|
+
JSON.parse(cert_output)
|
|
877
|
+
rescue StandardError
|
|
878
|
+
nil
|
|
879
|
+
end
|
|
880
|
+
return nil unless cert_data
|
|
881
|
+
|
|
882
|
+
# Extract validation options
|
|
883
|
+
validation_options = cert_data.dig('Certificate', 'DomainValidationOptions') || []
|
|
884
|
+
|
|
885
|
+
# Return records that need DNS validation
|
|
886
|
+
validation_options.filter_map do |opt|
|
|
887
|
+
next unless opt['ValidationMethod'] == 'DNS'
|
|
888
|
+
next if opt['ValidationStatus'] == 'SUCCESS'
|
|
889
|
+
|
|
890
|
+
resource_record = opt['ResourceRecord']
|
|
891
|
+
next unless resource_record
|
|
892
|
+
|
|
893
|
+
{
|
|
894
|
+
name: resource_record['Name'],
|
|
895
|
+
type: resource_record['Type'],
|
|
896
|
+
value: resource_record['Value']
|
|
897
|
+
}
|
|
898
|
+
end
|
|
669
899
|
end
|
|
670
900
|
|
|
671
|
-
def
|
|
672
|
-
|
|
673
|
-
return [] unless status.success?
|
|
901
|
+
def create_validation_cname_in_root_zone(root_zone_id, record, dns_config)
|
|
902
|
+
require 'open3'
|
|
674
903
|
|
|
675
|
-
|
|
904
|
+
env = {}
|
|
905
|
+
env['AWS_PROFILE'] = dns_config.aws_profile if dns_config&.aws_profile?
|
|
906
|
+
|
|
907
|
+
# Create a change batch to upsert the CNAME
|
|
908
|
+
change_batch = {
|
|
909
|
+
'Changes' => [
|
|
910
|
+
{
|
|
911
|
+
'Action' => 'UPSERT',
|
|
912
|
+
'ResourceRecordSet' => {
|
|
913
|
+
'Name' => record[:name],
|
|
914
|
+
'Type' => 'CNAME',
|
|
915
|
+
'TTL' => 300,
|
|
916
|
+
'ResourceRecords' => [
|
|
917
|
+
{ 'Value' => record[:value] }
|
|
918
|
+
]
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
require 'tempfile'
|
|
925
|
+
Tempfile.create(['change-batch', '.json']) do |f|
|
|
926
|
+
f.write(JSON.generate(change_batch))
|
|
927
|
+
f.flush
|
|
928
|
+
|
|
929
|
+
output, status = Open3.capture2e(
|
|
930
|
+
env,
|
|
931
|
+
'aws', 'route53', 'change-resource-record-sets',
|
|
932
|
+
'--hosted-zone-id', root_zone_id,
|
|
933
|
+
'--change-batch', "file://#{f.path}"
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
unless status.success?
|
|
937
|
+
puts " ⚠ Failed to create validation CNAME: #{record[:name]}" unless @quiet
|
|
938
|
+
puts " #{output}" unless @quiet
|
|
939
|
+
end
|
|
940
|
+
end
|
|
676
941
|
end
|
|
677
942
|
|
|
943
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
944
|
+
# Original private methods
|
|
945
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
946
|
+
|
|
678
947
|
def templates
|
|
679
948
|
{
|
|
680
949
|
'main.tf.erb' => 'main.tf',
|