Skip to content

Commit 0dcaff5

Browse files
committed
encoding/jsonschema: initial support for Kubernetes CRDs
This CL introduces a new JSON Schema "version" dedicated to translating CRD schemas. We add all the relevant keywords but leave most of them as TODOs. We implement some of the easier checks but leave harder ones (structural schema checking, for example) for later. The main short-term TODO is support for `x-kubernetes-embedded-resource`, as most other things are less strict than needed currently, so can be punted for now. Signed-off-by: Roger Peppe <[email protected]> Change-Id: I2a66ba0007a2401357dcae151dab15bd45f72b24 Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1211200 TryBot-Result: CUEcueckoo <[email protected]> Reviewed-by: Daniel Martí <[email protected]> Unity-Result: CUE porcuepine <[email protected]>
1 parent 90c1965 commit 0dcaff5

18 files changed

+562
-86
lines changed

Diff for: encoding/jsonschema/constraints.go

+37-30
Original file line numberDiff line numberDiff line change
@@ -71,62 +71,69 @@ var constraints = []*constraint{
7171
p0("$schema", constraintSchema, allVersions),
7272
px("$vocabulary", constraintTODO, vfrom(VersionDraft2019_09)),
7373
p4("additionalItems", constraintAdditionalItems, vto(VersionDraft2019_09)),
74-
p4("additionalProperties", constraintAdditionalProperties, allVersions|openAPI),
75-
p3("allOf", constraintAllOf, allVersions|openAPI),
76-
p3("anyOf", constraintAnyOf, allVersions|openAPI),
74+
p4("additionalProperties", constraintAdditionalProperties, allVersions|openAPI|k8sCRD),
75+
p3("allOf", constraintAllOf, allVersions|openAPI|k8sCRD),
76+
p3("anyOf", constraintAnyOf, allVersions|openAPI|k8sCRD),
7777
p2("const", constraintConst, vfrom(VersionDraft6)),
7878
p2("contains", constraintContains, vfrom(VersionDraft6)),
7979
p2("contentEncoding", constraintContentEncoding, vfrom(VersionDraft7)),
8080
p2("contentMediaType", constraintContentMediaType, vfrom(VersionDraft7)),
8181
px("contentSchema", constraintTODO, vfrom(VersionDraft2019_09)),
82-
p2("default", constraintDefault, allVersions|openAPI),
82+
p2("default", constraintDefault, allVersions|openAPI|k8sCRD),
8383
p2("definitions", constraintAddDefinitions, allVersions),
8484
p2("dependencies", constraintDependencies, allVersions),
8585
px("dependentRequired", constraintTODO, vfrom(VersionDraft2019_09)),
8686
px("dependentSchemas", constraintTODO, vfrom(VersionDraft2019_09)),
8787
p2("deprecated", constraintDeprecated, vfrom(VersionDraft2019_09)|openAPI),
88-
p2("description", constraintDescription, allVersions|openAPI),
88+
p2("description", constraintDescription, allVersions|openAPI|k8sCRD),
8989
px("discriminator", constraintTODO, openAPI),
9090
p1("else", constraintElse, vfrom(VersionDraft7)),
91-
p2("enum", constraintEnum, allVersions|openAPI),
92-
px("example", constraintTODO, openAPI),
91+
p2("enum", constraintEnum, allVersions|openAPI|k8sCRD),
92+
px("example", constraintTODO, openAPI|k8sCRD),
9393
p2("examples", constraintExamples, vfrom(VersionDraft6)),
94-
p2("exclusiveMaximum", constraintExclusiveMaximum, allVersions|openAPI),
95-
p2("exclusiveMinimum", constraintExclusiveMinimum, allVersions|openAPI),
96-
px("externalDocs", constraintTODO, openAPI),
97-
p1("format", constraintFormat, allVersions|openAPI),
94+
p2("exclusiveMaximum", constraintExclusiveMaximum, allVersions|openAPI|k8sCRD),
95+
p2("exclusiveMinimum", constraintExclusiveMinimum, allVersions|openAPI|k8sCRD),
96+
px("externalDocs", constraintTODO, openAPI|k8sCRD),
97+
p1("format", constraintFormat, allVersions|openAPI|k8sCRD),
9898
p1("id", constraintID, vto(VersionDraft4)),
9999
p1("if", constraintIf, vfrom(VersionDraft7)),
100-
p2("items", constraintItems, allVersions|openAPI),
100+
p2("items", constraintItems, allVersions|openAPI|k8sCRD),
101101
p1("maxContains", constraintMaxContains, vfrom(VersionDraft2019_09)),
102-
p2("maxItems", constraintMaxItems, allVersions|openAPI),
103-
p2("maxLength", constraintMaxLength, allVersions|openAPI),
104-
p2("maxProperties", constraintMaxProperties, allVersions|openAPI),
105-
p3("maximum", constraintMaximum, allVersions|openAPI),
102+
p2("maxItems", constraintMaxItems, allVersions|openAPI|k8sCRD),
103+
p2("maxLength", constraintMaxLength, allVersions|openAPI|k8sCRD),
104+
p2("maxProperties", constraintMaxProperties, allVersions|openAPI|k8sCRD),
105+
p3("maximum", constraintMaximum, allVersions|openAPI|k8sCRD),
106106
p1("minContains", constraintMinContains, vfrom(VersionDraft2019_09)),
107-
p2("minItems", constraintMinItems, allVersions|openAPI),
108-
p2("minLength", constraintMinLength, allVersions|openAPI),
109-
p1("minProperties", constraintMinProperties, allVersions|openAPI),
110-
p3("minimum", constraintMinimum, allVersions|openAPI),
111-
p2("multipleOf", constraintMultipleOf, allVersions|openAPI),
112-
p3("not", constraintNot, allVersions|openAPI),
113-
p2("nullable", constraintNullable, openAPI),
114-
p3("oneOf", constraintOneOf, allVersions|openAPI),
115-
p2("pattern", constraintPattern, allVersions|openAPI),
107+
p2("minItems", constraintMinItems, allVersions|openAPI|k8sCRD),
108+
p2("minLength", constraintMinLength, allVersions|openAPI|k8sCRD),
109+
p1("minProperties", constraintMinProperties, allVersions|openAPI|k8sCRD),
110+
p3("minimum", constraintMinimum, allVersions|openAPI|k8sCRD),
111+
p2("multipleOf", constraintMultipleOf, allVersions|openAPI|k8sCRD),
112+
p3("not", constraintNot, allVersions|openAPI|k8sCRD),
113+
p2("nullable", constraintNullable, openAPI|k8sCRD),
114+
p3("oneOf", constraintOneOf, allVersions|openAPI|k8sCRD),
115+
p2("pattern", constraintPattern, allVersions|openAPI|k8sCRD),
116116
p3("patternProperties", constraintPatternProperties, allVersions),
117117
p2("prefixItems", constraintPrefixItems, vfrom(VersionDraft2020_12)),
118-
p2("properties", constraintProperties, allVersions|openAPI),
118+
p2("properties", constraintProperties, allVersions|openAPI|k8sCRD),
119119
p2("propertyNames", constraintPropertyNames, vfrom(VersionDraft6)),
120120
px("readOnly", constraintTODO, vfrom(VersionDraft7)|openAPI),
121-
p3("required", constraintRequired, allVersions|openAPI),
121+
p3("required", constraintRequired, allVersions|openAPI|k8sCRD),
122122
p1("then", constraintThen, vfrom(VersionDraft7)),
123-
p2("title", constraintTitle, allVersions|openAPI),
124-
p2("type", constraintType, allVersions|openAPI),
123+
p2("title", constraintTitle, allVersions|openAPI|k8sCRD),
124+
p2("type", constraintType, allVersions|openAPI|k8sCRD),
125125
px("unevaluatedItems", constraintTODO, vfrom(VersionDraft2019_09)),
126126
px("unevaluatedProperties", constraintTODO, vfrom(VersionDraft2019_09)),
127-
p2("uniqueItems", constraintUniqueItems, allVersions|openAPI),
127+
p2("uniqueItems", constraintUniqueItems, allVersions|openAPI|k8sCRD),
128128
px("writeOnly", constraintTODO, vfrom(VersionDraft7)|openAPI),
129129
px("xml", constraintTODO, openAPI),
130+
px("x-kubernetes-embedded-resource", constraintTODO, k8sCRD),
131+
p2("x-kubernetes-int-or-string", constraintIntOrString, k8sCRD),
132+
px("x-kubernetes-list-map-keys", constraintTODO, k8sCRD),
133+
px("x-kubernetes-list-type", constraintTODO, k8sCRD),
134+
px("x-kubernetes-map-type", constraintTODO, k8sCRD),
135+
p2("x-kubernetes-preserve-unknown-fields", constraintPreserveUnknownFields, k8sCRD),
136+
px("x-kubernetes-validations", constraintTODO, k8sCRD),
130137
}
131138

