belt 0.3.40 → 0.3.42

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: 848174ed726800ea7e2a60b16a43714e558bf8adae32017a195a4ef0982cff11
4
- data.tar.gz: 3d71874ef9d72422b72059e768de032b07caeb9b719bfaaf307d5d4d634b48ee
3
+ metadata.gz: a46f69166308a770107e86d0ccee9499a62e60858ebf000c74990183670eacfc
4
+ data.tar.gz: 988f5e06c5b6f03fb42be923ea9864e9a9bdbf2f12bc4d8bd963c82066eaa9fe
5
5
  SHA512:
6
- metadata.gz: f431f7d645a3e94cdbf799f0d6fdd44a817491fd991da85b3bce7df95e760359638b1eeb528b39f3dc33edc51a324f4d0a4627152e0e4c025749020b3549ab91
7
- data.tar.gz: 59fc7c275194d8ac49c4f69a0eb0109dcdf516eb1f6f597d5c7a9a5f0e16046d2d0dd6c88920c94c58735e643a0e231b3ce331fa919a7bc1d240c91ecb710c2f
6
+ metadata.gz: 6f614e8b43dfbe1a6431b0ae8f8a04f3bd92a33f305afd4c75d5a84252a45714b9462fbbbdc63d6978a873da674da053581f7a7afb521f011a06badcd6bc5913
7
+ data.tar.gz: 766f648f1bc4b795698f605f729682ab44baf7e27fa66dd8ff73e6c618cd1bba21cd8ae15c75cdff9d8c1b6e9a98b84f5be4b1bd828d7e16c0efcc0071ef9a77
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.41
4
+
5
+ ### Bug Fix
6
+
7
+ - **Fix API Gateway sibling path parameter conflict**: When a resource has nested
8
+ resources (a block), member routes (show, update, destroy) now use `{param_name}`
9
+ (e.g., `{project_id}`) instead of `{id}` to match the nested routes. This prevents
10
+ API Gateway from rejecting routes due to sibling path segments having different
11
+ parameter names.
12
+
13
+ Without nested resources (no block):
14
+ ```
15
+ GET /posts/{id}
16
+ PUT /posts/{id}
17
+ DELETE /posts/{id}
18
+ ```
19
+
20
+ With nested resources:
21
+ ```
22
+ GET /projects/{project_id}
23
+ PUT /projects/{project_id}
24
+ DELETE /projects/{project_id}
25
+ GET /projects/{project_id}/epics
26
+ GET /projects/{project_id}/epics/{id}
27
+ ```
28
+
29
+ This fixes the error: "Unable to create resource at path '...': A sibling ({id})
30
+ of this resource already has a variable path part".
31
+
3
32
  ## 0.3.40
4
33
 
