Skip to content

Commit

Permalink
Windows: (WCOW) Generate OCI spec that remote runtime can escape
Browse files Browse the repository at this point in the history
Signed-off-by: John Howard <[email protected]>

Also fixes moby#22874

This commit is a pre-requisite to moving moby/moby on Windows to using
Containerd for its runtime.

The reason for this is that the interface between moby and containerd
for the runtime is an OCI spec which must be unambigious.

It is the responsibility of the runtime (runhcs in the case of
containerd on Windows) to ensure that arguments are escaped prior
to calling into HCS and onwards to the Win32 CreateProcess call.

Previously, the builder was always escaping arguments which has
led to several bugs in moby. Because the local runtime in
libcontainerd had context of whether or not arguments were escaped,
it was possible to hack around in daemon/oci_windows.go with
knowledge of the context of the call (from builder or not).

With a remote runtime, this is not possible as there's rightly
no context of the caller passed across in the OCI spec. Put another
way, as I put above, the OCI spec must be unambigious.

The other previous limitation (which leads to various subtle bugs)
is that moby is coded entirely from a Linux-centric point of view.

Unfortunately, Windows != Linux. Windows CreateProcess uses a
command line, not an array of arguments. And it has very specific
rules about how to escape a command line. Some interesting reading
links about this are:

https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
https://stackoverflow.com/questions/31838469/how-do-i-convert-argv-to-lpcommandline-parameter-of-createprocess
https://docs.microsoft.com/en-us/cpp/cpp/parsing-cpp-command-line-arguments?view=vs-2017

For this reason, the OCI spec has recently been updated to cater
for more natural syntax by including a CommandLine option in
Process.

What does this commit do?

Primary objective is to ensure that the built OCI spec is unambigious.

It changes the builder so that `ArgsEscaped` as commited in a
layer is only controlled by the use of CMD or ENTRYPOINT.

Subsequently, when calling in to create a container from the builder,
if follows a different path to both `docker run` and `docker create`
using the added `ContainerCreateIgnoreImagesArgsEscaped`. This allows
a RUN from the builder to control how to escape in the OCI spec.

It changes the builder so that when shell form is used for RUN,
CMD or ENTRYPOINT, it builds (for WCOW) a more natural command line
using the original as put by the user in the dockerfile, not
the parsed version as a set of args which loses fidelity.
This command line is put into args[0] and `ArgsEscaped` is set
to true for CMD or ENTRYPOINT. A RUN statement does not commit
`ArgsEscaped` to the commited layer regardless or whether shell
or exec form were used.
  • Loading branch information
