diff --git a/README.md b/README.md index 881393e..5da23cf 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ airlock destroy my-project | Command | Description | |---------|-------------| | `airlock setup [name]` | Create and provision a VM, then take a clean snapshot | +| `airlock init [path-or-url]` | Interactive wizard that walks you through sandbox creation | | `airlock sandbox ` | Create an isolated sandbox | | `airlock run ` | Run a command inside a sandbox | | `airlock shell [name]` | Open an interactive shell inside the VM | @@ -93,6 +94,18 @@ Runtime auto-detection checks for: `package.json` (node), `go.mod` (go), `Cargo. GitHub URL formats: `gh:user/repo` or `https://github.com/user/repo` — the repo name becomes the sandbox name. +### Interactive wizard + +If you'd rather not remember flags, `airlock init` walks you through the common choices: + +```bash +airlock init # new sandbox in the current directory +airlock init ./my-project +airlock init gh:user/repo +``` + +The wizard prompts for name, trust level, resource size, mounts, **development runtimes** (Node.js, Bun, Docker) and **AI tools** (claude-code, gemini, codex, opencode, ollama), then offers to save the answers to `airlock.toml` so future runs are zero-prompt. + ## Security profiles Profiles encode security policies so you don't have to be an expert. The default is `cautious`. @@ -131,6 +144,12 @@ docker = false [services] compose = "./docker-compose.yml" # Docker Compose file to start automatically +[tools] +node = true # install Node.js + npm + pnpm +bun = false # install Bun +docker = false # install Docker +ai_tools = ["claude-code"] # claude-code, gemini, codex, opencode, ollama + [[mounts]] path = "./api" # required: path relative to config file @@ -178,6 +197,10 @@ mounts: | `mounts[]` | `path` | string | — | Required. Relative path to mount | | `mounts[]` | `writable` | bool | `true` | Whether mount is writable in VM | | `mounts[]` | `inotify` | bool | `false` | Enable inotify file-watch events | +| `tools` | `node` | bool | `false` | Install Node.js (npm + pnpm) in the sandbox | +| `tools` | `bun` | bool | `false` | Install Bun | +| `tools` | `docker` | bool | `false` | Install Docker | +| `tools` | `ai_tools` | []string | `[]` | AI CLIs to install: `claude-code`, `gemini`, `codex`, `opencode`, `ollama` | ## Network control diff --git a/cmd/airlock/cli/cli.go b/cmd/airlock/cli/cli.go index db9fcb6..e023e09 100644 --- a/cmd/airlock/cli/cli.go +++ b/cmd/airlock/cli/cli.go @@ -146,13 +146,20 @@ func newRootCmd(stdout, stderr io.Writer, deps *Dependencies) *cobra.Command { func newSetupCmd(deps *Dependencies) *cobra.Command { var nodeVersion int + var installNode, installBun, installDocker bool + var aiToolsFlag []string cmd := &cobra.Command{ Use: "setup [flags] [name]", Short: "Create and provision the airlock VM", Long: `Create a fresh Lima VM, install system packages and development tools, then take a clean snapshot for future resets. This is the first command to run -before creating any sandboxes.`, +before creating any sandboxes. + +By default, Node.js, Bun, and Docker are installed for the shared 'airlock' VM +so any sandbox can use them. Pass --node=false / --bun=false / --docker=false +to skip a runtime. Use --ai-tool repeatedly to install AI tools +(claude-code, gemini, codex, opencode, ollama).`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -174,6 +181,30 @@ before creating any sandboxes.`, return fmt.Errorf("load config: %w", err) } + // Flag values take precedence over config values when the flag + // was explicitly set. For the shared 'airlock' VM, flag defaults + // match historical behavior (node/bun/docker on) so existing + // users don't lose their runtimes. + provisionOpts := api.ProvisionOptions{ + NodeVersion: nodeVersion, + InstallNode: installNode, + InstallBun: installBun, + InstallDocker: installDocker, + AITools: append([]string(nil), aiToolsFlag...), + } + if !cmd.Flags().Changed("node") { + provisionOpts.InstallNode = cfg.Tools.Node + } + if !cmd.Flags().Changed("bun") { + provisionOpts.InstallBun = cfg.Tools.Bun + } + if !cmd.Flags().Changed("docker") { + provisionOpts.InstallDocker = cfg.Tools.Docker + } + if !cmd.Flags().Changed("ai-tool") { + provisionOpts.AITools = append([]string(nil), cfg.Tools.AITools...) + } + spec := api.SandboxSpec{ Name: name, Profile: cfg.Security.Profile, @@ -211,7 +242,7 @@ before creating any sandboxes.`, }, }, } - for _, step := range deps.Provisioner.ProvisionSteps(name, nodeVersion) { + for _, step := range deps.Provisioner.ProvisionSteps(name, provisionOpts) { step := step phases = append(phases, tui.Phase{ Label: step.Label, @@ -244,6 +275,10 @@ before creating any sandboxes.`, } cmd.Flags().IntVar(&nodeVersion, "node-version", 22, "Node.js major version to install") + cmd.Flags().BoolVar(&installNode, "node", true, "Install Node.js, npm, and pnpm") + cmd.Flags().BoolVar(&installBun, "bun", true, "Install Bun") + cmd.Flags().BoolVar(&installDocker, "docker", true, "Install Docker") + cmd.Flags().StringSliceVar(&aiToolsFlag, "ai-tool", nil, "AI tool to install (repeatable): claude-code, gemini, codex, opencode, ollama") return cmd } @@ -862,7 +897,8 @@ Examples: } // Add provisioning steps - for _, step := range deps.Provisioner.ProvisionSteps(spec.Name, wizardCfg.VM.NodeVersion) { + provisionOpts := result.ToProvisionOptions(wizardCfg.VM.NodeVersion) + for _, step := range deps.Provisioner.ProvisionSteps(spec.Name, provisionOpts) { step := step phases = append(phases, tui.Phase{ Label: step.Label, diff --git a/cmd/airlock/cli/tui/styles.go b/cmd/airlock/cli/tui/styles.go index e014217..de34fd0 100644 --- a/cmd/airlock/cli/tui/styles.go +++ b/cmd/airlock/cli/tui/styles.go @@ -183,34 +183,106 @@ func LockColor(locked bool) lipgloss.Style { func CleanError(err error) string { msg := err.Error() var clean []string + for _, line := range strings.Split(msg, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "time=") { - if idx := strings.Index(line, "msg="); idx != -1 { - msg := strings.Trim(strings.TrimPrefix(line[idx:], "msg="), `"`) - if msg != "" { - clean = append(clean, msg) - } - } + if cleaned := cleanErrorLine(line); cleaned != "" { + clean = append(clean, cleaned) + } + } + + if len(clean) == 0 { + return msg + } + return strings.Join(clean, ": ") +} + +func cleanErrorLine(line string) string { + line = strings.TrimSpace(line) + if line == "" { + return "" + } + + if strings.HasPrefix(line, "time=") || strings.HasPrefix(line, "level=") { + return extractLogMessage(line) + } + + return line +} + +// indexLogField finds `key=` at a token boundary in a logfmt-style line, +// ignoring occurrences inside quoted values. Returns -1 if the key is absent. +func indexLogField(line, key string) int { + needle := key + "=" + inQuote := false + escaped := false + atTokenStart := true + + for i := 0; i < len(line); i++ { + c := line[i] + if escaped { + escaped = false continue } - if strings.HasPrefix(line, "level=") { - if idx := strings.Index(line, "msg="); idx != -1 { - msg := strings.Trim(strings.TrimPrefix(line[idx:], "msg="), `"`) - if msg != "" { - clean = append(clean, msg) - } + if inQuote { + switch c { + case '\\': + escaped = true + case '"': + inQuote = false } continue } - if line != "" { - clean = append(clean, line) + switch c { + case ' ', '\t': + atTokenStart = true + continue + case '"': + inQuote = true + atTokenStart = false + continue + } + if atTokenStart && strings.HasPrefix(line[i:], needle) { + return i } + atTokenStart = false } - if len(clean) == 0 { - return msg + return -1 +} + +func extractLogMessage(line string) string { + idx := indexLogField(line, "msg") + if idx == -1 { + return "" } - return strings.Join(clean, ": ") + rest := line[idx+len("msg="):] + if len(rest) == 0 { + return "" + } + if rest[0] == '"' { + // Quoted value: scan for the next unescaped quote so trailing + // key=value fields after the message don't bleed in. + escaped := false + for i := 1; i < len(rest); i++ { + c := rest[i] + if escaped { + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == '"' { + return strings.ReplaceAll(rest[1:i], `\"`, `"`) + } + } + // Unterminated quote — fall back to stripping the leading quote. + return rest[1:] + } + if j := strings.IndexAny(rest, " \t"); j != -1 { + return rest[:j] + } + return rest } func init() { diff --git a/cmd/airlock/cli/wizard/mappings.go b/cmd/airlock/cli/wizard/mappings.go index a1cca6e..b557b5d 100644 --- a/cmd/airlock/cli/wizard/mappings.go +++ b/cmd/airlock/cli/wizard/mappings.go @@ -185,6 +185,53 @@ func IsInsecureNetwork(level NetworkLevel) bool { return level == NetworkOngoing } +// Runtime keys match ProvisionOptions booleans and ToolsConfig fields. +const ( + RuntimeNode = "node" + RuntimeBun = "bun" + RuntimeDocker = "docker" +) + +// AI tool short-name keys. These must match the strings accepted by +// api.ProvisionOptions.AITools and lima provisioner's aiToolStep switch. +const ( + AIToolClaudeCode = "claude-code" + AIToolGemini = "gemini" + AIToolCodex = "codex" + AIToolOpenCode = "opencode" + AIToolOllama = "ollama" +) + +// AIToolInfo describes an AI tool option shown in the wizard. +type AIToolInfo struct { + Key string + Label string // shown in multi-select + ShortLabel string // shown in summary +} + +// AIToolRequiresNpm reports whether an AI tool key is installed via npm and +// therefore implies Node.js must be installed. Must stay in sync with the +// provisioner registry in internal/vm/lima. +func AIToolRequiresNpm(key string) bool { + switch key { + case AIToolClaudeCode, AIToolGemini, AIToolCodex: + return true + } + return false +} + +// AITools returns all AI tool options the wizard offers. Declared once here +// so the wizard UI and tests share the same source of truth. +func AITools() []AIToolInfo { + return []AIToolInfo{ + {Key: AIToolClaudeCode, Label: "Claude Code (Anthropic)", ShortLabel: "Claude Code"}, + {Key: AIToolGemini, Label: "Gemini CLI (Google)", ShortLabel: "Gemini CLI"}, + {Key: AIToolCodex, Label: "Codex CLI (OpenAI)", ShortLabel: "Codex CLI"}, + {Key: AIToolOpenCode, Label: "OpenCode", ShortLabel: "OpenCode"}, + {Key: AIToolOllama, Label: "Ollama (local LLM runtime)", ShortLabel: "Ollama"}, + } +} + // WizardResult contains the final configuration from the wizard. type WizardResult struct { Source string @@ -192,11 +239,38 @@ type WizardResult struct { TrustLevel TrustLevel ResourceLevel ResourceLevel NetworkLevel NetworkLevel + InstallNode bool + InstallBun bool + InstallDocker bool + AITools []string StartAtLogin bool SaveConfig bool CreateNow bool } +// ToProvisionOptions converts the wizard tool selections into the +// api.ProvisionOptions the Provisioner consumes. AI tools are deduplicated +// preserving first-seen order. +func (r *WizardResult) ToProvisionOptions(nodeVersion int) api.ProvisionOptions { + // Deduplicate AI tools preserving first-seen order + seen := make(map[string]bool) + uniqueAITools := make([]string, 0, len(r.AITools)) + for _, tool := range r.AITools { + if !seen[tool] { + seen[tool] = true + uniqueAITools = append(uniqueAITools, tool) + } + } + + return api.ProvisionOptions{ + NodeVersion: nodeVersion, + InstallNode: r.InstallNode, + InstallBun: r.InstallBun, + InstallDocker: r.InstallDocker, + AITools: uniqueAITools, + } +} + // ToSandboxSpec converts wizard result to API sandbox spec. func (r *WizardResult) ToSandboxSpec(runtime string) api.SandboxSpec { cpu, memory := MapResourceLevel(r.ResourceLevel) @@ -231,6 +305,12 @@ func (r *WizardResult) ToConfig(runtime string) config.Config { cfg.Security.Profile = profile cfg.Runtime.Type = runtime cfg.StartAtLogin = r.StartAtLogin + cfg.Tools = config.ToolsConfig{ + Node: r.InstallNode, + Bun: r.InstallBun, + Docker: r.InstallDocker, + AITools: append([]string(nil), r.AITools...), + } return cfg } diff --git a/cmd/airlock/cli/wizard/mappings_test.go b/cmd/airlock/cli/wizard/mappings_test.go index acb6db7..37bc08b 100644 --- a/cmd/airlock/cli/wizard/mappings_test.go +++ b/cmd/airlock/cli/wizard/mappings_test.go @@ -192,6 +192,82 @@ func TestResourceLevels(t *testing.T) { } } +func TestAITools_Keys(t *testing.T) { + tools := AITools() + want := map[string]bool{ + AIToolClaudeCode: false, + AIToolGemini: false, + AIToolCodex: false, + AIToolOpenCode: false, + AIToolOllama: false, + } + for _, info := range tools { + if info.Key == "" { + t.Errorf("AITools() entry has empty Key: %+v", info) + } + if info.Label == "" { + t.Errorf("AITools() entry has empty Label: %+v", info) + } + if _, ok := want[info.Key]; !ok { + t.Errorf("AITools() returned unexpected key %q", info.Key) + continue + } + want[info.Key] = true + } + for k, found := range want { + if !found { + t.Errorf("AITools() missing expected key %q", k) + } + } +} + +func TestWizardResult_ToProvisionOptions(t *testing.T) { + r := WizardResult{ + InstallNode: true, + InstallBun: false, + InstallDocker: true, + AITools: []string{AIToolClaudeCode, AIToolOllama}, + } + + opts := r.ToProvisionOptions(20) + + if opts.NodeVersion != 20 { + t.Errorf("NodeVersion = %d, want 20", opts.NodeVersion) + } + if !opts.InstallNode || opts.InstallBun || !opts.InstallDocker { + t.Errorf("runtime flags = %+v, want node=true bun=false docker=true", opts) + } + if len(opts.AITools) != 2 || opts.AITools[0] != AIToolClaudeCode || opts.AITools[1] != AIToolOllama { + t.Errorf("AITools = %v, want [claude-code ollama]", opts.AITools) + } + + // Mutating the returned slice must not alias the caller's slice. + opts.AITools[0] = "tampered" + if r.AITools[0] != AIToolClaudeCode { + t.Errorf("ToProvisionOptions aliased AITools slice") + } +} + +func TestWizardResult_ToConfig_Tools(t *testing.T) { + r := WizardResult{ + TrustLevel: TrustCautious, + ResourceLevel: ResourceStandard, + InstallNode: true, + InstallBun: true, + InstallDocker: false, + AITools: []string{AIToolGemini}, + } + + cfg := r.ToConfig("node") + + if !cfg.Tools.Node || !cfg.Tools.Bun || cfg.Tools.Docker { + t.Errorf("Tools runtime flags wrong: %+v", cfg.Tools) + } + if len(cfg.Tools.AITools) != 1 || cfg.Tools.AITools[0] != AIToolGemini { + t.Errorf("Tools.AITools = %v, want [gemini]", cfg.Tools.AITools) + } +} + func TestNetworkLevels(t *testing.T) { levels := NetworkLevels() if len(levels) != 3 { diff --git a/cmd/airlock/cli/wizard/questions.go b/cmd/airlock/cli/wizard/questions.go index 4a7a079..a7aa35c 100644 --- a/cmd/airlock/cli/wizard/questions.go +++ b/cmd/airlock/cli/wizard/questions.go @@ -47,6 +47,20 @@ func DeriveSandboxName(source string) string { } } + // Resolve bare-dot / empty / trailing-slash sources to the current + // working directory's basename so the default matches what the user sees + // in their shell prompt, not the literal ".". + trimmed := strings.TrimRight(source, "/") + if source == "" || trimmed == "" || trimmed == "." { + if cwd, err := os.Getwd(); err == nil { + base := filepath.Base(cwd) + if base != "" && base != "." && base != "/" { + return sanitizeName(base) + } + } + return "sandbox" + } + // Handle local paths base := filepath.Base(source) base = strings.TrimSuffix(base, filepath.Ext(base)) @@ -234,7 +248,61 @@ func Run(source string) (*WizardResult, error) { } } - // Step 6: Persistence options + // Step 6a: Development runtimes (optional — only install what you need) + runtimes := []string{} + runtimeForm := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Which development runtimes should we install?"). + Description("Pick only what this sandbox needs. Space to toggle, enter to confirm."). + Options( + huh.NewOption("Node.js (npm + pnpm)", RuntimeNode), + huh.NewOption("Bun", RuntimeBun), + huh.NewOption("Docker", RuntimeDocker), + ). + Value(&runtimes), + ), + ) + + if err := runtimeForm.Run(); err != nil { + return nil, err + } + + result.InstallNode = containsString(runtimes, RuntimeNode) + result.InstallBun = containsString(runtimes, RuntimeBun) + result.InstallDocker = containsString(runtimes, RuntimeDocker) + + // Step 6b: AI tools + aiTools := []string{} + aiToolOptions := []huh.Option[string]{} + for _, info := range AITools() { + aiToolOptions = append(aiToolOptions, huh.NewOption(info.Label, info.Key)) + } + aiToolForm := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Install AI coding tools?"). + Description("npm-based tools (Claude Code, Gemini, Codex) will auto-enable Node.js."). + Options(aiToolOptions...). + Value(&aiTools), + ), + ) + + if err := aiToolForm.Run(); err != nil { + return nil, err + } + + result.AITools = aiTools + // npm-delivered AI tools imply Node.js; reflect that in the result so + // the summary screen and saved config match what provisioning will do. + for _, t := range aiTools { + if AIToolRequiresNpm(t) { + result.InstallNode = true + break + } + } + + // Step 7: Persistence options startAtLogin := false // Default saveConfig := true // Default @@ -259,7 +327,7 @@ func Run(source string) (*WizardResult, error) { result.StartAtLogin = startAtLogin result.SaveConfig = saveConfig - // Step 7: Confirmation + // Step 8: Confirmation cpu, memory := MapResourceLevel(result.ResourceLevel) profileName := MapTrustLevelToProfile(result.TrustLevel) defaults := config.Defaults() @@ -272,6 +340,8 @@ func Run(source string) (*WizardResult, error) { fmt.Printf(" Security: %s\n", profileName) fmt.Printf(" Resources: %d CPU, %s RAM, %s disk\n", cpu, memory, defaults.VM.Disk) fmt.Printf(" Network: %s\n", getNetworkDescription(result.NetworkLevel)) + fmt.Printf(" Runtimes: %s\n", summarizeRuntimes(result)) + fmt.Printf(" AI tools: %s\n", summarizeAITools(result.AITools)) fmt.Printf(" Auto-start: %s\n", boolToYesNo(result.StartAtLogin)) fmt.Printf(" Config: %s\n", boolToYesNo(result.SaveConfig)+" (airlock.toml)") fmt.Println(strings.Repeat("─", 50)) @@ -354,6 +424,56 @@ func boolToYesNo(b bool) string { return "No" } +// containsString reports whether slice contains target. +func containsString(slice []string, target string) bool { + for _, s := range slice { + if s == target { + return true + } + } + return false +} + +// summarizeRuntimes renders the user's runtime picks for the confirmation +// screen. Returns "none" if nothing is selected. +func summarizeRuntimes(r WizardResult) string { + var parts []string + if r.InstallNode { + parts = append(parts, "Node.js") + } + if r.InstallBun { + parts = append(parts, "Bun") + } + if r.InstallDocker { + parts = append(parts, "Docker") + } + if len(parts) == 0 { + return "none" + } + return strings.Join(parts, ", ") +} + +// summarizeAITools renders the AI tool list for the confirmation screen. +// Returns "none" if no tools are selected. +func summarizeAITools(keys []string) string { + if len(keys) == 0 { + return "none" + } + labels := make([]string, 0, len(keys)) + infoByKey := map[string]AIToolInfo{} + for _, info := range AITools() { + infoByKey[info.Key] = info + } + for _, k := range keys { + if info, ok := infoByKey[k]; ok { + labels = append(labels, info.ShortLabel) + } else { + labels = append(labels, k) + } + } + return strings.Join(labels, ", ") +} + // IsTTY returns true if stdout is a terminal. func IsTTY() bool { fi, err := os.Stdout.Stat() diff --git a/cmd/airlock/cli/wizard/questions_test.go b/cmd/airlock/cli/wizard/questions_test.go index acb765a..98e8bc9 100644 --- a/cmd/airlock/cli/wizard/questions_test.go +++ b/cmd/airlock/cli/wizard/questions_test.go @@ -1,6 +1,8 @@ package wizard import ( + "os" + "path/filepath" "strings" "testing" ) @@ -11,7 +13,6 @@ func TestDeriveSandboxName(t *testing.T) { source string expected string }{ - {"current dir", ".", "sandbox"}, {"simple path", "./my-project", "my-project"}, {"nested path", "./projects/my-app", "my-app"}, {"gh short", "gh:user/repo", "repo"}, @@ -19,7 +20,6 @@ func TestDeriveSandboxName(t *testing.T) { {"github https", "https://github.com/user/repo", "repo"}, {"github https with git", "https://github.com/user/repo.git", "repo"}, {"path with extension", "./my-project.tar.gz", "my-project.tar"}, // Double extension behavior - {"empty base", "./", "sandbox"}, } for _, tt := range tests { @@ -32,6 +32,24 @@ func TestDeriveSandboxName(t *testing.T) { } } +// TestDeriveSandboxName_CurrentDir verifies "." and "./" resolve to the +// current working directory's basename, so users running `airlock init` in +// a real project see their project name as the default. +func TestDeriveSandboxName_CurrentDir(t *testing.T) { + tmp := t.TempDir() + projDir := filepath.Join(tmp, "my-real-project") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + t.Chdir(projDir) + + for _, src := range []string{".", "./", ""} { + if got := DeriveSandboxName(src); got != "my-real-project" { + t.Errorf("DeriveSandboxName(%q) = %q, want %q", src, got, "my-real-project") + } + } +} + func TestDeriveSandboxName_Sanitization(t *testing.T) { tests := []struct { name string diff --git a/internal/api/vm.go b/internal/api/vm.go index 9167d2e..564f721 100644 --- a/internal/api/vm.go +++ b/internal/api/vm.go @@ -26,12 +26,35 @@ type Provider interface { // This is a separate interface from Provider following the Interface Segregation // Principle — not every Provider can provision VMs or take snapshots. type Provisioner interface { - ProvisionVM(ctx context.Context, name string, nodeVersion int) error - ProvisionSteps(name string, nodeVersion int) []ProvisionStep + ProvisionVM(ctx context.Context, name string, opts ProvisionOptions) error + ProvisionSteps(name string, opts ProvisionOptions) []ProvisionStep SnapshotClean(ctx context.Context, name string) error HasCleanSnapshot(ctx context.Context, name string) (bool, error) } +// ProvisionOptions controls which runtimes and AI tools are installed during +// provisioning. An empty options value installs only the required baseline +// (system packages, airlock user). Callers opt into each runtime explicitly. +type ProvisionOptions struct { + // NodeVersion is the Node.js major version to install when InstallNode + // is true (or when any AI tool requires npm). Zero means default (22). + NodeVersion int + + // InstallNode installs Node.js, npm, and pnpm. + InstallNode bool + + // InstallBun installs the Bun runtime. + InstallBun bool + + // InstallDocker installs Docker. + InstallDocker bool + + // AITools lists AI tools to install by short name. Supported values: + // "claude-code", "gemini", "opencode", "codex", "ollama". Unknown + // names are ignored. npm-based tools auto-require Node. + AITools []string +} + // ProvisionStep is a named unit of work in the provisioning sequence. // Callers (e.g. the setup command) iterate these to render branded, // per-step progress instead of a single opaque "Provisioning" phase. diff --git a/internal/config/config.go b/internal/config/config.go index 168e777..4f01743 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { Runtime RuntimeConfig `json:"runtime" yaml:"runtime" toml:"runtime"` Security SecurityConfig `json:"security" yaml:"security" toml:"security"` Services ServicesConfig `json:"services" yaml:"services" toml:"services"` + Tools ToolsConfig `json:"tools" yaml:"tools" toml:"tools"` Mounts []MountConfig `json:"mounts" yaml:"mounts" toml:"mounts"` StartAtLogin bool `json:"start_at_login,omitempty" yaml:"start_at_login,omitempty" toml:"start_at_login,omitempty"` } @@ -59,6 +60,15 @@ type ServicesConfig struct { Compose string `json:"compose,omitempty" yaml:"compose,omitempty" toml:"compose,omitempty"` } +// ToolsConfig selects which runtimes and AI tools are installed during +// provisioning. All fields default to false/empty — callers opt in. +type ToolsConfig struct { + Node bool `json:"node,omitempty" yaml:"node,omitempty" toml:"node,omitempty"` + Bun bool `json:"bun,omitempty" yaml:"bun,omitempty" toml:"bun,omitempty"` + Docker bool `json:"docker,omitempty" yaml:"docker,omitempty" toml:"docker,omitempty"` + AITools []string `json:"ai_tools,omitempty" yaml:"ai_tools,omitempty" toml:"ai_tools,omitempty"` +} + // MountConfig defines an additional host directory to mount. type MountConfig struct { Path string `json:"path" yaml:"path" toml:"path"` @@ -85,6 +95,12 @@ func Defaults() Config { Security: SecurityConfig{ Profile: "cautious", }, + Tools: ToolsConfig{ + Node: false, + Bun: false, + Docker: false, + AITools: nil, + }, Mounts: nil, } } @@ -169,6 +185,20 @@ func mergeWithDefaults(cfg Config) Config { cfg.Security.Profile = defaults.Security.Profile } + // Merge Tools fields: copy defaults for false/empty fields + if !cfg.Tools.Node { + cfg.Tools.Node = defaults.Tools.Node + } + if !cfg.Tools.Bun { + cfg.Tools.Bun = defaults.Tools.Bun + } + if !cfg.Tools.Docker { + cfg.Tools.Docker = defaults.Tools.Docker + } + if cfg.Tools.AITools == nil { + cfg.Tools.AITools = defaults.Tools.AITools + } + for i := range cfg.Mounts { if cfg.Mounts[i].Writable == nil { w := false diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4a25004..bfa054a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -450,6 +450,38 @@ func TestRoundTripTOML(t *testing.T) { } } +func TestRoundTripTOML_Tools(t *testing.T) { + original := Defaults() + original.Tools = ToolsConfig{ + Node: true, + Bun: false, + Docker: true, + AITools: []string{"claude-code", "ollama"}, + } + + data, err := WriteTOML(original) + if err != nil { + t.Fatalf("WriteTOML() error: %v", err) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "airlock.toml"), data, 0644); err != nil { + t.Fatal(err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if !loaded.Tools.Node || loaded.Tools.Bun || !loaded.Tools.Docker { + t.Errorf("Tools flags mismatch: %+v", loaded.Tools) + } + if len(loaded.Tools.AITools) != 2 || loaded.Tools.AITools[0] != "claude-code" || loaded.Tools.AITools[1] != "ollama" { + t.Errorf("Tools.AITools mismatch: %v", loaded.Tools.AITools) + } +} + func TestRoundTripYAML(t *testing.T) { original := Defaults() writable := false diff --git a/internal/config/save.go b/internal/config/save.go index 12bf746..399607a 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -124,6 +124,8 @@ func sectionComment(section string, cfg Config) string { return runtimeSectionComment(cfg.Runtime.Type) case "services": return servicesSectionComment() + case "tools": + return toolsSectionComment() case "mounts": return mountsSectionComment() default: @@ -131,6 +133,10 @@ func sectionComment(section string, cfg Config) string { } } +func toolsSectionComment() string { + return "# Tools\n# Runtimes and AI tools to install during provisioning.\n# All optional — set to true or add to ai_tools to enable." +} + func securitySectionComment(profile string) string { var desc string switch profile { diff --git a/internal/vm/lima/aitools.go b/internal/vm/lima/aitools.go new file mode 100644 index 0000000..8653e29 --- /dev/null +++ b/internal/vm/lima/aitools.go @@ -0,0 +1,97 @@ +package lima + +import ( + "context" +) + +// aiToolInstaller describes how to install one AI CLI tool inside a VM. +// New AI tools register themselves via registerAITool — the provisioning +// pipeline never switches on tool name, satisfying OCP. +type aiToolInstaller struct { + Label string + RunsOnNpm bool + Install func(ctx context.Context, p *LimaProvider, name string) error +} + +var aiToolRegistry = map[string]aiToolInstaller{} + +func registerAITool(key string, t aiToolInstaller) { + aiToolRegistry[key] = t +} + +// lookupAITool returns the installer for a known tool, or (zero, false). +func lookupAITool(key string) (aiToolInstaller, bool) { + t, ok := aiToolRegistry[key] + return t, ok +} + +// aiToolRequiresNpm reports whether an AI tool is installed via npm and +// therefore forces Node.js installation. +func aiToolRequiresNpm(tool string) bool { + t, ok := lookupAITool(tool) + return ok && t.RunsOnNpm +} + +// npmInstallGlobal installs an npm package globally for the airlock user, +// using their login shell so npm's per-user prefix applies. +func npmInstallGlobal(ctx context.Context, p *LimaProvider, name, pkg string) error { + // Single-quote escape so shell metacharacters ($(...), backticks, $VAR) + // in pkg cannot break out of the bash -c string. Today all callers pass + // static constants; defence in depth for future additions. %q would emit + // a double-quoted literal, which bash still expands. + _, err := p.Exec(ctx, name, []string{ + "sudo", "-u", "airlock", "bash", "--login", "-c", + "npm install -g " + shellEscape(pkg), + }) + return err +} + +func init() { + registerAITool("claude-code", aiToolInstaller{ + Label: "Installing Claude Code", + RunsOnNpm: true, + Install: func(ctx context.Context, p *LimaProvider, name string) error { + return npmInstallGlobal(ctx, p, name, "@anthropic-ai/claude-code") + }, + }) + registerAITool("gemini", aiToolInstaller{ + Label: "Installing Gemini CLI", + RunsOnNpm: true, + Install: func(ctx context.Context, p *LimaProvider, name string) error { + return npmInstallGlobal(ctx, p, name, "@google/gemini-cli") + }, + }) + registerAITool("codex", aiToolInstaller{ + Label: "Installing Codex CLI", + RunsOnNpm: true, + Install: func(ctx context.Context, p *LimaProvider, name string) error { + return npmInstallGlobal(ctx, p, name, "@openai/codex") + }, + }) + registerAITool("opencode", aiToolInstaller{ + Label: "Installing OpenCode", + Install: func(ctx context.Context, p *LimaProvider, name string) error { + // Installer writes to invoking user's home; run as airlock. + _, err := p.Exec(ctx, name, []string{ + "sudo", "-u", "airlock", "bash", "--login", "-c", + "curl -fsSL https://opencode.ai/install | bash", + }) + return err + }, + }) + registerAITool("ollama", aiToolInstaller{ + Label: "Installing Ollama", + Install: func(ctx context.Context, p *LimaProvider, name string) error { + // Accepted risk: curl | sh as root, matches vendor-documented + // install (https://ollama.com/install.sh). Ollama publishes no + // stable checksum or signed package for this path. The shell + // runs inside the sandbox VM, not the host, so blast radius is + // bounded to the VM the user opted to provision. + _, err := p.Exec(ctx, name, []string{ + "sudo", "bash", "-c", + "curl -fsSL https://ollama.com/install.sh | sh", + }) + return err + }, + }) +} diff --git a/internal/vm/lima/config_test.go b/internal/vm/lima/config_test.go index 6260ab1..77a3640 100644 --- a/internal/vm/lima/config_test.go +++ b/internal/vm/lima/config_test.go @@ -224,7 +224,7 @@ func TestLimaProviderCreate(t *testing.T) { script := "#!/bin/sh\necho \"$@\" >> " + logPath + "\necho 'created'\n" os.WriteFile(limactlPath, []byte(script), 0755) - p := NewLimaProviderWithPaths(limactlPath, dir) + p := NewLimaProviderWithPaths(limactlPath, dir, "") spec := api.VMSpec{ Name: "test-sandbox", CPU: 2, @@ -253,7 +253,7 @@ func TestLimaProviderExec(t *testing.T) { script := "#!/bin/sh\nshift 2; shift; echo \"$@\"\n" os.WriteFile(limactlPath, []byte(script), 0755) - p := NewLimaProviderWithPaths(limactlPath, dir) + p := NewLimaProviderWithPaths(limactlPath, dir, "") output, err := p.Exec(context.Background(), "test-vm", []string{"echo", "hello"}) if err != nil { t.Fatalf("Exec() error: %v", err) @@ -272,7 +272,7 @@ func TestLimaProviderExecAsUserArgPreservation(t *testing.T) { script := "#!/bin/sh\necho \"$@\" > " + cmdFile + "\n" os.WriteFile(limactlPath, []byte(script), 0755) - p := NewLimaProviderWithPaths(limactlPath, dir) + p := NewLimaProviderWithPaths(limactlPath, dir, "") _, err := p.ExecAsUser(context.Background(), "test-vm", "airlock", []string{"echo", "hello world", "arg3"}) if err != nil { t.Fatalf("ExecAsUser() error: %v", err) @@ -341,7 +341,7 @@ echo "unknown command: $1" >&2; exit 1 ` os.WriteFile(limactlPath, []byte(script), 0755) - p := NewLimaProviderWithPaths(limactlPath, dir) + p := NewLimaProviderWithPaths(limactlPath, dir, "") exists, err := p.Exists(context.Background(), "existing-vm") if err != nil { @@ -373,7 +373,7 @@ fi ` os.WriteFile(limactlPath, []byte(script), 0755) - p := NewLimaProviderWithPaths(limactlPath, dir) + p := NewLimaProviderWithPaths(limactlPath, dir, "") running, err := p.IsRunning(context.Background(), "running-vm") if err != nil { @@ -400,7 +400,7 @@ func TestLimaProviderCopyToVM(t *testing.T) { script := "#!/bin/sh\necho \"$@\" > " + filepath.Join(dir, "args") + "\n" os.WriteFile(limactlPath, []byte(script), 0755) - p := NewLimaProviderWithPaths(limactlPath, dir) + p := NewLimaProviderWithPaths(limactlPath, dir, "") err := p.CopyToVM(context.Background(), "test-vm", "/src/file.txt", "/dst/file.txt") if err != nil { t.Fatalf("CopyToVM() error: %v", err) diff --git a/internal/vm/lima/provider.go b/internal/vm/lima/provider.go index 352d4a9..7326f43 100644 --- a/internal/vm/lima/provider.go +++ b/internal/vm/lima/provider.go @@ -16,10 +16,15 @@ import ( type LimaProvider struct { limactlPath string limaDir string + // snapshotDir holds the clean-baseline copies used by airlock reset. + // It must live OUTSIDE limaDir so Lima does not pick it up as an extra + // VM entry in `limactl list`. + snapshotDir string } -// NewLimaProvider creates a provider using the default limactl binary -// and Lima state directory ($HOME/.lima). +// NewLimaProvider creates a provider using the default limactl binary, +// the default Lima state directory ($HOME/.lima), and an airlock-owned +// snapshot directory ($HOME/.airlock/snapshots). func NewLimaProvider() (*LimaProvider, error) { path, err := exec.LookPath("limactl") if err != nil { @@ -32,14 +37,21 @@ func NewLimaProvider() (*LimaProvider, error) { return &LimaProvider{ limactlPath: path, limaDir: filepath.Join(home, ".lima"), + snapshotDir: filepath.Join(home, ".airlock", "snapshots"), }, nil } // NewLimaProviderWithPaths creates a provider with explicit paths, for testing. -func NewLimaProviderWithPaths(limactlPath, limaDir string) *LimaProvider { +// If snapshotDir is empty, it defaults to limaDir + "-snapshots" so tests that +// don't care about snapshot location still get a valid, isolated path. +func NewLimaProviderWithPaths(limactlPath, limaDir, snapshotDir string) *LimaProvider { + if snapshotDir == "" { + snapshotDir = limaDir + "-snapshots" + } return &LimaProvider{ limactlPath: limactlPath, limaDir: limaDir, + snapshotDir: snapshotDir, } } diff --git a/internal/vm/lima/provision.go b/internal/vm/lima/provision.go new file mode 100644 index 0000000..8950b5c --- /dev/null +++ b/internal/vm/lima/provision.go @@ -0,0 +1,175 @@ +package lima + +import ( + "context" + "fmt" + "os" + + "github.com/muneebs/airlock/internal/api" +) + +// ProvisionVM runs the provision sequence for a fresh VM. +// Exact steps depend on opts; the baseline always installs system packages +// and creates the airlock user. +func (p *LimaProvider) ProvisionVM(ctx context.Context, name string, opts api.ProvisionOptions) error { + for _, step := range p.ProvisionSteps(name, opts) { + if err := step.Run(ctx); err != nil { + return err + } + } + return nil +} + +// sudoersNopasswdScript validates the sudoers drop-in with visudo before +// installing it, so a malformed policy cannot lock root out of the VM. +const sudoersNopasswdScript = `set -e +tmp=$(mktemp) +printf 'airlock ALL=(ALL) NOPASSWD:ALL\n' > "$tmp" +chmod 0440 "$tmp" +visudo -cf "$tmp" +mv "$tmp" /etc/sudoers.d/airlock` + +// ProvisionSteps returns the provisioning sequence as discrete, named steps +// so callers can render per-step progress. The baseline (system packages, +// airlock user, airlock home) always runs. Node.js/Bun/Docker and AI tools +// install only when opts requests them. Failing baseline steps propagate +// their error; optional runtime and AI tool installs swallow errors so a +// broken install script cannot brick the VM. +func (p *LimaProvider) ProvisionSteps(name string, opts api.ProvisionOptions) []api.ProvisionStep { + nodeVersion := opts.NodeVersion + if nodeVersion <= 0 { + nodeVersion = 22 + } + + needNode := opts.InstallNode + for _, t := range opts.AITools { + if aiToolRequiresNpm(t) { + needNode = true + break + } + } + + type shellStep struct { + label string + desc string + cmd []string + } + + baseline := []shellStep{ + {"Installing system packages", "system packages", []string{"sudo", "bash", "-c", "export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -y curl jq iptables unzip git"}}, + {"Creating airlock user", "create airlock user", []string{"sudo", "bash", "-c", "id airlock &>/dev/null || useradd -m -s /bin/bash airlock"}}, + {"Granting passwordless sudo", "sudoers nopasswd", []string{"sudo", "bash", "-c", sudoersNopasswdScript}}, + // chown must be -xdev so it does not descend into virtiofs mounts + // (e.g. /home/airlock/projects/) where chown returns EPERM. + // -xdev alone still visits the mountpoint entry itself, so prune + // /home/airlock/projects explicitly and chown only that directory + // (not its contents). + {"Preparing airlock home", "setup airlock dirs", []string{"sudo", "bash", "-c", "mkdir -p /home/airlock/.npm-global /home/airlock/projects && find /home/airlock -xdev -path /home/airlock/projects -prune -o -print0 | xargs -0 chown airlock:airlock && chown airlock:airlock /home/airlock/projects"}}, + } + + var node []shellStep + if needNode { + node = []shellStep{ + {fmt.Sprintf("Installing Node.js %d", nodeVersion), "node.js", []string{"sudo", "bash", "-c", fmt.Sprintf("curl -fsSL https://deb.nodesource.com/setup_%d.x | bash - && apt-get install -y nodejs", nodeVersion)}}, + {"Installing pnpm", "pnpm", []string{"sudo", "npm", "install", "-g", "pnpm"}}, + {"Configuring npm prefix", "npm prefix", []string{"sudo", "-u", "airlock", "bash", "--login", "-c", "npm config set prefix /home/airlock/.npm-global"}}, + } + } + + steps := make([]api.ProvisionStep, 0, len(baseline)+len(node)+len(opts.AITools)+2) + appendRequired := func(s shellStep) { + steps = append(steps, api.ProvisionStep{ + Label: s.label, + Run: func(ctx context.Context) error { + if err := validateName(name); err != nil { + return fmt.Errorf("invalid vm name: %w", err) + } + if _, err := p.Exec(ctx, name, s.cmd); err != nil { + return fmt.Errorf("provision %s: %w", s.desc, err) + } + return nil + }, + }) + } + for _, s := range baseline { + appendRequired(s) + } + for _, s := range node { + appendRequired(s) + } + + if opts.InstallBun { + steps = append(steps, api.ProvisionStep{ + Label: "Installing Bun", + Run: func(ctx context.Context) error { + if err := p.installBun(ctx, name); err != nil { + warnOptionalInstall("Bun", err) + } + return nil + }, + }) + } + if opts.InstallDocker { + steps = append(steps, api.ProvisionStep{ + Label: "Installing Docker", + Run: func(ctx context.Context) error { + if err := p.installDocker(ctx, name); err != nil { + warnOptionalInstall("Docker", err) + } + return nil + }, + }) + } + + // Dedup AITools preserving first-seen order. The wizard multi-select + // cannot produce duplicates, but `--ai-tool foo --ai-tool foo` on the + // CLI would otherwise queue the install twice. + seen := make(map[string]struct{}, len(opts.AITools)) + for _, t := range opts.AITools { + if _, dup := seen[t]; dup { + continue + } + seen[t] = struct{}{} + tool, ok := lookupAITool(t) + if !ok { + continue + } + install := tool.Install + label := tool.Label + steps = append(steps, api.ProvisionStep{ + Label: label, + Run: func(ctx context.Context) error { + if err := install(ctx, p, name); err != nil { + warnOptionalInstall(label, err) + } + return nil + }, + }) + } + + return steps +} + +// warnOptionalInstall logs a non-fatal install failure so the user can see +// that an optional tool did not install, without failing the whole provision. +// A clean return of nil from the step's Run lets the TUI render DoneLabel, so +// the stderr warning is the only surfaced signal — keep it terse and visible. +func warnOptionalInstall(tool string, err error) { + fmt.Fprintf(os.Stderr, "warning: %s install failed: %v\n", tool, err) +} + +func (p *LimaProvider) installBun(ctx context.Context, name string) error { + _, err := p.Exec(ctx, name, []string{ + "sudo", "bash", "-c", + "export HOME=/root && curl -fsSL https://bun.sh/install | bash && cp /root/.bun/bin/bun /usr/local/bin/bun && chmod +x /usr/local/bin/bun", + }) + return err +} + +func (p *LimaProvider) installDocker(ctx context.Context, name string) error { + if _, err := p.Exec(ctx, name, []string{"sudo", "bash", "-c", "curl -fsSL https://get.docker.com | bash"}); err != nil { + return err + } + _, err := p.Exec(ctx, name, []string{"sudo", "usermod", "-aG", "docker", "airlock"}) + return err +} diff --git a/internal/vm/lima/snapshot.go b/internal/vm/lima/snapshot.go index 62e1557..a57fe05 100644 --- a/internal/vm/lima/snapshot.go +++ b/internal/vm/lima/snapshot.go @@ -43,25 +43,48 @@ func safeFilePerm(m fs.FileMode) fs.FileMode { return m.Perm() & 0755 } -// SnapshotClean copies the VM's Lima directory to a -clean baseline, -// excluding runtime files like sockets. This allows airlock reset to -// restore the VM to a clean state without re-provisioning. +// cleanSnapshotPath returns the current snapshot path for a VM. +// Snapshots live outside limaDir so Lima does not list them as VMs. +func (p *LimaProvider) cleanSnapshotPath(name string) string { + return filepath.Join(p.snapshotDir, name) +} + +// legacyCleanSnapshotPath returns the pre-migration snapshot path +// (/-clean). Still read by RestoreClean/HasCleanSnapshot so +// users with existing snapshots don't have to re-run setup. +func (p *LimaProvider) legacyCleanSnapshotPath(name string) string { + return filepath.Join(p.limaDir, name+"-clean") +} + +// SnapshotClean copies the VM's Lima directory to a clean baseline stored +// outside Lima's state dir (so Lima does not list the snapshot as a second VM). +// Runtime files like sockets are excluded. Any legacy in-Lima snapshot for the +// same VM is deleted so `limactl list` stops surfacing it. func (p *LimaProvider) SnapshotClean(ctx context.Context, name string) error { if err := validateName(name); err != nil { return fmt.Errorf("invalid vm name: %w", err) } vmDir := filepath.Join(p.limaDir, name) - cleanDir := filepath.Join(p.limaDir, name+"-clean") + cleanDir := p.cleanSnapshotPath(name) if _, err := os.Stat(vmDir); err != nil { return fmt.Errorf("vm dir %s not found: %w", vmDir, err) } - if err := os.RemoveAll(cleanDir); err != nil { - return fmt.Errorf("remove old clean dir: %w", err) + if err := os.MkdirAll(filepath.Dir(cleanDir), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) } + // Write into a sibling tmp dir and swap on success so a mid-walk failure + // does not destroy the previous snapshot. + tmpDir := cleanDir + ".tmp" + if err := os.RemoveAll(tmpDir); err != nil { + return fmt.Errorf("remove stale tmp dir: %w", err) + } + // Legacy snapshot (inside limaDir) is removed on success only: if the + // new snapshot below fails we'd rather keep the old one as fallback. + legacyDir := p.legacyCleanSnapshotPath(name) - return filepath.WalkDir(vmDir, func(path string, d fs.DirEntry, err error) error { + if err := filepath.WalkDir(vmDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -79,7 +102,7 @@ func (p *LimaProvider) SnapshotClean(ctx context.Context, name string) error { return nil } - targetPath := filepath.Join(cleanDir, relPath) + targetPath := filepath.Join(tmpDir, relPath) if d.IsDir() { info, err := d.Info() @@ -99,20 +122,52 @@ func (p *LimaProvider) SnapshotClean(ctx context.Context, name string) error { } return copyFileStreaming(path, targetPath, safeFilePerm(info.Mode())) - }) + }); err != nil { + _ = os.RemoveAll(tmpDir) + return err + } + + // Swap: remove old snapshot only after the new one is fully written. + // os.Rename over an existing directory fails on Linux/macOS, so clear + // the target first. Failure between these two lines is the narrow window + // where a snapshot can be lost; acceptable since tmpDir stays on disk + // for manual recovery. + if err := os.RemoveAll(cleanDir); err != nil { + return fmt.Errorf("remove old clean dir: %w", err) + } + if err := os.Rename(tmpDir, cleanDir); err != nil { + return fmt.Errorf("promote snapshot: %w", err) + } + + if _, err := os.Stat(legacyDir); err == nil { + if err := os.RemoveAll(legacyDir); err != nil { + return fmt.Errorf("remove legacy clean dir: %w", err) + } + } + return nil } -// RestoreClean copies the -clean baseline back over the VM directory, +// RestoreClean copies the clean baseline back over the VM directory, // restoring it to a freshly-provisioned state. The VM must be stopped. +// Falls back to the legacy in-Lima snapshot location if the new one is absent +// (so users with pre-migration snapshots don't break on reset). func (p *LimaProvider) RestoreClean(ctx context.Context, name string) error { if err := validateName(name); err != nil { return fmt.Errorf("invalid vm name: %w", err) } - cleanDir := filepath.Join(p.limaDir, name+"-clean") vmDir := filepath.Join(p.limaDir, name) + cleanDir := p.cleanSnapshotPath(name) if _, err := os.Stat(cleanDir); err != nil { - return fmt.Errorf("clean baseline %s not found: %w", cleanDir, err) + if !os.IsNotExist(err) { + return fmt.Errorf("stat clean baseline: %w", err) + } + // Fall back to legacy path. + legacy := p.legacyCleanSnapshotPath(name) + if _, lerr := os.Stat(legacy); lerr != nil { + return fmt.Errorf("clean baseline not found (checked %s and %s)", cleanDir, legacy) + } + cleanDir = legacy } if err := os.RemoveAll(vmDir); err != nil { @@ -122,111 +177,20 @@ func (p *LimaProvider) RestoreClean(ctx context.Context, name string) error { return copyDir(cleanDir, vmDir) } -// HasCleanSnapshot checks whether a -clean baseline exists for the VM. +// HasCleanSnapshot reports whether a clean baseline exists for the VM at +// either the current or legacy location. func (p *LimaProvider) HasCleanSnapshot(ctx context.Context, name string) (bool, error) { if err := validateName(name); err != nil { return false, fmt.Errorf("invalid vm name: %w", err) } - cleanDir := filepath.Join(p.limaDir, name+"-clean") - if _, err := os.Stat(cleanDir); err != nil { - if os.IsNotExist(err) { - return false, nil + for _, dir := range []string{p.cleanSnapshotPath(name), p.legacyCleanSnapshotPath(name)} { + if _, err := os.Stat(dir); err == nil { + return true, nil + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("stat clean snapshot dir: %w", err) } - return false, fmt.Errorf("stat clean snapshot dir: %w", err) - } - return true, nil -} - -// ProvisionVM runs the standard provision commands for a fresh VM. -// This installs Node.js, pnpm, bun, Docker, and creates the airlock user. -func (p *LimaProvider) ProvisionVM(ctx context.Context, name string, nodeVersion int) error { - for _, step := range p.ProvisionSteps(name, nodeVersion) { - if err := step.Run(ctx); err != nil { - return err - } - } - return nil -} - -// ProvisionSteps returns the provisioning sequence as discrete, named steps -// so callers can render per-step progress. Failing required steps propagate -// their error; non-fatal steps (Bun, Docker) swallow errors to match the -// previous ProvisionVM behavior. -func (p *LimaProvider) ProvisionSteps(name string, nodeVersion int) []api.ProvisionStep { - if nodeVersion <= 0 { - nodeVersion = 22 - } - - required := []struct { - label string - desc string - cmd []string - }{ - {"Installing system packages", "system packages", []string{"sudo", "bash", "-c", "export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -y curl jq iptables unzip git"}}, - {fmt.Sprintf("Installing Node.js %d", nodeVersion), "node.js", []string{"sudo", "bash", "-c", fmt.Sprintf("curl -fsSL https://deb.nodesource.com/setup_%d.x | bash - && apt-get install -y nodejs", nodeVersion)}}, - {"Installing pnpm", "pnpm", []string{"sudo", "npm", "install", "-g", "pnpm"}}, - {"Creating airlock user", "create airlock user", []string{"sudo", "bash", "-c", "id airlock &>/dev/null || useradd -m -s /bin/bash airlock"}}, - // chown must be -xdev so it does not descend into virtiofs mounts - // (e.g. /home/airlock/projects/) where chown returns EPERM. - // chown must skip virtiofs mount points under /home/airlock/projects/* - // (EPERM across fs boundary). -xdev alone still visits the mountpoint - // entry itself, so prune /home/airlock/projects explicitly and chown - // only that directory (not its contents). - {"Preparing airlock home", "setup airlock dirs", []string{"sudo", "bash", "-c", "mkdir -p /home/airlock/.npm-global /home/airlock/projects && find /home/airlock -xdev -path /home/airlock/projects -prune -o -print0 | xargs -0 chown airlock:airlock && chown airlock:airlock /home/airlock/projects"}}, - {"Configuring npm prefix", "npm prefix", []string{"sudo", "-u", "airlock", "bash", "--login", "-c", "npm config set prefix /home/airlock/.npm-global"}}, - } - - steps := make([]api.ProvisionStep, 0, len(required)+2) - for _, r := range required { - r := r - steps = append(steps, api.ProvisionStep{ - Label: r.label, - Run: func(ctx context.Context) error { - if err := validateName(name); err != nil { - return fmt.Errorf("invalid vm name: %w", err) - } - if _, err := p.Exec(ctx, name, r.cmd); err != nil { - return fmt.Errorf("provision %s: %w", r.desc, err) - } - return nil - }, - }) - } - - steps = append(steps, - api.ProvisionStep{ - Label: "Installing Bun", - Run: func(ctx context.Context) error { - _ = p.installBun(ctx, name) - return nil - }, - }, - api.ProvisionStep{ - Label: "Installing Docker", - Run: func(ctx context.Context) error { - _ = p.installDocker(ctx, name) - return nil - }, - }, - ) - - return steps -} - -func (p *LimaProvider) installBun(ctx context.Context, name string) error { - _, err := p.Exec(ctx, name, []string{ - "sudo", "bash", "-c", - "export HOME=/root && curl -fsSL https://bun.sh/install | bash && cp /root/.bun/bin/bun /usr/local/bin/bun && chmod +x /usr/local/bin/bun", - }) - return err -} - -func (p *LimaProvider) installDocker(ctx context.Context, name string) error { - if _, err := p.Exec(ctx, name, []string{"sudo", "bash", "-c", "curl -fsSL https://get.docker.com | bash"}); err != nil { - return err } - _, err := p.Exec(ctx, name, []string{"sudo", "usermod", "-aG", "docker", "airlock"}) - return err + return false, nil } func copyDir(src, dst string) error { diff --git a/internal/vm/lima/snapshot_test.go b/internal/vm/lima/snapshot_test.go index db60b68..247b4c9 100644 --- a/internal/vm/lima/snapshot_test.go +++ b/internal/vm/lima/snapshot_test.go @@ -4,27 +4,30 @@ import ( "context" "os" "path/filepath" + "strings" "testing" + + "github.com/muneebs/airlock/internal/api" ) func TestSnapshotClean(t *testing.T) { dir := t.TempDir() + snapDir := t.TempDir() vmDir := filepath.Join(dir, "test-vm") os.MkdirAll(filepath.Join(vmDir, "sub"), 0755) os.WriteFile(filepath.Join(vmDir, "lima.yaml"), []byte("vmType: vz"), 0644) os.WriteFile(filepath.Join(vmDir, "sub", "data"), []byte("hello"), 0644) os.WriteFile(filepath.Join(vmDir, "runtime.sock"), []byte(""), 0644) - p := NewLimaProviderWithPaths("/bin/true", dir) + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) - err := p.SnapshotClean(nil, "test-vm") - if err != nil { + if err := p.SnapshotClean(nil, "test-vm"); err != nil { t.Fatalf("SnapshotClean() error: %v", err) } - cleanDir := filepath.Join(dir, "test-vm-clean") + cleanDir := filepath.Join(snapDir, "test-vm") if _, err := os.Stat(cleanDir); err != nil { - t.Fatalf("clean dir not created: %v", err) + t.Fatalf("clean dir not created at snapshot path: %v", err) } data, err := os.ReadFile(filepath.Join(cleanDir, "lima.yaml")) @@ -40,12 +43,43 @@ func TestSnapshotClean(t *testing.T) { if _, err := os.Stat(filepath.Join(cleanDir, "runtime.sock")); err == nil { t.Error("socket files should be excluded from snapshot") } + + // Snapshot must NOT live inside limaDir — that was the whole point of + // moving it (Lima was listing it as an extra VM). + legacy := filepath.Join(dir, "test-vm-clean") + if _, err := os.Stat(legacy); err == nil { + t.Errorf("snapshot leaked into limaDir at %s", legacy) + } +} + +// TestSnapshotClean_RemovesLegacy verifies that calling SnapshotClean after +// an upgrade deletes the pre-migration snapshot from inside limaDir so the +// user's `limactl list` output is no longer polluted. +func TestSnapshotClean_RemovesLegacy(t *testing.T) { + dir := t.TempDir() + snapDir := t.TempDir() + vmDir := filepath.Join(dir, "test-vm") + os.MkdirAll(vmDir, 0755) + os.WriteFile(filepath.Join(vmDir, "lima.yaml"), []byte("x"), 0644) + + legacy := filepath.Join(dir, "test-vm-clean") + os.MkdirAll(legacy, 0755) + os.WriteFile(filepath.Join(legacy, "lima.yaml"), []byte("old"), 0644) + + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) + if err := p.SnapshotClean(nil, "test-vm"); err != nil { + t.Fatalf("SnapshotClean() error: %v", err) + } + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("legacy snapshot should be removed, stat err = %v", err) + } } func TestRestoreClean(t *testing.T) { dir := t.TempDir() + snapDir := t.TempDir() - cleanDir := filepath.Join(dir, "test-vm-clean") + cleanDir := filepath.Join(snapDir, "test-vm") os.MkdirAll(cleanDir, 0755) os.WriteFile(filepath.Join(cleanDir, "lima.yaml"), []byte("vmType: vz"), 0644) @@ -53,10 +87,9 @@ func TestRestoreClean(t *testing.T) { os.MkdirAll(vmDir, 0755) os.WriteFile(filepath.Join(vmDir, "lima.yaml"), []byte("dirty"), 0644) - p := NewLimaProviderWithPaths("/bin/true", dir) + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) - err := p.RestoreClean(nil, "test-vm") - if err != nil { + if err := p.RestoreClean(nil, "test-vm"); err != nil { t.Fatalf("RestoreClean() error: %v", err) } @@ -69,22 +102,48 @@ func TestRestoreClean(t *testing.T) { } } +// TestRestoreClean_LegacyFallback ensures users with pre-migration snapshots +// (only the in-limaDir copy) can still reset without re-running setup. +func TestRestoreClean_LegacyFallback(t *testing.T) { + dir := t.TempDir() + snapDir := t.TempDir() + + legacy := filepath.Join(dir, "test-vm-clean") + os.MkdirAll(legacy, 0755) + os.WriteFile(filepath.Join(legacy, "lima.yaml"), []byte("legacy-clean"), 0644) + + vmDir := filepath.Join(dir, "test-vm") + os.MkdirAll(vmDir, 0755) + os.WriteFile(filepath.Join(vmDir, "lima.yaml"), []byte("dirty"), 0644) + + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) + + if err := p.RestoreClean(nil, "test-vm"); err != nil { + t.Fatalf("RestoreClean() error: %v", err) + } + data, _ := os.ReadFile(filepath.Join(vmDir, "lima.yaml")) + if string(data) != "legacy-clean" { + t.Errorf("expected legacy content, got: %s", string(data)) + } +} + func TestSnapshotCleanMasksPermissions(t *testing.T) { dir := t.TempDir() + snapDir := t.TempDir() vmDir := filepath.Join(dir, "test-vm") os.MkdirAll(vmDir, 0755) os.WriteFile(filepath.Join(vmDir, "suid-file"), []byte("suid"), 04755) os.WriteFile(filepath.Join(vmDir, "world-writable"), []byte("ww"), 0777) os.WriteFile(filepath.Join(vmDir, "normal-file"), []byte("normal"), 0644) - p := NewLimaProviderWithPaths("/bin/true", dir) + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) err := p.SnapshotClean(nil, "test-vm") if err != nil { t.Fatalf("SnapshotClean() error: %v", err) } - cleanDir := filepath.Join(dir, "test-vm-clean") + cleanDir := filepath.Join(snapDir, "test-vm") suidInfo, err := os.Stat(filepath.Join(cleanDir, "suid-file")) if err != nil { @@ -116,15 +175,16 @@ func TestSnapshotCleanMasksPermissions(t *testing.T) { func TestRestoreCleanMasksPermissions(t *testing.T) { dir := t.TempDir() + snapDir := t.TempDir() - cleanDir := filepath.Join(dir, "test-vm-clean") + cleanDir := filepath.Join(snapDir, "test-vm") os.MkdirAll(cleanDir, 0755) os.WriteFile(filepath.Join(cleanDir, "config"), []byte("clean"), 0644) vmDir := filepath.Join(dir, "test-vm") os.MkdirAll(vmDir, 0755) - p := NewLimaProviderWithPaths("/bin/true", dir) + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) err := p.RestoreClean(nil, "test-vm") if err != nil { @@ -140,9 +200,122 @@ func TestRestoreCleanMasksPermissions(t *testing.T) { } } +func stepLabels(steps []api.ProvisionStep) []string { + out := make([]string, len(steps)) + for i, s := range steps { + out[i] = s.Label + } + return out +} + +func hasPrefix(labels []string, prefix string) bool { + for _, l := range labels { + if strings.HasPrefix(l, prefix) { + return true + } + } + return false +} + +func TestProvisionSteps_BaselineOnly(t *testing.T) { + p := NewLimaProviderWithPaths("/bin/true", t.TempDir(), "") + + steps := p.ProvisionSteps("vm", api.ProvisionOptions{}) + labels := stepLabels(steps) + + // Baseline steps are always present. + for _, want := range []string{"Installing system packages", "Creating airlock user", "Preparing airlock home"} { + if !hasPrefix(labels, want) { + t.Errorf("missing baseline step %q in %v", want, labels) + } + } + // Nothing optional should be installed. + for _, forbidden := range []string{"Installing Node.js", "Installing pnpm", "Configuring npm prefix", "Installing Bun", "Installing Docker", "Installing Claude Code", "Installing Gemini CLI", "Installing Codex CLI", "Installing OpenCode", "Installing Ollama"} { + if hasPrefix(labels, forbidden) { + t.Errorf("unexpected optional step %q in %v", forbidden, labels) + } + } +} + +func TestProvisionSteps_NodeEnabled(t *testing.T) { + p := NewLimaProviderWithPaths("/bin/true", t.TempDir(), "") + + steps := p.ProvisionSteps("vm", api.ProvisionOptions{InstallNode: true, NodeVersion: 20}) + labels := stepLabels(steps) + + if !hasPrefix(labels, "Installing Node.js 20") { + t.Errorf("want Node.js 20 step, got %v", labels) + } + for _, want := range []string{"Installing pnpm", "Configuring npm prefix"} { + if !hasPrefix(labels, want) { + t.Errorf("missing %q in %v", want, labels) + } + } +} + +func TestProvisionSteps_AIToolForcesNode(t *testing.T) { + p := NewLimaProviderWithPaths("/bin/true", t.TempDir(), "") + + // Claude Code is npm-based, so Node must auto-enable even when InstallNode is false. + steps := p.ProvisionSteps("vm", api.ProvisionOptions{AITools: []string{"claude-code"}}) + labels := stepLabels(steps) + + if !hasPrefix(labels, "Installing Node.js") { + t.Errorf("npm-based AI tool should force Node.js: %v", labels) + } + if !hasPrefix(labels, "Installing Claude Code") { + t.Errorf("missing Claude Code step: %v", labels) + } +} + +func TestProvisionSteps_AllOptions(t *testing.T) { + p := NewLimaProviderWithPaths("/bin/true", t.TempDir(), "") + + steps := p.ProvisionSteps("vm", api.ProvisionOptions{ + NodeVersion: 22, + InstallNode: true, + InstallBun: true, + InstallDocker: true, + AITools: []string{"claude-code", "gemini", "codex", "opencode", "ollama"}, + }) + labels := stepLabels(steps) + + for _, want := range []string{ + "Installing Bun", + "Installing Docker", + "Installing Claude Code", + "Installing Gemini CLI", + "Installing Codex CLI", + "Installing OpenCode", + "Installing Ollama", + } { + if !hasPrefix(labels, want) { + t.Errorf("missing %q in %v", want, labels) + } + } +} + +func TestProvisionSteps_UnknownAIToolIgnored(t *testing.T) { + p := NewLimaProviderWithPaths("/bin/true", t.TempDir(), "") + + steps := p.ProvisionSteps("vm", api.ProvisionOptions{AITools: []string{"unknown-tool"}}) + labels := stepLabels(steps) + + for _, l := range labels { + if strings.Contains(l, "unknown-tool") { + t.Errorf("unknown AI tool should be ignored, got label %q", l) + } + } + // An unknown tool is not npm-based, so Node should NOT be forced on. + if hasPrefix(labels, "Installing Node.js") { + t.Errorf("unknown tool should not force Node.js: %v", labels) + } +} + func TestHasCleanSnapshot(t *testing.T) { dir := t.TempDir() - p := NewLimaProviderWithPaths("/bin/true", dir) + snapDir := t.TempDir() + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) ok, err := p.HasCleanSnapshot(context.Background(), "test-vm") if err != nil { @@ -152,12 +325,30 @@ func TestHasCleanSnapshot(t *testing.T) { t.Error("expected no clean snapshot initially") } - os.MkdirAll(filepath.Join(dir, "test-vm-clean"), 0755) + // Current location: snapshotDir/ + os.MkdirAll(filepath.Join(snapDir, "test-vm"), 0755) ok, err = p.HasCleanSnapshot(context.Background(), "test-vm") if err != nil { - t.Fatalf("HasCleanSnapshot error after creating dir: %v", err) + t.Fatalf("HasCleanSnapshot error: %v", err) + } + if !ok { + t.Error("expected clean snapshot to exist at new location") + } +} + +// TestHasCleanSnapshot_LegacyOnly confirms HasCleanSnapshot recognises the +// pre-migration in-limaDir snapshot so upgraded users see reset as available. +func TestHasCleanSnapshot_LegacyOnly(t *testing.T) { + dir := t.TempDir() + snapDir := t.TempDir() + p := NewLimaProviderWithPaths("/bin/true", dir, snapDir) + + os.MkdirAll(filepath.Join(dir, "test-vm-clean"), 0755) + ok, err := p.HasCleanSnapshot(context.Background(), "test-vm") + if err != nil { + t.Fatalf("HasCleanSnapshot error: %v", err) } if !ok { - t.Error("expected clean snapshot to exist after creating dir") + t.Error("legacy snapshot path should be detected") } } diff --git a/test/integration/harness_test.go b/test/integration/harness_test.go index e1f6697..dcd4198 100644 --- a/test/integration/harness_test.go +++ b/test/integration/harness_test.go @@ -106,7 +106,7 @@ exit 0 t.Fatalf("write fake limactl: %v", err) } - provider := lima.NewLimaProviderWithPaths(fakeLimactl, limaDir) + provider := lima.NewLimaProviderWithPaths(fakeLimactl, limaDir, "") detector := detect.NewCompositeDetector() profiles := profile.NewRegistry() @@ -184,6 +184,13 @@ func (h *harness) ctx() context.Context { return context.Background() } +// snapshotPath returns the clean-snapshot directory the harness's provider +// writes to. Tests MUST use this instead of concatenating suffixes inline so +// the construction lives in one place and matches the provider's default. +func (h *harness) snapshotPath(name string) string { + return filepath.Join(h.limaDir+"-snapshots", name) +} + func (h *harness) createVMFiles(t *testing.T, name string) { t.Helper() vmDir := filepath.Join(h.limaDir, name) diff --git a/test/integration/snapshot_test.go b/test/integration/snapshot_test.go index 0e47b7b..db1005b 100644 --- a/test/integration/snapshot_test.go +++ b/test/integration/snapshot_test.go @@ -12,7 +12,7 @@ func TestSnapshotAndReset(t *testing.T) { h := newHarness(t) h.createVMFiles(t, "snap-test") - cleanDir := filepath.Join(h.limaDir, "snap-test-clean") + cleanDir := h.snapshotPath("snap-test") if err := os.MkdirAll(cleanDir, 0755); err != nil { t.Fatalf("mkdir clean dir: %v", err) } @@ -98,7 +98,7 @@ func TestSnapshotCleanCapturesVMState(t *testing.T) { t.Fatalf("SnapshotClean() error: %v", err) } - cleanDir := filepath.Join(h.limaDir, "snap-capture-clean") + cleanDir := h.snapshotPath("snap-capture") data, err := os.ReadFile(filepath.Join(cleanDir, "test.txt")) if err != nil { t.Fatalf("read clean test.txt: %v", err)