belt 0.4.1 → 0.4.3

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: 846db7f1373b5bd98f3e795404b1f157825f5dd94541bd46aa76e3510ba89adf
4
- data.tar.gz: 251135fea6ee2d3d55e7dcdf46b98460ddc3df50fa1e4113b561f721755c935d
3
+ metadata.gz: f2a6059e3ba3ce67327476626815d9a1b61b441836877277c44d04978dfa2727
4
+ data.tar.gz: d714bba5f18070ed1504e33f473a9fd2497019da7fe7c5609b9d97e4676d70ee
5
5
  SHA512:
6
- metadata.gz: 60b0ecf81e375ea1c219c84ccf7f90d568437cc81507c708ec56a40f903178e0611e5b6be03e1e71185ce66263d1ec0c12ddee8e1a4d2cfdc26f99dba4828c48
7
- data.tar.gz: fb3fa14275a68c7e79892504d0fef2cbe86727225033a7d7bc3c1272b963413b5ed7259c3a5c2f1e202770d16b0234ff242571e4d2b7abed50a040090da250e1
6
+ metadata.gz: d6feaddb7cb071ee7cb75427544d2d32b65ddf9354c3fa08461a67247485fdd6adddf986d93d0ee3fa778ab491136ca14395e5b6dc67fc918eb19b748b8a4214
7
+ data.tar.gz: 56b99bafbc94e4abcb7fa64cb3ca7b1744591c17c84afea3fe1cab07bb1cbeffe6c76f7af5b6c6f19a51bebffecba10091a70125de012f885709e417cf817bd2
data/CHANGELOG.md CHANGED
@@ -1,9 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3
4
+
5
+ ### Bug Fix
6
+
7
+ - **`belt deploy` preflight no longer false-fails on `belongs_to ..., index: false`.**
8
+ The deploy preflight added in 0.4.2 demanded a `{Assoc}Index` GSI for *every*
9
+ `belongs_to`, ignoring `index: false` and `index: 'CustomName'` — the exact
10
+ opt-outs the table generator honours. The two halves of the gem disagreed: a
11
+ model that opts out (its reverse lookup is covered by another GSI, or it doesn't
12
+ need one) got a passing `belt setup tables` and a failing `belt deploy`, with no
13
+ way out but `--force`-ing the generator or pinning back to 0.4.1. The preflight's
14
+ index extraction now mirrors the generator exactly: it skips `index: false` and
15
+ honours an explicit `index: 'Name'`. `foreign_key:`-remapped associations
16
+ (`belongs_to :user, foreign_key: 'cognito_sub', index: false`) no longer demand a
17
+ phantom `UserIndex` either.
18
+
3
19
  ## Unreleased
4
20
 
5
21
  ### Bug Fix
6
22
 
23
+ - **`belt setup tables` no longer silently clobbers hand-added tables/GSIs.**
24
+ Regenerating `dynamodb.tf` is a full overwrite from `lambda/models/*.rb`, so a
25
+ table or `global_secondary_index` added straight into the `.tf` file — one the
26
+ models don't declare — used to vanish on the next run. Dropping a live GSI is
27
+ not harmless: queries against it start failing. The generator now diffs the
28
+ existing file and, when the regen would remove infrastructure it can't
29
+ re-derive, it warns and lists exactly what would disappear. An interactive run
30
+ prompts before overwriting; a generator auto-sync refuses and leaves the file
31
+ untouched. Pass `--force` (`-f`/`--yes`/`-y`) to overwrite anyway. The
32
+ `dynamodb.tf` header comment now spells out this behavior too. (Fixes the
33
+ misleading "harmless diff" framing in the 0.4.0 notes below.)
34
+
7
35
  - **Apply environment AWS profile in `belt deploy frontend`, `belt frontend env`, `belt logs`, and `belt server`**:
8
36
  Standalone frontend deployment (`belt deploy frontend <env>`), frontend env generation (`belt frontend env <env>`),
9
37
  log viewing (`belt logs`), and the local dev server (`belt server`) now load `infrastructure/<env>/belt.rb`
@@ -262,19 +262,33 @@ module Belt
262
262
  end
263
263
  end
264
264
 
265
+ # Which GSIs a model's belongs_to declarations require. This MUST agree with the
266
+ # generator (TablesCommand#extract_belongs_to_indexes) or the two halves of the gem
267
+ # disagree: the generator refuses to create an index the preflight then demands,
268
+ # and `belt setup tables` → `belt deploy` loops forever.
269
+ #
270
+ # `index: false` opts out — the reverse lookup is covered some other way (the
271
+ # model's own indexes() entry under a different key, or no reverse lookup at all).
272
+ # The generator skips those, so the preflight must too.
273
+ #
274
+ # An explicit `index: 'Name'` overrides the convention name; we honour it so the
275
+ # check matches the GSI the model actually declared.
265
276
  def extract_expected_indexes(content)
266
277
  indexes = []
267
- content.lines.reject { |line| line.strip.start_with?('#') }.join
268
- .scan(/belongs_to\s+:(\w+)/) do |match|
269
- association_name = match[0]
270
- indexes << {
271
- name: "#{Belt::Inflector.classify(association_name)}Index",
272
- association: association_name
273
- }
278
+ uncommented(content).scan(/belongs_to\s+:(\w+)([^\n]*)/) do |association_name, options|
279
+ next if options.match?(/index:\s*false/)
280
+
281
+ explicit = options.match(/index:\s*['"]([^'"]+)['"]/)
282
+ name = explicit ? explicit[1] : "#{Belt::Inflector.classify(association_name)}Index"
283
+ indexes << { name: name, association: association_name }
274
284
  end
275
285
  indexes
276
286
  end
277
287
 
288
+ def uncommented(content)
289
+ content.lines.reject { |line| line.strip.start_with?('#') }.join
290
+ end
291
+
278
292
  def check_cognito_auth
279
293
  return unless Belt.root?
280
294
 
@@ -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.1'
4
+ VERSION = '0.4.3'
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.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla