Skip to content

Commit b0f283f

Browse files
committed
implement opaque declarative validation marker
This commit introduces , a field-level marker that suppresses type-level validation inheritance for a specific field, while still allowing field-level markers
1 parent 1aeb8c2 commit b0f283f

4 files changed

Lines changed: 64 additions & 4 deletions

File tree

pkg/crd/markers/validation.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const (
3535
ValidationExactlyOneOfPrefix = validationPrefix + "ExactlyOneOf"
3636
ValidationAtMostOneOfPrefix = validationPrefix + "AtMostOneOf"
3737
ValidationAtLeastOneOfPrefix = validationPrefix + "AtLeastOneOf"
38+
39+
OpaqueFieldName = "k8s:opaque"
3840
)
3941

4042
// ValidationMarkers lists all available markers that affect CRD schema generation,
@@ -123,6 +125,9 @@ var FieldOnlyMarkers = []*definitionWithHelp{
123125

124126
must(markers.MakeDefinition(SchemalessName, markers.DescribesField, Schemaless{})).
125127
WithHelp(Schemaless{}.Help()),
128+
129+
must(markers.MakeDefinition(OpaqueFieldName, markers.DescribesField, Opaque{})).
130+
WithHelp(markers.SimpleHelp("CRD validation", "suppresses type-level validation inheritance for this field; field-level markers still apply.")),
126131
}
127132

128133
// ValidationIshMarkers are field-and-type markers that don't fall under the
@@ -568,6 +573,10 @@ type XIntOrString struct{}
568573
// +controllertools:marker:generateHelp:category="CRD validation"
569574
type Schemaless struct{}
570575

576+
// Opaque instructs the CRD generator to suppress inheritance of type-level
577+
// validation for this field. Field-level markers still apply.
578+
type Opaque struct{}
579+
571580
func hasNumericType(schema *apiextensionsv1.JSONSchemaProps) bool {
572581
return schema.Type == string(Integer) || schema.Type == string(Number)
573582
}

pkg/crd/schema.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ type schemaContext struct {
7373

7474
allowDangerousTypes bool
7575
ignoreUnexportedFields bool
76+
opaque bool
7677
}
7778

7879
// newSchemaContext constructs a new schemaContext for the given package and schema requester.
@@ -99,6 +100,16 @@ func (c *schemaContext) ForInfo(info *markers.TypeInfo) *schemaContext {
99100
}
100101
}
101102

