From ab15619826c1020d2197a280481721fb5d9f97ae Mon Sep 17 00:00:00 2001 From: David Son Date: Tue, 8 Oct 2024 22:26:31 +0000 Subject: [PATCH 1/3] Add id-mapped nerdctl to Makefile nerdctl currently does not support ID mapping natively, so this commit adds the code for us to use a patched version of nerdctl that supports this. Once this is supported upstream natively, we can revert this. Signed-off-by: David Son --- Dockerfile | 4 +- Makefile | 23 +- integration/config/nerdctl.patch | 1200 ++++++++++++++++++++++++++++++ 3 files changed, 1224 insertions(+), 3 deletions(-) create mode 100644 integration/config/nerdctl.patch diff --git a/Dockerfile b/Dockerfile index df24750c0..ca6b6f307 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,9 @@ RUN cp $GOPATH/src/github.com/awslabs/soci-snapshotter/out/soci /usr/local/bin/ cp $GOPATH/src/github.com/awslabs/soci-snapshotter/soci-snapshotter.socket /etc/systemd/system RUN curl -sSL --output /tmp/containerd.tgz https://github.com/containerd/containerd/releases/download/v${CONTAINERD_VERSION}/containerd-${CONTAINERD_VERSION}-linux-${TARGETARCH:-amd64}.tar.gz && \ tar zxvf /tmp/containerd.tgz -C /usr/local/ && \ - rm -f /tmp/containerd.tgz + rm -f /tmp/containerd.tgz && \ + cp $GOPATH/src/github.com/awslabs/soci-snapshotter/out/nerdctl-with-idmapping /usr/local/bin && \ + chmod +x /usr/local/bin/nerdctl-with-idmapping RUN curl -sSL --output /tmp/runc https://github.com/opencontainers/runc/releases/download/v${RUNC_VERSION}/runc.${TARGETARCH:-amd64} && \ cp /tmp/runc /usr/local/bin/ && \ chmod +x /usr/local/bin/runc && \ diff --git a/Makefile b/Makefile index f76987f1e..e02428ff9 100644 --- a/Makefile +++ b/Makefile @@ -64,8 +64,12 @@ SOCI_GRPC_PACKAGE_LIST=$(shell echo $(SOCI_LIBRARY_PACKAGE_LIST),$(shell cd $(SO GO_BENCHMARK_TESTS?=. +NERDCTL_REPO = https://github.com/containerd/nerdctl.git +NERDCTL_TAG = v1.7.7 +NERDCTL_PATCH = $(SOCI_SNAPSHOTTER_PROJECT_ROOT)/integration/config/nerdctl.patch + .PHONY: all build check flatc add-ltag install uninstall tidy vendor clean clean-coverage \ - clean-integration test test-with-coverage show-test-coverage show-test-coverage-html \ + clean-integration test test-with-coverage show-test-coverage show-test-coverage-html nerdctl-with-idmapping \ integration integration-with-coverage show-integration-coverage show-integration-coverage-html \ release benchmarks build-benchmarks benchmarks-perf-test benchmarks-comparison-test @@ -162,7 +166,7 @@ $(COVDIR)/unit: $(COVDIR) GO_BUILD_FLAGS="$(GO_BUILD_FLAGS) -coverpkg=$(SOCI_LIBRARY_PACKAGE_LIST)"\ $(MAKE) test -integration: build +integration: build nerdctl-with-idmapping @echo "$@" @echo "SOCI_SNAPSHOTTER_PROJECT_ROOT=$(SOCI_SNAPSHOTTER_PROJECT_ROOT)" @GO111MODULE=$(GO111MODULE_VALUE) SOCI_SNAPSHOTTER_PROJECT_ROOT=$(SOCI_SNAPSHOTTER_PROJECT_ROOT) ENABLE_INTEGRATION_TEST=true go test $(GO_TEST_FLAGS) -v -timeout=0 ./integration @@ -182,6 +186,21 @@ $(COVDIR)/integration: $(COVDIR) GO_BUILD_FLAGS="$(GO_BUILD_FLAGS) -coverpkg=$(SOCI_CLI_PACKAGE_LIST),$(SOCI_GRPC_PACKAGE_LIST)" \ $(MAKE) integration +nerdctl-with-idmapping: $(OUTDIR)/nerdctl-with-idmapping + +$(OUTDIR)/nerdctl-with-idmapping: + # Use a custom patch for testing ID-mapping as nerdctl doesn't fully support this yet. + rm -rf $(SOCI_SNAPSHOTTER_PROJECT_ROOT)/tempfolder + + git clone $(NERDCTL_REPO) $(SOCI_SNAPSHOTTER_PROJECT_ROOT)/tempfolder + cd $(SOCI_SNAPSHOTTER_PROJECT_ROOT)/tempfolder && \ + git checkout $(NERDCTL_TAG) && \ + git apply $(NERDCTL_PATCH) && \ + make && \ + cp _output/nerdctl $(OUTDIR)/nerdctl-with-idmapping && \ + cd ../ + rm -rf $(SOCI_SNAPSHOTTER_PROJECT_ROOT)/tempfolder + release: @echo "$@" @$(SOCI_SNAPSHOTTER_PROJECT_ROOT)/scripts/create-releases.sh $(RELEASE_TAG) diff --git a/integration/config/nerdctl.patch b/integration/config/nerdctl.patch new file mode 100644 index 000000000..e731c4068 --- /dev/null +++ b/integration/config/nerdctl.patch @@ -0,0 +1,1200 @@ +diff --git a/cmd/nerdctl/container_create.go b/cmd/nerdctl/container_create.go +index 60b7de34..cca5aaf4 100644 +--- a/cmd/nerdctl/container_create.go ++++ b/cmd/nerdctl/container_create.go +@@ -390,6 +390,10 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat + } + // #endregion + ++ opt.Userns, err = cmd.Flags().GetString("userns") ++ if err != nil { ++ return ++ } + // #region for image pull and verify options + imageVerifyOpt, err := processImageVerifyOptions(cmd) + if err != nil { +diff --git a/cmd/nerdctl/container_run.go b/cmd/nerdctl/container_run.go +index 5eff72b3..82c60910 100644 +--- a/cmd/nerdctl/container_run.go ++++ b/cmd/nerdctl/container_run.go +@@ -269,6 +269,8 @@ 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") ++ cmd.Flags().Lookup("userns").Hidden = true + 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 +diff --git a/cmd/nerdctl/container_run_linux_test.go b/cmd/nerdctl/container_run_linux_test.go +index 0900489f..d3dd5700 100644 +--- a/cmd/nerdctl/container_run_linux_test.go ++++ b/cmd/nerdctl/container_run_linux_test.go +@@ -25,6 +25,7 @@ import ( + "io" + "net/http" + "os" ++ "os/exec" + "path/filepath" + "strconv" + "strings" +@@ -35,6 +36,7 @@ import ( + "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/containerd/nerdctl/pkg/strutil" + "github.com/containerd/nerdctl/pkg/testutil" ++ "github.com/sirupsen/logrus" + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" + ) +@@ -327,6 +329,158 @@ func TestRunSigProxy(t *testing.T) { + } + } + ++func TestUsernsMapping(t *testing.T) { ++ t.Parallel() ++ ++ image := testutil.CommonImage ++ processCmd := "sleep 30" ++ validUserns := "nerdctltestuser" ++ invalidUserns := "nonexistentuser" ++ expectedHostUID := 123456789 //setting an arbitary number to reduce collision ++ ++ defer removeUsernsConfig(validUserns, expectedHostUID) ++ ++ t.Run("validUserns", func(t *testing.T) { ++ if err := appendUsernsConfig(validUserns, expectedHostUID); err != nil { ++ t.Fatalf("Failed to append userns config: %v", err) ++ } ++ ++ containerName := testutil.Identifier(t) ++ defer removeContainer(t, containerName) ++ ++ result := runUsernsContainer(t, containerName, validUserns, image, processCmd) ++ fmt.Printf(result.Combined()) ++ assert.Assert(t, result.ExitCode == 0) ++ ++ actualHostUID, err := getContainerHostUID(t, containerName) ++ if err != nil { ++ t.Fatalf("Failed to get container host UID: %v", err) ++ } ++ ++ if actualHostUID != expectedHostUID { ++ t.Fatalf("Expected host UID %d, got %d", expectedHostUID, actualHostUID) ++ } ++ ++ t.Logf("Valid userns test passed: container mapped to host UID %d", actualHostUID) ++ }) ++ ++ t.Run("invalidUserns", func(t *testing.T) { ++ containerName := testutil.Identifier(t) ++ ++ result := runUsernsContainer(t, containerName, invalidUserns, image, processCmd) ++ assert.Assert(t, result.ExitCode != 0) ++ ++ }) ++} ++ ++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(t *testing.T, containerName string) (int, error) { ++ base := testutil.NewBase(t) ++ result := base.Cmd("inspect", "--format", "{{.State.Pid}}", containerName).Run() ++ if result.Error != nil { ++ return 0, fmt.Errorf("failed to get container PID: %v", result.Error) ++ } ++ ++ pidStr := strings.TrimSpace(result.Stdout()) ++ pid, err := strconv.Atoi(pidStr) ++ if err != nil { ++ return 0, fmt.Errorf("invalid PID: %v", err) ++ } ++ ++ stat, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) ++ if err != nil { ++ return 0, fmt.Errorf("failed to stat process: %v", err) ++ } ++ ++ uid := int(stat.Sys().(*syscall.Stat_t).Uid) ++ return uid, nil ++} ++ ++func appendUsernsConfig(userns string, hostUid int) error { ++ if err := addUser(userns, hostUid); err != nil { ++ return fmt.Errorf("failed to add user %s: %w", userns, err) ++ } ++ ++ entry := fmt.Sprintf("%s:%d:65536\n", userns, hostUid) ++ ++ files := []string{"/etc/subuid", "/etc/subgid"} ++ for _, file := range files { ++ f, err := os.OpenFile(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 int) error { ++ cmd := exec.Command("sudo", "groupadd", "-g", strconv.Itoa(hostId), username) ++ output, err := cmd.CombinedOutput() ++ if err != nil { ++ return fmt.Errorf("groupadd failed: %s, %w", string(output), err) ++ } ++ cmd = exec.Command("sudo", "useradd", "-u", strconv.Itoa(hostId), "-g", strconv.Itoa(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(userns string, hostUid int) { ++ if err := delUser(userns); err != nil { ++ logrus.Errorf("failed to del user %s", userns) ++ return ++ } ++ ++ entry := fmt.Sprintf("%s:%d:65536\n", userns, hostUid) ++ ++ files := []string{"/etc/subuid", "/etc/subgid"} ++ for _, file := range files { ++ content, err := os.ReadFile(file) ++ if err != nil { ++ logrus.Errorf("Failed to read %s: %v", file, err) ++ continue ++ } ++ ++ newContent := strings.ReplaceAll(string(content), entry, "") ++ if err := os.WriteFile(file, []byte(newContent), 0644); err != nil { ++ logrus.Errorf("Failed to write to %s: %v", file, err) ++ } ++ } ++} ++ ++func delUser(username string) error { ++ cmd := exec.Command("sudo", "userdel", username) ++ output, err := cmd.CombinedOutput() ++ if err != nil { ++ return fmt.Errorf("userdel failed: %s, %w", string(output), err) ++ } ++ return nil ++} ++ ++func removeContainer(t *testing.T, name string) { ++ base := testutil.NewBase(t) ++ base.Cmd("rm", "-f", name).Run() ++} ++ + func TestRunWithFluentdLogDriver(t *testing.T) { + base := testutil.NewBase(t) + tempDirectory := t.TempDir() +diff --git a/go.mod b/go.mod +index e43c1699..c2c92cea 100644 +--- a/go.mod ++++ b/go.mod +@@ -38,6 +38,7 @@ require ( + github.com/ipfs/go-cid v0.4.1 + github.com/mattn/go-isatty v0.0.20 + github.com/mitchellh/mapstructure v1.5.0 ++ github.com/moby/moby v27.3.1+incompatible + github.com/moby/sys/mount v0.3.3 + github.com/moby/sys/signal v0.7.0 + github.com/moby/term v0.5.0 +@@ -50,6 +51,7 @@ require ( + github.com/rootless-containers/rootlesskit v1.1.1 + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 ++ github.com/stretchr/testify v1.8.4 + github.com/tidwall/gjson v1.17.0 + github.com/vishvananda/netlink v1.2.1-beta.2 + github.com/vishvananda/netns v0.0.4 +@@ -65,6 +67,11 @@ require ( + gotest.tools/v3 v3.5.1 + ) + ++require ( ++ github.com/davecgh/go-spew v1.1.1 // indirect ++ github.com/pmezard/go-difflib v1.0.0 // indirect ++) ++ + require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect +diff --git a/go.sum b/go.sum +index 723f2a36..de61f612 100644 +--- a/go.sum ++++ b/go.sum +@@ -212,6 +212,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= + github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= + github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= ++github.com/moby/moby v27.3.1+incompatible h1:KQbXBjo7PavKpzIl7UkHT31y9lw/e71Uvrqhr4X+zMA= ++github.com/moby/moby v27.3.1+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= + github.com/moby/sys/mount v0.3.3 h1:fX1SVkXFJ47XWDoeFW4Sq7PdQJnV2QIDZAqjNqgEjUs= + github.com/moby/sys/mount v0.3.3/go.mod h1:PBaEorSNTLG5t/+4EgukEQVlAvVEc6ZjTySwKdqp5K0= + github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= +diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go +index b9e64757..fc11e7e0 100644 +--- a/pkg/api/types/container_types.go ++++ b/pkg/api/types/container_types.go +@@ -259,6 +259,9 @@ type ContainerCreateOptions struct { + + // ImagePullOpt specifies image pull options which holds the ImageVerifyOptions for verifying the image. + ImagePullOpt ImagePullOptions ++ ++ // Userns name for user namespace mapping of container ++ Userns string + } + + // ContainerStopOptions specifies options for `nerdctl (container) stop`. +diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go +index 43f5e682..953c07b1 100644 +--- a/pkg/cmd/container/create.go ++++ b/pkg/cmd/container/create.go +@@ -128,6 +128,26 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa + opts = append(opts, rootfsOpts...) + cOpts = append(cOpts, rootfsCOpts...) + ++ if options.Userns != "" { ++ userNameSpaceOpts, userNameSpaceCOpts, err := getUserNamespaceOpts(ctx, client, &options, *ensuredImage, id) ++ if err != nil { ++ return nil, nil, err ++ } ++ opts = append(opts, userNameSpaceOpts...) ++ cOpts = append(cOpts, userNameSpaceCOpts...) ++ ++ userNsOpts, err := getContainerUserNamespaceNetOpts(ctx, client, netManager) ++ if err != nil { ++ return nil, nil, err ++ } ++ if userNsOpts != nil { ++ opts = append(opts, userNsOpts...) ++ } ++ ++ } else { ++ cOpts = append(cOpts, containerd.WithNewSnapshot(id, ensuredImage.Image)) ++ } ++ + if options.Workdir != "" { + opts = append(opts, oci.WithProcessCwd(options.Workdir)) + } +@@ -316,7 +336,6 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, + cOpts = append(cOpts, + containerd.WithImage(ensured.Image), + containerd.WithSnapshotter(ensured.Snapshotter), +- containerd.WithNewSnapshot(id, ensured.Image), + containerd.WithImageStopSignal(ensured.Image, "SIGTERM"), + ) + +diff --git a/pkg/cmd/container/create_userns_opts_linux.go b/pkg/cmd/container/create_userns_opts_linux.go +new file mode 100644 +index 00000000..d9e903ce +--- /dev/null ++++ b/pkg/cmd/container/create_userns_opts_linux.go +@@ -0,0 +1,305 @@ ++/* ++ Copyright The containerd Authors. ++ ++ 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 container ++ ++import ( ++ "context" ++ "errors" ++ "fmt" ++ "strings" ++ ++ "github.com/containerd/containerd" ++ "github.com/containerd/containerd/oci" ++ "github.com/containerd/containerd/snapshots" ++ "github.com/containerd/nerdctl/pkg/api/types" ++ "github.com/containerd/nerdctl/pkg/containerutil" ++ "github.com/containerd/nerdctl/pkg/idutil/containerwalker" ++ "github.com/containerd/nerdctl/pkg/imgutil" ++ "github.com/containerd/nerdctl/pkg/netutil/nettype" ++ nerdctlUserns "github.com/containerd/nerdctl/pkg/userns" ++ "github.com/moby/moby/pkg/idtools" ++ "github.com/opencontainers/runtime-spec/specs-go" ++) ++ ++const ( ++ capabMultiRemapIDs = "multi-remap-ids" ++) ++ ++// getUserNamespaceOpts generates spec opts and container opts for usernamespace ++func getUserNamespaceOpts( ++ ctx context.Context, ++ client *containerd.Client, ++ options *types.ContainerCreateOptions, ++ ensuredImage imgutil.EnsuredImage, ++ id string, ++) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { ++ ++ idMapping, err := loadAndValidateIDMapping(options.Userns) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ supportsMultiRemap, err := checkSnapshotterSupport(ctx, client, ensuredImage.Snapshotter) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ uidMaps, gidMaps := convertMappings(idMapping) ++ specOpts := []oci.SpecOpts{oci.WithUserNamespace(uidMaps, gidMaps)} ++ ++ snapshotOpts, err := createSnapshotOpts(id, ensuredImage, uidMaps, gidMaps, supportsMultiRemap) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ return specOpts, snapshotOpts, nil ++} ++ ++// getContainerUserNamespaceNetOpts retrieves the user namespace opts for the specified network container. ++func getContainerUserNamespaceNetOpts( ++ ctx context.Context, ++ client *containerd.Client, ++ netManager containerutil.NetworkOptionsManager, ++) ([]oci.SpecOpts, error) { ++ netOpts, err := netManager.InternalNetworkingOptionLabels(ctx) ++ netType, err := nettype.Detect(netOpts.NetworkSlice) ++ if err != nil { ++ return nil, err ++ } else if netType != nettype.Host { ++ return []oci.SpecOpts{}, nil ++ ++ } ++ ++ containerName, err := getContainerNameFromNetworkSlice(netOpts) ++ if err != nil { ++ return nil, err ++ } ++ ++ container, err := findContainer(ctx, client, containerName) ++ if err != nil { ++ return nil, err ++ } ++ ++ if err := validateContainerStatus(ctx, container); err != nil { ++ return nil, err ++ } ++ ++ userNsPath, err := getUserNamespacePath(ctx, container) ++ if err != nil { ++ return nil, err ++ } ++ ++ var userNameSpaceSpecOpts []oci.SpecOpts ++ userNameSpaceSpecOpts = append(userNameSpaceSpecOpts, oci.WithLinuxNamespace(specs.LinuxNamespace{ ++ Type: specs.UserNamespace, ++ Path: userNsPath, ++ })) ++ return userNameSpaceSpecOpts, nil ++} ++ ++func convertIDMapToLinuxIDMapping(idMaps []idtools.IDMap) []specs.LinuxIDMapping { ++ linuxIDMappings := make([]specs.LinuxIDMapping, len(idMaps)) ++ ++ for i, idMap := range idMaps { ++ linuxIDMappings[i] = specs.LinuxIDMapping{ ++ ContainerID: uint32(idMap.ContainerID), ++ HostID: uint32(idMap.HostID), ++ Size: uint32(idMap.Size), ++ } ++ } ++ ++ return linuxIDMappings ++} ++ ++// withMultiRemapperLabels creates the labels used by any supporting snapshotter ++// to shift the filesystem ownership with multiple ranges of maps ++func withMultiRemapperLabels(uidmaps, gidmaps []specs.LinuxIDMapping) snapshots.Opt { ++ idMap := nerdctlUserns.IDMap{ ++ UidMap: uidmaps, ++ GidMap: gidmaps, ++ } ++ uidmapLabel, gidmapLabel := idMap.Marshal() ++ return snapshots.WithLabels(map[string]string{ ++ snapshots.LabelSnapshotUIDMapping: uidmapLabel, ++ snapshots.LabelSnapshotGIDMapping: gidmapLabel, ++ }) ++} ++ ++// findContainer searches for a container by name and returns it if found. ++func findContainer( ++ ctx context.Context, ++ client *containerd.Client, ++ containerName string, ++) (containerd.Container, error) { ++ var container containerd.Container ++ ++ walker := &containerwalker.ContainerWalker{ ++ Client: client, ++ OnFound: func(_ context.Context, found containerwalker.Found) error { ++ if found.MatchCount > 1 { ++ return fmt.Errorf("multiple containers found with prefix: %s", containerName) ++ } ++ container = found.Container ++ return nil ++ }, ++ } ++ ++ if n, err := walker.Walk(ctx, containerName); err != nil { ++ return container, err ++ } else if n == 0 { ++ return container, fmt.Errorf("container not found: %s", containerName) ++ } ++ ++ return container, nil ++} ++ ++// validateContainerStatus checks if the container is running. ++func validateContainerStatus(ctx context.Context, container containerd.Container) error { ++ task, err := container.Task(ctx, nil) ++ if err != nil { ++ return err ++ } ++ ++ status, err := task.Status(ctx) ++ if err != nil { ++ return err ++ } ++ ++ if status.Status != containerd.Running { ++ return fmt.Errorf("container %s is not running", container.ID()) ++ } ++ ++ return nil ++} ++ ++// getUserNamespacePath returns the path to the container's user namespace. ++func getUserNamespacePath(ctx context.Context, container containerd.Container) (string, error) { ++ task, err := container.Task(ctx, nil) ++ if err != nil { ++ return "", err ++ } ++ ++ return fmt.Sprintf("/proc/%d/ns/user", task.Pid()), nil ++} ++ ++// Creates default snapshot options. ++func createDefaultSnapshotOpts(id string, image imgutil.EnsuredImage) []containerd.NewContainerOpts { ++ return []containerd.NewContainerOpts{ ++ containerd.WithNewSnapshot(id, image.Image), ++ } ++} ++ ++// Loads and validates the ID mapping from the given Userns. ++func loadAndValidateIDMapping(userns string) (idtools.IdentityMapping, error) { ++ idMapping, err := idtools.LoadIdentityMapping(userns) ++ if err != nil { ++ return idtools.IdentityMapping{}, err ++ } ++ if !validIDMapping(idMapping) { ++ return idtools.IdentityMapping{}, errors.New("no valid UID/GID mappings found") ++ } ++ return idMapping, nil ++} ++ ++// Checks if the snapshotter supports multi-remap IDs. ++func checkSnapshotterSupport( ++ ctx context.Context, ++ client *containerd.Client, ++ snapshotter string, ++) (bool, error) { ++ return snapshotterSupportsMultiRemap(ctx, client, snapshotter) ++} ++ ++// Validates that both UID and GID mappings are available. ++func validIDMapping(mapping idtools.IdentityMapping) bool { ++ return len(mapping.UIDMaps) > 0 && len(mapping.GIDMaps) > 0 ++} ++ ++// Converts IDMapping into LinuxIDMapping structures. ++func convertMappings(mapping idtools.IdentityMapping) ([]specs.LinuxIDMapping, []specs.LinuxIDMapping) { ++ return convertIDMapToLinuxIDMapping(mapping.UIDMaps), ++ convertIDMapToLinuxIDMapping(mapping.GIDMaps) ++} ++ ++// Creates snapshot options based on ID mappings and snapshotter capabilities. ++func createSnapshotOpts( ++ id string, ++ image imgutil.EnsuredImage, ++ uidMaps, gidMaps []specs.LinuxIDMapping, ++ supportsMultiRemap bool, ++) ([]containerd.NewContainerOpts, error) { ++ if !isValidMapping(uidMaps, gidMaps) { ++ return nil, errors.New("snapshotter uidmap gidmap config invalid") ++ } ++ if isMultiMapping(uidMaps, gidMaps) { ++ if supportsMultiRemap { ++ return []containerd.NewContainerOpts{ ++ containerd.WithNewSnapshot(id, image.Image, withMultiRemapperLabels(uidMaps, gidMaps)), ++ }, nil ++ } ++ return nil, errors.New("snapshotter doesn't support multiple UID/GID remapping") ++ } ++ return []containerd.NewContainerOpts{ ++ containerd.WithNewSnapshot(id, image.Image, ++ containerd.WithRemapperLabels(0, uidMaps[0].HostID, 0, gidMaps[0].HostID, uidMaps[0].Size)), ++ }, nil ++} ++ ++// Checks if there are multiple mappings available. ++func isMultiMapping(uidMaps, gidMaps []specs.LinuxIDMapping) bool { ++ return len(uidMaps) > 1 || len(gidMaps) > 1 ++} ++ ++func isValidMapping(uidMaps, gidMaps []specs.LinuxIDMapping) bool { ++ return len(uidMaps) > 0 && len(gidMaps) > 0 ++} ++ ++// Helper function to check if the snapshotter supports multi-remap IDs. ++func snapshotterSupportsMultiRemap( ++ ctx context.Context, ++ client *containerd.Client, ++ snapshotterName string, ++) (bool, error) { ++ caps, err := client.GetSnapshotterCapabilities(ctx, snapshotterName) ++ if err != nil { ++ return false, err ++ } ++ return hasCapability(caps, capabMultiRemapIDs), nil ++} ++ ++// Checks if the given capability exists in the list. ++func hasCapability(caps []string, capability string) bool { ++ for _, cap := range caps { ++ if cap == capability { ++ return true ++ } ++ } ++ return false ++} ++ ++func getContainerNameFromNetworkSlice(netOpts types.NetworkOptions) (string, error) { ++ ++ netItems := strings.Split(netOpts.NetworkSlice[0], ":") ++ if len(netItems) < 2 { ++ return "", fmt.Errorf("container networking argument format must be 'container:', got: %q", netOpts.NetworkSlice[0]) ++ } else if len(netItems[1]) == 0 { ++ return "", fmt.Errorf("container name length invald, got length: 0") ++ } ++ containerName := netItems[1] ++ ++ return containerName, nil ++} +diff --git a/pkg/cmd/container/create_userns_opts_linux_test.go b/pkg/cmd/container/create_userns_opts_linux_test.go +new file mode 100644 +index 00000000..913eb4a9 +--- /dev/null ++++ b/pkg/cmd/container/create_userns_opts_linux_test.go +@@ -0,0 +1,143 @@ ++package container ++ ++import ( ++ "testing" ++ ++ "github.com/containerd/nerdctl/pkg/api/types" ++ "github.com/containerd/nerdctl/pkg/imgutil" ++ "github.com/opencontainers/runtime-spec/specs-go" ++ "github.com/stretchr/testify/assert" ++) ++ ++// TestCreateSnapshotOpts tests the createSnapshotOpts function. ++func TestCreateSnapshotOpts(t *testing.T) { ++ tests := []struct { ++ name string ++ id string ++ image imgutil.EnsuredImage ++ uidMaps []specs.LinuxIDMapping ++ gidMaps []specs.LinuxIDMapping ++ supportsMultiRemap bool ++ expectError bool ++ }{ ++ { ++ name: "Single remapping", ++ id: "container1", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ }, ++ gidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ }, ++ supportsMultiRemap: false, ++ expectError: false, ++ }, ++ { ++ name: "Multi remapping with support", ++ id: "container2", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ {HostID: 2000, Size: 1}, ++ }, ++ gidMaps: []specs.LinuxIDMapping{ ++ {HostID: 3000, Size: 1}, ++ }, ++ supportsMultiRemap: true, ++ expectError: false, ++ }, ++ { ++ name: "Multi remapping without support", ++ id: "container3", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ {HostID: 2000, Size: 1}, ++ }, ++ gidMaps: []specs.LinuxIDMapping{ ++ {HostID: 3000, Size: 1}, ++ }, ++ supportsMultiRemap: false, ++ expectError: true, ++ }, ++ { ++ name: "Empty UID/GID maps", ++ id: "container4", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{}, ++ gidMaps: []specs.LinuxIDMapping{}, ++ supportsMultiRemap: false, ++ expectError: true, ++ }, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ opts, err := createSnapshotOpts(tt.id, tt.image, tt.uidMaps, tt.gidMaps, tt.supportsMultiRemap) ++ ++ if tt.expectError { ++ assert.Error(t, err) ++ } else { ++ assert.NoError(t, err) ++ assert.NotNil(t, opts) ++ } ++ }) ++ } ++} ++ ++// TestGetContainerNameFromNetworkSlice tests the getContainerNameFromNetworkSlice function. ++func TestGetContainerNameFromNetworkSlice(t *testing.T) { ++ tests := []struct { ++ name string ++ netOpts types.NetworkOptions ++ expected string ++ expectError bool ++ }{ ++ { ++ name: "Valid input with container name", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{"container:mycontainer"}, ++ }, ++ expected: "mycontainer", ++ expectError: false, ++ }, ++ { ++ name: "Invalid input with no colon separator", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{"container-mycontainer"}, ++ }, ++ expected: "", ++ expectError: true, ++ }, ++ { ++ name: "Empty NetworkSlice", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{""}, ++ }, ++ expected: "", ++ expectError: true, ++ }, ++ { ++ name: "Missing container name", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{"container:"}, ++ }, ++ expected: "", ++ expectError: true, ++ }, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ containerName, err := getContainerNameFromNetworkSlice(tt.netOpts) ++ ++ if tt.expectError { ++ assert.Error(t, err) ++ } else { ++ assert.NoError(t, err) ++ assert.Equal(t, tt.expected, containerName) ++ } ++ }) ++ } ++} +diff --git a/pkg/userns/idmap.go b/pkg/userns/idmap.go +new file mode 100644 +index 00000000..caa3c311 +--- /dev/null ++++ b/pkg/userns/idmap.go +@@ -0,0 +1,171 @@ ++/* ++ Copyright The containerd Authors. ++ ++ 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. ++*/ ++ ++/* ++ This file is copied and customized based on ++ https://github.com/moby/moby/blob/master/pkg/idtools/idtools.go ++*/ ++ ++package userns ++ ++import ( ++ "errors" ++ "fmt" ++ "strings" ++ ++ "github.com/opencontainers/runtime-spec/specs-go" ++) ++ ++const invalidID = 1<<32 - 1 ++ ++var invalidUser = User{Uid: invalidID, Gid: invalidID} ++ ++// User is a Uid and Gid pair of a user ++// ++//nolint:revive ++type User struct { ++ Uid uint32 ++ Gid uint32 ++} ++ ++// IDMap contains the mappings of Uids and Gids. ++// ++//nolint:revive ++type IDMap struct { ++ UidMap []specs.LinuxIDMapping `json:"UidMap"` ++ GidMap []specs.LinuxIDMapping `json:"GidMap"` ++} ++ ++// RootPair returns the ID pair for the root user ++func (i *IDMap) RootPair() (User, error) { ++ uid, err := toHost(0, i.UidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ gid, err := toHost(0, i.GidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ return User{Uid: uid, Gid: gid}, nil ++} ++ ++// ToHost returns the host user ID pair for the container ID pair. ++func (i IDMap) ToHost(pair User) (User, error) { ++ var ( ++ target User ++ err error ++ ) ++ target.Uid, err = toHost(pair.Uid, i.UidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ target.Gid, err = toHost(pair.Gid, i.GidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ return target, nil ++} ++ ++// toHost takes an id mapping and a remapped ID, and translates the ++// ID to the mapped host ID. If no map is provided, then the translation ++// assumes a 1-to-1 mapping and returns the passed in id # ++func toHost(contID uint32, idMap []specs.LinuxIDMapping) (uint32, error) { ++ if idMap == nil { ++ return contID, nil ++ } ++ for _, m := range idMap { ++ high, err := safeSum(m.ContainerID, m.Size) ++ if err != nil { ++ break ++ } ++ if contID >= m.ContainerID && contID < high { ++ hostID, err := safeSum(m.HostID, contID-m.ContainerID) ++ if err != nil || hostID == invalidID { ++ break ++ } ++ return hostID, nil ++ } ++ } ++ return invalidID, fmt.Errorf("container ID %d cannot be mapped to a host ID", contID) ++} ++ ++// safeSum returns the sum of x and y. or an error if the result overflows ++func safeSum(x, y uint32) (uint32, error) { ++ z := x + y ++ if z < x || z < y { ++ return invalidID, errors.New("ID overflow") ++ } ++ return z, nil ++} ++ ++func (i *IDMap) Marshal() (string, string) { ++ marshal := func(mappings []specs.LinuxIDMapping) string { ++ var arr []string ++ for _, m := range mappings { ++ arr = append(arr, serializeLinuxIDMapping(m)) ++ } ++ return strings.Join(arr, ",") ++ } ++ return marshal(i.UidMap), marshal(i.GidMap) ++} ++ ++func (i *IDMap) Unmarshal(uidMap, gidMap string) error { ++ unmarshal := func(str string, fn func(m specs.LinuxIDMapping)) error { ++ if len(str) == 0 { ++ return nil ++ } ++ for _, mapping := range strings.Split(str, ",") { ++ m, err := deserializeLinuxIDMapping(mapping) ++ if err != nil { ++ return err ++ } ++ fn(m) ++ } ++ return nil ++ } ++ if err := unmarshal(uidMap, func(m specs.LinuxIDMapping) { ++ i.UidMap = append(i.UidMap, m) ++ }); err != nil { ++ return err ++ } ++ return unmarshal(gidMap, func(m specs.LinuxIDMapping) { ++ i.GidMap = append(i.GidMap, m) ++ }) ++} ++ ++// serializeLinuxIDMapping marshals a LinuxIDMapping object to string ++func serializeLinuxIDMapping(m specs.LinuxIDMapping) string { ++ return fmt.Sprintf("%d:%d:%d", m.ContainerID, m.HostID, m.Size) ++} ++ ++// deserializeLinuxIDMapping unmarshals a string to a LinuxIDMapping object ++func deserializeLinuxIDMapping(str string) (specs.LinuxIDMapping, error) { ++ var ( ++ hostID, ctrID, length int64 ++ ) ++ _, err := fmt.Sscanf(str, "%d:%d:%d", &ctrID, &hostID, &length) ++ if err != nil { ++ return specs.LinuxIDMapping{}, fmt.Errorf("input value %s unparsable: %w", str, err) ++ } ++ if ctrID < 0 || ctrID >= invalidID || hostID < 0 || hostID >= invalidID || length < 0 || length >= invalidID { ++ return specs.LinuxIDMapping{}, fmt.Errorf("invalid mapping \"%s\"", str) ++ } ++ return specs.LinuxIDMapping{ ++ ContainerID: uint32(ctrID), ++ HostID: uint32(hostID), ++ Size: uint32(length), ++ }, nil ++} +diff --git a/pkg/userns/idmap_test.go b/pkg/userns/idmap_test.go +new file mode 100644 +index 00000000..30375ad6 +--- /dev/null ++++ b/pkg/userns/idmap_test.go +@@ -0,0 +1,252 @@ ++/* ++ Copyright The containerd Authors. ++ ++ 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 userns ++ ++import ( ++ "testing" ++ ++ "github.com/opencontainers/runtime-spec/specs-go" ++ "github.com/stretchr/testify/assert" ++) ++ ++func TestToHost(t *testing.T) { ++ idmap := IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1, ++ Size: 2, ++ }, ++ { ++ ContainerID: 2, ++ HostID: 4, ++ Size: 1000, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 2, ++ Size: 4, ++ }, ++ { ++ ContainerID: 4, ++ HostID: 8, ++ Size: 1000, ++ }, ++ }, ++ } ++ for _, test := range []struct { ++ container User ++ host User ++ }{ ++ { ++ container: User{ ++ Uid: 0, ++ Gid: 0, ++ }, ++ host: User{ ++ Uid: 1, ++ Gid: 2, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 1, ++ Gid: 1, ++ }, ++ host: User{ ++ Uid: 2, ++ Gid: 3, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 2, ++ Gid: 4, ++ }, ++ host: User{ ++ Uid: 4, ++ Gid: 8, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 100, ++ Gid: 200, ++ }, ++ host: User{ ++ Uid: 102, ++ Gid: 204, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 1001, ++ Gid: 1003, ++ }, ++ host: User{ ++ Uid: 1003, ++ Gid: 1007, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 1004, ++ Gid: 1008, ++ }, ++ host: invalidUser, ++ }, ++ { ++ container: User{ ++ Uid: 2000, ++ Gid: 2000, ++ }, ++ host: invalidUser, ++ }, ++ } { ++ r, err := idmap.ToHost(test.container) ++ assert.Equal(t, test.host, r) ++ if r == invalidUser { ++ assert.Error(t, err) ++ } else { ++ assert.NoError(t, err) ++ } ++ } ++} ++ ++func TestToHostOverflow(t *testing.T) { ++ for _, test := range []struct { ++ idmap IDMap ++ user User ++ }{ ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 1<<32 - 1000, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 1<<32 - 100, ++ Gid: 0, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 1<<32 - 1000, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 0, ++ Gid: 1<<32 - 100, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 1<<32 - 2, ++ Gid: 0, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 0, ++ Gid: 1<<32 - 2, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 1<<32 - 2, ++ Gid: 1<<32 - 2, ++ }, ++ }, ++ } { ++ r, err := test.idmap.ToHost(test.user) ++ assert.Error(t, err) ++ assert.Equal(t, r, invalidUser) ++ } ++} From e390d8534948a4d7d86bb8bc348214b2e614d446 Mon Sep 17 00:00:00 2001 From: David Son Date: Thu, 26 Sep 2024 20:01:50 +0000 Subject: [PATCH 2/3] Add idtools package Taken from containerd commit 83aaa89, this adds the necessary tools to add idmapping capabilities to SOCI. Signed-off-by: David Son --- idtools/idmap.go | 169 +++++++++++++++++++++++++++ idtools/idmap_test.go | 266 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 idtools/idmap.go create mode 100644 idtools/idmap_test.go diff --git a/idtools/idmap.go b/idtools/idmap.go new file mode 100644 index 000000000..d5d677285 --- /dev/null +++ b/idtools/idmap.go @@ -0,0 +1,169 @@ +/* + Copyright The Soci Snapshotter Authors. + + 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. +*/ + +/* + Copyright The containerd Authors. + 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. +*/ + +// Copied from https://github.com/containerd/containerd/blob/2ca3ff87255a4aa6b4244cb942033d45b6d44546/internal/userns/idmap.go + +/* + This file is copied and customized based on + https://github.com/moby/moby/blob/master/pkg/idtools/idtools.go +*/ + +package idtools + +import ( + "errors" + "fmt" + "strings" + + "github.com/opencontainers/runtime-spec/specs-go" +) + +const invalidID = 1<<32 - 1 + +var invalidUser = User{Uid: invalidID, Gid: invalidID} + +// User is a Uid and Gid pair of a user +// +//nolint:revive +type User struct { + Uid uint32 + Gid uint32 +} + +// IDMap contains the mappings of Uids and Gids. +// +//nolint:revive +type IDMap struct { + UidMap []specs.LinuxIDMapping `json:"UidMap"` + GidMap []specs.LinuxIDMapping `json:"GidMap"` +} + +// ToHost returns the host user ID pair for the container ID pair. +func (i IDMap) ToHost(pair User) (User, error) { + var ( + target User + err error + ) + + if i.Empty() { + return pair, nil + } + + target.Uid, err = toHost(pair.Uid, i.UidMap) + if err != nil { + return invalidUser, err + } + target.Gid, err = toHost(pair.Gid, i.GidMap) + if err != nil { + return invalidUser, err + } + return target, nil +} + +// Empty returns true if there are no id mappings +func (i IDMap) Empty() bool { + return len(i.UidMap) == 0 && len(i.GidMap) == 0 +} + +// toHost takes an id mapping and a remapped ID, and translates the +// ID to the mapped host ID. If no map is provided, then the translation +// assumes a 1-to-1 mapping and returns the passed in id # +func toHost(contID uint32, idMap []specs.LinuxIDMapping) (uint32, error) { + if idMap == nil { + return contID, nil + } + for _, m := range idMap { + high, err := safeSum(m.ContainerID, m.Size) + if err != nil { + break + } + if contID >= m.ContainerID && contID < high { + hostID, err := safeSum(m.HostID, contID-m.ContainerID) + if err != nil || hostID == invalidID { + break + } + return hostID, nil + } + } + return invalidID, fmt.Errorf("container ID %d cannot be mapped to a host ID", contID) +} + +// Unmarshal deserialize the passed uidmap and gidmap strings +// into a IDMap object. Error is returned in case of failure +func (i *IDMap) Unmarshal(uidMap, gidMap string) error { + unmarshal := func(str string, fn func(m specs.LinuxIDMapping)) error { + if len(str) == 0 { + return nil + } + for _, mapping := range strings.Split(str, ",") { + m, err := deserializeLinuxIDMapping(mapping) + if err != nil { + return err + } + fn(m) + } + return nil + } + if err := unmarshal(uidMap, func(m specs.LinuxIDMapping) { + i.UidMap = append(i.UidMap, m) + }); err != nil { + return err + } + return unmarshal(gidMap, func(m specs.LinuxIDMapping) { + i.GidMap = append(i.GidMap, m) + }) +} + +// deserializeLinuxIDMapping unmarshals a string to a LinuxIDMapping object +func deserializeLinuxIDMapping(str string) (specs.LinuxIDMapping, error) { + var ( + hostID, ctrID, length int64 + ) + _, err := fmt.Sscanf(str, "%d:%d:%d", &ctrID, &hostID, &length) + if err != nil { + return specs.LinuxIDMapping{}, fmt.Errorf("input value %s unparsable: %w", str, err) + } + if ctrID < 0 || ctrID >= invalidID || hostID < 0 || hostID >= invalidID || length < 0 || length >= invalidID { + return specs.LinuxIDMapping{}, fmt.Errorf("invalid mapping \"%s\"", str) + } + return specs.LinuxIDMapping{ + ContainerID: uint32(ctrID), + HostID: uint32(hostID), + Size: uint32(length), + }, nil +} + +// safeSum returns the sum of x and y. or an error if the result overflows +func safeSum(x, y uint32) (uint32, error) { + z := x + y + if z < x || z < y { + return invalidID, errors.New("ID overflow") + } + return z, nil +} diff --git a/idtools/idmap_test.go b/idtools/idmap_test.go new file mode 100644 index 000000000..8d1b5756c --- /dev/null +++ b/idtools/idmap_test.go @@ -0,0 +1,266 @@ +/* + Copyright The Soci Snapshotter Authors. + + 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. +*/ + +/* + Copyright The containerd Authors. + 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. +*/ + +// Copied from https://github.com/containerd/containerd/blob/2ca3ff87255a4aa6b4244cb942033d45b6d44546/internal/userns/idmap_test.go + +package idtools + +import ( + "testing" + + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" +) + +func TestToHost(t *testing.T) { + idmap := IDMap{ + UidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1, + Size: 2, + }, + { + ContainerID: 2, + HostID: 4, + Size: 1000, + }, + }, + GidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 2, + Size: 4, + }, + { + ContainerID: 4, + HostID: 8, + Size: 1000, + }, + }, + } + for _, test := range []struct { + container User + host User + }{ + { + container: User{ + Uid: 0, + Gid: 0, + }, + host: User{ + Uid: 1, + Gid: 2, + }, + }, + { + container: User{ + Uid: 1, + Gid: 1, + }, + host: User{ + Uid: 2, + Gid: 3, + }, + }, + { + container: User{ + Uid: 2, + Gid: 4, + }, + host: User{ + Uid: 4, + Gid: 8, + }, + }, + { + container: User{ + Uid: 100, + Gid: 200, + }, + host: User{ + Uid: 102, + Gid: 204, + }, + }, + { + container: User{ + Uid: 1001, + Gid: 1003, + }, + host: User{ + Uid: 1003, + Gid: 1007, + }, + }, + { + container: User{ + Uid: 1004, + Gid: 1008, + }, + host: invalidUser, + }, + { + container: User{ + Uid: 2000, + Gid: 2000, + }, + host: invalidUser, + }, + } { + r, err := idmap.ToHost(test.container) + assert.Equal(t, test.host, r) + if r == invalidUser { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + } +} +func TestToHostOverflow(t *testing.T) { + for _, test := range []struct { + idmap IDMap + user User + }{ + { + idmap: IDMap{ + UidMap: []specs.LinuxIDMapping{ + { + ContainerID: 1<<32 - 1000, + HostID: 1000, + Size: 10000, + }, + }, + GidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1000, + Size: 10000, + }, + }, + }, + user: User{ + Uid: 1<<32 - 100, + Gid: 0, + }, + }, + { + idmap: IDMap{ + UidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1000, + Size: 10000, + }, + }, + GidMap: []specs.LinuxIDMapping{ + { + ContainerID: 1<<32 - 1000, + HostID: 1000, + Size: 10000, + }, + }, + }, + user: User{ + Uid: 0, + Gid: 1<<32 - 100, + }, + }, + { + idmap: IDMap{ + UidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1000, + Size: 1<<32 - 1, + }, + }, + GidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1000, + Size: 1<<32 - 1, + }, + }, + }, + user: User{ + Uid: 1<<32 - 2, + Gid: 0, + }, + }, + { + idmap: IDMap{ + UidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1000, + Size: 1<<32 - 1, + }, + }, + GidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1000, + Size: 1<<32 - 1, + }, + }, + }, + user: User{ + Uid: 0, + Gid: 1<<32 - 2, + }, + }, + { + idmap: IDMap{ + UidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1, + Size: 1<<32 - 1, + }, + }, + GidMap: []specs.LinuxIDMapping{ + { + ContainerID: 0, + HostID: 1, + Size: 1<<32 - 1, + }, + }, + }, + user: User{ + Uid: 1<<32 - 2, + Gid: 1<<32 - 2, + }, + }, + } { + r, err := test.idmap.ToHost(test.user) + assert.Error(t, err) + assert.Equal(t, r, invalidUser) + } +} From 4a2234de3c6325a3c7ead04e50a2f81954291b75 Mon Sep 17 00:00:00 2001 From: David Son Date: Thu, 6 Jun 2024 16:42:48 +0000 Subject: [PATCH 3/3] Add support for idmapped layers This commit adds ID mapping functionality in SOCI. ID mapping is enabled if the correct labels are passed through. To avoid having containerd handle the ID mapping, we must declare in the containerd config file that the snapshotter supports ID mapping. Note that usage of this feature requires proxy plugins to have capabilities, which is only supported in containerd v1.7.23 onwards. Signed-off-by: David Son --- fs/fs.go | 113 +++++++++++++++--- fs/fs_test.go | 11 +- fs/layer/layer.go | 7 +- fs/layer/node.go | 39 ++++--- fs/layer/node_test.go | 3 +- fs/layer/util_test.go | 3 +- fs/source/source.go | 3 + go.mod | 2 +- idtools/idmap.go | 66 +++++++++++ integration/run_test.go | 235 ++++++++++++++++++++++++++++++++++++++ integration/util_test.go | 1 + snapshot/snapshot.go | 122 +++++++++++++++++++- snapshot/snapshot_test.go | 17 +++ 13 files changed, 571 insertions(+), 51 deletions(-) diff --git a/fs/fs.go b/fs/fs.go index 3f3dbbb58..e5edd18a7 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -45,9 +45,13 @@ package fs import ( "context" "fmt" + "io" golog "log" "net/http" + "os" "os/exec" + "path/filepath" + "strings" "sync" "syscall" "time" @@ -59,6 +63,7 @@ import ( layermetrics "github.com/awslabs/soci-snapshotter/fs/metrics/layer" "github.com/awslabs/soci-snapshotter/fs/remote" "github.com/awslabs/soci-snapshotter/fs/source" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/awslabs/soci-snapshotter/metadata" "github.com/awslabs/soci-snapshotter/snapshot" "github.com/awslabs/soci-snapshotter/soci" @@ -67,6 +72,7 @@ import ( ctdsnapshotters "github.com/containerd/containerd/pkg/snapshotters" "github.com/containerd/containerd/reference" "github.com/containerd/containerd/remotes/docker" + "github.com/containerd/errdefs" "github.com/containerd/log" metrics "github.com/docker/go-metrics" fusefs "github.com/hanwen/go-fuse/v2/fs" @@ -455,6 +461,58 @@ func (fs *filesystem) getSociContext(ctx context.Context, imageRef, indexDigest, return c, err } +func getIDMappedMountpoint(mountpoint, activeLayerID string) string { + d := filepath.Dir(mountpoint) + return filepath.Join(fmt.Sprintf("%s_%s", d, activeLayerID), "fs") +} + +func (fs *filesystem) IDMapMount(ctx context.Context, mountpoint, activeLayerID string, idmapper idtools.IDMap) (string, error) { + newMountpoint := getIDMappedMountpoint(mountpoint, activeLayerID) + logger := log.G(ctx).WithField("mountpoint", newMountpoint) + + logger.Debug("creating remote id-mapped mount") + if err := os.Mkdir(filepath.Dir(newMountpoint), 0700); err != nil { + return "", err + } + if err := os.Mkdir(newMountpoint, 0755); err != nil { + return "", err + } + + fs.layerMu.Lock() + l := fs.layer[mountpoint] + if l == nil { + fs.layerMu.Unlock() + logger.Error("failed to create remote id-mapped mount") + return "", errdefs.ErrNotFound + } + fs.layer[newMountpoint] = l + fs.layerMu.Unlock() + node, err := l.RootNode(0, idmapper) + if err != nil { + return "", err + } + + fuseLogger := log.L. + WithField("mountpoint", mountpoint). + WriterLevel(logrus.TraceLevel) + + return newMountpoint, fs.setupFuseServer(ctx, newMountpoint, node, l, fuseLogger, nil) +} + +func (fs *filesystem) IDMapMountLocal(ctx context.Context, mountpoint, activeLayerID string, idmapper idtools.IDMap) (string, error) { + newMountpoint := getIDMappedMountpoint(mountpoint, activeLayerID) + logger := log.G(ctx).WithField("mountpoint", newMountpoint) + + logger.Debug("creating local id-mapped mount") + if err := idtools.RemapDir(ctx, mountpoint, newMountpoint, idmapper); err != nil { + logger.WithError(err).Error("failed to create local mount") + return "", err + } + + logger.Debug("successfully created local mountpoint") + return newMountpoint, nil +} + func (fs *filesystem) Mount(ctx context.Context, mountpoint string, labels map[string]string) (retErr error) { // Setting the start time to measure the Mount operation duration. start := time.Now() @@ -560,7 +618,7 @@ func (fs *filesystem) Mount(ctx context.Context, mountpoint string, labels map[s } }() - node, err := l.RootNode(0) + node, err := l.RootNode(0, idtools.IDMap{}) if err != nil { log.G(ctx).WithError(err).Warnf("Failed to get root node") retErr = fmt.Errorf("failed to get root node: %w", err) @@ -577,6 +635,17 @@ func (fs *filesystem) Mount(ctx context.Context, mountpoint string, labels map[s fs.layerMu.Unlock() fs.metricsController.Add(mountpoint, l) + // Pass in a logger to go-fuse with the layer digest + // The go-fuse logs are useful for tracing exactly what's happening at the fuse level. + fuseLogger := log.L. + WithField("layerDigest", labels[ctdsnapshotters.TargetLayerDigestLabel]). + WriterLevel(logrus.TraceLevel) + + retErr = fs.setupFuseServer(ctx, mountpoint, node, l, fuseLogger, c) + return +} + +func (fs *filesystem) setupFuseServer(ctx context.Context, mountpoint string, node fusefs.InodeEmbedder, l layer.Layer, logger *io.PipeWriter, c *sociContext) error { // mount the node to the specified mountpoint // TODO: bind mount the state directory as a read-only fs on snapshotter's side rawFS := fusefs.NewNodeFS(node, &fusefs.Options{ @@ -585,11 +654,6 @@ func (fs *filesystem) Mount(ctx context.Context, mountpoint string, labels map[s NegativeTimeout: &fs.negativeTimeout, NullPermissions: true, }) - // Pass in a logger to go-fuse with the layer digest - // The go-fuse logs are useful for tracing exactly what's happening at the fuse level. - logger := log.L. - WithField("layerDigest", labels[ctdsnapshotters.TargetLayerDigestLabel]). - WriterLevel(logrus.TraceLevel) mountOpts := &fuse.MountOptions{ AllowOther: true, // allow users other than root&mounter to access fs FsName: "soci", // name this filesystem as "soci" @@ -600,25 +664,26 @@ func (fs *filesystem) Mount(ctx context.Context, mountpoint string, labels map[s if _, err := exec.LookPath(fusermountBin); err == nil { mountOpts.Options = []string{"suid"} // option for fusermount; allow setuid inside container } else { - log.G(ctx).WithError(err).Infof("%s not installed; trying direct mount", fusermountBin) + log.G(ctx).WithField("binary", fusermountBin).WithError(err).Info("fusermount binary not installed; trying direct mount") mountOpts.DirectMount = true } server, err := fuse.NewServer(rawFS, mountpoint, mountOpts) if err != nil { - log.G(ctx).WithError(err).Debug("failed to make filesystem server") - retErr = err - return + log.G(ctx).WithError(err).Error("failed to make filesystem server") + return err } go server.Serve() - // Send a signal to the background fetcher that a new image is being mounted - // and to pause all background fetches. - c.bgFetchPauseOnce.Do(func() { - if fs.bgFetcher != nil { - fs.bgFetcher.Pause() - } - }) + if c != nil { + // Send a signal to the background fetcher that a new image is being mounted + // and to pause all background fetches. + c.bgFetchPauseOnce.Do(func() { + if fs.bgFetcher != nil { + fs.bgFetcher.Pause() + } + }) + } return server.WaitMount() } @@ -681,6 +746,11 @@ func (fs *filesystem) check(ctx context.Context, l layer.Layer, labels map[strin return rErr } +func isIDMappedDir(mountpoint string) bool { + dirName := filepath.Base(mountpoint) + return len(strings.Split(dirName, "_")) > 1 +} + func (fs *filesystem) Unmount(ctx context.Context, mountpoint string) error { fs.layerMu.Lock() l, ok := fs.layer[mountpoint] @@ -688,8 +758,13 @@ func (fs *filesystem) Unmount(ctx context.Context, mountpoint string) error { fs.layerMu.Unlock() return fmt.Errorf("specified path %q isn't a mountpoint", mountpoint) } - delete(fs.layer, mountpoint) // unregisters the corresponding layer - l.Done() + + delete(fs.layer, mountpoint) + // If the mountpoint is an id-mapped layer, it is pointing to the + // underlying layer, so we cannot call done on it. + if !isIDMappedDir(mountpoint) { + l.Done() + } fs.layerMu.Unlock() fs.metricsController.Remove(mountpoint) // The goroutine which serving the mountpoint possibly becomes not responding. diff --git a/fs/fs_test.go b/fs/fs_test.go index 2591a270a..119ec9c2c 100644 --- a/fs/fs_test.go +++ b/fs/fs_test.go @@ -46,6 +46,7 @@ import ( "github.com/awslabs/soci-snapshotter/fs/layer" "github.com/awslabs/soci-snapshotter/fs/remote" "github.com/awslabs/soci-snapshotter/fs/source" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/containerd/containerd/reference" "github.com/containerd/containerd/remotes/docker" fusefs "github.com/hanwen/go-fuse/v2/fs" @@ -83,10 +84,12 @@ func (l *breakableLayer) Info() layer.Info { Size: 1, } } -func (l *breakableLayer) DisableXAttrs() bool { return false } -func (l *breakableLayer) RootNode(uint32) (fusefs.InodeEmbedder, error) { return nil, nil } -func (l *breakableLayer) Verify(tocDigest digest.Digest) error { return nil } -func (l *breakableLayer) SkipVerify() {} +func (l *breakableLayer) DisableXAttrs() bool { return false } +func (l *breakableLayer) RootNode(uint32, idtools.IDMap) (fusefs.InodeEmbedder, error) { + return nil, nil +} +func (l *breakableLayer) Verify(tocDigest digest.Digest) error { return nil } +func (l *breakableLayer) SkipVerify() {} func (l *breakableLayer) ReadAt([]byte, int64, ...remote.Option) (int, error) { return 0, fmt.Errorf("fail") } diff --git a/fs/layer/layer.go b/fs/layer/layer.go index 5d802fee9..d5ef17fe9 100644 --- a/fs/layer/layer.go +++ b/fs/layer/layer.go @@ -58,6 +58,7 @@ import ( "github.com/awslabs/soci-snapshotter/fs/remote" spanmanager "github.com/awslabs/soci-snapshotter/fs/span-manager" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/awslabs/soci-snapshotter/metadata" "github.com/awslabs/soci-snapshotter/soci" "github.com/awslabs/soci-snapshotter/util/lrucache" @@ -86,7 +87,7 @@ type Layer interface { Info() Info // RootNode returns the root node of this layer. - RootNode(baseInode uint32) (fusefs.InodeEmbedder, error) + RootNode(baseInode uint32, idMapper idtools.IDMap) (fusefs.InodeEmbedder, error) // Check checks if the layer is still connectable. Check() error @@ -456,11 +457,11 @@ func (l *layerRef) Done() { l.done() } -func (l *layer) RootNode(baseInode uint32) (fusefs.InodeEmbedder, error) { +func (l *layer) RootNode(baseInode uint32, idMapper idtools.IDMap) (fusefs.InodeEmbedder, error) { if l.isClosed() { return nil, fmt.Errorf("layer is already closed") } - return newNode(l.desc.Digest, l.r, l.blob, baseInode, l.resolver.overlayOpaqueType, l.resolver.config.LogFuseOperations, l.fuseOperationCounter) + return newNode(l.desc.Digest, l.r, l.blob, baseInode, l.resolver.overlayOpaqueType, l.resolver.config.LogFuseOperations, l.fuseOperationCounter, idMapper) } func (l *layer) ReadAt(p []byte, offset int64, opts ...remote.Option) (int, error) { diff --git a/fs/layer/node.go b/fs/layer/node.go index 6459c1a48..553a073d7 100644 --- a/fs/layer/node.go +++ b/fs/layer/node.go @@ -56,6 +56,7 @@ import ( commonmetrics "github.com/awslabs/soci-snapshotter/fs/metrics/common" "github.com/awslabs/soci-snapshotter/fs/reader" "github.com/awslabs/soci-snapshotter/fs/remote" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/awslabs/soci-snapshotter/metadata" "github.com/containerd/log" fusefs "github.com/hanwen/go-fuse/v2/fs" @@ -189,7 +190,7 @@ func (f *FuseOperationCounter) Run(ctx context.Context) { // logFSOperations may cause sensitive information to be emitted to logs // e.g. filenames and paths within an image -func newNode(layerDgst digest.Digest, r reader.Reader, blob remote.Blob, baseInode uint32, opaque OverlayOpaqueType, logFSOperations bool, opCounter *FuseOperationCounter) (fusefs.InodeEmbedder, error) { +func newNode(layerDgst digest.Digest, r reader.Reader, blob remote.Blob, baseInode uint32, opaque OverlayOpaqueType, logFSOperations bool, opCounter *FuseOperationCounter, idMapper idtools.IDMap) (fusefs.InodeEmbedder, error) { rootID := r.Metadata().RootID() rootAttr, err := r.Metadata().GetAttr(rootID) if err != nil { @@ -210,9 +211,10 @@ func newNode(layerDgst digest.Digest, r reader.Reader, blob remote.Blob, baseIno } ffs.s = ffs.newState(layerDgst, blob) return &node{ - id: rootID, - attr: rootAttr, - fs: ffs, + id: rootID, + attr: rootAttr, + fs: ffs, + idMapper: idMapper, }, nil } @@ -272,9 +274,10 @@ func (fs *fs) inodeOfID(id uint32) (uint64, error) { // node is a filesystem inode abstraction. type node struct { fusefs.Inode - fs *fs - id uint32 - attr metadata.Attr + fs *fs + id uint32 + attr metadata.Attr + idMapper idtools.IDMap ents []fuse.DirEntry entsCached bool @@ -407,14 +410,14 @@ func (n *node) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fu n.fs.reportFailure(fuseOpLookup, fmt.Errorf("%s: %v", fuseOpLookup, err)) return nil, syscall.EIO } - entryToAttr(ino, tn.attr, &out.Attr) + n.entryToAttr(ino, tn.attr, &out.Attr) case *whiteout: ino, err := n.fs.inodeOfID(tn.id) if err != nil { n.fs.reportFailure(fuseOpLookup, fmt.Errorf("%s: %v", fuseOpLookup, err)) return nil, syscall.EIO } - entryToAttr(ino, tn.attr, &out.Attr) + n.entryToAttr(ino, tn.attr, &out.Attr) default: n.fs.reportFailure(fuseOpLookup, fmt.Errorf("%s: unknown node type detected", fuseOpLookup)) return nil, syscall.EIO @@ -463,10 +466,11 @@ func (n *node) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fu return nil, syscall.EIO } return n.NewInode(ctx, &node{ - id: id, - fs: n.fs, - attr: ce, - }, entryToAttr(ino, ce, &out.Attr)), 0 + id: id, + fs: n.fs, + attr: ce, + idMapper: n.idMapper, + }, n.entryToAttr(ino, ce, &out.Attr)), 0 } var _ = (fusefs.NodeOpener)((*node)(nil)) @@ -495,7 +499,7 @@ func (n *node) Getattr(ctx context.Context, f fusefs.FileHandle, out *fuse.AttrO n.fs.reportFailure(fuseOpGetattr, fmt.Errorf("%s: %v", fuseOpGetattr, err)) return syscall.EIO } - entryToAttr(ino, n.attr, &out.Attr) + n.entryToAttr(ino, n.attr, &out.Attr) return 0 } @@ -594,7 +598,7 @@ func (f *file) Getattr(ctx context.Context, out *fuse.AttrOut) syscall.Errno { f.n.fs.reportFailure(fuseOpFileGetattr, fmt.Errorf("%s: %v", fuseOpFileGetattr, err)) return syscall.EIO } - entryToAttr(ino, f.n.attr, &out.Attr) + f.n.entryToAttr(ino, f.n.attr, &out.Attr) return 0 } @@ -797,7 +801,7 @@ func (sf *statFile) updateStatUnlocked() ([]byte, error) { } // entryToAttr converts metadata.Attr to go-fuse's Attr. -func entryToAttr(ino uint64, e metadata.Attr, out *fuse.Attr) fusefs.StableAttr { +func (n *node) entryToAttr(ino uint64, e metadata.Attr, out *fuse.Attr) fusefs.StableAttr { out.Ino = ino out.Size = uint64(e.Size) if e.Mode&os.ModeSymlink != 0 { @@ -808,7 +812,8 @@ func entryToAttr(ino uint64, e metadata.Attr, out *fuse.Attr) fusefs.StableAttr mtime := e.ModTime out.SetTimes(nil, &mtime, nil) out.Mode = fileModeToSystemMode(e.Mode) - out.Owner = fuse.Owner{Uid: uint32(e.UID), Gid: uint32(e.GID)} + mappedID, _ := n.idMapper.ToHost(idtools.User{Uid: uint32(e.UID), Gid: uint32(e.GID)}) + out.Owner = fuse.Owner{Uid: mappedID.Uid, Gid: mappedID.Gid} out.Rdev = uint32(unix.Mkdev(uint32(e.DevMajor), uint32(e.DevMinor))) out.Nlink = uint32(e.NumLink) if out.Nlink == 0 { diff --git a/fs/layer/node_test.go b/fs/layer/node_test.go index 432b3b66e..9f12e6810 100644 --- a/fs/layer/node_test.go +++ b/fs/layer/node_test.go @@ -50,7 +50,8 @@ func TestEntryToAttr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { var actual fuse.Attr - entryToAttr(0, tc.attr, &actual) + var n node + n.entryToAttr(0, tc.attr, &actual) tc.expected.Mtime = actual.Mtime if actual != tc.expected { t.Fatalf("unexpected fuse attr. actual %v expected %v", actual, tc.expected) diff --git a/fs/layer/util_test.go b/fs/layer/util_test.go index 2fec52d89..dcb24420f 100644 --- a/fs/layer/util_test.go +++ b/fs/layer/util_test.go @@ -56,6 +56,7 @@ import ( "github.com/awslabs/soci-snapshotter/fs/reader" "github.com/awslabs/soci-snapshotter/fs/remote" spanmanager "github.com/awslabs/soci-snapshotter/fs/span-manager" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/awslabs/soci-snapshotter/metadata" "github.com/awslabs/soci-snapshotter/util/testutil" "github.com/awslabs/soci-snapshotter/ztoc" @@ -360,7 +361,7 @@ func hasSize(name string, size int) check { } func getRootNode(t *testing.T, r reader.Reader, opaque OverlayOpaqueType) *node { - rootNode, err := newNode(testStateLayerDigest, &testReader{r}, &testBlobState{10, 5}, 100, opaque, false, nil) + rootNode, err := newNode(testStateLayerDigest, &testReader{r}, &testBlobState{10, 5}, 100, opaque, false, nil, idtools.IDMap{}) if err != nil { t.Fatalf("failed to get root node: %v", err) } diff --git a/fs/source/source.go b/fs/source/source.go index ce394d840..9da0b36b2 100644 --- a/fs/source/source.go +++ b/fs/source/source.go @@ -84,6 +84,9 @@ const ( // TargetSociIndexDigestLabel is a label which contains the digest of the soci index. TargetSociIndexDigestLabel = "containerd.io/snapshot/remote/soci.index.digest" + + // HasSociIndexDigest is a label that tells if the layer was pulled with a SOCI index. + HasSociIndexDigest = "containerd.io/snapshot/remote/has.soci.index.digest" ) // RegistryHosts is copied from [github.com/awslabs/soci-snapshotter/service/resolver.RegistryHosts] diff --git a/go.mod b/go.mod index 520b1de5b..74d0df687 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/prometheus/client_golang v1.20.5 github.com/rs/xid v1.6.0 github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.9.0 go.etcd.io/bbolt v1.3.11 golang.org/x/crypto v0.28.0 golang.org/x/sync v0.8.0 @@ -90,7 +91,6 @@ require ( github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.9.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect go.opentelemetry.io/otel v1.21.0 // indirect diff --git a/idtools/idmap.go b/idtools/idmap.go index d5d677285..33d7c632b 100644 --- a/idtools/idmap.go +++ b/idtools/idmap.go @@ -37,10 +37,17 @@ package idtools import ( + "context" "errors" "fmt" + "os" + "os/exec" + "path/filepath" "strings" + "syscall" + "github.com/containerd/containerd/mount" + "github.com/containerd/containerd/snapshots" "github.com/opencontainers/runtime-spec/specs-go" ) @@ -64,6 +71,18 @@ type IDMap struct { GidMap []specs.LinuxIDMapping `json:"GidMap"` } +func LoadIDMap(id string, labels map[string]string) (IDMap, error) { + var idmap IDMap + uidmapJSON, okUID := labels[snapshots.LabelSnapshotUIDMapping] + gidmapJSON, okGID := labels[snapshots.LabelSnapshotGIDMapping] + if okUID && okGID { + if err := idmap.Unmarshal(uidmapJSON, gidmapJSON); err != nil { + return IDMap{}, err + } + } + return idmap, nil +} + // ToHost returns the host user ID pair for the container ID pair. func (i IDMap) ToHost(pair User) (User, error) { var ( @@ -167,3 +186,50 @@ func safeSum(x, y uint32) (uint32, error) { } return z, nil } + +func RemapDir(ctx context.Context, originalMountpoint, newMountpoint string, idMap IDMap) error { + idmappedSnapshotBase := filepath.Dir(newMountpoint) + if err := os.Mkdir(idmappedSnapshotBase, 0755); err != nil { + return err + } + + // Use --no-preserve=links to avoid copying hardlinks + // (Copying hardlinks results in issues with chown as + // it will attempt to chown twice on the same inode) + if err := exec.Command("cp", "-a", "--no-preserve=links", originalMountpoint, idmappedSnapshotBase).Run(); err != nil { + return err + } + return filepath.Walk(newMountpoint, chown(idMap)) +} + +func RemapRoot(ctx context.Context, root string, idMap IDMap) error { + return filepath.Walk(root, chown(idMap)) +} + +func RemapRootFS(ctx context.Context, mounts []mount.Mount, idmap IDMap) error { + return mount.WithTempMount(ctx, mounts, func(root string) error { + return filepath.Walk(root, chown(idmap)) + }) +} + +func chown(idMap IDMap) filepath.WalkFunc { + return func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + stat := info.Sys().(*syscall.Stat_t) + h, cerr := idMap.ToHost(User{Uid: stat.Uid, Gid: stat.Gid}) + if cerr != nil { + return cerr + } + // be sure the lchown the path as to not de-reference the symlink to a host file + if cerr = os.Lchown(path, int(h.Uid), int(h.Gid)); cerr != nil { + return cerr + } + // we must retain special permissions such as setuid, setgid and sticky bits + if mode := info.Mode(); mode&os.ModeSymlink == 0 && mode&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 { + return os.Chmod(path, mode) + } + return nil + } +} diff --git a/integration/run_test.go b/integration/run_test.go index 260eb7a56..0992b0a3a 100644 --- a/integration/run_test.go +++ b/integration/run_test.go @@ -35,8 +35,10 @@ package integration import ( "bufio" "bytes" + "errors" "fmt" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -520,3 +522,236 @@ func TestRunInNamespace(t *testing.T) { } } } + +func TestRunWithIdMap(t *testing.T) { + type checker struct { + path string + + // All expected UID/GIDs should be of length 5, + // so make sure to left pad any UIDs/GIDs to + // be strings of length 5 + expectedUIDOnHost string + expectedGIDOnHost string + expectedUIDInContainer string + expectedGIDInContainer string + } + + baseSnapshotDir := "/var/lib/soci-snapshotter-grpc/snapshotter/snapshots" + baseRuntimeDir := "/run/containerd/io.containerd.runtime.v2.task/default" + + uidPath := "/etc/subuid" + gidPath := "/etc/subgid" + + dummyuser := "dummy-user" + dummygroup := "dummy-group" + + minContainerdVersion := "v1.7.23" + shouldSkip := func(version string) bool { + semVer := strings.Replace(version, "v", "", 1) + onlySemVer := strings.Split(semVer, "-")[0] // remove rc tag if exists + onlySemVerArr := strings.Split(onlySemVer, ".") + + major, _ := strconv.Atoi(onlySemVerArr[0]) + minor, _ := strconv.Atoi(onlySemVerArr[1]) + patch, _ := strconv.Atoi(onlySemVerArr[2]) + + switch { + case major >= 2 || minor >= 8: + return false + case minor == 7 && patch >= 23: + return false + default: + return true + } + } + + checkUIDGID := func(stat, uid, gid string) error { + if len(uid) < 5 || len(gid) < 5 { + return errors.New("UID or GID not a string of length 5") + } + + matchUID := fmt.Sprintf("Uid: (%s", uid) + if !strings.Contains(stat, matchUID) { + return fmt.Errorf("expected UID: %s; actual UID: %s", matchUID, uid) + } + + matchGID := fmt.Sprintf("Uid: (%s", gid) + if !strings.Contains(stat, matchGID) { + return fmt.Errorf("expected GID: %s; actual GID: %s", matchGID, gid) + } + + return nil + } + + modes := []struct { + name string + indexBuilderFn func(sh *shell.Shell, src imageInfo, opts ...indexBuildOption) string + }{ + { + name: "with only FUSE layers", + indexBuilderFn: func(sh *shell.Shell, src imageInfo, opts ...indexBuildOption) string { + opts = append(opts, withMinLayerSize(0)) + return buildIndex(sh, src, opts...) + }, + }, + { + name: "with mixed layers", + indexBuilderFn: func(sh *shell.Shell, src imageInfo, opts ...indexBuildOption) string { + return buildIndex(sh, src, opts...) + }, + }, + { + name: "with no SOCI index", + indexBuilderFn: func(sh *shell.Shell, src imageInfo, opts ...indexBuildOption) string { + return "" + }, + }, + } + + tests := []struct { + name string + imageName string + subUIDContents string + subGIDContents string + checkFiles []checker + }{ + { + name: "with one set of substitutions", + imageName: rabbitmqImage, + subUIDContents: fmt.Sprintf("%s:12345:1001", dummyuser), + subGIDContents: fmt.Sprintf("%s:12345:1001", dummyuser), + checkFiles: []checker{ + { + path: "/usr/bin/sh", + expectedUIDOnHost: "12345", + expectedGIDOnHost: "12345", + expectedUIDInContainer: " 0", + expectedGIDInContainer: " 0", + }, + }, + }, + { + name: "with multiple substitutions", + // This version of ubuntu has a default "ubuntu" user, so we can use that + // to ensure that the user there has proper multi-uid/gid perms done + // Note that as the ubuntu image is one layer, the mixed layer use case will + // effectively act the same as the only FUSE layer usecase. + // Maybe we can find a more suitable image for this test. + imageName: "ubuntu:24.04", + subUIDContents: fmt.Sprintf("%s:12345:1000\n%s:22222:1", dummyuser, dummyuser), + subGIDContents: fmt.Sprintf("%s:12345:1000\n%s:22222:1", dummyuser, dummyuser), + checkFiles: []checker{ + { + path: "/usr/bin/sh", + expectedUIDOnHost: "12345", + expectedGIDOnHost: "12345", + expectedUIDInContainer: " 0", + expectedGIDInContainer: " 0", + }, + { + path: "/home/ubuntu", + expectedUIDOnHost: "22222", + expectedGIDOnHost: "22222", + expectedUIDInContainer: " 1000", + expectedGIDInContainer: " 1000", + }, + }, + }, + } + + for _, mode := range modes { + mode := mode + for _, tt := range tests { + tt := tt + t.Run(tt.name+" "+mode.name, func(t *testing.T) { + regConfig := newRegistryConfig() + sh, done := newShellWithRegistry(t, regConfig) + defer done() + + arr := strings.Split(string(sh.O("containerd", "--version")), " ") + if len(arr) < 3 { + t.Fatal("error parsing containerd version") + } + + // index 2 contains the semantic version when running 'containerd --version' + ver := arr[2] + if shouldSkip(ver) { + t.Skipf("version %s is less than %s, skipping", ver, minContainerdVersion) + } + + sh.X("groupadd", "-g", "12345", dummygroup) + sh.X("useradd", "-u", "12345", "-g", "12345", "-m", dummyuser) + + sh.Pipe(nil, shell.C("echo", tt.subUIDContents), shell.C("tee", uidPath)) + sh.Pipe(nil, shell.C("echo", tt.subGIDContents), shell.C("tee", gidPath)) + + rebootContainerd(t, sh, getContainerdConfigToml(t, false), getSnapshotterConfigToml(t, false)) + imageInfo := dockerhub(tt.imageName) + sh.X("nerdctl", "pull", "-q", imageInfo.ref) + + filenames, err := sh.OLog("ls", baseSnapshotDir) + if err != nil { + t.Fatalf("error listing files in %s", baseSnapshotDir) + } + + // Copy image, remove blobs, and re-pull with SOCI + mirrorInfo := regConfig.mirror(imageInfo.ref) + copyImage(sh, imageInfo, mirrorInfo) + indexDigest := mode.indexBuilderFn(sh, mirrorInfo) + if indexDigest != "" { + sh.X("soci", "push", "--user", regConfig.creds(), mirrorInfo.ref) + } + sh.X("rm", "-rf", filepath.Join(store.DefaultSociContentStorePath, "blobs", "sha256")) + + pullCmd := imagePullCmd + if indexDigest != "" { + pullCmd = append(pullCmd, "--soci-index-digest", indexDigest) + } + sh.X(append(pullCmd, mirrorInfo.ref)...) + containerID := strings.TrimSpace(string(sh.O("nerdctl-with-idmapping", "run", "-d", + "--net", "none", + "--pull", "never", + "--userns", dummyuser, + "--snapshotter", "soci", + imageInfo.ref, "sleep", "infinity", + ))) + + newFilenames, err := sh.OLog("ls", baseSnapshotDir) + if err != nil { + t.Fatalf("error listing files in %s", baseSnapshotDir) + } + + if len(filenames) == len(newFilenames) { + t.Fatalf("error: id-mapping failed") + } + + for _, check := range tt.checkFiles { + // Check UID/GID on host + fullCheckPath := filepath.Join(baseRuntimeDir, containerID, "rootfs", check.path) + statHost, err := sh.OLog("stat", fullCheckPath) + if err != nil { + t.Fatalf("error stat files in %s", fullCheckPath) + } + strstatHost := string(statHost) + + err = checkUIDGID(strstatHost, check.expectedUIDOnHost, check.expectedGIDOnHost) + if err != nil { + t.Fatalf("incorrect IDs on host at %s: %v", check.path, err) + } + + // Check UID/GID in container + statContainer, err := sh.OLog("nerdctl", "exec", containerID, "stat", check.path) + if err != nil { + t.Fatalf("error stat files in %s", fullCheckPath) + } + strStatContainer := string(statContainer) + + err = checkUIDGID(strStatContainer, check.expectedUIDInContainer, check.expectedGIDInContainer) + if err != nil { + t.Fatalf("incorrect IDs in container at %s: %v", check.path, err) + } + } + }) + } + } +} diff --git a/integration/util_test.go b/integration/util_test.go index 60c3f823f..7c3f38475 100644 --- a/integration/util_test.go +++ b/integration/util_test.go @@ -106,6 +106,7 @@ const proxySnapshotterConfig = ` [proxy_plugins.soci] type = "snapshot" address = "/run/soci-snapshotter-grpc/soci-snapshotter-grpc.sock" + capabilities = ["multi-remap-ids", "remap-ids"] ` const containerdConfigTemplate = ` diff --git a/snapshot/snapshot.go b/snapshot/snapshot.go index f770b5b5b..b1207bf75 100644 --- a/snapshot/snapshot.go +++ b/snapshot/snapshot.go @@ -40,10 +40,12 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" commonmetrics "github.com/awslabs/soci-snapshotter/fs/metrics/common" "github.com/awslabs/soci-snapshotter/fs/source" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/containerd/containerd/mount" ctdsnapshotters "github.com/containerd/containerd/pkg/snapshotters" "github.com/containerd/containerd/snapshots" @@ -105,6 +107,8 @@ type FileSystem interface { Check(ctx context.Context, mountpoint string, labels map[string]string) error Unmount(ctx context.Context, mountpoint string) error MountLocal(ctx context.Context, mountpoint string, labels map[string]string, mounts []mount.Mount) error + IDMapMount(ctx context.Context, mountpoint, activeLayerID string, idmap idtools.IDMap) (string, error) + IDMapMountLocal(ctx context.Context, mountpoint, activeLayerID string, idmap idtools.IDMap) (string, error) } // SnapshotterConfig is used to configure the remote snapshotter instance @@ -150,6 +154,7 @@ type snapshotter struct { userxattr bool // whether to enable "userxattr" mount option minLayerSize int64 // minimum layer size for remote mounting allowInvalidMountsOnRestart bool + idmapped *sync.Map } // NewSnapshotter returns a Snapshotter which can use unpacked remote layers @@ -192,6 +197,8 @@ func NewSnapshotter(ctx context.Context, root string, targetFs FileSystem, opts logrus.WithError(err).Warnf("cannot detect whether \"userxattr\" option needs to be used, assuming to be %v", userxattr) } + idMap := &sync.Map{} + o := &snapshotter{ root: root, ms: ms, @@ -200,6 +207,7 @@ func NewSnapshotter(ctx context.Context, root string, targetFs FileSystem, opts userxattr: userxattr, minLayerSize: config.minLayerSize, allowInvalidMountsOnRestart: config.allowInvalidMountsOnRestart, + idmapped: idMap, } if err := o.restoreRemoteSnapshot(ctx); err != nil { @@ -285,6 +293,48 @@ func (o *snapshotter) Usage(ctx context.Context, key string) (snapshots.Usage, e return usage, nil } +func (o *snapshotter) setupIDMap(ctx context.Context, s storage.Snapshot, parent string, labels map[string]string) error { + // load id-map if appropriate labels are present. + idmap, err := idtools.LoadIDMap(s.ID, labels) + if err != nil { + log.G(ctx).WithError(err).Error("failed to load id-map") + return err + } + + if !idmap.Empty() { + parentSnapshot, err := o.Stat(ctx, parent) + if err != nil { + log.G(ctx).WithError(err).Error("failed to stat parent snapshot") + return err + } + + // If there is no SOCI index, you can safely mount from the root without copying over every single layer + if _, ok := parentSnapshot.Labels[source.HasSociIndexDigest]; !ok { + // Fallback to overlay + log.G(ctx).Debug("no SOCI index found, remapping from root") + mounts, err := o.mounts(ctx, s, parent) + if err != nil { + return err + } + + err = idtools.RemapRootFS(ctx, mounts, idmap) + if err != nil { + return err + } + } else { + o.idmapped.Store(s.ID, struct{}{}) + err = o.createIDMapMounts(ctx, s, idmap) + if err != nil { + log.G(ctx).WithError(err).Error("failed to create id-mapped mounts") + return err + } + } + + log.G(ctx).Debug("id-mapping successful") + } + return nil +} + func (o *snapshotter) Prepare(ctx context.Context, key, parent string, opts ...snapshots.Opt) ([]mount.Mount, error) { log.G(ctx).WithField("key", key).WithField("parent", parent).Debug("prepare") s, err := o.createSnapshot(ctx, snapshots.KindActive, key, parent, opts) @@ -302,7 +352,13 @@ func (o *snapshotter) Prepare(ctx context.Context, key, parent string, opts ...s } target, ok := base.Labels[targetSnapshotLabel] + // !ok means we are in an active snapshot if !ok { + // Setup id-mapped mounts if config allows. + // Any error here needs to stop the container from starting. + if err := o.setupIDMap(ctx, s, parent, base.Labels); err != nil { + return nil, err + } return o.mounts(ctx, s, parent) } @@ -319,7 +375,8 @@ func (o *snapshotter) Prepare(ctx context.Context, key, parent string, opts ...s if !o.skipRemoteSnapshotPrepare(lCtx, base.Labels) { err := o.prepareRemoteSnapshot(lCtx, key, base.Labels) if err == nil { - base.Labels[remoteLabel] = remoteLabelVal // Mark this snapshot as remote + base.Labels[remoteLabel] = remoteLabelVal // Mark this snapshot as remote + base.Labels[source.HasSociIndexDigest] = "true" // Mark that this snapshot was loaded with a SOCI index err := o.commit(ctx, true, target, key, append(opts, snapshots.WithLabels(base.Labels))...) if err == nil || errdefs.IsAlreadyExists(err) { // count also AlreadyExists as "success" @@ -361,6 +418,7 @@ func (o *snapshotter) Prepare(ctx context.Context, key, parent string, opts ...s log.G(ctx).WithField("layerDigest", base.Labels[ctdsnapshotters.TargetLayerDigestLabel]).Info("preparing snapshot as local snapshot") err = o.prepareLocalSnapshot(lCtx, key, base.Labels, mounts) if err == nil { + base.Labels[source.HasSociIndexDigest] = "true" // Mark that this snapshot was loaded with a SOCI index err := o.commit(ctx, false, target, key, append(opts, snapshots.WithLabels(base.Labels))...) if err == nil || errdefs.IsAlreadyExists(err) { // count also AlreadyExists as "success" @@ -578,7 +636,18 @@ func (o *snapshotter) getCleanupDirectories(ctx context.Context, t storage.Trans cleanup := []string{} for _, d := range dirs { if !cleanupCommitted { - if _, ok := ids[d]; ok { + // If the directory name is just a number (e.g '2'), + // we want to check if the dir name (2) must be cleaned + // If the directory has an underscore (e.g. '1_2'), + // we want to check the suffix (2) to determine if + // the directory must be cleaned + cleanupID := d + temp := strings.Split(d, "_") + if len(temp) > 1 { + cleanupID = temp[1] + } + + if _, ok := ids[cleanupID]; ok { continue } } @@ -757,15 +826,16 @@ func (o *snapshotter) mounts(ctx context.Context, s storage.Snapshot, checkKey s }, nil } - parentPaths := make([]string, len(s.ParentIDs)) - for i := range s.ParentIDs { - parentPaths[i] = o.upperPath(s.ParentIDs[i]) + parentPaths, err := o.getParentPaths(s) + if err != nil { + return nil, err } options = append(options, fmt.Sprintf("lowerdir=%s", strings.Join(parentPaths, ":"))) if o.userxattr { options = append(options, "userxattr") } + return []mount.Mount{ { Type: "overlay", @@ -773,7 +843,49 @@ func (o *snapshotter) mounts(ctx context.Context, s storage.Snapshot, checkKey s Options: options, }, }, nil +} + +func (o *snapshotter) getParentPaths(s storage.Snapshot) ([]string, error) { + parentPaths := make([]string, len(s.ParentIDs)) + + for i, id := range s.ParentIDs { + if _, ok := o.idmapped.Load(s.ID); ok { + id = fmt.Sprintf("%s_%s", id, s.ID) + } + parentPaths[i] = o.upperPath(id) + } + + return parentPaths, nil +} +func (o *snapshotter) createIDMapMounts(ctx context.Context, s storage.Snapshot, idmap idtools.IDMap) error { + log.G(ctx).Debug("mapping ids") + + for _, id := range s.ParentIDs { + err := o.createIDMapMount(ctx, o.upperPath(id), s.ID, idmap) + if err != nil { + return err + } + } + + return idtools.RemapRoot(ctx, o.upperPath(s.ID), idmap) +} + +func (o *snapshotter) createIDMapMount(ctx context.Context, path, id string, idmap idtools.IDMap) error { + // s.ID is the shortest unique identifier for each new container, + // so append it to the end of the new mountpoint + _, err := o.fs.IDMapMount(ctx, path, id, idmap) + if errdefs.IsNotFound(err) { + // Remote mount failed, attempt to create a local id-mapped mount + + // Cleanup dirty snapshot folder — perhaps we can have a return cleanup func? + dirtyDir := fmt.Sprintf("%s_%s", filepath.Dir(path), id) + if err := os.RemoveAll(dirtyDir); err != nil { + return err + } + _, err = o.fs.IDMapMountLocal(ctx, path, id, idmap) + } + return err } // upperPath produces a file path like "{snapshotter.root}/snapshots/{id}/fs" diff --git a/snapshot/snapshot_test.go b/snapshot/snapshot_test.go index cc798a607..ff2542044 100644 --- a/snapshot/snapshot_test.go +++ b/snapshot/snapshot_test.go @@ -41,6 +41,7 @@ import ( "syscall" "testing" + "github.com/awslabs/soci-snapshotter/idtools" "github.com/containerd/containerd/mount" "github.com/containerd/containerd/pkg/testutil" "github.com/containerd/containerd/snapshots" @@ -417,6 +418,14 @@ func (fs *bindFs) MountLocal(ctx context.Context, mountpoint string, labels map[ return nil } +func (fs *bindFs) IDMapMount(ctx context.Context, mountpoint, activeLayerID string, idmap idtools.IDMap) (string, error) { + return mountpoint, nil +} + +func (fs *bindFs) IDMapMountLocal(ctx context.Context, mountpoint, activeLayerID string, idmap idtools.IDMap) (string, error) { + return mountpoint, nil +} + func dummyFileSystem() FileSystem { return &dummyFs{} } type dummyFs struct{} @@ -437,6 +446,14 @@ func (fs *dummyFs) MountLocal(ctx context.Context, mountpoint string, labels map return fmt.Errorf("dummy") } +func (fs *dummyFs) IDMapMount(ctx context.Context, mountpoint, activeLayerID string, idmap idtools.IDMap) (string, error) { + return "", fmt.Errorf("dummy") +} + +func (fs *dummyFs) IDMapMountLocal(ctx context.Context, mountpoint, activeLayerID string, idmap idtools.IDMap) (string, error) { + return "", fmt.Errorf("dummy") +} + // ============================================================================= // Tests backword-comaptibility of overlayfs snapshotter.