belt 0.4.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a04110a957fa2d24cbca0245df1aa6aa421547ac140467cb5e6a15e9d0e1fb1
4
- data.tar.gz: 17ffdd470fe0dd613ac82cd0554c12146fb527ee1a6ccd179f3de4b3e3024eab
3
+ metadata.gz: 846db7f1373b5bd98f3e795404b1f157825f5dd94541bd46aa76e3510ba89adf
4
+ data.tar.gz: 251135fea6ee2d3d55e7dcdf46b98460ddc3df50fa1e4113b561f721755c935d
5
5
  SHA512:
6
- metadata.gz: 540c4da110ef61d412c09af5ce4db33bd534ec460d636ac2988e9784cb0fc58ac54e6cd9eeb201b48f863832d7d936ae5677bbcb78d37e702f04915e234ee230
7
- data.tar.gz: ecc50065fcff0b93904c89cbc8c0e3c1a0c45e8f54c7c12af80d9546d894ed12b0a9f8103f8ca5eae3623c1cbd0e38d703137acf86e242ca9fe05946734ac28f
6
+ metadata.gz: 60b0ecf81e375ea1c219c84ccf7f90d568437cc81507c708ec56a40f903178e0611e5b6be03e1e71185ce66263d1ec0c12ddee8e1a4d2cfdc26f99dba4828c48
7
+ data.tar.gz: fb3fa14275a68c7e79892504d0fef2cbe86727225033a7d7bc3c1272b963413b5ed7259c3a5c2f1e202770d16b0234ff242571e4d2b7abed50a040090da250e1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Bug Fix
6
+
7
+ - **Apply environment AWS profile in `belt deploy frontend`, `belt frontend env`, `belt logs`, and `belt server`**:
8
+ Standalone frontend deployment (`belt deploy frontend <env>`), frontend env generation (`belt frontend env <env>`),
9
+ log viewing (`belt logs`), and the local dev server (`belt server`) now load `infrastructure/<env>/belt.rb`
10
+ and apply its configured `aws_profile` and environment variables. Previously, running `belt deploy frontend <env>`
11
+ directly would query Terraform outputs without the environment's AWS profile, causing a 403 against remote S3
12
+ state backends and aborting with `Error: Could not determine S3 bucket. Run belt apply <env> first.`
13
+
14
+ ## 0.4.1
15
+
16
+ ### Feature
17
+
18
+ - **`belt dns doctor`**: New diagnostic command that checks DNS health across all
19
+ environments. Shows zone status, NS delegation, ACM certificate state, and
20
+ API Gateway custom domain configuration. Use `--env prod` to check a specific
21
+ environment only.
22
+
23
+ ```bash
24
+ belt dns doctor
25
+ belt dns doctor --env prod
26
+ ```
27
+
28
+ - **`belt dns sync-validation`**: New command to sync ACM validation CNAMEs from
29
+ an environment's zone to the root zone. Needed for apex domains (e.g., prod →
30
+ `example.com`) where the root zone is authoritative for the apex domain.
31
+
32
+ ```bash
33
+ belt dns sync-validation prod
34
+ ```
35
+
36
+ - **Auto-sync ACM validation for apex environments**: `belt deploy prod` now
37
+ automatically syncs ACM validation CNAMEs to the root zone when deploying
38
+ environments that use the apex domain. No manual intervention needed — the
39
+ "prod is special" logic is handled by Belt internally.
40
+
41
+ This fixes the issue where prod ACM certificates would timeout waiting for
42
+ validation because the validation CNAMEs were created in the prod zone, but
43
+ ACM validates against the authoritative zone (the root zone managed by
44
+ `infrastructure/dns`).
45
+
46
+ ### Bug Fix
47
+
48
+ - **Fix misleading nested environment domain announcement**: `belt generate
49
+ environment <name> <parent>` printed `Domain will be:
50
+ api.<env>.<parent>.<domain>`, but the deployed infrastructure actually uses
51
+ the single-level `api-<env>.<parent>.<domain>` form (the `api-` prefix keeps
52
+ the host under the parent's `*.<parent>.<domain>` wildcard cert). The message
53
+ now matches the real deployed domain. Infrastructure was already correct —
54
+ only the CLI output was wrong.
55
+
3
56
  ## 0.4.0
4
57
 
5
58
  ### New Features
@@ -14,6 +14,7 @@ require_relative 'zip_artifact_builder'
14
14
  require_relative 'nested_environment'
15
15
  require_relative 'cognito_sharer'
16
16
  require_relative 'dynamo_copier'
17
+ require_relative 'dns_command'
17
18
  require_relative 'apex_dns_sync'
18
19
 
19
20
  module Belt
@@ -177,6 +178,11 @@ module Belt
177
178
  # Sync apex DNS records to root zone (if this is a prod/apex environment)
178
179
  run_apex_dns_sync
179
180
 
181
+ # Sync ACM validation CNAMEs for apex domains (e.g., prod)
182
+ # This handles the case where prod uses the apex domain (example.com)
183
+ # and needs validation CNAMEs in the root zone, not the env's zone.
184
+ sync_acm_validation_if_apex
185
+
180
186
  puts "\n✅ Deployed #{@env} successfully!"
181
187
  print_outputs(env_dir)
182
188
 
@@ -299,6 +305,27 @@ module Belt
299
305
  end
300
306
  end
301
307
 
308
+ # ─── ACM Validation Sync ────────────────────────────────────────
309
+
310
+ # For apex environments (prod), ACM validation CNAMEs need to be in the
311
+ # root zone (managed by infrastructure/dns), not the environment's zone.
312
+ # This is because the registrar points to the root zone, which is
313
+ # authoritative for the apex domain.
314
+ def sync_acm_validation_if_apex
315
+ # Only sync if DNS infrastructure exists
316
+ return unless Dir.exist?(File.join(@infra_dir, '..', 'infrastructure', 'dns')) ||
317
+ Dir.exist?('infrastructure/dns')
318
+
319
+ # Check if this is an apex environment
320
+ return unless apex_environment?
321
+
322
+ DnsCommand.sync_acm_validation_if_needed(@env)
323
+ end
324
+
325
+ def apex_environment?
326
+ %w[prod production].include?(@env)
327
+ end
328
+
302
329
  # ─── Backup Phase ───────────────────────────────────────────────
303
330
 
304
331
  def run_backups
@@ -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',
@@ -95,11 +95,11 @@ module Belt
95
95
  if @parent_environment
96
96
  puts "\nThis is a nested environment under '#{@parent_environment}'."
97
97
  puts "It will use the parent's wildcard certificate and hosted zone."
98
- if @domain
99
- puts "Domain will be: api.#{@env_name}.#{@parent_environment}.#{@domain}"
100
- else
101
- puts "Domain will be: #{@env_name}.#{@parent_environment}.<your-domain>"
102
- end
98
+ # Nested envs use an `api-<env>` prefix (not `api.<env>`) so the API
99
+ # host stays a single level under the parent's *.<parent>.<domain>
100
+ # wildcard cert. See lib/templates/module/dns.tf.erb (api_domain).
101
+ puts "Frontend will be: https://#{@env_name}.#{@parent_environment}.#{@domain}"
102
+ puts "API will be: https://api-#{@env_name}.#{@parent_environment}.#{@domain}"
103
103
  puts "\nDeploy with:"
104
104
  puts " belt deploy #{@env_name}"
105
105
  puts "\nNo DNS delegation needed — the parent environment handles DNS."
@@ -75,6 +75,12 @@ module Belt
75
75
 
76
76
  private
77
77
 
78
+ # Load infrastructure/<env>/belt.rb and apply its aws_profile + env vars
79
+ # to the current process. Without this, `terraform output` can't reach the
80
+ # S3 state backend (403), fetch_tf_output returns nil, and the deploy aborts
81
+ # with a misleading "Could not determine S3 bucket" error. The full
82
+ # `belt deploy` path applies this before invoking the frontend deploy;
83
+ # standalone `belt deploy frontend` must do it too.
78
84
  def load_and_apply_env_config!
79
85
  env_config = EnvironmentConfig.load(@env, infra_dir: @infra_dir)
80
86
  env_config.apply!
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative 'app_detection'
4
4
  require_relative 'env_resolver'
5
+ require_relative 'environment_config'
5
6
  require_relative 'frontend_env_map'
6
7
  require_relative 'frontend_registry'
8
+ require_relative 'terraform_command'
7
9
 
8
10
  module Belt
9
11
  module CLI
@@ -111,18 +113,19 @@ module Belt
111
113
  def initialize(env, frontend: nil)
112
114
  @env = env
113
115
  @app_name = detect_app_name
114
- @env_dir = "infrastructure/#{@env}"
116
+ @infra_dir = TerraformCommand.find_infrastructure_dir || 'infrastructure'
117
+ @env_dir = File.join(@infra_dir, @env)
115
118
  @frontend = frontend || FrontendRegistry.new.resolve!
116
119
  end
117
120
 
118
121
  def run
122
+ load_and_apply_env_config!
123
+
119
124
  unless Dir.exist?(@frontend.path)
120
125
  abort "Error: No #{@frontend.path}/ directory found. Run `belt generate frontend react` first."
121
126
  end
122
127
 
123
- unless Dir.exist?(@env_dir)
124
- abort "Error: infrastructure/#{@env} not found. Run `belt generate environment #{@env}` first."
125
- end
128
+ abort "Error: #{@env_dir} not found. Run `belt generate environment #{@env}` first." unless Dir.exist?(@env_dir)
126
129
 
127
130
  map = FrontendEnvMap.new(@env, env_dir: @env_dir, frontend_path: @frontend.path)
128
131
 
@@ -142,6 +145,12 @@ module Belt
142
145
  puts "✅ Updated #{result[:path]} (#{updated.join(', ')})"
143
146
  end
144
147
  end
148
+
149
+ private
150
+
151
+ def load_and_apply_env_config!
152
+ EnvironmentConfig.load(@env, infra_dir: @infra_dir).apply!
153
+ end
145
154
  end
146
155
  end
147
156
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'json'
4
4
  require 'open3'
5
+ require_relative 'environment_config'
6
+ require_relative 'terraform_command'
5
7
 
6
8
  module Belt
7
9
  module CLI
@@ -76,6 +78,8 @@ module Belt
76
78
  @env ||= detect_environment
77
79
  abort 'Error: Cannot determine environment. Pass -e ENV or set BELT_ENV.' unless @env
78
80
 
81
+ apply_env_config!
82
+
79
83
  @app_name = detect_app_name
80
84
  abort 'Error: Cannot determine app name.' unless @app_name
81
85
 
@@ -88,6 +92,13 @@ module Belt
88
92
 
89
93
  private
90
94
 
95
+ def apply_env_config!
96
+ infra_dir = TerraformCommand.find_infrastructure_dir || find_infra_dir
97
+ env_config = EnvironmentConfig.load(@env, infra_dir: infra_dir)
98
+ env_config.apply!
99
+ puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
100
+ end
101
+
91
102
  def parse_args(args)
92
103
  i = 0
93
104
  while i < args.length
@@ -3,6 +3,7 @@
3
3
  require 'base64'
4
4
  require 'json'
5
5
  require_relative 'app_detection'
6
+ require_relative 'environment_config'
6
7
  require_relative 'frontend_env_map'
7
8
  require_relative 'frontend_registry'
8
9
  require_relative 'terraform_command'
@@ -125,6 +126,9 @@ module Belt
125
126
  env_name = @deploy_env || ENV.fetch('BELT_ENV', nil) || TerraformCommand.list_environments.first
126
127
  return {} unless env_name
127
128
 
129
+ infra_dir = TerraformCommand.find_infrastructure_dir
130
+ EnvironmentConfig.load(env_name, infra_dir: infra_dir).apply!
131
+
128
132
  FrontendEnvMap.new(env_name, frontend_path: @frontend.path).process_env
129
133
  rescue StandardError
130
134
  # Fall back to legacy api_url detection if map resolution fails
@@ -274,6 +278,7 @@ module Belt
274
278
  next unless Dir.exist?(env_dir)
275
279
  next unless File.exist?(File.join(env_dir, '.terraform'))
276
280
 
281
+ EnvironmentConfig.load(env, infra_dir: infra_dir).apply!
277
282
  url = read_api_url_from_outputs(env_dir)
278
283
  if url
279
284
  @deploy_env = env
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla