Add --resolve-symlinks flag to build and push artifact commands

When building OCI artifacts from directories containing symlinks (e.g.,
symlink trees created by Nix), the symlinked files are silently skipped
because the underlying archive logic only handles regular files and
directories. This results in empty or incomplete artifacts.

This change adds a --resolve-symlinks flag to both 'flux build artifact'
and 'flux push artifact' commands. When set, symlinks are resolved by
copying their target contents into a temporary directory before building
the artifact. This approach:

- Preserves backward compatibility (default behavior unchanged)
- Works with symlinks pointing outside the source directory
- Handles symlinked files and directories
- Cleans up the temporary directory after the build completes

Fixes fluxcd/flux2#5055

Signed-off-by: rohansood10 <rohansood10@users.noreply.github.com>
This commit is contained in:
rohansood10 2026-02-19 09:28:54 -08:00
parent 7132eb3435
commit 46ad3b2e7a
3 changed files with 186 additions and 14 deletions

View file

@ -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, err := resolveSymlinks(path)
if err != nil {
return fmt.Errorf("resolving symlinks failed: %w", err)
}
defer os.RemoveAll(resolved)
path = resolved
}
logger.Actionf("building artifact from %s", path)
ociClient := oci.NewClient(oci.DefaultOptions())
@ -96,6 +108,110 @@ 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.
func resolveSymlinks(srcPath 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 a temp dir containing it
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 tmpDir, nil
}
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
if err != nil {
return "", err
}
err = filepath.Walk(absPath, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(absPath, p)
if err != nil {
return err
}
dstPath := filepath.Join(tmpDir, relPath)
// Resolve symlinks to get the real file info
realPath := p
realInfo := fi
if fi.Mode()&os.ModeSymlink != 0 {
realPath, err = filepath.EvalSymlinks(p)
if err != nil {
return fmt.Errorf("resolving symlink %s: %w", p, err)
}
realInfo, err = os.Stat(realPath)
if err != nil {
return fmt.Errorf("stat resolved path %s: %w", realPath, err)
}
}
if realInfo.IsDir() {
return os.MkdirAll(dstPath, realInfo.Mode())
}
if !realInfo.Mode().IsRegular() {
return nil
}
return copyFile(realPath, dstPath)
})
if err != nil {
os.RemoveAll(tmpDir)
return "", err
}
return tmpDir, nil
}
func copyFile(src, dst string) error {
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.Create(dst)
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 {

View file

@ -18,6 +18,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
@ -68,3 +69,47 @@ 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, err := resolveSymlinks(symlinkDir)
g.Expect(err).To(BeNil())
t.Cleanup(func() { os.RemoveAll(resolved) })
// 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())
}

View file

@ -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, err := resolveSymlinks(path)
if err != nil {
return fmt.Errorf("resolving symlinks failed: %w", err)
}
defer os.RemoveAll(resolved)
path = resolved
}
annotations := map[string]string{}
for _, annotation := range pushArtifactArgs.annotations {
kv := strings.Split(annotation, "=")