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 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/docs/tutorials/existing-container-linux.md b/docs/tutorials/existing-container-linux.md index 0dde898de..e2df9473a 100644 --- a/docs/tutorials/existing-container-linux.md +++ b/docs/tutorials/existing-container-linux.md @@ -77,11 +77,13 @@ 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 -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,9 @@ 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.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..4dc0420b3 100644 --- a/pkg/unikontainers/annotations_test.go +++ b/pkg/unikontainers/annotations_test.go @@ -1060,3 +1060,136 @@ 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) + }) +} + +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 54d2784db..d58439230 100644 --- a/pkg/unikontainers/types/types.go +++ b/pkg/unikontainers/types/types.go @@ -100,11 +100,13 @@ 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 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 69fc17edc..655e23f3b 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" @@ -42,6 +43,7 @@ const ( type Linux struct { App string Command string + Args []string // application command, verbatim, for urunit Monitor string Env []string Net LinuxNet @@ -123,7 +125,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 } @@ -229,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 } @@ -256,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 { @@ -275,6 +307,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 { @@ -337,6 +370,17 @@ 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") + 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/unikontainers.go b/pkg/unikontainers/unikontainers.go index 41416ee62..d9c33fc2c 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, @@ -811,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 @@ -892,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) {