diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index 617397a2648..af8c65cf72b 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -1968,6 +1968,14 @@ spec: cluster. If empty, a new resource group will created for the cluster. type: string + userTags: + additionalProperties: + type: string + description: UserTags has additional keys and values that the + installer will add as tags to all resources that it creates. + Resources created by the cluster itself may not include these + tags. + type: object virtualNetwork: description: VirtualNetwork specifies the name of an existing VNet for the installer to use diff --git a/pkg/explain/printer_test.go b/pkg/explain/printer_test.go index 85fe104febf..4f0f676107d 100644 --- a/pkg/explain/printer_test.go +++ b/pkg/explain/printer_test.go @@ -209,6 +209,9 @@ func Test_PrintFields(t *testing.T) { resourceGroupName ResourceGroupName is the name of an already existing resource group where the cluster should be installed. This resource group should only be used for this specific cluster and the cluster components will assume ownership of all resources in the resource group. Destroying the cluster using installer will delete this resource group. This resource group must be empty with no other resources when trying to use it for creating a cluster. If empty, a new resource group will created for the cluster. + userTags + UserTags has additional keys and values that the installer will add as tags to all resources that it creates. Resources created by the cluster itself may not include these tags. + virtualNetwork VirtualNetwork specifies the name of an existing VNet for the installer to use`, }, { diff --git a/pkg/types/azure/platform.go b/pkg/types/azure/platform.go index 61377e8bff5..cec872087a7 100644 --- a/pkg/types/azure/platform.go +++ b/pkg/types/azure/platform.go @@ -86,6 +86,12 @@ type Platform struct { // // +optional ResourceGroupName string `json:"resourceGroupName,omitempty"` + + // UserTags has additional keys and values that the installer will add + // as tags to all resources that it creates. Resources created by the + // cluster itself may not include these tags. + // +optional + UserTags map[string]string `json:"userTags,omitempty"` } // CloudEnvironment is the name of the Azure cloud environment diff --git a/pkg/types/azure/validation/platform.go b/pkg/types/azure/validation/platform.go index c1a90bf3192..171c695a9cf 100644 --- a/pkg/types/azure/validation/platform.go +++ b/pkg/types/azure/validation/platform.go @@ -2,6 +2,7 @@ package validation import ( "fmt" + "regexp" "sort" "k8s.io/apimachinery/pkg/util/validation/field" @@ -28,6 +29,21 @@ var ( }() ) +var ( + // tagKeyRegex is for verifying that the tag key contains only allowed characters. + tagKeyRegex = regexp.MustCompile(`^[a-zA-Z][0-9A-Za-z_.=+\-@]{1,127}$`) + + // tagValueRegex is for verifying that the tag value contains only allowed characters. + tagValueRegex = regexp.MustCompile(`^[0-9A-Za-z_.=+\-@]{1,256}$`) + + // tagKeyPrefixRegex is for verifying that the tag value does not contain restricted prefixes. + tagKeyPrefixRegex = regexp.MustCompile(`^(?i)(name$|kubernetes\.io|openshift\.io|microsoft|azure|windows)`) +) + +// maxUserTagLimit is the maximum userTags that can be configured as defined in openshift/api. +// https://github.com/openshift/api/blob/068483260288d83eac56053e202761b1702d46f5/config/v1/types_infrastructure.go#L482-L488 +const maxUserTagLimit = 10 + // ValidatePlatform checks that the specified platform is valid. func ValidatePlatform(p *azure.Platform, publish types.PublishingStrategy, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} @@ -72,6 +88,9 @@ func ValidatePlatform(p *azure.Platform, publish types.PublishingStrategy, fldPa allErrs = append(allErrs, field.Invalid(fldPath.Child("outboundType"), p.OutboundType, fmt.Sprintf("%s is only allowed when installing to pre-existing network", azure.UserDefinedRoutingOutboundType))) } + // check if configured userTags are valid. + allErrs = append(allErrs, validateUserTags(p.UserTags, fldPath.Child("userTags"))...) + switch cloud := p.CloudName; cloud { case azure.StackCloud: allErrs = append(allErrs, validateAzureStack(p, fldPath)...) @@ -87,6 +106,48 @@ func ValidatePlatform(p *azure.Platform, publish types.PublishingStrategy, fldPa return allErrs } +// validateUserTags verifies if configured number of UserTags is not more than +// allowed limit and the tag keys and values are valid. +func validateUserTags(tags map[string]string, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + if len(tags) == 0 { + return allErrs + } + + if len(tags) > maxUserTagLimit { + allErrs = append(allErrs, field.TooMany(fldPath, len(tags), maxUserTagLimit)) + } + + for key, value := range tags { + if err := validateTag(key, value); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Key(key), value, err.Error())) + } + } + return allErrs +} + +// validateTag checks the following to ensure that the tag configured is acceptable. +// - The key and value contain only allowed characters. +// - The key is not empty and at most 128 characters and starts with an alphabet. +// - The value is not empty and at most 256 characters. +// Note: Although azure allows empty value, the tags may be applied to resources +// in services that do not accept empty tag values. Consequently, OpenShift cannot +// accept empty tag values. +// - The key cannot be Name or have kubernetes.io, openshift.io, microsoft, azure, +// windows prefixes. +func validateTag(key, value string) error { + if !tagKeyRegex.MatchString(key) { + return fmt.Errorf("key is invalid or contains invalid characters") + } + if !tagValueRegex.MatchString(value) { + return fmt.Errorf("value is invalid or contains invalid characters") + } + if tagKeyPrefixRegex.MatchString(key) { + return fmt.Errorf("key contains restricted prefix") + } + return nil +} + var ( validOutboundTypes = map[azure.OutboundType]struct{}{ azure.LoadbalancerOutboundType: {}, diff --git a/pkg/types/azure/validation/platform_test.go b/pkg/types/azure/validation/platform_test.go index 7b36c52b5d6..36c4f84bcc3 100644 --- a/pkg/types/azure/validation/platform_test.go +++ b/pkg/types/azure/validation/platform_test.go @@ -167,3 +167,107 @@ func TestValidatePlatform(t *testing.T) { }) } } + +func TestValidateUserTags(t *testing.T) { + fieldPath := "spec.platform.azure.userTags" + cases := []struct { + name string + userTags map[string]string + wantErr bool + }{ + { + name: "userTags not configured", + userTags: map[string]string{}, + wantErr: false, + }, + { + name: "userTags configured", + userTags: map[string]string{ + "key1": "value1", "key_2": "value_2", "key.3": "value.3", "key=4": "value=4", "key+5": "value+5", + "key-6": "value-6", "key@7": "value@7", "key8_": "value8-", "key9=": "value9+", "key10-": "value10@"}, + wantErr: false, + }, + { + name: "userTags configured is more than max limit", + userTags: map[string]string{ + "key1": "value1", "key2": "value2", "key3": "value3", "key4": "value4", "key5": "value5", + "key6": "value6", "key7": "value7", "key8": "value8", "key9": "value9", "key10": "value10", + "key11": "value11"}, + wantErr: true, + }, + { + name: "userTags contains key starting a number", + userTags: map[string]string{"1key": "1value"}, + wantErr: true, + }, + { + name: "userTags contains empty key", + userTags: map[string]string{"": "value"}, + wantErr: true, + }, + { + name: "userTags contains key length greater than 128", + userTags: map[string]string{ + "thisisaverylongkeywithmorethan128characterswhichisnotallowedforazureresourcetagkeysandthetagkeyvalidationshouldfailwithinvalidfieldvalueerror": "value"}, + wantErr: true, + }, + { + name: "userTags contains key with invalid character", + userTags: map[string]string{"key/test": "value"}, + wantErr: true, + }, + { + name: "userTags contains value length greater than 256", + userTags: map[string]string{"key": "thisisaverylongvaluewithmorethan256characterswhichisnotallowedforazureresourcetagvaluesandthetagvaluevalidationshouldfailwithinvalidfieldvalueerrorrepeatthisisaverylongvaluewithmorethan256characterswhichisnotallowedforazureresourcetagvaluesandthetagvaluevalidationshouldfailwithinvalidfieldvalueerror"}, + wantErr: true, + }, + { + name: "userTags contains empty value", + userTags: map[string]string{"key": ""}, + wantErr: true, + }, + { + name: "userTags contains value with invalid character", + userTags: map[string]string{"key": "value*^%"}, + wantErr: true, + }, + { + name: "userTags contains key as name", + userTags: map[string]string{"name": "value"}, + wantErr: true, + }, + { + name: "userTags contains allowed key name123", + userTags: map[string]string{"name123": "value"}, + wantErr: false, + }, + { + name: "userTags contains key with prefix kubernetes.io", + userTags: map[string]string{"kubernetes.io_cluster": "value"}, + wantErr: true, + }, + { + name: "userTags contains allowed key prefix for_openshift.io", + userTags: map[string]string{"for_openshift.io": "azure"}, + wantErr: false, + }, + { + name: "userTags contains key with prefix azure", + userTags: map[string]string{"azure": "microsoft"}, + wantErr: true, + }, + { + name: "userTags contains allowed key resourcename", + userTags: map[string]string{"resourcename": "value"}, + wantErr: false, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + err := validateUserTags(tt.userTags, field.NewPath(fieldPath)) + if (len(err) > 0) != tt.wantErr { + t.Errorf("unexpected error, err: %v", err) + } + }) + } +}