diff --git a/pkg/gator/errors.go b/pkg/gator/errors.go index 11d828b8c2a..bef2dbf9d01 100644 --- a/pkg/gator/errors.go +++ b/pkg/gator/errors.go @@ -15,9 +15,12 @@ var ( // 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 indicates the user-indicated file does not contain a + // GVK Manifest. ErrNotAGVKManifest = errors.New("not a GVKManifest") + // ErrNotAnExpansion indicates the user-indicated file does not contain an + // ExpansionTemplate. + ErrNotAnExpansion = errors.New("not an Expansion Template") // ErrAddingTemplate indicates a problem instantiating a Suite's ConstraintTemplate. ErrAddingTemplate = errors.New("adding template") // ErrAddingConstraint indicates a problem instantiating a Suite's Constraint. diff --git a/pkg/gator/fixtures/fixtures.go b/pkg/gator/fixtures/fixtures.go index 220bb63487d..bf7e3007f3b 100644 --- a/pkg/gator/fixtures/fixtures.go +++ b/pkg/gator/fixtures/fixtures.go @@ -158,6 +158,34 @@ spec: } ` + TemplateRestrictCustomField = ` +kind: ConstraintTemplate +apiVersion: templates.gatekeeper.sh/v1beta1 +metadata: + name: restrictedcustomfield +spec: + crd: + spec: + names: + kind: RestrictedCustomField + validation: + openAPIV3Schema: + type: object + properties: + expectedCustomField: + type: boolean + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package restrictedcustomfield + violation[{"msg": msg}] { + got := input.review.object.spec.customField + expected := input.parameters.expectedCustomField + got == expected + msg := sprintf("foo object has restricted custom field value of %v", [expected]) + } +` + ConstraintAlwaysValidate = ` kind: AlwaysValidate apiVersion: constraints.gatekeeper.sh/v1beta1 @@ -262,6 +290,22 @@ metadata: name: other ` + ConstraintRestrictCustomField = ` +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: RestrictedCustomField +metadata: + name: restrict-foo-custom-field +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Foo"] + namespaces: + - "default" + parameters: + expectedCustomField: true +` + Object = ` kind: Object apiVersion: group.sh/v1 @@ -328,6 +372,17 @@ apiVersion: group.sh/v1 metadata: name: object` + ObjectFooTemplate = ` +apiVersion: apps/v1 +kind: FooTemplate +metadata: + name: foo-template +spec: + template: + spec: + customField: true +` + NamespaceSelected = ` kind: Namespace apiVersion: /v1 @@ -682,4 +737,21 @@ spec: - apiGroups: ["*"] kinds: ["*"] ` + + ExpansionRestrictCustomField = ` +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-foo +spec: + applyTo: + - groups: [ "apps" ] + kinds: [ "FooTemplate" ] + versions: [ "v1" ] + templateSource: "spec.template" + generatedGVK: + kind: "Foo" + group: "" + version: "v1" +` ) diff --git a/pkg/gator/reader/read_resources.go b/pkg/gator/reader/read_resources.go index b5ec0260f5b..64b3f3483a5 100644 --- a/pkg/gator/reader/read_resources.go +++ b/pkg/gator/reader/read_resources.go @@ -243,6 +243,20 @@ func ReadConstraint(f fs.FS, path string) (*unstructured.Unstructured, error) { return u, nil } +func ReadExpansion(f fs.FS, path string) (*unstructured.Unstructured, error) { + u, err := ReadObject(f, path) + if err != nil { + return nil, err + } + + gvk := u.GroupVersionKind() + if gvk.Group != "expansion.gatekeeper.sh" || gvk.Kind != "ExpansionTemplate" { + return nil, gator.ErrNotAnExpansion + } + + return u, nil +} + // ReadK8sResources reads JSON or YAML k8s resources from an io.Reader, // decoding them into Unstructured objects and returning those objects as a // slice. diff --git a/pkg/gator/verify/runner.go b/pkg/gator/verify/runner.go index 87557030c1d..2f995124c24 100644 --- a/pkg/gator/verify/runner.go +++ b/pkg/gator/verify/runner.go @@ -12,7 +12,9 @@ import ( "github.com/open-policy-agent/frameworks/constraint/pkg/client/reviews" "github.com/open-policy-agent/frameworks/constraint/pkg/types" "github.com/open-policy-agent/gatekeeper/v3/apis" + "github.com/open-policy-agent/gatekeeper/v3/pkg/expansion" "github.com/open-policy-agent/gatekeeper/v3/pkg/gator" + "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/expand" "github.com/open-policy-agent/gatekeeper/v3/pkg/gator/reader" mutationtypes "github.com/open-policy-agent/gatekeeper/v3/pkg/mutation/types" "github.com/open-policy-agent/gatekeeper/v3/pkg/target" @@ -179,6 +181,20 @@ func (r *Runner) runCases(ctx context.Context, suiteDir string, filter Filter, t return c, nil } + newExpander := func() (*expand.Expander, error) { + e, err := r.makeTestExpander(suiteDir, t) + if err != nil { + return nil, err + } + + return e, nil + } + + _, err := newExpander() + if err != nil { + return nil, err + } + results := make([]CaseResult, len(t.Cases)) for i, c := range t.Cases { @@ -187,7 +203,7 @@ func (r *Runner) runCases(ctx context.Context, suiteDir string, filter Filter, t continue } - results[i] = r.runCase(ctx, newClient, suiteDir, c) + results[i] = r.runCase(ctx, newClient, newExpander, suiteDir, c) } return results, nil @@ -216,6 +232,22 @@ func (r *Runner) makeTestClient(ctx context.Context, suiteDir string, t *Test) ( return client, nil } +func (r *Runner) makeTestExpander(suiteDir string, t *Test) (*expand.Expander, error) { + // Support Mutator logic? Then we need to add support for mutators as well or do we just ignore them? + expansionPath := t.Expansion + if expansionPath == "" { + return nil, nil + } + + et, err := reader.ReadExpansion(r.filesystem, path.Join(suiteDir, expansionPath)) + if err != nil { + return nil, err + } + + er, err := expand.NewExpander([]*unstructured.Unstructured{et}) + return er, err +} + func (r *Runner) addConstraint(ctx context.Context, suiteDir, constraintPath string, client gator.Client) error { if constraintPath == "" { return fmt.Errorf("%w: missing constraint", gator.ErrInvalidSuite) @@ -252,9 +284,9 @@ func (r *Runner) addTemplate(suiteDir, templatePath string, client gator.Client) } // RunCase executes a Case and returns the result of the run. -func (r *Runner) runCase(ctx context.Context, newClient func() (gator.Client, error), suiteDir string, tc *Case) CaseResult { +func (r *Runner) runCase(ctx context.Context, newClient func() (gator.Client, error), newExpander func() (*expand.Expander, error), suiteDir string, tc *Case) CaseResult { start := time.Now() - trace, err := r.checkCase(ctx, newClient, suiteDir, tc) + trace, err := r.checkCase(ctx, newClient, newExpander, suiteDir, tc) return CaseResult{ Name: tc.Name, @@ -264,7 +296,7 @@ func (r *Runner) runCase(ctx context.Context, newClient func() (gator.Client, er } } -func (r *Runner) checkCase(ctx context.Context, newClient func() (gator.Client, error), suiteDir string, tc *Case) (trace *string, err error) { +func (r *Runner) checkCase(ctx context.Context, newClient func() (gator.Client, error), newExpander func() (*expand.Expander, error), suiteDir string, tc *Case) (trace *string, err error) { if tc.Object == "" { return nil, fmt.Errorf("%w: must define object", gator.ErrInvalidCase) } @@ -274,7 +306,7 @@ func (r *Runner) checkCase(ctx context.Context, newClient func() (gator.Client, return nil, fmt.Errorf("%w: assertions must be non-empty", gator.ErrInvalidCase) } - review, err := r.runReview(ctx, newClient, suiteDir, tc) + review, err := r.runReview(ctx, newClient, newExpander, suiteDir, tc) if err != nil { return nil, err } @@ -293,12 +325,17 @@ func (r *Runner) checkCase(ctx context.Context, newClient func() (gator.Client, return trace, nil } -func (r *Runner) runReview(ctx context.Context, newClient func() (gator.Client, error), suiteDir string, tc *Case) (*types.Responses, error) { +func (r *Runner) runReview(ctx context.Context, newClient func() (gator.Client, error), newExpander func() (*expand.Expander, error), suiteDir string, tc *Case) (*types.Responses, error) { c, err := newClient() if err != nil { return nil, err } + e, err := newExpander() + if err != nil { + return nil, err + } + toReviewPath := path.Join(suiteDir, tc.Object) toReviewObjs, err := readObjects(r.filesystem, toReviewPath) if err != nil { @@ -327,7 +364,36 @@ func (r *Runner) runReview(ctx context.Context, newClient func() (gator.Client, Object: *toReview, Source: mutationtypes.SourceTypeOriginal, } - return c.Review(ctx, au, reviews.EnforcementPoint(util.GatorEnforcementPoint)) + + review, err := c.Review(ctx, au, reviews.EnforcementPoint(util.GatorEnforcementPoint)) + if err != nil { + return nil, fmt.Errorf("reviewing %v %s/%s: %w", + toReview.GroupVersionKind(), toReview.GetNamespace(), toReview.GetName(), err) + } + + if e != nil { + resultants, err := e.Expand(toReview) + if err != nil { + return nil, fmt.Errorf("expanding resource %s: %w", toReview.GetName(), err) + } + + for _, resultant := range resultants { + au := target.AugmentedUnstructured{ + Object: *resultant.Obj, + Source: mutationtypes.SourceTypeGenerated, + } + resultantReview, err := c.Review(ctx, au, reviews.EnforcementPoint(util.GatorEnforcementPoint)) + if err != nil { + return nil, fmt.Errorf("reviewing expanded resource %v %s/%s: %w", + resultant.Obj.GroupVersionKind(), resultant.Obj.GetNamespace(), resultant.Obj.GetName(), err) + } + expansion.OverrideEnforcementAction(resultant.EnforcementAction, resultantReview) + expansion.AggregateResponses(resultant.TemplateName, review, resultantReview) + expansion.AggregateStats(resultant.TemplateName, review, resultantReview) + } + } + + return review, err } func (r *Runner) validateAndReviewAdmissionReviewRequest(ctx context.Context, c gator.Client, toReview *unstructured.Unstructured) (*types.Responses, error) { diff --git a/pkg/gator/verify/runner_test.go b/pkg/gator/verify/runner_test.go index 2c901f8acc4..9dad0480753 100644 --- a/pkg/gator/verify/runner_test.go +++ b/pkg/gator/verify/runner_test.go @@ -1170,6 +1170,68 @@ func TestRunner_Run(t *testing.T) { }, }, }, + { + name: "expansion system", + suite: Suite{ + Tests: []Test{ + { + Name: "check custom field with expansion system", + Template: "template.yaml", + Constraint: "constraint.yaml", + Expansion: "expansion.yaml", + Cases: []*Case{ + { + Name: "Foo Template object", + Object: "foo-template.yaml", + Assertions: []Assertion{{Message: ptr.To[string]("foo object has restricted custom field")}}, + }, + }, + }, + { + Name: "check custom field without expansion system", + Template: "template.yaml", + Constraint: "constraint.yaml", + Cases: []*Case{ + { + Name: "Foo Template object", + Object: "foo-template.yaml", + Assertions: []Assertion{{Violations: gator.IntStrFromStr("no")}}, + }, + }, + }, + }, + }, + f: fstest.MapFS{ + "template.yaml": &fstest.MapFile{ + Data: []byte(fixtures.TemplateRestrictCustomField), + }, + "constraint.yaml": &fstest.MapFile{ + Data: []byte(fixtures.ConstraintRestrictCustomField), + }, + "foo-template.yaml": &fstest.MapFile{ + Data: []byte(fixtures.ObjectFooTemplate), + }, + "expansion.yaml": &fstest.MapFile{ + Data: []byte(fixtures.ExpansionRestrictCustomField), + }, + }, + want: SuiteResult{ + TestResults: []TestResult{ + { + Name: "check custom field with expansion system", + CaseResults: []CaseResult{ + {Name: "Foo Template object"}, + }, + }, + { + Name: "check custom field without expansion system", + CaseResults: []CaseResult{ + {Name: "Foo Template object"}, + }, + }, + }, + }, + }, } for _, tc := range testCases { diff --git a/pkg/gator/verify/suite.go b/pkg/gator/verify/suite.go index 24cc51e5eb9..d454d7ec439 100644 --- a/pkg/gator/verify/suite.go +++ b/pkg/gator/verify/suite.go @@ -35,6 +35,10 @@ type Test struct { // the Suite. Must be an instance of Template. Constraint string `json:"constraint"` + // Expansion is the path to the Expansion, relative to the file defining + // the Suite. + Expansion string `json:"expansion"` + // Cases are the test cases to run on the instantiated Constraint. // Mutually exclusive with Invalid. Cases []*Case `json:"cases,omitempty"` diff --git a/test/gator/verify/allow_expansion.yaml b/test/gator/verify/allow_expansion.yaml new file mode 100644 index 00000000000..3f9e8dfd86b --- /dev/null +++ b/test/gator/verify/allow_expansion.yaml @@ -0,0 +1,8 @@ +apiVersion: apps/v1 +kind: FooTemplate +metadata: + name: foo-template +spec: + template: + foo: bar + diff --git a/test/gator/verify/deny_expansion.yaml b/test/gator/verify/deny_expansion.yaml new file mode 100644 index 00000000000..45c107adf6e --- /dev/null +++ b/test/gator/verify/deny_expansion.yaml @@ -0,0 +1,8 @@ +apiVersion: apps/v1 +kind: FooTemplate +metadata: + name: foo-template +spec: + template: + foo: qux + diff --git a/test/gator/verify/expansion.yaml b/test/gator/verify/expansion.yaml new file mode 100644 index 00000000000..08a1b4a27ad --- /dev/null +++ b/test/gator/verify/expansion.yaml @@ -0,0 +1,15 @@ +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-foo +spec: + applyTo: + - groups: [ "apps" ] + kinds: [ "FooTemplate" ] + versions: [ "v1" ] + templateSource: "spec.template" + generatedGVK: + kind: "FooIsBar" + group: "" + version: "v1" + diff --git a/test/gator/verify/suite.yaml b/test/gator/verify/suite.yaml index 1b769ccc193..61782b5a839 100644 --- a/test/gator/verify/suite.yaml +++ b/test/gator/verify/suite.yaml @@ -36,4 +36,18 @@ tests: - name: foo-not-bar object: deny.yaml assertions: - - violations: no \ No newline at end of file + - violations: no +- name: foo-is-bar-expansion + template: template.yaml + constraint: constraint.yaml + expansion: expansion.yaml + cases: + - name: foo-bar + object: allow_expansion.yaml + assertions: + - violations: no + - name: foo-not-bar + object: deny_expansion.yaml + assertions: + - violations: yes + diff --git a/website/docs/gator.md b/website/docs/gator.md index 6a11425c4e1..924aed02749 100644 --- a/website/docs/gator.md +++ b/website/docs/gator.md @@ -128,7 +128,7 @@ gator test --filename=manifests-and-policies/ --output=json `gator verify` organizes tests into three levels: Suites, Tests, and Cases: - A Suite is a file which defines Tests. -- A Test declares a ConstraintTemplate, a Constraint, and Cases to test the +- A Test declares a ConstraintTemplate, a Constraint, an ExpansionTemplate (optional), and Cases to test the Constraint. - A Case defines an object to validate and whether the object is expected to pass validation. @@ -162,6 +162,8 @@ ConstraintTemplate. It is an error for the Constraint to have a different type than that defined in the ConstraintTemplate spec.crd.spec.names.kind, or for the ConstraintTemplate to not compile. +A Test can also optionally compile an ExpansionTemplate. + ### Cases Each Test contains a list of Cases under the `cases` field. @@ -264,6 +266,25 @@ the `run` flag: gator verify path/to/suites/... --run "disallowed" ``` +### Validating Generated Resources with ExpansionTemplates +`gator verify` may be used along with expansion templates to validate generated resources. The expansion template is optionally declared at the test level. If an expansion template is set for a test, gator will attempt to expand each object under the test. The violations for the parent object & its expanded resources will be aggregated. + +Example for declaring an expansion template in a Gator Suite: +```yaml +apiVersion: test.gatekeeper.sh/v1alpha1 +kind: Suite +tests: +- name: expansion + template: template.yaml + constraint: constraint.yaml + expansion: expansion.yaml + cases: + - name: example-expand + object: deployment.yaml + assertions: + - violations: yes +``` + ### Validating Metadata-Based Constraint Templates `gator verify` may be used with an [`AdmissionReview`](https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/admission#AdmissionReview)