Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cache/contenthash/checksum.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sync"
"sync/atomic"

cerrdefs "github.com/containerd/errdefs"
iradix "github.com/hashicorp/go-immutable-radix/v2"
simplelru "github.com/hashicorp/golang-lru/v2/simplelru"
"github.com/moby/buildkit/cache"
Expand Down Expand Up @@ -59,6 +60,7 @@ type ChecksumOpts struct {
Wildcard bool
IncludePatterns []string
ExcludePatterns []string
RequiredPaths []string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this needs to take paths, instead of just being boolean? If it is boolean then I guess if no paths match then it returns a constant "zero checksum" that would be possible to check on the caller side.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little fuzzy on why I went this direction exactly compared to the boolean, but I think there were two reasons.

  1. The error message that gave the path was really difficult to figure out without putting the paths explicitly.
  2. Wildcards complicated the boolean method because the correct prefix wasn't necessarily trivial to determine from the checksum path.

Adding the required paths as an explicit argument took away the guesswork involved with this. The main problem is we needed information both about the pivot point and the non-wildcard section of the pattern to determine which path we needed so it wasn't as simple as just saying "if things are empty it's invalid" because the directory being empty could still be valid.

}

func Checksum(ctx context.Context, ref cache.ImmutableRef, path string, opts ChecksumOpts, s session.Group) (digest.Digest, error) {
Expand Down Expand Up @@ -690,6 +692,17 @@ func (cc *cacheContext) includedPaths(ctx context.Context, m *mount, p string, o
cc.tree = txn.Commit()
cc.dirty = updated

// Validate that all required paths exist.
for _, requiredPath := range opts.RequiredPaths {
found := slices.ContainsFunc(includedPaths, func(includedPath *includedPath) bool {
return strings.HasPrefix(includedPath.path, requiredPath)
})

if !found {
return "", nil, errors.Wrapf(cerrdefs.ErrNotFound, "%q", requiredPath)
}
}

return origPrefix, includedPaths, nil
}

Expand Down
5 changes: 5 additions & 0 deletions client/llb/fileop.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ type CopyInfo struct {
CopyDirContentsOnly bool
IncludePatterns []string
ExcludePatterns []string
RequiredPaths []string
AttemptUnpack bool
CreateDestPath bool
AllowWildcard bool
Expand Down Expand Up @@ -603,6 +604,7 @@ func (a *fileActionCopy) toProtoAction(ctx context.Context, parent string, base
Owner: a.info.ChownOpt.marshal(base),
IncludePatterns: a.info.IncludePatterns,
ExcludePatterns: a.info.ExcludePatterns,
RequiredPaths: a.info.RequiredPaths,
AllowWildcard: a.info.AllowWildcard,
AllowEmptyWildcard: a.info.AllowEmptyWildcard,
FollowSymlink: a.info.FollowSymlinks,
Expand Down Expand Up @@ -647,6 +649,9 @@ func (a *fileActionCopy) addCaps(f *FileOp) {
if len(a.info.IncludePatterns) != 0 || len(a.info.ExcludePatterns) != 0 {
addCap(&f.constraints, pb.CapFileCopyIncludeExcludePatterns)
}
if len(a.info.RequiredPaths) != 0 {
addCap(&f.constraints, pb.CapFileCopyRequiredPaths)
}
if a.info.AlwaysReplaceExistingDestPaths {
addCap(&f.constraints, pb.CapFileCopyAlwaysReplaceExistingDestPaths)
}
Expand Down
26 changes: 23 additions & 3 deletions frontend/dockerfile/dockerfile2llb/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ func dispatchRun(d *dispatchState, c *instructions.RunCommand, proxy *llb.ProxyE
// Run command can potentially access any file. Mark the full filesystem as used.
d.paths["/"] = struct{}{}

var args = c.CmdLine
args := c.CmdLine
if len(c.Files) > 0 {
if len(args) != 1 || !c.PrependShell {
return errors.Errorf("parsing produced an invalid run command: %v", args)
Expand Down Expand Up @@ -1606,6 +1606,7 @@ func dispatchCopy(d *dispatchState, cfg copyConfig) error {
} else {
validateCopySourcePath(src, &cfg)
var patterns []string
var requiredPaths []string
if cfg.parents {
// detect optional pivot point
parent, pattern, ok := strings.Cut(src, "/./")
Expand All @@ -1622,6 +1623,12 @@ func dispatchCopy(d *dispatchState, cfg copyConfig) error {
}

patterns = []string{strings.TrimPrefix(pattern, "/")}

// determine if we want to require any paths to exist.
// we only require a path to exist if wildcards aren't present.
if !containsWildcards(src) && !containsWildcards(pattern) {
requiredPaths = []string{filepath.Join(src, pattern)}
}
}

src, err = system.NormalizePath("/", src, d.platform.OS, false)
Expand All @@ -1639,6 +1646,7 @@ func dispatchCopy(d *dispatchState, cfg copyConfig) error {
FollowSymlinks: true,
CopyDirContentsOnly: true,
IncludePatterns: patterns,
RequiredPaths: requiredPaths,
AttemptUnpack: unpack,
CreateDestPath: true,
AllowWildcard: true,
Expand Down Expand Up @@ -1760,7 +1768,7 @@ func dispatchOnbuild(d *dispatchState, c *instructions.OnbuildCommand) error {
func dispatchCmd(d *dispatchState, c *instructions.CmdCommand, lint *linter.Linter) error {
validateUsedOnce(c, &d.cmd, lint)

var args = c.CmdLine
args := c.CmdLine
if c.PrependShell {
if len(d.image.Config.Shell) == 0 {
msg := linter.RuleJSONArgsRecommended.Format(c.Name())
Expand All @@ -1776,7 +1784,7 @@ func dispatchCmd(d *dispatchState, c *instructions.CmdCommand, lint *linter.Lint
func dispatchEntrypoint(d *dispatchState, c *instructions.EntrypointCommand, lint *linter.Linter) error {
validateUsedOnce(c, &d.entrypoint, lint)

var args = c.CmdLine
args := c.CmdLine
if c.PrependShell {
if len(d.image.Config.Shell) == 0 {
msg := linter.RuleJSONArgsRecommended.Format(c.Name())
Expand Down Expand Up @@ -2692,3 +2700,15 @@ func (emptyEnvs) Get(string) (string, bool) {
func (emptyEnvs) Keys() []string {
return nil
}

func containsWildcards(name string) bool {
for i := 0; i < len(name); i++ {
switch name[i] {
case '*', '?', '[':
return true
case '\\':
i++
}
}
return false
}
98 changes: 98 additions & 0 deletions frontend/dockerfile/dockerfile_parents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
var parentsTests = integration.TestFuncs(
testCopyParents,
testCopyRelativeParents,
testCopyParentsMissingDirectory,
)

func init() {
Expand Down Expand Up @@ -180,3 +181,100 @@ eot
require.NoError(t, err)
}
}

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

dockerfile := []byte(`
FROM alpine AS base
WORKDIR /test
RUN <<eot
set -ex
mkdir -p a/b/c/d/e
touch a/b/c/d/foo
touch a/b/c/d/e/bay
eot

FROM alpine AS normal
COPY --from=base --parents /test/a/b/c/d /out/
RUN <<eot
set -ex
[ -d /out/test/a/b/c/d/e ]
[ -f /out/test/a/b/c/d/e/bay ]
[ ! -d /out/e ]
[ ! -d /out/a ]
eot

FROM alpine AS withpivot
COPY --from=base --parents /test/a/b/./c/d /out/
RUN <<eot
set -ex
[ -d /out/c/d/e ]
[ -f /out/c/d/foo ]
[ ! -d /out/a ]
[ ! -d /out/e ]
eot

FROM alpine AS nonexistentfile
COPY --from=base --parents /test/nonexistent-file /out/

FROM alpine AS wildcard-nonexistent
COPY --from=base --parents /test/a/b2*/c /out/
RUN <<eot
set -ex
[ -d /out ]
[ ! -d /out/a ]
eot

FROM alpine AS wildcard-afterpivot
COPY --from=base --parents /test/a/b/./c2* /out/
RUN <<eot
set -ex
[ -d /out ]
[ ! -d /out/a ]
[ ! -d /out/c* ]
eot
`)

dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)

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

type test struct {
target string
errorRegex any
}

tests := []test{
{"normal", nil},
{"withpivot", nil},
{"nonexistentfile", `failed to calculate checksum of ref.*: "/test/nonexistent-file": not found`},
{"wildcard-nonexistent", nil},
{"wildcard-afterpivot", nil},
}

for _, tt := range tests {
t.Logf("target: %s", tt.target)
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
FrontendAttrs: map[string]string{
"target": tt.target,
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)

if tt.errorRegex != nil {
require.Error(t, err)
require.Regexp(t, tt.errorRegex, err.Error())
} else {
require.NoError(t, err)
}
}
}
9 changes: 5 additions & 4 deletions solver/llbsolver/ops/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func (f *fileOp) CacheMap(ctx context.Context, g session.Group, index int) (*sol
markInvalid(action.Input)
processOwner(p.Owner, selectors)
if action.SecondaryInput != -1 && int(action.SecondaryInput) < f.numInputs {
addSelector(selectors, int(action.SecondaryInput), p.Src, p.AllowWildcard, p.FollowSymlink, p.IncludePatterns, p.ExcludePatterns)
addSelector(selectors, int(action.SecondaryInput), p.Src, p.AllowWildcard, p.FollowSymlink, p.IncludePatterns, p.ExcludePatterns, p.RequiredPaths)
p.Src = path.Base(p.Src)
}
dt, err = json.Marshal(p)
Expand Down Expand Up @@ -215,13 +215,14 @@ func (f *fileOp) Acquire(ctx context.Context) (solver.ReleaseFunc, error) {
}, nil
}

func addSelector(m map[int][]opsutils.Selector, idx int, sel string, wildcard, followLinks bool, includePatterns, excludePatterns []string) {
func addSelector(m map[int][]opsutils.Selector, idx int, sel string, wildcard, followLinks bool, includePatterns, excludePatterns, requiredPaths []string) {
s := opsutils.Selector{
Path: sel,
FollowLinks: followLinks,
Wildcard: wildcard && containsWildcards(sel),
IncludePatterns: includePatterns,
ExcludePatterns: excludePatterns,
RequiredPaths: requiredPaths,
}

m[idx] = append(m[idx], s)
Expand Down Expand Up @@ -284,15 +285,15 @@ func processOwner(chopt *pb.ChownOpt, selectors map[int][]opsutils.Selector) err
if u.ByName.Input < 0 {
return errors.Errorf("invalid user index %d", u.ByName.Input)
}
addSelector(selectors, int(u.ByName.Input), "/etc/passwd", false, true, nil, nil)
addSelector(selectors, int(u.ByName.Input), "/etc/passwd", false, true, nil, nil, nil)
}
}
if chopt.Group != nil {
if u, ok := chopt.Group.User.(*pb.UserOpt_ByName); ok {
if u.ByName.Input < 0 {
return errors.Errorf("invalid user index %d", u.ByName.Input)
}
addSelector(selectors, int(u.ByName.Input), "/etc/group", false, true, nil, nil)
addSelector(selectors, int(u.ByName.Input), "/etc/group", false, true, nil, nil, nil)
}
}
return nil
Expand Down
2 changes: 2 additions & 0 deletions solver/llbsolver/ops/opsutils/contenthash.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Selector struct {
FollowLinks bool
IncludePatterns []string
ExcludePatterns []string
RequiredPaths []string
}

func (sel Selector) HasWildcardOrFilters() bool {
Expand Down Expand Up @@ -52,6 +53,7 @@ func NewContentHashFunc(selectors []Selector) solver.ResultBasedCacheFunc {
FollowLinks: sel.FollowLinks,
IncludePatterns: sel.IncludePatterns,
ExcludePatterns: sel.ExcludePatterns,
RequiredPaths: sel.RequiredPaths,
},
s,
)
Expand Down
7 changes: 7 additions & 0 deletions solver/pb/caps.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const (
CapFileBase apicaps.CapID = "file.base"
CapFileRmWildcard apicaps.CapID = "file.rm.wildcard"
CapFileCopyIncludeExcludePatterns apicaps.CapID = "file.copy.includeexcludepatterns"
CapFileCopyRequiredPaths apicaps.CapID = "file.copy.requiredpaths"
CapFileRmNoFollowSymlink apicaps.CapID = "file.rm.nofollowsymlink"
CapFileCopyAlwaysReplaceExistingDestPaths apicaps.CapID = "file.copy.alwaysreplaceexistingdestpaths"
CapFileCopyModeStringFormat apicaps.CapID = "file.copy.modestring"
Expand Down Expand Up @@ -451,6 +452,12 @@ func init() {
Status: apicaps.CapStatusExperimental,
})

Caps.Init(apicaps.Cap{
ID: CapFileCopyRequiredPaths,
Enabled: true,
Status: apicaps.CapStatusExperimental,
})

Caps.Init(apicaps.Cap{
ID: CapFileCopyAlwaysReplaceExistingDestPaths,
Enabled: true,
Expand Down
17 changes: 14 additions & 3 deletions solver/pb/ops.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions solver/pb/ops.proto
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ message FileActionCopy {
bool alwaysReplaceExistingDestPaths = 14;
// mode in non-octal format
string modeStr = 15;
// required paths that must be included in the copy. This is only used when
// include_patterns has at least one pattern.
repeated string required_paths = 16;
}

message FileActionMkFile {
Expand Down
Loading