diff --git a/v2/pkg/genruntime/configmaps.go b/v2/pkg/genruntime/configmaps.go index 902ff8f0233..b9d120c3c44 100644 --- a/v2/pkg/genruntime/configmaps.go +++ b/v2/pkg/genruntime/configmaps.go @@ -59,19 +59,17 @@ func (s NamespacedConfigMapReference) String() string { } // ConfigMapDestination describes the location to store a single configmap value -// Note: This is similar to SecretDestination in secrets.go. Changes to one should likely also be made to the other. +// Note: This is similar to: SecretDestination in secrets.go. +// Changes to one may need to be made to the others as well. type ConfigMapDestination struct { - // Note: We could embed ConfigMapReference here, but it makes our life harder because then our reflection based tools will "find" ConfigMapReferences's - // inside of ConfigMapDestination and try to resolve them. It also gives a worse experience when using the Go Types (the YAML is the same either way). - - // Name is the name of the Kubernetes ConfigMap being referenced. - // The ConfigMap must be in the same namespace as the resource + // Name is the name of the Kubernetes ConfigMap to write to. + // The ConfigMap will be created in the same namespace as the resource. // +kubebuilder:validation:Required - Name string `json:"name"` + Name string `json:"name,omitempty"` // Key is the key in the ConfigMap being referenced // +kubebuilder:validation:Required - Key string `json:"key"` + Key string `json:"key,omitempty"` // This is a type separate from ConfigMapReference as in the future we may want to support things like // customizable annotations or labels, instructions to not delete the ConfigMap when the resource is diff --git a/v2/pkg/genruntime/configmaps/export.go b/v2/pkg/genruntime/configmaps/export.go new file mode 100644 index 00000000000..2d02191bf6a --- /dev/null +++ b/v2/pkg/genruntime/configmaps/export.go @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package configmaps + +import ( + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/core" +) + +// Exporter defines an interface for exporting ConfigMaps based on CEL expressions. +type Exporter interface { + ConfigMapDestinationExpressions() []*core.DestinationExpression +} diff --git a/v2/pkg/genruntime/configmaps/validation.go b/v2/pkg/genruntime/configmaps/validation.go index 5ee7b7c8593..9c97ae5dba2 100644 --- a/v2/pkg/genruntime/configmaps/validation.go +++ b/v2/pkg/genruntime/configmaps/validation.go @@ -11,6 +11,7 @@ import ( "github.com/Azure/azure-service-operator/v2/internal/set" "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/core" ) type keyPair struct { @@ -21,6 +22,14 @@ type keyPair struct { // ValidateDestinations checks that no two destinations are writing to the same configmap/key, as that could cause // those values to overwrite one another. func ValidateDestinations(destinations []*genruntime.ConfigMapDestination) (admission.Warnings, error) { + return ValidateDestinationsExt(destinations, nil) +} + +// TODO: ValidateDestinationsExt will replace ValidateDestinations in a future PR. +func ValidateDestinationsExt( + destinations []*genruntime.ConfigMapDestination, + destinationExpressions []*core.DestinationExpression, +) (admission.Warnings, error) { locations := set.Make[keyPair]() for _, dest := range destinations { @@ -39,6 +48,28 @@ func ValidateDestinations(destinations []*genruntime.ConfigMapDestination) (admi locations.Add(pair) } + for _, dest := range destinationExpressions { + if dest == nil { + continue + } + + if dest.Key == "" { + // TODO: Key may be empty because of map[string]string supported exports. + // TODO: We should validate that in more depth but need a CEL parser to do so. + continue + } + + pair := keyPair{ + name: dest.Name, + key: dest.Key, + } + if locations.Contains(pair) { + return nil, errors.Errorf("cannot write more than one configmap value to destination %s", dest.String()) + } + + locations.Add(pair) + } + return nil, nil } diff --git a/v2/pkg/genruntime/configmaps/validation_test.go b/v2/pkg/genruntime/configmaps/validation_test.go new file mode 100644 index 00000000000..ab49d477b12 --- /dev/null +++ b/v2/pkg/genruntime/configmaps/validation_test.go @@ -0,0 +1,177 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package configmaps_test + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/configmaps" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/core" +) + +func Test_ValidateConfigMapDestination_EmptyListValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + warnings, err := configmaps.ValidateDestinationsExt(nil, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestination_ListWithNilElementsValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.ConfigMapDestination{ + nil, + nil, + } + + warnings, err := configmaps.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestinationExpressions_ListWithNilElementsValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + nil, + nil, + } + + warnings, err := configmaps.ValidateDestinationsExt(nil, destinations) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestination_LengthOneListValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.ConfigMapDestination{ + {Name: "n1", Key: "key1"}, + } + + warnings, err := configmaps.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestinationExpressions_LengthOneListValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + } + + warnings, err := configmaps.ValidateDestinationsExt(nil, destinations) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestination_ListWithoutCollisionsValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.ConfigMapDestination{ + {Name: "n1", Key: "key1"}, + {Name: "n1", Key: "key2"}, + {Name: "n1", Key: "key3"}, + {Name: "n1", Key: "key4"}, + } + + warnings, err := configmaps.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestinationExpressions_ListWithoutCollisionsValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + {Name: "n1", Key: "key2", Value: "resource.status.id"}, + {Name: "n1", Key: "key3", Value: "resource.status.id"}, + {Name: "n1", Key: "key4", Value: "resource.status.id"}, + } + + warnings, err := configmaps.ValidateDestinationsExt(nil, destinations) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestination_ListWithDifferentCasesValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.ConfigMapDestination{ + {Name: "n1", Key: "key1"}, + {Name: "n1", Key: "Key1"}, + {Name: "n1", Key: "key3"}, + {Name: "n1", Key: "key4"}, + } + + warnings, err := configmaps.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateConfigMapDestination_ListWithCollisionsFailsValidation(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.ConfigMapDestination{ + {Name: "n1", Key: "key1"}, + {Name: "n2", Key: "key1"}, + {Name: "n3", Key: "key1"}, + {Name: "n1", Key: "key1"}, + } + _, err := configmaps.ValidateDestinationsExt(destinations, nil) + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(Equal("cannot write more than one configmap value to destination Name: \"n1\", Key: \"key1\"")) +} + +func Test_ValidateConfigMapDestinationAndExpressions_CollisionBetweenEachFailsValidation(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.ConfigMapDestination{ + {Name: "n3", Key: "key1"}, + {Name: "n4", Key: "key1"}, + {Name: "n5", Key: "key1"}, + } + + destinationExpressions := []*core.DestinationExpression{ + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + {Name: "n2", Key: "key1", Value: "resource.status.id"}, + {Name: "n3", Key: "key1", Value: "resource.status.id"}, + } + + _, err := configmaps.ValidateDestinationsExt(destinations, destinationExpressions) + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(Equal("cannot write more than one configmap value to destination Name: \"n3\", Key: \"key1\", Value: \"resource.status.id\"")) +} + +func Test_ValidateConfigMapDestinationExpressions_EmptyKeyIgnored(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + {Name: "n1", Value: "resource.status.id"}, + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + } + + warnings, err := configmaps.ValidateDestinationsExt(nil, destinations) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} diff --git a/v2/pkg/genruntime/core/destination_expression.go b/v2/pkg/genruntime/core/destination_expression.go new file mode 100644 index 00000000000..e92c2e3b3aa --- /dev/null +++ b/v2/pkg/genruntime/core/destination_expression.go @@ -0,0 +1,35 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package core + +import ( + "fmt" +) + +// DestinationExpression is a CEL expression and a destination to store the result in. The destination may +// be a secret or a configmap. The value of the expression is stored at the specified location in +// the destination. +// +kubebuilder:object:generate=true +type DestinationExpression struct { + // Name is the name of the Kubernetes configmap or secret to write to. + // The configmap or secret will be created in the same namespace as the resource. + // +kubebuilder:validation:Required + Name string `json:"name,omitempty"` + + // Key is the key in the ConfigMap or Secret being written to. If the CEL expression in Value returns a string + // this is required to identify what key to write to. If the CEL expression in Value returns a map[string]string + // Key must not be set, instead the keys written will be determined dynamically based on the keys of the resulting + // map[string]string. + Key string `json:"key,omitempty"` + + // Value is a CEL expression. The CEL expression may return a string or a map[string]string. For more information see TODO (improve this) + // +kubebuilder:validation:Required + Value string `json:"value,omitempty"` +} + +func (s DestinationExpression) String() string { + return fmt.Sprintf("Name: %q, Key: %q, Value: %q", s.Name, s.Key, s.Value) +} diff --git a/v2/pkg/genruntime/core/zz_generated.deepcopy.go b/v2/pkg/genruntime/core/zz_generated.deepcopy.go new file mode 100644 index 00000000000..51116be076c --- /dev/null +++ b/v2/pkg/genruntime/core/zz_generated.deepcopy.go @@ -0,0 +1,25 @@ +//go:build !ignore_autogenerated + +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package core + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DestinationExpression) DeepCopyInto(out *DestinationExpression) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DestinationExpression. +func (in *DestinationExpression) DeepCopy() *DestinationExpression { + if in == nil { + return nil + } + out := new(DestinationExpression) + in.DeepCopyInto(out) + return out +} diff --git a/v2/pkg/genruntime/secrets.go b/v2/pkg/genruntime/secrets.go index cb390b9f673..823309d22a9 100644 --- a/v2/pkg/genruntime/secrets.go +++ b/v2/pkg/genruntime/secrets.go @@ -111,19 +111,17 @@ func (s NamespacedSecretMapReference) String() string { } // SecretDestination describes the location to store a single secret value. -// Note: This is similar to ConfigMapDestination in configmaps.go. Changes to one should likely also be made to the other. +// Note: This is similar to: ConfigMapDestination in configmaps.go. +// Changes to one may need to be made to the others as well. type SecretDestination struct { - // Note: We could embed SecretReference here, but it makes our life harder because then our reflection based tools will "find" SecretReference's - // inside of SecretDestination and try to resolve them. It also gives a worse experience when using the Go Types (the YAML is the same either way). - - // Name is the name of the Kubernetes secret being referenced. - // The secret must be in the same namespace as the resource + // Name is the name of the Kubernetes secret to write to. + // The secret will be created in the same namespace as the resource. // +kubebuilder:validation:Required - Name string `json:"name"` + Name string `json:"name,omitempty"` - // Key is the key in the Kubernetes secret being referenced + // Key is the key in the Kubernetes secret being referenced. // +kubebuilder:validation:Required - Key string `json:"key"` + Key string `json:"key,omitempty"` // This is a type separate from SecretReference as in the future we may want to support things like // customizable annotations or labels, instructions to not delete the secret when the resource is diff --git a/v2/pkg/genruntime/secrets/export.go b/v2/pkg/genruntime/secrets/export.go new file mode 100644 index 00000000000..b88de7e3ca9 --- /dev/null +++ b/v2/pkg/genruntime/secrets/export.go @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +package secrets + +import ( + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/core" +) + +// Exporter defines an interface for exporting Secrets based on CEL expressions. +type Exporter interface { + SecretDestinationExpressions() []*core.DestinationExpression +} diff --git a/v2/pkg/genruntime/secrets/validation.go b/v2/pkg/genruntime/secrets/validation.go index 8c7a339248b..75f8a6955c5 100644 --- a/v2/pkg/genruntime/secrets/validation.go +++ b/v2/pkg/genruntime/secrets/validation.go @@ -11,6 +11,7 @@ import ( "github.com/Azure/azure-service-operator/v2/internal/set" "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/core" ) type keyPair struct { @@ -21,6 +22,14 @@ type keyPair struct { // ValidateDestinations checks that no two destinations are writing to the same secret/key, as that could cause // those secrets to overwrite one another. func ValidateDestinations(destinations []*genruntime.SecretDestination) (admission.Warnings, error) { + return ValidateDestinationsExt(destinations, nil) +} + +// TODO: ValidateDestinationsExt will replace ValidateDestinations in a future PR. +func ValidateDestinationsExt( + destinations []*genruntime.SecretDestination, + destinationExpressions []*core.DestinationExpression, +) (admission.Warnings, error) { // Map of secret -> keys locations := set.Make[keyPair]() @@ -40,5 +49,27 @@ func ValidateDestinations(destinations []*genruntime.SecretDestination) (admissi locations.Add(pair) } + for _, dest := range destinationExpressions { + if dest == nil { + continue + } + + if dest.Key == "" { + // TODO: Key may be empty because of map[string]string supported exports. + // TODO: We should validate that in more depth but need a CEL parser to do so. + continue + } + + pair := keyPair{ + name: dest.Name, + key: dest.Key, + } + if locations.Contains(pair) { + return nil, errors.Errorf("cannot write more than one secret to destination %s", dest.String()) + } + + locations.Add(pair) + } + return nil, nil } diff --git a/v2/pkg/genruntime/secrets/validation_test.go b/v2/pkg/genruntime/secrets/validation_test.go index d65f162cb71..16cc489cd97 100644 --- a/v2/pkg/genruntime/secrets/validation_test.go +++ b/v2/pkg/genruntime/secrets/validation_test.go @@ -11,6 +11,7 @@ import ( . "github.com/onsi/gomega" "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime/core" "github.com/Azure/azure-service-operator/v2/pkg/genruntime/secrets" ) @@ -18,7 +19,7 @@ func Test_ValidateSecretDestination_EmptyListValidates(t *testing.T) { t.Parallel() g := NewGomegaWithT(t) - warnings, err := secrets.ValidateDestinations(nil) + warnings, err := secrets.ValidateDestinationsExt(nil, nil) g.Expect(warnings).To(BeNil()) g.Expect(err).To(BeNil()) } @@ -32,7 +33,21 @@ func Test_ValidateSecretDestination_ListWithNilElementsValidates(t *testing.T) { nil, } - warnings, err := secrets.ValidateDestinations(destinations) + warnings, err := secrets.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateSecretDestinationExpressions_ListWithNilElementsValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + nil, + nil, + } + + warnings, err := secrets.ValidateDestinationsExt(nil, destinations) g.Expect(warnings).To(BeNil()) g.Expect(err).To(BeNil()) } @@ -45,7 +60,20 @@ func Test_ValidateSecretDestination_LengthOneListValidates(t *testing.T) { {Name: "n1", Key: "key1"}, } - warnings, err := secrets.ValidateDestinations(destinations) + warnings, err := secrets.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateSecretDestinationExpressions_LengthOneListValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + } + + warnings, err := secrets.ValidateDestinationsExt(nil, destinations) g.Expect(warnings).To(BeNil()) g.Expect(err).To(BeNil()) } @@ -61,7 +89,23 @@ func Test_ValidateSecretDestination_ListWithoutCollisionsValidates(t *testing.T) {Name: "n1", Key: "key4"}, } - warnings, err := secrets.ValidateDestinations(destinations) + warnings, err := secrets.ValidateDestinationsExt(destinations, nil) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +} + +func Test_ValidateSecretDestinationExpressions_ListWithoutCollisionsValidates(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + {Name: "n1", Key: "key2", Value: "resource.status.id"}, + {Name: "n1", Key: "key3", Value: "resource.status.id"}, + {Name: "n1", Key: "key4", Value: "resource.status.id"}, + } + + warnings, err := secrets.ValidateDestinationsExt(nil, destinations) g.Expect(warnings).To(BeNil()) g.Expect(err).To(BeNil()) } @@ -77,7 +121,7 @@ func Test_ValidateSecretDestination_ListWithDifferentCasesValidates(t *testing.T {Name: "n1", Key: "key4"}, } - warnings, err := secrets.ValidateDestinations(destinations) + warnings, err := secrets.ValidateDestinationsExt(destinations, nil) g.Expect(warnings).To(BeNil()) g.Expect(err).To(BeNil()) } @@ -92,7 +136,43 @@ func Test_ValidateSecretDestination_ListWithCollisionsFailsValidation(t *testing {Name: "n3", Key: "key1"}, {Name: "n1", Key: "key1"}, } - _, err := secrets.ValidateDestinations(destinations) + + _, err := secrets.ValidateDestinationsExt(destinations, nil) g.Expect(err).ToNot(BeNil()) g.Expect(err.Error()).To(Equal("cannot write more than one secret to destination Name: \"n1\", Key: \"key1\"")) } + +func Test_ValidateSecretDestinationAndExpressions_CollisionBetweenEachFailsValidation(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*genruntime.SecretDestination{ + {Name: "n3", Key: "key1"}, + {Name: "n4", Key: "key1"}, + {Name: "n5", Key: "key1"}, + } + + destinationExpressions := []*core.DestinationExpression{ + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + {Name: "n2", Key: "key1", Value: "resource.status.id"}, + {Name: "n3", Key: "key1", Value: "resource.status.id"}, + } + + _, err := secrets.ValidateDestinationsExt(destinations, destinationExpressions) + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(Equal("cannot write more than one secret to destination Name: \"n3\", Key: \"key1\", Value: \"resource.status.id\"")) +} + +func Test_ValidateSecretDestinationExpressions_EmptyKeyIgnored(t *testing.T) { + t.Parallel() + g := NewGomegaWithT(t) + + destinations := []*core.DestinationExpression{ + {Name: "n1", Value: "resource.status.id"}, + {Name: "n1", Key: "key1", Value: "resource.status.id"}, + } + + warnings, err := secrets.ValidateDestinationsExt(nil, destinations) + g.Expect(warnings).To(BeNil()) + g.Expect(err).To(BeNil()) +}