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