Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: patch schemas at runtime #67

Merged
merged 14 commits into from
Sep 14, 2023
145 changes: 2 additions & 143 deletions cmd/download-builtin-schemas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"

jsonpatch "github.com/evanphx/json-patch"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
"sigs.k8s.io/kubectl-validate/pkg/openapiclient"
"sigs.k8s.io/kubectl-validate/pkg/utils"
"sigs.k8s.io/kubectl-validate/pkg/validatorfactory"
)

// Downloads builtin schemas from GitHub and saves them to disk for embedding
Expand Down Expand Up @@ -92,7 +89,7 @@ func main() {
}

for k, d := range parsed.Components.Schemas {
applySchemaPatches(i, gv, k, d)
validatorfactory.ApplySchemaPatches(i, gv, k, d)
}

newJSON, err := json.Marshal(parsed)
Expand Down Expand Up @@ -127,141 +124,3 @@ func main() {
// might be error prone since some (very few like IntOrString) types are
// handled differently
}

func isTimeSchema(s string) bool {
return s == "io.k8s.apimachinery.pkg.apis.meta.v1.Time" || s == "io.k8s.apimachinery.pkg.apis.meta.v1.MicroTime"
}

func isBuiltInType(gv schema.GroupVersion) bool {
// filter out non built-in types
if gv.Group == "" {
return true
}
if strings.HasSuffix(gv.Group, ".k8s.io") {
return true
}
if gv.Group == "apps" || gv.Group == "autoscaling" || gv.Group == "batch" || gv.Group == "policy" {
return true
}
return false
}

type SchemaPatch struct {
Slug string
Description string

// (Inclusive) version range for which this patch applies
MinMinorVersion int
MaxMinorVersion int

// Nil is wildcard
AppliesToGV func(schema.GroupVersion) bool
AppliesToDefinition func(string) bool
Transformer utils.SchemaVisitor
}

var zero int64 = int64(0)
var schemaPatches []SchemaPatch = []SchemaPatch{
{
Slug: "AllowEmptyByteFormat",
Description: "Work around discrepency between treatment of native vs CRD `byte` strings. Native types allow empty, CRDs do not",
MinMinorVersion: 0,
MaxMinorVersion: 0,
AppliesToGV: isBuiltInType,
Transformer: utils.PostorderVisitor(func(ctx utils.VisitingContext, s *spec.Schema) bool {
if s.Format != "byte" || len(s.Type) != 1 || s.Type[0] != "string" {
return true
}

// Change format to "", and add new `$and: {$or: [{format: "byte"}, {maxLength: 0}]}
s.AllOf = append(s.AllOf, spec.Schema{
SchemaProps: spec.SchemaProps{
AnyOf: []spec.Schema{
{
SchemaProps: spec.SchemaProps{
Format: s.Format,
},
},
{
SchemaProps: spec.SchemaProps{
MaxLength: &zero,
},
},
},
},
})
s.Format = ""
return true
}),
},
{
Slug: "FixTime",
AppliesToDefinition: isTimeSchema,
Description: "metav1.Time published OpenAPI definitions do not allow empty/null, but Kubernetes in practice does.",
Transformer: utils.PostorderVisitor(func(ctx utils.VisitingContext, s *spec.Schema) bool {
if s.Format != "date-time" || len(s.Type) != 1 || s.Type[0] != "string" || s.Nullable {
return true
}

s.Nullable = true
return true
}),
},
{
Slug: "RemoveInvalidDefaults",
Description: "Kubernetes publishes a {} default for any struct type. This doesn't make sense if the type is special with custom marshalling",
Transformer: utils.PostorderVisitor(func(ctx utils.VisitingContext, s *spec.Schema) bool {
if s.Default == nil || !(reflect.DeepEqual(s.Default, map[string]any{}) || reflect.DeepEqual(s.Default, map[any]any{})) {
return true
}

// k8s forces default of {} for struct types
// A bug in the code-generator makes it not realize these "struct" types
// have custom marshalling and OpenAPI types for which {} does not
// make sense
// These are all struct types in upstream k8s which implement
// OpenAPISchemaType to something other than `struct`
toWipe := sets.New(
"io.k8s.apimachinery.pkg.api.resource.Quantity",
"io.k8s.apimachinery.pkg.runtime.RawExtension",
"io.k8s.apimachinery.pkg.util.intstr.IntOrString",
"io.k8s.apimachinery.pkg.apis.meta.v1.Time",
"io.k8s.apimachinery.pkg.apis.meta.v1.MicroTime",
"io.k8s.apimachinery.pkg.apis.meta.v1.Duration",
"io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSON",
"io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSONSchemaPropsOrBool",
"io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSONSchemaPropsOrStringArray",
)
shouldPatch := toWipe.Has(filepath.Base(s.Ref.String()))
for _, subschema := range s.AllOf {
if toWipe.Has(filepath.Base(subschema.Ref.String())) {
shouldPatch = true
break
}
}

if !shouldPatch {
return true
}

s.Default = nil
return true
}),
},
}

