belt 0.6.2 → 0.6.4
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 +38 -0
- data/README.md +25 -0
- data/SKILL.md +1 -1
- data/lib/belt/cli/command_registry.rb +93 -0
- data/lib/belt/cli/db_migrate_command.rb +208 -0
- data/lib/belt/cli/deploy_command.rb +47 -4
- data/lib/belt/cli/explain_command.rb +5 -0
- data/lib/belt/cli/migration_store.rb +54 -0
- data/lib/belt/cli.rb +52 -4
- data/lib/belt/docs/data_seeding.md +1 -0
- data/lib/belt/docs/deployment.md +10 -0
- data/lib/belt/docs/migrations.md +104 -0
- data/lib/belt/docs/plugins.md +34 -0
- data/lib/belt/migration.rb +37 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +3 -1
- data/references/cli.md +3 -1
- data/references/deploy-and-ops.md +12 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fcb629702530cd06ffb9af298dcd259c4fc0503107ef19ab58a58da476617b4
|
|
4
|
+
data.tar.gz: 167a12259478e5079620446f76dad474cde1d456f08b328663fe11f82860fa66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb37dd0fe9e0cd392a0824d14b0ab4c51c5d002b36240fc7795ad11ce2313dd0014bea535c020fab6e4ec8988c7cf4d5a90992e2f8cfcdbcea35bc780586592f
|
|
7
|
+
data.tar.gz: d8d7423e5b3cd0c3d1598ff7b9e82446c9de4705b68313ade0ee0479ec26a87b5d4e3acd23a52b664ec2f50fd37e66cb046e1383f18bbf696cd01a13d24b140e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.4
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Plugins can register top-level commands.** A gem that ships
|
|
8
|
+
`lib/belt/commands/<name>_command.rb` defining `Belt::Commands::<Name>Command.run(args)`
|
|
9
|
+
gets `belt <name> ...` (underscores in the file name become hyphens), listed under
|
|
10
|
+
"Plugin Commands" in `belt --help`. Discovery mirrors `GeneratorRegistry`
|
|
11
|
+
(`Belt::CLI::CommandRegistry`). Optional `.description` for help text and `.standalone?`
|
|
12
|
+
to run outside a Belt project. Built-in commands and terraform actions win on a name
|
|
13
|
+
clash. `belt help` / `belt --help` / `belt -h` now print usage and exit 0.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **The data-migrations step of `belt deploy` no longer fails with "You have already
|
|
18
|
+
activated json X, but your Gemfile requires json Y".** The child `belt db:migrate` now
|
|
19
|
+
starts with the app's bundle loaded (`BUNDLE_GEMFILE` + `-rbundler/setup`), so the belt
|
|
20
|
+
CLI's own `require 'json'` can't activate a newer json than the app's lockfile pins.
|
|
21
|
+
- **A fresh environment's Lambdas missing their `config/lambda/*.yml` `env_vars`** (nested
|
|
22
|
+
envs especially, since they are always created fresh) is fixed in conveyor-belt 0.0.19:
|
|
23
|
+
the provider's create path read only the HCL `lambda_config` map and ignored
|
|
24
|
+
`lambda_config_dir`, so the YAML env vars, `iam_policy_arns` and triggers landed only on
|
|
25
|
+
a second deploy. Existing apps on `version = "~> 0.0.16"` pick it up with
|
|
26
|
+
`terraform init -upgrade` in the env's infrastructure directory.
|
|
27
|
+
|
|
28
|
+
## 0.6.3
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Data migrations.** `db/migrate/<timestamp>_<name>.rb` files define `Belt::Migration`
|
|
33
|
+
subclasses whose `up` runs in the booted app context (same as `belt console`).
|
|
34
|
+
`belt deploy <env>` runs pending ones after terraform apply and the nested-env data copy
|
|
35
|
+
(also after `--rebuild`; opt out with `--skip-migrations`). Each version runs once per
|
|
36
|
+
environment, nested envs included, recorded in SSM at
|
|
37
|
+
`/belt/<app>/<env>/schema_migrations/<version>`. A failed migration stays pending, the
|
|
38
|
+
deploy carries on and exits non-zero. New `belt db:migrate [env]` and
|
|
39
|
+
`belt db:migrate:status [env]` run or check them by hand. See `belt explain migrations`.
|
|
40
|
+
|
|
3
41
|
## 0.6.2
|
|
4
42
|
|
|
5
43
|
### Fixed
|
data/README.md
CHANGED
|
@@ -731,6 +731,29 @@ puts "Created post: #{post.id}"
|
|
|
731
731
|
|
|
732
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
733
|
|
|
734
|
+
### `belt db:migrate` — data migrations
|
|
735
|
+
|
|
736
|
+
For changes to rows that already exist (backfilling an attribute, populating a new GSI for old records), add a migration to `db/migrate/`:
|
|
737
|
+
|
|
738
|
+
```ruby
|
|
739
|
+
# db/migrate/20261030120000_index_usernames.rb
|
|
740
|
+
class IndexUsernames < Belt::Migration
|
|
741
|
+
def up
|
|
742
|
+
PoshNosh::MatchStore.new.index_usernames!
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
`belt deploy <env>` runs pending migrations after terraform apply, once per environment (nested envs included). Applied versions are recorded in SSM under `/belt/<app>/<env>/schema_migrations/`.
|
|
748
|
+
|
|
749
|
+
```bash
|
|
750
|
+
belt db:migrate dev01 # run pending migrations by hand
|
|
751
|
+
belt db:migrate:status prod # which have run
|
|
752
|
+
belt deploy dev --skip-migrations
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
Migrations must be idempotent and batch-safe. See `belt explain migrations`.
|
|
756
|
+
|
|
734
757
|
## Testing
|
|
735
758
|
|
|
736
759
|
Belt ships an in-process **end-to-end harness** that drives the real router — no AWS, no HTTP server, no browser. It's the tier between controller unit tests (dispatch an action directly, never touch routing) and full cloud e2e (needs a deployed stack).
|
|
@@ -807,6 +830,8 @@ No central registry file, no initializer hook. Belt discovers generators by scan
|
|
|
807
830
|
|
|
808
831
|
That is the whole contract. After `bundle install`, `belt generate <name>` and `belt destroy <name>` just work.
|
|
809
832
|
|
|
833
|
+
Plugins can also add a top-level command: a file at `lib/belt/commands/<name>_command.rb` defining `Belt::Commands::<Name>Command.run(args)` becomes `belt <name> ...` and shows up under "Plugin Commands" in `belt --help`. Optional `.description` and `.standalone?` (run outside a project). Built-in commands win on a name clash. See `Belt::CLI::CommandRegistry` and `belt explain plugins`.
|
|
834
|
+
|
|
810
835
|
See `Belt::CLI::GeneratorRegistry` and the CHANGELOG entry for **0.1.13 Generator Extension API**.
|
|
811
836
|
|
|
812
837
|
### Plugin layout (canonical)
|
data/SKILL.md
CHANGED
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
Belt routing DSL, writing BeltController actions, modeling DynamoDB data
|
|
9
9
|
with ActiveItem, wiring Cognito auth, configuring the Lambda handler,
|
|
10
10
|
running `belt` CLI commands (new, generate, deploy, console, routes, logs,
|
|
11
|
-
server, setup, plugin, explain, db:copy/db:seed), Terraform via belt,
|
|
11
|
+
server, setup, plugin, explain, db:copy/db:seed/db:migrate), Terraform via belt,
|
|
12
12
|
backups, observability, or authoring Belt plugins. Triggers on "belt new",
|
|
13
13
|
"belt deploy", "belt generate", "belt routes", "BeltController", "ActiveItem",
|
|
14
14
|
"Belt::LambdaHandler", "belt console", "belt server", "conveyor-belt". Do NOT
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module CLI
|
|
5
|
+
# Discovers top-level CLI commands provided by external gems.
|
|
6
|
+
#
|
|
7
|
+
# Gems register commands by placing a file at:
|
|
8
|
+
# lib/belt/commands/<name>_command.rb
|
|
9
|
+
#
|
|
10
|
+
# Each command class must:
|
|
11
|
+
# - Be named <Name>Command (e.g., ConnectCommand)
|
|
12
|
+
# - Live in the Belt::Commands module
|
|
13
|
+
# - Implement .run(args)
|
|
14
|
+
# - Implement .description for help text (optional)
|
|
15
|
+
# - Implement .standalone? returning true to run outside a Belt project (optional)
|
|
16
|
+
#
|
|
17
|
+
# Example gem structure:
|
|
18
|
+
# belt-connect/
|
|
19
|
+
# lib/
|
|
20
|
+
# belt/
|
|
21
|
+
# commands/
|
|
22
|
+
# connect_command.rb
|
|
23
|
+
#
|
|
24
|
+
# The command is then available as:
|
|
25
|
+
# belt connect ...
|
|
26
|
+
#
|
|
27
|
+
# Underscores in the file name become hyphens in the command name
|
|
28
|
+
# (lib/belt/commands/feature_flags_command.rb → `belt feature-flags`).
|
|
29
|
+
# Built-in commands win on a name clash; see Belt::CLI.plugin_command.
|
|
30
|
+
module CommandRegistry
|
|
31
|
+
COMMAND_PATH = 'lib/belt/commands'
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Returns a hash of { "name" => command_class } for all discovered commands.
|
|
35
|
+
def discovered_commands
|
|
36
|
+
@discovered_commands ||= discover_all
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns just the names of discovered commands.
|
|
40
|
+
def command_names
|
|
41
|
+
discovered_commands.keys
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the command class for a given name, or nil if not found.
|
|
45
|
+
def find(name)
|
|
46
|
+
discovered_commands[name]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reset cache (useful in tests)
|
|
50
|
+
def reset!
|
|
51
|
+
@discovered_commands = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def discover_all
|
|
57
|
+
commands = {}
|
|
58
|
+
|
|
59
|
+
Gem.loaded_specs.each_value do |spec|
|
|
60
|
+
commands_dir = File.join(spec.gem_dir, COMMAND_PATH)
|
|
61
|
+
next unless File.directory?(commands_dir)
|
|
62
|
+
|
|
63
|
+
Dir.glob(File.join(commands_dir, '*_command.rb')).each do |file|
|
|
64
|
+
basename = File.basename(file, '.rb').sub(/_command\z/, '')
|
|
65
|
+
klass = load_command_class(file, basename)
|
|
66
|
+
commands[basename.tr('_', '-')] = klass if klass
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
commands
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_command_class(file, basename)
|
|
74
|
+
require file
|
|
75
|
+
|
|
76
|
+
class_name = "#{classify(basename)}Command"
|
|
77
|
+
Belt::Commands.const_get(class_name, false)
|
|
78
|
+
rescue LoadError, NameError => e
|
|
79
|
+
warn "[Belt] Warning: Failed to load command '#{basename}' from #{file}: #{e.message}"
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def classify(name)
|
|
84
|
+
name.split('_').map(&:capitalize).join
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Namespace for top-level command classes provided by external gems
|
|
91
|
+
module Commands
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require_relative 'app_detection'
|
|
6
|
+
require_relative 'environment_config'
|
|
7
|
+
require_relative 'migration_store'
|
|
8
|
+
require_relative '../inflector'
|
|
9
|
+
require_relative '../migration'
|
|
10
|
+
|
|
11
|
+
module Belt
|
|
12
|
+
module CLI
|
|
13
|
+
# `belt db:migrate` / `belt db:migrate:status` — data migrations.
|
|
14
|
+
#
|
|
15
|
+
# Each `db/migrate/<timestamp>_<name>.rb` defines a `Belt::Migration`
|
|
16
|
+
# subclass named after the file (`20261030120000_index_usernames.rb` →
|
|
17
|
+
# `IndexUsernames`). Pending ones run oldest-first in the same booted app
|
|
18
|
+
# context `belt console` uses, and each is recorded in SSM (see
|
|
19
|
+
# MigrationStore) only after its `up` returns, so a failure is retried on
|
|
20
|
+
# the next run. `belt deploy` runs this after terraform apply.
|
|
21
|
+
class DbMigrateCommand
|
|
22
|
+
include AppDetection
|
|
23
|
+
|
|
24
|
+
MIGRATIONS_DIR = File.join('db', 'migrate')
|
|
25
|
+
FILENAME_PATTERN = /\A(\d+)_([a-z0-9_]+)\.rb\z/
|
|
26
|
+
|
|
27
|
+
MigrationFile = Struct.new(:version, :name, :path) do
|
|
28
|
+
def class_name
|
|
29
|
+
Belt::Inflector.camelize(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def label
|
|
33
|
+
name.tr('_', ' ').capitalize
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.run(args, status: false)
|
|
38
|
+
new(args, status: status).run
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.migrations?(root = Dir.pwd)
|
|
42
|
+
Dir.glob(File.join(root, MIGRATIONS_DIR, '*.rb')).any?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(args, status: false)
|
|
46
|
+
@options = { status: status, yes: false }
|
|
47
|
+
parse_options(args)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
ENV['BUNDLE_GEMFILE'] ||= File.join(Belt.root, 'Gemfile')
|
|
52
|
+
unless File.exist?(ENV['BUNDLE_GEMFILE'])
|
|
53
|
+
abort "Error: No Gemfile found at #{ENV['BUNDLE_GEMFILE']}. Are you in a Belt project?"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@environment = @env_arg || ENV.fetch('BELT_ENV', nil) || 'dev'
|
|
57
|
+
ENV['ENVIRONMENT'] = @environment
|
|
58
|
+
apply_env_config!
|
|
59
|
+
|
|
60
|
+
@store = MigrationStore.new(app_name: detect_app_name, env: @environment)
|
|
61
|
+
@options[:status] ? print_status : migrate
|
|
62
|
+
rescue MigrationStore::Error => e
|
|
63
|
+
abort "Error: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def parse_options(args)
|
|
69
|
+
OptionParser.new do |opts|
|
|
70
|
+
opts.banner = if @options[:status]
|
|
71
|
+
'Usage: belt db:migrate:status [environment]'
|
|
72
|
+
else
|
|
73
|
+
'Usage: belt db:migrate [environment] [options]'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
unless @options[:status]
|
|
77
|
+
opts.on('-y', '--yes', 'Skip the production confirmation prompt') { @options[:yes] = true }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
81
|
+
puts opts
|
|
82
|
+
exit
|
|
83
|
+
end
|
|
84
|
+
end.parse!(args)
|
|
85
|
+
|
|
86
|
+
@env_arg = args.shift
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def apply_env_config!
|
|
90
|
+
env_config = EnvironmentConfig.load(@environment)
|
|
91
|
+
env_config.apply!
|
|
92
|
+
puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def migrate
|
|
96
|
+
pending = pending_migrations
|
|
97
|
+
if pending.empty?
|
|
98
|
+
puts "belt → no pending migrations for #{@environment}"
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
production_guard!
|
|
103
|
+
boot_app
|
|
104
|
+
|
|
105
|
+
puts "belt → running #{pending.size} migration(s) against #{@environment}"
|
|
106
|
+
pending.each { |file| run_migration(file) }
|
|
107
|
+
puts "✅ Migrations complete (#{@environment})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_migration(file)
|
|
111
|
+
puts " == #{file.version} #{file.label}"
|
|
112
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
instantiate(file).up
|
|
114
|
+
@store.record!(file.version, file.name)
|
|
115
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
116
|
+
puts format(' == %<version>s migrated (%<elapsed>.1fs)', version: file.version, elapsed: elapsed)
|
|
117
|
+
rescue MigrationStore::Error
|
|
118
|
+
raise
|
|
119
|
+
rescue StandardError, ScriptError => e
|
|
120
|
+
abort "\n❌ Migration #{file.version} (#{file.name}) failed: #{e.class}: #{e.message}\n" \
|
|
121
|
+
"#{Array(e.backtrace).first(5).join("\n")}\n\n" \
|
|
122
|
+
'Later migrations were not run. Fix it and re-run ' \
|
|
123
|
+
"`belt db:migrate #{@environment}` (migrations must be safe to re-run)."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def instantiate(file)
|
|
127
|
+
load File.expand_path(file.path)
|
|
128
|
+
klass = Object.const_get(file.class_name) if Object.const_defined?(file.class_name)
|
|
129
|
+
unless klass.is_a?(Class) && klass < Belt::Migration
|
|
130
|
+
raise NameError, "#{file.path} must define `class #{file.class_name} < Belt::Migration`"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
klass.new(version: file.version, name: file.name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def print_status
|
|
137
|
+
applied = @store.applied_versions
|
|
138
|
+
files = migration_files
|
|
139
|
+
|
|
140
|
+
puts "\nenvironment: #{@environment}\n\n"
|
|
141
|
+
puts ' Status Migration ID Migration Name'
|
|
142
|
+
puts '-' * 50
|
|
143
|
+
files.each do |file|
|
|
144
|
+
puts status_line(applied.include?(file.version) ? 'up' : 'down', file.version, file.label)
|
|
145
|
+
end
|
|
146
|
+
(applied - files.map(&:version)).sort.each do |version|
|
|
147
|
+
puts status_line('up', version, '** NO FILE **')
|
|
148
|
+
end
|
|
149
|
+
puts ' (no migrations in db/migrate)' if files.empty? && applied.empty?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def status_line(state, version, label)
|
|
153
|
+
format(' %<state>-6s %<version>-14s %<label>s', state: state, version: version, label: label)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def pending_migrations
|
|
157
|
+
applied = @store.applied_versions
|
|
158
|
+
migration_files.reject { |file| applied.include?(file.version) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def migration_files
|
|
162
|
+
Dir.glob(File.join(MIGRATIONS_DIR, '*.rb')).map do |path|
|
|
163
|
+
match = File.basename(path).match(FILENAME_PATTERN)
|
|
164
|
+
abort "Error: #{path} must be named <timestamp>_<snake_case_name>.rb" unless match
|
|
165
|
+
|
|
166
|
+
MigrationFile.new(match[1], match[2], path)
|
|
167
|
+
end.sort_by(&:version)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def production_guard!
|
|
171
|
+
return unless @environment == 'prod' && !@options[:yes]
|
|
172
|
+
|
|
173
|
+
$stdout.write "\n⚠️ WARNING: You are about to migrate PRODUCTION data!\nType 'yes' to continue: "
|
|
174
|
+
response = $stdin.gets&.chomp
|
|
175
|
+
abort "\n❌ Cancelled." unless response&.downcase == 'yes'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def boot_app
|
|
179
|
+
suppress_warnings { require 'bundler/setup' }
|
|
180
|
+
|
|
181
|
+
environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
|
|
182
|
+
if File.exist?(environment_file)
|
|
183
|
+
load environment_file
|
|
184
|
+
else
|
|
185
|
+
require 'belt'
|
|
186
|
+
load_dir('lib')
|
|
187
|
+
load_dir('models')
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def load_dir(subdir)
|
|
192
|
+
dir = File.join(Belt.root, 'lambda', subdir)
|
|
193
|
+
Dir.glob(File.join(dir, '**', '*.rb')).each { |f| require f } if Dir.exist?(dir)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def suppress_warnings
|
|
197
|
+
original_verbose = $VERBOSE
|
|
198
|
+
$VERBOSE = nil
|
|
199
|
+
original_stderr = $stderr
|
|
200
|
+
$stderr = StringIO.new
|
|
201
|
+
yield
|
|
202
|
+
ensure
|
|
203
|
+
$stderr = original_stderr
|
|
204
|
+
$VERBOSE = original_verbose
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -16,11 +16,13 @@ require_relative 'cognito_sharer'
|
|
|
16
16
|
require_relative 'dynamo_copier'
|
|
17
17
|
require_relative 'dns_command'
|
|
18
18
|
require_relative 'apex_dns_sync'
|
|
19
|
+
require_relative 'db_migrate_command'
|
|
19
20
|
|
|
20
21
|
module Belt
|
|
21
22
|
module CLI
|
|
22
23
|
class DeployCommand
|
|
23
24
|
DEFAULT_DOCKER_BUILD_IMAGE = 'public.ecr.aws/sam/build-ruby3.4:latest-x86_64'
|
|
25
|
+
BELT_EXE = File.expand_path('../../../exe/belt', __dir__)
|
|
24
26
|
def self.run(args)
|
|
25
27
|
# Handle `belt deploy frontend <env>` as before
|
|
26
28
|
if args.first == 'frontend'
|
|
@@ -33,6 +35,7 @@ module Belt
|
|
|
33
35
|
rebuild = false
|
|
34
36
|
skip_backup = false
|
|
35
37
|
backup_only = false
|
|
38
|
+
skip_migrations = false
|
|
36
39
|
filtered_args = []
|
|
37
40
|
|
|
38
41
|
args.each do |arg|
|
|
@@ -45,6 +48,8 @@ module Belt
|
|
|
45
48
|
skip_backup = true
|
|
46
49
|
when '--backup-only'
|
|
47
50
|
backup_only = true
|
|
51
|
+
when '--skip-migrations'
|
|
52
|
+
skip_migrations = true
|
|
48
53
|
when '-h', '--help'
|
|
49
54
|
puts help_text
|
|
50
55
|
exit 0
|
|
@@ -79,12 +84,13 @@ module Belt
|
|
|
79
84
|
end
|
|
80
85
|
end
|
|
81
86
|
|
|
87
|
+
options = { auto_approve: auto_approve, skip_migrations: skip_migrations, extra_args: filtered_args }
|
|
82
88
|
if backup_only
|
|
83
|
-
new(env,
|
|
89
|
+
new(env, **options, skip_backup: false).run_backup_only
|
|
84
90
|
elsif rebuild
|
|
85
|
-
new(env,
|
|
91
|
+
new(env, **options, skip_backup: skip_backup).run_rebuild
|
|
86
92
|
else
|
|
87
|
-
new(env,
|
|
93
|
+
new(env, **options, skip_backup: skip_backup).run
|
|
88
94
|
end
|
|
89
95
|
end
|
|
90
96
|
|
|
@@ -105,6 +111,7 @@ module Belt
|
|
|
105
111
|
7. Prompt for confirmation (unless --auto)
|
|
106
112
|
8. terraform apply (deploy changes)
|
|
107
113
|
9. Nested envs: share parent Cognito + copy DynamoDB if empty
|
|
114
|
+
10. Run pending data migrations in db/migrate (see `belt db:migrate`)
|
|
108
115
|
|
|
109
116
|
Options:
|
|
110
117
|
--auto, --yes, -y Skip confirmation prompt (auto-approve)
|
|
@@ -114,6 +121,7 @@ module Belt
|
|
|
114
121
|
--skip-backup Skip pre-deploy backup phase
|
|
115
122
|
--no-backup Alias for --skip-backup
|
|
116
123
|
--backup-only Run backups only, do not deploy
|
|
124
|
+
--skip-migrations Don't run pending db/migrate data migrations
|
|
117
125
|
-h, --help Show this help
|
|
118
126
|
|
|
119
127
|
Environment:
|
|
@@ -144,10 +152,11 @@ module Belt
|
|
|
144
152
|
HELP
|
|
145
153
|
end
|
|
146
154
|
|
|
147
|
-
def initialize(env, auto_approve: false, skip_backup: false, extra_args: [])
|
|
155
|
+
def initialize(env, auto_approve: false, skip_backup: false, skip_migrations: false, extra_args: [])
|
|
148
156
|
@env = env
|
|
149
157
|
@auto_approve = auto_approve
|
|
150
158
|
@skip_backup = skip_backup
|
|
159
|
+
@skip_migrations = skip_migrations
|
|
151
160
|
@extra_args = extra_args
|
|
152
161
|
@infra_dir = TerraformCommand.find_infrastructure_dir
|
|
153
162
|
@project_root = find_project_root
|
|
@@ -188,9 +197,12 @@ module Belt
|
|
|
188
197
|
|
|
189
198
|
run_nested_env_hooks
|
|
190
199
|
|
|
200
|
+
migrations_ok = run_migrations
|
|
201
|
+
|
|
191
202
|
deploy_frontend_if_exists
|
|
192
203
|
|
|
193
204
|
puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
|
|
205
|
+
exit 1 unless migrations_ok
|
|
194
206
|
end
|
|
195
207
|
|
|
196
208
|
def run_backup_only
|
|
@@ -232,6 +244,7 @@ module Belt
|
|
|
232
244
|
ensure_lockfile_consistent!
|
|
233
245
|
generate_routes(lambda_dir)
|
|
234
246
|
build_and_deploy(lambda_dir, function_name)
|
|
247
|
+
exit 1 unless run_migrations
|
|
235
248
|
end
|
|
236
249
|
|
|
237
250
|
private
|
|
@@ -901,6 +914,36 @@ module Belt
|
|
|
901
914
|
[raw, sanitized].uniq
|
|
902
915
|
end
|
|
903
916
|
|
|
917
|
+
# Runs pending db/migrate data migrations in a child `belt db:migrate`
|
|
918
|
+
# so the app's code is booted in its own process, not the deployer's.
|
|
919
|
+
# Runs after the nested-env DynamoDB copy so copied rows get migrated
|
|
920
|
+
# too. Returns false on failure: the infra is already live, so the rest
|
|
921
|
+
# of the deploy continues and the exit status reports the failure.
|
|
922
|
+
def run_migrations # rubocop:disable Naming/PredicateMethod
|
|
923
|
+
return true if @skip_migrations || !DbMigrateCommand.migrations?(@project_root)
|
|
924
|
+
|
|
925
|
+
puts "\n━━━ data migrations ━━━"
|
|
926
|
+
return true if system(migration_child_env, Gem.ruby, BELT_EXE, 'db:migrate', @env, '--yes',
|
|
927
|
+
chdir: @project_root)
|
|
928
|
+
|
|
929
|
+
puts "\n ⚠ Data migrations failed for #{@env}. The deploy itself succeeded."
|
|
930
|
+
puts " Fix the migration and run `belt db:migrate #{@env}` to retry."
|
|
931
|
+
false
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
# Load the app's bundle before the belt CLI does. Otherwise the CLI's own
|
|
935
|
+
# `require 'json'` activates the newest installed json, and db:migrate's
|
|
936
|
+
# `bundler/setup` then fails with "You have already activated json X,
|
|
937
|
+
# but your Gemfile requires json Y" (Fizzy #1734).
|
|
938
|
+
def migration_child_env
|
|
939
|
+
gemfile = File.join(@project_root, 'Gemfile')
|
|
940
|
+
return {} unless File.exist?(gemfile)
|
|
941
|
+
|
|
942
|
+
rubyopt = ENV.fetch('RUBYOPT', '')
|
|
943
|
+
rubyopt = "-rbundler/setup #{rubyopt}".strip unless rubyopt.include?('bundler/setup')
|
|
944
|
+
{ 'BUNDLE_GEMFILE' => gemfile, 'RUBYOPT' => rubyopt }
|
|
945
|
+
end
|
|
946
|
+
|
|
904
947
|
def deploy_frontend_if_exists
|
|
905
948
|
require_relative 'frontend_registry'
|
|
906
949
|
require_relative 'frontend_deploy_command'
|
|
@@ -52,6 +52,10 @@ module Belt
|
|
|
52
52
|
'db:copy' => 'data_seeding',
|
|
53
53
|
'copy' => 'data_seeding',
|
|
54
54
|
'db_copy' => 'data_seeding',
|
|
55
|
+
'migration' => 'migrations',
|
|
56
|
+
'migrate' => 'migrations',
|
|
57
|
+
'db:migrate' => 'migrations',
|
|
58
|
+
'data_migrations' => 'migrations',
|
|
55
59
|
'e2e' => 'testing',
|
|
56
60
|
'test' => 'testing',
|
|
57
61
|
'tests' => 'testing',
|
|
@@ -120,6 +124,7 @@ module Belt
|
|
|
120
124
|
irb, repl → console
|
|
121
125
|
seeds, seed, db:seed → data_seeding
|
|
122
126
|
db:copy, copy → data_seeding
|
|
127
|
+
migrate, db:migrate → migrations
|
|
123
128
|
e2e, test, harness → testing
|
|
124
129
|
|
|
125
130
|
Examples:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# Records which data migrations have run against an environment, as one
|
|
10
|
+
# SSM parameter per version:
|
|
11
|
+
#
|
|
12
|
+
# /belt/<app>/<env>/schema_migrations/<version>
|
|
13
|
+
#
|
|
14
|
+
# SSM rather than a DynamoDB table so the record isn't part of the
|
|
15
|
+
# `<app>-<env>-*` tables that `belt db:copy` and the nested-env copy move
|
|
16
|
+
# between environments — each stack keeps its own history.
|
|
17
|
+
class MigrationStore
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
def initialize(app_name:, env:)
|
|
21
|
+
@app_name = app_name
|
|
22
|
+
@env = env
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def path
|
|
26
|
+
"/belt/#{@app_name}/#{@env}/schema_migrations"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Versions already applied to this environment, as strings.
|
|
30
|
+
def applied_versions
|
|
31
|
+
data = aws_json('ssm', 'get-parameters-by-path', '--path', path, '--output', 'json')
|
|
32
|
+
Array(data['Parameters']).map { |param| File.basename(param['Name']) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record!(version, name)
|
|
36
|
+
value = JSON.generate('name' => name, 'applied_at' => Time.now.utc.iso8601)
|
|
37
|
+
aws_json('ssm', 'put-parameter', '--name', "#{path}/#{version}", '--type', 'String',
|
|
38
|
+
'--value', value, '--overwrite', '--output', 'json')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def aws_json(*args)
|
|
44
|
+
command = "aws #{args.first(2).join(' ')}"
|
|
45
|
+
output, error, status = Open3.capture3('aws', *args)
|
|
46
|
+
raise Error, "#{command} failed: #{error.strip}" unless status.success?
|
|
47
|
+
|
|
48
|
+
output.strip.empty? ? {} : JSON.parse(output)
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
raise Error, "#{command} returned invalid JSON: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/belt/cli.rb
CHANGED
|
@@ -26,10 +26,12 @@ require_relative 'cli/tasks_command'
|
|
|
26
26
|
require_relative 'cli/console_command'
|
|
27
27
|
require_relative 'cli/db_copy_command'
|
|
28
28
|
require_relative 'cli/db_seed_command'
|
|
29
|
+
require_relative 'cli/db_migrate_command'
|
|
29
30
|
require_relative 'cli/logs_command'
|
|
30
31
|
require_relative 'cli/doctor_command'
|
|
31
32
|
require_relative 'cli/plugin_command'
|
|
32
33
|
require_relative 'cli/explain_command'
|
|
34
|
+
require_relative 'cli/command_registry'
|
|
33
35
|
|
|
34
36
|
module Belt
|
|
35
37
|
module CLI
|
|
@@ -43,6 +45,8 @@ module Belt
|
|
|
43
45
|
%w[console c] => Belt::CLI::ConsoleCommand,
|
|
44
46
|
'db:copy' => Belt::CLI::DbCopyCommand,
|
|
45
47
|
'db:seed' => Belt::CLI::DbSeedCommand,
|
|
48
|
+
'db:migrate' => Belt::CLI::DbMigrateCommand,
|
|
49
|
+
'db:migrate:status' => ->(args) { Belt::CLI::DbMigrateCommand.run(args, status: true) },
|
|
46
50
|
'logs' => Belt::CLI::LogsCommand,
|
|
47
51
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
48
52
|
'setup' => Belt::CLI::SetupCommand,
|
|
@@ -53,7 +57,8 @@ module Belt
|
|
|
53
57
|
'dns' => Belt::CLI::DnsCommand,
|
|
54
58
|
'frontend' => Belt::CLI::FrontendEnvCommand,
|
|
55
59
|
%w[server s] => Belt::CLI::ServerCommand,
|
|
56
|
-
%w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
|
|
60
|
+
%w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" },
|
|
61
|
+
%w[help --help -h] => ->(_args) { puts usage }
|
|
57
62
|
}.freeze
|
|
58
63
|
|
|
59
64
|
COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
|
|
@@ -63,7 +68,7 @@ module Belt
|
|
|
63
68
|
TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
|
|
64
69
|
|
|
65
70
|
# Commands that can run without being inside a Belt project
|
|
66
|
-
STANDALONE_COMMANDS = %w[new version --version -v doctor explain].freeze
|
|
71
|
+
STANDALONE_COMMANDS = %w[new version --version -v help --help -h doctor explain].freeze
|
|
67
72
|
|
|
68
73
|
def self.start(args)
|
|
69
74
|
command = args.shift
|
|
@@ -73,8 +78,10 @@ module Belt
|
|
|
73
78
|
exit 1
|
|
74
79
|
end
|
|
75
80
|
|
|
81
|
+
plugin = plugin_command(command)
|
|
82
|
+
|
|
76
83
|
# For project-level commands, find and chdir to the project root
|
|
77
|
-
ensure_project_root!(command) unless
|
|
84
|
+
ensure_project_root!(command) unless standalone?(command, plugin)
|
|
78
85
|
|
|
79
86
|
# `belt destroy` is ambiguous: could be terraform destroy or belt destroy <generator>.
|
|
80
87
|
return if command == 'destroy' && route_destroy_command(args)
|
|
@@ -82,7 +89,7 @@ module Belt
|
|
|
82
89
|
# Terraform shorthand: belt init wups, belt plan wups, belt apply wups, belt destroy wups
|
|
83
90
|
return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
|
|
84
91
|
|
|
85
|
-
handler = COMMANDS[command]
|
|
92
|
+
handler = COMMANDS[command] || plugin
|
|
86
93
|
|
|
87
94
|
# If no built-in command matched, try running it as a rake task
|
|
88
95
|
if handler.nil?
|
|
@@ -99,7 +106,43 @@ module Belt
|
|
|
99
106
|
end
|
|
100
107
|
end
|
|
101
108
|
|
|
109
|
+
# Returns the gem-provided command class for `name`, or nil. Built-in
|
|
110
|
+
# commands and terraform actions win on a name clash.
|
|
111
|
+
def self.plugin_command(name)
|
|
112
|
+
return nil if name.nil? || builtin_command?(name)
|
|
113
|
+
|
|
114
|
+
CommandRegistry.find(name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.builtin_command?(name)
|
|
118
|
+
COMMANDS.key?(name) || TERRAFORM_ACTIONS.include?(name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.standalone?(command, plugin)
|
|
122
|
+
return STANDALONE_COMMANDS.include?(command) unless plugin
|
|
123
|
+
|
|
124
|
+
plugin.respond_to?(:standalone?) && plugin.standalone?
|
|
125
|
+
end
|
|
126
|
+
|
|
102
127
|
def self.usage
|
|
128
|
+
base_usage.sub("\nRake Tasks:\n", "#{plugin_usage}\nRake Tasks:\n")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# "Plugin Commands:" help section for gem-provided commands ("" when none).
|
|
132
|
+
def self.plugin_usage
|
|
133
|
+
names = CommandRegistry.command_names.reject { |name| builtin_command?(name) }.sort
|
|
134
|
+
return '' if names.empty?
|
|
135
|
+
|
|
136
|
+
lines = names.map do |name|
|
|
137
|
+
klass = CommandRegistry.find(name)
|
|
138
|
+
description = klass.respond_to?(:description) ? klass.description.to_s : ''
|
|
139
|
+
" #{name.ljust(42)} #{description}".rstrip
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
"\nPlugin Commands:\n#{lines.join("\n")}\n"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.base_usage
|
|
103
146
|
<<~USAGE
|
|
104
147
|
Usage: belt <command> [options]
|
|
105
148
|
|
|
@@ -131,6 +174,8 @@ module Belt
|
|
|
131
174
|
c Alias for console
|
|
132
175
|
db:copy <from-env> <to-env> [--force] Copy DynamoDB data between environments
|
|
133
176
|
db:seed [environment] [--force] Run config/seeds.rb against an environment
|
|
177
|
+
db:migrate [environment] [--yes] Run pending db/migrate data migrations
|
|
178
|
+
db:migrate:status [environment] Show which data migrations have run
|
|
134
179
|
logs [lambda] [-f] [-s 5m] [-e env] View Lambda function logs
|
|
135
180
|
tasks [-g PATTERN] [-a] List available rake tasks
|
|
136
181
|
-T [-g PATTERN] [-a] Alias for tasks
|
|
@@ -146,6 +191,7 @@ module Belt
|
|
|
146
191
|
destroy [environment] <env> terraform destroy for environment
|
|
147
192
|
output [environment] <env> terraform output for environment
|
|
148
193
|
--version Show Belt version
|
|
194
|
+
help, --help, -h Show this help
|
|
149
195
|
|
|
150
196
|
Rake Tasks:
|
|
151
197
|
Any rake task from your Gemfile dependencies can be run directly:
|
|
@@ -180,6 +226,8 @@ module Belt
|
|
|
180
226
|
belt db:copy prod dev --force # overwrite dev tables even if non-empty
|
|
181
227
|
belt db:seed # run config/seeds.rb against dev (or BELT_ENV)
|
|
182
228
|
belt db:seed dev01
|
|
229
|
+
belt db:migrate prod # run pending data migrations against prod
|
|
230
|
+
belt db:migrate:status dev01
|
|
183
231
|
USAGE
|
|
184
232
|
end
|
|
185
233
|
|
|
@@ -126,4 +126,5 @@ file, no generator required.
|
|
|
126
126
|
|
|
127
127
|
- `belt explain backups` — recovery-point snapshots, not data seeding
|
|
128
128
|
- `belt explain console` — the app-booting mechanism `db:seed` reuses
|
|
129
|
+
- `belt explain migrations` — changing rows already in a deployed env
|
|
129
130
|
- `belt explain deployment` — nested environments and the parent → child copy hook
|
data/lib/belt/docs/deployment.md
CHANGED
|
@@ -11,6 +11,7 @@ belt deploy <env> # init → plan → apply (interactive)
|
|
|
11
11
|
belt deploy prod --auto # skip confirmation prompt
|
|
12
12
|
belt deploy prod --skip-backup # skip pre-deploy backup
|
|
13
13
|
belt deploy --backup-only # create recovery point without deploying
|
|
14
|
+
belt deploy dev --skip-migrations # don't run pending db/migrate migrations
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
## First-Time Setup
|
|
@@ -147,6 +148,14 @@ end
|
|
|
147
148
|
Backups run automatically before each deploy (DynamoDB snapshots, Cognito
|
|
148
149
|
exports, S3 syncs). See `belt explain backups` for full documentation.
|
|
149
150
|
|
|
151
|
+
## Data Migrations
|
|
152
|
+
|
|
153
|
+
After terraform apply (and the nested-env data copy), `belt deploy` runs any
|
|
154
|
+
pending `db/migrate/<timestamp>_<name>.rb` data migrations against the env,
|
|
155
|
+
each exactly once per environment. A failing migration doesn't roll back the
|
|
156
|
+
deploy; it's reported and the command exits non-zero. Run them by hand with
|
|
157
|
+
`belt db:migrate <env>`. See `belt explain migrations`.
|
|
158
|
+
|
|
150
159
|
## Environment Variables
|
|
151
160
|
|
|
152
161
|
Set `BELT_ENV` to avoid typing the environment every time:
|
|
@@ -176,4 +185,5 @@ deploys every configured frontend after terraform apply.
|
|
|
176
185
|
|
|
177
186
|
- `belt explain routing` — how routes map to infrastructure
|
|
178
187
|
- `belt explain backups` — pre-deploy backup configuration
|
|
188
|
+
- `belt explain migrations` — data migrations run after deploy
|
|
179
189
|
- `belt doctor` — check system dependencies before deploying
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Data Migrations
|
|
2
|
+
|
|
3
|
+
`belt setup tables` handles the *shape* of a table (keys, GSIs). Data
|
|
4
|
+
migrations handle the *rows already in it*: backfilling a new attribute, or
|
|
5
|
+
populating a new index for records written before it existed.
|
|
6
|
+
|
|
7
|
+
They live in `db/migrate/`, run automatically after `belt deploy <env>`, and
|
|
8
|
+
each one runs **exactly once per environment** — prod, dev and every nested
|
|
9
|
+
(PR-preview) env keep their own history.
|
|
10
|
+
|
|
11
|
+
## Writing a migration
|
|
12
|
+
|
|
13
|
+
One file per migration, named `<timestamp>_<snake_case_name>.rb`, defining a
|
|
14
|
+
`Belt::Migration` subclass named after the file:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# db/migrate/20261030120000_index_usernames.rb
|
|
18
|
+
class IndexUsernames < Belt::Migration
|
|
19
|
+
def up
|
|
20
|
+
PoshNosh::MatchStore.new.index_usernames!
|
|
21
|
+
say 'usernames indexed'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`up` runs in the same booted app context as `belt console`
|
|
27
|
+
(`lambda/config/environment.rb`, or `lambda/lib` + `lambda/models`), with
|
|
28
|
+
`ENVIRONMENT` set to the target env, so models talk to that env's tables.
|
|
29
|
+
`say` prints an indented progress line.
|
|
30
|
+
|
|
31
|
+
Use `date -u +%Y%m%d%H%M%S` for the timestamp. Migrations run in timestamp
|
|
32
|
+
order.
|
|
33
|
+
|
|
34
|
+
## Running them
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
belt deploy dev # runs pending migrations after terraform apply
|
|
38
|
+
belt deploy dev --skip-migrations
|
|
39
|
+
|
|
40
|
+
belt db:migrate # dev, or $BELT_ENV if set
|
|
41
|
+
belt db:migrate dev01
|
|
42
|
+
belt db:migrate prod # prompts for confirmation (--yes to skip)
|
|
43
|
+
belt db:migrate:status prod # which versions are up / down
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
Status Migration ID Migration Name
|
|
48
|
+
--------------------------------------------------
|
|
49
|
+
up 20261030120000 Index usernames
|
|
50
|
+
down 20261102090000 Backfill display names
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
During `belt deploy`, migrations run after terraform apply and the
|
|
54
|
+
nested-environment data copy (so rows copied from the parent get migrated
|
|
55
|
+
too), and before the frontend deploy. They also run after
|
|
56
|
+
`belt deploy --rebuild`. Nothing happens if `db/migrate/` is empty.
|
|
57
|
+
|
|
58
|
+
## Where history is kept
|
|
59
|
+
|
|
60
|
+
Each applied version is an SSM parameter:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
/belt/<app>/<env>/schema_migrations/<version> → {"name": "...", "applied_at": "..."}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It lives in SSM rather than a DynamoDB table so `belt db:copy` and the
|
|
67
|
+
nested-env copy (which move every `<app>-<env>-*` table) don't carry one env's
|
|
68
|
+
history into another. The deploying credentials need
|
|
69
|
+
`ssm:GetParametersByPath` and `ssm:PutParameter` on `/belt/<app>/*`.
|
|
70
|
+
|
|
71
|
+
To re-run a migration, delete its parameter:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
aws ssm delete-parameter --name /belt/myapp/dev/schema_migrations/20261030120000
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Failures
|
|
78
|
+
|
|
79
|
+
A version is recorded only after `up` returns. If one raises, it and every
|
|
80
|
+
later migration stay pending, and the command exits non-zero. In
|
|
81
|
+
`belt deploy` the infrastructure is already live, so the deploy carries on
|
|
82
|
+
(frontend included), prints a warning and exits 1 at the end. Fix the
|
|
83
|
+
migration and run `belt db:migrate <env>`.
|
|
84
|
+
|
|
85
|
+
## Write them idempotent and batch-safe
|
|
86
|
+
|
|
87
|
+
Migrations run against live data, often while the previous code is still
|
|
88
|
+
serving requests:
|
|
89
|
+
|
|
90
|
+
- **Idempotent.** A migration that dies halfway is re-run from the start, and
|
|
91
|
+
two overlapping deploys can both run it. Only write rows that still need
|
|
92
|
+
the change, and make writes safe to repeat (set a value, don't increment).
|
|
93
|
+
- **Batch-safe.** A GSI created in the same deploy may still be backfilling,
|
|
94
|
+
so don't query the index you're populating — page through the base table
|
|
95
|
+
and write in small batches. Expect throttling on big tables.
|
|
96
|
+
- **Data only.** Keys, GSIs and table settings belong in Terraform
|
|
97
|
+
(`belt setup tables`). The deployed code should work both before and
|
|
98
|
+
after a migration runs.
|
|
99
|
+
|
|
100
|
+
## See Also
|
|
101
|
+
|
|
102
|
+
- `belt explain data_seeding` — `db:copy` and `db:seed`
|
|
103
|
+
- `belt explain console` — the app-booting mechanism migrations reuse
|
|
104
|
+
- `belt explain deployment` — the full deploy lifecycle
|
data/lib/belt/docs/plugins.md
CHANGED
|
@@ -45,6 +45,38 @@ Belt discovers plugins automatically when:
|
|
|
45
45
|
|
|
46
46
|
Optional: `.destroy(args)` and `.description` for destroy path and help text.
|
|
47
47
|
|
|
48
|
+
## Top-level Commands
|
|
49
|
+
|
|
50
|
+
A plugin can also add its own `belt <name> ...` command, the way `belt-connect`
|
|
51
|
+
adds `belt connect`:
|
|
52
|
+
|
|
53
|
+
1. Gem has a file at `lib/belt/commands/<name>_command.rb`
|
|
54
|
+
2. Class is `Belt::Commands::<Name>Command`
|
|
55
|
+
3. Class implements `.run(args)` (required); `args` is everything after `<name>`
|
|
56
|
+
|
|
57
|
+
Optional: `.description` (shown under "Plugin Commands" in `belt --help`) and
|
|
58
|
+
`.standalone?` returning `true` to run outside a Belt project. Otherwise the
|
|
59
|
+
command runs from the project root, like built-ins.
|
|
60
|
+
|
|
61
|
+
Underscores in the file name become hyphens (`feature_flags_command.rb` →
|
|
62
|
+
`belt feature-flags`). Built-in commands and terraform actions win on a name
|
|
63
|
+
clash, so a plugin can't shadow `belt deploy`.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# lib/belt/commands/connect_command.rb
|
|
67
|
+
module Belt
|
|
68
|
+
module Commands
|
|
69
|
+
class ConnectCommand
|
|
70
|
+
def self.description = 'Connect widgets to your app'
|
|
71
|
+
|
|
72
|
+
def self.run(args)
|
|
73
|
+
# belt connect widget orders --env dev → args == ['widget', 'orders', '--env', 'dev']
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
48
80
|
## Generator Checklist
|
|
49
81
|
|
|
50
82
|
A good plugin generator typically installs:
|
|
@@ -71,6 +103,8 @@ belt-messaging/
|
|
|
71
103
|
│ │ ├── version.rb
|
|
72
104
|
│ │ ├── controllers/ # Default controllers
|
|
73
105
|
│ │ └── templates/ # ERB templates for generator
|
|
106
|
+
│ ├── commands/
|
|
107
|
+
│ │ └── messaging_command.rb # ← optional `belt messaging`
|
|
74
108
|
│ └── generators/
|
|
75
109
|
│ └── messaging_generator.rb # ← auto-discovered
|
|
76
110
|
└── spec/
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
# Base class for data migrations in `db/migrate/<timestamp>_<name>.rb`.
|
|
5
|
+
#
|
|
6
|
+
# # db/migrate/20261030120000_index_usernames.rb
|
|
7
|
+
# class IndexUsernames < Belt::Migration
|
|
8
|
+
# def up
|
|
9
|
+
# PoshNosh::MatchStore.new.index_usernames!
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# `belt deploy <env>` runs pending migrations after terraform apply, and
|
|
14
|
+
# `belt db:migrate <env>` runs them by hand. Each version runs once per
|
|
15
|
+
# environment (nested envs included); applied versions are recorded in SSM.
|
|
16
|
+
#
|
|
17
|
+
# Migrations touch live data, possibly while a new GSI is still backfilling
|
|
18
|
+
# or the previous code is still serving requests, so `up` must be
|
|
19
|
+
# idempotent (safe to re-run after a partial failure) and batch-safe (page
|
|
20
|
+
# through the base table rather than querying a GSI it is populating).
|
|
21
|
+
class Migration
|
|
22
|
+
attr_reader :version, :name
|
|
23
|
+
|
|
24
|
+
def initialize(version:, name:)
|
|
25
|
+
@version = version
|
|
26
|
+
@name = name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def up
|
|
30
|
+
raise NotImplementedError, "#{self.class.name} must define #up"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def say(message)
|
|
34
|
+
puts " #{message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/belt/version.rb
CHANGED
|
@@ -137,6 +137,7 @@ belt output <env> # terraform output
|
|
|
137
137
|
belt routes [-g PATTERN] [-f json] # Show route definitions
|
|
138
138
|
belt contracts [-g PATTERN] [-f json] # Show API contracts
|
|
139
139
|
belt console <env> # Interactive Ruby console
|
|
140
|
+
belt db:migrate <env> # Run pending db/migrate data migrations (deploy does this too)
|
|
140
141
|
belt logs <lambda> [-f] [-s 5m] [-e env] # View Lambda logs
|
|
141
142
|
belt doctor # Check dependencies
|
|
142
143
|
```
|
|
@@ -269,7 +270,8 @@ belt deploy <env> # Deploy everything (init → plan → apply)
|
|
|
269
270
|
| `rails routes` | `belt routes` |
|
|
270
271
|
| `rails console` | `belt console <env>` |
|
|
271
272
|
| `config/routes.rb` → URL paths | `config/routes.rb` → URL paths + infrastructure |
|
|
272
|
-
|
|
|
273
|
+
| Schema migrations | `belt setup tables <env>` |
|
|
274
|
+
| Data migrations (`rails db:migrate`) | `db/migrate/*.rb` + `belt db:migrate <env>` (run by `belt deploy`) |
|
|
273
275
|
| `has_many` / `belongs_to` | Manual (no joins in DynamoDB) |
|
|
274
276
|
| Template rendering | JSON responses (default) |
|
|
275
277
|
|
data/references/cli.md
CHANGED
|
@@ -21,13 +21,15 @@ default environment so you can omit the `<env>` argument.
|
|
|
21
21
|
| `belt doctor` | Check system deps + AWS config. |
|
|
22
22
|
| `belt plugin new <name>` | Scaffold a Belt plugin gem. |
|
|
23
23
|
| `belt explain <topic>` | Explain a concept (see topic list below). |
|
|
24
|
-
| `belt deploy [env] [--auto] [--skip-backup] [--backup-only]` | Deploy to AWS (init → plan → apply, runs backups first if configured). |
|
|
24
|
+
| `belt deploy [env] [--auto] [--skip-backup] [--backup-only] [--skip-migrations]` | Deploy to AWS (init → plan → apply, runs backups first if configured, data migrations after). |
|
|
25
25
|
| `belt deploy frontend <env> [--frontend NAME]` | Build + deploy frontend(s). |
|
|
26
26
|
| `belt dns <deploy\|add <env>\|show>` | Manage the root DNS zone. |
|
|
27
27
|
| `belt frontend <env <env>\|list>` | Write `<frontend>/.env` from TF outputs, or list frontends. |
|
|
28
28
|
| `belt server [--frontend NAME]` (alias `s`) | Start local dev server. |
|
|
29
29
|
| `belt db:copy <from> <to> [--force]` | Copy DynamoDB data between environments. |
|
|
30
30
|
| `belt db:seed [env] [--force]` | Run `config/seeds.rb` against an environment. |
|
|
31
|
+
| `belt db:migrate [env] [--yes]` | Run pending `db/migrate` data migrations (also runs after `belt deploy`). |
|
|
32
|
+
| `belt db:migrate:status [env]` | Show which data migrations have run against an environment. |
|
|
31
33
|
| `belt version` | Show Belt version. |
|
|
32
34
|
|
|
33
35
|
### Terraform shorthand
|
|
@@ -64,6 +64,18 @@ belt db:seed [env] [--force] # run config/seeds.rb in the booted console con
|
|
|
64
64
|
`db:copy` skips non-empty destination tables by default. `db:seed` refuses to
|
|
65
65
|
run against an env that already has data unless `--force`.
|
|
66
66
|
|
|
67
|
+
## Data migrations
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
belt db:migrate [env] [--yes] # run pending db/migrate/<timestamp>_<name>.rb
|
|
71
|
+
belt db:migrate:status [env] # up/down per version
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`belt deploy` runs pending migrations after apply (`--skip-migrations` to
|
|
75
|
+
opt out). Each version runs once per env, recorded in SSM at
|
|
76
|
+
`/belt/<app>/<env>/schema_migrations/<version>`. Write them idempotent and
|
|
77
|
+
batch-safe: see `belt explain migrations`.
|
|
78
|
+
|
|
67
79
|
## Observability
|
|
68
80
|
|
|
69
81
|
`Belt::LambdaHandler` wires these global facades automatically:
|
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.6.
|
|
4
|
+
version: 0.6.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -118,9 +118,11 @@ files:
|
|
|
118
118
|
- lib/belt/cli/backup_runner.rb
|
|
119
119
|
- lib/belt/cli/bucket_security.rb
|
|
120
120
|
- lib/belt/cli/cognito_sharer.rb
|
|
121
|
+
- lib/belt/cli/command_registry.rb
|
|
121
122
|
- lib/belt/cli/console_command.rb
|
|
122
123
|
- lib/belt/cli/contracts_command.rb
|
|
123
124
|
- lib/belt/cli/db_copy_command.rb
|
|
125
|
+
- lib/belt/cli/db_migrate_command.rb
|
|
124
126
|
- lib/belt/cli/db_seed_command.rb
|
|
125
127
|
- lib/belt/cli/deploy_command.rb
|
|
126
128
|
- lib/belt/cli/destroy_command.rb
|
|
@@ -142,6 +144,7 @@ files:
|
|
|
142
144
|
- lib/belt/cli/index_command.rb
|
|
143
145
|
- lib/belt/cli/lambda_config_command.rb
|
|
144
146
|
- lib/belt/cli/logs_command.rb
|
|
147
|
+
- lib/belt/cli/migration_store.rb
|
|
145
148
|
- lib/belt/cli/nested_environment.rb
|
|
146
149
|
- lib/belt/cli/new_command.rb
|
|
147
150
|
- lib/belt/cli/path_gem_materializer.rb
|
|
@@ -168,6 +171,7 @@ files:
|
|
|
168
171
|
- lib/belt/docs/frontend.md
|
|
169
172
|
- lib/belt/docs/generators.md
|
|
170
173
|
- lib/belt/docs/lambda_handler.md
|
|
174
|
+
- lib/belt/docs/migrations.md
|
|
171
175
|
- lib/belt/docs/models.md
|
|
172
176
|
- lib/belt/docs/observability.md
|
|
173
177
|
- lib/belt/docs/plugins.md
|
|
@@ -181,6 +185,7 @@ files:
|
|
|
181
185
|
- lib/belt/http_status.rb
|
|
182
186
|
- lib/belt/inflector.rb
|
|
183
187
|
- lib/belt/lambda_handler.rb
|
|
188
|
+
- lib/belt/migration.rb
|
|
184
189
|
- lib/belt/observability.rb
|
|
185
190
|
- lib/belt/parameters.rb
|
|
186
191
|
- lib/belt/rendering.rb
|