belt 0.3.28 → 0.3.30

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: d392dbd28d642a7f18a3087e8640ef1382b5da70199804a77232b99f365a6822
4
- data.tar.gz: 791f51d17d764ada25f3ce6395a141d02f3b1474556df4299067b78726b1db3c
3
+ metadata.gz: 79ffa332a2eb6408d42c4652d1643009edac470adb6eabbcb9e6818286276977
4
+ data.tar.gz: 145edae1f0f725c16232a648d64ad3224ac93baca18273648b901e7d4ef6ad66
5
5
  SHA512:
6
- metadata.gz: bc6051c477ad262a13029781cb3a12a8df262a8d3a94d9a5f8a98b3adcb379d8c4f0d761244144cac8c69a37abbf7d3a5eba88fb3b7a7f2ba21a1cc41e77e196
7
- data.tar.gz: b6470146bd1920c26462f8b37279eda6c35a92f5a184e37e2171d1015e2d694ac7e5b6b07b2a34cc7bd40f1af8464d891ae4f75d9808c8ee0af956e618aa494e
6
+ metadata.gz: '09e7a47ed0e3d34e699cbd2e1c14785399015ea66cde8b21dfd194e86d09456df246da0363e0bc0e543ad06129938376c148e87edc6885b0b6c9d94645ffdcd3'
7
+ data.tar.gz: 16ec119be87259081c896497229cf3202901c3657ac4aea2f3e47e05ff3976d7130e23a99dcdf1c85b80f8d386243878e0acccd4bc9cb721b0ca8d8e189e4122
@@ -194,6 +194,26 @@ module Belt
194
194
  exit 1
195
195
  end
196
196
 
197
+ # Check if any environments are nested under this one
198
+ nested_children = find_nested_children(@name)
199
+ if nested_children.any?
200
+ puts "⚠ The following environments are nested under '#{@name}':"
201
+ nested_children.each { |child| puts " - #{child}" }
202
+ puts ''
203
+ puts ' Destroying the parent will leave them with broken DNS references.'
204
+ puts ' Consider destroying nested environments first:'
205
+ nested_children.each { |child| puts " belt destroy environment #{child}" }
206
+ puts ''
207
+ unless @force
208
+ print ' Continue anyway? [y/N] '
209
+ response = $stdin.gets&.strip&.downcase
210
+ unless response&.start_with?('y')
211
+ puts 'Cancelled.'
212
+ exit 0
213
+ end
214
+ end
215
+ end
216
+
197
217
  # Check if terraform state exists (infra may still be live)
198
218
  if !@skip_terraform && terraform_state_exists?(dir)
199
219
  puts "⚠ Environment '#{@name}' appears to have active infrastructure."
@@ -236,6 +256,24 @@ module Belt
236
256
  @removed << dir
237
257
  puts " remove #{dir}/"
238
258
  puts "\n✓ Environment '#{@name}' destroyed!"
259
+
260
+ # Check if DNS delegation exists and warn about cleanup
261
+ warn_about_dns_delegation(@name)
262
+ end
263
+
264
+ def find_nested_children(env_name)
265
+ nested_children = []
266
+ Dir.glob('infrastructure/*/terraform.tfvars').each do |tfvars_file|
267
+ next if tfvars_file.include?('/dns/')
268
+ next if tfvars_file.include?('/modules/')
269
+
270
+ content = File.read(tfvars_file)
271
+ next unless content =~ /^\s*parent_environment\s*=\s*"#{Regexp.escape(env_name)}"/
272
+
273
+ child_env = File.basename(File.dirname(tfvars_file))
274
+ nested_children << child_env
275
+ end
276
+ nested_children
239
277
  end
240
278
 
241
279
  def terraform_state_exists?(dir)
@@ -258,6 +296,62 @@ module Belt
258
296
  true
259
297
  end
260
298
 