func applySchemaPatches(k8sVersion int, gv schema.GroupVersion, defName string, schema *spec.Schema) {
for _, p := range schemaPatches {
if p.MinMinorVersion != 0 && p.MinMinorVersion > k8sVersion {
continue
} else if p.MaxMinorVersion != 0 && p.MaxMinorVersion < k8sVersion {
continue
} else if p.AppliesToGV != nil && !p.AppliesToGV(gv) {
continue
} else if p.AppliesToDefinition != nil && !p.AppliesToDefinition(defName) {
continue
}

utils.VisitSchema(defName, schema, p.Transformer)
}
}
39 changes: 14 additions & 25 deletions pkg/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,32 +183,21 @@ func (c *commandFlags) Run(cmd *cobra.Command, args []string) error {
openapiclient.NewOverlay(
// Hand-written hardcoded patches.
openapiclient.HardcodedPatchLoader(c.version),
openapiclient.NewOverlay(
// apply schema extensions to builtins
//!TODO: if kubeconfig is used, these patches may not be
// compatible. Use active version of kubernetes to decide
// patch to use if connected to cluster.
//
// Generated hardcoded patches. These patches address
// bugs with past versions of Kubernetes' published openapi
// by rewriting certain parts of schemas
openapiclient.HardcodedGeneratedPatchLoader(c.version),
// try cluster for schemas first, if they are not available
// then fallback to hardcoded or builtin schemas
// try cluster for schemas first, if they are not available
// then fallback to hardcoded or builtin schemas
openapiclient.NewFallback(
// contact connected cluster for any schemas. (should this be opt-in?)
openapiclient.NewKubeConfig(c.kubeConfigOverrides),
// try hardcoded builtins first, if they are not available
// fall back to GitHub builtins
openapiclient.NewFallback(
// contact connected cluster for any schemas. (should this be opt-in?)
openapiclient.NewKubeConfig(c.kubeConfigOverrides),
// try hardcoded builtins first, if they are not available
// fall back to GitHub builtins
openapiclient.NewFallback(
// schemas for known k8s versions are scraped from GH and placed here
openapiclient.NewHardcodedBuiltins(c.version),
// check github for builtins not hardcoded.
// subject to rate limiting. should use a diskcache
// since etag requests are not limited
openapiclient.NewGitHubBuiltins(c.version),
)),
),
// schemas for known k8s versions are scraped from GH and placed here
openapiclient.NewHardcodedBuiltins(c.version),
// check github for builtins not hardcoded.
// subject to rate limiting. should use a diskcache
// since etag requests are not limited
openapiclient.NewGitHubBuiltins(c.version),
)),
),
),
),
Expand Down
48 changes: 48 additions & 0 deletions pkg/openapiclient/local_crds.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package openapiclient

import (
_ "embed"
"encoding/json"
"fmt"
"io/fs"
"path/filepath"
Expand All @@ -18,6 +20,17 @@ import (
"sigs.k8s.io/kubectl-validate/pkg/utils"
)

//go:embed local_crds_metadata.json
var metadataSchemasJSON string

var metadataSchemas map[string]*spec.Schema = func() map[string]*spec.Schema {
res := map[string]*spec.Schema{}
if err := json.Unmarshal([]byte(metadataSchemasJSON), &res); err != nil {
panic(err)
}
return res
}()

// client which provides openapi read from files on disk
type localCRDsClient struct {
fs fs.FS
Expand Down Expand Up @@ -110,6 +123,36 @@ func (k *localCRDsClient) Paths() (map[string]openapi.GroupVersion, error) {
// Add schema extension to propagate the scope
sch.AddExtension("x-kubectl-validate-scope", string(crd.Spec.Scope))
key := fmt.Sprintf("%s/%s.%s", gvk.Group, gvk.Version, gvk.Kind)

// Emulate APIServer behavior by injecting ObjectMeta & its Dependencies into CRD
sch.Properties["metadata"] = spec.Schema{
SchemaProps: spec.SchemaProps{
AllOf: []spec.Schema{
spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"),
},
},
},
Default: map[string]interface{}{},
Description: "Standard object metadata; More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata.",
},
}
sch.Properties["apiVersion"] = spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Description: "API version of the referent.",
Type: spec.StringOrArray{"string"},
},
}
sch.Properties["kind"] = spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Description: "Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: spec.StringOrArray{"string"},
},
}

if existing, exists := crds[gvk.GroupVersion()]; exists {
existing.Components.Schemas[key] = sch
} else {
Expand All @@ -123,8 +166,13 @@ func (k *localCRDsClient) Paths() (map[string]openapi.GroupVersion, error) {
}
}
}

res := map[string]openapi.GroupVersion{}
for k, v := range crds {
// Inject metadata definitions into each group-version document
for defName, def := range metadataSchemas {
v.Components.Schemas[defName] = def
}
res[fmt.Sprintf("apis/%s/%s", k.Group, k.Version)] = groupversion.NewForOpenAPI(v)
}
return res, nil
Expand Down
Loading
Loading