Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ Lima runs a Linux guest from a YAML template. On macOS the VM type is `vz`
`~/.lima/<name>/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 <name>`.
(`go run . recreate <name>`, which preserves the guest `$HOME`, or
`go run . destroy && go run . create`) or `limactl edit <name>`.
- 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`,
Expand Down Expand Up @@ -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 <name> && go run . create <name>`) for the change to
apply.
(`go run . recreate <name>`, or `go run . destroy <name> && go run . create
<name>`) 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 <name> sh -c 'tar -C "$HOME" -czf - …'` into
`~/.config/dev-vm/backups/<name>-<timestamp>.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.
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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/<name>-<timestamp>.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
Expand All @@ -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

Expand Down Expand Up @@ -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 <name>`
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`.
Expand Down
2 changes: 1 addition & 1 deletion completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
14 changes: 13 additions & 1 deletion completions/dev-vm.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"))
Expand Down
11 changes: 9 additions & 2 deletions completions/dev-vm.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions completions/dev-vm.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
;;
Expand Down
46 changes: 34 additions & 12 deletions create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -74,39 +83,52 @@ 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,
"public_key": pub,
})
}

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)
}
Expand Down
38 changes: 37 additions & 1 deletion devvm.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
Expand All @@ -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@:._/+~-]+$`)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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":
Expand Down
Loading