belt 0.4.6 → 0.4.8

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: f29c42b3eb7869a90ab1a0a22d56fd6b1304dbd6e22f32503669b3c9b0974890
4
- data.tar.gz: f313115ee77ceafb82a4c424c6721f6776f3b869c1b71cc1e458332bc16b53fd
3
+ metadata.gz: 21aaed39c7b6868b8c6f370cbc97a686bf369f7d3fe7a03c56b850c1bc49fd62
4
+ data.tar.gz: 8825ef22ce89cee45435dcd1e7b405c954c9f630e90f569ee0d575de33db5b49
5
5
  SHA512:
6
- metadata.gz: 57150cb2d0934d723b6fafb9eaee652dc933c4522fcae787518d2e03990da13e1c9f229d498b990a86fb1daa90ec3916e4e049e411a418a4534e76f4274a658e
7
- data.tar.gz: 8e2d42b8ba9417dacdf725a66cc0e0a0d37e3a7ae59c3085a50f40c7c1e38eb298be49fa0a0ac3d3489e445e3907d85174bc07f45959a385ccbe55a605a5016d
6
+ metadata.gz: 9bdf007e3adcc59dd1e852f2d9563002416272ae4f954ce72231856bead07c261cc6bad6a86a8b6bbdbc6a2896b321eb717dcf5f70bda7e77cc2538ed67b2970
7
+ data.tar.gz: 95c20325ca7427776e6c08dbf9c6e2ad3a8dacb4293339bf24cb48dc74021a887dfd0152865a0cc295c311314047f605119e22ea8b293ba9e9dbb977a0a8c549
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.8
4
+
5
+ ### Added
6
+
7
+ - **`belt deploy` auto-bootstraps the ACM certificate on first deploy.** First-time
8
+ deploys hit the classic ACM chicken-and-egg problem: `terraform plan` fails because
9
+ `domain_validation_options` aren't known until the certificate is applied. Belt now
10
+ detects that error pattern, runs
11
+ `terraform apply -target=module.app.aws_acm_certificate.app` to create the certificate,
12
+ and re-runs the plan automatically. No manual terraform commands needed — first-time
13
+ deploys just work. This is a one-time bootstrap; subsequent deploys run normally since
14
+ the certificate already exists.
15
+
16
+ ## 0.4.7
17
+
18
+ ### Bug Fix
19
+
20
+ - **`apex_dns_sync` now publishes SES DKIM CNAMEs to the root zone.** Apex
21
+ environments delegate the bare domain to the shared-services root zone, so DNS
22
+ records written only to the prod-account zone never reach the internet.
23
+ `ApexDnsSync` already mirrored apex/www/api A aliases and ACM validation CNAMEs
24
+ into the root zone, but not the 3 SES DKIM CNAMEs — leaving SES domain email at
25
+ the apex stuck in `PENDING` / `HOST_NOT_FOUND` with every send rejected.
26
+ `#run` now reads the domain identity's DKIM tokens from SES (under the prod
27
+ profile) and upserts `<token>._domainkey.<domain>` → `<token>.dkim.amazonses.com`
28
+ into the root zone via the same cross-account write path. No-ops silently when
29
+ the domain has no SES identity, so email-less apps are unaffected.
30
+
3
31
  ## 0.4.6
4
32
 
5
33
  ### Added
@@ -62,6 +62,12 @@ module Belt
62
62
  # Sync ACM validation first (cert must validate before CloudFront works)
63
63
  sync_acm_validation(root_zone_id, targets[:domain])
64
64
 
65
+ # Sync SES DKIM CNAMEs so the email domain identity can verify. Like the
66
+ # apex A records, these must live in the ROOT zone (the registrar delegates
67
+ # the apex there) — writing them to the prod account's own zone leaves them
68
+ # invisible and the SES identity stuck at PENDING/HOST_NOT_FOUND.
69
+ sync_dkim_cnames(root_zone_id, targets[:domain])
70
+
65
71
  # Sync A alias records
66
72
  sync_alias_records(root_zone_id, targets)
67
73
 
@@ -303,6 +309,75 @@ module Belt
303
309
  end
304
310
  end
305
311
 
