Skip to content

Commit 9e58e98

Browse files
committed
add VZ block device attachments on macOS
1 parent 7e52923 commit 9e58e98

15 files changed

Lines changed: 400 additions & 10 deletions

cmd/limactl/editflags/editflags.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func RegisterEdit(cmd *cobra.Command, commentPrefix string) {
6767
flags.Bool("nested-virt", false, commentPrefix+"Enable nested virtualization")
6868

6969
flags.Bool("rosetta", false, commentPrefix+"Enable Rosetta (for vz instances)")
70+
flags.StringSlice("block-device", nil, commentPrefix+"Host block devices to attach to a VZ VM on macOS, e.g. /dev/disk4")
7071

7172
flags.StringArray("set", []string{}, commentPrefix+"Modify the template inplace, using yq syntax. Can be passed multiple times.")
7273

@@ -332,6 +333,25 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
332333
false,
333334
false,
334335
},
336+
{
337+
"block-device",
338+
func(_ *flag.Flag) ([]string, error) {
339+
paths, err := flags.GetStringSlice("block-device")
340+
if err != nil {
341+
return nil, err
342+
}
343+
devices := make([]string, len(paths))
344+
for i, path := range paths {
345+
if path == "" {
346+
return nil, errors.New("block device path must not be empty")
347+
}
348+
devices[i] = strconv.Quote(path)
349+
}
350+
return []string{fmt.Sprintf(".vmOpts.vz.blockDevices += [%s] | .vmOpts.vz.blockDevices |= unique", strings.Join(devices, ","))}, nil
351+
},
352+
false,
353+
false,
354+
},
335355
{"set", func(v *flag.Flag) ([]string, error) {
336356
return v.Value.(flag.SliceValue).GetSlice(), nil
337357
}, false, false},

