belt 0.6.7 → 0.6.9

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: ef904a59417a3f019eab7e6339fc6d13290bf05ff3d1d435c434834cea189bc2
4
- data.tar.gz: 93a8820ef48b523d41e7a4aa8d0544090b2472bc7805b96bfee9f0f45c8e8114
3
+ metadata.gz: d7f8eec96efe780f5d58b8242c0f2c4a8d6dc0e46f53e4484d1b10072ab059aa
4
+ data.tar.gz: a065c3fbd7b2a5ada177a4b516a07314408988c53e498e302e88b0903c01029c
5
5
  SHA512:
6
- metadata.gz: b562534dbed0dcad13db7d4e6f0ff9ff99f469af4de025a682902375d39ffe0bf73447a749674443acb81632e40f2f2c4b0e34ceced872a37d4a904dd5725b7f
7
- data.tar.gz: d0915177422b65ef6f9a2dfb542cdd63b578acdbae1abf97cc4699354400b046e507818f71f7756a73f54183916921d2ad6862c877c4b8385fc6feb22ebd7b2d
6
+ metadata.gz: 3aca3754425f363160d92b60b9385a8b3efaada2a4182e059361acf42e6763001ee9f53834386705e5b638a54c32e2608025f154a284e3280053de096558f99e
7
+ data.tar.gz: 4c314ee70436b6fd009493051349e83e2986956d0e42f7cf22d3a55461ad7f05024aecc7901428da8873fba8d1e0c5c03bf6559763a098c4822b3dadec105945
data/CHANGELOG.md CHANGED
@@ -1,6 +1,38 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.9
4
+
5
+ ### Fixed
6
+
7
+ - **`belt deploy` no longer hangs forever on an interactive terraform prompt.**
8
+ `run_terraform_plan` shelled out to `terraform plan` via `Open3.popen3` with
9
+ no `-input=false` and left the child's stdin open. If terraform ever hit a
10
+ prompt (a missing variable, a backend/provider confirmation), it blocked on
11
+ `read()` from a pipe nobody was feeding — an unattended `belt deploy --auto`
12
+ could sit at 0% CPU indefinitely (observed: ~8 hours). The plan now runs with
13
+ `-input=false`, sets `TF_IN_AUTOMATION=1`, and closes the child's stdin so
14
+ terraform gets EOF instead of blocking. `terraform init`, the ACM-bootstrap
15
+ `terraform apply`, the final `terraform apply`, and the automated
16
+ `terraform destroy` all gained `-input=false` for the same reason. The
17
+ manual `belt init/plan/apply` subcommands (`terraform_command.rb`) stay
18
+ interactive — they inherit a real TTY on purpose.
19
+
20
+ ## 0.6.8
21
+
22
+ ### Added
23
+
24
+ - **`ttl_attribute` model macro.** `ttl_attribute :expires_at` on a model makes
25
+ `belt setup tables` emit `ttl { attribute_name = "expiresAt", enabled = true }`
26
+ for its table. A string is used verbatim. `belt explain models`. (Fizzy #1796)
27
+
28
+ ### Fixed
29
+
30
+ - **`belt setup tables` no longer silently drops `ttl` blocks.** A hand-added `ttl`,
31
+ or any other table block or setting the models don't produce (`stream_enabled`,
32
+ `server_side_encryption`, …), now counts as dropped infrastructure like a
33
+ hand-added GSI. Interactive runs prompt, and generator auto-syncs (`belt g policies`
34
+ and friends) skip the file. Hand-written comments the regen removes are now
35
+ reported instead of vanishing quietly. (Fizzy #1796)
4
36
 
5
37
  ## 0.6.7
6
38
 
@@ -772,7 +772,9 @@ module Belt
772
772
 
773
773
  def run_init
774
774
  puts '━━━ terraform init ━━━'
775
- success = system('terraform', 'init')
775
+ # -input=false: never block on an interactive prompt. In an automated
776
+ # deploy there is nobody at the keyboard, so a prompt would hang forever.
777
+ success = system('terraform', 'init', '-input=false')
776
778
  abort "\n✗ terraform init failed" unless success
777
779
  puts ''
778
780
  end
@@ -794,7 +796,17 @@ module Belt
794
796
  require 'open3'
795
797
  stderr_buffer = +''
796
798
 
797
- Open3.popen3('terraform', 'plan', '-out=tfplan', *@extra_args) do |_stdin, stdout, stderr, wait_thr|
799
+ # -input=false makes terraform fail fast on any missing input instead of
800
+ # prompting. TF_IN_AUTOMATION trims interactive hints from the output.
801
+ # We also close stdin immediately below so that even if terraform tried
802
+ # to read from it, it gets EOF rather than blocking forever — this is
803
+ # what caused `belt deploy` to hang indefinitely on an unanswered prompt.
804
+ tf_env = { 'TF_IN_AUTOMATION' => '1' }
805
+ tf_cmd = ['terraform', 'plan', '-input=false', '-out=tfplan', *@extra_args]
806
+
807
+ Open3.popen3(tf_env, *tf_cmd) do |stdin, stdout, stderr, wait_thr|
808
+ stdin.close
809
+
798
810
  stdout_thread = Thread.new do
799
811
  while (line = stdout.gets)
800
812
  print line
@@ -838,7 +850,8 @@ module Belt
838
850
  puts ''
839
851
 
840
852
  # Phase 1: Create the certificate only
841
- success = system('terraform', 'apply', '-target=module.app.aws_acm_certificate.app', '-auto-approve')
853
+ success = system('terraform', 'apply', '-input=false',
854
+ '-target=module.app.aws_acm_certificate.app', '-auto-approve')
842
855
  unless success
843
856
  puts ''
844
857
  puts ' ✗ Certificate creation failed.'
@@ -881,7 +894,7 @@ module Belt
881
894
 
882
895
  def run_apply
883
896
  puts '━━━ terraform apply ━━━'
884
- success = system('terraform', 'apply', 'tfplan')
897
+ success = system('terraform', 'apply', '-input=false', 'tfplan')
885
898
  cleanup_plan
886
899
  abort "\n✗ terraform apply failed" unless success
887
900
  end
@@ -401,8 +401,9 @@ module Belt
401
401
  # Empty S3 buckets before destroy — terraform can't delete non-empty buckets
402
402
  empty_s3_buckets_in_state
403
403
 
404
- # Run destroy with auto-approve (user already confirmed)
405
- unless system('terraform', 'destroy', '-auto-approve')
404
+ # Run destroy with auto-approve (user already confirmed). -input=false
405
+ # keeps it from blocking on an interactive prompt in an automated run.
406
+ unless system('terraform', 'destroy', '-input=false', '-auto-approve')
406
407
  puts "\n✗ terraform destroy failed."
407
408
  puts ' Infrastructure may still be running. Fix and retry, or use --skip-terraform.'
408
409
  exit 1
@@ -9,6 +9,8 @@ module Belt
9
9
  class TablesCommand
10
10
  MODULE_DIR = 'infrastructure/modules/app'
11
11
  MODELS_DIR = 'lambda/models'
12
+ # Table blocks the drop check compares some other way (see #table_settings).
13
+ DERIVED_BLOCKS = %w[attribute global_secondary_index].freeze
12
14
 
13
15
  def self.run(args)
14
16
  # Environment argument accepted for backwards compatibility but unused —
@@ -99,7 +101,16 @@ module Belt
99
101
  # Deduplicate by index name
100
102
  indexes.uniq! { |idx| idx[:name] }
101
103
 
102
- { name: model_name, indexes: indexes }
104
+ { name: model_name, indexes: indexes, ttl: extract_ttl_attribute(content) }
105
+ end
106
+
107
+ # `ttl_attribute :expires_at` (Belt::Models::TtlMacro). A symbol is camelCased the
108
+ # way ActiveItem writes it; a string is the DynamoDB attribute name verbatim.
109
+ def extract_ttl_attribute(content)
110
+ match = uncommented(content).match(/^\s*ttl_attribute[\s(]+(?::(\w+)|['"]([^'"]+)['"])/)
111
+ return nil unless match
112
+
113
+ match[1] ? Belt::Inflector.camelize_lower(match[1]) : match[2]
103
114
  end
104
115
 
105
116
  def extract_indexes(content)
@@ -180,6 +191,8 @@ module Belt
180
191
  if existing_content
181
192
  dropped = detect_dropped_infrastructure(existing_content, new_content)
182
193
  return if dropped.any? && !safe_to_overwrite?(dest, dropped)
194
+
195
+ warn_dropped_comments(dest, dropped_comments(existing_content, new_content))
183
196
  end
184
197
 
185
198
  File.write(dest, new_content)
@@ -215,11 +228,63 @@ module Belt
215
228
  old_gsis = gsi_names(existing_content, label)
216
229
  new_gsis = gsi_names(new_content, label)
217
230
  dropped.concat((old_gsis - new_gsis).map { |gsi| "GSI \"#{gsi}\" on table \"#{label}\"" })
231
+ dropped.concat(dropped_table_settings(existing_content, new_content, label))
218
232
  end
219
233
 
220
234
  dropped
221
235
  end
222
236
 
237
+ # Nested blocks (`ttl`, `server_side_encryption`, …) and settings (`stream_enabled`,
238
+ # …) a table has today that the regenerated table wouldn't. A hand-added ttl block
239
+ # is the usual one — dropping it turns expiry off and rows quietly pile up.
240
+ def dropped_table_settings(existing_content, new_content, label)
241
+ old_settings = table_settings(table_block(existing_content, label))
242
+ new_settings = table_settings(table_block(new_content, label))
243
+
244
+ (old_settings.keys - new_settings.keys).map do |key|
245
+ "#{old_settings[key]} on table \"#{label}\""
246
+ end
247
+ end
248
+
249
+ # Map of identity → description for every top-level setting in a table body.
250
+ # `attribute` and `global_secondary_index` are compared separately (GSIs) or are
251
+ # derived from them (attributes), so they're left out here.
252
+ def table_settings(block)
253
+ return {} unless block
254
+
255
+ settings = {}
256
+ depth = 0
257
+ block.each_line do |line|
258
+ code = line.sub(/#.*/, '').strip
259
+ if depth.zero?
260
+ if (name = code[/\A(\w+)\s*\{/, 1])
261
+ settings.merge!(block_setting(name, block, line)) unless DERIVED_BLOCKS.include?(name)
262
+ elsif (name = code[/\A(\w+)\s*=/, 1])
263
+ settings[name] = "setting \"#{name}\""
264
+ end
265
+ end
266
+ depth += code.count('{') - code.count('}')
267
+ end
268
+ settings
269
+ end
270
+
271
+ def block_setting(name, block, line)
272
+ return { name => "block \"#{name}\"" } unless name == 'ttl'
273
+
274
+ body = block[block.index(line)..][/\{(.*?)\}/m, 1].to_s
275
+ attr = body[/attribute_name\s*=\s*"([^"]+)"/, 1]
276
+ { "ttl:#{attr}" => "ttl on \"#{attr}\"" }
277
+ end
278
+
279
+ # Hand-written comments can't be re-derived either. They're not infrastructure,
280
+ # so losing them doesn't block the write, but it shouldn't happen silently.
281
+ def dropped_comments(existing_content, new_content)
282
+ # The leading banner is Belt's own and changes between versions — skip it.
283
+ body = existing_content.lines.drop_while { |l| l.strip.empty? || l.lstrip.start_with?('#') }
284
+ body.map(&:strip).select { |l| l.start_with?('#', '//') } -
285
+ new_content.lines.map(&:strip)
286
+ end
287
+
223
288
  # Resource labels for every aws_dynamodb_table block in the content.
224
289
  def table_labels(content)
225
290
  content.scan(/resource\s+"aws_dynamodb_table"\s+"([^"]+)"/).flatten
@@ -280,19 +345,28 @@ module Belt
280
345
  def warn_dropped(dest, dropped)
281
346
  puts "\n⚠ Regenerating #{dest} would DROP infrastructure not derived from your models:"
282
347
  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,'
348
+ puts "\n This usually means a table, GSI, or setting (e.g. ttl) was added to dynamodb.tf by hand."
349
+ puts ' Belt only tracks what it can read from lambda/models/*.rb (indexes, belongs_to,'
350
+ puts ' cognito_authenticatable, ttl_attribute),'
285
351
  puts ' so a hand-added definition is invisible to the generator and gets overwritten.'
286
352
  end
287
353
 
354
+ def warn_dropped_comments(dest, comments)
355
+ return if comments.empty?
356
+
357
+ puts " ⚠ #{dest}: removed #{comments.size} hand-written comment line(s) " \
358
+ '(the file is regenerated — put notes on the model instead)'
359
+ end
360
+
288
361
  def render_dynamodb(models)
289
362
  blocks = models.map { |m| render_table(m) }
290
363
  "# Auto-generated by Belt from model definitions in lambda/models/*.rb\n" \
291
364
  "#\n" \
292
365
  "# Do NOT edit manually. `belt setup tables` overwrites this whole file from\n" \
293
366
  "# 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"
367
+ "# declare) will be DROPPED on the next regeneration, as will ttl blocks and\n" \
368
+ "# comments. Define indexes on the model via indexes(), belongs_to, or\n" \
369
+ "# cognito_authenticatable, and TTL via ttl_attribute, instead.\n\n#{blocks.join("\n\n")}\n"
296
370
  end
297
371
 
298
372
  def render_table(model)
@@ -360,6 +434,14 @@ module Belt
360
434
  lines << ' }'
361
435
  end
362
436
 
437
+ if model[:ttl]
438
+ lines << ''
439
+ lines << ' ttl {'
440
+ lines << " attribute_name = \"#{model[:ttl]}\""
441
+ lines << ' enabled = true'
442
+ lines << ' }'
443
+ end
444
+
363
445
  lines << ''
364
446
  lines << ' point_in_time_recovery {'
365
447
  lines << ' enabled = var.enable_pitr'
@@ -144,6 +144,26 @@ ActiveItem::Transaction.write do |tx|
144
144
  end
145
145
  ```
146
146
 
147
+ ## Time To Live (TTL)
148
+
149
+ Let DynamoDB expire rows by declaring the TTL attribute on the model:
150
+
151
+ ```ruby
152
+ class Session < ApplicationRecord
153
+ ttl_attribute :expires_at # stored as expiresAt; must hold epoch seconds
154
+ end
155
+ ```
156
+
157
+ `belt setup tables` emits the matching `ttl { attribute_name = "expiresAt" }` block in
158
+ `dynamodb.tf`. A symbol is camelCased the way ActiveItem writes it; pass a string
159
+ (`ttl_attribute 'expires_at_epoch'`) to use the DynamoDB attribute name as-is.
160
+
161
+ Don't add `ttl` (or any other block) to `dynamodb.tf` by hand. The generator overwrites
162
+ the whole file from your models. If a regeneration would drop a hand-added table, GSI,
163
+ `ttl` or other table setting, `belt setup tables` asks before overwriting, and a
164
+ generator's auto-sync skips the file. Comments in the file are lost too (Belt prints a
165
+ warning), so put notes on the model.
166
+
147
167
  ## Generating Models
148
168
 
149
169
  ```bash
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'activeitem'
4
+ require_relative '../inflector'
5
+
6
+ module Belt
7
+ module Models
8
+ # Declares the DynamoDB Time To Live attribute for a model's table:
9
+ #
10
+ # class Session < ApplicationRecord
11
+ # ttl_attribute :expires_at # → ttl { attribute_name = "expiresAt" }
12
+ # end
13
+ #
14
+ # DynamoDB does the expiring, so at runtime this only records the name. The point
15
+ # is that `belt setup tables` reads it and emits the `ttl { }` block — a block
16
+ # added straight into dynamodb.tf would be dropped by the next regeneration.
17
+ #
18
+ # A symbol is camelCased the way ActiveItem stores it (`:expires_at` → `expiresAt`).
19
+ # Pass a string to use the DynamoDB attribute name verbatim (e.g. when the model
20
+ # remaps it with dynamo_attribute_map).
21
+ module TtlMacro
22
+ def ttl_attribute(name = nil)
23
+ return @ttl_attribute if name.nil?
24
+
25
+ @ttl_attribute = name.is_a?(Symbol) ? Belt::Inflector.camelize_lower(name.to_s) : name.to_s
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ ActiveItem::Base.extend(Belt::Models::TtlMacro)
data/lib/belt/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.6.7'
4
+ VERSION = '0.6.9'
5
5
 
6
6
  # Ruby version new apps run on in Lambda. Rendered into the conveyor-belt
7
7
  # provider block (`ruby_version`) by `belt new` / `belt generate environment`.
data/lib/belt.rb CHANGED
@@ -55,5 +55,6 @@ module Belt
55
55
  end
56
56
 
57
57
  require_relative 'belt/authentication'
58
+ require_relative 'belt/models/ttl_macro'
58
59
  require_relative 'belt_controller/base'
59
60
  require_relative 'belt/controllers/welcome_controller'
@@ -57,6 +57,11 @@ belt apply <env>
57
57
 
58
58
  This creates: model, controller (with all CRUD actions), route entry, and DynamoDB schema.
59
59
 
60
+ `infrastructure/modules/app/dynamodb.tf` is generated. Declare everything on the model:
61
+ GSIs via `indexes(...)` / `belongs_to`, TTL via `ttl_attribute :expires_at`. Anything
62
+ you add to the `.tf` by hand (a `ttl` block, a comment) gets dropped the next time it's
63
+ regenerated.
64
+
60
65
  ### Add a model only (no API endpoint)
61
66
 
62
67
  ```bash
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.6.7
4
+ version: 0.6.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -186,6 +186,7 @@ files:
186
186
  - lib/belt/inflector.rb
187
187
  - lib/belt/lambda_handler.rb
188
188
  - lib/belt/migration.rb
189
+ - lib/belt/models/ttl_macro.rb
189
190
  - lib/belt/observability.rb
190
191
  - lib/belt/parameters.rb
191
192
  - lib/belt/rendering.rb