Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 150 additions & 3 deletions cmd/flux/build_artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
Expand All @@ -48,9 +49,10 @@
}

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, ",")...)
Expand All @@ -61,6 +63,7 @@
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)
}
Expand All @@ -85,6 +88,15 @@
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())
Expand All @@ -96,6 +108,141 @@
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()

Check warning

Code scanning / CodeQL

Writable file handle closed without error handling Warning

File handle may be writable as a result of data flow from a
call to OpenFile
and closing it may result in data loss upon failure, which is not handled explicitly.

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 {
Expand Down
111 changes: 111 additions & 0 deletions cmd/flux/build_artifact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package main

import (
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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())
}
33 changes: 22 additions & 11 deletions cmd/flux/push_artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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, "=")
Expand Down