belt 0.0.7 → 0.1.1
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +29 -1
- data/README.md +150 -51
- data/exe/belt +6 -0
- data/lib/belt/action_router.rb +7 -1
- data/lib/belt/cli/app_detection.rb +16 -0
- data/lib/belt/cli/bucket_security.rb +122 -0
- data/lib/belt/cli/env_resolver.rb +15 -0
- data/lib/belt/cli/environment_command.rb +77 -0
- data/lib/belt/cli/frontend_command.rb +85 -0
- data/lib/belt/cli/frontend_deploy_command.rb +125 -0
- data/lib/belt/cli/frontend_setup_command.rb +64 -0
- data/lib/belt/cli/generate_command.rb +206 -0
- data/lib/belt/cli/new_command.rb +126 -0
- data/lib/belt/cli/routes_command/route_inference.rb +100 -0
- data/lib/belt/cli/routes_command/schema_loader.rb +71 -0
- data/lib/belt/cli/routes_command.rb +307 -0
- data/lib/belt/cli/setup_command.rb +261 -0
- data/lib/belt/cli/tables_command.rb +138 -0
- data/lib/belt/cli/tasks_command.rb +110 -0
- data/lib/belt/cli/terraform_command.rb +77 -0
- data/lib/belt/cli/views_command.rb +134 -0
- data/lib/belt/cli.rb +117 -0
- data/lib/belt/lambda_handler.rb +16 -0
- data/lib/belt/root.rb +26 -0
- data/lib/belt/route_dsl.rb +605 -0
- data/lib/belt/table_inference.rb +71 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/environment/backend.tf.erb +8 -0
- data/lib/templates/environment/main.tf.erb +42 -0
- data/lib/templates/environment/terraform.tfvars.erb +1 -0
- data/lib/templates/environment/variables.tf.erb +16 -0
- data/lib/templates/frontend/react/index.html.erb +12 -0
- data/lib/templates/frontend/react/package.json.erb +20 -0
- data/lib/templates/frontend/react/src/App.jsx +14 -0
- data/lib/templates/frontend/react/src/index.css +10 -0
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
- data/lib/templates/frontend/react/src/main.jsx +10 -0
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
- data/lib/templates/frontend/react/vite.config.js +8 -0
- data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
- data/lib/templates/generate/controller.rb.erb +59 -0
- data/lib/templates/generate/model.rb.erb +20 -0
- data/lib/templates/new_app/AGENTS.md.erb +130 -0
- data/lib/templates/new_app/Gemfile.erb +5 -0
- data/lib/templates/new_app/README.md.erb +25 -0
- data/lib/templates/new_app/Rakefile.erb +12 -0
- data/lib/templates/new_app/gitignore.erb +14 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
- data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
- data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
- data/lib/templates/new_app/lambda/api.rb.erb +22 -0
- data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
- data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
- data/lib/templates/views/Edit.jsx.erb +38 -0
- data/lib/templates/views/Form.jsx.erb +34 -0
- data/lib/templates/views/Index.jsx.erb +39 -0
- data/lib/templates/views/New.jsx.erb +26 -0
- data/lib/templates/views/Show.jsx.erb +46 -0
- data.tar.gz.sig +0 -0
- metadata +73 -3
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative '../route_dsl'
|
|
7
|
+
require_relative '../table_inference'
|
|
8
|
+
require_relative 'routes_command/schema_loader'
|
|
9
|
+
require_relative 'routes_command/route_inference'
|
|
10
|
+
|
|
11
|
+
module Belt
|
|
12
|
+
module CLI
|
|
13
|
+
class RoutesCommand
|
|
14
|
+
include SchemaLoader
|
|
15
|
+
include RouteInference
|
|
16
|
+
|
|
17
|
+
def self.run(args)
|
|
18
|
+
new(args).run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(args)
|
|
22
|
+
@options = {}
|
|
23
|
+
parse_options(args)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
routes_file = find_routes_file
|
|
28
|
+
abort 'Error: No routes file found. Expected infrastructure/routes.tf.rb' unless routes_file
|
|
29
|
+
|
|
30
|
+
dsl = load_routes(routes_file)
|
|
31
|
+
@table_inference = TableInference.new(@options[:tables_file])
|
|
32
|
+
routes = collect_routes(dsl)
|
|
33
|
+
routes = apply_grep(routes) if @options[:grep]
|
|
34
|
+
|
|
35
|
+
warn 'Warning: --output-dir has no effect without --namespace' if @options[:output_dir] && !@options[:namespace]
|
|
36
|
+
|
|
37
|
+
if @options[:namespace]
|
|
38
|
+
output_ruby(routes, @options[:namespace], routes_file)
|
|
39
|
+
elsif @options[:format] == 'json'
|
|
40
|
+
output = { routes: routes }
|
|
41
|
+
models = load_schema_models(routes_file)
|
|
42
|
+
output[:models] = models if models.any?
|
|
43
|
+
puts JSON.pretty_generate(output)
|
|
44
|
+
else
|
|
45
|
+
output_concise(routes)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_options(args)
|
|
52
|
+
OptionParser.new do |opts|
|
|
53
|
+
opts.banner = 'Usage: belt routes [options]'
|
|
54
|
+
|
|
55
|
+
opts.on('-g', '--grep PATTERN', 'Filter routes matching pattern') do |pattern|
|
|
56
|
+
@options[:grep] = pattern
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on('-f', '--format FORMAT', 'Output format: concise (default), json') do |format|
|
|
60
|
+
@options[:format] = format
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on('--namespace NAMESPACE', 'Generate Ruby route files for NAMESPACE (or "all")') do |ns|
|
|
64
|
+
@options[:namespace] = ns
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
opts.on('--output-dir DIR', 'Output directory for generated files') do |dir|
|
|
68
|
+
@options[:output_dir] = dir
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
opts.on('--schema FILE', 'Path to schema.tf.rb for model definitions') do |file|
|
|
72
|
+
@options[:schema_file] = file
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts.on('--tables-file FILE', 'Path to Terraform file with DynamoDB table definitions') do |file|
|
|
76
|
+
@options[:tables_file] = file
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
80
|
+
puts opts
|
|
81
|
+
exit
|
|
82
|
+
end
|
|
83
|
+
end.parse!(args)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_routes_file
|
|
87
|
+
path = 'infrastructure/routes.tf.rb'
|
|
88
|
+
File.exist?(path) ? path : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def load_routes(file)
|
|
92
|
+
# Reset schema builder for clean state
|
|
93
|
+
Belt.instance_variable_set(:@application, nil)
|
|
94
|
+
|
|
95
|
+
content = File.read(file)
|
|
96
|
+
if content.include?('Belt.application.routes.draw')
|
|
97
|
+
binding_context = binding
|
|
98
|
+
eval(content, binding_context, file) # rubocop:disable Security/Eval
|
|
99
|
+
else
|
|
100
|
+
Belt::RouteDSL.load_from_file(file)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def collect_routes(dsl)
|
|
105
|
+
routes = []
|
|
106
|
+
dsl.api_gateways.each do |gateway|
|
|
107
|
+
gateway.routes.each do |route|
|
|
108
|
+
routes << build_route_hash(route, gateway)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
routes.sort_by { |r| route_specificity(r[:path], r[:verb]) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_route_hash(route, gateway)
|
|
115
|
+
hash = {
|
|
116
|
+
name: extract_route_name(route.path),
|
|
117
|
+
verb: route.method,
|
|
118
|
+
path: normalize_path(route.path),
|
|
119
|
+
gateway: gateway.name,
|
|
120
|
+
lambda: route.lambda.to_s,
|
|
121
|
+
controller: infer_controller(route, gateway),
|
|
122
|
+
action: infer_action(route, gateway),
|
|
123
|
+
auth: route.auth.to_s,
|
|
124
|
+
tables: get_route_tables(route),
|
|
125
|
+
request_model: route.request_model.to_s,
|
|
126
|
+
response_model: route.response_model.to_s
|
|
127
|
+
}
|
|
128
|
+
rc = route.response_context.to_s
|
|
129
|
+
hash[:response_context] = rc unless rc.empty?
|
|
130
|
+
hash
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get_route_tables(route)
|
|
134
|
+
if route.tables.any?
|
|
135
|
+
route.tables.map(&:to_s)
|
|
136
|
+
else
|
|
137
|
+
@table_inference.infer_tables_from_route(route)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def extract_route_name(path)
|
|
142
|
+
segments = path.split('/').reject(&:empty?)
|
|
143
|
+
return 'root' if segments.empty?
|
|
144
|
+
|
|
145
|
+
segments.reject { |s| s.start_with?('{', ':') }
|
|
146
|
+
.map { |s| s.gsub('-', '_') }
|
|
147
|
+
.join('_')
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def output_ruby(routes, namespace, _routes_file)
|
|
151
|
+
output_dir = @options[:output_dir] || File.join(Belt.root, 'lambda/lib/routes')
|
|
152
|
+
FileUtils.mkdir_p(output_dir)
|
|
153
|
+
puts "Writing to #{output_dir}/:"
|
|
154
|
+
|
|
155
|
+
if namespace == 'all'
|
|
156
|
+
generate_all_manifests(routes, output_dir)
|
|
157
|
+
else
|
|
158
|
+
# Generate gateway-based manifest (all routes for this gateway)
|
|
159
|
+
filtered = routes.select { |r| r[:gateway] == namespace }
|
|
160
|
+
# Fall back to lambda-based if no gateway match
|
|
161
|
+
filtered = routes.select { |r| r[:lambda] == namespace } if filtered.empty?
|
|
162
|
+
if filtered.empty?
|
|
163
|
+
warn "No routes found for namespace '#{namespace}' - skipping"
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
write_ruby_manifest(filtered, namespace, output_dir)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def generate_all_manifests(routes, output_dir)
|
|
171
|
+
# Gateway-based manifests (primary — used by main Lambda entry points)
|
|
172
|
+
by_gateway = routes.group_by { |r| r[:gateway] }
|
|
173
|
+
by_gateway.each { |gw, gw_routes| write_ruby_manifest(gw_routes, gw, output_dir) }
|
|
174
|
+
|
|
175
|
+
# Scoped lambda manifests (where lambda != gateway — separate Lambda functions)
|
|
176
|
+
scoped = routes.reject { |r| r[:lambda] == r[:gateway] }
|
|
177
|
+
by_lambda = scoped.group_by { |r| r[:lambda] }
|
|
178
|
+
by_lambda.each { |lam, lam_routes| write_ruby_manifest(lam_routes, lam, output_dir) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def write_ruby_manifest(routes, name, output_dir)
|
|
182
|
+
output_file = File.join(output_dir, "#{name}_routes.rb")
|
|
183
|
+
content = generate_ruby_content(routes, name)
|
|
184
|
+
File.write(output_file, content)
|
|
185
|
+
puts " ✅ #{name}_routes.rb (#{routes.length} routes)"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def generate_ruby_content(routes, namespace)
|
|
189
|
+
constant_name = namespace.upcase
|
|
190
|
+
lines = [
|
|
191
|
+
'# frozen_string_literal: true',
|
|
192
|
+
'',
|
|
193
|
+
"# Auto-generated by: belt routes --namespace #{namespace}",
|
|
194
|
+
'# Do not edit manually',
|
|
195
|
+
'',
|
|
196
|
+
'module Routes',
|
|
197
|
+
" #{constant_name} = ["
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
routes.each_with_index do |route, index|
|
|
201
|
+
lines << ' {'
|
|
202
|
+
lines << " verb: #{route[:verb].inspect},"
|
|
203
|
+
lines << " path: #{route[:path].inspect},"
|
|
204
|
+
lines << " gateway: #{route[:gateway].inspect},"
|
|
205
|
+
lines << " lambda: #{route[:lambda].inspect},"
|
|
206
|
+
lines << " controller: #{route[:controller].inspect},"
|
|
207
|
+
lines << " action: #{route[:action].inspect},"
|
|
208
|
+
lines << " auth: #{route[:auth].inspect},"
|
|
209
|
+
tables_syms = route[:tables].map { |t| ":#{t}" }.join(', ')
|
|
210
|
+
lines << " tables: [#{tables_syms}]"
|
|
211
|
+
lines << " }#{',' if index < routes.length - 1}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
lines << ' ].freeze'
|
|
215
|
+
lines << 'end'
|
|
216
|
+
lines << ''
|
|
217
|
+
lines.join("\n")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def normalize_path(path)
|
|
221
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
222
|
+
normalized = path.gsub(%r{/([a-zA-Z_][a-zA-Z0-9_]*?)/:id(/|$)}) do
|
|
223
|
+
resource = ::Regexp.last_match(1)
|
|
224
|
+
trailing = ::Regexp.last_match(2)
|
|
225
|
+
singular = singularize(resource)
|
|
226
|
+
"/#{resource}/{#{singular}_id}#{trailing}"
|
|
227
|
+
end
|
|
228
|
+
normalized.gsub(/:([a-zA-Z_][a-zA-Z0-9_]*)/) { "{#{::Regexp.last_match(1)}}" }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def apply_grep(routes)
|
|
232
|
+
pattern = Regexp.new(@options[:grep], Regexp::IGNORECASE)
|
|
233
|
+
routes.select do |r|
|
|
234
|
+
r[:path].match?(pattern) ||
|
|
235
|
+
r[:gateway].to_s.match?(pattern) ||
|
|
236
|
+
r[:lambda].match?(pattern) ||
|
|
237
|
+
r[:verb].match?(pattern) ||
|
|
238
|
+
r[:controller].match?(pattern) ||
|
|
239
|
+
r[:action].match?(pattern)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def output_concise(routes)
|
|
244
|
+
return puts('No routes defined.') if routes.empty?
|
|
245
|
+
|
|
246
|
+
multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
|
|
247
|
+
verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
|
|
248
|
+
path_w = [routes.map { |r| r[:path].length }.max, 4].max
|
|
249
|
+
|
|
250
|
+
if multi_gateway
|
|
251
|
+
output_concise_multi_gateway(routes, verb_w, path_w)
|
|
252
|
+
else
|
|
253
|
+
output_concise_single_gateway(routes, verb_w, path_w)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def output_concise_multi_gateway(routes, verb_w, path_w)
|
|
258
|
+
gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
|
|
259
|
+
lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
|
|
260
|
+
|
|
261
|
+
header = "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} " \
|
|
262
|
+
"#{'GATEWAY'.ljust(gw_w)} #{'LAMBDA'.ljust(lam_w)} CONTROLLER#ACTION"
|
|
263
|
+
puts header
|
|
264
|
+
puts '-' * (verb_w + path_w + gw_w + lam_w + 20)
|
|
265
|
+
|
|
266
|
+
routes.each do |r|
|
|
267
|
+
line = "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} " \
|
|
268
|
+
"#{r[:gateway].to_s.ljust(gw_w)} #{r[:lambda].ljust(lam_w)} " \
|
|
269
|
+
"#{r[:controller]}##{r[:action]}"
|
|
270
|
+
puts line
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def output_concise_single_gateway(routes, verb_w, path_w)
|
|
275
|
+
puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
|
|
276
|
+
puts '-' * (verb_w + path_w + 30)
|
|
277
|
+
|
|
278
|
+
routes.each do |r|
|
|
279
|
+
puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def route_specificity(path, verb)
|
|
284
|
+
segments = path.split('/').reject(&:empty?)
|
|
285
|
+
param_count = segments.count { |s| s.start_with?('{') }
|
|
286
|
+
segment_count = segments.length
|
|
287
|
+
[param_count, -segment_count, path, verb_order(verb)]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def verb_order(verb)
|
|
291
|
+
{ 'GET' => 0, 'POST' => 1, 'PUT' => 2, 'PATCH' => 3, 'DELETE' => 4 }[verb] || 99
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def singularize(word)
|
|
295
|
+
if word.end_with?('ies')
|
|
296
|
+
"#{word[0..-4]}y"
|
|
297
|
+
elsif word.end_with?('ses') || word.end_with?('xes') || word.end_with?('zes')
|
|
298
|
+
word[0..-3]
|
|
299
|
+
elsif word.end_with?('s') && !word.end_with?('ss')
|
|
300
|
+
word[0..-2]
|
|
301
|
+
else
|
|
302
|
+
word
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require_relative 'app_detection'
|
|
7
|
+
require_relative 'bucket_security'
|
|
8
|
+
require_relative 'tables_command'
|
|
9
|
+
require_relative 'frontend_setup_command'
|
|
10
|
+
|
|
11
|
+
module Belt
|
|
12
|
+
module CLI
|
|
13
|
+
class SetupCommand
|
|
14
|
+
SUBCOMMANDS = %w[state tables frontend].freeze
|
|
15
|
+
|
|
16
|
+
SECURITY_CHECKS = %i[versioning encryption public_access_block tls_policy].freeze
|
|
17
|
+
|
|
18
|
+
include AppDetection
|
|
19
|
+
include BucketSecurity
|
|
20
|
+
|
|
21
|
+
def self.run(args)
|
|
22
|
+
subcommand = args.shift
|
|
23
|
+
|
|
24
|
+
case subcommand
|
|
25
|
+
when 'state'
|
|
26
|
+
new(args).run_state_setup
|
|
27
|
+
when 'tables'
|
|
28
|
+
Belt::CLI::TablesCommand.run(args)
|
|
29
|
+
when 'frontend'
|
|
30
|
+
Belt::CLI::FrontendSetupCommand.run(args)
|
|
31
|
+
else
|
|
32
|
+
puts 'Usage: belt setup <state|tables|frontend> [options]'
|
|
33
|
+
puts "\nSubcommands:"
|
|
34
|
+
puts ' state Set up S3 bucket for Terraform state'
|
|
35
|
+
puts ' tables Generate DynamoDB table definitions from schema.tf.rb'
|
|
36
|
+
puts ' frontend Generate S3 + CloudFront infrastructure for frontend hosting'
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(args = [])
|
|
42
|
+
@app_name = detect_app_name
|
|
43
|
+
@env_name = nil
|
|
44
|
+
@custom_bucket = nil
|
|
45
|
+
@select_mode = false
|
|
46
|
+
|
|
47
|
+
parse_args(args)
|
|
48
|
+
|
|
49
|
+
@region = detect_region
|
|
50
|
+
@bucket_name = resolve_bucket_name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_state_setup
|
|
54
|
+
unless aws_configured?
|
|
55
|
+
puts '✗ AWS credentials not configured. Set AWS_PROFILE or configure aws sso login.'
|
|
56
|
+
exit 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@bucket_name = interactive_bucket_selection if @select_mode
|
|
60
|
+
setup_or_verify_bucket
|
|
61
|
+
apply_lifecycle(@bucket_name)
|
|
62
|
+
puts ' ensure lifecycle rules (90-day noncurrent expiration)'
|
|
63
|
+
update_backend_config
|
|
64
|
+
print_success_message
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def setup_or_verify_bucket
|
|
68
|
+
if bucket_exists?(@bucket_name)
|
|
69
|
+
verify_existing_bucket
|
|
70
|
+
else
|
|
71
|
+
create_new_bucket
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def verify_existing_bucket
|
|
76
|
+
puts "Found existing bucket: #{@bucket_name}"
|
|
77
|
+
audit = audit_bucket_security(@bucket_name)
|
|
78
|
+
print_security_audit(audit)
|
|
79
|
+
|
|
80
|
+
if audit.values.all?
|
|
81
|
+
puts "\n✓ Bucket '#{@bucket_name}' passes all security checks"
|
|
82
|
+
else
|
|
83
|
+
prompt_and_harden(audit)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prompt_and_harden(audit)
|
|
88
|
+
puts "\n⚠ Bucket '#{@bucket_name}' has security issues."
|
|
89
|
+
print "\nApply security hardening? [Y/n] "
|
|
90
|
+
response = $stdin.gets&.strip&.downcase
|
|
91
|
+
if response.nil? || response.empty? || response == 'y'
|
|
92
|
+
harden_bucket(@bucket_name, audit)
|
|
93
|
+
else
|
|
94
|
+
puts '✗ Refusing to use insecure bucket. Fix manually or choose a different bucket.'
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_new_bucket
|
|
100
|
+
puts "Creating state bucket: #{@bucket_name} (#{@region})"
|
|
101
|
+
create_bucket(@bucket_name)
|
|
102
|
+
puts " create s3://#{@bucket_name}"
|
|
103
|
+
harden_bucket(@bucket_name, {})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def print_success_message
|
|
107
|
+
puts "\n✓ State bucket '#{@bucket_name}' is ready!"
|
|
108
|
+
if @env_name
|
|
109
|
+
puts "\n cd infrastructure/#{@env_name} && terraform init"
|
|
110
|
+
else
|
|
111
|
+
puts "\n cd infrastructure/<env> && terraform init"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def parse_args(args)
|
|
118
|
+
while (arg = args.shift)
|
|
119
|
+
case arg
|
|
120
|
+
when '--bucket'
|
|
121
|
+
@custom_bucket = args.shift
|
|
122
|
+
abort '✗ --bucket requires a value' unless @custom_bucket
|
|
123
|
+
when '--select'
|
|
124
|
+
@select_mode = true
|
|
125
|
+
when '--help', '-h'
|
|
126
|
+
self.class.run([])
|
|
127
|
+
else
|
|
128
|
+
@env_name = arg unless arg.start_with?('-')
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def resolve_bucket_name
|
|
134
|
+
if @custom_bucket
|
|
135
|
+
@custom_bucket
|
|
136
|
+
elsif @env_name
|
|
137
|
+
"#{@app_name}-terraform-state-#{@env_name}"
|
|
138
|
+
else
|
|
139
|
+
"#{@app_name}-terraform-state"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- Interactive selection ---
|
|
144
|
+
|
|
145
|
+
def interactive_bucket_selection
|
|
146
|
+
puts "Listing S3 buckets in account...\n\n"
|
|
147
|
+
buckets = list_buckets
|
|
148
|
+
if buckets.empty?
|
|
149
|
+
puts 'No buckets found. Creating a new one.'
|
|
150
|
+
return @bucket_name
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Show buckets with index
|
|
154
|
+
buckets.each_with_index do |b, i|
|
|
155
|
+
puts " [#{i + 1}] #{b}"
|
|
156
|
+
end
|
|
157
|
+
puts " [N] Create new bucket (#{@bucket_name})"
|
|
158
|
+
puts ''
|
|
159
|
+
print "Select bucket [1-#{buckets.size}] or N for new: "
|
|
160
|
+
choice = $stdin.gets&.strip
|
|
161
|
+
|
|
162
|
+
if choice.nil? || choice.downcase == 'n' || choice.empty?
|
|
163
|
+
@bucket_name
|
|
164
|
+
else
|
|
165
|
+
idx = choice.to_i - 1
|
|
166
|
+
if idx >= 0 && idx < buckets.size
|
|
167
|
+
buckets[idx]
|
|
168
|
+
else
|
|
169
|
+
puts '✗ Invalid selection'
|
|
170
|
+
exit 1
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def list_buckets
|
|
176
|
+
output = safe_capture('aws', 's3api', 'list-buckets', '--query', 'Buckets[].Name', '--output', 'json')
|
|
177
|
+
return [] unless output
|
|
178
|
+
|
|
179
|
+
JSON.parse(output)
|
|
180
|
+
rescue JSON::ParserError
|
|
181
|
+
[]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# --- AWS operations ---
|
|
185
|
+
|
|
186
|
+
def aws_configured?
|
|
187
|
+
system('aws', 'sts', 'get-caller-identity', out: File::NULL, err: File::NULL)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def bucket_exists?(bucket)
|
|
191
|
+
system('aws', 's3api', 'head-bucket', '--bucket', bucket, err: File::NULL)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def create_bucket(bucket)
|
|
195
|
+
args = ['aws', 's3api', 'create-bucket', '--bucket', bucket, '--region', @region]
|
|
196
|
+
args.push('--create-bucket-configuration', "LocationConstraint=#{@region}") unless @region == 'us-east-1'
|
|
197
|
+
run!(*args)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def apply_lifecycle(bucket)
|
|
201
|
+
lifecycle = {
|
|
202
|
+
Rules: [{
|
|
203
|
+
ID: 'expire-noncurrent-versions',
|
|
204
|
+
Status: 'Enabled',
|
|
205
|
+
Filter: {},
|
|
206
|
+
NoncurrentVersionExpiration: { NoncurrentDays: 90 },
|
|
207
|
+
AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 }
|
|
208
|
+
}]
|
|
209
|
+
}
|
|
210
|
+
run!('aws', 's3api', 'put-bucket-lifecycle-configuration', '--bucket', bucket,
|
|
211
|
+
'--lifecycle-configuration', JSON.generate(lifecycle))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def run!(*args)
|
|
215
|
+
return if system(*args)
|
|
216
|
+
|
|
217
|
+
puts "\n✗ Command failed: #{args.shelljoin}"
|
|
218
|
+
exit 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def safe_capture(*)
|
|
222
|
+
output, status = Open3.capture2(*, err: File::NULL)
|
|
223
|
+
status.success? ? output : nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# --- Backend config ---
|
|
227
|
+
|
|
228
|
+
def update_backend_config
|
|
229
|
+
dirs = if @env_name
|
|
230
|
+
[File.join('infrastructure', @env_name)]
|
|
231
|
+
else
|
|
232
|
+
Dir.glob('infrastructure/*/').select { |d| File.exist?(File.join(d, 'backend.tf')) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
dirs.each do |env_dir|
|
|
236
|
+
next unless Dir.exist?(env_dir)
|
|
237
|
+
|
|
238
|
+
backend_file = File.join(env_dir, 'backend.tf')
|
|
239
|
+
next unless File.exist?(backend_file)
|
|
240
|
+
|
|
241
|
+
content = File.read(backend_file)
|
|
242
|
+
updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@bucket_name}\"")
|
|
243
|
+
if updated != content
|
|
244
|
+
File.write(backend_file, updated)
|
|
245
|
+
puts " update #{backend_file} → bucket = \"#{@bucket_name}\""
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# --- Detection ---
|
|
251
|
+
|
|
252
|
+
def detect_region
|
|
253
|
+
Dir.glob('infrastructure/*/backend.tf').each do |f|
|
|
254
|
+
match = File.read(f).match(/region\s*=\s*"([^"]+)"/)
|
|
255
|
+
return match[1] if match
|
|
256
|
+
end
|
|
257
|
+
'us-east-1'
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'app_detection'
|
|
4
|
+
require_relative 'env_resolver'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class TablesCommand
|
|
9
|
+
SCHEMA_FILE = 'infrastructure/schema.tf.rb'
|
|
10
|
+
|
|
11
|
+
include AppDetection
|
|
12
|
+
|
|
13
|
+
def self.run(args)
|
|
14
|
+
env = EnvResolver.resolve(args)
|
|
15
|
+
|
|
16
|
+
if env.nil?
|
|
17
|
+
puts 'Usage: belt setup tables <environment>'
|
|
18
|
+
puts "\nReads schema.tf.rb and generates dynamodb.tf in the environment directory."
|
|
19
|
+
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
20
|
+
puts "\nExamples:"
|
|
21
|
+
puts ' belt setup tables wups'
|
|
22
|
+
puts ' belt setup tables dev01'
|
|
23
|
+
puts ' BELT_ENV=wups belt setup tables'
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
new(env).run
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(env)
|
|
31
|
+
@env = env
|
|
32
|
+
@app_name = detect_app_name
|
|
33
|
+
@env_dir = "infrastructure/#{@env}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
validate!
|
|
38
|
+
models = parse_schema
|
|
39
|
+
if models.empty?
|
|
40
|
+
puts "No models found in #{SCHEMA_FILE}"
|
|
41
|
+
exit 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
generate_dynamodb_tf(models)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate!
|
|
50
|
+
abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless File.exist?(SCHEMA_FILE)
|
|
51
|
+
return if Dir.exist?(@env_dir)
|
|
52
|
+
|
|
53
|
+
abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
|
|
54
|
+
"Create it with: belt generate environment #{@env}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_schema
|
|
58
|
+
parser = SchemaParser.new
|
|
59
|
+
schema_content = File.read(SCHEMA_FILE)
|
|
60
|
+
|
|
61
|
+
# Replace DSL wrapper with direct parser call
|
|
62
|
+
# Belt.application.schema.draw do ... end → parser.instance_eval do ... end
|
|
63
|
+
inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\nend\s*\z/, '')
|
|
64
|
+
parser.instance_eval(inner, SCHEMA_FILE)
|
|
65
|
+
parser.models
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generate_dynamodb_tf(models)
|
|
69
|
+
dest = File.join(@env_dir, 'dynamodb.tf')
|
|
70
|
+
content = render_dynamodb(models)
|
|
71
|
+
File.write(dest, content)
|
|
72
|
+
puts " create #{dest}"
|
|
73
|
+
puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
|
|
74
|
+
models.each { |m| puts " • #{table_name(m[:name])}" }
|
|
75
|
+
puts "\nRun `belt apply #{@env}` to create them."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_dynamodb(models)
|
|
79
|
+
blocks = models.map { |m| render_table(m) }
|
|
80
|
+
"# Auto-generated by Belt from schema.tf.rb\n" \
|
|
81
|
+
"# Do not edit manually — re-run `belt setup tables #{@env}`\n\n#{blocks.join("\n\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_table(model)
|
|
85
|
+
name = table_name(model[:name])
|
|
86
|
+
<<~HCL
|
|
87
|
+
resource "aws_dynamodb_table" "#{model[:name]}s" {
|
|
88
|
+
name = "#{name}"
|
|
89
|
+
billing_mode = "PAY_PER_REQUEST"
|
|
90
|
+
hash_key = "id"
|
|
91
|
+
|
|
92
|
+
attribute {
|
|
93
|
+
name = "id"
|
|
94
|
+
type = "S"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
tags = {
|
|
98
|
+
Name = "#{name}"
|
|
99
|
+
Environment = var.environment
|
|
100
|
+
ManagedBy = "Terraform"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
HCL
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def table_name(model_name)
|
|
107
|
+
"#{@app_name}-#{@env}-#{model_name}s"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Minimal DSL parser for schema.tf.rb
|
|
111
|
+
class SchemaParser
|
|
112
|
+
attr_reader :models
|
|
113
|
+
|
|
114
|
+
def initialize
|
|
115
|
+
@models = []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def model(name, &block)
|
|
119
|
+
model_def = ModelParser.new
|
|
120
|
+
model_def.instance_eval(&block) if block
|
|
121
|
+
@models << { name: name.to_s, fields: model_def.fields }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class ModelParser
|
|
126
|
+
attr_reader :fields
|
|
127
|
+
|
|
128
|
+
def initialize
|
|
129
|
+
@fields = []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field(name, **opts)
|
|
133
|
+
@fields << { name: name.to_s, type: opts[:type]&.to_s || 'string' }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|