belt 0.2.3 → 0.2.5

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: f58b01f78d1a1b0b6fda926c2916b28bb0db5045c20585a866fff9738ce3d674
4
- data.tar.gz: 426efc91999e3c39aab7f9bcf267addc2bdffd2fc62e74103fa0c5e0222acfb2
3
+ metadata.gz: 6e11ea8efb5dc66136ff67b14d3a0edc5cf7fb7df101972113ac45c9ec849f2f
4
+ data.tar.gz: c6236fc136329ab8f1ce875efaa21b1e0ed4a74fd1ad593677bf707f9b7bbe2c
5
5
  SHA512:
6
- metadata.gz: 41d8f9e02028ca0e19b79d70c87e660c28311cf3012d9b422574fb97e2559b7ae4b0d8e763a926fb4be5f6ffcc9ed7d200a21bba11780c29054feb551f9989d5
7
- data.tar.gz: f7f77a0b00ed82d58523cf3a83fafc3b92fa075ee2d0e86c0973a427d86d4611ba21c849e5bd2048aae04c9c75cb9437ecd11f7a162f2180de8b4e0002671334
6
+ metadata.gz: c54d5f823fb61c6b922f87cf8da31ca7fa1f6e460d3d4d13ed3be42c04f5ddcd6bc9096324fe3de493d58b84d5b81e83e939910ca6827f160257107c191c18e3
7
+ data.tar.gz: c1d9324c79092e48c012141b152fa8bf30035284fb0738904694f741b25eee1437e8f36bc889d4446113584d4e6e702706e10b860411d0bb34a502030457f500
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class BackupConfig
6
+ attr_reader :dynamodb_tables, :cognito_exports, :s3_buckets, :retention
7
+
8
+ # Load backup config from infrastructure/<env>/belt.rb
9
+ # Returns nil if no config file exists or backups aren't configured
10
+ def self.load(env, infra_dir: nil)
11
+ infra_dir ||= 'infrastructure'
12
+ config_file = File.join(infra_dir, env, 'belt.rb')
13
+ return nil unless File.exist?(config_file)
14
+
15
+ config = new
16
+ evaluator = ConfigEvaluator.new(config)
17
+ evaluator.evaluate(File.read(config_file), config_file)
18
+
19
+ # Return nil if no backups were configured
20
+ config.any? ? config : nil
21
+ end
22
+
23
+ def initialize
24
+ @dynamodb_tables = nil # nil = not configured, :all = all tables, Array = specific tables
25
+ @cognito_exports = [] # e.g. [:users, :pool_config]
26
+ @s3_buckets = [] # e.g. [:legal_documents]
27
+ @retention = { snapshots: 90, cognito: 10, s3: 10 }
28
+ end
29
+
30
+ def dynamodb?
31
+ !@dynamodb_tables.nil?
32
+ end
33
+
34
+ def cognito?
35
+ @cognito_exports.any?
36
+ end
37
+
38
+ def any?
39
+ dynamodb? || cognito? || @s3_buckets.any?
40
+ end
41
+
42
+ # Evaluates the belt.rb config file in an isolated context
43
+ # that intercepts Belt.configure calls
44
+ class ConfigEvaluator
45
+ def initialize(config)
46
+ @config = config
47
+ end
48
+
49
+ def evaluate(content, filename)
50
+ # The config file calls Belt.configure { |config| ... }
51
+ # We wrap the content to intercept the Belt constant lookup
52
+ # by evaluating in a module where Belt is redefined
53
+ config = @config
54
+ sandbox = Module.new
55
+ # Define a Belt module inside the sandbox that has .configure
56
+ belt_proxy = Module.new do
57
+ define_singleton_method(:configure) do |&block|
58
+ block.call(ConfigDSL.new(config))
59
+ end
60
+ end
61
+ sandbox.const_set(:Belt, belt_proxy)
62
+
63
+ # Evaluate the file content within the sandbox module
64
+ sandbox.module_eval(content, filename)
65
+ end
66
+ end
67
+
68
+ # DSL yielded to the block inside Belt.configure { |config| ... }
69
+ class ConfigDSL
70
+ def initialize(config)
71
+ @config = config
72
+ end
73
+
74
+ def backups(enabled = nil, &block)
75
+ if enabled == true
76
+ # Simple mode: config.backups = true → just DynamoDB for all tables
77
+ @config.instance_variable_set(:@dynamodb_tables, :all)
78
+ elsif block
79
+ backup_dsl = BackupDSL.new(@config)
80
+ backup_dsl.instance_eval(&block)
81
+ end
82
+ end
83
+ end
84
+
85
+ # DSL for the backups block
86
+ class BackupDSL
87
+ def initialize(config)
88
+ @config = config
89
+ end
90
+
91
+ def dynamodb(scope = :all)
92
+ if scope == :all
93
+ @config.instance_variable_set(:@dynamodb_tables, :all)
94
+ else
95
+ tables = Array(scope)
96
+ @config.instance_variable_set(:@dynamodb_tables, tables.map(&:to_s))
97
+ end
98
+ end
99
+
100
+ def cognito(*exports)
101
+ @config.instance_variable_set(:@cognito_exports, exports.flatten.map(&:to_sym))
102
+ end
103
+
104
+ def s3(*buckets)
105
+ @config.instance_variable_set(:@s3_buckets, buckets.flatten.map(&:to_sym))
106
+ end
107
+
108
+ def retention(opts = {})
109
+ current = @config.instance_variable_get(:@retention)
110
+ merged = current.merge(opts.transform_keys(&:to_sym))
111
+
112
+ # Convert duration objects (90.days) to integer days
113
+ merged.transform_values! do |v|
114
+ v.respond_to?(:to_i) ? v.to_i : v
115
+ end
116
+
117
+ @config.instance_variable_set(:@retention, merged)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+ require 'fileutils'
7
+
8
+ module Belt
9
+ module CLI
10
+ class BackupRunner
11
+ def initialize(env, config, infra_dir:, app_name:)
12
+ @env = env
13
+ @config = config
14
+ @infra_dir = infra_dir
15
+ @app_name = app_name
16
+ @timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
17
+ @backup_bucket = "#{@app_name}-backups-#{@env}"
18
+ @errors = []
19
+ @summary = []
20
+ end
21
+
22
+ def run
23
+ ensure_backup_bucket!
24
+ backup_dynamodb if @config.dynamodb?
25
+ backup_cognito if @config.cognito?
26
+ backup_s3_buckets if @config.s3_buckets.any?
27
+ cleanup_old_backups!
28
+ print_summary
29
+
30
+ abort "\n✗ Backup completed with errors (see above)" if @errors.any?
31
+ end
32
+
33
+ private
34
+
35
+ # ─── Backup Bucket ───────────────────────────────────────────────
36
+
37
+ def ensure_backup_bucket!
38
+ # Check if bucket exists
39
+ _, status = Open3.capture2('aws', 's3', 'ls', "s3://#{@backup_bucket}", err: File::NULL)
40
+ return if status.success?
41
+
42
+ puts " 📦 Creating backup bucket: #{@backup_bucket}"
43
+
44
+ run_aws('s3', 'mb', "s3://#{@backup_bucket}", '--region', detect_region)
45
+
46
+ # Enable versioning
47
+ run_aws('s3api', 'put-bucket-versioning',
48
+ '--bucket', @backup_bucket,
49
+ '--versioning-configuration', 'Status=Enabled')
50
+
51
+ # Block public access
52
+ run_aws('s3api', 'put-public-access-block',
53
+ '--bucket', @backup_bucket,
54
+ '--public-access-block-configuration',
55
+ 'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true')
56
+
57
+ puts ' ✅ Backup bucket created and secured'
58
+ end
59
+
60
+ # ─── DynamoDB ────────────────────────────────────────────────────
61
+
62
+ def backup_dynamodb
63
+ puts ' 💾 DynamoDB backups...'
64
+ table_names = resolve_table_names
65
+
66
+ if table_names.empty?
67
+ @errors << 'Could not resolve DynamoDB table names'
68
+ puts ' ⚠️ No table names found'
69
+ return
70
+ end
71
+
72
+ # Filter tables if specific ones configured
73
+ if @config.dynamodb_tables.is_a?(Array)
74
+ table_names = table_names.select do |name|
75
+ @config.dynamodb_tables.intersect?(name)
76
+ end
77
+ end
78
+
79
+ # Ensure PITR is enabled on all tables
80
+ table_names.each do |table_name|
81
+ ensure_pitr(table_name)
82
+
83
+ # Create on-demand snapshots
84
+ create_snapshot(table_name)
85
+ end
86
+
87
+ @summary << "DynamoDB: #{table_names.size} table(s) — PITR verified + snapshot created"
88
+ end
89
+
90
+ def ensure_pitr(table_name)
91
+ output, status = Open3.capture2(
92
+ 'aws', 'dynamodb', 'describe-continuous-backups',
93
+ '--table-name', table_name
94
+ )
95
+
96
+ if status.success?
97
+ data = JSON.parse(output)
98
+ pitr_status = data.dig('ContinuousBackupsDescription', 'PointInTimeRecoveryDescription',
99
+ 'PointInTimeRecoveryStatus')
100
+
101
+ return if pitr_status == 'ENABLED'
102
+ end
103
+
104
+ puts " Enabling PITR for #{short_table_name(table_name)}..."
105
+ run_aws('dynamodb', 'update-continuous-backups',
106
+ '--table-name', table_name,
107
+ '--point-in-time-recovery-specification', 'PointInTimeRecoveryEnabled=true')
108
+ end
109
+
110
+ def create_snapshot(table_name)
111
+ short = short_table_name(table_name)
112
+ backup_name = "#{short}-backup-#{@timestamp}"
113
+
114
+ output, status = Open3.capture2e(
115
+ 'aws', 'dynamodb', 'create-backup',
116
+ '--table-name', table_name,
117
+ '--backup-name', backup_name
118
+ )
119
+
120
+ if status.success?
121
+ puts " ✅ Snapshot: #{short}"
122
+ else
123
+ puts " ⚠️ Snapshot failed for #{short}: #{output.strip}"
124
+ end
125
+ end
126
+
127
+ # ─── Cognito ─────────────────────────────────────────────────────
128
+
129
+ def backup_cognito
130
+ puts ' 👥 Cognito backups...'
131
+ pool_id = resolve_cognito_pool_id
132
+
133
+ if pool_id.nil? || pool_id.empty?
134
+ @errors << 'Could not resolve Cognito user pool ID'
135
+ puts ' ⚠️ No Cognito pool ID found'
136
+ return
137
+ end
138
+
139
+ exports_done = []
140
+
141
+ if @config.cognito_exports.include?(:users)
142
+ export_cognito_users(pool_id)
143
+ exports_done << 'users'
144
+ end
145
+
146
+ if @config.cognito_exports.include?(:pool_config)
147
+ export_cognito_pool_config(pool_id)
148
+ exports_done << 'pool_config'
149
+ end
150
+
151
+ @summary << "Cognito: #{exports_done.join(', ')} exported" if exports_done.any?
152
+ end
153
+
154
+ def export_cognito_users(pool_id)
155
+ all_users = []
156
+ pagination_token = nil
157
+
158
+ loop do
159
+ args = ['aws', 'cognito-idp', 'list-users', '--user-pool-id', pool_id, '--max-results', '60']
160
+ args += ['--pagination-token', pagination_token] if pagination_token
161
+
162
+ output, status = Open3.capture2(*args)
163
+ break unless status.success?
164
+
165
+ data = JSON.parse(output)
166
+ all_users.concat(data['Users'] || [])
167
+
168
+ pagination_token = data['PaginationToken']
169
+ break if pagination_token.nil? || pagination_token.empty?
170
+
171
+ puts ' Fetching next page of users...'
172
+ end
173
+
174
+ # Upload to backup bucket
175
+ tmp_file = File.join(Dir.tmpdir, "cognito-users-#{@timestamp}.json")
176
+ File.write(tmp_file, JSON.pretty_generate(all_users))
177
+
178
+ run_aws('s3', 'cp', tmp_file, "s3://#{@backup_bucket}/cognito/users-#{@timestamp}.json", '--quiet')
179
+ FileUtils.rm_f(tmp_file)
180
+
181
+ puts " ✅ Cognito users exported (#{all_users.size} users)"
182
+ end
183
+
184
+ def export_cognito_pool_config(pool_id)
185
+ output, status = Open3.capture2(
186
+ 'aws', 'cognito-idp', 'describe-user-pool',
187
+ '--user-pool-id', pool_id
188
+ )
189
+
190
+ unless status.success?
191
+ @errors << 'Failed to export Cognito pool configuration'
192
+ return
193
+ end
194
+
195
+ tmp_file = File.join(Dir.tmpdir, "cognito-pool-config-#{@timestamp}.json")
196
+ File.write(tmp_file, output)
197
+
198
+ run_aws('s3', 'cp', tmp_file, "s3://#{@backup_bucket}/cognito/pool-config-#{@timestamp}.json", '--quiet')
199
+ FileUtils.rm_f(tmp_file)
200
+
201
+ puts ' ✅ Cognito pool configuration exported'
202
+ end
203
+
204
+ # ─── S3 Bucket Sync ──────────────────────────────────────────────
205
+
206
+ def backup_s3_buckets
207
+ puts ' 📄 S3 bucket backups...'
208
+
209
+ @config.s3_buckets.each do |bucket_key|
210
+ bucket_name = resolve_s3_bucket_name(bucket_key)
211
+
212
+ if bucket_name.nil? || bucket_name.empty?
213
+ puts " ⚠️ Could not resolve bucket name for :#{bucket_key}"
214
+ @errors << "Could not resolve S3 bucket: #{bucket_key}"
215
+ next
216
+ end
217
+
218
+ puts " Syncing #{bucket_name}..."
219
+ output, status = Open3.capture2e(
220
+ 'aws', 's3', 'sync',
221
+ "s3://#{bucket_name}",
222
+ "s3://#{@backup_bucket}/s3/#{bucket_key}/#{@timestamp}/",
223
+ '--quiet'
224
+ )
225
+
226
+ if status.success?
227
+ puts " ✅ #{bucket_key} synced"
228
+ else
229
+ puts " ⚠️ #{bucket_key} sync failed: #{output.strip}"
230
+ @errors << "S3 sync failed for #{bucket_key}"
231
+ end
232
+ end
233
+
234
+ @summary << "S3: #{@config.s3_buckets.size} bucket(s) synced" if @errors.none? { |e| e.include?('S3') }
235
+ end
236
+
237
+ # ─── Cleanup ─────────────────────────────────────────────────────
238
+
239
+ def cleanup_old_backups!
240
+ puts ' 🧹 Cleaning up old backups...'
241
+ cleanup_cognito_backups if @config.cognito?
242
+ cleanup_s3_backups if @config.s3_buckets.any?
243
+ cleanup_dynamodb_snapshots if @config.dynamodb?
244
+ end
245
+
246
+ def cleanup_cognito_backups
247
+ max_keep = @config.retention[:cognito] || 10
248
+
249
+ %w[users pool-config].each do |prefix|
250
+ output, status = Open3.capture2(
251
+ 'aws', 's3api', 'list-objects-v2',
252
+ '--bucket', @backup_bucket,
253
+ '--prefix', "cognito/#{prefix}-",
254
+ '--query', 'Contents[].Key',
255
+ '--output', 'json'
256
+ )
257
+ next unless status.success?
258
+
259
+ keys = begin
260
+ JSON.parse(output)
261
+ rescue StandardError
262
+ []
263
+ end
264
+ keys = Array(keys).compact.sort.reverse
265
+
266
+ next if keys.size <= max_keep
267
+
268
+ keys[max_keep..].each do |key|
269
+ Open3.capture2('aws', 's3', 'rm', "s3://#{@backup_bucket}/#{key}")
270
+ end
271
+ end
272
+ end
273
+
274
+ def cleanup_s3_backups
275
+ max_keep = @config.retention[:s3] || 10
276
+
277
+ @config.s3_buckets.each do |bucket_key|
278
+ output, status = Open3.capture2(
279
+ 'aws', 's3api', 'list-objects-v2',
280
+ '--bucket', @backup_bucket,
281
+ '--prefix', "s3/#{bucket_key}/",
282
+ '--delimiter', '/',
283
+ '--query', 'CommonPrefixes[].Prefix',
284
+ '--output', 'json'
285
+ )
286
+ next unless status.success?
287
+
288
+ prefixes = begin
289
+ JSON.parse(output)
290
+ rescue StandardError
291
+ []
292
+ end
293
+ prefixes = Array(prefixes).compact.sort.reverse
294
+
295
+ next if prefixes.size <= max_keep
296
+
297
+ prefixes[max_keep..].each do |prefix|
298
+ Open3.capture2e('aws', 's3', 'rm', "s3://#{@backup_bucket}/#{prefix}", '--recursive')
299
+ end
300
+ end
301
+ end
302
+
303
+ def cleanup_dynamodb_snapshots
304
+ retention_days = @config.retention[:snapshots] || 90
305
+ cutoff = Time.now - (retention_days * 86_400)
306
+ cutoff_epoch = cutoff.to_i
307
+
308
+ table_names = resolve_table_names
309
+ table_names.each do |table_name|
310
+ output, status = Open3.capture2(
311
+ 'aws', 'dynamodb', 'list-backups',
312
+ '--table-name', table_name,
313
+ '--time-range-upper-bound', cutoff_epoch.to_s
314
+ )
315
+ next unless status.success?
316
+
317
+ data = begin
318
+ JSON.parse(output)
319
+ rescue StandardError
320
+ {}
321
+ end
322
+ arns = (data['BackupSummaries'] || []).map { |b| b['BackupArn'] }.compact
323
+
324
+ arns.each do |arn|
325
+ Open3.capture2('aws', 'dynamodb', 'delete-backup', '--backup-arn', arn)
326
+ end
327
+ end
328
+ end
329
+
330
+ # ─── Terraform Output Resolution ─────────────────────────────────
331
+
332
+ def resolve_table_names
333
+ @resolve_table_names ||= fetch_table_names_from_terraform
334
+ end
335
+
336
+ def fetch_table_names_from_terraform
337
+ env_dir = File.join(@infra_dir, @env)
338
+ return [] unless Dir.exist?(env_dir)
339
+
340
+ Dir.chdir(env_dir) do
341
+ names = try_terraform_table_output
342
+ return names if names
343
+
344
+ fetch_table_names_from_all_outputs
345
+ end
346
+ end
347
+
348
+ def try_terraform_table_output
349
+ output, status = Open3.capture2('terraform', 'output', '-json', 'dynamodb_table_names')
350
+ return nil unless status.success?
351
+
352
+ names = begin
353
+ JSON.parse(output)
354
+ rescue StandardError
355
+ nil
356
+ end
357
+ names.is_a?(Array) && names.any? ? Array(names) : nil
358
+ end
359
+
360
+ def fetch_table_names_from_all_outputs
361
+ output, status = Open3.capture2('terraform', 'output', '-json')
362
+ return [] unless status.success?
363
+
364
+ all_outputs = begin
365
+ JSON.parse(output)
366
+ rescue StandardError
367
+ {}
368
+ end
369
+
370
+ # Look for dynamodb_table_names in output
371
+ if all_outputs['dynamodb_table_names']
372
+ val = all_outputs['dynamodb_table_names']['value']
373
+ return Array(val) if val
374
+ end
375
+
376
+ # Try to find any output that looks like table names
377
+ all_outputs.each do |key, data|
378
+ next unless key.include?('table')
379
+
380
+ val = data['value']
381
+ return Array(val) if val.is_a?(Array) && val.any?
382
+ end
383
+
384
+ []
385
+ end
386
+
387
+ def resolve_cognito_pool_id
388
+ env_dir = File.join(@infra_dir, @env)
389
+ return nil unless Dir.exist?(env_dir)
390
+
391
+ Dir.chdir(env_dir) do
392
+ output, status = Open3.capture2('terraform', 'output', '-json', 'user_pool_id')
393
+ if status.success?
394
+ val = begin
395
+ JSON.parse(output)
396
+ rescue StandardError
397
+ nil
398
+ end
399
+ return val if val.is_a?(String) && !val.empty?
400
+ end
401
+
402
+ # Fallback: search all outputs
403
+ output, status = Open3.capture2('terraform', 'output', '-json')
404
+ if status.success?
405
+ all_outputs = begin
406
+ JSON.parse(output)
407
+ rescue StandardError
408
+ {}
409
+ end
410
+ %w[user_pool_id cognito_user_pool_id cognito_pool_id].each do |key|
411
+ val = all_outputs.dig(key, 'value')
412
+ return val if val.is_a?(String) && !val.empty?
413
+ end
414
+ end
415
+ end
416
+
417
+ nil
418
+ end
419
+
420
+ def resolve_s3_bucket_name(bucket_key)
421
+ env_dir = File.join(@infra_dir, @env)
422
+ return nil unless Dir.exist?(env_dir)
423
+
424
+ name = Dir.chdir(env_dir) { fetch_s3_bucket_from_terraform(bucket_key) }
425
+ name || discover_bucket_by_prefix(bucket_key)
426
+ end
427
+
428
+ def fetch_s3_bucket_from_terraform(bucket_key)
429
+ key_name = "#{bucket_key}_bucket_name"
430
+ output, status = Open3.capture2('terraform', 'output', '-json', key_name)
431
+ if status.success?
432
+ val = begin
433
+ JSON.parse(output)
434
+ rescue StandardError
435
+ nil
436
+ end
437
+ return val if val.is_a?(String) && !val.empty?
438
+ end
439
+
440
+ fetch_s3_bucket_from_all_outputs(bucket_key)
441
+ end
442
+
443
+ def fetch_s3_bucket_from_all_outputs(bucket_key)
444
+ output, status = Open3.capture2('terraform', 'output', '-json')
445
+ return nil unless status.success?
446
+
447
+ all_outputs = begin
448
+ JSON.parse(output)
449
+ rescue StandardError
450
+ {}
451
+ end
452
+
453
+ [
454
+ "#{bucket_key}_bucket_name",
455
+ "#{bucket_key}_bucket",
456
+ bucket_key.to_s
457
+ ].each do |var|
458
+ val = all_outputs.dig(var, 'value')
459
+ return val if val.is_a?(String) && !val.empty?
460
+ end
461
+
462
+ nil
463
+ end
464
+
465
+ def discover_bucket_by_prefix(bucket_key)
466
+ prefix = "#{@app_name}-#{bucket_key.to_s.tr('_', '-')}-#{@env}"
467
+ output, status = Open3.capture2(
468
+ 'aws', 's3api', 'list-buckets',
469
+ '--query', "Buckets[?starts_with(Name, '#{prefix}')].Name | [0]",
470
+ '--output', 'text'
471
+ )
472
+ return nil unless status.success?
473
+
474
+ val = output.strip
475
+ val == 'None' ? nil : val
476
+ end
477
+
478
+ # ─── Helpers ─────────────────────────────────────────────────────
479
+
480
+ def short_table_name(table_name)
481
+ table_name.sub(/\A#{Regexp.escape(@app_name)}-#{Regexp.escape(@env)}-/, '')
482
+ end
483
+
484
+ def detect_region
485
+ # Try AWS_DEFAULT_REGION, then AWS_REGION, then fall back to us-east-1
486
+ ENV['AWS_DEFAULT_REGION'] || ENV['AWS_REGION'] || 'us-east-1'
487
+ end
488
+
489
+ def run_aws(*args)
490
+ output, status = Open3.capture2e('aws', *args)
491
+ return if status.success?
492
+
493
+ @errors << "AWS CLI failed: aws #{args.join(' ')}"
494
+ puts " ⚠️ #{output.strip}" unless output.strip.empty?
495
+ end
496
+
497
+ def print_summary
498
+ puts ''
499
+ if @summary.any?
500
+ puts " ✅ Backup complete (#{@backup_bucket})"
501
+ @summary.each { |s| puts " • #{s}" }
502
+ end
503
+ return unless @errors.any?
504
+
505
+ puts " ⚠️ #{@errors.size} warning(s):"
506
+ @errors.each { |e| puts " • #{e}" }
507
+ end
508
+ end
509
+ end
510
+ end
@@ -6,6 +6,8 @@ require 'open3'
6
6
  require 'tmpdir'
7
7
  require_relative 'env_resolver'
8
8
  require_relative 'terraform_command'
9
+ require_relative 'backup_config'
10
+ require_relative 'backup_runner'
9
11
 
10
12
  module Belt
11
13
  module CLI
@@ -20,6 +22,8 @@ module Belt
20
22
 
21
23
  auto_approve = false
22
24
  rebuild = false
25
+ skip_backup = false
26
+ backup_only = false
23
27
  filtered_args = []
24
28
 
25
29
  args.each do |arg|
@@ -28,6 +32,10 @@ module Belt
28
32
  auto_approve = true
29
33
  when '--rebuild'
30
34
  rebuild = true
35
+ when '--skip-backup', '--no-backup'
36
+ skip_backup = true
37
+ when '--backup-only'
38
+ backup_only = true
31
39
  when '-h', '--help'
32
40
  puts help_text
33
41
  exit 0
@@ -61,10 +69,12 @@ module Belt
61
69
  end
62
70
  end
63
71
 
64
- if rebuild
65
- new(env, auto_approve: auto_approve, extra_args: filtered_args).run_rebuild
72
+ if backup_only
73
+ new(env, auto_approve: auto_approve, skip_backup: false, extra_args: filtered_args).run_backup_only
74
+ elsif rebuild
75
+ new(env, auto_approve: auto_approve, skip_backup: skip_backup, extra_args: filtered_args).run_rebuild
66
76
  else
67
- new(env, auto_approve: auto_approve, extra_args: filtered_args).run
77
+ new(env, auto_approve: auto_approve, skip_backup: skip_backup, extra_args: filtered_args).run
68
78
  end
69
79
  end
70
80
 
@@ -77,34 +87,54 @@ module Belt
77
87
 
78
88
  This runs the full deployment lifecycle:
79
89
  1. Ensure Gemfile.lock is consistent (fix stale PATH refs)
80
- 2. terraform init (initialize providers/modules)
81
- 3. terraform plan (preview changes)
82
- 4. Prompt for confirmation (unless --auto)
83
- 5. terraform apply (deploy changes)
90
+ 2. Regenerate route manifests
91
+ 3. Run pre-deploy backups (if configured in belt.rb)
92
+ 4. terraform init (initialize providers/modules)
93
+ 5. terraform plan (preview changes)
94
+ 6. Prompt for confirmation (unless --auto)
95
+ 7. terraform apply (deploy changes)
84
96
 
85
97
  Options:
86
98
  --auto, --yes, -y Skip confirmation prompt (auto-approve)
87
99
  --rebuild Rebuild and push Lambda code directly (bypasses Terraform).
88
100
  Much faster for code-only changes — packages gems via Docker,
89
101
  zips, and pushes with `aws lambda update-function-code`.
102
+ --skip-backup Skip pre-deploy backup phase
103
+ --no-backup Alias for --skip-backup
104
+ --backup-only Run backups only, do not deploy
90
105
  -h, --help Show this help
91
106
 
92
107
  Environment:
93
108
  Defaults to BELT_ENV if set, otherwise the first available environment.
94
109
 
110
+ Backup Configuration:
111
+ Create `infrastructure/<env>/belt.rb` to configure backups:
112
+
113
+ Belt.configure do |config|
114
+ config.backups do
115
+ dynamodb :all
116
+ cognito :users, :pool_config
117
+ s3 :legal_documents
118
+ retention snapshots: 90, cognito: 10, s3: 10
119
+ end
120
+ end
121
+
95
122
  Examples:
96
123
  belt deploy # Deploy dev (or BELT_ENV)
97
124
  belt deploy prod # Deploy to prod
98
125
  belt deploy dev --auto # Deploy without confirmation (CI mode)
99
126
  belt deploy --rebuild # Fast code push to dev (no infra changes)
100
127
  belt deploy prod --rebuild # Fast code push to prod
128
+ belt deploy prod --skip-backup # Skip backups for this deploy
129
+ belt deploy prod --backup-only # Run backups without deploying
101
130
  belt deploy frontend dev # Deploy frontend assets only
102
131
  HELP
103
132
  end
104
133
 
105
- def initialize(env, auto_approve: false, extra_args: [])
134
+ def initialize(env, auto_approve: false, skip_backup: false, extra_args: [])
106
135
  @env = env
107
136
  @auto_approve = auto_approve
137
+ @skip_backup = skip_backup
108
138
  @extra_args = extra_args
109
139
  @infra_dir = TerraformCommand.find_infrastructure_dir
110
140
  @project_root = find_project_root
@@ -118,6 +148,7 @@ module Belt
118
148
 
119
149
  ensure_lockfile_consistent!
120
150
  generate_routes_if_needed
151
+ run_backups unless @skip_backup
121
152
 
122
153
  Dir.chdir(env_dir) do
123
154
  run_init
@@ -135,6 +166,28 @@ module Belt
135
166
  puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
136
167
  end
137
168
 
169
+ def run_backup_only
170
+ validate!
171
+
172
+ puts "belt → running backups for #{@env}\n\n"
173
+
174
+ backup_config = load_backup_config
175
+ if backup_config.nil?
176
+ puts "No backup configuration found for #{@env}."
177
+ puts "Create infrastructure/#{@env}/belt.rb to configure backups."
178
+ puts "\nExample:"
179
+ puts ' Belt.configure do |config|'
180
+ puts ' config.backups do'
181
+ puts ' dynamodb :all'
182
+ puts ' end'
183
+ puts ' end'
184
+ exit 1
185
+ end
186
+
187
+ run_backup_phase(backup_config)
188
+ puts "\n✅ Backups complete for #{@env}!"
189
+ end
190
+
138
191
  def run_rebuild
139
192
  validate!
140
193
  validate_aws!
@@ -200,6 +253,42 @@ module Belt
200
253
  end
201
254
  end
202
255
 
256
+ # ─── Backup Phase ───────────────────────────────────────────────
257
+
258
+ def run_backups
259
+ backup_config = load_backup_config
260
+ return unless backup_config
261
+
262
+ run_backup_phase(backup_config)
263
+ puts ''
264
+ end
265
+
266
+ def load_backup_config
267
+ BackupConfig.load(@env, infra_dir: @infra_dir)
268
+ end
269
+
270
+ def run_backup_phase(backup_config)
271
+ puts '━━━ pre-deploy backup ━━━'
272
+ app_name = detect_app_name_for_backup
273
+ runner = BackupRunner.new(@env, backup_config, infra_dir: @infra_dir, app_name: app_name)
274
+ runner.run
275
+ end
276
+
277
+ def detect_app_name_for_backup
278
+ # Try terraform.tfvars first for the app name
279
+ tfvars_file = File.join(@infra_dir, @env, 'terraform.tfvars')
280
+ if File.exist?(tfvars_file)
281
+ match = File.read(tfvars_file).match(/^\s*app_name\s*=\s*"([^"]+)"/)
282
+ return match[1] if match
283
+
284
+ match = File.read(tfvars_file).match(/^\s*project\s*=\s*"([^"]+)"/)
285
+ return match[1] if match
286
+ end
287
+
288
+ # Fall back to directory name
289
+ File.basename(@project_root)
290
+ end
291
+
203
292
  # Ensure Gemfile.lock doesn't have stale PATH references that will break
