belt 0.4.3 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2a6059e3ba3ce67327476626815d9a1b61b441836877277c44d04978dfa2727
4
- data.tar.gz: d714bba5f18070ed1504e33f473a9fd2497019da7fe7c5609b9d97e4676d70ee
3
+ metadata.gz: 1436a1cb03096bd9de50abd65ecfbb973fc0f054c7e45902bdadd78516806a0c
4
+ data.tar.gz: 88ddfe6f1b1789a539bd71d407a736514ef091d2b126fc8c3296e5ee2f4e23ca
5
5
  SHA512:
6
- metadata.gz: d6feaddb7cb071ee7cb75427544d2d32b65ddf9354c3fa08461a67247485fdd6adddf986d93d0ee3fa778ab491136ca14395e5b6dc67fc918eb19b748b8a4214
7
- data.tar.gz: 56b99bafbc94e4abcb7fa64cb3ca7b1744591c17c84afea3fe1cab07bb1cbeffe6c76f7af5b6c6f19a51bebffecba10091a70125de012f885709e417cf817bd2
6
+ metadata.gz: a5e7bee23ca7077b19273c9c7605da6c42a1ec8226332d629a6d7a51b9f3ece913cc415005b905a32774eb0b8e47159fa2abeee08e5b3b4888008981a116ad82
7
+ data.tar.gz: 33ffbdc6d97e3600f09d90877d0edde784f2d5b377d3250d4044ff456256ecb7642936f7c4531b47dc88d7be69c6160e3b694873a6f7d712fb532e62cbb97d85
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Bug Fix
6
+
7
+ - **`belt deploy frontend` now explains *why* the S3 bucket lookup failed.**
8
+ Previously any nil bucket output produced the same misleading
9
+ `Could not determine S3 bucket. Run belt apply <env> first.` — even when the env
10
+ had simply never been applied, or when the AWS profile/SSO session had expired.
11
+ `fetch_tf_output` swallows terraform's stderr, so the real cause was invisible.
12
+ The frontend deploy now re-probes terraform with stderr captured and emits a
13
+ targeted message for three cases: no Terraform state yet (provision the backend
14
+ first), a credential/SSO failure (fix the `aws_profile` / run `aws sso login`),
15
+ or an applied backend that's missing the frontend's bucket output (check
16
+ `config/frontends.yml`). This surfaces most often during ephemeral environment
17
+ setup, where the frontend step can run before the backend is applied.
18
+
3
19
  ## 0.4.3
4
20
 
5
21
  ### Bug Fix
@@ -18,8 +34,59 @@
18
34
 
19
35
  ## Unreleased
20
36
 
37
+ ## 0.4.5
38
+
39
+ ### Feature
40
+
41
+ - **`belt db:copy <from-env> <to-env>` — copy DynamoDB data between environments on demand.**
42
+ Promotes the `DynamoCopier` used by nested (PR-preview) deploys into a standalone
43
+ command, so you can pull one environment's data into another whenever you want
44
+ (e.g. prod data into dev for realistic seed data), not just on a nested-env deploy.
45
+ Matches tables by name suffix after stripping each environment's `<app>-<env>-`
46
+ prefix. Destination tables that already have data are skipped by default (safe to
47
+ re-run); pass `--force` to overwrite them. Source/destination AWS profiles are
48
+ resolved independently from each environment's `infrastructure/<env>/belt.rb`
49
+ (`config.aws_profile`), or overridden with `--from-profile` / `--to-profile` —
50
+ needed when source and destination live in different AWS accounts (e.g. prod vs.
51
+ dev). See `belt explain data_seeding`.
52
+
53
+ Cognito identities are per-environment (each environment has its own user pool,
54
+ so the same person has a different `sub` in each), so `db:copy` re-anchors
55
+ Cognito-sub foreign keys (e.g. a membership's `cognito_sub`) to the destination
56
+ environment's user with the matching email, and leaves the destination's own
57
+ `users` table untouched. Without this a copied row points at a `sub` that doesn't
58
+ exist in the destination pool and silently vanishes (a copied project you can't
59
+ see). Rows whose email has no destination user yet have the stale sub cleared so
60
+ they read as unclaimed (e.g. a pending invitation) rather than dangling. The same
61
+ re-anchoring now runs in the nested-env deploy hook. Pass `--no-remap-identity` to
62
+ copy those references verbatim.
63
+
64
+ - **`belt db:seed [environment]` — Rails-style `config/seeds.rb`.**
65
+ Loads `config/seeds.rb` in the same booted context `belt console` uses (models,
66
+ ActiveItem, `ENVIRONMENT` set), targeting the resolved environment's tables.
67
+ Honours `BELT_ENV` or an explicit environment argument, and prompts for
68
+ confirmation against `prod` like `belt console` does. Refuses to run if the
69
+ target environment already has data in any matching table — pass `--force` to
70
+ seed anyway (seeds.rb is responsible for its own idempotency if re-run).
71
+ `belt new` now scaffolds a starter `config/seeds.rb` with usage notes.
72
+
73
+ ## 0.4.4
74
+
21
75
  ### Bug Fix
22
76
 
77
+ - **Gateway-level `scope path:` with a param segment no longer emits malformed paths.**
78
+ A gateway-level `scope path: 'accounts/:account_id'` used to leak the raw Rails-style
79
+ `:account_id` segment straight into route paths (`/accounts/:account_id/changes`)
80
+ instead of the API Gateway `{account_id}` form, and — worse — a bare-param scope with a
81
+ path that didn't start with `/` (e.g. `get 'summary'`) produced fused garbage like
82
+ `/accounts/{account_idsummary}`. It also folded the param segment into the derived
83
+ controller module (`accounts/:account_id/changes`). Now `build_path` joins the scope
84
+ prefix and route path with exactly one `/` and normalizes `:param` → `{param}`, and
85
+ the controller module is derived only from the scope's *static* segments (matching
86
+ Rails, where `scope path:` shapes the URL, not the module). Static-only scopes
87
+ (`scope path: 'admin'` → `admin/users`) are unchanged. A scope nested inside a
88
+ `resources` block was already fine; only the gateway-level case was broken.
89
+
23
90
  - **`belt setup tables` no longer silently clobbers hand-added tables/GSIs.**
24
91
  Regenerating `dynamodb.tf` is a full overwrite from `lambda/models/*.rb`, so a
25
92
  table or `global_secondary_index` added straight into the `.tf` file — one the
data/README.md CHANGED
@@ -701,6 +701,36 @@ These are set via Terraform variables in each environment's `terraform.tfvars`.
701
701
 
702
702
  The backup phase reads table names from Terraform outputs. On a brand-new environment that has never been deployed, there are no outputs yet — Belt will warn and skip the backup phase gracefully. After the first successful deploy, backups run normally on subsequent deploys.
703
703
 
704
+ ## Data Seeding
705
+
706
+ Two ways to get realistic data into an environment without hand-crafting rows.
707
+
708
+ ### `belt db:copy` — copy data between environments
709
+
710
+ ```bash
711
+ belt db:copy prod dev # copy prod's DynamoDB data into dev
712
+ belt db:copy prod dev --force # overwrite dev tables even if non-empty
713
+ ```
714
+
715
+ Matches tables by name after stripping each environment's `<app>-<env>-` prefix. Destination tables that already have data are skipped by default (safe to re-run). AWS profiles are resolved per-environment from `infrastructure/<env>/belt.rb`, or overridden with `--from-profile` / `--to-profile` — useful when source and destination live in different AWS accounts (e.g. prod vs. dev).
716
+
717
+ ### `belt db:seed` — Rails-style seed file
718
+
719
+ ```bash
720
+ belt db:seed # seeds dev, or $BELT_ENV if set
721
+ belt db:seed dev01
722
+ ```
723
+
724
+ Loads `config/seeds.rb` in the same booted context `belt console` uses — models are available, targeting the resolved environment's tables:
725
+
726
+ ```ruby
727
+ # config/seeds.rb
728
+ post = Post.create!(title: "Hello, world", body: "Seeded post")
729
+ puts "Created post: #{post.id}"
730
+ ```
731
+
732
+ Refuses to run against an environment that already has data (pass `--force` to override). `belt new` scaffolds a starter `config/seeds.rb`. See `belt explain data_seeding` for details.
733
+
704
734
  ## Plugins
