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
3 changes: 3 additions & 0 deletions .github/contributors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,6 @@ users:
Chennamma-Hotkar:
name: Chennamma Hotkar
email: channuhotkar@gmail.com
Anamika1608:
name: Anamika Aggarwal
email: anamikaagg18@gmail.com
1 change: 1 addition & 0 deletions .github/linters/urunc-dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Sharedfs
Syscalls
TUNSETGROUP
TUNSETOWNER
TUNSETPERSIST
TUNTAP
Timestamping
Tmpfs
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ 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 specified, urunc uses a per-container default under `/tmp`. When a custom path is set, urunc creates its parent directory; setting it to an invalid location (a file already exists on the path) makes the monitor fail to start. Currently used by Qemu. |
| `boot_mode` | string | (empty) | Optional boot mode. When set to `api`, urunc starts the monitor as a supervised child and triggers the guest's start through the monitor's control socket; when unset, the monitor boots from its command line as before. Currently only used by Qemu. |

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
Expand All @@ -125,6 +127,7 @@ default_memory_mb = 1024
default_vcpus = 4
path = "/usr/local/bin/qemu-system-x86_64"
data_path = "/usr/local/share/"
boot_mode = "api"

[monitors.firecracker]
default_memory_mb = 512
Expand Down
19 changes: 19 additions & 0 deletions pkg/network/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,35 @@ func createTapDevice(name string, mtu int, ownerUID, ownerGID uint32) (netlink.L
return nil, fmt.Errorf("failed to create tap device: %w", err)
}

// LinkAdd opened the tap device's fd and set TUNSETPERSIST, so the
// interface survives independently of any open fd. Nothing after the
// owner/group ioctl calls uses the fd (the remaining setup goes through
// netlink and the monitor attaches to the device by name with its own
// open), so close it right away instead of relying on a later exec to
// close it (O_CLOEXEC). A single-queue tap device only allows one
// attached fd at a time, so holding it open blocks the monitor from
// attaching if the caller does not exec away.
for _, tapFd := range tapLink.Fds {
err = unix.IoctlSetInt(int(tapFd.Fd()), unix.TUNSETOWNER, int(ownerUID))
if err != nil {
if closeErr := tapFd.Close(); closeErr != nil {
netlog.Warnf("failed to close tap %s fd after owner ioctl error: %v", name, closeErr)
}
return nil, fmt.Errorf("failed to set tap %s owner to uid %d: %w", name, ownerUID, err)
}

err = unix.IoctlSetInt(int(tapFd.Fd()), unix.TUNSETGROUP, int(ownerGID))
if err != nil {
if closeErr := tapFd.Close(); closeErr != nil {
netlog.Warnf("failed to close tap %s fd after group ioctl error: %v", name, closeErr)
}
return nil, fmt.Errorf("failed to set tap %s group to gid %d: %w", name, ownerGID, err)
}

err = tapFd.Close()
if err != nil {
return nil, fmt.Errorf("failed to close the fd of tap %s: %w", name, err)
}
}

err = netlink.LinkSetMTU(tapLink, mtu)
Expand Down
121 changes: 121 additions & 0 deletions pkg/unikontainers/hypervisors/qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@
package hypervisors

import (
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"syscall"
"time"

"github.com/urunc-dev/urunc/pkg/unikontainers/types"
"golang.org/x/sys/unix"
Expand Down Expand Up @@ -67,6 +73,10 @@ 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 so the runtime can talk to QEMU after boot
// (e.g. for graceful shutdown). server,nowait lets QEMU boot without
// waiting for a client to connect.
cmdString += " -qmp unix:" + ResolveSocketPath(args) + ",server,nowait"

if args.VCPUs > 0 {
cmdString += fmt.Sprintf(" -smp %d", args.VCPUs)
Expand Down Expand Up @@ -152,6 +162,117 @@ func (q *Qemu) PreExec(_ types.ExecArgs) error {
return nil
}

// QemuSession is a QEMU child process started with its CPUs frozen (-S) and
// controlled over its QMP socket. Create it with SpawnPausedVMM, start the
// guest with Resume once the caller's start handshake allows it, then hand
// the calling process over with Supervise.
type QemuSession struct {
cmd *exec.Cmd
client *qmpClient
}

// SpawnPausedVMM starts QEMU as a supervised child with the guest frozen (-S)
// and performs the QMP handshake over the control socket. It is called after
// changeRoot, so the child inherits the pivoted root and its QMP socket lives
// inside the monitor rootfs. The caller is still privileged here and only
// drops its own privileges afterwards, so when uid/gid are non-zero the child
// is started directly under that credential.
func (q *Qemu) SpawnPausedVMM(args types.ExecArgs, ukernel types.Unikernel, uid, gid uint32) (*QemuSession, error) {
execCmd, err := q.BuildExecCmd(args, ukernel)
if err != nil {
return nil, err
}
execCmd = append(execCmd, "-S")

socketPath := ResolveSocketPath(args)
// QEMU binds the QMP socket itself; a stale file left from an earlier run
// would make its bind fail, so remove any leftover first.
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to remove stale socket %q: %w", socketPath, err)
}

cmd := exec.Command(execCmd[0], execCmd[1:]...) //nolint: gosec
cmd.Env = args.Environment
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if uid != 0 || gid != 0 {
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{Uid: uid, Gid: gid},
}
}
vmmLog.WithField("command", execCmd).Debug("starting QEMU as a paused supervised child")
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start qemu: %w", err)
}