299
+ def warn_about_dns_delegation(env_name)
300
+ # Check if this is a nested environment (DNS records are in parent's zone, handled by terraform)
301
+ tfvars_file = "infrastructure/#{env_name}/terraform.tfvars"
302
+ if File.exist?(tfvars_file)
303
+ tfvars_content = File.read(tfvars_file)
304
+ if tfvars_content =~ /^\s*parent_environment\s*=\s*"([^"]+)"/
305
+ parent_env = ::Regexp.last_match(1)
306
+ puts ''
307
+ puts "ℹ This was a nested environment under '#{parent_env}'."
308
+ puts ' The DNS A record in the parent zone was deleted by terraform destroy.'
309
+ return
310
+ end
311
+ end
312
+
313
+ # Check if any other environments are nested under this one
314
+ warn_about_nested_children(env_name)
315
+
316
+ # Check for standalone environment DNS delegation
317
+ dns_tfvars = 'infrastructure/dns/terraform.tfvars'
318
+ return unless File.exist?(dns_tfvars)
319
+
320
+ content = File.read(dns_tfvars)
321
+ # Only match a real HCL entry (e.g. " dev = [") at the start of a line,
322
+ # not the commented-out examples in the tfvars template (e.g. "# dev = [").
323
+ # This mirrors the anchored patterns DNSCommand uses when writing entries.
324
+ return unless content =~ /^\s*#{Regexp.escape(env_name)}\s*=/
325
+
326
+ puts ''
327
+ puts '⚠ DNS delegation still exists for this environment.'
328
+ puts ' To clean up the root zone delegation:'
329
+ puts ''
330
+ puts " belt dns remove #{env_name}"
331
+ puts ' belt dns deploy'
332
+ end
333
+
334
+ def warn_about_nested_children(env_name)
335
+ nested_children = []
336
+ Dir.glob('infrastructure/*/terraform.tfvars').each do |tfvars_file|
337
+ next if tfvars_file.include?('/dns/')
338
+
339
+ content = File.read(tfvars_file)
340
+ next unless content =~ /^\s*parent_environment\s*=\s*"#{Regexp.escape(env_name)}"/
341
+
342
+ child_env = File.basename(File.dirname(tfvars_file))
343
+ nested_children << child_env
344
+ end
345
+
346
+ return if nested_children.empty?
347
+
348
+ puts ''
349
+ puts '⚠ The following environments were nested under this one:'
350
+ nested_children.each { |child| puts " - #{child}" }
351
+ puts ' They may have dangling DNS references. Consider destroying them first,'
352
+ puts ' or manually cleaning up their Route53 records.'
353
+ end
354
+
261
355
  def run_terraform_destroy(dir)
262
356
  puts "\n━━━ terraform destroy (#{@name}) ━━━"
263
357
 
@@ -25,6 +25,8 @@ module Belt
25
25
  new.generate(args)
26
26
  when 'add'
27
27
  new.add_environment(args)
28
+ when 'remove', 'rm'
29
+ new.remove_environment(args)
28
30
  when 'show', 'list'
29
31
  new.show(args)
30
32
  when '--help', '-h', 'help'
@@ -43,6 +45,7 @@ module Belt
43
45
  deploy Deploy the dns infrastructure (init → plan → apply)
44
46
  generate Create the infrastructure/dns directory (same as belt generate dns)
45
47
  add <env> Add an environment's NS records to dns/terraform.tfvars
48
+ remove <env> Remove an environment's NS records from dns/terraform.tfvars
46
49
  show Show root zone name servers (for registrar configuration)
47
50
  help Show this help
48
51
 
@@ -55,6 +58,7 @@ module Belt
55
58
  belt dns generate # Scaffold infrastructure/dns (prompts for profile)
56
59
  belt dns generate --aws-profile fpshared # Non-interactive with profile
57
60
  belt dns add staging # Add staging's NS records to tfvars
61
+ belt dns remove staging # Remove staging's NS delegation
58
62
  belt dns show # Show root name servers to configure at registrar
59
63
 
60
64
  The dns directory manages your root domain and delegates subdomains to
@@ -67,6 +71,11 @@ module Belt
67
71
  3. belt dns add dev # Add dev's NS records
68
72
  4. belt dns deploy # Deploy root zone
69
73
  5. Update registrar NS records to output values
74
+
75
+ When destroying an environment:
76
+ 1. belt destroy environment dev # Destroy the environment
77
+ 2. belt dns remove dev # Remove DNS delegation
78
+ 3. belt dns deploy # Apply the change
70
79
  HELP
71
80
  end
72
81
 
@@ -208,6 +217,37 @@ module Belt
208
217
  puts "\nRun 'belt dns deploy' to apply the changes."
209
218
  end
210
219
 
