diff --git a/cmd/controller_cmd.go b/cmd/controller_cmd.go index c55ab174..fc3c77c3 100644 --- a/cmd/controller_cmd.go +++ b/cmd/controller_cmd.go @@ -7,13 +7,14 @@ import ( "syscall" "time" - "github.com/zapier/kubechecks/pkg/events" - _ "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/zapier/kubechecks/pkg" + "github.com/zapier/kubechecks/pkg/config" + "github.com/zapier/kubechecks/pkg/events" "github.com/zapier/kubechecks/pkg/server" ) @@ -25,7 +26,7 @@ var ControllerCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { fmt.Println("Starting KubeChecks:", pkg.GitTag, pkg.GitCommit) - server := server.NewServer(&pkg.ServerConfig{ + server := server.NewServer(&config.ServerConfig{ UrlPrefix: viper.GetString("webhook-url-prefix"), WebhookSecret: viper.GetString("webhook-secret"), }) diff --git a/cmd/version.go b/cmd/version.go index d8a5bd8b..c84c98ea 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/zapier/kubechecks/pkg" ) diff --git a/hacks/env-to-docs.go b/hacks/env-to-docs.go index 091cb9dc..1fefcc5d 100644 --- a/hacks/env-to-docs.go +++ b/hacks/env-to-docs.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/zapier/kubechecks/cmd" ) diff --git a/pkg/affected_apps/argocd_matcher.go b/pkg/affected_apps/argocd_matcher.go index e2a1181b..35dd52f4 100644 --- a/pkg/affected_apps/argocd_matcher.go +++ b/pkg/affected_apps/argocd_matcher.go @@ -2,29 +2,55 @@ package affected_apps import ( "context" + "os" "github.com/rs/zerolog/log" - "github.com/zapier/kubechecks/pkg" - "github.com/zapier/kubechecks/pkg/app_directory" + + "github.com/zapier/kubechecks/pkg/config" "github.com/zapier/kubechecks/pkg/repo" ) type ArgocdMatcher struct { - appsDirectory *app_directory.AppDirectory + appsDirectory *config.AppDirectory } -func NewArgocdMatcher(vcsToArgoMap pkg.VcsToArgoMap, repo *repo.Repo) *ArgocdMatcher { - log.Debug().Msgf("looking for %s repos", repo.CloneURL) - repoApps := vcsToArgoMap.GetAppsInRepo(repo.CloneURL) +func NewArgocdMatcher(vcsToArgoMap config.VcsToArgoMap, repo *repo.Repo, repoPath string) (*ArgocdMatcher, error) { + repoApps := getArgocdApps(vcsToArgoMap, repo) + kustomizeAppFiles := getKustomizeApps(vcsToArgoMap, repo, repoPath) + + appDirectory := config.NewAppDirectory(). + Union(repoApps). + Union(kustomizeAppFiles) + + return &ArgocdMatcher{ + appsDirectory: appDirectory, + }, nil +} + +func logCounts(repoApps *config.AppDirectory) { if repoApps == nil { log.Debug().Msg("found no apps") } else { log.Debug().Msgf("found %d apps", repoApps.Count()) } +} - return &ArgocdMatcher{ - appsDirectory: repoApps, - } +func getKustomizeApps(vcsToArgoMap config.VcsToArgoMap, repo *repo.Repo, repoPath string) *config.AppDirectory { + log.Debug().Msgf("creating fs for %s", repoPath) + fs := os.DirFS(repoPath) + log.Debug().Msg("following kustomize apps") + kustomizeAppFiles := vcsToArgoMap.WalkKustomizeApps(repo, fs) + + logCounts(kustomizeAppFiles) + return kustomizeAppFiles +} + +func getArgocdApps(vcsToArgoMap config.VcsToArgoMap, repo *repo.Repo) *config.AppDirectory { + log.Debug().Msgf("looking for %s repos", repo.CloneURL) + repoApps := vcsToArgoMap.GetAppsInRepo(repo.CloneURL) + + logCounts(repoApps) + return repoApps } func (a *ArgocdMatcher) AffectedApps(ctx context.Context, changeList []string) (AffectedItems, error) { diff --git a/pkg/affected_apps/argocd_matcher_test.go b/pkg/affected_apps/argocd_matcher_test.go index 4ce77298..875759b8 100644 --- a/pkg/affected_apps/argocd_matcher_test.go +++ b/pkg/affected_apps/argocd_matcher_test.go @@ -5,22 +5,26 @@ import ( "testing" "github.com/stretchr/testify/require" - "github.com/zapier/kubechecks/pkg" + + "github.com/zapier/kubechecks/pkg/config" repo2 "github.com/zapier/kubechecks/pkg/repo" ) func TestCreateNewMatcherWithNilVcsMap(t *testing.T) { // setup var ( - vcsMap pkg.VcsToArgoMap - repo repo2.Repo + repo repo2.Repo + path string + + vcsMap = config.NewVcsToArgoMap() ) // run test - matcher := NewArgocdMatcher(vcsMap, &repo) + matcher, err := NewArgocdMatcher(vcsMap, &repo, path) + require.NoError(t, err) // verify results - require.Nil(t, matcher.appsDirectory) + require.NotNil(t, matcher.appsDirectory) } func TestFindAffectedAppsWithNilAppsDirectory(t *testing.T) { diff --git a/pkg/affected_apps/best_effort.go b/pkg/affected_apps/best_effort.go index 70fe1c84..978d9ca8 100644 --- a/pkg/affected_apps/best_effort.go +++ b/pkg/affected_apps/best_effort.go @@ -7,16 +7,12 @@ import ( "strings" "github.com/rs/zerolog/log" - "github.com/zapier/kubechecks/pkg/app_directory" -) -// TODO: move this out to config and or in an .kubechecks.yaml as well -var ( - HelmPath = []string{"apps/", "argocd/", "charts/", "manifests/"} - HelmFileTypes = []string{".yaml", ".yml", ".tpl"} - KustomizeSubPaths = []string{"base/", "bases/", "components/", "overlays/", "resources/"} + "github.com/zapier/kubechecks/pkg/config" ) +var KustomizeSubPaths = []string{"base/", "bases/", "components/", "overlays/", "resources/"} + type BestEffort struct { repoName string repoFileList []string @@ -29,7 +25,7 @@ func NewBestEffortMatcher(repoName string, repoFileList []string) *BestEffort { } } -func (b *BestEffort) AffectedApps(ctx context.Context, changeList []string) (AffectedItems, error) { +func (b *BestEffort) AffectedApps(_ context.Context, changeList []string) (AffectedItems, error) { appsMap := make(map[string]string) for _, file := range changeList { @@ -87,9 +83,9 @@ func (b *BestEffort) AffectedApps(ctx context.Context, changeList []string) (Aff } } - var appsSlice []app_directory.ApplicationStub + var appsSlice []config.ApplicationStub for name, path := range appsMap { - appsSlice = append(appsSlice, app_directory.ApplicationStub{Name: name, Path: path}) + appsSlice = append(appsSlice, config.ApplicationStub{Name: name, Path: path}) } return AffectedItems{Applications: appsSlice}, nil @@ -115,9 +111,9 @@ func isKustomizeApp(file string) bool { func isKustomizeBaseComponentsChange(file string) bool { return strings.Contains(file, "base/") || - strings.Contains(file, "bases/") || - strings.Contains(file, "components/") || - strings.Contains(file, "resources/") + strings.Contains(file, "bases/") || + strings.Contains(file, "components/") || + strings.Contains(file, "resources/") } func overlaysDir(file string) string { diff --git a/pkg/affected_apps/best_effort_test.go b/pkg/affected_apps/best_effort_test.go index 0fb3ce82..26c995e8 100644 --- a/pkg/affected_apps/best_effort_test.go +++ b/pkg/affected_apps/best_effort_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zapier/kubechecks/pkg/app_directory" + "github.com/zapier/kubechecks/pkg/config" ) func TestBestEffortMatcher(t *testing.T) { @@ -28,7 +28,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-echo-server", Path: "apps/echo-server/foo-eks-01/"}, }, }, @@ -42,7 +42,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-echo-server", Path: "apps/echo-server/foo-eks-01/"}, {Name: "foo-eks-02-echo-server", Path: "apps/echo-server/foo-eks-02/"}, }, @@ -58,7 +58,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-echo-server", Path: "apps/echo-server/foo-eks-01/"}, {Name: "foo-eks-02-echo-server", Path: "apps/echo-server/foo-eks-02/"}, }, @@ -75,7 +75,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-echo-server", Path: "apps/echo-server/foo-eks-01/"}, {Name: "foo-eks-02-echo-server", Path: "apps/echo-server/foo-eks-02/"}, }, @@ -90,7 +90,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-httpbin", Path: "apps/httpbin/overlays/foo-eks-01/"}, }, }, @@ -104,7 +104,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-httpbin", Path: "apps/httpbin/overlays/foo-eks-01/"}, }, }, @@ -118,7 +118,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-httpbin", Path: "apps/httpbin/overlays/foo-eks-01/"}, }, }, @@ -132,7 +132,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-httpbin", Path: "apps/httpbin/overlays/foo-eks-01/"}, }, }, @@ -146,7 +146,7 @@ func TestBestEffortMatcher(t *testing.T) { repoName: "", }, want: AffectedItems{ - Applications: []app_directory.ApplicationStub{ + Applications: []config.ApplicationStub{ {Name: "foo-eks-01-httpbin", Path: "apps/httpbin/overlays/foo-eks-01/"}, }, }, @@ -180,7 +180,7 @@ func appSetKey(item ApplicationSet) string { return item.Name } -func appStubKey(stub app_directory.ApplicationStub) string { +func appStubKey(stub config.ApplicationStub) string { return stub.Name } diff --git a/pkg/affected_apps/config_matcher.go b/pkg/affected_apps/config_matcher.go index c5e0a2c6..b9b67aec 100644 --- a/pkg/affected_apps/config_matcher.go +++ b/pkg/affected_apps/config_matcher.go @@ -7,9 +7,8 @@ import ( "strings" "github.com/rs/zerolog/log" - "github.com/zapier/kubechecks/pkg/app_directory" - "github.com/zapier/kubechecks/pkg/argo_client" + "github.com/zapier/kubechecks/pkg/config" "github.com/zapier/kubechecks/pkg/repo_config" ) @@ -40,9 +39,9 @@ func (b *ConfigMatcher) AffectedApps(ctx context.Context, changeList []string) ( appSetList = append(appSetList, ApplicationSet{appset.Name}) } - var appsSlice []app_directory.ApplicationStub + var appsSlice []config.ApplicationStub for name, appPath := range appsMap { - appsSlice = append(appsSlice, app_directory.ApplicationStub{Name: name, Path: appPath}) + appsSlice = append(appsSlice, config.ApplicationStub{Name: name, Path: appPath}) } return AffectedItems{Applications: appsSlice, ApplicationSets: appSetList}, nil diff --git a/pkg/affected_apps/matcher.go b/pkg/affected_apps/matcher.go index 599f7de6..49b4318c 100644 --- a/pkg/affected_apps/matcher.go +++ b/pkg/affected_apps/matcher.go @@ -4,11 +4,11 @@ import ( "context" "path" - "github.com/zapier/kubechecks/pkg/app_directory" + "github.com/zapier/kubechecks/pkg/config" ) type AffectedItems struct { - Applications []app_directory.ApplicationStub + Applications []config.ApplicationStub ApplicationSets []ApplicationSet } diff --git a/pkg/commitState.go b/pkg/commitState.go index a1270e5d..f382a387 100644 --- a/pkg/commitState.go +++ b/pkg/commitState.go @@ -24,6 +24,14 @@ func (s CommitState) Emoji() string { } } +func (s CommitState) BareString() string { + text, ok := stateString[s] + if !ok { + text = defaultString + } + return text +} + func (s CommitState) String() string { text, ok := stateString[s] if !ok { diff --git a/pkg/app_directory/app_directory.go b/pkg/config/app_directory.go similarity index 50% rename from pkg/app_directory/app_directory.go rename to pkg/config/app_directory.go index fe3ef524..fd67396e 100644 --- a/pkg/app_directory/app_directory.go +++ b/pkg/config/app_directory.go @@ -1,4 +1,4 @@ -package app_directory +package config import ( "path/filepath" @@ -10,10 +10,12 @@ import ( type ApplicationStub struct { Name, Path string + + IsHelm, IsKustomize bool } type AppDirectory struct { - appPaths map[string][]string // directory -> array of app names + appDirs map[string][]string // directory -> array of app names appFiles map[string][]string // file path -> array of app names appsMap map[string]ApplicationStub // app name -> app stub @@ -21,7 +23,7 @@ type AppDirectory struct { func NewAppDirectory() *AppDirectory { return &AppDirectory{ - appPaths: make(map[string][]string), + appDirs: make(map[string][]string), appFiles: make(map[string][]string), appsMap: make(map[string]ApplicationStub), } @@ -31,7 +33,15 @@ func (d *AppDirectory) Count() int { return len(d.appsMap) } -func (d *AppDirectory) AddApp(app v1alpha1.Application) { +func (d *AppDirectory) Union(other *AppDirectory) *AppDirectory { + var join AppDirectory + join.appsMap = mergeMaps(d.appsMap, other.appsMap, takeFirst[ApplicationStub]) + join.appDirs = mergeMaps(d.appDirs, other.appDirs, mergeLists[string]) + join.appFiles = mergeMaps(d.appFiles, other.appFiles, mergeLists[string]) + return &join +} + +func (d *AppDirectory) ProcessApp(app v1alpha1.Application) { appName := app.Name src := app.Spec.Source @@ -41,19 +51,18 @@ func (d *AppDirectory) AddApp(app v1alpha1.Application) { // common data srcPath := src.Path - d.appsMap[appName] = ApplicationStub{Name: appName, Path: srcPath} - d.appPaths[srcPath] = append(d.appPaths[srcPath], appName) + d.AddAppStub(appName, srcPath, src.IsHelm(), !src.Kustomize.IsZero()) // handle extra helm paths if helm := src.Helm; helm != nil { for _, param := range helm.FileParameters { path := filepath.Join(srcPath, param.Path) - d.appFiles[path] = append(d.appFiles[path], appName) + d.AddFile(appName, path) } for _, valueFilePath := range helm.ValueFiles { path := filepath.Join(srcPath, valueFilePath) - d.appFiles[path] = append(d.appFiles[path], appName) + d.AddFile(appName, path) } } } @@ -66,7 +75,7 @@ func (d *AppDirectory) FindAppsBasedOnChangeList(changeList []string) []Applicat for _, changePath := range changeList { log.Debug().Msgf("change: %s", changePath) - for dir, appNames := range d.appPaths { + for dir, appNames := range d.appDirs { log.Debug().Msgf("- app path: %s", dir) if strings.HasPrefix(changePath, dir) { log.Debug().Msg("dir match!") @@ -98,3 +107,56 @@ func (d *AppDirectory) FindAppsBasedOnChangeList(changeList []string) []Applicat log.Debug().Msgf("matched %d files into %d apps", len(appsMap), len(appsSet)) return appsSlice } + +func (d *AppDirectory) GetApps(filter func(stub ApplicationStub) bool) []ApplicationStub { + var result []ApplicationStub + for _, value := range d.appsMap { + if filter != nil && !filter(value) { + continue + } + result = append(result, value) + } + return result +} + +func (d *AppDirectory) AddAppStub(appName, srcPath string, isHelm, isKustomize bool) { + d.appsMap[appName] = ApplicationStub{ + Name: appName, + Path: srcPath, + IsHelm: isHelm, + IsKustomize: isKustomize, + } + d.AddDir(appName, srcPath) +} + +func (d *AppDirectory) AddDir(appName, path string) { + d.appDirs[path] = append(d.appDirs[path], appName) +} + +func (d *AppDirectory) AddFile(appName, path string) { + d.appFiles[path] = append(d.appFiles[path], appName) +} + +func mergeMaps[T any](first map[string]T, second map[string]T, combine func(T, T) T) map[string]T { + result := make(map[string]T) + for key, value := range first { + result[key] = value + } + for key, value := range second { + exist, ok := result[key] + if ok { + result[key] = combine(exist, value) + } else { + result[key] = value + } + } + return result +} + +func mergeLists[T any](a []T, b []T) []T { + return append(a, b...) +} + +func takeFirst[T any](a, _ T) T { + return a +} diff --git a/pkg/app_directory/app_directory_test.go b/pkg/config/app_directory_test.go similarity index 95% rename from pkg/app_directory/app_directory_test.go rename to pkg/config/app_directory_test.go index 192d226a..093cc6e6 100644 --- a/pkg/app_directory/app_directory_test.go +++ b/pkg/config/app_directory_test.go @@ -1,4 +1,4 @@ -package app_directory +package config import ( "testing" @@ -29,7 +29,7 @@ func TestPathsAreJoinedProperly(t *testing.T) { }, } - rad.AddApp(app1) + rad.ProcessApp(app1) assert.Equal(t, map[string]ApplicationStub{ "test-app": { @@ -39,7 +39,7 @@ func TestPathsAreJoinedProperly(t *testing.T) { }, rad.appsMap) assert.Equal(t, map[string][]string{ "/test1/test2": {"test-app"}, - }, rad.appPaths) + }, rad.appDirs) assert.Equal(t, map[string][]string{ "/test1/test2/one.json": {"test-app"}, "/test1/test2/two.json": {"test-app"}, diff --git a/pkg/config.go b/pkg/config/config.go similarity index 51% rename from pkg/config.go rename to pkg/config/config.go index 19257ddb..c6edec28 100644 --- a/pkg/config.go +++ b/pkg/config/config.go @@ -1,4 +1,4 @@ -package pkg +package config import ( "fmt" @@ -8,7 +8,6 @@ import ( "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" "github.com/rs/zerolog/log" giturls "github.com/whilp/git-urls" - "github.com/zapier/kubechecks/pkg/app_directory" ) type repoURL struct { @@ -42,46 +41,14 @@ func normalizeRepoUrl(s string) (repoURL, error) { return buildNormalizedRepoUrl(r.Host, r.Path), nil } -type VcsToArgoMap struct { - vcsAppStubsByRepo map[repoURL]*app_directory.AppDirectory -} - -func NewVcsToArgoMap() VcsToArgoMap { - return VcsToArgoMap{ - vcsAppStubsByRepo: make(map[repoURL]*app_directory.AppDirectory), - } -} - -func (v2a *VcsToArgoMap) GetAppsInRepo(repoCloneUrl string) *app_directory.AppDirectory { - repoUrl, err := normalizeRepoUrl(repoCloneUrl) - if err != nil { - log.Warn().Err(err).Msgf("failed to parse %s", repoCloneUrl) - } - - return v2a.vcsAppStubsByRepo[repoUrl] -} - func (v2a *VcsToArgoMap) AddApp(app v1alpha1.Application) { if app.Spec.Source == nil { log.Warn().Msgf("%s/%s: no source, skipping", app.Namespace, app.Name) return } - rawRepoUrl := app.Spec.Source.RepoURL - cleanRepoUrl, err := normalizeRepoUrl(rawRepoUrl) - if err != nil { - log.Warn().Err(err).Msgf("%s/%s: failed to parse %s", app.Namespace, app.Name, rawRepoUrl) - return - } - - log.Debug().Msgf("%s/%s: %s => %s", app.Namespace, app.Name, rawRepoUrl, cleanRepoUrl) - - appDirectory := v2a.vcsAppStubsByRepo[cleanRepoUrl] - if appDirectory == nil { - appDirectory = app_directory.NewAppDirectory() - } - appDirectory.AddApp(app) - v2a.vcsAppStubsByRepo[cleanRepoUrl] = appDirectory + appDirectory := v2a.GetAppsInRepo(app.Spec.Source.RepoURL) + appDirectory.ProcessApp(app) } type ServerConfig struct { @@ -92,7 +59,7 @@ type ServerConfig struct { func (cfg *ServerConfig) GetVcsRepos() []string { var repos []string - for key := range cfg.VcsToArgoMap.vcsAppStubsByRepo { + for key := range cfg.VcsToArgoMap.appDirByRepo { repos = append(repos, key.CloneURL()) } return repos diff --git a/pkg/config_test.go b/pkg/config/config_test.go similarity index 98% rename from pkg/config_test.go rename to pkg/config/config_test.go index bbb95a49..33296f17 100644 --- a/pkg/config_test.go +++ b/pkg/config/config_test.go @@ -1,4 +1,4 @@ -package pkg +package config import ( "fmt" diff --git a/pkg/config/vcstoargomap.go b/pkg/config/vcstoargomap.go new file mode 100644 index 00000000..5a1e7d19 --- /dev/null +++ b/pkg/config/vcstoargomap.go @@ -0,0 +1,52 @@ +package config + +import ( + "io/fs" + + "github.com/rs/zerolog/log" + + "github.com/zapier/kubechecks/pkg/repo" +) + +type VcsToArgoMap struct { + appDirByRepo map[repoURL]*AppDirectory +} + +func NewVcsToArgoMap() VcsToArgoMap { + return VcsToArgoMap{ + appDirByRepo: make(map[repoURL]*AppDirectory), + } +} + +func (v2a *VcsToArgoMap) GetAppsInRepo(repoCloneUrl string) *AppDirectory { + repoUrl, err := normalizeRepoUrl(repoCloneUrl) + if err != nil { + log.Warn().Err(err).Msgf("failed to parse %s", repoCloneUrl) + } + + appdir := v2a.appDirByRepo[repoUrl] + if appdir == nil { + appdir = NewAppDirectory() + v2a.appDirByRepo[repoUrl] = appdir + } + + return appdir +} + +func (v2a *VcsToArgoMap) WalkKustomizeApps(repo *repo.Repo, fs fs.FS) *AppDirectory { + var ( + err error + + result = NewAppDirectory() + appdir = v2a.GetAppsInRepo(repo.CloneURL) + apps = appdir.GetApps(nil) + ) + + for _, app := range apps { + if err = walkKustomizeFiles(result, fs, app.Name, app.Path); err != nil { + log.Error().Err(err).Msgf("failed to parse kustomize.yaml in %s", app.Path) + } + } + + return result +} diff --git a/pkg/config/walk_kustomize_files.go b/pkg/config/walk_kustomize_files.go new file mode 100644 index 00000000..c68a6894 --- /dev/null +++ b/pkg/config/walk_kustomize_files.go @@ -0,0 +1,98 @@ +package config + +import ( + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/util/yaml" +) + +type patchJson6902 struct { + Path string `yaml:"path"` +} + +func walkKustomizeFiles(result *AppDirectory, fs fs.FS, appName, dirpath string) error { + kustomizeFile := filepath.Join(dirpath, "kustomization.yaml") + + var ( + err error + + kustomize struct { + Bases []string `yaml:"bases"` + Resources []string `yaml:"resources"` + PatchesJson6902 []patchJson6902 `yaml:"patchesJson6902"` + PatchesStrategicMerge []string `yaml:"patchesStrategicMerge"` + } + ) + + reader, err := fs.Open(kustomizeFile) + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return errors.Wrap(err, "failed to open file") + } + + bytes, err := io.ReadAll(reader) + if err != nil { + return errors.Wrap(err, "failed to read file") + } + + if err = yaml.Unmarshal(bytes, &kustomize); err != nil { + return errors.Wrap(err, "failed to unmarshal file") + } + + for _, resource := range kustomize.Resources { + var relPath string + if len(resource) >= 1 && resource[0] == '/' { + relPath = resource[1:] + } else { + relPath = filepath.Join(dirpath, resource) + } + + file, err := fs.Open(relPath) + if err != nil { + log.Warn().Err(err).Msgf("failed to read %s", relPath) + continue + } + stat, err := file.Stat() + if err != nil { + log.Warn().Err(err).Msgf("failed to stat %s", relPath) + } + + if !stat.IsDir() { + result.AddFile(appName, relPath) + continue + } + + result.AddDir(appName, relPath) + if err = walkKustomizeFiles(result, fs, appName, relPath); err != nil { + log.Warn().Err(err).Msgf("failed to read kustomize.yaml in %s", relPath) + } + } + + for _, basePath := range kustomize.Bases { + relPath := filepath.Join(dirpath, basePath) + result.AddDir(appName, relPath) + if err = walkKustomizeFiles(result, fs, appName, relPath); err != nil { + log.Warn().Err(err).Msgf("failed to read kustomize.yaml in %s", relPath) + } + } + + for _, patchFile := range kustomize.PatchesStrategicMerge { + relPath := filepath.Join(dirpath, patchFile) + result.AddFile(appName, relPath) + } + + for _, patch := range kustomize.PatchesJson6902 { + relPath := filepath.Join(dirpath, patch.Path) + result.AddFile(appName, relPath) + } + + return nil +} diff --git a/pkg/config/walk_kustomize_files_test.go b/pkg/config/walk_kustomize_files_test.go new file mode 100644 index 00000000..0f1352b3 --- /dev/null +++ b/pkg/config/walk_kustomize_files_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKustomizeWalking(t *testing.T) { + var ( + err error + + toBytes = func(s string) []byte { + return []byte(s) + } + + kustomizeBaseName = "kustomize-base" + kustomizeBasePath = "test/base" + + kustomizeApp1Name = "kustomize-app" + kustomizeApp1Path = "test/app" + + kustomizeApp2Name = "kustomize-app-2" + kustomizeApp2Path = "test/app2" + + fs = fstest.MapFS{ + "test/app/kustomization.yaml": { + Data: toBytes(` +bases: +- ../base + +resources: +- file1.yaml +- ./file2.yaml +- ../file3.yaml +- ../overlays/base +- ./overlays/dev +- /common/overlays/prod +`)}, + + "test/app2/kustomization.yaml": { + Data: toBytes(` +patchesStrategicMerge: +- patch.yaml + +patchesJson6902: +- path: patch2.yaml + +resources: +- file1.yaml +- ../overlays/base +- /common/overlays/prod +`)}, + "test/overlays/base/kustomization.yaml": { + Data: toBytes(` +resources: +- some-file1.yaml +- some-file2.yaml +- ../common +`)}, + + "test/overlays/common/kustomization.yaml": {Data: toBytes("hello: world")}, + "test/app/file1.yaml": {Data: toBytes("hello: world")}, + "test/app/file2.yaml": {Data: toBytes("hello: world")}, + "test/app2/file1.yaml": {Data: toBytes("hello: world")}, + "test/file3.yaml": {Data: toBytes("hello: world")}, + "test/app/overlays/dev/kustomization.yaml": {Data: toBytes("hello: world")}, + "common/overlays/prod/kustomization.yaml": {Data: toBytes("hello: world")}, + "test/overlays/base/some-file1.yaml": {Data: toBytes("hello: world")}, + "test/overlays/base/some-file2.yaml": {Data: toBytes("hello: world")}, + } + ) + + appdir := NewAppDirectory() + appdir.AddAppStub(kustomizeApp1Name, kustomizeApp1Path, false, true) + appdir.AddAppStub(kustomizeApp2Name, kustomizeApp2Path, false, true) + appdir.AddAppStub(kustomizeBaseName, kustomizeBasePath, false, true) + + err = walkKustomizeFiles(appdir, fs, kustomizeApp1Name, kustomizeApp1Path) + require.NoError(t, err) + + err = walkKustomizeFiles(appdir, fs, kustomizeApp2Name, kustomizeApp2Path) + require.NoError(t, err) + + assert.Equal(t, map[string][]string{ + "test/app": { + kustomizeApp1Name, + }, + "test/app2": { + kustomizeApp2Name, + }, + "test/app/overlays/dev": { + kustomizeApp1Name, + }, + "test/base": { + kustomizeBaseName, + kustomizeApp1Name, + }, + "test/overlays/base": { + kustomizeApp1Name, + kustomizeApp2Name, + }, + "test/overlays/common": { + kustomizeApp1Name, + kustomizeApp2Name, + }, + "common/overlays/prod": { + kustomizeApp1Name, + kustomizeApp2Name, + }, + }, appdir.appDirs) + + assert.Equal(t, map[string][]string{ + "test/app/file1.yaml": { + kustomizeApp1Name, + }, + "test/app/file2.yaml": { + kustomizeApp1Name, + }, + "test/file3.yaml": { + kustomizeApp1Name, + }, + "test/app2/patch2.yaml": { + kustomizeApp2Name, + }, + "test/app2/patch.yaml": { + kustomizeApp2Name, + }, + "test/overlays/base/some-file1.yaml": { + kustomizeApp1Name, + kustomizeApp2Name, + }, + "test/overlays/base/some-file2.yaml": { + kustomizeApp1Name, + kustomizeApp2Name, + }, + "test/app2/file1.yaml": { + kustomizeApp2Name, + }, + }, appdir.appFiles) +} diff --git a/pkg/events/check.go b/pkg/events/check.go index cf5c6c28..0dbec6c5 100644 --- a/pkg/events/check.go +++ b/pkg/events/check.go @@ -20,6 +20,7 @@ import ( "github.com/zapier/kubechecks/pkg" "github.com/zapier/kubechecks/pkg/affected_apps" "github.com/zapier/kubechecks/pkg/argo_client" + "github.com/zapier/kubechecks/pkg/config" "github.com/zapier/kubechecks/pkg/conftest" "github.com/zapier/kubechecks/pkg/diff" "github.com/zapier/kubechecks/pkg/kubepug" @@ -41,12 +42,12 @@ type CheckEvent struct { affectedItems affected_apps.AffectedItems - cfg *pkg.ServerConfig + cfg *config.ServerConfig } var inFlight int32 -func NewCheckEvent(repo *repo.Repo, client pkg.Client, cfg *pkg.ServerConfig) *CheckEvent { +func NewCheckEvent(repo *repo.Repo, client pkg.Client, cfg *config.ServerConfig) *CheckEvent { ce := &CheckEvent{ cfg: cfg, client: client, @@ -57,9 +58,9 @@ func NewCheckEvent(repo *repo.Repo, client pkg.Client, cfg *pkg.ServerConfig) *C return ce } -// GetRepo gets the repo from a CheckEvent. In normal operations a CheckEvent can only be made by the VCSHookHandler +// getRepo gets the repo from a CheckEvent. In normal operations a CheckEvent can only be made by the VCSHookHandler // As the Repo is built from a webhook payload via the VCSClient, it should always be present. If not, error -func (ce *CheckEvent) GetRepo(ctx context.Context) (*repo.Repo, error) { +func (ce *CheckEvent) getRepo(ctx context.Context) (*repo.Repo, error) { _, span := otel.Tracer("Kubechecks").Start(ctx, "CheckEventGetRepo") defer span.End() var err error @@ -112,7 +113,7 @@ func (ce *CheckEvent) CloneRepoLocal(ctx context.Context) error { func (ce *CheckEvent) MergeIntoTarget(ctx context.Context) error { ctx, span := otel.Tracer("Kubechecks").Start(ctx, "MergeIntoTarget") defer span.End() - gitRepo, err := ce.GetRepo(ctx) + gitRepo, err := ce.getRepo(ctx) if err != nil { return err } @@ -124,7 +125,7 @@ func (ce *CheckEvent) GetListOfChangedFiles(ctx context.Context) ([]string, erro ctx, span := otel.Tracer("Kubechecks").Start(ctx, "CheckEventGetListOfChangedFiles") defer span.End() - gitRepo, err := ce.GetRepo(ctx) + gitRepo, err := ce.getRepo(ctx) if err != nil { return nil, err } @@ -153,7 +154,7 @@ func (ce *CheckEvent) GenerateListOfAffectedApps(ctx context.Context) error { matcher = affected_apps.NewConfigMatcher(cfg) } else if viper.GetBool("monitor-all-applications") { log.Debug().Msg("using an argocd matcher") - matcher = affected_apps.NewArgocdMatcher(ce.cfg.VcsToArgoMap, ce.repo) + matcher, err = affected_apps.NewArgocdMatcher(ce.cfg.VcsToArgoMap, ce.repo, ce.TempWorkingDir) if err != nil { return errors.Wrap(err, "failed to create argocd matcher") } diff --git a/pkg/github_client/client.go b/pkg/github_client/client.go index feb6b157..1ae9d832 100644 --- a/pkg/github_client/client.go +++ b/pkg/github_client/client.go @@ -171,11 +171,28 @@ func buildRepoFromEvent(event *github.PullRequestEvent) *repo.Repo { } } +func toGithubCommitStatus(state pkg.CommitState) *string { + switch state { + case pkg.StateError, pkg.StatePanic: + return pkg.Pointer("error") + case pkg.StateFailure, pkg.StateWarning: + return pkg.Pointer("failure") + case pkg.StateRunning: + return pkg.Pointer("pending") + case pkg.StateSuccess: + return pkg.Pointer("success") + + default: // maybe a different one? panic? + log.Warn().Str("state", state.String()).Msg("failed to convert to a github commit status") + return pkg.Pointer("failure") + } +} + func (c *Client) CommitStatus(ctx context.Context, repo *repo.Repo, status pkg.CommitState) error { log.Info().Str("repo", repo.Name).Str("sha", repo.SHA).Str("status", status.String()).Msg("setting Github commit status") repoStatus, _, err := c.Repositories.CreateStatus(ctx, repo.Owner, repo.Name, repo.SHA, &github.RepoStatus{ - State: pkg.Pointer(status.String()), - Description: pkg.Pointer(status.String()), + State: toGithubCommitStatus(status), + Description: pkg.Pointer(status.BareString()), ID: pkg.Pointer(int64(repo.CheckID)), Context: pkg.Pointer("kubechecks"), }) diff --git a/pkg/message.go b/pkg/message.go index c708e2a3..e047c8f2 100644 --- a/pkg/message.go +++ b/pkg/message.go @@ -57,7 +57,7 @@ func (m *Message) IsSuccess() bool { for _, r := range m.apps { for _, result := range r.results { switch result.State { - case StateSuccess, StateWarning, StateNone: + case StateError, StateFailure, StatePanic, StateWarning: isSuccess = false } } diff --git a/pkg/message_test.go b/pkg/message_test.go index fc08a92a..af7d2de5 100644 --- a/pkg/message_test.go +++ b/pkg/message_test.go @@ -33,3 +33,62 @@ func TestBuildComment(t *testing.T) { should add some important details here `, comment) } + +func TestMessageIsSuccess(t *testing.T) { + t.Run("logic works", func(t *testing.T) { + var ( + message = NewMessage("name", 1, 2) + ctx = context.TODO() + ) + + // no apps mean success + assert.True(t, message.IsSuccess()) + + // one app, no checks = success + message.AddNewApp(ctx, "some-app") + assert.True(t, message.IsSuccess()) + + // one app, one success = success + message.AddToAppMessage(ctx, "some-app", CheckResult{State: StateSuccess}) + assert.True(t, message.IsSuccess()) + + // one app, one success, one failure = failure + message.AddToAppMessage(ctx, "some-app", CheckResult{State: StateFailure}) + assert.False(t, message.IsSuccess()) + + // one app, two successes, one failure = failure + message.AddToAppMessage(ctx, "some-app", CheckResult{State: StateSuccess}) + assert.False(t, message.IsSuccess()) + + // one app, two successes, one failure = failure + message.AddToAppMessage(ctx, "some-app", CheckResult{State: StateSuccess}) + assert.False(t, message.IsSuccess()) + + // two apps: second app's success does not override first app's failure + message.AddNewApp(ctx, "some-other-app") + message.AddToAppMessage(ctx, "some-other-app", CheckResult{State: StateSuccess}) + assert.False(t, message.IsSuccess()) + }) + + testcases := map[CommitState]bool{ + StateNone: true, + StateSuccess: true, + StateRunning: true, + StateWarning: false, + StateFailure: false, + StateError: false, + StatePanic: false, + } + + for state, expected := range testcases { + t.Run(state.BareString(), func(t *testing.T) { + var ( + message = NewMessage("name", 1, 2) + ctx = context.TODO() + ) + message.AddNewApp(ctx, "some-app") + message.AddToAppMessage(ctx, "some-app", CheckResult{State: state}) + assert.Equal(t, expected, message.IsSuccess()) + }) + } +} diff --git a/pkg/server/hook_handler.go b/pkg/server/hook_handler.go index 8e063ca1..0da4dbb1 100644 --- a/pkg/server/hook_handler.go +++ b/pkg/server/hook_handler.go @@ -15,6 +15,7 @@ import ( "go.opentelemetry.io/otel/trace" "github.com/zapier/kubechecks/pkg" + "github.com/zapier/kubechecks/pkg/config" "github.com/zapier/kubechecks/pkg/events" "github.com/zapier/kubechecks/pkg/github_client" "github.com/zapier/kubechecks/pkg/gitlab_client" @@ -25,7 +26,7 @@ import ( type VCSHookHandler struct { client pkg.Client tokenUser string - cfg *pkg.ServerConfig + cfg *config.ServerConfig // labelFilter is a string specifying the required label name to filter merge events by; if empty, all merge events will pass the filter. labelFilter string } @@ -60,7 +61,7 @@ func createVCSClient() (pkg.Client, string) { } -func NewVCSHookHandler(cfg *pkg.ServerConfig) *VCSHookHandler { +func NewVCSHookHandler(cfg *config.ServerConfig) *VCSHookHandler { client, tokenUser := GetVCSClient() labelFilter := viper.GetString("label-filter") diff --git a/pkg/server/server.go b/pkg/server/server.go index cb044b7d..f75553d4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/zapier/kubechecks/pkg" "github.com/zapier/kubechecks/pkg/argo_client" + "github.com/zapier/kubechecks/pkg/config" ) const KubeChecksHooksPathPrefix = "/hooks" @@ -23,10 +24,10 @@ const KubeChecksHooksPathPrefix = "/hooks" var singleton *Server type Server struct { - cfg *pkg.ServerConfig + cfg *config.ServerConfig } -func NewServer(cfg *pkg.ServerConfig) *Server { +func NewServer(cfg *config.ServerConfig) *Server { singleton = &Server{cfg: cfg} return singleton } @@ -128,7 +129,7 @@ func (s *Server) buildVcsToArgoMap() error { ctx := context.TODO() - result := pkg.NewVcsToArgoMap() + result := config.NewVcsToArgoMap() argoClient := argo_client.GetArgoClient() diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 1e39afbd..bffa5541 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -3,47 +3,47 @@ package server import ( "testing" - "github.com/zapier/kubechecks/pkg" + "github.com/zapier/kubechecks/pkg/config" ) func TestHooksPrefix(t *testing.T) { tests := []struct { name string want string - cfg *pkg.ServerConfig + cfg *config.ServerConfig }{ { name: "no-prefix", want: "/hooks", - cfg: &pkg.ServerConfig{ + cfg: &config.ServerConfig{ UrlPrefix: "", }, }, { name: "prefix-no-slash", want: "/test/hooks", - cfg: &pkg.ServerConfig{ + cfg: &config.ServerConfig{ UrlPrefix: "test", }, }, { name: "prefix-trailing-slash", want: "/test/hooks", - cfg: &pkg.ServerConfig{ + cfg: &config.ServerConfig{ UrlPrefix: "test/", }, }, { name: "prefix-leading-slash", want: "/test/hooks", - cfg: &pkg.ServerConfig{ + cfg: &config.ServerConfig{ UrlPrefix: "/test", }, }, { name: "prefix-slash-sandwich", want: "/test/hooks", - cfg: &pkg.ServerConfig{ + cfg: &config.ServerConfig{ UrlPrefix: "/test/", }, },