From 3cc730ed8a43783cc5f0d93de2dfc4281aa5a362 Mon Sep 17 00:00:00 2001 From: Anlan Du Date: Mon, 28 Oct 2024 19:15:18 -0700 Subject: [PATCH] feat: Gator sync test support (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Anlan Du Co-authored-by: alex <8968914+acpana@users.noreply.github.com> Co-authored-by: Sertaç Özercan <852750+sozercan@users.noreply.github.com> Co-authored-by: Rita Zhang --- Makefile | 2 +- apis/addtoscheme_gvkmanifest.go | 10 + .../gvkmanifest/v1alpha1/groupversion_info.go | 20 ++ .../gvkmanifest/v1alpha1/gvkmanifest_types.go | 42 +++ .../v1alpha1/zz_generated.deepcopy.go | 193 ++++++++++++++ cmd/gator/gator.go | 2 + cmd/gator/sync/sync.go | 24 ++ cmd/gator/sync/test/test.go | 73 +++++ .../parser/syncannotationreader.go | 52 +++- pkg/controller/config/config_controller.go | 3 +- pkg/fakes/gvkmanifest.go | 34 +++ pkg/fakes/sync.go | 28 ++ pkg/gator/errors.go | 15 ++ pkg/gator/fixtures/fixtures.go | 74 +++++ ...{read_constraints.go => read_resources.go} | 121 +++++++-- ...traints_test.go => read_resources_test.go} | 0 pkg/gator/sync/test/test.go | 153 +++++++++++ pkg/gator/sync/test/test_test.go | 252 ++++++++++++++++++ pkg/gator/test/test.go | 15 +- pkg/gator/test/test_test.go | 35 +-- website/docs/gator.md | 94 +++++++ 21 files changed, 1173 insertions(+), 69 deletions(-) create mode 100644 apis/addtoscheme_gvkmanifest.go create mode 100644 apis/gvkmanifest/v1alpha1/groupversion_info.go create mode 100644 apis/gvkmanifest/v1alpha1/gvkmanifest_types.go create mode 100644 apis/gvkmanifest/v1alpha1/zz_generated.deepcopy.go create mode 100644 cmd/gator/sync/sync.go create mode 100644 cmd/gator/sync/test/test.go create mode 100644 pkg/fakes/gvkmanifest.go rename pkg/gator/reader/{read_constraints.go => read_resources.go} (60%) rename pkg/gator/reader/{read_constraints_test.go => read_resources_test.go} (100%) create mode 100644 pkg/gator/sync/test/test.go create mode 100644 pkg/gator/sync/test/test_test.go diff --git a/Makefile b/Makefile index a02eb707abb..285b29da4a6 100644 --- a/Makefile +++ b/Makefile @@ -363,7 +363,7 @@ generate: __conversion-gen __controller-gen $(CONTROLLER_GEN) object:headerFile=./hack/boilerplate.go.txt paths="./apis/..." paths="./pkg/..." $(CONVERSION_GEN) \ --output-base=/gatekeeper \ - --input-dirs=./apis/mutations/v1,./apis/mutations/v1beta1,./apis/mutations/v1alpha1,./apis/expansion/v1alpha1,./apis/syncset/v1alpha1 \ + --input-dirs=./apis/mutations/v1,./apis/mutations/v1beta1,./apis/mutations/v1alpha1,./apis/expansion/v1alpha1,./apis/syncset/v1alpha1,./apis/gvkmanifest/v1alpha1 \ --go-header-file=./hack/boilerplate.go.txt \ --output-file-base=zz_generated.conversion diff --git a/apis/addtoscheme_gvkmanifest.go b/apis/addtoscheme_gvkmanifest.go new file mode 100644 index 00000000000..11f47b48238 --- /dev/null +++ b/apis/addtoscheme_gvkmanifest.go @@ -0,0 +1,10 @@ +package apis + +import ( + "github.com/open-policy-agent/gatekeeper/v3/apis/gvkmanifest/v1alpha1" +) + +func init() { + // Register the types with the Scheme so the components can map objects to GroupVersionKinds and back + AddToSchemes = append(AddToSchemes, v1alpha1.AddToScheme) +} diff --git a/apis/gvkmanifest/v1alpha1/groupversion_info.go b/apis/gvkmanifest/v1alpha1/groupversion_info.go new file mode 100644 index 00000000000..c4e4d067b8b --- /dev/null +++ b/apis/gvkmanifest/v1alpha1/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1alpha1 contains API Schema definitions for the GVKManifest v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=gvkmanifest.gatekeeper.sh +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "gvkmanifest.gatekeeper.sh", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/apis/gvkmanifest/v1alpha1/gvkmanifest_types.go b/apis/gvkmanifest/v1alpha1/gvkmanifest_types.go new file mode 100644 index 00000000000..8b7d8a4ce3b --- /dev/null +++ b/apis/gvkmanifest/v1alpha1/gvkmanifest_types.go @@ -0,0 +1,42 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type GVKManifestSpec struct { + Groups map[string]Versions `json:"groups,omitempty"` +} + +type Versions map[string]Kinds + +type Kinds []string + +type Version struct { + Name string `json:"name,omitempty"` + Kinds []string `json:"kinds,omitempty"` +} + +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:object:root=true + +// GVKManifest is the Schema for the GVKManifest API. +type GVKManifest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec GVKManifestSpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true + +// GVKManifestList contains a list of GVKManifests. +type GVKManifestList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GVKManifest `json:"items"` +} + +func init() { + SchemeBuilder.Register(&GVKManifest{}, &GVKManifestList{}) +} diff --git a/apis/gvkmanifest/v1alpha1/zz_generated.deepcopy.go b/apis/gvkmanifest/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..aae278ad6c3 --- /dev/null +++ b/apis/gvkmanifest/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,193 @@ +//go:build !ignore_autogenerated + +/* + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GVKManifest) DeepCopyInto(out *GVKManifest) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GVKManifest. +func (in *GVKManifest) DeepCopy() *GVKManifest { + if in == nil { + return nil + } + out := new(GVKManifest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GVKManifest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GVKManifestList) DeepCopyInto(out *GVKManifestList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GVKManifest, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GVKManifestList. +func (in *GVKManifestList) DeepCopy() *GVKManifestList { + if in == nil { + return nil + } + out := new(GVKManifestList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GVKManifestList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GVKManifestSpec) DeepCopyInto(out *GVKManifestSpec) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make(map[string]Versions, len(*in)) + for key, val := range *in { + var outVal map[string]Kinds + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make(Versions, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make(Kinds, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GVKManifestSpec. +func (in *GVKManifestSpec) DeepCopy() *GVKManifestSpec { + if in == nil { + return nil + } + out := new(GVKManifestSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in Kinds) DeepCopyInto(out *Kinds) { + { + in := &in + *out = make(Kinds, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Kinds. +func (in Kinds) DeepCopy() Kinds { + if in == nil { + return nil + } + out := new(Kinds) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Version) DeepCopyInto(out *Version) { + *out = *in + if in.Kinds != nil { + in, out := &in.Kinds, &out.Kinds + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Version. +func (in *Version) DeepCopy() *Version { + if in == nil { + return nil + } + out := new(Version) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in Versions) DeepCopyInto(out *Versions) { + { + in := &in + *out = make(Versions, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make(Kinds, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Versions. +func (in Versions) DeepCopy() Versions { + if in == nil { + return nil + } + out := new(Versions) + in.DeepCopyInto(out) + return *out +} diff --git a/cmd/gator/gator.go b/cmd/gator/gator.go index be611a9713c..cd0c57e363e 100644 --- a/cmd/gator/gator.go +++ b/cmd/gator/gator.go @@ -4,6 +4,7 @@ import ( "os" "github.com/open-policy-agent/gatekeeper/v3/cmd/gator/expand" + "github.com/open-policy-agent/gatekeeper/v3/cmd/gator/sync" "github.com/open-policy-agent/gatekeeper/v3/cmd/gator/test" "github.com/open-policy-agent/gatekeeper/v3/cmd/gator/verify" "github.com/open-policy-agent/gatekeeper/v3/pkg/version" @@ -15,6 +16,7 @@ var commands = []*cobra.Command{ verify.Cmd, test.Cmd, expand.Cmd, + sync.Cmd, k8sVersion.WithFont("alligator2"), } diff --git a/cmd/gator/sync/sync.go b/cmd/gator/sync/sync.go new file mode 100644 index 00000000000..bc96ec26c16 --- /dev/null +++ b/cmd/gator/sync/sync.go @@ -0,0 +1,24 @@ +package sync + +import ( + "fmt" + + synctest "github.com/open-policy-agent/gatekeeper/v3/cmd/gator/sync/test" + "github.com/spf13/cobra" +) + +var commands = []*cobra.Command{ + synctest.Cmd, +} + +var Cmd = &cobra.Command{ + Use: "sync", + Short: "Manage SyncSets and Config", + Run: func(_ *cobra.Command, _ []string) { + fmt.Println("Usage: gator sync test") + }, +} + +func init() { + Cmd.AddCommand(commands...) +} diff --git a/cmd/gator/sync/test/test.go b/cmd/gator/sync/test/test.go new file mode 100644 index 00000000000..fe68aa70582 --- /dev/null +++ b/cmd/gator/sync/test/test.go @@ -0,0 +1,73 @@ +package test + +import ( + "fmt" + "os" + "strings" + + cmdutils "github.com/open-policy-agent/gatekeeper/v3/cmd/gator/util" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/reader" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/sync/test" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "test", + Short: "Test that the provided SyncSet(s) and/or Config contain the GVKs required by the input templates.", + Run: run, +} + +var ( + flagFilenames []string + flagImages []string + flagOmitGVKManifest bool + flagTempDir string +) + +const ( + flagNameFilename = "filename" + flagNameImage = "image" + flagNameForce = "force-omit-gvk-manifest" + flagNameTempDir = "tempdir" +) + +func init() { + Cmd.Flags().StringArrayVarP(&flagFilenames, flagNameFilename, "n", []string{}, "a file or directory containing Kubernetes resources. Can be specified multiple times.") + Cmd.Flags().StringArrayVarP(&flagImages, flagNameImage, "i", []string{}, "a URL to an OCI image containing policies. Can be specified multiple times.") + Cmd.Flags().BoolVarP(&flagOmitGVKManifest, flagNameForce, "f", false, "Do not require a GVK manifest; if one is not provided, assume all GVKs listed in the requirements "+ + "and configs are supported by the cluster under test. If this assumption isn't true, the given config may cause errors or templates may not be enforced correctly even after passing this test.") + Cmd.Flags().StringVarP(&flagTempDir, flagNameTempDir, "d", "", fmt.Sprintf("Specifies the temporary directory to download and unpack images to, if using the --%s flag. Optional.", flagNameImage)) +} + +func run(_ *cobra.Command, _ []string) { + unstrucs, err := reader.ReadSources(flagFilenames, flagImages, flagTempDir) + if err != nil { + cmdutils.ErrFatalf("reading: %v", err) + } + if len(unstrucs) == 0 { + cmdutils.ErrFatalf("no input data identified") + } + + missingRequirements, templateErrors, err := test.Test(unstrucs, flagOmitGVKManifest) + if err != nil { + cmdutils.ErrFatalf("checking: %v", err) + } + + if len(missingRequirements) > 0 { + cmdutils.ErrFatalf("the following requirements were not met: \n%v", resultsToString(missingRequirements)) + } + + if len(templateErrors) > 0 { + cmdutils.ErrFatalf("encountered errors parsing the following templates: \n%v", resultsToString(templateErrors)) + } + + os.Exit(0) +} + +func resultsToString[T any](results map[string]T) string { + var sb strings.Builder + for template, vals := range results { + sb.WriteString(fmt.Sprintf("%s:\n%v\n", template, vals)) + } + return sb.String() +} diff --git a/pkg/cachemanager/parser/syncannotationreader.go b/pkg/cachemanager/parser/syncannotationreader.go index ae22dac98d8..f296db10561 100644 --- a/pkg/cachemanager/parser/syncannotationreader.go +++ b/pkg/cachemanager/parser/syncannotationreader.go @@ -2,6 +2,7 @@ package parser import ( "encoding/json" + "fmt" "strings" "github.com/open-policy-agent/frameworks/constraint/pkg/core/templates" @@ -13,7 +14,7 @@ import ( const SyncAnnotationName = "metadata.gatekeeper.sh/requires-sync-data" // SyncRequirements contains a list of ANDed requirements, each of which -// contains an expanded set of equivalent (ORed) GVKs. +// contains a GVK equivalence set. type SyncRequirements []GVKEquivalenceSet // GVKEquivalenceSet is a set of GVKs that a template can use @@ -21,14 +22,14 @@ type SyncRequirements []GVKEquivalenceSet type GVKEquivalenceSet map[schema.GroupVersionKind]struct{} // CompactSyncRequirements contains a list of ANDed requirements, each of -// which contains a list of equivalent (ORed) GVKs in compact form. -type CompactSyncRequirements [][]CompactGVKEquivalenceSet +// which contains a list of GVK clauses. +type CompactSyncRequirements [][]GVKClause -// compactGVKEquivalenceSet contains a set of equivalent GVKs, expressed -// in the compact form [groups, versions, kinds] where any combination of -// items from these three fields can be considered a valid equivalent. +// GVKClause contains a set of equivalent GVKs, expressed +// in the form [groups, versions, kinds] where any combination of +// items from these three fields can be considered a valid option. // Used for unmarshalling as this is the form used in requiressync annotations. -type CompactGVKEquivalenceSet struct { +type GVKClause struct { Groups []string `json:"groups"` Versions []string `json:"versions"` Kinds []string `json:"kinds"` @@ -57,12 +58,13 @@ func ReadSyncRequirements(t *templates.ConstraintTemplate) (SyncRequirements, er return SyncRequirements{}, nil } -// Takes a compactGVKSet and expands it into a GVKEquivalenceSet. -func ExpandCompactEquivalenceSet(compactEquivalenceSet CompactGVKEquivalenceSet) GVKEquivalenceSet { +// Takes a GVK Clause and expands it into a GVKEquivalenceSet (to be unioned +// with the GVKEquivalenceSet expansions of the other clauses). +func ExpandGVKClause(clause GVKClause) GVKEquivalenceSet { equivalenceSet := GVKEquivalenceSet{} - for _, group := range compactEquivalenceSet.Groups { - for _, version := range compactEquivalenceSet.Versions { - for _, kind := range compactEquivalenceSet.Kinds { + for _, group := range clause.Groups { + for _, version := range clause.Versions { + for _, kind := range clause.Kinds { equivalenceSet[schema.GroupVersionKind{Group: group, Version: version, Kind: kind}] = struct{}{} } } @@ -76,8 +78,8 @@ func ExpandCompactRequirements(compactSyncRequirements CompactSyncRequirements) syncRequirements := SyncRequirements{} for _, compactRequirement := range compactSyncRequirements { requirement := GVKEquivalenceSet{} - for _, compactEquivalenceSet := range compactRequirement { - for equivalentGVK := range ExpandCompactEquivalenceSet(compactEquivalenceSet) { + for _, clause := range compactRequirement { + for equivalentGVK := range ExpandGVKClause(clause) { requirement[equivalentGVK] = struct{}{} } } @@ -85,3 +87,25 @@ func ExpandCompactRequirements(compactSyncRequirements CompactSyncRequirements) } return syncRequirements, nil } + +func (s GVKEquivalenceSet) String() string { + var sb strings.Builder + for gvk := range s { + if sb.Len() != 0 { + sb.WriteString(" OR ") + } + sb.WriteString(fmt.Sprintf("%s/%s:%s", gvk.Group, gvk.Version, gvk.Kind)) + } + return sb.String() +} + +func (s SyncRequirements) String() string { + var sb strings.Builder + for _, equivSet := range s { + if sb.Len() != 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("- %v", equivSet)) + } + return sb.String() +} diff --git a/pkg/controller/config/config_controller.go b/pkg/controller/config/config_controller.go index afe0cd7adc7..b3f4ec96e43 100644 --- a/pkg/controller/config/config_controller.go +++ b/pkg/controller/config/config_controller.go @@ -193,8 +193,7 @@ func (r *ReconcileConfig) Reconcile(ctx context.Context, request reconcile.Reque if !deleted { for _, entry := range instance.Spec.Sync.SyncOnly { - gvk := schema.GroupVersionKind{Group: entry.Group, Version: entry.Version, Kind: entry.Kind} - gvksToSync = append(gvksToSync, gvk) + gvksToSync = append(gvksToSync, entry.ToGroupVersionKind()) } newExcluder.Add(instance.Spec.Match) diff --git a/pkg/fakes/gvkmanifest.go b/pkg/fakes/gvkmanifest.go new file mode 100644 index 00000000000..af72b93a17b --- /dev/null +++ b/pkg/fakes/gvkmanifest.go @@ -0,0 +1,34 @@ +package fakes + +import ( + gvkmanifestv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/gvkmanifest/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GVKManifestFor returns a GVKManifest resource with the given name for the requested set of resources. +func GVKManifestFor(name string, gvks []schema.GroupVersionKind) *gvkmanifestv1alpha1.GVKManifest { + groups := map[string]gvkmanifestv1alpha1.Versions{} + for _, gvk := range gvks { + if groups[gvk.Group] == nil { + groups[gvk.Group] = gvkmanifestv1alpha1.Versions{} + } + if groups[gvk.Group][gvk.Version] == nil { + groups[gvk.Group][gvk.Version] = gvkmanifestv1alpha1.Kinds{} + } + groups[gvk.Group][gvk.Version] = append(groups[gvk.Group][gvk.Version], gvk.Kind) + } + + return &gvkmanifestv1alpha1.GVKManifest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gvkmanifestv1alpha1.GroupVersion.String(), + Kind: "GVKManifest", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: gvkmanifestv1alpha1.GVKManifestSpec{ + Groups: groups, + }, + } +} diff --git a/pkg/fakes/sync.go b/pkg/fakes/sync.go index 882ae52cee6..e70e08f74f3 100644 --- a/pkg/fakes/sync.go +++ b/pkg/fakes/sync.go @@ -1,6 +1,7 @@ package fakes import ( + configv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/config/v1alpha1" syncsetv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/syncset/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -14,6 +15,10 @@ func SyncSetFor(name string, kinds []schema.GroupVersionKind) *syncsetv1alpha1.S } return &syncsetv1alpha1.SyncSet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: syncsetv1alpha1.GroupVersion.String(), + Kind: "SyncSet", + }, ObjectMeta: metav1.ObjectMeta{ Name: name, }, @@ -22,3 +27,26 @@ func SyncSetFor(name string, kinds []schema.GroupVersionKind) *syncsetv1alpha1.S }, } } + +// ConfigFor returns a config resource with a SyncOnly containing the requested set of resources. +func ConfigFor(kinds []schema.GroupVersionKind) *configv1alpha1.Config { + entries := make([]configv1alpha1.SyncOnlyEntry, len(kinds)) + for i := range kinds { + entries[i] = configv1alpha1.SyncOnlyEntry(kinds[i]) + } + + return &configv1alpha1.Config{ + TypeMeta: metav1.TypeMeta{ + APIVersion: configv1alpha1.GroupVersion.String(), + Kind: "Config", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "config", + }, + Spec: configv1alpha1.ConfigSpec{ + Sync: configv1alpha1.Sync{ + SyncOnly: entries, + }, + }, + } +} diff --git a/pkg/gator/errors.go b/pkg/gator/errors.go index 43d84059085..11d828b8c2a 100644 --- a/pkg/gator/errors.go +++ b/pkg/gator/errors.go @@ -9,10 +9,25 @@ var ( // ErrNotAConstraint indicates the user-indicated file does not contain a // Constraint. ErrNotAConstraint = errors.New("not a Constraint") + // ErrNotAConfig indicates the user-indicated file does not contain a + // Config. + ErrNotAConfig = errors.New("not a Config") + // ErrNotASyncSet indicates the user-indicated file does not contain a + // SyncSet. + ErrNotASyncSet = errors.New("not a SyncSet") + // ErrNotASyncSet indicates the user-indicated file does not contain a + // SyncSet. + ErrNotAGVKManifest = errors.New("not a GVKManifest") // ErrAddingTemplate indicates a problem instantiating a Suite's ConstraintTemplate. ErrAddingTemplate = errors.New("adding template") // ErrAddingConstraint indicates a problem instantiating a Suite's Constraint. ErrAddingConstraint = errors.New("adding constraint") + // ErrAddingSyncSet indicates a problem instantiating a user-indicated SyncSet. + ErrAddingSyncSet = errors.New("adding syncset") + // ErrAddingGVKManifest indicates a problem instantiating a user-indicated GVKManifest. + ErrAddingGVKManifest = errors.New("adding gvkmanifest") + // ErrAddingConfig indicates a problem instantiating a user-indicated Config. + ErrAddingConfig = errors.New("adding config") // ErrInvalidSuite indicates a Suite does not define the required fields. ErrInvalidSuite = errors.New("invalid Suite") // ErrCreatingClient indicates an error instantiating the Client which compiles diff --git a/pkg/gator/fixtures/fixtures.go b/pkg/gator/fixtures/fixtures.go index 8089ce3cb34..220bb63487d 100644 --- a/pkg/gator/fixtures/fixtures.go +++ b/pkg/gator/fixtures/fixtures.go @@ -352,6 +352,16 @@ metadata: name: k8suniqueserviceselector annotations: description: Requires Services to have unique selectors within a namespace. + metadata.gatekeeper.sh/requires-sync-data: | + "[ + [ + { + "groups": [""], + "versions": ["v1"], + "kinds": ["Service"] + } + ] + ]" spec: crd: spec: @@ -394,6 +404,70 @@ spec: } ` + TemplateReferentialMultEquivSets = ` +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8suniqueingresshost + annotations: + metadata.gatekeeper.sh/title: "Unique Ingress Host" + metadata.gatekeeper.sh/version: 1.0.3 + metadata.gatekeeper.sh/requires-sync-data: | + "[ + [ + { + "groups": ["extensions"], + "versions": ["v1beta1"], + "kinds": ["Ingress"] + }, + { + "groups": ["networking.k8s.io"], + "versions": ["v1beta1", "v1"], + "kinds": ["Ingress"] + } + ] + ]" +` + + TemplateReferentialMultReqs = ` +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8suniqueingresshostmultireq + annotations: + metadata.gatekeeper.sh/title: "Unique Ingress Host" + metadata.gatekeeper.sh/version: 1.0.3 + metadata.gatekeeper.sh/requires-sync-data: | + "[ + [ + { + "groups": [""], + "versions": ["v1"], + "kinds": ["Pod"] + } + ], + [ + { + "groups": ["networking.k8s.io"], + "versions": ["v1beta1", "v1"], + "kinds": ["Ingress"] + } + ] + ]" +` + + TemplateReferentialBadAnnotation = ` +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8suniqueingresshostbadannotation + annotations: + metadata.gatekeeper.sh/title: "Unique Ingress Host" + metadata.gatekeeper.sh/version: 1.0.3 + metadata.gatekeeper.sh/requires-sync-data: | + "{}" +` + ConstraintReferential = ` apiVersion: constraints.gatekeeper.sh/v1beta1 kind: K8sUniqueServiceSelector diff --git a/pkg/gator/reader/read_constraints.go b/pkg/gator/reader/read_resources.go similarity index 60% rename from pkg/gator/reader/read_constraints.go rename to pkg/gator/reader/read_resources.go index 09623dbc695..b5ec0260f5b 100644 --- a/pkg/gator/reader/read_constraints.go +++ b/pkg/gator/reader/read_resources.go @@ -10,6 +10,9 @@ import ( templatesv1 "github.com/open-policy-agent/frameworks/constraint/pkg/apis/templates/v1" "github.com/open-policy-agent/frameworks/constraint/pkg/core/templates" + configv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/config/v1alpha1" + gvkmanifestv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/gvkmanifest/v1alpha1" + syncsetv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/syncset/v1alpha1" "github.com/open-policy-agent/gatekeeper/v3/pkg/gator" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -58,7 +61,7 @@ func ReadUnstructureds(bytes []byte) ([]*unstructured.Unstructured, error) { continue } - u, err := readUnstructured([]byte(split)) + u, err := ReadUnstructured([]byte(split)) if err != nil { return nil, fmt.Errorf("%w: %w", gator.ErrInvalidYAML, err) } @@ -69,7 +72,7 @@ func ReadUnstructureds(bytes []byte) ([]*unstructured.Unstructured, error) { return result, nil } -func readUnstructured(bytes []byte) (*unstructured.Unstructured, error) { +func ReadUnstructured(bytes []byte) (*unstructured.Unstructured, error) { u := &unstructured.Unstructured{ Object: make(map[string]interface{}), } @@ -89,7 +92,7 @@ func ReadTemplate(scheme *runtime.Scheme, f fs.FS, path string) (*templates.Cons return nil, fmt.Errorf("reading ConstraintTemplate from %q: %w", path, err) } - u, err := readUnstructured(bytes) + u, err := ReadUnstructured(bytes) if err != nil { return nil, fmt.Errorf("%w: parsing ConstraintTemplate YAML from %q: %w", gator.ErrAddingTemplate, path, err) } @@ -101,21 +104,14 @@ func ReadTemplate(scheme *runtime.Scheme, f fs.FS, path string) (*templates.Cons return template, nil } -// TODO (https://github.com/open-policy-agent/gatekeeper/issues/1779): Move -// this function into a location that makes it more obviously a shared resource -// between `gator test` and `gator verify` - -// ToTemplate converts an unstructured template into a versionless ConstraintTemplate struct. -func ToTemplate(scheme *runtime.Scheme, u *unstructured.Unstructured) (*templates.ConstraintTemplate, error) { +// ToStructured converts an unstructured object into an object with the schema defined +// by u's group, version, and kind. +func ToStructured(scheme *runtime.Scheme, u *unstructured.Unstructured) (runtime.Object, error) { gvk := u.GroupVersionKind() - if gvk.Group != templatesv1.SchemeGroupVersion.Group || gvk.Kind != "ConstraintTemplate" { - return nil, fmt.Errorf("%w", gator.ErrNotATemplate) - } - t, err := scheme.New(gvk) if err != nil { // The type isn't registered in the scheme. - return nil, fmt.Errorf("%w: %w", gator.ErrAddingTemplate, err) + return nil, err } // YAML parsing doesn't properly handle ObjectMeta, so we must @@ -127,6 +123,19 @@ func ToTemplate(scheme *runtime.Scheme, u *unstructured.Unstructured) (*template return nil, fmt.Errorf("calling unstructured.MarshalJSON(): %w", err) } err = json.Unmarshal(jsonBytes, t) + if err != nil { + return nil, err + } + return t, nil +} + +// ToTemplate converts an unstructured template into a versionless ConstraintTemplate struct. +func ToTemplate(scheme *runtime.Scheme, u *unstructured.Unstructured) (*templates.ConstraintTemplate, error) { + if u.GroupVersionKind().Group != templatesv1.SchemeGroupVersion.Group || u.GroupVersionKind().Kind != "ConstraintTemplate" { + return nil, fmt.Errorf("%w", gator.ErrNotATemplate) + } + + t, err := ToStructured(scheme, u) if err != nil { return nil, fmt.Errorf("%w: %w", gator.ErrAddingTemplate, err) } @@ -146,6 +155,63 @@ func ToTemplate(scheme *runtime.Scheme, u *unstructured.Unstructured) (*template return template, nil } +// ToSyncSet converts an unstructured SyncSet into a SyncSet struct. +func ToSyncSet(scheme *runtime.Scheme, u *unstructured.Unstructured) (*syncsetv1alpha1.SyncSet, error) { + if u.GroupVersionKind().Group != syncsetv1alpha1.GroupVersion.Group || u.GroupVersionKind().Kind != "SyncSet" { + return nil, fmt.Errorf("%w", gator.ErrNotASyncSet) + } + + s, err := ToStructured(scheme, u) + if err != nil { + return nil, fmt.Errorf("%w: %w", gator.ErrAddingSyncSet, err) + } + + syncSet, isSyncSet := s.(*syncsetv1alpha1.SyncSet) + if !isSyncSet { + return nil, fmt.Errorf("%w: %T", gator.ErrAddingSyncSet, syncSet) + } + + return syncSet, nil +} + +// ToConfig converts an unstructured Config into a Config struct. +func ToConfig(scheme *runtime.Scheme, u *unstructured.Unstructured) (*configv1alpha1.Config, error) { + if u.GroupVersionKind().Group != configv1alpha1.GroupVersion.Group || u.GroupVersionKind().Kind != "Config" { + return nil, fmt.Errorf("%w", gator.ErrNotAConfig) + } + + s, err := ToStructured(scheme, u) + if err != nil { + return nil, fmt.Errorf("%w: %w", gator.ErrAddingConfig, err) + } + + config, isConfig := s.(*configv1alpha1.Config) + if !isConfig { + return nil, fmt.Errorf("%w: %T", gator.ErrAddingConfig, config) + } + + return config, nil +} + +// ToGVKManifest converts an unstructured GVKManifest into a GVKManifest struct. +func ToGVKManifest(scheme *runtime.Scheme, u *unstructured.Unstructured) (*gvkmanifestv1alpha1.GVKManifest, error) { + if u.GroupVersionKind().Group != gvkmanifestv1alpha1.GroupVersion.Group || u.GroupVersionKind().Kind != "GVKManifest" { + return nil, fmt.Errorf("%w", gator.ErrNotAGVKManifest) + } + + s, err := ToStructured(scheme, u) + if err != nil { + return nil, fmt.Errorf("%w: %w", gator.ErrAddingGVKManifest, err) + } + + gvkManifest, isGVKManifest := s.(*gvkmanifestv1alpha1.GVKManifest) + if !isGVKManifest { + return nil, fmt.Errorf("%w: %T", gator.ErrAddingGVKManifest, gvkManifest) + } + + return gvkManifest, nil +} + // ReadObject reads a file from the filesystem abstraction at the specified // path, and returns an unstructured.Unstructured object if the file can be // successfully unmarshalled. @@ -155,7 +221,7 @@ func ReadObject(f fs.FS, path string) (*unstructured.Unstructured, error) { return nil, fmt.Errorf("reading Constraint from %q: %w", path, err) } - u, err := readUnstructured(bytes) + u, err := ReadUnstructured(bytes) if err != nil { return nil, fmt.Errorf("%w: parsing Constraint from %q: %w", gator.ErrAddingConstraint, path, err) } @@ -207,3 +273,28 @@ func ReadK8sResources(r io.Reader) ([]*unstructured.Unstructured, error) { return objs, nil } + +func IsTemplate(u *unstructured.Unstructured) bool { + gvk := u.GroupVersionKind() + return gvk.Group == templatesv1.SchemeGroupVersion.Group && gvk.Kind == "ConstraintTemplate" +} + +func IsConfig(u *unstructured.Unstructured) bool { + gvk := u.GroupVersionKind() + return gvk.Group == configv1alpha1.GroupVersion.Group && gvk.Kind == "Config" +} + +func IsSyncSet(u *unstructured.Unstructured) bool { + gvk := u.GroupVersionKind() + return gvk.Group == syncsetv1alpha1.GroupVersion.Group && gvk.Kind == "SyncSet" +} + +func IsGVKManifest(u *unstructured.Unstructured) bool { + gvk := u.GroupVersionKind() + return gvk.Group == gvkmanifestv1alpha1.GroupVersion.Group && gvk.Kind == "GVKManifest" +} + +func IsConstraint(u *unstructured.Unstructured) bool { + gvk := u.GroupVersionKind() + return gvk.Group == "constraints.gatekeeper.sh" +} diff --git a/pkg/gator/reader/read_constraints_test.go b/pkg/gator/reader/read_resources_test.go similarity index 100% rename from pkg/gator/reader/read_constraints_test.go rename to pkg/gator/reader/read_resources_test.go diff --git a/pkg/gator/sync/test/test.go b/pkg/gator/sync/test/test.go new file mode 100644 index 00000000000..960f8705fa5 --- /dev/null +++ b/pkg/gator/sync/test/test.go @@ -0,0 +1,153 @@ +package test + +import ( + "fmt" + + cfapis "github.com/open-policy-agent/frameworks/constraint/pkg/apis" + "github.com/open-policy-agent/frameworks/constraint/pkg/core/templates" + gkapis "github.com/open-policy-agent/gatekeeper/v3/apis" + gvkmanifestv1alpha1 "github.com/open-policy-agent/gatekeeper/v3/apis/gvkmanifest/v1alpha1" + "github.com/open-policy-agent/gatekeeper/v3/pkg/cachemanager/aggregator" + "github.com/open-policy-agent/gatekeeper/v3/pkg/cachemanager/parser" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/reader" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var scheme *runtime.Scheme + +func init() { + scheme = runtime.NewScheme() + err := cfapis.AddToScheme(scheme) + if err != nil { + panic(err) + } + err = gkapis.AddToScheme(scheme) + if err != nil { + panic(err) + } +} + +// Reads a list of unstructured objects and a string containing supported GVKs and +// outputs a set of missing sync requirements per template and ingestion problems per template. +func Test(unstrucs []*unstructured.Unstructured, omitGVKManifest bool) (map[string]parser.SyncRequirements, map[string]error, error) { + gvkAggregator := aggregator.NewGVKAggregator() + templates := map[*templates.ConstraintTemplate]parser.SyncRequirements{} + templateErrs := map[string]error{} + hasConfig := false + var gvkManifest *gvkmanifestv1alpha1.GVKManifest + var err error + + for _, obj := range unstrucs { + switch { + case reader.IsSyncSet(obj): + syncSet, err := reader.ToSyncSet(scheme, obj) + if err != nil { + return nil, nil, fmt.Errorf("converting unstructured %q to syncset: %w", obj.GetName(), err) + } + key := aggregator.Key{Source: "syncset", ID: syncSet.ObjectMeta.Name} + gvks := make([]schema.GroupVersionKind, len(syncSet.Spec.GVKs)) + for _, gvkEntry := range syncSet.Spec.GVKs { + gvk := schema.GroupVersionKind{ + Group: gvkEntry.Group, + Version: gvkEntry.Version, + Kind: gvkEntry.Kind, + } + gvks = append(gvks, gvk) + gvkAggregator.Upsert(key, gvks) + } + case reader.IsConfig(obj): + if hasConfig { + return nil, nil, fmt.Errorf("multiple configs found; Config is a singleton resource") + } + config, err := reader.ToConfig(scheme, obj) + if err != nil { + return nil, nil, fmt.Errorf("converting unstructured %q to config: %w", obj.GetName(), err) + } + hasConfig = true + + key := aggregator.Key{Source: "config", ID: config.ObjectMeta.Name} + gvks := make([]schema.GroupVersionKind, len(config.Spec.Sync.SyncOnly)) + for _, syncOnlyEntry := range config.Spec.Sync.SyncOnly { + gvk := schema.GroupVersionKind{ + Group: syncOnlyEntry.Group, + Version: syncOnlyEntry.Version, + Kind: syncOnlyEntry.Kind, + } + gvks = append(gvks, gvk) + gvkAggregator.Upsert(key, gvks) + } + case reader.IsTemplate(obj): + templ, err := reader.ToTemplate(scheme, obj) + if err != nil { + templateErrs[obj.GetName()] = err + continue + } + syncRequirements, err := parser.ReadSyncRequirements(templ) + if err != nil { + templateErrs[templ.GetName()] = err + continue + } + templates[templ] = syncRequirements + case reader.IsGVKManifest(obj): + if gvkManifest == nil { + gvkManifest, err = reader.ToGVKManifest(scheme, obj) + if err != nil { + return nil, nil, fmt.Errorf("converting unstructured %q to gvkmanifest: %w", obj.GetName(), err) + } + } else { + return nil, nil, fmt.Errorf("multiple GVK manifests found; please provide one manifest enumerating the GVKs supported by the cluster") + } + default: + fmt.Printf("skipping unstructured %q because it is not a syncset, config, gvk manifest, or template\n", obj.GetName()) + } + } + + // Don't assess requirement fulfillment if there was an error parsing any of the templates. + if len(templateErrs) != 0 { + return nil, templateErrs, nil + } + + supportedGVKs := map[schema.GroupVersionKind]struct{}{} + // Crosscheck synced gvks with supported gvks. + if gvkManifest == nil { + if !omitGVKManifest { + return nil, nil, fmt.Errorf("no GVK manifest found; please provide a manifest enumerating the GVKs supported by the cluster") + } + fmt.Print("ignoring absence of supported GVK manifest due to --force-omit-gvk-manifest flag; will assume all synced GVKs are supported by cluster\n") + } else { + for group, versions := range gvkManifest.Spec.Groups { + for version, kinds := range versions { + for _, kind := range kinds { + gvk := schema.GroupVersionKind{ + Group: group, + Version: version, + Kind: kind, + } + supportedGVKs[gvk] = struct{}{} + } + } + } + } + + missingReqs := map[string]parser.SyncRequirements{} + + for templ, reqs := range templates { + // Fetch syncrequirements from template + for _, requirement := range reqs { + requirementMet := false + for gvk := range requirement { + if gvkAggregator.IsPresent(gvk) { + if _, isPresent := supportedGVKs[gvk]; isPresent || omitGVKManifest { + requirementMet = true + } + } + } + if !requirementMet { + missingReqs[templ.Name] = append(missingReqs[templ.Name], requirement) + } + } + } + return missingReqs, nil, nil +} diff --git a/pkg/gator/sync/test/test_test.go b/pkg/gator/sync/test/test_test.go new file mode 100644 index 00000000000..791abeb639e --- /dev/null +++ b/pkg/gator/sync/test/test_test.go @@ -0,0 +1,252 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/gatekeeper/v3/pkg/cachemanager/parser" + "github.com/open-policy-agent/gatekeeper/v3/pkg/fakes" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/fixtures" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/reader" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" +) + +func TestTest(t *testing.T) { + DeploymentGVK := schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + } + ServiceGVK := schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + } + IngressGVK := schema.GroupVersionKind{ + Group: "networking.k8s.io", + Version: "v1", + Kind: "Ingress", + } + PodGVK := schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + } + + tcs := []struct { + name string + inputs []string + omitManifest bool + wantReqs map[string]parser.SyncRequirements + }{ + { + name: "basic req unfulfilled", + inputs: []string{ + fixtures.TemplateReferential, + }, + omitManifest: true, + wantReqs: map[string]parser.SyncRequirements{ + "k8suniqueserviceselector": { + parser.GVKEquivalenceSet{ + ServiceGVK: struct{}{}, + }, + }, + }, + }, + { + name: "basic req fulfilled by config", + inputs: []string{ + fixtures.TemplateReferential, + toYAMLString(t, fakes.ConfigFor([]schema.GroupVersionKind{ServiceGVK, DeploymentGVK})), + }, + omitManifest: true, + wantReqs: map[string]parser.SyncRequirements{}, + }, + { + name: "basic req fulfilled by config and supported by cluster", + inputs: []string{ + fixtures.TemplateReferential, + toYAMLString(t, fakes.ConfigFor([]schema.GroupVersionKind{ServiceGVK, DeploymentGVK})), + toYAMLString(t, fakes.GVKManifestFor("gvkmanifest", []schema.GroupVersionKind{ServiceGVK})), + }, + wantReqs: map[string]parser.SyncRequirements{}, + }, + { + name: "basic req fulfilled by config but not supported by cluster", + inputs: []string{ + fixtures.TemplateReferential, + toYAMLString(t, fakes.ConfigFor([]schema.GroupVersionKind{ServiceGVK, DeploymentGVK})), + toYAMLString(t, fakes.GVKManifestFor("gvkmanifest", []schema.GroupVersionKind{DeploymentGVK})), + }, + wantReqs: map[string]parser.SyncRequirements{ + "k8suniqueserviceselector": { + parser.GVKEquivalenceSet{ + ServiceGVK: struct{}{}, + }, + }, + }, + }, + { + name: "multi equivalentset req fulfilled by syncset", + inputs: []string{ + fixtures.TemplateReferentialMultEquivSets, + toYAMLString(t, fakes.SyncSetFor("syncset", []schema.GroupVersionKind{DeploymentGVK, IngressGVK})), + }, + omitManifest: true, + wantReqs: map[string]parser.SyncRequirements{}, + }, + { + name: "multi requirement, one req fulfilled by syncset", + inputs: []string{ + fixtures.TemplateReferentialMultReqs, + toYAMLString(t, fakes.SyncSetFor("syncset", []schema.GroupVersionKind{DeploymentGVK, IngressGVK})), + }, + omitManifest: true, + wantReqs: map[string]parser.SyncRequirements{ + "k8suniqueingresshostmultireq": { + parser.GVKEquivalenceSet{ + PodGVK: struct{}{}, + }, + }, + }, + }, + { + name: "multiple templates, syncset and config", + inputs: []string{ + fixtures.TemplateReferential, + fixtures.TemplateReferentialMultEquivSets, + fixtures.TemplateReferentialMultReqs, + toYAMLString(t, fakes.ConfigFor([]schema.GroupVersionKind{ServiceGVK, DeploymentGVK})), + toYAMLString(t, fakes.SyncSetFor("syncset", []schema.GroupVersionKind{DeploymentGVK, IngressGVK})), + }, + omitManifest: true, + wantReqs: map[string]parser.SyncRequirements{ + "k8suniqueingresshostmultireq": { + parser.GVKEquivalenceSet{ + PodGVK: struct{}{}, + }, + }, + }, + }, + { + name: "no data of any kind", + inputs: []string{}, + omitManifest: true, + wantReqs: map[string]parser.SyncRequirements{}, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + // convert the test resources to unstructureds + var objs []*unstructured.Unstructured + for _, input := range tc.inputs { + u, err := reader.ReadUnstructured([]byte(input)) + require.NoError(t, err) + objs = append(objs, u) + } + + gotReqs, gotErrs, err := Test(objs, tc.omitManifest) + + require.NoError(t, err) + + if gotErrs != nil { + t.Errorf("got unexpected errors: %v", gotErrs) + } + + if diff := cmp.Diff(tc.wantReqs, gotReqs); diff != "" { + t.Errorf("diff in missingRequirements objects (-want +got):\n%s", diff) + } + }) + } +} + +func TestTest_Errors(t *testing.T) { + DeploymentGVK := schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + } + ServiceGVK := schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + } + tcs := []struct { + name string + inputs []string + omitManifest bool + wantErrs map[string]error + err error + }{ + { + name: "one template having error stops requirement evaluation", + inputs: []string{ + fixtures.TemplateReferential, + fixtures.TemplateReferentialBadAnnotation, + }, + omitManifest: true, + wantErrs: map[string]error{ + "k8suniqueingresshostbadannotation": fmt.Errorf("json: cannot unmarshal object into Go value of type parser.CompactSyncRequirements"), + }, + }, + { + name: "error if manifest not provided and omitGVKManifest not set", + inputs: []string{ + fixtures.TemplateReferential, + toYAMLString(t, fakes.ConfigFor([]schema.GroupVersionKind{ServiceGVK, DeploymentGVK})), + }, + wantErrs: map[string]error{}, + err: fmt.Errorf("no GVK manifest found; please provide a manifest enumerating the GVKs supported by the cluster"), + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + // convert the test resources to unstructureds + var objs []*unstructured.Unstructured + for _, input := range tc.inputs { + u, err := reader.ReadUnstructured([]byte(input)) + require.NoError(t, err) + objs = append(objs, u) + } + + gotReqs, gotErrs, err := Test(objs, tc.omitManifest) + + if tc.err != nil { + if tc.err.Error() != err.Error() { + t.Errorf("error mismatch: want %v, got %v", tc.err, err) + } + } else if err != nil { + require.NoError(t, err) + } + + if gotReqs != nil { + t.Errorf("got unexpected requirements: %v", gotReqs) + } + + for key, wantErr := range tc.wantErrs { + if gotErr, ok := gotErrs[key]; ok { + if wantErr.Error() != gotErr.Error() { + t.Errorf("error mismatch for %s: want %v, got %v", key, wantErr, gotErr) + } + } else { + t.Errorf("missing error for %s", key) + } + } + }) + } +} + +func toYAMLString(t *testing.T, obj runtime.Object) string { + t.Helper() + + yaml, err := yaml.Marshal(obj) + require.NoError(t, err) + + return string(yaml) +} diff --git a/pkg/gator/test/test.go b/pkg/gator/test/test.go index c7710a60351..355cfb79dfd 100644 --- a/pkg/gator/test/test.go +++ b/pkg/gator/test/test.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/open-policy-agent/frameworks/constraint/pkg/apis" - templatesv1 "github.com/open-policy-agent/frameworks/constraint/pkg/apis/templates/v1" constraintclient "github.com/open-policy-agent/frameworks/constraint/pkg/client" "github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/k8scel" "github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/rego" @@ -60,7 +59,7 @@ func Test(objs []*unstructured.Unstructured, tOpts Opts) (*GatorResponses, error // search for templates, add them if they exist ctx := context.Background() for _, obj := range objs { - if !isTemplate(obj) { + if !reader.IsTemplate(obj) { continue } @@ -78,7 +77,7 @@ func Test(objs []*unstructured.Unstructured, tOpts Opts) (*GatorResponses, error // add all constraints. A constraint must be added after its associated // template or OPA will return an error for _, obj := range objs { - if !isConstraint(obj) { + if !reader.IsConstraint(obj) { continue } @@ -175,16 +174,6 @@ func Test(objs []*unstructured.Unstructured, tOpts Opts) (*GatorResponses, error return responses, nil } -func isTemplate(u *unstructured.Unstructured) bool { - gvk := u.GroupVersionKind() - return gvk.Group == templatesv1.SchemeGroupVersion.Group && gvk.Kind == "ConstraintTemplate" -} - -func isConstraint(u *unstructured.Unstructured) bool { - gvk := u.GroupVersionKind() - return gvk.Group == "constraints.gatekeeper.sh" -} - func makeRegoDriver(tOpts Opts) (*rego.Driver, error) { var args []rego.Arg if tOpts.GatherStats { diff --git a/pkg/gator/test/test_test.go b/pkg/gator/test/test_test.go index 83163587dcc..851009c5392 100644 --- a/pkg/gator/test/test_test.go +++ b/pkg/gator/test/test_test.go @@ -9,11 +9,11 @@ import ( "github.com/open-policy-agent/frameworks/constraint/pkg/instrumentation" "github.com/open-policy-agent/frameworks/constraint/pkg/types" "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/fixtures" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/reader" "github.com/open-policy-agent/gatekeeper/v3/pkg/target" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/util/yaml" ) var ( @@ -28,37 +28,37 @@ var ( func init() { var err error - templateNeverValidate, err = readUnstructured([]byte(fixtures.TemplateNeverValidate)) + templateNeverValidate, err = reader.ReadUnstructured([]byte(fixtures.TemplateNeverValidate)) if err != nil { panic(err) } - constraintNeverValidate, err = readUnstructured([]byte(fixtures.ConstraintNeverValidate)) + constraintNeverValidate, err = reader.ReadUnstructured([]byte(fixtures.ConstraintNeverValidate)) if err != nil { panic(err) } - constraintGatorValidate, err = readUnstructured([]byte(fixtures.ConstraintGatorValidate)) + constraintGatorValidate, err = reader.ReadUnstructured([]byte(fixtures.ConstraintGatorValidate)) if err != nil { panic(err) } - constraintReferential, err = readUnstructured([]byte(fixtures.ConstraintReferential)) + constraintReferential, err = reader.ReadUnstructured([]byte(fixtures.ConstraintReferential)) if err != nil { panic(err) } - object, err = readUnstructured([]byte(fixtures.Object)) + object, err = reader.ReadUnstructured([]byte(fixtures.Object)) if err != nil { panic(err) } - objectReferentialInventory, err = readUnstructured([]byte(fixtures.ObjectReferentialInventory)) + objectReferentialInventory, err = reader.ReadUnstructured([]byte(fixtures.ObjectReferentialInventory)) if err != nil { panic(err) } - objectReferentialDeny, err = readUnstructured([]byte(fixtures.ObjectReferentialDeny)) + objectReferentialDeny, err = reader.ReadUnstructured([]byte(fixtures.ObjectReferentialDeny)) if err != nil { panic(err) } @@ -240,7 +240,7 @@ func TestTest(t *testing.T) { // convert the test resources to unstructureds var objs []*unstructured.Unstructured for _, input := range tc.inputs { - u, err := readUnstructured([]byte(input)) + u, err := reader.ReadUnstructured([]byte(input)) require.NoError(t, err) objs = append(objs, u) } @@ -278,7 +278,7 @@ func Test_Test_withTrace(t *testing.T) { var objs []*unstructured.Unstructured for _, input := range inputs { - u, err := readUnstructured([]byte(input)) + u, err := reader.ReadUnstructured([]byte(input)) if err != nil { t.Fatalf("readUnstructured for input %q: %v", input, err) } @@ -341,7 +341,7 @@ func Test_Test_withStats(t *testing.T) { var objs []*unstructured.Unstructured for _, input := range inputs { - u, err := readUnstructured([]byte(input)) + u, err := reader.ReadUnstructured([]byte(input)) assert.NoErrorf(t, err, "readUnstructured for input %q: %v", input, err) objs = append(objs, u) } @@ -411,16 +411,3 @@ func Test_Test_withStats(t *testing.T) { } } } - -func readUnstructured(bytes []byte) (*unstructured.Unstructured, error) { - u := &unstructured.Unstructured{ - Object: make(map[string]interface{}), - } - - err := yaml.Unmarshal(bytes, u) - if err != nil { - return nil, err - } - - return u, nil -} diff --git a/website/docs/gator.md b/website/docs/gator.md index 62510054e04..6a11425c4e1 100644 --- a/website/docs/gator.md +++ b/website/docs/gator.md @@ -499,6 +499,100 @@ However, not including the `namespace` definition in the call to `gator expand` error expanding resources: error expanding resource nginx-deployment: failed to mutate resultant resource nginx-deployment-pod: matching for mutator Assign.mutations.gatekeeper.sh /always-pull-image failed for Pod my-ns nginx-deployment-pod: failed to run Match criteria: namespace selector for namespace-scoped object but missing Namespace ``` +## The `gator sync test` subcommand + +Certain templates require [replicating data](sync.md) into OPA to enable correct evaluation. These templates can use the annotation `metadata.gatekeeper.sh/requires-sync-data` to indicate which resources need to be synced. The annotation contains a json object representing a list of requirements, each of which contains a list of one or more GVK clauses forming an equivalence set of interchangeable GVKs. Each of these clauses has `groups`, `versions`, and `kinds` fields; any group-version-kind combination within a clause within a requirement should be considered sufficient to satisfy that requirement. For example (comments added for clarity): +``` +[ + [ // Requirement 1 + { // Clause 1 + "groups": ["group1", group2"] + "versions": ["version1", "version2", "version3"] + "kinds": ["kind1", "kind2"] + }, + { // Clause 2 + "groups": ["group3", group4"] + "versions": ["version3", "version4"] + "kinds": ["kind3", "kind4"] + } + ], + [ // Requirement 2 + { // Clause 1 + "groups": ["group5"] + "versions": ["version5"] + "kinds": ["kind5"] + } + ] +] +``` +This annotation contains two requirements. Requirement 1 contains two clauses. Syncing resources of group1, version3, kind1 (drawn from clause 1) would be sufficient to fulfill Requirement 1. So, too, would syncing resources of group3, version3, kind4 (drawn from clause 2). Syncing resources of group1, version1, and kind3 would not be, however. + +Requirement 2 is simpler: it denotes that group5, version5, kind5 must be synced for the policy to work properly. + +This template annotation is descriptive, not prescriptive. The prescription of which resources to sync is done in `SyncSet` resources and/or the Gatekeeper `Config` resource. The management of these various requirements can get challenging as the number of templates requiring replicated data increases. + +`gator sync test` aims to mitigate this challenge by enabling the user to check that their sync configuration is correct. The user passes in a set of Constraint Templates, GVK Manifest listing GVKs supported by the cluster, SyncSets, and/or a Gatekeeper Config, and the command will determine which requirements enumerated by the Constraint Templates are unfulfilled by the cluster and SyncSet(s)/Config. + +### Usage + +#### Specifying Inputs + +`gator sync test` expects a `--filename` or `--image` flag, or input fron stdin. The flags can be used individually, in combination, and/or repeated. + +``` +gator sync test --filename="template.yaml" –-filename="syncsets/" --filename="manifest.yaml" +``` + +Or, using an OCI Artifact containing templates as described previously: + +``` +gator sync test --filename="config.yaml" --image=localhost:5000/gator/template-library:v1 +``` + +The manifest of GVKs supported by the cluster should be passed as a GVKManifest resource (CRD visible under the apis directory in the repo): +``` +apiVersion: gvkmanifest.gatekeeper.sh/v1alpha1 +kind: GVKManifest +metadata: + name: gvkmanifest +spec: + groups: + - name: "group1" + versions: + - name: "v1" + kinds: ["Kind1", "Kind2"] + - name: "v2" + kinds: ["Kind1", "Kind3"] + - name: "group2" + versions: + - name: "v1beta1" + kinds: ["Kind4", "Kind5"] +``` + +Optionally, the `--omit-gvk-manifest` flag can be used to skip the requirement of providing a manifest of supported GVKs for the cluster. If this is provided, all GVKs will be assumed to be supported by the cluster. If this assumption is not true, then the given config and templates may cause caching errors or incorrect evaluation on the cluster despite passing this command. + +#### Exit Codes + +`gator sync test` will return a `0` exit status when the Templates, SyncSets, and +Config are successfully ingested and all requirements are fulfilled. + +An error during evaluation, for example a failure to read a file, will result in +a `1` exit status with an error message printed to stderr. + +Unfulfilled requirements will generate a `1` exit status as well, and the unfulfilled requirements per template will be printed to stderr, like so: +``` +the following requirements were not met: +templatename1: +- extensions/v1beta1:Ingress +- networking.k8s.io/v1beta1:Ingress OR networking.k8s.io/v1:Ingress +templatename2: +- apps/v1:Deployment +templatename3: +- /v1:Service +``` + + + ## Bundling Policy into OCI Artifacts It may be useful to bundle policy files into OCI Artifacts for ingestion during