belt 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +30 -0
- data/SKILL.md +144 -0
- data/lib/belt/cli/db_copy_command.rb +150 -0
- data/lib/belt/cli/db_seed_command.rb +178 -0
- data/lib/belt/cli/deploy_command.rb +20 -1
- data/lib/belt/cli/dynamo_copier.rb +185 -73
- data/lib/belt/cli/explain_command.rb +10 -1
- data/lib/belt/cli/frontend_deploy_command.rb +58 -1
- data/lib/belt/cli/new_command.rb +1 -0
- data/lib/belt/cli.rb +10 -0
- data/lib/belt/docs/data_seeding.md +129 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/new_app/config/seeds.rb.erb +24 -0
- data/references/cli.md +76 -0
- data/references/controllers.md +73 -0
- data/references/deploy-and-ops.md +77 -0
- data/references/models-and-auth.md +71 -0
- data/references/plugins.md +64 -0
- data/references/routing.md +78 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f29c42b3eb7869a90ab1a0a22d56fd6b1304dbd6e22f32503669b3c9b0974890
|
|
4
|
+
data.tar.gz: f313115ee77ceafb82a4c424c6721f6776f3b869c1b71cc1e458332bc16b53fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57150cb2d0934d723b6fafb9eaee652dc933c4522fcae787518d2e03990da13e1c9f229d498b990a86fb1daa90ec3916e4e049e411a418a4534e76f4274a658e
|
|
7
|
+
data.tar.gz: 8e2d42b8ba9417dacdf725a66cc0e0a0d37e3a7ae59c3085a50f40c7c1e38eb298be49fa0a0ac3d3489e445e3907d85174bc07f45959a385ccbe55a605a5016d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.6
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Agent Skill (`SKILL.md`) — installable via `npx skills add stowzilla/belt`.**
|
|
8
|
+
A root-level `SKILL.md` with trigger-rich frontmatter, a lean activation body
|
|
9
|
+
(core patterns + decision guide), and on-demand `references/` docs (CLI, routing,
|
|
10
|
+
controllers, models+auth, deploy/ops, plugins). A CI workflow validates the
|
|
11
|
+
frontmatter and that every reference link resolves.
|
|
12
|
+
- **`SKILL.md` + `references/` now ship inside the `belt` gem.** The gemspec
|
|
13
|
+
`files` manifest was extended so a `gem install belt` lands the skill on disk
|
|
14
|
+
alongside the runtime. The `skills` CLI resolves git repos and local paths (not
|
|
15
|
+
RubyGems), so a gem install won't *auto-register* the skill — but the files are
|
|
16
|
+
present and can be picked up via a local-path install
|
|
17
|
+
(`npx skills add <gem-install-dir>`), and belt.dev tooling can vendor them.
|
|
18
|
+
|
|
19
|
+
### Bug Fix
|
|
20
|
+
|
|
21
|
+
- **`belt deploy frontend` now explains *why* the S3 bucket lookup failed.**
|
|
22
|
+
Previously any nil bucket output produced the same misleading
|
|
23
|
+
`Could not determine S3 bucket. Run belt apply <env> first.` — even when the env
|
|
24
|
+
had simply never been applied, or when the AWS profile/SSO session had expired.
|
|
25
|
+
`fetch_tf_output` swallows terraform's stderr, so the real cause was invisible.
|
|
26
|
+
The frontend deploy now re-probes terraform with stderr captured and emits a
|
|
27
|
+
targeted message for three cases: no Terraform state yet (provision the backend
|
|
28
|
+
first), a credential/SSO failure (fix the `aws_profile` / run `aws sso login`),
|
|
29
|
+
or an applied backend that's missing the frontend's bucket output (check
|
|
30
|
+
`config/frontends.yml`). This surfaces most often during ephemeral environment
|
|
31
|
+
setup, where the frontend step can run before the backend is applied.
|
|
32
|
+
|
|
3
33
|
## 0.4.3
|
|
4
34
|
|
|
5
35
|
### Bug Fix
|
|
@@ -18,6 +48,42 @@
|
|
|
18
48
|
|
|
19
49
|
## Unreleased
|
|
20
50
|
|
|
51
|
+
## 0.4.5
|
|
52
|
+
|
|
53
|
+
### Feature
|
|
54
|
+
|
|
55
|
+
- **`belt db:copy <from-env> <to-env>` — copy DynamoDB data between environments on demand.**
|
|
56
|
+
Promotes the `DynamoCopier` used by nested (PR-preview) deploys into a standalone
|
|
57
|
+
command, so you can pull one environment's data into another whenever you want
|
|
58
|
+
(e.g. prod data into dev for realistic seed data), not just on a nested-env deploy.
|
|
59
|
+
Matches tables by name suffix after stripping each environment's `<app>-<env>-`
|
|
60
|
+
prefix. Destination tables that already have data are skipped by default (safe to
|
|
61
|
+
re-run); pass `--force` to overwrite them. Source/destination AWS profiles are
|
|
62
|
+
resolved independently from each environment's `infrastructure/<env>/belt.rb`
|
|
63
|
+
(`config.aws_profile`), or overridden with `--from-profile` / `--to-profile` —
|
|
64
|
+
needed when source and destination live in different AWS accounts (e.g. prod vs.
|
|
65
|
+
dev). See `belt explain data_seeding`.
|
|
66
|
+
|
|
67
|
+
Cognito identities are per-environment (each environment has its own user pool,
|
|
68
|
+
so the same person has a different `sub` in each), so `db:copy` re-anchors
|
|
69
|
+
Cognito-sub foreign keys (e.g. a membership's `cognito_sub`) to the destination
|
|
70
|
+
environment's user with the matching email, and leaves the destination's own
|
|
71
|
+
`users` table untouched. Without this a copied row points at a `sub` that doesn't
|
|
72
|
+
exist in the destination pool and silently vanishes (a copied project you can't
|
|
73
|
+
see). Rows whose email has no destination user yet have the stale sub cleared so
|
|
74
|
+
they read as unclaimed (e.g. a pending invitation) rather than dangling. The same
|
|
75
|
+
re-anchoring now runs in the nested-env deploy hook. Pass `--no-remap-identity` to
|
|
76
|
+
copy those references verbatim.
|
|
77
|
+
|
|
78
|
+
- **`belt db:seed [environment]` — Rails-style `config/seeds.rb`.**
|
|
79
|
+
Loads `config/seeds.rb` in the same booted context `belt console` uses (models,
|
|
80
|
+
ActiveItem, `ENVIRONMENT` set), targeting the resolved environment's tables.
|
|
81
|
+
Honours `BELT_ENV` or an explicit environment argument, and prompts for
|
|
82
|
+
confirmation against `prod` like `belt console` does. Refuses to run if the
|
|
83
|
+
target environment already has data in any matching table — pass `--force` to
|
|
84
|
+
seed anyway (seeds.rb is responsible for its own idempotency if re-run).
|
|
85
|
+
`belt new` now scaffolds a starter `config/seeds.rb` with usage notes.
|
|
86
|
+
|
|
21
87
|
## 0.4.4
|
|
22
88
|
|
|
23
89
|
### 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.
|
data/SKILL.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: belt
|
|
3
|
+
license: MIT
|
|
4
|
+
description: >-
|
|
5
|
+
Build, run, and deploy serverless Ruby apps on AWS Lambda with the Belt
|
|
6
|
+
framework (Rails-inspired). Use when working in a Belt app or the belt gem:
|
|
7
|
+
scaffolding apps/models/controllers/frontends, defining routes with the
|
|
8
|
+
Belt routing DSL, writing BeltController actions, modeling DynamoDB data
|
|
9
|
+
with ActiveItem, wiring Cognito auth, configuring the Lambda handler,
|
|
10
|
+
running `belt` CLI commands (new, generate, deploy, console, routes, logs,
|
|
11
|
+
server, setup, plugin, explain, db:copy/db:seed), Terraform via belt,
|
|
12
|
+
backups, observability, or authoring Belt plugins. Triggers on "belt new",
|
|
13
|
+
"belt deploy", "belt generate", "belt routes", "BeltController", "ActiveItem",
|
|
14
|
+
"Belt::LambdaHandler", "belt console", "belt server", "conveyor-belt". Do NOT
|
|
15
|
+
activate for physical belts, conveyor hardware, or unrelated Ruby web
|
|
16
|
+
frameworks like Rails/Sinatra outside a Belt project.
|
|
17
|
+
metadata:
|
|
18
|
+
author: stowzilla
|
|
19
|
+
version: "0.1.0"
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Belt
|
|
23
|
+
|
|
24
|
+
Belt is a Rails-inspired framework for serverless Ruby on AWS Lambda. It ships a
|
|
25
|
+
runtime (`BeltController`, `Belt::LambdaHandler`, `Belt::ActionRouter`,
|
|
26
|
+
`Belt::Authentication`), a DynamoDB ORM (`ActiveItem`), logging/metrics
|
|
27
|
+
(`lambda_loadout`), a routing DSL consumed by the **conveyor-belt** Terraform
|
|
28
|
+
provider, and a `belt` CLI. Optional features (`belt-messaging`, `belt-pay`)
|
|
29
|
+
ship as separate plugin gems.
|
|
30
|
+
|
|
31
|
+
## First: orient yourself
|
|
32
|
+
|
|
33
|
+
Belt has an authoritative, in-repo docs system. **Use it before guessing.**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
belt explain <topic> # routing controllers models deployment generators
|
|
37
|
+
# lambda_handler observability console backups
|
|
38
|
+
# data_seeding plugins structure frontend authentication
|
|
39
|
+
belt --help # full command list
|
|
40
|
+
belt routes # list every endpoint (verb, path, controller#action)
|
|
41
|
+
belt doctor # check deps + AWS config
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Working in a Belt app?** Read the app's own `AGENTS.md` (scaffolded by `belt new`).
|
|
45
|
+
- **Working on the belt gem itself?** Read `AGENTS.md` + `README.md` at the gem root.
|
|
46
|
+
- The `belt explain` docs live at `lib/belt/docs/*.md` in the gem — the single
|
|
47
|
+
source of truth for CLI behavior, controller lifecycle, and routing.
|
|
48
|
+
|
|
49
|
+
## Decision guide
|
|
50
|
+
|
|
51
|
+
| I want to… | Do this |
|
|
52
|
+
|---|---|
|
|
53
|
+
| Scaffold an app | `belt new <name> --frontend react` |
|
|
54
|
+
| Add a resource | `belt generate scaffold post title:string body:text` |
|
|
55
|
+
| See all routes | `belt routes` (add `-f json` for tooling) |
|
|
56
|
+
| Deploy to AWS | `belt deploy <env>` (`--auto` to skip prompt) |
|
|
57
|
+
| Poke at data live | `belt console <env>` |
|
|
58
|
+
| Run a local frontend | `belt server` (see references for env targeting) |
|
|
59
|
+
| Add Cognito auth | `belt generate auth` → `cognito_authenticatable` in a model |
|
|
60
|
+
| Build a plugin | `belt plugin new <name>` |
|
|
61
|
+
| Learn a concept | `belt explain <topic>` |
|
|
62
|
+
|
|
63
|
+
## Core patterns (memorize these)
|
|
64
|
+
|
|
65
|
+
**Controller** — assigns become the JSON body; explicit helpers win.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class PostsController < BeltController::Base
|
|
69
|
+
before_action :authenticate_user!
|
|
70
|
+
|
|
71
|
+
def index
|
|
72
|
+
@posts = Post.where(user_id: current_user.id, index: "UserIndex")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def create
|
|
76
|
+
@post = Post.create!(params.require(:post).permit(:title, :body).to_h)
|
|
77
|
+
response_status :created # → 201 + { post: {...} }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def destroy
|
|
81
|
+
Post.find(params["id"]).destroy
|
|
82
|
+
head :no_content
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Model** — ActiveItem over DynamoDB.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class Post < ActiveItem::Base
|
|
91
|
+
self.primary_key = :id
|
|
92
|
+
attr_accessor :id, :user_id, :title, :body, :created_at
|
|
93
|
+
validates :title, presence: true
|
|
94
|
+
before_create { self.id ||= SecureRandom.uuid }
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Routes DSL** — `infrastructure/routes.tf.rb`. `gateway`/`function` pick the
|
|
99
|
+
Lambda; `namespace`/`scope` only affect paths + controller module.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
Belt.application.routes.draw do
|
|
103
|
+
gateway :api, auth: :cognito do
|
|
104
|
+
resources :posts do
|
|
105
|
+
member { post :publish } # /posts/:post_id/publish
|
|
106
|
+
collection { get :recent } # /posts/recent
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Lambda entry point** — `Belt::LambdaHandler` gives observability, CORS
|
|
113
|
+
preflight, JSON parsing, and error wrapping for free.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
require "belt"
|
|
117
|
+
include Belt::LambdaHandler
|
|
118
|
+
ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api")
|
|
119
|
+
|
|
120
|
+
def execute(path:, body:, event:)
|
|
121
|
+
ROUTER.route(event: event, body: body)
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Golden rules for agents
|
|
126
|
+
|
|
127
|
+
1. **`belt explain <topic>` before inventing behavior.** The docs are canonical.
|
|
128
|
+
2. **`namespace`/`scope` never change which Lambda serves a route** — only
|
|
129
|
+
`gateway` and `function` do. Do not conflate them.
|
|
130
|
+
3. **Controllers need no registration.** Belt discovers them by convention.
|
|
131
|
+
4. **Runtime code stays in gems; generators copy only what the app must own.**
|
|
132
|
+
5. **Never commit secrets** (`.env`, AWS keys, real account IDs).
|
|
133
|
+
6. **`rubocop` + `rspec` must pass** before a PR on the belt gem.
|
|
134
|
+
|
|
135
|
+
## Deeper references
|
|
136
|
+
|
|
137
|
+
Load these only when the task needs them:
|
|
138
|
+
|
|
139
|
+
- [references/cli.md](references/cli.md) — every `belt` command, flags, env vars, Terraform shorthand
|
|
140
|
+
- [references/routing.md](references/routing.md) — full routing DSL, nested resources, request/response model inference
|
|
141
|
+
- [references/controllers.md](references/controllers.md) — callbacks, strong params, responses, error handling, formats
|
|
142
|
+
- [references/models-and-auth.md](references/models-and-auth.md) — ActiveItem + Cognito authentication
|
|
143
|
+
- [references/deploy-and-ops.md](references/deploy-and-ops.md) — deploy lifecycle, environments, backups, seeding, observability
|
|
144
|
+
- [references/plugins.md](references/plugins.md) — authoring Belt plugin gems and the GeneratorRegistry contract
|
|
@@ -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
|