mirror of
https://github.com/fluxcd/flux2.git
synced 2026-03-29 08:09:25 +00:00
Merge pull request #5724 from rohansood10/feat/resolve-symlinks-5055
Some checks are pending
conformance / conform-kubernetes (1.33.0) (push) Waiting to run
conformance / conform-kubernetes (1.34.1) (push) Waiting to run
conformance / conform-kubernetes (1.35.0) (push) Waiting to run
conformance / conform-k3s (1.33.7) (push) Waiting to run
conformance / conform-k3s (1.34.3) (push) Waiting to run
conformance / conform-k3s (1.35.0) (push) Waiting to run
conformance / conform-openshift (4.20.0-okd) (push) Waiting to run
e2e-bootstrap / e2e-boostrap-github (push) Waiting to run
e2e / e2e-amd64-kubernetes (push) Waiting to run
ossf / scorecard (push) Waiting to run
scan / analyze (push) Waiting to run
update / update-components (push) Waiting to run
Some checks are pending
conformance / conform-kubernetes (1.33.0) (push) Waiting to run
conformance / conform-kubernetes (1.34.1) (push) Waiting to run
conformance / conform-kubernetes (1.35.0) (push) Waiting to run
conformance / conform-k3s (1.33.7) (push) Waiting to run
conformance / conform-k3s (1.34.3) (push) Waiting to run
conformance / conform-k3s (1.35.0) (push) Waiting to run
conformance / conform-openshift (4.20.0-okd) (push) Waiting to run
e2e-bootstrap / e2e-boostrap-github (push) Waiting to run
e2e / e2e-amd64-kubernetes (push) Waiting to run
ossf / scorecard (push) Waiting to run
scan / analyze (push) Waiting to run
update / update-components (push) Waiting to run
Add --resolve-symlinks flag to build and push artifact commands
This commit is contained in:
commit
5fc8afcaaf
3 changed files with 283 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, 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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "=")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue