feat(vm): render virtio-serial ports backed by listening unix sockets (#1443)

This commit is contained in:
Josh Siviour
2026-11-02 20:30:52 +10:00
committed by GitHub
parent a293d4e983
commit d7905107bd
3 changed files with 94 additions and 0 deletions
+2
View File
@@ -36,6 +36,7 @@ type MicroVMSpec struct {
FwCfg []FwCfgEntry
NetDevs []NetDev
Devices []Device
SerialPorts []SerialPort
}
// NewMicroVMConfig builds the Config for a direct-boot microvm launch: qboot,
@@ -58,6 +59,7 @@ func NewMicroVMConfig(s MicroVMSpec) Config {
FwCfg: s.FwCfg,
NetDevs: s.NetDevs,
Devices: s.Devices,
SerialPorts: s.SerialPorts,
}
}
+40
View File
@@ -468,6 +468,17 @@ type Config struct {
Initrd string `json:"initrd,omitempty"` // path to initramfs; emits -initrd when set
KernelCmdline string `json:"kernel_cmdline,omitempty"` // emits -append when set
FwCfg []FwCfgEntry `json:"fw_cfg,omitempty"` // each emits -fw_cfg name=<name>,file=<path>
// SerialPorts are named virtio-serial ports, each backed by a host unix
// socket that QEMU listens on. They carry no network traffic.
SerialPorts []SerialPort `json:"serial_ports,omitempty"`
}
// SerialPort is one virtserialport named Name in the guest, whose host end is
// a unix socket at Socket that QEMU creates and listens on.
type SerialPort struct {
Name string `json:"name"`
Socket string `json:"socket"`
}
// FwCfgEntry describes a single QEMU firmware configuration file entry.
@@ -672,6 +683,12 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-fw_cfg", fmt.Sprintf("name=%s,file=%s", fw.Name, fw.File))
}
portArgs, err := serialPortArgs(cfg.SerialPorts)
if err != nil {
return nil, err
}
args = append(args, portArgs...)
if cfg.Architecture == "" {
return nil, fmt.Errorf("architecture missing")
}
@@ -700,6 +717,29 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
return cmd, nil
}
// serialPortArgs renders one virtio-serial controller and, per port, a
// listening socket chardev and its virtserialport. Commas are refused because
// QEMU would read them as further options.
func serialPortArgs(ports []SerialPort) ([]string, error) {
if len(ports) == 0 {
return nil, nil
}
args := []string{"-device", "virtio-serial-pci,id=vserial0"}
for i, p := range ports {
if p.Name == "" || p.Socket == "" {
return nil, fmt.Errorf("serial port %d: name and socket are required", i)
}
if strings.ContainsRune(p.Name, ',') || strings.ContainsRune(p.Socket, ',') {
return nil, fmt.Errorf("serial port %q: name and socket must not contain a comma", p.Name)
}
id := fmt.Sprintf("vport%d", i)
args = append(args,
"-chardev", fmt.Sprintf("socket,id=%s,path=%s,server=on,wait=off", id, p.Socket),
"-device", fmt.Sprintf("virtserialport,bus=vserial0.0,chardev=%s,name=%s", id, p.Name))
}
return args, nil
}
// appendOpt renders a chardev append option, or nothing when off, so a
// fresh launch keeps the command line it always had.
func appendOpt(key string, on bool) string {
+52
View File
@@ -694,3 +694,55 @@ func TestExecute_MicrovmWithPCIEFileChardev(t *testing.T) {
assert.Equal(t, "file,id=console0,path=/var/log/console.log", argValue(args, "-chardev"))
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
}
func TestExecute_SerialPortsRenderListeningSocketsAndPorts(t *testing.T) {
cfg := NewMicroVMConfig(MicroVMSpec{
Name: "lambda", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256,
ImageDir: "/img",
SerialPorts: []SerialPort{
{Name: "lambda.ctl", Socket: "/run/env/ctl.sock"},
{Name: "second", Socket: "/run/env/second.sock"},
},
})
cmd, err := cfg.Execute()
require.NoError(t, err)
args := cmd.Args[1:]
assert.Equal(t, []string{
"socket,id=vport0,path=/run/env/ctl.sock,server=on,wait=off",
"socket,id=vport1,path=/run/env/second.sock,server=on,wait=off",
}, allArgValues(args, "-chardev"))
devs := allArgValues(args, "-device")
assert.Equal(t, 1, strings.Count(strings.Join(devs, " "), "virtio-serial-pci"))
assert.Contains(t, devs, "virtio-serial-pci,id=vserial0")
assert.Contains(t, devs, "virtserialport,bus=vserial0.0,chardev=vport0,name=lambda.ctl")
assert.Contains(t, devs, "virtserialport,bus=vserial0.0,chardev=vport1,name=second")
}
func TestExecute_NoSerialPortsAddsNoVirtioSerial(t *testing.T) {
cfg := NewMicroVMConfig(MicroVMSpec{Name: "lb", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256, ImageDir: "/img"})
cmd, err := cfg.Execute()
require.NoError(t, err)
assert.NotContains(t, strings.Join(cmd.Args, " "), "virtio-serial")
assert.NotContains(t, strings.Join(cmd.Args, " "), "virtserialport")
}
func TestExecute_SerialPortRefusesOptionInjection(t *testing.T) {
for _, tc := range []struct {
port SerialPort
want string
}{
{SerialPort{Name: "ctl,id=x", Socket: "/run/ctl.sock"}, "must not contain a comma"},
{SerialPort{Name: "ctl", Socket: "/run/ctl.sock,server=off"}, "must not contain a comma"},
{SerialPort{Name: "", Socket: "/run/ctl.sock"}, "name and socket are required"},
{SerialPort{Name: "ctl", Socket: ""}, "name and socket are required"},
} {
cfg := NewMicroVMConfig(MicroVMSpec{
Name: "lambda", Architecture: "x86_64", VCPUs: 1, MemoryMiB: 256,
ImageDir: "/img", SerialPorts: []SerialPort{tc.port},
})
_, err := cfg.Execute()
require.Error(t, err, "port %+v", tc.port)
assert.Contains(t, err.Error(), tc.want, "port %+v", tc.port)
}
}