Skip to content

Commit 068faa5

Browse files
feat: add support for userns
Signed-off-by: Shubharanshu Mahapatra <[email protected]>
1 parent 39058fb commit 068faa5

15 files changed

+1070
-36
lines changed

cmd/nerdctl/container/container_create.go

+7
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,13 @@ func createOptions(cmd *cobra.Command) (types.ContainerCreateOptions, error) {
446446
}
447447
// #endregion
448448

449+
// #region for UserNS
450+
opt.UserNS, err = cmd.Flags().GetString("userns")
451+
if err != nil {
452+
return opt, err
453+
}
454+
// #endregion
455+
449456
return opt, nil
450457
}
451458

cmd/nerdctl/container/container_create_linux_test.go

+212
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@ package container
1919
import (
2020
"errors"
2121
"fmt"
22+
"io"
2223
"os"
24+
"os/exec"
2325
"path/filepath"
26+
"strconv"
2427
"strings"
28+
"syscall"
2529
"testing"
2630

2731
"github.com/opencontainers/go-digest"
2832
"gotest.tools/v3/assert"
33+
"gotest.tools/v3/icmd"
2934

3035
"github.com/containerd/containerd/v2/defaults"
3136
"github.com/containerd/nerdctl/mod/tigron/require"
@@ -325,3 +330,210 @@ func TestCreateFromOCIArchive(t *testing.T) {
325330
base.Cmd("create", "--rm", "--name", containerName, fmt.Sprintf("oci-archive://%s", tarPath)).AssertOK()
326331
base.Cmd("start", "--attach", containerName).AssertOutContains("test-nerdctl-create-from-oci-archive")
327332
}
333+
334+
func TestUsernsMappingCreateCmd(t *testing.T) {
335+
nerdtest.Setup()
336+
337+
testCase := &test.Case{
338+
Require: require.All(
339+
nerdtest.AllowModifyUserns,
340+
require.Not(nerdtest.ContainerdV1),
341+
require.Not(nerdtest.Docker)),
342+
SubTests: []*test.Case{
343+
{
344+
Description: "Test container start with valid Userns",
345+
NoParallel: true, // Changes system config so running in non parallel mode
346+
Setup: func(data test.Data, helpers test.Helpers) {
347+
data.Set("validUserns", "nerdctltestuser")
348+
data.Set("expectedHostUID", "123456789")
349+
// need to be compiled with containerd version >2.0.2 to support multi uidmap and gidmap.
350+
if err := appendUsernsConfig(data.Get("validUserns"), data.Get("expectedHostUID")); err != nil {
351+
t.Fatalf("Failed to append Userns config: %v", err)
352+
}
353+
},
354+
Cleanup: func(data test.Data, helpers test.Helpers) {
355+
removeUsernsConfig(t, data.Get("validUserns"), data.Get("expectedHostUID"))
356+
helpers.Anyhow("rm", "-f", data.Identifier())
357+
},
358+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
359+
helpers.Ensure("create", "--tty", "--userns", data.Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
360+
return helpers.Command("start", data.Identifier())
361+
},
362+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
363+
return &test.Expected{
364+
ExitCode: 0,
365+
Output: func(stdout string, info string, t *testing.T) {
366+
actualHostUID, err := getContainerHostUID(helpers, data.Identifier())
367+
if err != nil {
368+
t.Fatalf("Failed to get container host UID: %v", err)
369+
}
370+
assert.Assert(t, actualHostUID == data.Get("expectedHostUID"), info)
371+
},
372+
}
373+
},
374+
},
375+
{
376+
Description: "Test container start with invalid Userns",
377+
NoParallel: true, // Changes system config so running in non parallel mode
378+
Setup: func(data test.Data, helpers test.Helpers) {
379+
data.Set("invalidUserns", "invaliduser")
380+
},
381+
Cleanup: func(data test.Data, helpers test.Helpers) {
382+
helpers.Anyhow("rm", "-f", data.Identifier())
383+
},
384+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
385+
return helpers.Command("create", "--tty", "--userns", data.Get("invalidUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
386+
},
387+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
388+
return &test.Expected{
389+
ExitCode: 1,
390+
}
391+
},
392+
},
393+
},
394+
}
395+
testCase.Run(t)
396+
}
397+
398+
func runUsernsContainer(t *testing.T, name, Userns, image, cmd string) *icmd.Result {
399+
base := testutil.NewBase(t)
400+
removeContainerArgs := []string{
401+
"rm", "-f", name,
402+
}
403+
base.Cmd(removeContainerArgs...).Run()
404+
405+
args := []string{
406+
"run", "-d", "--userns", Userns, "--name", name, image, "sh", "-c", cmd,
407+
}
408+
return base.Cmd(args...).Run()
409+
}
410+
411+
func getContainerHostUID(helpers test.Helpers, containerName string) (string, error) {
412+
result := helpers.Capture("inspect", "--format", "{{.State.Pid}}", containerName)
413+
pidStr := strings.TrimSpace(result)
414+
pid, err := strconv.Atoi(pidStr)
415+
if err != nil {
416+
return "", fmt.Errorf("invalid PID: %v", err)
417+
}
418+
419+
stat, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
420+
if err != nil {
421+
return "", fmt.Errorf("failed to stat process: %v", err)
422+
}
423+
424+
uid := int(stat.Sys().(*syscall.Stat_t).Uid)
425+
return strconv.Itoa(uid), nil
426+
}
427+
428+
func appendUsernsConfig(Userns string, hostUid string) error {
429+
if err := addUser(Userns, hostUid); err != nil {
430+
return fmt.Errorf("failed to add user %s: %w", Userns, err)
431+
}
432+
433+
entry := fmt.Sprintf("%s:%s:65536\n", Userns, hostUid)
434+
435+
tempDir := os.TempDir()
436+
437+
files := []string{"subuid", "subgid"}
438+
for _, file := range files {
439+
440+
fileBak := fmt.Sprintf("%s/%s.bak", tempDir, file)
441+
defer os.Remove(fileBak)
442+
d, err := os.Create(fileBak)
443+
if err != nil {
444+
return fmt.Errorf("failed to create %s: %w", fileBak, err)
445+
}
446+
447+
s, err := os.Open(fmt.Sprintf("/etc/%s", file))
448+
if err != nil {
449+
return fmt.Errorf("failed to open %s: %w", file, err)
450+
}
451+
defer s.Close()
452+
453+
_, err = io.Copy(d, s)
454+
if err != nil {
455+
return fmt.Errorf("failed to copy %s to %s: %w", file, fileBak, err)
456+
}
457+
458+
f, err := os.OpenFile(fmt.Sprintf("/etc/%s", file), os.O_APPEND|os.O_WRONLY, 0644)
459+
if err != nil {
460+
return fmt.Errorf("failed to open %s: %w", file, err)
461+
}
462+
defer f.Close()
463+
464+
if _, err := f.WriteString(entry); err != nil {
465+
return fmt.Errorf("failed to write to %s: %w", file, err)
466+
}
467+
}
468+
return nil
469+
}
470+
471+
func addUser(username string, hostId string) error {
472+
cmd := exec.Command("groupadd", "-g", hostId, username)
473+
output, err := cmd.CombinedOutput()
474+
if err != nil {
475+
return fmt.Errorf("groupadd failed: %s, %w", string(output), err)
476+
}
477+
cmd = exec.Command("useradd", "-u", hostId, "-g", hostId, "-s", "/bin/false", username)
478+
output, err = cmd.CombinedOutput()
479+
if err != nil {
480+
return fmt.Errorf("useradd failed: %s, %w", string(output), err)
481+
}
482+
return nil
483+
}
484+
485+
func removeUsernsConfig(t *testing.T, Userns string, hostUid string) {
486+
487+
if err := delUser(Userns); err != nil {
488+
t.Logf("failed to del user %s, Error: %s", Userns, err)
489+
}
490+
491+
if err := delGroup(Userns); err != nil {
492+
t.Logf("failed to del group %s, Error: %s", Userns, err)
493+
}
494+
495+
tempDir := os.TempDir()
496+
files := []string{"subuid", "subgid"}
497+
for _, file := range files {
498+
fileBak := fmt.Sprintf("%s/%s.bak", tempDir, file)
499+
s, err := os.Open(fileBak)
500+
if err != nil {
501+
t.Logf("failed to open %s, Error: %s", fileBak, err)
502+
continue
503+
}
504+
defer s.Close()
505+
506+
d, err := os.Open(fmt.Sprintf("/etc/%s", file))
507+
if err != nil {
508+
t.Logf("failed to open %s, Error: %s", file, err)
509+
continue
510+
511+
}
512+
defer d.Close()
513+
514+
_, err = io.Copy(d, s)
515+
if err != nil {
516+
t.Logf("failed to restore. Copy %s to %s failed, Error %s", fileBak, file, err)
517+
continue
518+
}
519+
520+
}
521+
}
522+
523+
func delUser(username string) error {
524+
cmd := exec.Command("sudo", "userdel", username)
525+
output, err := cmd.CombinedOutput()
526+
if err != nil {
527+
return fmt.Errorf("userdel failed: %s, %w", string(output), err)
528+
}
529+
return nil
530+
}
531+
532+
func delGroup(groupname string) error {
533+
cmd := exec.Command("sudo", "groupdel", groupname)
534+
output, err := cmd.CombinedOutput()
535+
if err != nil {
536+
return fmt.Errorf("groupdel failed: %s, %w", string(output), err)
537+
}
538+
return nil
539+
}

cmd/nerdctl/container/container_run.go

+6
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ func setCreateFlags(cmd *cobra.Command) {
287287
cmd.Flags().String("ipfs-address", "", "multiaddr of IPFS API (default uses $IPFS_PATH env variable if defined or local directory ~/.ipfs)")
288288

289289
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")
290+
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")
290291
cmd.RegisterFlagCompletionFunc("isolation", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
291292
if runtime.GOOS == "windows" {
292293
return []string{"default", "host", "process", "hyperv"}, cobra.ShellCompDirectiveNoFileComp
@@ -325,6 +326,11 @@ func processCreateCommandFlagsInRun(cmd *cobra.Command) (types.ContainerCreateOp
325326
return opt, err
326327
}
327328

329+
opt.UserNS, err = cmd.Flags().GetString("userns")
330+
if err != nil {
331+
return opt, err
332+
}
333+
328334
validAttachFlag := true
329335
for i, str := range opt.Attach {
330336
opt.Attach[i] = strings.ToUpper(str)

cmd/nerdctl/container/container_run_user_linux_test.go

+92
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@
1717
package container
1818

1919
import (
20+
"fmt"
2021
"testing"
2122

23+
"github.com/containerd/nerdctl/mod/tigron/require"
24+
"github.com/containerd/nerdctl/mod/tigron/test"
2225
"github.com/containerd/nerdctl/v2/pkg/testutil"
26+
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
27+
"gotest.tools/v3/assert"
2328
)
2429

2530
func TestRunUserGID(t *testing.T) {
@@ -181,3 +186,90 @@ func TestRunAddGroup_CVE_2023_25173(t *testing.T) {
181186
base.Cmd(cmd...).AssertOutContains(testCase.expected + "\n")
182187
}
183188
}
189+
190+
func TestUsernsMappingRunCmd(t *testing.T) {
191+
nerdtest.Setup()
192+
testCase := &test.Case{
193+
Require: require.All(
194+
nerdtest.AllowModifyUserns,
195+
require.Not(nerdtest.ContainerdV1),
196+
require.Not(nerdtest.Docker)),
197+
SubTests: []*test.Case{
198+
{
199+
Description: "Test container start with valid Userns",
200+
NoParallel: true, // Changes system config so running in non parallel mode
201+
Setup: func(data test.Data, helpers test.Helpers) {
202+
data.Set("validUserns", "nerdctltestuser")
203+
data.Set("expectedHostUID", "123456789")
204+
if err := appendUsernsConfig(data.Get("validUserns"), data.Get("expectedHostUID")); err != nil {
205+
t.Fatalf("Failed to append Userns config: %v", err)
206+
}
207+
},
208+
Cleanup: func(data test.Data, helpers test.Helpers) {
209+
helpers.Anyhow("rm", "-f", data.Identifier())
210+
removeUsernsConfig(t, data.Get("validUserns"), data.Get("expectedHostUID"))
211+
},
212+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
213+
return helpers.Command("run", "--tty", "-d", "--userns", data.Get("validUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
214+
},
215+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
216+
return &test.Expected{
217+
ExitCode: 0,
218+
Output: func(stdout string, info string, t *testing.T) {
219+
actualHostUID, err := getContainerHostUID(helpers, data.Identifier())
220+
if err != nil {
221+
t.Fatalf("Failed to get container host UID: %v", err)
222+
}
223+
assert.Assert(t, actualHostUID == data.Get("expectedHostUID"), info)
224+
},
225+
}
226+
},
227+
},
228+
{
229+
Description: "Test container network share with valid Userns",
230+
NoParallel: true, // Changes system config so running in non parallel mode
231+
Setup: func(data test.Data, helpers test.Helpers) {
232+
data.Set("validUserns", "nerdctltestuser")
233+
data.Set("expectedHostUID", "123456789")
234+
data.Set("net-container", "net-container")
235+
if err := appendUsernsConfig(data.Get("validUserns"), data.Get("expectedHostUID")); err != nil {
236+
t.Fatalf("Failed to append Userns config: %v", err)
237+
}
238+
239+
helpers.Ensure("run", "--tty", "-d", "--userns", data.Get("validUserns"), "--name", data.Get("net-container"), testutil.NginxAlpineImage)
240+
},
241+
Cleanup: func(data test.Data, helpers test.Helpers) {
242+
helpers.Anyhow("rm", "-f", data.Identifier())
243+
helpers.Anyhow("rm", "-f", data.Get("net-container"))
244+
removeUsernsConfig(t, data.Get("validUserns"), data.Get("expectedHostUID"))
245+
},
246+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
247+
return helpers.Command("run", "--tty", "-d", "--userns", data.Get("validUserns"), "--net", fmt.Sprintf("container:%s", data.Get("net-container")), "--name", data.Identifier(), testutil.NginxAlpineImage)
248+
},
249+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
250+
return &test.Expected{
251+
ExitCode: 0,
252+
}
253+
},
254+
},
255+
{
256+
Description: "Test container start with invalid Userns",
257+
Setup: func(data test.Data, helpers test.Helpers) {
258+
data.Set("invalidUserns", "invaliduser")
259+
},
260+
Cleanup: func(data test.Data, helpers test.Helpers) {
261+
helpers.Anyhow("rm", "-f", data.Identifier())
262+
},
263+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
264+
return helpers.Command("run", "--tty", "-d", "--userns", data.Get("invalidUserns"), "--name", data.Identifier(), testutil.NginxAlpineImage)
265+
},
266+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
267+
return &test.Expected{
268+
ExitCode: 1,
269+
}
270+
},
271+
},
272+
},
273+
}
274+
testCase.Run(t)
275+
}

0 commit comments

Comments
 (0)