5
34
  ### Breaking Change
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require_relative 'environment_config'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Syncs DNS records for apex (prod) environments to the root zone.
10
+ #
11
+ # Problem: When prod uses the apex domain (example.com), it creates a Route53
12
+ # zone and A records. But the registrar's NS records point to the ROOT zone
13
+ # (infrastructure/dns), not the prod zone. So the prod zone's records are
14
+ # invisible to the internet.
15
+ #
16
+ # Solution: After deploying prod, copy the A alias records (CloudFront, API
17
+ # Gateway) to the root zone, and ensure ACM validation CNAMEs are there too.
18
+ class ApexDnsSync
19
+ DNS_DIR = 'infrastructure/dns'
20
+
21
+ def initialize(env, infra_dir:, quiet: false)
22
+ @env = env
23
+ @infra_dir = infra_dir
24
+ @quiet = quiet
25
+ @project_root = find_project_root
26
+ @env_config = EnvironmentConfig.load(env, infra_dir: infra_dir)
27
+ @dns_config = dns_dir_exists? ? load_dns_config : nil
28
+ end
29
+
30
+ # Check if this environment needs apex DNS sync.
31
+ # Returns true if:
32
+ # 1. This is a prod environment (app_domain == domain, no env prefix)
33
+ # 2. DNS root zone exists (infrastructure/dns)
34
+ # 3. Domain is configured
35
+ def needs_sync?
36
+ return false unless dns_dir_exists?
37
+ return false unless apex_environment?
38
+
39
+ true
40
+ end
41
+
42
+ # Run the sync: copy prod's DNS records to the root zone.
43
+ def run
44
+ return unless needs_sync?
45
+
46
+ puts '━━━ apex DNS sync ━━━' unless @quiet
47
+
48
+ # Get prod's DNS targets (CloudFront, API Gateway)
49
+ targets = fetch_prod_targets
50
+ if targets.empty? || targets[:domain].nil?
51
+ puts ' ⚠ Could not read DNS targets from prod — skipping sync' unless @quiet
52
+ return
53
+ end
54
+
55
+ # Get root zone ID
56
+ root_zone_id = fetch_root_zone_id
57
+ unless root_zone_id
58
+ puts ' ⚠ Could not read root zone ID — skipping sync' unless @quiet
59
+ return
60
+ end
61
+
62
+ # Sync ACM validation first (cert must validate before CloudFront works)
63
+ sync_acm_validation(root_zone_id, targets[:domain])
64
+
65
+ # Sync A alias records
66
+ sync_alias_records(root_zone_id, targets)
67
+
68
+ puts ' ✓ Apex DNS synced to root zone' unless @quiet
69
+ end
70
+
71
+ private
72
+
73
+ def dns_dir_exists?
74
+ dns_path = File.join(@project_root, DNS_DIR)
75
+ Dir.exist?(dns_path) && File.exist?(File.join(dns_path, 'terraform.tfvars'))
76
+ end
77
+
78
+ def dns_dir_path
79
+ File.join(@project_root, DNS_DIR)
80
+ end
81
+
82
+ def find_project_root
83
+ dir = @infra_dir
84
+ while dir != '/'
85
+ return dir if File.exist?(File.join(dir, 'Gemfile')) ||
86
+ File.exist?(File.join(dir, 'belt.rb'))
87
+
88
+ dir = File.dirname(dir)
89
+ end
90
+ File.dirname(@infra_dir)
91
+ end
92
+
93
+ def load_dns_config
94
+ EnvironmentConfig.load('dns', infra_dir: File.join(@project_root, 'infrastructure'))
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ def apex_environment?
100
+ # Check tfvars to see if this is a prod environment
101
+ env_dir = File.join(@infra_dir, @env)
102
+ return false unless Dir.exist?(env_dir)
103
+
104
+ tfvars_path = File.join(env_dir, 'terraform.tfvars')
105
+ return false unless File.exist?(tfvars_path)
106
+
107
+ content = File.read(tfvars_path)
108
+ domain_match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
109
+ env_match = content.match(/^\s*environment\s*=\s*"([^"]+)"/)
110
+ parent_match = content.match(/^\s*parent_environment\s*=\s*"([^"]+)"/)
111
+
112
+ return false unless domain_match
113
+
114
+ # Not apex if it's a nested environment
115
+ parent = parent_match ? parent_match[1] : ''
116
+ return false unless parent.empty?
117
+
118
+ # Apex if environment is "prod" (convention)
119
+ env_name = env_match ? env_match[1] : @env
120
+ env_name == 'prod'
121
+ end
122
+
123
+ def fetch_prod_targets
124
+ env_dir = File.join(@infra_dir, @env)
125
+ targets = {}
126
+
127
+ Dir.chdir(env_dir) do
128
+ env = aws_env_for(@env_config)
129
+
130
+ # Get terraform outputs
131
+ output, status = Open3.capture2e(env, 'terraform', 'output', '-json')
132
+ return targets unless status.success?
133
+
134
+ data = parse_json(output) || {}
135
+ extract_cloudfront_target(data, targets)
136
+ extract_api_gateway_target(data, targets)
137
+ extract_domain(targets)
138
+ end
139
+
140
+ targets
141
+ end
142
+
143
+ def extract_cloudfront_target(data, targets)
144
+ cf_domain = data.dig('cloudfront_domain_name', 'value')
145
+ return unless cf_domain
146
+
147
+ cf_zone = data.dig('cloudfront_hosted_zone_id', 'value')
148
+ targets[:cloudfront] = {
149
+ domain_name: cf_domain,
150
+ hosted_zone_id: cf_zone || 'Z2FDTNDATAQYW2' # CloudFront's fixed zone ID
151
+ }
152
+ end
153
+
154
+ def extract_api_gateway_target(data, targets)
155
+ apigw_domain = data.dig('api_gateway_domain_name', 'value')
156
+ apigw_zone = data.dig('api_gateway_hosted_zone_id', 'value')
157
+ return unless apigw_domain && !apigw_domain.empty? && apigw_zone && !apigw_zone.empty?
158
+
159
+ targets[:api_gateway] = {
160
+ domain_name: apigw_domain,
161
+ hosted_zone_id: apigw_zone
162
+ }
163
+ end
164
+
165
+ def extract_domain(targets)
166
+ tfvars_path = 'terraform.tfvars'
167
+ return unless File.exist?(tfvars_path)
168
+
169
+ match = File.read(tfvars_path).match(/^\s*domain\s*=\s*"([^"]+)"/)
170
+ targets[:domain] = match[1] if match
171
+ end
172
+
173
+ def fetch_root_zone_id
174
+ return nil unless dns_dir_exists?
175
+
176
+ Dir.chdir(dns_dir_path) do
177
+ env = aws_env_for(@dns_config)
178
+
179
+ output, status = Open3.capture2e(env, 'terraform', 'output', '-json', 'root_zone_id')
180
+ return nil unless status.success?
181
+
182
+ begin
183
+ JSON.parse(output)
184
+ rescue JSON::ParserError
185
+ nil
186
+ end
187
+ end
188
+ end
189
+
190
+ def sync_alias_records(root_zone_id, targets)
191
+ return unless targets[:domain] && targets[:cloudfront]
192
+
193
+ domain = targets[:domain]
194
+ cf = targets[:cloudfront]
195
+ apigw = targets[:api_gateway]
196
+
197
+ # Build change batch for alias records
198
+ changes = []
199
+
200
+ # Apex domain → CloudFront
201
+ changes << alias_change('UPSERT', domain, cf[:domain_name], cf[:hosted_zone_id])
202
+
203
+ # www → CloudFront
204
+ changes << alias_change('UPSERT', "www.#{domain}", cf[:domain_name], cf[:hosted_zone_id])
205
+
206
+ # api → API Gateway (if configured)
207
+ if apigw && apigw[:domain_name] && apigw[:hosted_zone_id]
208
+ changes << alias_change('UPSERT', "api.#{domain}", apigw[:domain_name], apigw[:hosted_zone_id])
209
+ end
210
+
211
+ change_batch = {
212
+ Comment: 'Belt apex DNS sync',
213
+ Changes: changes
214
+ }
215
+
216
+ # Apply via Route53 API
217
+ env = aws_env_for(@dns_config)
218
+ _, status = Open3.capture2e(
219
+ env,
220
+ 'aws', 'route53', 'change-resource-record-sets',
221
+ '--hosted-zone-id', root_zone_id,
222
+ '--change-batch', JSON.generate(change_batch)
223
+ )
224
+
225
+ if status.success?
226
+ records = [domain, "www.#{domain}"]
227
+ records << "api.#{domain}" if apigw
228
+ puts " ✓ A records: #{records.join(', ')}" unless @quiet
229
+ else
230
+ puts ' ⚠ Failed to sync A records to root zone' unless @quiet
231
+ end
232
+ end
233
+
234
+ def sync_acm_validation(root_zone_id, _domain)
235
+ env_dir = File.join(@infra_dir, @env)
236
+
237
+ Dir.chdir(env_dir) do
238
+ env = aws_env_for(@env_config)
239
+
240
+ # Get ACM certificate from state
241
+ output, status = Open3.capture2e(
242
+ env,
243
+ 'terraform', 'state', 'show', '-json', 'module.app.aws_acm_certificate.app[0]'
244
+ )
245
+ return unless status.success?
246
+
247
+ cert_data = parse_json(output)
248
+ return unless cert_data
249
+
250
+ cert_status = cert_data.dig('values', 'status')
251
+
252
+ # Skip if already issued
253
+ if cert_status == 'ISSUED'
254
+ puts ' ✓ ACM certificate already issued' unless @quiet
255
+ return
256
+ end
257
+
258
+ # Get validation options
259
+ validation_options = cert_data.dig('values', 'domain_validation_options') || []
260
+ return if validation_options.empty?
261
+
262
+ changes = build_validation_changes(validation_options)
263
+ upsert_validation_cnames(root_zone_id, changes)
264
+ end
265
+ end
266
+
267
+ def build_validation_changes(validation_options)
268
+ changes = validation_options.map do |opt|
269
+ {
270
+ Action: 'UPSERT',
271
+ ResourceRecordSet: {
272
+ Name: opt['resource_record_name'],
273
+ Type: 'CNAME',
274
+ TTL: 300,
275
+ ResourceRecords: [{ Value: opt['resource_record_value'] }]
276
+ }
277
+ }
278
+ end
279
+
280
+ # Dedupe by name (ACM uses same CNAME for base and wildcard)
281
+ changes.uniq! { |c| c[:ResourceRecordSet][:Name] }
282
+ changes
283
+ end
284
+
285
+ def upsert_validation_cnames(root_zone_id, changes)
286
+ change_batch = {
287
+ Comment: 'Belt ACM validation sync',
288
+ Changes: changes
289
+ }
290
+
291
+ dns_env = aws_env_for(@dns_config)
292
+ _, status = Open3.capture2e(
293
+ dns_env,
294
+ 'aws', 'route53', 'change-resource-record-sets',
295
+ '--hosted-zone-id', root_zone_id,
296
+ '--change-batch', JSON.generate(change_batch)
297
+ )
298
+
299
+ if status.success?
300
+ puts ' ✓ ACM validation CNAME synced (cert pending)' unless @quiet
301
+ else
302
+ puts ' ⚠ Failed to sync ACM validation CNAME' unless @quiet
303
+ end
304
+ end
305
+
306
+ def alias_change(action, name, target_domain, target_zone_id)
307
+ {
308
+ Action: action,
309
+ ResourceRecordSet: {
310
+ Name: name,
311
+ Type: 'A',
312
+ AliasTarget: {
313
+ DNSName: target_domain,
314
+ HostedZoneId: target_zone_id,
315
+ EvaluateTargetHealth: false
316
+ }
317
+ }
318
+ }
319
+ end
320
+
321
+ def aws_env_for(config)
322
+ env = {}
323
+ env['AWS_PROFILE'] = config.aws_profile if config&.aws_profile?
324
+ env
325
+ end
326
+
327
+ def parse_json(output)
328
+ JSON.parse(output)
329
+ rescue JSON::ParserError
330
+ nil
331
+ end
332
+ end
333
+ end
334
+ end
@@ -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 'apex_dns_sync'
17
18
 
