Add support for duration (#328)

* Add custom validation logic for durations
This commit is contained in:
Yann Hamon 2025-05-12 11:15:53 +02:00 committed by GitHub
parent a23275d5ca
commit e65429b1e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 565 additions and 1 deletions

View file

@ -16,6 +16,7 @@ import (
"os"
"sigs.k8s.io/yaml"
"strings"
"time"
)
// Different types of validation results
@ -284,6 +285,85 @@ func (val *v) Validate(filename string, r io.ReadCloser) []Result {
return val.ValidateWithContext(context.Background(), filename, r)
}
// validateDuration is a custom validator for the duration format
// as JSONSchema only supports the ISO 8601 format, i.e. `PT1H30M`,
// while Kubernetes API machinery expects the Go duration format, i.e. `1h30m`
// which is commonly used in Kubernetes operators for specifying intervals.
// https://github.com/kubernetes/apiextensions-apiserver/blob/1ecd29f74da0639e2e6e3b8fac0c9bfd217e05eb/pkg/apis/apiextensions/v1/types_jsonschema.go#L71
func validateDuration(v any) error {
// Try validation with the Go duration format
if _, err := time.ParseDuration(v.(string)); err == nil {
return nil
}
s, ok := v.(string)
if !ok {
return nil
}
// must start with 'P'
s, ok = strings.CutPrefix(s, "P")
if !ok {
return fmt.Errorf("must start with P")
}
if s == "" {
return fmt.Errorf("nothing after P")
}
// dur-week
if s, ok := strings.CutSuffix(s, "W"); ok {
if s == "" {
return fmt.Errorf("no number in week")
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return fmt.Errorf("invalid week")
}
}
return nil
}
allUnits := []string{"YMD", "HMS"}
for i, s := range strings.Split(s, "T") {
if i != 0 && s == "" {
return fmt.Errorf("no time elements")
}
if i >= len(allUnits) {
return fmt.Errorf("more than one T")
}
units := allUnits[i]
for s != "" {
digitCount := 0
for _, ch := range s {
if ch >= '0' && ch <= '9' {
digitCount++
} else {
break
}
}
if digitCount == 0 {
return fmt.Errorf("missing number")
}
s = s[digitCount:]
if s == "" {
return fmt.Errorf("missing unit")
}
unit := s[0]
j := strings.IndexByte(units, unit)
if j == -1 {
if strings.IndexByte(allUnits[i], unit) != -1 {
return fmt.Errorf("unit %q out of order", unit)
}
return fmt.Errorf("invalid unit %q", unit)
}
units = units[j+1:]
s = s[1:]
}
}
return nil
}
func downloadSchema(registries []registry.Registry, l jsonschema.SchemeURLLoader, kind, version, k8sVersion string) (*jsonschema.Schema, error) {
var err error
var path string
@ -293,6 +373,7 @@ func downloadSchema(registries []registry.Registry, l jsonschema.SchemeURLLoader
path, s, err = reg.DownloadSchema(kind, version, k8sVersion)
if err == nil {
c := jsonschema.NewCompiler()
c.RegisterFormat(&jsonschema.Format{"duration", validateDuration})
c.UseLoader(l)
c.DefaultDraft(jsonschema.Draft4)
if err := c.AddResource(path, s); err != nil {

View file

@ -379,6 +379,87 @@ lastName: bar
Error,
[]ValidationError{},
},
{
"valid resource duration - go format",
[]byte(`
kind: name
apiVersion: v1
interval: 5s
`),
[]byte(`{
"title": "Example Schema",
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"interval": {
"type": "string",
"format": "duration"
}
},
"required": ["interval"]
}`),
nil,
false,
false,
Valid,
[]ValidationError{},
},
{
"valid resource duration - iso8601 format",
[]byte(`
kind: name
apiVersion: v1
interval: PT1H
`),
[]byte(`{
"title": "Example Schema",
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"interval": {
"type": "string",
"format": "duration"
}
},
"required": ["interval"]
}`),
nil,
false,
false,
Valid,
[]ValidationError{},
},
{
"invalid resource duration",
[]byte(`
kind: name
apiVersion: v1
interval: test
`),
[]byte(`{
"title": "Example Schema",
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"interval": {
"type": "string",
"format": "duration"
}
},
"required": ["interval"]
}`),
nil,
false,
false,
Invalid,
[]ValidationError{{Path: "/interval", Msg: "'test' is not valid duration: must start with P"}},
},
} {
val := v{
opts: Opts{