add validation for metav1 conditions

This commit is contained in:
David Eads 2020-06-25 15:01:19 -04:00
parent 6cedc0853f
commit e5fdc77a3d
2 changed files with 209 additions and 0 deletions

View File

@ -18,6 +18,7 @@ package validation
import (
"fmt"
"regexp"
"unicode"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -184,3 +185,78 @@ func ValidateManagedFields(fieldsList []metav1.ManagedFieldsEntry, fldPath *fiel
}
return allErrs
}
func ValidateConditions(conditions []metav1.Condition, fldPath *field.Path) field.ErrorList {
var allErrs field.ErrorList
conditionTypeToFirstIndex := map[string]int{}
for i, condition := range conditions {
if _, ok := conditionTypeToFirstIndex[condition.Type]; ok {
allErrs = append(allErrs, field.Duplicate(fldPath.Index(i).Child("type"), condition.Type))
} else {
conditionTypeToFirstIndex[condition.Type] = i
}
allErrs = append(allErrs, ValidateCondition(condition, fldPath.Index(i))...)
}
return allErrs
}
// validConditionStatuses is used internally to check validity and provide a good message
var validConditionStatuses = sets.NewString(string(metav1.ConditionTrue), string(metav1.ConditionFalse), string(metav1.ConditionUnknown))
const (
maxReasonLen = 1 * 1024
maxMessageLen = 32 * 1024
)
func ValidateCondition(condition metav1.Condition, fldPath *field.Path) field.ErrorList {
var allErrs field.ErrorList
// type is set and is a valid format
allErrs = append(allErrs, ValidateLabelName(condition.Type, fldPath.Child("type"))...)
// status is set and is an accepted value
if !validConditionStatuses.Has(string(condition.Status)) {
allErrs = append(allErrs, field.NotSupported(fldPath.Child("status"), condition.Status, validConditionStatuses.List()))
}
if condition.ObservedGeneration < 0 {
allErrs = append(allErrs, field.Invalid(fldPath.Child("observedGeneration"), condition.ObservedGeneration, "must be greater than or equal to zero"))
}
if condition.LastTransitionTime.IsZero() {
allErrs = append(allErrs, field.Required(fldPath.Child("lastTransitionTime"), "must be set"))
}
if len(condition.Reason) == 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("reason"), "must be set"))
} else {
for _, currErr := range isValidConditionReason(condition.Reason) {
allErrs = append(allErrs, field.Invalid(fldPath.Child("reason"), condition.Reason, currErr))
}
if len(condition.Reason) > maxReasonLen {
allErrs = append(allErrs, field.TooLong(fldPath.Child("reason"), condition.Reason, maxReasonLen))
}
}
if len(condition.Message) > maxMessageLen {
allErrs = append(allErrs, field.TooLong(fldPath.Child("message"), condition.Message, maxMessageLen))
}
return allErrs
}
const conditionReasonFmt string = "[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?"
const conditionReasonErrMsg string = "a condition reason must start with alphabetic character, optionally followed by a string of alphanumeric characters or '_,:', and must end with an alphanumeric character or '_'"
var conditionReasonRegexp = regexp.MustCompile("^" + conditionReasonFmt + "$")
// isValidConditionReason tests for a string that conforms to rules for condition reasons. This checks the format, but not the length.
func isValidConditionReason(value string) []string {
if !conditionReasonRegexp.MatchString(value) {
return []string{validation.RegexError(conditionReasonErrMsg, conditionReasonFmt, "my_name", "MY_NAME", "MyName", "ReasonA,ReasonB", "ReasonA:ReasonB")}
}
return nil
}

View File

@ -293,3 +293,136 @@ func TestValidateMangedFieldsValid(t *testing.T) {
})
}
}
func TestValidateConditions(t *testing.T) {
tests := []struct {
name string
conditions []metav1.Condition
validateErrs func(t *testing.T, errs field.ErrorList)
}{
{
name: "bunch-of-invalid-fields",
conditions: []metav1.Condition{{
Type: ":invalid",
Status: "unknown",
ObservedGeneration: -1,
LastTransitionTime: metav1.Time{},
Reason: "invalid;val",
Message: "",
}},
validateErrs: func(t *testing.T, errs field.ErrorList) {
needle := `status.conditions[0].type: Invalid value: ":invalid": name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
needle = `status.conditions[0].status: Unsupported value: "unknown": supported values: "False", "True", "Unknown"`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
needle = `status.conditions[0].observedGeneration: Invalid value: -1: must be greater than or equal to zero`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
needle = `status.conditions[0].lastTransitionTime: Required value: must be set`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
needle = `status.conditions[0].reason: Invalid value: "invalid;val": a condition reason must start with alphabetic character, optionally followed by a string of alphanumeric characters or '_,:', and must end with an alphanumeric character or '_' (e.g. 'my_name', or 'MY_NAME', or 'MyName', or 'ReasonA,ReasonB', or 'ReasonA:ReasonB', regex used for validation is '[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?')`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
},
},
{
name: "duplicates",
conditions: []metav1.Condition{{
Type: "First",
},
{
Type: "Second",
},
{
Type: "First",
},
},
validateErrs: func(t *testing.T, errs field.ErrorList) {
needle := `status.conditions[2].type: Duplicate value: "First"`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
},
},
{
name: "colon-allowed-in-reason",
conditions: []metav1.Condition{{
Type: "First",
Reason: "valid:val",
}},
validateErrs: func(t *testing.T, errs field.ErrorList) {
needle := `status.conditions[0].reason`
if hasPrefixError(errs, needle) {
t.Errorf("has %q in\n%v", needle, errorsAsString(errs))
}
},
},
{
name: "comma-allowed-in-reason",
conditions: []metav1.Condition{{
Type: "First",
Reason: "valid,val",
}},
validateErrs: func(t *testing.T, errs field.ErrorList) {
needle := `status.conditions[0].reason`
if hasPrefixError(errs, needle) {
t.Errorf("has %q in\n%v", needle, errorsAsString(errs))
}
},
},
{
name: "reason-does-not-end-in-delimiter",
conditions: []metav1.Condition{{
Type: "First",
Reason: "valid,val:",
}},
validateErrs: func(t *testing.T, errs field.ErrorList) {
needle := `status.conditions[0].reason: Invalid value: "valid,val:": a condition reason must start with alphabetic character, optionally followed by a string of alphanumeric characters or '_,:', and must end with an alphanumeric character or '_' (e.g. 'my_name', or 'MY_NAME', or 'MyName', or 'ReasonA,ReasonB', or 'ReasonA:ReasonB', regex used for validation is '[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?')`
if !hasError(errs, needle) {
t.Errorf("missing %q in\n%v", needle, errorsAsString(errs))
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
errs := ValidateConditions(test.conditions, field.NewPath("status").Child("conditions"))
test.validateErrs(t, errs)
})
}
}
func hasError(errs field.ErrorList, needle string) bool {
for _, curr := range errs {
if curr.Error() == needle {
return true
}
}
return false
}
func hasPrefixError(errs field.ErrorList, prefix string) bool {
for _, curr := range errs {
if strings.HasPrefix(curr.Error(), prefix) {
return true
}
}
return false
}
func errorsAsString(errs field.ErrorList) string {
messages := []string{}
for _, curr := range errs {
messages = append(messages, curr.Error())
}
return strings.Join(messages, "\n")
}