204
293
  # the Docker-based gem build. If the Gemfile no longer uses `path:` but the
205
294
  # lockfile still references a PATH source, refresh the lock.
@@ -39,8 +39,9 @@ module Belt
39
39
  lambda/models/<name>.rb Model with validations and fields
40
40
  lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
41
41
  config/routes.tf.rb Route entry added
42
- config/schema.tf.rb DynamoDB table schema added
42
+ config/schema.tf.rb API response contract added
43
43
  lambda/lib/routes/<app>_routes.rb Route manifest updated
44
+ infrastructure/modules/app/dynamodb.tf DynamoDB table generated
44
45
  frontend/src/pages/<names>/ React pages (if frontend exists)
45
46
 
46
47
  Alias: `belt g resource` works the same way.
@@ -58,7 +59,9 @@ module Belt
58
59
  ],
59
60
  notes: <<~NOTES
60
61
  Creates:
61
- lambda/models/<name>.rb Model class inheriting from ActiveItem::Base
62
+ lambda/models/<name>.rb Model class inheriting from ApplicationRecord
63
+ config/schema.tf.rb API response contract added
64
+ infrastructure/modules/app/dynamodb.tf DynamoDB table generated
62
65
  NOTES
63
66
  },
64
67
  'controller' => {
@@ -452,11 +455,12 @@ module Belt
452
455
 
453
456
  content = File.read(schema_file)
454
457
 
455
- field_lines = @fields.map { |f| " field :#{f[:name]}, type: :#{f[:type]}" }
456
- field_lines << ' field :created_at, type: :string'
457
- field_lines << ' field :updated_at, type: :string'
458
+ # Generate OpenAPI-style model block for API response contracts
459
+ response_fields = @fields.map { |f| " #{schema_type_for(f[:type])} :#{f[:name]}" }
460
+ response_fields << ' string :created_at'
461
+ response_fields << ' string :updated_at'
458
462
 
459
- schema_block = " model :#{@singular_name} do\n#{field_lines.join("\n")}\n end\n"
463
+ schema_block = " model :#{@singular_name} do\n#{response_fields.join("\n")}\n end\n"
460
464
 
461
465
  # If model already exists (force mode), replace it
462
466
  existing_model_pattern = /^ model :#{Regexp.escape(@singular_name)} do\n.*?^ end\n/m
@@ -474,6 +478,18 @@ module Belt
474
478
  puts " update #{schema_file}"
475
479
  end
476
480
 
481
+ # Map generator field types to OpenAPI schema DSL types
482
+ def schema_type_for(field_type)
483
+ case field_type.to_s
484
+ when 'text' then 'string'
485
+ when 'integer' then 'integer'
486
+ when 'float' then 'number'
487
+ when 'boolean' then 'boolean'
488
+ when 'date', 'datetime' then 'string'
489
+ else 'string'
490
+ end
491
+ end
492
+
477
493
  def sync_tables
478
494
  Belt::CLI::TablesCommand.sync_all_environments
479
495
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'app_detection'
4
3
  require_relative 'env_resolver'
5
4
  require_relative 'terraform_command'
6
5
  require_relative '../inflector'
@@ -8,10 +7,8 @@ require_relative '../inflector'
8
7
  module Belt
9
8
  module CLI
10
9
  class TablesCommand
11
- SCHEMA_FILE_CANDIDATES = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].freeze
12
10
  MODULE_DIR = 'infrastructure/modules/app'
