Skip to content

Commit ce777bb

Browse files
committed
dxvm: rosetta, silent boot and other enhancements
A bunch of new miscellaneous new features and improvements: - Rosetta is enabled on `aarch64-darwin`. This allows VMs to transparently run aarch64 and x86_64 Linux binaries simultaneously. - Install x86_64 binaries with Nix using the `--system` flag. For example, `nix profile install --system x86_64 <pkg>`. - The current Devbox project directory is now automatically detected and mounted. It can be found in `~/devbox` in the VM. - Devbox is automatically installed when the VM is created using the `github:jetpack-io/devbox?ref=gcurtis/flake` flake. - Starting a VM is now completely silent. The kernel's console is sent to `.devbox/vm/console` instead of stdout so that the first thing the user sees is their own shell prompt. - This work really well when launching a paused VM. If the VM resumes fast enough, it feels like using a normal shell. - Bootstrapping from an NixOS installer ISO with the `-install` flag is now fully automated. This is done by reading/writing to the VM's console using a pipe (similar to a program like `expect`). - Fixed a bug where stopping the VM would always return an error.
1 parent b381699 commit ce777bb

File tree

9 files changed

+446
-190
lines changed

9 files changed

+446
-190
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# ignore files by Jetbrains IDEs
22
*.idea
3+
bin/

pkg/sandbox/vm/README.md

+17-13
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,16 @@ Experimental support for Devbox virtual machines on macOS.
77
The `dxvm` command acts like `devbox shell` except that it launches the Devbox
88
environment in a VM.
99

10-
To create a new VM, run the following:
10+
To create a new VM, run with the `-install` flag inside a Devbox project
11+
directory:
1112

1213
cd ~/my/project
1314
dxvm -install
14-
# Wait for the prompt. This might appear to hang the first time it's run
15-
# while downloading the NixOS installer.
1615

17-
mkdir bootstrap
18-
sudo mount -t virtiofs bootstrap bootstrap
19-
sudo bootstrap/install.sh
20-
sudo shutdown now
21-
# ^C to exit
16+
The VM will start, install NixOS, and then reboot into a shell. This step might
17+
appear to hang at times while it downloads NixOS.
2218

23-
Now that the VM is bootstrapped, you can launch it any time with:
19+
After the VM is created, you no longer need the `-install` flag:
2420

2521
cd ~/my/project
2622
dxvm
@@ -33,6 +29,7 @@ The first time `dxvm` is run in a Devbox project, it creates a `.devbox/vm`
3329
directory that contains the VM's state and configuration files:
3430

3531
- `log` - error and debug log messages
32+
- `console` - the Linux kernel console output
3633
- `disk.img` - main disk image, typically mounted as root
3734
- `id` - an opaque Virtualization Framework machine ID
3835

@@ -42,6 +39,13 @@ VM's resources:
4239
- `mem` - the amount of memory (in bytes) to allocate to the VM
4340
- `cpu` - the number of CPUs to allocate to the VM
4441

42+
There are two directories shared between the host and guest machines:
43+
44+
- `boot -> /boot` - gives the host access to the NixOS kernel and initrd so it
45+
can create a bootloader
46+
- `bootstrap -> ~/bootstrap` - contains a script for bootstrapping a new VM from
47+
a vanilla NixOS installer ISO (only mounted with `-install`)
48+
4549
## Building
4650

4751
This package uses the macOS Virtualization Framework, and therefore needs CGO.
@@ -55,16 +59,16 @@ To compile and sign `dxvm` run:
5559

5660
devbox run build
5761

62+
It's okay if it prints a couple of warnings about duplicate libraries and
63+
replacing the code-signing signature.
64+
5865
The `devbox run build` script uses `./cmd/dxvmsign` to sign the Go binary, which
5966
allows it to use the Virtualization Framework. It's a small wrapper around
6067
Apple's `codesign` utility.
6168

6269
## Limitations
6370

64-
- Mounting the Devbox project directory was temporarily removed while cleaning
65-
things up. Needs to be brought back.
66-
- Only aarch64-linux is implemented right now. Other systems have been tested,
67-
but they aren't an option in the dxvm command.
71+
- Intel macOS hasn't been tested yet.
6872
- Using ctrl-c to exit has the unfortunate side-effect of making it impossible
6973
to interrupt a program in the VM.
7074
- The host terminal has no way of telling the guest when it has resized (usually

pkg/sandbox/vm/bin/dxvm

-9.3 MB
Binary file not shown.

pkg/sandbox/vm/bootstrap/configuration.nix

+23-3
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,29 @@
2020
};
2121

