Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/linters/urunc-dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,4 @@ hyperlight
Hyperlight
Odysseas
Kalaitsidis
nowait
24 changes: 24 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,34 @@ 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 `socket_path` inside that rootfs after it drops
privileges to the monitor's user, so the path is subject to two constraints:

- Its parent directory must be one the monitor's user can create and write. A
directory that only `root` can write does not work for a non-root monitor.
- It must not sit over an existing file or directory. `urunc` creates the
directory of `socket_path`; that fails cleanly if a file already exists at one
of the directories in the path. The monitor then binds the socket at
`socket_path`; that fails if a file already exists at `socket_path` itself.

`urunc` removes the socket when the container is stopped (on a terminating
signal) and when it is deleted, so a restart that reuses the same `socket_path`
does not find a stale socket.

**Example:**

```toml
Expand All @@ -130,6 +153,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
Expand Down
12 changes: 12 additions & 0 deletions pkg/unikontainers/hypervisors/cloud_hypervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ func (ch *CloudHypervisor) UsesKVM() bool {
return true
}

// SupportsControlSocket reports that Cloud Hypervisor exposes a control socket
// (its REST API socket).
func (ch *CloudHypervisor) SupportsControlSocket() bool {
return true
}

// SupportsSharedfs returns true as Cloud Hypervisor supports virtiofs
func (ch *CloudHypervisor) SupportsSharedfs(fsType string) bool {
switch fsType {
Expand All @@ -70,6 +76,12 @@ func (ch *CloudHypervisor) BuildExecCmd(args types.ExecArgs, ukernel types.Unike
// Start building the command
exArgs := []string{ch.binaryPath}

// Expose the REST API socket only when socket_path is set, so the runtime
// can talk to Cloud Hypervisor after boot.
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))
Expand Down
78 changes: 78 additions & 0 deletions pkg/unikontainers/hypervisors/cloud_hypervisor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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 Cloud Hypervisor emits
// --api-socket only when socket_path is set.
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)
}
})
}
}
17 changes: 15 additions & 2 deletions pkg/unikontainers/hypervisors/firecracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ func (fc *Firecracker) SupportsSharedfs(_ string) bool {
return false
}

// SupportsControlSocket reports that Firecracker exposes a control socket (its API
// socket).
func (fc *Firecracker) SupportsControlSocket() bool {
return true
}

func (fc *Firecracker) Path() string {
return fc.binaryPath
}
Expand All @@ -108,9 +114,16 @@ 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 "
JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename)
cmdString += JSONConfigFile
cmdString := fc.Path()
// Enable the API socket when a socket_path is configured, otherwise keep the
// upstream --no-api. Either way the guest boots from the config file.
if args.SocketPath != "" {
cmdString += " --api-sock " + args.SocketPath
} else {
cmdString += " --no-api"
}
cmdString += " --config-file " + JSONConfigFile
if !args.Seccomp {
cmdString += " --no-seccomp"
}
Expand Down
80 changes: 80 additions & 0 deletions pkg/unikontainers/hypervisors/firecracker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 Firecracker enables --api-sock
// only when socket_path is set, and restores --no-api otherwise.
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)
}
})
}
}
5 changes: 5 additions & 0 deletions pkg/unikontainers/hypervisors/hedge.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ func (h *Hedge) SupportsSharedfs(_ string) bool {
return false
}

// SupportsControlSocket reports that Hedge exposes no control socket.
func (h *Hedge) SupportsControlSocket() bool {
return false
}

func (h *Hedge) Path() string {
return ""
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/unikontainers/hypervisors/hvt.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ func (h *HVT) SupportsSharedfs(_ string) bool {
return false
}

// SupportsControlSocket reports that HVT exposes no control socket.
func (h *HVT) SupportsControlSocket() bool {
return false
}

// Path returns the path to the hvt binary.
func (h *HVT) Path() string {
return h.binaryPath
Expand Down
5 changes: 5 additions & 0 deletions pkg/unikontainers/hypervisors/hyperlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ func (h *Hyperlight) SupportsSharedfs(_ string) bool {
return false
}

// SupportsControlSocket reports that Hyperlight exposes no control socket.
func (h *Hyperlight) SupportsControlSocket() bool {
return false
}

// Path returns the path to the hyperlight binary.
func (h *Hyperlight) Path() string {
return h.binaryPath
Expand Down
10 changes: 10 additions & 0 deletions pkg/unikontainers/hypervisors/qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func (q *Qemu) SupportsSharedfs(_ string) bool {
return true
}

// SupportsControlSocket reports that QEMU exposes a control socket (QMP).
func (q *Qemu) SupportsControlSocket() bool {
return true
}

func (q *Qemu) Path() string {
return q.binaryPath
}
Expand All @@ -67,6 +72,11 @@ 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 the QMP socket only when socket_path is set. server,nowait lets
// QEMU boot without waiting for a client.
if args.SocketPath != "" {
cmdString += " -qmp unix:" + args.SocketPath + ",server,nowait"
}

if args.VCPUs > 0 {
cmdString += fmt.Sprintf(" -smp %d", args.VCPUs)
Expand Down
21 changes: 21 additions & 0 deletions pkg/unikontainers/hypervisors/qemu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
5 changes: 5 additions & 0 deletions pkg/unikontainers/hypervisors/spt.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ func (s *SPT) SupportsSharedfs(_ string) bool {
return false
}

// SupportsControlSocket reports that SPT exposes no control socket.
func (s *SPT) SupportsControlSocket() bool {
return false
}

// Path returns the path to the spt binary.
func (s *SPT) Path() string {
return s.binaryPath
Expand Down
Loading