Skip to content

Commit

Permalink
feat: oci: honor USER in image config
Browse files Browse the repository at this point in the history
When a USER is specified in the image config:

* If running unprivileged, ensure the inner uid / gid mapping results
  in the container process running as the USER, by default.
* If running privileged, run as the USER, by default.

Fixes sylabs#77
  • Loading branch information
dtrudg committed Jan 9, 2023
1 parent ae86852 commit e8d8c71
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 67 deletions.
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ reserved.

Copyright (c) 2017, SingularityWare, LLC. All rights reserved.

Copyright (c) 2018-2022, Sylabs, Inc. All rights reserved.
Copyright (c) 2018-2023, Sylabs, Inc. All rights reserved.

Copyright (c) Contributors to the Apptainer project, established as Apptainer a
Series of LF Projects LLC.
Expand Down
51 changes: 50 additions & 1 deletion e2e/docker/docker.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2019-2022 Sylabs Inc. All rights reserved.
// Copyright (c) 2019-2023 Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand Down Expand Up @@ -824,6 +824,54 @@ func (c ctx) testDockerCMDQuotes(t *testing.T) {
)
}

// Check that the USER in a docker container is honored under --oci mode
func (c ctx) testDockerUSER(t *testing.T) {
tests := []struct {
name string
expectOutput string
profile e2e.Profile
}{
// Sanity check singularity native engine... no support for USER
{
name: "default",
profile: e2e.UserProfile,
expectOutput: fmt.Sprintf("uid=%d(%s) gid=%d",
e2e.UserProfile.ContainerUser(t).UID,
e2e.UserProfile.ContainerUser(t).Name,
e2e.UserProfile.ContainerUser(t).GID),
},
// `--oci` modes (USER honored by default)
{
name: "OCIUser",
profile: e2e.OCIUserProfile,
expectOutput: `uid=2000(testuser) gid=2000(testgroup)`,
},
{
name: "OCIFakeroot",
profile: e2e.OCIFakerootProfile,
expectOutput: `uid=0(root) gid=0(root)`,
},
{
name: "OCIRoot",
profile: e2e.OCIRootProfile,
expectOutput: `uid=2000(testuser) gid=2000(testgroup)`,
},
}

for _, tt := range tests {
c.env.RunSingularity(
t,
e2e.AsSubtest(tt.name),
e2e.WithProfile(tt.profile),
e2e.WithCommand("run"),
e2e.WithArgs("docker://sylabsio/docker-user"),
e2e.ExpectExit(0,
e2e.ExpectOutput(e2e.ContainMatch, tt.expectOutput),
),
)
}
}

