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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/belt/cli/apex_dns_sync.rb +75 -0
- data/lib/belt/cli/deploy_command.rb +80 -2
- data/lib/belt/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21aaed39c7b6868b8c6f370cbc97a686bf369f7d3fe7a03c56b850c1bc49fd62
|
|
4
|
+
data.tar.gz: 8825ef22ce89cee45435dcd1e7b405c954c9f630e90f569ee0d575de33db5b49
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
735
|
-
|
|
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