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/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, 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/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/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/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/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..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) } @@ -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, @@ -723,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 @@ -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) {