13
-
14
- include AppDetection
11
+ MODELS_DIR = 'lambda/models'
15
12
 
16
13
  def self.run(args)
17
14
  # Environment argument accepted for backwards compatibility but unused —
@@ -22,27 +19,22 @@ module Belt
22
19
  end
23
20
 
24
21
  # Automatically sync dynamodb.tf in the app module.
25
- # Called by generators after updating schema.tf.rb.
22
+ # Called by generators after creating/updating model files.
26
23
  def self.sync_all_environments
27
- return unless schema_file_path
24
+ return unless Dir.exist?(MODELS_DIR)
28
25
 
29
26
  new(quiet: true).run
30
27
  end
31
28
 
32
- def self.schema_file_path
33
- SCHEMA_FILE_CANDIDATES.find { |f| File.exist?(f) }
34
- end
35
-
36
29
  def initialize(quiet: false)
37
30
  @quiet = quiet
38
- @app_name = detect_app_name
39
31
  end
40
32
 
41
33
  def run
42
34
  validate!
43
- models = parse_schema
35
+ models = parse_models
44
36
  if models.empty?
45
- puts "No models found in #{SCHEMA_FILE}" unless @quiet
37
+ puts "No models found in #{MODELS_DIR}/" unless @quiet
46
38
  return
47
39
  end
