belt 0.1.4 → 0.1.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/lib/belt/action_router.rb +7 -0
- data/lib/belt/assets/belt-default.jpg +0 -0
- data/lib/belt/assets/welcome.css +112 -0
- data/lib/belt/cli/app_detection.rb +17 -1
- data/lib/belt/cli/deploy_command.rb +495 -0
- data/lib/belt/cli/environment_command.rb +12 -7
- data/lib/belt/cli/generate_command.rb +165 -10
- data/lib/belt/cli/new_command.rb +168 -25
- data/lib/belt/cli/server_command.rb +302 -0
- data/lib/belt/cli/setup_command.rb +40 -6
- data/lib/belt/cli.rb +12 -11
- data/lib/belt/controllers/welcome_controller.rb +46 -0
- data/lib/belt/rendering.rb +136 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +51 -0
- data/lib/belt.rb +2 -0
- data/lib/belt_controller/base.rb +2 -0
- data/lib/templates/environment/main.tf.erb +17 -3
- data/lib/templates/environment/outputs.tf.erb +9 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +2 -1
- data/lib/templates/new_app/lambda/api.rb.erb +2 -2
- data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +1 -1
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +2 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b09432f7426352033b67c6cc696bc90cddcae4b1facae08ce7b6fc08763b7eef
|
|
4
|
+
data.tar.gz: bb4617d6514bef6636b5b2efcfd32eaee6ebbc66b09a7e7f83581340c0fe5714
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c67896f36d6b09e394e19a73a92a6c8e31e9ec43071bf92c905f7d78ba260766d5b46aa2ee9d6c4d665c3313679557df527b866089bad240693fcdd1ce89e91
|
|
7
|
+
data.tar.gz: 8cf8956ea9a6227237be1dccc0cfe15b96ac9e75c3ddae543873cb067fcc5b8648fa23a1a258ccd3470afb9097ba54a7bc2dedfadcc701294b6ff8d9449ac755
|
data/lib/belt/action_router.rb
CHANGED
|
@@ -155,6 +155,13 @@ module Belt
|
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# Final fallback: gem-embedded controllers under Belt:: namespace
|
|
159
|
+
class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
|
|
160
|
+
belt_class_name = "Belt::#{class_name}"
|
|
161
|
+
if Belt.const_defined?(class_name, false)
|
|
162
|
+
return Belt.const_get(class_name, false)
|
|
163
|
+
end
|
|
164
|
+
|
|
158
165
|
raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
|
|
159
166
|
end
|
|
160
167
|
|
|
Binary file
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2
|
+
body {
|
|
3
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
4
|
+
background: #f8f9fa;
|
|
5
|
+
color: #2d3748;
|
|
6
|
+
min-height: 100vh;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Hero section — full width background image */
|
|
10
|
+
.hero {
|
|
11
|
+
position: relative;
|
|
12
|
+
width: 100%;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
.hero-bg {
|
|
16
|
+
width: 100%;
|
|
17
|
+
height: auto;
|
|
18
|
+
display: block;
|
|
19
|
+
}
|
|
20
|
+
.overlay {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 35%;
|
|
23
|
+
left: 50%;
|
|
24
|
+
transform: translate(-50%, -50%);
|
|
25
|
+
padding: 2rem 3rem;
|
|
26
|
+
background: rgba(255, 255, 255, 0.50);
|
|
27
|
+
backdrop-filter: blur(6px);
|
|
28
|
+
-webkit-backdrop-filter: blur(6px);
|
|
29
|
+
border-radius: 12px;
|
|
30
|
+
text-align: center;
|
|
31
|
+
max-width: 600px;
|
|
32
|
+
width: 90%;
|
|
33
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
|
34
|
+
}
|
|
35
|
+
.overlay h1 {
|
|
36
|
+
font-size: 2.2rem;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
color: #2d3748;
|
|
39
|
+
margin-bottom: 0.4rem;
|
|
40
|
+
}
|
|
41
|
+
.overlay .subtitle {
|
|
42
|
+
font-size: 1.1rem;
|
|
43
|
+
color: #4a5568;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Content below the hero */
|
|
47
|
+
.container {
|
|
48
|
+
max-width: 700px;
|
|
49
|
+
margin: 0 auto;
|
|
50
|
+
padding: 2rem;
|
|
51
|
+
}
|
|
52
|
+
.stack-check {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
gap: 0.75rem;
|
|
57
|
+
margin-bottom: 2rem;
|
|
58
|
+
flex-wrap: wrap;
|
|
59
|
+
}
|
|
60
|
+
.check-item {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: 0.4rem;
|
|
64
|
+
padding: 0.5rem 1rem;
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
}
|
|
68
|
+
.check-pass {
|
|
69
|
+
background: rgba(39, 174, 96, 0.1);
|
|
70
|
+
border: 1px solid rgba(39, 174, 96, 0.3);
|
|
71
|
+
color: #1e8449;
|
|
72
|
+
}
|
|
73
|
+
.check-warn {
|
|
74
|
+
background: rgba(243, 156, 18, 0.1);
|
|
75
|
+
border: 1px solid rgba(243, 156, 18, 0.3);
|
|
76
|
+
color: #d68910;
|
|
77
|
+
}
|
|
78
|
+
.icon { font-size: 1.1rem; }
|
|
79
|
+
.arrow { color: #718096; font-size: 1.2rem; }
|
|
80
|
+
.info {
|
|
81
|
+
background: #ffffff;
|
|
82
|
+
border: 1px solid #e2e8f0;
|
|
83
|
+
border-radius: 12px;
|
|
84
|
+
padding: 1.5rem;
|
|
85
|
+
margin-bottom: 1.5rem;
|
|
86
|
+
}
|
|
87
|
+
.info p { margin-bottom: 0.5rem; color: #4a5568; }
|
|
88
|
+
.info p:last-child { margin-bottom: 0; }
|
|
89
|
+
.info strong { color: #2d3748; }
|
|
90
|
+
.info code {
|
|
91
|
+
background: rgba(99, 45, 145, 0.08);
|
|
92
|
+
padding: 0.15rem 0.4rem;
|
|
93
|
+
border-radius: 4px;
|
|
94
|
+
color: #632d91;
|
|
95
|
+
font-size: 0.85rem;
|
|
96
|
+
}
|
|
97
|
+
.next-steps {
|
|
98
|
+
background: #ffffff;
|
|
99
|
+
border: 1px solid #e2e8f0;
|
|
100
|
+
border-radius: 12px;
|
|
101
|
+
padding: 1.5rem;
|
|
102
|
+
}
|
|
103
|
+
.next-steps h2 { font-size: 1.1rem; margin-bottom: 1rem; color: #2d3748; }
|
|
104
|
+
.next-steps ol { padding-left: 1.5rem; }
|
|
105
|
+
.next-steps li { margin-bottom: 0.6rem; color: #4a5568; line-height: 1.5; }
|
|
106
|
+
.next-steps code {
|
|
107
|
+
background: rgba(99, 45, 145, 0.08);
|
|
108
|
+
padding: 0.15rem 0.4rem;
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
color: #632d91;
|
|
111
|
+
font-size: 0.85rem;
|
|
112
|
+
}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
module Belt
|
|
4
4
|
module CLI
|
|
5
5
|
module AppDetection
|
|
6
|
-
|
|
6
|
+
# Detects the primary namespace from the route definitions.
|
|
7
|
+
# Used by generators to determine controller directory and route file naming.
|
|
8
|
+
def detect_namespace
|
|
7
9
|
routes_file = 'infrastructure/routes.tf.rb'
|
|
8
10
|
if File.exist?(routes_file)
|
|
9
11
|
match = File.read(routes_file).match(/namespace :(\w+)/)
|
|
@@ -12,6 +14,20 @@ module Belt
|
|
|
12
14
|
File.basename(Dir.pwd)
|
|
13
15
|
end
|
|
14
16
|
|
|
17
|
+
# Detects the application name.
|
|
18
|
+
# Prefers terraform.tfvars app_name, then falls back to directory name.
|
|
19
|
+
def detect_app_name
|
|
20
|
+
# Check any environment tfvars for the app_name
|
|
21
|
+
Dir.glob('infrastructure/*/terraform.tfvars').each do |f|
|
|
22
|
+
content = File.read(f)
|
|
23
|
+
match = content.match(/app_name\s*=\s*"([^"]+)"/)
|
|
24
|
+
return match[1] if match
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fall back to directory name
|
|
28
|
+
File.basename(Dir.pwd)
|
|
29
|
+
end
|
|
30
|
+
|
|
15
31
|
# S3 bucket names follow DNS rules — underscores are not allowed.
|
|
16
32
|
# App names often use underscores (Ruby namespaces); convert for S3.
|
|
17
33
|
def s3_safe_name(name)
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require_relative 'env_resolver'
|
|
8
|
+
require_relative 'terraform_command'
|
|
9
|
+
|
|
10
|
+
module Belt
|
|
11
|
+
module CLI
|
|
12
|
+
class DeployCommand
|
|
13
|
+
def self.run(args)
|
|
14
|
+
# Handle `belt deploy frontend <env>` as before
|
|
15
|
+
if args.first == 'frontend'
|
|
16
|
+
args.shift
|
|
17
|
+
Belt::CLI::FrontendDeployCommand.run(args)
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
auto_approve = false
|
|
22
|
+
rebuild = false
|
|
23
|
+
filtered_args = []
|
|
24
|
+
|
|
25
|
+
args.each do |arg|
|
|
26
|
+
case arg
|
|
27
|
+
when '--auto', '--yes', '-y'
|
|
28
|
+
auto_approve = true
|
|
29
|
+
when '--rebuild'
|
|
30
|
+
rebuild = true
|
|
31
|
+
when '-h', '--help'
|
|
32
|
+
puts help_text
|
|
33
|
+
exit 0
|
|
34
|
+
else
|
|
35
|
+
filtered_args << arg
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
env = EnvResolver.resolve(filtered_args)
|
|
40
|
+
|
|
41
|
+
# Default to the first available environment when none specified
|
|
42
|
+
if env.nil?
|
|
43
|
+
available = TerraformCommand.list_environments
|
|
44
|
+
if available.any?
|
|
45
|
+
env = available.first
|
|
46
|
+
else
|
|
47
|
+
puts 'Usage: belt deploy [environment] [options]'
|
|
48
|
+
puts "\nDefaults to BELT_ENV or the first available environment."
|
|
49
|
+
puts "\nOptions:"
|
|
50
|
+
puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
|
|
51
|
+
puts ' --rebuild Rebuild and push Lambda code directly (bypasses Terraform)'
|
|
52
|
+
puts ' -h, --help Show this help'
|
|
53
|
+
puts "\nExamples:"
|
|
54
|
+
puts ' belt deploy # Deploy dev (or BELT_ENV)'
|
|
55
|
+
puts ' belt deploy prod # Deploy to prod'
|
|
56
|
+
puts ' belt deploy dev --auto # Deploy without confirmation'
|
|
57
|
+
puts ' belt deploy --rebuild # Fast code push (no infra changes)'
|
|
58
|
+
puts ' belt deploy frontend dev # Deploy frontend only'
|
|
59
|
+
puts "\nNo environments found. Run `belt generate environment dev` first."
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if rebuild
|
|
65
|
+
new(env, auto_approve: auto_approve, extra_args: filtered_args).run_rebuild
|
|
66
|
+
else
|
|
67
|
+
new(env, auto_approve: auto_approve, extra_args: filtered_args).run
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.help_text
|
|
72
|
+
<<~HELP
|
|
73
|
+
Deploy your Belt application to AWS.
|
|
74
|
+
|
|
75
|
+
Usage: belt deploy [environment] [options]
|
|
76
|
+
belt deploy frontend <environment>
|
|
77
|
+
|
|
78
|
+
This runs the full deployment lifecycle:
|
|
79
|
+
1. Ensure Gemfile.lock is consistent (fix stale PATH refs)
|
|
80
|
+
2. terraform init (initialize providers/modules)
|
|
81
|
+
3. terraform plan (preview changes)
|
|
82
|
+
4. Prompt for confirmation (unless --auto)
|
|
83
|
+
5. terraform apply (deploy changes)
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
--auto, --yes, -y Skip confirmation prompt (auto-approve)
|
|
87
|
+
--rebuild Rebuild and push Lambda code directly (bypasses Terraform).
|
|
88
|
+
Much faster for code-only changes — packages gems via Docker,
|
|
89
|
+
zips, and pushes with `aws lambda update-function-code`.
|
|
90
|
+
-h, --help Show this help
|
|
91
|
+
|
|
92
|
+
Environment:
|
|
93
|
+
Defaults to BELT_ENV if set, otherwise the first available environment.
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
belt deploy # Deploy dev (or BELT_ENV)
|
|
97
|
+
belt deploy prod # Deploy to prod
|
|
98
|
+
belt deploy dev --auto # Deploy without confirmation (CI mode)
|
|
99
|
+
belt deploy --rebuild # Fast code push to dev (no infra changes)
|
|
100
|
+
belt deploy prod --rebuild # Fast code push to prod
|
|
101
|
+
belt deploy frontend dev # Deploy frontend assets only
|
|
102
|
+
HELP
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def initialize(env, auto_approve: false, extra_args: [])
|
|
106
|
+
@env = env
|
|
107
|
+
@auto_approve = auto_approve
|
|
108
|
+
@extra_args = extra_args
|
|
109
|
+
@infra_dir = TerraformCommand.find_infrastructure_dir
|
|
110
|
+
@project_root = find_project_root
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def run
|
|
114
|
+
validate!
|
|
115
|
+
env_dir = File.join(@infra_dir, @env)
|
|
116
|
+
|
|
117
|
+
puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
|
|
118
|
+
|
|
119
|
+
ensure_lockfile_consistent!
|
|
120
|
+
|
|
121
|
+
Dir.chdir(env_dir) do
|
|
122
|
+
run_init
|
|
123
|
+
run_plan
|
|
124
|
+
return unless confirm_apply
|
|
125
|
+
run_apply
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
puts "\n✅ Deployed #{@env} successfully!"
|
|
129
|
+
print_outputs(env_dir)
|
|
130
|
+
puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def run_rebuild
|
|
134
|
+
validate!
|
|
135
|
+
validate_aws!
|
|
136
|
+
|
|
137
|
+
puts "belt → rebuilding Lambda for #{@env}\n\n"
|
|
138
|
+
|
|
139
|
+
lambda_dir = find_lambda_dir
|
|
140
|
+
abort 'Error: Cannot find lambda/ directory.' unless lambda_dir
|
|
141
|
+
|
|
142
|
+
function_name = find_lambda_function_name
|
|
143
|
+
abort 'Error: Cannot determine Lambda function name from Terraform state.' unless function_name
|
|
144
|
+
|
|
145
|
+
ensure_lockfile_consistent!
|
|
146
|
+
generate_routes(lambda_dir)
|
|
147
|
+
build_and_deploy(lambda_dir, function_name)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def find_project_root
|
|
153
|
+
# Walk up from infra dir to find project root (where Gemfile lives)
|
|
154
|
+
dir = @infra_dir ? File.dirname(@infra_dir) : Dir.pwd
|
|
155
|
+
while dir != '/'
|
|
156
|
+
return dir if File.exist?(File.join(dir, 'Gemfile'))
|
|
157
|
+
dir = File.dirname(dir)
|
|
158
|
+
end
|
|
159
|
+
Dir.pwd
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def find_lambda_dir
|
|
163
|
+
candidates = [
|
|
164
|
+
File.join(@project_root, 'lambda'),
|
|
165
|
+
File.join(Dir.pwd, 'lambda')
|
|
166
|
+
]
|
|
167
|
+
candidates.find { |d| Dir.exist?(d) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate!
|
|
171
|
+
unless @infra_dir
|
|
172
|
+
abort "Error: No infrastructure/ directory found.\n" \
|
|
173
|
+
"Run `belt generate environment #{@env}` first."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
env_dir = File.join(@infra_dir, @env)
|
|
177
|
+
return if Dir.exist?(env_dir)
|
|
178
|
+
|
|
179
|
+
abort "Error: Environment '#{@env}' not found at #{env_dir}/.\n\n" \
|
|
180
|
+
"Available environments:\n#{TerraformCommand.list_environments.map { |e| " #{e}" }.join("\n")}\n\n" \
|
|
181
|
+
"Create it with: belt generate environment #{@env}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_aws!
|
|
185
|
+
stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
|
|
186
|
+
unless status.success?
|
|
187
|
+
abort "Error: AWS credentials not available.\n" \
|
|
188
|
+
"Run `aws sso login` or configure AWS_PROFILE."
|
|
189
|
+
end
|
|
190
|
+
@aws_account = JSON.parse(stdout)['Account'] rescue nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Ensure Gemfile.lock doesn't have stale PATH references that will break
|
|
194
|
+
# the Docker-based gem build. If the Gemfile no longer uses `path:` but the
|
|
195
|
+
# lockfile still references a PATH source, refresh the lock.
|
|
196
|
+
def ensure_lockfile_consistent!
|
|
197
|
+
gemfile = File.join(@project_root, 'Gemfile')
|
|
198
|
+
lockfile = File.join(@project_root, 'Gemfile.lock')
|
|
199
|
+
return unless File.exist?(gemfile) && File.exist?(lockfile)
|
|
200
|
+
|
|
201
|
+
gemfile_content = File.read(gemfile)
|
|
202
|
+
lockfile_content = File.read(lockfile)
|
|
203
|
+
|
|
204
|
+
# Find gems that are PATH-sourced in the lockfile but NOT path-sourced in the Gemfile
|
|
205
|
+
stale_path_gems = detect_stale_path_gems(gemfile_content, lockfile_content)
|
|
206
|
+
return if stale_path_gems.empty?
|
|
207
|
+
|
|
208
|
+
puts " 🔧 Fixing stale Gemfile.lock (#{stale_path_gems.join(', ')} referenced as PATH but Gemfile uses RubyGems)"
|
|
209
|
+
Dir.chdir(@project_root) do
|
|
210
|
+
success = system('bundle', 'lock', '--update', *stale_path_gems)
|
|
211
|
+
unless success
|
|
212
|
+
puts " ⚠ `bundle lock --update #{stale_path_gems.join(' ')}` failed — trying full bundle update"
|
|
213
|
+
system('bundle', 'update', *stale_path_gems)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
puts ''
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def detect_stale_path_gems(gemfile_content, lockfile_content)
|
|
220
|
+
stale = []
|
|
221
|
+
|
|
222
|
+
# Parse PATH sections from lockfile
|
|
223
|
+
# Format:
|
|
224
|
+
# PATH
|
|
225
|
+
# remote: ../something
|
|
226
|
+
# specs:
|
|
227
|
+
# gem_name (version)
|
|
228
|
+
lockfile_content.scan(/^PATH\n\s+remote:.*?\n\s+specs:\n((?:\s{4}\S.*\n?)+)/m).each do |match|
|
|
229
|
+
match[0].scan(/^\s{4}(\S+)\s/).each do |gem_match|
|
|
230
|
+
gem_name = gem_match[0]
|
|
231
|
+
# Check if the Gemfile still uses path: for this gem
|
|
232
|
+
# Match patterns like: gem 'name', path: ... or gem "name", path: ...
|
|
233
|
+
has_path_in_gemfile = gemfile_content.match?(/gem\s+['"]#{Regexp.escape(gem_name)}['"].*path:/)
|
|
234
|
+
stale << gem_name unless has_path_in_gemfile
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
stale
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def find_lambda_function_name
|
|
242
|
+
env_dir = File.join(@infra_dir, @env)
|
|
243
|
+
return nil unless Dir.exist?(File.join(env_dir, '.terraform'))
|
|
244
|
+
|
|
245
|
+
# Get function name from terraform state
|
|
246
|
+
Dir.chdir(env_dir) do
|
|
247
|
+
output, status = Open3.capture2('terraform', 'output', '-json')
|
|
248
|
+
return nil unless status.success?
|
|
249
|
+
|
|
250
|
+
data = JSON.parse(output) rescue {}
|
|
251
|
+
|
|
252
|
+
# Try lambda_functions output first
|
|
253
|
+
if data['lambda_functions']
|
|
254
|
+
funcs = data['lambda_functions']['value']
|
|
255
|
+
if funcs.is_a?(Hash) && funcs.any?
|
|
256
|
+
return funcs.values.first # ARN — we need just the function name
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Fallback: parse from terraform state directly
|
|
261
|
+
state_output, state_status = Open3.capture2('terraform', 'state', 'show', 'conveyor_belt.main')
|
|
262
|
+
return nil unless state_status.success?
|
|
263
|
+
|
|
264
|
+
# Look for lambda function names in state
|
|
265
|
+
state_output.match(/function_name\s*=\s*"([^"]+)"/)&.captures&.first
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def generate_routes(lambda_dir)
|
|
270
|
+
routes_dir = File.join(lambda_dir, 'lib', 'routes')
|
|
271
|
+
return unless Dir.exist?(routes_dir)
|
|
272
|
+
|
|
273
|
+
puts ' 🗺️ Regenerating route manifests...'
|
|
274
|
+
success = system('belt', 'routes', '--namespace', 'all', '--output-dir', routes_dir)
|
|
275
|
+
if success
|
|
276
|
+
puts ' ✅ Routes updated'
|
|
277
|
+
else
|
|
278
|
+
puts ' ⚠ Route generation failed — using existing manifests'
|
|
279
|
+
end
|
|
280
|
+
puts ''
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def build_and_deploy(lambda_dir, function_identifier)
|
|
284
|
+
# Extract function name from ARN if needed
|
|
285
|
+
function_name = if function_identifier.include?(':')
|
|
286
|
+
function_identifier.split(':').last
|
|
287
|
+
else
|
|
288
|
+
function_identifier
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
build_dir = Dir.mktmpdir('belt-rebuild-')
|
|
292
|
+
|
|
293
|
+
begin
|
|
294
|
+
puts ' 📦 Building Lambda package...'
|
|
295
|
+
assemble_package(lambda_dir, build_dir)
|
|
296
|
+
build_gems(build_dir)
|
|
297
|
+
zip_file = create_zip(build_dir)
|
|
298
|
+
|
|
299
|
+
puts ' 🚀 Deploying to AWS...'
|
|
300
|
+
deploy_to_lambda(function_name, zip_file)
|
|
301
|
+
|
|
302
|
+
puts "\n✅ Lambda #{function_name} rebuilt and deployed!"
|
|
303
|
+
puts ''
|
|
304
|
+
puts ' 📋 View logs:'
|
|
305
|
+
puts " aws logs tail /aws/lambda/#{function_name} --follow"
|
|
306
|
+
ensure
|
|
307
|
+
FileUtils.rm_rf(build_dir)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def assemble_package(lambda_dir, build_dir)
|
|
312
|
+
# Copy handler file(s) — .rb files at root of lambda/
|
|
313
|
+
Dir.glob(File.join(lambda_dir, '*.rb')).each do |f|
|
|
314
|
+
FileUtils.cp(f, build_dir)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Copy shared directories
|
|
318
|
+
%w[controllers models lib helpers templates serializers jobs config].each do |dir|
|
|
319
|
+
src = File.join(lambda_dir, dir)
|
|
320
|
+
FileUtils.cp_r(src, File.join(build_dir, dir)) if Dir.exist?(src)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Copy Gemfile from project root
|
|
324
|
+
gemfile = File.join(@project_root, 'Gemfile')
|
|
325
|
+
lockfile = File.join(@project_root, 'Gemfile.lock')
|
|
326
|
+
FileUtils.cp(gemfile, build_dir) if File.exist?(gemfile)
|
|
327
|
+
FileUtils.cp(lockfile, build_dir) if File.exist?(lockfile)
|
|
328
|
+
|
|
329
|
+
puts ' 📁 Copied handlers, controllers, models, lib, config'
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def build_gems(build_dir)
|
|
333
|
+
puts ' 🐳 Building gems in Docker (Lambda-compatible)...'
|
|
334
|
+
|
|
335
|
+
docker_cmd = [
|
|
336
|
+
'docker', 'run', '--rm',
|
|
337
|
+
'--platform', 'linux/amd64',
|
|
338
|
+
'-v', "#{build_dir}:/var/task",
|
|
339
|
+
'-w', '/var/task',
|
|
340
|
+
'public.ecr.aws/sam/build-ruby3.4:latest-x86_64',
|
|
341
|
+
'/bin/bash', '-c',
|
|
342
|
+
"bundle config set --local path 'vendor/bundle' && " \
|
|
343
|
+
"bundle config set --local without 'development test' && " \
|
|
344
|
+
'bundle config set silence_root_warning 1 && ' \
|
|
345
|
+
'bundle install --jobs 4 && ' \
|
|
346
|
+
'bundle clean --force'
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
output, status = Open3.capture2e(*docker_cmd)
|
|
350
|
+
unless status.success?
|
|
351
|
+
puts output
|
|
352
|
+
abort "\n✗ Gem build failed. Check Docker is running and the Gemfile is valid."
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Strip fat from vendor bundle
|
|
356
|
+
strip_vendor_fat(build_dir)
|
|
357
|
+
puts ' ✅ Gems installed'
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def strip_vendor_fat(build_dir)
|
|
361
|
+
vendor_dir = File.join(build_dir, 'vendor')
|
|
362
|
+
return unless Dir.exist?(vendor_dir)
|
|
363
|
+
|
|
364
|
+
# Remove docs, tests, and non-runtime files from gems
|
|
365
|
+
Dir.glob(File.join(vendor_dir, 'bundle/ruby/*/gems/*/')).each do |gem_dir|
|
|
366
|
+
%w[spec test tests doc docs examples benchmarks].each do |fat_dir|
|
|
367
|
+
FileUtils.rm_rf(File.join(gem_dir, fat_dir))
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Remove non-essential files
|
|
372
|
+
exts = %w[*.md *.rdoc *.txt *.c *.h *.o Makefile *.log CHANGELOG* HISTORY* LICENSE* README* .gitignore .travis.yml .rubocop.yml Rakefile]
|
|
373
|
+
exts.each do |pattern|
|
|
374
|
+
Dir.glob(File.join(vendor_dir, "**", pattern)).each do |f|
|
|
375
|
+
File.delete(f) if File.file?(f)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Strip debug symbols from native extensions
|
|
380
|
+
Dir.glob(File.join(vendor_dir, '**/*.so')).each do |so_file|
|
|
381
|
+
system('strip', '--strip-debug', so_file, err: File::NULL, out: File::NULL)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def create_zip(build_dir)
|
|
386
|
+
zip_file = File.join(Dir.tmpdir, "belt-lambda-#{@env}-#{Time.now.to_i}.zip")
|
|
387
|
+
|
|
388
|
+
Dir.chdir(build_dir) do
|
|
389
|
+
output, status = Open3.capture2e('zip', '-qr', zip_file, '.')
|
|
390
|
+
unless status.success?
|
|
391
|
+
puts output
|
|
392
|
+
abort "\n✗ Failed to create ZIP"
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
size_mb = (File.size(zip_file) / 1024.0 / 1024.0).round(1)
|
|
397
|
+
puts " 🗜️ Package: #{size_mb}MB"
|
|
398
|
+
zip_file
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def deploy_to_lambda(function_name, zip_file)
|
|
402
|
+
_output, status = Open3.capture2(
|
|
403
|
+
'aws', 'lambda', 'update-function-code',
|
|
404
|
+
'--function-name', function_name,
|
|
405
|
+
'--zip-file', "fileb://#{zip_file}",
|
|
406
|
+
'--output', 'json'
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
File.delete(zip_file) if File.exist?(zip_file)
|
|
410
|
+
|
|
411
|
+
unless status.success?
|
|
412
|
+
abort "\n✗ Failed to update Lambda function code.\n" \
|
|
413
|
+
" Function: #{function_name}\n" \
|
|
414
|
+
" Check that the function exists and you have permissions."
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Wait for update to complete
|
|
418
|
+
print ' Waiting for Lambda to be ready...'
|
|
419
|
+
30.times do
|
|
420
|
+
sleep 1
|
|
421
|
+
state_output, = Open3.capture2(
|
|
422
|
+
'aws', 'lambda', 'get-function',
|
|
423
|
+
'--function-name', function_name,
|
|
424
|
+
'--query', 'Configuration.LastUpdateStatus',
|
|
425
|
+
'--output', 'text'
|
|
426
|
+
)
|
|
427
|
+
case state_output.strip
|
|
428
|
+
when 'Successful'
|
|
429
|
+
puts ' ✅'
|
|
430
|
+
return
|
|
431
|
+
when 'Failed'
|
|
432
|
+
puts ' ✗'
|
|
433
|
+
abort ' Lambda update failed. Check CloudWatch logs.'
|
|
434
|
+
end
|
|
435
|
+
print '.'
|
|
436
|
+
end
|
|
437
|
+
puts ' (timed out waiting, but code was pushed)'
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def run_init
|
|
441
|
+
puts '━━━ terraform init ━━━'
|
|
442
|
+
success = system('terraform', 'init')
|
|
443
|
+
abort "\n✗ terraform init failed" unless success
|
|
444
|
+
puts ''
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def run_plan
|
|
448
|
+
puts '━━━ terraform plan ━━━'
|
|
449
|
+
success = system('terraform', 'plan', '-out=tfplan', *@extra_args)
|
|
450
|
+
abort "\n✗ terraform plan failed" unless success
|
|
451
|
+
puts ''
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def confirm_apply
|
|
455
|
+
return true if @auto_approve
|
|
456
|
+
|
|
457
|
+
if @env == 'prod' || @env == 'production'
|
|
458
|
+
print "⚠️ You are about to deploy to \e[1;31m#{@env}\e[0m. Continue? [y/N] "
|
|
459
|
+
else
|
|
460
|
+
print "Apply these changes to #{@env}? [y/N] "
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
response = $stdin.gets
|
|
464
|
+
return true if response&.strip&.downcase&.start_with?('y')
|
|
465
|
+
|
|
466
|
+
puts 'Cancelled.'
|
|
467
|
+
cleanup_plan
|
|
468
|
+
false
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def run_apply
|
|
472
|
+
puts '━━━ terraform apply ━━━'
|
|
473
|
+
success = system('terraform', 'apply', 'tfplan')
|
|
474
|
+
cleanup_plan
|
|
475
|
+
abort "\n✗ terraform apply failed" unless success
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def cleanup_plan
|
|
479
|
+
File.delete('tfplan') if File.exist?('tfplan')
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def print_outputs(env_dir)
|
|
483
|
+
Dir.chdir(env_dir) do
|
|
484
|
+
output = `terraform output 2>/dev/null`.strip
|
|
485
|
+
return if output.empty?
|
|
486
|
+
|
|
487
|
+
puts "\nOutputs:"
|
|
488
|
+
output.each_line do |line|
|
|
489
|
+
puts " #{line}"
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|