Skip to content

Commit

Permalink
dockerfile: allow heredocs for CMD instruction
Browse files Browse the repository at this point in the history
This patch introduces support for using heredocs for the CMD instruction -
they work identically to the heredocs for RUN, with a couple of
differences for shebang scripts:

- We create a new layer writing the script contents to /mnt/pipes/

  Usually CMD does not create a new layer, however, the file is required
  to run the script as we would with RUN, since we want to avoid manual
  parsing of the shebang line.

- We have to use /mnt/pipes instead of /dev/pipes, since /dev/pipes is
  mounted at runtime.

Signed-off-by: Justin Chadwell <[email protected]>
  • Loading branch information
jedevc committed Sep 7, 2022
1 parent 2254cfe commit 48cfb7e
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 59 deletions.
148 changes: 89 additions & 59 deletions frontend/dockerfile/dockerfile2llb/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -884,62 +884,9 @@ func dispatchEnv(d *dispatchState, c *instructions.EnvCommand) error {
func dispatchRun(d *dispatchState, c *instructions.RunCommand, proxy *llb.ProxyEnv, sources []*dispatchState, dopt dispatchOpt) error {
var opt []llb.RunOption

customname := c.String()

var args []string = c.CmdLine
if len(c.Files) > 0 {
if len(args) != 1 || !c.PrependShell {
return fmt.Errorf("parsing produced an invalid run command: %v", args)
}

if heredoc := parser.MustParseHeredoc(args[0]); heredoc != nil {
if d.image.OS != "windows" && strings.HasPrefix(c.Files[0].Data, "#!") {
// This is a single heredoc with a shebang, so create a file
// and run it.
// NOTE: choosing to expand doesn't really make sense here, so
// we silently ignore that option if it was provided.
sourcePath := "/"
destPath := "/dev/pipes/"

f := c.Files[0].Name
data := c.Files[0].Data
if c.Files[0].Chomp {
data = parser.ChompHeredocContent(data)
}
st := llb.Scratch().Dir(sourcePath).File(
llb.Mkfile(f, 0755, []byte(data)),
WithInternalName("preparing inline document"),
)

mount := llb.AddMount(destPath, st, llb.SourcePath(sourcePath), llb.Readonly)
opt = append(opt, mount)

args = []string{path.Join(destPath, f)}
} else {
// Just a simple heredoc, so just run the contents in the
// shell: this creates the effect of a "fake"-heredoc, so that
// the syntax can still be used for shells that don't support
// heredocs directly.
// NOTE: like above, we ignore the expand option.
data := c.Files[0].Data
if c.Files[0].Chomp {
data = parser.ChompHeredocContent(data)
}
args = []string{data}
}
customname += fmt.Sprintf(" (%s)", summarizeHeredoc(c.Files[0].Data))
} else {
// More complex heredoc, so reconstitute it, and pass it to the
// shell to handle.
full := args[0]
for _, file := range c.Files {
full += "\n" + file.Data + file.Name
}
args = []string{full}
}
}
if c.PrependShell {
args = withShell(d.image, args)
args, opt, customname, _, err := processCmdLine(d, &c.ShellDependantCmdLine, c.String(), false)
if err != nil {
return err
}

env, err := d.state.Env(context.TODO())
Expand Down Expand Up @@ -1258,13 +1205,17 @@ func dispatchOnbuild(d *dispatchState, c *instructions.OnbuildCommand) error {
}

func dispatchCmd(d *dispatchState, c *instructions.CmdCommand) error {
var args []string = c.CmdLine
if c.PrependShell {
args = withShell(d.image, args)
args, _, _, createdLayer, err := processCmdLine(d, &c.ShellDependantCmdLine, c.String(), true)
if err != nil {
return err
}

d.image.Config.Cmd = args
d.image.Config.ArgsEscaped = true
d.cmdSet = true
if createdLayer {
return commitToHistory(&d.image, fmt.Sprintf("CMD %q", args), true, &d.state)
}
return commitToHistory(&d.image, fmt.Sprintf("CMD %q", args), false, nil)
}

Expand Down Expand Up @@ -1712,6 +1663,85 @@ func location(sm *llb.SourceMap, locations []parser.Range) llb.ConstraintsOpt {
return sm.Location(loc)
}

func processCmdLine(d *dispatchState, c *instructions.ShellDependantCmdLine, name string, createLayer bool) (args []string, opt []llb.RunOption, customname string, createdLayer bool, err error) {
args = c.CmdLine
customname = name

// Attempt to process heredocs if present
if len(c.Files) > 0 {
if len(args) != 1 || !c.PrependShell {
err = fmt.Errorf("parsing produced an invalid run command: %v", args)
return
}

if heredoc := parser.MustParseHeredoc(args[0]); heredoc != nil {
if d.image.OS != "windows" && strings.HasPrefix(c.Files[0].Data, "#!") {
// This is a single heredoc with a shebang, so create a file
// and run it.
// NOTE: choosing to expand doesn't really make sense here, so
// we silently ignore that option if it was provided.
sourcePath := "/"
destPath := "/dev/pipes/"
if createLayer {
destPath = "/mnt/pipes/"
}

f := c.Files[0].Name
data := c.Files[0].Data
if c.Files[0].Chomp {
data = parser.ChompHeredocContent(data)
}
st := llb.Scratch().Dir(sourcePath).File(
llb.Mkfile(f, 0755, []byte(data)),
WithInternalName("preparing inline document"),
)

if createLayer {
opts := []llb.CopyOption{&llb.CopyInfo{
CreateDestPath: true,
}}
d.state = d.state.File(
llb.Copy(st, f, destPath, opts...),
WithInternalName("preparing inline document"),
)
createdLayer = true
} else {
mount := llb.AddMount(destPath, st, llb.SourcePath(sourcePath), llb.Readonly)
opt = append(opt, mount)
}

args = []string{path.Join(destPath, f)}
} else {
// Just a simple heredoc, so just run the contents in the
// shell: this creates the effect of a "fake"-heredoc, so that
// the syntax can still be used for shells that don't support
// heredocs directly.
// NOTE: like above, we ignore the expand option.
data := c.Files[0].Data
if c.Files[0].Chomp {
data = parser.ChompHeredocContent(data)
}
args = []string{data}
}
customname += fmt.Sprintf(" (%s)", summarizeHeredoc(c.Files[0].Data))
} else {
// More complex heredoc, so reconstitute it, and pass it to the
// shell to handle.
full := args[0]
for _, file := range c.Files {
full += "\n" + file.Data + file.Name
}
args = []string{full}
}
}

if c.PrependShell {
args = withShell(d.image, args)
}

return
}

func summarizeHeredoc(doc string) string {
doc = strings.TrimSpace(doc)
lines := strings.Split(strings.ReplaceAll(doc, "\r\n", "\n"), "\n")
Expand Down
140 changes: 140 additions & 0 deletions frontend/dockerfile/dockerfile_heredoc_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package dockerfile

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"

"github.com/containerd/containerd/content"
"github.com/containerd/containerd/namespaces"
"github.com/containerd/containerd/platforms"
"github.com/containerd/continuity/fs/fstest"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/frontend/dockerfile/builder"
"github.com/moby/buildkit/util/testutil/integration"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
Expand All @@ -24,6 +29,8 @@ var hdTests = integration.TestFuncs(
testHeredocIndent,
testHeredocVarSubstitution,
testOnBuildHeredoc,
testCmdHeredoc,
testCmdShebangHeredoc,
)

func init() {
Expand Down Expand Up @@ -676,3 +683,136 @@ EOF
require.NoError(t, err)
require.Equal(t, "hello world\n", string(dt))
}

func testCmdHeredoc(t *testing.T, sb integration.Sandbox) {
f := getFrontend(t, sb)

cdAddress := sb.ContainerdAddress()
if cdAddress == "" {
t.Skip("test is only for containerd worker")
}

dockerfile := []byte(`
FROM scratch
CMD <<EOF
echo 'Hello World'
EOF
`)

dir, err := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", []byte(dockerfile), 0600),
)
require.NoError(t, err)
defer os.RemoveAll(dir)

c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

target := "docker.io/moby/cmdheredoctest:latest"
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": target,
},
},
},
LocalDirs: map[string]string{
builder.DefaultLocalNameDockerfile: dir,
builder.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)

ctr, err := newContainerd(cdAddress)
require.NoError(t, err)
defer ctr.Close()

ctx := namespaces.WithNamespace(sb.Context(), "buildkit")

img, err := ctr.ImageService().Get(ctx, target)
require.NoError(t, err)

desc, err := img.Config(ctx, ctr.ContentStore(), platforms.Default())
require.NoError(t, err)

dt, err := content.ReadBlob(ctx, ctr.ContentStore(), desc)
require.NoError(t, err)

var ociimg ocispecs.Image
err = json.Unmarshal(dt, &ociimg)
require.NoError(t, err)

require.Equal(t, []string{"/bin/sh", "-c", "echo 'Hello World'\n"}, ociimg.Config.Cmd)
}

func testCmdShebangHeredoc(t *testing.T, sb integration.Sandbox) {
f := getFrontend(t, sb)

cdAddress := sb.ContainerdAddress()
if cdAddress == "" {
t.Skip("test is only for containerd worker")
}

dockerfile := []byte(`
FROM scratch
CMD <<EOF
#!/bin/bash
echo "Hello World"
EOF
`)

dir, err := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", []byte(dockerfile), 0600),
)
require.NoError(t, err)
defer os.RemoveAll(dir)

c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

target := "docker.io/moby/cmdheredocshebangtest:latest"
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": target,
},
},
},
LocalDirs: map[string]string{
builder.DefaultLocalNameDockerfile: dir,
builder.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)

ctr, err := newContainerd(cdAddress)
require.NoError(t, err)
defer ctr.Close()

ctx := namespaces.WithNamespace(sb.Context(), "buildkit")

img, err := ctr.ImageService().Get(ctx, target)
require.NoError(t, err)

desc, err := img.Config(ctx, ctr.ContentStore(), platforms.Default())
require.NoError(t, err)

dt, err := content.ReadBlob(ctx, ctr.ContentStore(), desc)
require.NoError(t, err)

var ociimg ocispecs.Image
err = json.Unmarshal(dt, &ociimg)
require.NoError(t, err)

require.Equal(t, []string{"/bin/sh", "-c", "/mnt/pipes/EOF"}, ociimg.Config.Cmd)
}
1 change: 1 addition & 0 deletions frontend/dockerfile/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ var (
// Directives allowed to contain heredocs
heredocDirectives = map[string]bool{
command.Add: true,
command.Cmd: true,
command.Copy: true,
command.Run: true,
}
Expand Down

0 comments on commit 48cfb7e

Please sign in to comment.