Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Earthfile
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ docker:
RUN apt update && apt install -y ca-certificates curl git

WORKDIR /tmp
ARG KUSTOMIZE_VERSION=4.5.7
ARG KUSTOMIZE_VERSION=5.6.0
RUN \
curl \
--fail \
Expand Down
186 changes: 161 additions & 25 deletions pkg/argo_client/manifests.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
Expand All @@ -26,6 +25,9 @@ import (
"github.com/zapier/kubechecks/pkg"
"github.com/zapier/kubechecks/pkg/git"
"github.com/zapier/kubechecks/pkg/vcs"
"sigs.k8s.io/kustomize/api/types"
"sigs.k8s.io/kustomize/kyaml/filesys"
"sigs.k8s.io/kustomize/kyaml/yaml"
)

type getRepo func(ctx context.Context, cloneURL string, branchName string) (*git.Repo, error)
Expand Down Expand Up @@ -110,7 +112,7 @@ func (a *ArgoClient) generateManifests(ctx context.Context, app v1alpha1.Applica
clusterCloser, clusterClient := a.GetClusterClient()
defer clusterCloser.Close()

cluster, err := clusterClient.Get(ctx, &cluster.ClusterQuery{Name: app.Spec.Destination.Name, Server: app.Spec.Destination.Server})
clusterData, err := clusterClient.Get(ctx, &cluster.ClusterQuery{Name: app.Spec.Destination.Name, Server: app.Spec.Destination.Server})
if err != nil {
getManifestsFailed.WithLabelValues(app.Name).Inc()
return nil, errors.Wrap(err, "failed to get cluster")
Expand Down Expand Up @@ -214,8 +216,8 @@ func (a *ArgoClient) generateManifests(ctx context.Context, app v1alpha1.Applica
ApplicationSource: &source,
Repos: permittedHelmRepos,
KustomizeOptions: argoSettings.KustomizeOptions,
KubeVersion: cluster.Info.ServerVersion,
ApiVersions: cluster.Info.APIVersions,
KubeVersion: clusterData.Info.ServerVersion,
ApiVersions: clusterData.Info.APIVersions,
HelmRepoCreds: permittedHelmCredentials,
HelmOptions: helmOptions,
TrackingMethod: argoSettings.TrackingMethod,
Expand Down Expand Up @@ -282,6 +284,46 @@ func (a *ArgoClient) generateManifests(ctx context.Context, app v1alpha1.Applica
return response.Manifests, nil
}

func copyDir(fs filesys.FileSystem, src, dst string) error {

if !fs.Exists(dst) {
// First create the destination root directory
if err := os.MkdirAll(dst, 0o777); err != nil {
return errors.Wrapf(err, "failed to create directory %s", dst)
}
}

return filepath.Walk(src, func(srcPath string, info os.FileInfo, err error) error {
if err != nil {
return err
}

// Skip root directory creation (already handled above)
if srcPath == src {
return nil
}

// Get relative path from source root
relPath, err := filepath.Rel(src, srcPath)
if err != nil {
return errors.Wrapf(err, "failed to get relative path for %s", srcPath)
}

dstPath := filepath.Join(dst, relPath)

// Handle directories
if info.IsDir() {
if err := os.MkdirAll(dstPath, 0o777); err != nil {
return errors.Wrapf(err, "failed to create directory %s", dstPath)
}
return nil
}

// Handle regular files
return copyFile(srcPath, dstPath)
})
}

func copyFile(srcpath, dstpath string) error {
dstdir := filepath.Dir(dstpath)
if err := os.MkdirAll(dstdir, 0o777); err != nil {
Expand Down Expand Up @@ -317,33 +359,23 @@ func packageApp(ctx context.Context, source v1alpha1.ApplicationSource, refs []v
return "", errors.Wrap(err, "failed to make temp dir")
}

repoFs := filesys.MakeFsOnDisk()
tempAppDir := filepath.Join(tempDir, source.Path)
appPath := filepath.Join(repo.Directory, source.Path)

// copy app files to the temp dir
if err = filepath.Walk(appPath, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}

if info.IsDir() {
return nil
}
// First copy the entire source directory
if err := copyDir(repoFs, appPath, filepath.Join(tempDir, source.Path)); err != nil {
return "", errors.Wrap(err, "failed to copy base directory")
}

relPath, err := filepath.Rel(appPath, path)
if err != nil {
return errors.Wrapf(err, "failed to calculate rel between %q and %q", appPath, path)
// Process kustomization dependencies
kustPath := filepath.Join(appPath, "kustomization.yaml")
if repoFs.Exists(kustPath) {
// Process kustomization dependencies with repo root
if err := processKustomizationDeps(repoFs, repo.Directory, appPath, tempDir); err != nil {
return "", errors.Wrap(err, "failed to process kustomization dependencies")
}
src := path
dst := filepath.Join(tempAppDir, relPath)
if err := copyFile(src, dst); err != nil {
return errors.Wrapf(err, "failed to %s => %s", src, dst)
}
return nil
}); err != nil {
return "", errors.Wrap(err, "failed to copy files")
}

if source.Helm != nil {
refsByName := make(map[string]v1alpha1.ApplicationSource)
for _, ref := range refs {
Expand Down Expand Up @@ -489,3 +521,107 @@ func sendFile(ctx context.Context, sender sender, file *os.File) error {
func areSameTargetRef(ref1, ref2 string) bool {
return ref1 == ref2
}

func processKustomizationDeps(fs filesys.FileSystem, repoRoot string, basePath string, tempDir string) error {
visited := make(map[string]bool)
return walkKustomizationDeps(fs, repoRoot, basePath, tempDir, visited)
}

// walkKustomizationDeps recursively processes kustomization dependencies and copies them to the temp directory
func walkKustomizationDeps(fs filesys.FileSystem, repoRoot string, currentPath string, tempDir string, visited map[string]bool) error {
kustPath := filepath.Join(currentPath, "kustomization.yaml")
if !fs.Exists(kustPath) {
return nil // No kustomization.yaml in this directory
}

if visited[currentPath] {
return nil // Already processed
}
visited[currentPath] = true

// Parse using official Kustomization type
content, err := fs.ReadFile(kustPath)
if err != nil {
return errors.Wrapf(err, "failed to read kustomization.yaml at %s", currentPath)
}

kust := &types.Kustomization{}
if err := yaml.Unmarshal(content, kust); err != nil {
return errors.Wrapf(err, "failed to parse kustomization.yaml at %s", currentPath)
}

// Collect all dependencies from various fields
var allDeps []string
allDeps = append(allDeps, kust.Resources...)
allDeps = append(allDeps, kust.Components...)
allDeps = append(allDeps, kust.Configurations...)
allDeps = append(allDeps, kust.Crds...)

// Handle replacements
for _, r := range kust.Replacements {
allDeps = append(allDeps, r.Path)
}

// Process all dependencies
for _, dep := range allDeps {
absDepPath := filepath.Clean(filepath.Join(currentPath, dep))

// Skip remote resources
if isRemoteResource(dep) {
continue
}

// Get relative path from repo root
relPath, err := filepath.Rel(repoRoot, absDepPath)
if err != nil {
return errors.Wrapf(err, "failed to get relative path for %s", absDepPath)
}

// check if the file exists in the temp directory
// skip copying if it exists
tempPath := filepath.Join(tempDir, relPath)
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
continue
}

// Copy the dependency
if err := copyDir(fs, absDepPath, tempPath); err != nil {
return errors.Wrapf(err, "failed to copy dependency %s", dep)
}

// Recursively process nested kustomizations (e.g. base/kustomization.yaml imports other resources)
if fs.IsDir(absDepPath) {
if err := walkKustomizationDeps(fs, repoRoot, absDepPath, tempDir, visited); err != nil {
return err
}
}
}

return nil
}

func isRemoteResource(resource string) bool {
// Check for URL schemes
if strings.Contains(resource, "://") {
return true
}

// Check for common Git SSH patterns
if strings.HasPrefix(resource, "git@") {
return true
}

// Check for Kustomize's special GitHub/Bitbucket shorthand
if strings.HasPrefix(resource, "github.com/") ||
strings.HasPrefix(resource, "bitbucket.org/") ||
strings.HasPrefix(resource, "gitlab.com/") {
return true
}

// Check for HTTP(S) URLs without explicit scheme (kustomize allows this)
if strings.HasPrefix(resource, "//") {
return true
}

return false
}
111 changes: 105 additions & 6 deletions pkg/argo_client/manifests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,11 @@ type repoTargetPath struct {

func TestPackageApp(t *testing.T) {
testCases := map[string]struct {
app v1alpha1.Application
pullRequest vcs.PullRequest
filesByRepo map[repoTarget]set[string]
expectedFiles map[string]repoTargetPath
app v1alpha1.Application
pullRequest vcs.PullRequest
filesByRepo map[repoTarget]set[string]
filesByRepoWithContent map[repoTarget]map[string]string
expectedFiles map[string]repoTargetPath
}{
"unused-paths-are-ignored": {
app: v1alpha1.Application{
Expand Down Expand Up @@ -309,6 +310,67 @@ func TestPackageApp(t *testing.T) {
".refs/staging/base.yaml": {"git@github.com:testuser/otherrepo.git", "main", "base.yaml"},
},
},

"kustomize-deps-are-copied": {
pullRequest: vcs.PullRequest{
CloneURL: "git@github.com:testuser/testrepo.git",
BaseRef: "main",
HeadRef: "update-code",
},
app: v1alpha1.Application{
Spec: v1alpha1.ApplicationSpec{
Sources: []v1alpha1.ApplicationSource{
{
RepoURL: "git@github.com:testuser/testrepo.git",
Path: "app1/",
TargetRevision: "main",
},
},
},
},
filesByRepo: map[repoTarget]set[string]{
repoTarget{"git@github.com:testuser/testrepo.git", "main"}: newSet[string](
"app1/resource1.yaml",
"app1/component1.yaml",
"app1/crds/crd1.yaml",
"base/resource2.yaml",
"base/component2.yaml",
"base/crds/crd2.yaml",
),
},
filesByRepoWithContent: map[repoTarget]map[string]string{
repoTarget{"git@github.com:testuser/testrepo.git", "main"}: map[string]string{
"app1/kustomization.yaml": `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- ../base
- resource1.yaml
components:
- component1.yaml
crds:
- crds/crd1.yaml`,
"base/kustomization.yaml": `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- resource2.yaml
components:
- component2.yaml
crds:
- crds/crd2.yaml`,
},
},
expectedFiles: map[string]repoTargetPath{
"app1/kustomization.yaml": {"git@github.com:testuser/testrepo.git", "main", "app1/kustomization.yaml"},
"app1/resource1.yaml": {"git@github.com:testuser/testrepo.git", "main", "app1/resource1.yaml"},
"app1/crds/crd1.yaml": {"git@github.com:testuser/testrepo.git", "main", "app1/crds/crd1.yaml"},
"app1/component1.yaml": {"git@github.com:testuser/testrepo.git", "main", "app1/component1.yaml"},
"base/kustomization.yaml": {"git@github.com:testuser/testrepo.git", "main", "base/kustomization.yaml"},
"base/resource2.yaml": {"git@github.com:testuser/testrepo.git", "main", "base/resource2.yaml"},
"base/crds/crd2.yaml": {"git@github.com:testuser/testrepo.git", "main", "base/crds/crd2.yaml"},
"base/component2.yaml": {"git@github.com:testuser/testrepo.git", "main", "base/component2.yaml"},
},
},
}

for name, tc := range testCases {
Expand All @@ -318,7 +380,7 @@ func TestPackageApp(t *testing.T) {

// write garbage content for files in fake repos, and
// store the tempdirs as repos
repoDirs, fileContentByRepo := createTestRepos(t, tc.filesByRepo)
repoDirs, fileContentByRepo := createTestRepos(t, tc.filesByRepo, tc.filesByRepoWithContent)

// split sources from refs
sources, refs := preprocessSources(&tc.app, tc.pullRequest)
Expand Down Expand Up @@ -376,7 +438,11 @@ func makeExpectedFilesSet(t *testing.T, files map[string]repoTargetPath) set[str
return result
}

func createTestRepos(t *testing.T, filesByRepo map[repoTarget]set[string]) (map[string]*git.Repo, map[repoTargetPath]string) {
func createTestRepos(
t *testing.T,
filesByRepo map[repoTarget]set[string],
filesByRepoWithContent map[repoTarget]map[string]string,
) (map[string]*git.Repo, map[repoTargetPath]string) {
repoDirs := make(map[string]*git.Repo)
fileContents := make(map[repoTargetPath]string)

Expand Down Expand Up @@ -409,6 +475,39 @@ func createTestRepos(t *testing.T, filesByRepo map[repoTarget]set[string]) (map[
}
}

for cloneURL, files := range filesByRepoWithContent {
repoHash := hash(t, cloneURL)
repoDir, ok := repoDirs[repoHash]

var tempDir string
if !ok {
tempDir = filepath.Join(t.TempDir(), repoHash)
repoDirs[repoHash] = &git.Repo{
BranchName: cloneURL.target,
CloneURL: cloneURL.repo,
Directory: tempDir,
}
} else {
tempDir = repoDir.Directory
}

for file, fileContent := range files {
fullfilepath := filepath.Join(tempDir, file)

// ensure the directories exist
filedir := filepath.Dir(fullfilepath)
err = os.MkdirAll(filedir, 0o755)
require.NoError(t, err)

// generate and store content
fileContents[repoTargetPath{cloneURL.repo, cloneURL.target, file}] = fileContent

// write the file to disk
err = os.WriteFile(fullfilepath, []byte(fileContent), 0o600)
require.NoError(t, err)
}
}

return repoDirs, fileContents
}

Expand Down