diff --git a/builder/dockerfile/builder_unix.go b/builder/dockerfile/builder_unix.go new file mode 100644 index 0000000000..76a7ce74f9 --- /dev/null +++ b/builder/dockerfile/builder_unix.go @@ -0,0 +1,5 @@ +// +build !windows + +package dockerfile + +var defaultShell = []string{"/bin/sh", "-c"} diff --git a/builder/dockerfile/builder_windows.go b/builder/dockerfile/builder_windows.go new file mode 100644 index 0000000000..37e9fbcf4b --- /dev/null +++ b/builder/dockerfile/builder_windows.go @@ -0,0 +1,3 @@ +package dockerfile + +var defaultShell = []string{"cmd", "/S", "/C"} diff --git a/builder/dockerfile/command/command.go b/builder/dockerfile/command/command.go index 3e087e422e..f23c6874b5 100644 --- a/builder/dockerfile/command/command.go +++ b/builder/dockerfile/command/command.go @@ -3,42 +3,44 @@ package command // Define constants for the command strings const ( + Add = "add" + Arg = "arg" + Cmd = "cmd" + Copy = "copy" + Entrypoint = "entrypoint" Env = "env" + Expose = "expose" + From = "from" + Healthcheck = "healthcheck" Label = "label" Maintainer = "maintainer" - Add = "add" - Copy = "copy" - From = "from" Onbuild = "onbuild" - Workdir = "workdir" Run = "run" - Cmd = "cmd" - Entrypoint = "entrypoint" - Expose = "expose" - Volume = "volume" - User = "user" + Shell = "shell" StopSignal = "stopsignal" - Arg = "arg" - Healthcheck = "healthcheck" + User = "user" + Volume = "volume" + Workdir = "workdir" ) // Commands is list of all Dockerfile commands var Commands = map[string]struct{}{ + Add: {}, + Arg: {}, + Cmd: {}, + Copy: {}, + Entrypoint: {}, Env: {}, + Expose: {}, + From: {}, + Healthcheck: {}, Label: {}, Maintainer: {}, - Add: {}, - Copy: {}, - From: {}, Onbuild: {}, - Workdir: {}, Run: {}, - Cmd: {}, - Entrypoint: {}, - Expose: {}, - Volume: {}, - User: {}, + Shell: {}, StopSignal: {}, - Arg: {}, - Healthcheck: {}, + User: {}, + Volume: {}, + Workdir: {}, } diff --git a/builder/dockerfile/dispatchers.go b/builder/dockerfile/dispatchers.go index 2f3b56cfd2..3e1bb822be 100644 --- a/builder/dockerfile/dispatchers.go +++ b/builder/dockerfile/dispatchers.go @@ -274,8 +274,8 @@ func workdir(b *Builder, args []string, attributes map[string]bool, original str // RUN some command yo // // run a command and commit the image. Args are automatically prepended with -// 'sh -c' under linux or 'cmd /S /C' under Windows, in the event there is -// only one argument. The difference in processing: +// the current SHELL which defaults to 'sh -c' under linux or 'cmd /S /C' under +// Windows, in the event there is only one argument The difference in processing: // // RUN echo hi # sh -c echo hi (Linux) // RUN echo hi # cmd /S /C echo hi (Windows) @@ -293,13 +293,8 @@ func run(b *Builder, args []string, attributes map[string]bool, original string) args = handleJSONArgs(args, attributes) if !attributes["json"] { - if runtime.GOOS != "windows" { - args = append([]string{"/bin/sh", "-c"}, args...) - } else { - args = append([]string{"cmd", "/S", "/C"}, args...) - } + args = append(getShell(b.runConfig), args...) } - config := &container.Config{ Cmd: strslice.StrSlice(args), Image: b.image, @@ -408,11 +403,7 @@ func cmd(b *Builder, args []string, attributes map[string]bool, original string) cmdSlice := handleJSONArgs(args, attributes) if !attributes["json"] { - if runtime.GOOS != "windows" { - cmdSlice = append([]string{"/bin/sh", "-c"}, cmdSlice...) - } else { - cmdSlice = append([]string{"cmd", "/S", "/C"}, cmdSlice...) - } + cmdSlice = append(getShell(b.runConfig), cmdSlice...) } b.runConfig.Cmd = strslice.StrSlice(cmdSlice) @@ -535,8 +526,8 @@ func healthcheck(b *Builder, args []string, attributes map[string]bool, original // ENTRYPOINT /usr/sbin/nginx // -// Set the entrypoint (which defaults to sh -c on linux, or cmd /S /C on Windows) to -// /usr/sbin/nginx. Will accept the CMD as the arguments to /usr/sbin/nginx. +// Set the entrypoint to /usr/sbin/nginx. Will accept the CMD as the arguments +// to /usr/sbin/nginx. Uses the default shell if not in JSON format. // // Handles command processing similar to CMD and RUN, only b.runConfig.Entrypoint // is initialized at NewBuilder time instead of through argument parsing. @@ -557,11 +548,7 @@ func entrypoint(b *Builder, args []string, attributes map[string]bool, original b.runConfig.Entrypoint = nil default: // ENTRYPOINT echo hi - if runtime.GOOS != "windows" { - b.runConfig.Entrypoint = strslice.StrSlice{"/bin/sh", "-c", parsed[0]} - } else { - b.runConfig.Entrypoint = strslice.StrSlice{"cmd", "/S", "/C", parsed[0]} - } + b.runConfig.Entrypoint = strslice.StrSlice(append(getShell(b.runConfig), parsed[0])) } // when setting the entrypoint if a CMD was not explicitly set then @@ -727,6 +714,28 @@ func arg(b *Builder, args []string, attributes map[string]bool, original string) return b.commit("", b.runConfig.Cmd, fmt.Sprintf("ARG %s", arg)) } +// SHELL powershell -command +// +// Set the non-default shell to use. +func shell(b *Builder, args []string, attributes map[string]bool, original string) error { + if err := b.flags.Parse(); err != nil { + return err + } + shellSlice := handleJSONArgs(args, attributes) + switch { + case len(shellSlice) == 0: + // SHELL [] + return errAtLeastOneArgument("SHELL") + case attributes["json"]: + // SHELL ["powershell", "-command"] + b.runConfig.Shell = strslice.StrSlice(shellSlice) + default: + // SHELL powershell -command - not JSON + return errNotJSON("SHELL", original) + } + return b.commit("", b.runConfig.Cmd, fmt.Sprintf("SHELL %v", shellSlice)) +} + func errAtLeastOneArgument(command string) error { return fmt.Errorf("%s requires at least one argument", command) } @@ -738,3 +747,12 @@ func errExactlyOneArgument(command string) error { func errTooManyArguments(command string) error { return fmt.Errorf("Bad input to %s, too many arguments", command) } + +// getShell is a helper function which gets the right shell for prefixing the +// shell-form of RUN, ENTRYPOINT and CMD instructions +func getShell(c *container.Config) []string { + if 0 == len(c.Shell) { + return defaultShell[:] + } + return c.Shell[:] +} diff --git a/builder/dockerfile/dispatchers_unix.go b/builder/dockerfile/dispatchers_unix.go index e1b9fde0bb..8b0dfc3911 100644 --- a/builder/dockerfile/dispatchers_unix.go +++ b/builder/dockerfile/dispatchers_unix.go @@ -21,3 +21,7 @@ func normaliseWorkdir(current string, requested string) (string, error) { } return requested, nil } + +func errNotJSON(command, _ string) error { + return fmt.Errorf("%s requires the arguments to be in JSON form", command) +} diff --git a/builder/dockerfile/dispatchers_windows.go b/builder/dockerfile/dispatchers_windows.go index 7a102c8552..5a40ae09d3 100644 --- a/builder/dockerfile/dispatchers_windows.go +++ b/builder/dockerfile/dispatchers_windows.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/docker/docker/pkg/system" @@ -43,3 +44,22 @@ func normaliseWorkdir(current string, requested string) (string, error) { // Upper-case drive letter return (strings.ToUpper(string(requested[0])) + requested[1:]), nil } + +func errNotJSON(command, original string) error { + // For Windows users, give a hint if it looks like it might contain + // a path which hasn't been escaped such as ["c:\windows\system32\prog.exe", "-param"], + // as JSON must be escaped. Unfortunate... + // + // Specifically looking for quote-driveletter-colon-backslash, there's no + // double backslash and a [] pair. No, this is not perfect, but it doesn't + // have to be. It's simply a hint to make life a little easier. + extra := "" + original = filepath.FromSlash(strings.ToLower(strings.Replace(strings.ToLower(original), strings.ToLower(command)+" ", "", -1))) + if len(regexp.MustCompile(`"[a-z]:\\.*`).FindStringSubmatch(original)) > 0 && + !strings.Contains(original, `\\`) && + strings.Contains(original, "[") && + strings.Contains(original, "]") { + extra = fmt.Sprintf(`. It looks like '%s' includes a file path without an escaped back-slash. JSON requires back-slashes to be escaped such as ["c:\\path\\to\\file.exe", "/parameter"]`, original) + } + return fmt.Errorf("%s requires the arguments to be in JSON form%s", command, extra) +} diff --git a/builder/dockerfile/evaluator.go b/builder/dockerfile/evaluator.go index 52786371df..4c9d425f9b 100644 --- a/builder/dockerfile/evaluator.go +++ b/builder/dockerfile/evaluator.go @@ -58,23 +58,24 @@ var evaluateTable map[string]func(*Builder, []string, map[string]bool, string) e func init() { evaluateTable = map[string]func(*Builder, []string, map[string]bool, string) error{ + command.Add: add, + command.Arg: arg, + command.Cmd: cmd, + command.Copy: dispatchCopy, // copy() is a go builtin + command.Entrypoint: entrypoint, command.Env: env, + command.Expose: expose, + command.From: from, + command.Healthcheck: healthcheck, command.Label: label, command.Maintainer: maintainer, - command.Add: add, - command.Copy: dispatchCopy, // copy() is a go builtin - command.From: from, command.Onbuild: onbuild, - command.Workdir: workdir, command.Run: run, - command.Cmd: cmd, - command.Entrypoint: entrypoint, - command.Expose: expose, - command.Volume: volume, - command.User: user, + command.Shell: shell, command.StopSignal: stopSignal, - command.Arg: arg, - command.Healthcheck: healthcheck, + command.User: user, + command.Volume: volume, + command.Workdir: workdir, } } diff --git a/builder/dockerfile/internals.go b/builder/dockerfile/internals.go index 2f26265618..c9fcb15f54 100644 --- a/builder/dockerfile/internals.go +++ b/builder/dockerfile/internals.go @@ -14,7 +14,6 @@ import ( "net/url" "os" "path/filepath" - "runtime" "sort" "strings" "sync" @@ -51,11 +50,7 @@ func (b *Builder) commit(id string, autoCmd strslice.StrSlice, comment string) e if id == "" { cmd := b.runConfig.Cmd - if runtime.GOOS != "windows" { - b.runConfig.Cmd = strslice.StrSlice{"/bin/sh", "-c", "#(nop) " + comment} - } else { - b.runConfig.Cmd = strslice.StrSlice{"cmd", "/S /C", "REM (nop) " + comment} - } + b.runConfig.Cmd = strslice.StrSlice(append(getShell(b.runConfig), "#(nop) ", comment)) defer func(cmd strslice.StrSlice) { b.runConfig.Cmd = cmd }(cmd) hit, err := b.probeCache() @@ -177,11 +172,7 @@ func (b *Builder) runContextCommand(args []string, allowRemote bool, allowLocalD } cmd := b.runConfig.Cmd - if runtime.GOOS != "windows" { - b.runConfig.Cmd = strslice.StrSlice{"/bin/sh", "-c", fmt.Sprintf("#(nop) %s %s in %s", cmdName, srcHash, dest)} - } else { - b.runConfig.Cmd = strslice.StrSlice{"cmd", "/S", "/C", fmt.Sprintf("REM (nop) %s %s in %s", cmdName, srcHash, dest)} - } + b.runConfig.Cmd = strslice.StrSlice(append(getShell(b.runConfig), "#(nop) %s %s in %s ", cmdName, srcHash, dest)) defer func(cmd strslice.StrSlice) { b.runConfig.Cmd = cmd }(cmd) if hit, err := b.probeCache(); err != nil { diff --git a/builder/dockerfile/parser/parser.go b/builder/dockerfile/parser/parser.go index 48aee99103..5a61a9d408 100644 --- a/builder/dockerfile/parser/parser.go +++ b/builder/dockerfile/parser/parser.go @@ -67,23 +67,24 @@ func init() { // functions. Errors are propagated up by Parse() and the resulting AST can // be incorporated directly into the existing AST as a next. dispatch = map[string]func(string) (*Node, map[string]bool, error){ - command.User: parseString, - command.Onbuild: parseSubCommand, - command.Workdir: parseString, + command.Add: parseMaybeJSONToList, + command.Arg: parseNameOrNameVal, + command.Cmd: parseMaybeJSON, + command.Copy: parseMaybeJSONToList, + command.Entrypoint: parseMaybeJSON, command.Env: parseEnv, + command.Expose: parseStringsWhitespaceDelimited, + command.From: parseString, + command.Healthcheck: parseHealthConfig, command.Label: parseLabel, command.Maintainer: parseString, - command.From: parseString, - command.Add: parseMaybeJSONToList, - command.Copy: parseMaybeJSONToList, + command.Onbuild: parseSubCommand, command.Run: parseMaybeJSON, - command.Cmd: parseMaybeJSON, - command.Entrypoint: parseMaybeJSON, - command.Expose: parseStringsWhitespaceDelimited, - command.Volume: parseMaybeJSONToList, + command.Shell: parseMaybeJSON, command.StopSignal: parseString, - command.Arg: parseNameOrNameVal, - command.Healthcheck: parseHealthConfig, + command.User: parseString, + command.Volume: parseMaybeJSONToList, + command.Workdir: parseString, } } diff --git a/builder/dockerfile/support.go b/builder/dockerfile/support.go index d2fdada874..e87588910b 100644 --- a/builder/dockerfile/support.go +++ b/builder/dockerfile/support.go @@ -2,7 +2,7 @@ package dockerfile import "strings" -// handleJSONArgs parses command passed to CMD, ENTRYPOINT or RUN instruction in Dockerfile +// handleJSONArgs parses command passed to CMD, ENTRYPOINT, RUN and SHELL instruction in Dockerfile // for exec form it returns untouched args slice // for shell form it returns concatenated args as the first element of a slice func handleJSONArgs(args []string, attributes map[string]bool) []string { diff --git a/docs/reference/builder.md b/docs/reference/builder.md index b9a894444b..6217d0d341 100644 --- a/docs/reference/builder.md +++ b/docs/reference/builder.md @@ -497,7 +497,8 @@ generated images. RUN has 2 forms: -- `RUN ` (*shell* form, the command is run in a shell - `/bin/sh -c`) +- `RUN ` (*shell* form, the command is run in a shell, which by +default is `/bin/sh -c` on Linux or `cmd /S /C` on Windows) - `RUN ["executable", "param1", "param2"]` (*exec* form) The `RUN` instruction will execute any commands in a new layer on top of the @@ -509,7 +510,10 @@ concepts of Docker where commits are cheap and containers can be created from any point in an image's history, much like source control. The *exec* form makes it possible to avoid shell string munging, and to `RUN` -commands using a base image that does not contain `/bin/sh`. +commands using a base image that does not contain the specified shell executable. + +The default shell for the *shell* form can be changed using the `SHELL` +command. In the *shell* form you can use a `\` (backslash) to continue a single RUN instruction onto the next line. For example, consider these two lines: @@ -1469,7 +1473,7 @@ For example you might add something like this: ## STOPSIGNAL - STOPSIGNAL signal + STOPSIGNAL signal The `STOPSIGNAL` instruction sets the system call signal that will be sent to the container to exit. This signal can be a valid unsigned number that matches a position in the kernel's syscall table, for instance 9, @@ -1541,6 +1545,120 @@ generated with the new status. The `HEALTHCHECK` feature was added in Docker 1.12. +## SHELL + + SHELL ["executable", "parameters"] + +The `SHELL` instruction allows the default shell used for the *shell* form of +commands to be overridden. The default shell on Linux is `["/bin/sh", "-c"]`, and on +Windows is `["cmd", "/S", "/C"]`. The `SHELL` instruction *must* be written in JSON +form in a Dockerfile. + +The `SHELL` instruction is particularly useful on Windows where there are +two commonly used and quite different native shells: `cmd` and `powershell`, as +well as alternate shells available including `sh`. + +The `SHELL` instruction can appear multiple times. Each `SHELL` instruction overrides +all previous `SHELL` instructions, and affects all subsequent instructions. For example: + + FROM windowsservercore + + # Executed as cmd /S /C echo default + RUN echo default + + # Executed as cmd /S /C powershell -command Write-Host default + RUN powershell -command Write-Host default + + # Executed as powershell -command Write-Host hello + SHELL ["powershell", "-command"] + RUN Write-Host hello + + # Executed as cmd /S /C echo hello + SHELL ["cmd", "/S"", "/C"] + RUN echo hello + +The following instructions can be affected by the `SHELL` instruction when the +*shell* form of them is used in a Dockerfile: `RUN`, `CMD` and `ENTRYPOINT`. + +The following example is a common pattern found on Windows which can be +streamlined by using the `SHELL` instruction: + + ... + RUN powershell -command Execute-MyCmdlet -param1 "c:\foo.txt" + ... + +The command invoked by docker will be: + + cmd /S /C powershell -command Execute-MyCmdlet -param1 "c:\foo.txt" + + This is inefficient for two reasons. First, there is an un-necessary cmd.exe command + processor (aka shell) being invoked. Second, each `RUN` instruction in the *shell* + form requires an extra `powershell -command` prefixing the command. + +To make this more efficient, one of two mechanisms can be employed. One is to +use the JSON form of the RUN command such as: + + ... + RUN ["powershell", "-command", "Execute-MyCmdlet", "-param1 \"c:\\foo.txt\""] + ... + +While the JSON form is unambiguous and does not use the un-necessary cmd.exe, +it does require more verbosity through double-quoting and escaping. The alternate +mechanism is to use the `SHELL` instruction and the *shell* form, +making a more natural syntax for Windows users, especially when combined with +the `escape` parser directive: + + # escape=` + + FROM windowsservercore + SHELL ["powershell","-command"] + RUN New-Item -ItemType Directory C:\Example + ADD Execute-MyCmdlet.ps1 c:\example\ + RUN c:\example\Execute-MyCmdlet -sample 'hello world' + +Resulting in: + + PS E:\docker\build\shell> docker build -t shell . + Sending build context to Docker daemon 3.584 kB + Step 1 : FROM windowsservercore + ---> 5bc36a335344 + Step 2 : SHELL powershell -command + ---> Running in 87d7a64c9751 + ---> 4327358436c1 + Removing intermediate container 87d7a64c9751 + Step 3 : RUN New-Item -ItemType Directory C:\Example + ---> Running in 3e6ba16b8df9 + + + Directory: C:\ + + + Mode LastWriteTime Length Name + ---- ------------- ------ ---- + d----- 6/2/2016 2:59 PM Example + + + ---> 1f1dfdcec085 + Removing intermediate container 3e6ba16b8df9 + Step 4 : ADD Execute-MyCmdlet.ps1 c:\example\ + ---> 6770b4c17f29 + Removing intermediate container b139e34291dc + Step 5 : RUN c:\example\Execute-MyCmdlet -sample 'hello world' + ---> Running in abdcf50dfd1f + Hello from Execute-MyCmdlet.ps1 - passed hello world + ---> ba0e25255fda + Removing intermediate container abdcf50dfd1f + Successfully built ba0e25255fda + PS E:\docker\build\shell> + +The `SHELL` instruction could also be used to modify the way in which +a shell operates. For example, using `SHELL cmd /S /C /V:ON|OFF` on Windows, delayed +environment variable expansion semantics could be modified. + +The `SHELL` instruction can also be used on Linux should an alternate shell be +required such `zsh`, `csh`, `tcsh` and others. + +The `SHELL` feature was added in Docker 1.12. ## Dockerfile examples diff --git a/integration-cli/docker_cli_build_test.go b/integration-cli/docker_cli_build_test.go index 9d84a7e7c0..c17a5e79b8 100644 --- a/integration-cli/docker_cli_build_test.go +++ b/integration-cli/docker_cli_build_test.go @@ -6834,3 +6834,146 @@ func (s *DockerSuite) TestBuildWithUTF8BOMDockerignore(c *check.C) { c.Fatal(err) } } + +// #22489 Shell test to confirm config gets updated correctly +func (s *DockerSuite) TestBuildShellUpdatesConfig(c *check.C) { + name := "testbuildshellupdatesconfig" + + expected := `["foo","-bar","#(nop) ","SHELL [foo -bar]"]` + _, err := buildImage(name, + `FROM `+minimalBaseImage()+` + SHELL ["foo", "-bar"]`, + true) + if err != nil { + c.Fatal(err) + } + res := inspectFieldJSON(c, name, "ContainerConfig.Cmd") + if res != expected { + c.Fatalf("%s, expected %s", res, expected) + } + res = inspectFieldJSON(c, name, "ContainerConfig.Shell") + if res != `["foo","-bar"]` { + c.Fatalf(`%s, expected ["foo","-bar"]`, res) + } +} + +// #22489 Changing the shell multiple times and CMD after. +func (s *DockerSuite) TestBuildShellMultiple(c *check.C) { + name := "testbuildshellmultiple" + + _, out, _, err := buildImageWithStdoutStderr(name, + `FROM busybox + RUN echo defaultshell + SHELL ["echo"] + RUN echoshell + SHELL ["ls"] + RUN -l + CMD -l`, + true) + if err != nil { + c.Fatal(err) + } + + // Must contain 'defaultshell' twice + if len(strings.Split(out, "defaultshell")) != 3 { + c.Fatalf("defaultshell should have appeared twice in %s", out) + } + + // Must contain 'echoshell' twice + if len(strings.Split(out, "echoshell")) != 3 { + c.Fatalf("echoshell should have appeared twice in %s", out) + } + + // Must contain "total " (part of ls -l) + if !strings.Contains(out, "total ") { + c.Fatalf("%s should have contained 'total '", out) + } + + // A container started from the image uses the shell-form CMD. + // Last shell is ls. CMD is -l. So should contain 'total '. + outrun, _ := dockerCmd(c, "run", "--rm", name) + if !strings.Contains(outrun, "total ") { + c.Fatalf("Expected started container to run ls -l. %s", outrun) + } +} + +// #22489. Changed SHELL with ENTRYPOINT +func (s *DockerSuite) TestBuildShellEntrypoint(c *check.C) { + name := "testbuildshellentrypoint" + + _, err := buildImage(name, + `FROM busybox + SHELL ["ls"] + ENTRYPOINT -l`, + true) + if err != nil { + c.Fatal(err) + } + + // A container started from the image uses the shell-form ENTRYPOINT. + // Shell is ls. ENTRYPOINT is -l. So should contain 'total '. + outrun, _ := dockerCmd(c, "run", "--rm", name) + if !strings.Contains(outrun, "total ") { + c.Fatalf("Expected started container to run ls -l. %s", outrun) + } +} + +// #22489 Shell test to confirm shell is inherited in a subsequent build +func (s *DockerSuite) TestBuildShellInherited(c *check.C) { + name1 := "testbuildshellinherited1" + _, err := buildImage(name1, + `FROM busybox + SHELL ["ls"]`, + true) + if err != nil { + c.Fatal(err) + } + + name2 := "testbuildshellinherited2" + _, out, _, err := buildImageWithStdoutStderr(name2, + `FROM `+name1+` + RUN -l`, + true) + if err != nil { + c.Fatal(err) + } + + // ls -l has "total " followed by some number in it, ls without -l does not. + if !strings.Contains(out, "total ") { + c.Fatalf("Should have seen total in 'ls -l'.\n%s", out) + } +} + +// #22489 Shell test to confirm non-JSON doesn't work +func (s *DockerSuite) TestBuildShellNotJSON(c *check.C) { + name := "testbuildshellnotjson" + + _, err := buildImage(name, + `FROM `+minimalBaseImage()+` + sHeLl exec -form`, // Casing explicit to ensure error is upper-cased. + true) + if err == nil { + c.Fatal("Image build should have failed") + } + if !strings.Contains(err.Error(), "SHELL requires the arguments to be in JSON form") { + c.Fatal("Error didn't indicate that arguments must be in JSON form") + } +} + +// #22489 Windows shell test to confirm native is powershell if executing a PS command +// This would error if the default shell were still cmd. +func (s *DockerSuite) TestBuildShellWindowsPowershell(c *check.C) { + testRequires(c, DaemonIsWindows) + name := "testbuildshellpowershell" + _, out, err := buildImageWithOut(name, + `FROM `+minimalBaseImage()+` + SHELL ["powershell", "-command"] + RUN Write-Host John`, + true) + if err != nil { + c.Fatal(err) + } + if !strings.Contains(out, "\nJohn\n") { + c.Fatalf("Line with 'John' not found in output %q", out) + } +}