belt 0.2.8 → 0.2.10
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 +87 -0
- data/README.md +48 -8
- data/lib/belt/action_router.rb +1 -1
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/backup_runner.rb +37 -54
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +6 -6
- data/lib/belt/cli/frontend_command.rb +25 -12
- data/lib/belt/cli/frontend_setup_command.rb +41 -0
- data/lib/belt/cli/new_command.rb +106 -38
- data/lib/belt/cli/path_gem_materializer.rb +141 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +4 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/response.rb +35 -4
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/rendering.rb +1 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +31 -31
- data/lib/belt.rb +16 -0
- data/lib/belt_controller/base.rb +24 -1
- data/lib/belt_controller/implicit_response.rb +104 -0
- data/lib/templates/environment/backend.tf.erb +1 -0
- data/lib/templates/frontend/react/src/index.css +4 -4
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
- data/lib/templates/module/main.tf.erb +13 -3
- data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a550043d0d16c901e656de4a9d89a0f473d77778121e2b27fb8791f3edf74d4
|
|
4
|
+
data.tar.gz: f9404b6e69d725650ee612af06b96917886d2856fca14955bb99cb9555811dcd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e719bed32a829bf952c22885d63a1415ea292c3b1770a7f7bdb398ef874a5182a1b8208654e2672d6d7fcccbb4b2e9f24a82186708ed3982d9ca7ec94d9bedbf
|
|
7
|
+
data.tar.gz: 32fa436e2a97b5d3597af8d2afcfb8657c5de91f12a01b551d8368be97a80fdfd544e5b6b711a930ff741c1e8228d393088893700f13c719b454b59e01c3d6a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.10
|
|
4
|
+
|
|
5
|
+
### Quiet `belt new` (with optional verbose)
|
|
6
|
+
|
|
7
|
+
`belt new` prints a short phase summary by default (skeleton, environments, frontend,
|
|
8
|
+
bundle, state bucket) instead of every path plus AWS/npm noise.
|
|
9
|
+
|
|
10
|
+
Use `-v` / `--verbose` for Rails-style per-file `create` lines when you want the
|
|
11
|
+
inventory. Nested generators skip their own next-steps banners; the final success
|
|
12
|
+
block still owns next steps. State-bucket setup stays non-interactive either way.
|
|
13
|
+
|
|
14
|
+
### State bucket naming (global uniqueness)
|
|
15
|
+
|
|
16
|
+
S3 bucket names are a **global** namespace across all AWS accounts. The previous default
|
|
17
|
+
`belt-terraform-state` only works for the first account that creates it — everyone else
|
|
18
|
+
hits "owned by a different AWS account".
|
|
19
|
+
|
|
20
|
+
**Fix:** shared bucket is still one-per-account (all apps share it), but the name is now:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
belt-terraform-state-<account_id>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- Still one bucket for all belt apps in an account (state key: `<app>/<env>/terraform.tfstate`)
|
|
27
|
+
- `--bucket` still overrides when you want a custom name
|
|
28
|
+
- `belt setup state` rewrites `backend.tf` with the resolved name
|
|
29
|
+
- Existing installs that already own `belt-terraform-state` can keep using `--bucket belt-terraform-state`
|
|
30
|
+
|
|
31
|
+
### Implicit responses (Rails-style assigns)
|
|
32
|
+
|
|
33
|
+
Controllers can set instance variables and skip the explicit `success_response` call:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
def index
|
|
37
|
+
@posts = Post.all
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def show
|
|
41
|
+
@post = Post.find(params[:id])
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Belt auto-builds `success_response({ posts: [...] })` / `{ post: {...} }` from assigns set
|
|
46
|
+
during the action. Serialization:
|
|
47
|
+
|
|
48
|
+
- Models → `to_h` (ActiveItem already defines this)
|
|
49
|
+
- `ActiveItem::Relation` / Enumerable → map each record via `to_h`
|
|
50
|
+
- Nested hashes/arrays recurse
|
|
51
|
+
|
|
52
|
+
Explicit `success_response` / `error_response` / `html_response` / `render` / `head` still win
|
|
53
|
+
when returned. Rescue handlers still use `error_response`.
|
|
54
|
+
|
|
55
|
+
### Default format (`:json` / `:html`)
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# App-wide (lambda/config/environment.rb)
|
|
59
|
+
Belt.configure do |c|
|
|
60
|
+
c.default_format = :json # default — API first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Per-controller override (inherits down the chain)
|
|
64
|
+
class PagesController < ApplicationController
|
|
65
|
+
self.default_format = :html
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| `default_format` | Implicit response (no explicit helper) |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `:json` (default) | `success_response` from assigns |
|
|
72
|
+
| `:html` | `render` template `views/<controller>/<action>.html.erb` (missing → `TemplateNotFound`) |
|
|
73
|
+
|
|
74
|
+
### Symbol status codes, `head`, `response_status`
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
success_response({ post: post.to_h }, :created) # 201
|
|
78
|
+
error_response("Nope", :unprocessable_entity) # 422
|
|
79
|
+
head :no_content # 204 empty body
|
|
80
|
+
head :created # 201 empty body
|
|
81
|
+
|
|
82
|
+
def create
|
|
83
|
+
@post = Post.create!(...)
|
|
84
|
+
response_status :created # 201 + implicit assigns body
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Bare integers still work. Symbols match the Rack/Rails names (`:created`, `:not_found`, …).
|
|
89
|
+
|
|
3
90
|
## 0.3.0
|
|
4
91
|
|
|
5
92
|
### Pre-Deploy Backups
|
data/README.md
CHANGED
|
@@ -71,24 +71,31 @@ require "belt"
|
|
|
71
71
|
class PostsController < BeltController::Base
|
|
72
72
|
before_action :authenticate!
|
|
73
73
|
|
|
74
|
+
# Implicit response: assigns become the JSON body
|
|
75
|
+
# → { "posts": [ { "id": "...", "title": "..." }, ... ] }
|
|
74
76
|
def index
|
|
75
|
-
posts = Post.where(user_id: current_user_id, index: "UserIndex")
|
|
76
|
-
success_response(posts.map(&:attributes))
|
|
77
|
+
@posts = Post.where(user_id: current_user_id, index: "UserIndex")
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
def show
|
|
80
|
-
post = Post.find(params["id"])
|
|
81
|
-
success_response(post.attributes)
|
|
81
|
+
@post = Post.find(params["id"])
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Implicit body + non-200 status
|
|
84
85
|
def create
|
|
85
86
|
attrs = params.require(:post).permit(:title, :body).to_h
|
|
86
|
-
post = Post.create!(attrs.merge(user_id: current_user_id))
|
|
87
|
-
|
|
87
|
+
@post = Post.create!(attrs.merge(user_id: current_user_id))
|
|
88
|
+
response_status :created
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def destroy
|
|
92
|
+
Post.find(params["id"]).destroy
|
|
93
|
+
head :no_content
|
|
88
94
|
end
|
|
89
95
|
end
|
|
90
96
|
```
|
|
91
97
|
|
|
98
|
+
|
|
92
99
|
### 4. Lambda entry point
|
|
93
100
|
|
|
94
101
|
Use `Belt::LambdaHandler` to get automatic observability, CORS preflight handling, and error wrapping:
|
|
@@ -199,9 +206,42 @@ end
|
|
|
199
206
|
|
|
200
207
|
```ruby
|
|
201
208
|
success_response({ id: "123", name: "Example" }) # 200 JSON with CORS
|
|
202
|
-
success_response({ id: "123" },
|
|
203
|
-
error_response("Not found",
|
|
209
|
+
success_response({ id: "123" }, :created) # 201 Created (symbol or int)
|
|
210
|
+
error_response("Not found", :not_found) # 404 JSON error
|
|
211
|
+
error_response("Nope", :unprocessable_entity) # 422
|
|
204
212
|
html_response("<h1>Hello</h1>") # 200 HTML with CORS
|
|
213
|
+
head :no_content # 204 empty body
|
|
214
|
+
head :created # 201 empty body
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Default format (API vs HTML)
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# App-wide — typically in lambda/config/environment.rb
|
|
221
|
+
Belt.configure do |config|
|
|
222
|
+
config.default_format = :json # default
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Per-controller override
|
|
226
|
+
class PagesController < ApplicationController
|
|
227
|
+
self.default_format = :html
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- **`:json`** (default): action assigns → `success_response({ posts: [...] })`
|
|
232
|
+
- **`:html`**: action assigns stay on the controller; Belt implicitly `render`s
|
|
233
|
+
`views/<controller>/<action>.html.erb`. Missing template raises `Belt::TemplateNotFound`
|
|
234
|
+
(no silent JSON fallback).
|
|
235
|
+
- Explicit helpers always win: `success_response`, `error_response`, `html_response`,
|
|
236
|
+
`render`, `head`.
|
|
237
|
+
|
|
238
|
+
### Non-200 with implicit assigns
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
def create
|
|
242
|
+
@post = Post.create!(...)
|
|
243
|
+
response_status :created # → 201 + { post: {...} }
|
|
244
|
+
end
|
|
205
245
|
```
|
|
206
246
|
|
|
207
247
|
## Controller Discovery
|
data/lib/belt/action_router.rb
CHANGED
|
@@ -165,7 +165,7 @@ module Belt
|
|
|
165
165
|
def error_response(message, status_code, event = nil)
|
|
166
166
|
origin = Belt::Helpers::CorsOrigin.resolve_origin(Belt::Helpers::CorsOrigin.origin_from_event(event))
|
|
167
167
|
headers = {
|
|
168
|
-
'Access-Control-Allow-Headers' => 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
168
|
+
'Access-Control-Allow-Headers' => 'Content-Type,Accept,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
169
169
|
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
|
|
170
170
|
'Content-Type' => 'application/json'
|
|
171
171
|
}
|
data/lib/belt/assets/welcome.css
CHANGED
|
@@ -1,82 +1,136 @@
|
|
|
1
1
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2
|
+
html, body {
|
|
3
|
+
height: 100%;
|
|
4
|
+
}
|
|
2
5
|
body {
|
|
3
6
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
4
|
-
background: #
|
|
7
|
+
background: #0b1220;
|
|
5
8
|
color: #2d3748;
|
|
6
9
|
min-height: 100vh;
|
|
10
|
+
overflow: hidden;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
|
-
/* Hero
|
|
13
|
+
/* Hero fills the viewport — all content lives in the overlay (no scroll) */
|
|
10
14
|
.hero {
|
|
11
15
|
position: relative;
|
|
12
16
|
width: 100%;
|
|
17
|
+
height: 100vh;
|
|
13
18
|
overflow: hidden;
|
|
14
19
|
}
|
|
15
20
|
.hero-bg {
|
|
16
21
|
width: 100%;
|
|
17
|
-
height:
|
|
22
|
+
height: 100%;
|
|
23
|
+
object-fit: cover;
|
|
24
|
+
/* Pin the logo / sky — crop hills at the bottom if the viewport is short */
|
|
25
|
+
object-position: top center;
|
|
18
26
|
display: block;
|
|
19
27
|
}
|
|
20
28
|
.overlay {
|
|
21
29
|
position: absolute;
|
|
22
|
-
top:
|
|
30
|
+
top: 50%;
|
|
23
31
|
left: 50%;
|
|
24
32
|
transform: translate(-50%, -50%);
|
|
25
|
-
padding:
|
|
26
|
-
background: rgba(255, 255, 255, 0.
|
|
27
|
-
backdrop-filter: blur(
|
|
28
|
-
-webkit-backdrop-filter: blur(
|
|
29
|
-
border-radius:
|
|
33
|
+
padding: 1.35rem 1.6rem 1.25rem;
|
|
34
|
+
background: rgba(255, 255, 255, 0.55);
|
|
35
|
+
backdrop-filter: blur(8px);
|
|
36
|
+
-webkit-backdrop-filter: blur(8px);
|
|
37
|
+
border-radius: 14px;
|
|
30
38
|
text-align: center;
|
|
31
|
-
max-width:
|
|
32
|
-
width:
|
|
33
|
-
|
|
39
|
+
max-width: 560px;
|
|
40
|
+
width: min(92vw, 560px);
|
|
41
|
+
max-height: min(92vh, 620px);
|
|
42
|
+
overflow: auto;
|
|
43
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
|
34
44
|
}
|
|
35
45
|
.overlay h1 {
|
|
36
|
-
font-size:
|
|
46
|
+
font-size: 1.85rem;
|
|
37
47
|
font-weight: 700;
|
|
38
48
|
color: #2d3748;
|
|
39
|
-
margin-bottom: 0.
|
|
49
|
+
margin-bottom: 0.3rem;
|
|
50
|
+
line-height: 1.2;
|
|
40
51
|
}
|
|
41
52
|
.overlay .subtitle {
|
|
42
|
-
font-size:
|
|
53
|
+
font-size: 0.95rem;
|
|
43
54
|
color: #4a5568;
|
|
55
|
+
line-height: 1.4;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
/*
|
|
47
|
-
.container {
|
|
48
|
-
max-width: 700px;
|
|
49
|
-
margin: 0 auto;
|
|
50
|
-
padding: 2rem;
|
|
51
|
-
}
|
|
58
|
+
/* Stack + next steps sit inside the white card */
|
|
52
59
|
.stack-check {
|
|
53
60
|
display: flex;
|
|
54
61
|
align-items: center;
|
|
55
62
|
justify-content: center;
|
|
56
|
-
gap: 0.
|
|
57
|
-
margin
|
|
63
|
+
gap: 0.4rem;
|
|
64
|
+
margin: 1rem 0 0.85rem;
|
|
58
65
|
flex-wrap: wrap;
|
|
59
66
|
}
|
|
60
67
|
.check-item {
|
|
61
68
|
display: flex;
|
|
62
69
|
align-items: center;
|
|
63
|
-
gap: 0.
|
|
64
|
-
padding: 0.
|
|
70
|
+
gap: 0.35rem;
|
|
71
|
+
padding: 0.35rem 0.7rem;
|
|
65
72
|
border-radius: 8px;
|
|
66
73
|
font-weight: 500;
|
|
74
|
+
font-size: 0.88rem;
|
|
67
75
|
}
|
|
68
76
|
.check-pass {
|
|
69
|
-
background: rgba(39, 174, 96, 0.
|
|
70
|
-
border: 1px solid rgba(39, 174, 96, 0.
|
|
77
|
+
background: rgba(39, 174, 96, 0.12);
|
|
78
|
+
border: 1px solid rgba(39, 174, 96, 0.35);
|
|
71
79
|
color: #1e8449;
|
|
72
80
|
}
|
|
81
|
+
/* Optional / empty DynamoDB — not an error, just not in use yet */
|
|
82
|
+
.check-neutral {
|
|
83
|
+
background: rgba(113, 128, 150, 0.1);
|
|
84
|
+
border: 1px solid rgba(113, 128, 150, 0.28);
|
|
85
|
+
color: #4a5568;
|
|
86
|
+
}
|
|
73
87
|
.check-warn {
|
|
74
88
|
background: rgba(243, 156, 18, 0.1);
|
|
75
89
|
border: 1px solid rgba(243, 156, 18, 0.3);
|
|
76
90
|
color: #d68910;
|
|
77
91
|
}
|
|
78
|
-
.icon { font-size:
|
|
79
|
-
.arrow { color: #718096; font-size: 1.
|
|
92
|
+
.icon { font-size: 1rem; }
|
|
93
|
+
.arrow { color: #718096; font-size: 1.05rem; }
|
|
94
|
+
|
|
95
|
+
.next-steps {
|
|
96
|
+
text-align: left;
|
|
97
|
+
border-top: 1px solid rgba(45, 55, 72, 0.12);
|
|
98
|
+
padding-top: 0.75rem;
|
|
99
|
+
margin-top: 0.15rem;
|
|
100
|
+
}
|
|
101
|
+
.next-steps h2 {
|
|
102
|
+
font-size: 0.92rem;
|
|
103
|
+
margin-bottom: 0.45rem;
|
|
104
|
+
color: #2d3748;
|
|
105
|
+
text-align: left;
|
|
106
|
+
}
|
|
107
|
+
.next-steps ol {
|
|
108
|
+
padding-left: 1.2rem;
|
|
109
|
+
margin: 0;
|
|
110
|
+
}
|
|
111
|
+
.next-steps li {
|
|
112
|
+
margin-bottom: 0.4rem;
|
|
113
|
+
color: #4a5568;
|
|
114
|
+
line-height: 1.4;
|
|
115
|
+
font-size: 0.88rem;
|
|
116
|
+
}
|
|
117
|
+
.next-steps li:last-child { margin-bottom: 0; }
|
|
118
|
+
.next-steps code,
|
|
119
|
+
.info code {
|
|
120
|
+
background: rgba(99, 45, 145, 0.1);
|
|
121
|
+
padding: 0.12rem 0.35rem;
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
color: #632d91;
|
|
124
|
+
font-size: 0.78rem;
|
|
125
|
+
word-break: break-word;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Legacy blocks (local server welcome, older layouts) */
|
|
129
|
+
.container {
|
|
130
|
+
max-width: 700px;
|
|
131
|
+
margin: 0 auto;
|
|
132
|
+
padding: 2rem;
|
|
133
|
+
}
|
|
80
134
|
.info {
|
|
81
135
|
background: #ffffff;
|
|
82
136
|
border: 1px solid #e2e8f0;
|
|
@@ -87,26 +141,44 @@ body {
|
|
|
87
141
|
.info p { margin-bottom: 0.5rem; color: #4a5568; }
|
|
88
142
|
.info p:last-child { margin-bottom: 0; }
|
|
89
143
|
.info strong { color: #2d3748; }
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
144
|
+
.dynamodb-hint {
|
|
145
|
+
text-align: center;
|
|
146
|
+
margin: 0.5rem 0;
|
|
147
|
+
color: #4a5568;
|
|
148
|
+
font-size: 0.9rem;
|
|
149
|
+
line-height: 1.45;
|
|
150
|
+
}
|
|
151
|
+
.dynamodb-hint code {
|
|
152
|
+
background: rgba(99, 45, 145, 0.1);
|
|
153
|
+
padding: 0.12rem 0.35rem;
|
|
93
154
|
border-radius: 4px;
|
|
94
155
|
color: #632d91;
|
|
95
|
-
font-size: 0.
|
|
156
|
+
font-size: 0.8rem;
|
|
157
|
+
word-break: break-word;
|
|
96
158
|
}
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
border-
|
|
101
|
-
|
|
159
|
+
.footer-note {
|
|
160
|
+
margin-top: 0.75rem;
|
|
161
|
+
padding-top: 0.65rem;
|
|
162
|
+
border-top: 1px solid rgba(45, 55, 72, 0.1);
|
|
163
|
+
font-size: 0.78rem;
|
|
164
|
+
color: #718096;
|
|
165
|
+
text-align: center;
|
|
102
166
|
}
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
167
|
+
.footer-note code {
|
|
168
|
+
background: rgba(45, 55, 72, 0.06);
|
|
169
|
+
padding: 0.08rem 0.3rem;
|
|
170
|
+
border-radius: 3px;
|
|
171
|
+
color: #4a5568;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* Tiny viewports: shrink type a bit so everything still fits */
|
|
175
|
+
@media (max-height: 640px) {
|
|
176
|
+
.overlay {
|
|
177
|
+
padding: 1rem 1.15rem;
|
|
178
|
+
}
|
|
179
|
+
.overlay h1 { font-size: 1.5rem; }
|
|
180
|
+
.overlay .subtitle { font-size: 0.88rem; }
|
|
181
|
+
.stack-check { margin: 0.7rem 0 0.55rem; }
|
|
182
|
+
.check-item { padding: 0.28rem 0.55rem; font-size: 0.8rem; }
|
|
183
|
+
.next-steps li { font-size: 0.82rem; }
|
|
112
184
|
}
|
|
@@ -14,7 +14,7 @@ module Belt
|
|
|
14
14
|
@infra_dir = infra_dir
|
|
15
15
|
@app_name = app_name
|
|
16
16
|
@timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
|
|
17
|
-
@backup_bucket = "#{@app_name}-backups-#{@env}"
|
|
17
|
+
@backup_bucket = sanitize_bucket_name("#{@app_name}-backups-#{@env}")
|
|
18
18
|
@errors = []
|
|
19
19
|
@summary = []
|
|
20
20
|
end
|
|
@@ -41,6 +41,8 @@ module Belt
|
|
|
41
41
|
|
|
42
42
|
puts " 📦 Creating backup bucket: #{@backup_bucket}"
|
|
43
43
|
|
|
44
|
+
errors_before = @errors.size
|
|
45
|
+
|
|
44
46
|
run_aws('s3', 'mb', "s3://#{@backup_bucket}", '--region', detect_region)
|
|
45
47
|
|
|
46
48
|
# Enable versioning
|
|
@@ -54,7 +56,11 @@ module Belt
|
|
|
54
56
|
'--public-access-block-configuration',
|
|
55
57
|
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true')
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
if @errors.size > errors_before
|
|
60
|
+
puts ' ⚠️ Backup bucket creation failed (see warnings below)'
|
|
61
|
+
else
|
|
62
|
+
puts ' ✅ Backup bucket created and secured'
|
|
63
|
+
end
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
# ─── DynamoDB ────────────────────────────────────────────────────
|
|
@@ -153,11 +159,11 @@ module Belt
|
|
|
153
159
|
|
|
154
160
|
def export_cognito_users(pool_id)
|
|
155
161
|
all_users = []
|
|
156
|
-
|
|
162
|
+
next_token = nil
|
|
157
163
|
|
|
158
164
|
loop do
|
|
159
|
-
args = ['aws', 'cognito-idp', 'list-users', '--user-pool-id', pool_id, '--max-
|
|
160
|
-
args += ['--
|
|
165
|
+
args = ['aws', 'cognito-idp', 'list-users', '--user-pool-id', pool_id, '--max-items', '60']
|
|
166
|
+
args += ['--starting-token', next_token] if next_token
|
|
161
167
|
|
|
162
168
|
output, status = Open3.capture2(*args)
|
|
163
169
|
break unless status.success?
|
|
@@ -165,8 +171,8 @@ module Belt
|
|
|
165
171
|
data = JSON.parse(output)
|
|
166
172
|
all_users.concat(data['Users'] || [])
|
|
167
173
|
|
|
168
|
-
|
|
169
|
-
break if
|
|
174
|
+
next_token = data['NextToken']
|
|
175
|
+
break if next_token.nil? || next_token.empty?
|
|
170
176
|
|
|
171
177
|
puts ' Fetching next page of users...'
|
|
172
178
|
end
|
|
@@ -327,69 +333,41 @@ module Belt
|
|
|
327
333
|
end
|
|
328
334
|
end
|
|
329
335
|
|
|
330
|
-
# ───
|
|
336
|
+
# ─── Table Discovery ──────────────────────────────────────────────
|
|
331
337
|
|
|
332
338
|
def resolve_table_names
|
|
333
|
-
@resolve_table_names ||=
|
|
339
|
+
@resolve_table_names ||= discover_tables_from_aws
|
|
334
340
|
end
|
|
335
341
|
|
|
336
|
-
def
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
names = try_terraform_table_output
|
|
342
|
-
return names if names
|
|
342
|
+
def discover_tables_from_aws
|
|
343
|
+
# List all DynamoDB tables matching the app-env prefix directly from AWS.
|
|
344
|
+
# This is the source of truth — no Terraform output maintenance required.
|
|
345
|
+
prefix = "#{@app_name}-#{@env}-"
|
|
346
|
+
sanitized_prefix = sanitize_bucket_name(prefix)
|
|
343
347
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
def try_terraform_table_output
|
|
349
|
-
output, status = Open3.capture2('terraform', 'output', '-json', 'dynamodb_table_names')
|
|
350
|
-
return nil unless status.success?
|
|
351
|
-
|
|
352
|
-
names = begin
|
|
353
|
-
JSON.parse(output)
|
|
354
|
-
rescue StandardError
|
|
355
|
-
nil
|
|
348
|
+
output, status = Open3.capture2('aws', 'dynamodb', 'list-tables', '--output', 'json')
|
|
349
|
+
unless status.success?
|
|
350
|
+
@errors << 'Failed to list DynamoDB tables from AWS'
|
|
351
|
+
return []
|
|
356
352
|
end
|
|
357
|
-
names.is_a?(Array) && names.any? ? Array(names) : nil
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
def fetch_table_names_from_all_outputs
|
|
361
|
-
output, status = Open3.capture2('terraform', 'output', '-json')
|
|
362
|
-
return [] unless status.success?
|
|
363
353
|
|
|
364
|
-
|
|
365
|
-
JSON.parse(output)
|
|
354
|
+
all_tables = begin
|
|
355
|
+
JSON.parse(output)['TableNames'] || []
|
|
366
356
|
rescue StandardError
|
|
367
|
-
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
# Look for dynamodb_table_names in output
|
|
371
|
-
if all_outputs['dynamodb_table_names']
|
|
372
|
-
val = all_outputs['dynamodb_table_names']['value']
|
|
373
|
-
return Array(val) if val
|
|
357
|
+
[]
|
|
374
358
|
end
|
|
375
359
|
|
|
376
|
-
|
|
377
|
-
all_outputs.each do |key, data|
|
|
378
|
-
next unless key.include?('table')
|
|
379
|
-
|
|
380
|
-
val = data['value']
|
|
381
|
-
return Array(val) if val.is_a?(Array) && val.any?
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
[]
|
|
360
|
+
all_tables.select { |t| t.start_with?(prefix) || t.start_with?(sanitized_prefix) }
|
|
385
361
|
end
|
|
386
362
|
|
|
363
|
+
# ─── Terraform Output Resolution ─────────────────────────────────
|
|
364
|
+
|
|
387
365
|
def resolve_cognito_pool_id
|
|
388
366
|
env_dir = File.join(@infra_dir, @env)
|
|
389
367
|
return nil unless Dir.exist?(env_dir)
|
|
390
368
|
|
|
391
369
|
Dir.chdir(env_dir) do
|
|
392
|
-
output, status = Open3.capture2('terraform', 'output', '-json', 'user_pool_id')
|
|
370
|
+
output, status = Open3.capture2('terraform', 'output', '-json', 'user_pool_id', err: File::NULL)
|
|
393
371
|
if status.success?
|
|
394
372
|
val = begin
|
|
395
373
|
JSON.parse(output)
|
|
@@ -400,7 +378,7 @@ module Belt
|
|
|
400
378
|
end
|
|
401
379
|
|
|
402
380
|
# Fallback: search all outputs
|
|
403
|
-
output, status = Open3.capture2('terraform', 'output', '-json')
|
|
381
|
+
output, status = Open3.capture2('terraform', 'output', '-json', err: File::NULL)
|
|
404
382
|
if status.success?
|
|
405
383
|
all_outputs = begin
|
|
406
384
|
JSON.parse(output)
|
|
@@ -477,6 +455,11 @@ module Belt
|
|
|
477
455
|
|
|
478
456
|
# ─── Helpers ─────────────────────────────────────────────────────
|
|
479
457
|
|
|
458
|
+
def sanitize_bucket_name(name)
|
|
459
|
+
# S3 bucket names must be DNS-compliant: lowercase, hyphens, no underscores
|
|
460
|
+
name.tr('_', '-').downcase.gsub(/[^a-z0-9\-.]/, '-').gsub(/-{2,}/, '-')
|
|
461
|
+
end
|
|
462
|
+
|
|
480
463
|
def short_table_name(table_name)
|
|
481
464
|
table_name.sub(/\A#{Regexp.escape(@app_name)}-#{Regexp.escape(@env)}-/, '')
|
|
482
465
|
end
|
|
@@ -71,20 +71,26 @@ module Belt
|
|
|
71
71
|
def harden_bucket(bucket, audit)
|
|
72
72
|
unless audit[:versioning]
|
|
73
73
|
enable_versioning(bucket)
|
|
74
|
-
|
|
74
|
+
say_security ' enable versioning'
|
|
75
75
|
end
|
|
76
76
|
unless audit[:encryption]
|
|
77
77
|
enable_encryption(bucket)
|
|
78
|
-
|
|
78
|
+
say_security ' enable AES-256 encryption'
|
|
79
79
|
end
|
|
80
80
|
unless audit[:public_access_block]
|
|
81
81
|
block_public_access(bucket)
|
|
82
|
-
|
|
82
|
+
say_security ' enable public access block'
|
|
83
83
|
end
|
|
84
84
|
return if audit[:tls_policy]
|
|
85
85
|
|
|
86
86
|
apply_tls_policy(bucket)
|
|
87
|
-
|
|
87
|
+
say_security ' enable TLS-only bucket policy'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def say_security(message)
|
|
91
|
+
return if respond_to?(:say, true) && @quiet
|
|
92
|
+
|
|
93
|
+
puts message
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
private
|