belt 0.2.4 → 0.2.6

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.
@@ -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.