diff --git a/pkg/reposerverextract/extract.go b/pkg/reposerverextract/extract.go index 122676d7..9a11dc6b 100644 --- a/pkg/reposerverextract/extract.go +++ b/pkg/reposerverextract/extract.go @@ -737,12 +737,20 @@ func buildManifestRequestForSource( } } - // Copy the content source directory into the temp tree. + // Kustomize can reference files outside the source dir, so stage the + // whole checkout (like the no-refs fast path). srcContentDir := filepath.Join(branchFolder, primarySource.Path) - dstContentDir := filepath.Join(tempDir, primarySource.Path) - if err := copyDir(srcContentDir, dstContentDir); err != nil { - cleanup() - return nil, "", nil, fmt.Errorf("failed to copy content source dir %q: %w", srcContentDir, err) + if isKustomizeSource(srcContentDir) { + if err := stageCheckout(branchFolder, tempDir); err != nil { + cleanup() + return nil, "", nil, fmt.Errorf("failed to stage branch folder %q: %w", branchFolder, err) + } + } else { + dstContentDir := filepath.Join(tempDir, primarySource.Path) + if err := copyDir(srcContentDir, dstContentDir); err != nil { + cleanup() + return nil, "", nil, fmt.Errorf("failed to copy content source dir %q: %w", srcContentDir, err) + } } // Copy only the ref files used by this content source into @@ -1115,6 +1123,41 @@ func hasExternalRefSource(refSources []v1alpha1.ApplicationSource, repoSelector return false } +// stageCheckout mirrors a checkout into dst: files are hardlinked (staging +// is read-only), symlinks stay symlinks, .git is skipped. Links are left for +// the repo server and kustomize to validate, like on a real clone. +func stageCheckout(src, dst string) error { + return filepath.Walk(src, func(srcPath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() && info.Name() == ".git" { + return filepath.SkipDir + } + rel, err := filepath.Rel(src, srcPath) + if err != nil { + return err + } + dstPath := filepath.Join(dst, rel) + switch { + case info.IsDir(): + return os.MkdirAll(dstPath, 0o755) + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(srcPath) + if err != nil { + return err + } + return os.Symlink(target, dstPath) + default: + // parent dirs already exist: Walk visits directories first + if err := os.Link(srcPath, dstPath); err == nil { + return nil + } + return copyFile(srcPath, dstPath) + } + }) +} + // copyDir recursively copies src into dst, creating dst if needed. func copyDir(src, dst string) error { return filepath.Walk(src, func(srcPath string, info os.FileInfo, err error) error { diff --git a/pkg/reposerverextract/extract_test.go b/pkg/reposerverextract/extract_test.go index 2c3cb5ae..e2934465 100644 --- a/pkg/reposerverextract/extract_test.go +++ b/pkg/reposerverextract/extract_test.go @@ -540,6 +540,85 @@ spec: assertDefaultProjectFields(t, req) } +// Multi-source: a kustomize content source referencing files outside its own +// directory must stage the whole checkout (like the no-refs fast path), or +// the escaping reference fails with "no such file or directory". +func TestBuildManifestRequest_MultiSource_Kustomize_WithRef_StagesBranchRoot(t *testing.T) { + branchFolder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(branchFolder, "clusters", "dev", "my-app"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(branchFolder, "clusters", "dev", "my-app", "kustomization.yaml"), + []byte("resources:\n - ../../../base/my-app\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(branchFolder, "base", "my-app"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(branchFolder, "base", "my-app", "kustomization.yaml"), + []byte("resources: []\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(branchFolder, "values.yaml"), []byte("a: 1\n"), 0o644)) + + app := makeApp(t, ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: my-app-dev +spec: + destination: + namespace: production + sources: + - repoURL: https://github.com/org/repo.git + ref: values + targetRevision: HEAD + - repoURL: https://github.com/org/repo.git + path: clusters/dev/my-app + targetRevision: HEAD +`) + + contentSources, refSources, hasMultipleSources, err := splitSources(app) + require.NoError(t, err) + require.Len(t, contentSources, 1) + require.Len(t, refSources, 1) + + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, manifestRequestRenderContext{ + repoSelector: testRepoSelector(t, ""), + }) + require.NoError(t, err) + require.NotEmpty(t, streamDir) + defer cleanup() + + assert.Equal(t, "clusters/dev/my-app", req.ApplicationSource.Path) + _, statErr := os.Stat(filepath.Join(streamDir, "base", "my-app", "kustomization.yaml")) + assert.NoError(t, statErr, "files referenced outside the kustomize source dir must be staged") +} + +// symlinks stay symlinks (the repo server and kustomize validate them, like +// on a real clone), files are hardlinked, .git is skipped +func TestStageCheckout_PreservesSymlinksAndHardlinks(t *testing.T) { + src := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(src, "app.yaml"), []byte("a: 1"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(src, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "HEAD"), []byte("ref"), 0o644)) + require.NoError(t, os.Symlink("app.yaml", filepath.Join(src, "link-in.yaml"))) + require.NoError(t, os.Symlink("../outside.yaml", filepath.Join(src, "link-out.yaml"))) + + dst := filepath.Join(t.TempDir(), "dst") + require.NoError(t, stageCheckout(src, dst)) + + // symlinks preserved verbatim, including ones pointing outside the tree + target, err := os.Readlink(filepath.Join(dst, "link-in.yaml")) + require.NoError(t, err) + assert.Equal(t, "app.yaml", target) + target, err = os.Readlink(filepath.Join(dst, "link-out.yaml")) + require.NoError(t, err) + assert.Equal(t, "../outside.yaml", target) + + // regular files hardlinked, .git skipped + srcInfo, err := os.Stat(filepath.Join(src, "app.yaml")) + require.NoError(t, err) + dstInfo, err := os.Stat(filepath.Join(dst, "app.yaml")) + require.NoError(t, err) + assert.True(t, os.SameFile(srcInfo, dstInfo), "regular files should be hardlinked") + assert.NoDirExists(t, filepath.Join(dst, ".git")) +} + // ───────────────────────────────────────────────────────────────────────────── // 4b. Multi-source: external chart with a ref+path dual-purpose source (GH #401) //