Skip to content

Commit 9174104

Browse files
mishushakovclaudegithub-actions[bot]
authored
fix(orchestrator): implement Docker COPY merge semantics in template builds (#3283)
## Problem The template build `COPY` command silently dropped files whenever a copied directory already existed non-empty at the destination: - `COPY rootfs/ /` dropped **everything** (every top-level dir like `etc`, `usr` already exists in `/`), leaving files only in the `/tmp` unpack dir - `COPY rootfs/etc /etc` copied `/etc/motd` but silently dropped `/etc/profile.d/test-env.sh`, because `/etc/profile.d` already exists on the Ubuntu image Root cause: `copy_script.sh` moved directory contents with `find -exec mv`, but `mv` cannot merge into a non-empty directory and `find -exec \;` exits 0 even when the command fails. A second bug made the script operate on the alphabetically-first entry of the source's parent directory instead of the entry named by the source path. ## Fix Merge directory contents with `cp -a` (existing directories merged, existing files overwritten — Docker COPY semantics), select the entry by `basename` of the source path, and fail the build step on copy errors instead of swallowing them. Added regression tests covering merging into existing trees, overwriting existing files, and correct entry selection; verified they reproduce the bugs against the old script and pass (with `-race`) against the fix, with golangci-lint v2.11.4 clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent f18f05f commit 9174104

3 files changed

Lines changed: 253 additions & 9 deletions

File tree

packages/orchestrator/pkg/template/build/commands/copy.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ var copyScriptTemplate = txtTemplate.Must(txtTemplate.New("copy-script-template"
5757
// 3) Extracts it (still in the /tmp directory)
5858
// 4) Moves the extracted files to the target path in the sandbox
5959
// - If the source is a file, it creates the parent directories and moves the file
60-
// - If the source is a directory, it moves all its contents to the target directory
60+
// - If the source is a directory, it merges its contents into the target
61+
// directory (Docker COPY semantics: existing directories are merged into,
62+
// existing files are overwritten)
6163

6264
// Note: The temporary files in the /tmp directory are cleaned up automatically on sandbox restart
6365
// because the /tmp is mounted as a tmpfs and deleted on restart.

packages/orchestrator/pkg/template/build/commands/copy_script.sh

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#!/bin/bash
22

3+
set -o pipefail
4+
35
targetPath="{{ .TargetPath }}"
46
sourcePath="{{ .SourcePath }}"
57
owner="{{ .Owner }}"
@@ -28,11 +30,11 @@ fi
2830

2931
cd "$sourceFolder" || exit 1
3032

31-
# Get the first entry (file, directory, or symlink)
32-
entry=$(ls -A | head -n 1)
33+
# Get the entry (file, directory, or symlink) named by the source path
34+
entry="$(basename "$sourcePath")"
3335

34-
if [ -z "$entry" ]; then
35-
echo "Error: sourceFolder is empty"
36+
if [ ! -e "$entry" ] && [ ! -L "$entry" ]; then
37+
echo "Error: source path does not exist: $sourcePath"
3638
exit 1
3739
fi
3840

@@ -54,14 +56,24 @@ elif [ -f "$entry" ]; then
5456
mkdir -p "$(dirname "$targetPath")"
5557
mv "$entry" "$targetPath"
5658
elif [ -d "$entry" ]; then
57-
# It's a directory – apply ownership/permissions recursively, then move contents
59+
# It's a directory – apply ownership/permissions recursively, then merge
60+
# its contents into the target (Docker COPY semantics: existing directories
61+
# are merged into, existing files overwritten – mv can't merge into
62+
# non-empty directories, so copy and remove the source instead)
5863
chown -R "$owner" "$entry"
5964
if [ -n "$permissions" ]; then
6065
chmod -R "$permissions" "$entry"
6166
fi
6267
mkdir -p "$targetPath"
63-
# Move all contents including hidden files
64-
find "$entry" -mindepth 1 -maxdepth 1 -exec mv {} "$targetPath/" \;
68+
# Merge via tar, matching Docker's tar-based COPY: unlike cp, it replaces
69+
# destination file symlinks instead of writing through them, follows
70+
# destination directory symlinks (usrmerge, e.g. /lib -> usr/lib), and
71+
# keeps the metadata of the target directory and other existing dirs
72+
(cd "$entry" && tar -cf - .) | tar -xf - -C "$targetPath" --keep-directory-symlink --no-overwrite-dir || exit 1
73+
# Restore write permissions so cleanup works even when a read-only
74+
# permissions argument was applied and we are not running as root
75+
chmod -R u+rwx "$entry"
76+
rm -rf "$entry"
6577
else
6678
echo "Error: entry is neither file, directory, nor symlink"
6779
exit 1

packages/orchestrator/pkg/template/build/commands/copy_test.go

Lines changed: 231 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12+
"strings"
1213
"testing"
1314

1415
"github.com/stretchr/testify/assert"
@@ -92,6 +93,12 @@ func renderTemplate(t *testing.T, data copyScriptData) string {
9293
// createFilesAndDirs creates files, directories, and symlinks from a map
9394
// Values: "file", "dir", "symlink"
9495
func createFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
96+
t.Helper()
97+
createFilesAndDirsWithContent(t, baseDir, paths, "dummy")
98+
}
99+
100+
// createFilesAndDirsWithContent is createFilesAndDirs with custom file content
101+
func createFilesAndDirsWithContent(t *testing.T, baseDir string, paths map[string]string, content string) {
95102
t.Helper()
96103
for path, entryType := range paths {
97104
fullPath := filepath.Join(baseDir, path)
@@ -103,7 +110,7 @@ func createFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
103110
// Ensure parent dir exists
104111
dir := filepath.Dir(fullPath)
105112
require.NoError(t, os.MkdirAll(dir, 0o755))
106-
require.NoError(t, os.WriteFile(fullPath, []byte("dummy"), 0o644))
113+
require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
107114
case "symlink":
108115
// Create symlink target outside the tree
109116
dir := filepath.Dir(fullPath)
@@ -112,6 +119,13 @@ func createFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
112119
require.NoError(t, os.WriteFile(targetFile, []byte("symlink target"), 0o644))
113120
require.NoError(t, os.Symlink(targetFile, fullPath))
114121
default:
122+
// "symlink:<target>" creates a symlink pointing at the given path
123+
if target, ok := strings.CutPrefix(entryType, "symlink:"); ok {
124+
require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
125+
require.NoError(t, os.Symlink(target, fullPath))
126+
127+
continue
128+
}
115129
t.Fatalf("Unknown entry type: %s", entryType)
116130
}
117131
}
@@ -132,6 +146,11 @@ func verifyFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
132146
info, err := os.Lstat(fullPath)
133147
require.NoError(t, err, "Symlink %s should exist", path)
134148
assert.Equal(t, os.ModeSymlink, info.Mode()&os.ModeSymlink, "%s should be a symlink", path)
149+
case "regular":
150+
// A regular file, NOT a symlink pointing at one
151+
info, err := os.Lstat(fullPath)
152+
require.NoError(t, err, "File %s should exist", path)
153+
assert.True(t, info.Mode().IsRegular(), "%s should be a regular file, got %s", path, info.Mode())
135154
default:
136155
t.Fatalf("Unknown entry type: %s", entryType)
137156
}
@@ -148,6 +167,10 @@ type testCase struct {
148167
// Example: {"app/": "dir", "app/main.js": "file", "link": "symlink"}
149168
files map[string]string
150169

170+
// Setup: paths pre-created in the target before the copy runs,
171+
// to simulate copying over an existing filesystem tree
172+
preexistingTargetPaths map[string]string
173+
151174
// Input: the path within the extracted files to copy from
152175
// Examples: "." (root), "app/" (subdirectory), "src/main.js" (specific file)
153176
copyFrom string
@@ -168,6 +191,15 @@ type testCase struct {
168191

169192
// Verification: what paths to check in the target with their types
170193
expectedPaths map[string]string
194+
195+
// Verification: paths that must NOT exist in the target
196+
absentPaths []string
197+
198+
// Verification: file contents to check in the target
199+
expectedContents map[string]string
200+
201+
// Verification: octal permissions to check in the target
202+
expectedPerms map[string]string
171203
}
172204

173205
func TestParseCopyArgs(t *testing.T) {
@@ -623,6 +655,178 @@ func TestCopyScriptBehavior(t *testing.T) { //nolint:paralleltest // no idea why
623655
"deep.txt": "file",
624656
},
625657
},
658+
{
659+
name: "merge_directory_into_existing_tree",
660+
description: "COPY rootfs/etc /etc: merge into a target directory that already has content",
661+
files: map[string]string{
662+
"etc/motd": "file",
663+
"etc/profile.d/test-env.sh": "file",
664+
},
665+
copyFrom: "etc/",
666+
copyTo: "etc/",
667+
preexistingTargetPaths: map[string]string{
668+
"etc/profile.d/existing.sh": "file",
669+
},
670+
shouldSucceed: true,
671+
expectedPaths: map[string]string{
672+
"etc/motd": "file",
673+
"etc/profile.d/test-env.sh": "file",
674+
"etc/profile.d/existing.sh": "file",
675+
},
676+
},
677+
{
678+
name: "merge_root_directory_into_existing_root",
679+
description: "COPY rootfs/ /: every top-level dir already exists in the target root",
680+
files: map[string]string{
681+
"rootfs/etc/profile.d/test-env.sh": "file",
682+
"rootfs/usr/local/bin/tool": "file",
683+
},
684+
copyFrom: "rootfs/",
685+
copyTo: ".",
686+
preexistingTargetPaths: map[string]string{
687+
"etc/profile.d/00-existing.sh": "file",
688+
"usr/local/bin/existing-tool": "file",
689+
},
690+
shouldSucceed: true,
691+
expectedPaths: map[string]string{
692+
"etc/profile.d/test-env.sh": "file",
693+
"etc/profile.d/00-existing.sh": "file",
694+
"usr/local/bin/tool": "file",
695+
"usr/local/bin/existing-tool": "file",
696+
},
697+
},
698+
{
699+
name: "overwrite_existing_file_in_target",
700+
description: "Files that already exist in the target are overwritten",
701+
files: map[string]string{
702+
"etc/motd": "file",
703+
},
704+
copyFrom: "etc/",
705+
copyTo: "etc/",
706+
preexistingTargetPaths: map[string]string{
707+
"etc/motd": "file",
708+
},
709+
shouldSucceed: true,
710+
expectedPaths: map[string]string{
711+
"etc/motd": "file",
712+
},
713+
expectedContents: map[string]string{
714+
"etc/motd": "dummy",
715+
},
716+
},
717+
{
718+
name: "preserve_target_directory_metadata",
719+
description: "Only directory contents are copied; the existing target directory keeps its own permissions",
720+
files: map[string]string{
721+
"etc/motd": "file",
722+
},
723+
copyFrom: "etc/",
724+
copyTo: "etc/",
725+
// 700 must apply to the copied contents, not the existing target dir
726+
permissions: "700",
727+
preexistingTargetPaths: map[string]string{
728+
"etc/": "dir",
729+
},
730+
shouldSucceed: true,
731+
expectedPaths: map[string]string{
732+
"etc/motd": "file",
733+
},
734+
expectedPerms: map[string]string{
735+
"etc": "755",
736+
},
737+
},
738+
{
739+
name: "directory_with_readonly_permissions",
740+
description: "Read-only permissions on the copied tree do not break source cleanup",
741+
files: map[string]string{
742+
"app/config.json": "file",
743+
},
744+
copyFrom: "app/",
745+
copyTo: "dest/",
746+
permissions: "500",
747+
shouldSucceed: true,
748+
expectedPaths: map[string]string{
749+
"dest/config.json": "file",
750+
},
751+
},
752+
{
753+
name: "replace_target_symlink_to_file",
754+
description: "A source file replaces a destination symlink instead of writing through it",
755+
files: map[string]string{
756+
"etc/resolv.conf": "file",
757+
},
758+
copyFrom: "etc/",
759+
copyTo: "etc/",
760+
preexistingTargetPaths: map[string]string{
761+
// resolv.conf points outside the target tree (as on Ubuntu images)
762+
"../real/resolv.conf": "file",
763+
"etc/resolv.conf": "symlink:../../real/resolv.conf",
764+
},
765+
shouldSucceed: true,
766+
expectedPaths: map[string]string{
767+
"etc/resolv.conf": "regular",
768+
},
769+
expectedContents: map[string]string{
770+
"etc/resolv.conf": "dummy",
771+
// The symlink's old target must not have been written through
772+
"../real/resolv.conf": "preexisting",
773+
},
774+
},
775+
{
776+
name: "follow_target_symlink_to_directory",
777+
description: "A source directory merges through a destination directory symlink (usrmerge layout)",
778+
files: map[string]string{
779+
"lib/mylib.so": "file",
780+
},
781+
copyFrom: ".",
782+
copyTo: ".",
783+
preexistingTargetPaths: map[string]string{
784+
"usr/lib/existing.so": "file",
785+
"lib": "symlink:usr/lib",
786+
},
787+
shouldSucceed: true,
788+
expectedPaths: map[string]string{
789+
"lib": "symlink",
790+
"usr/lib/mylib.so": "file",
791+
"usr/lib/existing.so": "file",
792+
},
793+
},
794+
{
795+
name: "copy_directory_that_is_not_first_alphabetically",
796+
description: "The entry named by the source path is copied, not the first entry in its parent",
797+
files: map[string]string{
798+
"project/components/Button.tsx": "file",
799+
"project/utils/helpers.ts": "file",
800+
},
801+
copyFrom: "project/utils/",
802+
copyTo: "out/",
803+
shouldSucceed: true,
804+
expectedPaths: map[string]string{
805+
"out/helpers.ts": "file",
806+
},
807+
absentPaths: []string{
808+
"out/Button.tsx",
809+
"out/components",
810+
},
811+
},
812+
{
813+
name: "copy_file_that_is_not_first_alphabetically",
814+
description: "The file named by the source path is copied, not the first entry in its parent",
815+
files: map[string]string{
816+
"assets/logo.png": "file",
817+
"config.json": "file",
818+
},
819+
copyFrom: "config.json",
820+
copyTo: "dest/config.json",
821+
shouldSucceed: true,
822+
expectedPaths: map[string]string{
823+
"dest/config.json": "file",
824+
},
825+
absentPaths: []string{
826+
"dest/logo.png",
827+
"dest/config.json/logo.png",
828+
},
829+
},
626830
{
627831
name: "deeply_nested_folder",
628832
description: "Deeply nested folder should be copied correctly",
@@ -656,6 +860,11 @@ func TestCopyScriptBehavior(t *testing.T) { //nolint:paralleltest // no idea why
656860
verifyFilesAndDirs(t, unpackDir, tc.files)
657861
}
658862

863+
// Pre-populate the target to simulate an existing filesystem tree
864+
if len(tc.preexistingTargetPaths) > 0 {
865+
createFilesAndDirsWithContent(t, targetBaseDir, tc.preexistingTargetPaths, "preexisting")
866+
}
867+
659868
// Internal: construct SourcePath (sbxUnpackPath + user's copyFrom path)
660869
// This mimics how copy.go constructs the path: filepath.Join(sbxUnpackPath, sourcePath)
661870
sourcePath := filepath.Join(unpackDir, tc.copyFrom)
@@ -701,6 +910,27 @@ func TestCopyScriptBehavior(t *testing.T) { //nolint:paralleltest // no idea why
701910
verifyFilesAndDirs(t, targetBaseDir, tc.expectedPaths)
702911
}
703912

913+
// Verify paths that must not exist in the target
914+
for _, path := range tc.absentPaths {
915+
assert.NoFileExists(t, filepath.Join(targetBaseDir, path), "Path %s should not exist", path)
916+
assert.NoDirExists(t, filepath.Join(targetBaseDir, path), "Path %s should not exist", path)
917+
}
918+
919+
// Verify file contents
920+
for path, expectedContent := range tc.expectedContents {
921+
content, err := os.ReadFile(filepath.Join(targetBaseDir, path))
922+
require.NoError(t, err, "Failed to read file %s", path)
923+
assert.Equal(t, expectedContent, string(content), "File %s content mismatch", path)
924+
}
925+
926+
// Verify permissions of specific paths
927+
for path, permStr := range tc.expectedPerms {
928+
perms := getFilePermissions(t, filepath.Join(targetBaseDir, path))
929+
expectedPerms := os.FileMode(0)
930+
fmt.Sscanf(permStr, "%o", &expectedPerms)
931+
assert.Equal(t, expectedPerms, perms, "Path %s should have %s permissions", path, permStr)
932+
}
933+
704934
// Special verification for permissions tests
705935
if tc.permissions != "" && tc.shouldSucceed {
706936
for path, entryType := range tc.expectedPaths {

0 commit comments

Comments
 (0)