diff --git a/config/crd/bases/carto.run_clusterdeliveries.yaml b/config/crd/bases/carto.run_clusterdeliveries.yaml index bd8d77322..1a196fdab 100644 --- a/config/crd/bases/carto.run_clusterdeliveries.yaml +++ b/config/crd/bases/carto.run_clusterdeliveries.yaml @@ -251,6 +251,68 @@ spec: description: 'Specifies the label key-value pairs used to select deliverables See: https://cartographer.sh/docs/v0.1.0/architecture/#selectors' type: object + selectorMatchExpressions: + description: 'Specifies the requirements used to select deliverables + based on their labels See: FIXME update docs and provide link' + items: + description: A label selector requirement is a selector that contains + values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: operator represents a key's relationship to a set + of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator + is In or NotIn, the values array must be non-empty. If the + operator is Exists or DoesNotExist, the values array must + be empty. This array is replaced during a strategic merge + patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + selectorMatchFields: + description: 'Specifies the requirements used to select deliverables + based on their fields See: FIXME update docs and provide link' + items: + properties: + key: + description: 'Key is the JSON path in the workload to match + against. e.g. for workload: "workload.spec.source.git.url", + e.g. for deliverable: "deliverable.spec.source.git.url"' + minLength: 1 + type: string + operator: + description: Operator represents a key's relationship to a set + of values. Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: Values is an array of string values. If the operator + is In or NotIn, the values array must be non-empty. If the + operator is Exists or DoesNotExist, the values array must + be empty. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array serviceAccountRef: description: "ServiceAccountName refers to the Service account with permissions to create resources submitted by the supply chain. \n @@ -270,7 +332,6 @@ spec: type: object required: - resources - - selector type: object status: description: 'Status conforms to the Kubernetes conventions: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties' diff --git a/config/crd/bases/carto.run_clustersupplychains.yaml b/config/crd/bases/carto.run_clustersupplychains.yaml index 7428d9de1..c6c3d8809 100644 --- a/config/crd/bases/carto.run_clustersupplychains.yaml +++ b/config/crd/bases/carto.run_clustersupplychains.yaml @@ -257,6 +257,68 @@ spec: description: 'Specifies the label key-value pairs used to select workloads See: https://cartographer.sh/docs/v0.1.0/architecture/#selectors' type: object + selectorMatchExpressions: + description: 'Specifies the requirements used to select workloads + based on their labels See: FIXME update docs and provide link' + items: + description: A label selector requirement is a selector that contains + values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: operator represents a key's relationship to a set + of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator + is In or NotIn, the values array must be non-empty. If the + operator is Exists or DoesNotExist, the values array must + be empty. This array is replaced during a strategic merge + patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + selectorMatchFields: + description: 'Specifies the requirements used to select workloads + based on their fields See: FIXME update docs and provide link' + items: + properties: + key: + description: 'Key is the JSON path in the workload to match + against. e.g. for workload: "workload.spec.source.git.url", + e.g. for deliverable: "deliverable.spec.source.git.url"' + minLength: 1 + type: string + operator: + description: Operator represents a key's relationship to a set + of values. Valid operators are In, NotIn, Exists and DoesNotExist. + enum: + - In + - NotIn + - Exists + - DoesNotExist + type: string + values: + description: Values is an array of string values. If the operator + is In or NotIn, the values array must be non-empty. If the + operator is Exists or DoesNotExist, the values array must + be empty. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array serviceAccountRef: description: "ServiceAccountName refers to the Service account with permissions to create resources submitted by the supply chain. \n @@ -276,7 +338,6 @@ spec: type: object required: - resources - - selector type: object status: description: 'Status conforms to the Kubernetes conventions: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties' diff --git a/go.mod b/go.mod index ceb66ad0a..b9b382732 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/go-logr/logr v1.2.2 github.com/go-yaml/yaml v2.1.0+incompatible + github.com/googleapis/gnostic v0.5.5 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.18.1 github.com/valyala/fasttemplate v1.2.1 @@ -46,7 +47,6 @@ require ( github.com/google/go-cmp v0.5.6 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/googleapis/gnostic v0.5.5 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect diff --git a/pkg/apis/v1alpha1/cluster_delivery.go b/pkg/apis/v1alpha1/cluster_delivery.go index d8736e728..195dbfe45 100644 --- a/pkg/apis/v1alpha1/cluster_delivery.go +++ b/pkg/apis/v1alpha1/cluster_delivery.go @@ -22,6 +22,7 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -70,7 +71,18 @@ type DeliverySpec struct { // Specifies the label key-value pairs used to select deliverables // See: https://cartographer.sh/docs/v0.1.0/architecture/#selectors - Selector map[string]string `json:"selector"` + // +optional + Selector map[string]string `json:"selector,omitempty"` + + // Specifies the requirements used to select deliverables based on their labels + // See: FIXME update docs and provide link + // +optional + SelectorMatchExpressions []metav1.LabelSelectorRequirement `json:"selectorMatchExpressions,omitempty"` + + // Specifies the requirements used to select deliverables based on their fields + // See: FIXME update docs and provide link + // +optional + SelectorMatchFields []FieldSelectorRequirement `json:"selectorMatchFields,omitempty"` // Additional parameters. // See: https://cartographer.sh/docs/latest/architecture/#parameter-hierarchy @@ -185,10 +197,18 @@ func (c *ClusterDelivery) ValidateDelete() error { return nil } -func (c *ClusterDelivery) GetSelector() map[string]string { +func (c *ClusterDelivery) GetMatchLabels() labels.Set { return c.Spec.Selector } +func (c *ClusterDelivery) GetMatchExpressions() []metav1.LabelSelectorRequirement { + return c.Spec.SelectorMatchExpressions +} + +func (c *ClusterDelivery) GetMatchFields() []FieldSelectorRequirement { + return c.Spec.SelectorMatchFields +} + func init() { SchemeBuilder.Register( &ClusterDelivery{}, diff --git a/pkg/apis/v1alpha1/cluster_delivery_validations.go b/pkg/apis/v1alpha1/cluster_delivery_validations.go index 74d25a298..a2ee166d5 100644 --- a/pkg/apis/v1alpha1/cluster_delivery_validations.go +++ b/pkg/apis/v1alpha1/cluster_delivery_validations.go @@ -17,6 +17,10 @@ package v1alpha1 import "fmt" func (c *ClusterDelivery) validateNewState() error { + if len(c.Spec.Selector) == 0 && len(c.Spec.SelectorMatchExpressions) == 0 && len(c.Spec.SelectorMatchFields) == 0 { + return fmt.Errorf("at least one selector, selectorMatchExpression, selectorMatchField must be specified") + } + if err := c.validateParams(); err != nil { return err } diff --git a/pkg/apis/v1alpha1/cluster_delivery_test.go b/pkg/apis/v1alpha1/cluster_delivery_validations_test.go similarity index 81% rename from pkg/apis/v1alpha1/cluster_delivery_test.go rename to pkg/apis/v1alpha1/cluster_delivery_validations_test.go index ade807c18..69d2e6bf8 100644 --- a/pkg/apis/v1alpha1/cluster_delivery_test.go +++ b/pkg/apis/v1alpha1/cluster_delivery_validations_test.go @@ -41,6 +41,7 @@ var _ = Describe("Delivery Validation", func() { Namespace: "default", }, Spec: v1alpha1.DeliverySpec{ + Selector: map[string]string { "requires": "at-least-one" }, Resources: []v1alpha1.DeliveryResource{ { Name: "source-provider", @@ -195,6 +196,7 @@ var _ = Describe("Delivery Validation", func() { Namespace: "default", }, Spec: v1alpha1.DeliverySpec{ + Selector: map[string]string { "one-selector-of-any-kind": "is-needed" }, Resources: []v1alpha1.DeliveryResource{ { Name: "source-provider", @@ -506,6 +508,138 @@ var _ = Describe("Delivery Validation", func() { }) }) }) + + Describe("OneOf Selector, SelectorMatchExpressions, or SelectorMatchFields", func() { + var deliveryFactory = func(selector map[string]string, expressions []metav1.LabelSelectorRequirement, fields []v1alpha1.FieldSelectorRequirement) *v1alpha1.ClusterDelivery { + return &v1alpha1.ClusterDelivery{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delivery-resource", + Namespace: "default", + }, + Spec: v1alpha1.DeliverySpec{ + Selector: selector, + SelectorMatchExpressions: expressions, + SelectorMatchFields: fields, + Resources: []v1alpha1.DeliveryResource{ + { + Name: "source-provider", + TemplateRef: v1alpha1.DeliveryTemplateReference{ + Kind: "ClusterSourceTemplate", + Name: "source-template", + }, + }, + { + Name: "other-source-provider", + TemplateRef: v1alpha1.DeliveryTemplateReference{ + Kind: "ClusterSourceTemplate", + Name: "source-template", + }, + }, + }, + }, + } + + } + Context("No selection", func() { + var delivery *v1alpha1.ClusterDelivery + BeforeEach(func() { + delivery = deliveryFactory(nil, nil, nil) + }) + + It("on create, returns an error", func() { + Expect(delivery.ValidateCreate()).To(MatchError( + "error validating clusterdelivery [delivery-resource]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("on update, returns an error", func() { + Expect(delivery.ValidateUpdate(nil)).To(MatchError( + "error validating clusterdelivery [delivery-resource]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("deletes without error", func() { + Expect(delivery.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("Empty selection", func() { + var delivery *v1alpha1.ClusterDelivery + BeforeEach(func() { + delivery = deliveryFactory(map[string]string{}, []metav1.LabelSelectorRequirement{}, []v1alpha1.FieldSelectorRequirement{}) + }) + + It("on create, returns an error", func() { + Expect(delivery.ValidateCreate()).To(MatchError( + "error validating clusterdelivery [delivery-resource]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("on update, returns an error", func() { + Expect(delivery.ValidateUpdate(nil)).To(MatchError( + "error validating clusterdelivery [delivery-resource]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("deletes without error", func() { + Expect(delivery.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("A Selector", func() { + var delivery *v1alpha1.ClusterDelivery + BeforeEach(func() { + delivery = deliveryFactory(map[string]string{"foo":"bar"}, nil, nil) + }) + + It("creates without error", func() { + Expect(delivery.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("on update, returns an error", func() { + Expect(delivery.ValidateUpdate(nil)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(delivery.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("A SelectorMatchExpression", func() { + var delivery *v1alpha1.ClusterDelivery + BeforeEach(func() { + delivery = deliveryFactory(nil, []metav1.LabelSelectorRequirement{{Key: "whatever", Operator: "Exists" }}, nil) + }) + + It("creates without error", func() { + Expect(delivery.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(delivery.ValidateUpdate(nil)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(delivery.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("A SelectorMatchFields", func() { + var delivery *v1alpha1.ClusterDelivery + BeforeEach(func() { + delivery = deliveryFactory(nil, nil, []v1alpha1.FieldSelectorRequirement{{Key: "whatever", Operator: "Exists" }}) + }) + + It("creates without error", func() { + Expect(delivery.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(delivery.ValidateUpdate(nil)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(delivery.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) + }) var _ = Describe("DeliveryTemplateReference", func() { diff --git a/pkg/apis/v1alpha1/cluster_supply_chain.go b/pkg/apis/v1alpha1/cluster_supply_chain.go index c786a3734..fe20c025f 100644 --- a/pkg/apis/v1alpha1/cluster_supply_chain.go +++ b/pkg/apis/v1alpha1/cluster_supply_chain.go @@ -22,6 +22,7 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -70,7 +71,18 @@ type SupplyChainSpec struct { // Specifies the label key-value pairs used to select workloads // See: https://cartographer.sh/docs/v0.1.0/architecture/#selectors - Selector map[string]string `json:"selector"` + // +optional + Selector map[string]string `json:"selector,omitempty"` + + // Specifies the requirements used to select workloads based on their labels + // See: FIXME update docs and provide link + // +optional + SelectorMatchExpressions []metav1.LabelSelectorRequirement `json:"selectorMatchExpressions,omitempty"` + + // Specifies the requirements used to select workloads based on their fields + // See: FIXME update docs and provide link + // +optional + SelectorMatchFields []FieldSelectorRequirement `json:"selectorMatchFields,omitempty"` // Additional parameters. // See: https://cartographer.sh/docs/latest/architecture/#parameter-hierarchy @@ -189,10 +201,19 @@ func (c *ClusterSupplyChain) ValidateDelete() error { return nil } -func (c *ClusterSupplyChain) GetSelector() map[string]string { +func (c *ClusterSupplyChain) GetMatchLabels() labels.Set { return c.Spec.Selector } +func (c *ClusterSupplyChain) GetMatchExpressions() []metav1.LabelSelectorRequirement { + return c.Spec.SelectorMatchExpressions +} + +func (c *ClusterSupplyChain) GetMatchFields() []FieldSelectorRequirement { + return c.Spec.SelectorMatchFields +} + + func GetSelectorsFromObject(o client.Object) []string { var res []string res = []string{} diff --git a/pkg/apis/v1alpha1/cluster_supply_chain_test.go b/pkg/apis/v1alpha1/cluster_supply_chain_test.go index 48b14c6c9..9f4de327a 100644 --- a/pkg/apis/v1alpha1/cluster_supply_chain_test.go +++ b/pkg/apis/v1alpha1/cluster_supply_chain_test.go @@ -15,15 +15,10 @@ package v1alpha1_test import ( - "fmt" "reflect" - "strings" . "github.com/onsi/ginkgo" - . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers" "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" @@ -48,14 +43,6 @@ var _ = Describe("ClusterSupplyChain", func() { Expect(jsonValue).NotTo(ContainSubstring("omitempty")) }) - It("requires a selector", func() { - selectorField, found := supplyChainSpecType.FieldByName("Selector") - Expect(found).To(BeTrue()) - jsonValue := selectorField.Tag.Get("json") - Expect(jsonValue).To(ContainSubstring("selector")) - Expect(jsonValue).NotTo(ContainSubstring("omitempty")) - }) - It("allows but does not require a service account ref", func() { serviceAccountNameField, found := supplyChainSpecType.FieldByName("ServiceAccountRef") Expect(found).To(BeTrue()) @@ -123,647 +110,6 @@ var _ = Describe("ClusterSupplyChain", func() { Expect(jsonValue).To(ContainSubstring("omitempty")) }) }) - - Describe("Webhook Validation", func() { - Context("supply chain without options", func() { - var ( - supplyChain *v1alpha1.ClusterSupplyChain - oldSupplyChain *v1alpha1.ClusterSupplyChain - ) - - BeforeEach(func() { - supplyChain = &v1alpha1.ClusterSupplyChain{ - ObjectMeta: metav1.ObjectMeta{ - Name: "responsible-ops---default-params", - Namespace: "default", - }, - Spec: v1alpha1.SupplyChainSpec{ - Resources: []v1alpha1.SupplyChainResource{ - { - Name: "source-provider", - TemplateRef: v1alpha1.SupplyChainTemplateReference{ - Kind: "ClusterSourceTemplate", - Name: "git-template---default-params", - }, - }, - { - Name: "other-source-provider", - TemplateRef: v1alpha1.SupplyChainTemplateReference{ - Kind: "ClusterSourceTemplate", - Name: "git-template---default-params", - }, - }, - }, - Selector: map[string]string{"integration-test": "workload-no-supply-chain"}, - Params: []v1alpha1.BlueprintParam{ - { - Name: "some-param", - Value: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, - }, - }, - }, - } - }) - - Context("Well formed supply chain", func() { - It("creates without error", func() { - Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) - }) - - It("updates without error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).NotTo(HaveOccurred()) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("Supply chain with a resource reference that does not exist", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[1].Sources = []v1alpha1.ResourceReference{ - { - Name: "some-source", - Resource: "some-nonexistent-resource", - }, - } - }) - - It("on create, returns an error", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: invalid sources for resource [other-source-provider]: [some-source] is provided by unknown resource [some-nonexistent-resource]", - )) - }) - - It("on update, returns an error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: invalid sources for resource [other-source-provider]: [some-source] is provided by unknown resource [some-nonexistent-resource]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("Two resources with the same name", func() { - BeforeEach(func() { - for i := range supplyChain.Spec.Resources { - supplyChain.Spec.Resources[i].Name = "some-duplicate-name" - } - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: duplicate resource name [some-duplicate-name] found", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: duplicate resource name [some-duplicate-name] found", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("SupplyChain with malformed params", func() { - Context("Top level params are malformed", func() { - Context("param does not specify a value or default", func() { - BeforeEach(func() { - supplyChain.Spec.Params = []v1alpha1.BlueprintParam{ - { - Name: "some-param", - }, - } - }) - It("on create, returns an error", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("on update, returns an error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("param specifies both a value and a default", func() { - BeforeEach(func() { - supplyChain.Spec.Params = []v1alpha1.BlueprintParam{ - { - Name: "some-param", - Value: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, - DefaultValue: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, - }, - } - }) - - It("on create, returns an error", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("on update, returns an error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - }) - - Context("Params of an individual resource are malformed", func() { - Context("param does not specify a value or default", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].Params = []v1alpha1.BlueprintParam{ - { - Name: "some-param", - }, - } - }) - It("on create, returns an error", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("on update, returns an error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("param specifies both a value and a default", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].Params = []v1alpha1.BlueprintParam{ - { - Name: "some-param", - Value: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, - DefaultValue: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, - }, - } - }) - It("on create, returns an error", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("on update, returns an error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - }) - }) - - Describe("Template inputs must reference a resource with a matching type", func() { - var consumerToProviderMapping = map[string]string{ - "Source": "ClusterSourceTemplate", - "Config": "ClusterConfigTemplate", - "Image": "ClusterImageTemplate", - } - BeforeEach(func() { - supplyChain = &v1alpha1.ClusterSupplyChain{ - ObjectMeta: metav1.ObjectMeta{ - Name: "responsible-ops---default-params", - Namespace: "default", - }, - Spec: v1alpha1.SupplyChainSpec{ - Resources: []v1alpha1.SupplyChainResource{ - { - Name: "input-provider", - TemplateRef: v1alpha1.SupplyChainTemplateReference{ - Name: "output-template", - }, - }, - { - Name: "input-consumer", - TemplateRef: v1alpha1.SupplyChainTemplateReference{ - Kind: "ClusterTemplate", - Name: "consuming-template", - }, - }, - }, - Selector: map[string]string{"integration-test": "workload-no-supply-chain"}, - }, - } - }) - DescribeTable("template input does not match template type", - func(firstResourceKind string, inputReferenceType string, happy bool) { - supplyChain.Spec.Resources[0].TemplateRef.Kind = firstResourceKind - - reference := v1alpha1.ResourceReference{ - Name: "input-name", - Resource: "input-provider", - } - - switch inputReferenceType { - case "Source": - supplyChain.Spec.Resources[1].Sources = []v1alpha1.ResourceReference{reference} - case "Image": - supplyChain.Spec.Resources[1].Images = []v1alpha1.ResourceReference{reference} - case "Config": - supplyChain.Spec.Resources[1].Configs = []v1alpha1.ResourceReference{reference} - } - - // Create - createErr := supplyChain.ValidateCreate() - - // Update - updateErr := supplyChain.ValidateUpdate(oldSupplyChain) - - // Delete - deleteErr := supplyChain.ValidateDelete() - - if happy { - Expect(createErr).NotTo(HaveOccurred()) - Expect(updateErr).NotTo(HaveOccurred()) - Expect(deleteErr).NotTo(HaveOccurred()) - } else { - Expect(createErr).To(HaveOccurred()) - Expect(createErr).To(MatchError(fmt.Sprintf( - "error validating clustersupplychain [responsible-ops---default-params]: invalid %ss for resource [input-consumer]: resource [input-provider] providing [input-name] must reference a %s", - strings.ToLower(inputReferenceType), - consumerToProviderMapping[inputReferenceType]), - )) - - Expect(updateErr).To(HaveOccurred()) - Expect(updateErr).To(MatchError(fmt.Sprintf( - "error validating clustersupplychain [responsible-ops---default-params]: invalid %ss for resource [input-consumer]: resource [input-provider] providing [input-name] must reference a %s", - strings.ToLower(inputReferenceType), - consumerToProviderMapping[inputReferenceType]), - )) - - Expect(deleteErr).NotTo(HaveOccurred()) - } - }, - Entry("Config cannot be a source provider", "ClusterTemplate", "Source", false), - Entry("Config cannot be a image provider", "ClusterTemplate", "Image", false), - Entry("Config cannot be an config provider", "ClusterTemplate", "Config", false), - Entry("Build cannot be a source provider", "ClusterImageTemplate", "Source", false), - Entry("Build can be a image provider", "ClusterImageTemplate", "Image", true), - Entry("Build cannot be a config provider", "ClusterImageTemplate", "Config", false), - Entry("Source can be a source provider", "ClusterSourceTemplate", "Source", true), - Entry("Source cannot be a image provider", "ClusterSourceTemplate", "Image", false), - Entry("Source cannot be a config provider", "ClusterSourceTemplate", "Config", false), - Entry("Config cannot be a source provider", "ClusterConfigTemplate", "Source", false), - Entry("Config cannot be a image provider", "ClusterConfigTemplate", "Image", false), - Entry("Config can be a config provider", "ClusterConfigTemplate", "Config", true), - ) - }) - }) - - Context("supply chain with options", func() { - var ( - supplyChain *v1alpha1.ClusterSupplyChain - oldSupplyChain *v1alpha1.ClusterSupplyChain - ) - - BeforeEach(func() { - supplyChain = &v1alpha1.ClusterSupplyChain{ - ObjectMeta: metav1.ObjectMeta{ - Name: "responsible-ops---default-params", - Namespace: "default", - }, - Spec: v1alpha1.SupplyChainSpec{ - Resources: []v1alpha1.SupplyChainResource{ - { - Name: "source-provider", - TemplateRef: v1alpha1.SupplyChainTemplateReference{ - Kind: "ClusterSourceTemplate", - Options: []v1alpha1.TemplateOption{ - { - Name: "source-1", - Selector: v1alpha1.Selector{ - MatchFields: []v1alpha1.FieldSelectorRequirement{ - { - Key: "spec.source.git.url", - Operator: v1alpha1.FieldSelectorOpExists, - }, - }, - }, - }, - { - Name: "source-2", - Selector: v1alpha1.Selector{ - MatchFields: []v1alpha1.FieldSelectorRequirement{ - { - Key: "spec.source.git.url", - Operator: v1alpha1.FieldSelectorOpDoesNotExist, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - }) - - Context("Well formed supply chain", func() { - It("creates without error", func() { - Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) - }) - - It("updates without error", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).NotTo(HaveOccurred()) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("Two options with the same name", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Name = supplyChain.Spec.Resources[0].TemplateRef.Options[1].Name - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: duplicate template name [source-2] found in options for resource [source-provider]", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: duplicate template name [source-2] found in options for resource [source-provider]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("only one option is specified", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options = []v1alpha1.TemplateOption{ - { - Name: "only-option", - Selector: v1alpha1.Selector{}, - }, - } - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: templateRef.Options must have more than one option", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: templateRef.Options must have more than one option", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("operator values", func() { - Context("operator is Exists and has values", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ - Key: "something", - Operator: v1alpha1.FieldSelectorOpExists, - Values: []string{"bad"}, - } - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [Exists]", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [Exists]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("operator is NotExists and has values", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ - Key: "something", - Operator: v1alpha1.FieldSelectorOpDoesNotExist, - Values: []string{"bad"}, - } - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [DoesNotExist]", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [DoesNotExist]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - Context("operator is In and does NOT have values", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ - Key: "something", - Operator: v1alpha1.FieldSelectorOpIn, - } - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [In]", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [In]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - Context("operator is NotIn and does NOT have values", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ - Key: "something", - Operator: v1alpha1.FieldSelectorOpNotIn, - } - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [NotIn]", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [NotIn]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - }) - - Context("2 options with identical requirements", func() { - Context("selectors are identical", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector = supplyChain.Spec.Resources[0].TemplateRef.Options[1].Selector - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: duplicate selector found in options [source-1, source-2]", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: duplicate selector found in options [source-1, source-2]", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - }) - - Context("option points to key that doesn't exist in spec", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0].Key = "spec.does.not.exist" - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: requirement key [spec.does.not.exist] is not a valid path", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: requirement key [spec.does.not.exist] is not a valid path", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("option points to key that is a valid prefix into an array", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0].Key = `spec.env[?(@.name=="some-name")].value` - }) - - It("on create, it does not reject the Resource", func() { - err := supplyChain.ValidateCreate() - Expect(err).NotTo(HaveOccurred()) - }) - - It("on update, it does not reject the Resource", func() { - err := supplyChain.ValidateUpdate(oldSupplyChain) - Expect(err).NotTo(HaveOccurred()) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("both name and options specified", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Name = "some-name" - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found both", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found both", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - - Context("neither name and options are specified", func() { - BeforeEach(func() { - supplyChain.Spec.Resources[0].TemplateRef.Options = nil - }) - - It("on create, it rejects the Resource", func() { - Expect(supplyChain.ValidateCreate()).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found neither", - )) - }) - - It("on update, it rejects the Resource", func() { - Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( - "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found neither", - )) - }) - - It("deletes without error", func() { - Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) - }) - }) - }) - }) - Describe("GetSelectorsFromObject", func() { var expectedSelectors, actualSelectors []string Context("when object is a supply chain", func() { diff --git a/pkg/apis/v1alpha1/cluster_supply_chain_validations.go b/pkg/apis/v1alpha1/cluster_supply_chain_validations.go index 2da99cd61..06c0a5165 100644 --- a/pkg/apis/v1alpha1/cluster_supply_chain_validations.go +++ b/pkg/apis/v1alpha1/cluster_supply_chain_validations.go @@ -21,6 +21,10 @@ import ( func (c *ClusterSupplyChain) validateNewState() error { names := make(map[string]bool) + if len(c.Spec.Selector) == 0 && len(c.Spec.SelectorMatchExpressions) == 0 && len(c.Spec.SelectorMatchFields) == 0 { + return fmt.Errorf("at least one selector, selectorMatchExpression, selectorMatchField must be specified") + } + if err := c.validateParams(); err != nil { return err } diff --git a/pkg/apis/v1alpha1/cluster_supply_chain_validations_test.go b/pkg/apis/v1alpha1/cluster_supply_chain_validations_test.go new file mode 100644 index 000000000..258101abc --- /dev/null +++ b/pkg/apis/v1alpha1/cluster_supply_chain_validations_test.go @@ -0,0 +1,789 @@ +// Copyright 2021 VMware +// +// 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. + +package v1alpha1_test + +import ( + "fmt" + "strings" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" +) + +var _ = Describe("Webhook Validation", func() { + Context("supply chain without options", func() { + var ( + supplyChain *v1alpha1.ClusterSupplyChain + oldSupplyChain *v1alpha1.ClusterSupplyChain + ) + + BeforeEach(func() { + supplyChain = &v1alpha1.ClusterSupplyChain{ + ObjectMeta: metav1.ObjectMeta{ + Name: "responsible-ops---default-params", + }, + Spec: v1alpha1.SupplyChainSpec{ + Resources: []v1alpha1.SupplyChainResource{ + { + Name: "source-provider", + TemplateRef: v1alpha1.SupplyChainTemplateReference{ + Kind: "ClusterSourceTemplate", + Name: "git-template---default-params", + }, + }, + { + Name: "other-source-provider", + TemplateRef: v1alpha1.SupplyChainTemplateReference{ + Kind: "ClusterSourceTemplate", + Name: "git-template---default-params", + }, + }, + }, + Selector: map[string]string{"integration-test": "workload-no-supply-chain"}, + Params: []v1alpha1.BlueprintParam{ + { + Name: "some-param", + Value: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, + }, + }, + }, + } + }) + + Context("Well formed supply chain", func() { + It("creates without error", func() { + Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("Supply chain with a resource reference that does not exist", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[1].Sources = []v1alpha1.ResourceReference{ + { + Name: "some-source", + Resource: "some-nonexistent-resource", + }, + } + }) + + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: invalid sources for resource [other-source-provider]: [some-source] is provided by unknown resource [some-nonexistent-resource]", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: invalid sources for resource [other-source-provider]: [some-source] is provided by unknown resource [some-nonexistent-resource]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("Two resources with the same name", func() { + BeforeEach(func() { + for i := range supplyChain.Spec.Resources { + supplyChain.Spec.Resources[i].Name = "some-duplicate-name" + } + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: duplicate resource name [some-duplicate-name] found", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: duplicate resource name [some-duplicate-name] found", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("SupplyChain with malformed params", func() { + Context("Top level params are malformed", func() { + Context("param does not specify a value or default", func() { + BeforeEach(func() { + supplyChain.Spec.Params = []v1alpha1.BlueprintParam{ + { + Name: "some-param", + }, + } + }) + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("param specifies both a value and a default", func() { + BeforeEach(func() { + supplyChain.Spec.Params = []v1alpha1.BlueprintParam{ + { + Name: "some-param", + Value: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, + DefaultValue: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, + }, + } + }) + + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) + + Context("Params of an individual resource are malformed", func() { + Context("param does not specify a value or default", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].Params = []v1alpha1.BlueprintParam{ + { + Name: "some-param", + }, + } + }) + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("param specifies both a value and a default", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].Params = []v1alpha1.BlueprintParam{ + { + Name: "some-param", + Value: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, + DefaultValue: &apiextensionsv1.JSON{Raw: []byte(`"some value"`)}, + }, + } + }) + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: resource [source-provider] is invalid: param [some-param] is invalid: must set exactly one of value and default", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) + }) + + Describe("Template inputs must reference a resource with a matching type", func() { + var consumerToProviderMapping = map[string]string{ + "Source": "ClusterSourceTemplate", + "Config": "ClusterConfigTemplate", + "Image": "ClusterImageTemplate", + } + BeforeEach(func() { + supplyChain = &v1alpha1.ClusterSupplyChain{ + ObjectMeta: metav1.ObjectMeta{ + Name: "responsible-ops---default-params", + }, + Spec: v1alpha1.SupplyChainSpec{ + Resources: []v1alpha1.SupplyChainResource{ + { + Name: "input-provider", + TemplateRef: v1alpha1.SupplyChainTemplateReference{ + Name: "output-template", + }, + }, + { + Name: "input-consumer", + TemplateRef: v1alpha1.SupplyChainTemplateReference{ + Kind: "ClusterTemplate", + Name: "consuming-template", + }, + }, + }, + Selector: map[string]string{"integration-test": "workload-no-supply-chain"}, + }, + } + }) + DescribeTable("template input does not match template type", + func(firstResourceKind string, inputReferenceType string, happy bool) { + supplyChain.Spec.Resources[0].TemplateRef.Kind = firstResourceKind + + reference := v1alpha1.ResourceReference{ + Name: "input-name", + Resource: "input-provider", + } + + switch inputReferenceType { + case "Source": + supplyChain.Spec.Resources[1].Sources = []v1alpha1.ResourceReference{reference} + case "Image": + supplyChain.Spec.Resources[1].Images = []v1alpha1.ResourceReference{reference} + case "Config": + supplyChain.Spec.Resources[1].Configs = []v1alpha1.ResourceReference{reference} + } + + // Create + createErr := supplyChain.ValidateCreate() + + // Update + updateErr := supplyChain.ValidateUpdate(oldSupplyChain) + + // Delete + deleteErr := supplyChain.ValidateDelete() + + if happy { + Expect(createErr).NotTo(HaveOccurred()) + Expect(updateErr).NotTo(HaveOccurred()) + Expect(deleteErr).NotTo(HaveOccurred()) + } else { + Expect(createErr).To(HaveOccurred()) + Expect(createErr).To(MatchError(fmt.Sprintf( + "error validating clustersupplychain [responsible-ops---default-params]: invalid %ss for resource [input-consumer]: resource [input-provider] providing [input-name] must reference a %s", + strings.ToLower(inputReferenceType), + consumerToProviderMapping[inputReferenceType]), + )) + + Expect(updateErr).To(HaveOccurred()) + Expect(updateErr).To(MatchError(fmt.Sprintf( + "error validating clustersupplychain [responsible-ops---default-params]: invalid %ss for resource [input-consumer]: resource [input-provider] providing [input-name] must reference a %s", + strings.ToLower(inputReferenceType), + consumerToProviderMapping[inputReferenceType]), + )) + + Expect(deleteErr).NotTo(HaveOccurred()) + } + }, + Entry("Config cannot be a source provider", "ClusterTemplate", "Source", false), + Entry("Config cannot be a image provider", "ClusterTemplate", "Image", false), + Entry("Config cannot be an config provider", "ClusterTemplate", "Config", false), + Entry("Build cannot be a source provider", "ClusterImageTemplate", "Source", false), + Entry("Build can be a image provider", "ClusterImageTemplate", "Image", true), + Entry("Build cannot be a config provider", "ClusterImageTemplate", "Config", false), + Entry("Source can be a source provider", "ClusterSourceTemplate", "Source", true), + Entry("Source cannot be a image provider", "ClusterSourceTemplate", "Image", false), + Entry("Source cannot be a config provider", "ClusterSourceTemplate", "Config", false), + Entry("Config cannot be a source provider", "ClusterConfigTemplate", "Source", false), + Entry("Config cannot be a image provider", "ClusterConfigTemplate", "Image", false), + Entry("Config can be a config provider", "ClusterConfigTemplate", "Config", true), + ) + }) + + }) + + Describe("OneOf Selector, SelectorMatchExpressions, or SelectorMatchFields", func() { + var supplyChainFactory = func(selector map[string]string, expressions []metav1.LabelSelectorRequirement, fields []v1alpha1.FieldSelectorRequirement) *v1alpha1.ClusterSupplyChain { + return &v1alpha1.ClusterSupplyChain{ + ObjectMeta: metav1.ObjectMeta{ + Name: "responsible-ops---default-params", + }, + Spec: v1alpha1.SupplyChainSpec{ + Resources: []v1alpha1.SupplyChainResource{ + { + Name: "source-provider", + TemplateRef: v1alpha1.SupplyChainTemplateReference{ + Kind: "ClusterSourceTemplate", + Name: "git-template---default-params", + }, + }, + }, + Selector: selector, + SelectorMatchExpressions: expressions, + SelectorMatchFields: fields, + }, + } + } + Context("No selection", func() { + var supplyChain *v1alpha1.ClusterSupplyChain + BeforeEach(func() { + supplyChain = supplyChainFactory(nil, nil, nil) + }) + + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(nil)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("Empty selection", func() { + var supplyChain *v1alpha1.ClusterSupplyChain + BeforeEach(func() { + supplyChain = supplyChainFactory(map[string]string{}, []metav1.LabelSelectorRequirement{}, []v1alpha1.FieldSelectorRequirement{}) + }) + + It("on create, returns an error", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("on update, returns an error", func() { + Expect(supplyChain.ValidateUpdate(nil)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: at least one selector, selectorMatchExpression, selectorMatchField must be specified", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("A Selector", func() { + var supplyChain *v1alpha1.ClusterSupplyChain + BeforeEach(func() { + supplyChain = supplyChainFactory(map[string]string{"foo": "bar"}, nil, nil) + }) + + It("creates without error", func() { + Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(supplyChain.ValidateUpdate(nil)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("A SelectorMatchExpression", func() { + var supplyChain *v1alpha1.ClusterSupplyChain + BeforeEach(func() { + supplyChain = supplyChainFactory(nil, []metav1.LabelSelectorRequirement{{Key: "whatever", Operator: "Exists"}}, nil) + }) + + It("creates without error", func() { + Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(supplyChain.ValidateUpdate(nil)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("A SelectorMatchFields", func() { + var supplyChain *v1alpha1.ClusterSupplyChain + BeforeEach(func() { + supplyChain = supplyChainFactory(nil, nil, []v1alpha1.FieldSelectorRequirement{{Key: "whatever", Operator: "Exists"}}) + }) + + It("creates without error", func() { + Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(supplyChain.ValidateUpdate(nil)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) + + Context("supply chain with options", func() { + var ( + supplyChain *v1alpha1.ClusterSupplyChain + oldSupplyChain *v1alpha1.ClusterSupplyChain + ) + + BeforeEach(func() { + supplyChain = &v1alpha1.ClusterSupplyChain{ + ObjectMeta: metav1.ObjectMeta{ + Name: "responsible-ops---default-params", + }, + Spec: v1alpha1.SupplyChainSpec{ + Selector: map[string]string{"foo": "bar"}, + Resources: []v1alpha1.SupplyChainResource{ + { + Name: "source-provider", + TemplateRef: v1alpha1.SupplyChainTemplateReference{ + Kind: "ClusterSourceTemplate", + Options: []v1alpha1.TemplateOption{ + { + Name: "source-1", + Selector: v1alpha1.Selector{ + MatchFields: []v1alpha1.FieldSelectorRequirement{ + { + Key: "spec.source.git.url", + Operator: v1alpha1.FieldSelectorOpExists, + }, + }, + }, + }, + { + Name: "source-2", + Selector: v1alpha1.Selector{ + MatchFields: []v1alpha1.FieldSelectorRequirement{ + { + Key: "spec.source.git.url", + Operator: v1alpha1.FieldSelectorOpDoesNotExist, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + }) + + Context("Well formed supply chain", func() { + It("creates without error", func() { + Expect(supplyChain.ValidateCreate()).NotTo(HaveOccurred()) + }) + + It("updates without error", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("Two options with the same name", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Name = supplyChain.Spec.Resources[0].TemplateRef.Options[1].Name + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: duplicate template name [source-2] found in options for resource [source-provider]", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: duplicate template name [source-2] found in options for resource [source-provider]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("only one option is specified", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options = []v1alpha1.TemplateOption{ + { + Name: "only-option", + Selector: v1alpha1.Selector{}, + }, + } + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: templateRef.Options must have more than one option", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: templateRef.Options must have more than one option", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("operator values", func() { + Context("operator is Exists and has values", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ + Key: "something", + Operator: v1alpha1.FieldSelectorOpExists, + Values: []string{"bad"}, + } + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [Exists]", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [Exists]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("operator is NotExists and has values", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ + Key: "something", + Operator: v1alpha1.FieldSelectorOpDoesNotExist, + Values: []string{"bad"}, + } + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [DoesNotExist]", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: cannot specify values with operator [DoesNotExist]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("operator is In and does NOT have values", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ + Key: "something", + Operator: v1alpha1.FieldSelectorOpIn, + } + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [In]", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [In]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + Context("operator is NotIn and does NOT have values", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0] = v1alpha1.FieldSelectorRequirement{ + Key: "something", + Operator: v1alpha1.FieldSelectorOpNotIn, + } + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [NotIn]", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: must specify values with operator [NotIn]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) + + Context("2 options with identical requirements", func() { + Context("selectors are identical", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector = supplyChain.Spec.Resources[0].TemplateRef.Options[1].Selector + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: duplicate selector found in options [source-1, source-2]", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: duplicate selector found in options [source-1, source-2]", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) + + Context("option points to key that doesn't exist in spec", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0].Key = "spec.does.not.exist" + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: requirement key [spec.does.not.exist] is not a valid path", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: error validating option [source-1]: requirement key [spec.does.not.exist] is not a valid path", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("option points to key that is a valid prefix into an array", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options[0].Selector.MatchFields[0].Key = `spec.env[?(@.name=="some-name")].value` + }) + + It("on create, it does not reject the Resource", func() { + err := supplyChain.ValidateCreate() + Expect(err).NotTo(HaveOccurred()) + }) + + It("on update, it does not reject the Resource", func() { + err := supplyChain.ValidateUpdate(oldSupplyChain) + Expect(err).NotTo(HaveOccurred()) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("both name and options specified", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Name = "some-name" + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found both", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found both", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + + Context("neither name and options are specified", func() { + BeforeEach(func() { + supplyChain.Spec.Resources[0].TemplateRef.Options = nil + }) + + It("on create, it rejects the Resource", func() { + Expect(supplyChain.ValidateCreate()).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found neither", + )) + }) + + It("on update, it rejects the Resource", func() { + Expect(supplyChain.ValidateUpdate(oldSupplyChain)).To(MatchError( + "error validating clustersupplychain [responsible-ops---default-params]: error validating resource [source-provider]: exactly one of templateRef.Name or templateRef.Options must be specified, found neither", + )) + }) + + It("deletes without error", func() { + Expect(supplyChain.ValidateDelete()).NotTo(HaveOccurred()) + }) + }) + }) +}) diff --git a/pkg/apis/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/v1alpha1/zz_generated.deepcopy.go index f48763e5e..ea2d68b39 100644 --- a/pkg/apis/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/v1alpha1/zz_generated.deepcopy.go @@ -699,6 +699,20 @@ func (in *DeliverySpec) DeepCopyInto(out *DeliverySpec) { (*out)[key] = val } } + if in.SelectorMatchExpressions != nil { + in, out := &in.SelectorMatchExpressions, &out.SelectorMatchExpressions + *out = make([]v1.LabelSelectorRequirement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SelectorMatchFields != nil { + in, out := &in.SelectorMatchFields, &out.SelectorMatchFields + *out = make([]FieldSelectorRequirement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Params != nil { in, out := &in.Params, &out.Params *out = make([]BlueprintParam, len(*in)) @@ -1398,6 +1412,20 @@ func (in *SupplyChainSpec) DeepCopyInto(out *SupplyChainSpec) { (*out)[key] = val } } + if in.SelectorMatchExpressions != nil { + in, out := &in.SelectorMatchExpressions, &out.SelectorMatchExpressions + *out = make([]v1.LabelSelectorRequirement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SelectorMatchFields != nil { + in, out := &in.SelectorMatchFields, &out.SelectorMatchFields + *out = make([]FieldSelectorRequirement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Params != nil { in, out := &in.Params, &out.Params *out = make([]BlueprintParam, len(*in)) diff --git a/pkg/repository/label_matcher.go b/pkg/repository/label_matcher.go deleted file mode 100644 index feee87bbb..000000000 --- a/pkg/repository/label_matcher.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2021 VMware -// -// 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. - -package repository - -type SelectorGetter interface { - GetSelector() map[string]string -} - -type LabelsGetter interface { - GetLabels() map[string]string -} - -// BestLabelMatches attempts at finding the targets that best match the label set -// of the source. -// -func BestLabelMatches(source LabelsGetter, targets []SelectorGetter) []SelectorGetter { - if len(targets) == 0 { - return nil - } - - // count the number of matches - matchCounter := make([]int, len(targets)) - for idx, target := range targets { - if !subsetOf(source.GetLabels(), target.GetSelector()) { - continue - } - - for key, value := range target.GetSelector() { - srcValue, found := source.GetLabels()[key] - if !found || srcValue != value { - continue - } - - matchCounter[idx] += 1 - } - } - - // keep just those that have the highest amount of matches - var selectors []SelectorGetter - if highestMatch := maxSlice(matchCounter); highestMatch > 0 { - for idx := range matchCounter { - if matchCounter[idx] == highestMatch { - selectors = append(selectors, targets[idx]) - } - } - } - - // filter down to the most specific set - selectorsCount := make([]int, len(selectors)) - for idx, selector := range selectors { - selectorsCount[idx] = len(selector.GetSelector()) - } - - var res []SelectorGetter - minSelectorCount := minSlice(selectorsCount) - for _, selector := range selectors { - if len(selector.GetSelector()) == minSelectorCount { - res = append(res, selector) - } - } - - return res -} - -// minSlice gets the minimum value in a given slice (or 999, otherwise) -// -func minSlice(slice []int) int { - min := 999 - - for idx, element := range slice { - if idx == 0 || element < min { - min = element - } - } - - return min -} - -// maxSlice gets the maximum value in a given slice (or 0, otherwise) -// -func maxSlice(slice []int) int { - max := 0 - - for idx, element := range slice { - if idx == 0 || element > max { - max = element - } - } - - return max -} - -// subsetOf verifies whether `a` is a subset of `b` -// -func subsetOf(a, b map[string]string) bool { - for key, value := range b { - if a[key] != value { - return false - } - } - - return true -} diff --git a/pkg/repository/label_matcher_test.go b/pkg/repository/label_matcher_test.go deleted file mode 100644 index 0e068850b..000000000 --- a/pkg/repository/label_matcher_test.go +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright 2021 VMware -// -// 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. - -package repository_test - -import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/ginkgo/extensions/table" - . "github.com/onsi/gomega" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" - "github.com/vmware-tanzu/cartographer/pkg/repository" -) - -var _ = Describe("BestLabelMatches", func() { - - type testcase struct { - source repository.LabelsGetter - targets []repository.SelectorGetter - expected []repository.SelectorGetter - } - - type labels map[string]string - - var lg = func(labelset labels) repository.LabelsGetter { - return &v1alpha1.Workload{ - ObjectMeta: metav1.ObjectMeta{ - Labels: labelset, - }, - } - } - - var sg = func(labelset labels) repository.SelectorGetter { - return &v1alpha1.ClusterSupplyChain{ - Spec: v1alpha1.SupplyChainSpec{ - Selector: labelset, - }, - } - } - - DescribeTable("cases", - func(tc testcase) { - actual := repository.BestLabelMatches( - tc.source, tc.targets, - ) - - if tc.expected == nil { - Expect(actual).To(BeNil()) - return - } - - Expect(actual).To(Equal(tc.expected)) - }, - - Entry("empty targets", testcase{ - source: lg(labels{"foo": "bar"}), - expected: nil, - }), - - Entry("complete mismatched src & targets", testcase{ - source: lg(labels{ - "type": "web", - }), - targets: []repository.SelectorGetter{ - sg(labels{ - "----": "----", - }), - }, - expected: nil, - }), - - Entry("partial match; target with less labels than source", testcase{ - source: lg(labels{ - "type": "web", - "test": "tekton", - }), - targets: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - }), - }, - expected: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - }), - }, - }), - - Entry("partial match; source with less labels than target", testcase{ - source: lg(labels{ - "type": "web", - }), - targets: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - "test": "tekton", - }), - }, - expected: nil, - }), - - Entry("absolute match", testcase{ - source: lg(labels{ - "type": "web", - "test": "tekton", - }), - targets: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - "test": "----", - }), - sg(labels{ // ! this - "type": "web", - "test": "tekton", - }), - sg(labels{ - "type": "mobile", - "test": "----", - }), - }, - expected: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - "test": "tekton", - }), - }, - }), - - Entry("exact partial match", testcase{ - source: lg(labels{ - "type": "web", - "test": "tekton", - "scan": "security", - "input": "image", - }), - targets: []repository.SelectorGetter{ - sg(labels{ - "type": "----", - "test": "tekton", - "scan": "----", - }), - sg(labels{ // ! this - "type": "web", - "test": "tekton", - "scan": "security", - }), - sg(labels{ // ! this - "type": "web", - "test": "tekton", - "input": "image", - }), - }, - expected: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - "test": "tekton", - "scan": "security", - }), - sg(labels{ - "type": "web", - "test": "tekton", - "input": "image", - }), - }, - }), - - Entry("exact match with no extras", testcase{ - source: lg(labels{ - "type": "web", - "test": "tekton", - "scan": "security", - }), - targets: []repository.SelectorGetter{ - sg(labels{ - "type": "----", - "test": "tekton", - "scan": "----", - }), - sg(labels{ // ! this - "type": "web", - "test": "tekton", - "scan": "security", - }), - sg(labels{ - "type": "web", - "test": "tekton", - "scan": "security", - "input": "image", - }), - }, - expected: []repository.SelectorGetter{ - sg(labels{ - "type": "web", - "test": "tekton", - "scan": "security", - }), - }, - }), - ) -}) diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index 0a567d4b7..8a8e4ee8a 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -327,14 +327,18 @@ func (r *repository) GetSupplyChainsForWorkload(ctx context.Context, workload *v return nil, fmt.Errorf("unable to list supply chains from api server: %w", err) } - var selectorGetters []SelectorGetter + var selectorGetters []Selector for _, item := range list.Items { - item := item - selectorGetters = append(selectorGetters, &item) + itemValue := item + selectorGetters = append(selectorGetters, &itemValue) } var supplyChains []*v1alpha1.ClusterSupplyChain - for _, matchingObject := range BestLabelMatches(workload, selectorGetters) { + matches, err := BestSelectorMatch(workload, selectorGetters) + if err != nil { + return nil, fmt.Errorf("evaluating supply chain selectors against workload [%s/%s] failed: %w", workload.Namespace, workload.Name, err) + } + for _, matchingObject := range matches { log.V(logger.DEBUG).Info("supply chain matched workload", "supply chain", matchingObject) supplyChains = append(supplyChains, matchingObject.(*v1alpha1.ClusterSupplyChain)) @@ -353,14 +357,18 @@ func (r *repository) GetDeliveriesForDeliverable(ctx context.Context, deliverabl return nil, fmt.Errorf("unable to list deliveries from api server: %w", err) } - var selectorGetters []SelectorGetter + var selectorGetters []Selector for _, item := range list.Items { - item := item - selectorGetters = append(selectorGetters, &item) + itemValue := item + selectorGetters = append(selectorGetters, &itemValue) } var deliveries []*v1alpha1.ClusterDelivery - for _, matchingObject := range BestLabelMatches(deliverable, selectorGetters) { + matches, err := BestSelectorMatch(deliverable, selectorGetters) + if err != nil { + return nil, fmt.Errorf("evaluating supply chain selectors against deliverable [%s/%s] failed: %w", deliverable.Namespace, deliverable.Name, err) + } + for _, matchingObject := range matches { log.V(logger.DEBUG).Info("delivery matched deliverable", "delivery", matchingObject) deliveries = append(deliveries, matchingObject.(*v1alpha1.ClusterDelivery)) diff --git a/pkg/repository/repository_test.go b/pkg/repository/repository_test.go index 2d823fff5..c7653e214 100644 --- a/pkg/repository/repository_test.go +++ b/pkg/repository/repository_test.go @@ -1092,6 +1092,46 @@ spec: }) Context("GetSupplyChainsForWorkload", func() { + Context("When a matchFields key is invalid", func() { + BeforeEach(func() { + var supplyChain = &v1alpha1.ClusterSupplyChain{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterSupplyChain", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "supplychain-name", + }, + Spec: v1alpha1.SupplyChainSpec{ + SelectorMatchFields: []v1alpha1.FieldSelectorRequirement{ + { + Key: "spec.env[asdfasdfadkf3", + Operator: "Exists", + }, + }, + }, + } + supplyChain.GetObjectKind() + clientObjects = []client.Object{supplyChain} + }) + + It("returns an error", func() { + workload := &v1alpha1.Workload{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "workload-name", + Namespace: "myNS", + Labels: map[string]string{"foo": "bar"}, + }, + Spec: v1alpha1.WorkloadSpec{}, + Status: v1alpha1.WorkloadStatus{}, + } + _, err := repo.GetSupplyChainsForWorkload(ctx, workload) + Expect(err).To(MatchError(ContainSubstring("evaluating supply chain selectors against workload [myNS/workload-name] failed"))) + Expect(err).To(MatchError(ContainSubstring("failed to evaluate all matched fields of [ClusterSupplyChain/supplychain-name]"))) + Expect(err).To(MatchError(ContainSubstring("unable to match field requirement with key [spec.env[asdfasdfadkf3] operator [Exists] values [[]]"))) + }) + }) + Context("One supply chain", func() { BeforeEach(func() { supplyChain := &v1alpha1.ClusterSupplyChain{ diff --git a/pkg/repository/selector_matcher.go b/pkg/repository/selector_matcher.go new file mode 100644 index 000000000..039c8163c --- /dev/null +++ b/pkg/repository/selector_matcher.go @@ -0,0 +1,129 @@ +// Copyright 2021 VMware +// +// 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. + +package repository + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" + "github.com/vmware-tanzu/cartographer/pkg/selector" +) + +// * handle errors correctly (panic!) + +// Selector interface is an object we can find selectors on, and identify in errors +// TODO: Refactor GetMatchLabels, GetMatchFields and GetMatchLabelExpressions as +// GetSelector() v1alpha1.Selector +// and make that return type: +// type Selector struct { +// metav1.LabelSelector +// SelectorMatchFields []v1alpha1.FieldSelectorRequirement +// } + +type Selector interface { + GetMatchLabels() labels.Set + GetMatchFields() []v1alpha1.FieldSelectorRequirement + GetMatchExpressions() []metav1.LabelSelectorRequirement + GetObjectKind() schema.ObjectKind + GetName() string +} + +type Selectable interface { + GetLabels() map[string]string +} + +// BestSelectorMatch attempts at finding the selectors that best match their selectors +// against the selectors. +func BestSelectorMatch(selectable Selectable, blueprints []Selector) ([]Selector, error) { + + if len(blueprints) == 0 { + return nil, nil + } + + var matchingSelectors = map[int][]Selector{} + var highWaterMark = 0 + + for _, target := range blueprints { + size := 0 + labelSelector := &metav1.LabelSelector{ + MatchLabels: target.GetMatchLabels(), + MatchExpressions: target.GetMatchExpressions(), + } + + // -- Labels + sel, err := metav1.LabelSelectorAsSelector(labelSelector) + if err != nil { + return nil, fmt.Errorf( + "selectorMatchExpressions or selectors of [%s/%s] are not valid: %w", + target.GetObjectKind().GroupVersionKind().Kind, + target.GetName(), + err, + ) + } + if !sel.Matches(labels.Set(selectable.GetLabels())) { + continue // Bail early! + } + + size += len(labelSelector.MatchLabels) + size += len(labelSelector.MatchExpressions) + + // -- Fields + matchFields := target.GetMatchFields() + allFieldsMatched, err := matchesAllFields(selectable, matchFields) + if err != nil { + // Todo: test in unit test + return nil, fmt.Errorf( + "failed to evaluate all matched fields of [%s/%s]: %w", + target.GetObjectKind().GroupVersionKind().Kind, + target.GetName(), + err, + ) + } + if !allFieldsMatched { + continue // Bail early! + } + size += len(matchFields) + + // -- decision time + if size > 0 { + if matchingSelectors[size] == nil { + matchingSelectors[size] = []Selector{} + } + if size > highWaterMark { + highWaterMark = size + } + matchingSelectors[size] = append(matchingSelectors[size], target) + } + } + + return matchingSelectors[highWaterMark], nil +} + +func matchesAllFields(source Selectable, fields []v1alpha1.FieldSelectorRequirement) (bool, error) { + for _, requirement := range fields { + match, err := selector.Matches(requirement, source) + if err != nil { + return false, fmt.Errorf("unable to match field requirement with key [%s] operator [%s] values [%v]: %w", requirement.Key, requirement.Operator, requirement.Values, err) + } + if !match { + return false, nil + } + } + return true, nil +} diff --git a/pkg/repository/selector_matcher_test.go b/pkg/repository/selector_matcher_test.go new file mode 100644 index 000000000..9511fc4d0 --- /dev/null +++ b/pkg/repository/selector_matcher_test.go @@ -0,0 +1,395 @@ +// Copyright 2021 VMware +// +// 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. + +package repository_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels2 "k8s.io/apimachinery/pkg/labels" + + "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" + "github.com/vmware-tanzu/cartographer/pkg/repository" +) + +var _ = Describe("BestSelectorMatch", func() { + + type testcase struct { + selectable repository.Selectable + selectors []repository.Selector + expectedSelectors []repository.Selector + } + + DescribeTable("cases", + func(tc testcase) { + actual, _ := repository.BestSelectorMatch( + tc.selectable, tc.selectors, + ) + + if tc.expectedSelectors == nil { + Expect(actual).To(BeNil()) + return + } + + Expect(actual).To(Equal(tc.expectedSelectors)) + }, + + // ---------- Label Selectors + + Entry("empty selectors", testcase{ + selectable: selectable{ + labels: labels2.Set{}}, + expectedSelectors: nil, + }), + + Entry("complete mismatched selectors & selectors", testcase{ + selectable: selectable{ + labels: labels2.Set{ + "type": "web", + }, + }, + selectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "my": "label", + }, + fields: fields{ + v1alpha1.FieldSelectorRequirement{ + Key: "Spec.libertyGibbet", + Operator: "Exists", + Values: nil, + }, + }, + }, + }, + expectedSelectors: nil, + }), + + Entry("partial match; selectors with less labels than selectors", testcase{ + selectable: selectable{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + }}, + selectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + }, + fields: fields{}, + }, + }, + expectedSelectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + }, + fields: fields{}, + }, + }, + }), + + Entry("partial match; selectors with less labels than target", testcase{ + selectable: selectable{ + labels: labels2.Set{ + "type": "web", + }}, + selectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + }, + fields: fields{}, + }, + }, + expectedSelectors: nil, + }), + + Entry("absolute match", testcase{ + selectable: selectable{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + }}, + selectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "webvalue", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ // ! this + "type": "web", + "test": "tekton", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ + "type": "mobile", + "test": "mobilevalue", + }, + fields: fields{}, + }, + }, + expectedSelectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + }, + fields: fields{}, + }, + }, + }), + + Entry("exact partial match", testcase{ + selectable: selectable{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + "scan": "security", + "input": "image", + }}, + selectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "atype", + "test": "tekton", + "scan": "ascan", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ // ! this + "type": "web", + "test": "tekton", + "scan": "security", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ // ! this + "type": "web", + "test": "tekton", + "input": "image", + }, + fields: fields{}, + }, + }, + expectedSelectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + "scan": "security", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + "input": "image", + }, + fields: fields{}, + }, + }, + }), + + Entry("exact match with no extras", testcase{ + selectable: selectable{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + "scan": "security", + }}, + selectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "atype", + "test": "tekton", + "scan": "ascan", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ // ! this + "type": "web", + "test": "tekton", + "scan": "security", + }, + fields: fields{}, + }, + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + "scan": "security", + "input": "image", + }, + fields: fields{}, + }, + }, + expectedSelectors: []repository.Selector{ + &selector{ + labels: labels2.Set{ + "type": "web", + "test": "tekton", + "scan": "security", + }, + fields: fields{}, + }, + }, + }), + + // ---------- Field Selectors + // TODO: Comprehensive testing!? Eesh? + + Entry("match selectors with many fields in selectors", testcase{ + selectable: selectable{ + Spec: Spec{ + Color: "red", + Age: 4, + }, + }, + selectors: []repository.Selector{ + &selector{ + fields: fields{ + { + Key: "Spec.Color", + Operator: "NotIn", + Values: []string{"green", "blue"}, + }, + }, + }, + }, + expectedSelectors: []repository.Selector{ + &selector{ + fields: fields{ + { + Key: "Spec.Color", + Operator: "NotIn", + Values: []string{"green", "blue"}, + }, + }, + }, + }, + }), + + // TODO: Error cases and handling with field context + ) + + Describe("malformed selectors", func() { + Context("label selector invalid", func() { + var sel []repository.Selector + BeforeEach(func() { + sel = []repository.Selector{ + &selector{ + TypeMeta: metav1.TypeMeta{ + Kind: "Test", + APIVersion: "testv1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-selector", + }, + labels: labels2.Set{ + "fred-": "derf-", + }, + }, + } + }) + + It("returns an error", func() { + _, err := repository.BestSelectorMatch(selectable{}, sel) + Expect(err).To(MatchError(ContainSubstring("selectorMatchExpressions or selectors of [Test/my-selector] are not valid"))) + Expect(err).To(MatchError(ContainSubstring("key: Invalid value"))) + }) + }) + + Context("expression selector invalid", func() { + var sel []repository.Selector + BeforeEach(func() { + sel = []repository.Selector{ + &selector{ + TypeMeta: metav1.TypeMeta{ + Kind: "Test", + APIVersion: "testv1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-selector", + }, + labels: nil, + expressions: []metav1.LabelSelectorRequirement{ + { + Key: "fred", + Operator: "Matchingest", + Values: nil, + }, + }, + fields: nil, + }, + } + }) + + It("returns an error", func() { + _, err := repository.BestSelectorMatch(selectable{}, sel) + Expect(err).To(MatchError(ContainSubstring("selectorMatchExpressions or selectors of [Test/my-selector] are not valid"))) + // TODO: 'pod' - Hmmmmm - perhaps we shouldn't be using v1 code? + Expect(err).To(MatchError(ContainSubstring("\"Matchingest\" is not a valid pod selector operator"))) + }) + }) + }) +}) + +type fields []v1alpha1.FieldSelectorRequirement + +type Spec struct { + Color string `json:"color"` + Age int `json:"age"` +} + +type selectable struct { + labels map[string]string + Spec `json:"spec"` +} + +func (o selectable) GetLabels() map[string]string { + return o.labels +} + +type selector struct { + metav1.TypeMeta + metav1.ObjectMeta + labels labels2.Set + expressions []metav1.LabelSelectorRequirement + fields +} + +func (b *selector) GetMatchExpressions() []metav1.LabelSelectorRequirement { + return b.expressions +} + +func (b *selector) GetMatchFields() []v1alpha1.FieldSelectorRequirement { + return b.fields +} + +func (b *selector) GetMatchLabels() labels2.Set { + return b.labels +} diff --git a/pkg/selector/selector.go b/pkg/selector/selector.go index ba1678dbf..ffa61223c 100644 --- a/pkg/selector/selector.go +++ b/pkg/selector/selector.go @@ -21,7 +21,7 @@ import ( "github.com/vmware-tanzu/cartographer/pkg/eval" ) -func Matches(req v1alpha1.FieldSelectorRequirement, context map[string]interface{}) (bool, error) { +func Matches(req v1alpha1.FieldSelectorRequirement, context interface{}) (bool, error) { evaluator := eval.EvaluatorBuilder() actualValue, err := evaluator.EvaluateJsonPath(req.Key, context) if err != nil { diff --git a/tests/integration/delivery/delivery_selection_test.go b/tests/integration/delivery/delivery_selection_test.go new file mode 100644 index 000000000..40b93ded3 --- /dev/null +++ b/tests/integration/delivery/delivery_selection_test.go @@ -0,0 +1,351 @@ +// Copyright 2021 VMware +// +// 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. + +// TODO: this test looks exactly like the Supply Chain delivery test (Coz it is) +// Make it a little more realistic for delivery/deliverable + +package delivery_test + +import ( + "context" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" + "github.com/vmware-tanzu/cartographer/pkg/utils" +) + +var _ = Describe("Delivery selection for deliverables", func() { + var ( + ctx context.Context + cleanups []client.Object + ) + + var apply = func(resourceYaml string) { + resource := &unstructured.Unstructured{} + err := yaml.Unmarshal([]byte(resourceYaml), resource) + Expect(err).NotTo(HaveOccurred()) + + err = c.Create(ctx, resource, &client.CreateOptions{}) + cleanups = append(cleanups, resource) + Expect(err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + ctx = context.Background() + + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterTemplate + metadata: + name: my-template + spec: + template: {} + `)) + + }) + + AfterEach(func() { + for _, obj := range cleanups { + _ = c.Delete(ctx, obj, &client.DeleteOptions{}) + } + }) + + // Scenario: Deliverable matches label on one Delivery + // Scenario: Deliverable matches expression on one Delivery + // Scenario: Deliverable matches field on one Delivery + + Describe("Deliverable does not match any of many deliveries", func() { + BeforeEach(func() { + // web-on-main-delivery: delivery that matches on type:web and is git and is on main [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: web-on-main-delivery + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // web-with-git-delivery: delivery that matches on type:web and is git [shouldn't match, less specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: web-with-git-delivery + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // job-delivery: delivery that matches on type:job [shouldn't match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: job-delivery + spec: + selector: + "type": "job" + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("I apply a deliverable with an image and label type:edgeIOT", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Deliverable + metadata: + labels: + type: edgeIOT + name: deliverable-with-image-for-edge-iot + namespace: %s + spec: + serviceAccountName: default + image: https://docker.io/samsplace/my-iot-image + `, testNS)) + }) + + It("does not match", func() { + Eventually(func() ([]metav1.Condition, error) { + deliverable := &v1alpha1.Deliverable{} + err := c.Get(ctx, client.ObjectKey{Name: "deliverable-with-image-for-edge-iot", Namespace: testNS}, deliverable) + return deliverable.Status.Conditions, err + }).Should( + ContainElements( + MatchFields(IgnoreExtras, Fields{ + "Type": Equal("DeliveryReady"), + "Status": Equal(metav1.ConditionFalse), + "Reason": Equal("DeliveryNotFound"), + "Message": MatchRegexp("^no delivery found where"), // Fixme: The error we emit is specific to fields and only one delivery. Needs review (post dev release) + }), + ), + ) + }) + }) + + }) + + Describe("Deliverable matches most specific delivery", func() { + BeforeEach(func() { + // web-on-main-delivery: delivery that matches on type:web and is git and is on main [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: web-on-main-delivery + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // web-with-git-delivery: delivery that matches on type:web and is git [shouldn't match, less specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: web-with-git-delivery + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // job-delivery: delivery that matches on type:job [shouldn't match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: job-delivery + spec: + selector: + "type": "job" + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("A deliverable with a git repository on main and a web label is applied", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Deliverable + metadata: + labels: + type: web + name: deliverable-on-main-for-web + namespace: %s + spec: + serviceAccountName: default + source: + git: + url: https://github.com/my-app.git + ref: + branch: main + `, testNS)) + }) + + It("matches the web-on-main-delivery delivery", func() { + Eventually(func() (v1alpha1.DeliverableStatus, error) { + deliverable := &v1alpha1.Deliverable{} + err := c.Get(ctx, client.ObjectKey{Name: "deliverable-on-main-for-web", Namespace: testNS}, deliverable) + return deliverable.Status, err + }).Should( + MatchFields(IgnoreExtras, Fields{ + "DeliveryRef": MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("ClusterDelivery"), + "Name": Equal("web-on-main-delivery"), + }), + }), + ) + }) + }) + }) + + Describe("Deliverable matches two 'most specific' deliveries", func() { // suggested domain term: `Most Machingest` + BeforeEach(func() { + // web-on-main-delivery: delivery that matches on type:web and is git and is on main [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: web-on-main-delivery + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // web-on-main-or-master-delivery: delivery that matches on type:web and is git and is on main or master [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterDelivery + metadata: + name: web-on-main-or-master-delivery + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main", "master"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + }) + When("A deliverable with a git repository on main and a web label is applied", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Deliverable + metadata: + labels: + type: web + name: deliverable-on-main-for-web + namespace: %s + spec: + serviceAccountName: default + source: + git: + url: https://github.com/my-app.git + ref: + branch: main + `, testNS)) + }) + + It("matches the web-on-main-delivery delivery", func() { + Eventually(func() ([]metav1.Condition, error) { + deliverable := &v1alpha1.Deliverable{} + err := c.Get(ctx, client.ObjectKey{Name: "deliverable-on-main-for-web", Namespace: testNS}, deliverable) + return deliverable.Status.Conditions, err + }).Should( + ContainElements( + MatchFields(IgnoreExtras, Fields{ + "Type": Equal("DeliveryReady"), + "Status": Equal(metav1.ConditionFalse), + "Reason": Equal("MultipleDeliveryMatches"), + "Message": Equal("deliverable may only match a single delivery's selector"), + }), + ), + ) + }) + }) + }) +}) diff --git a/tests/integration/supplychain/supply_chain_selection_test.go b/tests/integration/supplychain/supply_chain_selection_test.go new file mode 100644 index 000000000..edd22b7d0 --- /dev/null +++ b/tests/integration/supplychain/supply_chain_selection_test.go @@ -0,0 +1,506 @@ +// Copyright 2021 VMware +// +// 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. + +package supplychain_test + +import ( + "context" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/vmware-tanzu/cartographer/pkg/apis/v1alpha1" + "github.com/vmware-tanzu/cartographer/pkg/utils" +) + +var _ = Describe("Supply Chain selection for workloads", func() { + var ( + ctx context.Context + cleanups []client.Object + ) + + var apply = func(resourceYaml string) { + resource := &unstructured.Unstructured{} + err := yaml.Unmarshal([]byte(resourceYaml), resource) + Expect(err).NotTo(HaveOccurred()) + + err = c.Create(ctx, resource, &client.CreateOptions{}) + cleanups = append(cleanups, resource) + Expect(err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + ctx = context.Background() + + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterTemplate + metadata: + name: my-template + spec: + template: {} + `)) + + }) + + AfterEach(func() { + for _, obj := range cleanups { + _ = c.Delete(ctx, obj, &client.DeleteOptions{}) + } + }) + + Describe("Workload matches one SupplyChain based on label", func() { + BeforeEach(func() { + // job-sc: supply chain that matches on type:job via selector [should match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: job-sc + spec: + selector: + "type": "job" + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("I apply a workload with label type:job", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Workload + metadata: + labels: + type: job + name: workload-with-type-job + namespace: %s + spec: + serviceAccountName: default + image: https://docker.io/samsplace/my-iot-image + `, testNS)) + }) + + It("matches", func() { + Eventually(func() (v1alpha1.WorkloadStatus, error) { + workload := &v1alpha1.Workload{} + err := c.Get(ctx, client.ObjectKey{Name: "workload-with-type-job", Namespace: testNS}, workload) + return workload.Status, err + }).Should( + MatchFields(IgnoreExtras, Fields{ + "SupplyChainRef": MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("ClusterSupplyChain"), + "Name": Equal("job-sc"), + }), + }), + ) + }) + }) + }) + + Describe("Workload matches one SupplyChain based on selectorMatchExpressions", func() { + BeforeEach(func() { + // job-via-match-expression-sc: supply chain that matches on type:job via selectorMatchExpressions [should match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: job-via-match-expression-sc + spec: + selectorMatchExpressions: + - { key: "type", operator: "In", values: [ "job" ]} + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("I apply a workload with label type:job", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Workload + metadata: + labels: + type: job + name: workload-with-type-job + namespace: %s + spec: + serviceAccountName: default + image: https://docker.io/samsplace/my-iot-image + `, testNS)) + }) + + It("matches", func() { + Eventually(func() (v1alpha1.WorkloadStatus, error) { + workload := &v1alpha1.Workload{} + err := c.Get(ctx, client.ObjectKey{Name: "workload-with-type-job", Namespace: testNS}, workload) + return workload.Status, err + }).Should( + MatchFields(IgnoreExtras, Fields{ + "SupplyChainRef": MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("ClusterSupplyChain"), + "Name": Equal("job-via-match-expression-sc"), + }), + }), + ) + }) + }) + }) + + Describe("Workload matches one SupplyChain based on selectorMatchFields", func() { + BeforeEach(func() { + // job-via-match-fields-sc: supply chain that matches on type:job via selectorMatchFields [should match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: job-via-match-fields-sc + spec: + selectorMatchFields: + - { key: "metadata.labels.type", operator: "In", values: [ "job" ]} + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("I apply a workload with label type:job", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Workload + metadata: + labels: + type: job + name: workload-with-type-job + namespace: %s + spec: + serviceAccountName: default + image: https://docker.io/samsplace/my-iot-image + `, testNS)) + }) + + It("matches", func() { + Eventually(func() (v1alpha1.WorkloadStatus, error) { + workload := &v1alpha1.Workload{} + err := c.Get(ctx, client.ObjectKey{Name: "workload-with-type-job", Namespace: testNS}, workload) + return workload.Status, err + }).Should( + MatchFields(IgnoreExtras, Fields{ + "SupplyChainRef": MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("ClusterSupplyChain"), + "Name": Equal("job-via-match-fields-sc"), + }), + }), + ) + }) + }) + }) + + Describe("Workload does not match any of many supply chains", func() { + BeforeEach(func() { + // web-on-main-sc: supply chain that matches on type:web and is git and is on main [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: web-on-main-sc + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // web-with-git-sc: supply chain that matches on type:web and is git [shouldn't match, less specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: web-with-git-sc + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // job-sc: supply chain that matches on type:job [shouldn't match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: job-sc + spec: + selector: + "type": "job" + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("I apply a workload with an image and label type:edgeIOT", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Workload + metadata: + labels: + type: edgeIOT + name: workload-with-image-for-edge-iot + namespace: %s + spec: + serviceAccountName: default + image: https://docker.io/samsplace/my-iot-image + `, testNS)) + }) + + It("does not match", func() { + Eventually(func() ([]metav1.Condition, error) { + workload := &v1alpha1.Workload{} + err := c.Get(ctx, client.ObjectKey{Name: "workload-with-image-for-edge-iot", Namespace: testNS}, workload) + return workload.Status.Conditions, err + }).Should( + ContainElements( + MatchFields(IgnoreExtras, Fields{ + "Type": Equal("SupplyChainReady"), + "Status": Equal(metav1.ConditionFalse), + "Reason": Equal("SupplyChainNotFound"), + "Message": MatchRegexp("^no supply chain found where"), // Fixme: The error we emit is specific to fields and only one supply chain. Needs review (post dev release) + }), + ), + ) + }) + }) + + }) + + Describe("Workload matches most specific supply chain", func() { + BeforeEach(func() { + // web-on-main-sc: supply chain that matches on type:web and is git and is on main [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: web-on-main-sc + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // web-with-git-sc: supply chain that matches on type:web and is git [shouldn't match, less specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: web-with-git-sc + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // job-sc: supply chain that matches on type:job [shouldn't match] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: job-sc + spec: + selector: + "type": "job" + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + }) + + When("A workload with a git repository on main and a web label is applied", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Workload + metadata: + labels: + type: web + name: workload-on-main-for-web + namespace: %s + spec: + serviceAccountName: default + source: + git: + url: https://github.com/my-app.git + ref: + branch: main + `, testNS)) + }) + + It("matches the web-on-main-sc supply chain", func() { + Eventually(func() (v1alpha1.WorkloadStatus, error) { + workload := &v1alpha1.Workload{} + err := c.Get(ctx, client.ObjectKey{Name: "workload-on-main-for-web", Namespace: testNS}, workload) + return workload.Status, err + }).Should( + MatchFields(IgnoreExtras, Fields{ + "SupplyChainRef": MatchFields(IgnoreExtras, Fields{ + "Kind": Equal("ClusterSupplyChain"), + "Name": Equal("web-on-main-sc"), + }), + }), + ) + }) + }) + }) + + Describe("Workload matches two 'most specific' supply chains", func() { // suggested domain term: `Most Machingest` + BeforeEach(func() { + // web-on-main-sc: supply chain that matches on type:web and is git and is on main [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: web-on-main-sc + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + // web-on-main-or-master-sc: supply chain that matches on type:web and is git and is on main or master [should match, most specific] + apply(utils.HereYaml(` + --- + apiVersion: carto.run/v1alpha1 + kind: ClusterSupplyChain + metadata: + name: web-on-main-or-master-sc + spec: + selector: + "type": "web" + selectorMatchFields: + - { key: "spec.source.git", operator: "Exists" } + - { key: "spec.source.git.ref.branch", operator: "In", values: ["main", "master"] } + resources: + - name: my-first-resource + templateRef: + kind: ClusterTemplate + name: my-template + `)) + + }) + When("A workload with a git repository on main and a web label is applied", func() { + BeforeEach(func() { + apply(utils.HereYamlF(` + --- + apiVersion: carto.run/v1alpha1 + kind: Workload + metadata: + labels: + type: web + name: workload-on-main-for-web + namespace: %s + spec: + serviceAccountName: default + source: + git: + url: https://github.com/my-app.git + ref: + branch: main + `, testNS)) + }) + + It("matches the web-on-main-sc supply chain", func() { + Eventually(func() ([]metav1.Condition, error) { + workload := &v1alpha1.Workload{} + err := c.Get(ctx, client.ObjectKey{Name: "workload-on-main-for-web", Namespace: testNS}, workload) + return workload.Status.Conditions, err + }).Should( + ContainElements( + MatchFields(IgnoreExtras, Fields{ + "Type": Equal("SupplyChainReady"), + "Status": Equal(metav1.ConditionFalse), + "Reason": Equal("MultipleSupplyChainMatches"), + "Message": Equal("workload may only match a single supply chain's selector"), + }), + ), + ) + }) + }) + }) +})