18
19
  module Belt
19
20
  module CLI
@@ -173,6 +174,9 @@ module Belt
173
174
  run_apply
174
175
  end
175
176
 
177
+ # Sync apex DNS records to root zone (if this is a prod/apex environment)
178
+ run_apex_dns_sync
179
+
176
180
  puts "\n✅ Deployed #{@env} successfully!"
177
181
  print_outputs(env_dir)
178
182
 
@@ -733,6 +737,14 @@ module Belt
733
737
  FileUtils.rm_f('tfplan')
734
738
  end
735
739
 
740
+ def run_apex_dns_sync
741
+ sync = ApexDnsSync.new(@env, infra_dir: @infra_dir)
742
+ return unless sync.needs_sync?
743
+
744
+ puts ''
745
+ sync.run
746
+ end
747
+
736
748
  def run_nested_env_hooks
737
749
  nested = NestedEnvironment.for(@env, infra_dir: @infra_dir)
738
750
  return unless nested
@@ -29,6 +29,8 @@ module Belt
29
29
  new.remove_environment(args)
30
30
  when 'show', 'list'
31
31
  new.show(args)
32
+ when 'doctor'
33
+ new.doctor(args)
32
34
  when '--help', '-h', 'help'
33
35
  puts help
34
36
  else
@@ -47,6 +49,7 @@ module Belt
47
49
  add <env> Add an environment's NS records to dns/terraform.tfvars
