ADD/COPY --link support added

What type of PR is this?
/kind feature

What this PR does / why we need it:
It implements --link for COPY and ADD instructions and enables the creation of
cachable layers that can be reused independently across builds.

Follows buildkit `--link` specifications

How to verify it
bats tests/bud.bats

Which issue(s) this PR fixes:
Fixes #4325

Does this PR introduce a user-facing change?
Yes, gives extra functionality to Containerfiles

Signed-off-by: Joshua Arrevillaga <2004jarrevillaga@gmail.com>
This commit is contained in:
Joshua Arrevillaga 2025-06-13 14:26:23 -04:00
parent 7a243f955e
commit eea4838d88
8 changed files with 666 additions and 35 deletions

132
add.go
View File

@ -33,6 +33,7 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/moby/sys/userns"
digest "github.com/opencontainers/go-digest"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/opencontainers/runtime-spec/specs-go"
"github.com/sirupsen/logrus"
)
@ -102,6 +103,15 @@ type AddAndCopyOptions struct {
Parents bool
// Timestamp is a timestamp to override on all content as it is being read.
Timestamp *time.Time
// Link, when set to true, creates an independent layer containing the copied content
// that sits on top of existing layers. This layer can be cached and reused
// separately, and is not affected by filesystem changes from previous instructions.
Link bool
// BuildMetadata is consulted only when Link is true. Contains metadata used by
// imagebuildah for cache evaluation of linked layers (inheritLabels, unsetAnnotations,
// inheritAnnotations, newAnnotations). This field is internally managed and should
// not be set by external API users.
BuildMetadata string
}
// gitURLFragmentSuffix matches fragments to use as Git reference and build
@ -495,15 +505,75 @@ func (b *Builder) Add(destination string, extract bool, options AddAndCopyOption
}
destUIDMap, destGIDMap := convertRuntimeIDMaps(b.IDMappingOptions.UIDMap, b.IDMappingOptions.GIDMap)
// Create the target directory if it doesn't exist yet.
var putRoot, putDir, stagingDir string
var createdDirs []string
var latestTimestamp time.Time
mkdirOptions := copier.MkdirOptions{
UIDMap: destUIDMap,
GIDMap: destGIDMap,
ChownNew: chownDirs,
}
// If --link is specified, we create a staging directory to hold the content
// that will then become an independent layer
if options.Link {
containerDir, err := b.store.ContainerDirectory(b.ContainerID)
if err != nil {
return fmt.Errorf("getting container directory for %q: %w", b.ContainerID, err)
}
stagingDir, err = os.MkdirTemp(containerDir, "link-stage-")
if err != nil {
return fmt.Errorf("creating staging directory for link %q: %w", b.ContainerID, err)
}
putRoot = stagingDir
cleanDest := filepath.Clean(destination)
if strings.Contains(cleanDest, "..") {
return fmt.Errorf("invalid destination path %q: contains path traversal", destination)
}
if renameTarget != "" {
putDir = filepath.Dir(filepath.Join(stagingDir, cleanDest))
} else {
putDir = filepath.Join(stagingDir, cleanDest)
}
putDirAbs, err := filepath.Abs(putDir)
if err != nil {
return fmt.Errorf("failed to resolve absolute path: %w", err)
}
stagingDirAbs, err := filepath.Abs(stagingDir)
if err != nil {
return fmt.Errorf("failed to resolve staging directory absolute path: %w", err)
}
if !strings.HasPrefix(putDirAbs, stagingDirAbs+string(os.PathSeparator)) && putDirAbs != stagingDirAbs {
return fmt.Errorf("destination path %q escapes staging directory", destination)
}
if err := copier.Mkdir(putRoot, putDirAbs, mkdirOptions); err != nil {
return fmt.Errorf("ensuring target directory exists: %w", err)
}
tempPath := putDir
for tempPath != stagingDir && tempPath != filepath.Dir(tempPath) {
if _, err := os.Stat(tempPath); err == nil {
createdDirs = append(createdDirs, tempPath)
}
tempPath = filepath.Dir(tempPath)
}
} else {
if err := copier.Mkdir(mountPoint, extractDirectory, mkdirOptions); err != nil {
return fmt.Errorf("ensuring target directory exists: %w", err)
}
putRoot = extractDirectory
putDir = extractDirectory
}
// Copy each source in turn.
for _, src := range sources {
var multiErr *multierror.Error
@ -580,7 +650,7 @@ func (b *Builder) Add(destination string, extract bool, options AddAndCopyOption
ChmodFiles: nil,
IgnoreDevices: userns.RunningInUserNS(),
}
putErr = copier.Put(extractDirectory, extractDirectory, putOptions, io.TeeReader(pipeReader, hasher))
putErr = copier.Put(putRoot, putDir, putOptions, io.TeeReader(pipeReader, hasher))
}
hashCloser.Close()
pipeReader.Close()
@ -658,6 +728,9 @@ func (b *Builder) Add(destination string, extract bool, options AddAndCopyOption
itemsCopied++
}
st := localSourceStat.Results[globbed]
if options.Link && st.ModTime.After(latestTimestamp) {
latestTimestamp = st.ModTime
}
pipeReader, pipeWriter := io.Pipe()
wg.Add(1)
go func() {
@ -741,12 +814,13 @@ func (b *Builder) Add(destination string, extract bool, options AddAndCopyOption
ChmodFiles: nil,
IgnoreDevices: userns.RunningInUserNS(),
}
putErr = copier.Put(extractDirectory, extractDirectory, putOptions, io.TeeReader(pipeReader, hasher))
putErr = copier.Put(putRoot, putDir, putOptions, io.TeeReader(pipeReader, hasher))
}
hashCloser.Close()
pipeReader.Close()
wg.Done()
}()
wg.Wait()
if getErr != nil {
getErr = fmt.Errorf("reading %q: %w", src, getErr)
@ -776,6 +850,58 @@ func (b *Builder) Add(destination string, extract bool, options AddAndCopyOption
return fmt.Errorf("no items matching glob %q copied (%d filtered out%s): %w", localSourceStat.Glob, len(localSourceStat.Globbed), excludesFile, syscall.ENOENT)
}
}
if options.Link {
if !latestTimestamp.IsZero() {
for _, dir := range createdDirs {
if err := os.Chtimes(dir, latestTimestamp, latestTimestamp); err != nil {
logrus.Warnf("failed to set timestamp on directory %q: %v", dir, err)
}
}
}
var created time.Time
if options.Timestamp != nil {
created = *options.Timestamp
} else if !latestTimestamp.IsZero() {
created = latestTimestamp
} else {
created = time.Unix(0, 0).UTC()
}
command := "ADD"
if !extract {
command = "COPY"
}
contentType, digest := b.ContentDigester.Digest()
summary := contentType
if digest != "" {
if summary != "" {
summary = summary + ":"
}
summary = summary + digest.Encoded()
logrus.Debugf("added content from --link %s", summary)
}
createdBy := "/bin/sh -c #(nop) " + command + " --link " + summary + " in " + destination + " " + options.BuildMetadata
history := v1.History{
Created: &created,
CreatedBy: createdBy,
Comment: b.HistoryComment(),
}
linkedLayer := LinkedLayer{
History: history,
BlobPath: stagingDir,
}
b.AppendedLinkedLayers = append(b.AppendedLinkedLayers, linkedLayer)
if err := b.Save(); err != nil {
return fmt.Errorf("saving builder state after queuing linked layer: %w", err)
}
}
return nil
}