103+
// ForInfoOpaque produces a new schemaContext with containing the same information
104+
// as this one, except with the given type information and marked as opaque.
105+
// An opaque context prevents the emission of $ref to the type's schema, suppressing
106+
// inherited type-level validations.
107+
func (c *schemaContext) ForInfoOpaque(info *markers.TypeInfo) *schemaContext {
108+
ctx := c.ForInfo(info)
109+
ctx.opaque = true
110+
return ctx
111+
}
112+
102113
// requestSchema asks for the schema for a type in the package with the
103114
// given import path.
104115
func (c *schemaContext) requestSchema(pkgPath, typeName string) {
@@ -282,6 +293,12 @@ func localNamedToSchema(ctx *schemaContext, ident *ast.Ident) *apiextensionsv1.J
282293
// > Otherwise, the alias information is only in the type name, which
283294
// > points directly to the actual (aliased) type.
284295
if basicInfo.Name() != ident.Name {
296+
if ctx.opaque {
297+
return &apiextensionsv1.JSONSchemaProps{
298+
Type: typ,
299+
Format: fmt,
300+
}
301+
}
285302
ctx.requestSchema("", ident.Name)
286303
link := TypeRefLink("", ident.Name)
287304
return &apiextensionsv1.JSONSchemaProps{
@@ -303,8 +320,9 @@ func localNamedToSchema(ctx *schemaContext, ident *ast.Ident) *apiextensionsv1.J
303320
if pkg == ctx.pkg.Types {
304321
pkgPath = ""
305322
}
306-
ctx.requestSchema(pkgPath, typeNameInfo.Name())
307-
link := TypeRefLink(pkgPath, typeNameInfo.Name())
323+
if !ctx.opaque {
324+
ctx.requestSchema(pkgPath, typeNameInfo.Name())
325+
}
308326

309327
// In cases where we have a named type, apply the type and format from the named schema
310328
// to allow markers that need this information to apply correctly.
@@ -321,11 +339,17 @@ func localNamedToSchema(ctx *schemaContext, ident *ast.Ident) *apiextensionsv1.J
321339
}
322340
}
323341

324-
return &apiextensionsv1.JSONSchemaProps{
342+
props := &apiextensionsv1.JSONSchemaProps{
325343
Type: typ,
326344
Format: fmt,
327-
Ref: &link,
328345
}
346+
347+
if !ctx.opaque {
348+
link := TypeRefLink(pkgPath, typeNameInfo.Name())
349+
props.Ref = &link
350+
}
351+
352+
return props
329353
}
330354

331355
// namedSchema creates a schema (ref) for an explicitly external type reference.
@@ -338,6 +362,11 @@ func namedToSchema(ctx *schemaContext, named *ast.SelectorExpr) *apiextensionsv1
338362
typeInfo := typeInfoRaw.(interface{ Obj() *types.TypeName })
339363
typeNameInfo := typeInfo.Obj()
340364
nonVendorPath := loader.NonVendorPath(typeNameInfo.Pkg().Path())
365+
366+
if ctx.opaque {
367+
return &apiextensionsv1.JSONSchemaProps{}
368+
}
369+
341370
ctx.requestSchema(nonVendorPath, typeNameInfo.Name())
342371
link := TypeRefLink(nonVendorPath, typeNameInfo.Name())
343372
return &apiextensionsv1.JSONSchemaProps{
@@ -522,6 +551,8 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiextensio
522551
var propSchema *apiextensionsv1.JSONSchemaProps
523552
if field.Markers.Get(crdmarkers.SchemalessName) != nil {
524553
propSchema = &apiextensionsv1.JSONSchemaProps{}
554+
} else if field.Markers.Get(crdmarkers.OpaqueFieldName) != nil {
555+
propSchema = typeToSchema(ctx.ForInfoOpaque(&markers.TypeInfo{}), field.RawField.Type)
525556
} else {
526557
propSchema = typeToSchema(ctx.ForInfo(&markers.TypeInfo{}), field.RawField.Type)
527558
}

pkg/crd/testdata/cronjob_types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,15 @@ type CronJobSpec struct {
430430
// +kubebuilder:validation:MinLength=10
431431
// +kubebuilder:validation:MaxLength=10
432432
FieldLevelLocalDeclarationOverride LongerString `json:"fieldLevelLocalDeclarationOverride,omitempty"`
433+
434+
// This tests that +k8s:opaque suppresses type-level validation from LongerString (MinLength=4).
435+
// +k8s:opaque
436+
// +kubebuilder:validation:MaxLength=5
437+
OpaqueField LongerString `json:"opaqueField,omitempty"`
438+
439+
// This tests that without +k8s:opaque, type-level validations from LongerString are inherited.
440+
// +kubebuilder:validation:MaxLength=5
441+
NonOpaqueField LongerString `json:"nonOpaqueField,omitempty"`
433442
}
434443

435444
type InlineAlias = EmbeddedStruct

pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9542,6 +9542,12 @@ spec:
95429542
This flag is like suspend, but for when you really mean it.
95439543
It helps test the +kubebuilder:validation:Type marker.
95449544
type: string
9545+
nonOpaqueField:
9546+
description: This tests that without +k8s:opaque, type-level validations
9547+
from LongerString are inherited.
9548+
maxLength: 5
9549+
minLength: 4
9550+
type: string
95459551
onlyAllowSettingOnUpdate:
95469552
description: Test that we can add a field that can only be set to
95479553
a non-default value on updates using XValidation OptionalOldSelf.
@@ -9552,6 +9558,11 @@ spec:
95529558
an update.
95539559
optionalOldSelf: true
95549560
rule: oldSelf.hasValue() || self == 0
9561+
opaqueField:
9562+
description: This tests that +k8s:opaque suppresses type-level validation
9563+
from LongerString (MinLength=4).
9564+
maxLength: 5
9565+
type: string
95559566
patternObject:
95569567
description: This tests that pattern validator is properly applied.
95579568
pattern: ^$|^((https):\/\/?)[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/?))$

0 commit comments

Comments
 (0)