220
+ # --- Remove Environment ---
221
+ def remove_environment(args)
222
+ env_name = args.shift
223
+
224
+ if env_name.nil? || env_name.start_with?('-')
225
+ puts 'Usage: belt dns remove <env>'
226
+ puts "\nExample: belt dns remove staging"
227
+ exit 1
228
+ end
229
+
230
+ tfvars_path = "#{DNS_DIR}/terraform.tfvars"
231
+ unless File.exist?(tfvars_path)
232
+ puts "#{tfvars_path} not found."
233
+ puts "\nNo DNS infrastructure to modify."
234
+ exit 1
235
+ end
236
+
237
+ content = File.read(tfvars_path)
238
+
239
+ # Check if the environment exists in the tfvars
240
+ unless content.include?("#{env_name} =")
241
+ puts "Environment '#{env_name}' not found in #{tfvars_path}."
242
+ puts "\nNothing to remove."
243
+ exit 0
244
+ end
245
+
246
+ remove_env_from_tfvars(tfvars_path, env_name)
247
+ puts "✓ Removed #{env_name} NS records from #{tfvars_path}"
248
+ puts "\nRun 'belt dns deploy' to apply the changes."
249
+ end
250
+
211
251
  # --- Show ---
212
252
  def show(_args)
213
253
  require 'open3'
@@ -399,6 +439,31 @@ module Belt
399
439
  File.write(path, content)
400
440
  end
401
441
 
442
+ def remove_env_from_tfvars(path, env_name)
443
+ content = File.read(path)
444
+
445
+ # Match the environment entry: " env_name = [\n ...\n ]" with optional trailing comma
446
+ # The entry can be followed by another entry, a closing brace, or whitespace
447
+ #
448
+ # Pattern breakdown:
449
+ # - ^(\s*)#{env_name}\s*= matches " env_name =" at start of line
450
+ # - \s*\[\s* matches " [" with optional whitespace
451
+ # - [^\]]* matches everything inside brackets (the NS records)
452
+ # - \]\s*,?\s* matches "]" with optional trailing comma and whitespace
453
+ # - (?=\n|\s*[}\w]) lookahead for newline or closing brace/next entry
454
+ entry_pattern = /^\s*#{Regexp.escape(env_name)}\s*=\s*\[[^\]]*\]\s*,?\s*\n?/m
455
+
456
+ content.gsub!(entry_pattern, '')
457
+
458
+ # Clean up any double newlines that may have been created
459
+ content.gsub!(/\n{3,}/, "\n\n")
460
+
461
+ # If the environment_zones block is now empty (just whitespace), clean it up
462
+ content.gsub!(/^environment_zones\s*=\s*\{\s*\n\s*\}/, 'environment_zones = {}')
463
+
464
+ File.write(path, content)
465
+ end
466
+
402
467
  # Resolve the state bucket name to use in backend.tf.
403
468
  # Priority: existing sibling backend.tf → AWS account ID → bare placeholder.
404
469
  def resolve_state_bucket
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative 'app_detection'
6
+ require_relative 'environment_config'
6
7
  require_relative 'frontend_registry'
7
8
  require_relative 'frontend_setup_command'
8
9
 
@@ -15,26 +16,49 @@ module Belt
15
16
 
16
17
  def self.run(args)
17
18
  env_name = args.shift
19
+ parent_env = args.shift # Optional: parent environment for nested subdomains
18
20
 
19
21
  if env_name.nil? || env_name.empty?
20
- puts 'Usage: belt generate environment <name>'
22
+ puts 'Usage: belt generate environment <name> [parent]'
21
23
  puts "\nExamples:"
22
24
  puts ' belt generate environment dev01'
23
25
  puts ' belt generate environment staging'
24
26
  puts ' belt generate environment prod'
27
+ puts ''
28
+ puts 'Nested environments (subdomain of existing environment):'
29
+ puts ' belt generate environment fizzy123 dev'
30
+ puts ' → Creates fizzy123.dev.example.com using dev\'s wildcard cert'
25
31
  exit 1
26
32
  end
27
33
 
28
- new(env_name).generate
34
+ # Validate parent environment exists if provided
35
+ if parent_env && !parent_env.empty?
36
+ parent_dir = "infrastructure/#{parent_env}"
37
+ unless Dir.exist?(parent_dir)
38
+ puts "✗ Parent environment '#{parent_env}' not found at #{parent_dir}/"
39
+ puts ' The parent environment must exist before creating a nested environment.'
40
+ exit 1
41
+ end
42
+ end
43
+
44
+ new(env_name, parent_environment: parent_env).generate
29
45
  end
30
46
 
31
- def initialize(env_name, quiet: false, domain: nil, announce: true)
47
+ def initialize(env_name, quiet: false, domain: nil, announce: true, parent_environment: nil)
32
48
  @env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
33
49
  @app_name = detect_app_name
34
- @domain = domain
35
50
  @quiet = quiet
36
51
  @announce = announce
52
+ @parent_environment = parent_environment&.downcase&.gsub(/[^a-z0-9_-]/, '')
37
53
  @state_bucket = resolve_state_bucket
54
+
55
+ # For nested envs, inherit domain and aws_profile from parent
56
+ if @parent_environment
57
+ load_parent_config
58
+ @domain = domain || @parent_domain
59
+ else
60
+ @domain = domain
61
+ end
38
62
  end
39
63
 
40
64
  def generate
@@ -46,6 +70,7 @@ module Belt
46
70
  end
47
71
 
48
72
  puts "Creating environment: #{@env_name}" unless @quiet
73
+ puts " (nested under #{@parent_environment})" if @parent_environment && !@quiet
49
74
  FileUtils.mkdir_p(dest_dir)
50
75
 
51
76
  templates.each do |template_name, dest_file|
@@ -54,19 +79,40 @@ module Belt
54
79
  puts " create #{dest_path}" unless @quiet
55
80
  end
56
81
 
82
+ # Generate belt.rb if we have an inherited aws_profile
83
+ if @parent_aws_profile
84
+ belt_rb_path = File.join(dest_dir, 'belt.rb')
85
+ write_belt_rb(belt_rb_path)
86
+ puts " create #{belt_rb_path}" unless @quiet
87
+ end
88
+
57
89
  append_extra_frontend_outputs(File.join(dest_dir, 'outputs.tf'))
58
90
 
59
91
  return if @quiet || !@announce
60
92
 
61
93
  puts "\n✓ Environment '#{@env_name}' created!"
62
- puts "\nReview your settings in #{dest_dir}/terraform.tfvars:"
63
- puts ' environment = "..." # environment name (defaults to directory name)'
64
- puts ' domain = "..." # your domain (e.g., "myapp.com")'
65
- puts "\nThen deploy:"
66
- puts " belt deploy #{@env_name}"
67
- puts "\nIf using multiple environments with a custom domain, run:"
68
- puts ' belt dns generate'
69
- puts 'to manage root domain delegation to per-environment zones.'
94
+
95
+ if @parent_environment
96
+ puts "\nThis is a nested environment under '#{@parent_environment}'."
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
103
+ puts "\nDeploy with:"
104
+ puts " belt deploy #{@env_name}"
105
+ puts "\nNo DNS delegation needed — the parent environment handles DNS."
106
+ else
107
+ puts "\nReview your settings in #{dest_dir}/terraform.tfvars:"
108
+ puts ' environment = "..." # environment name (defaults to directory name)'
109
+ puts ' domain = "..." # your domain (e.g., "myapp.com")'
110
+ puts "\nThen deploy:"
111
+ puts " belt deploy #{@env_name}"
112
+ puts "\nIf using multiple environments with a custom domain, run:"
113
+ puts ' belt dns generate'
114
+ puts 'to manage root domain delegation to per-environment zones.'
115
+ end
70
116
  end
71
117
 
72
118
  private
@@ -101,6 +147,35 @@ module Belt
101
147
  bucket_from_sibling || bucket_from_aws || 'belt-terraform-state'
102
148
  end
103
149
 
150
+ # Load domain and aws_profile from parent environment for nested envs
151
+ def load_parent_config
152
+ parent_dir = "infrastructure/#{@parent_environment}"
153
+
154
+ # Load domain from parent's terraform.tfvars
155
+ tfvars_path = File.join(parent_dir, 'terraform.tfvars')
156
+ if File.exist?(tfvars_path)
157
+ content = File.read(tfvars_path)
158
+ domain_match = content.match(/^\s*domain\s*=\s*"([^"]+)"/)
159
+ @parent_domain = domain_match[1] if domain_match
160
+ end
161
+
162
+ # Load aws_profile from parent's belt.rb
163
+ parent_config = EnvironmentConfig.load(@parent_environment)
164
+ @parent_aws_profile = parent_config.aws_profile if parent_config.aws_profile?
165
+ end
166
+
167
+ # Generate belt.rb with inherited aws_profile from parent
168
+ def write_belt_rb(path)
169
+ content = <<~RUBY
170
+ # frozen_string_literal: true
171
+
172
+ Belt.configure do |config|
173
+ config.aws_profile = "#{@parent_aws_profile}"
174
+ end
175
+ RUBY
176
+ File.write(path, content)
177
+ end
178
+
104
179
  def bucket_from_sibling
105
180
  Dir.glob('infrastructure/*/backend.tf').each do |f|
106
181
  match = File.read(f).match(/bucket\s*=\s*"([^"]+)"/)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'backup_config'
4
+
3
5
  module Belt
4
6
  module CLI
5
7
  class EnvironmentConfig
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.28'
4
+ VERSION = '0.3.30'
5
5
  end
@@ -35,14 +35,15 @@ provider "conveyor-belt" {
35
35
  module "app" {
36
36
  source = "../modules/app"
37
37
 
38
- app_name = var.app_name
39
- environment = var.environment
40
- aws_region = var.aws_region
41
- domain = var.domain
42
- enable_pitr = var.enable_pitr
38
+ app_name = var.app_name
39
+ environment = var.environment
40
+ parent_environment = var.parent_environment
41
+ aws_region = var.aws_region
42
+ domain = var.domain
43
+ enable_pitr = var.enable_pitr
43
44
  deletion_protection = var.deletion_protection
44
45
  frontend_urls = concat(
45
- var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
46
+ var.domain != "" ? ["https://${var.parent_environment != "" ? "${var.environment}.${var.parent_environment}.${var.domain}" : (var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}")}"] : [],
46
47
  var.environment == "prod" ? [] : ["http://localhost:3000"]
47
48
  )
48
49
 
@@ -1,4 +1,7 @@
1
1
  environment = "<%= @env_name %>"
2
+ <% if @parent_environment && !@parent_environment.empty? -%>
3
+ parent_environment = "<%= @parent_environment %>"
4
+ <% end -%>
2
5
  <% if @domain && !@domain.empty? -%>
3
6
  domain = "<%= @domain %>"
4
7
  <% else -%>
@@ -9,6 +9,12 @@ variable "environment" {
9
9
  type = string
10
10
  }
11
11
 
12
+ variable "parent_environment" {
13
+ description = "Parent environment for nested subdomains (e.g., 'dev' creates fizzy123.dev.example.com). Leave empty for standalone environments."
14
+ type = string
15
+ default = ""
16
+ }
17
+
12
18
  variable "aws_region" {
13
19
  description = "AWS region"
14
20
  type = string
@@ -1,30 +1,58 @@
1
1
  # DNS configuration for Belt application
2
- # Convention: prod → mydomain.com, other envs → <env>.mydomain.com
2
+ # Convention:
3
+ # prod → api.mydomain.com (standalone, creates own zone + cert)
4
+ # dev → api.dev.mydomain.com (standalone, creates own zone + cert)
5
+ # fizzy123 (dev) → api-fizzy123.dev.mydomain.com (nested, uses parent zone + cert)
6
+ #
7
+ # Note: Nested envs use api-<env> prefix (not api.<env>) because SSL wildcards are
8
+ # single-level. The parent's *.dev.mydomain.com covers api-fizzy123.dev.mydomain.com
9
+ # but NOT api.fizzy123.dev.mydomain.com.
3
10
 
4
11
  locals {
12
+ # S3 bucket names follow DNS rules — underscores are not allowed.
13
+ s3_safe_name = replace(var.app_name, "_", "-")
14
+
15
+ # Is this a nested environment?
16
+ is_nested = var.parent_environment != ""
17
+
18
+ # Parent domain for nested envs (e.g., "dev.featureparity.dev")
19
+ parent_domain = local.is_nested ? "${var.parent_environment}.${var.domain}" : ""
20
+
5
21
  # Determine the domain for this environment
22
+ # Nested: fizzy123.dev.mydomain.com
23
+ # Standalone: dev.mydomain.com (or mydomain.com for prod)
6
24
  app_domain = var.domain != "" ? (
7
- var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"
25
+ local.is_nested ? "${var.environment}.${local.parent_domain}" : (
26
+ var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"
27
+ )
8
28
  ) : ""
9
29
 
10
- # API subdomain: api.mydomain.com (prod) or api.<env>.mydomain.com (non-prod)
11
- api_domain = var.domain != "" ? "api.${local.app_domain}" : ""
30
+ # API subdomain:
31
+ # Standalone: api.dev.mydomain.com (covered by *.dev.mydomain.com)
32
+ # Nested: api-fizzy123.dev.mydomain.com (api- prefix keeps it single-level under wildcard)
33
+ api_domain = var.domain != "" ? (
34
+ local.is_nested ? "api-${var.environment}.${local.parent_domain}" : "api.${local.app_domain}"
35
+ ) : ""
12
36
 
13
37
  # Whether DNS is enabled
14
38
  dns_enabled = var.domain != ""
15
39
  }
16
40
 
41
+ # =============================================================================
42
+ # STANDALONE ENVIRONMENTS (no parent)
43
+ # =============================================================================
44
+
17
45
  # --- Route53 Hosted Zone ---
18
46
  # One zone per environment subdomain (or root domain for prod)
19
47
  resource "aws_route53_zone" "app" {
20
- count = local.dns_enabled ? 1 : 0
48
+ count = local.dns_enabled && !local.is_nested ? 1 : 0
21
49
  name = local.app_domain
22
50
  }
23
51
 
24
52
  # --- ACM Certificate ---
25
53
  # Wildcard cert covers the app domain + all subdomains (api.*, www.*, etc.)
26
54
  resource "aws_acm_certificate" "app" {
27
- count = local.dns_enabled ? 1 : 0
55
+ count = local.dns_enabled && !local.is_nested ? 1 : 0
28
56
  domain_name = local.app_domain
29
57
  subject_alternative_names = ["*.${local.app_domain}"]
30
58
  validation_method = "DNS"
@@ -41,7 +69,7 @@ resource "aws_acm_certificate" "app" {
41
69
  # Use ellipsis (...) to group duplicates, then take the first element since
42
70
  # they're identical anyway.
43
71
  resource "aws_route53_record" "cert_validation" {
44
- for_each = local.dns_enabled ? {
72
+ for_each = local.dns_enabled && !local.is_nested ? {
45
73
  for dvo in aws_acm_certificate.app[0].domain_validation_options : dvo.resource_record_name => {
46
74
  name = dvo.resource_record_name
47
75
  type = dvo.resource_record_type
@@ -49,24 +77,53 @@ resource "aws_route53_record" "cert_validation" {
49
77
  }...
50
78
  } : {}
51
79
 
52
- zone_id = aws_route53_zone.app[0].zone_id
53
- name = each.value[0].name
54
- type = each.value[0].type
55
- ttl = 300
56
- records = [each.value[0].record]
80
+ zone_id = aws_route53_zone.app[0].zone_id
81
+ name = each.value[0].name
82
+ type = each.value[0].type
83
+ ttl = 300
84
+ records = [each.value[0].record]
85
+ allow_overwrite = true
57
86
  }
58
87
 
59
88
  resource "aws_acm_certificate_validation" "app" {
60
- count = local.dns_enabled ? 1 : 0
89
+ count = local.dns_enabled && !local.is_nested ? 1 : 0
61
90
  certificate_arn = aws_acm_certificate.app[0].arn
62
91
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
63
92
  }
64
93
 
94
+ # =============================================================================
95
+ # NESTED ENVIRONMENTS (uses parent zone + wildcard cert)
96
+ # =============================================================================
97
+
98
+ # Look up parent's hosted zone
99
+ data "aws_route53_zone" "parent" {
100
+ count = local.dns_enabled && local.is_nested ? 1 : 0
101
+ name = local.parent_domain
102
+ }
103
+
104
+ # Look up parent's wildcard certificate
105
+ data "aws_acm_certificate" "parent" {
106
+ count = local.dns_enabled && local.is_nested ? 1 : 0
107
+ domain = local.parent_domain
108
+ statuses = ["ISSUED"]
109
+ most_recent = true
110
+ }
111
+
112
+ # =============================================================================
113
+ # API GATEWAY (shared logic, uses appropriate cert/zone)
114
+ # =============================================================================
115
+
65
116
  # --- API Gateway Custom Domain ---
66
117
  resource "aws_api_gateway_domain_name" "api" {
67
- count = local.dns_enabled ? 1 : 0
68
- domain_name = local.api_domain
69
- regional_certificate_arn = aws_acm_certificate_validation.app[0].certificate_arn
118
+ count = local.dns_enabled ? 1 : 0
119
+ domain_name = local.api_domain
120
+
121
+ # Use parent's cert for nested, own cert for standalone
122
+ regional_certificate_arn = local.is_nested ? (
123
+ data.aws_acm_certificate.parent[0].arn
124
+ ) : (
125
+ aws_acm_certificate_validation.app[0].certificate_arn
126
+ )
70
127
 
71
128
  endpoint_configuration {
72
129
  types = ["REGIONAL"]
@@ -74,9 +131,10 @@ resource "aws_api_gateway_domain_name" "api" {
74
131
  }
75
132
 
76
133
  # Route53 A record for api.<domain>
134
+ # For nested envs, this goes in the parent zone
77
135
  resource "aws_route53_record" "api" {
78
136
  count = local.dns_enabled ? 1 : 0
79
- zone_id = aws_route53_zone.app[0].zone_id
137
+ zone_id = local.is_nested ? data.aws_route53_zone.parent[0].zone_id : aws_route53_zone.app[0].zone_id
80
138
  name = local.api_domain
81
139
  type = "A"
82
140
 
@@ -102,7 +102,7 @@ resource "aws_cloudfront_distribution" "<%= @tf_name %>" {
102
102
 
103
103
  viewer_certificate {
104
104
  cloudfront_default_certificate = <%= @include_dns ? '!local.dns_enabled' : 'true' %>
105
- acm_certificate_arn = <%= @include_dns ? 'local.dns_enabled ? aws_acm_certificate_validation.app[0].certificate_arn : null' : 'null' %>
105
+ acm_certificate_arn = <%= @include_dns ? 'local.dns_enabled ? (local.is_nested ? data.aws_acm_certificate.parent[0].arn : aws_acm_certificate_validation.app[0].certificate_arn) : null' : 'null' %>
106
106
  ssl_support_method = <%= @include_dns ? 'local.dns_enabled ? "sni-only" : null' : 'null' %>
107
107
  minimum_protocol_version = <%= @include_dns ? 'local.dns_enabled ? "TLSv1.2_2021" : null' : 'null' %>
108
108
  }
@@ -144,7 +144,7 @@ resource "aws_s3_bucket_policy" "<%= @tf_name %>" {
144
144
  # Route53 record for frontend (app domain → CloudFront)
145
145
  resource "aws_route53_record" "<%= @tf_name %>" {
146
146
  count = local.dns_enabled ? 1 : 0
147
- zone_id = aws_route53_zone.app[0].zone_id
147
+ zone_id = local.is_nested ? data.aws_route53_zone.parent[0].zone_id : aws_route53_zone.app[0].zone_id
148
148
  name = local.app_domain
149
149
  type = "A"
150
150
 
@@ -155,9 +155,9 @@ resource "aws_route53_record" "<%= @tf_name %>" {
155
155
  }
156
156
  }
157
157
 
158
- # www record for prod only
158
+ # www record for prod only (standalone envs only — nested envs don't need www)
159
159
  resource "aws_route53_record" "<%= @tf_name %>_www" {
160
- count = local.dns_enabled && var.environment == "prod" ? 1 : 0
160
+ count = local.dns_enabled && var.environment == "prod" && !local.is_nested ? 1 : 0
161
161
  zone_id = aws_route53_zone.app[0].zone_id
162
162
  name = "www.${var.domain}"
163
163
  type = "A"
@@ -19,6 +19,6 @@ output "app_domain" {
19
19
  }
20
20
 
21
21
  output "name_servers" {
22
- description = "NS records to configure at your registrar"
23
- value = local.dns_enabled ? aws_route53_zone.app[0].name_servers : []
22
+ description = "NS records to configure at your registrar (standalone envs only)"
23
+ value = local.dns_enabled && !local.is_nested ? aws_route53_zone.app[0].name_servers : []
24
24
  }
@@ -8,6 +8,12 @@ variable "environment" {
8
8
  type = string
9
9
  }
10
10
 
11
+ variable "parent_environment" {
12
+ description = "Parent environment for nested subdomains (e.g., 'dev' creates fizzy123.dev.example.com). Leave empty for standalone environments."
13
+ type = string
14
+ default = ""
15
+ }
16
+
11
17
  variable "aws_region" {
12
18
  description = "AWS region"
13
19
  type = string
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.28
4
+ version: 0.3.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla