diff --git a/CLAUDE.md b/CLAUDE.md index 642eb37..eef401d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,8 @@ Lima runs a Linux guest from a YAML template. On macOS the VM type is `vz` `~/.lima//lima.yaml`. That file — not the repo template — is the source of truth for later boots. Editing `lima/dev-vm.yaml` or `lima/scripts/*.sh` has **no effect on an existing VM**; recreate it - (`go run . destroy && go run . create`) or `limactl edit `. + (`go run . recreate `, which preserves the guest `$HOME`, or + `go run . destroy && go run . create`) or `limactl edit `. - Instance directory holds `lima.yaml`, `basedisk`/`diffdisk`, `cidata.iso`, `ha.stdout.log`, `ha.stderr.log`, `serial*.log`. - Guest configuration is delivered by **cloud-init** through `cidata.iso`, @@ -346,5 +347,39 @@ change in this repo. time; `go run .` picks up edits automatically, a prebuilt `devvm` binary must be rebuilt. - After changing a template or script, the VM must be recreated - (`go run . destroy && go run . create `) for the change to - apply. + (`go run . recreate `, or `go run . destroy && go run . create + `) for the change to apply. + +## recreate + +`go run . recreate [name]` rebuilds a VM from the current template while +keeping the guest home directory. `recreate.go` archives `$HOME` from the guest +over `limactl shell sh -c 'tar -C "$HOME" -czf - …'` into +`~/.config/dev-vm/backups/-.tar.gz`, deletes the instance, +calls the same `createVM` helper `create` uses, then extracts the archive back +with `tar -xzf -`. Both directions go through `limactlPipe`, the only limactl +helper that wires stdin/stdout to the caller and returns the error, so a tar +exit code of 1 (a file changed underneath it in the live guest) is a warning +and anything else is fatal. + +- `homeExcludes` lists what provisioning owns and must not come back: + `~/.ssh/id_ed25519{,.pub}`, `~/.ssh/config`, `~/.ssh/lima-github.conf`, + `~/.ssh/authorized_keys` (Lima's, regenerated per instance), `~/.cache` and + `~/.local/share/docker`. Restoring the old private key would leave the VM + with a key GitHub no longer holds, and restoring the old `authorized_keys` + would lock `limactl shell` out. Everything else in the archive overwrites the + freshly provisioned copy, which is safe because the dotfiles repo is the + same one. +- The dotfiles repo is not a flag: it is read from the state entry, so a + restored home and its dotfiles cannot diverge. `-dotfiles`/`-no-dotfiles` + are deliberately absent; every other `create` flag is accepted. +- `-cpus`/`-memory`/`-disk` default to 0 meaning "unset", and + `recreateResources` resolves them against the live instance's size + (`limactl list --format json`, bytes, so `>>30`) instead of + `settingsResources()`. A rebuild therefore keeps the size the VM has, and + passing a flag resizes it — the one supported way to resize without losing + `$HOME`. +- Everything that can fail cheaply happens before the instance is deleted: + `checkScopes`, the confirmation prompt, then the archive, which must be at + least `minArchiveSize` bytes on disk. `backupHome` starts a stopped VM first + (`ensureRunning`) since the home directory only exists inside the guest. diff --git a/README.md b/README.md index 5cbee42..4abab63 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ forwards, rootless Docker, zsh + oh-my-zsh, mise, neovim, GitHub SSH access. ```sh go run . create [name] # create and start the VM +go run . recreate [name] # rebuild it from the template, keeping $HOME go run . start [name] # boot a stopped VM go run . stop [name] # shut it down, keeping the disk go run . destroy [name] # delete it (confirms first; -force skips) @@ -162,7 +163,52 @@ release. `stop -force` kills the VM instead of shutting the guest down gracefully — faster, but unwritten guest data is lost. -8. Throw it away (deletes the VM, the GitHub key and the local key pair). It +8. Rebuild it after changing the template or a provisioning script, keeping + the guest home directory: + + ```sh + go run . recreate myvm + ``` + + Provisioning is flattened into the instance at create time, so template and + script edits only reach a VM that is built again. `recreate` does that + without costing the state that lives in `$HOME` — dotfiles, `gh` and + `claude` credentials, shell history, checked-out repositories: + + 1. archives the guest home directory to + `~/.config/dev-vm/backups/-.tar.gz`, starting the VM + first if it is stopped, + 2. deletes the Lima VM, + 3. creates it again from the current template, with the dotfiles repo + recorded in the state file, + 4. extracts the archive back over the new home directory. + + Everything outside `$HOME` is gone: installed packages, Docker images, + `/etc` edits. That is the point — the new VM is provisioned from scratch. + + What the archive leaves out is what provisioning rewrites anyway: the VM's + ssh key and `~/.ssh/config`, Lima's `authorized_keys`, `~/.cache`, and the + rootless Docker data in `~/.local/share/docker`. So the rebuilt VM gets a + fresh GitHub key, as `create` does, and keeps the rest of `~/.ssh` + (`known_hosts`, your own extra keys). + + `recreate` takes every `create` flag except `-dotfiles`/`-no-dotfiles`: the + dotfiles repo has to stay the same for the restored home to make sense, so + it comes from `~/.config/dev-vm/state.json`. `-cpus`, `-memory` and `-disk` + default to the size the instance already has rather than to settings.json, + which is how a rebuild can also resize: + + ```sh + go run . recreate myvm -memory 32 + ``` + + It confirms first (`y`/`N`); `-force` skips the prompt and is mandatory + without a terminal on stdin. The archive is gzipped inside the guest, so a + multi-gigabyte home takes a few minutes in each direction. The token scope is checked and the archive is + written and size-checked before the VM is deleted, so an auth failure or a + failed archive costs nothing. The archive is kept afterwards. + +9. Throw it away (deletes the VM, the GitHub key and the local key pair). It asks first — type the VM name back to go ahead, anything else aborts: ```sh @@ -182,7 +228,9 @@ release. (logged out, expired token, offline) leaves the VM usable. State lives in `~/.config/dev-vm/state.json`, keys in -`~/.config/dev-vm/keys/`. +`~/.config/dev-vm/keys/`, home directory archives from `recreate` in +`~/.config/dev-vm/backups/`. Nothing prunes the archives — delete the old ones +yourself. ## Networking @@ -477,7 +525,8 @@ scripts skip their apt and download work once everything is in place, so only the first boot goes to the network and restarts stay fast; upgrades are the cron jobs' work ([staying patched](#staying-patched)). The template is flattened at create time, so after editing `lima/dev-vm.yaml` or -`lima/scripts/*.sh` the VM must be recreated. +`lima/scripts/*.sh` the VM must be built again — `go run . recreate ` +does that without losing the guest home directory. Order: data files, then system scripts (root), then user scripts (guest login user), then readiness probes gate `limactl start`. diff --git a/completion_test.go b/completion_test.go index 12b2f51..ca80807 100644 --- a/completion_test.go +++ b/completion_test.go @@ -35,7 +35,7 @@ func TestCompletionCoversCommands(t *testing.T) { if err != nil { t.Fatal(err) } - for _, cmd := range []string{"create", "start", "stop", "destroy", "list", "status"} { + for _, cmd := range []string{"create", "recreate", "start", "stop", "destroy", "list", "status"} { if !strings.Contains(string(script), cmd) { t.Errorf("dev-vm.%s does not mention %q", shell, cmd) } diff --git a/completions/dev-vm.bash b/completions/dev-vm.bash index 8637968..dd6fce7 100644 --- a/completions/dev-vm.bash +++ b/completions/dev-vm.bash @@ -8,7 +8,7 @@ _dev_vm() { COMPREPLY=() if [ "$COMP_CWORD" -eq 1 ]; then - COMPREPLY=($(compgen -W "create start stop destroy list status completion version help" -- "$cur")) + COMPREPLY=($(compgen -W "create recreate start stop destroy list status completion version help" -- "$cur")) return fi @@ -21,6 +21,18 @@ _dev_vm() { esac COMPREPLY=($(compgen -W "-create-ssh-key= -dotfiles -no-dotfiles -cpus -memory -disk -help" -- "$cur")) ;; + recreate) + case $prev in + -cpus | --cpus | -memory | --memory | -disk | --disk) + return + ;; + esac + if [ "${cur:0:1}" = "-" ]; then + COMPREPLY=($(compgen -W "-create-ssh-key= -force -cpus -memory -disk -help" -- "$cur")) + else + COMPREPLY=($(compgen -W "$("${COMP_WORDS[0]}" __names 2>/dev/null)" -- "$cur")) + fi + ;; start) if [ "${cur:0:1}" = "-" ]; then COMPREPLY=($(compgen -W "-help" -- "$cur")) diff --git a/completions/dev-vm.fish b/completions/dev-vm.fish index dc61f01..f6a5aa0 100644 --- a/completions/dev-vm.fish +++ b/completions/dev-vm.fish @@ -2,11 +2,12 @@ function __dev_vm_names command dev-vm __names 2>/dev/null end -set -l dev_vm_commands create start stop destroy list status completion version help +set -l dev_vm_commands create recreate start stop destroy list status completion version help complete -c dev-vm -f complete -c dev-vm -n "not __fish_seen_subcommand_from $dev_vm_commands" -a create -d 'create a dev VM' +complete -c dev-vm -n "not __fish_seen_subcommand_from $dev_vm_commands" -a recreate -d 'rebuild a dev VM, keeping the guest home directory' complete -c dev-vm -n "not __fish_seen_subcommand_from $dev_vm_commands" -a start -d 'start a stopped dev VM' complete -c dev-vm -n "not __fish_seen_subcommand_from $dev_vm_commands" -a stop -d 'stop a running dev VM' complete -c dev-vm -n "not __fish_seen_subcommand_from $dev_vm_commands" -a destroy -d 'destroy a dev VM and its GitHub key' @@ -23,7 +24,13 @@ complete -c dev-vm -n '__fish_seen_subcommand_from create' -o cpus -r -d 'vCPUs complete -c dev-vm -n '__fish_seen_subcommand_from create' -o memory -r -d 'RAM in GiB' complete -c dev-vm -n '__fish_seen_subcommand_from create' -o disk -r -d 'disk size in GiB' -complete -c dev-vm -n '__fish_seen_subcommand_from start stop destroy status' -a '(__dev_vm_names)' -d 'dev VM' +complete -c dev-vm -n '__fish_seen_subcommand_from recreate' -o create-ssh-key -r -a 'true false' -d 'create and register a new key' +complete -c dev-vm -n '__fish_seen_subcommand_from recreate' -o force -d 'skip the confirmation prompt' +complete -c dev-vm -n '__fish_seen_subcommand_from recreate' -o cpus -r -d 'vCPUs for the VM' +complete -c dev-vm -n '__fish_seen_subcommand_from recreate' -o memory -r -d 'RAM in GiB' +complete -c dev-vm -n '__fish_seen_subcommand_from recreate' -o disk -r -d 'disk size in GiB' + +complete -c dev-vm -n '__fish_seen_subcommand_from recreate start stop destroy status' -a '(__dev_vm_names)' -d 'dev VM' complete -c dev-vm -n '__fish_seen_subcommand_from status' -o ip -d 'print only the guest IP, empty when unavailable' complete -c dev-vm -n '__fish_seen_subcommand_from stop' -o force -d 'kill the VM instead of shutting the guest down gracefully' complete -c dev-vm -n '__fish_seen_subcommand_from destroy' -o force -d 'skip the confirmation prompt' diff --git a/completions/dev-vm.zsh b/completions/dev-vm.zsh index 5bf8a06..ab4ea26 100644 --- a/completions/dev-vm.zsh +++ b/completions/dev-vm.zsh @@ -12,6 +12,7 @@ _dev_vm() { local -a commands commands=( 'create:create a dev VM' + 'recreate:rebuild a dev VM, keeping the guest home directory' 'start:start a stopped dev VM' 'stop:stop a running dev VM' 'destroy:destroy a dev VM and its GitHub key' @@ -40,6 +41,15 @@ _dev_vm() { '-disk[disk size in GiB]:gib:' \ '1:name:' ;; + recreate) + _arguments \ + '-create-ssh-key=-[create and register a new key]:bool:(true false)' \ + '-force[skip the confirmation prompt]' \ + '-cpus[vCPUs for the VM]:count:' \ + '-memory[RAM in GiB]:gib:' \ + '-disk[disk size in GiB]:gib:' \ + '1:name:_dev_vm_names' + ;; start) _arguments '1:name:_dev_vm_names' ;; diff --git a/create.go b/create.go index f8a05a0..d814599 100644 --- a/create.go +++ b/create.go @@ -45,6 +45,15 @@ type resources struct { var defaultResources = resources{cpus: 2, memory: 2, disk: 50} +// createOptions is everything needed to build a VM, shared by create and +// recreate. +type createOptions struct { + name string + dotfiles string + res resources + createSSHKey bool +} + func cmdCreate(argv []string) { fs := flag.NewFlagSet("create", flag.ExitOnError) fs.Usage = func() { @@ -74,19 +83,31 @@ func cmdCreate(argv []string) { die("VM %q already exists; run: devvm destroy %s", name, name) } - key, pub := keyPaths(name) + createVM(createOptions{ + name: name, + dotfiles: dotfiles, + res: res, + createSSHKey: createSSHKey, + }) +} + +// createVM registers the GitHub key, boots the VM from the embedded template +// and records it in the state file. The caller decides whether an instance of +// that name may already exist. +func createVM(opt createOptions) { + key, pub := keyPaths(opt.name) - if !createSSHKey { + if !opt.createSSHKey { if !fileExists(key) || !fileExists(pub) { die("no key at %s; rerun without -create-ssh-key=false", key) } fmt.Printf("reusing key %s\n", key) } else { checkScopes() - title := keyTitle(name) + title := keyTitle(opt.name) createKey(title, key, pub) - keyID := registerKey(name, title, pub) - putVM(name, map[string]any{ + keyID := registerKey(opt.name, title, pub) + putVM(opt.name, map[string]any{ "github_key_id": keyID, "github_key_title": title, "private_key": key, @@ -94,19 +115,20 @@ func cmdCreate(argv []string) { }) } - if dotfiles != "" { - fmt.Printf("installing dotfiles from %s\n", dotfiles) + if opt.dotfiles != "" { + fmt.Printf("installing dotfiles from %s\n", opt.dotfiles) } - fmt.Printf("VM size %d vCPU, %dGiB RAM, %dGiB disk\n", res.cpus, res.memory, res.disk) - startVM(name, dotfiles, res, key) - putVM(name, map[string]any{ + fmt.Printf("VM size %d vCPU, %dGiB RAM, %dGiB disk\n", + opt.res.cpus, opt.res.memory, opt.res.disk) + startVM(opt.name, opt.dotfiles, opt.res, key) + putVM(opt.name, map[string]any{ "template": "embedded:lima/dev-vm.yaml", "started_at": now(), "private_key": key, "public_key": pub, - "dotfiles": dotfiles, + "dotfiles": opt.dotfiles, }) - if inst, ok := limaInstances()[name]; ok { + if inst, ok := limaInstances()[opt.name]; ok { if ip := guestIP(inst); ip != "" { fmt.Printf("IP %s (no port forwards; reach guest services here)\n", ip) } diff --git a/devvm.go b/devvm.go index a497ba1..870bac7 100644 --- a/devvm.go +++ b/devvm.go @@ -1,4 +1,4 @@ -// Shared helpers for the create and destroy subcommands. +// Shared helpers for the create, recreate and destroy subcommands. // // All external work goes through command line tools: limactl and gh. // VM metadata lives in a JSON state file under ~/.config/dev-vm, alongside an @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -29,6 +30,7 @@ var ( stateFile = filepath.Join(stateDir, "state.json") settingsFile = filepath.Join(stateDir, "settings.json") keyDir = filepath.Join(stateDir, "keys") + backupDir = filepath.Join(stateDir, "backups") nameRE = regexp.MustCompile(`^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$`) repoRE = regexp.MustCompile(`^[A-Za-z0-9@:._/+~-]+$`) @@ -51,6 +53,16 @@ func now() string { return time.Now().UTC().Format(time.RFC3339) } +// stamp is a filename-safe UTC timestamp for backup archives. +func stamp() string { + return time.Now().UTC().Format("20060102T150405Z") +} + +// mib renders a byte count as MiB, for progress messages about archives. +func mib(n int64) string { + return fmt.Sprintf("%.1fMiB", float64(n)/(1<<20)) +} + func checkName(name string) { if !nameRE.MatchString(name) { die("invalid VM name %q", name) @@ -144,6 +156,30 @@ func limactlTry(timeout time.Duration, args ...string) ([]byte, error) { return exec.CommandContext(ctx, "limactl", args...).Output() } +// limactlPipe runs limactl with stdin and stdout wired to the caller, for +// streaming a tar archive out of or into the guest, and returns the error +// instead of exiting: a non-zero tar exit needs interpreting, not dying. +func limactlPipe(stdin io.Reader, stdout io.Writer, args ...string) error { + cmd := exec.Command("limactl", args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if errors.Is(err, exec.ErrNotFound) { + die("limactl not found; install Lima") + } + return err +} + +// exitCode is the process exit code, or -1 when the command never ran. +func exitCode(err error) int { + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode() + } + return -1 +} + func limactlRun(args ...string) { cmd := exec.Command("limactl", args...) cmd.Stdin = os.Stdin diff --git a/main.go b/main.go index 2333942..b5faf0a 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,8 @@ const usage = `Manage isolated Lima dev VMs with SSH access to GitHub. Usage: dev-vm create [name] [-create-ssh-key=false] [-dotfiles REPO|-no-dotfiles] [-cpus N] [-memory GiB] [-disk GiB] + dev-vm recreate [name] [-create-ssh-key=false] [-force] + [-cpus N] [-memory GiB] [-disk GiB] dev-vm start [name] dev-vm stop [name] [-force] dev-vm destroy [name] [-force] @@ -33,6 +35,8 @@ func main() { switch os.Args[1] { case "create": cmdCreate(os.Args[2:]) + case "recreate": + cmdRecreate(os.Args[2:]) case "start": cmdStart(os.Args[2:]) case "stop": diff --git a/recreate.go b/recreate.go new file mode 100644 index 0000000..cef756e --- /dev/null +++ b/recreate.go @@ -0,0 +1,243 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const recreateUsage = `Rebuild a dev VM from the current template, keeping the guest home directory. + +Usage: devvm recreate [name] [-create-ssh-key=false] [-force] + [-cpus N] [-memory GiB] [-disk GiB] + +Same flags as devvm create, minus -dotfiles/-no-dotfiles: the dotfiles repo is +the one the VM already uses, read from ~/.config/dev-vm/state.json. + +- Archives the guest home directory to + ~/.config/dev-vm/backups/-.tar.gz, starting the VM first + when it is not running. The archive is kept afterwards. +- Deletes the Lima VM, creates it again from the current embedded template and + extracts the archive back over the new home directory. Dotfiles, gh and + claude credentials, shell history and everything else under $HOME survive; + the rest of the guest (installed packages, docker images, /etc edits) does + not. +- Leaves out of the archive what provisioning owns and rewrites: the VM's ssh + key and config, Lima's authorized_keys, ~/.cache, and the rootless docker + data in ~/.local/share/docker. +- Defaults the VM size to the instance's current size rather than to + settings.json, so a rebuild keeps the size the VM already has; -cpus, + -memory and -disk override it. +- Registers a fresh GitHub key like create does, replacing the VM's old one; + -create-ssh-key=false reuses the existing pair. + +` + +// homeExcludes are the home directory paths provisioning owns, left out of the +// archive so the rebuilt VM keeps its own ssh identity and docker state. Paths +// are relative to $HOME, matching the "." tar walks. +var homeExcludes = []string{ + "./.ssh/id_ed25519", + "./.ssh/id_ed25519.pub", + "./.ssh/config", + "./.ssh/lima-github.conf", + "./.ssh/authorized_keys", + "./.cache", + "./.local/share/docker", +} + +// minArchiveSize guards against destroying a VM over a truncated archive: an +// empty gzip stream is 20 bytes, a real home directory is far bigger. +const minArchiveSize = 1024 + +func cmdRecreate(argv []string) { + fs := flag.NewFlagSet("recreate", flag.ExitOnError) + fs.Usage = func() { + fmt.Fprint(os.Stderr, recreateUsage) + fs.PrintDefaults() + } + var createSSHKey bool + var force bool + fs.BoolVar(&createSSHKey, "create-ssh-key", true, + "create and register a new key; -create-ssh-key=false reuses the existing key") + fs.BoolVar(&force, "force", false, + "skip the confirmation prompt; required when stdin is not a terminal") + // Zero means unset: the default comes from the live instance, which is not + // known until the name is parsed. + var flags resources + fs.IntVar(&flags.cpus, "cpus", 0, "vCPUs for the VM (default: its current size)") + fs.IntVar(&flags.memory, "memory", 0, "RAM in GiB (default: its current size)") + fs.IntVar(&flags.disk, "disk", 0, "disk size in GiB (default: its current size)") + name := parseArgs(fs, argv) + + checkName(name) + inst, exists := limaInstances()[name] + if !exists { + die("no VM %q to recreate; run: devvm create %s", name, name) + } + res := recreateResources(flags, inst) + checkResources(res) + + dotfiles, _ := getVM(name)["dotfiles"].(string) + if dotfiles != "" { + checkRepo(dotfiles) + } + // Before anything is deleted: an auth problem must cost nothing. + if createSSHKey { + checkScopes() + } + if !force { + if !isTerminal(os.Stdin) { + die("recreate needs a terminal to confirm; rerun with: devvm recreate %s -force", name) + } + if err := confirmRecreate(name, os.Stdin, os.Stdout); err != nil { + die("%v", err) + } + } + + archive := backupHome(name) + deleteVM(name) + createVM(createOptions{ + name: name, + dotfiles: dotfiles, + res: res, + createSSHKey: createSSHKey, + }) + restoreHome(name, archive) + putVM(name, map[string]any{ + "home_backup": archive, + "recreated_at": now(), + }) +} + +// recreateResources resolves the VM size: a flag that was passed wins, anything +// unset keeps the instance's current size, and a size Lima does not report +// falls back to the create defaults. +func recreateResources(flags resources, inst limaInstance) resources { + base := settingsResources() + if inst.CPUs > 0 { + base.cpus = inst.CPUs + } + if inst.Memory > 0 { + base.memory = int(inst.Memory >> 30) + } + if inst.Disk > 0 { + base.disk = int(inst.Disk >> 30) + } + for _, r := range []struct{ flag, base *int }{ + {&flags.cpus, &base.cpus}, + {&flags.memory, &base.memory}, + {&flags.disk, &base.disk}, + } { + if *r.flag == 0 { + *r.flag = *r.base + } + } + return flags +} + +// confirmRecreate asks for a plain yes: the home directory survives, so this +// is a lighter prompt than destroy's type-the-name. A short read is a no. +func confirmRecreate(name string, in io.Reader, out io.Writer) error { + fmt.Fprintf(out, "This deletes VM %q and builds it again. The guest home directory is "+ + "archived and restored; everything else in the guest is lost.\n", name) + fmt.Fprintf(out, "Recreate %s? [y/N]: ", name) + answer, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("cannot read confirmation: %v", err) + } + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return nil + } + return errors.New("not confirmed, nothing changed") +} + +// backupHome streams a tar of the guest home directory to a file on the host, +// starting the VM when needed. It dies without leaving an archive behind +// unless the archive looks usable, since the caller deletes the VM next. +func backupHome(name string) string { + ensureRunning(name) + if err := os.MkdirAll(backupDir, 0o700); err != nil { + die("cannot create %s: %v", backupDir, err) + } + path := filepath.Join(backupDir, fmt.Sprintf("%s-%s.tar.gz", name, stamp())) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + die("cannot create %s: %v", path, err) + } + + fmt.Printf("archiving guest home of %s\n", name) + runErr := limactlPipe(nil, f, "shell", "--workdir", "/", name, + "sh", "-c", archiveCommand()) + closeErr := f.Close() + // tar exits 1 when a file changed underneath it, which a live guest does. + if runErr != nil && exitCode(runErr) != 1 { + os.Remove(path) + die("cannot archive the guest home of %s: %v", name, runErr) + } + if runErr != nil { + fmt.Fprintln(os.Stderr, "warning: tar reported changed files while archiving") + } + if closeErr != nil { + die("cannot write %s: %v", path, closeErr) + } + info, err := os.Stat(path) + if err != nil { + die("cannot read %s: %v", path, err) + } + if info.Size() < minArchiveSize { + os.Remove(path) + die("archive of the guest home is empty; nothing destroyed") + } + fmt.Printf("archived %s (%s)\n", path, mib(info.Size())) + return path +} + +// archiveCommand builds the guest side of the backup: tar the home directory +// to stdout, skipping the paths provisioning rewrites. +func archiveCommand() string { + var b strings.Builder + b.WriteString(`tar -C "$HOME" -czf -`) + for _, e := range homeExcludes { + fmt.Fprintf(&b, " --exclude=%s", e) + } + b.WriteString(" .") + return b.String() +} + +// restoreHome extracts the archive over the freshly provisioned home +// directory, so the old contents win wherever both have a file. +func restoreHome(name, archive string) { + f, err := os.Open(archive) + if err != nil { + die("cannot read %s: %v", archive, err) + } + defer f.Close() + + fmt.Printf("restoring guest home from %s\n", archive) + err = limactlPipe(f, os.Stdout, "shell", "--workdir", "/", name, + "sh", "-c", `tar -C "$HOME" -xzf -`) + if err != nil && exitCode(err) != 1 { + die("cannot restore the guest home from %s: %v", archive, err) + } + if err != nil { + fmt.Fprintln(os.Stderr, "warning: tar reported problems while restoring") + } + fmt.Printf("home restored; archive kept at %s\n", archive) +} + +// ensureRunning boots the VM when it is not running, since the home directory +// can only be read from inside the guest. +func ensureRunning(name string) { + if inst, ok := limaInstances()[name]; ok && inst.Status == "Running" { + return + } + fmt.Printf("starting %s to read its home directory\n", name) + limactlRun("start", "--tty=false", name) +} diff --git a/recreate_test.go b/recreate_test.go new file mode 100644 index 0000000..7e5ea8f --- /dev/null +++ b/recreate_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "io" + "slices" + "strings" + "testing" +) + +func TestConfirmRecreate(t *testing.T) { + for _, tc := range []struct { + name string + answer string + wantErr bool + }{ + {name: "y", answer: "y\n"}, + {name: "yes", answer: "yes\n"}, + {name: "uppercase", answer: " Y \n"}, + {name: "no newline", answer: "y"}, + {name: "n", answer: "n\n", wantErr: true}, + {name: "name typed back", answer: "myvm\n", wantErr: true}, + {name: "empty", answer: "\n", wantErr: true}, + {name: "eof", answer: "", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := confirmRecreate("myvm", strings.NewReader(tc.answer), io.Discard) + if (err != nil) != tc.wantErr { + t.Errorf("confirmRecreate(%q) = %v, wantErr %v", tc.answer, err, tc.wantErr) + } + }) + } +} + +// TestRecreateResources: an unset flag keeps the size Lima reports for the +// instance, a passed flag wins, and a size Lima does not report falls back to +// the create defaults. +func TestRecreateResources(t *testing.T) { + full := limaInstance{CPUs: 8, Memory: 16 << 30, Disk: 100 << 30} + for _, tc := range []struct { + name string + flags resources + inst limaInstance + want resources + }{ + { + name: "no flags keeps the instance size", + inst: full, + want: resources{cpus: 8, memory: 16, disk: 100}, + }, + { + name: "one flag wins", + flags: resources{memory: 4}, + inst: full, + want: resources{cpus: 8, memory: 4, disk: 100}, + }, + { + name: "all flags win", + flags: resources{cpus: 1, memory: 2, disk: 3}, + inst: full, + want: resources{cpus: 1, memory: 2, disk: 3}, + }, + { + name: "unreported size falls back to defaults", + inst: limaInstance{}, + want: defaultResources, + }, + } { + t.Run(tc.name, func(t *testing.T) { + withSettings(t, "") + if got := recreateResources(tc.flags, tc.inst); got != tc.want { + t.Errorf("recreateResources() = %+v, want %+v", got, tc.want) + } + }) + } +} + +// TestArchiveCommand: the guest tar must skip everything provisioning owns, +// or the rebuilt VM gets the old ssh key back and loses GitHub access. +func TestArchiveCommand(t *testing.T) { + cmd := archiveCommand() + for _, path := range homeExcludes { + if !strings.Contains(cmd, "--exclude="+path) { + t.Errorf("archiveCommand() does not exclude %q: %s", path, cmd) + } + } + for _, want := range []string{`tar -C "$HOME"`, "-czf -"} { + if !strings.Contains(cmd, want) { + t.Errorf("archiveCommand() = %q, want it to contain %q", cmd, want) + } + } + if !strings.HasSuffix(cmd, " .") { + t.Errorf("archiveCommand() = %q, want it to archive the whole home", cmd) + } +} + +func TestHomeExcludesCoverProvisionedSSHFiles(t *testing.T) { + for _, path := range []string{ + "./.ssh/id_ed25519", + "./.ssh/config", + "./.ssh/lima-github.conf", + "./.ssh/authorized_keys", + } { + if !slices.Contains(homeExcludes, path) { + t.Errorf("homeExcludes is missing %q", path) + } + } +}