View File

@ -41,6 +41,7 @@ type addCopyResults struct {
excludes []string
parents bool
timestamp string
link bool
}
func createCommand(addCopy string, desc string, short string, opts *addCopyResults) *cobra.Command {
@ -74,6 +75,7 @@ func applyFlagVars(flags *pflag.FlagSet, opts *addCopyResults) {
flags.StringVar(&opts.chown, "chown", "", "set the user and group ownership of the destination content")
flags.StringVar(&opts.chmod, "chmod", "", "set the access permissions of the destination content")
flags.StringVar(&opts.creds, "creds", "", "use `[username[:password]]` for accessing registries when pulling images")
flags.BoolVar(&opts.link, "link", false, "enable layer caching for this operation (creates an independent layer)")
if err := flags.MarkHidden("creds"); err != nil {
panic(fmt.Sprintf("error marking creds as hidden: %v", err))
}
@ -263,6 +265,7 @@ func addAndCopyCmd(c *cobra.Command, args []string, verb string, iopts addCopyRe
MaxRetries: iopts.retry,
Parents: iopts.parents,
Timestamp: timestamp,
Link: iopts.link,
}
if iopts.contextdir != "" {
var excludes []string

View File

@ -65,6 +65,13 @@ can be used.
Path to an alternative .containerignore (.dockerignore) file. Requires \-\-contextdir be specified.
**--link**
Create an independent image layer for the added files instead of modifying the working
container's filesystem. If `buildah run` creates a file and `buildah add --link` adds a file
to the same path, the file from `buildah add --link` will be present in the committed image.
The --link layer is applied after all container filesystem changes at commit time.
**--quiet**, **-q**
Refrain from printing a digest of the added content.

View File

@ -65,6 +65,13 @@ is preserved.
Path to an alternative .containerignore (.dockerignore) file. Requires \-\-contextdir be specified.
**--link**
Create an independent image layer for the added files instead of modifying the working
container's filesystem. If `buildah run` creates a file and `buildah copy --link` adds a file
to the same path, the file from `buildah copy --link` will be present in the committed image.
The --link layer is applied after all container filesystem changes at commit time.
**--parents**
Preserve leading directories in the paths of items being copied, relative to either the

View File

@ -76,6 +76,8 @@ type StageExecutor struct {
stage *imagebuilder.Stage
didExecute bool
argsFromContainerfile []string
hasLink bool
isLastStep bool
}
// Preserve informs the stage executor that from this point on, it needs to
@ -359,8 +361,11 @@ func (s *StageExecutor) Copy(excludes []string, copies ...imagebuilder.Copy) err
}
return errors.New("COPY --keep-git-dir is not supported")
}
if cp.Link {
return errors.New("COPY --link is not supported")
if cp.Link && s.executor.layers {
s.hasLink = true
} else if cp.Link {
s.executor.logger.Warn("--link is not supported when building without --layers, ignoring --link")
s.hasLink = false
}
if len(cp.Excludes) > 0 {
excludes = append(slices.Clone(excludes), cp.Excludes...)
@ -564,6 +569,7 @@ func (s *StageExecutor) performCopy(excludes []string, copies ...imagebuilder.Co
sources = append(sources, src)
}
}
labelsAndAnnotations := s.buildMetadata(s.isLastStep, true)
options := buildah.AddAndCopyOptions{
Chmod: copy.Chmod,
Chown: copy.Chown,
@ -583,6 +589,8 @@ func (s *StageExecutor) performCopy(excludes []string, copies ...imagebuilder.Co
MaxRetries: s.executor.maxPullPushRetries,
RetryDelay: s.executor.retryPullPushDelay,
Parents: copy.Parents,
Link: s.hasLink,
BuildMetadata: labelsAndAnnotations,
}
if len(copy.Files) > 0 {
// If we are copying heredoc files, we need to temporary place
@ -1330,6 +1338,8 @@ func (s *StageExecutor) Execute(ctx context.Context, base string) (imgID string,
logRusage()
moreInstructions := i < len(children)-1
lastInstruction := !moreInstructions
s.isLastStep = lastStage && lastInstruction
// Resolve any arguments in this instruction.
step := ib.Step()
if err := step.Resolve(node); err != nil {
@ -1795,6 +1805,8 @@ func (s *StageExecutor) Execute(ctx context.Context, base string) (imgID string,
return "", nil, false, fmt.Errorf("preparing container for next step: %w", err)
}
}
s.hasLink = false
}
return imgID, ref, onlyBaseImage, nil
@ -1888,30 +1900,13 @@ func (s *StageExecutor) getCreatedBy(node *parser.Node, addedContentSummary stri
if node == nil {
return "/bin/sh", nil
}
inheritLabels := ""
unsetAnnotations := ""
inheritAnnotations := ""
newAnnotations := ""
// If --inherit-label was manually set to false then update history.
if s.executor.inheritLabels == types.OptionalBoolFalse {
inheritLabels = "|inheritLabels=false"
}
if isLastStep {
for _, annotation := range s.executor.unsetAnnotations {
unsetAnnotations += "|unsetAnnotation=" + annotation
}
// If --inherit-annotation was manually set to false then update history.
if s.executor.inheritAnnotations == types.OptionalBoolFalse {
inheritAnnotations = "|inheritAnnotations=false"
}
// If new annotations are added, they must be added as part of the last step of the build,
// so mention in history that new annotations were added inorder to make sure the builds
// can either reuse layers or burst the cache depending upon new annotations.
if len(s.executor.annotations) > 0 {
newAnnotations += strings.Join(s.executor.annotations, ",")
}
}
switch strings.ToUpper(node.Value) {
command := strings.ToUpper(node.Value)
addcopy := command == "ADD" || command == "COPY"
labelsAndAnnotations := s.buildMetadata(isLastStep, addcopy)
switch command {
case "ARG":
for _, variable := range strings.Fields(node.Original) {
if variable != "ARG" {
@ -1919,7 +1914,7 @@ func (s *StageExecutor) getCreatedBy(node *parser.Node, addedContentSummary stri
}
}
buildArgs := s.getBuildArgsKey()
return "/bin/sh -c #(nop) ARG " + buildArgs + inheritLabels + unsetAnnotations + inheritAnnotations + newAnnotations, nil
return "/bin/sh -c #(nop) ARG " + buildArgs + labelsAndAnnotations, nil
case "RUN":
shArg := ""
buildArgs := s.getBuildArgsResolvedForRun()
@ -1999,16 +1994,20 @@ func (s *StageExecutor) getCreatedBy(node *parser.Node, addedContentSummary stri
if buildArgs != "" {
result = result + "|" + strconv.Itoa(len(strings.Split(buildArgs, " "))) + " " + buildArgs + " "
}
result = result + "/bin/sh -c " + shArg + heredoc + appendCheckSum + inheritLabels + unsetAnnotations + inheritAnnotations + newAnnotations
result = result + "/bin/sh -c " + shArg + heredoc + appendCheckSum + labelsAndAnnotations
return result, nil
case "ADD", "COPY":
destination := node
for destination.Next != nil {
destination = destination.Next
}
return "/bin/sh -c #(nop) " + strings.ToUpper(node.Value) + " " + addedContentSummary + " in " + destination.Value + " " + inheritLabels + " " + unsetAnnotations + " " + inheritAnnotations + " " + newAnnotations, nil
hasLink := ""
if s.hasLink {
hasLink = " --link"
}
return "/bin/sh -c #(nop) " + strings.ToUpper(node.Value) + hasLink + " " + addedContentSummary + " in " + destination.Value + " " + labelsAndAnnotations, nil
default:
return "/bin/sh -c #(nop) " + node.Original + inheritLabels + unsetAnnotations + inheritAnnotations + newAnnotations, nil
return "/bin/sh -c #(nop) " + node.Original + labelsAndAnnotations, nil
}
}
@ -2326,6 +2325,7 @@ func (s *StageExecutor) intermediateImageExists(ctx context.Context, currNode *p
if s.builder.TopLayer != imageParentLayerID {
continue
}
// Next we double check that the history of this image is equivalent to the previous
// lines in the Dockerfile up till the point we are at in the build.
manifestType, history, diffIDs, err := s.executor.getImageTypeAndHistoryAndDiffIDs(ctx, image.ID)
@ -2495,6 +2495,7 @@ func (s *StageExecutor) commit(ctx context.Context, createdBy string, emptyLayer
Squash: squash,
OmitHistory: s.executor.commonBuildOptions.OmitHistory,
EmptyLayer: emptyLayer,
OmitLayerHistoryEntry: s.hasLink,
BlobDirectory: s.executor.blobDirectory,
SignBy: s.executor.signBy,
MaxRetries: s.executor.maxPullPushRetries,
@ -2581,3 +2582,34 @@ func (s *StageExecutor) EnsureContainerPathAs(path, user string, mode *os.FileMo
logrus.Debugf("EnsureContainerPath %q (owner %q, mode %o) in %q", path, user, mode, s.builder.ContainerID)
return s.builder.EnsureContainerPathAs(path, user, mode)
}
func (s *StageExecutor) buildMetadata(isLastStep bool, addcopy bool) string {
inheritLabels := ""
unsetAnnotations := ""
inheritAnnotations := ""
newAnnotations := ""
// If --inherit-label was manually set to false then update history.
if s.executor.inheritLabels == types.OptionalBoolFalse {
inheritLabels = "|inheritLabels=false"
}
if isLastStep {
for _, annotation := range s.executor.unsetAnnotations {
unsetAnnotations += "|unsetAnnotation=" + annotation
}
// If --inherit-annotation was manually set to false then update history.
if s.executor.inheritAnnotations == types.OptionalBoolFalse {
inheritAnnotations = "|inheritAnnotations=false"
}
// If new annotations are added, they must be added as part of the last step of the build,
// so mention in history that new annotations were added inorder to make sure the builds
// can either reuse layers or burst the cache depending upon new annotations.
if len(s.executor.annotations) > 0 {
newAnnotations += strings.Join(s.executor.annotations, ",")
}
}
if addcopy {
return inheritLabels + " " + unsetAnnotations + " " + inheritAnnotations + " " + newAnnotations
}
return inheritLabels + unsetAnnotations + inheritAnnotations + newAnnotations
}

View File

@ -392,3 +392,120 @@ EOF
run_buildah copy --chmod=0755 "$cid" $TEST_SCRATCH_DIR/context/check-dates.sh /
run_buildah run "$cid" sh -x /check-dates.sh
}
@test "add-link-flag" {
createrandom ${TEST_SCRATCH_DIR}/randomfile
createrandom ${TEST_SCRATCH_DIR}/other-randomfile
run_buildah from $WITH_POLICY_JSON scratch
cid=$output
run_buildah mount $cid
root=$output
run_buildah config --workingdir=/ $cid
# Test 1: Simple add
run_buildah add --link $cid ${TEST_SCRATCH_DIR}/randomfile
# Test 2: Add with rename (file to file with different name)
run_buildah add --link $cid ${TEST_SCRATCH_DIR}/randomfile /renamed-file
# Test 3: Multiple files to directory
mkdir $root/subdir
run_buildah add --link $cid ${TEST_SCRATCH_DIR}/randomfile ${TEST_SCRATCH_DIR}/other-randomfile /subdir
run_buildah unmount $cid
run_buildah commit $WITH_POLICY_JSON $cid add-link-image
run_buildah inspect --type=image add-link-image
layers=$(echo "$output" | jq -r '.OCIv1.rootfs.diff_ids | length')
if [ "$layers" -lt 3 ]; then
echo "Expected at least 3 layers from 3 --link operations, but found $layers"
echo "Layers found:"
echo "$output" | jq -r '.OCIv1.rootfs.diff_ids[]'
exit 1
fi
run_buildah from $WITH_POLICY_JSON add-link-image
newcid=$output
run_buildah mount $newcid
newroot=$output
test -s $newroot/randomfile
cmp ${TEST_SCRATCH_DIR}/randomfile $newroot/randomfile
test -s $newroot/renamed-file
cmp ${TEST_SCRATCH_DIR}/randomfile $newroot/renamed-file
test -s $newroot/subdir/randomfile
cmp ${TEST_SCRATCH_DIR}/randomfile $newroot/subdir/randomfile
test -s $newroot/subdir/other-randomfile
cmp ${TEST_SCRATCH_DIR}/other-randomfile $newroot/subdir/other-randomfile
}
@test "add-link-archive" {
createrandom ${TEST_SCRATCH_DIR}/file1
createrandom ${TEST_SCRATCH_DIR}/file2
tar -c -C ${TEST_SCRATCH_DIR} -f ${TEST_SCRATCH_DIR}/archive.tar file1 file2
run_buildah from $WITH_POLICY_JSON scratch
cid=$output
run_buildah config --workingdir=/ $cid
run_buildah add --link $cid ${TEST_SCRATCH_DIR}/archive.tar
run_buildah add --link $cid ${TEST_SCRATCH_DIR}/archive.tar /destdir/
run_buildah commit $WITH_POLICY_JSON $cid add-link-archive-image
run_buildah inspect --type=image add-link-archive-image
layers=$(echo "$output" | jq -r '.OCIv1.rootfs.diff_ids | length')
if [ "$layers" -lt 2 ]; then
echo "Expected at least 2 layers from 2 --link operations, but found $layers"
exit 1
fi
run_buildah from $WITH_POLICY_JSON add-link-archive-image
newcid=$output
run_buildah mount $newcid
newroot=$output
test -s $newroot/file1
cmp ${TEST_SCRATCH_DIR}/file1 $newroot/file1
test -s $newroot/file2
cmp ${TEST_SCRATCH_DIR}/file2 $newroot/file2
test -s $newroot/destdir/file1
cmp ${TEST_SCRATCH_DIR}/file1 $newroot/destdir/file1
test -s $newroot/destdir/file2
cmp ${TEST_SCRATCH_DIR}/file2 $newroot/destdir/file2
}
@test "add-link-directory" {
mkdir -p ${TEST_SCRATCH_DIR}/testdir/subdir
createrandom ${TEST_SCRATCH_DIR}/testdir/file1
createrandom ${TEST_SCRATCH_DIR}/testdir/subdir/file2
run_buildah from $WITH_POLICY_JSON scratch
cid=$output
run_buildah config --workingdir=/ $cid
run_buildah add --link $cid ${TEST_SCRATCH_DIR}/testdir /testdir
run_buildah commit $WITH_POLICY_JSON $cid add-link-dir-image
run_buildah from $WITH_POLICY_JSON add-link-dir-image
newcid=$output
run_buildah mount $newcid
newroot=$output
test -d $newroot/testdir
test -s $newroot/testdir/file1
test -s $newroot/testdir/subdir/file2
cmp ${TEST_SCRATCH_DIR}/testdir/file1 $newroot/testdir/file1
cmp ${TEST_SCRATCH_DIR}/testdir/subdir/file2 $newroot/testdir/subdir/file2
}

View File

@ -8146,3 +8146,250 @@ EOF
assert $status = 0 "error running jq"
assert "$output" = "$datestamp" "SOURCE_DATE_EPOCH build arg didn't affect image config creation date"
}
@test "bud --link consistent diffID with --no-cache" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-diffid-nocache
mkdir -p $contextdir
cat > $contextdir/Dockerfile << EOF
FROM alpine
RUN echo "before link layer" > /before.txt
COPY --link test.txt /test.txt
RUN echo "after link layer" > /after.txt
RUN ls -l /test.txt
RUN cat /test.txt
EOF
echo "test content" > $contextdir/test.txt
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-layout1 $contextdir
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-layout2 $contextdir
diffid1=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout1 2)
diffid2=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout2 2)
echo $diffid1
echo $diffid2
assert "$diffid1" = "$diffid2" "COPY --link should produce identical diffIDs with --no-cache"
}
@test "bud --link consistent diffID with different base images" {
_prefetch alpine busybox
local contextdir=${TEST_SCRATCH_DIR}/bud/link-diffid-bases
mkdir -p $contextdir
echo "shared content" > $contextdir/shared.txt
cat > $contextdir/Containerfile.alpine << EOF
FROM alpine
RUN echo "alpine setup" > /setup.txt
COPY --link shared.txt /shared.txt
RUN echo "alpine complete" > /complete.txt
RUN cat /shared.txt
EOF
cat > $contextdir/Containerfile.busybox << EOF
FROM busybox
RUN echo "busybox setup" > /setup.txt
COPY --link shared.txt /shared.txt
RUN echo "busybox complete" > /complete.txt
RUN cat /shared.txt
EOF
run_buildah build --no-cache --layers $WITH_POLICY_JSON -f $contextdir/Containerfile.alpine -t oci:${TEST_SCRATCH_DIR}/oci-layout-alpine $contextdir
run_buildah build --no-cache --layers $WITH_POLICY_JSON -f $contextdir/Containerfile.busybox -t oci:${TEST_SCRATCH_DIR}/oci-layout-busybox $contextdir
diffid_alpine=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout-alpine 2)
diffid_busybox=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout-busybox 2)
assert "$diffid_alpine" = "$diffid_busybox" "COPY --link should produce identical diffIDs regardless of base image"
}
@test "bud --link consistent diffID with multiple files" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-diffid-multi
mkdir -p $contextdir/subdir
echo "file1" > $contextdir/file1.txt
echo "file2" > $contextdir/file2.txt
echo "subfile" > $contextdir/subdir/sub.txt
cat > $contextdir/Dockerfile << EOF
FROM alpine
RUN echo "setup" > /setup.txt
COPY --link file1.txt file2.txt /files/
ADD --link subdir /subdir
RUN echo "complete" > /complete.txt
RUN ls -l /files/
EOF
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-layout-multi1 $contextdir
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-layout-multi2 $contextdir
copy_diffid1=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout-multi1 2)
copy_diffid2=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout-multi2 2)
add_diffid1=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout-multi1 3)
add_diffid2=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-layout-multi2 3)
assert "$copy_diffid1" = "$copy_diffid2" "COPY --link with multiple files should have consistent diffID"
assert "$add_diffid1" = "$add_diffid2" "ADD --link should have consistent diffID"
}
@test "bud --link with glob patterns consistent diffID" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-diffid-glob
mkdir -p $contextdir
echo "test1" > $contextdir/test1.txt
echo "test2" > $contextdir/test2.txt
echo "json1" > $contextdir/data1.json
echo "json2" > $contextdir/data2.json
cat > $contextdir/Dockerfile << EOF
FROM alpine
COPY --link test*.txt /tests/
COPY --link *.json /data/
RUN echo "globbing complete" > /complete.txt
RUN ls -l /tests/
RUN ls -l /data/
EOF
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-glob-1 $contextdir
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-glob-2 $contextdir
glob_txt_diffid1=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-glob-1 1)
glob_txt_diffid2=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-glob-2 1)
glob_json_diffid1=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-glob-1 2)
glob_json_diffid2=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-glob-2 2)
assert "$glob_txt_diffid1" = "$glob_txt_diffid2" "COPY --link with glob *.txt should have consistent diffID"
assert "$glob_json_diffid1" = "$glob_json_diffid2" "COPY --link with glob *.json should have consistent diffID"
}
@test "bud --link cache behavior basic" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-cache
mkdir -p $contextdir
echo "test content" > $contextdir/testfile.txt
cat > $contextdir/Dockerfile << EOF
FROM alpine
COPY --link testfile.txt /testfile.txt
RUN echo "build complete" > /complete.txt
RUN cat /testfile.txt
EOF
# First build
run_buildah build --layers $WITH_POLICY_JSON -t link-cache1 $contextdir
run_buildah build --layers $WITH_POLICY_JSON -t link-cache2 $contextdir
assert "$output" =~ "Using cache"
# Modify content
echo "modified content" > $contextdir/testfile.txt
run_buildah build --layers $WITH_POLICY_JSON -t link-cache3 $contextdir
assert "$output" !~ "STEP 2/3: COPY --link testfile.txt /testfile.txt"$'\n'".*Using cache"
}
@test "bud --link with chmod and chown" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-perms
mkdir -p $contextdir
echo "test content" > $contextdir/testfile.txt
cat > $contextdir/Dockerfile << EOF
FROM alpine
COPY --link --chmod=755 --chown=1000:1000 testfile.txt /testfile.txt
RUN stat -c '%u:%g %a' /testfile.txt
EOF
run_buildah build --layers $WITH_POLICY_JSON -t link-perms $contextdir
expect_output --substring "1000:1000 755"
}
@test "bud --link multi-stage build cache" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-multistage
mkdir -p $contextdir
echo "stage1 content" > $contextdir/stage1.txt
echo "stage2 content" > $contextdir/stage2.txt
cat > $contextdir/Dockerfile << EOF
FROM alpine AS stage1
ADD --link stage1.txt /stage1.txt
RUN echo "stage1 complete" > /complete.txt
RUN cat /stage1.txt
FROM alpine AS stage2
COPY --from=stage1 /stage1.txt /from-stage1.txt
ADD --link stage2.txt /stage2.txt
RUN echo "stage2 complete" > /complete.txt
RUN cat /stage2.txt
EOF
run_buildah build --layers $WITH_POLICY_JSON -t link-multistage1 $contextdir
run_buildah build --layers $WITH_POLICY_JSON -t link-multistage2 $contextdir
assert "$output" =~ "Using cache"
run_buildah from --name test-ctr link-multistage2
run_buildah run test-ctr cat /from-stage1.txt
expect_output "stage1 content"
run_buildah run test-ctr cat /stage2.txt
expect_output "stage2 content"
run_buildah rm test-ctr
}
@test "bud --link ADD with remote URL consistent diffID" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-url
mkdir -p $contextdir
cat > $contextdir/Dockerfile << EOF
FROM alpine
ADD --link https://github.com/moby/moby/raw/master/README.md /README.md
RUN echo "remote add complete" > /complete.txt
RUN cat /README.md
EOF
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-url1 $contextdir
run_buildah build --no-cache --layers $WITH_POLICY_JSON -t oci:${TEST_SCRATCH_DIR}/oci-url2 $contextdir
diffid1=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-url1 1)
diffid2=$(oci_image_diff_id ${TEST_SCRATCH_DIR}/oci-url2 1)
assert "$diffid1" = "$diffid2" "ADD --link with URL should have consistent diffID"
}
@test "bud --link respects dockerignore" {
_prefetch alpine
local contextdir=${TEST_SCRATCH_DIR}/bud/link-ignore
mkdir -p $contextdir
echo "included" > $contextdir/included.txt
echo "excluded" > $contextdir/excluded.txt
echo "excluded.txt" > $contextdir/.dockerignore
cat > $contextdir/Dockerfile << EOF
FROM alpine
RUN echo "Starting" > /start.txt
COPY --link *.txt /files/
RUN echo "Ending" > /end.txt
RUN ls -l /files/
EOF
run_buildah build --layers $WITH_POLICY_JSON -t link-ignore $contextdir
expect_output --substring "included.txt"
assert "$output" !~ "excluded.txt"
}

