From a090301bbb10424ce4f99e40c97959f0e8664718 Mon Sep 17 00:00:00 2001 From: Valentin Rothberg Date: Tue, 2 Mar 2021 09:20:53 +0100 Subject: podman cp: support copying on tmpfs mounts Traditionally, the path resolution for containers has been resolved on the *host*; relative to the container's mount point or relative to specified bind mounts or volumes. While this works nicely for non-running containers, it poses a problem for running ones. In that case, certain kinds of mounts (e.g., tmpfs) will not resolve correctly. A tmpfs is held in memory and hence cannot be resolved relatively to the container's mount point. A copy operation will succeed but the data will not show up inside the container. To support these kinds of mounts, we need to join the *running* container's mount namespace (and PID namespace) when copying. Note that this change implies moving the copy and stat logic into `libpod` since we need to keep the container locked to avoid race conditions. The immediate benefit is that all logic is now inside `libpod`; the code isn't scattered anymore. Further note that Docker does not support copying to tmpfs mounts. Tests have been extended to cover *both* path resolutions for running and created containers. New tests have been added to exercise the tmpfs-mount case. For the record: Some tests could be improved by using `start -a` instead of a start-exec sequence. Unfortunately, `start -a` is flaky in the CI which forced me to use the more expensive start-exec option. Signed-off-by: Valentin Rothberg --- libpod/container.go | 6 + libpod/container_api.go | 64 ++++++--- libpod/container_copy_linux.go | 266 +++++++++++++++++++++++++++++++++++ libpod/container_copy_unsupported.go | 16 +++ libpod/container_internal.go | 4 + libpod/container_path_resolution.go | 23 +-- libpod/container_stat_linux.go | 157 +++++++++++++++++++++ libpod/container_stat_unsupported.go | 13 ++ libpod/define/fileinfo.go | 16 +++ 9 files changed, 537 insertions(+), 28 deletions(-) create mode 100644 libpod/container_copy_linux.go create mode 100644 libpod/container_copy_unsupported.go create mode 100644 libpod/container_stat_linux.go create mode 100644 libpod/container_stat_unsupported.go create mode 100644 libpod/define/fileinfo.go (limited to 'libpod') diff --git a/libpod/container.go b/libpod/container.go index ee6e243ac..65abbfd5e 100644 --- a/libpod/container.go +++ b/libpod/container.go @@ -904,6 +904,12 @@ func (c *Container) NamespacePath(linuxNS LinuxNS) (string, error) { //nolint:in } } + return c.namespacePath(linuxNS) +} + +// namespacePath returns the path of one of the container's namespaces +// If the container is not running, an error will be returned +func (c *Container) namespacePath(linuxNS LinuxNS) (string, error) { //nolint:interfacer if c.state.State != define.ContainerStateRunning && c.state.State != define.ContainerStatePaused { return "", errors.Wrapf(define.ErrCtrStopped, "cannot get namespace path unless container %s is running", c.ID()) } diff --git a/libpod/container_api.go b/libpod/container_api.go index 2818ac841..6fa8b27cd 100644 --- a/libpod/container_api.go +++ b/libpod/container_api.go @@ -2,6 +2,7 @@ package libpod import ( "context" + "io" "io/ioutil" "net/http" "os" @@ -349,10 +350,6 @@ func (c *Container) Mount() (string, error) { } } - if c.state.State == define.ContainerStateRemoving { - return "", errors.Wrapf(define.ErrCtrStateInvalid, "cannot mount container %s as it is being removed", c.ID()) - } - defer c.newContainerEvent(events.Mount) return c.mount() } @@ -367,7 +364,6 @@ func (c *Container) Unmount(force bool) error { return err } } - if c.state.Mounted { mounted, err := c.runtime.storageService.MountedContainerImage(c.ID()) if err != nil { @@ -847,31 +843,59 @@ func (c *Container) ShouldRestart(ctx context.Context) bool { return c.shouldRestart() } -// ResolvePath resolves the specified path on the root for the container. The -// root must either be the mounted image of the container or the already -// mounted container storage. -// -// It returns the resolved root and the resolved path. Note that the path may -// resolve to the container's mount point or to a volume or bind mount. -func (c *Container) ResolvePath(ctx context.Context, root string, path string) (string, string, error) { - logrus.Debugf("Resolving path %q (root %q) on container %s", path, root, c.ID()) +// CopyFromArchive copies the contents from the specified tarStream to path +// *inside* the container. +func (c *Container) CopyFromArchive(ctx context.Context, containerPath string, tarStream io.Reader) (func() error, error) { + if !c.batched { + c.lock.Lock() + defer c.lock.Unlock() - // Minimal sanity checks. - if len(root)*len(path) == 0 { - return "", "", errors.Wrapf(define.ErrInternal, "ResolvePath: root (%q) and path (%q) must be non empty", root, path) + if err := c.syncContainer(); err != nil { + return nil, err + } } - if _, err := os.Stat(root); err != nil { - return "", "", errors.Wrapf(err, "cannot locate root to resolve path on container %s", c.ID()) + + return c.copyFromArchive(ctx, containerPath, tarStream) +} + +// CopyToArchive copies the contents from the specified path *inside* the +// container to the tarStream. +func (c *Container) CopyToArchive(ctx context.Context, containerPath string, tarStream io.Writer) (func() error, error) { + if !c.batched { + c.lock.Lock() + defer c.lock.Unlock() + + if err := c.syncContainer(); err != nil { + return nil, err + } } + return c.copyToArchive(ctx, containerPath, tarStream) +} + +// Stat the specified path *inside* the container and return a file info. +func (c *Container) Stat(ctx context.Context, containerPath string) (*define.FileInfo, error) { if !c.batched { c.lock.Lock() defer c.lock.Unlock() if err := c.syncContainer(); err != nil { - return "", "", err + return nil, err + } + } + + var mountPoint string + var err error + if c.state.Mounted { + mountPoint = c.state.Mountpoint + } else { + mountPoint, err = c.mount() + if err != nil { + return nil, err } + defer c.unmount(false) } - return c.resolvePath(root, path) + info, _, _, err := c.stat(ctx, mountPoint, containerPath) + return info, err } diff --git a/libpod/container_copy_linux.go b/libpod/container_copy_linux.go new file mode 100644 index 000000000..66ccd2f1f --- /dev/null +++ b/libpod/container_copy_linux.go @@ -0,0 +1,266 @@ +// +build linux + +package libpod + +import ( + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + buildahCopiah "github.com/containers/buildah/copier" + "github.com/containers/buildah/pkg/chrootuser" + "github.com/containers/buildah/util" + "github.com/containers/podman/v3/libpod/define" + "github.com/containers/storage" + "github.com/containers/storage/pkg/idtools" + "github.com/docker/docker/pkg/archive" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +func (c *Container) copyFromArchive(ctx context.Context, path string, reader io.Reader) (func() error, error) { + var ( + mountPoint string + resolvedRoot string + resolvedPath string + unmount func() + err error + ) + + // Make sure that "/" copies the *contents* of the mount point and not + // the directory. + if path == "/" { + path = "/." + } + + // Optimization: only mount if the container is not already. + if c.state.Mounted { + mountPoint = c.state.Mountpoint + unmount = func() {} + } else { + // NOTE: make sure to unmount in error paths. + mountPoint, err = c.mount() + if err != nil { + return nil, err + } + unmount = func() { c.unmount(false) } + } + + if c.state.State == define.ContainerStateRunning { + resolvedRoot = "/" + resolvedPath = c.pathAbs(path) + } else { + resolvedRoot, resolvedPath, err = c.resolvePath(mountPoint, path) + if err != nil { + unmount() + return nil, err + } + } + + decompressed, err := archive.DecompressStream(reader) + if err != nil { + unmount() + return nil, err + } + + idMappings, idPair, err := getIDMappingsAndPair(c, mountPoint) + if err != nil { + decompressed.Close() + unmount() + return nil, err + } + + logrus.Debugf("Container copy *to* %q (resolved: %q) on container %q (ID: %s)", path, resolvedPath, c.Name(), c.ID()) + + return func() error { + defer unmount() + defer decompressed.Close() + putOptions := buildahCopiah.PutOptions{ + UIDMap: idMappings.UIDMap, + GIDMap: idMappings.GIDMap, + ChownDirs: idPair, + ChownFiles: idPair, + } + + return c.joinMountAndExec(ctx, + func() error { + return buildahCopiah.Put(resolvedRoot, resolvedPath, putOptions, decompressed) + }, + ) + }, nil +} + +func (c *Container) copyToArchive(ctx context.Context, path string, writer io.Writer) (func() error, error) { + var ( + mountPoint string + unmount func() + err error + ) + + // Optimization: only mount if the container is not already. + if c.state.Mounted { + mountPoint = c.state.Mountpoint + unmount = func() {} + } else { + // NOTE: make sure to unmount in error paths. + mountPoint, err = c.mount() + if err != nil { + return nil, err + } + unmount = func() { c.unmount(false) } + } + + statInfo, resolvedRoot, resolvedPath, err := c.stat(ctx, mountPoint, path) + if err != nil { + unmount() + return nil, err + } + + idMappings, idPair, err := getIDMappingsAndPair(c, mountPoint) + if err != nil { + unmount() + return nil, err + } + + logrus.Debugf("Container copy *from* %q (resolved: %q) on container %q (ID: %s)", path, resolvedPath, c.Name(), c.ID()) + + return func() error { + defer unmount() + getOptions := buildahCopiah.GetOptions{ + // Unless the specified points to ".", we want to copy the base directory. + KeepDirectoryNames: statInfo.IsDir && filepath.Base(path) != ".", + UIDMap: idMappings.UIDMap, + GIDMap: idMappings.GIDMap, + ChownDirs: idPair, + ChownFiles: idPair, + Excludes: []string{"dev", "proc", "sys"}, + } + return c.joinMountAndExec(ctx, + func() error { + return buildahCopiah.Get(resolvedRoot, "", getOptions, []string{resolvedPath}, writer) + }, + ) + }, nil +} + +// getIDMappingsAndPair returns the ID mappings for the container and the host +// ID pair. +func getIDMappingsAndPair(container *Container, containerMount string) (*storage.IDMappingOptions, *idtools.IDPair, error) { + user, err := getContainerUser(container, containerMount) + if err != nil { + return nil, nil, err + } + + idMappingOpts, err := container.IDMappings() + if err != nil { + return nil, nil, err + } + + hostUID, hostGID, err := util.GetHostIDs(idtoolsToRuntimeSpec(idMappingOpts.UIDMap), idtoolsToRuntimeSpec(idMappingOpts.GIDMap), user.UID, user.GID) + if err != nil { + return nil, nil, err + } + + idPair := idtools.IDPair{UID: int(hostUID), GID: int(hostGID)} + return &idMappingOpts, &idPair, nil +} + +// getContainerUser returns the specs.User of the container. +func getContainerUser(container *Container, mountPoint string) (specs.User, error) { + userspec := container.Config().User + + uid, gid, _, err := chrootuser.GetUser(mountPoint, userspec) + u := specs.User{ + UID: uid, + GID: gid, + Username: userspec, + } + + if !strings.Contains(userspec, ":") { + groups, err2 := chrootuser.GetAdditionalGroupsForUser(mountPoint, uint64(u.UID)) + if err2 != nil { + if errors.Cause(err2) != chrootuser.ErrNoSuchUser && err == nil { + err = err2 + } + } else { + u.AdditionalGids = groups + } + } + + return u, err +} + +// idtoolsToRuntimeSpec converts idtools ID mapping to the one of the runtime spec. +func idtoolsToRuntimeSpec(idMaps []idtools.IDMap) (convertedIDMap []specs.LinuxIDMapping) { + for _, idmap := range idMaps { + tempIDMap := specs.LinuxIDMapping{ + ContainerID: uint32(idmap.ContainerID), + HostID: uint32(idmap.HostID), + Size: uint32(idmap.Size), + } + convertedIDMap = append(convertedIDMap, tempIDMap) + } + return convertedIDMap +} + +// joinMountAndExec executes the specified function `f` inside the container's +// mount and PID namespace. That allows for having the exact view on the +// container's file system. +// +// Note, if the container is not running `f()` will be executed as is. +func (c *Container) joinMountAndExec(ctx context.Context, f func() error) error { + if c.state.State != define.ContainerStateRunning { + return f() + } + + // Container's running, so we need to execute `f()` inside its mount NS. + errChan := make(chan error) + go func() { + runtime.LockOSThread() + + // Join the mount and PID NS of the container. + getFD := func(ns LinuxNS) (*os.File, error) { + nsPath, err := c.namespacePath(ns) + if err != nil { + return nil, err + } + return os.Open(nsPath) + } + + mountFD, err := getFD(MountNS) + if err != nil { + errChan <- err + return + } + defer mountFD.Close() + + pidFD, err := getFD(PIDNS) + if err != nil { + errChan <- err + return + } + defer pidFD.Close() + if err := unix.Unshare(unix.CLONE_NEWNS); err != nil { + errChan <- err + return + } + if err := unix.Setns(int(pidFD.Fd()), unix.CLONE_NEWPID); err != nil { + errChan <- err + return + } + + if err := unix.Setns(int(mountFD.Fd()), unix.CLONE_NEWNS); err != nil { + errChan <- err + return + } + + // Last but not least, execute the workload. + errChan <- f() + }() + return <-errChan +} diff --git a/libpod/container_copy_unsupported.go b/libpod/container_copy_unsupported.go new file mode 100644 index 000000000..b2bdd3e3d --- /dev/null +++ b/libpod/container_copy_unsupported.go @@ -0,0 +1,16 @@ +// +build !linux + +package libpod + +import ( + "context" + "io" +) + +func (c *Container) copyFromArchive(ctx context.Context, path string, reader io.Reader) (func() error, error) { + return nil, nil +} + +func (c *Container) copyToArchive(ctx context.Context, path string, writer io.Writer) (func() error, error) { + return nil, nil +} diff --git a/libpod/container_internal.go b/libpod/container_internal.go index 7e8226de4..a7da7f395 100644 --- a/libpod/container_internal.go +++ b/libpod/container_internal.go @@ -2086,6 +2086,10 @@ func (c *Container) setupOCIHooks(ctx context.Context, config *spec.Spec) (map[s // mount mounts the container's root filesystem func (c *Container) mount() (string, error) { + if c.state.State == define.ContainerStateRemoving { + return "", errors.Wrapf(define.ErrCtrStateInvalid, "cannot mount container %s as it is being removed", c.ID()) + } + mountPoint, err := c.runtime.storageService.MountContainerImage(c.ID()) if err != nil { return "", errors.Wrapf(err, "error mounting storage for container %s", c.ID()) diff --git a/libpod/container_path_resolution.go b/libpod/container_path_resolution.go index 5245314ae..d798963b1 100644 --- a/libpod/container_path_resolution.go +++ b/libpod/container_path_resolution.go @@ -1,3 +1,4 @@ +// +linux package libpod import ( @@ -10,6 +11,19 @@ import ( "github.com/sirupsen/logrus" ) +// pathAbs returns an absolute path. If the specified path is +// relative, it will be resolved relative to the container's working dir. +func (c *Container) pathAbs(path string) string { + if !filepath.IsAbs(path) { + // If the containerPath is not absolute, it's relative to the + // container's working dir. To be extra careful, let's first + // join the working dir with "/", and the add the containerPath + // to it. + path = filepath.Join(filepath.Join("/", c.WorkingDir()), path) + } + return path +} + // resolveContainerPaths resolves the container's mount point and the container // path as specified by the user. Both may resolve to paths outside of the // container's mount point when the container path hits a volume or bind mount. @@ -20,14 +34,7 @@ import ( // the host). func (c *Container) resolvePath(mountPoint string, containerPath string) (string, string, error) { // Let's first make sure we have a path relative to the mount point. - pathRelativeToContainerMountPoint := containerPath - if !filepath.IsAbs(containerPath) { - // If the containerPath is not absolute, it's relative to the - // container's working dir. To be extra careful, let's first - // join the working dir with "/", and the add the containerPath - // to it. - pathRelativeToContainerMountPoint = filepath.Join(filepath.Join("/", c.WorkingDir()), containerPath) - } + pathRelativeToContainerMountPoint := c.pathAbs(containerPath) resolvedPathOnTheContainerMountPoint := filepath.Join(mountPoint, pathRelativeToContainerMountPoint) pathRelativeToContainerMountPoint = strings.TrimPrefix(pathRelativeToContainerMountPoint, mountPoint) pathRelativeToContainerMountPoint = filepath.Join("/", pathRelativeToContainerMountPoint) diff --git a/libpod/container_stat_linux.go b/libpod/container_stat_linux.go new file mode 100644 index 000000000..307b75c14 --- /dev/null +++ b/libpod/container_stat_linux.go @@ -0,0 +1,157 @@ +// +build linux + +package libpod + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/containers/buildah/copier" + "github.com/containers/podman/v3/libpod/define" + "github.com/containers/podman/v3/pkg/copy" + "github.com/pkg/errors" +) + +// statInsideMount stats the specified path *inside* the container's mount and PID +// namespace. It returns the file info along with the resolved root ("/") and +// the resolved path (relative to the root). +func (c *Container) statInsideMount(ctx context.Context, containerPath string) (*copier.StatForItem, string, string, error) { + resolvedRoot := "/" + resolvedPath := c.pathAbs(containerPath) + var statInfo *copier.StatForItem + + err := c.joinMountAndExec(ctx, + func() error { + var statErr error + statInfo, statErr = secureStat(resolvedRoot, resolvedPath) + return statErr + }, + ) + + return statInfo, resolvedRoot, resolvedPath, err +} + +// statOnHost stats the specified path *on the host*. It returns the file info +// along with the resolved root and the resolved path. Both paths are absolute +// to the host's root. Note that the paths may resolved outside the +// container's mount point (e.g., to a volume or bind mount). +func (c *Container) statOnHost(ctx context.Context, mountPoint string, containerPath string) (*copier.StatForItem, string, string, error) { + // Now resolve the container's path. It may hit a volume, it may hit a + // bind mount, it may be relative. + resolvedRoot, resolvedPath, err := c.resolvePath(mountPoint, containerPath) + if err != nil { + return nil, "", "", err + } + + statInfo, err := secureStat(resolvedRoot, resolvedPath) + return statInfo, resolvedRoot, resolvedPath, err +} + +func (c *Container) stat(ctx context.Context, containerMountPoint string, containerPath string) (*define.FileInfo, string, string, error) { + var ( + resolvedRoot string + resolvedPath string + absContainerPath string + statInfo *copier.StatForItem + statErr error + ) + + // Make sure that "/" copies the *contents* of the mount point and not + // the directory. + if containerPath == "/" { + containerPath = "/." + } + + if c.state.State == define.ContainerStateRunning { + // If the container is running, we need to join it's mount namespace + // and stat there. + statInfo, resolvedRoot, resolvedPath, statErr = c.statInsideMount(ctx, containerPath) + } else { + // If the container is NOT running, we need to resolve the path + // on the host. + statInfo, resolvedRoot, resolvedPath, statErr = c.statOnHost(ctx, containerMountPoint, containerPath) + } + + if statErr != nil { + if statInfo == nil { + return nil, "", "", statErr + } + // Not all errors from secureStat map to ErrNotExist, so we + // have to look into the error string. Turning it into an + // ENOENT let's the API handlers return the correct status code + // which is crucial for the remote client. + if os.IsNotExist(statErr) || strings.Contains(statErr.Error(), "o such file or directory") { + statErr = copy.ErrENOENT + } + } + + if statInfo.IsSymlink { + // Evaluated symlinks are always relative to the container's mount point. + absContainerPath = statInfo.ImmediateTarget + } else if strings.HasPrefix(resolvedPath, containerMountPoint) { + // If the path is on the container's mount point, strip it off. + absContainerPath = strings.TrimPrefix(resolvedPath, containerMountPoint) + absContainerPath = filepath.Join("/", absContainerPath) + } else { + // No symlink and not on the container's mount point, so let's + // move it back to the original input. It must have evaluated + // to a volume or bind mount but we cannot return host paths. + absContainerPath = containerPath + } + + // Preserve the base path as specified by the user. The `filepath` + // packages likes to remove trailing slashes and dots that are crucial + // to the copy logic. + absContainerPath = copy.PreserveBasePath(containerPath, absContainerPath) + resolvedPath = copy.PreserveBasePath(containerPath, resolvedPath) + + info := &define.FileInfo{ + IsDir: statInfo.IsDir, + Name: filepath.Base(absContainerPath), + Size: statInfo.Size, + Mode: statInfo.Mode, + ModTime: statInfo.ModTime, + LinkTarget: absContainerPath, + } + + return info, resolvedRoot, resolvedPath, statErr +} + +// secureStat extracts file info for path in a chroot'ed environment in root. +func secureStat(root string, path string) (*copier.StatForItem, error) { + var glob string + var err error + + // If root and path are equal, then dir must be empty and the glob must + // be ".". + if filepath.Clean(root) == filepath.Clean(path) { + glob = "." + } else { + glob, err = filepath.Rel(root, path) + if err != nil { + return nil, err + } + } + + globStats, err := copier.Stat(root, "", copier.StatOptions{}, []string{glob}) + if err != nil { + return nil, err + } + + if len(globStats) != 1 { + return nil, errors.Errorf("internal error: secureStat: expected 1 item but got %d", len(globStats)) + } + + stat, exists := globStats[0].Results[glob] // only one glob passed, so that's okay + if !exists { + return nil, copy.ErrENOENT + } + + var statErr error + if stat.Error != "" { + statErr = errors.New(stat.Error) + } + return stat, statErr +} diff --git a/libpod/container_stat_unsupported.go b/libpod/container_stat_unsupported.go new file mode 100644 index 000000000..c002e4d32 --- /dev/null +++ b/libpod/container_stat_unsupported.go @@ -0,0 +1,13 @@ +// +build !linux + +package libpod + +import ( + "context" + + "github.com/containers/podman/v3/libpod/define" +) + +func (c *Container) stat(ctx context.Context, containerMountPoint string, containerPath string) (*define.FileInfo, string, string, error) { + return nil, "", "", nil +} diff --git a/libpod/define/fileinfo.go b/libpod/define/fileinfo.go new file mode 100644 index 000000000..2c7b6fe99 --- /dev/null +++ b/libpod/define/fileinfo.go @@ -0,0 +1,16 @@ +package define + +import ( + "os" + "time" +) + +// FileInfo describes the attributes of a file or diretory. +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + Mode os.FileMode `json:"mode"` + ModTime time.Time `json:"mtime"` + IsDir bool `json:"isDir"` + LinkTarget string `json:"linkTarget"` +} -- cgit v1.2.3-54-g00ecf