48
40
 
@@ -52,11 +44,10 @@ module Belt
52
44
  private
53
45
 
54
46
  def validate!
55
- schema_file = self.class.schema_file_path
56
- unless schema_file
47
+ unless Dir.exist?(MODELS_DIR)
57
48
  unless @quiet
58
- abort 'Error: No schema.tf.rb found (checked config/ and infrastructure/). ' \
59
- 'Run `belt generate resource` first.'
49
+ abort "Error: No models directory found at #{MODELS_DIR}/. " \
50
+ 'Run `belt generate model` to create your first model.'
60
51
  end
61
52
  return
62
53
  end
@@ -68,21 +59,53 @@ module Belt
68
59
  'Run `belt new` to create a project with the correct structure.'
69
60
  end
70
61
 
71
- def parse_schema
72
- schema_file = self.class.schema_file_path
73
- return [] unless schema_file && File.exist?(schema_file)
62
+ def parse_models
63
+ model_files = Dir.glob(File.join(MODELS_DIR, '*.rb'))
64
+ .reject { |f| File.basename(f) == 'application_record.rb' }
65
+ .reject { |f| File.basename(f).start_with?('concerns') }
66
+ .sort
67
+
68
+ model_files.filter_map { |f| parse_model_file(f) }
69
+ end
70
+
71
+ # Parse a model file to extract its name and index declarations.
72
+ # Uses lightweight regex parsing — does NOT require loading activeitem or the model.
73
+ def parse_model_file(file_path)
74
+ content = File.read(file_path)
75
+
76
+ # Extract class name from `class Foo < ApplicationRecord`
77
+ class_match = content.match(/^class\s+(\w+)\s*<\s*ApplicationRecord/)
78
+ return nil unless class_match
79
+
80
+ class_name = class_match[1]
81
+ model_name = Belt::Inflector.underscore(class_name)
82
+
83
+ # Extract indexes() declaration
84
+ indexes = extract_indexes(content)
85
+
86
+ { name: model_name, indexes: indexes }
87
+ end
88
+
89
+ def extract_indexes(content)
90
+ indexes = []
91
+
92
+ # Match the indexes() DSL: indexes('Name' => { partition_key: 'pk' }, ...)
93
+ # This handles the Ruby hash syntax used by ActiveItem
94
+ index_block = content.match(/^\s*indexes\(\s*\n?(.*?)\n?\s*\)/m)
95
+ return indexes unless index_block
74
96
 
75
- parser = SchemaParser.new
76
- schema_content = File.read(schema_file)
97
+ index_content = index_block[1]
77
98
 
78
- # Replace DSL wrapper with direct parser call
79
- inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
80
- inner.strip!
99
+ # Parse each index entry: 'IndexName' => { partition_key: 'key', sort_key: 'key' }
100
+ index_content.scan(/'([^']+)'\s*=>\s*\{([^}]+)\}/) do |name, opts|
101
+ partition_key = opts.match(/partition_key:\s*'([^']+)'/)&.captures&.first
102
+ sort_key = opts.match(/sort_key:\s*'([^']+)'/)&.captures&.first
103
+ next unless partition_key
81
104
 
82
- return [] if inner.empty? || inner.match?(/\A\s*\z/m)
105
+ indexes << { name: name, partition_key: partition_key, sort_key: sort_key }
106
+ end
83
107
 
84
- parser.instance_eval(inner, schema_file)
85
- parser.models
108
+ indexes
86
109
  end
87
110
 
88
111
  def generate_dynamodb_tf(models)
@@ -108,78 +131,94 @@ module Belt
108
131
 
109
132
  def render_dynamodb(models)
110
133
  blocks = models.map { |m| render_table(m) }
111
- "# Auto-generated by Belt from schema.tf.rb\n" \
134
+ "# Auto-generated by Belt from model definitions\n" \
112
135
  "# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
113
136
  end
114
137
 
115
138
  def render_table(model)
116
139
  name = table_name(model[:name])
117
- <<~HCL
118
- resource "aws_dynamodb_table" "#{Belt::Inflector.pluralize(model[:name])}" {
119
- name = "#{name}"
120
- billing_mode = "PAY_PER_REQUEST"
121
- hash_key = "id"
122
-
123
- attribute {
124
- name = "id"
125
- type = "S"
126
- }
127
-
128
- attribute {
129
- name = "_recent_pk"
130
- type = "S"
131
- }
132
-
133
- attribute {
134
- name = "createdAt"
135
- type = "S"
136
- }
137
-
138
- global_secondary_index {
139
- name = "RecentIndex"
140
- hash_key = "_recent_pk"
141
- range_key = "createdAt"
142
- projection_type = "ALL"
143
- }
144
-
145
- tags = {
146
- Name = "#{name}"
147
- Environment = var.environment
148
- ManagedBy = "Terraform"
149
- }
150
- }
151
- HCL
152
- end
153
-
154
- def table_name(model_name)
155
- "#{@app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
156
- end
157
-
158
- # Minimal DSL parser for schema.tf.rb
159
- class SchemaParser
160
- attr_reader :models
161
-
162
- def initialize
163
- @models = []
140
+ custom_indexes = model[:indexes] || []
141
+
142
+ # Collect all custom index attribute names, deduplicating against built-in ones
143
+ builtin_attrs = Set.new(%w[id _recent_pk createdAt])
144
+ extra_attrs = []
145
+ custom_indexes.each do |idx|
146
+ unless builtin_attrs.include?(idx[:partition_key])
147
+ extra_attrs << idx[:partition_key]
148
+ builtin_attrs.add(idx[:partition_key])
149
+ end
150
+ if idx[:sort_key] && !builtin_attrs.include?(idx[:sort_key])
151
+ extra_attrs << idx[:sort_key]
152
+ builtin_attrs.add(idx[:sort_key])
153
+ end
164
154
  end
165
155
 
166
- def model(name, &block)
167
- model_def = ModelParser.new
168
- model_def.instance_eval(&block) if block
169
- @models << { name: name.to_s, fields: model_def.fields }
156
+ lines = []
157
+ lines << "resource \"aws_dynamodb_table\" \"#{Belt::Inflector.pluralize(model[:name])}\" {"
158
+ lines << " name = \"#{name}\""
159
+ lines << ' billing_mode = "PAY_PER_REQUEST"'
160
+ lines << ' hash_key = "id"'
161
+ lines << ''
162
+ lines << ' attribute {'
163
+ lines << ' name = "id"'
164
+ lines << ' type = "S"'
165
+ lines << ' }'
166
+ lines << ''
167
+ lines << ' attribute {'
168
+ lines << ' name = "_recent_pk"'
169
+ lines << ' type = "S"'
170
+ lines << ' }'
171
+ lines << ''
172
+ lines << ' attribute {'
173
+ lines << ' name = "createdAt"'
174
+ lines << ' type = "S"'
175
+ lines << ' }'
176
+
177
+ extra_attrs.each do |attr|
178
+ lines << ''
179
+ lines << ' attribute {'
180
+ lines << " name = \"#{attr}\""
181
+ lines << ' type = "S"'
182
+ lines << ' }'
170
183
  end
