Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for userns #3941

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/nerdctl/container/container_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,13 @@ func createOptions(cmd *cobra.Command) (types.ContainerCreateOptions, error) {
}
// #endregion

// #region for UserNS
opt.UserNS, err = cmd.Flags().GetString("userns")
if err != nil {
return opt, err
}
// #endregion

return opt, nil
}

Expand Down
212 changes: 212 additions & 0 deletions cmd/nerdctl/container/container_create_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ package container
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"

"github.com/opencontainers/go-digest"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"

"github.com/containerd/containerd/v2/defaults"
"github.com/containerd/nerdctl/mod/tigron/require"
Expand Down Expand Up @@ -325,3 +330,210 @@ func TestCreateFromOCIArchive(t *testing.T) {
base.Cmd("create", "--rm", "--name", containerName, fmt.Sprintf("oci-archive://%s", tarPath)).AssertOK()
base.Cmd("start", "--attach", containerName).AssertOutContains("test-nerdctl-create-from-oci-archive")
}

func TestUsernsMappingCreateCmd(t *testing.T) {
nerdtest.Setup()

testCase := &test.Case{
Require: require.All(
nerdtest.AllowModifyUserns,
require.Not(nerdtest.ContainerdV1),
require.Not(nerdtest.Docker)),
SubTests: []*test.Case{
{
Description: "Test container start with valid Userns",
NoParallel: true, // Changes system config so running in non parallel mode
Setup: func(data test.Data, helpers test.Helpers) {
data.Set("validUserns", "nerdctltestuser")
data.Set("expectedHostUID", "123456789")
// need to be compiled with containerd version >2.0.2 to support multi uidmap and gidmap.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change comment to 2.1.x

if err := appendUsernsConfig(data.Get("validUserns"), data.Get("expectedHostUID")); err != nil {
t.Fatalf("Failed to append Userns config: %v", err)
}
},
Cleanup: func(data test.Data, helpers test.Helpers) {
removeUsernsConfig(t, data.Get("validUserns"), data.Get("expectedHostUID"))
helpers.Anyhow("rm", "-f", data.Identifier())
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
helpers.Ensure("create", "--tty", "--userns", data.Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
return helpers.Command("start", data.Identifier())
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: 0,
Output: func(stdout string, info string, t *testing.T) {
actualHostUID, err := getContainerHostUID(helpers, data.Identifier())
if err != nil {
t.Fatalf("Failed to get container host UID: %v", err)
}
assert.Assert(t, actualHostUID == data.Get("expectedHostUID"), info)
},
}
},
},
{
Description: "Test container start with invalid Userns",
NoParallel: true, // Changes system config so running in non parallel mode
Setup: func(data test.Data, helpers test.Helpers) {
data.Set("invalidUserns", "invaliduser")
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("rm", "-f", data.Identifier())
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("create", "--tty", "--userns", data.Get("invalidUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: 1,
}
},
},
},
}
testCase.Run(t)
}

func runUsernsContainer(t *testing.T, name, Userns, image, cmd string) *icmd.Result {
base := testutil.NewBase(t)
removeContainerArgs := []string{
"rm", "-f", name,
}
base.Cmd(removeContainerArgs...).Run()

args := []string{
"run", "-d", "--userns", Userns, "--name", name, image, "sh", "-c", cmd,
}
return base.Cmd(args...).Run()
}

func getContainerHostUID(helpers test.Helpers, containerName string) (string, error) {
result := helpers.Capture("inspect", "--format", "{{.State.Pid}}", containerName)
pidStr := strings.TrimSpace(result)
pid, err := strconv.Atoi(pidStr)
if err != nil {
return "", fmt.Errorf("invalid PID: %v", err)
}

stat, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
if err != nil {
return "", fmt.Errorf("failed to stat process: %v", err)
}

uid := int(stat.Sys().(*syscall.Stat_t).Uid)
return strconv.Itoa(uid), nil
}

func appendUsernsConfig(Userns string, hostUid string) error {
if err := addUser(Userns, hostUid); err != nil {
return fmt.Errorf("failed to add user %s: %w", Userns, err)
}

entry := fmt.Sprintf("%s:%s:65536\n", Userns, hostUid)

tempDir := os.TempDir()

files := []string{"subuid", "subgid"}
for _, file := range files {

fileBak := fmt.Sprintf("%s/%s.bak", tempDir, file)
defer os.Remove(fileBak)
d, err := os.Create(fileBak)
if err != nil {
return fmt.Errorf("failed to create %s: %w", fileBak, err)
}

s, err := os.Open(fmt.Sprintf("/etc/%s", file))
if err != nil {
return fmt.Errorf("failed to open %s: %w", file, err)
}
defer s.Close()

_, err = io.Copy(d, s)
if err != nil {
return fmt.Errorf("failed to copy %s to %s: %w", file, fileBak, err)
}

f, err := os.OpenFile(fmt.Sprintf("/etc/%s", file), os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open %s: %w", file, err)
}
defer f.Close()

if _, err := f.WriteString(entry); err != nil {
return fmt.Errorf("failed to write to %s: %w", file, err)
}
}
return nil
}

func addUser(username string, hostId string) error {
Copy link
Member

@AkihiroSuda AkihiroSuda Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is scary to run by default. Needs to have an opt-in flag like -test.allow-modify-user

Similar:

flag.BoolVar(&flagTestKillDaemon, "test.allow-kill-daemon", false, "enable tests that kill the daemon")

cmd := exec.Command("groupadd", "-g", hostId, username)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("groupadd failed: %s, %w", string(output), err)
}
cmd = exec.Command("useradd", "-u", hostId, "-g", hostId, "-s", "/bin/false", username)
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("useradd failed: %s, %w", string(output), err)
}
return nil
}

func removeUsernsConfig(t *testing.T, Userns string, hostUid string) {

if err := delUser(Userns); err != nil {
t.Logf("failed to del user %s, Error: %s", Userns, err)
}

if err := delGroup(Userns); err != nil {
t.Logf("failed to del group %s, Error: %s", Userns, err)
}

tempDir := os.TempDir()
files := []string{"subuid", "subgid"}
for _, file := range files {
fileBak := fmt.Sprintf("%s/%s.bak", tempDir, file)
s, err := os.Open(fileBak)
if err != nil {
t.Logf("failed to open %s, Error: %s", fileBak, err)
continue
}
defer s.Close()

d, err := os.Open(fmt.Sprintf("/etc/%s", file))
if err != nil {
t.Logf("failed to open %s, Error: %s", file, err)
continue

}
defer d.Close()

_, err = io.Copy(d, s)
if err != nil {
t.Logf("failed to restore. Copy %s to %s failed, Error %s", fileBak, file, err)
continue
}

}
}

func delUser(username string) error {
cmd := exec.Command("sudo", "userdel", username)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sudo shouldn't be needed when the test is running as the root

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("userdel failed: %s, %w", string(output), err)
}
return nil
}

func delGroup(groupname string) error {
cmd := exec.Command("sudo", "groupdel", groupname)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("groupdel failed: %s, %w", string(output), err)
}
return nil
}
6 changes: 6 additions & 0 deletions cmd/nerdctl/container/container_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ func setCreateFlags(cmd *cobra.Command) {
cmd.Flags().String("ipfs-address", "", "multiaddr of IPFS API (default uses $IPFS_PATH env variable if defined or local directory ~/.ipfs)")

cmd.Flags().String("isolation", "default", "Specify isolation technology for container. On Linux the only valid value is default. Windows options are host, process and hyperv with process isolation as the default")
cmd.Flags().String("userns", "", "Support idmapping of containers. This options is only supported on linux. If `host` is passed, no idmapping is done. if a user name is passed, it does idmapping based on the uidmap and gidmap ranges specified in /etc/subuid and /etc/subgid respectively")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add docs/command-reference.md.

Also, the command line seems incompatible with Docker?
Docker doesn't accept a username here, and the name is hardcoded to "dockremap".
Maybe we should have its equivalent as "nerdremap"?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Podman accepts --subuidname string --subgidname string to specify a custom user name

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the command line seems incompatible with Docker?
if they add host it will behave as docker as we check for that string and create the default snapshot.

For other names it behaves as docker daemon but at a container level rather than at daemon level. Will you suggest we configure it in nerdctl config instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For other names it behaves as docker daemon but at a container level rather than at daemon level. Will you suggest we configure it in nerdctl config instead?

Eventually, the both level should be supported, as in Podman: https://github.com/containers/podman/blob/v5.4.1/docs/source/markdown/options/userns.container.md?plain=1

  • podman run --userns=auto allocates subuids from the "containers" entry in /etc/subuid.
  • When userns=... is specified in containers.conf, Podman enables UserNS globally, unless --userns=host is specified.

nerdctl should probably follow the same convention, but s/containers.conf/nerdctl.toml/

Not all the features need to be implemented at once. Can just begin with the easiest one.

cmd.RegisterFlagCompletionFunc("isolation", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if runtime.GOOS == "windows" {
return []string{"default", "host", "process", "hyperv"}, cobra.ShellCompDirectiveNoFileComp
Expand Down Expand Up @@ -325,6 +326,11 @@ func processCreateCommandFlagsInRun(cmd *cobra.Command) (types.ContainerCreateOp
return opt, err
}

opt.UserNS, err = cmd.Flags().GetString("userns")
if err != nil {
return opt, err
}

validAttachFlag := true
for i, str := range opt.Attach {
opt.Attach[i] = strings.ToUpper(str)
Expand Down
92 changes: 92 additions & 0 deletions cmd/nerdctl/container/container_run_user_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@
package container

import (
"fmt"
"testing"

"github.com/containerd/nerdctl/mod/tigron/require"
"github.com/containerd/nerdctl/mod/tigron/test"
"github.com/containerd/nerdctl/v2/pkg/testutil"
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
"gotest.tools/v3/assert"
)

func TestRunUserGID(t *testing.T) {
Expand Down Expand Up @@ -181,3 +186,90 @@ func TestRunAddGroup_CVE_2023_25173(t *testing.T) {
base.Cmd(cmd...).AssertOutContains(testCase.expected + "\n")
}
}

func TestUsernsMappingRunCmd(t *testing.T) {
nerdtest.Setup()
testCase := &test.Case{
Require: require.All(
nerdtest.AllowModifyUserns,
require.Not(nerdtest.ContainerdV1),
require.Not(nerdtest.Docker)),
SubTests: []*test.Case{
{
Description: "Test container start with valid Userns",
NoParallel: true, // Changes system config so running in non parallel mode
Setup: func(data test.Data, helpers test.Helpers) {
data.Set("validUserns", "nerdctltestuser")
data.Set("expectedHostUID", "123456789")
if err := appendUsernsConfig(data.Get("validUserns"), data.Get("expectedHostUID")); err != nil {
t.Fatalf("Failed to append Userns config: %v", err)
}
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("rm", "-f", data.Identifier())
removeUsernsConfig(t, data.Get("validUserns"), data.Get("expectedHostUID"))
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--tty", "-d", "--userns", data.Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: 0,
Output: func(stdout string, info string, t *testing.T) {
actualHostUID, err := getContainerHostUID(helpers, data.Identifier())
if err != nil {
t.Fatalf("Failed to get container host UID: %v", err)
}
assert.Assert(t, actualHostUID == data.Get("expectedHostUID"), info)
},
}
},
},
{
Description: "Test container network share with valid Userns",
NoParallel: true, // Changes system config so running in non parallel mode
Setup: func(data test.Data, helpers test.Helpers) {
data.Set("validUserns", "nerdctltestuser")
data.Set("expectedHostUID", "123456789")
data.Set("net-container", "net-container")
if err := appendUsernsConfig(data.Get("validUserns"), data.Get("expectedHostUID")); err != nil {
t.Fatalf("Failed to append Userns config: %v", err)
}

helpers.Ensure("run", "--tty", "-d", "--userns", data.Get("validUserns"), "--name", data.Get("net-container"), testutil.NginxAlpineImage)
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("rm", "-f", data.Identifier())
helpers.Anyhow("rm", "-f", data.Get("net-container"))
removeUsernsConfig(t, data.Get("validUserns"), data.Get("expectedHostUID"))
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--tty", "-d", "--userns", data.Get("validUserns"), "--net", fmt.Sprintf("container:%s", data.Get("net-container")), "--name", data.Identifier(), testutil.NginxAlpineImage)
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: 0,
}
},
},
{
Description: "Test container start with invalid Userns",
Setup: func(data test.Data, helpers test.Helpers) {
data.Set("invalidUserns", "invaliduser")
},
Cleanup: func(data test.Data, helpers test.Helpers) {
helpers.Anyhow("rm", "-f", data.Identifier())
},
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
return helpers.Command("run", "--tty", "-d", "--userns", data.Get("invalidUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
},
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
return &test.Expected{
ExitCode: 1,
}
},
},
},
}
testCase.Run(t)
}
Loading
Loading