belt 0.4.4 → 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 +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +30 -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
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1436a1cb03096bd9de50abd65ecfbb973fc0f054c7e45902bdadd78516806a0c
|
|
4
|
+
data.tar.gz: 88ddfe6f1b1789a539bd71d407a736514ef091d2b126fc8c3296e5ee2f4e23ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,6 +34,42 @@
|
|
|
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
|
+
|
|
21
73
|
## 0.4.4
|
|
22
74
|
|
|
23
75
|
### Bug Fix
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
data/lib/belt/version.rb
CHANGED
|
@@ -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.
|
|
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
|