diff --git a/Rakefile b/Rakefile index d947677..3041641 100644 --- a/Rakefile +++ b/Rakefile @@ -24,6 +24,7 @@ task :install => [ :macos, :vscode, :cfg, + :sandbox, ] task :shell => [ :sh, @@ -36,7 +37,8 @@ task :check => [ :test_zsh, :shellcheck, :test_bats, - :test_cfg + :test_cfg, + :test_sandbox ] task :shellcheck do @@ -308,6 +310,10 @@ task :cfg => :bash do sh 'install -m 755 ruby/bin/cfg "$HOME/bin/cfg"' end +task :sandbox => :cfg do + sh 'install -m 755 ruby/bin/sandbox "$HOME/bin/sandbox"' +end + task :test_cfg do begin require 'rspec/core/rake_task' @@ -325,3 +331,21 @@ task :test_cfg do end Rake::Task[:_run_cfg_specs].invoke end + +task :test_sandbox do + begin + require 'rspec/core/rake_task' + rescue LoadError + puts "Warning: rspec not installed, skipping sandbox tests" + next + end + test_files = Dir.glob("ruby/lib/sandbox/spec/*_spec.rb") + if test_files.empty? + puts "No sandbox spec files found" + next + end + RSpec::Core::RakeTask.new(:_run_sandbox_specs) do |t| + t.pattern = test_files + end + Rake::Task[:_run_sandbox_specs].invoke +end diff --git a/ruby/bin/sandbox b/ruby/bin/sandbox new file mode 100644 index 0000000..909d6ad --- /dev/null +++ b/ruby/bin/sandbox @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift("#{Dir.home}/.local/lib/dotfiles") +require 'sandbox' +Sandbox::CLI.run(ARGV) diff --git a/ruby/lib/sandbox.rb b/ruby/lib/sandbox.rb new file mode 100644 index 0000000..1e7c4bf --- /dev/null +++ b/ruby/lib/sandbox.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Sandbox + class Error < StandardError; end +end + +require_relative 'sandbox/paths' +require_relative 'sandbox/name' +require_relative 'sandbox/ssh_alias' +require_relative 'sandbox/command_runner' +require_relative 'sandbox/spinner' +require_relative 'sandbox/connection_info' +require_relative 'sandbox/backend_interface' +require_relative 'sandbox/backends/bash_backend' +require_relative 'sandbox/backends/container' +require_relative 'sandbox/backends/kvm' +require_relative 'sandbox/backends/hcloud' +require_relative 'sandbox/backends/proxy' +require_relative 'sandbox/ai_bootstrapper' +require_relative 'sandbox/proxy_cli' +require_relative 'sandbox/app' +require_relative 'sandbox/cli' diff --git a/ruby/lib/sandbox/ai_bootstrapper.rb b/ruby/lib/sandbox/ai_bootstrapper.rb new file mode 100644 index 0000000..2ad5d0f --- /dev/null +++ b/ruby/lib/sandbox/ai_bootstrapper.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'shellwords' + +module Sandbox + class AiBootstrapper + def initialize(runner:, backend_name:, proxy: false) + @runner = runner + @backend_name = backend_name + @proxy = proxy + end + + def validate_agent!(agent) + run_script("ensure_know_agent #{Shellwords.escape(agent)}") + end + + def bootstrap!(sandbox_name, agent) + script = <<~BASH + ensure_know_agent #{Shellwords.escape(agent)} + bootstrap_ai #{Shellwords.escape(sandbox_name)} #{Shellwords.escape(agent)} + BASH + run_script(script) + end + + private + + def run_script(body) + script = +"set -euo pipefail\n" + script << "BACKEND=#{Shellwords.escape(@backend_name)}\n" + script << "PROXY=#{Shellwords.escape(@proxy ? 'true' : 'false')}\n" + script << "source #{Shellwords.escape(common_path)}\n" + script << "source #{Shellwords.escape(proxy_path)}\n" + script << "source #{Shellwords.escape(ai_bootstrap_path)}\n" + script << "source #{Shellwords.escape(container_path)}\n" + script << "source #{Shellwords.escape(kvm_path)}\n" + script << "source #{Shellwords.escape(hcloud_path)}\n" + script << <<~BASH + backend_get_ssh_port() { + local name="$1" + case "$BACKEND" in + container) get_container_ssh_port "$name" ;; + kvm) get_kvm_ssh_port "$name" ;; + hcloud) get_hcloud_ssh_port "$name" ;; + esac + } + BASH + script << body + @runner.run(['bash', '-lc', script]) + end + + def common_path + File.join(Dir.home, '.config/lib/bash/sandbox/common') + end + + def proxy_path + File.join(Dir.home, '.config/lib/bash/sandbox/proxy-backend') + end + + def ai_bootstrap_path + File.join(Dir.home, '.config/lib/bash/sandbox/ai-bootstrap') + end + + def container_path + File.join(Dir.home, '.config/lib/bash/sandbox/container-backend') + end + + def kvm_path + File.join(Dir.home, '.config/lib/bash/sandbox/kvm-backend') + end + + def hcloud_path + File.join(Dir.home, '.config/lib/bash/sandbox/hcloud-backend') + end + end +end diff --git a/ruby/lib/sandbox/app.rb b/ruby/lib/sandbox/app.rb new file mode 100644 index 0000000..f924ca9 --- /dev/null +++ b/ruby/lib/sandbox/app.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true + +module Sandbox + class App + def initialize( + backends:, + runner: CommandRunner.new, + spinner: Spinner.new, + ssh_alias: SshAlias.new, + proxy_cli: nil, + stdout: $stdout, + stderr: $stderr + ) + @backends = backends + @runner = runner + @spinner = spinner + @ssh_alias = ssh_alias + @proxy_cli = proxy_cli + @stdout = stdout + @stderr = stderr + end + + def run(options, args) + backend_name = select_backend_name(options) + backend = resolve_backend(backend_name, options[:proxy]) + BackendInterface.validate!(backend) + + validate_backend_requirements(backend) + + case args.first + when nil, '', 'start', 'enter' + cmd_start_or_enter(backend, options) + when 'idea' + cmd_idea(backend) + when 'code' + cmd_code(backend) + when 'tmux' + cmd_tmux(backend) + when 'proxy' + cmd_proxy(args[1..], backend) + when 'sync' + cmd_sync(args[1..], backend) + when 'list', 'ls' + cmd_list + when 'stop' + cmd_stop(args[1..], backend) + when 'info' + cmd_info(backend) + when 'help', '--help', '-h' + cmd_help(backend_name) + else + raise Error, "Unknown command: #{args.first}" + end + end + + private + + def select_backend_name(options) + return options.fetch(:backend) if options[:backend] + + name = Name.from_dir + running = @backends.values.select { |backend| backend.backend_is_running(name) } + + raise Error, 'Multiple backends running' if running.size > 1 + + running.first&.name || 'container' + end + + def resolve_backend(backend_name, proxy) + backend = @backends.fetch(backend_name) + return backend unless proxy + + return backend.with_proxy if backend.respond_to?(:with_proxy) + + backend + end + + def validate_backend_requirements(backend) + return unless backend.respond_to?(:validate_requirements) + + backend.validate_requirements + end + + def sandbox_name + Name.from_dir + end + + def ensure_sandbox_running(backend) + name = sandbox_name + return name if backend.backend_is_running(name) + + raise Error, 'No sandbox running for current directory' + end + + def get_ssh_port_or_fail(backend, name) + port = backend.backend_get_ssh_port(name) + raise Error, 'Could not determine SSH port' if port.nil? || port.empty? + + port + end + + def cmd_start_or_enter(backend, options) + name = sandbox_name + raise Error, 'Already inside sandbox container' if ENV['SANDBOX_CONTAINER'] + + if backend.backend_is_running(name) + @stdout.puts("Entering existing sandbox: #{name} (#{backend.name} backend)") + else + validate_no_backend_conflict(backend) + @stdout.puts("Starting sandbox: #{name} (#{backend.name} backend)") + @spinner.run('Starting sandbox') do + backend.backend_start(name, pty: true) + end + end + + bootstrap_agents(name, options, backend) + + if options[:sync] + cmd_sync(['up'], backend) + end + + backend.backend_enter(name) + end + + def validate_no_backend_conflict(selected_backend) + name = sandbox_name + running = @backends.values.select { |backend| backend.backend_is_running(name) } + return if running.empty? + return if running.size == 1 && running.first.name == selected_backend.name + + raise Error, "Already a running #{running.first.name} sandbox" + end + + def bootstrap_agents(name, options, backend) + return unless options[:agents]&.any? + + bootstrapper = AiBootstrapper.new( + runner: @runner, + backend_name: backend.name, + proxy: options.fetch(:proxy, false) + ) + + options[:agents].each do |agent| + bootstrapper.validate_agent!(agent) + end + + options[:agents].each do |agent| + bootstrapper.bootstrap!(name, agent) + end + end + + def cmd_list + @stdout.puts('Running sandboxes (container backend):') + @backends.fetch('container').list + @stdout.puts("\nRunning sandboxes (kvm backend):") + @backends.fetch('kvm').list + @stdout.puts("\nRunning sandboxes (hcloud backend):") + @backends.fetch('hcloud').list + end + + def cmd_stop(args, backend) + stop_all = %w[-a --all].include?(args.first) + + if stop_all + @stdout.puts('Stopping all sandboxes (all backends)...') + @backends.values.each(&:stop_all) + @ssh_alias.remove_all + @stdout.puts('Done') + return + end + + name = sandbox_name + @stdout.puts("Stopping sandbox: #{name}") + if backend.backend_is_running(name) + backend.backend_stop(name) + @ssh_alias.remove(name) + end + @stdout.puts('Done') + end + + def cmd_info(backend) + name = ensure_sandbox_running(backend) + port = get_ssh_port_or_fail(backend, name) + ip = backend.backend_get_ip(name) + raise Error, 'Could not determine IP address' if ip.nil? || ip.empty? + + @stdout.puts("Sandbox: #{name}") + @stdout.puts("Backend: #{backend.name}") + @stdout.puts("Workspace: #{Dir.pwd}") + + if @ssh_alias.configured?(name) + @stdout.puts("\nYou can connect using the SSH alias:") + @stdout.puts(" ssh #{name}") + else + @stdout.puts("\nSSH connection:") + @stdout.puts(" ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p #{port} dev@#{ip}") + @stdout.puts("\nJetBrains Gateway:") + @stdout.puts(" Host: #{ip}") + @stdout.puts(" Port: #{port}") + @stdout.puts(' User: dev') + end + + return unless backend.name == 'kvm' + + console_socket = File.join(Paths.backend_state_dir('kvm', name), 'console.sock') + @stdout.puts("\nConnect to vm console (to exit: ctrl+] or ctrl+altgr + 9 on qwertz keyboard):") + @stdout.puts(" socat STDIO,raw,echo=0,escape=0x1d UNIX-CONNECT:#{console_socket}") + end + + def cmd_idea(backend) + name = ensure_sandbox_running(backend) + port = get_ssh_port_or_fail(backend, name) + url = if @ssh_alias.configured?(name) + "jetbrains://gateway/ssh/environment?h=#{name}&launchIde=true&ideHint=IU&projectHint=/home/dev/workspace" + else + "jetbrains://gateway/ssh/environment?h=localhost&u=dev&p=#{port}&launchIde=true&ideHint=IU&projectHint=/home/dev/workspace" + end + @stdout.puts("Opening IntelliJ IDEA on #{name}...") + open_url(url) + end + + def cmd_code(backend) + name = ensure_sandbox_running(backend) + port = get_ssh_port_or_fail(backend, name) + remote = if @ssh_alias.configured?(name) + "ssh-remote+#{name}/home/dev/workspace" + else + "ssh-remote+dev@localhost:#{port}/home/dev/workspace" + end + url = "vscode://vscode-remote/#{remote}" + @stdout.puts("Opening Visual Studio Code on #{name}...") + + if command_available?('code') + @runner.run(['code', '--folder-uri', "vscode-remote://#{remote}"]) + else + open_url(url) + end + end + + def cmd_tmux(backend) + name = ensure_sandbox_running(backend) + raise Error, 'Alacritty terminal emulator not found' unless command_available?('alacritty') + + @stdout.puts("Opening Alacritty terminal with tmux session on #{name}...") + title = "dev@#{name} (ssh)" + + if @ssh_alias.configured?(name) + spawn_detached(['alacritty', '--title', title, '-e', 'ssh', '-t', name, + 'cd /home/dev/workspace && exec tmux new-session -A']) + else + port = get_ssh_port_or_fail(backend, name) + ip = backend.backend_get_ip(name) + spawn_detached(['alacritty', '--title', title, '-e', 'ssh', '-t', + '-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', + '-p', port.to_s, "dev@#{ip}", + 'cd /home/dev/workspace && exec tmux new-session -A']) + end + end + + def cmd_sync(args, backend) + direction = args.first + raise Error, "sync requires 'up' or 'down' argument" unless %w[up down].include?(direction) + + if %w[container kvm].include?(backend.name) + raise Error, 'sync command is only available for cloud backends (hcloud)' + end + + name = ensure_sandbox_running(backend) + ssh_target = if @ssh_alias.configured?(name) + name + else + ip = backend.backend_get_ip(name) + raise Error, 'Could not determine IP address' if ip.nil? || ip.empty? + "dev@#{ip}" + end + + if direction == 'up' + @stdout.puts('Uploading current directory to sandbox workspace...') + @runner.run([ + 'rsync', '-hzav', '--no-o', '--no-g', '--delete', + '-e', 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no', + './', "#{ssh_target}:/home/dev/workspace/" + ]) + else + @stdout.puts('Downloading sandbox workspace to current directory...') + @runner.run([ + 'rsync', '-hzav', '--no-o', '--no-g', '--delete', + '-e', 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no', + "#{ssh_target}:/home/dev/workspace/", './' + ]) + end + + @stdout.puts('Sync complete') + end + + def cmd_proxy(args, backend) + raise Error, 'Proxy backend not available' unless @proxy_cli + + @proxy_cli.run(args, sandbox_name) + end + + def cmd_help(backend_name) + @stdout.puts(<<~HELP) + sandbox - Containerized development sandbox + + Usage: + sandbox [--kvm|--container|--hcloud] [flags] + + Global Flags: + --kvm Use KVM backend + Stronger Isolation, sudo and root access inside sandbox + BUT: without the proxy option the vm has access to all port open on the host... + --container Use container backend + Container Isolation, no root access + --hcloud Use Hetzner Cloud backend + Cloud-based VMs with ephemeral lifecycle (destroyed on stop) + Requires hcloud CLI and authentication + --sync Sync workspace to VM (hcloud backend only) + Runs rsync from current directory to /home/dev/workspace + --proxy Force all communication to go throug a restrictive proxy + --agents Bootstrap AI agents in the sandbox with credentials from the host + Accepts comma-separated list (e.g., claude,gemini) + Supported Agents: claude, gemini, opencode + + Backend Selection: + The backend is automatically detected based on running sandboxes. + If no sandbox is running, defaults to container backend. + Use --kvm, --container, or --hcloud to explicitly select a backend. + + Commands: + (none) Start/enter sandbox for current directory + idea Open IntelliJ IDEA connected to sandbox + code Open Visual Studio Code connected to sandbox + tmux Open Alacritty terminal with tmux session + proxy Manage proxy and domain allowlist (see 'proxy help') + sync up Upload current directory to sandbox (cloud backends only) + sync down Download from sandbox to current directory (cloud backends only) + list List running sandboxes + stop Stop sandbox for current directory + stop -a Stop all sandboxes (all backends) + info Show SSH connection details + help Show this help + + Examples: + sandbox Start/enter (auto-detects backend) + sandbox --kvm Start KVM sandbox + sandbox --hcloud Start Hetzner Cloud sandbox + sandbox --hcloud --sync Start Hetzner Cloud sandbox with workspace sync + sandbox code Open VS Code (auto-detects backend) + sandbox info Show info (auto-detects backend) + sandbox stop -a Stop all sandboxes + + Current backend: #{backend_name} + HELP + end + + def open_url(url) + if command_available?('open') + @runner.run(['open', url], allow_failure: false) + else + raise Error, 'open command found' + end + end + + def spawn_detached(cmd) + pid = Process.spawn(*cmd) + Process.detach(pid) + end + + def command_available?(name) + system("command -v #{name} >/dev/null 2>&1") + end + end +end diff --git a/ruby/lib/sandbox/backend_interface.rb b/ruby/lib/sandbox/backend_interface.rb new file mode 100644 index 0000000..4592f01 --- /dev/null +++ b/ruby/lib/sandbox/backend_interface.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Sandbox + module BackendInterface + REQUIRED_METHODS = %i[ + backend_start + backend_stop + backend_enter + backend_is_running + backend_get_ssh_port + backend_get_ip + ].freeze + + module_function + + def validate!(backend) + missing = REQUIRED_METHODS.reject { |method| backend.respond_to?(method) } + return if missing.empty? + + raise Error, "Backend #{backend.class} missing methods: #{missing.join(', ')}" + end + end +end diff --git a/ruby/lib/sandbox/backends/bash_backend.rb b/ruby/lib/sandbox/backends/bash_backend.rb new file mode 100644 index 0000000..1bda935 --- /dev/null +++ b/ruby/lib/sandbox/backends/bash_backend.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'shellwords' + +module Sandbox + module Backends + class BashBackend + attr_reader :name + + def initialize(name:, source_files:, functions:, runner:, env: {}) + @name = name + @source_files = source_files + @functions = functions + @runner = runner + @env = env + end + + def backend_start(sandbox_name, pty: false) + run_function(@functions.fetch(:start), sandbox_name, pty: pty) + end + + def backend_stop(sandbox_name) + run_function(@functions.fetch(:stop), sandbox_name) + end + + def backend_enter(sandbox_name) + exec_function(@functions.fetch(:enter), sandbox_name) + end + + def backend_is_running(sandbox_name) + status = run_function(@functions.fetch(:is_running), sandbox_name, allow_failure: true) + status.success? + end + + def backend_get_ssh_port(sandbox_name) + capture_function(@functions.fetch(:get_ssh_port), sandbox_name).strip + end + + def backend_get_ip(sandbox_name) + capture_function(@functions.fetch(:get_ip), sandbox_name).strip + end + + def list + function = @functions[:list] + return unless function + + run_function(function) + end + + def stop_all + function = @functions[:stop_all] + return unless function + + run_function(function) + end + + private + + def exec_function(function, *args) + @runner.exec(build_command(function, args)) + end + + def run_function(function, *args, pty: false, allow_failure: false) + @runner.run(build_command(function, args), env: @env, pty: pty, allow_failure: allow_failure) + end + + def capture_function(function, *args) + @runner.capture(build_command(function, args), env: @env) + end + + def build_command(function, args) + script = +"set -euo pipefail\n" + @source_files.each do |file| + script << "source #{Shellwords.escape(file)}\n" + end + script << "SANDBOX_NAME=#{Shellwords.escape(args.first.to_s)}\n" + script << "sandbox_name() { echo \"$SANDBOX_NAME\"; }\n" + script << "#{function} #{args.map { |arg| Shellwords.escape(arg.to_s) }.join(' ')}\n" + ['bash', '-lc', script] + end + end + end +end diff --git a/ruby/lib/sandbox/backends/container.rb b/ruby/lib/sandbox/backends/container.rb new file mode 100644 index 0000000..a6216c5 --- /dev/null +++ b/ruby/lib/sandbox/backends/container.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Container + def initialize(runner:, proxy: false) + @runner = runner + @proxy = proxy + env = { 'PROXY' => proxy ? 'true' : 'false', 'BACKEND' => 'container' } + @backend = BashBackend.new( + name: 'container', + source_files: [common_path, backend_path, proxy_path], + functions: { + start: 'start_container_sandbox', + stop: 'stop_container_sandbox', + is_running: 'is_container_running', + get_ssh_port: 'get_container_ssh_port', + get_ip: 'get_container_ip', + list: 'list_container_sandboxes', + stop_all: 'stop_all_container_sandboxes' + }, + runner: runner, + env: env + ) + end + + def backend_start(name, pty: false) + @backend.backend_start(name, pty: pty) + end + + def backend_stop(name) + @backend.backend_stop(name) + end + + def backend_enter(name) + engine = detect_container_engine + @runner.exec([ + engine, 'exec', '-it', + '-e', "TERM=#{ENV.fetch('TERM', 'xterm-256color')}", + '-u', 'dev', + '-w', '/home/dev/workspace', + name, + 'zsh' + ]) + end + + def backend_is_running(name) + @backend.backend_is_running(name) + end + + def backend_get_ssh_port(name) + @backend.backend_get_ssh_port(name) + end + + def backend_get_ip(name) + @backend.backend_get_ip(name) + end + + def name + @backend.name + end + + def list + @backend.list + end + + def stop_all + @backend.stop_all + end + + def with_proxy + return self if @proxy + + self.class.new(runner: @runner, proxy: true) + end + + private + + def detect_container_engine + return ENV['CONTAINER_ENGINE'] if ENV['CONTAINER_ENGINE'] && !ENV['CONTAINER_ENGINE'].empty? + + return 'podman' if command_available?('podman') + return 'docker' if command_available?('docker') + + raise Error, 'Neither podman nor docker found' + end + + def command_available?(name) + system("command -v #{name} >/dev/null 2>&1") + end + + private + + def common_path + File.join(Dir.home, '.config/lib/bash/sandbox/common') + end + + def backend_path + File.join(Dir.home, '.config/lib/bash/sandbox/container-backend') + end + + def proxy_path + File.join(Dir.home, '.config/lib/bash/sandbox/proxy-backend') + end + end + end +end diff --git a/ruby/lib/sandbox/backends/hcloud.rb b/ruby/lib/sandbox/backends/hcloud.rb new file mode 100644 index 0000000..27621ed --- /dev/null +++ b/ruby/lib/sandbox/backends/hcloud.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Hcloud + def initialize(runner:, ssh_alias:) + @runner = runner + @ssh_alias = ssh_alias + env = { 'BACKEND' => 'hcloud' } + @backend = BashBackend.new( + name: 'hcloud', + source_files: [common_path, backend_path], + functions: { + start: 'start_hcloud_sandbox', + stop: 'stop_hcloud_sandbox', + is_running: 'is_hcloud_running', + get_ssh_port: 'get_hcloud_ssh_port', + get_ip: 'get_hcloud_ip', + list: 'list_hcloud_sandboxes', + stop_all: 'stop_all_hcloud_sandboxes' + }, + runner: runner, + env: env + ) + end + + def backend_start(name, pty: false) + @backend.backend_start(name, pty: pty) + end + + def backend_stop(name) + @backend.backend_stop(name) + end + + def backend_enter(name) + if @ssh_alias.configured?(name) + @runner.exec(['ssh', name]) + return + end + + ip = backend_get_ip(name) + raise Error, 'Could not determine server IP' if ip.nil? || ip.empty? + + @runner.exec([ + 'ssh', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=no', + "dev@#{ip}" + ]) + end + + def backend_is_running(name) + @backend.backend_is_running(name) + end + + def backend_get_ssh_port(name) + @backend.backend_get_ssh_port(name) + end + + def backend_get_ip(name) + @backend.backend_get_ip(name) + end + + def name + @backend.name + end + + def list + @backend.list + end + + def stop_all + @backend.stop_all + end + + def with_proxy + self + end + + private + + def common_path + File.join(Dir.home, '.config/lib/bash/sandbox/common') + end + + def backend_path + File.join(Dir.home, '.config/lib/bash/sandbox/hcloud-backend') + end + end + end +end diff --git a/ruby/lib/sandbox/backends/kvm.rb b/ruby/lib/sandbox/backends/kvm.rb new file mode 100644 index 0000000..4bad246 --- /dev/null +++ b/ruby/lib/sandbox/backends/kvm.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Kvm + def initialize(runner:, proxy: false) + @runner = runner + @proxy = proxy + env = { 'PROXY' => proxy ? 'true' : 'false', 'BACKEND' => 'kvm' } + @backend = BashBackend.new( + name: 'kvm', + source_files: [common_path, backend_path, proxy_path], + functions: { + start: 'start_kvm_sandbox', + stop: 'stop_kvm_sandbox', + is_running: 'is_kvm_running', + get_ssh_port: 'get_kvm_ssh_port', + get_ip: 'get_kvm_ip', + list: 'list_kvm_sandboxes', + stop_all: 'stop_all_kvm_sandboxes' + }, + runner: runner, + env: env + ) + end + + def validate_requirements + raise Error, 'KVM not available (no /dev/kvm)' unless File.exist?('/dev/kvm') + raise Error, 'KVM not writeable for this user' unless File.writable?('/dev/kvm') + raise Error, 'qemu-system-x86_64 not found' unless command_available?('qemu-system-x86_64') + end + + def backend_start(name, pty: false) + @backend.backend_start(name, pty: pty) + end + + def backend_stop(name) + @backend.backend_stop(name) + end + + def backend_enter(name) + port = backend_get_ssh_port(name) + raise Error, 'Could not determine SSH port' if port.nil? || port.empty? + + @runner.exec([ + 'ssh', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=no', + '-p', port.to_s, + 'dev@localhost' + ]) + end + + def backend_is_running(name) + @backend.backend_is_running(name) + end + + def backend_get_ssh_port(name) + @backend.backend_get_ssh_port(name) + end + + def backend_get_ip(name) + @backend.backend_get_ip(name) + end + + def name + @backend.name + end + + def list + @backend.list + end + + def stop_all + @backend.stop_all + end + + def with_proxy + return self if @proxy + + self.class.new(runner: @runner, proxy: true) + end + + private + + def command_available?(name) + system("command -v #{name} >/dev/null 2>&1") + end + + def common_path + File.join(Dir.home, '.config/lib/bash/sandbox/common') + end + + def backend_path + File.join(Dir.home, '.config/lib/bash/sandbox/kvm-backend') + end + + def proxy_path + File.join(Dir.home, '.config/lib/bash/sandbox/proxy-backend') + end + end + end +end diff --git a/ruby/lib/sandbox/backends/proxy.rb b/ruby/lib/sandbox/backends/proxy.rb new file mode 100644 index 0000000..73dd012 --- /dev/null +++ b/ruby/lib/sandbox/backends/proxy.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Proxy + def initialize(base_backend) + @base_backend = base_backend + end + + def name + @base_backend.respond_to?(:name) ? @base_backend.name : 'proxy' + end + + def method_missing(method, *args, **kwargs, &block) + return @base_backend.public_send(method, *args, **kwargs, &block) if @base_backend.respond_to?(method) + + super + end + + def respond_to_missing?(method, include_private = false) + @base_backend.respond_to?(method, include_private) || super + end + end + end +end diff --git a/ruby/lib/sandbox/cli.rb b/ruby/lib/sandbox/cli.rb new file mode 100644 index 0000000..4d107ae --- /dev/null +++ b/ruby/lib/sandbox/cli.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'optparse' + +module Sandbox + module CLI + module_function + + def run(argv) + options = { + backend: nil, + proxy: false, + sync: false, + agents: nil + } + + parser = OptionParser.new do |o| + o.on('--kvm') { options[:backend] = 'kvm' } + o.on('--container') { options[:backend] = 'container' } + o.on('--hcloud') { options[:backend] = 'hcloud' } + o.on('--proxy') { options[:proxy] = true } + o.on('--sync') { options[:sync] = true } + o.on('--agents LIST') { |list| options[:agents] = list } + o.on('--help', '-h') { options[:help] = true } + end + + args = parser.parse(argv) + + if options[:help] + app(options).run(options, ['help']) + return + end + + if options[:agents] + options[:agents] = options[:agents].split(',').map(&:strip).reject(&:empty?) + end + + app(options).run(options, args) + rescue Error => e + $stderr.puts("sandbox: #{e.message}") + exit 1 + end + + def app(options) + runner = CommandRunner.new + ssh_alias = SshAlias.new + backends = { + 'container' => Backends::Container.new(runner: runner, proxy: options[:proxy]), + 'kvm' => Backends::Kvm.new(runner: runner, proxy: options[:proxy]), + 'hcloud' => Backends::Hcloud.new(runner: runner, ssh_alias: ssh_alias) + } + + App.new( + backends: backends, + runner: runner, + spinner: Spinner.new, + ssh_alias: ssh_alias, + proxy_cli: ProxyCli.new(runner: runner) + ) + end + end +end diff --git a/ruby/lib/sandbox/command_runner.rb b/ruby/lib/sandbox/command_runner.rb new file mode 100644 index 0000000..94bf664 --- /dev/null +++ b/ruby/lib/sandbox/command_runner.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'open3' +require 'pty' + +module Sandbox + class CommandRunner + def initialize(stdout: $stdout, stderr: $stderr) + @stdout = stdout + @stderr = stderr + end + + def run(cmd, env: {}, pty: false, allow_failure: false) + status = if pty + run_with_pty(cmd, env: env) + else + run_with_open3(cmd, env: env) + end + return status if allow_failure || status.success? + + raise Error, "Command failed: #{cmd.join(' ')}" + end + + def capture(cmd, env: {}, allow_failure: false) + output, status = Open3.capture2e(env, *cmd) + return output if allow_failure || status.success? + + raise Error, "Command failed: #{cmd.join(' ')}" + end + + def exec(cmd, env: {}) + Kernel.exec(env, *cmd) + end + + private + + def run_with_open3(cmd, env:) + Open3.popen2e(env, *cmd) do |_stdin, stdout_err, wait| + stdout_err.each { |chunk| @stdout.write(chunk) } + @stdout.flush + wait.value + end + end + + def run_with_pty(cmd, env:) + status = nil + PTY.spawn(env, *cmd) do |read, write, pid| + write.close + begin + read.each do |chunk| + @stdout.write(chunk) + @stdout.flush + end + rescue Errno::EIO + # PTY closed + ensure + Process.wait(pid) + status = $? + end + end + status + end + end +end diff --git a/ruby/lib/sandbox/connection_info.rb b/ruby/lib/sandbox/connection_info.rb new file mode 100644 index 0000000..2809891 --- /dev/null +++ b/ruby/lib/sandbox/connection_info.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Sandbox + ConnectionInfo = Struct.new(:host, :port, :user, keyword_init: true) +end diff --git a/ruby/lib/sandbox/name.rb b/ruby/lib/sandbox/name.rb new file mode 100644 index 0000000..01f5d52 --- /dev/null +++ b/ruby/lib/sandbox/name.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Sandbox + module Name + module_function + + def from_dir(dir = Dir.pwd) + base = File.basename(dir) + sanitized = base.gsub(/[^a-zA-Z0-9_-]/, '_') + "sandbox-#{sanitized}" + end + end +end diff --git a/ruby/lib/sandbox/paths.rb b/ruby/lib/sandbox/paths.rb new file mode 100644 index 0000000..863289d --- /dev/null +++ b/ruby/lib/sandbox/paths.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Sandbox + module Paths + module_function + + def cache_base + ENV.fetch('SANDBOX_CACHE_BASE', File.join(Dir.home, '.cache', 'sandbox')) + end + + def ssh_config_dir + ENV.fetch('SSH_CONFIG_DIR', File.join(Dir.home, '.ssh', 'config.d')) + end + + def backend_state_dir(backend, name) + File.join(cache_base, "#{backend}-vms", name) + end + end +end diff --git a/ruby/lib/sandbox/proxy_cli.rb b/ruby/lib/sandbox/proxy_cli.rb new file mode 100644 index 0000000..4b8c9c8 --- /dev/null +++ b/ruby/lib/sandbox/proxy_cli.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'shellwords' + +module Sandbox + class ProxyCli + def initialize(runner:) + @runner = runner + end + + def run(args, sandbox_name) + script = +"set -euo pipefail\n" + script << "source #{Shellwords.escape(common_path)}\n" + script << "source #{Shellwords.escape(proxy_backend_path)}\n" + script << "source #{Shellwords.escape(proxy_cli_path)}\n" + script << "SANDBOX_NAME=#{Shellwords.escape(sandbox_name)}\n" + script << "sandbox_name() { echo \"$SANDBOX_NAME\"; }\n" + script << "cmd_proxy #{args.map { |arg| Shellwords.escape(arg) }.join(' ')}\n" + @runner.run(['bash', '-lc', script]) + end + + private + + def common_path + File.join(Dir.home, '.config/lib/bash/sandbox/common') + end + + def proxy_backend_path + File.join(Dir.home, '.config/lib/bash/sandbox/proxy-backend') + end + + def proxy_cli_path + File.join(Dir.home, '.config/lib/bash/sandbox/proxy-cli') + end + end +end diff --git a/ruby/lib/sandbox/spec/backend_interface_spec.rb b/ruby/lib/sandbox/spec/backend_interface_spec.rb new file mode 100644 index 0000000..11429d3 --- /dev/null +++ b/ruby/lib/sandbox/spec/backend_interface_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'sandbox' +require 'sandbox/spec/spec_helper' + +RSpec.describe Sandbox::BackendInterface do + it 'raises when backend misses required methods' do + backend = Struct.new(:backend_start).new(-> {}) + + expect do + described_class.validate!(backend) + end.to raise_error(Sandbox::Error, /missing methods/) + end +end diff --git a/ruby/lib/sandbox/spec/backend_selection_spec.rb b/ruby/lib/sandbox/spec/backend_selection_spec.rb new file mode 100644 index 0000000..e496313 --- /dev/null +++ b/ruby/lib/sandbox/spec/backend_selection_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'sandbox' +require 'sandbox/spec/spec_helper' + +RSpec.describe Sandbox::App do + let(:runner) { Sandbox::CommandRunner.new(stdout: StringIO.new, stderr: StringIO.new) } + let(:spinner) { Sandbox::Spinner.new(io: StringIO.new, tty: false) } + let(:ssh_alias) { Sandbox::SshAlias.new(config_dir: Dir.mktmpdir) } + + def app_with(backends) + described_class.new( + backends: backends, + runner: runner, + spinner: spinner, + ssh_alias: ssh_alias, + proxy_cli: Sandbox::ProxyCli.new(runner: runner), + stdout: StringIO.new, + stderr: StringIO.new + ) + end + + def backend_stub(name, running: false) + Struct.new(:name) do + define_method(:backend_is_running) { |_sandbox| running } + end.new(name) + end + + it 'uses explicit backend when provided' do + app = app_with( + 'container' => backend_stub('container', running: true), + 'kvm' => backend_stub('kvm', running: true), + 'hcloud' => backend_stub('hcloud', running: false) + ) + expect(app.send(:select_backend_name, backend: 'kvm')).to eq('kvm') + end + + it 'detects running backend when not explicit' do + app = app_with( + 'container' => backend_stub('container', running: false), + 'kvm' => backend_stub('kvm', running: true), + 'hcloud' => backend_stub('hcloud', running: false) + ) + expect(app.send(:select_backend_name, {})).to eq('kvm') + end + + it 'defaults to container when none running' do + app = app_with( + 'container' => backend_stub('container', running: false), + 'kvm' => backend_stub('kvm', running: false), + 'hcloud' => backend_stub('hcloud', running: false) + ) + expect(app.send(:select_backend_name, {})).to eq('container') + end + + it 'raises when multiple backends are running' do + app = app_with( + 'container' => backend_stub('container', running: true), + 'kvm' => backend_stub('kvm', running: true), + 'hcloud' => backend_stub('hcloud', running: false) + ) + expect { app.send(:select_backend_name, {}) }.to raise_error(Sandbox::Error, /Multiple backends running/) + end +end diff --git a/ruby/lib/sandbox/spec/name_spec.rb b/ruby/lib/sandbox/spec/name_spec.rb new file mode 100644 index 0000000..d9b95f0 --- /dev/null +++ b/ruby/lib/sandbox/spec/name_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'sandbox' +require 'sandbox/spec/spec_helper' + +RSpec.describe Sandbox::Name do + it 'sanitizes directory names' do + expect(described_class.from_dir('/tmp/hello world!')).to eq('sandbox-hello_world_') + end + + it 'preserves alphanumeric, underscore, and dash' do + expect(described_class.from_dir('/tmp/app-name_123')).to eq('sandbox-app-name_123') + end +end diff --git a/ruby/lib/sandbox/spec/spec_helper.rb b/ruby/lib/sandbox/spec/spec_helper.rb new file mode 100644 index 0000000..d4773ea --- /dev/null +++ b/ruby/lib/sandbox/spec/spec_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'tmpdir' +require 'fileutils' +require 'stringio' + +$LOAD_PATH.unshift(File.expand_path('../../', __dir__)) + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.disable_monkey_patching! + config.warnings = true + config.order = :random + Kernel.srand config.seed +end diff --git a/ruby/lib/sandbox/spec/spinner_spec.rb b/ruby/lib/sandbox/spec/spinner_spec.rb new file mode 100644 index 0000000..31e0d83 --- /dev/null +++ b/ruby/lib/sandbox/spec/spinner_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'sandbox' +require 'sandbox/spec/spec_helper' + +RSpec.describe Sandbox::Spinner do + it 'prints a status line when stdout is not a TTY' do + output = StringIO.new + spinner = described_class.new(io: output, tty: false) + + spinner.run('Updating image') { output.write('done') } + + expect(output.string).to include('Updating image...') + expect(output.string).to include('done') + end + + it 'renders spinner frames when TTY is available' do + output = StringIO.new + spinner = described_class.new(io: output, tty: true, interval: 0.01) + + spinner.run('Starting container') { sleep(0.03) } + + expect(output.string).to include("\e[1A") + expect(output.string).to match(/Starting container/) + end +end diff --git a/ruby/lib/sandbox/spinner.rb b/ruby/lib/sandbox/spinner.rb new file mode 100644 index 0000000..2d0ef77 --- /dev/null +++ b/ruby/lib/sandbox/spinner.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Sandbox + class Spinner + FRAMES = %w[⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷].freeze + + def initialize(io: $stdout, interval: 0.1, tty: nil) + @io = io + @interval = interval + @tty = tty.nil? ? io.tty? : tty + @mutex = Mutex.new + @running = false + @task = nil + @index = 0 + end + + def run(task) + return run_without_tty(task) { yield } unless @tty + + @mutex.synchronize do + @task = task + @running = true + end + @io.print("#{frame} #{@task}\n") + @io.flush + thread = Thread.new { spin_loop } + yield + ensure + stop(thread) + end + + def update_task(task) + @mutex.synchronize { @task = task } + end + + private + + def run_without_tty(task) + @io.puts("#{task}...") + yield + end + + def stop(thread) + @mutex.synchronize { @running = false } + thread&.join + end + + def spin_loop + while running? + render + sleep @interval + end + end + + def running? + @mutex.synchronize { @running } + end + + def frame + @index = (@index + 1) % FRAMES.length + FRAMES[@index] + end + + def render + task = @mutex.synchronize { @task } + @io.print("\e[s\e[1A\e[2K#{frame} #{task}\e[u") + @io.flush + end + end +end diff --git a/ruby/lib/sandbox/ssh_alias.rb b/ruby/lib/sandbox/ssh_alias.rb new file mode 100644 index 0000000..4d6414e --- /dev/null +++ b/ruby/lib/sandbox/ssh_alias.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Sandbox + class SshAlias + def initialize(config_dir: Paths.ssh_config_dir) + @config_dir = config_dir + end + + def setup(name, port, host: '127.0.0.1') + return unless Dir.exist?(@config_dir) + + FileUtils.mkdir_p(@config_dir) + path = alias_path(name) + File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file| + file.write(<<~CONFIG) + Host #{name} + HostName #{host} + Port #{port} + User dev + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + CONFIG + end + end + + def remove(name) + FileUtils.rm_f(alias_path(name)) + end + + def remove_all + Dir.glob(File.join(@config_dir, 'alias-sandbox-*')).each do |path| + FileUtils.rm_f(path) + end + end + + def configured?(name) + File.exist?(alias_path(name)) + end + + private + + def alias_path(name) + File.join(@config_dir, "alias-#{name}.conf") + end + end +end