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.
@@ -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 across all environments
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
- # --- Doctor ---
351
- # Diagnose DNS configuration across all environments
352
- def doctor(args)
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
- # Parse --env flag for filtering to a specific environment
357
- env_filter = nil
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
- # Read domain from dns/terraform.tfvars
364
- domain = read_domain_from_dns_tfvars
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
- puts "DNS Health: #{domain}"
373
- puts '═' * 64
374
- puts ''
521
+ all_ok = true
375
522
 
376
- # Check root zone
377
- if Dir.exist?(DNS_DIR)
378
- check_root_zone(domain)
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 Zone (infrastructure/dns)'
381
- puts '────────────────────────────────────────'
382
- puts ' ⚠ Not configured'
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
- # Check each environment
388
- environments = if env_filter
389
- [env_filter]
390
- else
391
- TerraformCommand.list_environments
392
- end
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
- environments.each do |env_name|
395
- check_environment(env_name, domain)
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
- private
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
- def parse_json(output)
402
- JSON.parse(output)
403
- rescue JSON::ParserError
404
- nil
564
+ all_ok
405
565
  end
406
566
 
407
- def check_root_zone(domain)
408
- puts 'Root Zone (shared account)'
409
- puts '────────────────────────────────────────'
567
+ def check_environment(env_name, domain, dns_config)
568
+ require 'open3'
410
569
 
411
- dns_config = load_dns_config
570
+ env_config = EnvironmentConfig.load(env_name)
412
571
  env = {}
413
- env['AWS_PROFILE'] = dns_config.aws_profile if dns_config.aws_profile?
572
+ env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
414
573
 
415
- outputs = Dir.chdir(DNS_DIR) do
416
- output, status = Open3.capture2e(env, 'terraform', 'output', '-json')
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
- parse_json(output)
424
- end
577
+ puts "#{env_name} (#{env_domain})#{' [apex]' if is_apex}"
578
+ puts '-' * 40
425
579
 
426
- unless outputs
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
- if zone_exists_in_aws?(zone_id, env)
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 ' Zone not created yet'
587
+ puts ' Hosted zone not found'
588
+ puts " Run: belt deploy #{env_name}"
589
+ return false
443
590
  end
444
591
 
445
- if name_servers.any?
446
- puts " ✓ NS records configured (#{name_servers.length} servers)"
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
- # Check if registrar NS matches (via DNS lookup)
449
- actual_ns = lookup_ns_records(domain)
450
- if actual_ns.sort == name_servers.sort
451
- puts ' ✓ Registrar NS records match'
452
- elsif actual_ns.any?
453
- puts ' ⚠ Registrar NS records differ from root zone'
454
- puts " Expected: #{name_servers.first}..."
455
- puts " Got: #{actual_ns.first}..."
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 ' Could not verify registrar NS records (DNS lookup failed)'
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 'No NS records found'
630
+ puts "ACM certificate: #{cert_status} (#{cert_domain})"
461
631
  end
462
632
 
463
- if delegated.any?
464
- puts " ✓ Delegated: #{delegated.join(', ')}"
465
- else
466
- puts ' ⚠ No environments delegated'
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
- # Check for apex records (should exist if prod is deployed)
470
- check_root_zone_apex_records(zone_id, domain, env) if zone_id
645
+ all_ok
646
+ end
471
647
 
472
- puts ''
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 check_root_zone_apex_records(zone_id, domain, env)
476
- # List records in root zone to see if apex A records exist
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
- records = begin
487
- JSON.parse(output)
488
- rescue JSON::ParserError
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
- apex_exists = records.any? { |r| r.chomp('.') == domain }
493
- www_exists = records.any? { |r| r.chomp('.') == "www.#{domain}" }
494
- api_exists = records.any? { |r| r.chomp('.') == "api.#{domain}" }
668
+ def read_domain_from_tfvars
669
+ tfvars_path = "#{DNS_DIR}/terraform.tfvars"
670
+ return nil unless File.exist?(tfvars_path)
495
671
 
496
- if apex_exists && www_exists && api_exists
497
- puts " ✓ Apex A records present (#{domain}, www, api)"
498
- elsif apex_exists || www_exists || api_exists
499
- missing = []
500
- missing << domain unless apex_exists
501
- missing << "www.#{domain}" unless www_exists
502
- missing << "api.#{domain}" unless api_exists
503
- puts " ⚠ Some apex A records missing: #{missing.join(', ')}"
504
- else
505
- puts ' ⚠ No apex A records (prod not synced? run: belt deploy prod)'
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 check_environment(env_name, domain)
510
- env_dir = "infrastructure/#{env_name}"
511
- return unless Dir.exist?(env_dir)
700
+ def fetch_delegated_environments(dns_config)
701
+ require 'open3'
512
702
 
513
- # Determine expected domain for this env
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
- if File.exist?(tfvars_path)
519
- content = File.read(tfvars_path)
520
- domain_match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
521
- env_match = content.match(/^\s*environment\s*=\s*"([^"]+)"/)
522
- parent_match = content.match(/^\s*parent_environment\s*=\s*"([^"]+)"/)
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
- label = if is_prod
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
- env_config = begin
550
- EnvironmentConfig.load(env_name)
714
+ begin
715
+ JSON.parse(output)
551
716
  rescue StandardError
552
- nil
717
+ []
553
718
  end
719
+ end
554
720
 
555
- unless env_config
556
- puts ' ⚠ No belt.rb config found'
557
- puts ''
558
- return
559
- end
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
- # Check terraform state
565
- Dir.chdir(env_dir) do
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
- puts ''
734
+ status.success? && !output.strip.empty? ? output.strip : nil
584
735
  end
585
736
 
586
- def report_environment_status(outputs, env_name, env_dir, env, domain, env_domain, is_prod)
587
- # Zone info
588
- name_servers = outputs.dig('name_servers', 'value') || []
589
- if name_servers.any?
590
- puts " ✓ Zone deployed (#{name_servers.length} NS records)"
591
- else
592
- puts ' ⚠ No hosted zone found'
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
- # Check ACM certificate
596
- check_acm_cert(env_dir, env)
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
- # Check delegation in root zone (for non-apex envs)
599
- check_delegation(env_name, domain, name_servers) unless is_prod
763
+ return [nil, nil] unless cert_status.success?
600
764
 
601
- # For apex (prod), check if DNS resolves
602
- check_dns_resolution(env_domain) if is_prod && env_domain
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 check_acm_cert(_env_dir, aws_env)
606
- # Try to get cert status from state
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
- aws_env,
609
- 'terraform', 'state', 'show', '-json', 'module.app.aws_acm_certificate.app[0]'
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
- cert_data = parse_json(output)
614
- return unless cert_data
793
+ return :not_found unless status.success?
615
794
 
616
- cert_domain = cert_data.dig('values', 'domain_name')
617
- cert_status = cert_data.dig('values', 'status')
795
+ data = begin
796
+ JSON.parse(output)
797
+ rescue StandardError
798
+ nil
799
+ end
800
+ return :not_found unless data
618
801
 
619
- case cert_status
620
- when 'ISSUED'
621
- puts " ✓ ACM certificate: ISSUED (#{cert_domain})"
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
- puts " ⚠ ACM certificate: #{cert_status || 'unknown'}"
806
+ :pending
627
807
  end
628
808
  end
629
809
 
630
- def check_delegation(env_name, _domain, _expected_ns)
631
- return unless Dir.exist?(DNS_DIR)
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.aws_profile?
820
+ env['AWS_PROFILE'] = dns_config.aws_profile if dns_config&.aws_profile?
636
821
 
637
- # Read tfvars to check if this env is delegated
638
- tfvars_path = "#{DNS_DIR}/terraform.tfvars"
639
- return unless File.exist?(tfvars_path)
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
- content = File.read(tfvars_path)
642
- if content.include?("#{env_name} =")
643
- puts ' ✓ NS delegation configured in root zone'
644
- else
645
- puts ' ⚠ NS delegation not configured in root zone'
646
- puts " Run: belt dns add #{env_name}"
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
- def check_dns_resolution(domain)
651
- # Try to resolve the domain
652
- output, status = Open3.capture2e('dig', '+short', domain)
653
- if status.success? && output.strip.length.positive?
654
- ips = output.strip.split("\n")
655
- puts " ✓ DNS resolves: #{ips.first}"
656
- else
657
- puts " ⚠ DNS does not resolve for #{domain}"
658
- puts ' Apex A records may be missing from root zone'
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
- def read_domain_from_dns_tfvars
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
- content = File.read(tfvars_path)
667
- match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
668
- match ? match[1] : nil
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 lookup_ns_records(domain)
672
- output, status = Open3.capture2e('dig', '+short', 'NS', domain)
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
- output.strip.split("\n").map { |ns| ns.chomp('.') }.sort
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',