2222
environment = {
23-
# defaultPackages = [ ];
24-
systemPackages = with pkgs; [ curl vim ];
23+
defaultPackages = [ ];
24+
systemPackages = with pkgs; [
25+
curl
26+
git
27+
vim
28+
((builtins.getFlake "github:jetpack-io/devbox?ref=gcurtis/flake").packages."{{.System}}".default)
29+
];
2530
};
2631

2732
fileSystems = {
2833
"/" = {
2934
device = "/dev/vda";
3035
fsType = "ext4";
3136
};
37+
"/boot" = {
38+
device = "boot";
39+
fsType = "virtiofs";
40+
};
41+
"/home/{{.User.Username}}/devbox" = {
42+
device = "home";
43+
fsType = "virtiofs";
44+
options = [ "nofail" ];
45+
};
3246
};
3347

3448
nix = {
@@ -46,14 +60,18 @@
4660
hostPlatform = lib.mkDefault "{{.System}}";
4761
};
4862

63+
programs.bash.promptInit = "PS1='dxvm\$ '";
64+
4965
security.sudo = {
5066
extraConfig = "Defaults lecture = never";
5167
wheelNeedsPassword = false;
5268
};
5369

5470
services.getty = {
5571
autologinUser = "{{.User.Username}}";
56-
greetingLine = "";
72+
greetingLine = lib.mkForce "";
73+
helpLine = lib.mkForce "";
74+
extraArgs = [ "--skip-login" "--nohostname" "--noissue" "--noclear" "--nonewline" "--8bits" ];
5775
};
5876

5977
system.stateVersion = "23.05";
@@ -71,4 +89,6 @@
7189
extraGroups = [ "wheel" ];
7290
};
7391
};
92+
93+
virtualisation.rosetta.enable = {{.Rosetta}};
7494
}

pkg/sandbox/vm/bootstrap/install.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ mount -t virtiofs boot /mnt/boot
77
cat << 'EOF' > /mnt/etc/nixos/configuration.nix
88
{{ template "configuration.nix" . -}}
99
EOF
10-
nixos-install --no-root-password --show-trace --root /mnt
10+
NIX_CONFIG="experimental-features = nix-command flakes" nixos-install --no-root-password --show-trace --root /mnt

pkg/sandbox/vm/cmd/dxvm/main.go

+72-12
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package main
22

