Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
811a606
feat(firecracker): launch with control socket instead of config file
Anamika1608 Jul 8, 2026
ae3bf54
feat(firecracker): allow configuring the control socket path
Anamika1608 Jul 11, 2026
9b4f4ff
docs(configuration): document the socket_path monitor option
Anamika1608 Jul 11, 2026
7efd0be
feat(firecracker): add boot_mode to choose config-file-based or API-b…
Anamika1608 Jul 13, 2026
a361b4c
feat(firecracker): drive API-based boot as a supervised child process
Anamika1608 Jul 13, 2026
095f46f
feat(firecracker): configure the VMM in stages while urunc sets up
Anamika1608 Jul 20, 2026
8a32f41
perf(firecracker): poll the control socket every 1ms instead of 10ms
Anamika1608 Jul 21, 2026
9837cf2
fix(firecracker): remove a stale control socket before spawning
Anamika1608 Jul 21, 2026
745e80e
refactor(firecracker): resolve the socket path via a shared helper
Anamika1608 Jul 25, 2026
180d70d
feat(firecracker): add UsesControlSocket to gate control-socket setup
Anamika1608 Jul 25, 2026
c743614
feat(firecracker): create the control socket directory
Anamika1608 Jul 25, 2026
9b38833
docs(configuration): describe socket_path directory creation
Anamika1608 Jul 25, 2026
a44a435
fix(firecracker): confine api-mode monitor in the monitor rootfs
Anamika1608 Jul 27, 2026
3bf7f50
test(firecracker): fix lint findings in the session test
Anamika1608 Jul 27, 2026
1bfc031
chore(firecracker): use plain ASCII in an exec comment
Anamika1608 Jul 27, 2026
4f9cf29
test(firecracker): cover the vsock config and refresh comments
Anamika1608 Jul 27, 2026
03896d2
fix(firecracker): pass stdin to the api-mode monitor
Anamika1608 Jul 28, 2026
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
4 changes: 4 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 only used by Firecracker. |
| `boot_mode` | string | `api` | Optional: `api` drives the monitor's boot over its control socket, `config-file` lets the monitor boot itself from a config file (socket only used afterward). Currently only used by Firecracker. |

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 @@ -130,6 +132,8 @@ data_path = "/usr/local/share/"
default_memory_mb = 512
default_vcpus = 2
path = "/opt/firecracker/firecracker"
socket_path = "/run/urunc/fc.sock"
boot_mode = "config-file"
```

### Extra binaries Configuration
Expand Down
17 changes: 17 additions & 0 deletions pkg/network/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ type UnikernelNetworkInfo struct {
EthDevice Interface
}
type Manager interface {
// HasNetwork does a cheap check for whether this container has a network
// interface to configure, without doing the more expensive tap device
// creation and configuration. It lets callers learn this fact early,
// before the full NetworkSetup (which does the expensive work) finishes.
HasNetwork() (bool, error)
NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkInfo, error)
}

Expand Down Expand Up @@ -113,6 +118,18 @@ func createTapDevice(name string, mtu int, ownerUID, ownerGID uint32) (netlink.L
}
}

// LinkAdd opens the tap device's file descriptor with O_CLOEXEC and sets
// TUNSETPERSIST, so the interface survives independent of any open fd.
// We don't need to keep it open past this point (the rest of setup, and
// whatever process later attaches to this tap by name, uses netlink/its
// own independent open, not this fd) - closing it now, rather than
// relying on a future exec to do it, matters because a process that
// keeps this fd open (instead of exec-ing away) would otherwise block
// anything else from opening the same single-queue tap device.
for _, tapFd := range tapLink.Fds {
_ = tapFd.Close()
}

err = netlink.LinkSetMTU(tapLink, mtu)
if err != nil {
return nil, fmt.Errorf("failed to set tap device MTU to %d: %w", mtu, err)
Expand Down
11 changes: 11 additions & 0 deletions pkg/network/network_dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ type DynamicNetwork struct {
// FIXME: CUrrently only one tap device per netns can provide functional networking. We need to find a proper way to handle networking
// for multiple unikernels in the same pod/network namespace.
// See: https://github.com/urunc-dev/urunc/issues/13
// HasNetwork checks, cheaply, whether a container network interface exists
// in the current netns, without creating the tap device or doing any of the
// other, more expensive setup NetworkSetup does.
func (n DynamicNetwork) HasNetwork() (bool, error) {
_, err := discoverContainerIface()
if err != nil {
return false, nil
}
return true, nil
}

func (n DynamicNetwork) NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkInfo, error) {
tapIndex, err := getTapIndex()
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions pkg/network/network_static.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ func setNATRule(iface string, sourceIP string) error {
return nil
}

// HasNetwork checks, cheaply, whether a container network interface exists
// in the current netns, without creating the tap device or doing any of the
// other, more expensive setup NetworkSetup does.
func (n StaticNetwork) HasNetwork() (bool, error) {
_, err := discoverContainerIface()
if err != nil {
return false, nil
}
return true, nil
}

func (n StaticNetwork) NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkInfo, error) {
newTapName := strings.ReplaceAll(DefaultTap, "X", "0")
addTCRules := false
Expand Down
217 changes: 203 additions & 14 deletions pkg/unikontainers/hypervisors/firecracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@
package hypervisors

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

"github.com/urunc-dev/urunc/pkg/unikontainers/types"
"golang.org/x/sys/unix"
Expand Down Expand Up @@ -108,13 +114,43 @@ 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 "
apiSockPath := ResolveSocketPath(args)
cmdString := fc.Path() + " --api-sock " + apiSockPath
JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename)
cmdString += JSONConfigFile
if args.BootMode == "config-file" {
// config-file-based: Firecracker boots itself from the JSON config file
// below; the socket stays open only for use after the guest is running.
cmdString += " --config-file " + JSONConfigFile
}
if !args.Seccomp {
cmdString += " --no-seccomp"
}

FCConfig := buildFirecrackerConfig(args, ukernel)
FCConfigJSON, err := json.Marshal(FCConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal Firecracker config: %w", err)
}
if err = os.WriteFile(JSONConfigFile, FCConfigJSON, 0o644); err != nil { //nolint: gosec
return nil, fmt.Errorf("failed to save Firecracker json config: %w", err)
}
vmmLog.WithField("Json", string(FCConfigJSON)).Debug("Firecracker json config")

exArgs := strings.Split(cmdString, " ")
return exArgs, nil
}

// PreExec performs pre-execution setup. Firecracker has no special pre-exec requirements.
func (fc *Firecracker) PreExec(_ types.ExecArgs) error {
return nil
}

// buildFirecrackerConfig builds the microVM configuration from args and
// ukernel. Used both to write the JSON config file (config-file-based boot)
// and to drive the same configuration over the API socket (API-based boot),
// so both paths always agree on what the guest actually gets configured
// with.
func buildFirecrackerConfig(args types.ExecArgs, ukernel types.Unikernel) *FirecrackerConfig {
// VM config for Firecracker
fcMem := DefaultMemory
if args.MemSizeB != 0 {
Expand Down Expand Up @@ -184,27 +220,180 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel
}
}

FCConfig := &FirecrackerConfig{
return &FirecrackerConfig{
Source: FCSource,
Machine: FCMachine,
Drives: FCDrives,
NetIfs: FCNet,
VSock: FCVSockDev,
}
FCConfigJSON, err := json.Marshal(FCConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal Firecracker config: %w", err)
}

// FirecrackerSession is a Firecracker child process configured over its API
// socket, one resource at a time. Create it with SpawnSocketVMM, send the
// configuration with the Configure* methods, boot the guest with StartGuest
// once the caller's start handshake allows it, then hand the calling process
// over with Supervise.
type FirecrackerSession struct {
cmd *exec.Cmd
client *firecrackerClient
}

// SpawnSocketVMM starts Firecracker as a child process with only its API
// socket enabled, and establishes the single persistent connection all later
// configuration stages use.
//
// This is called after changeRoot, so the child inherits the already-pivoted
// root: its control socket and every path it opens live inside the monitor
// rootfs, the same confinement the exec path gets. It also inherits the
// sandbox's network namespace, which Exec has already joined. The caller is
// still privileged here and only drops its own privileges just after
// (setupUser), so when uid/gid are non-zero the child is started directly
// under that credential.
func (fc *Firecracker) SpawnSocketVMM(args types.ExecArgs, uid, gid uint32) (*FirecrackerSession, error) {
socketPath := ResolveSocketPath(args)
// Firecracker binds this path itself; a stale socket file left from an
// earlier run (e.g. a crash) would make its bind fail with "address
// already in use", 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)
}
if err = os.WriteFile(JSONConfigFile, FCConfigJSON, 0o644); err != nil { //nolint: gosec
return nil, fmt.Errorf("failed to save Firecracker json config: %w", err)
execCmd := []string{fc.Path(), "--api-sock", socketPath}
if !args.Seccomp {
execCmd = append(execCmd, "--no-seccomp")
}
vmmLog.WithField("Json", string(FCConfigJSON)).Debug("Firecracker json config")

exArgs := strings.Split(cmdString, " ")
return exArgs, nil
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 Firecracker as a supervised child")
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start firecracker: %w", err)
}

client := newFirecrackerClient(socketPath)
if err := client.connect(5 * time.Second); err != nil {
s := &FirecrackerSession{cmd: cmd, client: client}
s.Kill()
return nil, fmt.Errorf("firecracker socket never became ready: %w", err)
}
return &FirecrackerSession{cmd: cmd, client: client}, nil
}

// PreExec performs pre-execution setup. Firecracker has no special pre-exec requirements.
func (fc *Firecracker) PreExec(_ types.ExecArgs) error {
return nil
// ConfigureMachine sends the vCPU/memory configuration. Nothing but the
// container spec is needed for this, so it is the first stage, sent right
// after the spawn.
func (s *FirecrackerSession) ConfigureMachine(ctx context.Context, args types.ExecArgs) error {
fcMem := DefaultMemory
if args.MemSizeB != 0 {
fcMem = bytesToMiB(args.MemSizeB)
if fcMem == 0 {
fcMem = DefaultMemory
}
}
machine := FirecrackerMachine{
VcpuCount: args.VCPUs,
MemSizeMiB: fcMem,
Smt: false,
TrackDirtyPages: false,
}
vmmLog.Debug("staged boot: sending machine-config")
return s.client.putMachineConfig(ctx, machine)
}

// ConfigureNetwork attaches the container's network interface. Firecracker
// opens the tap device during this call, so it must only run once the
// network setup that creates the tap has finished. No-op without a tap.
func (s *FirecrackerSession) ConfigureNetwork(ctx context.Context, net types.NetDevParams) error {
if net.TapDev == "" {
return nil
}
iface := FirecrackerNet{
IfaceID: "net1",
GuestMAC: net.MAC,
HostIF: net.TapDev,
}
vmmLog.Debug("staged boot: sending network-interfaces")
return s.client.putNetworkIface(ctx, iface)
}

// ConfigureGuest sends everything that depends on unikernel.Init having run:
// the block devices (their IDs/paths come from the unikernel's
// MonitorBlockCli), the boot source (its boot args are the unikernel command
// line, which bakes in the resolved network), and the vsock device if any.
// The child runs inside the monitor rootfs, so every path is sent exactly as
// the exec path would use it.
func (s *FirecrackerSession) ConfigureGuest(ctx context.Context, args types.ExecArgs, ukernel types.Unikernel) error {
cfg := buildFirecrackerConfig(args, ukernel)

vmmLog.Debug("staged boot: sending drives, boot-source and vsock")
if err := s.client.putDrives(ctx, cfg.Drives); err != nil {
return err
}
if err := s.client.putBootSource(ctx, cfg.Source); err != nil {
return err
}
return s.client.putVSock(ctx, cfg.VSock)
}

// StartGuest powers on the configured microVM.
func (s *FirecrackerSession) StartGuest(ctx context.Context) error {
vmmLog.Debug("staged boot: sending InstanceStart")
return s.client.startGuest(ctx)
}

// Kill terminates the child and reaps it. For error paths before Supervise.
func (s *FirecrackerSession) Kill() {
_ = 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 *FirecrackerSession) 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("firecracker exited with an unexpected error")
exitCode = 1
}
}
os.Exit(exitCode)
return nil // unreachable
}
Loading