705
735
 
706
736
  Belt is designed to stay lean. Optional capabilities ship as **separate gems** that plug into the CLI and runtime the same way Rails engines and generators do.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'app_detection'
5
+ require_relative 'environment_config'
6
+ require_relative 'dynamo_copier'
7
+
8
+ module Belt
9
+ module CLI
10
+ # `belt db:copy <from-env> <to-env>` — copies DynamoDB table contents from
11
+ # one environment into another on demand (e.g. pulling prod data into dev
12
+ # for realistic seed data).
13
+ #
14
+ # Reuses the same DynamoCopier used by the nested-environment deploy hook,
15
+ # but resolves table prefixes and AWS profiles for two arbitrary
16
+ # environments instead of a parent/child pair. Prod and dev commonly live
17
+ # in separate AWS accounts, so source and destination profiles are
18
+ # resolved independently (from each environment's `belt.rb`, with
19
+ # `--from-profile` / `--to-profile` available to override).
20
+ class DbCopyCommand
21
+ include AppDetection
22
+
23
+ def self.run(args)
24
+ new(args).run
25
+ end
26
+
27
+ def initialize(args)
28
+ @options = { force: false, remap_identity: true }
29
+ parse_options(args)
30
+ end
31
+
32
+ def run
33
+ unless @from_env && @to_env
34
+ puts usage
35
+ exit 1
36
+ end
37
+
38
+ abort "Error: source and destination environment are the same ('#{@from_env}')." if @from_env == @to_env
39
+
40
+ app_name = detect_app_name
41
+
42
+ from_profile = @options[:from_profile] || EnvironmentConfig.load(@from_env, infra_dir: infra_dir).aws_profile
43
+ to_profile = @options[:to_profile] || EnvironmentConfig.load(@to_env, infra_dir: infra_dir).aws_profile
44
+
45
+ puts "belt → copying DynamoDB data: #{@from_env} → #{@to_env}"
46
+ puts " from profile: #{from_profile || '(current credentials)'}"
47
+ puts " to profile: #{to_profile || '(current credentials)'}"
48
+ puts ''
49
+
50
+ success = DynamoCopier.new(
51
+ from_prefixes: prefixes_for(app_name, @from_env),
52
+ to_prefixes: prefixes_for(app_name, @to_env),
53
+ from_profile: from_profile,
54
+ to_profile: to_profile,
55
+ force: @options[:force],
56
+ remap_identity: @options[:remap_identity],
57
+ label: "#{@from_env} → #{@to_env}"
58
+ ).run
59
+
60
+ abort "\n✗ db:copy finished with errors" unless success
61
+
62
+ puts "\n✅ db:copy complete"
63
+ end
64
+
65
+ private
66
+
67
+ def infra_dir
68
+ 'infrastructure'
69
+ end
70
+
71
+ def prefixes_for(app_name, env_name)
72
+ raw = "#{app_name}-#{env_name}-"
73
+ sanitized = raw.tr('_', '-').downcase
74
+ [raw, sanitized].uniq
75
+ end
76
+
77
+ def parse_options(args)
78
+ OptionParser.new do |opts|
79
+ opts.banner = 'Usage: belt db:copy <from-env> <to-env> [options]'
80
+
81
+ opts.on('--force', 'Overwrite destination tables that already have data') do
82
+ @options[:force] = true
83
+ end
84
+
85
+ opts.on('--no-remap-identity',
86
+ 'Copy Cognito-sub foreign keys verbatim instead of re-anchoring ' \
87
+ "them to the destination environment's users by email") do
88
+ @options[:remap_identity] = false
89
+ end
90
+
91
+ opts.on('--from-profile PROFILE', 'AWS profile to read the source environment with') do |profile|
92
+ @options[:from_profile] = profile
93
+ end
94
+
95
+ opts.on('--to-profile PROFILE', 'AWS profile to write the destination environment with') do |profile|
96
+ @options[:to_profile] = profile
97
+ end
98
+
99
+ opts.on('-h', '--help', 'Show this help') do
100
+ puts opts
101
+ exit
102
+ end
103
+ end.parse!(args)
104
+
105
+ @from_env = args.shift
106
+ @to_env = args.shift
107
+ end
108
+
109
+ def usage
110
+ <<~USAGE
111
+ Usage: belt db:copy <from-env> <to-env> [options]
112
+
113
+ Copy DynamoDB table contents from one environment into another.
114
+ Matches tables by name suffix after stripping each environment's
115
+ `<app>-<env>-` prefix (e.g. myapp-prod-posts → myapp-dev-posts).
116
+
117
+ By default, destination tables that already contain data are
118
+ skipped (safe to re-run). Use --force to overwrite them.
119
+
120
+ Cognito identities are per-environment: each environment has its own
121
+ user pool, so the same person has a different `sub` in each one. By
122
+ default db:copy re-anchors Cognito-sub foreign keys (e.g. a
123
+ membership's cognito_sub) to the destination environment's user with
124
+ the same email, and leaves the destination's own `users` table
125
+ untouched. Without this, copied rows would point at subs that don't
126
+ exist in the destination pool and silently disappear (a copied
127
+ project you can't see, etc.). Pass --no-remap-identity to copy those
128
+ references verbatim.
129
+
130
+ AWS profiles are resolved from each environment's
131
+ infrastructure/<env>/belt.rb (config.aws_profile), or overridden
132
+ with --from-profile / --to-profile — useful when source and
133
+ destination live in different AWS accounts.
134
+
135
+ Options:
136
+ --force Overwrite destination tables with existing data
137
+ --no-remap-identity Copy Cognito-sub foreign keys verbatim
138
+ --from-profile PROFILE AWS profile for reading the source environment
139
+ --to-profile PROFILE AWS profile for writing the destination environment
140
+ -h, --help Show this help
141
+
142
+ Examples:
143
+ belt db:copy prod dev
144
+ belt db:copy prod dev --force
145
+ belt db:copy prod dev01 --from-profile prod-readonly --to-profile dev
146
+ USAGE
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'optparse'
6
+ require_relative 'app_detection'
7
+ require_relative 'environment_config'
8
+
9
+ module Belt
10
+ module CLI
11
+ # `belt db:seed` — Rails-style `rails db:seed` for Belt apps.
12
+ #
13
+ # Loads config/seeds.rb in the same booted app context `belt console`
14
+ # uses (models required, ActiveItem configured), targeting the resolved
15
+ # environment's DynamoDB tables (`<app>-<env>-*`).
16
+ #
17
+ # Refuses to run against an environment that already has data in any of
18
+ # its tables, to avoid silently clobbering a live environment — pass
19
+ # --force to seed anyway (seeds.rb itself is responsible for being
20
+ # idempotent if re-run).
21
+ class DbSeedCommand
22
+ include AppDetection
23
+
24
+ SEEDS_FILE = File.join('config', 'seeds.rb')
25
+
26
+ def self.run(args)
27
+ new(args).run
28
+ end
29
+
30
+ def initialize(args)
31
+ @options = { force: false }
32
+ parse_options(args)
33
+ end
34
+
35
+ def run
36
+ ENV['BUNDLE_GEMFILE'] ||= File.join(Belt.root, 'Gemfile')
37
+ unless File.exist?(ENV['BUNDLE_GEMFILE'])
38
+ abort "Error: No Gemfile found at #{ENV['BUNDLE_GEMFILE']}. Are you in a Belt project?"
39
+ end
40
+
41
+ unless File.exist?(SEEDS_FILE)
42
+ abort "Error: No #{SEEDS_FILE} found. Create one to define your seed data " \
43
+ '(see `belt explain seeds` for an example).'
44
+ end
45
+
46
+ @environment = @env_arg || ENV.fetch('BELT_ENV', nil) || 'dev'
47
+ ENV['ENVIRONMENT'] = @environment
48
+
49
+ apply_env_config!
50
+ production_guard!
51
+ guard_against_existing_data! unless @options[:force]
52
+
53
+ boot_app
54
+
55
+ puts "belt → seeding #{@environment} from #{SEEDS_FILE}"
56
+ load File.expand_path(SEEDS_FILE)
57
+ puts "✅ Seed complete (#{@environment})"
58
+ end
59
+
60
+ private
61
+
62
+ def parse_options(args)
63
+ OptionParser.new do |opts|
64
+ opts.banner = 'Usage: belt db:seed [environment] [options]'
65
+
66
+ opts.on('--force', "Seed even if the environment's tables already have data") do
67
+ @options[:force] = true
68
+ end
69
+
70
+ opts.on('-h', '--help', 'Show this help') do
71
+ puts opts
72
+ exit
73
+ end
74
+ end.parse!(args)
75
+
76
+ @env_arg = args.shift
77
+ end
78
+
79
+ def apply_env_config!
80
+ env_config = EnvironmentConfig.load(@environment)
81
+ env_config.apply!
82
+ puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
83
+ end
84
+
85
+ def production_guard!
86
+ return unless @environment == 'prod'
87
+
88
+ $stdout.write "\n⚠️ WARNING: You are about to seed the PRODUCTION environment!\nType 'yes' to continue: "
89
+ response = $stdin.gets&.chomp
90
+ abort "\n❌ Cancelled." unless response&.downcase == 'yes'
91
+ end
92
+
93
+ # Refuses to seed if any table matching this environment's prefix
94
+ # already contains data — avoids clobbering an environment someone
95
+ # already loaded with real (or prior seed) data.
96
+ def guard_against_existing_data!
97
+ app_name = detect_app_name
98
+ prefixes = prefixes_for(app_name, @environment)
99
+ tables = list_tables.select { |name| prefixes.any? { |prefix| name.start_with?(prefix) } }
100
+
101
+ non_empty = tables.select { |t| table_has_items?(t) }
102
+ return if non_empty.empty?
103
+
104
+ abort "Error: #{@environment} already has data in: #{non_empty.join(', ')}.\n" \
105
+ 'Refusing to seed a non-empty environment. Pass --force to seed anyway ' \
106
+ '(seeds.rb is responsible for being idempotent).'
107
+ end
108
+
109
+ def prefixes_for(app_name, env_name)
110
+ raw = "#{app_name}-#{env_name}-"
111
+ sanitized = raw.tr('_', '-').downcase
112
+ [raw, sanitized].uniq
113
+ end
114
+
115
+ def list_tables
116
+ names = []
117
+ start_name = nil
118
+ loop do
119
+ args = ['dynamodb', 'list-tables', '--output', 'json']
120
+ args += ['--exclusive-start-table-name', start_name] if start_name
121
+ data = aws_json(*args)
122
+ return names if data.nil?
123
+
124
+ names.concat(Array(data['TableNames']))
125
+ start_name = data['LastEvaluatedTableName']
126
+ break if start_name.nil? || start_name.empty?
127
+ end
128
+ names
129
+ end
130
+
131
+ def table_has_items?(table_name)
132
+ data = aws_json('dynamodb', 'scan', '--table-name', table_name,
133
+ '--select', 'COUNT', '--limit', '1', '--output', 'json')
134
+ return false if data.nil?
135
+
136
+ data.fetch('Count', 0).to_i.positive?
137
+ end
138
+
139
+ def aws_json(*)
140
+ output, status = Open3.capture2('aws', *)
141
+ return nil unless status.success?
142
+
143
+ JSON.parse(output)
144
+ rescue JSON::ParserError
145
+ nil
146
+ end
147
+
148
+ def boot_app
149
+ suppress_warnings { require 'bundler/setup' }
150
+
151
+ environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
152
+ if File.exist?(environment_file)
153
+ load environment_file
154
+ else
155
+ require 'belt'
156
+ load_dir('lib')
157
+ load_dir('models')
158
+ end
159
+ end
160
+
161
+ def load_dir(subdir)
162
+ dir = File.join(Belt.root, 'lambda', subdir)
163
+ Dir.glob(File.join(dir, '**', '*.rb')).each { |f| require f } if Dir.exist?(dir)
164
+ end
165
+
166
+ def suppress_warnings
167
+ original_verbose = $VERBOSE
168
+ $VERBOSE = nil
169
+ original_stderr = $stderr
170
+ $stderr = StringIO.new
171
+ yield
172
+ ensure
173
+ $stderr = original_stderr
174
+ $VERBOSE = original_verbose
175
+ end
176
+ end
177
+ end
178
+ end
@@ -778,7 +778,26 @@ module Belt
778
778
 
779
779
  puts "\n━━━ nested environment (parent: #{nested.parent}) ━━━"
780
780
  CognitoSharer.new(nested).run
781
- DynamoCopier.new(nested, app_name: detect_app_name_for_backup).run
781
+
782
+ app_name = detect_app_name_for_backup
783
+ parent_profile = EnvironmentConfig.load(nested.parent, infra_dir: @infra_dir).aws_profile
784
+ child_profile = EnvironmentConfig.load(nested.env, infra_dir: @infra_dir).aws_profile
785
+
786
+ DynamoCopier.new(
787
+ from_prefixes: prefixes_for(app_name, nested.parent),
788
+ to_prefixes: prefixes_for(app_name, nested.env),
789
+ from_profile: parent_profile,
790
+ to_profile: child_profile,
791
+ label: "#{nested.parent} → #{nested.env}"
792
+ ).run
793
+ end
794
+
795
+ # Both the raw and S3/DNS-safe (underscore→dash, lowercased) forms of the
796
+ # table-name prefix, since app names may contain underscores.
797
+ def prefixes_for(app_name, env_name)
798
+ raw = "#{app_name}-#{env_name}-"
799
+ sanitized = raw.tr('_', '-').downcase
800
+ [raw, sanitized].uniq
782
801
  end
783
802
 
784
803
  def deploy_frontend_if_exists
@@ -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
 
@@ -0,0 +1,129 @@
1
+ # Data Seeding
2
+
3
+ Belt provides two ways to get data into an environment without hand-crafting
4
+ rows: copying data from another environment, and Rails-style seed files.
5
+
6
+ ## `belt db:copy` — copy data between environments
7
+
8
+ Copies DynamoDB table contents from one environment into another, matching
9
+ tables by name after stripping each environment's `<app>-<env>-` prefix.
10
+
11
+ ```bash
12
+ belt db:copy prod dev # copy prod data into dev
13
+ belt db:copy prod dev --force # overwrite dev tables even if non-empty
14
+ ```
15
+
16
+ By default, destination tables that already contain data are skipped — safe
17
+ to re-run against a live environment. `--force` overwrites them instead.
18
+
19
+ ### Cognito identity re-anchoring
20
+
21
+ Cognito identities are **per-environment**: each environment has its own user
22
+ pool, so the same person has a *different* `sub` in every environment. Any row
23
+ that references a user by their `sub` (e.g. a membership's `cognito_sub`) has a
24
+ reference that's meaningless in another environment — copy it verbatim and the
25
+ row points at a user who doesn't exist in the destination pool, so it silently
26
+ disappears (a copied project you can't see, a member who isn't there).
27
+
28
+ `belt db:copy` handles this automatically:
29
+
30
+ - The destination's own `users` table is **left untouched** — the destination
31
+ pool is authoritative for who its users are and what `sub` each one has.
32
+ - For every other table, any row carrying both an `email` and a `cognito_sub`
33
+ has its `cognito_sub` **re-anchored** to the destination user with the same
34
+ email.
35
+ - A row whose email has no destination user yet has its stale `cognito_sub`
36
+ **cleared**, so it reads as unclaimed (e.g. a pending invitation Belt binds
37
+ on that person's first login) rather than dangling.
38
+
39
+ ```bash
40
+ belt db:copy prod dev # re-anchors identities (default)
41
+ belt db:copy prod dev --no-remap-identity # copy cognito_sub refs verbatim
42
+ ```
43
+
44
+ This relies on Belt's `cognito_authenticatable` convention: the users table is
45
+ `<app>-<env>-users`, its primary key is `id` (the Cognito `sub`), it carries an
46
+ `email`, and `cognito_sub` is the foreign-key attribute referencing it. The
47
+ same re-anchoring runs in the nested (PR-preview) environment deploy hook.
48
+
49
+ ### Cross-account copies
50
+
51
+ Source and destination environments often live in different AWS accounts
52
+ (e.g. prod vs. dev). `belt db:copy` resolves the AWS profile for each side
53
+ independently from `infrastructure/<env>/belt.rb` (`config.aws_profile`):
54
+
55
+ ```ruby
56
+ # infrastructure/prod/belt.rb
57
+ Belt.configure do |config|
58
+ config.aws_profile = "prod-readonly"
59
+ end
60
+ ```
61
+
62
+ Override either side explicitly if you don't want to rely on `belt.rb`:
63
+
64
+ ```bash
65
+ belt db:copy prod dev --from-profile prod-readonly --to-profile dev
66
+ ```
67
+
68
+ ### How it works
69
+
70
+ 1. Lists tables under each environment's prefix (`<app>-<env>-`) using the
71
+ AWS CLI (`aws dynamodb list-tables`)
72
+ 2. Pairs up tables by matching suffix (e.g. `myapp-prod-posts` ↔ `myapp-dev-posts`)
73
+ 3. Scans the source table and `batch-write-item`s into the destination,
74
+ re-anchoring Cognito-sub foreign keys to the destination's users by email
75
+ (unless `--no-remap-identity`; see above)
76
+ 4. Skips (or overwrites, with `--force`) destination tables that already
77
+ have items
78
+
79
+ This is the same mechanism used by nested (PR-preview) environment deploys
80
+ to seed a preview environment's tables from its parent — `belt db:copy` just
81
+ exposes it as a standalone command for any two environments.
82
+
83
+ ## `belt db:seed` — Rails-style seed file
84
+
85
+ Mirrors `rails db:seed`. Loads `config/seeds.rb` in the same booted context
86
+ `belt console` uses — your models (ActiveItem) are available, targeting the
87
+ resolved environment's tables.
88
+
89
+ ```bash
90
+ belt db:seed # seeds dev, or $BELT_ENV if set
91
+ belt db:seed dev01 # explicit environment
92
+ belt db:seed prod # prompts for confirmation, like belt console prod
93
+ ```
94
+
95
+ `config/seeds.rb` is a plain Ruby file:
96
+
97
+ ```ruby
98
+ # frozen_string_literal: true
99
+
100
+ post = Post.create!(title: "Hello, world", body: "Seeded post")
101
+ puts "Created post: #{post.id}"
102
+ ```
103
+
104
+ ### Safety
105
+
106
+ `belt db:seed` refuses to run if the target environment's tables already
107
+ have data, to avoid clobbering a live environment (or accidentally reseeding
108
+ one that's already loaded). Pass `--force` to seed anyway:
109
+
110
+ ```bash
111
+ belt db:seed dev01 --force
112
+ ```
113
+
114
+ Because of this guard, seeds are typically run once against a fresh
115
+ environment. If you want `seeds.rb` to be safely re-runnable regardless,
116
+ write it idempotently (`find_or_create_by`-style) — `belt db:seed --force`
117
+ does not enforce idempotency for you.
118
+
119
+ ### Scaffolding
120
+
121
+ `belt new` generates a starter `config/seeds.rb` with usage notes and an
122
+ example. Existing apps can add the file manually — it's just a plain Ruby
123
+ file, no generator required.
124
+
125
+ ## See Also
126
+
127
+ - `belt explain backups` — recovery-point snapshots, not data seeding
128
+ - `belt explain console` — the app-booting mechanism `db:seed` reuses
129
+ - `belt explain deployment` — nested environments and the parent → child copy hook
@@ -788,14 +788,25 @@ module Belt
788
788
  private
789
789
 
790
790
  def build_path(path)
791
- @scope_prefix.empty? ? path : "/#{@scope_prefix}#{path}"
791
+ path = path.to_s
792
+ sep = @scope_prefix.empty? || path.empty? || path.start_with?('/') ? '' : '/'
793
+ prefix = @scope_prefix.empty? ? '' : "/#{@scope_prefix}"
794
+ # Normalize Rails-style `:param` → API Gateway `{param}` so a gateway-level
795
+ # `scope path:` with a param (e.g. "accounts/:account_id") emits valid paths.
796
+ "#{prefix}#{sep}#{path}".gsub(/:([a-zA-Z_]\w*)/) { "{#{::Regexp.last_match(1)}}" }
797
+ end
798
+
799
+ # Strip param segments (`:param` / `{param}`) from a scope prefix before using it
800
+ # as a controller module: `scope path:` affects the URL, not the module.
801
+ def controller_module_from_prefix(prefix)
802
+ prefix.to_s.split('/').reject { |s| s.empty? || s.start_with?(':', '{') }.join('/')
792
803
  end
793
804
 
794
805
  def determine_scoped_controller(resource_name)
795
806
  if @scope_module && !@scope_module.empty?
796
807
  "#{@scope_module}/#{resource_name}"
797
- elsif !@scope_prefix.empty?
798
- "#{@scope_prefix}/#{resource_name}"
808
+ elsif !@scope_prefix.empty? && !(mod = controller_module_from_prefix(@scope_prefix)).empty?
809
+ "#{mod}/#{resource_name}"
799
810
  else
800
811
  resource_name
801
812
  end
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.4.3'
4
+ VERSION = '0.4.5'
5
5
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Seed data for <%= @app_name %>. Run with:
4
+ #
5
+ # belt db:seed # seeds dev (or BELT_ENV)
6
+ # belt db:seed prod # explicit environment (prompts for confirmation)
7
+ # BELT_ENV=dev01 belt db:seed
8
+ #
9
+ # This file is loaded in the same booted context as `belt console` — your
10
+ # models (ActiveItem) are available, and they target the resolved
11
+ # environment's DynamoDB tables (<%= @app_name %>-<env>-*).
12
+ #
13
+ # `belt db:seed` refuses to run against an environment that already has
14
+ # data, to avoid clobbering something real. Pass --force to seed anyway.
15
+ # Keep this file idempotent if you expect to re-run it (e.g. find_or_create
16
+ # rather than create).
17
+ #
18
+ # Example:
19
+ #
20
+ # post = Post.create!(
21
+ # title: "Hello, world",
22
+ # body: "This post was created by config/seeds.rb"
23
+ # )
24
+ # puts "Created post: #{post.id}"
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.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -119,6 +119,8 @@ files:
119
119
  - lib/belt/cli/cognito_sharer.rb
120
120
  - lib/belt/cli/console_command.rb
121
121
  - lib/belt/cli/contracts_command.rb
122
+ - lib/belt/cli/db_copy_command.rb
123
+ - lib/belt/cli/db_seed_command.rb
122
124
  - lib/belt/cli/deploy_command.rb
123
125
  - lib/belt/cli/destroy_command.rb
124
126
  - lib/belt/cli/dns_command.rb
@@ -160,6 +162,7 @@ files:
160
162
  - lib/belt/docs/backups.md
161
163
  - lib/belt/docs/console.md
162
164
  - lib/belt/docs/controllers.md
165
+ - lib/belt/docs/data_seeding.md
163
166
  - lib/belt/docs/deployment.md
164
167
  - lib/belt/docs/frontend.md
165
168
  - lib/belt/docs/generators.md
@@ -231,6 +234,7 @@ files:
231
234
  - lib/templates/new_app/config/contracts.rb.erb
232
235
  - lib/templates/new_app/config/lambda/api.yml.erb
233
236
  - lib/templates/new_app/config/routes.rb.erb
237
+ - lib/templates/new_app/config/seeds.rb.erb
234
238
  - lib/templates/new_app/gitignore.erb
235
239
  - lib/templates/new_app/lambda/api.rb.erb
236
240
  - lib/templates/new_app/lambda/config/environment.rb.erb