Module: BeltEnvironment

Extended by:
BeltHelpers
Defined in:
lib/brainiac/handlers/shared/belt.rb

Overview

Belt environment operations (create, deploy, destroy). These wrap the belt CLI commands for ephemeral environment management.

Class Method Summary collapse

Methods included from BeltHelpers

belt_app?, belt_routes_file?, belt_routes_path

Class Method Details

.create_environment(worktree:, env_name:, parent_env:) ⇒ Boolean

Create an ephemeral environment from a parent environment.

Parameters:

  • worktree (String)

    Path to the worktree

  • env_name (String)

    Name for the ephemeral environment

  • parent_env (String)

    Parent environment to copy from

Returns:

  • (Boolean)

    True on success



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/brainiac/handlers/shared/belt.rb', line 208

def create_environment(worktree:, env_name:, parent_env:)
  return false unless belt_app?(worktree)

  LOG.info "[Belt] Creating ephemeral environment '#{env_name}' from parent '#{parent_env}'"

  _, stderr, status = Open3.capture3("belt", "g", "environment", env_name, parent_env, chdir: worktree)

  if status.success?
    LOG.info "[Belt] Successfully created environment '#{env_name}'"
    true
  else
    LOG.error "[Belt] Failed to create environment '#{env_name}': #{stderr.strip}"
    false
  end
rescue StandardError => e
  LOG.error "[Belt] Error creating environment: #{e.message}"
  false
end

.deploy(worktree:, env_name:, frontend_only: false, capture3: nil) ⇒ Boolean

Deploy to an environment.

Always non-interactive: belt deploy prompts "Apply these changes? [y/N]" unless --auto is passed. Open3.capture3 provides empty stdin, so without --auto belt prints "Cancelled." and still exits 0 — a silent no-op. Frontend-only uses belt deploy frontend <env> (subcommand first).

Parameters:

  • worktree (String)

    Path to the worktree

  • env_name (String)

    Environment name

  • frontend_only (Boolean) (defaults to: false)

    If true, only deploy frontend

Returns:

  • (Boolean)

    True on success



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/brainiac/handlers/shared/belt.rb', line 238

def deploy(worktree:, env_name:, frontend_only: false, capture3: nil)
  return false unless belt_app?(worktree)

  cmd = deploy_command(env_name, frontend_only: frontend_only)

  LOG.info "[Belt] Deploying to '#{env_name}'#{" (frontend only)" if frontend_only}"
  LOG.info "[Belt] Running: #{cmd.join(" ")} (in #{worktree})"

  runner = capture3 || Open3.method(:capture3)
  stdout, stderr, status = runner.call(*cmd, chdir: worktree)
  log_cli_tail(stdout, stderr)

  if deploy_cancelled?(stdout, stderr)
    LOG.error "[Belt] Deploy to '#{env_name}' cancelled — non-interactive belt deploy needs --auto"
    return false
  end

  if status.success?
    LOG.info "[Belt] Successfully deployed to '#{env_name}'"
    true
  else
    LOG.error "[Belt] Failed to deploy to '#{env_name}': #{stderr.strip}"
    false
  end
rescue StandardError => e
  LOG.error "[Belt] Error deploying: #{e.message}"
  false
end

.deploy_cancelled?(stdout, stderr) ⇒ Boolean

Returns:

  • (Boolean)


395
396
397
# File 'lib/brainiac/handlers/shared/belt.rb', line 395

def deploy_cancelled?(stdout, stderr)
  [stdout, stderr].any? { |s| s.to_s.match?(/\bCancelled\.?\s*$/) }
end

.deploy_command(env_name, frontend_only: false) ⇒ Object

argv for belt deploy. Extracted so tests can assert the command without stubbing Open3 for every call site.



269
270
271
272
273
274
275
# File 'lib/brainiac/handlers/shared/belt.rb', line 269

def deploy_command(env_name, frontend_only: false)
  if frontend_only
    ["belt", "deploy", "frontend", env_name]
  else
    ["belt", "deploy", env_name, "--auto"]
  end
end

.destroy_environment(worktree:, env_name:) ⇒ Boolean

Destroy an ephemeral environment.

Parameters:

  • worktree (String)

    Path to the worktree (infrastructure lives here)

  • env_name (String)

    Environment name

Returns:

  • (Boolean)

    True on success



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/brainiac/handlers/shared/belt.rb', line 282

def destroy_environment(worktree:, env_name:)
  return false unless belt_app?(worktree)

  LOG.info "[Belt] Destroying ephemeral environment '#{env_name}'"

  _, stderr, status = Open3.capture3("belt", "destroy", "environment", env_name, "--full", chdir: worktree)

  if status.success?
    LOG.info "[Belt] Successfully destroyed environment '#{env_name}'"
    BeltConfig.mark_ephemeral_destroyed(env_name)
    true
  else
    LOG.error "[Belt] Failed to destroy environment '#{env_name}': #{stderr.strip}"
    false
  end
