From fae0c281b638bc54c45eacdbe4fe7ef804246766 Mon Sep 17 00:00:00 2001 From: John Starks Date: Mon, 31 Jul 2017 14:23:52 -0700 Subject: [PATCH] Windows: Add named pipe mount support Current insider builds of Windows have support for mounting individual named pipe servers from the host to the guest. This allows, for example, exposing the docker engine's named pipe to a container. This change allows the user to request such a mount via the normal bind mount syntax in the CLI: docker run -v \\.\pipe\docker_engine:\\.\pipe\docker_engine Signed-off-by: John Starks Upstream-commit: 54354db850664783918a1fc9d208bcfcf47c28e2 Component: engine --- components/engine/api/types/mount/mount.go | 2 + .../docker_api_containers_windows_test.go | 71 +++++++++++++++++++ .../integration-cli/docker_cli_run_test.go | 5 +- .../integration-cli/requirements_test.go | 6 ++ .../engine/libcontainerd/client_windows.go | 36 +++++++--- components/engine/volume/validate.go | 38 +++++++--- components/engine/volume/volume.go | 10 +-- components/engine/volume/volume_test.go | 48 +++++++------ components/engine/volume/volume_unix.go | 14 +++- components/engine/volume/volume_windows.go | 41 ++++++++--- 10 files changed, 209 insertions(+), 62 deletions(-) create mode 100644 components/engine/integration-cli/docker_api_containers_windows_test.go diff --git a/components/engine/api/types/mount/mount.go b/components/engine/api/types/mount/mount.go index 2744f85d6d..71368643dd 100644 --- a/components/engine/api/types/mount/mount.go +++ b/components/engine/api/types/mount/mount.go @@ -15,6 +15,8 @@ const ( TypeVolume Type = "volume" // TypeTmpfs is the type for mounting tmpfs TypeTmpfs Type = "tmpfs" + // TypeNamedPipe is the type for mounting Windows named pipes + TypeNamedPipe Type = "npipe" ) // Mount represents a mount (volume). diff --git a/components/engine/integration-cli/docker_api_containers_windows_test.go b/components/engine/integration-cli/docker_api_containers_windows_test.go new file mode 100644 index 0000000000..4cbe067cd5 --- /dev/null +++ b/components/engine/integration-cli/docker_api_containers_windows_test.go @@ -0,0 +1,71 @@ +// +build windows + +package main + +import ( + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "strings" + + winio "github.com/Microsoft/go-winio" + "github.com/docker/docker/integration-cli/checker" + "github.com/docker/docker/integration-cli/request" + "github.com/go-check/check" +) + +func (s *DockerSuite) TestContainersAPICreateMountsBindNamedPipe(c *check.C) { + testRequires(c, SameHostDaemon, DaemonIsWindowsAtLeastBuild(16210)) // Named pipe support was added in RS3 + + // Create a host pipe to map into the container + hostPipeName := fmt.Sprintf(`\\.\pipe\docker-cli-test-pipe-%x`, rand.Uint64()) + pc := &winio.PipeConfig{ + SecurityDescriptor: "D:P(A;;GA;;;AU)", // Allow all users access to the pipe + } + l, err := winio.ListenPipe(hostPipeName, pc) + if err != nil { + c.Fatal(err) + } + defer l.Close() + + // Asynchronously read data that the container writes to the mapped pipe. + var b []byte + ch := make(chan error) + go func() { + conn, err := l.Accept() + if err == nil { + b, err = ioutil.ReadAll(conn) + conn.Close() + } + ch <- err + }() + + containerPipeName := `\\.\pipe\docker-cli-test-pipe` + text := "hello from a pipe" + cmd := fmt.Sprintf("echo %s > %s", text, containerPipeName) + + name := "test-bind-npipe" + data := map[string]interface{}{ + "Image": testEnv.MinimalBaseImage(), + "Cmd": []string{"cmd", "/c", cmd}, + "HostConfig": map[string]interface{}{"Mounts": []map[string]interface{}{{"Type": "npipe", "Source": hostPipeName, "Target": containerPipeName}}}, + } + + status, resp, err := request.SockRequest("POST", "/containers/create?name="+name, data, daemonHost()) + c.Assert(err, checker.IsNil, check.Commentf(string(resp))) + c.Assert(status, checker.Equals, http.StatusCreated, check.Commentf(string(resp))) + + status, _, err = request.SockRequest("POST", "/containers/"+name+"/start", nil, daemonHost()) + c.Assert(err, checker.IsNil) + c.Assert(status, checker.Equals, http.StatusNoContent) + + err = <-ch + if err != nil { + c.Fatal(err) + } + result := strings.TrimSpace(string(b)) + if result != text { + c.Errorf("expected pipe to contain %s, got %s", text, result) + } +} diff --git a/components/engine/integration-cli/docker_cli_run_test.go b/components/engine/integration-cli/docker_cli_run_test.go index 544cfdf9a8..6032de4d93 100644 --- a/components/engine/integration-cli/docker_cli_run_test.go +++ b/components/engine/integration-cli/docker_cli_run_test.go @@ -4610,10 +4610,7 @@ func (s *DockerSuite) TestRunAddDeviceCgroupRule(c *check.C) { // Verifies that running as local system is operating correctly on Windows func (s *DockerSuite) TestWindowsRunAsSystem(c *check.C) { - testRequires(c, DaemonIsWindows) - if testEnv.DaemonKernelVersionNumeric() < 15000 { - c.Skip("Requires build 15000 or later") - } + testRequires(c, DaemonIsWindowsAtLeastBuild(15000)) out, _ := dockerCmd(c, "run", "--net=none", `--user=nt authority\system`, "--hostname=XYZZY", minimalBaseImage(), "cmd", "/c", `@echo %USERNAME%`) c.Assert(strings.TrimSpace(out), checker.Equals, "XYZZY$") } diff --git a/components/engine/integration-cli/requirements_test.go b/components/engine/integration-cli/requirements_test.go index 15b3df2265..d6cc27b1d0 100644 --- a/components/engine/integration-cli/requirements_test.go +++ b/components/engine/integration-cli/requirements_test.go @@ -37,6 +37,12 @@ func DaemonIsWindows() bool { return PlatformIs("windows") } +func DaemonIsWindowsAtLeastBuild(buildNumber int) func() bool { + return func() bool { + return DaemonIsWindows() && testEnv.DaemonKernelVersionNumeric() >= buildNumber + } +} + func DaemonIsLinux() bool { return PlatformIs("linux") } diff --git a/components/engine/libcontainerd/client_windows.go b/components/engine/libcontainerd/client_windows.go index a12948a96d..3c17803615 100644 --- a/components/engine/libcontainerd/client_windows.go +++ b/components/engine/libcontainerd/client_windows.go @@ -16,6 +16,7 @@ import ( "github.com/Microsoft/hcsshim" "github.com/docker/docker/pkg/sysinfo" + "github.com/docker/docker/pkg/system" opengcs "github.com/jhowardmsft/opengcs/gogcs/client" specs "github.com/opencontainers/runtime-spec/specs-go" "github.com/sirupsen/logrus" @@ -230,20 +231,35 @@ func (clnt *client) createWindows(containerID string, checkpoint string, checkpo } // Add the mounts (volumes, bind mounts etc) to the structure - mds := make([]hcsshim.MappedDir, len(spec.Mounts)) - for i, mount := range spec.Mounts { - mds[i] = hcsshim.MappedDir{ - HostPath: mount.Source, - ContainerPath: mount.Destination, - ReadOnly: false, - } - for _, o := range mount.Options { - if strings.ToLower(o) == "ro" { - mds[i].ReadOnly = true + var mds []hcsshim.MappedDir + var mps []hcsshim.MappedPipe + for _, mount := range spec.Mounts { + const pipePrefix = `\\.\pipe\` + if strings.HasPrefix(mount.Destination, pipePrefix) { + mp := hcsshim.MappedPipe{ + HostPath: mount.Source, + ContainerPipeName: mount.Destination[len(pipePrefix):], } + mps = append(mps, mp) + } else { + md := hcsshim.MappedDir{ + HostPath: mount.Source, + ContainerPath: mount.Destination, + ReadOnly: false, + } + for _, o := range mount.Options { + if strings.ToLower(o) == "ro" { + md.ReadOnly = true + } + } + mds = append(mds, md) } } configuration.MappedDirectories = mds + if len(mps) > 0 && system.GetOSVersion().Build < 16210 { // replace with Win10 RS3 build number at RTM + return errors.New("named pipe mounts are not supported on this version of Windows") + } + configuration.MappedPipes = mps hcsContainer, err := hcsshim.CreateContainer(containerID, configuration) if err != nil { diff --git a/components/engine/volume/validate.go b/components/engine/volume/validate.go index 42396a0dad..5de46198f6 100644 --- a/components/engine/volume/validate.go +++ b/components/engine/volume/validate.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" "os" - "path/filepath" + "runtime" "github.com/docker/docker/api/types/mount" ) @@ -12,8 +12,7 @@ import ( var errBindNotExist = errors.New("bind source path does not exist") type validateOpts struct { - skipBindSourceCheck bool - skipAbsolutePathCheck bool + skipBindSourceCheck bool } func validateMountConfig(mnt *mount.Mount, options ...func(*validateOpts)) error { @@ -30,10 +29,8 @@ func validateMountConfig(mnt *mount.Mount, options ...func(*validateOpts)) error return &errMountConfig{mnt, err} } - if !opts.skipAbsolutePathCheck { - if err := validateAbsolute(mnt.Target); err != nil { - return &errMountConfig{mnt, err} - } + if err := validateAbsolute(mnt.Target); err != nil { + return &errMountConfig{mnt, err} } switch mnt.Type { @@ -97,6 +94,31 @@ func validateMountConfig(mnt *mount.Mount, options ...func(*validateOpts)) error if _, err := ConvertTmpfsOptions(mnt.TmpfsOptions, mnt.ReadOnly); err != nil { return &errMountConfig{mnt, err} } + case mount.TypeNamedPipe: + if runtime.GOOS != "windows" { + return &errMountConfig{mnt, errors.New("named pipe bind mounts are not supported on this OS")} + } + + if len(mnt.Source) == 0 { + return &errMountConfig{mnt, errMissingField("Source")} + } + + if mnt.BindOptions != nil { + return &errMountConfig{mnt, errExtraField("BindOptions")} + } + + if mnt.ReadOnly { + return &errMountConfig{mnt, errExtraField("ReadOnly")} + } + + if detectMountType(mnt.Source) != mount.TypeNamedPipe { + return &errMountConfig{mnt, fmt.Errorf("'%s' is not a valid pipe path", mnt.Source)} + } + + if detectMountType(mnt.Target) != mount.TypeNamedPipe { + return &errMountConfig{mnt, fmt.Errorf("'%s' is not a valid pipe path", mnt.Target)} + } + default: return &errMountConfig{mnt, errors.New("mount type unknown")} } @@ -121,7 +143,7 @@ func errMissingField(name string) error { func validateAbsolute(p string) error { p = convertSlash(p) - if filepath.IsAbs(p) { + if isAbsPath(p) { return nil } return fmt.Errorf("invalid mount path: '%s' mount path must be absolute", p) diff --git a/components/engine/volume/volume.go b/components/engine/volume/volume.go index 8598d4cb8f..7e8d16cc68 100644 --- a/components/engine/volume/volume.go +++ b/components/engine/volume/volume.go @@ -3,7 +3,6 @@ package volume import ( "fmt" "os" - "path/filepath" "strings" "syscall" "time" @@ -284,12 +283,7 @@ func ParseMountRaw(raw, volumeDriver string) (*MountPoint, error) { return nil, errInvalidMode(mode) } - if filepath.IsAbs(spec.Source) { - spec.Type = mounttypes.TypeBind - } else { - spec.Type = mounttypes.TypeVolume - } - + spec.Type = detectMountType(spec.Source) spec.ReadOnly = !ReadWrite(mode) // cannot assume that if a volume driver is passed in that we should set it @@ -350,7 +344,7 @@ func ParseMountSpec(cfg mounttypes.Mount, options ...func(*validateOpts)) (*Moun mp.CopyData = false } } - case mounttypes.TypeBind: + case mounttypes.TypeBind, mounttypes.TypeNamedPipe: mp.Source = clean(convertSlash(cfg.Source)) if cfg.BindOptions != nil && len(cfg.BindOptions.Propagation) > 0 { mp.Propagation = cfg.BindOptions.Propagation diff --git a/components/engine/volume/volume_test.go b/components/engine/volume/volume_test.go index 5c3e0e381b..395f374ff0 100644 --- a/components/engine/volume/volume_test.go +++ b/components/engine/volume/volume_test.go @@ -143,6 +143,7 @@ func TestParseMountRaw(t *testing.T) { type testParseMountRaw struct { bind string driver string + expType mount.Type expDest string expSource string expName string @@ -155,28 +156,31 @@ func TestParseMountRawSplit(t *testing.T) { var cases []testParseMountRaw if runtime.GOOS == "windows" { cases = []testParseMountRaw{ - {`c:\:d:`, "local", `d:`, `c:\`, ``, "", true, false}, - {`c:\:d:\`, "local", `d:\`, `c:\`, ``, "", true, false}, - {`c:\:d:\:ro`, "local", `d:\`, `c:\`, ``, "", false, false}, - {`c:\:d:\:rw`, "local", `d:\`, `c:\`, ``, "", true, false}, - {`c:\:d:\:foo`, "local", `d:\`, `c:\`, ``, "", false, true}, - {`name:d::rw`, "local", `d:`, ``, `name`, "local", true, false}, - {`name:d:`, "local", `d:`, ``, `name`, "local", true, false}, - {`name:d::ro`, "local", `d:`, ``, `name`, "local", false, false}, - {`name:c:`, "", ``, ``, ``, "", true, true}, - {`driver/name:c:`, "", ``, ``, ``, "", true, true}, + {`c:\:d:`, "local", mount.TypeBind, `d:`, `c:\`, ``, "", true, false}, + {`c:\:d:\`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", true, false}, + {`c:\:d:\:ro`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", false, false}, + {`c:\:d:\:rw`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", true, false}, + {`c:\:d:\:foo`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", false, true}, + {`\\.\pipe\foo:\\.\pipe\bar`, "local", mount.TypeNamedPipe, `\\.\pipe\bar`, `\\.\pipe\foo`, "", "", true, false}, + {`\\.\pipe\foo:c:\foo\bar`, "local", mount.TypeNamedPipe, ``, ``, "", "", true, true}, + {`c:\foo\bar:\\.\pipe\foo`, "local", mount.TypeNamedPipe, ``, ``, "", "", true, true}, + {`name:d::rw`, "local", mount.TypeVolume, `d:`, ``, `name`, "local", true, false}, + {`name:d:`, "local", mount.TypeVolume, `d:`, ``, `name`, "local", true, false}, + {`name:d::ro`, "local", mount.TypeVolume, `d:`, ``, `name`, "local", false, false}, + {`name:c:`, "", mount.TypeVolume, ``, ``, ``, "", true, true}, + {`driver/name:c:`, "", mount.TypeVolume, ``, ``, ``, "", true, true}, } } else { cases = []testParseMountRaw{ - {"/tmp:/tmp1", "", "/tmp1", "/tmp", "", "", true, false}, - {"/tmp:/tmp2:ro", "", "/tmp2", "/tmp", "", "", false, false}, - {"/tmp:/tmp3:rw", "", "/tmp3", "/tmp", "", "", true, false}, - {"/tmp:/tmp4:foo", "", "", "", "", "", false, true}, - {"name:/named1", "", "/named1", "", "name", "", true, false}, - {"name:/named2", "external", "/named2", "", "name", "external", true, false}, - {"name:/named3:ro", "local", "/named3", "", "name", "local", false, false}, - {"local/name:/tmp:rw", "", "/tmp", "", "local/name", "", true, false}, - {"/tmp:tmp", "", "", "", "", "", true, true}, + {"/tmp:/tmp1", "", mount.TypeBind, "/tmp1", "/tmp", "", "", true, false}, + {"/tmp:/tmp2:ro", "", mount.TypeBind, "/tmp2", "/tmp", "", "", false, false}, + {"/tmp:/tmp3:rw", "", mount.TypeBind, "/tmp3", "/tmp", "", "", true, false}, + {"/tmp:/tmp4:foo", "", mount.TypeBind, "", "", "", "", false, true}, + {"name:/named1", "", mount.TypeVolume, "/named1", "", "name", "", true, false}, + {"name:/named2", "external", mount.TypeVolume, "/named2", "", "name", "external", true, false}, + {"name:/named3:ro", "local", mount.TypeVolume, "/named3", "", "name", "local", false, false}, + {"local/name:/tmp:rw", "", mount.TypeVolume, "/tmp", "", "local/name", "", true, false}, + {"/tmp:tmp", "", mount.TypeBind, "", "", "", "", true, true}, } } @@ -195,8 +199,12 @@ func TestParseMountRawSplit(t *testing.T) { continue } + if m.Type != c.expType { + t.Fatalf("Expected type '%s', was '%s', for spec '%s'", c.expType, m.Type, c.bind) + } + if m.Destination != c.expDest { - t.Fatalf("Expected destination '%s, was %s', for spec '%s'", c.expDest, m.Destination, c.bind) + t.Fatalf("Expected destination '%s', was '%s', for spec '%s'", c.expDest, m.Destination, c.bind) } if m.Source != c.expSource { diff --git a/components/engine/volume/volume_unix.go b/components/engine/volume/volume_unix.go index e35b70c03b..5dde82147f 100644 --- a/components/engine/volume/volume_unix.go +++ b/components/engine/volume/volume_unix.go @@ -124,7 +124,12 @@ func validateCopyMode(mode bool) error { } func convertSlash(p string) string { - return filepath.ToSlash(p) + return p +} + +// isAbsPath reports whether the path is absolute. +func isAbsPath(p string) bool { + return filepath.IsAbs(p) } func splitRawSpec(raw string) ([]string, error) { @@ -139,6 +144,13 @@ func splitRawSpec(raw string) ([]string, error) { return arr, nil } +func detectMountType(p string) mounttypes.Type { + if filepath.IsAbs(p) { + return mounttypes.TypeBind + } + return mounttypes.TypeVolume +} + func clean(p string) string { return filepath.Clean(p) } diff --git a/components/engine/volume/volume_windows.go b/components/engine/volume/volume_windows.go index 22f6fc7a14..d792b385f8 100644 --- a/components/engine/volume/volume_windows.go +++ b/components/engine/volume/volume_windows.go @@ -6,6 +6,8 @@ import ( "path/filepath" "regexp" "strings" + + mounttypes "github.com/docker/docker/api/types/mount" ) // read-write modes @@ -18,14 +20,7 @@ var roModes = map[string]bool{ "ro": true, } -var platformRawValidationOpts = []func(*validateOpts){ - // filepath.IsAbs is weird on Windows: - // `c:` is not considered an absolute path - // `c:\` is considered an absolute path - // In any case, the regex matching below ensures absolute paths - // TODO: consider this a bug with filepath.IsAbs (?) - func(o *validateOpts) { o.skipAbsolutePathCheck = true }, -} +var platformRawValidationOpts = []func(*validateOpts){} const ( // Spec should be in the format [source:]destination[:mode] @@ -49,11 +44,13 @@ const ( RXHostDir = `[a-z]:\\(?:[^\\/:*?"<>|\r\n]+\\?)*` // RXName is the second option of a source RXName = `[^\\/:*?"<>|\r\n]+` + // RXPipe is a named path pipe (starts with `\\.\pipe\`, possibly with / instead of \) + RXPipe = `[/\\]{2}.[/\\]pipe[/\\][^:*?"<>|\r\n]+` // RXReservedNames are reserved names not possible on Windows RXReservedNames = `(con)|(prn)|(nul)|(aux)|(com[1-9])|(lpt[1-9])` // RXSource is the combined possibilities for a source - RXSource = `((?P((` + RXHostDir + `)|(` + RXName + `))):)?` + RXSource = `((?P((` + RXHostDir + `)|(` + RXName + `)|(` + RXPipe + `))):)?` // Source. Can be either a host directory, a name, or omitted: // HostDir: @@ -69,8 +66,10 @@ const ( // - And then followed by a colon which is not in the capture group // - And can be optional + // RXDestinationDir is the file path option for the mount destination + RXDestinationDir = `([a-z]):((?:\\[^\\/:*?"<>\r\n]+)*\\?)` // RXDestination is the regex expression for the mount destination - RXDestination = `(?P([a-z]):((?:\\[^\\/:*?"<>\r\n]+)*\\?))` + RXDestination = `(?P(` + RXDestinationDir + `)|(` + RXPipe + `))` // Destination (aka container path): // - Variation on hostdir but can be a drive followed by colon as well // - If a path, must be absolute. Can include spaces @@ -140,6 +139,15 @@ func splitRawSpec(raw string) ([]string, error) { return split, nil } +func detectMountType(p string) mounttypes.Type { + if strings.HasPrefix(filepath.FromSlash(p), `\\.\pipe\`) { + return mounttypes.TypeNamedPipe + } else if filepath.IsAbs(p) { + return mounttypes.TypeBind + } + return mounttypes.TypeVolume +} + // IsVolumeNameValid checks a volume name in a platform specific manner. func IsVolumeNameValid(name string) (bool, error) { nameExp := regexp.MustCompile(`^` + RXName + `$`) @@ -186,8 +194,19 @@ func convertSlash(p string) string { return filepath.FromSlash(p) } +// isAbsPath returns whether a path is absolute for the purposes of mounting into a container +// (absolute paths, drive letter paths such as X:, and paths starting with `\\.\` to support named pipes). +func isAbsPath(p string) bool { + return filepath.IsAbs(p) || + strings.HasPrefix(p, `\\.\`) || + (len(p) == 2 && p[1] == ':' && ((p[0] >= 'a' && p[0] <= 'z') || (p[0] >= 'A' && p[0] <= 'Z'))) +} + +// Do not clean plain drive letters or paths starting with `\\.\`. +var cleanRegexp = regexp.MustCompile(`^([a-z]:|[/\\]{2}\.[/\\].*)$`) + func clean(p string) string { - if match, _ := regexp.MatchString("^[a-z]:$", p); match { + if match := cleanRegexp.MatchString(p); match { return p } return filepath.Clean(p)