Skip to content
Merged
8 changes: 8 additions & 0 deletions data/data/install.openshift.io_installconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pkg/explain/printer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ func Test_PrintFields(t *testing.T) {
resourceGroupName <string>
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 <object>
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 <string>
VirtualNetwork specifies the name of an existing VNet for the installer to use`,
}, {
Expand Down
6 changes: 6 additions & 0 deletions pkg/types/azure/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions pkg/types/azure/validation/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package validation

import (
"fmt"
"regexp"
"sort"

"k8s.io/apimachinery/pkg/util/validation/field"
Expand All @@ -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{}
Expand Down Expand Up @@ -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)...)
Expand All @@ -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: {},
Expand Down
104 changes: 104 additions & 0 deletions pkg/types/azure/validation/platform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}