48
50
  remove <env> Remove an environment's NS records from dns/terraform.tfvars
49
51
  show Show root zone name servers (for registrar configuration)
52
+ doctor Diagnose DNS configuration across all environments
50
53
  help Show this help
51
54
 
52
55
  Options for generate:
@@ -60,6 +63,7 @@ module Belt
60
63
  belt dns add staging # Add staging's NS records to tfvars
61
64
  belt dns remove staging # Remove staging's NS delegation
62
65
  belt dns show # Show root name servers to configure at registrar
66
+ belt dns doctor # Check DNS health for all environments
63
67
 
64
68
  The dns directory manages your root domain and delegates subdomains to
65
69
  per-environment hosted zones. Each environment (dev, staging, prod) gets
@@ -343,8 +347,334 @@ module Belt
343
347
  end
344
348
  end
345
349
 
350
+ # --- Doctor ---
351
+ # Diagnose DNS configuration across all environments
352
+ def doctor(args)
353
+ require 'open3'
354
+ require_relative 'terraform_command'
355
+
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
362
+
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
371
+
372
+ puts "DNS Health: #{domain}"
373
+ puts '═' * 64
374
+ puts ''
375
+
376
+ # Check root zone
377
+ if Dir.exist?(DNS_DIR)
378
+ check_root_zone(domain)
379
+ else
380
+ puts 'Root Zone (infrastructure/dns)'
381
+ puts '────────────────────────────────────────'
382
+ puts ' ⚠ Not configured'
383
+ puts ' Run: belt dns generate'
384
+ puts ''
385
+ end
386
+
387
+ # Check each environment
388
+ environments = if env_filter
389
+ [env_filter]
390
+ else
391
+ TerraformCommand.list_environments
392
+ end
393
+
394
+ environments.each do |env_name|
395
+ check_environment(env_name, domain)
396
+ end
397
+ end
398
+
346
399
  private
