belt 0.2.9 ā 0.2.11
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 +119 -0
- data/README.md +217 -8
- data/lib/belt/action_router.rb +33 -5
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/destroy_command.rb +119 -10
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +38 -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/plugin_command.rb +221 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +8 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/cors_origin.rb +19 -2
- data/lib/belt/helpers/response.rb +48 -6
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/lambda_handler.rb +14 -5
- 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 -1
- 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
- data/lib/templates/plugin/AGENTS.md.erb +135 -0
- data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
- data/lib/templates/plugin/Gemfile.erb +11 -0
- data/lib/templates/plugin/LICENSE.erb +21 -0
- data/lib/templates/plugin/README.md.erb +63 -0
- data/lib/templates/plugin/Rakefile.erb +7 -0
- data/lib/templates/plugin/gemspec.erb +26 -0
- data/lib/templates/plugin/gitignore.erb +11 -0
- data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
- data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
- data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
- data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
- data/lib/templates/plugin/lib/entry.rb.erb +3 -0
- data/lib/templates/plugin/rspec.erb +3 -0
- data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
- data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
- metadata +37 -1
|
@@ -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
|
|
@@ -9,6 +9,7 @@ require_relative 'terraform_command'
|
|
|
9
9
|
require_relative 'backup_config'
|
|
10
10
|
require_relative 'backup_runner'
|
|
11
11
|
require_relative 'environment_config'
|
|
12
|
+
require_relative 'path_gem_materializer'
|
|
12
13
|
|
|
13
14
|
module Belt
|
|
14
15
|
module CLI
|
|
@@ -150,6 +151,7 @@ module Belt
|
|
|
150
151
|
puts "belt ā deploying #{@env} (in #{env_dir}/)\n\n"
|
|
151
152
|
|
|
152
153
|
ensure_lockfile_consistent!
|
|
154
|
+
warn_active_path_gems!
|
|
153
155
|
generate_routes_if_needed
|
|
154
156
|
run_backups unless @skip_backup
|
|
155
157
|
|
|
@@ -457,9 +459,55 @@ module Belt
|
|
|
457
459
|
FileUtils.cp(gemfile, build_dir) if File.exist?(gemfile)
|
|
458
460
|
FileUtils.cp(lockfile, build_dir) if File.exist?(lockfile)
|
|
459
461
|
|
|
462
|
+
copy_vendor_cache(build_dir)
|
|
463
|
+
materialize_path_gems!(build_dir)
|
|
464
|
+
|
|
460
465
|
puts ' š Copied handlers, controllers, models, lib, config'
|
|
461
466
|
end
|
|
462
467
|
|
|
468
|
+
# Stage prebuilt .gem files so Docker `bundle install` can install unreleased
|
|
469
|
+
# gems as normal package installs (with specifications/), not path/git layouts.
|
|
470
|
+
# Prefer project-root vendor/cache (next to Gemfile ā Bundler's natural path);
|
|
471
|
+
# fall back to lambda/vendor/cache for older layouts.
|
|
472
|
+
def copy_vendor_cache(build_dir)
|
|
473
|
+
cache_dir = [
|
|
474
|
+
File.join(@project_root, 'vendor', 'cache'),
|
|
475
|
+
File.join(@project_root, 'lambda', 'vendor', 'cache')
|
|
476
|
+
].find { |dir| Dir.exist?(dir) && !Dir.empty?(dir) }
|
|
477
|
+
return unless cache_dir
|
|
478
|
+
|
|
479
|
+
dest = File.join(build_dir, 'vendor', 'cache')
|
|
480
|
+
FileUtils.mkdir_p(dest)
|
|
481
|
+
FileUtils.cp_r(Dir.glob(File.join(cache_dir, '*')), dest)
|
|
482
|
+
gem_count = Dir.glob(File.join(dest, '*.gem')).size
|
|
483
|
+
puts " š¦ Copied vendor/cache (#{gem_count} local gem(s))"
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# path: gems install under bundler/gems/ with no specifications/ ā Lambda's
|
|
487
|
+
# bare `require 'belt'` can't see them. Build real .gem files into the
|
|
488
|
+
# package's vendor/cache and pin versions in the *build* Gemfile/lock only.
|
|
489
|
+
def materialize_path_gems!(build_dir)
|
|
490
|
+
gems = PathGemMaterializer.materialize!(build_dir, project_root: @project_root)
|
|
491
|
+
return if gems.empty?
|
|
492
|
+
|
|
493
|
+
puts " š§ Materialized path gem(s) ā vendor/cache: #{gems.join(', ')}"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# path: gems need host-side materialize before Docker install. --rebuild
|
|
497
|
+
# always does it. Full terraform apply needs a conveyor-belt build that
|
|
498
|
+
# includes path-gem materialize (discord-vendor-cache-gemfile-parent+).
|
|
499
|
+
def warn_active_path_gems!
|
|
500
|
+
lockfile = File.join(@project_root, 'Gemfile.lock')
|
|
501
|
+
return unless File.exist?(lockfile)
|
|
502
|
+
return unless File.read(lockfile).match?(/^PATH\n/)
|
|
503
|
+
|
|
504
|
+
puts ' ā Gemfile.lock has PATH gems (path: in Gemfile).'
|
|
505
|
+
puts " Safe now: belt deploy #{@env} --rebuild (always materializes)."
|
|
506
|
+
puts ' Full terraform apply needs conveyor-belt with path-gem materialize.'
|
|
507
|
+
puts ' Or pin a version + drop a built .gem in vendor/cache/'
|
|
508
|
+
puts ''
|
|
509
|
+
end
|
|
510
|
+
|
|
463
511
|
def build_gems(build_dir)
|
|
464
512
|
puts ' š³ Building gems in Docker (Lambda-compatible)...'
|
|
465
513
|
|
|
@@ -44,10 +44,11 @@ module Belt
|
|
|
44
44
|
when 'environment'
|
|
45
45
|
name = args.shift
|
|
46
46
|
if name.nil? || name.empty?
|
|
47
|
-
puts 'Usage: belt destroy environment <name>'
|
|
47
|
+
puts 'Usage: belt destroy environment <name> [--force] [--skip-terraform]'
|
|
48
48
|
exit 1
|
|
49
49
|
end
|
|
50
|
-
|
|
50
|
+
flags = parse_environment_flags(args)
|
|
51
|
+
new(generator, name, [], **flags).destroy
|
|
51
52
|
when 'frontend'
|
|
52
53
|
new(generator, nil, []).destroy
|
|
53
54
|
when 'views'
|
|
@@ -67,6 +68,19 @@ module Belt
|
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
|
|
71
|
+
def self.parse_environment_flags(args)
|
|
72
|
+
flags = { force: false, skip_terraform: false }
|
|
73
|
+
args.each do |arg|
|
|
74
|
+
case arg
|
|
75
|
+
when '--force', '-f'
|
|
76
|
+
flags[:force] = true
|
|
77
|
+
when '--skip-terraform'
|
|
78
|
+
flags[:skip_terraform] = true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
flags
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
def self.parse_field(arg)
|
|
71
85
|
name, type = arg.split(':', 2)
|
|
72
86
|
{ name: name, type: type || 'string' }
|
|
@@ -84,26 +98,37 @@ module Belt
|
|
|
84
98
|
resource Alias for scaffold
|
|
85
99
|
model Remove an ActiveItem model
|
|
86
100
|
controller Remove a controller
|
|
87
|
-
environment Remove a deployment environment
|
|
101
|
+
environment Remove a deployment environment and tear down infrastructure
|
|
88
102
|
frontend Remove the frontend/ directory
|
|
89
103
|
views Remove React pages for a resource
|
|
90
104
|
|
|
105
|
+
Environment options:
|
|
106
|
+
--force, -f Skip all prompts (CI mode)
|
|
107
|
+
--skip-terraform Delete local files without running terraform destroy
|
|
108
|
+
|
|
91
109
|
Examples:
|
|
92
110
|
belt d scaffold post title:string body:text status:string
|
|
93
111
|
belt d model user
|
|
94
112
|
belt d controller comments
|
|
95
113
|
belt d environment staging
|
|
114
|
+
belt d environment dev --skip-terraform
|
|
115
|
+
belt d environment dev --force
|
|
96
116
|
belt d frontend
|
|
97
117
|
belt d views post
|
|
98
118
|
|
|
99
119
|
ā This is destructive. Files will be permanently deleted.
|
|
120
|
+
|
|
121
|
+
For environments: if terraform state is detected, you'll be prompted to run
|
|
122
|
+
`terraform destroy` first to tear down cloud resources before removing files.
|
|
100
123
|
HELP
|
|
101
124
|
end
|
|
102
125
|
|
|
103
|
-
def initialize(generator, name, fields)
|
|
126
|
+
def initialize(generator, name, fields, force: false, skip_terraform: false)
|
|
104
127
|
@generator = generator
|
|
105
128
|
@name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
|
|
106
129
|
@fields = fields
|
|
130
|
+
@force = force
|
|
131
|
+
@skip_terraform = skip_terraform
|
|
107
132
|
@app_name = detect_namespace
|
|
108
133
|
@singular_name = @name ? Belt::Inflector.singularize(@name) : nil
|
|
109
134
|
@resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
|
|
@@ -149,15 +174,99 @@ module Belt
|
|
|
149
174
|
|
|
150
175
|
def destroy_environment
|
|
151
176
|
dir = "infrastructure/#{@name}"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@removed << dir
|
|
155
|
-
puts " remove #{dir}/"
|
|
156
|
-
puts "\nā Environment '#{@name}' destroyed!"
|
|
157
|
-
else
|
|
177
|
+
|
|
178
|
+
unless Dir.exist?(dir)
|
|
158
179
|
puts "ā Environment '#{@name}' not found at #{dir}/"
|
|
159
180
|
exit 1
|
|
160
181
|
end
|
|
182
|
+
|
|
183
|
+
# Check if terraform state exists (infra may still be live)
|
|
184
|
+
if !@skip_terraform && terraform_state_exists?(dir)
|
|
185
|
+
puts "ā Environment '#{@name}' appears to have active infrastructure."
|
|
186
|
+
puts " Terraform state was found ā resources may still be running.\n\n"
|
|
187
|
+
|
|
188
|
+
if @force
|
|
189
|
+
puts ' --force passed, skipping terraform destroy.'
|
|
190
|
+
else
|
|
191
|
+
puts ' Options:'
|
|
192
|
+
puts ' 1) Run `terraform destroy` to tear down infrastructure first (recommended)'
|
|
193
|
+
puts ' 2) Skip terraform and just delete the local files (--skip-terraform)'
|
|
194
|
+
puts " 3) Cancel\n\n"
|
|
195
|
+
|
|
196
|
+
print " Run terraform destroy for '#{@name}'? [y/N/skip] "
|
|
197
|
+
response = $stdin.gets&.strip&.downcase
|
|
198
|
+
|
|
199
|
+
case response
|
|
200
|
+
when 'y', 'yes'
|
|
201
|
+
run_terraform_destroy(dir)
|
|
202
|
+
when 'skip', 's'
|
|
203
|
+
puts ' Skipping terraform destroy.'
|
|
204
|
+
else
|
|
205
|
+
puts 'Cancelled.'
|
|
206
|
+
exit 0
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Final confirmation before deleting files
|
|
212
|
+
unless @force
|
|
213
|
+
print "\nPermanently delete #{dir}/? [y/N] "
|
|
214
|
+
response = $stdin.gets&.strip&.downcase
|
|
215
|
+
unless response&.start_with?('y')
|
|
216
|
+
puts 'Cancelled.'
|
|
217
|
+
exit 0
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
FileUtils.rm_rf(dir)
|
|
222
|
+
@removed << dir
|
|
223
|
+
puts " remove #{dir}/"
|
|
224
|
+
puts "\nā Environment '#{@name}' destroyed!"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def terraform_state_exists?(dir)
|
|
228
|
+
# Check for local .terraform directory (initialized state)
|
|
229
|
+
return true if Dir.exist?(File.join(dir, '.terraform'))
|
|
230
|
+
|
|
231
|
+
# Check if backend.tf exists (remote state configured)
|
|
232
|
+
backend_file = File.join(dir, 'backend.tf')
|
|
233
|
+
return false unless File.exist?(backend_file)
|
|
234
|
+
|
|
235
|
+
# Try to query remote state ā if terraform is initialized and state exists,
|
|
236
|
+
# the environment likely has live resources
|
|
237
|
+
Dir.chdir(dir) do
|
|
238
|
+
# Quick check: does `terraform show` return anything?
|
|
239
|
+
output = `terraform show -no-color 2>&1`
|
|
240
|
+
Process.last_status.success? && !output.strip.empty? && !output.include?('No state')
|
|
241
|
+
end
|
|
242
|
+
rescue StandardError
|
|
243
|
+
# If we can't determine state, assume it might exist and warn
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def run_terraform_destroy(dir)
|
|
248
|
+
puts "\nāāā terraform destroy (#{@name}) āāā"
|
|
249
|
+
|
|
250
|
+
Dir.chdir(dir) do
|
|
251
|
+
# Initialize if needed
|
|
252
|
+
unless Dir.exist?('.terraform')
|
|
253
|
+
puts ' Initializing terraform...'
|
|
254
|
+
unless system('terraform', 'init', '-input=false')
|
|
255
|
+
puts "\nā terraform init failed. You may need to destroy manually:"
|
|
256
|
+
puts " cd #{dir} && terraform init && terraform destroy"
|
|
257
|
+
exit 1
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Run destroy with auto-approve (user already confirmed)
|
|
262
|
+
unless system('terraform', 'destroy', '-auto-approve')
|
|
263
|
+
puts "\nā terraform destroy failed."
|
|
264
|
+
puts ' Infrastructure may still be running. Fix and retry, or use --skip-terraform.'
|
|
265
|
+
exit 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
puts ' ā Infrastructure destroyed.'
|
|
161
270
|
end
|
|
162
271
|
|
|
163
272
|
def destroy_frontend
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class DoctorCommand
|
|
9
|
+
REQUIRED_TOOLS = [
|
|
10
|
+
{ name: 'aws', check: %w[aws --version],
|
|
11
|
+
install_url: 'https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html' },
|
|
12
|
+
{ name: 'terraform', check: %w[terraform --version],
|
|
13
|
+
install_url: 'https://developer.hashicorp.com/terraform/install' },
|
|
14
|
+
{ name: 'ruby', check: %w[ruby --version], min_version: '3.3' },
|
|
15
|
+
{ name: 'bundler', check: %w[bundle --version], install_hint: 'gem install bundler' }
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.run(args)
|
|
19
|
+
verbose = args.include?('--verbose') || args.include?('-v')
|
|
20
|
+
|
|
21
|
+
if args.include?('--help') || args.include?('-h')
|
|
22
|
+
puts usage
|
|
23
|
+
exit 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new(verbose: verbose).run
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.usage
|
|
30
|
+
<<~USAGE
|
|
31
|
+
Usage: belt doctor [options]
|
|
32
|
+
|
|
33
|
+
Check system dependencies and AWS configuration for Belt.
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
-v, --verbose Show detailed output for each check
|
|
37
|
+
-h, --help Show this help
|
|
38
|
+
USAGE
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(verbose: false)
|
|
42
|
+
@verbose = verbose
|
|
43
|
+
@issues = []
|
|
44
|
+
@warnings = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run
|
|
48
|
+
puts "Belt Doctor\n"
|
|
49
|
+
puts "Checking your system for Belt prerequisites...\n\n"
|
|
50
|
+
|
|
51
|
+
check_tools
|
|
52
|
+
check_aws_credentials
|
|
53
|
+
check_aws_identity
|
|
54
|
+
|
|
55
|
+
puts ''
|
|
56
|
+
print_summary
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def check_tools
|
|
62
|
+
puts 'āā Tools āā'
|
|
63
|
+
REQUIRED_TOOLS.each do |tool|
|
|
64
|
+
check_tool(tool)
|
|
65
|
+
end
|
|
66
|
+
puts ''
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_tool(tool)
|
|
70
|
+
output, status = Open3.capture2e(*tool[:check])
|
|
71
|
+
|
|
72
|
+
if status.success?
|
|
73
|
+
version = extract_version(output)
|
|
74
|
+
if tool[:min_version] && version && Gem::Version.new(version) < Gem::Version.new(tool[:min_version])
|
|
75
|
+
print_warn(tool[:name], "found #{version}, need >= #{tool[:min_version]}")
|
|
76
|
+
@warnings << "#{tool[:name]} version #{version} is below minimum #{tool[:min_version]}"
|
|
77
|
+
else
|
|
78
|
+
print_ok(tool[:name], version ? version.to_s : output.strip.lines.first&.strip)
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
print_fail(tool[:name], 'not found')
|
|
82
|
+
hint = tool[:install_hint] || tool[:install_url]
|
|
83
|
+
puts " Install: #{hint}" if hint
|
|
84
|
+
@issues << "#{tool[:name]} is not installed"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_aws_credentials
|
|
89
|
+
puts 'āā AWS Configuration āā'
|
|
90
|
+
|
|
91
|
+
# Check for credential sources
|
|
92
|
+
has_profile = ENV.fetch('AWS_PROFILE', nil) && !ENV['AWS_PROFILE'].empty?
|
|
93
|
+
has_env_keys = ENV.fetch('AWS_ACCESS_KEY_ID', nil) && !ENV['AWS_ACCESS_KEY_ID'].empty?
|
|
94
|
+
has_config_file = File.exist?(File.expand_path('~/.aws/config'))
|
|
95
|
+
has_credentials_file = File.exist?(File.expand_path('~/.aws/credentials'))
|
|
96
|
+
|
|
97
|
+
if has_profile
|
|
98
|
+
print_ok('AWS_PROFILE', ENV.fetch('AWS_PROFILE', nil))
|
|
99
|
+
elsif has_env_keys
|
|
100
|
+
print_ok('AWS_ACCESS_KEY_ID', 'set (environment variable)')
|
|
101
|
+
elsif has_credentials_file
|
|
102
|
+
print_ok('~/.aws/credentials', 'exists')
|
|
103
|
+
elsif has_config_file
|
|
104
|
+
print_ok('~/.aws/config', 'exists')
|
|
105
|
+
else
|
|
106
|
+
print_fail('AWS credentials', 'no credential source found')
|
|
107
|
+
puts ' Set AWS_PROFILE, or configure credentials via:'
|
|
108
|
+
puts ' aws configure sso # SSO (recommended)'
|
|
109
|
+
puts ' aws configure # access key + secret'
|
|
110
|
+
@issues << 'No AWS credentials configured'
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check config file for profiles
|
|
115
|
+
return unless has_config_file && @verbose
|
|
116
|
+
|
|
117
|
+
profiles = parse_aws_config_profiles
|
|
118
|
+
puts " Profiles: #{profiles.join(', ')}" if profiles.any?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def check_aws_identity
|
|
122
|
+
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
123
|
+
|
|
124
|
+
if status.success?
|
|
125
|
+
data = begin
|
|
126
|
+
JSON.parse(output)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
account = data['Account'] || '?'
|
|
131
|
+
arn = data['Arn'] || '?'
|
|
132
|
+
print_ok('Authentication', "account #{account}")
|
|
133
|
+
puts " ARN: #{arn}" if @verbose
|
|
134
|
+
else
|
|
135
|
+
error = output.strip
|
|
136
|
+
if error.include?('ExpiredToken') || error.include?('expired')
|
|
137
|
+
print_fail('Authentication', 'credentials expired')
|
|
138
|
+
puts ' Run: aws sso login'
|
|
139
|
+
@issues << 'AWS credentials are expired ā run `aws sso login`'
|
|
140
|
+
elsif error.include?('InvalidClientTokenId') || error.include?('SignatureDoesNotMatch')
|
|
141
|
+
print_fail('Authentication', 'invalid credentials')
|
|
142
|
+
puts ' Your access key or secret is incorrect.'
|
|
143
|
+
puts ' Run: aws configure'
|
|
144
|
+
@issues << 'AWS credentials are invalid'
|
|
145
|
+
elsif error.include?('Could not connect') || error.include?('Unable to locate credentials')
|
|
146
|
+
print_fail('Authentication', 'unable to authenticate')
|
|
147
|
+
puts ' Run: aws configure sso # or set AWS_PROFILE'
|
|
148
|
+
@issues << 'Unable to authenticate with AWS'
|
|
149
|
+
else
|
|
150
|
+
print_fail('Authentication', 'failed')
|
|
151
|
+
puts " #{error.lines.first&.strip}" if error.length.positive?
|
|
152
|
+
@issues << 'AWS authentication failed'
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def print_summary
|
|
158
|
+
if @issues.empty? && @warnings.empty?
|
|
159
|
+
puts 'ā All checks passed ā you\'re ready to belt!'
|
|
160
|
+
elsif @issues.empty?
|
|
161
|
+
puts "ā #{@warnings.size} warning#{'s' if @warnings.size > 1}:"
|
|
162
|
+
@warnings.each { |w| puts " ⢠#{w}" }
|
|
163
|
+
else
|
|
164
|
+
puts "ā #{@issues.size} issue#{'s' if @issues.size > 1} found:"
|
|
165
|
+
@issues.each { |i| puts " ⢠#{i}" }
|
|
166
|
+
@warnings.each { |w| puts " ⢠ā #{w}" } if @warnings.any?
|
|
167
|
+
puts "\nFix the issues above, then run `belt doctor` again."
|
|
168
|
+
exit 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def print_ok(label, detail = nil)
|
|
173
|
+
msg = " ā #{label}"
|
|
174
|
+
msg += " ā #{detail}" if detail
|
|
175
|
+
puts msg
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def print_fail(label, detail = nil)
|
|
179
|
+
msg = " ā #{label}"
|
|
180
|
+
msg += " ā #{detail}" if detail
|
|
181
|
+
puts msg
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_warn(label, detail = nil)
|
|
185
|
+
msg = " ā #{label}"
|
|
186
|
+
msg += " ā #{detail}" if detail
|
|
187
|
+
puts msg
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def extract_version(output)
|
|
191
|
+
# Match common version patterns:
|
|
192
|
+
# "aws-cli/2.15.0" "Terraform v1.7.0" "Bundler version 2.5.0" "ruby 3.3.0"
|
|
193
|
+
match = output.match(%r{(?:version\s+|v|/|^ruby\s+)(\d+\.\d+(?:\.\d+)?)}i)
|
|
194
|
+
match ? match[1] : nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_aws_config_profiles
|
|
198
|
+
config_path = File.expand_path('~/.aws/config')
|
|
199
|
+
return [] unless File.exist?(config_path)
|
|
200
|
+
|
|
201
|
+
File.readlines(config_path)
|
|
202
|
+
.filter_map { |line| line.match(/\[profile\s+(.+)\]/)&.captures&.first }
|
|
203
|
+
rescue StandardError
|
|
204
|
+
[]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -26,11 +26,13 @@ module Belt
|
|
|
26
26
|
new(env_name).generate
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(env_name, quiet: false, domain: nil)
|
|
29
|
+
def initialize(env_name, quiet: false, domain: nil, announce: true)
|
|
30
30
|
@env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
|
|
31
31
|
@app_name = detect_app_name
|
|
32
32
|
@domain = domain
|
|
33
33
|
@quiet = quiet
|
|
34
|
+
@announce = announce
|
|
35
|
+
@state_bucket = resolve_state_bucket
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def generate
|
|
@@ -41,19 +43,18 @@ module Belt
|
|
|
41
43
|
exit 1
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
puts "Creating environment: #{@env_name}"
|
|
46
|
+
puts "Creating environment: #{@env_name}" unless @quiet
|
|
45
47
|
FileUtils.mkdir_p(dest_dir)
|
|
46
48
|
|
|
47
49
|
templates.each do |template_name, dest_file|
|
|
48
50
|
dest_path = File.join(dest_dir, dest_file)
|
|
49
51
|
write_template(template_name, dest_path)
|
|
50
|
-
puts " create #{dest_path}"
|
|
52
|
+
puts " create #{dest_path}" unless @quiet
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return if @quiet
|
|
55
|
+
return if @quiet || !@announce
|
|
56
56
|
|
|
57
|
+
puts "\nā Environment '#{@env_name}' created!"
|
|
57
58
|
puts "\nNext steps:"
|
|
58
59
|
puts " cd #{dest_dir}"
|
|
59
60
|
puts ' terraform init'
|
|
@@ -80,6 +81,37 @@ module Belt
|
|
|
80
81
|
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
81
82
|
File.write(dest_path, content)
|
|
82
83
|
end
|
|
84
|
+
|
|
85
|
+
# Resolve the state bucket name to use in backend.tf.
|
|
86
|
+
# Priority: existing sibling backend.tf ā AWS account ID ā bare placeholder.
|
|
87
|
+
def resolve_state_bucket
|
|
88
|
+
bucket_from_sibling || bucket_from_aws || 'belt-terraform-state'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def bucket_from_sibling
|
|
92
|
+
Dir.glob('infrastructure/*/backend.tf').each do |f|
|
|
93
|
+
match = File.read(f).match(/bucket\s*=\s*"([^"]+)"/)
|
|
94
|
+
next unless match
|
|
95
|
+
# Skip the bare placeholder ā it means state wasn't set up yet
|
|
96
|
+
return match[1] unless match[1] == 'belt-terraform-state'
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bucket_from_aws
|
|
102
|
+
require 'open3'
|
|
103
|
+
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
104
|
+
return nil unless status.success?
|
|
105
|
+
|
|
106
|
+
data = begin
|
|
107
|
+
JSON.parse(output)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
return nil unless data&.dig('Account')
|
|
112
|
+
|
|
113
|
+
"belt-terraform-state-#{data['Account']}"
|
|
114
|
+
end
|
|
83
115
|
end
|
|
84
116
|
end
|
|
85
117
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'erb'
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'open3'
|
|
6
7
|
require_relative 'app_detection'
|
|
7
8
|
|
|
8
9
|
module Belt
|
|
@@ -28,8 +29,10 @@ module Belt
|
|
|
28
29
|
new(framework).generate
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
def initialize(framework)
|
|
32
|
+
def initialize(framework, quiet: false, announce: true)
|
|
32
33
|
@framework = framework
|
|
34
|
+
@quiet = quiet
|
|
35
|
+
@announce = announce
|
|
33
36
|
@app_name = detect_app_name
|
|
34
37
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
35
38
|
end
|
|
@@ -42,7 +45,7 @@ module Belt
|
|
|
42
45
|
exit 1
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
puts "Creating #{@framework} frontend application..."
|
|
48
|
+
puts "Creating #{@framework} frontend application..." unless @quiet
|
|
46
49
|
framework_dir = File.join(TEMPLATE_DIR, @framework)
|
|
47
50
|
|
|
48
51
|
unless Dir.exist?(framework_dir)
|
|
@@ -52,25 +55,35 @@ module Belt
|
|
|
52
55
|
|
|
53
56
|
copy_template(framework_dir, dest_dir)
|
|
54
57
|
|
|
55
|
-
puts "\nā Frontend (#{@framework}) created in frontend/"
|
|
58
|
+
puts "\nā Frontend (#{@framework}) created in frontend/" unless @quiet
|
|
56
59
|
|
|
57
60
|
install_dependencies(dest_dir)
|
|
58
61
|
setup_frontend_infra_for_existing_environments
|
|
59
62
|
|
|
63
|
+
return if @quiet || !@announce
|
|
64
|
+
|
|
60
65
|
puts "\nNext steps:"
|
|
61
66
|
puts ' belt server # Start local dev server'
|
|
62
67
|
puts ' belt deploy # Deploy everything to AWS'
|
|
63
68
|
end
|
|
64
69
|
|
|
70
|
+
def npm_ok?
|
|
71
|
+
@npm_ok != false
|
|
72
|
+
end
|
|
73
|
+
|
|
65
74
|
private
|
|
66
75
|
|
|
67
76
|
def install_dependencies(dest_dir)
|
|
68
|
-
puts "\n Installing npm dependencies..."
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
puts "\n Installing npm dependencies..." unless @quiet
|
|
78
|
+
_output, status = Open3.capture2e(
|
|
79
|
+
'npm', 'install', '--prefix', dest_dir, '--no-fund', '--no-audit'
|
|
80
|
+
)
|
|
81
|
+
if status.success?
|
|
82
|
+
puts ' ā npm dependencies installed' unless @quiet
|
|
83
|
+
@npm_ok = true
|
|
72
84
|
else
|
|
73
|
-
puts " ā npm install failed ā run `cd #{dest_dir} && npm install` manually"
|
|
85
|
+
puts " ā npm install failed ā run `cd #{dest_dir} && npm install` manually" unless @quiet
|
|
86
|
+
@npm_ok = false
|
|
74
87
|
end
|
|
75
88
|
end
|
|
76
89
|
|
|
@@ -79,16 +92,16 @@ module Belt
|
|
|
79
92
|
frontend_tf = File.join(module_dir, 'frontend.tf')
|
|
80
93
|
|
|
81
94
|
if File.exist?(frontend_tf)
|
|
82
|
-
puts " skip #{frontend_tf} (already exists)"
|
|
95
|
+
puts " skip #{frontend_tf} (already exists)" unless @quiet
|
|
83
96
|
return
|
|
84
97
|
end
|
|
85
98
|
|
|
86
99
|
return unless Dir.exist?(module_dir)
|
|
87
100
|
|
|
88
|
-
puts "\n Setting up frontend infrastructure..."
|
|
101
|
+
puts "\n Setting up frontend infrastructure..." unless @quiet
|
|
89
102
|
require_relative 'frontend_setup_command'
|
|
90
103
|
FrontendSetupCommand.new(nil, quiet: true).run
|
|
91
|
-
puts " create #{frontend_tf}"
|
|
104
|
+
puts " create #{frontend_tf}" unless @quiet
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
def copy_template(src_dir, dest_dir)
|
|
@@ -107,7 +120,7 @@ module Belt
|
|
|
107
120
|
else
|
|
108
121
|
FileUtils.cp(src, dest_path)
|
|
109
122
|
end
|
|
110
|
-
puts " create #{dest_path}"
|
|
123
|
+
puts " create #{dest_path}" unless @quiet
|
|
111
124
|
end
|
|
112
125
|
end
|
|
113
126
|
end
|