View File

@ -668,3 +668,95 @@ parents/y/b.txt"
run_buildah run $ctr stat -c %u:%g /random-file-6
assert 789:789
}
@test "copy-link-flag" {
createrandom ${TEST_SCRATCH_DIR}/randomfile
createrandom ${TEST_SCRATCH_DIR}/other-randomfile
run_buildah from $WITH_POLICY_JSON scratch
cid=$output
run_buildah mount $cid
root=$output
run_buildah config --workingdir=/ $cid
run_buildah copy --link $cid ${TEST_SCRATCH_DIR}/randomfile
run_buildah copy --link $cid ${TEST_SCRATCH_DIR}/randomfile ${TEST_SCRATCH_DIR}/other-randomfile /subdir/
run_buildah unmount $cid
run_buildah commit $WITH_POLICY_JSON $cid copy-link-image
run_buildah inspect --type=image copy-link-image
layers=$(echo "$output" | jq -r '.OCIv1.rootfs.diff_ids | length')
assert "$layers" -eq 3 "Expected 3 layers from 2 --link operations and base, but found $layers"
run_buildah from $WITH_POLICY_JSON copy-link-image
newcid=$output
run_buildah mount $newcid
newroot=$output
test -s $newroot/randomfile
cmp ${TEST_SCRATCH_DIR}/randomfile $newroot/randomfile
test -s $newroot/subdir/randomfile
cmp ${TEST_SCRATCH_DIR}/randomfile $newroot/subdir/randomfile
test -s $newroot/subdir/other-randomfile
cmp ${TEST_SCRATCH_DIR}/other-randomfile $newroot/subdir/other-randomfile
}
@test "copy-link-directory" {
mkdir -p ${TEST_SCRATCH_DIR}/sourcedir
createrandom ${TEST_SCRATCH_DIR}/sourcedir/file1
createrandom ${TEST_SCRATCH_DIR}/sourcedir/file2
run_buildah from $WITH_POLICY_JSON scratch
cid=$output
run_buildah config --workingdir=/ $cid
run_buildah copy --link $cid ${TEST_SCRATCH_DIR}/sourcedir /destdir
run_buildah commit $WITH_POLICY_JSON $cid copy-link-dir-image
run_buildah from $WITH_POLICY_JSON copy-link-dir-image
newcid=$output
run_buildah mount $newcid
newroot=$output
test -d $newroot/destdir
test -s $newroot/destdir/file1
cmp ${TEST_SCRATCH_DIR}/sourcedir/file1 $newroot/destdir/file1
test -s $newroot/destdir/file2
cmp ${TEST_SCRATCH_DIR}/sourcedir/file2 $newroot/destdir/file2
}
@test "copy-link-with-chown" {
createrandom ${TEST_SCRATCH_DIR}/randomfile
_prefetch busybox
run_buildah from --quiet $WITH_POLICY_JSON busybox
cid=$output
run_buildah copy --link --chown bin:bin $cid ${TEST_SCRATCH_DIR}/randomfile /tmp/random
run_buildah commit $WITH_POLICY_JSON $cid copy-link-chown-image
run_buildah from $WITH_POLICY_JSON copy-link-chown-image
newcid=$output
run_buildah run $newcid ls -l /tmp/random
expect_output --substring "bin.*bin"
}
@test "copy-link-with-chmod" {
createrandom ${TEST_SCRATCH_DIR}/randomfile
_prefetch busybox
run_buildah from --quiet $WITH_POLICY_JSON busybox
cid=$output
run_buildah copy --link --chmod 777 $cid ${TEST_SCRATCH_DIR}/randomfile /tmp/random
run_buildah commit $WITH_POLICY_JSON $cid copy-link-chmod-image
run_buildah from $WITH_POLICY_JSON copy-link-chmod-image
newcid=$output
run_buildah run $newcid ls -l /tmp/random
expect_output --substring "rwxrwxrwx"
}