diff --git a/cmd/flux/build_artifact.go b/cmd/flux/build_artifact.go index 9da0ca0e8c..7dcc7d4214 100644 --- a/cmd/flux/build_artifact.go +++ b/cmd/flux/build_artifact.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -48,9 +49,10 @@ from the given directory or a single manifest file.`, } type buildArtifactFlags struct { - output string - path string - ignorePaths []string + output string + path string + ignorePaths []string + resolveSymlinks bool } var excludeOCI = append(strings.Split(sourceignore.ExcludeVCS, ","), strings.Split(sourceignore.ExcludeExt, ",")...) @@ -61,6 +63,7 @@ func init() { buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.path, "path", "p", "", "Path to the directory where the Kubernetes manifests are located.") buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.output, "output", "o", "artifact.tgz", "Path to where the artifact tgz file should be written.") buildArtifactCmd.Flags().StringSliceVar(&buildArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format") + buildArtifactCmd.Flags().BoolVar(&buildArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact") buildCmd.AddCommand(buildArtifactCmd) } @@ -85,6 +88,15 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid path '%s', must point to an existing directory or file", path) } + if buildArtifactArgs.resolveSymlinks { + resolved, cleanupDir, err := resolveSymlinks(path) + if err != nil { + return fmt.Errorf("resolving symlinks failed: %w", err) + } + defer os.RemoveAll(cleanupDir) + path = resolved + } + logger.Actionf("building artifact from %s", path) ociClient := oci.NewClient(oci.DefaultOptions()) @@ -96,6 +108,141 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error { return nil } +// resolveSymlinks creates a temporary directory with symlinks resolved to their +// real file contents. This allows building artifacts from symlink trees (e.g., +// those created by Nix) where the actual files live outside the source directory. +// It returns the resolved path and the temporary directory path for cleanup. +func resolveSymlinks(srcPath string) (string, string, error) { + absPath, err := filepath.Abs(srcPath) + if err != nil { + return "", "", err + } + + info, err := os.Stat(absPath) + if err != nil { + return "", "", err + } + + // For a single file, resolve the symlink and return the path to the + // copied file within the temp dir, preserving file semantics for callers. + if !info.IsDir() { + resolved, err := filepath.EvalSymlinks(absPath) + if err != nil { + return "", "", fmt.Errorf("resolving symlink for %s: %w", absPath, err) + } + tmpDir, err := os.MkdirTemp("", "flux-artifact-*") + if err != nil { + return "", "", err + } + dst := filepath.Join(tmpDir, filepath.Base(absPath)) + if err := copyFile(resolved, dst); err != nil { + os.RemoveAll(tmpDir) + return "", "", err + } + return dst, tmpDir, nil + } + + tmpDir, err := os.MkdirTemp("", "flux-artifact-*") + if err != nil { + return "", "", err + } + + visited := make(map[string]bool) + if err := copyDir(absPath, tmpDir, visited); err != nil { + os.RemoveAll(tmpDir) + return "", "", err + } + + return tmpDir, tmpDir, nil +} + +// copyDir recursively copies the contents of srcDir to dstDir, resolving any +// symlinks encountered along the way. The visited map tracks resolved real +// directory paths to detect and break symlink cycles. +func copyDir(srcDir, dstDir string, visited map[string]bool) error { + real, err := filepath.EvalSymlinks(srcDir) + if err != nil { + return fmt.Errorf("resolving symlink %s: %w", srcDir, err) + } + abs, err := filepath.Abs(real) + if err != nil { + return fmt.Errorf("getting absolute path for %s: %w", real, err) + } + if visited[abs] { + return nil // break the cycle + } + visited[abs] = true + + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(srcDir, entry.Name()) + dstPath := filepath.Join(dstDir, entry.Name()) + + // Resolve symlinks to get the real path and info. + realPath, err := filepath.EvalSymlinks(srcPath) + if err != nil { + return fmt.Errorf("resolving symlink %s: %w", srcPath, err) + } + realInfo, err := os.Stat(realPath) + if err != nil { + return fmt.Errorf("stat resolved path %s: %w", realPath, err) + } + + if realInfo.IsDir() { + if err := os.MkdirAll(dstPath, realInfo.Mode()); err != nil { + return err + } + // Recursively copy the resolved directory contents. + if err := copyDir(realPath, dstPath, visited); err != nil { + return err + } + continue + } + + if !realInfo.Mode().IsRegular() { + continue + } + + if err := copyFile(realPath, dstPath); err != nil { + return err + } + } + + return nil +} + +func copyFile(src, dst string) error { + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + func saveReaderToFile(reader io.Reader) (string, error) { b, err := io.ReadAll(bufio.NewReader(reader)) if err != nil { diff --git a/cmd/flux/build_artifact_test.go b/cmd/flux/build_artifact_test.go index ba84186c3d..bfdaaaed85 100644 --- a/cmd/flux/build_artifact_test.go +++ b/cmd/flux/build_artifact_test.go @@ -18,6 +18,7 @@ package main import ( "os" + "path/filepath" "strings" "testing" @@ -68,3 +69,113 @@ data: } } + +func Test_resolveSymlinks(t *testing.T) { + g := NewWithT(t) + + // Create source directory with a real file + srcDir := t.TempDir() + realFile := filepath.Join(srcDir, "real.yaml") + g.Expect(os.WriteFile(realFile, []byte("apiVersion: v1\nkind: Namespace\nmetadata:\n name: test\n"), 0o644)).To(Succeed()) + + // Create a directory with symlinks pointing to files outside it + symlinkDir := t.TempDir() + symlinkFile := filepath.Join(symlinkDir, "linked.yaml") + g.Expect(os.Symlink(realFile, symlinkFile)).To(Succeed()) + + // Also add a regular file in the symlink dir + regularFile := filepath.Join(symlinkDir, "regular.yaml") + g.Expect(os.WriteFile(regularFile, []byte("apiVersion: v1\nkind: ConfigMap\n"), 0o644)).To(Succeed()) + + // Create a symlinked subdirectory + subDir := filepath.Join(srcDir, "subdir") + g.Expect(os.MkdirAll(subDir, 0o755)).To(Succeed()) + g.Expect(os.WriteFile(filepath.Join(subDir, "nested.yaml"), []byte("nested"), 0o644)).To(Succeed()) + g.Expect(os.Symlink(subDir, filepath.Join(symlinkDir, "linkeddir"))).To(Succeed()) + + // Resolve symlinks + resolved, cleanupDir, err := resolveSymlinks(symlinkDir) + g.Expect(err).To(BeNil()) + t.Cleanup(func() { os.RemoveAll(cleanupDir) }) + + // Verify the regular file was copied + content, err := os.ReadFile(filepath.Join(resolved, "regular.yaml")) + g.Expect(err).To(BeNil()) + g.Expect(string(content)).To(Equal("apiVersion: v1\nkind: ConfigMap\n")) + + // Verify the symlinked file was resolved and copied + content, err = os.ReadFile(filepath.Join(resolved, "linked.yaml")) + g.Expect(err).To(BeNil()) + g.Expect(string(content)).To(ContainSubstring("kind: Namespace")) + + // Verify that the resolved file is a regular file, not a symlink + info, err := os.Lstat(filepath.Join(resolved, "linked.yaml")) + g.Expect(err).To(BeNil()) + g.Expect(info.Mode().IsRegular()).To(BeTrue()) + + // Verify that the symlinked directory was resolved and its contents were copied + content, err = os.ReadFile(filepath.Join(resolved, "linkeddir", "nested.yaml")) + g.Expect(err).To(BeNil()) + g.Expect(string(content)).To(Equal("nested")) + + // Verify that the file inside the symlinked directory is a regular file + info, err = os.Lstat(filepath.Join(resolved, "linkeddir", "nested.yaml")) + g.Expect(err).To(BeNil()) + g.Expect(info.Mode().IsRegular()).To(BeTrue()) +} + +func Test_resolveSymlinks_singleFile(t *testing.T) { + g := NewWithT(t) + + // Create a real file + srcDir := t.TempDir() + realFile := filepath.Join(srcDir, "manifest.yaml") + g.Expect(os.WriteFile(realFile, []byte("kind: ConfigMap"), 0o644)).To(Succeed()) + + // Create a symlink to the real file + linkDir := t.TempDir() + linkFile := filepath.Join(linkDir, "link.yaml") + g.Expect(os.Symlink(realFile, linkFile)).To(Succeed()) + + // Resolve the single symlinked file + resolved, cleanupDir, err := resolveSymlinks(linkFile) + g.Expect(err).To(BeNil()) + t.Cleanup(func() { os.RemoveAll(cleanupDir) }) + + // The returned path should be a file, not a directory + info, err := os.Stat(resolved) + g.Expect(err).To(BeNil()) + g.Expect(info.IsDir()).To(BeFalse()) + + // Verify contents + content, err := os.ReadFile(resolved) + g.Expect(err).To(BeNil()) + g.Expect(string(content)).To(Equal("kind: ConfigMap")) +} + +func Test_resolveSymlinks_cycle(t *testing.T) { + g := NewWithT(t) + + // Create a directory with a symlink cycle: dir/link -> dir + dir := t.TempDir() + g.Expect(os.WriteFile(filepath.Join(dir, "file.yaml"), []byte("data"), 0o644)).To(Succeed()) + g.Expect(os.Symlink(dir, filepath.Join(dir, "cycle"))).To(Succeed()) + + // resolveSymlinks should not infinite-loop + resolved, cleanupDir, err := resolveSymlinks(dir) + g.Expect(err).To(BeNil()) + t.Cleanup(func() { os.RemoveAll(cleanupDir) }) + + // The file should be copied + content, err := os.ReadFile(filepath.Join(resolved, "file.yaml")) + g.Expect(err).To(BeNil()) + g.Expect(string(content)).To(Equal("data")) + + // The cycle directory should exist but not cause infinite nesting + _, err = os.Stat(filepath.Join(resolved, "cycle")) + g.Expect(err).To(BeNil()) + + // There should NOT be deeply nested cycle/cycle/cycle/... paths + _, err = os.Stat(filepath.Join(resolved, "cycle", "cycle", "cycle")) + g.Expect(os.IsNotExist(err)).To(BeTrue()) +} diff --git a/cmd/flux/push_artifact.go b/cmd/flux/push_artifact.go index c37f0ef141..237c25932e 100644 --- a/cmd/flux/push_artifact.go +++ b/cmd/flux/push_artifact.go @@ -103,17 +103,18 @@ The command can read the credentials from '~/.docker/config.json' but they can a } type pushArtifactFlags struct { - path string - source string - revision string - creds string - provider flags.SourceOCIProvider - ignorePaths []string - annotations []string - output string - debug bool - reproducible bool - insecure bool + path string + source string + revision string + creds string + provider flags.SourceOCIProvider + ignorePaths []string + annotations []string + output string + debug bool + reproducible bool + insecure bool + resolveSymlinks bool } var pushArtifactArgs = newPushArtifactFlags() @@ -137,6 +138,7 @@ func init() { pushArtifactCmd.Flags().BoolVarP(&pushArtifactArgs.debug, "debug", "", false, "display logs from underlying library") pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.reproducible, "reproducible", false, "ensure reproducible image digests by setting the created timestamp to '1970-01-01T00:00:00Z'") pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.insecure, "insecure-registry", false, "allows artifacts to be pushed without TLS") + pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact") pushCmd.AddCommand(pushArtifactCmd) } @@ -183,6 +185,15 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid path '%s', must point to an existing directory or file: %w", path, err) } + if pushArtifactArgs.resolveSymlinks { + resolved, cleanupDir, err := resolveSymlinks(path) + if err != nil { + return fmt.Errorf("resolving symlinks failed: %w", err) + } + defer os.RemoveAll(cleanupDir) + path = resolved + } + annotations := map[string]string{} for _, annotation := range pushArtifactArgs.annotations { kv := strings.Split(annotation, "=")