belt 0.2.4 → 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 +4 -4
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e11ea8efb5dc66136ff67b14d3a0edc5cf7fb7df101972113ac45c9ec849f2f
|
|
4
|
+
data.tar.gz: c6236fc136329ab8f1ce875efaa21b1e0ed4a74fd1ad593677bf707f9b7bbe2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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#{
|
|
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
|
|
@@ -7,8 +7,8 @@ require_relative '../inflector'
|
|
|
7
7
|
module Belt
|
|
8
8
|
module CLI
|
|
9
9
|
class TablesCommand
|
|
10
|
-
SCHEMA_FILE_CANDIDATES = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].freeze
|
|
11
10
|
MODULE_DIR = 'infrastructure/modules/app'
|
|
11
|
+
MODELS_DIR = 'lambda/models'
|
|
12
12
|
|
|
13
13
|
def self.run(args)
|
|
14
14
|
# Environment argument accepted for backwards compatibility but unused —
|
|
@@ -19,26 +19,22 @@ module Belt
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Automatically sync dynamodb.tf in the app module.
|
|
22
|
-
# Called by generators after updating
|
|
22
|
+
# Called by generators after creating/updating model files.
|
|
23
23
|
def self.sync_all_environments
|
|
24
|
-
return unless
|
|
24
|
+
return unless Dir.exist?(MODELS_DIR)
|
|
25
25
|
|
|
26
26
|
new(quiet: true).run
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def self.schema_file_path
|
|
30
|
-
SCHEMA_FILE_CANDIDATES.find { |f| File.exist?(f) }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
29
|
def initialize(quiet: false)
|
|
34
30
|
@quiet = quiet
|
|
35
31
|
end
|
|
36
32
|
|
|
37
33
|
def run
|
|
38
34
|
validate!
|
|
39
|
-
models =
|
|
35
|
+
models = parse_models
|
|
40
36
|
if models.empty?
|
|
41
|
-
puts "No models found in #{
|
|
37
|
+
puts "No models found in #{MODELS_DIR}/" unless @quiet
|
|
42
38
|
return
|
|
43
39
|
end
|
|
44
40
|
|
|
@@ -48,11 +44,10 @@ module Belt
|
|
|
48
44
|
private
|
|
49
45
|
|
|
50
46
|
def validate!
|
|
51
|
-
|
|
52
|
-
unless schema_file
|
|
47
|
+
unless Dir.exist?(MODELS_DIR)
|
|
53
48
|
unless @quiet
|
|
54
|
-
abort
|
|
55
|
-
'Run `belt generate
|
|
49
|
+
abort "Error: No models directory found at #{MODELS_DIR}/. " \
|
|
50
|
+
'Run `belt generate model` to create your first model.'
|
|
56
51
|
end
|
|
57
52
|
return
|
|
58
53
|
end
|
|
@@ -64,21 +59,53 @@ module Belt
|
|
|
64
59
|
'Run `belt new` to create a project with the correct structure.'
|
|
65
60
|
end
|
|
66
61
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
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)
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
inner.strip!
|
|
80
|
+
class_name = class_match[1]
|
|
81
|
+
model_name = Belt::Inflector.underscore(class_name)
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
# Extract indexes() declaration
|
|
84
|
+
indexes = extract_indexes(content)
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
96
|
+
|
|
97
|
+
index_content = index_block[1]
|
|
98
|
+
|
|
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
|
|
104
|
+
|
|
105
|
+
indexes << { name: name, partition_key: partition_key, sort_key: sort_key }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
indexes
|
|
82
109
|
end
|
|
83
110
|
|
|
84
111
|
def generate_dynamodb_tf(models)
|
|
@@ -104,78 +131,94 @@ module Belt
|
|
|
104
131
|
|
|
105
132
|
def render_dynamodb(models)
|
|
106
133
|
blocks = models.map { |m| render_table(m) }
|
|
107
|
-
"# Auto-generated by Belt from
|
|
134
|
+
"# Auto-generated by Belt from model definitions\n" \
|
|
108
135
|
"# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
|
|
109
136
|
end
|
|
110
137
|
|
|
111
138
|
def render_table(model)
|
|
112
139
|
name = table_name(model[:name])
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
attribute {
|
|
130
|
-
name = "createdAt"
|
|
131
|
-
type = "S"
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
global_secondary_index {
|
|
135
|
-
name = "RecentIndex"
|
|
136
|
-
hash_key = "_recent_pk"
|
|
137
|
-
range_key = "createdAt"
|
|
138
|
-
projection_type = "ALL"
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
tags = {
|
|
142
|
-
Name = "#{name}"
|
|
143
|
-
Environment = var.environment
|
|
144
|
-
ManagedBy = "Terraform"
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
HCL
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def table_name(model_name)
|
|
151
|
-
"${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Minimal DSL parser for schema.tf.rb
|
|
155
|
-
class SchemaParser
|
|
156
|
-
attr_reader :models
|
|
157
|
-
|
|
158
|
-
def initialize
|
|
159
|
-
@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
|
|
160
154
|
end
|
|
161
155
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 << ' }'
|
|
166
183
|
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
class ModelParser
|
|
170
|
-
attr_reader :fields
|
|
171
184
|
|
|
172
|
-
|
|
173
|
-
|
|
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 << ' }'
|
|
174
201
|
end
|
|
175
202
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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)}"
|
|
179
222
|
end
|
|
180
223
|
end
|
|
181
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
|
@@ -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.
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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.
|
|
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
|