diff --git a/.github/linters/urunc-dict.txt b/.github/linters/urunc-dict.txt index 07a4855b5..7d4e24a05 100644 --- a/.github/linters/urunc-dict.txt +++ b/.github/linters/urunc-dict.txt @@ -421,3 +421,4 @@ ESRCH Prafful praffq libcontainers +nowait diff --git a/docs/configuration.md b/docs/configuration.md index a2daa4ba4..c74426427 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,11 +112,23 @@ Each monitor subsection supports the following options: | `default_vcpus` | integer | `1` | Default number of virtual CPUs | | `path` | string | (empty) | Optional custom path to the monitor binary. If not specified, urunc will search for the binary in PATH | | `data_path` | string | (empty) | Optional custom path for the monitor's data file directory | +| `socket_path` | string | (empty) | Optional path for the monitor's control socket. If not set, the monitor runs without a control socket | Since Qemu is the only currently supported monitor which requires extra data to boot a VM, `urunc` will first check `/usr/local/share` and then `/usr/share` for Qemu's data files. +The `socket_path` option applies to the monitors that expose a control socket: +Firecracker (its API socket), Qemu (a QMP socket) and Cloud Hypervisor (its REST +API socket). It has no effect on the other monitors. The control socket is +opt-in: it exists only when `socket_path` is set. If it is not set, the +monitor runs with no control socket at all; an operator can leave it unset if +they do not need the socket, or to keep a smaller attack surface. When it is +set, the monitor creates the socket inside its own (pivoted) rootfs; `urunc` +creates the directory of a custom `socket_path` there for you, so the path +can be anywhere. It fails cleanly if the location is invalid, for example +when a file already exists at one of the directories in the path. + **Example:** ```toml @@ -130,6 +142,7 @@ data_path = "/usr/local/share/" default_memory_mb = 512 default_vcpus = 2 path = "/opt/firecracker/firecracker" +socket_path = "/run/urunc/fc.sock" ``` ### Extra binaries Configuration diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor.go b/pkg/unikontainers/hypervisors/cloud_hypervisor.go index 606a3c02e..9f4c55007 100644 --- a/pkg/unikontainers/hypervisors/cloud_hypervisor.go +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor.go @@ -70,6 +70,13 @@ func (ch *CloudHypervisor) BuildExecCmd(args types.ExecArgs, ukernel types.Unike // Start building the command exArgs := []string{ch.binaryPath} + // Expose the REST API over a control socket only when a socket_path is + // configured, so the runtime can talk to Cloud Hypervisor after boot (e.g. + // for graceful shutdown). With no configured path no control socket is set up. + if args.SocketPath != "" { + exArgs = append(exArgs, "--api-socket", "path="+args.SocketPath) + } + // Memory configuration if args.Sharedfs.Type == "virtiofs" { exArgs = append(exArgs, "--memory", fmt.Sprintf("size=%sM,shared=on", chMem)) diff --git a/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go b/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go new file mode 100644 index 000000000..ceeebe50e --- /dev/null +++ b/pkg/unikontainers/hypervisors/cloud_hypervisor_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hypervisors + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" +) + +const testCHBinary = "/usr/bin/cloud-hypervisor" + +// TestCloudHypervisorBuildExecCmdSocket verifies that Cloud Hypervisor exposes +// its REST API control socket only when a socket_path is configured. With no +// configured path, no --api-socket flag is emitted. +func TestCloudHypervisorBuildExecCmdSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args types.ExecArgs + mustContain []string + mustNotContain []string + }{ + { + name: "configured SocketPath renders --api-socket on that path", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + SocketPath: "/run/urunc/ch.sock", + }, + mustContain: []string{"--api-socket path=/run/urunc/ch.sock"}, + }, + { + name: "unset SocketPath omits --api-socket", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + ContainerID: "abc123", + }, + mustNotContain: []string{"--api-socket"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ch := &CloudHypervisor{binary: CloudHypervisorBinary, binaryPath: testCHBinary} + out, err := ch.BuildExecCmd(tt.args, &fakeUnikernel{}) + assert.NoError(t, err) + assert.NotEmpty(t, out) + + assert.Equal(t, testCHBinary, out[0], "binary path must be the first element") + joined := strings.Join(out, " ") + + for _, want := range tt.mustContain { + assert.Contains(t, joined, want, "expected %q to be present", want) + } + for _, notWant := range tt.mustNotContain { + assert.NotContains(t, joined, notWant, "expected %q to be absent", notWant) + } + }) + } +} diff --git a/pkg/unikontainers/hypervisors/firecracker.go b/pkg/unikontainers/hypervisors/firecracker.go index 9588a45bf..13cbcfb7a 100644 --- a/pkg/unikontainers/hypervisors/firecracker.go +++ b/pkg/unikontainers/hypervisors/firecracker.go @@ -108,9 +108,21 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel // options in FC, since the string return value of the Monitor related // functions in the unikernel interface do not integrate well with FC's // json configuration. - cmdString := fc.Path() + " --no-api --config-file " + // Launch Firecracker in one of two modes, both booting the guest from the + // config file: + // - With a socket_path configured, enable the API socket + // (--api-sock ) so the control socket stays open for use after + // the guest has started. + // - With no socket_path, launch with --no-api (upstream default) so no + // control socket is exposed. JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename) - cmdString += JSONConfigFile + cmdString := fc.Path() + if args.SocketPath != "" { + cmdString += " --api-sock " + args.SocketPath + } else { + cmdString += " --no-api" + } + cmdString += " --config-file " + JSONConfigFile if !args.Seccomp { cmdString += " --no-seccomp" } diff --git a/pkg/unikontainers/hypervisors/firecracker_test.go b/pkg/unikontainers/hypervisors/firecracker_test.go new file mode 100644 index 000000000..556ff9d20 --- /dev/null +++ b/pkg/unikontainers/hypervisors/firecracker_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hypervisors + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urunc-dev/urunc/pkg/unikontainers/types" +) + +const testFCBinary = "/usr/bin/firecracker" + +// TestFirecrackerBuildExecCmdSocket verifies that Firecracker enables its API +// socket only when a socket_path is configured. With no configured path it +// restores the upstream launch mode (--no-api --config-file), which boots the +// guest from the config file without exposing a control socket. +func TestFirecrackerBuildExecCmdSocket(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args types.ExecArgs + mustContain []string + mustNotContain []string + }{ + { + name: "configured SocketPath renders --api-sock and keeps --config-file", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + SocketPath: "/run/urunc/fc.sock", + }, + mustContain: []string{"--api-sock /run/urunc/fc.sock", "--config-file"}, + mustNotContain: []string{"--no-api"}, + }, + { + name: "unset SocketPath restores --no-api and omits --api-sock", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + ContainerID: "abc123", + }, + mustContain: []string{"--no-api", "--config-file"}, + mustNotContain: []string{"--api-sock"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fc := &Firecracker{binary: FirecrackerBinary, binaryPath: testFCBinary} + out, err := fc.BuildExecCmd(tt.args, &fakeUnikernel{}) + assert.NoError(t, err) + assert.NotEmpty(t, out) + + assert.Equal(t, testFCBinary, out[0], "binary path must be the first element") + joined := strings.Join(out, " ") + + for _, want := range tt.mustContain { + assert.Contains(t, joined, want, "expected %q to be present", want) + } + for _, notWant := range tt.mustNotContain { + assert.NotContains(t, joined, notWant, "expected %q to be absent", notWant) + } + }) + } +} diff --git a/pkg/unikontainers/hypervisors/qemu.go b/pkg/unikontainers/hypervisors/qemu.go index 1ac77f870..456c3e098 100644 --- a/pkg/unikontainers/hypervisors/qemu.go +++ b/pkg/unikontainers/hypervisors/qemu.go @@ -67,6 +67,13 @@ func (q *Qemu) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel) ([]str cmdString += " -cpu host" // Choose CPU cmdString += " -enable-kvm" // Enable KVM to use CPU virt extensions cmdString += " -display none -vga none -serial stdio -monitor null" // Disable graphic output + // Expose a QMP control socket only when a socket_path is configured, so the + // runtime can talk to QEMU after boot (e.g. for graceful shutdown). With no + // configured path QEMU boots with no control socket. server,nowait lets QEMU + // boot without waiting for a client to connect. + if args.SocketPath != "" { + cmdString += " -qmp unix:" + args.SocketPath + ",server,nowait" + } if args.VCPUs > 0 { cmdString += fmt.Sprintf(" -smp %d", args.VCPUs) diff --git a/pkg/unikontainers/hypervisors/qemu_test.go b/pkg/unikontainers/hypervisors/qemu_test.go index fff45244c..e959165f4 100644 --- a/pkg/unikontainers/hypervisors/qemu_test.go +++ b/pkg/unikontainers/hypervisors/qemu_test.go @@ -92,8 +92,29 @@ func TestQemuBuildExecCmd(t *testing.T) { "vhost-user-fs-pci", "virtio-blk-pci", "vhost-vsock-pci", + "-qmp", }, }, + { + name: "configured SocketPath renders -qmp on that path", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + SocketPath: "/run/urunc/q.sock", + }, + unikernel: &fakeUnikernel{}, + mustContain: []string{"-qmp unix:/run/urunc/q.sock,server,nowait"}, + }, + { + name: "unset SocketPath omits -qmp", + args: types.ExecArgs{ + UnikernelPath: testKernelPath, + Command: testCommand, + ContainerID: "abc123", + }, + unikernel: &fakeUnikernel{}, + mustNotContain: []string{"-qmp"}, + }, { name: "custom MemSizeB renders -m in MB", args: types.ExecArgs{ diff --git a/pkg/unikontainers/hypervisors/vmm.go b/pkg/unikontainers/hypervisors/vmm.go index c8600957b..9633c6617 100644 --- a/pkg/unikontainers/hypervisors/vmm.go +++ b/pkg/unikontainers/hypervisors/vmm.go @@ -27,6 +27,17 @@ const DefaultMemory uint64 = 256 // The default memory for every hypervisor: 256 type VmmType string +// UsesControlSocket reports whether a monitor exposes a control socket whose +// path (socket_path) urunc must make reachable before the monitor launches. +func UsesControlSocket(vmmType VmmType) bool { + switch vmmType { + case FirecrackerVmm, QemuVmm, CloudHypervisorVmm: + return true + default: + return false + } +} + var ErrVMMNotInstalled = errors.New("vmm not found") var vmmLog = logrus.WithField("subsystem", "monitors") diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index c6388e2cc..1993681dd 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -106,6 +106,7 @@ type ExecArgs struct { VAccelType string // Specifies the vAccel acceleration type(e.g. vsock). When empty, vAccel is disabled VSockDevPath string // The host directory where the fc unix socket is created VSockDevID int // The guest-cid + SocketPath string // The path of the monitor's control socket (empty means no control socket) Net NetDevParams Sharedfs SharedfsParams } @@ -133,7 +134,8 @@ type ExtraBinConfig struct { type MonitorConfig struct { DefaultMemoryMB uint `toml:"default_memory_mb"` DefaultVCPUs uint `toml:"default_vcpus"` - BinaryPath string `toml:"path,omitempty"` // Optional path to the hypervisor binary - DataPath string `toml:"data_path,omitempty"` // Optional path to the hypervisor data files (e.g. qemu bios stuff) - Vhost bool `toml:"vhost,omitempty"` // Optional: enable vhost for network performance optimization + BinaryPath string `toml:"path,omitempty"` // Optional path to the hypervisor binary + DataPath string `toml:"data_path,omitempty"` // Optional path to the hypervisor data files (e.g. qemu bios stuff) + Vhost bool `toml:"vhost,omitempty"` // Optional: enable vhost for network performance optimization + SocketPath string `toml:"socket_path,omitempty"` // Optional path for the monitor's control socket (unset means no control socket) } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 84172aca4..c93082b96 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -402,6 +402,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { defaultVCPUs = 1 } defaultMemSizeMB := u.UruncCfg.Monitors[vmmType].DefaultMemoryMB + socketPath := u.UruncCfg.Monitors[vmmType].SocketPath // ExecArgs vmmArgs := types.ExecArgs{ @@ -411,6 +412,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { Seccomp: true, // Enable Seccomp by default MemSizeB: uint64(defaultMemSizeMB * 1024 * 1024), VCPUs: uint(defaultVCPUs), + SocketPath: socketPath, Environment: os.Environ(), } @@ -655,6 +657,22 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { return err } + // Set up the monitor's control socket, only when one is configured. + // This runs after setupUser so the directory and any stale socket are + // handled as the monitor's user, and the monitor (which may be non-root) + // can bind its socket there. + if hypervisors.UsesControlSocket(hypervisors.VmmType(vmmType)) && vmmArgs.SocketPath != "" { + sockDir := filepath.Dir(vmmArgs.SocketPath) + if err = os.MkdirAll(sockDir, 0o755); err != nil { + return fmt.Errorf("failed to create control socket directory %q: %w", sockDir, err) + } + // Remove a stale socket left by a previous instance (e.g. a restart + // reusing the same socket_path) so the monitor can bind it again. + if err = os.Remove(vmmArgs.SocketPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove stale control socket %q: %w", vmmArgs.SocketPath, err) + } + } + // execute hooks // NOTE: StartContainer hooks are supposed to run right before the init of // the container. However, in the case of a Linux-based container, the init diff --git a/pkg/unikontainers/urunc_config.go b/pkg/unikontainers/urunc_config.go index 22573f43c..3037e933a 100644 --- a/pkg/unikontainers/urunc_config.go +++ b/pkg/unikontainers/urunc_config.go @@ -145,6 +145,7 @@ func (p *UruncConfig) Map() map[string]string { cfgMap[prefix+"binary_path"] = hvCfg.BinaryPath cfgMap[prefix+"data_path"] = hvCfg.DataPath cfgMap[prefix+"vhost"] = strconv.FormatBool(hvCfg.Vhost) + cfgMap[prefix+"socket_path"] = hvCfg.SocketPath } for eb, ebCfg := range p.ExtraBins { prefix := "urunc_config.extra_binaries." + eb + "." @@ -191,6 +192,8 @@ func UruncConfigFromMap(cfgMap map[string]string) *UruncConfig { hvCfg.BinaryPath = val case "data_path": hvCfg.DataPath = val + case "socket_path": + hvCfg.SocketPath = val case "vhost": boolVal, err := strconv.ParseBool(val) if err != nil {