From 90cba194cc4173482b83ef4ad0da03d327535b18 Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:17:21 +0000 Subject: [PATCH 1/9] fix(linux): pass the application command to urunit in its config The Linux kernel command line cannot carry an arbitrary argument list: an empty argument disappears, a literal `--` cuts off the rest, the list is truncated at 2048 bytes and more than 32 arguments panic the kernel. When urunit is the init, write the application command in the urunit configuration as `ARC`/`ARV` entries, as FreeBSD guests already do, and stop passing it on the kernel command line. Also return a clear error when an argument contains a newline. This fix requires a urunit that reads `ARC`/`ARV`. Fixes: #1067 Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- pkg/unikontainers/unikernels/linux.go | 22 +++++- pkg/unikontainers/unikernels/linux_test.go | 82 ++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 pkg/unikontainers/unikernels/linux_test.go diff --git a/pkg/unikontainers/unikernels/linux.go b/pkg/unikontainers/unikernels/linux.go index 69fc17edc..6cdc833e9 100644 --- a/pkg/unikontainers/unikernels/linux.go +++ b/pkg/unikontainers/unikernels/linux.go @@ -42,6 +42,7 @@ const ( type Linux struct { App string Command string + Args []string // application command, verbatim, for urunit Monitor string Env []string Net LinuxNet @@ -123,7 +124,11 @@ func (l *Linux) CommandString() (string, error) { bootParams += " URUNIT_DEFROUTE=1" } if l.App != "" { - initParams := rdinit + "init=" + l.App + " -- " + l.Command + initParams := rdinit + "init=" + l.App + // urunit takes the application command from its configuration + if !l.InitrdConf { + initParams += " -- " + l.Command + } bootParams += " " + initParams } @@ -275,6 +280,7 @@ func (l *Linux) parseCmdLine(cmdLine []string) error { } l.App = normalizedArgs[0] + l.Args = cmdLine[1:] if len(normalizedArgs) > 1 { l.Command = strings.Join(normalizedArgs[1:], " ") } else { @@ -293,6 +299,12 @@ func (l *Linux) configureNetwork(net types.NetDevParams) { // setupUrunitConfig creates the urunit configuration file with environment variables. func (l *Linux) setupUrunitConfig(rfs types.RootfsParams) error { + // The configuration is line based, so an argument cannot hold a newline + for _, arg := range l.Args { + if strings.Contains(arg, "\n") { + return fmt.Errorf("failed to setup urunit config: argument %q contains a newline", arg) + } + } urunitConfig := l.buildUrunitConfig() var err error @@ -337,6 +349,14 @@ func (l *Linux) buildUrunitConfig() string { sb.WriteString("WD:") sb.WriteString(l.ProcConfig.WorkDir) sb.WriteString("\n") + sb.WriteString("ARC:") + sb.WriteString(strconv.Itoa(len(l.Args))) + sb.WriteString("\n") + for _, arg := range l.Args { + sb.WriteString("ARV:") + sb.WriteString(arg) + sb.WriteString("\n") + } sb.WriteString(lpcEndMarker) sb.WriteString("\n") sb.WriteString(blkStartMarker) diff --git a/pkg/unikontainers/unikernels/linux_test.go b/pkg/unikontainers/unikernels/linux_test.go new file mode 100644 index 000000000..0d92a5d4b --- /dev/null +++ b/pkg/unikontainers/unikernels/linux_test.go @@ -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 unikernels + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLinuxUrunitArgs covers the arguments that the kernel command line +// mangles: an empty argument and a literal "--". +func TestLinuxUrunitArgs(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cmdLine []string + expected string + }{ + { + name: "empty argument", + cmdLine: []string{"/urunit", "/app", "a", "", "b"}, + expected: "ARC:4\nARV:/app\nARV:a\nARV:\nARV:b\nUCE\n", + }, + { + name: "literal --", + cmdLine: []string{"/urunit", "/app", "a", "--", "b", "c"}, + expected: "ARC:5\nARV:/app\nARV:a\nARV:--\nARV:b\nARV:c\nUCE\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + l := &Linux{InitrdConf: true, RootFsType: "virtiofs"} + require.NoError(t, l.parseCmdLine(tc.cmdLine)) + assert.Contains(t, l.buildUrunitConfig(), tc.expected) + + cmd, err := l.CommandString() + require.NoError(t, err) + assert.True(t, strings.HasSuffix(cmd, " init=/urunit"), "unexpected command line: %s", cmd) + }) + } +} + +// TestLinuxUrunitLongArgs covers an argument list longer than the kernel +// command line (2048 bytes) with more than 32 arguments. +func TestLinuxUrunitLongArgs(t *testing.T) { + t.Parallel() + + cmdLine := []string{"/urunit", "/app"} + for i := range 40 { + cmdLine = append(cmdLine, strconv.Itoa(i)+strings.Repeat("x", 100)) + } + l := &Linux{InitrdConf: true, RootFsType: "virtiofs"} + require.NoError(t, l.parseCmdLine(cmdLine)) + conf := l.buildUrunitConfig() + + assert.Contains(t, conf, "\nARC:41\n") + assert.Equal(t, 41, strings.Count(conf, "\nARV:")) + assert.Contains(t, conf, "\nARV:"+cmdLine[41]+"\nUCE\n") + + cmd, err := l.CommandString() + require.NoError(t, err) + assert.True(t, strings.HasSuffix(cmd, " init=/urunit"), "unexpected command line: %s", cmd) +} From 0282936be878dbd5bc4fc37a6bb04d52648f39b4 Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:17:21 +0000 Subject: [PATCH 2/9] chore: add indrajithek to contributors Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- .github/contributors.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/contributors.yaml b/.github/contributors.yaml index ee3ba7e4a..6716b95a6 100644 --- a/.github/contributors.yaml +++ b/.github/contributors.yaml @@ -161,3 +161,6 @@ users: Inuth0603: name: Divyam Agarwal email: iitdivyamagarwal@gmail.com + indrajithek: + name: Indrajith + email: 29561593+indrajithek@users.noreply.github.com From 6dd22f1cb368d83cce71d98aea52090b4722ee20 Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:17:21 +0000 Subject: [PATCH 3/9] docs: describe the application command in the urunit config Add the `ARC`/`ARV` entries to the documented urunit configuration format and update the list of actions urunit performs. Ref: #1067 Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- docs/tutorials/existing-container-linux.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/tutorials/existing-container-linux.md b/docs/tutorials/existing-container-linux.md index 0dde898de..620dd9aea 100644 --- a/docs/tutorials/existing-container-linux.md +++ b/docs/tutorials/existing-container-linux.md @@ -77,7 +77,7 @@ designed specifically for `urunc`. It performs the following actions: the container in a Kubernetes cluster, where there is a high chance that the gateway of the container might be in a different subnet than the IP. As a result, the Linux kernel will fail to set the gateway. -2. Groups multi-word arguments correctly. +2. Reads the application command and its arguments from the configuration. 3. Mounts `/proc` and `/sys`. 4. Reads and sets the environment variables for the application execution 5. Reads and sets the uid,gid and working directory for the application execution @@ -95,6 +95,8 @@ UCS UID: GID: WD: +ARC: +ARV: /* one line per argument */ UCE ``` From 1f63e920aef84c5180bb1782b50e5352564f51b6 Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:15:09 +0100 Subject: [PATCH 4/9] fix: remove the newline check from this PR Noticed #1068 is also addressing arguments containing a newline. Removing it from this PR to avoid conflicts. Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- pkg/unikontainers/unikernels/linux.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/unikontainers/unikernels/linux.go b/pkg/unikontainers/unikernels/linux.go index 6cdc833e9..35571bb82 100644 --- a/pkg/unikontainers/unikernels/linux.go +++ b/pkg/unikontainers/unikernels/linux.go @@ -299,12 +299,6 @@ func (l *Linux) configureNetwork(net types.NetDevParams) { // setupUrunitConfig creates the urunit configuration file with environment variables. func (l *Linux) setupUrunitConfig(rfs types.RootfsParams) error { - // The configuration is line based, so an argument cannot hold a newline - for _, arg := range l.Args { - if strings.Contains(arg, "\n") { - return fmt.Errorf("failed to setup urunit config: argument %q contains a newline", arg) - } - } urunitConfig := l.buildUrunitConfig() var err error From 5f7a6263a8c1ca1788d74b4c745e93dbd8bb536e Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:25:13 +0000 Subject: [PATCH 5/9] test: remove the urunit argument tests Remove the test cases created for #1136. Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- pkg/unikontainers/unikernels/linux_test.go | 82 ---------------------- 1 file changed, 82 deletions(-) delete mode 100644 pkg/unikontainers/unikernels/linux_test.go diff --git a/pkg/unikontainers/unikernels/linux_test.go b/pkg/unikontainers/unikernels/linux_test.go deleted file mode 100644 index 0d92a5d4b..000000000 --- a/pkg/unikontainers/unikernels/linux_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// 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 unikernels - -import ( - "strconv" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestLinuxUrunitArgs covers the arguments that the kernel command line -// mangles: an empty argument and a literal "--". -func TestLinuxUrunitArgs(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - cmdLine []string - expected string - }{ - { - name: "empty argument", - cmdLine: []string{"/urunit", "/app", "a", "", "b"}, - expected: "ARC:4\nARV:/app\nARV:a\nARV:\nARV:b\nUCE\n", - }, - { - name: "literal --", - cmdLine: []string{"/urunit", "/app", "a", "--", "b", "c"}, - expected: "ARC:5\nARV:/app\nARV:a\nARV:--\nARV:b\nARV:c\nUCE\n", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - l := &Linux{InitrdConf: true, RootFsType: "virtiofs"} - require.NoError(t, l.parseCmdLine(tc.cmdLine)) - assert.Contains(t, l.buildUrunitConfig(), tc.expected) - - cmd, err := l.CommandString() - require.NoError(t, err) - assert.True(t, strings.HasSuffix(cmd, " init=/urunit"), "unexpected command line: %s", cmd) - }) - } -} - -// TestLinuxUrunitLongArgs covers an argument list longer than the kernel -// command line (2048 bytes) with more than 32 arguments. -func TestLinuxUrunitLongArgs(t *testing.T) { - t.Parallel() - - cmdLine := []string{"/urunit", "/app"} - for i := range 40 { - cmdLine = append(cmdLine, strconv.Itoa(i)+strings.Repeat("x", 100)) - } - l := &Linux{InitrdConf: true, RootFsType: "virtiofs"} - require.NoError(t, l.parseCmdLine(cmdLine)) - conf := l.buildUrunitConfig() - - assert.Contains(t, conf, "\nARC:41\n") - assert.Equal(t, 41, strings.Count(conf, "\nARV:")) - assert.Contains(t, conf, "\nARV:"+cmdLine[41]+"\nUCE\n") - - cmd, err := l.CommandString() - require.NoError(t, err) - assert.True(t, strings.HasSuffix(cmd, " init=/urunit"), "unexpected command line: %s", cmd) -} From 8e09e62ab89ece4cc546eefa51228b351c5d2ce6 Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:30:14 +0000 Subject: [PATCH 6/9] feat(linux): run the container command under a configured init Add the `com.urunc.unikernel.init` annotation, also read from `urunc.json`, with the absolute path of an init inside the container rootfs. When it is set, urunc boots that init and passes the container command to it as arguments, unless the command already starts urunit. Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- docs/package/index.md | 5 ++ pkg/unikontainers/annotations.go | 41 ++++++++++ pkg/unikontainers/annotations_test.go | 113 ++++++++++++++++++++++++++ pkg/unikontainers/types/types.go | 1 + pkg/unikontainers/unikernels/linux.go | 29 ++++++- pkg/unikontainers/unikontainers.go | 1 + 6 files changed, 189 insertions(+), 1 deletion(-) diff --git a/docs/package/index.md b/docs/package/index.md index de9e6273b..dc64b49ba 100644 --- a/docs/package/index.md +++ b/docs/package/index.md @@ -78,6 +78,11 @@ Apart from the above, `urunc` accepts the following optional annotations: guest may serve on, as a comma-separated list of ports and `LOW-HIGH` ranges (e.g. `8080,9000-9100`). Without it the guest serves on any port, as a container does. Only supported by `hyperlight-unikraft`. +- `com.urunc.unikernel.init`: The absolute path to the init of a Linux guest + inside the container's rootfs (e.g. `/urunit`). The container's command then + runs under that init, which is useful when something else sets the command + (i.e., [Argo Workflows](https://argoproj.github.io/workflows/)). A value in + the annotations of the container replaces the one in `urunc.json`. Due to the fact that [Docker](https://www.docker.com/) and some high-level container runtimes do not pass the image annotations to the underlying container diff --git a/pkg/unikontainers/annotations.go b/pkg/unikontainers/annotations.go index 8eb367b57..75db80073 100644 --- a/pkg/unikontainers/annotations.go +++ b/pkg/unikontainers/annotations.go @@ -55,6 +55,7 @@ const ( annotVAccel = "com.urunc.unikernel.vAccel" annotRPCAddress = "com.urunc.unikernel.RPCAddress" annotHyperlightPorts = "com.urunc.unikernel.hyperlightPorts" + annotInit = "com.urunc.unikernel.init" ) // Annotations that other runtimes set and urunc only reads. @@ -107,6 +108,7 @@ type UnikernelConfig struct { NetDev string `json:"com.urunc.unikernel.solo5NetDev,omitempty"` BlkDev string `json:"com.urunc.unikernel.solo5BlkDev,omitempty"` HyperlightPorts string `json:"com.urunc.unikernel.hyperlightPorts,omitempty"` + Init string `json:"com.urunc.unikernel.init,omitempty"` // The vAccel annotations are deliberately not part of urunc.json, since their // values are runtime specific and therefore we should only reach them // through the annotations of the spec. @@ -194,6 +196,10 @@ func GetUnikernelConfig(bundleDir string, spec *specs.Spec) (*UnikernelConfig, e if ports := spec.Annotations[annotHyperlightPorts]; ports != "" { jsonConf.HyperlightPorts = ports } + // init set in the spec annotations overrides urunc.json. + if initPath := spec.Annotations[annotInit]; initPath != "" { + jsonConf.Init = initPath + } return jsonConf, nil } @@ -212,6 +218,7 @@ func getConfigFromSpec(spec *specs.Spec) *UnikernelConfig { netDev := spec.Annotations[annotNetDev] blkDev := spec.Annotations[annotBlkDev] hyperlightPorts := spec.Annotations[annotHyperlightPorts] + initPath := spec.Annotations[annotInit] vAccel := spec.Annotations[annotVAccel] rpcAddress := spec.Annotations[annotRPCAddress] uniklog.WithFields(logrus.Fields{ @@ -227,6 +234,7 @@ func getConfigFromSpec(spec *specs.Spec) *UnikernelConfig { "netDev": netDev, "blkDev": blkDev, "hyperlightPorts": hyperlightPorts, + "init": initPath, "vAccel": vAccel, "rpcAddress": rpcAddress, }).WithField("source", "spec").Debug("urunc annotations") @@ -244,6 +252,7 @@ func getConfigFromSpec(spec *specs.Spec) *UnikernelConfig { NetDev: netDev, BlkDev: blkDev, HyperlightPorts: hyperlightPorts, + Init: initPath, VAccel: vAccel, RPCAddress: rpcAddress, } @@ -288,6 +297,7 @@ func getConfigFromJSON(jsonFilePath string) (*UnikernelConfig, error) { "netDev": tryDecode(conf.NetDev), "blkDev": tryDecode(conf.BlkDev), "hyperlightPorts": tryDecode(conf.HyperlightPorts), + "init": tryDecode(conf.Init), }).WithField("source", uruncJSONFilename).Debug("urunc annotations") return &conf, nil @@ -375,6 +385,12 @@ func (c *UnikernelConfig) decode() error { } c.HyperlightPorts = string(decoded) + decoded, err = base64.StdEncoding.DecodeString(c.Init) + if err != nil { + return fmt.Errorf("failed to decode init: %v", err) + } + c.Init = string(decoded) + return nil } @@ -417,6 +433,9 @@ func (c *UnikernelConfig) Map() map[string]string { if c.HyperlightPorts != "" { myMap[annotHyperlightPorts] = c.HyperlightPorts } + if c.Init != "" { + myMap[annotInit] = c.Init + } if c.VAccel != "" { myMap[annotVAccel] = c.VAccel } @@ -502,6 +521,11 @@ func (c *UnikernelConfig) validateValues() error { return err } + err = validateInit(c.UnikernelType, c.Init) + if err != nil { + return err + } + if c.VAccel == "" { if c.RPCAddress != "" { return fmt.Errorf("%s is set, but %s is not", annotRPCAddress, annotVAccel) @@ -548,6 +572,23 @@ func validateSolo5DevName(key string, val string) error { return nil } +// validateInit verifies that val is an absolute path to the init of a Linux +func validateInit(unikernelType string, val string) error { + if val == "" { + return nil + } + + if unikernelType != unikernels.LinuxUnikernel { + return fmt.Errorf("%s is only supported by %s guests", annotInit, unikernels.LinuxUnikernel) + } + + if !filepath.IsAbs(val) { + return fmt.Errorf("%s must be an absolute path, got %q", annotInit, val) + } + + return validateAnnotationPathClean(annotInit, val, true) +} + // validateHyperlightPorts verifies that val lists the ports a // hyperlight-unikraft guest may serve on: ports and LOW-HIGH ranges within // 1-65535, separated by commas. diff --git a/pkg/unikontainers/annotations_test.go b/pkg/unikontainers/annotations_test.go index 91bff5584..62d9b2e42 100644 --- a/pkg/unikontainers/annotations_test.go +++ b/pkg/unikontainers/annotations_test.go @@ -1060,3 +1060,116 @@ func TestValidateValuesHyperlightPorts(t *testing.T) { }) } } + +func TestValidateValuesInit(t *testing.T) { + t.Parallel() + + linux := map[string]string{ + annotType: unikernels.LinuxUnikernel, + annotHypervisor: string(hypervisors.QemuVmm), + annotBinary: "/.boot/kernel", + } + unikraft := map[string]string{ + annotType: unikernels.UnikraftUnikernel, + annotHypervisor: string(hypervisors.QemuVmm), + annotBinary: "/unikernel/app", + } + testCases := []struct { + name string + base map[string]string + init string + wantErr bool + }{ + {name: "unset", base: linux}, + {name: "an absolute path", base: linux, init: "/urunit"}, + {name: "a relative path is rejected", base: linux, init: "urunit", wantErr: true}, + {name: "an unclean path is rejected", base: linux, init: "/a/../urunit", wantErr: true}, + {name: "a space is rejected", base: linux, init: "/urunit panic=0", wantErr: true}, + {name: "a value on another guest is rejected", base: unikraft, init: "/urunit", wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + annots := maps.Clone(tc.base) + if tc.init != "" { + annots[annotInit] = tc.init + } + err := validateAnnots(annots) + if tc.wantErr { + assert.ErrorContains(t, err, annotInit, "Expected error to mention the annotation") + return + } + assert.NoError(t, err) + }) + } +} + +func TestGetUnikernelConfigInitFromJSONFallback(t *testing.T) { + t.Parallel() + + writeJSONBundle := func(t *testing.T, initPath string) string { + t.Helper() + jsonConf := map[string]string{ + annotType: base64.StdEncoding.EncodeToString([]byte(unikernels.LinuxUnikernel)), + annotHypervisor: base64.StdEncoding.EncodeToString([]byte(hypervisors.QemuVmm)), + annotBinary: base64.StdEncoding.EncodeToString([]byte("/.boot/kernel")), + } + if initPath != "" { + jsonConf[annotInit] = base64.StdEncoding.EncodeToString([]byte(initPath)) + } + configData, err := json.Marshal(jsonConf) + assert.NoError(t, err) + + bundle := t.TempDir() + rootfsDir := filepath.Join(bundle, rootfsDirName) + err = os.Mkdir(rootfsDir, 0755) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(rootfsDir, uruncJSONFilename), configData, 0600) + assert.NoError(t, err) + return bundle + } + + testCases := []struct { + name string + jsonInit string + specInit string + expected string + }{ + {name: "the spec annotation sets the init", specInit: "/urunit", expected: "/urunit"}, + {name: "the spec annotation replaces urunc.json", jsonInit: "/urunit", specInit: "/sbin/urunit", expected: "/sbin/urunit"}, + {name: "urunc.json stands without a spec annotation", jsonInit: "/urunit", expected: "/urunit"}, + {name: "no init anywhere"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + spec := &specs.Spec{ + Root: &specs.Root{Path: rootfsDirName}, + Annotations: map[string]string{}, + } + if tc.specInit != "" { + spec.Annotations[annotInit] = tc.specInit + } + + conf, err := GetUnikernelConfig(writeJSONBundle(t, tc.jsonInit), spec) + assert.NoError(t, err) + assert.Equal(t, tc.expected, conf.Init) + }) + } + + t.Run("an annotation alone does not make a unikernel container", func(t *testing.T) { + t.Parallel() + bundle := t.TempDir() + err := os.Mkdir(filepath.Join(bundle, rootfsDirName), 0755) + assert.NoError(t, err) + spec := &specs.Spec{ + Root: &specs.Root{Path: rootfsDirName}, + Annotations: map[string]string{annotInit: "/urunit"}, + } + + _, err = GetUnikernelConfig(bundle, spec) + assert.ErrorIs(t, err, ErrNotUnikernel) + }) +} diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index 54d2784db..2fc5f5ab6 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -105,6 +105,7 @@ type ProcessConfig struct { // UnikernelParams holds the data required by the unikernels commandline type UnikernelParams struct { CmdLine []string // The cmdline provided by the image + Init string // The init to start the cmdline under, if any EnvVars []string // The environment variables provided by the image Monitor string // The monitor where guest will execute Version string // The version of the unikernel diff --git a/pkg/unikontainers/unikernels/linux.go b/pkg/unikontainers/unikernels/linux.go index 35571bb82..2a69e1d0f 100644 --- a/pkg/unikontainers/unikernels/linux.go +++ b/pkg/unikontainers/unikernels/linux.go @@ -17,6 +17,7 @@ package unikernels import ( "fmt" "net" + "os" "runtime" "strconv" "strings" @@ -234,7 +235,12 @@ func (l *Linux) MonitorCli() types.MonitorCliArgs { } func (l *Linux) Init(data types.UnikernelParams) error { - err := l.parseCmdLine(data.CmdLine) + cmdLine, err := cmdLineWithInit(data) + if err != nil { + return err + } + + err = l.parseCmdLine(cmdLine) if err != nil { return err } @@ -261,6 +267,27 @@ func (l *Linux) Init(data types.UnikernelParams) error { return nil } +// cmdLineWithInit returns the command line to boot. A container that sets an +// init runs its command under it, unless the command already starts urunit. +func cmdLineWithInit(data types.UnikernelParams) ([]string, error) { + if data.Init == "" || (len(data.CmdLine) > 0 && strings.Contains(data.CmdLine[0], "urunit")) { + return data.CmdLine, nil + } + + if data.Rootfs.Type == "virtiofs" || data.Rootfs.Type == "9pfs" { + initPath, err := securejoin.SecureJoin(constants.ContainerRootfsMountPath, data.Init) + if err != nil { + return nil, fmt.Errorf("failed to find init %s: %w", data.Init, err) + } + _, err = os.Stat(initPath) + if err != nil { + return nil, fmt.Errorf("failed to find init %s in the container rootfs: %w", data.Init, err) + } + } + + return append([]string{data.Init}, data.CmdLine...), nil +} + // parseCmdLine extracts the application and command from command line arguments. // Multi-word arguments are wrapped in single quotes for urunit compatibility. func (l *Linux) parseCmdLine(cmdLine []string) error { diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 41416ee62..4226f6565 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -613,6 +613,7 @@ func (u *Unikontainer) buildMonitorSpec(rootfsParams types.RootfsParams, monRes guest := types.UnikernelParams{ CmdLine: u.Spec.Process.Args, + Init: u.State.Annotations[annotInit], EnvVars: u.Spec.Process.Env, Monitor: vmmType, Version: unikernelVersion, From df5c9d072b8258ecc88fa9abd67dcfd23a3e8f6b Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:35:39 +0000 Subject: [PATCH 7/9] feat(linux): give the application /dev/null as stdin when it has none When urunc gets `/dev/null` as stdin from the shim, add `STDIN:0` to the urunit configuration, so urunit gives the application `/dev/null` instead of the console. A container with stdin attached keeps the console. This requires a urunit that reads `STDIN` (nubificus/urunit#18). Older urunit versions ignore the entry. Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- docs/tutorials/existing-container-linux.md | 5 ++++- pkg/unikontainers/annotations_test.go | 20 ++++++++++++++++++++ pkg/unikontainers/types/types.go | 1 + pkg/unikontainers/unikernels/linux.go | 3 +++ pkg/unikontainers/unikontainers.go | 13 +++++++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/tutorials/existing-container-linux.md b/docs/tutorials/existing-container-linux.md index 620dd9aea..e2df9473a 100644 --- a/docs/tutorials/existing-container-linux.md +++ b/docs/tutorials/existing-container-linux.md @@ -81,7 +81,9 @@ designed specifically for `urunc`. It performs the following actions: 3. Mounts `/proc` and `/sys`. 4. Reads and sets the environment variables for the application execution 5. Reads and sets the uid,gid and working directory for the application execution -6. Acts as a reaper, cleaning up zombie processes. +6. Gives the application `/dev/null` as stdin when the container has no stdin + attached, instead of the console of the guest. +7. Acts as a reaper, cleaning up zombie processes. To pass the necessary information to [urunit](https://github.com/nubificus/urunit), `urunc` uses a configuration @@ -95,6 +97,7 @@ UCS UID: GID: WD: +STDIN:0 /* only when the container has no stdin attached */ ARC: ARV: /* one line per argument */ UCE diff --git a/pkg/unikontainers/annotations_test.go b/pkg/unikontainers/annotations_test.go index 62d9b2e42..4dc0420b3 100644 --- a/pkg/unikontainers/annotations_test.go +++ b/pkg/unikontainers/annotations_test.go @@ -1173,3 +1173,23 @@ func TestGetUnikernelConfigInitFromJSONFallback(t *testing.T) { assert.ErrorIs(t, err, ErrNotUnikernel) }) } + +func TestIsNullDevice(t *testing.T) { + t.Parallel() + + null, err := os.Open(os.DevNull) + assert.NoError(t, err) + defer null.Close() + assert.True(t, isNullDevice(null), "Expected /dev/null to be detected") + + reader, writer, err := os.Pipe() + assert.NoError(t, err) + defer reader.Close() + defer writer.Close() + assert.False(t, isNullDevice(reader), "Expected a pipe not to be /dev/null") + + file, err := os.Create(filepath.Join(t.TempDir(), "stdin")) + assert.NoError(t, err) + defer file.Close() + assert.False(t, isNullDevice(file), "Expected a file not to be /dev/null") +} diff --git a/pkg/unikontainers/types/types.go b/pkg/unikontainers/types/types.go index 2fc5f5ab6..d58439230 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -100,6 +100,7 @@ type ProcessConfig struct { UID uint32 // The uid of the process inside the guest GID uint32 // The gid of the process inside the guest WorkDir string // The workdir of the process inside the guest + NoStdin bool // The process has no stdin attached, so it must not read the console } // UnikernelParams holds the data required by the unikernels commandline diff --git a/pkg/unikontainers/unikernels/linux.go b/pkg/unikontainers/unikernels/linux.go index 2a69e1d0f..655e23f3b 100644 --- a/pkg/unikontainers/unikernels/linux.go +++ b/pkg/unikontainers/unikernels/linux.go @@ -370,6 +370,9 @@ func (l *Linux) buildUrunitConfig() string { sb.WriteString("WD:") sb.WriteString(l.ProcConfig.WorkDir) sb.WriteString("\n") + if l.ProcConfig.NoStdin { + sb.WriteString("STDIN:0\n") + } sb.WriteString("ARC:") sb.WriteString(strconv.Itoa(len(l.Args))) sb.WriteString("\n") diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index 4226f6565..d9c33fc2c 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -812,6 +812,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { // Initialize the unikernel after the pivot so that any file setup it performs // (e.g. writing the urunit config into the initrd) operates within the monitor // rootfs. + unikernelParams.ProcConf.NoStdin = isNullDevice(os.Stdin) vmmArgs.Command, err = buildUnikernelCommand(unikernel, unikernelParams) if err != nil { return err @@ -893,6 +894,18 @@ func confineToContainerRootfs(path string) (string, error) { return confined, nil } +// isNullDevice reports whether f is /dev/null. The shim hands that as stdin to +// a container with neither a terminal nor a stdin attached. +func isNullDevice(f *os.File) bool { + var st unix.Stat_t + err := unix.Fstat(int(f.Fd()), &st) + if err != nil { + return false + } + + return st.Mode&unix.S_IFMT == unix.S_IFCHR && unix.Major(st.Rdev) == 1 && unix.Minor(st.Rdev) == 3 +} + // buildUnikernelCommand initializes the unikernel with the collected parameters // and returns its command line. func buildUnikernelCommand(unikernel types.Unikernel, params types.UnikernelParams) (string, error) { From 0805b1f1b4f80cfd45b47d6cf762a92c45ea3f0c Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:26:01 +0000 Subject: [PATCH 8/9] feat(cmd): pass exec to runc for containers handed over to runc Add an exec command to urunc. For a container that urunc handed over to runc, it execs runc with the same arguments, as kill, start and delete already do. For a urunc guest it returns an error, since exec into a guest is not supported. Ref: #573 Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- cmd/urunc/exec.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/urunc/main.go | 1 + 2 files changed, 70 insertions(+) create mode 100644 cmd/urunc/exec.go diff --git a/cmd/urunc/exec.go b/cmd/urunc/exec.go new file mode 100644 index 000000000..5f4609e22 --- /dev/null +++ b/cmd/urunc/exec.go @@ -0,0 +1,69 @@ +// 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 main + +import ( + "context" + "errors" + "os" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +var errExecNotSupported = errors.New("exec is not supported for urunc guests") + +// execCommand handles exec for containers that urunc handed over to runc. +// The flags mirror the ones of runc exec, so that the arguments containerd +// passes are accepted. They are not used by urunc, since runc gets the +// original arguments. +var execCommand = &cli.Command{ + Name: "exec", + Usage: "execute new process inside a container handed over to runc", + ArgsUsage: ` [command options] || -p process.json `, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "console-socket"}, + &cli.StringFlag{Name: "pidfd-socket"}, + &cli.StringFlag{Name: "cwd"}, + &cli.StringSliceFlag{Name: "env", Aliases: []string{"e"}}, + &cli.BoolFlag{Name: "tty", Aliases: []string{"t"}}, + &cli.StringFlag{Name: "user", Aliases: []string{"u"}}, + &cli.StringSliceFlag{Name: "additional-gids", Aliases: []string{"g"}}, + &cli.StringFlag{Name: "process", Aliases: []string{"p"}}, + &cli.BoolFlag{Name: "detach", Aliases: []string{"d"}}, + &cli.StringFlag{Name: "pid-file"}, + &cli.StringFlag{Name: "process-label"}, + &cli.StringFlag{Name: "apparmor"}, + &cli.BoolFlag{Name: "no-new-privs"}, + &cli.StringSliceFlag{Name: "cap", Aliases: []string{"c"}}, + &cli.IntFlag{Name: "preserve-fds"}, + &cli.StringSliceFlag{Name: "cgroup"}, + &cli.BoolFlag{Name: "ignore-paused"}, + }, + Action: func(_ context.Context, cmd *cli.Command) error { + logrus.WithField("command", "EXEC").WithField("args", os.Args).Debug("urunc INVOKED") + if err := checkArgs(cmd, 1, minArgs); err != nil { + return err + } + + // For a container that urunc handed over to runc, this execs runc + // with the original arguments and does not return. + if _, err := getUnikontainer(cmd); err != nil { + return err + } + + return errExecNotSupported + }, +} diff --git a/cmd/urunc/main.go b/cmd/urunc/main.go index c2136f249..2878c2a97 100644 --- a/cmd/urunc/main.go +++ b/cmd/urunc/main.go @@ -110,6 +110,7 @@ func main() { Commands: []*cli.Command{ createCommand, deleteCommand, + execCommand, killCommand, monitorCommand, runCommand, From a5e221e1deb63da36f3eaf47a133a40180f19dae Mon Sep 17 00:00:00 2001 From: Indrajith <29561593+indrajithek@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:26:11 +0000 Subject: [PATCH 9/9] feat(network): keep the pod's own traffic when it sets a port range With the dynamic network, every packet that reaches eth0 is redirected to the tap, so the other containers of the pod lose the replies to their own connections while a guest runs. For a Linux guest, if the pod sets net.ipv4.ip_local_port_range above the range of the guest (61000 or higher), keep ARP and the TCP and UDP packets to the ports of that range for the pod, and redirect the rest to the guest as before. Ref: #573 Signed-off-by: Indrajith <29561593+indrajithek@users.noreply.github.com> --- pkg/network/network.go | 4 +- pkg/network/network_dynamic.go | 17 ++++ pkg/network/network_test.go | 2 +- pkg/network/pod_ports.go | 141 +++++++++++++++++++++++++++++ pkg/unikontainers/monitor.go | 2 +- pkg/unikontainers/unikontainers.go | 6 +- 6 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 pkg/network/pod_ports.go diff --git a/pkg/network/network.go b/pkg/network/network.go index 034d82232..363da0991 100644 --- a/pkg/network/network.go +++ b/pkg/network/network.go @@ -54,12 +54,12 @@ type Interface struct { MTU int } -func NewNetworkManager(networkType string) (Manager, error) { +func NewNetworkManager(networkType string, linuxGuest bool) (Manager, error) { switch networkType { case "static": return &StaticNetwork{}, nil case "dynamic": - return &DynamicNetwork{}, nil + return &DynamicNetwork{linuxGuest: linuxGuest}, nil default: return nil, fmt.Errorf("network manager %s not supported", networkType) diff --git a/pkg/network/network_dynamic.go b/pkg/network/network_dynamic.go index 74bee86c5..c6270fdeb 100644 --- a/pkg/network/network_dynamic.go +++ b/pkg/network/network_dynamic.go @@ -21,6 +21,9 @@ import ( ) type DynamicNetwork struct { + // linuxGuest is set when the guest is Linux, so its own connections use + // the local port range of the Linux kernel. + linuxGuest bool } // NetworkSetup checks if any tap device is available in the current netns. If it is, it assumes a running unikernel @@ -56,6 +59,20 @@ func (n DynamicNetwork) NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkI } netlog.Debugf("tap device created: %s", newTapDevice.Attrs().Name) + // The redirect sends every packet of the pod to the guest. If the pod + // keeps a local port range for its own connections, the other containers + // of the pod still need the replies to them. + if n.linuxGuest { + if low, high, ok := podPortBand(); ok { + err = addPodPortFilters(redirectLink, newTapDevice, low, high) + if err != nil { + netlog.Warnf("failed to keep ports %d-%d for the pod, all traffic goes to the guest: %v", low, high, err) + } else { + netlog.Infof("keeping ARP and ports %d-%d of %s for the pod", low, high, redirectLink.Attrs().Name) + } + } + } + netlog.Debugf("fetching info for %s", redirectLink.Attrs().Name) ifInfo, err := getInterfaceInfo(redirectLink.Attrs().Name) if err != nil { diff --git a/pkg/network/network_test.go b/pkg/network/network_test.go index da8c1d3d3..8730ace78 100644 --- a/pkg/network/network_test.go +++ b/pkg/network/network_test.go @@ -46,7 +46,7 @@ func TestNewNetworkManager(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := NewNetworkManager(tt.networkType) + got, err := NewNetworkManager(tt.networkType, false) if tt.expectedErr { assert.Error(t, err, "NewNetworkManager() should return an error") } else { diff --git a/pkg/network/pod_ports.go b/pkg/network/pod_ports.go new file mode 100644 index 000000000..b9ca79f3a --- /dev/null +++ b/pkg/network/pod_ports.go @@ -0,0 +1,141 @@ +// 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 network + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/vishvananda/netlink" + "github.com/vishvananda/netlink/nl" + "golang.org/x/sys/unix" +) + +const ( + // podPortRangePath holds the local port range of the network namespace + // of the reading process, which is the one of the pod. + podPortRangePath = "/proc/sys/net/ipv4/ip_local_port_range" + // linuxGuestPortMax is the end of the default local port range of the + // Linux kernel (32768-60999), which a Linux guest uses for its own + // connections. + linuxGuestPortMax = 60999 +) + +// podPortBand reads the local port range of the pod's network namespace +// and reports whether it can be kept for the pod's own connections. +func podPortBand() (uint16, uint16, bool) { + data, err := os.ReadFile(podPortRangePath) + if err != nil { + netlog.Debugf("failed to read %s: %v", podPortRangePath, err) + return 0, 0, false + } + return parsePortBand(string(data)) +} + +// parsePortBand parses a local port range ("low high"). The range is usable +// only when it is entirely above the local port range of a Linux guest, so +// that the ports of the guest's connections and services stay with the guest. +func parsePortBand(s string) (uint16, uint16, bool) { + fields := strings.Fields(s) + if len(fields) != 2 { + return 0, 0, false + } + low, err := strconv.ParseUint(fields[0], 10, 16) + if err != nil { + return 0, 0, false + } + high, err := strconv.ParseUint(fields[1], 10, 16) + if err != nil { + return 0, 0, false + } + if low <= linuxGuestPortMax || low > high { + return 0, 0, false + } + return uint16(low), uint16(high), true +} + +// addPodPortFilters adds filters on the ingress of eth that run before the +// redirect to the tap: ARP is copied to the tap and also passed to the pod, +// and TCP and UDP packets to the ports in [low, high] are passed to the pod. +// In that way, the other containers of the pod keep the replies to their own +// connections. If a filter can not be added, the ones already added are +// removed, leaving only the redirect. +func addPodPortFilters(eth netlink.Link, tap netlink.Link, low uint16, high uint16) error { + tcp := nl.IPPROTO_TCP + udp := nl.IPPROTO_UDP + filters := []netlink.Filter{ + &netlink.Flower{ + FilterAttrs: podFilterAttrs(eth, 1, unix.ETH_P_ARP), + EthType: unix.ETH_P_ARP, + Actions: []netlink.Action{ + &netlink.MirredAction{ + ActionAttrs: netlink.ActionAttrs{ + Action: netlink.TC_ACT_OK, + }, + MirredAction: netlink.TCA_EGRESS_MIRROR, + Ifindex: tap.Attrs().Index, + }, + }, + }, + podPortFilter(eth, 2, &tcp, low, high), + podPortFilter(eth, 3, &udp, low, high), + } + + var added []netlink.Filter + for _, filter := range filters { + err := netlink.FilterAdd(filter) + if err != nil { + var errs []error + for _, f := range added { + if delErr := netlink.FilterDel(f); delErr != nil { + errs = append(errs, delErr) + } + } + return errors.Join(fmt.Errorf("failed to add filter: %w", err), errors.Join(errs...)) + } + added = append(added, filter) + } + + return nil +} + +func podFilterAttrs(link netlink.Link, priority uint16, protocol uint16) netlink.FilterAttrs { + return netlink.FilterAttrs{ + LinkIndex: link.Attrs().Index, + Parent: netlink.MakeHandle(0xffff, 0), + Priority: priority, + Protocol: protocol, + } +} + +func podPortFilter(link netlink.Link, priority uint16, proto *nl.IPProto, low uint16, high uint16) *netlink.Flower { + return &netlink.Flower{ + FilterAttrs: podFilterAttrs(link, priority, unix.ETH_P_IP), + EthType: unix.ETH_P_IP, + IPProto: proto, + DstPortRangeMin: low, + DstPortRangeMax: high, + Actions: []netlink.Action{ + &netlink.GenericAction{ + ActionAttrs: netlink.ActionAttrs{ + Action: netlink.TC_ACT_OK, + }, + }, + }, + } +} diff --git a/pkg/unikontainers/monitor.go b/pkg/unikontainers/monitor.go index 0f138f0b4..4f730e4af 100644 --- a/pkg/unikontainers/monitor.go +++ b/pkg/unikontainers/monitor.go @@ -83,7 +83,7 @@ func runMonitor(metrics m.Writer, ms monitorSpec) error { return err } - netArgs, err := SetupNet(vmm.UsesTUNTAP(), ms.NetworkType, ms.User.UID, ms.User.GID) + netArgs, err := SetupNet(vmm.UsesTUNTAP(), ms.NetworkType, ms.UnikernelType == unikernels.LinuxUnikernel, ms.User.UID, ms.User.GID) if err != nil { return fmt.Errorf("failed to setup network: %w", err) } diff --git a/pkg/unikontainers/unikontainers.go b/pkg/unikontainers/unikontainers.go index d9c33fc2c..a623447f4 100644 --- a/pkg/unikontainers/unikontainers.go +++ b/pkg/unikontainers/unikontainers.go @@ -312,7 +312,7 @@ func monitorResolvConfMount(vmm types.VMM, cntrMounts []specs.Mount) (specs.Moun // sockets in that namespace itself, so the interface is left as it is and only // described, for the monitor to know whether the container has a network at // all: one started with none gets empty parameters. -func SetupNet(tuntap bool, networkType string, uid, gid uint32) (types.NetDevParams, error) { +func SetupNet(tuntap bool, networkType string, linuxGuest bool, uid, gid uint32) (types.NetDevParams, error) { netArgs := types.NetDevParams{} if !tuntap { iface, err := network.ContainerIface() @@ -332,7 +332,7 @@ func SetupNet(tuntap bool, networkType string, uid, gid uint32) (types.NetDevPar } uniklog.WithField("network type", networkType).Debug("Retrieved network type") - netManager, err := network.NewNetworkManager(networkType) + netManager, err := network.NewNetworkManager(networkType, linuxGuest) if err != nil { return netArgs, fmt.Errorf("failed to create network manager for %s type: %v", networkType, err) } @@ -724,7 +724,7 @@ func (u *Unikontainer) Exec(metrics m.Writer) error { } // handle network - netArgs, err := SetupNet(vmm.UsesTUNTAP(), u.getNetworkType(), u.Spec.Process.User.UID, u.Spec.Process.User.GID) + netArgs, err := SetupNet(vmm.UsesTUNTAP(), u.getNetworkType(), ms.UnikernelType == unikernels.LinuxUnikernel, u.Spec.Process.User.UID, u.Spec.Process.User.GID) if err != nil { uniklog.Errorf("failed to setup network: %v", err) return err