cmd/limactl/editflags/editflags_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ func TestYQExpressions(t *testing.T) {
225225
newInstance: false,
226226
expected: []string{`.nestedVirtualization = true`},
227227
},
228+
{
229+
name: "block-device",
230+
args: []string{"--block-device", "/dev/disk4", "--block-device", "/dev/disk5"},
231+
newInstance: false,
232+
expected: []string{`.vmOpts.vz.blockDevices += ["/dev/disk4","/dev/disk5"] | .vmOpts.vz.blockDevices |= unique`},
233+
},
228234
{
229235
name: "invalid network",
230236
args: []string{"--network", "invalid"},
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build darwin && !no_vz
2+
3+
// SPDX-FileCopyrightText: Copyright The Lima Authors
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package main
7+
8+
import (
9+
"github.com/spf13/cobra"
10+
11+
drv "github.com/lima-vm/lima/v2/pkg/driver/vz"
12+
)
13+
14+
const sudoOpenBlockDeviceCommand = "sudo-open-block-device"
15+
16+
func registerHiddenCommands(rootCmd *cobra.Command) {
17+
rootCmd.AddCommand(&cobra.Command{
18+
Use: sudoOpenBlockDeviceCommand,
19+
Short: "Open a host block device for a VZ VM",
20+
Args: WrapArgsError(cobra.NoArgs),
21+
Hidden: true,
22+
RunE: func(cmd *cobra.Command, _ []string) error {
23+
return drv.ServeSudoOpenBlockDevice(cmd.InOrStdin())
24+
},
25+
})
26+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !darwin || no_vz
2+
3+
// SPDX-FileCopyrightText: Copyright The Lima Authors
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package main
7+
8+
import "github.com/spf13/cobra"
9+
10+
const sudoOpenBlockDeviceCommand = "sudo-open-block-device"
11+
12+
func registerHiddenCommands(_ *cobra.Command) {}

cmd/limactl/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const (
3232
)
3333

3434
func main() {
35-
if os.Geteuid() == 0 && (len(os.Args) < 2 || os.Args[1] != "generate-doc") {
35+
if os.Geteuid() == 0 && (len(os.Args) < 2 || (os.Args[1] != "generate-doc" && os.Args[1] != sudoOpenBlockDeviceCommand)) {
3636
fmt.Fprint(os.Stderr, "limactl: must not run as the root user\n")
3737
os.Exit(1)
3838
}
@@ -136,6 +136,9 @@ func newApp() *cobra.Command {
136136
// allow commands that are used for packaging to run under rosetta to allow cross-architecture builds
137137
return errors.New("limactl is running under rosetta, please reinstall lima with native arch")
138138
}
139+
if cmd.Name() == sudoOpenBlockDeviceCommand {
140+
return nil
141+
}
139142

140143
// Make sure either $HOME or $LIMA_HOME is defined, so we don't need
141144
// to check for errors later
@@ -211,6 +214,7 @@ func newApp() *cobra.Command {
211214
newRenameCommand(),
212215
newWatchCommand(),
213216
)
217+
registerHiddenCommands(rootCmd)
214218
addPluginCommands(rootCmd)
215219

216220
return rootCmd

cmd/limactl/sudoers_darwin.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"fmt"
1010
"io"
1111

12-
"github.com/sirupsen/logrus"
1312
"github.com/spf13/cobra"
1413

1514
"github.com/lima-vm/lima/v2/pkg/networks"
@@ -21,11 +20,6 @@ func sudoersAction(cmd *cobra.Command, args []string) error {
2120
if err != nil {
2221
return err
2322
}
24-
// Make sure the current network configuration is secure
25-
if err := nwCfg.Validate(); err != nil {
26-
logrus.Infof("Please check %s for more information.", socketVMNetURL)
27-
return err
28-
}
2923
check, err := cmd.Flags().GetBool("check")
3024
if err != nil {
3125
return err
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
//go:build darwin && !no_vz
2+
3+
// SPDX-FileCopyrightText: Copyright The Lima Authors
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package vz
7+
8+
import (
9+
"bytes"
10+
"context"
11+
"encoding/json"
12+
"errors"
13+
"fmt"
14+
"io"
15+
"net"
16+
"os"
17+
"os/exec"
18+
"path/filepath"
19+
"strings"
20+
21+
vzapi "github.com/Code-Hex/vz/v3"
22+
"github.com/balajiv113/fd"
23+
"github.com/sirupsen/logrus"
24+
25+
"github.com/lima-vm/lima/v2/pkg/limatype"
26+
"github.com/lima-vm/lima/v2/pkg/limayaml"
27+
)
28+
29+
const sudoOpenBlockDeviceCommand = "sudo-open-block-device"
30+
31+
func ServeSudoOpenBlockDevice(r io.Reader) error {
32+
return serveSudoOpenBlockDevice(r)
33+
}
34+
35+
type sudoOpenBlockDeviceRequest struct {
36+
DevicePath string `json:"devicePath"`
37+
SocketPath string `json:"socketPath"`
38+
}
39+
40+
func serveSudoOpenBlockDevice(r io.Reader) error {
41+
var req sudoOpenBlockDeviceRequest
42+
if err := json.NewDecoder(r).Decode(&req); err != nil {
43+
return fmt.Errorf("failed to decode request: %w", err)
44+
}
45+
if err := req.validate(); err != nil {
46+
return err
47+
}
48+
49+
deviceFile, err := os.OpenFile(req.DevicePath, os.O_RDWR, 0)
50+
if err != nil {
51+
return fmt.Errorf("failed to open %q: %w", req.DevicePath, err)
52+
}
53+
defer deviceFile.Close()
54+
55+
fi, err := deviceFile.Stat()
56+
if err != nil {
57+
return fmt.Errorf("failed to stat %q: %w", req.DevicePath, err)
58+
}
59+
if fi.Mode()&os.ModeDevice == 0 {
60+
return fmt.Errorf("%q is not a device node", req.DevicePath)
61+
}
62+
63+
socketAddr, err := net.ResolveUnixAddr("unix", req.SocketPath)
64+
if err != nil {
65+
return fmt.Errorf("failed to resolve socket %q: %w", req.SocketPath, err)
66+
}
67+
conn, err := net.DialUnix("unix", nil, socketAddr)
68+
if err != nil {
69+
return fmt.Errorf("failed to connect to socket %q: %w", req.SocketPath, err)
70+
}
71+
defer conn.Close()
72+
73+
if err := fd.Put(conn, deviceFile); err != nil {
74+
return fmt.Errorf("failed to send file descriptor for %q: %w", req.DevicePath, err)
75+
}
76+
return nil
77+
}
78+
79+
func (r sudoOpenBlockDeviceRequest) validate() error {
80+
if r.DevicePath == "" {
81+
return errors.New("devicePath must not be empty")
82+
}
83+
if !filepath.IsAbs(r.DevicePath) {
84+
return fmt.Errorf("devicePath %q must be an absolute path", r.DevicePath)
85+
}
86+
if filepath.Clean(r.DevicePath) != r.DevicePath {
87+
return fmt.Errorf("devicePath %q must be normalized", r.DevicePath)
88+
}
89+
if !strings.HasPrefix(r.DevicePath, "/dev/") {
90+
return fmt.Errorf("devicePath %q must be under /dev", r.DevicePath)
91+
}
92+
93+
if r.SocketPath == "" {
94+
return errors.New("socketPath must not be empty")
95+
}
96+
if !filepath.IsAbs(r.SocketPath) {
97+
return fmt.Errorf("socketPath %q must be an absolute path", r.SocketPath)
98+
}
99+
if filepath.Clean(r.SocketPath) != r.SocketPath {
100+
return fmt.Errorf("socketPath %q must be normalized", r.SocketPath)
101+
}
102+
return nil
103+
}
104+
105+
func attachHostBlockDevices(ctx context.Context, inst *limatype.Instance, configurations []vzapi.StorageDeviceConfiguration) ([]vzapi.StorageDeviceConfiguration, error) {
106+
var vzOpts limatype.VZOpts
107+
if err := limayaml.Convert(inst.Config.VMOpts[limatype.VZ], &vzOpts, "vmOpts.vz"); err != nil {
108+
return nil, err
109+
}
110+
if len(vzOpts.BlockDevices) == 0 {
111+
return configurations, nil
112+
}
113+
114+
for i, devicePath := range vzOpts.BlockDevices {
115+
deviceFile, err := openHostBlockDevice(ctx, inst, devicePath, i)
116+
if err != nil {
117+
return nil, err
118+
}
119+
attachment, err := vzapi.NewDiskBlockDeviceStorageDeviceAttachment(deviceFile, false, vzapi.DiskSynchronizationModeFull)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to create block device attachment for %q: %w", devicePath, err)
122+
}
123+
device, err := vzapi.NewVirtioBlockDeviceConfiguration(attachment)
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to create virtio block device for %q: %w", devicePath, err)
126+
}
127+
if err := device.SetBlockDeviceIdentifier(guestBlockDeviceIdentifier(devicePath)); err != nil {
128+
return nil, fmt.Errorf("failed to set block device identifier for %q: %w", devicePath, err)
129+
}
130+
configurations = append(configurations, device)
131+
}
132+
return configurations, nil
133+
}
134+
135+
func openHostBlockDevice(ctx context.Context, inst *limatype.Instance, devicePath string, index int) (*os.File, error) {
136+
exe, err := os.Executable()
137+
if err != nil {
138+
return nil, err
139+
}
140+
141+
socketPath := filepath.Join(inst.Dir, fmt.Sprintf("vz-block-device.%d.sock", index))
142+
if err := os.RemoveAll(socketPath); err != nil {
143+
return nil, err
144+
}
145+
146+
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to listen on %q: %w", socketPath, err)
149+
}
150+
listener.SetUnlinkOnClose(true)
151+
defer func() {
152+
_ = listener.Close()
153+
_ = os.RemoveAll(socketPath)
154+
}()
155+
156+
req := sudoOpenBlockDeviceRequest{
157+
DevicePath: devicePath,
158+
SocketPath: socketPath,
159+
}
160+
var stdin bytes.Buffer
161+
if err := json.NewEncoder(&stdin).Encode(req); err != nil {
162+
return nil, err
163+
}
164+
165+
type receivedFD struct {
166+
file *os.File
167+
err error
168+
}
169+
fdCh := make(chan receivedFD, 1)
170+
go func() {
171+
conn, err := listener.AcceptUnix()
172+
if err != nil {
173+
fdCh <- receivedFD{err: err}
174+
return
175+
}
176+
defer conn.Close()
177+
178+
files, err := fd.Get(conn, 1, []string{filepath.Base(devicePath)})
179+
if err != nil {
180+
fdCh <- receivedFD{err: err}
181+
return
182+
}
183+
if len(files) != 1 {
184+
for _, file := range files {
185+
_ = file.Close()
186+
}
187+
fdCh <- receivedFD{err: fmt.Errorf("expected 1 file descriptor for %q, got %d", devicePath, len(files))}
188+
return
189+
}
190+
fdCh <- receivedFD{file: files[0]}
191+
}()
192+
193+
var stdout, stderr bytes.Buffer
194+
cmd := exec.CommandContext(ctx, "sudo", "--user", "root", "--group", "wheel", "--non-interactive", exe, sudoOpenBlockDeviceCommand)
195+
cmd.Stdin = &stdin
196+
cmd.Stdout = &stdout
197+
cmd.Stderr = &stderr
198+
logrus.Debugf("Opening block device %q via sudo helper: %v", devicePath, cmd.Args)
199+
if err := cmd.Run(); err != nil {
200+
_ = listener.Close()
201+
received := <-fdCh
202+
if received.file != nil {
203+
_ = received.file.Close()
204+
}
205+
return nil, fmt.Errorf("failed to run %v: stdout=%q, stderr=%q: %w", cmd.Args, stdout.String(), stderr.String(), err)
206+
}
207+
208+
received := <-fdCh
209+
if received.err != nil {
210+
return nil, fmt.Errorf("failed to receive file descriptor for %q: %w", devicePath, received.err)
211+
}
212+
vmNetworkFiles = append(vmNetworkFiles, received.file)
213+
return received.file, nil
214+
}
215+
216+
func guestBlockDeviceIdentifier(devicePath string) string {
217+
base := filepath.Base(devicePath)
218+
var b strings.Builder
219+
b.Grow(len(base))
220+
for _, r := range base {
221+
switch {
222+
case r >= 'a' && r <= 'z':
223+
b.WriteRune(r)
224+
case r >= 'A' && r <= 'Z':
225+
b.WriteRune(r)
226+
case r >= '0' && r <= '9':
227+
b.WriteRune(r)
228+
case r == '-', r == '_', r == '.':
229+
b.WriteRune(r)
230+
default:
231+
b.WriteByte('-')
232+
}
233+
}
234+
id := b.String()
235+
if id == "" {
236+
id = "block-device"
237+
}
238+
if len(id) > 20 {
239+
id = id[:20]
240+
}
241+
return id
242+
}

0 commit comments

Comments
 (0)