Add declarative validation for core Volume Health API changes

This commit is contained in:
Hemant Kumar
2026-07-20 20:44:10 -04:00
parent ee4c9675d0
commit 119f0c8b37
3 changed files with 25 additions and 244 deletions

View File

@@ -2686,57 +2686,6 @@ var resizeStatusSet = sets.New(core.PersistentVolumeClaimControllerResizeInProgr
core.PersistentVolumeClaimNodeResizeInProgress,
core.PersistentVolumeClaimNodeResizeInfeasible)
var validVolumeHealthStatusTypes = sets.New(
core.VolumeHealthInaccessible,
core.VolumeHealthDataLoss,
core.VolumeHealthDegraded,
)
const maxVolumeHealthConditions = 16
func validateVolumeHealthCondition(condition core.VolumeHealthCondition, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
if !validVolumeHealthStatusTypes.Has(condition.Status) {
allErrs = append(allErrs, field.NotSupported(fldPath.Child("status"), condition.Status, sets.List(validVolumeHealthStatusTypes)))
}
if len(condition.Reason) == 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("reason"), ""))
} else if len(condition.Reason) > 256 {
allErrs = append(allErrs, field.TooLong(fldPath.Child("reason"), condition.Reason, 256))
}
if len(condition.Message) > 1024 {
allErrs = append(allErrs, field.TooLong(fldPath.Child("message"), condition.Message, 1024))
}
return allErrs
}
func validateVolumeHealthConditions(conditions []core.VolumeHealthCondition, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
if len(conditions) > maxVolumeHealthConditions {
allErrs = append(allErrs, field.TooMany(fldPath, len(conditions), maxVolumeHealthConditions))
}
seen := sets.New[string]()
for i, condition := range conditions {
key := string(condition.Status) + "/" + condition.Reason
idxPath := fldPath.Index(i)
if seen.Has(key) {
allErrs = append(allErrs, field.Duplicate(idxPath, key))
}
seen.Insert(key)
allErrs = append(allErrs, validateVolumeHealthCondition(condition, idxPath)...)
}
return allErrs
}
func validateVolumeHealthStatus(healthStatus *core.VolumeHealthStatus, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
if healthStatus == nil {
return allErrs
}
allErrs = append(allErrs, validateVolumeHealthConditions(healthStatus.HealthConditions, fldPath.Child("healthConditions"))...)
return allErrs
}
func validatePodVolumeHealth(volumeHealth []core.PodVolumeHealth, spec *core.PodSpec, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
volumeNames := sets.New[string]()
@@ -2745,20 +2694,11 @@ func validatePodVolumeHealth(volumeHealth []core.PodVolumeHealth, spec *core.Pod
volumeNames.Insert(v.Name)
}
}
seenNames := sets.New[string]()
for i, vh := range volumeHealth {
idxPath := fldPath.Index(i)
if len(vh.Name) == 0 {
allErrs = append(allErrs, field.Required(idxPath.Child("name"), ""))
}
if seenNames.Has(vh.Name) {
allErrs = append(allErrs, field.Duplicate(idxPath.Child("name"), vh.Name))
}
seenNames.Insert(vh.Name)
if len(vh.Name) > 0 && !volumeNames.Has(vh.Name) {
allErrs = append(allErrs, field.NotFound(idxPath.Child("name"), vh.Name))
}
allErrs = append(allErrs, validateVolumeHealthConditions(vh.HealthConditions, idxPath.Child("healthConditions"))...)
}
return allErrs
}
@@ -2805,7 +2745,6 @@ func ValidatePersistentVolumeClaimStatusUpdate(newPvc, oldPvc *core.PersistentVo
}
}
}
allErrs = append(allErrs, validateVolumeHealthStatus(newPvc.Status.HealthStatus, field.NewPath("status", "healthStatus"))...)
return allErrs
}

View File