347
400
 
401
+ def parse_json(output)
402
+ JSON.parse(output)
403
+ rescue JSON::ParserError
404
+ nil
405
+ end
406
+
407
+ def check_root_zone(domain)
408
+ puts 'Root Zone (shared account)'
409
+ puts '────────────────────────────────────────'
410
+
411
+ dns_config = load_dns_config
412
+ env = {}
413
+ env['AWS_PROFILE'] = dns_config.aws_profile if dns_config.aws_profile?
414
+
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
422
+
423
+ parse_json(output)
424
+ end
425
+
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') || []
434
+
435
+ 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
441
+ else
442
+ puts ' ⚠ Zone not created yet'
443
+ end
444
+
445
+ if name_servers.any?
446
+ puts " ✓ NS records configured (#{name_servers.length} servers)"
447
+
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}..."
456
+ else
457
+ puts ' ⚠ Could not verify registrar NS records (DNS lookup failed)'
458
+ end
459
+ else
460
+ puts ' ⚠ No NS records found'
461
+ end
462
+
463
+ if delegated.any?
464
+ puts " ✓ Delegated: #{delegated.join(', ')}"
465
+ else
466
+ puts ' ⚠ No environments delegated'
467
+ end
468
+
469
+ # Check for apex records (should exist if prod is deployed)
470
+ check_root_zone_apex_records(zone_id, domain, env) if zone_id
471
+
472
+ puts ''
473
+ end
474
+
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?
485
+
486
+ records = begin
487
+ JSON.parse(output)
488
+ rescue JSON::ParserError
489
+ []
490
+ end
491
+
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}" }
495
+
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)'
506
+ end
507
+ end
508
+
509
+ def check_environment(env_name, domain)
510
+ env_dir = "infrastructure/#{env_name}"
511
+ return unless Dir.exist?(env_dir)
512
+
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'
517
+
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
539
+ end
540
+
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 '────────────────────────────────────────'
548
+
549
+ env_config = begin
550
+ EnvironmentConfig.load(env_name)
551
+ rescue StandardError
552
+ nil
553
+ end
554
+
555
+ unless env_config
556
+ puts ' ⚠ No belt.rb config found'
557
+ puts ''
558
+ return
559
+ end
560
+
561
+ env = {}
562
+ env['AWS_PROFILE'] = env_config.aws_profile if env_config.aws_profile?
563
+
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)
581
+ end
582
+
583
+ puts ''
584
+ end
585
+
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'
593
+ end
594
+
595
+ # Check ACM certificate
596
+ check_acm_cert(env_dir, env)
597
+
598
+ # Check delegation in root zone (for non-apex envs)
599
+ check_delegation(env_name, domain, name_servers) unless is_prod
600
+
601
+ # For apex (prod), check if DNS resolves
602
+ check_dns_resolution(env_domain) if is_prod && env_domain
603
+ end
604
+
605
+ def check_acm_cert(_env_dir, aws_env)
606
+ # Try to get cert status from state
607
+ output, status = Open3.capture2e(
608
+ aws_env,
609
+ 'terraform', 'state', 'show', '-json', 'module.app.aws_acm_certificate.app[0]'
610
+ )
611
+ return unless status.success?
612
+
613
+ cert_data = parse_json(output)
614
+ return unless cert_data
615
+
616
+ cert_domain = cert_data.dig('values', 'domain_name')
617
+ cert_status = cert_data.dig('values', 'status')
618
+
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'
625
+ else
626
+ puts " ⚠ ACM certificate: #{cert_status || 'unknown'}"
627
+ end
628
+ end
629
+
630
+ def check_delegation(env_name, _domain, _expected_ns)
631
+ return unless Dir.exist?(DNS_DIR)
632
+
633
+ dns_config = load_dns_config
634
+ env = {}
635
+ env['AWS_PROFILE'] = dns_config.aws_profile if dns_config.aws_profile?
636
+
637
+ # Read tfvars to check if this env is delegated
638
+ tfvars_path = "#{DNS_DIR}/terraform.tfvars"
639
+ return unless File.exist?(tfvars_path)
640
+
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}"
647
+ end
648
+ end
649
+
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'
659
+ end
660
+ end
661
+
662
+ def read_domain_from_dns_tfvars
663
+ tfvars_path = "#{DNS_DIR}/terraform.tfvars"
664
+ return nil unless File.exist?(tfvars_path)
665
+
666
+ content = File.read(tfvars_path)
667
+ match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
668
+ match ? match[1] : nil
669
+ end
670
+
671
+ def lookup_ns_records(domain)
672
+ output, status = Open3.capture2e('dig', '+short', 'NS', domain)
673
+ return [] unless status.success?
674
+
675
+ output.strip.split("\n").map { |ns| ns.chomp('.') }.sort
676
+ end
677
+
348
678
  def templates