John Howard committed Mar 13, 2019
1 parent 85ad4b1 commit 20833b0
Show file tree
Hide file tree
Showing 18 changed files with 276 additions and 93 deletions.
2 changes: 1 addition & 1 deletion api/types/container/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ type Config struct {
Env []string // List of environment variable to set in the container
Cmd strslice.StrSlice // Command to run when starting the container
Healthcheck *HealthConfig `json:",omitempty"` // Healthcheck describes how to check the container is healthy
ArgsEscaped bool `json:",omitempty"` // True if command is already escaped (Windows specific)
ArgsEscaped bool `json:",omitempty"` // True if command is already escaped (meaning treat as a command line) (Windows specific).
Image string // Name of the image as it was passed by the operator (e.g. could be symbolic)
Volumes map[string]struct{} // List of volumes (mounts) used for the container
WorkingDir string // Current directory (PWD) in the command will be launched
Expand Down
4 changes: 2 additions & 2 deletions builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ type ImageBackend interface {
type ExecBackend interface {
// ContainerAttachRaw attaches to container.
ContainerAttachRaw(cID string, stdin io.ReadCloser, stdout, stderr io.Writer, stream bool, attached chan struct{}) error
// ContainerCreate creates a new Docker container and returns potential warnings
ContainerCreate(config types.ContainerCreateConfig) (container.ContainerCreateCreatedBody, error)
// ContainerCreateIgnoreImagesArgsEscaped creates a new Docker container and returns potential warnings
ContainerCreateIgnoreImagesArgsEscaped(config types.ContainerCreateConfig) (container.ContainerCreateCreatedBody, error)
// ContainerRm removes a container specified by `id`.
ContainerRm(name string, config *types.ContainerRmConfig) error
// ContainerKill stops the container execution abruptly.
Expand Down
2 changes: 1 addition & 1 deletion builder/dockerfile/containerbackend.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func newContainerManager(docker builder.ExecBackend) *containerManager {

// Create a container
func (c *containerManager) Create(runConfig *container.Config, hostConfig *container.HostConfig) (container.ContainerCreateCreatedBody, error) {
container, err := c.backend.ContainerCreate(types.ContainerCreateConfig{
container, err := c.backend.ContainerCreateIgnoreImagesArgsEscaped(types.ContainerCreateConfig{
Config: runConfig,
HostConfig: hostConfig,
})
Expand Down
54 changes: 36 additions & 18 deletions builder/dockerfile/dispatchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (

"github.com/containerd/containerd/platforms"
"github.com/docker/docker/api"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/builder"
"github.com/docker/docker/errdefs"
Expand Down Expand Up @@ -330,14 +329,6 @@ func dispatchWorkdir(d dispatchRequest, c *instructions.WorkdirCommand) error {
return d.builder.commitContainer(d.state, containerID, runConfigWithCommentCmd)
}

func resolveCmdLine(cmd instructions.ShellDependantCmdLine, runConfig *container.Config, os string) []string {
result := cmd.CmdLine
if cmd.PrependShell && result != nil {
result = append(getShell(runConfig, os), result...)
}
return result
}

// RUN some command yo
//
// run a command and commit the image. Args are automatically prepended with
Expand All @@ -353,7 +344,7 @@ func dispatchRun(d dispatchRequest, c *instructions.RunCommand) error {
return system.ErrNotSupportedOperatingSystem
}
stateRunConfig := d.state.runConfig
cmdFromArgs := resolveCmdLine(c.ShellDependantCmdLine, stateRunConfig, d.state.operatingSystem)
cmdFromArgs, argsEscaped := resolveCmdLine(c.ShellDependantCmdLine, stateRunConfig, d.state.operatingSystem, c.Name(), c.String())
buildArgs := d.state.buildArgs.FilterAllowed(stateRunConfig.Env)

saveCmd := cmdFromArgs
Expand All @@ -363,20 +354,19 @@ func dispatchRun(d dispatchRequest, c *instructions.RunCommand) error {

runConfigForCacheProbe := copyRunConfig(stateRunConfig,
withCmd(saveCmd),
withArgsEscaped(argsEscaped),
withEntrypointOverride(saveCmd, nil))
if hit, err := d.builder.probeCache(d.state, runConfigForCacheProbe); err != nil || hit {
return err
}

runConfig := copyRunConfig(stateRunConfig,
withCmd(cmdFromArgs),
withArgsEscaped(argsEscaped),
withEnv(append(stateRunConfig.Env, buildArgs...)),
withEntrypointOverride(saveCmd, strslice.StrSlice{""}),
withoutHealthcheck())

// set config as already being escaped, this prevents double escaping on windows
runConfig.ArgsEscaped = true

cID, err := d.builder.create(runConfig)
if err != nil {
return err
Expand All @@ -399,6 +389,12 @@ func dispatchRun(d dispatchRequest, c *instructions.RunCommand) error {
return err
}

// Don't persist the argsEscaped value in the committed image. Use the original
// from previous build steps (only CMD and ENTRYPOINT persist this).
if d.state.operatingSystem == "windows" {
runConfigForCacheProbe.ArgsEscaped = stateRunConfig.ArgsEscaped
}

return d.builder.commitContainer(d.state, cID, runConfigForCacheProbe)
}

Expand Down Expand Up @@ -434,15 +430,23 @@ func prependEnvOnCmd(buildArgs *BuildArgs, buildArgVars []string, cmd strslice.S
//
func dispatchCmd(d dispatchRequest, c *instructions.CmdCommand) error {
runConfig := d.state.runConfig
cmd := resolveCmdLine(c.ShellDependantCmdLine, runConfig, d.state.operatingSystem)
cmd, argsEscaped := resolveCmdLine(c.ShellDependantCmdLine, runConfig, d.state.operatingSystem, c.Name(), c.String())

// We warn here as Windows shell processing operates differently to Linux.
// Linux: /bin/sh -c "echo hello" world --> hello
// Windows: cmd /s /c "echo hello" world --> hello world
if d.state.operatingSystem == "windows" &&
len(runConfig.Entrypoint) > 0 &&
d.state.runConfig.ArgsEscaped != argsEscaped {
fmt.Fprintf(d.builder.Stderr, " ---> [Warning] Shell-form ENTRYPOINT and exec-form CMD may have unexpected results\n")
}

runConfig.Cmd = cmd
// set config as already being escaped, this prevents double escaping on windows
runConfig.ArgsEscaped = true
runConfig.ArgsEscaped = argsEscaped

if err := d.builder.commit(d.state, fmt.Sprintf("CMD %q", cmd)); err != nil {
return err
}

if len(c.ShellDependantCmdLine.CmdLine) != 0 {
d.state.cmdSet = true
}
Expand Down Expand Up @@ -477,8 +481,22 @@ func dispatchHealthcheck(d dispatchRequest, c *instructions.HealthCheckCommand)
//
func dispatchEntrypoint(d dispatchRequest, c *instructions.EntrypointCommand) error {
runConfig := d.state.runConfig
cmd := resolveCmdLine(c.ShellDependantCmdLine, runConfig, d.state.operatingSystem)
cmd, argsEscaped := resolveCmdLine(c.ShellDependantCmdLine, runConfig, d.state.operatingSystem, c.Name(), c.String())

// This warning is a little more complex than in dispatchCmd(), as the Windows base images (similar
// universally to almost every Linux image out there) have a single .Cmd field populated so that
// `docker run --rm image` starts the default shell which would typically be sh on Linux,
// or cmd on Windows. The catch to this is that if a dockerfile had `CMD ["c:\\windows\\system32\\cmd.exe"]`,
// we wouldn't be able to tell the difference. However, that would be highly unlikely, and besides, this
// is only trying to give a helpful warning of possibly unexpected results.
if d.state.operatingSystem == "windows" &&
d.state.runConfig.ArgsEscaped != argsEscaped &&
((len(runConfig.Cmd) == 1 && strings.ToLower(runConfig.Cmd[0]) != `c:\windows\system32\cmd.exe` && len(runConfig.Shell) == 0) || (len(runConfig.Cmd) > 1)) {
fmt.Fprintf(d.builder.Stderr, " ---> [Warning] Shell-form CMD and exec-form ENTRYPOINT may have unexpected results\n")
}

runConfig.Entrypoint = cmd
runConfig.ArgsEscaped = argsEscaped
if !d.state.cmdSet {
runConfig.Cmd = nil
}
Expand Down
36 changes: 28 additions & 8 deletions builder/dockerfile/dispatchers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"runtime"
"strings"
"testing"

"github.com/docker/docker/api/types"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/docker/docker/pkg/system"
"github.com/docker/go-connections/nat"
"github.com/moby/buildkit/frontend/dockerfile/instructions"
"github.com/moby/buildkit/frontend/dockerfile/parser"
"github.com/moby/buildkit/frontend/dockerfile/shell"
"gotest.tools/assert"
is "gotest.tools/assert/cmp"
Expand Down Expand Up @@ -436,7 +438,14 @@ func TestRunWithBuildArgs(t *testing.T) {

runConfig := &container.Config{}
origCmd := strslice.StrSlice([]string{"cmd", "in", "from", "image"})
cmdWithShell := strslice.StrSlice(append(getShell(runConfig, runtime.GOOS), "echo foo"))

var cmdWithShell strslice.StrSlice
if runtime.GOOS == "windows" {
cmdWithShell = strslice.StrSlice([]string{strings.Join(append(getShell(runConfig, runtime.GOOS), []string{"echo foo"}...), " ")})
} else {
cmdWithShell = strslice.StrSlice(append(getShell(runConfig, runtime.GOOS), "echo foo"))
}

envVars := []string{"|1", "one=two"}
cachedCmd := strslice.StrSlice(append(envVars, cmdWithShell...))

Expand Down Expand Up @@ -478,13 +487,24 @@ func TestRunWithBuildArgs(t *testing.T) {
err := initializeStage(sb, from)
assert.NilError(t, err)
sb.state.buildArgs.AddArg("one", strPtr("two"))
run := &instructions.RunCommand{
ShellDependantCmdLine: instructions.ShellDependantCmdLine{
CmdLine: strslice.StrSlice{"echo foo"},
PrependShell: true,
},
}
assert.NilError(t, dispatch(sb, run))

// This is hugely annoying. On the Windows side, it relies on the
// RunCommand being able to emit String() and Name() (as implemented by
// withNameAndCode). Unfortunately, that is internal, and no way to directly
// set. However, we can fortunately use ParseInstruction in the instructions
// package to parse a fake node which can be used as our instructions.RunCommand
// instead.
node := &parser.Node{
Original: `RUN echo foo`,
Value: "run",
}
runint, err := instructions.ParseInstruction(node)
assert.NilError(t, err)
runinst := runint.(*instructions.RunCommand)
runinst.CmdLine = strslice.StrSlice{"echo foo"}
runinst.PrependShell = true

assert.NilError(t, dispatch(sb, runinst))

// Check that runConfig.Cmd has not been modified by run
assert.Check(t, is.DeepEqual(origCmd, sb.state.runConfig.Cmd))
Expand Down
13 changes: 13 additions & 0 deletions builder/dockerfile/dispatchers_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import (
"errors"
"os"
"path/filepath"

"github.com/docker/docker/api/types/container"
"github.com/moby/buildkit/frontend/dockerfile/instructions"
)

// normalizeWorkdir normalizes a user requested working directory in a
Expand All @@ -21,3 +24,13 @@ func normalizeWorkdir(_ string, current string, requested string) (string, error
}
return requested, nil
}

// resolveCmdLine takes a command line arg set and optionally prepends a platform-specific
// shell in front of it.
func resolveCmdLine(cmd instructions.ShellDependantCmdLine, runConfig *container.Config, os, _, _ string) ([]string, bool) {
result := cmd.CmdLine
if cmd.PrependShell && result != nil {
result = append(getShell(runConfig, os), result...)
}
return result, false
}
46 changes: 46 additions & 0 deletions builder/dockerfile/dispatchers_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"regexp"
"strings"

"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/system"
"github.com/moby/buildkit/frontend/dockerfile/instructions"
)

var pattern = regexp.MustCompile(`^[a-zA-Z]:\.$`)
Expand Down Expand Up @@ -93,3 +95,47 @@ func normalizeWorkdirWindows(current string, requested string) (string, error) {
// Upper-case drive letter
return (strings.ToUpper(string(requested[0])) + requested[1:]), nil
}

// resolveCmdLine takes a command line arg set and optionally prepends a platform-specific
// shell in front of it. It returns either an array of arguments and an indication that
// the arguments are not yet escaped; Or, an array containing a single command line element
// along with an indication that the arguments are escaped so the runtime shouldn't escape.
//
// A better solution could be made, but it would be exceptionally invasive throughout
// many parts of the daemon which are coded assuming Linux args array only only, not taking
// account of Windows-natural command line semantics and it's argv handling. Put another way,
// while what is here is good-enough, it could be improved, but would be highly invasive.
//
// The commands when this function is called are RUN, ENTRYPOINT and CMD.
func resolveCmdLine(cmd instructions.ShellDependantCmdLine, runConfig *container.Config, os, command, original string) ([]string, bool) {

// Make sure we return an empty array if there is no cmd.CmdLine
if len(cmd.CmdLine) == 0 {
return []string{}, runConfig.ArgsEscaped
}

if os == "windows" { // ie WCOW
if cmd.PrependShell {
// WCOW shell-form. Return a single-element array containing the original command line prepended with the shell.
// Also indicate that it has not been escaped (so will be passed through directly to HCS). Note that
// we go back to the original un-parsed command line in the dockerfile line, strip off both the command part of
// it (RUN/ENTRYPOINT/CMD), and also strip any leading white space. IOW, we deliberately ignore any prior parsing
// so as to ensure it is treated exactly as a command line. For those interested, `RUN mkdir "c:/foo"` is a particularly
// good example of why this is necessary if you fancy debugging how cmd.exe and its builtin mkdir works. (Windows
// doesn't have a mkdir.exe, and I'm guessing cmd.exe has some very long unavoidable and unchangeable historical
// design decisions over how both its built-in echo and mkdir are coded. Probably more too.)
original = original[len(command):] // Strip off the command
original = strings.TrimLeft(original, " \t\v\n") // Strip of leading whitespace
return []string{strings.Join(getShell(runConfig, os), " ") + " " + original}, true
}

// WCOW JSON/"exec" form.
return cmd.CmdLine, false
}

// LCOW - use args as an array, same as LCOL.
if cmd.PrependShell && cmd.CmdLine != nil {
return append(getShell(runConfig, os), cmd.CmdLine...), false
}
return cmd.CmdLine, false
}
6 changes: 6 additions & 0 deletions builder/dockerfile/internals.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ func withCmd(cmd []string) runConfigModifier {
}
}

func withArgsEscaped(argsEscaped bool) runConfigModifier {
return func(runConfig *container.Config) {
runConfig.ArgsEscaped = argsEscaped
}
}

// withCmdComment sets Cmd to a nop comment string. See withCmdCommentString for
// why there are two almost identical versions of this.
func withCmdComment(comment string, platform string) runConfigModifier {
Expand Down
1 change: 0 additions & 1 deletion builder/dockerfile/internals_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ func lookupNTAccount(builder *Builder, accountName string, state *dispatchState)
runConfig := copyRunConfig(state.runConfig,
withCmdCommentString("internal run to obtain NT account information.", optionsPlatform.OS))

runConfig.ArgsEscaped = true
runConfig.Cmd = []string{targetExecutable, "getaccountsid", accountName}

hostConfig := &container.HostConfig{Mounts: []mount.Mount{
Expand Down
2 changes: 1 addition & 1 deletion builder/dockerfile/mockbackend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (m *MockBackend) ContainerAttachRaw(cID string, stdin io.ReadCloser, stdout
return nil
}

func (m *MockBackend) ContainerCreate(config types.ContainerCreateConfig) (container.ContainerCreateCreatedBody, error) {
func (m *MockBackend) ContainerCreateIgnoreImagesArgsEscaped(config types.ContainerCreateConfig) (container.ContainerCreateCreatedBody, error) {
if m.containerCreateFunc != nil {
return m.containerCreateFunc(config)
}
Expand Down
1 change: 0 additions & 1 deletion daemon/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ func merge(userConf, imageConf *containertypes.Config) error {
if len(userConf.Entrypoint) == 0 {
if len(userConf.Cmd) == 0 {
userConf.Cmd = imageConf.Cmd
userConf.ArgsEscaped = imageConf.ArgsEscaped
}

if userConf.Entrypoint == nil {
Expand Down
Loading

0 comments on commit 20833b0

Please sign in to comment.