belt 0.1.5 → 0.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 330331693d70f3a18ad04c47b4f0b07c69b6a75538291be891946e12c7863dfc
4
- data.tar.gz: 643ed0cad19c1124581d86b7f816ce199d372be0415c03b53c8f9d5f990bc3a9
3
+ metadata.gz: 479b619266e398d1d3052c96eb18f05c8452306a01aa46576b82e072372820b5
4
+ data.tar.gz: d5697750193b1a1cbba8d39e81190035d8fd40db490efbdb24903e7647374767
5
5
  SHA512:
6
- metadata.gz: ca9f5d87f6b8ab597106789e5a381aa3ba6f33b85cc2620552b8c9380ad5508d7f27d5ed34cea52b59858db91ab02653ee4a487044562f7c3c68739c3fa40779
7
- data.tar.gz: 36cbc894c45482981d1c3ddd34f0b59dd7e5259dd5de975175af756c54488693e6b0396c44b81b396442149a70ff6067d8d40046cef749d7119d248f49d57990
6
+ metadata.gz: 73ac84ffd0e2238b929180f89972f70c55a7d43a92d0f9de23b85cc6c318982c40c9466f49fe1d2d0960a47685acc90e1352b43f7e390ffee2951c83568ba432
7
+ data.tar.gz: 74db2374a336ba5ac774a5a8a4f7c053136de9dc652907e4abeebd69ec6bfa6485f35d3339787e7e5b17feadb3cf23014ae356d8050e817c90a2ba1fbf817ba6
Binary file
@@ -23,7 +23,7 @@ body {
23
23
  left: 50%;
24
24
  transform: translate(-50%, -50%);
25
25
  padding: 2rem 3rem;
26
- background: rgba(255, 255, 255, 0.88);
26
+ background: rgba(255, 255, 255, 0.50);
27
27
  backdrop-filter: blur(6px);
28
28
  -webkit-backdrop-filter: blur(6px);
29
29
  border-radius: 12px;
@@ -3,7 +3,9 @@
3
3
  module Belt
4
4
  module CLI
5
5
  module AppDetection
6
- def detect_app_name
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,26 @@ 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
+
31
+ # Detects existing environments by scanning infrastructure/ directories.
32
+ # Each subdirectory with a main.tf is considered an environment.
33
+ def detect_environments
34
+ Dir.glob('infrastructure/*/main.tf').map { |f| File.basename(File.dirname(f)) }.sort
35
+ end
36
+
15
37
  # S3 bucket names follow DNS rules — underscores are not allowed.
16
38
  # App names often use underscores (Ruby namespaces); convert for S3.
17
39
  def s3_safe_name(name)
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'open3'
6
+ require 'tmpdir'
3
7
  require_relative 'env_resolver'
4
8
  require_relative 'terraform_command'
5
9
 
@@ -15,12 +19,15 @@ module Belt
15
19
  end
16
20
 
17
21
  auto_approve = false
22
+ rebuild = false
18
23
  filtered_args = []
19
24
 
20
25
  args.each do |arg|
21
26
  case arg
22
27
  when '--auto', '--yes', '-y'
23
28
  auto_approve = true
29
+ when '--rebuild'
30
+ rebuild = true
24
31
  when '-h', '--help'
25
32
  puts help_text
26
33
  exit 0
@@ -41,18 +48,24 @@ module Belt
41
48
  puts "\nDefaults to BELT_ENV or the first available environment."
42
49
  puts "\nOptions:"
43
50
  puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
51
+ puts ' --rebuild Rebuild and push Lambda code directly (bypasses Terraform)'
44
52
  puts ' -h, --help Show this help'
45
53
  puts "\nExamples:"
46
54
  puts ' belt deploy # Deploy dev (or BELT_ENV)'
47
55
  puts ' belt deploy prod # Deploy to prod'
48
56
  puts ' belt deploy dev --auto # Deploy without confirmation'
57
+ puts ' belt deploy --rebuild # Fast code push (no infra changes)'
49
58
  puts ' belt deploy frontend dev # Deploy frontend only'
50
59
  puts "\nNo environments found. Run `belt generate environment dev` first."
51
60
  exit 1
52
61
  end
53
62
  end
54
63
 
55
- new(env, auto_approve: auto_approve, extra_args: filtered_args).run
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
56
69
  end
57
70
 
58
71
  def self.help_text
@@ -63,22 +76,28 @@ module Belt
63
76
  belt deploy frontend <environment>
64
77
 
65
78
  This runs the full deployment lifecycle:
66
- 1. terraform init (initialize providers/modules)
67
- 2. terraform plan (preview changes)
68
- 3. Prompt for confirmation (unless --auto)
69
- 4. terraform apply (deploy changes)
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)
70
84
 
