From 44f2f95f28577ebff2ea8943aeafa91e278e14f4 Mon Sep 17 00:00:00 2001 From: Sankalp Date: Tue, 23 Dec 2025 17:04:03 +0000 Subject: [PATCH] feat(mirage): add dynamic device naming via manifest and annotations Previously, urunc hardcoded MirageOS network interfaces to 'service' and block devices to 'storage'. This caused failures for unikernels that defined different interface names in their Solo5 manifest. This commit introduces dynamic device discovery and mapping: - Parsed .note.solo5.manifest from ELF binary for auto-detection. - Added support for urunc.dev/mirage-net-map annotations. - Updated UnikernelParams to pass binary path and annotations. This ensures compatibility with diverse MirageOS unikernels. Fixes: #315 Signed-off-by: Sankalp --- .github/linters/urunc-dict.txt | 1 + pkg/unikontainers/types/types.go | 20 +- pkg/unikontainers/unikernels/mirage.go | 269 ++++++++++++++++++++++--- pkg/unikontainers/unikontainers.go | 29 +-- 4 files changed, 275 insertions(+), 44 deletions(-) diff --git a/.github/linters/urunc-dict.txt b/.github/linters/urunc-dict.txt index 40ab757f..5ade2c58 100644 --- a/.github/linters/urunc-dict.txt +++ b/.github/linters/urunc-dict.txt @@ -390,3 +390,4 @@ Liaskovitis Mallikopoulou Medfouni Khitem +namesz \ No newline at end of file diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index 85b1b49c..0ffc3fa0 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -69,15 +69,17 @@ type ProcessConfig struct { // UnikernelParams holds the data required to build the unikernels commandline type UnikernelParams struct { - CmdLine []string // The cmdline provided by the image - EnvVars []string // The environment variables provided by the image - Monitor string // The monitor where guest will execute - Version string // The version of the unikernel - InitrdPath string // The path to the initrd of the unikernel - Net NetDevParams - Block []BlockDevParams - Rootfs RootfsParams // Information about rootfs - ProcConf ProcessConfig // Information for the process execution inside the guest + CmdLine []string // The cmdline provided by the image + EnvVars []string // The environment variables provided by the image + Monitor string // The monitor where guest will execute + Version string // The version of the unikernel + InitrdPath string // The path to the initrd of the unikernel + Net NetDevParams + Block []BlockDevParams + Rootfs RootfsParams // Information about rootfs + ProcConf ProcessConfig // Information for the process execution inside the guest + UnikernelPath string + Annotations map[string]string } // ExecArgs holds the data required by Execve to start the VMM diff --git a/pkg/unikontainers/unikernels/mirage.go b/pkg/unikontainers/unikernels/mirage.go index badfa955..d5e2a8b8 100644 --- a/pkg/unikontainers/unikernels/mirage.go +++ b/pkg/unikontainers/unikernels/mirage.go @@ -15,89 +15,312 @@ package unikernels import ( + "bytes" + "debug/elf" + "encoding/binary" + "encoding/json" "fmt" + "regexp" "strings" "github.com/urunc-dev/urunc/pkg/unikontainers/types" ) -const MirageUnikernel string = "mirage" +const ( + // MirageUnikernel is the unikernel type for MirageOS + MirageUnikernel string = "mirage" + // AnnotationNetMap is the annotation key for network device mapping + AnnotationNetMap string = "urunc.dev/mirage-net-map" + // AnnotationBlockMap is the annotation key for block device mapping + AnnotationBlockMap string = "urunc.dev/mirage-block-map" +) + +// Constants from solo5 mft_abi.h +const ( + mftDevBlockBasic = 0 + mftDevNetBasic = 1 + mftNameMax = 64 +) +// Regular expression to validate device names (alphanumeric + underscore) +var validDeviceName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) + +// Mirage represents a MirageOS unikernel configuration type Mirage struct { - Command string - Monitor string - Net MirageNet - Block []MirageBlock + Command string + Monitor string + Net MirageNet + Block []MirageBlock + BinaryPath string + Manifest *Solo5Manifest + Annotations map[string]string } +// Solo5Manifest represents the parsed devices from the unikernel binary +type Solo5Manifest struct { + Devices []Solo5Device +} + +// Solo5Device represents a single device entry in the manifest +type Solo5Device struct { + Name string + Type string +} + +// MftHeader matches the 12-byte header found in the binary. +// 0x00: Pad/Reserved (4) +// 0x04: Version (4) +// 0x08: Entries (4) +type MftHeader struct { + Pad uint32 + Version uint32 + Entries uint32 +} + +// MftEntry matches the 104-byte stride. +// Name (65) + Pad (7) = 72 +// Type (8) = 80 +// Flags (8) = 88 +// Pad2 (16) = 104 +// NOTE: All fields MUST be exported (Capitalized) for binary.Read to work. +type MftEntry struct { + Name [mftNameMax + 1]byte + Pad [7]byte // Exported to avoid panic in binary.Read + Type uint64 + Flags uint64 + Pad2 [16]byte // Exported to avoid panic in binary.Read +} + +// MirageNet holds network configuration type MirageNet struct { Address string Gateway string } +// MirageBlock holds block device configuration type MirageBlock struct { ID string HostPath string } +// CommandString returns the command line arguments for the unikernel func (m *Mirage) CommandString() (string, error) { return fmt.Sprintf("%s %s %s", m.Net.Address, m.Net.Gateway, m.Command), nil } +// SupportsBlock returns true as Mirage supports block devices func (m *Mirage) SupportsBlock() bool { return true } +// SupportsFS returns false as Mirage does not support filesystem passthrough func (m *Mirage) SupportsFS(_ string) bool { return false } +// MonitorNetCli returns the network arguments for the monitor func (m *Mirage) MonitorNetCli(ifName string, mac string) string { switch m.Monitor { case "hvt", "spt": - netOption := "--net:service=" + ifName - netOption += " --net-mac:service=" + mac + mirageID := m.getMirageDeviceName(ifName, "NET_BASIC", "service") + + netOption := fmt.Sprintf("--net:%s=%s", mirageID, ifName) + netOption += fmt.Sprintf(" --net-mac:%s=%s", mirageID, mac) return netOption default: return "" } } +// MonitorBlockCli returns the block device arguments for the monitor func (m *Mirage) MonitorBlockCli() []types.MonitorBlockArgs { if len(m.Block) == 0 { return nil } switch m.Monitor { case "hvt", "spt": - // TODO: Explore options for multiple block devices in MirageOS - // over Solo5-spt and Solo5-hvt. Solo5 expects to use as an ID - // a specific name which the guest is also aware of in order to - // attach the respective block. As a result, urunc needs to know - // the correct ID to set, which is not straightforward. Therefore, - // there are two options. Either we read the Solo5 manifest or, - // we require specific IDs. Till we decide about that, we will - // use a single block device. We also need to find some use cases - // where multiple block devices are configured in MirageOS and check - // how MirageOS handles/configures them. - return []types.MonitorBlockArgs{ - { - ID: "storage", - Path: m.Block[0].HostPath, - }, + var blockArgs []types.MonitorBlockArgs + + for i, blk := range m.Block { + defaultName := "storage" + if i > 0 { + defaultName = fmt.Sprintf("storage%d", i) + } + + mirageID := m.getMirageDeviceName(blk.ID, "BLOCK_BASIC", defaultName) + + blockArgs = append(blockArgs, types.MonitorBlockArgs{ + ID: mirageID, + Path: blk.HostPath, + }) } + return blockArgs default: return nil } } +// MonitorCli returns general monitor arguments func (m *Mirage) MonitorCli() types.MonitorCliArgs { return types.MonitorCliArgs{} } +// getMirageDeviceName determines the correct Solo5 interface name. +func (m *Mirage) getMirageDeviceName(hostDev string, devType string, defaultName string) string { + resolvedName := "" + + // 1. Check Annotations first + var mapKey string + if devType == "NET_BASIC" { + mapKey = AnnotationNetMap + } else { + mapKey = AnnotationBlockMap + } + + if val, ok := m.Annotations[mapKey]; ok { + var mapping map[string]string + if err := json.Unmarshal([]byte(val), &mapping); err == nil { + if mirageName, found := mapping[hostDev]; found { + resolvedName = mirageName + } + } + } + + // 2. If not found in annotations, check Manifest (Auto-detect if only one device exists) + if resolvedName == "" && m.Manifest != nil { + var validDevs []string + for _, dev := range m.Manifest.Devices { + if dev.Type == devType { + validDevs = append(validDevs, dev.Name) + } + } + if len(validDevs) == 1 { + resolvedName = validDevs[0] + } + } + + // 3. Fallback + if resolvedName == "" { + resolvedName = defaultName + } + + // Security check: ensure the name is safe + if !validDeviceName.MatchString(resolvedName) { + // Log warning here if logger is available + return defaultName + } + return resolvedName +} + +func parseSolo5ManifestData(data []byte) (*Solo5Manifest, error) { + // Offset logic based on binary analysis: + // 0x00-0x13: ELF Note Header + // 0x14: Start of MftHeader (Pad=0, Ver=1, Ent=2) + + var offset int64 + + // Heuristic to skip ELF Note Header + if len(data) > 12 { + var namesz uint32 + // FIX: Checked error return value (errcheck warning) + if err := binary.Read(bytes.NewReader(data[0:4]), binary.LittleEndian, &namesz); err != nil { + // If we can't even read 4 bytes, just ignore this check and use offset 0 + } else { + // If namesz is 6 ("Solo5\0"), skip header. + // Header = 12 + aligned(namesz) + if namesz == 6 { + offset = int64(12 + ((namesz + 3) &^ 3)) + } + } + } + + r := bytes.NewReader(data) + if _, err := r.Seek(offset, 0); err != nil { + return nil, err + } + + var header MftHeader + if err := binary.Read(r, binary.LittleEndian, &header); err != nil { + return nil, fmt.Errorf("failed to read mft header: %v", err) + } + + // Sanity Check + if header.Version != 1 { + return nil, fmt.Errorf("invalid manifest version: %d (expected 1)", header.Version) + } + + manifest := &Solo5Manifest{} + + for i := 0; i < int(header.Entries); i++ { + var entry MftEntry + if err := binary.Read(r, binary.LittleEndian, &entry); err != nil { + break + } + + // Clean C-String (remove null bytes) + nameLen := bytes.IndexByte(entry.Name[:], 0) + if nameLen < 0 { + nameLen = len(entry.Name) + } + cleanName := string(entry.Name[:nameLen]) + + // Skip empty names (often internal devices) + if cleanName == "" { + continue + } + + var devType string + switch entry.Type { + + case mftDevNetBasic: + devType = "NET_BASIC" + case mftDevBlockBasic: + devType = "BLOCK_BASIC" + default: + devType = fmt.Sprintf("UNKNOWN_%d", entry.Type) + } + + manifest.Devices = append(manifest.Devices, Solo5Device{ + Name: cleanName, + Type: devType, + }) + } + + return manifest, nil +} + +// getSolo5Manifest extracts the .note.solo5.manifest section from the ELF +func getSolo5Manifest(path string) (*Solo5Manifest, error) { + f, err := elf.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + section := f.Section(".note.solo5.manifest") + if section == nil { + return nil, fmt.Errorf("no solo5 manifest section found") + } + + data, err := section.Data() + if err != nil { + return nil, err + } + + return parseSolo5ManifestData(data) +} + +// Init initializes the Mirage configuration func (m *Mirage) Init(data types.UnikernelParams) error { - // if Mask is empty, there is no network support + + m.BinaryPath = data.UnikernelPath + m.Annotations = data.Annotations + if manifest, err := getSolo5Manifest(m.BinaryPath); err == nil { + m.Manifest = manifest + } + if data.Net.Mask != "" { m.Net.Address = "--ipv4=" + data.Net.IP + "/24" m.Net.Gateway = "--ipv4-gateway=" + data.Net.Gateway diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 7d4136b8..a4c3dd5f 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -43,8 +43,8 @@ import ( ) const ( - monitorRootfsDirName string = "monRootfs" - containerRootfsMountPath string = "/cntrRootfs" + monitorRootfsDirName = "monRootfs" + containerRootfsMountPath = "/cntrRootfs" ) var uniklog = logrus.WithField("subsystem", "unikontainers") @@ -283,11 +283,13 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { // UnikernelParams // populate unikernel params unikernelParams := types.UnikernelParams{ - CmdLine: u.Spec.Process.Args, - EnvVars: u.Spec.Process.Env, - Monitor: vmmType, - Version: unikernelVersion, - ProcConf: procAttrs, + CmdLine: u.Spec.Process.Args, + EnvVars: u.Spec.Process.Env, + Monitor: vmmType, + Version: unikernelVersion, + ProcConf: procAttrs, + UnikernelPath: unikernelPath, + Annotations: u.Spec.Annotations, } if len(unikernelParams.CmdLine) == 0 { unikernelParams.CmdLine = strings.Fields(u.State.Annotations[annotCmdLine]) @@ -510,9 +512,10 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { func setupUser(user specs.User) error { runtime.LockOSThread() // Set the user for the current go routine to exec the Monitor - AddGidsLen := len(user.AdditionalGids) - if AddGidsLen > 0 { - err := unix.Setgroups(convertUint32ToIntSlice(user.AdditionalGids, AddGidsLen)) + + addGidsLen := len(user.AdditionalGids) + if addGidsLen > 0 { + err := unix.Setgroups(convertUint32ToIntSlice(user.AdditionalGids, addGidsLen)) if err != nil { return fmt.Errorf("could not set Additional groups %v : %v", user.AdditionalGids, err) } @@ -635,7 +638,8 @@ func (u *Unikontainer) Delete() error { // joinSandboxNetns joins the network namespace of the sandbox // This function should be called only from a locked thread // (i.e. runtime. LockOSThread()) -func (u Unikontainer) joinSandboxNetNs() error { + +func (u *Unikontainer) joinSandboxNetNs() error { netNsPath, err := findNS(u.Spec.Linux.Namespaces, specs.NetworkNamespace) if err != nil && !errors.Is(err, ErrNotExistingNS) { return err @@ -1131,7 +1135,8 @@ func (u *Unikontainer) isRunning() bool { } // getNetworkType checks if current container is a knative user-container -func (u Unikontainer) getNetworkType() string { +// FIX: Changed to pointer receiver for consistency +func (u *Unikontainer) getNetworkType() string { if u.Spec.Annotations["io.kubernetes.cri.container-name"] == "user-container" { return "static" }