Skip to content

Commit 7bf0bda

Browse files
committed
Add --resolve-symlinks flag to build and push artifact commands
This adds a --resolve-symlinks flag to the flux build artifact and flux push artifact commands. When enabled, symlinks in the source directory are resolved (copied as regular files/directories) before building the artifact. This includes: - Recursive symlink resolution with cycle detection - File permission preservation - Proper handling of both single-file and directory symlink targets - Comprehensive test coverage Fixes #5055 Signed-off-by: Rohan Sood <56945243+rohansood10@users.noreply.github.com>
1 parent d9f51d0 commit 7bf0bda

3 files changed

Lines changed: 283 additions & 14 deletions

File tree

cmd/flux/build_artifact.go

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"io"
2424
"os"
25+
"path/filepath"
2526
"strings"
2627

2728
"github.com/spf13/cobra"
@@ -48,9 +49,10 @@ from the given directory or a single manifest file.`,
4849
}
4950

5051
type buildArtifactFlags struct {
51-
output string
52-
path string
53-
ignorePaths []string
52+
output string
53+
path string
54+
ignorePaths []string
55+
resolveSymlinks bool
5456
}
5557

5658
var excludeOCI = append(strings.Split(sourceignore.ExcludeVCS, ","), strings.Split(sourceignore.ExcludeExt, ",")...)
@@ -61,6 +63,7 @@ func init() {
6163
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.path, "path", "p", "", "Path to the directory where the Kubernetes manifests are located.")
6264
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.output, "output", "o", "artifact.tgz", "Path to where the artifact tgz file should be written.")
6365
buildArtifactCmd.Flags().StringSliceVar(&buildArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
66+
buildArtifactCmd.Flags().BoolVar(&buildArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact")
6467

6568
buildCmd.AddCommand(buildArtifactCmd)
6669
}
@@ -85,6 +88,15 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error {
8588
return fmt.Errorf("invalid path '%s', must point to an existing directory or file", path)
8689
}
8790

91+
if buildArtifactArgs.resolveSymlinks {
92+
resolved, cleanupDir, err := resolveSymlinks(path)
93+
if err != nil {
94+
return fmt.Errorf("resolving symlinks failed: %w", err)
95+
}
96+
defer os.RemoveAll(cleanupDir)
97+
path = resolved
98+
}
99+
88100
logger.Actionf("building artifact from %s", path)
89101

90102
ociClient := oci.NewClient(oci.DefaultOptions())
@@ -96,6 +108,141 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error {
96108
return nil
97109
}
98110

111+
// resolveSymlinks creates a temporary directory with symlinks resolved to their
112+
// real file contents. This allows building artifacts from symlink trees (e.g.,
113+
// those created by Nix) where the actual files live outside the source directory.
114+
// It returns the resolved path and the temporary directory path for cleanup.
115+
func resolveSymlinks(srcPath string) (string, string, error) {
116+
absPath, err := filepath.Abs(srcPath)
117+
if err != nil {
118+
return "", "", err
119+
}
120+
121+
info, err := os.Stat(absPath)
122+
if err != nil {
123+
return "", "", err
124+
}
125+
126+
// For a single file, resolve the symlink and return the path to the
127+
// copied file within the temp dir, preserving file semantics for callers.
128+
if !info.IsDir() {
129+
resolved, err := filepath.EvalSymlinks(absPath)
130+
if err != nil {
131+
return "", "", fmt.Errorf("resolving symlink for %s: %w", absPath, err)
132+
}
133+
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
134+
if err != nil {
135+
return "", "", err
136+
}
137+
dst := filepath.Join(tmpDir, filepath.Base(absPath))
138+
if err := copyFile(resolved, dst); err != nil {
139+
os.RemoveAll(tmpDir)
140+
return "", "", err
141+
}
142+
return dst, tmpDir, nil
143+
}
144+
145+
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
146+
if err != nil {
147+
return "", "", err
148+
}
149+
150+
visited := make(map[string]bool)
151+
if err := copyDir(absPath, tmpDir, visited); err != nil {
152+
os.RemoveAll(tmpDir)
153+
return "", "", err
154+
}
155+
156+
return tmpDir, tmpDir, nil
157+
}
158+
159+
// copyDir recursively copies the contents of srcDir to dstDir, resolving any
160+
// symlinks encountered along the way. The visited map tracks resolved real
161+
// directory paths to detect and break symlink cycles.
162+
func copyDir(srcDir, dstDir string, visited map[string]bool) error {
163+
real, err := filepath.EvalSymlinks(srcDir)
164+
if err != nil {
165+
return fmt.Errorf("resolving symlink %s: %w", srcDir, err)
166+
}
167+
abs, err := filepath.Abs(real)
168+
if err != nil {
169+
return fmt.Errorf("getting absolute path for %s: %w", real, err)
170+
}
171+
if visited[abs] {
172+
return nil // break the cycle
173+
}
174+
visited[abs] = true
175+
176+
entries, err := os.ReadDir(srcDir)
177+
if err != nil {
178+
return err
179+
}
180+
181+
for _, entry := range entries {
182+
srcPath := filepath.Join(srcDir, entry.Name())
183+
dstPath := filepath.Join(dstDir, entry.Name())
184+
185+
// Resolve symlinks to get the real path and info.
186+
realPath, err := filepath.EvalSymlinks(srcPath)
187+
if err != nil {
188+
return fmt.Errorf("resolving symlink %s: %w", srcPath, err)
189+
}
190+
realInfo, err := os.Stat(realPath)
191+
if err != nil {
192+
return fmt.Errorf("stat resolved path %s: %w", realPath, err)
193+
}
194+
195+
if realInfo.IsDir() {
196+
if err := os.MkdirAll(dstPath, realInfo.Mode()); err != nil {
197+
return err
198+
}
199+
// Recursively copy the resolved directory contents.
200+
if err := copyDir(realPath, dstPath, visited); err != nil {
201+
return err
202+
}
203+
continue
204+
}
205+
206+
if !realInfo.Mode().IsRegular() {
207+
continue
208+
}
209+
210+
if err := copyFile(realPath, dstPath); err != nil {
211+
return err
212+
}
213+
}
214+
215+
return nil
216+
}
217+
218+
func copyFile(src, dst string) error {
219+
srcInfo, err := os.Stat(src)
220+
if err != nil {
221+
return err
222+
}
223+
224+
in, err := os.Open(src)
225+
if err != nil {
226+
return err
227+
}
228+
defer in.Close()
229+
230+
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
231+
return err
232+
}
233+
234+
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
235+
if err != nil {
236+
return err
237+
}
238+
defer out.Close()
239+
240+
if _, err := io.Copy(out, in); err != nil {
241+
return err
242+
}
243+
return out.Close()
244+
}
245+
99246
func saveReaderToFile(reader io.Reader) (string, error) {
100247
b, err := io.ReadAll(bufio.NewReader(reader))
101248
if err != nil {

cmd/flux/build_artifact_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package main
1818

1919
import (
2020
"os"
21+
"path/filepath"
2122
"strings"
2223
"testing"
2324

@@ -68,3 +69,113 @@ data:
6869

6970
}
7071
}
72+
73+
func Test_resolveSymlinks(t *testing.T) {
74+
g := NewWithT(t)
75+
76+
// Create source directory with a real file
77+
srcDir := t.TempDir()
78+
realFile := filepath.Join(srcDir, "real.yaml")
79+
g.Expect(os.WriteFile(realFile, []byte("apiVersion: v1\nkind: Namespace\nmetadata:\n name: test\n"), 0o644)).To(Succeed())
80+
81+
// Create a directory with symlinks pointing to files outside it
82+
symlinkDir := t.TempDir()
83+
symlinkFile := filepath.Join(symlinkDir, "linked.yaml")
84+
g.Expect(os.Symlink(realFile, symlinkFile)).To(Succeed())
85+
86+
// Also add a regular file in the symlink dir
87+
regularFile := filepath.Join(symlinkDir, "regular.yaml")
88+
g.Expect(os.WriteFile(regularFile, []byte("apiVersion: v1\nkind: ConfigMap\n"), 0o644)).To(Succeed())
89+
90+
// Create a symlinked subdirectory
91+
subDir := filepath.Join(srcDir, "subdir")
92+
g.Expect(os.MkdirAll(subDir, 0o755)).To(Succeed())
93+
g.Expect(os.WriteFile(filepath.Join(subDir, "nested.yaml"), []byte("nested"), 0o644)).To(Succeed())
94+
g.Expect(os.Symlink(subDir, filepath.Join(symlinkDir, "linkeddir"))).To(Succeed())
95+
96+
// Resolve symlinks
97+
resolved, cleanupDir, err := resolveSymlinks(symlinkDir)
98+
g.Expect(err).To(BeNil())
99+
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
100+
101+
// Verify the regular file was copied
102+
content, err := os.ReadFile(filepath.Join(resolved, "regular.yaml"))
103+
g.Expect(err).To(BeNil())
104+
g.Expect(string(content)).To(Equal("apiVersion: v1\nkind: ConfigMap\n"))
105+
106+
// Verify the symlinked file was resolved and copied
107+
content, err = os.ReadFile(filepath.Join(resolved, "linked.yaml"))
108+
g.Expect(err).To(BeNil())
109+
g.Expect(string(content)).To(ContainSubstring("kind: Namespace"))
110+
111+
// Verify that the resolved file is a regular file, not a symlink
112+
info, err := os.Lstat(filepath.Join(resolved, "linked.yaml"))
113+
g.Expect(err).To(BeNil())
114+
g.Expect(info.Mode().IsRegular()).To(BeTrue())
115+
116+
// Verify that the symlinked directory was resolved and its contents were copied
117+
content, err = os.ReadFile(filepath.Join(resolved, "linkeddir", "nested.yaml"))
118+
g.Expect(err).To(BeNil())
119+
g.Expect(string(content)).To(Equal("nested"))
120+
121+
// Verify that the file inside the symlinked directory is a regular file
122+
info, err = os.Lstat(filepath.Join(resolved, "linkeddir", "nested.yaml"))
123+
g.Expect(err).To(BeNil())
124+
g.Expect(info.Mode().IsRegular()).To(BeTrue())
125+
}
126+
127+
func Test_resolveSymlinks_singleFile(t *testing.T) {
128+
g := NewWithT(t)
129+
130+
// Create a real file
131+
srcDir := t.TempDir()
132+
realFile := filepath.Join(srcDir, "manifest.yaml")
133+
g.Expect(os.WriteFile(realFile, []byte("kind: ConfigMap"), 0o644)).To(Succeed())
134+
135+
// Create a symlink to the real file
136+
linkDir := t.TempDir()
137+
linkFile := filepath.Join(linkDir, "link.yaml")
138+
g.Expect(os.Symlink(realFile, linkFile)).To(Succeed())
139+
140+
// Resolve the single symlinked file
141+
resolved, cleanupDir, err := resolveSymlinks(linkFile)
142+
g.Expect(err).To(BeNil())
143+
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
144+
145+
// The returned path should be a file, not a directory
146+
info, err := os.Stat(resolved)
147+
g.Expect(err).To(BeNil())
148+
g.Expect(info.IsDir()).To(BeFalse())
149+
150+
// Verify contents
151+
content, err := os.ReadFile(resolved)
152+
g.Expect(err).To(BeNil())
153+
g.Expect(string(content)).To(Equal("kind: ConfigMap"))
154+
}
155+
156+
func Test_resolveSymlinks_cycle(t *testing.T) {
157+
g := NewWithT(t)
158+
159+
// Create a directory with a symlink cycle: dir/link -> dir
160+
dir := t.TempDir()
161+
g.Expect(os.WriteFile(filepath.Join(dir, "file.yaml"), []byte("data"), 0o644)).To(Succeed())
162+
g.Expect(os.Symlink(dir, filepath.Join(dir, "cycle"))).To(Succeed())
163+
164+
// resolveSymlinks should not infinite-loop
165+
resolved, cleanupDir, err := resolveSymlinks(dir)
166+
g.Expect(err).To(BeNil())
167+
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
168+
169+
// The file should be copied
170+
content, err := os.ReadFile(filepath.Join(resolved, "file.yaml"))
171+
g.Expect(err).To(BeNil())
172+
g.Expect(string(content)).To(Equal("data"))
173+
174+
// The cycle directory should exist but not cause infinite nesting
175+
_, err = os.Stat(filepath.Join(resolved, "cycle"))
176+
g.Expect(err).To(BeNil())
177+
178+
// There should NOT be deeply nested cycle/cycle/cycle/... paths
179+
_, err = os.Stat(filepath.Join(resolved, "cycle", "cycle", "cycle"))
180+
g.Expect(os.IsNotExist(err)).To(BeTrue())
181+
}

cmd/flux/push_artifact.go

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,18 @@ The command can read the credentials from '~/.docker/config.json' but they can a
103103
}
104104

105105
type pushArtifactFlags struct {
106-
path string
107-
source string
108-
revision string
109-
creds string
110-
provider flags.SourceOCIProvider
111-
ignorePaths []string
112-
annotations []string
113-
output string
114-
debug bool
115-
reproducible bool
116-
insecure bool
106+
path string
107+
source string
108+
revision string
109+
creds string
110+
provider flags.SourceOCIProvider
111+
ignorePaths []string
112+
annotations []string
113+
output string
114+
debug bool
115+
reproducible bool
116+
insecure bool
117+
resolveSymlinks bool
117118
}
118119

119120
var pushArtifactArgs = newPushArtifactFlags()
@@ -137,6 +138,7 @@ func init() {
137138
pushArtifactCmd.Flags().BoolVarP(&pushArtifactArgs.debug, "debug", "", false, "display logs from underlying library")
138139
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.reproducible, "reproducible", false, "ensure reproducible image digests by setting the created timestamp to '1970-01-01T00:00:00Z'")
139140
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.insecure, "insecure-registry", false, "allows artifacts to be pushed without TLS")
141+
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact")
140142

141143
pushCmd.AddCommand(pushArtifactCmd)
142144
}
@@ -183,6 +185,15 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error {
183185
return fmt.Errorf("invalid path '%s', must point to an existing directory or file: %w", path, err)
184186
}
185187

188+
if pushArtifactArgs.resolveSymlinks {
189+
resolved, cleanupDir, err := resolveSymlinks(path)
190+
if err != nil {
191+
return fmt.Errorf("resolving symlinks failed: %w", err)
192+
}
193+
defer os.RemoveAll(cleanupDir)
194+
path = resolved
195+
}
196+
186197
annotations := map[string]string{}
187198
for _, annotation := range pushArtifactArgs.annotations {
188199
kv := strings.Split(annotation, "=")

0 commit comments

Comments
 (0)