diff --git a/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go b/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go index fcd491f4c07..715adf2f973 100644 --- a/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go +++ b/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go @@ -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 +} diff --git a/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation_test.go b/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation_test.go index aa71a600ae2..19cc93b6b7e 100644 --- a/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation_test.go +++ b/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation_test.go @@ -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") +}