132139
// px represents a TODO constraint that we haven't decided on a phase for yet.

Diff for: encoding/jsonschema/constraints_array.go

+5
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func constraintItems(key string, n cue.Value, s *state) {
106106
elem := s.schema(n)
107107
ast.SetRelPos(elem, token.NoRelPos)
108108
s.add(n, arrayType, ast.NewList(&ast.Ellipsis{Type: elem}))
109+
s.hasItems = true
109110

110111
case cue.ListKind:
111112
if !vto(VersionDraft2019_09).contains(s.schemaVersion) {
@@ -157,6 +158,10 @@ func constraintMinItems(key string, n cue.Value, s *state) {
157158

158159
func constraintUniqueItems(key string, n cue.Value, s *state) {
159160
if s.boolValue(n) {
161+
if s.schemaVersion == VersionKubernetesCRD {
162+
s.errf(n, "cannot set uniqueItems to true in a CRD schema")
163+
return
164+
}
160165
list := s.addImport(n, "list")
161166
s.add(n, arrayType, ast.NewCall(ast.NewSel(list, "UniqueItems")))
162167
}

Diff for: encoding/jsonschema/constraints_combinator.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func constraintAllOf(key string, n cue.Value, s *state) {
3333
}
3434
a := make([]ast.Expr, 0, len(items))
3535
for _, v := range items {
36-
x, sub := s.schemaState(v, s.allowedTypes)
36+
x, sub := s.schemaState(v, s.allowedTypes, nil)
3737
s.allowedTypes &= sub.allowedTypes
3838
if sub.hasConstraints {
3939
// This might seem a little odd, since the actual
@@ -79,7 +79,7 @@ func constraintAnyOf(key string, n cue.Value, s *state) {
7979
}
8080
a := make([]ast.Expr, 0, len(items))
8181
for _, v := range items {
82-
x, sub := s.schemaState(v, s.allowedTypes)
82+
x, sub := s.schemaState(v, s.allowedTypes, nil)
8383
if sub.allowedTypes == 0 {
8484
// Nothing is allowed; omit.
8585
continue
@@ -123,7 +123,7 @@ func constraintOneOf(key string, n cue.Value, s *state) {
123123
}
124124
a := make([]ast.Expr, 0, len(items))
125125
for _, v := range items {
126-
x, sub := s.schemaState(v, s.allowedTypes)
126+
x, sub := s.schemaState(v, s.allowedTypes, nil)
127127
if sub.allowedTypes == 0 {
128128
// Nothing is allowed; omit
129129
continue
@@ -198,14 +198,14 @@ func constraintIfThenElse(s *state) {
198198
return
199199
}
200200
var ifExpr, thenExpr, elseExpr ast.Expr
201-
ifExpr, ifSub := s.schemaState(s.ifConstraint, s.allowedTypes)
201+
ifExpr, ifSub := s.schemaState(s.ifConstraint, s.allowedTypes, nil)
202202
if hasThen {
203203
// The allowed types of the "then" constraint are constrained both
204204
// by the current constraints and the "if" constraint.
205-
thenExpr, _ = s.schemaState(s.thenConstraint, s.allowedTypes&ifSub.allowedTypes)
205+
thenExpr, _ = s.schemaState(s.thenConstraint, s.allowedTypes&ifSub.allowedTypes, nil)
206206
}
207207
if hasElse {
208-
elseExpr, _ = s.schemaState(s.elseConstraint, s.allowedTypes)
208+
elseExpr, _ = s.schemaState(s.elseConstraint, s.allowedTypes, nil)
209209
}
210210
if thenExpr == nil {
211211
thenExpr = top()

Diff for: encoding/jsonschema/constraints_format.go

+30-12
Original file line numberDiff line numberDiff line change
@@ -26,37 +26,55 @@ type formatFuncInfo struct {
2626
f func(n cue.Value, s *state)
2727
}
2828

29+
// For reference, the Kubernetes-related format strings
30+
// are defined here:
31+
// https://github.com/kubernetes/apiextensions-apiserver/blob/aca9073a80bee92a0b77741b9c7ad444c49fe6be/pkg/apis/apiextensions/v1beta1/types_jsonschema.go#L73
32+
2933
var formatFuncs = sync.OnceValue(func() map[string]formatFuncInfo {
3034
return map[string]formatFuncInfo{
3135
"binary": {openAPI, formatTODO},
32-
"byte": {openAPI, formatTODO},
36+
"bsonobjectid": {k8sCRD, formatTODO},
37+
"byte|k8sCRD": {openAPI, formatTODO},
38+
"cidr": {k8sCRD, formatTODO},
39+
"creditcard": {k8sCRD, formatTODO},
3340
"data": {openAPI, formatTODO},
34-
"date": {vfrom(VersionDraft7) | openAPI, formatDate},
41+
"date": {vfrom(VersionDraft7) | openAPI | k8sCRD, formatDate},
3542
"date-time": {allVersions | openAPI, formatDateTime},
43+
"datetime": {k8sCRD, formatDateTime},
3644
"double": {openAPI, formatTODO},
37-
"duration": {vfrom(VersionDraft2019_09), formatTODO},
38-
"email": {allVersions | openAPI, formatTODO},
45+
"duration": {vfrom(VersionDraft2019_09) | k8sCRD, formatTODO},
46+
"email": {allVersions | openAPI | k8sCRD, formatTODO},
3947
"float": {openAPI, formatTODO},
40-
"hostname": {allVersions | openAPI, formatTODO},
48+
"hexcolor": {k8sCRD, formatTODO},
49+
"hostname": {allVersions | openAPI | k8sCRD, formatTODO},
4150
"idn-email": {vfrom(VersionDraft7), formatTODO},
4251
"idn-hostname": {vfrom(VersionDraft7), formatTODO},
4352
"int32": {openAPI, formatInt32},
4453
"int64": {openAPI, formatInt64},
45-
"ipv4": {allVersions | openAPI, formatTODO},
46-
"ipv6": {allVersions | openAPI, formatTODO},
54+
"ipv4": {allVersions | openAPI | k8sCRD, formatTODO},
55+
"ipv6": {allVersions | openAPI | k8sCRD, formatTODO},
4756
"iri": {vfrom(VersionDraft7), formatURI},
4857
"iri-reference": {vfrom(VersionDraft7), formatURIReference},
58+
"isbn": {k8sCRD, formatTODO},
59+
"isbn10": {k8sCRD, formatTODO},
60+
"isbn13": {k8sCRD, formatTODO},
4961
"json-pointer": {vfrom(VersionDraft6), formatTODO},
50-
"password": {openAPI, formatTODO},
62+
"mac": {k8sCRD, formatTODO},
63+
"password": {openAPI | k8sCRD, formatTODO},
5164
"regex": {vfrom(VersionDraft7), formatRegex},
5265
"relative-json-pointer": {vfrom(VersionDraft7), formatTODO},
66+
"rgbcolor": {k8sCRD, formatTODO},
67+
"ssn": {k8sCRD, formatTODO},
5368
"time": {vfrom(VersionDraft7), formatTODO},
5469
// TODO we should probably disallow non-ASCII URIs (IRIs) but
5570
// this is good enough for now.
56-
"uri": {allVersions | openAPI, formatURI},
71+
"uri": {allVersions | openAPI | k8sCRD, formatURI},
5772
"uri-reference": {vfrom(VersionDraft6), formatURIReference},
5873
"uri-template": {vfrom(VersionDraft6), formatTODO},
59-
"uuid": {vfrom(VersionDraft2019_09), formatTODO},
74+
"uuid": {vfrom(VersionDraft2019_09) | k8sCRD, formatTODO},
75+
"uuid3": {k8sCRD, formatTODO},
76+
"uuid4": {k8sCRD, formatTODO},
77+
"uuid5": {k8sCRD, formatTODO},
6078
}
6179
})
6280

@@ -77,13 +95,13 @@ func constraintFormat(key string, n cue.Value, s *state) {
7795
// we want unknown formats to be ignored even when StrictFeatures
7896
// is enabled, and StrictKeywords is closest to what we want.
7997
// Perhaps we should have a "lint" mode?
80-
if s.cfg.StrictKeywords && s.schemaVersion != VersionOpenAPI {
98+
if s.cfg.StrictKeywords && !openAPILike.contains(s.schemaVersion) {
8199
s.errf(n, "unknown format %q", formatStr)
82100
}
83101
return
84102
}
85103
if !finfo.versions.contains(s.schemaVersion) {
86-
if s.cfg.StrictKeywords && s.schemaVersion != VersionOpenAPI {
104+
if s.cfg.StrictKeywords && !openAPILike.contains(s.schemaVersion) {
87105
s.errf(n, "format %q is not recognized in schema version %v", formatStr, s.schemaVersion)
88106
}
89107
return

Diff for: encoding/jsonschema/constraints_generic.go

+18
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ func constraintTitle(key string, n cue.Value, s *state) {
169169
s.title, _ = s.strValue(n)
170170
}
171171

172+
func constraintIntOrString(key string, n cue.Value, s *state) {
173+
// See x-kubernetes-int-or-string in
174+
// https://kubernetes.io/docs/reference/kubernetes-api/extend-resources/custom-resource-definition-v1/#JSONSchemaProps.
175+
s.setTypeUsed(n, stringType)
176+
s.setTypeUsed(n, numType)
177+
s.add(n, numType, ast.NewIdent("int"))
178+
s.allowedTypes &= cue.StringKind | cue.IntKind
179+
}
180+
172181
func constraintType(key string, n cue.Value, s *state) {
173182
var types cue.Kind
174183
set := func(n cue.Value) {
@@ -197,6 +206,9 @@ func constraintType(key string, n cue.Value, s *state) {
197206
case "array":
198207
types |= cue.ListKind
199208
s.setTypeUsed(n, arrayType)
209+
// For OpenAPI, specifically keep track of whether type is array
210+
// so we can mandate the "items" keyword.
211+
s.isArray = true
200212
case "object":
201213
types |= cue.StructKind
202214
s.setTypeUsed(n, objectType)
@@ -210,6 +222,12 @@ func constraintType(key string, n cue.Value, s *state) {
210222
case cue.StringKind:
211223
set(n)
212224
case cue.ListKind:
225+
if openAPILike.contains(s.schemaVersion) {
226+
// From https://spec.openapis.org/oas/v3.0.3.html#properties:
227+
// "Value MUST be a string. Multiple types via an array are not supported."
228+
s.errf(n, `value of "type" must be a string in %v`, s.schemaVersion)
229+
return
230+
}
213231
for i, _ := n.List(); i.Next(); {
214232
set(i.Value())
215233
}

Diff for: encoding/jsonschema/constraints_object.go

+37-3
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,35 @@ import (
2323

2424
// Object constraints
2525

26+
func constraintPreserveUnknownFields(key string, n cue.Value, s *state) {
27+
// x-kubernetes-preserve-unknown-fields stops the API server decoding
28+
// step from pruning fields which are not specified in the validation
29+
// schema. This affects fields recursively, but switches back to normal
30+
// pruning behaviour if nested properties or additionalProperties are
31+
// specified in the schema. This can either be true or undefined. False
32+
// is forbidden.
33+
// Note: by experimentation, "nested properties" means "within a schema
34+
// within a nested property" not "within a schema that has the properties keyword".
35+
if !s.boolValue(n) {
36+
s.errf(n, "x-kubernetes-preserve-unknown-fields value may not be false")
37+
return
38+
}
39+
// TODO check that it's specified on an object type. This requires
40+
// either setting a bool (hasPreserveUnknownFields?) and checking
41+
// later or making a new phase and placing this after "type" but
42+
// before "allOf", because it's important that this value be
43+
// passed down recursively to allOf and friends.
44+
s.preserveUnknownFields = true
45+
}
46+
2647
func constraintAdditionalProperties(key string, n cue.Value, s *state) {
2748
switch n.Kind() {
2849
case cue.BoolKind:
50+
closeStruct := !s.boolValue(n)
51+
if s.schemaVersion == VersionKubernetesCRD && closeStruct {
52+
s.errf(n, "additionalProperties may not be set to false in a CRD schema")
53+
return
54+
}
2955
s.closeStruct = !s.boolValue(n)
3056
_ = s.object(n)
3157

@@ -41,15 +67,20 @@ func constraintAdditionalProperties(key string, n cue.Value, s *state) {
4167
}
4268
// [!~(properties|patternProperties)]: schema
4369
existing := append(s.patterns, excludeFields(obj.Elts)...)
70+
expr, _ := s.schemaState(n, allTypes, func(s *state) {
71+
s.preserveUnknownFields = false
72+
})
4473
f := internal.EmbedStruct(ast.NewStruct(&ast.Field{
4574
Label: ast.NewList(ast.NewBinExpr(token.AND, existing...)),
46-
Value: s.schema(n),
75+
Value: expr,
4776
}))
4877
obj.Elts = append(obj.Elts, f)
4978

5079
default:
5180
s.errf(n, `value of "additionalProperties" must be an object or boolean`)
81+
return
5282
}
83+
s.hasAdditionalProperties = true
5384
}
5485

5586
func constraintDependencies(key string, n cue.Value, s *state) {
@@ -118,7 +149,9 @@ func constraintProperties(key string, n cue.Value, s *state) {
118149
s.processMap(n, func(key string, n cue.Value) {
119150
// property?: value
120151
name := ast.NewString(key)
121-
expr, state := s.schemaState(n, allTypes)
152+
expr, state := s.schemaState(n, allTypes, func(s *state) {
153+
s.preserveUnknownFields = false
154+
})
122155
f := &ast.Field{Label: name, Value: expr}
123156
if doc := state.comment(); doc != nil {
124157
ast.SetComments(f, []*ast.CommentGroup{doc})
@@ -139,11 +172,12 @@ func constraintProperties(key string, n cue.Value, s *state) {
139172
}
140173
obj.Elts = append(obj.Elts, f)
141174
})
175+
s.hasProperties = true
142176
}
143177

144178
func constraintPropertyNames(key string, n cue.Value, s *state) {
145179
// [=~pattern]: _
146-
if names, _ := s.schemaState(n, cue.StringKind); !isTop(names) {
180+
if names, _ := s.schemaState(n, cue.StringKind, nil); !isTop(names) {
147181
x := ast.NewStruct(ast.NewList(names), top())
148182
s.add(n, objectType, x)
149183
}

0 commit comments

Comments
 (0)