client, err := connectQMP(socketPath, 5*time.Second)
if err != nil {
s := &QemuSession{cmd: cmd}
s.Kill()
return nil, err
}
return &QemuSession{cmd: cmd, client: client}, nil
}

// Resume unfreezes the guest CPUs; the guest boots from this moment.
func (s *QemuSession) Resume() error {
vmmLog.Debug("api boot: sending QMP cont")
return s.client.execute("cont")
}

// Kill terminates the child and reaps it. For error paths before Supervise.
func (s *QemuSession) Kill() {
if s.client != nil {
s.client.close()
}
_ = s.cmd.Process.Kill()
_, _ = s.cmd.Process.Wait()
}

// Supervise hands the calling process over to the child for the rest of its
// life: it forwards SIGTERM/SIGINT and, once the child exits, exits this
// process with the child's exit code, mirroring the semantics syscall.Exec
// would have had. The caller must not exit before the child, since it is
// the container's init process.
//
// On success this function does not return: it calls os.Exit with the
// child's exit status once the child exits.
func (s *QemuSession) Supervise() error {
// Forward the signals containerd would send to stop the container.
// SIGKILL cannot be caught, so it is not listed here: if it arrives,
// this process dies immediately and the child is left running, a known
// gap for this bounded experiment, not yet handled.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig, ok := <-sigCh
if !ok {
return
}
if sg, ok := sig.(syscall.Signal); ok {
_ = s.cmd.Process.Signal(sg)
}
}()

waitErr := s.cmd.Wait()
signal.Stop(sigCh)
close(sigCh)

exitCode := 0
if waitErr != nil {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
vmmLog.WithError(waitErr).Error("qemu exited with an unexpected error")
exitCode = 1
}
}
os.Exit(exitCode)
return nil // unreachable
}

func getVirtioNetArg() string {
devType := "virtio-net-pci"
if runtime.GOARCH == "arm64" {
Expand Down
123 changes: 123 additions & 0 deletions pkg/unikontainers/hypervisors/qemu_qmp_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 (
"bufio"
"encoding/json"
"fmt"
"net"
"time"
)

// qmpClient drives a running QEMU process over its QMP control socket
// (line-delimited JSON over a Unix socket). It implements only what urunc
// needs: the connection handshake with capability negotiation, and single
// commands such as cont for resuming a guest that was started frozen (-S).
type qmpClient struct {
conn net.Conn
rd *bufio.Reader
}

// connectQMP blocks until the QMP socket accepts a connection, then performs
// the mandatory handshake: read the server greeting, negotiate
// qmp_capabilities. QEMU binds the socket shortly after it starts, so the
// dial is retried tightly until the timeout elapses.
func connectQMP(socketPath string, timeout time.Duration) (*qmpClient, error) {
deadline := time.Now().Add(timeout)
var conn net.Conn
var lastErr error
for time.Now().Before(deadline) {
conn, lastErr = net.DialTimeout("unix", socketPath, 50*time.Millisecond)
if lastErr == nil {
break
}
time.Sleep(1 * time.Millisecond)
}
if conn == nil {
if lastErr == nil {
lastErr = fmt.Errorf("dial timed out after %s", timeout)
}
return nil, fmt.Errorf("qmp socket %q not ready within %s: %w", socketPath, timeout, lastErr)
}
c := &qmpClient{conn: conn, rd: bufio.NewReader(conn)}
// Bound the handshake itself: a dial succeeding does not guarantee QEMU
// keeps talking, so reads/writes below must not block forever.
_ = c.conn.SetDeadline(deadline)
// The server speaks first: nothing may be sent before its greeting is read.
greeting, err := c.readMessage()
if err != nil {
c.close()
return nil, fmt.Errorf("failed to read the QMP greeting: %w", err)
}
if _, ok := greeting["QMP"]; !ok {
c.close()
return nil, fmt.Errorf("unexpected QMP greeting: %v", greeting)
}
if err := c.execute("qmp_capabilities"); err != nil {
c.close()
return nil, err
}
_ = c.conn.SetDeadline(time.Time{})
return c, nil
}

// execute sends one argument-less QMP command and waits for its result.
func (c *qmpClient) execute(command string) error {
_ = c.conn.SetDeadline(time.Now().Add(5 * time.Second))
defer func() { _ = c.conn.SetDeadline(time.Time{}) }()

req, err := json.Marshal(map[string]string{"execute": command})
if err != nil {
return fmt.Errorf("failed to marshal QMP %s: %w", command, err)
}
if _, err := c.conn.Write(append(req, '\n')); err != nil {
return fmt.Errorf("failed to send QMP %s: %w", command, err)
}
resp, err := c.readMessage()
if err != nil {
return fmt.Errorf("failed to read the QMP response to %s: %w", command, err)
}
if errObj, ok := resp["error"]; ok {
return fmt.Errorf("QMP %s failed: %v", command, errObj)
}
if _, ok := resp["return"]; !ok {
return fmt.Errorf("unexpected QMP response to %s: %v", command, resp)
}
return nil
}

// readMessage returns the next QMP message that is not an asynchronous event.
// QEMU may interleave event lines (e.g. RESUME) with command responses.
func (c *qmpClient) readMessage() (map[string]any, error) {
for {
line, err := c.rd.ReadBytes('\n')
if err != nil {
return nil, err
}
var msg map[string]any
if err := json.Unmarshal(line, &msg); err != nil {
return nil, fmt.Errorf("invalid QMP message %q: %w", line, err)
}
if _, isEvent := msg["event"]; isEvent {
continue
}
return msg, nil
}
}

func (c *qmpClient) close() {
_ = c.conn.Close()
}
Loading