171
- end
172
184
 
173
- class ModelParser
174
- attr_reader :fields
175
-
176
- def initialize
177
- @fields = []
185
+ lines << ''
186
+ lines << ' global_secondary_index {'
187
+ lines << ' name = "RecentIndex"'
188
+ lines << ' hash_key = "_recent_pk"'
189
+ lines << ' range_key = "createdAt"'
190
+ lines << ' projection_type = "ALL"'
191
+ lines << ' }'
192
+
193
+ custom_indexes.each do |idx|
194
+ lines << ''
195
+ lines << ' global_secondary_index {'
196
+ lines << " name = \"#{idx[:name]}\""
197
+ lines << " hash_key = \"#{idx[:partition_key]}\""
198
+ lines << " range_key = \"#{idx[:sort_key]}\"" if idx[:sort_key]
199
+ lines << ' projection_type = "ALL"'
200
+ lines << ' }'
178
201
  end
179
202
 
180
- def field(name, **opts)
181
- @fields << { name: name.to_s, type: opts[:type]&.to_s || 'string' }
182
- end
203
+ lines << ''
204
+ lines << ' point_in_time_recovery {'
205
+ lines << ' enabled = var.enable_pitr'
206
+ lines << ' }'
207
+ lines << ''
208
+ lines << ' deletion_protection_enabled = var.deletion_protection'
209
+ lines << ''
210
+ lines << ' tags = {'
211
+ lines << " Name = \"#{name}\""
212
+ lines << ' Environment = var.environment'
213
+ lines << ' ManagedBy = "Terraform"'
214
+ lines << ' }'
215
+ lines << '}'
216
+
217
+ lines.join("\n")
218
+ end
219
+
220
+ def table_name(model_name)
221
+ "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
183
222
  end
184
223
  end
185
224
  end
data/lib/belt/cli.rb CHANGED
@@ -14,6 +14,8 @@ require_relative 'cli/views_command'
14
14
  require_relative 'cli/setup_command'
15
15
  require_relative 'cli/terraform_command'
16
16
  require_relative 'cli/deploy_command'
17
+ require_relative 'cli/backup_config'
18
+ require_relative 'cli/backup_runner'
17
19
  require_relative 'cli/server_command'
18
20
  require_relative 'cli/routes_command'
19
21
  require_relative 'cli/lambda_config_command'
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.2.3'
4
+ VERSION = '0.2.5'
5
5
  end
@@ -39,6 +39,8 @@ module "app" {
39
39
  environment = var.environment
40
40
  aws_region = var.aws_region
41
41
  domain = var.domain
42
+ enable_pitr = var.enable_pitr
43
+ deletion_protection = var.deletion_protection
42
44
  frontend_urls = concat(
43
45
  var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
44
46
  var.environment == "prod" ? [] : ["http://localhost:3000"]
@@ -4,3 +4,10 @@ domain = "<%= @domain %>"
4
4
  <% else -%>
5
5
  # domain = "myapp.com"
6
6
  <% end -%>
7
+
8
+ # DynamoDB protection (PITR is true by default everywhere)
9
+ <% if @env_name == 'prod' || @env_name == 'production' -%>
10
+ deletion_protection = true
11
+ <% else -%>
12
+ # deletion_protection = true # Enable in production environments
13
+ <% end -%>
@@ -20,3 +20,15 @@ variable "domain" {
20
20
  type = string
21
21
  default = ""
22
22
  }
23
+
24
+ variable "enable_pitr" {
25
+ description = "Enable DynamoDB Point-in-Time Recovery (PITR) for all tables"
26
+ type = bool
27
+ default = true
28
+ }
29
+
30
+ variable "deletion_protection" {
31
+ description = "Enable deletion protection on DynamoDB tables (prevents accidental terraform destroy)"
32
+ type = bool
33
+ default = false
34
+ }
@@ -37,3 +37,15 @@ variable "lambda_config" {
37
37
  type = any
38
38
  default = {}
39
39
  }
40
+
41
+ variable "enable_pitr" {
42
+ description = "Enable DynamoDB Point-in-Time Recovery (PITR) for all tables"
43
+ type = bool
44
+ default = true
45
+ }
46
+
47
+ variable "deletion_protection" {
48
+ description = "Enable deletion protection on DynamoDB tables (prevents accidental terraform destroy)"
49
+ type = bool
50
+ default = false
51
+ }
@@ -1,9 +1,22 @@
1
- Belt.application.schema.draw do
2
- # model :post do
3
- # field :title, type: :string, required: true
4
- # field :slug, type: :string, required: true
5
- # field :content, type: :string
6
- # field :status, type: :string, enum: %w[draft published]
7
- # field :published_at, type: :string
8
- # end
1
+ Belt.application.schema.define do
2
+ # API Schema Definitions — request/response contracts
3
+ #
4
+ # request :create_thing declares what an endpoint accepts
5
+ # model :thing — declares what an endpoint returns
6
+ #
7
+ # Example:
8
+ # request :create_post do
9
+ # string :title, required: true
10
+ # string :body, required: true
11
+ # string :status
12
+ # end
13
+ #
14
+ # model :post do
15
+ # string :id
16
+ # string :title
17
+ # string :body
18
+ # string :status
19
+ # string :created_at
20
+ # string :updated_at
21
+ # end
9
22
  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.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -70,6 +70,8 @@ files:
70
70
  - lib/belt/assets/welcome.css
71
71
  - lib/belt/cli.rb
72
72
  - lib/belt/cli/app_detection.rb
73
+ - lib/belt/cli/backup_config.rb
74
+ - lib/belt/cli/backup_runner.rb
73
75
  - lib/belt/cli/bucket_security.rb
74
76
  - lib/belt/cli/console_command.rb
75
77
  - lib/belt/cli/deploy_command.rb