From 2c8f3467b117e17a449c7590bf93d567b30fca17 Mon Sep 17 00:00:00 2001 From: Raffael Sahli Date: Fri, 7 Oct 2022 13:53:03 +0000 Subject: [PATCH] chore: added tests, move logic to separate package --- .github/workflows/release.yaml | 8 -- Makefile | 2 +- cmd/log.go | 6 +- cmd/main.go | 93 +++++++++------- cosign.pub | 4 - go.mod | 1 + go.sum | 1 + pkg/collector/resource.go | 132 +++++++++++++++++++++++ pkg/collector/resource_test.go | 187 +++++++++++++++++++++++++++++++++ 9 files changed, 377 insertions(+), 57 deletions(-) delete mode 100644 cosign.pub create mode 100644 pkg/collector/resource.go create mode 100644 pkg/collector/resource_test.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d62780f..1291ff9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -44,11 +44,3 @@ jobs: args: release --rm-dist --skip-validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish formula - run: | - mkdir -p Formula - cp dist/gitops-zombies.rb Formula/ - git add Formula/* - git commit Formula -m "publish formula" - git push - diff --git a/Makefile b/Makefile index 710341f..900bb3c 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ fmt: go fmt ./... test: - go test ./... + go test -v ./... vet: go vet ./... diff --git a/cmd/log.go b/cmd/log.go index 7fbf6db..0957c90 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -10,13 +10,9 @@ type stderrLogger struct { stderr io.Writer } -func (l stderrLogger) Infof(format string, a ...interface{}) { - fmt.Fprintln(l.stderr, `►`, fmt.Sprintf(format, a...)) -} - func (l stderrLogger) Debugf(format string, a ...interface{}) { if l.verbose { - fmt.Fprintln(l.stderr, `✚`, fmt.Sprintf(format, a...)) + fmt.Fprintln(l.stderr, `>`, fmt.Sprintf(format, a...)) } } diff --git a/cmd/main.go b/cmd/main.go index e82c8c7..75cdc51 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,6 +7,7 @@ import ( "strings" "sync" + "gihub.com/raffis/flux-zombies/pkg/collector" "github.com/spf13/cobra" "golang.org/x/exp/slices" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -83,6 +84,11 @@ func main() { os.Exit(1) } +var ( + helmReleases []unstructured.Unstructured + kustomizations []unstructured.Unstructured +) + func run(cmd *cobra.Command, args []string) error { if flags.version { fmt.Printf(`{"version":"%s","sha":"%s","date":"%s"}`+"\n", version, commit, date) @@ -114,9 +120,38 @@ func run(cmd *cobra.Command, args []string) error { return err } + helmReleases, err = listResources(context.TODO(), client.Resource(schema.GroupVersionResource{ + Group: "helm.toolkit.fluxcd.io", + Version: "v2beta1", + Resource: "helmreleases", + })) + + if err != nil { + return fmt.Errorf("failed to get helmreleases: %w", err) + } + + kustomizations, err = listResources(context.TODO(), client.Resource(schema.GroupVersionResource{ + Group: "kustomize.toolkit.fluxcd.io", + Version: "v1beta2", + Resource: "kustomizations", + })) + + if err != nil { + return fmt.Errorf("failed to get kustomizations: %w", err) + } + ch := make(chan unstructured.Unstructured) var wgProducer, wgConsumer sync.WaitGroup + discover := collector.NewDiscovery( + logger, + collector.IgnoreOwnedResource(), + collector.IgnoreServiceAccountSecret(), + collector.IgnoreHelmSecret(), + collector.IgnoreIfHelmReleaseFound(helmReleases), + collector.IgnoreIfKustomizationFound(kustomizations), + ) + for _, group := range list { logger.Debugf("discover resource group %#v", group.GroupVersion) gv, err := schema.ParseGroupVersion(group.GroupVersion) @@ -144,6 +179,7 @@ func run(cmd *cobra.Command, args []string) error { resAPI := client.Resource(gvr) + // Skip APIS which do not support list if !slices.Contains(resource.Verbs, "list") { continue } @@ -152,7 +188,10 @@ func run(cmd *cobra.Command, args []string) error { go func(resAPI dynamic.ResourceInterface) { defer wgProducer.Done() - handleResource(context.TODO(), resAPI, ch) + + if err := handleResource(context.TODO(), discover, resAPI, ch); err != nil { + logger.Failuref("could not hanlder resource: %s", err) + } }(resAPI) } } @@ -170,6 +209,18 @@ func run(cmd *cobra.Command, args []string) error { return nil } +func listResources(ctx context.Context, resAPI dynamic.ResourceInterface) (items []unstructured.Unstructured, err error) { + list, err := resAPI.List(ctx, metav1.ListOptions{ + LabelSelector: getLabelSelector(), + }) + + if err != nil { + return items, err + } + + return list.Items, err +} + func getLabelSelector() string { selector := "" if !flags.includeAll { @@ -183,7 +234,7 @@ func getLabelSelector() string { return selector } -func handleResource(ctx context.Context, resAPI dynamic.ResourceInterface, ch chan unstructured.Unstructured) error { +func handleResource(ctx context.Context, discover collector.Interface, resAPI dynamic.ResourceInterface, ch chan unstructured.Unstructured) error { list, err := resAPI.List(ctx, metav1.ListOptions{ LabelSelector: getLabelSelector(), }) @@ -192,43 +243,7 @@ func handleResource(ctx context.Context, resAPI dynamic.ResourceInterface, ch ch return err } - for _, res := range list.Items { - logger.Debugf("validate resource %s %s %s", res.GetName(), res.GetNamespace(), res.GetAPIVersion()) - - if refs := res.GetOwnerReferences(); len(refs) > 0 { - logger.Debugf("ignore resource owned by parent %s %s %s", res.GetName(), res.GetNamespace(), res.GetAPIVersion()) - continue - } - - labels := res.GetLabels() - if helmName, ok := labels[FLUX_HELM_NAME_LABEL]; ok { - if helmNamespace, ok := labels[FLUX_HELM_NAMESPACE_LABEL]; ok { - logger.Debugf("helm %s %s\n", helmName, helmNamespace) - continue - } - } - - if ksName, ok := labels[FLUX_KUSTOMIZE_NAME_LABEL]; ok { - if ksNamespace, ok := labels[FLUX_KUSTOMIZE_NAMESPACE_LABEL]; ok { - logger.Debugf("ks %s %s\n", ksName, ksNamespace) - continue - } - } - - if res.GetKind() == "Secret" && res.GetAPIVersion() == "v1" { - if _, ok := res.GetAnnotations()["kubernetes.io/service-account.name"]; ok { - continue - } - - if v, ok := res.GetLabels()["owner"]; ok && v == "helm" { - continue - } - } - - ch <- res - } - - return nil + return discover.Discover(ctx, list, ch) } func printer(ch chan unstructured.Unstructured) error { diff --git a/cosign.pub b/cosign.pub deleted file mode 100644 index b3087e2..0000000 --- a/cosign.pub +++ /dev/null @@ -1,4 +0,0 @@ ------BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEpuzwwfrQ2wOY7tz2UW5Fxqz6mnv4 -nGyOxjt02WHxagbyYoPrTK7qOWPIqmuYe5QB6gAB9JUY4Yoh11a15Yb+MA== ------END PUBLIC KEY----- diff --git a/go.mod b/go.mod index 29f5c6c..80d1731 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.18 require ( github.com/spf13/cobra v1.5.0 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 + gotest.tools/v3 v3.0.3 k8s.io/apimachinery v0.25.2 k8s.io/cli-runtime v0.25.2 k8s.io/client-go v0.25.2 diff --git a/go.sum b/go.sum index e00452a..127d8e1 100644 --- a/go.sum +++ b/go.sum @@ -514,6 +514,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/collector/resource.go b/pkg/collector/resource.go new file mode 100644 index 0000000..5074f06 --- /dev/null +++ b/pkg/collector/resource.go @@ -0,0 +1,132 @@ +package collector + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + FLUX_HELM_NAME_LABEL = "helm.toolkit.fluxcd.io/name" + FLUX_HELM_NAMESPACE_LABEL = "helm.toolkit.fluxcd.io/namespace" + FLUX_KUSTOMIZE_NAME_LABEL = "kustomize.toolkit.fluxcd.io/name" + FLUX_KUSTOMIZE_NAMESPACE_LABEL = "kustomize.toolkit.fluxcd.io/namespace" +) + +type FilterFunc func(res unstructured.Unstructured, logger logger) bool + +type Interface interface { + Discover(ctx context.Context, list *unstructured.UnstructuredList, ch chan unstructured.Unstructured) error +} + +type logger interface { + Debugf(format string, a ...interface{}) +} + +type discovery struct { + filters []FilterFunc + logger logger +} + +func NewDiscovery(logger logger, filters ...FilterFunc) Interface { + return &discovery{ + logger: logger, + filters: filters, + } +} + +func (d *discovery) Discover(ctx context.Context, list *unstructured.UnstructuredList, ch chan unstructured.Unstructured) error { +RESOURCES: + for _, res := range list.Items { + d.logger.Debugf("validate resource %s %s %s", res.GetName(), res.GetNamespace(), res.GetAPIVersion()) + + for _, filter := range d.filters { + if filter(res, d.logger) { + continue RESOURCES + } + } + + ch <- res + } + + return nil +} + +func IgnoreOwnedResource() FilterFunc { + return func(res unstructured.Unstructured, logger logger) bool { + if refs := res.GetOwnerReferences(); len(refs) > 0 { + logger.Debugf("ignore resource owned by parent %s %s %s", res.GetName(), res.GetNamespace(), res.GetAPIVersion()) + return true + } + + return false + } +} + +func IgnoreServiceAccountSecret() FilterFunc { + return func(res unstructured.Unstructured, logger logger) bool { + if res.GetKind() == "Secret" && res.GetAPIVersion() == "v1" { + if _, ok := res.GetAnnotations()["kubernetes.io/service-account.name"]; ok { + return true + } + } + + return false + } +} + +func IgnoreHelmSecret() FilterFunc { + return func(res unstructured.Unstructured, logger logger) bool { + if res.GetKind() == "Secret" && res.GetAPIVersion() == "v1" { + if v, ok := res.GetLabels()["owner"]; ok && v == "helm" { + return true + } + } + + return false + } +} + +func IgnoreIfHelmReleaseFound(helmReleases []unstructured.Unstructured) FilterFunc { + return func(res unstructured.Unstructured, logger logger) bool { + labels := res.GetLabels() + if helmName, ok := labels[FLUX_HELM_NAME_LABEL]; ok { + if helmNamespace, ok := labels[FLUX_HELM_NAMESPACE_LABEL]; ok { + if hasResource(helmReleases, helmName, helmNamespace) { + return true + } else { + logger.Debugf("helmrelease [%s.%s] not found from resource %s %s %s\n", helmName, helmNamespace, res.GetName(), res.GetNamespace(), res.GetAPIVersion()) + } + } + } + + return false + } +} + +func IgnoreIfKustomizationFound(kustomizations []unstructured.Unstructured) FilterFunc { + return func(res unstructured.Unstructured, logger logger) bool { + labels := res.GetLabels() + if ksName, ok := labels[FLUX_KUSTOMIZE_NAME_LABEL]; ok { + if ksNamespace, ok := labels[FLUX_KUSTOMIZE_NAMESPACE_LABEL]; ok { + if hasResource(kustomizations, ksName, ksNamespace) { + return true + } else { + logger.Debugf("kustomization [%s.%s] not found from resource %s %s %s\n", ksName, ksNamespace, res.GetName(), res.GetNamespace(), res.GetAPIVersion()) + } + } + } + + return false + } +} + +func hasResource(pool []unstructured.Unstructured, name, namespace string) bool { + for _, res := range pool { + if res.GetName() == name && res.GetNamespace() == namespace { + return true + } + } + + return false +} diff --git a/pkg/collector/resource_test.go b/pkg/collector/resource_test.go new file mode 100644 index 0000000..f8e19c2 --- /dev/null +++ b/pkg/collector/resource_test.go @@ -0,0 +1,187 @@ +package collector + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type NullLogger struct { +} + +func (l NullLogger) Debugf(format string, a ...interface{}) { +} + +func (l NullLogger) Failuref(format string, a ...interface{}) { +} + +var dummyLogger = &NullLogger{} + +type test struct { + name string + filters func() []FilterFunc + list func() *unstructured.UnstructuredList + expectedPass int +} + +func TestDisovery(t *testing.T) { + var tests = []test{ + { + name: "A resource which has owner references is skipped", + filters: func() []FilterFunc { + return []FilterFunc{IgnoreOwnedResource()} + }, + list: func() *unstructured.UnstructuredList { + list := &unstructured.UnstructuredList{} + expected := unstructured.Unstructured{} + expected.SetName("resource-without-owner") + + notExpected := unstructured.Unstructured{} + notExpected.SetName("resource-with-owner") + notExpected.SetOwnerReferences([]v1.OwnerReference{ + { + Name: "owner", + }, + }) + + list.Items = append(list.Items, expected, notExpected) + return list + }, + expectedPass: 1, + }, + { + name: "A secret which belongs to a service account is ignored", + filters: func() []FilterFunc { + return []FilterFunc{IgnoreServiceAccountSecret()} + }, + list: func() *unstructured.UnstructuredList { + list := &unstructured.UnstructuredList{} + expected := unstructured.Unstructured{} + expected.SetName("secret") + expected.SetAPIVersion("v1") + expected.SetKind("Secret") + + notExpected := unstructured.Unstructured{} + notExpected.SetName("service-account-secret") + notExpected.SetAPIVersion("v1") + notExpected.SetKind("Secret") + notExpected.SetAnnotations(map[string]string{ + "kubernetes.io/service-account.name": "sa", + }) + + list.Items = append(list.Items, expected, notExpected) + return list + }, + expectedPass: 1, + }, + { + name: "A secret which is labeled as a helm owner is ignored", + filters: func() []FilterFunc { + return []FilterFunc{IgnoreHelmSecret()} + }, + list: func() *unstructured.UnstructuredList { + list := &unstructured.UnstructuredList{} + expected := unstructured.Unstructured{} + expected.SetName("secret") + expected.SetAPIVersion("v1") + expected.SetKind("Secret") + + notExpected := unstructured.Unstructured{} + notExpected.SetName("service-account-secret") + notExpected.SetAPIVersion("v1") + notExpected.SetKind("Secret") + notExpected.SetLabels(map[string]string{ + "owner": "helm", + }) + + list.Items = append(list.Items, expected, notExpected) + return list + }, + expectedPass: 1, + }, + { + name: "A resource which is part of a helmrelease is ignored", + filters: func() []FilterFunc { + helmReleases := &unstructured.UnstructuredList{} + hr := unstructured.Unstructured{} + hr.SetName("release") + hr.SetNamespace("test") + + helmReleases.Items = append(helmReleases.Items, hr) + + return []FilterFunc{IgnoreIfHelmReleaseFound(helmReleases.Items)} + }, + list: func() *unstructured.UnstructuredList { + list := &unstructured.UnstructuredList{} + expected := unstructured.Unstructured{} + expected.SetName("resource") + + alsoExpected := unstructured.Unstructured{} + alsoExpected.SetName("service-account-secret") + alsoExpected.SetLabels(map[string]string{ + FLUX_HELM_NAME_LABEL: "release", + FLUX_HELM_NAMESPACE_LABEL: "not-existing", + }) + + notExpected := unstructured.Unstructured{} + notExpected.SetName("service-account-secret") + notExpected.SetLabels(map[string]string{ + FLUX_HELM_NAME_LABEL: "release", + FLUX_HELM_NAMESPACE_LABEL: "test", + }) + + list.Items = append(list.Items, expected, alsoExpected, notExpected) + return list + }, + expectedPass: 2, + }, + { + name: "A resource which is part of a kustomization is ignored", + filters: func() []FilterFunc { + kustomizations := &unstructured.UnstructuredList{} + ks := unstructured.Unstructured{} + ks.SetName("release") + ks.SetNamespace("test") + + kustomizations.Items = append(kustomizations.Items, ks) + + return []FilterFunc{IgnoreIfKustomizationFound(kustomizations.Items)} + }, + list: func() *unstructured.UnstructuredList { + list := &unstructured.UnstructuredList{} + expected := unstructured.Unstructured{} + expected.SetName("resource") + + alsoExpected := unstructured.Unstructured{} + alsoExpected.SetName("service-account-secret") + alsoExpected.SetLabels(map[string]string{ + FLUX_KUSTOMIZE_NAME_LABEL: "release", + FLUX_KUSTOMIZE_NAMESPACE_LABEL: "not-existing", + }) + + notExpected := unstructured.Unstructured{} + notExpected.SetName("service-account-secret") + notExpected.SetLabels(map[string]string{ + FLUX_KUSTOMIZE_NAME_LABEL: "release", + FLUX_KUSTOMIZE_NAMESPACE_LABEL: "test", + }) + + list.Items = append(list.Items, expected, alsoExpected, notExpected) + return list + }, + expectedPass: 2, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ch := make(chan unstructured.Unstructured, test.expectedPass+1) + discovery := NewDiscovery(dummyLogger, test.filters()...) + discovery.Discover(context.TODO(), test.list(), ch) + assert.Equal(t, test.expectedPass, len(ch)) + }) + } +}