312
+ # Publish the SES Easy-DKIM CNAMEs for the apex email domain into the root
313
+ # zone. SES issues three tokens per domain identity; each maps to a CNAME
314
+ # "<token>._domainkey.<domain>" -> "<token>.dkim.amazonses.com". Publishing
315
+ # them where the domain is actually delegated (the root zone) is what flips
316
+ # the identity from PENDING/HOST_NOT_FOUND to verified so sends are accepted.
317
+ # No-ops quietly when the domain has no SES identity (email not enabled).
318
+ def sync_dkim_cnames(root_zone_id, domain)
319
+ return unless domain
320
+
321
+ tokens = fetch_dkim_tokens(domain)
322
+ return if tokens.empty?
323
+
324
+ changes = build_dkim_changes(domain, tokens)
325
+ upsert_dkim_cnames(root_zone_id, changes, tokens.size)
326
+ end
327
+
328
+ # Read the domain identity's DKIM tokens from SES under the PROD profile
329
+ # (that's the account that owns the identity). Returns [] if there's no
330
+ # identity or SES call fails, so email-less apps skip silently.
331
+ def fetch_dkim_tokens(domain)
332
+ env = aws_env_for(@env_config)
333
+
334
+ output, status = Open3.capture2e(
335
+ env,
336
+ 'aws', 'sesv2', 'get-email-identity',
337
+ '--email-identity', domain,
338
+ '--output', 'json'
339
+ )
340
+ return [] unless status.success?
341
+
342
+ data = parse_json(output) || {}
343
+ data.dig('DkimAttributes', 'Tokens') || []
344
+ end
345
+
346
+ def build_dkim_changes(domain, tokens)
347
+ tokens.map do |token|
348
+ {
349
+ Action: 'UPSERT',
350
+ ResourceRecordSet: {
351
+ Name: "#{token}._domainkey.#{domain}",
352
+ Type: 'CNAME',
353
+ TTL: 300,
354
+ ResourceRecords: [{ Value: "#{token}.dkim.amazonses.com" }]
355
+ }
356
+ }
357
+ end
358
+ end
359
+
360
+ def upsert_dkim_cnames(root_zone_id, changes, count)
361
+ change_batch = {
362
+ Comment: 'Belt SES DKIM sync',
363
+ Changes: changes
364
+ }
365
+
366
+ dns_env = aws_env_for(@dns_config)
367
+ _, status = Open3.capture2e(
368
+ dns_env,
369
+ 'aws', 'route53', 'change-resource-record-sets',
370
+ '--hosted-zone-id', root_zone_id,
371
+ '--change-batch', JSON.generate(change_batch)
372
+ )
373
+
374
+ if status.success?
375
+ puts " ✓ SES DKIM CNAMEs synced (#{count})" unless @quiet
376
+ else
377
+ puts ' ⚠ Failed to sync SES DKIM CNAMEs' unless @quiet
378
+ end
379
+ end
380
+
306
381
  def alias_change(action, name, target_domain, target_zone_id)
307
382
  {
308
383
  Action: action,
@@ -731,9 +731,87 @@ module Belt
731
731
 
732
732
  def run_plan
733
733
  puts '━━━ terraform plan ━━━'
734
- success = system('terraform', 'plan', '-out=tfplan', *@extra_args)
735
- abort "\n✗ terraform plan failed" unless success
734
+
735
+ stderr_buffer = run_terraform_plan
736
+ return if stderr_buffer.nil? # Success
737
+
738
+ # If ACM for_each error, auto-bootstrap and retry
739
+ return if acm_for_each_error?(stderr_buffer) && acm_bootstrap_succeeded?
740
+
741
+ abort "\n✗ terraform plan failed"
742
+ end
743
+
744
+ # Returns nil on success, stderr buffer on failure
745
+ def run_terraform_plan
746
+ require 'open3'
747
+ stderr_buffer = +''
748
+
749
+ Open3.popen3('terraform', 'plan', '-out=tfplan', *@extra_args) do |_stdin, stdout, stderr, wait_thr|
750
+ stdout_thread = Thread.new do
751
+ while (line = stdout.gets)
752
+ print line
753
+ end
754
+ end
755
+
756
+ stderr_thread = Thread.new do
757
+ while (line = stderr.gets)
758
+ stderr_buffer << line
759
+ $stderr.print line
760
+ end
761
+ end
762
+
763
+ stdout_thread.join
764
+ stderr_thread.join
765
+
766
+ return nil if wait_thr.value.success?
767
+ end
768
+
769
+ stderr_buffer
770
+ end
771
+
772
+ def acm_for_each_error?(output)
773
+ # Detect the ACM certificate for_each chicken-and-egg error
774
+ output.include?('for_each') &&
775
+ output.include?('domain_validation_options') &&
776
+ output.include?('cannot be determined until apply')
777
+ end
778
+
779
+ # Returns true if bootstrap succeeded and retry plan passed
780
+ def acm_bootstrap_succeeded?
781
+ puts ''
782
+ puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
783
+ puts ' ACM CERTIFICATE BOOTSTRAP'
784
+ puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
785
+ puts ''
786
+ puts ' First-time deploy detected. Terraform needs the ACM certificate'
787
+ puts ' to exist before it can create DNS validation records.'
788
+ puts ''
789
+ puts ' Creating certificate now...'
736
790
  puts ''
791
+
792
+ # Phase 1: Create the certificate only
793
+ success = system('terraform', 'apply', '-target=module.app.aws_acm_certificate.app', '-auto-approve')
794
+ unless success
795
+ puts ''
796
+ puts ' ✗ Certificate creation failed.'
797
+ return false
798
+ end
799
+
800
+ puts ''
801
+ puts ' ✓ Certificate created. Re-running plan...'
802
+ puts ''
803
+
804
+ # Phase 2: Retry the plan
805
+ stderr_buffer = run_terraform_plan
806
+ if stderr_buffer.nil?
807
+ puts ''
808
+ return true # Success!
809
+ end
810
+
811
+ # Still failing - something else is wrong
812
+ puts ''
813
+ puts ' ✗ Plan still failing after bootstrap. Check the errors above.'
814
+ false
737
815
  end
738
816
 
739
817
  def confirm_apply?
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.6'
4
+ VERSION = '0.4.8'
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.6
4
+ version: 0.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla