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 +4 -4
- data/lib/belt/cli/destroy_command.rb +94 -0
- data/lib/belt/cli/dns_command.rb +65 -0
- data/lib/belt/cli/environment_command.rb +87 -12
- data/lib/belt/cli/environment_config.rb +2 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/main.tf.erb +7 -6
- data/lib/templates/environment/terraform.tfvars.erb +3 -0
- data/lib/templates/environment/variables.tf.erb +6 -0
- data/lib/templates/module/dns.tf.erb +75 -17
- data/lib/templates/module/frontend.tf.erb +4 -4
- data/lib/templates/module/outputs.tf.erb +2 -2
- data/lib/templates/module/variables.tf.erb +6 -0
- 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: 79ffa332a2eb6408d42c4652d1643009edac470adb6eabbcb9e6818286276977
|
|
4
|
+
data.tar.gz: 145edae1f0f725c16232a648d64ad3224ac93baca18273648b901e7d4ef6ad66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/belt/cli/dns_command.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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*"([^"]+)"/)
|
data/lib/belt/version.rb
CHANGED
|
@@ -35,14 +35,15 @@ provider "conveyor-belt" {
|
|
|
35
35
|
module "app" {
|
|
36
36
|
source = "../modules/app"
|
|
37
37
|
|
|
38
|
-
app_name
|
|
39
|
-
environment
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
11
|
-
|
|
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
|
|
53
|
-
name
|
|
54
|
-
type
|
|
55
|
-
ttl
|
|
56
|
-
records
|
|
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
|
|
68
|
-
domain_name
|
|
69
|
-
|
|
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
|