mirror of
https://github.com/fluxcd/flux2.git
synced 2026-02-22 15:41:47 +00:00
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:
parent
7132eb3435
commit
46ad3b2e7a
3 changed files with 186 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "=")
|
||||
|
|
|
|||
Loading…
Reference in a new issue