rescue StandardError => e
  LOG.error "[Belt] Error destroying environment: #{e.message}"
  false
end

.environment_configured?(worktree:, env_name:) ⇒ Boolean

Check whether an environment is configured in a worktree. belt g environment writes infrastructure/<env_name>/; that directory is the source of truth, not the ephemeral_envs.json tracking file.

Parameters:

  • worktree (String)

    Path to the worktree

  • env_name (String)

    Environment name (e.g. "fizzy-1299")

Returns:

  • (Boolean)


196
197
198
199
200
# File 'lib/brainiac/handlers/shared/belt.rb', line 196

def environment_configured?(worktree:, env_name:)
  return false unless worktree && env_name && File.directory?(worktree)

  File.directory?(File.join(worktree, "infrastructure", env_name.to_s))
end

.frontend_only_changes?(worktree:, base_branch: nil) ⇒ Boolean

Check if changes are frontend-only by examining the diff. Frontend-only changes can be deployed faster with belt deploy frontend <env>.

Parameters:

  • worktree (String)

    Path to the worktree

  • base_branch (String, nil) (defaults to: nil)

    Optional base (e.g. "master", "origin/main"). When omitted, uses origin/HEAD, then origin/main, then origin/master.

Returns:

  • (Boolean)

    True if changes are frontend-only



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/brainiac/handlers/shared/belt.rb', line 309

def frontend_only_changes?(worktree:, base_branch: nil)
  base_ref = resolve_frontend_diff_base(worktree, base_branch)
  unless base_ref
    LOG.warn "[Belt] No base ref for frontend-only check in #{worktree}"
    return false
  end

  stdout, stderr, status = Open3.capture3("git", "diff", "--name-only", base_ref, "--", chdir: worktree)
  unless status.success?
    LOG.warn "[Belt] Could not diff against #{base_ref}: #{stderr.strip}"
    return false
  end

  changed_files = stdout.strip.split("\n")
  return false if changed_files.empty?

  # Frontend directories that don't affect backend
  frontend_patterns = %w[
    frontend/
    app/javascript/
    app/assets/
    public/
    static/
    src/
  ]

  # Backend patterns that require full deploy
  backend_patterns = %w[
    lambda/
    infrastructure/
    config/routes
    config/contracts
    Gemfile
    *.gemspec
    Rakefile
  ]

  # Check if all changes are frontend-only
  changed_files.all? do |file|
    frontend_patterns.any? { |pattern| file.start_with?(pattern) } &&
      backend_patterns.none? { |pattern| file.start_with?(pattern.delete("*")) || File.fnmatch?(pattern, file) }
  end
rescue StandardError => e
  LOG.warn "[Belt] Error checking frontend-only changes: #{e.message}"
  false
end

.git_commit?(worktree, ref) ⇒ Boolean

Returns:

  • (Boolean)


385
386
387
388
389
390
391
392
393
# File 'lib/brainiac/handlers/shared/belt.rb', line 385

def git_commit?(worktree, ref)
  _stdout, _stderr, status = Open3.capture3(
    "git", "rev-parse", "--verify", "#{ref}^{commit}",
    chdir: worktree
  )
  status.success?
rescue StandardError
  false
end

.log_cli_tail(stdout, stderr, limit: 25) ⇒ Object



399
400
401
402
403
404
405
406
407
# File 'lib/brainiac/handlers/shared/belt.rb', line 399

def log_cli_tail(stdout, stderr, limit: 25)
  lines = []
  lines.concat(stdout.to_s.lines) unless stdout.to_s.strip.empty?
  lines.concat(stderr.to_s.lines.map { |l| "stderr: #{l}" }) unless stderr.to_s.strip.empty?
  return if lines.empty?

  tail = lines.last(limit).join
  LOG.info "[Belt] Output (last #{[lines.size, limit].min} lines):\n#{tail}"
end

.origin_head_branch(worktree) ⇒ Object



372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/brainiac/handlers/shared/belt.rb', line 372

def origin_head_branch(worktree)
  stdout, _stderr, status = Open3.capture3(
    "git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD",
    chdir: worktree
  )
  return nil unless status.success?

  name = stdout.strip.delete_prefix("origin/")
  name.empty? ? nil : name
rescue StandardError
  nil
end

.resolve_frontend_diff_base(worktree, explicit) ⇒ Object

Resolve a git ref to diff against. Never assumes origin/main. Prefer an explicit PR/base branch, then origin/HEAD, then main/master.



358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/brainiac/handlers/shared/belt.rb', line 358

def resolve_frontend_diff_base(worktree, explicit)
  candidates = []
  if explicit && !explicit.to_s.strip.empty?
    ref = explicit.to_s.strip
    ref = "origin/#{ref}" unless ref.include?("/")
    candidates << ref
  end

  head = origin_head_branch(worktree)
  candidates << "origin/#{head}" if head
  candidates.push("origin/main", "origin/master")
  candidates.uniq.find { |ref| git_commit?(worktree, ref) }
end