71
85
  Options:
72
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`.
73
90
  -h, --help Show this help
74
91
 
75
92
  Environment:
76
- Defaults to BELT_ENV if set, otherwise requires an argument.
93
+ Defaults to BELT_ENV if set, otherwise the first available environment.
77
94
 
78
95
  Examples:
79
96
  belt deploy # Deploy dev (or BELT_ENV)
80
97
  belt deploy prod # Deploy to prod
81
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
82
101
  belt deploy frontend dev # Deploy frontend assets only
83
102
  HELP
84
103
  end
@@ -88,6 +107,7 @@ module Belt
88
107
  @auto_approve = auto_approve
89
108
  @extra_args = extra_args
90
109
  @infra_dir = TerraformCommand.find_infrastructure_dir
110
+ @project_root = find_project_root
91
111
  end
92
112
 
93
113
  def run
@@ -96,6 +116,8 @@ module Belt
96
116
 
97
117
  puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
98
118
 
119
+ ensure_lockfile_consistent!
120
+
99
121
  Dir.chdir(env_dir) do
100
122
  run_init
101
123
  run_plan
@@ -105,11 +127,49 @@ module Belt
105
127
 
106
128
  puts "\n✅ Deployed #{@env} successfully!"
107
129
  print_outputs(env_dir)
130
+
131
+ deploy_frontend_if_exists
132
+
108
133
  puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
109
134
  end
110
135
 
136
+ def run_rebuild
137
+ validate!
138
+ validate_aws!
139
+
140
+ puts "belt → rebuilding Lambda for #{@env}\n\n"
141
+
142
+ lambda_dir = find_lambda_dir
143
+ abort 'Error: Cannot find lambda/ directory.' unless lambda_dir
144
+
145
+ function_name = find_lambda_function_name
146
+ abort 'Error: Cannot determine Lambda function name from Terraform state.' unless function_name
147
+
148
+ ensure_lockfile_consistent!
149
+ generate_routes(lambda_dir)
150
+ build_and_deploy(lambda_dir, function_name)
151
+ end
152
+
111
153
  private
112
154
 
155
+ def find_project_root
156
+ # Walk up from infra dir to find project root (where Gemfile lives)
157
+ dir = @infra_dir ? File.dirname(@infra_dir) : Dir.pwd
158
+ while dir != '/'
159
+ return dir if File.exist?(File.join(dir, 'Gemfile'))
160
+ dir = File.dirname(dir)
161
+ end
162
+ Dir.pwd
163
+ end
164
+
165
+ def find_lambda_dir
166
+ candidates = [
167
+ File.join(@project_root, 'lambda'),
168
+ File.join(Dir.pwd, 'lambda')
169
+ ]
170
+ candidates.find { |d| Dir.exist?(d) }
171
+ end
172
+
113
173
  def validate!
114
174
  unless @infra_dir
115
175
  abort "Error: No infrastructure/ directory found.\n" \
@@ -124,6 +184,262 @@ module Belt
124
184
  "Create it with: belt generate environment #{@env}"
125
185
  end
126
186
 
187
+ def validate_aws!
188
+ stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
189
+ unless status.success?
190
+ abort "Error: AWS credentials not available.\n" \
191
+ "Run `aws sso login` or configure AWS_PROFILE."
192
+ end
193
+ @aws_account = JSON.parse(stdout)['Account'] rescue nil
194
+ end
195
+
196
+ # Ensure Gemfile.lock doesn't have stale PATH references that will break
197
+ # the Docker-based gem build. If the Gemfile no longer uses `path:` but the
198
+ # lockfile still references a PATH source, refresh the lock.
199
+ def ensure_lockfile_consistent!
200
+ gemfile = File.join(@project_root, 'Gemfile')
201
+ lockfile = File.join(@project_root, 'Gemfile.lock')
202
+ return unless File.exist?(gemfile) && File.exist?(lockfile)
203
+
204
+ gemfile_content = File.read(gemfile)
205
+ lockfile_content = File.read(lockfile)
206
+
207
+ # Find gems that are PATH-sourced in the lockfile but NOT path-sourced in the Gemfile
208
+ stale_path_gems = detect_stale_path_gems(gemfile_content, lockfile_content)
209
+ return if stale_path_gems.empty?
210
+
211
+ puts " 🔧 Fixing stale Gemfile.lock (#{stale_path_gems.join(', ')} referenced as PATH but Gemfile uses RubyGems)"
212
+ Dir.chdir(@project_root) do
213
+ success = system('bundle', 'lock', '--update', *stale_path_gems)
214
+ unless success
215
+ puts " ⚠ `bundle lock --update #{stale_path_gems.join(' ')}` failed — trying full bundle update"
216
+ system('bundle', 'update', *stale_path_gems)
217
+ end
218
+ end
219
+ puts ''
220
+ end
221
+
222
+ def detect_stale_path_gems(gemfile_content, lockfile_content)
223
+ stale = []
224
+
225
+ # Parse PATH sections from lockfile
226
+ # Format:
227
+ # PATH
228
+ # remote: ../something
229
+ # specs:
230
+ # gem_name (version)
231
+ lockfile_content.scan(/^PATH\n\s+remote:.*?\n\s+specs:\n((?:\s{4}\S.*\n?)+)/m).each do |match|
232
+ match[0].scan(/^\s{4}(\S+)\s/).each do |gem_match|
233
+ gem_name = gem_match[0]
234
+ # Check if the Gemfile still uses path: for this gem
235
+ # Match patterns like: gem 'name', path: ... or gem "name", path: ...
236
+ has_path_in_gemfile = gemfile_content.match?(/gem\s+['"]#{Regexp.escape(gem_name)}['"].*path:/)
237
+ stale << gem_name unless has_path_in_gemfile
238
+ end
239
+ end
240
+
241
+ stale
242
+ end
243
+
244
+ def find_lambda_function_name
245
+ env_dir = File.join(@infra_dir, @env)
246
+ return nil unless Dir.exist?(File.join(env_dir, '.terraform'))
247
+
248
+ # Get function name from terraform state
249
+ Dir.chdir(env_dir) do
250
+ output, status = Open3.capture2('terraform', 'output', '-json')
251
+ return nil unless status.success?
252
+
253
+ data = JSON.parse(output) rescue {}
254
+
255
+ # Try lambda_functions output first
256
+ if data['lambda_functions']
257
+ funcs = data['lambda_functions']['value']
258
+ if funcs.is_a?(Hash) && funcs.any?
259
+ return funcs.values.first # ARN — we need just the function name
260
+ end
261
+ end
262
+
263
+ # Fallback: parse from terraform state directly
264
+ state_output, state_status = Open3.capture2('terraform', 'state', 'show', 'conveyor_belt.main')
265
+ return nil unless state_status.success?
266
+
267
+ # Look for lambda function names in state
268
+ state_output.match(/function_name\s*=\s*"([^"]+)"/)&.captures&.first
269
+ end
270
+ end
271
+
272
+ def generate_routes(lambda_dir)
273
+ routes_dir = File.join(lambda_dir, 'lib', 'routes')
274
+ return unless Dir.exist?(routes_dir)
275
+
276
+ puts ' 🗺️ Regenerating route manifests...'
277
+ success = system('belt', 'routes', '--namespace', 'all', '--output-dir', routes_dir)
278
+ if success
279
+ puts ' ✅ Routes updated'
280
+ else
281
+ puts ' ⚠ Route generation failed — using existing manifests'
282
+ end
283
+ puts ''
284
+ end
285
+
286
+ def build_and_deploy(lambda_dir, function_identifier)
287
+ # Extract function name from ARN if needed
288
+ function_name = if function_identifier.include?(':')
289
+ function_identifier.split(':').last
290
+ else
291
+ function_identifier
292
+ end
293
+
294
+ build_dir = Dir.mktmpdir('belt-rebuild-')
295
+
296
+ begin
297
+ puts ' 📦 Building Lambda package...'
298
+ assemble_package(lambda_dir, build_dir)
299
+ build_gems(build_dir)
300
+ zip_file = create_zip(build_dir)
301
+
302
+ puts ' 🚀 Deploying to AWS...'
303
+ deploy_to_lambda(function_name, zip_file)
304
+
305
+ puts "\n✅ Lambda #{function_name} rebuilt and deployed!"
306
+ puts ''
307
+ puts ' 📋 View logs:'
308
+ puts " aws logs tail /aws/lambda/#{function_name} --follow"
309
+ ensure
310
+ FileUtils.rm_rf(build_dir)
311
+ end
312
+ end
313
+
314
+ def assemble_package(lambda_dir, build_dir)
315
+ # Copy handler file(s) — .rb files at root of lambda/
316
+ Dir.glob(File.join(lambda_dir, '*.rb')).each do |f|
317
+ FileUtils.cp(f, build_dir)
318
+ end
319
+
320
+ # Copy shared directories
321
+ %w[controllers models lib helpers templates serializers jobs config].each do |dir|
322
+ src = File.join(lambda_dir, dir)
323
+ FileUtils.cp_r(src, File.join(build_dir, dir)) if Dir.exist?(src)
324
+ end
325
+
326
+ # Copy Gemfile from project root
327
+ gemfile = File.join(@project_root, 'Gemfile')
328
+ lockfile = File.join(@project_root, 'Gemfile.lock')
329
+ FileUtils.cp(gemfile, build_dir) if File.exist?(gemfile)
330
+ FileUtils.cp(lockfile, build_dir) if File.exist?(lockfile)
331
+
332
+ puts ' 📁 Copied handlers, controllers, models, lib, config'
333
+ end
334
+
335
+ def build_gems(build_dir)
336
+ puts ' 🐳 Building gems in Docker (Lambda-compatible)...'
337
+
338
+ docker_cmd = [
339
+ 'docker', 'run', '--rm',
340
+ '--platform', 'linux/amd64',
341
+ '-v', "#{build_dir}:/var/task",
342
+ '-w', '/var/task',
343
+ 'public.ecr.aws/sam/build-ruby3.4:latest-x86_64',
344
+ '/bin/bash', '-c',
345
+ "bundle config set --local path 'vendor/bundle' && " \
346
+ "bundle config set --local without 'development test' && " \
347
+ 'bundle config set silence_root_warning 1 && ' \
348
+ 'bundle install --jobs 4 && ' \
349
+ 'bundle clean --force'
350
+ ]
351
+
352
+ output, status = Open3.capture2e(*docker_cmd)
353
+ unless status.success?
354
+ puts output
355
+ abort "\n✗ Gem build failed. Check Docker is running and the Gemfile is valid."
356
+ end
357
+
358
+ # Strip fat from vendor bundle
359
+ strip_vendor_fat(build_dir)
360
+ puts ' ✅ Gems installed'
361
+ end
362
+
363
+ def strip_vendor_fat(build_dir)
364
+ vendor_dir = File.join(build_dir, 'vendor')
365
+ return unless Dir.exist?(vendor_dir)
366
+
367
+ # Remove docs, tests, and non-runtime files from gems
368
+ Dir.glob(File.join(vendor_dir, 'bundle/ruby/*/gems/*/')).each do |gem_dir|
369
+ %w[spec test tests doc docs examples benchmarks].each do |fat_dir|
370
+ FileUtils.rm_rf(File.join(gem_dir, fat_dir))
371
+ end
372
+ end
373
+
374
+ # Remove non-essential files
375
+ exts = %w[*.md *.rdoc *.txt *.c *.h *.o Makefile *.log CHANGELOG* HISTORY* LICENSE* README* .gitignore .travis.yml .rubocop.yml Rakefile]
376
+ exts.each do |pattern|
377
+ Dir.glob(File.join(vendor_dir, "**", pattern)).each do |f|
378
+ File.delete(f) if File.file?(f)
379
+ end
380
+ end
381
+
382
+ # Strip debug symbols from native extensions
383
+ Dir.glob(File.join(vendor_dir, '**/*.so')).each do |so_file|
384
+ system('strip', '--strip-debug', so_file, err: File::NULL, out: File::NULL)
385
+ end
386
+ end
387
+
388
+ def create_zip(build_dir)
389
+ zip_file = File.join(Dir.tmpdir, "belt-lambda-#{@env}-#{Time.now.to_i}.zip")
390
+
391
+ Dir.chdir(build_dir) do
392
+ output, status = Open3.capture2e('zip', '-qr', zip_file, '.')
393
+ unless status.success?
394
+ puts output
395
+ abort "\n✗ Failed to create ZIP"
396
+ end
397
+ end
398
+
399
+ size_mb = (File.size(zip_file) / 1024.0 / 1024.0).round(1)
400
+ puts " 🗜️ Package: #{size_mb}MB"
401
+ zip_file
402
+ end
403
+
404
+ def deploy_to_lambda(function_name, zip_file)
405
+ _output, status = Open3.capture2(
406
+ 'aws', 'lambda', 'update-function-code',
407
+ '--function-name', function_name,
408
+ '--zip-file', "fileb://#{zip_file}",
409
+ '--output', 'json'
410
+ )
411
+
412
+ File.delete(zip_file) if File.exist?(zip_file)
413
+
414
+ unless status.success?
415
+ abort "\n✗ Failed to update Lambda function code.\n" \
416
+ " Function: #{function_name}\n" \
417
+ " Check that the function exists and you have permissions."
418
+ end
419
+
420
+ # Wait for update to complete
421
+ print ' Waiting for Lambda to be ready...'
422
+ 30.times do
423
+ sleep 1
424
+ state_output, = Open3.capture2(
425
+ 'aws', 'lambda', 'get-function',
426
+ '--function-name', function_name,
427
+ '--query', 'Configuration.LastUpdateStatus',
428
+ '--output', 'text'
429
+ )
430
+ case state_output.strip
431
+ when 'Successful'
432
+ puts ' ✅'
433
+ return
434
+ when 'Failed'
435
+ puts ' ✗'
436
+ abort ' Lambda update failed. Check CloudWatch logs.'
437
+ end
438
+ print '.'
439
+ end
440
+ puts ' (timed out waiting, but code was pushed)'
441
+ end
442
+
127
443
  def run_init
128
444
  puts '━━━ terraform init ━━━'
129
445
  success = system('terraform', 'init')
@@ -166,6 +482,18 @@ module Belt
166
482
  File.delete('tfplan') if File.exist?('tfplan')
167
483
  end
168
484
 
485
+ def deploy_frontend_if_exists
486
+ frontend_dir = File.join(@project_root, 'frontend')
487
+ return unless Dir.exist?(frontend_dir) && File.exist?(File.join(frontend_dir, 'package.json'))
488
+
489
+ puts "\n━━━ frontend deploy ━━━"
490
+ require_relative 'frontend_deploy_command'
491
+ Belt::CLI::FrontendDeployCommand.new(@env).run
492
+ rescue StandardError => e
493
+ puts "\n ⚠ Frontend deploy failed: #{e.message}"
494
+ puts " Run `belt deploy frontend #{@env}` manually to retry."
495
+ end
496
+
169
497
  def print_outputs(env_dir)
170
498
  Dir.chdir(env_dir) do
171
499
  output = `terraform output 2>/dev/null`.strip