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.
@@ -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
- 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
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
- @env_dir = "infrastructure/#{@env}"
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 contracts.rb
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
- new.run
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
- "# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.2'
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.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla