belt 0.6.6 → 0.6.8

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: dd4b86a1b7dc7acf4f88189a24b5d39c5cfcf71f512aafb5f04fb0af6d558a65
4
- data.tar.gz: cdc8752352bca6b2ff3cce8a9abcf249c6975c0949d3a95b745be7fc2096544c
3
+ metadata.gz: 5c8fdcdc85f77c9538860db16ba868045f1a4482487ce7cfbf6b3e1921e9c793
4
+ data.tar.gz: 996b9b570963202a4b9acde7fdaa4e83550606a7e6ba9ba89cf20dabff1c5cf7
5
5
  SHA512:
6
- metadata.gz: 380937f0579d856a0f34407d5d1496306d5c9be64fb192d409e0bb78d2f6faea1d2016ae7db773312d92ff3f71876681b8e0d0384f2fc9127abd7eca386f5fbc
7
- data.tar.gz: 927a7c107926e787be5fe8ade9a0df86429b75ece20468176d4afa8de6ef15ccd10b3d99471c3cc52f3f7870dc6e5c18486a1578d0c6a8a58565a0d06c93023d
6
+ metadata.gz: 3dbbf13ab635583594d630355888e2197f8a07052348eee39f47a1c328fba39392df21c88f12a8ab34252de920ecf711ba1915785de606a527afc79299ed69e8
7
+ data.tar.gz: e2f3a01545969526003d72e5c0b486e412c852ef8f6cdd6220ad92be5c6771dba8e0e3f5a1880ed691c72ad9e27a5e0caf11a2916717fcdf74c6e81fe05e871b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.8
4
+
5
+ ### Added
6
+
7
+ - **`ttl_attribute` model macro.** `ttl_attribute :expires_at` on a model makes
8
+ `belt setup tables` emit `ttl { attribute_name = "expiresAt", enabled = true }`
9
+ for its table. A string is used verbatim. `belt explain models`. (Fizzy #1796)
10
+
11
+ ### Fixed
12
+
13
+ - **`belt setup tables` no longer silently drops `ttl` blocks.** A hand-added `ttl`,
14
+ or any other table block or setting the models don't produce (`stream_enabled`,
15
+ `server_side_encryption`, …), now counts as dropped infrastructure like a
16
+ hand-added GSI. Interactive runs prompt, and generator auto-syncs (`belt g policies`
17
+ and friends) skip the file. Hand-written comments the regen removes are now
18
+ reported instead of vanishing quietly. (Fizzy #1796)
19
+
20
+ ## 0.6.7
21
+
22
+ ### Changed
23
+
24
+ - **Non-prod deploys auto-approve by default.** `belt deploy <env>` no longer
25
+ prompts for confirmation when the environment isn't `prod`/`production` — the
26
+ Terraform plan is applied straight away. `prod` still prompts unless you pass
27
+ `--auto`. Pass `--confirm` (alias `--no-auto`) to force the prompt on any
28
+ environment. No more typing `--auto` on every dev deploy.
29
+
3
30
  ## 0.6.6
4
31
 
5
32
  ### Added
@@ -32,6 +32,7 @@ module Belt
32
32
  end
33
33
 
34
34
  auto_approve = false
35
+ force_confirm = false
35
36
  rebuild = false
36
37
  skip_backup = false
37
38
  backup_only = false
@@ -42,6 +43,8 @@ module Belt
42
43
  case arg
43
44
  when '--auto', '--yes', '-y'
44
45
  auto_approve = true
46
+ when '--confirm', '--no-auto'
47
+ force_confirm = true
45
48
  when '--rebuild'
46
49
  rebuild = true
47
50
  when '--skip-backup', '--no-backup'
@@ -69,13 +72,14 @@ module Belt
69
72
  puts 'Usage: belt deploy [environment] [options]'
70
73
  puts "\nDefaults to BELT_ENV or the first available environment."
71
74
  puts "\nOptions:"
72
- puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
75
+ puts ' --auto, --yes, -y Skip confirmation prompt (prod only; non-prod auto-approves)'
76
+ puts ' --confirm, --no-auto Force the confirmation prompt even for non-prod envs'
73
77
  puts ' --rebuild Rebuild and push Lambda code directly (bypasses Terraform)'
74
78
  puts ' -h, --help Show this help'
75
79
  puts "\nExamples:"
76
- puts ' belt deploy # Deploy dev (or BELT_ENV)'
77
- puts ' belt deploy prod # Deploy to prod'
78
- puts ' belt deploy dev --auto # Deploy without confirmation'
80
+ puts ' belt deploy # Deploy dev (or BELT_ENV), no prompt'
81
+ puts ' belt deploy prod # Deploy to prod (prompts)'
82
+ puts ' belt deploy prod --auto # Deploy prod without confirmation'
79
83
  puts ' belt deploy --rebuild # Fast code push (no infra changes)'
80
84
  puts ' belt deploy frontend dev # Deploy frontend(s)'
81
85
  puts ' belt deploy frontend dev --frontend ops'
@@ -84,6 +88,10 @@ module Belt
84
88
  end
85
89
  end
86
90
 
91
+ # Non-prod environments auto-approve by default — prod always prompts
92
+ # unless --auto is explicit. Pass --confirm to force the prompt anywhere.
93
+ auto_approve = true if !force_confirm && !%w[prod production].include?(env)
94
+
87
95
  options = { auto_approve: auto_approve, skip_migrations: skip_migrations, extra_args: filtered_args }
88
96
  if backup_only
89
97
  new(env, **options, skip_backup: false).run_backup_only
@@ -108,13 +116,16 @@ module Belt
108
116
  4. Build sidecar Lambda zips Terraform needs at plan time
109
117
  5. terraform init (initialize providers/modules)
110
118
  6. terraform plan (preview changes)
111
- 7. Prompt for confirmation (unless --auto)
119
+ 7. Prompt for confirmation (prod only; non-prod auto-approves)
112
120
  8. terraform apply (deploy changes)
113
121
  9. Nested envs: share parent Cognito + copy DynamoDB if empty
114
122
  10. Run pending data migrations in db/migrate (see `belt db:migrate`)
115
123
 
116
124
  Options:
117
- --auto, --yes, -y Skip confirmation prompt (auto-approve)
125
+ --auto, --yes, -y Skip confirmation prompt (auto-approve). Non-prod
126
+ environments auto-approve by default, so this is only
127
+ needed for prod.
128
+ --confirm, --no-auto Force the confirmation prompt even for non-prod envs
118
129
  --rebuild Rebuild and push Lambda code directly (bypasses Terraform).
119
130
  Much faster for code-only changes — packages gems via Docker,
120
131
  zips, and pushes with `aws lambda update-function-code`.
@@ -140,9 +151,10 @@ module Belt
140
151
  end
141
152
 
142
153
  Examples:
143
- belt deploy # Deploy dev (or BELT_ENV)
144
- belt deploy prod # Deploy to prod
145
- belt deploy dev --auto # Deploy without confirmation (CI mode)
154
+ belt deploy # Deploy dev (or BELT_ENV), no prompt
155
+ belt deploy prod # Deploy to prod (prompts for confirmation)
156
+ belt deploy prod --auto # Deploy to prod without confirmation (CI mode)
157
+ belt deploy dev --confirm # Force the confirmation prompt on dev
146
158
  belt deploy --rebuild # Fast code push to dev (no infra changes)
147
159
  belt deploy prod --rebuild # Fast code push to prod
148
160
  belt deploy prod --skip-backup # Skip backups for this deploy
@@ -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'
@@ -7,13 +7,21 @@ Lambda packaging, and pre-deploy backups.
7
7
  ## Quick Deploy
8
8
 
9
9
  ```bash
10
- belt deploy <env> # init → plan → apply (interactive)
11
- belt deploy prod --auto # skip confirmation prompt
10
+ belt deploy <env> # init → plan → apply
11
+ belt deploy prod # prod prompts for confirmation
12
+ belt deploy prod --auto # skip prod's confirmation prompt (CI)
13
+ belt deploy dev # non-prod auto-approves — no prompt
14
+ belt deploy dev --confirm # force the prompt on a non-prod env
12
15
  belt deploy prod --skip-backup # skip pre-deploy backup
13
16
  belt deploy --backup-only # create recovery point without deploying
14
17
  belt deploy dev --skip-migrations # don't run pending db/migrate migrations
15
18
  ```
16
19
 
20
+ Non-prod environments auto-approve the Terraform plan by default — only `prod`
21
+ (and `production`) prompt. Pass `--confirm` (alias `--no-auto`) to force the
22
+ prompt anywhere; `--auto` still works everywhere and is what you want in CI for
23
+ prod.
24
+
17
25
  ## First-Time Setup
18
26
 
19
27
  ```bash
@@ -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.6'
4
+ VERSION = '0.6.8'
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.6
4
+ version: 0.6.8
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