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.
@@ -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 from a parent environment into a nested child.
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
- # Copy is skipped when the child table already has any items, so a PR
13
- # sync / second deploy will not clobber test data. If a copy fails the
14
- # child table is wiped (it was empty when we started) so the next deploy
15
- # retries.
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
- def initialize(nested_env, app_name:)
21
- @nested = nested_env
22
- @app_name = app_name
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 parent DynamoDB tables found to copy'
68
+ puts ' ℹ No matching DynamoDB tables found to copy'
32
69
  return true
33
70
  end
34
71
 
35
- puts " 💾 Copying DynamoDB data from '#{@nested.parent}' (empty tables only)"
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 |parent_table, child_table|
40
- result = copy_pair(parent_table, child_table)
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(parent_table, child_table)
55
- short = suffix_for(child_table, child_prefixes)
92
+ def copy_pair(source_table, dest_table)
93
+ short = suffix_for(dest_table, @to_prefixes)
56
94
 
57
- unless table_exists?(child_table)
58
- puts " ⚠ #{short}: child table missing — skip"
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
- if table_has_items?(child_table)
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(parent_table)
115
+ items = scan_items(source_table, profile: @from_profile)
68
116
  if items.nil?
69
- fail_table(short, "failed to scan parent #{parent_table}")
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} (parent empty)"
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
- write_items(child_table, items)
80
- puts " copy #{short} (#{items.size} item#{'s' if items.size != 1})"
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(child_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
- parent_tables = tables_with_prefixes(parent_prefixes)
91
- child_tables = tables_with_prefixes(child_prefixes)
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
- parent_tables.each do |parent_table|
95
- suffix = suffix_for(parent_table, parent_prefixes)
217
+ source_tables.each do |source_table|
218
+ suffix = suffix_for(source_table, @from_prefixes)
96
219
  next if suffix.empty?
97
220
 
98
- child_prefixes.each do |prefix|
221
+ @to_prefixes.each do |prefix|
99
222
  candidate = "#{prefix}#{suffix}"
100
- next unless child_tables.include?(candidate)
223
+ next unless dest_tables.include?(candidate)
101
224
 
102
- pairs[parent_table] = candidate
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 ||= list_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 = 0)
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} (will retry on next deploy if table is empty)"
352
+ puts " ⚠ #{short}: #{message}"
243
353
  end
244
354
 
245
- def aws_json(*)
246
- output, status = Open3.capture2('aws', *)
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 "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
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)
@@ -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