Skip to content
Open
3 changes: 3 additions & 0 deletions .github/contributors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,6 @@ users:
Inuth0603:
name: Divyam Agarwal
email: iitdivyamagarwal@gmail.com
indrajithek:
name: Indrajith
email: 29561593+indrajithek@users.noreply.github.com
5 changes: 5 additions & 0 deletions docs/package/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions docs/tutorials/existing-container-linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -95,6 +97,9 @@ UCS
UID:<uid>
GID:<gid>
WD:<working_dir>
STDIN:0 /* only when the container has no stdin attached */
ARC:<number of arguments>
ARV:<argument> /* one line per argument */
UCE
```

Expand Down
41 changes: 41 additions & 0 deletions pkg/unikontainers/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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{
Expand All @@ -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")
Expand All @@ -244,6 +252,7 @@ func getConfigFromSpec(spec *specs.Spec) *UnikernelConfig {
NetDev: netDev,
BlkDev: blkDev,
HyperlightPorts: hyperlightPorts,
Init: initPath,
VAccel: vAccel,
RPCAddress: rpcAddress,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions pkg/unikontainers/annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
2 changes: 2 additions & 0 deletions pkg/unikontainers/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading