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 @@ -421,3 +421,4 @@ ESRCH
Prafful
praffq
libcontainers
nowait
13 changes: 13 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove "for you", the documentation should use third person only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the path can not be really anywhere. It should be in a directory that is accessible from all users (because we create it after user setup) and it should not be over existing files/directories.

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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions pkg/unikontainers/hypervisors/cloud_hypervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
79 changes: 79 additions & 0 deletions pkg/unikontainers/hypervisors/cloud_hypervisor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
16 changes: 14 additions & 2 deletions pkg/unikontainers/hypervisors/firecracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>) 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: comment too verbose without any benefit and in the wrong place. It should be before the if else

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"
}
Expand Down
82 changes: 82 additions & 0 deletions pkg/unikontainers/hypervisors/firecracker_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
7 changes: 7 additions & 0 deletions pkg/unikontainers/hypervisors/qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
11 changes: 11 additions & 0 deletions pkg/unikontainers/hypervisors/vmm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should replace this with a specific call to each VMM interface implementation. If we add a new monitor in the future, we will definitely forget to update this. As an example check #850

switch vmmType {
case FirecrackerVmm, QemuVmm, CloudHypervisorVmm:
return true
default:
return false
}
}

var ErrVMMNotInstalled = errors.New("vmm not found")
var vmmLog = logrus.WithField("subsystem", "monitors")

Expand Down
8 changes: 5 additions & 3 deletions pkg/unikontainers/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
18 changes: 18 additions & 0 deletions pkg/unikontainers/unikontainers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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(),
}

Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can be stricter here and use 0o700

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of the socket should not take place here, but in delete.

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
Expand Down
3 changes: 3 additions & 0 deletions pkg/unikontainers/urunc_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "."
Expand Down Expand Up @@ -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 {
Expand Down