@@ -22805,137 +22805,6 @@ func TestValidatePersistentVolumeClaimStatusUpdate(t *testing.T) {
}
}
func makeVolumeHealthConditions(n int) []core.VolumeHealthCondition {
conditions := make([]core.VolumeHealthCondition, n)
for i := range conditions {
conditions[i] = core.VolumeHealthCondition{
Status: core.VolumeHealthDegraded,
Reason: fmt.Sprintf("Reason%d", i),
}
}
return conditions
}
func TestValidateVolumeHealthStatus(t *testing.T) {
tests := []struct {
name string
healthStatus *core.VolumeHealthStatus
isErr bool
expectedErr string
}{
{
name: "valid health status",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: core.VolumeHealthDegraded, Reason: "DiskSlow"},
},
},
},
{
name: "valid health status with multiple conditions",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: core.VolumeHealthDegraded, Reason: "DiskSlow"},
{Status: core.VolumeHealthInaccessible, Reason: "VolumeNotFound"},
},
},
},
{
name: "nil health status is valid",
healthStatus: nil,
},
{
name: "invalid status type",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: "InvalidType", Reason: "SomeReason"},
},
},
isErr: true,
expectedErr: "healthConditions[0].status",
},
{
name: "empty reason",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: core.VolumeHealthDegraded, Reason: ""},
},
},
isErr: true,
expectedErr: "healthConditions[0].reason",
},
{
name: "duplicate conditions",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: core.VolumeHealthDegraded, Reason: "DiskSlow"},
{Status: core.VolumeHealthDegraded, Reason: "DiskSlow"},
},
},
isErr: true,
expectedErr: "healthConditions[1]",
},
{
name: "reason too long",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: core.VolumeHealthDegraded, Reason: strings.Repeat("a", 257)},
},
},
isErr: true,
expectedErr: "healthConditions[0].reason",
},
{
name: "message too long",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: []core.VolumeHealthCondition{
{Status: core.VolumeHealthDegraded, Reason: "DiskSlow", Message: strings.Repeat("a", 1025)},
},
},
isErr: true,
expectedErr: "healthConditions[0].message",
},
{
name: "exactly max health conditions is valid",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: makeVolumeHealthConditions(maxVolumeHealthConditions),
},
},
{
name: "too many health conditions",
healthStatus: &core.VolumeHealthStatus{
HealthConditions: makeVolumeHealthConditions(maxVolumeHealthConditions + 1),
},
isErr: true,
expectedErr: "healthConditions",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validateVolumeHealthStatus(tt.healthStatus, field.NewPath("healthStatus"))
if tt.isErr && len(errs) == 0 {
t.Errorf("expected error but got none")
}
if !tt.isErr && len(errs) > 0 {
t.Errorf("unexpected errors: %v", errs)
}
if tt.isErr && len(errs) > 0 && tt.expectedErr != "" {
found := false
for _, err := range errs {
if strings.Contains(err.Field, tt.expectedErr) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q but got: %v", tt.expectedErr, errs)
}
}
})
}
}
func TestValidatePodVolumeHealth(t *testing.T) {
podSpec := &core.PodSpec{
Volumes: []core.Volume{
@@ -22973,56 +22842,6 @@ func TestValidatePodVolumeHealth(t *testing.T) {
isErr: true,
expectedErr: "volumeHealth[0].name",
},
{
name: "duplicate volume names",
volumeHealth: []core.PodVolumeHealth{
{Name: "vol1", HealthConditions: []core.VolumeHealthCondition{{Status: core.VolumeHealthDegraded, Reason: "DiskSlow"}}},
{Name: "vol1", HealthConditions: []core.VolumeHealthCondition{{Status: core.VolumeHealthInaccessible, Reason: "NotFound"}}},
},
isErr: true,
expectedErr: "volumeHealth[1].name",
},
{
name: "empty volume name",
volumeHealth: []core.PodVolumeHealth{
{Name: "", HealthConditions: []core.VolumeHealthCondition{{Status: core.VolumeHealthDegraded, Reason: "DiskSlow"}}},
},
isErr: true,
expectedErr: "volumeHealth[0].name",
},
{
name: "invalid condition status in pod volume health",
volumeHealth: []core.PodVolumeHealth{
{
Name: "vol1",
HealthConditions: []core.VolumeHealthCondition{
{Status: "BadStatus", Reason: "SomeReason"},
},
},
},
isErr: true,
expectedErr: "volumeHealth[0].healthConditions[0].status",
},
{
name: "exactly max health conditions is valid",
volumeHealth: []core.PodVolumeHealth{
{
Name: "vol1",
HealthConditions: makeVolumeHealthConditions(maxVolumeHealthConditions),
},
},
},
{
name: "too many health conditions",
volumeHealth: []core.PodVolumeHealth{
{
Name: "vol1",
HealthConditions: makeVolumeHealthConditions(maxVolumeHealthConditions + 1),
},
},
isErr: true,
expectedErr: "volumeHealth[0].healthConditions",
},
}
for _, tt := range tests {

View File

@@ -767,6 +767,7 @@ type PersistentVolumeClaimCondition struct {
// VolumeHealthStatusType describes the health status category of a volume.
// +enum
// +k8s:enum
type VolumeHealthStatusType string
const (
@@ -782,15 +783,21 @@ const (
type VolumeHealthCondition struct {
// status is the machine-parseable health category.
// One of "Inaccessible", "DataLoss", "Degraded".
// +required
// +k8s:required
Status VolumeHealthStatusType `json:"status" protobuf:"bytes,1,opt,name=status,casttype=VolumeHealthStatusType"`
// reason is a brief CamelCase machine-parseable reason.
// Together with status it forms the unique identity of a condition entry.
// Maximum permitted length of a reason is 256 characters.a
// Maximum permitted length of a reason is 256 bytes.
// +required
// +k8s:required
// +k8s:maxBytes=256
Reason string `json:"reason" protobuf:"bytes,2,opt,name=reason"`
// message is a human-readable description.
// Maximum permitted length of a message is 1024 bytes.
// +optional
// Maximum permitted length of a message is 1024 characters.
// +k8s:optional
// +k8s:maxBytes=1024
Message string `json:"message,omitempty" protobuf:"bytes,3,opt,name=message"`
}
@@ -806,6 +813,11 @@ type VolumeHealthStatus struct {
// +patchMergeKey=status
// +patchStrategy=merge
// +listMapKey=reason
// +k8s:optional
// +k8s:listType=map
// +k8s:listMapKey=status
// +k8s:listMapKey=reason
// +k8s:maxItems=16
HealthConditions []VolumeHealthCondition `json:"healthConditions,omitempty" patchStrategy:"merge" patchMergeKey:"status" protobuf:"bytes,1,rep,name=healthConditions"`
// lastTransitionTime is when the current set of conditions first appeared.
// +optional
@@ -816,6 +828,8 @@ type VolumeHealthStatus struct {
// reported by the CSI node plugin via the kubelet.
type PodVolumeHealth struct {
// name matches an entry in pod.spec.volumes.
// +required
// +k8s:required
Name string `json:"name" protobuf:"bytes,1,opt,name=name"`
// conditions is the set of adverse conditions reported by
// the CSI node plugin for this volume on this node.
@@ -826,6 +840,11 @@ type PodVolumeHealth struct {
// +patchMergeKey=status
// +patchStrategy=merge
// +listMapKey=reason
// +k8s:optional
// +k8s:listType=map
// +k8s:listMapKey=status
// +k8s:listMapKey=reason
// +k8s:maxItems=16
HealthConditions []VolumeHealthCondition `json:"healthConditions,omitempty" patchStrategy:"merge" patchMergeKey:"status" protobuf:"bytes,2,rep,name=healthConditions"`
// lastTransitionTime is when the current set of conditions first appeared.
// +optional
@@ -929,6 +948,7 @@ type PersistentVolumeClaimStatus struct {
// for the volume bound to this claim.
// +featureGate=CSIVolumeHealth
// +optional
// +k8s:optional
HealthStatus *VolumeHealthStatus `json:"healthStatus,omitempty" protobuf:"bytes,10,opt,name=healthStatus"`
}
@@ -5618,6 +5638,9 @@ type PodStatus struct {
// +optional
// +listType=map
// +listMapKey=name
// +k8s:optional
// +k8s:listType=map
// +k8s:listMapKey=name
VolumeHealth []PodVolumeHealth `json:"volumeHealth,omitempty" protobuf:"bytes,22,rep,name=volumeHealth"`
}