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 +4 -4
- data/CHANGELOG.md +33 -1
- data/lib/belt/cli/deploy_command.rb +17 -4
- data/lib/belt/cli/destroy_command.rb +3 -2
- data/lib/belt/cli/tables_command.rb +87 -5
- data/lib/belt/docs/models.md +20 -0
- data/lib/belt/models/ttl_macro.rb +31 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/new_app/AGENTS.md.erb +5 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7f8eec96efe780f5d58b8242c0f2c4a8d6dc0e46f53e4484d1b10072ab059aa
|
|
4
|
+
data.tar.gz: a065c3fbd7b2a5ada177a4b516a07314408988c53e498e302e88b0903c01029c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3aca3754425f363160d92b60b9385a8b3efaada2a4182e059361acf42e6763001ee9f53834386705e5b638a54c32e2608025f154a284e3280053de096558f99e
|
|
7
|
+
data.tar.gz: 4c314ee70436b6fd009493051349e83e2986956d0e42f7cf22d3a55461ad7f05024aecc7901428da8873fba8d1e0c5c03bf6559763a098c4822b3dadec105945
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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', '-
|
|
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
|
-
|
|
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
|
|
284
|
-
puts ' Belt only tracks
|
|
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
|
|
295
|
-
"# model via indexes(), belongs_to, or
|
|
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'
|
data/lib/belt/docs/models.md
CHANGED
|
@@ -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
data/lib/belt.rb
CHANGED
|
@@ -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.
|
|
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
|