belt 0.4.0 → 0.4.2
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 +65 -0
- data/lib/belt/cli/deploy_command.rb +27 -0
- data/lib/belt/cli/dns_command.rb +510 -241
- data/lib/belt/cli/environment_command.rb +5 -5
- data/lib/belt/cli/frontend_deploy_command.rb +6 -0
- data/lib/belt/cli/frontend_env_command.rb +13 -4
- data/lib/belt/cli/logs_command.rb +11 -0
- data/lib/belt/cli/server_command.rb +5 -0
- data/lib/belt/cli/setup_command.rb +1 -1
- data/lib/belt/cli/tables_command.rb +114 -4
- data/lib/belt/version.rb +1 -1
- metadata +1 -1
|
@@ -95,11 +95,11 @@ module Belt
|
|
|
95
95
|
if @parent_environment
|
|
96
96
|
puts "\nThis is a nested environment under '#{@parent_environment}'."
|
|
97
97
|
puts "It will use the parent's wildcard certificate and hosted zone."
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
# Nested envs use an `api-<env>` prefix (not `api.<env>`) so the API
|
|
99
|
+
# host stays a single level under the parent's *.<parent>.<domain>
|
|
100
|
+
# wildcard cert. See lib/templates/module/dns.tf.erb (api_domain).
|
|
101
|
+
puts "Frontend will be: https://#{@env_name}.#{@parent_environment}.#{@domain}"
|
|
102
|
+
puts "API will be: https://api-#{@env_name}.#{@parent_environment}.#{@domain}"
|
|
103
103
|
puts "\nDeploy with:"
|
|
104
104
|
puts " belt deploy #{@env_name}"
|
|
105
105
|
puts "\nNo DNS delegation needed — the parent environment handles DNS."
|
|
@@ -75,6 +75,12 @@ module Belt
|
|
|
75
75
|
|
|
76
76
|
private
|
|
77
77
|
|
|
78
|
+
# Load infrastructure/<env>/belt.rb and apply its aws_profile + env vars
|
|
79
|
+
# to the current process. Without this, `terraform output` can't reach the
|
|
80
|
+
# S3 state backend (403), fetch_tf_output returns nil, and the deploy aborts
|
|
81
|
+
# with a misleading "Could not determine S3 bucket" error. The full
|
|
82
|
+
# `belt deploy` path applies this before invoking the frontend deploy;
|
|
83
|
+
# standalone `belt deploy frontend` must do it too.
|
|
78
84
|
def load_and_apply_env_config!
|
|
79
85
|
env_config = EnvironmentConfig.load(@env, infra_dir: @infra_dir)
|
|
80
86
|
env_config.apply!
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'app_detection'
|
|
4
4
|
require_relative 'env_resolver'
|
|
5
|
+
require_relative 'environment_config'
|
|
5
6
|
require_relative 'frontend_env_map'
|
|
6
7
|
require_relative 'frontend_registry'
|
|
8
|
+
require_relative 'terraform_command'
|
|
7
9
|
|
|
8
10
|
module Belt
|
|
9
11
|
module CLI
|
|
@@ -111,18 +113,19 @@ module Belt
|
|
|
111
113
|
def initialize(env, frontend: nil)
|
|
112
114
|
@env = env
|
|
113
115
|
@app_name = detect_app_name
|
|
114
|
-
@
|
|
116
|
+
@infra_dir = TerraformCommand.find_infrastructure_dir || 'infrastructure'
|
|
117
|
+
@env_dir = File.join(@infra_dir, @env)
|
|
115
118
|
@frontend = frontend || FrontendRegistry.new.resolve!
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
def run
|
|
122
|
+
load_and_apply_env_config!
|
|
123
|
+
|
|
119
124
|
unless Dir.exist?(@frontend.path)
|
|
120
125
|
abort "Error: No #{@frontend.path}/ directory found. Run `belt generate frontend react` first."
|
|
121
126
|
end
|
|
122
127
|
|
|
123
|
-
unless Dir.exist?(@env_dir)
|
|
124
|
-
abort "Error: infrastructure/#{@env} not found. Run `belt generate environment #{@env}` first."
|
|
125
|
-
end
|
|
128
|
+
abort "Error: #{@env_dir} not found. Run `belt generate environment #{@env}` first." unless Dir.exist?(@env_dir)
|
|
126
129
|
|
|
127
130
|
map = FrontendEnvMap.new(@env, env_dir: @env_dir, frontend_path: @frontend.path)
|
|
128
131
|
|
|
@@ -142,6 +145,12 @@ module Belt
|
|
|
142
145
|
puts "✅ Updated #{result[:path]} (#{updated.join(', ')})"
|
|
143
146
|
end
|
|
144
147
|
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def load_and_apply_env_config!
|
|
152
|
+
EnvironmentConfig.load(@env, infra_dir: @infra_dir).apply!
|
|
153
|
+
end
|
|
145
154
|
end
|
|
146
155
|
end
|
|
147
156
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'open3'
|
|
5
|
+
require_relative 'environment_config'
|
|
6
|
+
require_relative 'terraform_command'
|
|
5
7
|
|
|
6
8
|
module Belt
|
|
7
9
|
module CLI
|
|
@@ -76,6 +78,8 @@ module Belt
|
|
|
76
78
|
@env ||= detect_environment
|
|
77
79
|
abort 'Error: Cannot determine environment. Pass -e ENV or set BELT_ENV.' unless @env
|
|
78
80
|
|
|
81
|
+
apply_env_config!
|
|
82
|
+
|
|
79
83
|
@app_name = detect_app_name
|
|
80
84
|
abort 'Error: Cannot determine app name.' unless @app_name
|
|
81
85
|
|
|
@@ -88,6 +92,13 @@ module Belt
|
|
|
88
92
|
|
|
89
93
|
private
|
|
90
94
|
|
|
95
|
+
def apply_env_config!
|
|
96
|
+
infra_dir = TerraformCommand.find_infrastructure_dir || find_infra_dir
|
|
97
|
+
env_config = EnvironmentConfig.load(@env, infra_dir: infra_dir)
|
|
98
|
+
env_config.apply!
|
|
99
|
+
puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
|
|
100
|
+
end
|
|
101
|
+
|
|
91
102
|
def parse_args(args)
|
|
92
103
|
i = 0
|
|
93
104
|
while i < args.length
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'json'
|
|
5
5
|
require_relative 'app_detection'
|
|
6
|
+
require_relative 'environment_config'
|
|
6
7
|
require_relative 'frontend_env_map'
|
|
7
8
|
require_relative 'frontend_registry'
|
|
8
9
|
require_relative 'terraform_command'
|
|
@@ -125,6 +126,9 @@ module Belt
|
|
|
125
126
|
env_name = @deploy_env || ENV.fetch('BELT_ENV', nil) || TerraformCommand.list_environments.first
|
|
126
127
|
return {} unless env_name
|
|
127
128
|
|
|
129
|
+
infra_dir = TerraformCommand.find_infrastructure_dir
|
|
130
|
+
EnvironmentConfig.load(env_name, infra_dir: infra_dir).apply!
|
|
131
|
+
|
|
128
132
|
FrontendEnvMap.new(env_name, frontend_path: @frontend.path).process_env
|
|
129
133
|
rescue StandardError
|
|
130
134
|
# Fall back to legacy api_url detection if map resolution fails
|
|
@@ -274,6 +278,7 @@ module Belt
|
|
|
274
278
|
next unless Dir.exist?(env_dir)
|
|
275
279
|
next unless File.exist?(File.join(env_dir, '.terraform'))
|
|
276
280
|
|
|
281
|
+
EnvironmentConfig.load(env, infra_dir: infra_dir).apply!
|
|
277
282
|
url = read_api_url_from_outputs(env_dir)
|
|
278
283
|
if url
|
|
279
284
|
@deploy_env = env
|
|
@@ -40,7 +40,7 @@ module Belt
|
|
|
40
40
|
|
|
41
41
|
Subcommands:
|
|
42
42
|
state Set up S3 bucket for Terraform state
|
|
43
|
-
tables Generate DynamoDB table definitions from
|
|
43
|
+
tables Generate DynamoDB table definitions from lambda/models/*.rb
|
|
44
44
|
frontend Generate S3 + CloudFront infrastructure for frontend hosting
|
|
45
45
|
|
|
46
46
|
Options for state:
|
|
@@ -15,7 +15,12 @@ module Belt
|
|
|
15
15
|
# dynamodb.tf lives in infrastructure/modules/app and uses var.environment.
|
|
16
16
|
EnvResolver.resolve(args)
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# `--force`/`-y` overwrites dynamodb.tf even when the regen would drop a
|
|
19
|
+
# hand-added table or GSI. Without it, an interactive run prompts before
|
|
20
|
+
# clobbering and a quiet (generator) run refuses.
|
|
21
|
+
force = args.intersect?(%w[--force -f --yes -y])
|
|
22
|
+
|
|
23
|
+
new(force: force).run
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
# Automatically sync dynamodb.tf in the app module.
|
|
@@ -26,8 +31,9 @@ module Belt
|
|
|
26
31
|
new(quiet: true).run
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
def initialize(quiet: false)
|
|
34
|
+
def initialize(quiet: false, force: false)
|
|
30
35
|
@quiet = quiet
|
|
36
|
+
@force = force
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def run
|
|
@@ -166,6 +172,16 @@ module Belt
|
|
|
166
172
|
# Skip if content is unchanged
|
|
167
173
|
return if existing_content == new_content
|
|
168
174
|
|
|
175
|
+
# Regenerating dynamodb.tf is a full overwrite. Anything hand-added to the
|
|
176
|
+
# file that the generator can't re-derive from the models — a GSI added
|
|
177
|
+
# straight into the .tf, or a table for a model that no longer exists — would
|
|
178
|
+
# silently vanish. Detect that and refuse (quiet) or prompt (interactive)
|
|
179
|
+
# unless --force was passed.
|
|
180
|
+
if existing_content
|
|
181
|
+
dropped = detect_dropped_infrastructure(existing_content, new_content)
|
|
182
|
+
return if dropped.any? && !safe_to_overwrite?(dest, dropped)
|
|
183
|
+
end
|
|
184
|
+
|
|
169
185
|
File.write(dest, new_content)
|
|
170
186
|
|
|
171
187
|
if @quiet
|
|
@@ -179,10 +195,104 @@ module Belt
|
|
|
179
195
|
end
|
|
180
196
|
end
|
|
181
197
|
|
|
198
|
+
# Compare the existing dynamodb.tf against the freshly rendered content and
|
|
199
|
+
# return a list of human-readable descriptions of infrastructure that exists
|
|
200
|
+
# today but wouldn't be regenerated — i.e. would be dropped by the overwrite.
|
|
201
|
+
#
|
|
202
|
+
# We only surface *removals*, since additions and edits are the whole point of
|
|
203
|
+
# re-running the generator. A removal, on the other hand, is usually a mistake:
|
|
204
|
+
# a GSI someone added by hand that the model doesn't declare.
|
|
205
|
+
def detect_dropped_infrastructure(existing_content, new_content)
|
|
206
|
+
dropped = []
|
|
207
|
+
|
|
208
|
+
old_tables = table_labels(existing_content)
|
|
209
|
+
new_tables = table_labels(new_content)
|
|
210
|
+
dropped.concat((old_tables - new_tables).map { |label| "table \"#{label}\"" })
|
|
211
|
+
|
|
212
|
+
# Only compare GSIs on tables that survive — a dropped table already
|
|
213
|
+
# accounts for its indexes, no need to list them twice.
|
|
214
|
+
(old_tables & new_tables).each do |label|
|
|
215
|
+
old_gsis = gsi_names(existing_content, label)
|
|
216
|
+
new_gsis = gsi_names(new_content, label)
|
|
217
|
+
dropped.concat((old_gsis - new_gsis).map { |gsi| "GSI \"#{gsi}\" on table \"#{label}\"" })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
dropped
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Resource labels for every aws_dynamodb_table block in the content.
|
|
224
|
+
def table_labels(content)
|
|
225
|
+
content.scan(/resource\s+"aws_dynamodb_table"\s+"([^"]+)"/).flatten
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# GSI names declared inside the given table's resource block.
|
|
229
|
+
def gsi_names(content, label)
|
|
230
|
+
block = table_block(content, label)
|
|
231
|
+
return [] unless block
|
|
232
|
+
|
|
233
|
+
block.scan(/global_secondary_index\s*\{[^}]*?name\s*=\s*"([^"]+)"/m).flatten
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Extract the body of a single aws_dynamodb_table resource block by brace
|
|
237
|
+
# matching, so we can scope GSI lookups to one table.
|
|
238
|
+
def table_block(content, label)
|
|
239
|
+
marker = /resource\s+"aws_dynamodb_table"\s+"#{Regexp.escape(label)}"\s*\{/
|
|
240
|
+
match = content.match(marker)
|
|
241
|
+
return nil unless match
|
|
242
|
+
|
|
243
|
+
start = match.end(0)
|
|
244
|
+
depth = 1
|
|
245
|
+
idx = start
|
|
246
|
+
while idx < content.length && depth.positive?
|
|
247
|
+
case content[idx]
|
|
248
|
+
when '{' then depth += 1
|
|
249
|
+
when '}' then depth -= 1
|
|
250
|
+
end
|
|
251
|
+
idx += 1
|
|
252
|
+
end
|
|
253
|
+
content[start...(idx - 1)]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Decide whether it's safe to overwrite dynamodb.tf when the regen would drop
|
|
257
|
+
# hand-added infrastructure. Returns true to proceed, false to abort the write.
|
|
258
|
+
def safe_to_overwrite?(dest, dropped)
|
|
259
|
+
return true if @force
|
|
260
|
+
|
|
261
|
+
warn_dropped(dest, dropped)
|
|
262
|
+
|
|
263
|
+
# A generator auto-sync must never silently destroy custom infra. Refuse and
|
|
264
|
+
# tell the user to resolve it deliberately.
|
|
265
|
+
if @quiet
|
|
266
|
+
puts ' ⚠ skipped dynamodb.tf — would drop hand-added infrastructure ' \
|
|
267
|
+
'(run `belt setup tables` to review)'
|
|
268
|
+
return false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
print "\nOverwrite anyway and drop the above? [y/N] "
|
|
272
|
+
response = $stdin.gets&.strip&.downcase
|
|
273
|
+
return true if %w[y yes].include?(response)
|
|
274
|
+
|
|
275
|
+
puts '✗ Aborted. dynamodb.tf left unchanged.'
|
|
276
|
+
puts ' Move the custom definition into a model, or re-run with --force to overwrite.'
|
|
277
|
+
false
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def warn_dropped(dest, dropped)
|
|
281
|
+
puts "\n⚠ Regenerating #{dest} would DROP infrastructure not derived from your models:"
|
|
282
|
+
dropped.each { |d| puts " • #{d}" }
|
|
283
|
+
puts "\n This usually means a table or GSI was added to dynamodb.tf by hand."
|
|
284
|
+
puts ' Belt only tracks tables and indexes it can read from lambda/models/*.rb,'
|
|
285
|
+
puts ' so a hand-added definition is invisible to the generator and gets overwritten.'
|
|
286
|
+
end
|
|
287
|
+
|
|
182
288
|
def render_dynamodb(models)
|
|
183
289
|
blocks = models.map { |m| render_table(m) }
|
|
184
|
-
"# Auto-generated by Belt from model definitions\n" \
|
|
185
|
-
"
|
|
290
|
+
"# Auto-generated by Belt from model definitions in lambda/models/*.rb\n" \
|
|
291
|
+
"#\n" \
|
|
292
|
+
"# Do NOT edit manually. `belt setup tables` overwrites this whole file from\n" \
|
|
293
|
+
"# your models. Any table or GSI added here by hand (that a model doesn't\n" \
|
|
294
|
+
"# declare) will be DROPPED on the next regeneration. Define indexes on the\n" \
|
|
295
|
+
"# model via indexes(), belongs_to, or cognito_authenticatable instead.\n\n#{blocks.join("\n\n")}\n"
|
|
186
296
|
end
|
|
187
297
|
|
|
188
298
|
def render_table(model)
|
data/lib/belt/version.rb
CHANGED