belt 0.4.4 → 0.4.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 +66 -0
- data/README.md +30 -0
- data/SKILL.md +144 -0
- data/lib/belt/cli/db_copy_command.rb +150 -0
- data/lib/belt/cli/db_seed_command.rb +178 -0
- data/lib/belt/cli/deploy_command.rb +20 -1
- data/lib/belt/cli/dynamo_copier.rb +185 -73
- data/lib/belt/cli/explain_command.rb +10 -1
- data/lib/belt/cli/frontend_deploy_command.rb +58 -1
- data/lib/belt/cli/new_command.rb +1 -0
- data/lib/belt/cli.rb +10 -0
- data/lib/belt/docs/data_seeding.md +129 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/new_app/config/seeds.rb.erb +24 -0
- data/references/cli.md +76 -0
- data/references/controllers.md +73 -0
- data/references/deploy-and-ops.md +77 -0
- data/references/models-and-auth.md +71 -0
- data/references/plugins.md +64 -0
- data/references/routing.md +78 -0
- metadata +12 -1
|
@@ -3,23 +3,60 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'open3'
|
|
5
5
|
require 'tempfile'
|
|
6
|
-
require_relative 'nested_environment'
|
|
7
6
|
|
|
8
7
|
module Belt
|
|
9
8
|
module CLI
|
|
10
|
-
# Copies DynamoDB items
|
|
9
|
+
# Copies DynamoDB items between two sets of tables identified by name
|
|
10
|
+
# prefix (typically `<app>-<env>-`). Used both by the nested-environment
|
|
11
|
+
# (PR preview) deploy hook and the standalone `belt db:copy` command.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# By default, copy is skipped when the destination table already has any
|
|
14
|
+
# items, so re-running against a live environment will not clobber data.
|
|
15
|
+
# Pass `force: true` to overwrite non-empty destination tables anyway.
|
|
16
|
+
# If a copy fails, the destination table is wiped back to its starting
|
|
17
|
+
# state (empty, or restored — best effort) so a retry starts clean.
|
|
16
18
|
class DynamoCopier
|
|
17
19
|
BATCH_SIZE = 25
|
|
18
20
|
MAX_RETRIES = 8
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# Belt's `cognito_authenticatable` convention: the identity table is
|
|
23
|
+
# `<app>-<env>-users`, its primary key attribute is `id` (the Cognito
|
|
24
|
+
# `sub`), and it carries an `email` attribute. A Cognito `sub` is unique
|
|
25
|
+
# *per user pool*, and each environment has its own pool — so the same
|
|
26
|
+
# human has a different `id` in every environment. Any row that
|
|
27
|
+
# references a user by sub therefore has a stale reference the moment it
|
|
28
|
+
# crosses an environment boundary.
|
|
29
|
+
#
|
|
30
|
+
# `cognito_sub` is the conventional foreign-key attribute name for that
|
|
31
|
+
# reference (see FeatureParity's Membership, Belt's invitation pattern).
|
|
32
|
+
# When a referencing row also carries an `email`, we can re-anchor it to
|
|
33
|
+
# the destination environment's user with the same email.
|
|
34
|
+
IDENTITY_TABLE_SUFFIX = 'users'
|
|
35
|
+
IDENTITY_ID_ATTR = 'id'
|
|
36
|
+
IDENTITY_EMAIL_ATTR = 'email'
|
|
37
|
+
IDENTITY_FK_ATTR = 'cognito_sub'
|
|
38
|
+
|
|
39
|
+
# from_prefixes / to_prefixes: array of candidate table-name prefixes
|
|
40
|
+
# (the source/destination env's tables are matched against these).
|
|
41
|
+
# from_profile / to_profile: AWS_PROFILE to use when reading the source
|
|
42
|
+
# / writing the destination, respectively (nil = use current
|
|
43
|
+
# credentials / AWS_PROFILE already in the environment).
|
|
44
|
+
# label: short description used in log output (e.g. "dev01 → dev01-pr").
|
|
45
|
+
# remap_identity: when true, re-anchor Cognito-sub foreign keys to the
|
|
46
|
+
# destination environment's users by email (see IDENTITY_* above). The
|
|
47
|
+
# users table itself is left untouched so the destination keeps its own
|
|
48
|
+
# pool's identities. Defaults to true — copying identity-referencing
|
|
49
|
+
# rows verbatim across environments silently hides the data (the row
|
|
50
|
+
# points at a sub that doesn't exist in the destination pool).
|
|
51
|
+
def initialize(from_prefixes:, to_prefixes:, from_profile: nil, to_profile: nil,
|
|
52
|
+
force: false, label: nil, remap_identity: true)
|
|
53
|
+
@from_prefixes = Array(from_prefixes)
|
|
54
|
+
@to_prefixes = Array(to_prefixes)
|
|
55
|
+
@from_profile = from_profile
|
|
56
|
+
@to_profile = to_profile
|
|
57
|
+
@force = force
|
|
58
|
+
@label = label
|
|
59
|
+
@remap_identity = remap_identity
|
|
23
60
|
@errors = []
|
|
24
61
|
end
|
|
25
62
|
|
|
@@ -28,16 +65,17 @@ module Belt
|
|
|
28
65
|
# rubocop:enable Naming/PredicateMethod
|
|
29
66
|
pairs = table_pairs
|
|
30
67
|
if pairs.empty?
|
|
31
|
-
puts ' ℹ No
|
|
68
|
+
puts ' ℹ No matching DynamoDB tables found to copy'
|
|
32
69
|
return true
|
|
33
70
|
end
|
|
34
71
|
|
|
35
|
-
|
|
72
|
+
mode = @force ? 'overwriting existing data' : 'empty tables only'
|
|
73
|
+
puts " 💾 Copying DynamoDB data#{" (#{@label})" if @label} (#{mode})"
|
|
36
74
|
|
|
37
75
|
copied = 0
|
|
38
76
|
skipped = 0
|
|
39
|
-
pairs.each do |
|
|
40
|
-
result = copy_pair(
|
|
77
|
+
pairs.each do |source_table, dest_table|
|
|
78
|
+
result = copy_pair(source_table, dest_table)
|
|
41
79
|
case result
|
|
42
80
|
when :copied then copied += 1
|
|
43
81
|
when :skipped then skipped += 1
|
|
@@ -51,55 +89,140 @@ module Belt
|
|
|
51
89
|
|
|
52
90
|
private
|
|
53
91
|
|
|
54
|
-
def copy_pair(
|
|
55
|
-
short = suffix_for(
|
|
92
|
+
def copy_pair(source_table, dest_table)
|
|
93
|
+
short = suffix_for(dest_table, @to_prefixes)
|
|
56
94
|
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
# The identity (users) table is left alone when remapping: the
|
|
96
|
+
# destination environment's Cognito pool is authoritative for who its
|
|
97
|
+
# users are and what `sub` each one has. Overwriting it with the
|
|
98
|
+
# source pool's identities would create user rows that can never
|
|
99
|
+
# authenticate here (their sub belongs to the other pool).
|
|
100
|
+
if @remap_identity && identity_table?(short)
|
|
101
|
+
puts " skip #{short} (identity table — destination pool is authoritative)"
|
|
59
102
|
return :skipped
|
|
60
103
|
end
|
|
61
104
|
|
|
62
|
-
|
|
105
|
+
unless table_exists?(dest_table, profile: @to_profile)
|
|
106
|
+
puts " ⚠ #{short}: destination table missing — skip"
|
|
107
|
+
return :skipped
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if !@force && table_has_items?(dest_table, profile: @to_profile)
|
|
63
111
|
puts " skip #{short} (already has data)"
|
|
64
112
|
return :skipped
|
|
65
113
|
end
|
|
66
114
|
|
|
67
|
-
items = scan_items(
|
|
115
|
+
items = scan_items(source_table, profile: @from_profile)
|
|
68
116
|
if items.nil?
|
|
69
|
-
fail_table(short, "failed to scan
|
|
117
|
+
fail_table(short, "failed to scan source #{source_table}")
|
|
70
118
|
return :failed
|
|
71
119
|
end
|
|
72
120
|
|
|
73
121
|
if items.empty?
|
|
74
|
-
puts " skip #{short} (
|
|
122
|
+
puts " skip #{short} (source empty)"
|
|
75
123
|
return :skipped
|
|
76
124
|
end
|
|
77
125
|
|
|
126
|
+
items, remapped, dropped = remap_identity_refs(items) if @remap_identity
|
|
127
|
+
|
|
78
128
|
begin
|
|
79
|
-
|
|
80
|
-
|
|
129
|
+
wipe_table(dest_table, profile: @to_profile) if @force
|
|
130
|
+
write_items(dest_table, items, profile: @to_profile)
|
|
131
|
+
suffix = ''
|
|
132
|
+
if @remap_identity && (remapped.to_i.positive? || dropped.to_i.positive?)
|
|
133
|
+
parts = []
|
|
134
|
+
parts << "#{remapped} re-anchored" if remapped.to_i.positive?
|
|
135
|
+
parts << "#{dropped} unmatched" if dropped.to_i.positive?
|
|
136
|
+
suffix = ", #{parts.join(', ')}"
|
|
137
|
+
end
|
|
138
|
+
puts " copy #{short} (#{items.size} item#{'s' if items.size != 1}#{suffix})"
|
|
81
139
|
:copied
|
|
82
140
|
rescue StandardError => e
|
|
83
|
-
wipe_table(
|
|
141
|
+
wipe_table(dest_table, profile: @to_profile)
|
|
84
142
|
fail_table(short, e.message)
|
|
85
143
|
:failed
|
|
86
144
|
end
|
|
87
145
|
end
|
|
88
146
|
|
|
147
|
+
def identity_table?(suffix)
|
|
148
|
+
suffix == IDENTITY_TABLE_SUFFIX
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Re-anchors Cognito-sub foreign keys to the destination environment's
|
|
152
|
+
# users. For each item that carries both an email and a `cognito_sub`,
|
|
153
|
+
# the sub is rewritten to the destination user with the same email. Items
|
|
154
|
+
# whose email has no destination user keep their attributes but have the
|
|
155
|
+
# stale sub cleared, so they surface as unclaimed (e.g. a pending
|
|
156
|
+
# invitation) rather than pointing at a nonexistent identity.
|
|
157
|
+
#
|
|
158
|
+
# Returns [items, remapped_count, dropped_count].
|
|
159
|
+
def remap_identity_refs(items)
|
|
160
|
+
map = destination_email_to_id
|
|
161
|
+
remapped = 0
|
|
162
|
+
dropped = 0
|
|
163
|
+
|
|
164
|
+
rewritten = items.map do |item|
|
|
165
|
+
fk = item[IDENTITY_FK_ATTR]
|
|
166
|
+
email_attr = item[IDENTITY_EMAIL_ATTR]
|
|
167
|
+
# Only touch rows that actually reference an identity by sub AND
|
|
168
|
+
# carry an email to re-anchor on. Everything else passes through.
|
|
169
|
+
next item unless fk.is_a?(Hash) && fk.key?('S') && !fk['S'].to_s.empty?
|
|
170
|
+
next item unless email_attr.is_a?(Hash) && !email_attr['S'].to_s.empty?
|
|
171
|
+
|
|
172
|
+
email = email_attr['S'].to_s.downcase
|
|
173
|
+
dest_id = map[email]
|
|
174
|
+
|
|
175
|
+
if dest_id
|
|
176
|
+
next item if dest_id == fk['S']
|
|
177
|
+
|
|
178
|
+
remapped += 1
|
|
179
|
+
item.merge(IDENTITY_FK_ATTR => { 'S' => dest_id })
|
|
180
|
+
else
|
|
181
|
+
dropped += 1
|
|
182
|
+
item.reject { |key, _| key == IDENTITY_FK_ATTR }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
[rewritten, remapped, dropped]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# email (downcased) => destination user id (Cognito sub), built from the
|
|
190
|
+
# destination environment's users table as it exists right now.
|
|
191
|
+
def destination_email_to_id
|
|
192
|
+
@destination_email_to_id ||= begin
|
|
193
|
+
table = dest_identity_table
|
|
194
|
+
map = {}
|
|
195
|
+
if table
|
|
196
|
+
items = scan_items(table, profile: @to_profile) || []
|
|
197
|
+
items.each do |item|
|
|
198
|
+
email = item.dig(IDENTITY_EMAIL_ATTR, 'S')
|
|
199
|
+
id = item.dig(IDENTITY_ID_ATTR, 'S')
|
|
200
|
+
map[email.to_s.downcase] = id if email && id
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
map
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def dest_identity_table
|
|
208
|
+
dest_tables = tables_with_prefixes(@to_prefixes, profile: @to_profile)
|
|
209
|
+
dest_tables.find { |name| suffix_for(name, @to_prefixes) == IDENTITY_TABLE_SUFFIX }
|
|
210
|
+
end
|
|
211
|
+
|
|
89
212
|
def table_pairs
|
|
90
|
-
|
|
91
|
-
|
|
213
|
+
source_tables = tables_with_prefixes(@from_prefixes, profile: @from_profile)
|
|
214
|
+
dest_tables = tables_with_prefixes(@to_prefixes, profile: @to_profile)
|
|
92
215
|
pairs = {}
|
|
93
216
|
|
|
94
|
-
|
|
95
|
-
suffix = suffix_for(
|
|
217
|
+
source_tables.each do |source_table|
|
|
218
|
+
suffix = suffix_for(source_table, @from_prefixes)
|
|
96
219
|
next if suffix.empty?
|
|
97
220
|
|
|
98
|
-
|
|
221
|
+
@to_prefixes.each do |prefix|
|
|
99
222
|
candidate = "#{prefix}#{suffix}"
|
|
100
|
-
next unless
|
|
223
|
+
next unless dest_tables.include?(candidate)
|
|
101
224
|
|
|
102
|
-
pairs[
|
|
225
|
+
pairs[source_table] = candidate
|
|
103
226
|
break
|
|
104
227
|
end
|
|
105
228
|
end
|
|
@@ -107,20 +230,6 @@ module Belt
|
|
|
107
230
|
pairs
|
|
108
231
|
end
|
|
109
232
|
|
|
110
|
-
def parent_prefixes
|
|
111
|
-
@parent_prefixes ||= prefixes_for(@nested.parent)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def child_prefixes
|
|
115
|
-
@child_prefixes ||= prefixes_for(@nested.env)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def prefixes_for(env_name)
|
|
119
|
-
raw = "#{@app_name}-#{env_name}-"
|
|
120
|
-
sanitized = raw.tr('_', '-').downcase
|
|
121
|
-
[raw, sanitized].uniq
|
|
122
|
-
end
|
|
123
|
-
|
|
124
233
|
def suffix_for(table_name, prefixes)
|
|
125
234
|
prefixes.each do |prefix|
|
|
126
235
|
return table_name.delete_prefix(prefix) if table_name.start_with?(prefix)
|
|
@@ -128,21 +237,22 @@ module Belt
|
|
|
128
237
|
table_name
|
|
129
238
|
end
|
|
130
239
|
|
|
131
|
-
def tables_with_prefixes(prefixes)
|
|
132
|
-
all_tables.select { |name| prefixes.any? { |prefix| name.start_with?(prefix) } }
|
|
240
|
+
def tables_with_prefixes(prefixes, profile:)
|
|
241
|
+
all_tables(profile: profile).select { |name| prefixes.any? { |prefix| name.start_with?(prefix) } }
|
|
133
242
|
end
|
|
134
243
|
|
|
135
|
-
def all_tables
|
|
136
|
-
@all_tables ||=
|
|
244
|
+
def all_tables(profile:)
|
|
245
|
+
@all_tables ||= {}
|
|
246
|
+
@all_tables[profile] ||= list_all_tables(profile: profile)
|
|
137
247
|
end
|
|
138
248
|
|
|
139
|
-
def list_all_tables
|
|
249
|
+
def list_all_tables(profile:)
|
|
140
250
|
names = []
|
|
141
251
|
start_name = nil
|
|
142
252
|
loop do
|
|
143
253
|
args = ['dynamodb', 'list-tables', '--output', 'json']
|
|
144
254
|
args += ['--exclusive-start-table-name', start_name] if start_name
|
|
145
|
-
data = aws_json(*args)
|
|
255
|
+
data = aws_json(*args, profile: profile)
|
|
146
256
|
return names if data.nil?
|
|
147
257
|
|
|
148
258
|
names.concat(Array(data['TableNames']))
|
|
@@ -152,25 +262,25 @@ module Belt
|
|
|
152
262
|
names
|
|
153
263
|
end
|
|
154
264
|
|
|
155
|
-
def table_exists?(name)
|
|
156
|
-
all_tables.include?(name)
|
|
265
|
+
def table_exists?(name, profile:)
|
|
266
|
+
all_tables(profile: profile).include?(name)
|
|
157
267
|
end
|
|
158
268
|
|
|
159
|
-
def table_has_items?(table_name)
|
|
269
|
+
def table_has_items?(table_name, profile:)
|
|
160
270
|
data = aws_json('dynamodb', 'scan', '--table-name', table_name,
|
|
161
|
-
'--select', 'COUNT', '--limit', '1', '--output', 'json')
|
|
271
|
+
'--select', 'COUNT', '--limit', '1', '--output', 'json', profile: profile)
|
|
162
272
|
return false if data.nil?
|
|
163
273
|
|
|
164
274
|
data.fetch('Count', 0).to_i.positive?
|
|
165
275
|
end
|
|
166
276
|
|
|
167
|
-
def scan_items(table_name)
|
|
277
|
+
def scan_items(table_name, profile:)
|
|
168
278
|
items = []
|
|
169
279
|
start_key = nil
|
|
170
280
|
loop do
|
|
171
281
|
args = ['dynamodb', 'scan', '--table-name', table_name, '--output', 'json']
|
|
172
282
|
args += ['--exclusive-start-key', JSON.generate(start_key)] if start_key
|
|
173
|
-
data = aws_json(*args)
|
|
283
|
+
data = aws_json(*args, profile: profile)
|
|
174
284
|
return nil if data.nil?
|
|
175
285
|
|
|
176
286
|
items.concat(Array(data['Items']))
|
|
@@ -180,17 +290,17 @@ module Belt
|
|
|
180
290
|
items
|
|
181
291
|
end
|
|
182
292
|
|
|
183
|
-
def write_items(table_name, items)
|
|
293
|
+
def write_items(table_name, items, profile:)
|
|
184
294
|
items.each_slice(BATCH_SIZE) do |batch|
|
|
185
295
|
request = {
|
|
186
296
|
table_name => batch.map { |item| { 'PutRequest' => { 'Item' => item } } }
|
|
187
297
|
}
|
|
188
|
-
write_batch(request)
|
|
298
|
+
write_batch(request, profile: profile)
|
|
189
299
|
end
|
|
190
300
|
end
|
|
191
301
|
|
|
192
|
-
def write_batch(request_items, attempt
|
|
193
|
-
data = batch_write(request_items)
|
|
302
|
+
def write_batch(request_items, profile:, attempt: 0)
|
|
303
|
+
data = batch_write(request_items, profile: profile)
|
|
194
304
|
raise "batch-write-item failed for #{request_items.keys.join(', ')}" if data.nil?
|
|
195
305
|
|
|
196
306
|
unprocessed = data['UnprocessedItems']
|
|
@@ -198,24 +308,24 @@ module Belt
|
|
|
198
308
|
raise "unprocessed items after #{MAX_RETRIES} retries" if attempt >= MAX_RETRIES
|
|
199
309
|
|
|
200
310
|
sleep(0.2 * (2**attempt))
|
|
201
|
-
write_batch(unprocessed, attempt + 1)
|
|
311
|
+
write_batch(unprocessed, profile: profile, attempt: attempt + 1)
|
|
202
312
|
end
|
|
203
313
|
|
|
204
|
-
def batch_write(request_items)
|
|
314
|
+
def batch_write(request_items, profile:)
|
|
205
315
|
Tempfile.create(['belt-dynamo', '.json']) do |file|
|
|
206
316
|
file.write(JSON.generate(request_items))
|
|
207
317
|
file.flush
|
|
208
318
|
aws_json('dynamodb', 'batch-write-item',
|
|
209
319
|
'--request-items', "file://#{file.path}",
|
|
210
|
-
'--output', 'json')
|
|
320
|
+
'--output', 'json', profile: profile)
|
|
211
321
|
end
|
|
212
322
|
end
|
|
213
323
|
|
|
214
|
-
def wipe_table(table_name)
|
|
215
|
-
items = scan_items(table_name)
|
|
324
|
+
def wipe_table(table_name, profile:)
|
|
325
|
+
items = scan_items(table_name, profile: profile)
|
|
216
326
|
return if items.nil? || items.empty?
|
|
217
327
|
|
|
218
|
-
keys = key_attribute_names(table_name)
|
|
328
|
+
keys = key_attribute_names(table_name, profile: profile)
|
|
219
329
|
return if keys.empty?
|
|
220
330
|
|
|
221
331
|
items.each_slice(BATCH_SIZE) do |batch|
|
|
@@ -224,14 +334,14 @@ module Belt
|
|
|
224
334
|
{ 'DeleteRequest' => { 'Key' => item.slice(*keys) } }
|
|
225
335
|
end
|
|
226
336
|
}
|
|
227
|
-
batch_write(request)
|
|
337
|
+
batch_write(request, profile: profile)
|
|
228
338
|
end
|
|
229
339
|
rescue StandardError
|
|
230
340
|
nil
|
|
231
341
|
end
|
|
232
342
|
|
|
233
|
-
def key_attribute_names(table_name)
|
|
234
|
-
data = aws_json('dynamodb', 'describe-table', '--table-name', table_name, '--output', 'json')
|
|
343
|
+
def key_attribute_names(table_name, profile:)
|
|
344
|
+
data = aws_json('dynamodb', 'describe-table', '--table-name', table_name, '--output', 'json', profile: profile)
|
|
235
345
|
return [] if data.nil?
|
|
236
346
|
|
|
237
347
|
Array(data.dig('Table', 'KeySchema')).map { |key| key['AttributeName'] }.compact
|
|
@@ -239,11 +349,13 @@ module Belt
|
|
|
239
349
|
|
|
240
350
|
def fail_table(short, message)
|
|
241
351
|
@errors << "#{short}: #{message}"
|
|
242
|
-
puts " ⚠ #{short}: #{message}
|
|
352
|
+
puts " ⚠ #{short}: #{message}"
|
|
243
353
|
end
|
|
244
354
|
|
|
245
|
-
def aws_json(*)
|
|
246
|
-
|
|
355
|
+
def aws_json(*args, profile: nil)
|
|
356
|
+
cmd = ['aws'] + args
|
|
357
|
+
cmd += ['--profile', profile] if profile && !profile.empty?
|
|
358
|
+
output, status = Open3.capture2(*cmd)
|
|
247
359
|
return nil unless status.success?
|
|
248
360
|
|
|
249
361
|
JSON.parse(output)
|
|
@@ -44,7 +44,14 @@ module Belt
|
|
|
44
44
|
'irb' => 'console',
|
|
45
45
|
'repl' => 'console',
|
|
46
46
|
'frontends' => 'frontend',
|
|
47
|
-
'spa' => 'frontend'
|
|
47
|
+
'spa' => 'frontend',
|
|
48
|
+
'seeds' => 'data_seeding',
|
|
49
|
+
'seed' => 'data_seeding',
|
|
50
|
+
'seeding' => 'data_seeding',
|
|
51
|
+
'db:seed' => 'data_seeding',
|
|
52
|
+
'db:copy' => 'data_seeding',
|
|
53
|
+
'copy' => 'data_seeding',
|
|
54
|
+
'db_copy' => 'data_seeding'
|
|
48
55
|
}.freeze
|
|
49
56
|
|
|
50
57
|
def self.run(args)
|
|
@@ -105,6 +112,8 @@ module Belt
|
|
|
105
112
|
backup → backups
|
|
106
113
|
plugin → plugins
|
|
107
114
|
irb, repl → console
|
|
115
|
+
seeds, seed, db:seed → data_seeding
|
|
116
|
+
db:copy, copy → data_seeding
|
|
108
117
|
|
|
109
118
|
Examples:
|
|
110
119
|
belt explain routing
|
|
@@ -119,7 +119,7 @@ module Belt
|
|
|
119
119
|
|
|
120
120
|
def sync_to_s3
|
|
121
121
|
bucket = fetch_bucket_name
|
|
122
|
-
abort
|
|
122
|
+
abort(bucket_lookup_failure_message) unless bucket
|
|
123
123
|
|
|
124
124
|
dist = @frontend.dist_dir
|
|
125
125
|
unless Dir.exist?(dist)
|
|
@@ -161,6 +161,63 @@ module Belt
|
|
|
161
161
|
fetch_tf_output(@frontend.bucket_output)
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
+
# The bucket output came back nil. Figure out *why* instead of always
|
|
165
|
+
# blaming a missing apply. `terraform output` swallows its own stderr in
|
|
166
|
+
# fetch_tf_output, so re-run it once with stderr captured and translate the
|
|
167
|
+
# failure into something actionable:
|
|
168
|
+
# - no state at all → env was never applied (or wrong dir)
|
|
169
|
+
# - credential/SSO error → the AWS profile/session is the problem
|
|
170
|
+
# - output just missing → applied, but this frontend's output isn't there
|
|
171
|
+
def bucket_lookup_failure_message
|
|
172
|
+
_out, err, _status = Open3.capture3(
|
|
173
|
+
'terraform', 'output', '-raw', @frontend.bucket_output.to_s,
|
|
174
|
+
chdir: @env_dir
|
|
175
|
+
)
|
|
176
|
+
stderr = err.to_s.strip
|
|
177
|
+
first_line = stderr.lines.first&.strip
|
|
178
|
+
|
|
179
|
+
if credential_error?(stderr)
|
|
180
|
+
[
|
|
181
|
+
"Error: Could not reach Terraform state for '#{@env}' — AWS credentials failed.",
|
|
182
|
+
" #{first_line}",
|
|
183
|
+
" Check the aws_profile in infrastructure/#{@env}/belt.rb and that its SSO " \
|
|
184
|
+
'session is active (`aws sso login --profile <profile>`).'
|
|
185
|
+
].join("\n")
|
|
186
|
+
elsif no_state?(stderr) || !state_present?
|
|
187
|
+
[
|
|
188
|
+
"Error: No Terraform state for '#{@env}' yet — nothing to deploy the frontend against.",
|
|
189
|
+
" Run `belt deploy #{@env}` to provision the backend first, then retry the frontend deploy."
|
|
190
|
+
].join("\n")
|
|
191
|
+
else
|
|
192
|
+
lines = [
|
|
193
|
+
"Error: Terraform output `#{@frontend.bucket_output}` not found for '#{@env}'.",
|
|
194
|
+
" The backend is applied but this frontend's bucket output is missing. " \
|
|
195
|
+
'Check config/frontends.yml and that the frontend module is included in terraform.'
|
|
196
|
+
]
|
|
197
|
+
lines << " terraform: #{first_line}" if first_line
|
|
198
|
+
lines.join("\n")
|
|
199
|
+
end
|
|
200
|
+
rescue Errno::ENOENT
|
|
201
|
+
"Error: `terraform` not found on PATH. Install Terraform, then run `belt deploy #{@env}` first."
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def credential_error?(stderr)
|
|
205
|
+
stderr.match?(/credential|sso|token|AccessDenied|not authorized|403/i)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def no_state?(stderr)
|
|
209
|
+
stderr.match?(
|
|
210
|
+
/No state file|no outputs|state.*not.*found|Backend initialization required|not been initialized/i
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A locally-applied env has a terraform.tfstate; a remote-backed one has an
|
|
215
|
+
# initialized .terraform dir. Absence of both means it was never applied here.
|
|
216
|
+
def state_present?
|
|
217
|
+
File.exist?(File.join(@env_dir, 'terraform.tfstate')) ||
|
|
218
|
+
Dir.exist?(File.join(@env_dir, '.terraform'))
|
|
219
|
+
end
|
|
220
|
+
|
|
164
221
|
def fetch_distribution_id
|
|
165
222
|
if probe_distribution_output?
|
|
166
223
|
id = fetch_tf_output(@frontend.distribution_output)
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -168,6 +168,7 @@ module Belt
|
|
|
168
168
|
'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
|
|
169
169
|
'config/routes.rb.erb' => "#{@app_name}/config/routes.rb",
|
|
170
170
|
'config/contracts.rb.erb' => "#{@app_name}/config/contracts.rb",
|
|
171
|
+
'config/seeds.rb.erb' => "#{@app_name}/config/seeds.rb",
|
|
171
172
|
'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
|
|
172
173
|
'README.md.erb' => "#{@app_name}/README.md",
|
|
173
174
|
'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
|
data/lib/belt/cli.rb
CHANGED
|
@@ -24,6 +24,8 @@ require_relative 'cli/contracts_command'
|
|
|
24
24
|
require_relative 'cli/lambda_config_command'
|
|
25
25
|
require_relative 'cli/tasks_command'
|
|
26
26
|
require_relative 'cli/console_command'
|
|
27
|
+
require_relative 'cli/db_copy_command'
|
|
28
|
+
require_relative 'cli/db_seed_command'
|
|
27
29
|
require_relative 'cli/logs_command'
|
|
28
30
|
require_relative 'cli/doctor_command'
|
|
29
31
|
require_relative 'cli/plugin_command'
|
|
@@ -39,6 +41,8 @@ module Belt
|
|
|
39
41
|
'contracts' => Belt::CLI::ContractsCommand,
|
|
40
42
|
'lambda-config' => Belt::CLI::LambdaConfigCommand,
|
|
41
43
|
%w[console c] => Belt::CLI::ConsoleCommand,
|
|
44
|
+
'db:copy' => Belt::CLI::DbCopyCommand,
|
|
45
|
+
'db:seed' => Belt::CLI::DbSeedCommand,
|
|
42
46
|
'logs' => Belt::CLI::LogsCommand,
|
|
43
47
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
44
48
|
'setup' => Belt::CLI::SetupCommand,
|
|
@@ -125,6 +129,8 @@ module Belt
|
|
|
125
129
|
|
|
126
130
|
console Start an interactive console (IRB)
|
|
127
131
|
c Alias for console
|
|
132
|
+
db:copy <from-env> <to-env> [--force] Copy DynamoDB data between environments
|
|
133
|
+
db:seed [environment] [--force] Run config/seeds.rb against an environment
|
|
128
134
|
logs [lambda] [-f] [-s 5m] [-e env] View Lambda function logs
|
|
129
135
|
tasks [-g PATTERN] [-a] List available rake tasks
|
|
130
136
|
-T [-g PATTERN] [-a] Alias for tasks
|
|
@@ -170,6 +176,10 @@ module Belt
|
|
|
170
176
|
belt tasks # list all rake tasks
|
|
171
177
|
belt lambda:build_layer # run a rake task directly
|
|
172
178
|
belt plugin new messaging # scaffold a belt-messaging style plugin gem
|
|
179
|
+
belt db:copy prod dev # copy prod's DynamoDB data into dev
|
|
180
|
+
belt db:copy prod dev --force # overwrite dev tables even if non-empty
|
|
181
|
+
belt db:seed # run config/seeds.rb against dev (or BELT_ENV)
|
|
182
|
+
belt db:seed dev01
|
|
173
183
|
USAGE
|
|
174
184
|
end
|
|
175
185
|
|