// E2ETests is the main func to trigger the test suite
func E2ETests(env e2e.TestEnv) testhelper.Tests {
c := ctx{
Expand All @@ -845,6 +893,7 @@ func E2ETests(env e2e.TestEnv) testhelper.Tests {
t.Run("entrypoint", c.testDockerENTRYPOINT)
t.Run("cmdentrypoint", c.testDockerCMDENTRYPOINT)
t.Run("cmd quotes", c.testDockerCMDQuotes)
t.Run("user", c.testDockerUSER)
// Regressions
t.Run("issue 4524", c.issue4524)
},
Expand Down
102 changes: 70 additions & 32 deletions internal/pkg/runtime/launcher/oci/launcher_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// Copyright (c) 2022-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand All @@ -15,6 +15,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"

"github.com/containers/image/v5/types"
Expand Down Expand Up @@ -211,32 +212,15 @@ func checkOpts(lo launcher.Options) error {
return nil
}

// createSpec produces an OCI runtime specification, suitable to launch a
// container. This spec excludes the Process config, as this have to be
// computed where the image config is available, to account for the image's CMD
// / ENTRYPOINT / ENV.
// createSpec creates an initial OCI runtime specification, suitable to launch a
// container. This spec excludes the Process config, as this has to be computed
// where the image config is available, to account for the image's CMD /
// ENTRYPOINT / ENV / USER.
func (l *Launcher) createSpec() (*specs.Spec, error) {
spec := minimalSpec()

// If we are *not* requesting fakeroot, then we need to map the container
// uid back to host uid, through the initial fakeroot userns.
if !l.cfg.Fakeroot && os.Getuid() != 0 {
uidMap, gidMap, err := l.getReverseUserMaps()
if err != nil {
return nil, err
}
spec.Linux.UIDMappings = uidMap
spec.Linux.GIDMappings = gidMap
}

spec = addNamespaces(spec, l.cfg.Namespaces)

cwd, err := l.getProcessCwd()
if err != nil {
return nil, err
}
spec.Process.Cwd = cwd

mounts, err := l.getMounts()
if err != nil {
return nil, err
Expand All @@ -255,13 +239,64 @@ func (l *Launcher) createSpec() (*specs.Spec, error) {
return &spec, nil
}

func (l *Launcher) updateSpecFromImage(ctx context.Context, b ocibundle.Bundle, spec *specs.Spec, image string, process string, args []string) error {
// finalizeSpec updates the bundle config, filling in Process config that depends on the image spec.
func (l *Launcher) finalizeSpec(ctx context.Context, b ocibundle.Bundle, spec *specs.Spec, image string, process string, args []string) (err error) {
imgSpec := b.ImageSpec()
if imgSpec == nil {
return fmt.Errorf("bundle has no image spec")
}

specProcess, err := l.getProcess(ctx, *imgSpec, image, b.Path(), process, args)
// In the absence of a USER in the OCI image config, we will run the
// container process as our current user / group.
currentUID := uint32(os.Getuid())
currentGID := uint32(os.Getgid())
targetUID := currentUID
targetGID := currentGID
containerUser := false

// If the OCI image config specifies a USER we will:
// * When unprivileged - run as that user, via nested subuid/gid mappings (host user -> userns root -> OCI USER)
// * When privileged - directly run as that user, as a host uid/gid.
if imgSpec.Config.User != "" {
imgUser, err := tools.BundleUser(b.Path(), imgSpec.Config.User)
if err != nil {
return err
}
imgUID, err := strconv.ParseUint(imgUser.Uid, 10, 32)
if err != nil {
return err
}
imgGID, err := strconv.ParseUint(imgUser.Gid, 10, 32)
if err != nil {
return err
}
targetUID = uint32(imgUID)
targetGID = uint32(imgGID)
containerUser = true
sylog.Debugf("Running as USER specified in OCI image config %d:%d", targetUID, targetGID)
}

// Fakeroot always overrides to give us root in the container (via userns & idmap if unprivileged).
if l.cfg.Fakeroot {
targetUID = 0
targetGID = 0
}

if targetUID != 0 && currentUID != 0 {
uidMap, gidMap, err := l.getReverseUserMaps(targetUID, targetGID)
if err != nil {
return err
}
spec.Linux.UIDMappings = uidMap
spec.Linux.GIDMappings = gidMap
}

u := specs.User{
UID: targetUID,
GID: targetGID,
}

specProcess, err := l.getProcess(ctx, *imgSpec, image, b.Path(), process, args, u)
if err != nil {
return err
}
Expand All @@ -270,16 +305,19 @@ func (l *Launcher) updateSpecFromImage(ctx context.Context, b ocibundle.Bundle,
return err
}

if err := l.updatePasswdGroup(tools.RootFs(b.Path()).Path()); err != nil {
// If we are entering as root, or a USER defined in the container, then passwd/group
// information should be present already.
if targetUID == 0 || containerUser {
return nil
}
// Otherewise, add to the passwd and group files in the container.
if err := l.updatePasswdGroup(tools.RootFs(b.Path()).Path(), targetUID, targetGID); err != nil {
return err
}
return nil
}

func (l *Launcher) updatePasswdGroup(rootfs string) error {
uid := os.Getuid()
gid := os.Getgid()

func (l *Launcher) updatePasswdGroup(rootfs string, uid, gid uint32) error {
if os.Getuid() == 0 || l.cfg.Fakeroot {
return nil
}
Expand All @@ -293,7 +331,7 @@ func (l *Launcher) updatePasswdGroup(rootfs string) error {
}

sylog.Debugf("Updating passwd file: %s", containerPasswd)
content, err := files.Passwd(containerPasswd, pw.Dir, uid)
content, err := files.Passwd(containerPasswd, pw.Dir, int(uid))
if err != nil {
return fmt.Errorf("while creating passwd file: %w", err)
}
Expand All @@ -302,7 +340,7 @@ func (l *Launcher) updatePasswdGroup(rootfs string) error {
}

sylog.Debugf("Updating group file: %s", containerGroup)
content, err = files.Group(containerGroup, uid, []int{gid})
content, err = files.Group(containerGroup, int(uid), []int{int(gid)})
if err != nil {
return fmt.Errorf("while creating group file: %w", err)
}
Expand Down Expand Up @@ -377,7 +415,7 @@ func (l *Launcher) Exec(ctx context.Context, image string, process string, args
}

// With reference to the bundle's image spec, now set the process configuration.
if err := l.updateSpecFromImage(ctx, b, spec, image, process, args); err != nil {
if err := l.finalizeSpec(ctx, b, spec, image, process, args); err != nil {
return err
}

Expand Down
35 changes: 8 additions & 27 deletions internal/pkg/runtime/launcher/oci/process_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// Copyright (c) 2022-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand All @@ -24,7 +24,7 @@ import (

const singularityLibs = "/.singularity.d/libs"

func (l *Launcher) getProcess(ctx context.Context, imgSpec imgspecv1.Image, image, bundle, process string, args []string) (*specs.Process, error) {
func (l *Launcher) getProcess(ctx context.Context, imgSpec imgspecv1.Image, image, bundle, process string, args []string, u specs.User) (*specs.Process, error) {
// Assemble the runtime & user-requested environment, which will be merged
// with the image ENV and set in the container at runtime.
rtEnv := defaultEnv(image, bundle)
Expand All @@ -50,7 +50,7 @@ func (l *Launcher) getProcess(ctx context.Context, imgSpec imgspecv1.Image, imag
Args: getProcessArgs(imgSpec, process, args),
Cwd: cwd,
Env: getProcessEnv(imgSpec, rtEnv),
User: l.getProcessUser(),
User: u,
Terminal: getProcessTerminal(),
}

Expand All @@ -59,11 +59,8 @@ func (l *Launcher) getProcess(ctx context.Context, imgSpec imgspecv1.Image, imag

// getProcessTerminal determines whether the container process should run with a terminal.
func getProcessTerminal() bool {
// Override the default Process.Terminal to false if our stdin is not a terminal.
if term.IsTerminal(syscall.Stdin) {
return true
}
return false
// Sets the default Process.Terminal to false if our stdin is not a terminal.
return term.IsTerminal(syscall.Stdin)
}

// getProcessArgs returns the process args for a container, with reference to the OCI Image Spec.
Expand All @@ -88,22 +85,6 @@ func getProcessArgs(imageSpec imgspecv1.Image, process string, args []string) []
return processArgs
}

// getProcessUser computes the uid/gid(s) to be set on process execution.
// Currently this only supports the same uid / primary gid as on the host.
// TODO - expand for fakeroot, and arbitrary mapped user.
func (l *Launcher) getProcessUser() specs.User {
if l.cfg.Fakeroot {
return specs.User{
UID: 0,
GID: 0,
}
}
return specs.User{
UID: uint32(os.Getuid()),
GID: uint32(os.Getgid()),
}
}

// getProcessCwd computes the Cwd that the container process should start in.
// Currently this is the user's tmpfs home directory (see --containall).
func (l *Launcher) getProcessCwd() (dir string, err error) {
Expand All @@ -118,12 +99,12 @@ func (l *Launcher) getProcessCwd() (dir string, err error) {
return pw.Dir, nil
}

// getReverseUserMaps returns uid and gid mappings that re-map container uid to host
// getReverseUserMaps returns uid and gid mappings that re-map container uid to target
// uid. This 'reverses' the host user to container root mapping in the initial
// userns from which the OCI runtime is launched.
//
// host 1001 -> fakeroot userns 0 -> container 1001
func (l *Launcher) getReverseUserMaps() (uidMap, gidMap []specs.LinuxIDMapping, err error) {
// e.g. host 1001 -> fakeroot userns 0 -> container targetUID
func (l *Launcher) getReverseUserMaps(targetUID, targetGID uint32) (uidMap, gidMap []specs.LinuxIDMapping, err error) {
uid := uint32(os.Getuid())
gid := uint32(os.Getgid())
// Get user's configured subuid & subgid ranges
Expand Down
2 changes: 1 addition & 1 deletion internal/pkg/runtime/launcher/oci/process_linux_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// Copyright (c) 2022-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand Down
2 changes: 1 addition & 1 deletion pkg/ocibundle/native/bundle_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// Copyright (c) 2022-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand Down
2 changes: 1 addition & 1 deletion pkg/ocibundle/native/bundle_linux_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// Copyright (c) 2022-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand Down
2 changes: 1 addition & 1 deletion pkg/ocibundle/sif/bundle_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2019-2020, Sylabs Inc. All rights reserved.
// Copyright (c) 2019-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand Down
4 changes: 2 additions & 2 deletions pkg/ocibundle/tools/oci.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2019-2020, Sylabs Inc. All rights reserved.
// Copyright (c) 2019-2023, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.
Expand Down Expand Up @@ -108,7 +108,7 @@ func DeleteBundle(bundlePath string) error {
}

// BundleUser returns a user struct for the specified user, from the bundle passwd file.
func Bundle(bundlePath, user string) (u *user.User, err error) {
func BundleUser(bundlePath, user string) (u *user.User, err error) {
passwd := filepath.Join(RootFs(bundlePath).Path(), "etc", "passwd")
if _, err := os.Stat(passwd); err != nil {
return nil, fmt.Errorf("cannot access container passwd file: %w", err)
Expand Down

0 comments on commit e8d8c71

Please sign in to comment.