349
679
  {
350
680
  'main.tf.erb' => 'main.tf',
@@ -416,9 +416,12 @@ module Belt
416
416
  resource_options = options.merge(route_type: :resources)
417
417
  actions = determine_actions(options)
418
418
 
419
- add_resource_routes(resource_name, param_name, resource_options, actions)
419
+ # When there are nested resources, use {param_name} for member routes to avoid
420
+ # API Gateway sibling path parameter conflicts. Without nested resources, use {id}.
421
+ has_nested = block_given?
422
+ add_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: has_nested)
420
423
 
421
- return unless block_given?
424
+ return unless has_nested
422
425
 
423
426
  collection_prefix = "/#{resource_name}"
424
427
  member_prefix = "/#{resource_name}/{#{param_name}}"
@@ -497,8 +500,11 @@ module Belt
497
500
  options.merge(tables: [resource_name.to_sym])
498
501
  end
499
502
 
500
- def add_resource_routes(resource_name, _param_name, resource_options, actions)
501
- # Member routes (show/update/destroy) use {id}, not {singular_id} Rails convention
503
+ def add_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: false)
504
+ # Member routes (show/update/destroy) use {id} by default (Rails convention).
505
+ # When nested resources exist (use_param_name: true), use {param_name} instead
506
+ # to avoid API Gateway sibling path parameter conflicts.
507
+ member_param = use_param_name ? param_name : 'id'
502
508
  if actions.include?(:index)
503
509
  add_route(:get, "/#{resource_name}",
504
510
  resolve_request_model_for(resource_options, :index))
@@ -508,16 +514,16 @@ module Belt
508
514
  resolve_request_model_for(resource_options, :create))
509
515
  end
510
516
  if actions.include?(:show)
511
- add_route(:get, "/#{resource_name}/{id}",
517
+ add_route(:get, "/#{resource_name}/{#{member_param}}",
512
518
  resolve_request_model_for(resource_options, :show))
513
519
  end
514
520
  if actions.include?(:update)
515
- add_route(:put, "/#{resource_name}/{id}",
521
+ add_route(:put, "/#{resource_name}/{#{member_param}}",
516
522
  resolve_request_model_for(resource_options, :update))
517
523
  end
518
524
  return unless actions.include?(:destroy)
519
525
 
520
- add_route(:delete, "/#{resource_name}/{id}", resolve_request_model_for(resource_options, :destroy))
526
+ add_route(:delete, "/#{resource_name}/{#{member_param}}", resolve_request_model_for(resource_options, :destroy))
521
527
  end
522
528
 
523
529
  def resolve_request_model_for(options, action)
@@ -799,8 +805,11 @@ module Belt
799
805
  end
800
806
  end
801
807
 
802
- def add_scoped_resource_routes(resource_name, _param_name, resource_options, actions)
803
- # Member routes (show/update/destroy) use {id}, not {singular_id} Rails convention
808
+ def add_scoped_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: false)
809
+ # Member routes (show/update/destroy) use {id} by default (Rails convention).
810
+ # When nested resources exist (use_param_name: true), use {param_name} instead
811
+ # to avoid API Gateway sibling path parameter conflicts.
812
+ member_param = use_param_name ? param_name : 'id'
804
813
  if actions.include?(:index)
805
814
  @gateway.send(:add_route, :get, build_path("/#{resource_name}"),
806
815
  resolve_request_model_for(resource_options, :index))
@@ -810,16 +819,16 @@ module Belt
810
819
  resolve_request_model_for(resource_options, :create))
811
820
  end
812
821
  if actions.include?(:show)
813
- @gateway.send(:add_route, :get, build_path("/#{resource_name}/{id}"),
822
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}/{#{member_param}}"),
814
823
  resolve_request_model_for(resource_options, :show))
815
824
  end
816
825
  if actions.include?(:update)
817
- @gateway.send(:add_route, :put, build_path("/#{resource_name}/{id}"),
826
+ @gateway.send(:add_route, :put, build_path("/#{resource_name}/{#{member_param}}"),
818
827
  resolve_request_model_for(resource_options, :update))
819
828
  end
820
829
  return unless actions.include?(:destroy)
821
830
 
822
- @gateway.send(:add_route, :delete, build_path("/#{resource_name}/{id}"),
831
+ @gateway.send(:add_route, :delete, build_path("/#{resource_name}/{#{member_param}}"),
823
832
  resolve_request_model_for(resource_options, :destroy))
824
833
  end
825
834
 
@@ -842,7 +851,10 @@ module Belt
842
851
  resource_options = options.merge(route_type: :resources, controller: controller)
843
852
  actions = determine_scoped_actions(options)
844
853
 
845
- add_scoped_resource_routes(resource_name, param_name, resource_options, actions)
854
+ # When there are nested resources, use {param_name} for member routes to avoid
855
+ # API Gateway sibling path parameter conflicts. Without nested resources, use {id}.
856
+ has_nested = !block.nil?
857
+ add_scoped_resource_routes(resource_name, param_name, resource_options, actions, use_param_name: has_nested)
846
858
  build_nested_resource_block(resource_name, param_name, options, controller, &block) if block
847
859
  end
848
860
 
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.3.40'
4
+ VERSION = '0.3.42'
5
5
  end
@@ -22,3 +22,31 @@ output "name_servers" {
22
22
  description = "NS records to configure at your registrar (standalone envs only)"
23
23
  value = local.dns_enabled && !local.is_nested ? aws_route53_zone.app[0].name_servers : []
24
24
  }
25
+
26
+ # --- Apex DNS sync outputs ---
27
+ # These enable `belt deploy prod` to sync records to the root zone.
28
+
29
+ output "cloudfront_domain_name" {
30
+ description = "CloudFront distribution domain name (for DNS alias)"
31
+ value = aws_cloudfront_distribution.frontend.domain_name
32
+ }
33
+
34
+ output "cloudfront_hosted_zone_id" {
35
+ description = "CloudFront hosted zone ID (always Z2FDTNDATAQYW2)"
36
+ value = aws_cloudfront_distribution.frontend.hosted_zone_id
37
+ }
38
+
39
+ output "api_gateway_domain_name" {
40
+ description = "API Gateway custom domain regional domain name"
41
+ value = local.dns_enabled ? aws_api_gateway_domain_name.api[0].regional_domain_name : ""
42
+ }
43
+
44
+ output "api_gateway_hosted_zone_id" {
45
+ description = "API Gateway custom domain hosted zone ID"
46
+ value = local.dns_enabled ? aws_api_gateway_domain_name.api[0].regional_zone_id : ""
47
+ }
48
+
49
+ output "is_apex_environment" {
50
+ description = "True if this is an apex (prod) environment using the bare domain"
51
+ value = local.dns_enabled && !local.is_nested && var.environment == "prod"
52
+ }
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.3.40
4
+ version: 0.3.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -103,6 +103,7 @@ files:
103
103
  - lib/belt/assets/belt-default.jpg
104
104
  - lib/belt/assets/welcome.css
105
105
  - lib/belt/cli.rb
106
+ - lib/belt/cli/apex_dns_sync.rb
106
107
  - lib/belt/cli/app_detection.rb
107
108
  - lib/belt/cli/auth_command.rb
108
109
  - lib/belt/cli/backup_config.rb