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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +140 -0
- data/lib/belt/cli/backup_config.rb +122 -0
- data/lib/belt/cli/backup_runner.rb +510 -0
- data/lib/belt/cli/deploy_command.rb +97 -8
- data/lib/belt/cli/generate_command.rb +22 -6
- data/lib/belt/cli/tables_command.rb +128 -85
- data/lib/belt/cli.rb +2 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/main.tf.erb +2 -0
- data/lib/templates/environment/terraform.tfvars.erb +7 -0
- data/lib/templates/environment/variables.tf.erb +12 -0
- data/lib/templates/module/variables.tf.erb +12 -0
- data/lib/templates/new_app/config/schema.tf.rb.erb +21 -8
- metadata +3 -1
|
@@ -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
|
|
65
|
-
new(env, auto_approve: auto_approve, extra_args: filtered_args).
|
|
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.
|
|
81
|
-
3.
|
|
82
|
-
4.
|
|
83
|
-
5. terraform
|
|
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.
|