33
import (
4+
"cmp"
45
"context"
56
"flag"
7+
"fmt"
8+
"io/fs"
69
"log/slog"
710
"os"
811
"os/signal"
12+
"path/filepath"
13+
"slices"
914
"time"
1015

1116
"go.jetpack.io/pkg/sandbox/vm"
@@ -15,25 +20,80 @@ func main() {
1520
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
1621
defer cancel()
1722

18-
vm := vm.VM{}
19-
flag.StringVar(&vm.HostDataDir, "datadir", ".devbox/vm", "`path` to the directory for saving VM state")
20-
flag.BoolVar(&vm.Install, "install", false, "mount NixOS install image")
23+
dataDir := "./dxvm"
24+
devboxDir, devboxDirFound, err := findDevboxDir()
25+
if err != nil {
26+
fmt.Fprintf(os.Stderr, "no devbox.json found, using %s for state: %v\n", dataDir, err)
27+
} else if !devboxDirFound {
28+
fmt.Fprintf(os.Stderr, "no devbox.json found, using %s for state: searched up to %s\n", dataDir, devboxDir)
29+
} else {
30+
dataDir = filepath.Join(devboxDir, ".devbox", "vm")
31+
}
32+
33+
dxvm := vm.VM{}
34+
flag.StringVar(&dxvm.HostDataDir, "datadir", dataDir, "`path` to the directory for saving VM state")
35+
flag.BoolVar(&dxvm.Install, "install", false, "mount NixOS install image")
2136
flag.Parse()
2237

23-
if vm.Install {
38+
if dxvm.Install {
2439
slog.Debug("downloading the NixOS installer, this make take a few minutes")
40+
} else if devboxDirFound {
41+
dxvm.SharedDirectories = append(dxvm.SharedDirectories, vm.SharedDirectory{
42+
Path: devboxDir,
43+
HomeDir: true,
44+
ReadOnly: false,
45+
})
46+
fmt.Fprintln(os.Stderr, "booting virtual machine")
2547
}
2648

27-
err := vm.Start(ctx)
28-
if err != nil {
29-
slog.Error("start virtual machine", "err", err)
49+
go func() {
50+
<-ctx.Done()
51+
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
52+
defer cancel()
53+
54+
if err := dxvm.Stop(ctx); err != nil {
55+
slog.Error("stop virtual machine", "err", err)
56+
}
57+
}()
58+
if err := dxvm.Run(ctx); err != nil {
59+
slog.Error("run virtual machine install", "err", err)
3060
os.Exit(1)
3161
}
3262

33-
<-ctx.Done()
34-
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
35-
defer cancel()
36-
if err := vm.Stop(ctx); err != nil {
37-
slog.Error("stop virtual machine", "err", err)
63+
// Restart if we just finished bootrapping a new VM.
64+
if dxvm.Install {
65+
fmt.Fprintln(os.Stderr, "virtual machine created successfully")
66+
dxvm.Install = false
67+
if err := dxvm.Run(ctx); err != nil {
68+
slog.Error("run virtual machine", "err", err)
69+
os.Exit(1)
70+
}
71+
}
72+
}
73+
74+
func findDevboxDir() (dir string, found bool, err error) {
75+
dir = "."
76+
if wd, err := os.Getwd(); err == nil {
77+
dir = wd
78+
}
79+
80+
home, _ := os.UserHomeDir()
81+
vol := filepath.VolumeName(dir)
82+
for {
83+
// Refuse to go past the user's home directory or search root.
84+
if dir == "" || dir == "/" || dir == home || dir == vol {
85+
return dir, false, nil
86+
}
87+
entries, err := os.ReadDir(dir)
88+
if err != nil {
89+
return "", false, nil
90+
}
91+
_, found := slices.BinarySearchFunc(entries, "devbox.json", func(e fs.DirEntry, t string) int {
92+
return cmp.Compare(e.Name(), t)
93+
})
94+
if found {
95+
return dir, true, nil
96+
}
97+
dir = filepath.Dir(dir)
3898
}
3999
}

pkg/sandbox/vm/console.go

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package vm
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"os"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"github.com/Code-Hex/vz/v3"
15+
)
16+
17+
func scriptedConsole(ctx context.Context, logger *slog.Logger, prompt string, script []string) (*vz.VirtioConsoleDeviceSerialPortConfiguration, error) {
18+
stdinr, stdinw, err := os.Pipe()
19+
if err != nil {
20+
return nil, fmt.Errorf("create stdin pipe: %v", err)
21+
}
22+
stdoutr, stdoutw, err := os.Pipe()
23+
if err != nil {
24+
return nil, fmt.Errorf("create stdout pipe: %v", err)
25+
}
26+
27+
go func() {
28+
var idle *time.Timer
29+
idleDur := time.Second
30+
sawPrompt := false
31+
doneWriting := false
32+
scanner := bufio.NewScanner(io.TeeReader(stdoutr, os.Stdout))
33+
for scanner.Scan() && ctx.Err() == nil {
34+
logger.Debug("install console", "stdout", scanner.Text())
35+
36+
if doneWriting {
37+
continue
38+
}
39+
if idle != nil {
40+
doneWriting = !idle.Reset(idleDur)
41+
continue
42+
}
43+
44+
sawPrompt = sawPrompt || strings.Contains(scanner.Text(), prompt)
45+
if !sawPrompt {
46+
continue
47+
}
48+
idle = time.AfterFunc(idleDur, sync.OnceFunc(func() {
49+
_, err := stdinw.WriteString(strings.Join(script, " && ") + "\n")
50+
if err != nil {
51+
logger.Error("error writing to VM standard input", "err", err)
52+
}
53+
stdinw.Close()
54+
}))
55+
}
56+
if err := scanner.Err(); err != nil {
57+
logger.Error("error reading install console stdout", "err", err)
58+
}
59+
}()
60+
61+
attach, err := vz.NewFileHandleSerialPortAttachment(stdinr, stdoutw)
62+
if err != nil {
63+
return nil, fmt.Errorf("create serial port attachment: %v", err)
64+
}
65+
config, err := vz.NewVirtioConsoleDeviceSerialPortConfiguration(attach)
66+
if err != nil {
67+
return nil, fmt.Errorf("create serial port configuration: %v", err)
68+
}
69+
return config, nil
70+
}

0 commit comments

Comments
 (0)