diff --git a/.github/workflows/sync_jsonschema.yaml b/.github/workflows/sync_jsonschema.yaml new file mode 100644 index 0000000..1394785 --- /dev/null +++ b/.github/workflows/sync_jsonschema.yaml @@ -0,0 +1,30 @@ +name: Sync File from Repo B + +on: + schedule: + - cron: "0 0 * * *" # Runs daily at midnight + workflow_dispatch: + +jobs: + sync-file: + runs-on: ubuntu-latest + steps: + - name: Checkout Kubernetes API server + uses: actions/checkout@v4 + with: + repository: kubernetes/apiextensions-apiserver + path: apiextensions-apiserver + - name: Checkout This Repository + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + path: this + + - name: Install GitHub CLI + run: sudo apt-get install gh + + - name: Run sync script + run: | + ./this/hack/sync-file.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yaml b/.golangci.yaml index 811bc74..c9a80e1 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -77,6 +77,10 @@ linters-settings: exclude-generated: true issues: + exclude-files: + - pkg/types_jsonschema.go + - pkg/marshal.go + - pkg/marshal_test.go exclude: - composites exclude-rules: diff --git a/KrokCommand_sample.yaml b/KrokCommand_sample.yaml new file mode 100644 index 0000000..ff49039 --- /dev/null +++ b/KrokCommand_sample.yaml @@ -0,0 +1,14 @@ +apiVersion: delivery.krok.app/v1alpha1 +kind: KrokCommand +metadata: {} +spec: + commandHasOutputToWrite: true + dependencies: [] # minItems 0 of type string + enabled: true + image: string + platforms: [] # minItems 0 of type string + readInputFromSecret: + name: string + namespace: string + schedule: string +status: {} diff --git a/cmd/crd.go b/cmd/crd.go index 0c3cecc..1d0abb7 100644 --- a/cmd/crd.go +++ b/cmd/crd.go @@ -8,7 +8,6 @@ import ( "path/filepath" "github.com/spf13/cobra" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" "github.com/Skarlso/crd-to-sample-yaml/pkg" ) @@ -37,7 +36,7 @@ type crdGenArgs struct { var crdArgs = &crdGenArgs{} type Handler interface { - CRDs() ([]*v1beta1.CustomResourceDefinition, error) + CRDs() ([]*pkg.SchemaType, error) } func init() { @@ -104,7 +103,7 @@ func runGenerate(_ *cobra.Command, _ []string) error { if crdArgs.stdOut { w = os.Stdout } else { - outputLocation := filepath.Join(crdArgs.output, crd.Name+"_sample."+crdArgs.format) + outputLocation := filepath.Join(crdArgs.output, crd.Kind+"_sample."+crdArgs.format) // closed later during render outputFile, err := os.Create(outputLocation) if err != nil { diff --git a/cmd/file_handler.go b/cmd/file_handler.go index fea2443..da8bb25 100644 --- a/cmd/file_handler.go +++ b/cmd/file_handler.go @@ -4,9 +4,10 @@ import ( "fmt" "os" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" + "github.com/Skarlso/crd-to-sample-yaml/pkg" "github.com/Skarlso/crd-to-sample-yaml/pkg/sanitize" ) @@ -14,7 +15,7 @@ type FileHandler struct { location string } -func (h *FileHandler) CRDs() ([]*v1beta1.CustomResourceDefinition, error) { +func (h *FileHandler) CRDs() ([]*pkg.SchemaType, error) { if _, err := os.Stat(h.location); os.IsNotExist(err) { return nil, fmt.Errorf("file under '%s' does not exist", h.location) } @@ -28,10 +29,15 @@ func (h *FileHandler) CRDs() ([]*v1beta1.CustomResourceDefinition, error) { return nil, fmt.Errorf("failed to sanitize content: %w", err) } - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} if err := yaml.Unmarshal(content, crd); err != nil { return nil, fmt.Errorf("failed to unmarshal into custom resource definition: %w", err) } - return []*v1beta1.CustomResourceDefinition{crd}, nil + schemaType, err := pkg.ExtractSchemaType(crd) + if err != nil { + return nil, fmt.Errorf("failed to extract schema type: %w", err) + } + + return []*pkg.SchemaType{schemaType}, nil } diff --git a/cmd/folder_handler.go b/cmd/folder_handler.go index b0d833b..2c3f48c 100644 --- a/cmd/folder_handler.go +++ b/cmd/folder_handler.go @@ -6,9 +6,10 @@ import ( "os" "path/filepath" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" + "github.com/Skarlso/crd-to-sample-yaml/pkg" "github.com/Skarlso/crd-to-sample-yaml/pkg/sanitize" ) @@ -16,12 +17,12 @@ type FolderHandler struct { location string } -func (h *FolderHandler) CRDs() ([]*v1beta1.CustomResourceDefinition, error) { +func (h *FolderHandler) CRDs() ([]*pkg.SchemaType, error) { if _, err := os.Stat(h.location); os.IsNotExist(err) { return nil, fmt.Errorf("file under '%s' does not exist", h.location) } - var crds []*v1beta1.CustomResourceDefinition + var crds []*pkg.SchemaType if err := filepath.Walk(h.location, func(path string, info fs.FileInfo, err error) error { if err != nil { @@ -48,14 +49,18 @@ func (h *FolderHandler) CRDs() ([]*v1beta1.CustomResourceDefinition, error) { return fmt.Errorf("failed to sanitize content: %w", err) } - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} if err := yaml.Unmarshal(content, crd); err != nil { fmt.Fprintln(os.Stderr, "skipping none CRD file: "+path) return nil //nolint:nilerr // intentional } + schemaType, err := pkg.ExtractSchemaType(crd) + if err != nil { + return fmt.Errorf("failed to extract schema type: %w", err) + } - crds = append(crds, crd) + crds = append(crds, schemaType) return nil }); err != nil { diff --git a/cmd/schema.go b/cmd/schema.go index c833743..b77f149 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -50,20 +50,20 @@ func runGenerateSchema(_ *cobra.Command, _ []string) error { } for _, crd := range crds { - for _, v := range crd.Spec.Versions { - if v.Schema.OpenAPIV3Schema.ID == "" { - v.Schema.OpenAPIV3Schema.ID = "https://crdtoyaml.com/" + crd.Spec.Names.Kind + "." + crd.Spec.Group + "." + v.Name + ".schema.json" + for _, v := range crd.Versions { + if v.Schema.ID == "" { + v.Schema.ID = "https://crdtoyaml.com/" + crd.Kind + "." + crd.Group + "." + v.Name + ".schema.json" } - if v.Schema.OpenAPIV3Schema.Schema == "" { - v.Schema.OpenAPIV3Schema.Schema = "https://json-schema.org/draft/2020-12/schema" + if v.Schema.Schema == "" { + v.Schema.Schema = "https://json-schema.org/draft/2020-12/schema" } - content, err := json.Marshal(v.Schema.OpenAPIV3Schema) + content, err := json.Marshal(v.Schema) if err != nil { return fmt.Errorf("failed to marshal schema: %w", err) } const perm = 0o600 - if err := os.WriteFile(filepath.Join(schemaArgs.outputFolder, crd.Spec.Names.Kind+"."+crd.Spec.Group+"."+v.Name+".schema.json"), content, perm); err != nil { + if err := os.WriteFile(filepath.Join(schemaArgs.outputFolder, crd.Kind+"."+crd.Group+"."+v.Name+".schema.json"), content, perm); err != nil { return fmt.Errorf("failed to write schema: %w", err) } } diff --git a/cmd/url_handler.go b/cmd/url_handler.go index 74597cb..ace6eb8 100644 --- a/cmd/url_handler.go +++ b/cmd/url_handler.go @@ -5,9 +5,10 @@ import ( "net/http" "time" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" + "github.com/Skarlso/crd-to-sample-yaml/pkg" "github.com/Skarlso/crd-to-sample-yaml/pkg/fetcher" "github.com/Skarlso/crd-to-sample-yaml/pkg/sanitize" ) @@ -21,7 +22,7 @@ type URLHandler struct { token string } -func (h *URLHandler) CRDs() ([]*v1beta1.CustomResourceDefinition, error) { +func (h *URLHandler) CRDs() ([]*pkg.SchemaType, error) { client := http.DefaultClient client.Timeout = timeout * time.Second @@ -36,10 +37,14 @@ func (h *URLHandler) CRDs() ([]*v1beta1.CustomResourceDefinition, error) { return nil, fmt.Errorf("failed to sanitize content: %w", err) } - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} if err := yaml.Unmarshal(content, crd); err != nil { return nil, fmt.Errorf("failed to unmarshal into custom resource definition: %w", err) } + schemaType, err := pkg.ExtractSchemaType(crd) + if err != nil { + return nil, fmt.Errorf("failed to extract schema type: %w", err) + } - return []*v1beta1.CustomResourceDefinition{crd}, nil + return []*pkg.SchemaType{schemaType}, nil } diff --git a/go.mod b/go.mod index 3a0541e..9ce30fa 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.23 require ( github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/fatih/color v1.17.0 + github.com/fxamacker/cbor/v2 v2.7.0 + github.com/google/go-cmp v0.6.0 github.com/jedib0t/go-pretty/v6 v6.6.0 github.com/maxence-charriere/go-app/v10 v10.0.7 github.com/spf13/cobra v1.8.1 @@ -20,7 +22,6 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect diff --git a/hack/keep_json_schema_uptodate.sh b/hack/keep_json_schema_uptodate.sh new file mode 100644 index 0000000..4a383ee --- /dev/null +++ b/hack/keep_json_schema_uptodate.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -e + +# Move into the directory of repo_a and setup git user +git config user.name "GitHub Action" +git config user.email "action@github.com" + +# Copy from api server to local +BRANCH_NAME="update-file-$(date +%Y%m%d%H%M%S)" +FILE_PATH=this/pkg/types_jsonschema.go +cp apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go this/pkg + +# Check if there is a difference between the files +if git diff --exit-code "$FILE_PATH"; then + echo "No changes detected, exiting." + exit 0 +fi + +echo "Changes detected, creating a pull request..." + +# Stage the changes +git add "$FILE_PATH" +git commit -m "Updated $FILE_PATH from repository B" + +# Push the branch to repository A +git push origin "$BRANCH_NAME" + +# Create a pull request using the GitHub CLI +gh auth login --with-token <<< "$GITHUB_TOKEN" +gh pr create --title "Sync $FILE_PATH from repo B" --body "This PR updates $FILE_PATH from repository B" --head "$BRANCH_NAME" --base main + +echo "Pull request created successfully." diff --git a/pkg/README.md b/pkg/README.md new file mode 100644 index 0000000..f401166 --- /dev/null +++ b/pkg/README.md @@ -0,0 +1,11 @@ +# Files in this folder + +Some of the files in this folder are taken from the following repository verbatim: +[Kubernetes Extensions api server repository](https://github.com/kubernetes/apiextensions-apiserver) + +The following files are copied over: +- types_jsonschema.go +- marshal.go +- marshal_test.go + +This is to ensure that we marshal the JSON schema types correctly. diff --git a/pkg/create_html_output.go b/pkg/create_html_output.go index 03ca33f..34bbd66 100644 --- a/pkg/create_html_output.go +++ b/pkg/create_html_output.go @@ -10,8 +10,6 @@ import ( "io/fs" "slices" "sort" - - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" ) type Index struct { @@ -66,7 +64,7 @@ func LoadTemplates() error { } // RenderContent creates an HTML website from the CRD content. -func RenderContent(w io.WriteCloser, crds []*v1beta1.CustomResourceDefinition, comments, minimal bool) (err error) { +func RenderContent(w io.WriteCloser, crds []*SchemaType, comments, minimal bool) (err error) { defer func() { if cerr := w.Close(); cerr != nil { err = errors.Join(err, cerr) @@ -77,10 +75,10 @@ func RenderContent(w io.WriteCloser, crds []*v1beta1.CustomResourceDefinition, c for _, crd := range crds { versions := make([]Version, 0) - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, comments, minimal, false) + parser := NewParser(crd.Group, crd.Kind, comments, minimal, false) - for _, version := range crd.Spec.Versions { - v, err := generate(crd, version.Schema.OpenAPIV3Schema, minimal, parser) + for _, version := range crd.Versions { + v, err := generate(version.Name, crd.Group, crd.Kind, version.Schema, minimal, parser) if err != nil { return fmt.Errorf("failed to generate yaml sample: %w", err) } @@ -89,10 +87,8 @@ func RenderContent(w io.WriteCloser, crds []*v1beta1.CustomResourceDefinition, c } // parse validation instead - if len(versions) == 0 && crd.Spec.Validation != nil { - crd.Spec.Validation.OpenAPIV3Schema.Properties["kind"] = v1beta1.JSONSchemaProps{} - crd.Spec.Validation.OpenAPIV3Schema.Properties["apiVersion"] = v1beta1.JSONSchemaProps{} - version, err := generate(crd, crd.Spec.Validation.OpenAPIV3Schema, minimal, parser) + if len(versions) == 0 && crd.Validation != nil { + version, err := generate(crd.Validation.Name, crd.Group, crd.Kind, crd.Validation.Schema, minimal, parser) if err != nil { return fmt.Errorf("failed to generate yaml sample: %w", err) } @@ -103,7 +99,7 @@ func RenderContent(w io.WriteCloser, crds []*v1beta1.CustomResourceDefinition, c } view := ViewPage{ - Title: crd.Spec.Names.Kind, + Title: crd.Kind, Versions: versions, } @@ -123,22 +119,22 @@ func RenderContent(w io.WriteCloser, crds []*v1beta1.CustomResourceDefinition, c return nil } -func generate(crd *v1beta1.CustomResourceDefinition, properties *v1beta1.JSONSchemaProps, minimal bool, parser *Parser) (Version, error) { - out, err := parseCRD(properties.Properties, crd.Name, minimal, RootRequiredFields) +func generate(name, group, kind string, properties *JSONSchemaProps, minimal bool, parser *Parser) (Version, error) { + out, err := parseCRD(properties.Properties, name, minimal, RootRequiredFields) if err != nil { return Version{}, fmt.Errorf("failed to parse properties: %w", err) } var buffer []byte buf := bytes.NewBuffer(buffer) - if err := parser.ParseProperties(crd.Name, buf, properties.Properties); err != nil { + if err := parser.ParseProperties(name, buf, properties.Properties); err != nil { return Version{}, fmt.Errorf("failed to generate yaml sample: %w", err) } return Version{ - Version: crd.Name, + Version: name, Properties: out, - Kind: crd.Spec.Names.Kind, - Group: crd.Spec.Group, + Kind: kind, + Group: group, Description: properties.Description, YAML: buf.String(), }, nil @@ -161,7 +157,7 @@ type Property struct { // parseCRD takes the properties and constructs a linked list out of the embedded properties that the recursive // template can call and construct linked divs. -func parseCRD(properties map[string]v1beta1.JSONSchemaProps, version string, minimal bool, requiredList []string) ([]*Property, error) { +func parseCRD(properties map[string]JSONSchemaProps, version string, minimal bool, requiredList []string) ([]*Property, error) { output := make([]*Property, 0, len(properties)) sortedKeys := make([]string, 0, len(properties)) diff --git a/pkg/extract_schematype.go b/pkg/extract_schematype.go new file mode 100644 index 0000000..5b3dcf5 --- /dev/null +++ b/pkg/extract_schematype.go @@ -0,0 +1,184 @@ +package pkg + +import ( + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/json" +) + +// ExtractSchemaType makes sure the following required fields are +// present in the unstructured data and creates are own internal representation: +// - spec +// - spec.names.kind +// - spec.group +// either: +// - versions +// - version.Schema.OpenAPIV3Schema +// +// - validation // if versions is missing +// - validation.OpenAPIV3Schema +func ExtractSchemaType(obj *unstructured.Unstructured) (*SchemaType, error) { + spec, ok := obj.Object["spec"] + if !ok { + return nil, errors.New("no spec found in object") + } + + specMap, ok := spec.(map[string]any) + if !ok { + return nil, fmt.Errorf("failed to convert spec to map[string]any was: %T", specMap) + } + + versions, ok := specMap["versions"] + if !ok { + return extractValidation(obj, specMap) + } + + kind, group, err := extractGroupKind(specMap) + if err != nil { + return nil, err + } + + versionsList, ok := versions.([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid version list type not a list: %T", versionsList) + } + + schemaTypes := &SchemaType{ + Group: group, + Kind: kind, + } + for _, v := range versionsList { + vMap, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid version type not a map: %T", v) + } + + name, err := extractValue[string](vMap, "name") + if err != nil { + return nil, fmt.Errorf("no name found for version: %v", v) + } + + schema, ok := vMap["schema"] + if !ok { + return nil, fmt.Errorf("no schema found for version: %v", v) + } + openAPIV3schema, err := extractValue[map[string]any](schema, "openAPIV3Schema") + if err != nil { + return nil, err + } + + content, err := json.Marshal(openAPIV3schema) + if err != nil { + return nil, err + } + schemaValue := &JSONSchemaProps{} + if err := json.Unmarshal(content, schemaValue); err != nil { + return nil, err + } + + ensureKindAndAPIVersionIsSet(schemaValue.Properties) + + version := &CRDVersion{ + Name: name, + Schema: schemaValue, + } + + schemaTypes.Versions = append(schemaTypes.Versions, version) + } + + return schemaTypes, nil +} + +func extractValidation(obj *unstructured.Unstructured, specMap map[string]any) (*SchemaType, error) { + validation, ok := specMap["validation"] + if !ok { + return nil, errors.New("no validate found in object") + } + + kindValue, groupValue, err := extractGroupKind(specMap) + if err != nil { + return nil, err + } + + validationMap, ok := validation.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid validation map detected: %T", validation) + } + schema, ok := validationMap["openAPIV3Schema"] + if !ok { + return nil, fmt.Errorf("openAPIV3Schema not found in validation map: %v", validationMap) + } + + props := &JSONSchemaProps{} + content, err := json.Marshal(schema) + if err != nil { + return nil, err + } + if err = json.Unmarshal(content, props); err != nil { + return nil, err + } + + ensureKindAndAPIVersionIsSet(props.Properties) + + return &SchemaType{ + Schema: nil, + Validation: &Validation{ + Schema: props, + Name: obj.GetName(), + }, + Group: groupValue, + Kind: kindValue, + }, nil +} + +func ensureKindAndAPIVersionIsSet(properties map[string]JSONSchemaProps) { + if _, ok := properties["kind"]; !ok { + properties["kind"] = JSONSchemaProps{} + } + + if _, ok := properties["apiVersion"]; !ok { + properties["apiVersion"] = JSONSchemaProps{} + } +} + +func extractGroupKind(specMap map[string]any) (string, string, error) { + names, ok := specMap["names"] + if !ok { + return "", "", errors.New("no names found in object") + } + + kind, err := extractValue[string](names, "kind") + if err != nil { + return "", "", err + } + + group, err := extractValue[string](specMap, "group") + if err != nil { + return "", "", err + } + + return kind, group, nil +} + +// extractValue fetches a specific key value that we are looking for in a map. +func extractValue[T any](m any, k string) (T, error) { + var result T + v, ok := m.(map[string]any) + if !ok { + return result, fmt.Errorf("value was not of type map[string]any but: %T", m) + } + + vv, ok := v[k] + if !ok { + return result, fmt.Errorf("key %s was not found in map", k) + } + + vvv, ok := vv.(T) + if !ok { + return result, fmt.Errorf("value was not of type T but: %T", vvv) + } + + return vvv, nil +} diff --git a/pkg/extract_schematype_test.go b/pkg/extract_schematype_test.go new file mode 100644 index 0000000..692ca43 --- /dev/null +++ b/pkg/extract_schematype_test.go @@ -0,0 +1,83 @@ +package pkg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestExtractSchemaTypeForVersion(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]interface{}{ + "name": "this-is-my-name", + }, + "spec": map[string]interface{}{ + "group": "group", + "names": map[string]interface{}{ + "kind": "kind", + }, + "versions": []any{ + map[string]interface{}{ + "name": "v1beta1", + "schema": map[string]interface{}{ + "openAPIV3Schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "additionalProperties": map[string]interface{}{}, + "additionalItems": map[string]interface{}{}, + "id": "id", + "title": "title", + }, + }, + }, + }, + }, + }, + } + + schemaType, err := ExtractSchemaType(obj) + require.NoError(t, err) + assert.Equal(t, "object", schemaType.Versions[0].Schema.Type) + assert.Equal(t, "id", schemaType.Versions[0].Schema.ID) + assert.Equal(t, "title", schemaType.Versions[0].Schema.Title) +} + +func TestExtractSchemaTypeForValidation(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]interface{}{ + "name": "this-is-my-name", + }, + "spec": map[string]interface{}{ + "group": "group", + "names": map[string]interface{}{ + "kind": "kind", + }, + "validation": map[string]interface{}{ + "openAPIV3Schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "additionalProperties": map[string]interface{}{}, + "additionalItems": map[string]interface{}{}, + "id": "id", + "title": "title", + }, + }, + }, + }, + } + + schemaType, err := ExtractSchemaType(obj) + require.NoError(t, err) + assert.Equal(t, "this-is-my-name", schemaType.Validation.Name) + assert.Equal(t, "object", schemaType.Validation.Schema.Type) + assert.Equal(t, "id", schemaType.Validation.Schema.ID) + assert.Equal(t, "title", schemaType.Validation.Schema.Title) +} diff --git a/pkg/generate.go b/pkg/generate.go index 3a35ee7..06b0890 100644 --- a/pkg/generate.go +++ b/pkg/generate.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/brianvoe/gofakeit/v6" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" ) const array = "array" @@ -19,20 +18,20 @@ const array = "array" var RootRequiredFields = []string{"apiVersion", "kind", "spec", "metadata"} // Generate takes a CRD content and path, and outputs. -func Generate(crd *v1beta1.CustomResourceDefinition, w io.WriteCloser, enableComments, minimal, skipRandom bool) (err error) { +func Generate(crd *SchemaType, w io.WriteCloser, enableComments, minimal, skipRandom bool) (err error) { defer func() { if cerr := w.Close(); cerr != nil { err = errors.Join(err, cerr) } }() - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, enableComments, minimal, skipRandom) - for i, version := range crd.Spec.Versions { - if err := parser.ParseProperties(version.Name, w, version.Schema.OpenAPIV3Schema.Properties); err != nil { + parser := NewParser(crd.Group, crd.Kind, enableComments, minimal, skipRandom) + for i, version := range crd.Versions { + if err := parser.ParseProperties(version.Name, w, version.Schema.Properties); err != nil { return fmt.Errorf("failed to parse properties: %w", err) } - if i < len(crd.Spec.Versions)-1 { + if i < len(crd.Versions)-1 { if _, err := w.Write([]byte("\n---\n")); err != nil { return fmt.Errorf("failed to write yaml delimiter to writer: %w", err) } @@ -40,10 +39,8 @@ func Generate(crd *v1beta1.CustomResourceDefinition, w io.WriteCloser, enableCom } // Parse validation instead - if len(crd.Spec.Versions) == 0 && crd.Spec.Validation != nil { - crd.Spec.Validation.OpenAPIV3Schema.Properties["kind"] = v1beta1.JSONSchemaProps{} - crd.Spec.Validation.OpenAPIV3Schema.Properties["apiVersion"] = v1beta1.JSONSchemaProps{} - if err := parser.ParseProperties(crd.Name, w, crd.Spec.Validation.OpenAPIV3Schema.Properties); err != nil { + if len(crd.Versions) == 0 && crd.Validation != nil { + if err := parser.ParseProperties(crd.Validation.Name, w, crd.Validation.Schema.Properties); err != nil { return fmt.Errorf("failed to parse properties: %w", err) } } @@ -86,7 +83,7 @@ func NewParser(group, kind string, comments, requiredOnly, skipRandom bool) *Par // ParseProperties takes a writer and puts out any information / properties it encounters during the runs. // It will recursively parse every "properties:" and "additionalProperties:". Using the types, it will also output // some sample data based on those types. -func (p *Parser) ParseProperties(version string, file io.Writer, properties map[string]v1beta1.JSONSchemaProps) error { +func (p *Parser) ParseProperties(version string, file io.Writer, properties map[string]JSONSchemaProps) error { sortedKeys := make([]string, 0, len(properties)) for k := range properties { sortedKeys = append(sortedKeys, k) @@ -199,7 +196,7 @@ func (p *Parser) ParseProperties(version string, file io.Writer, properties map[ } // deletes properties from the properties that aren't required. -func (p *Parser) emptyAfterTrimRequired(properties map[string]v1beta1.JSONSchemaProps, required []string) bool { +func (p *Parser) emptyAfterTrimRequired(properties map[string]JSONSchemaProps, required []string) bool { for k := range properties { if !slices.Contains(required, k) { delete(properties, k) @@ -210,7 +207,7 @@ func (p *Parser) emptyAfterTrimRequired(properties map[string]v1beta1.JSONSchema } // outputValueType generate an output value based on the given type. -func outputValueType(v v1beta1.JSONSchemaProps, skipRandom bool) string { +func outputValueType(v JSONSchemaProps, skipRandom bool) string { if v.Default != nil { return string(v.Default.Raw) } @@ -250,18 +247,21 @@ func outputValueType(v v1beta1.JSONSchemaProps, skipRandom bool) string { case "object": return "{}" case array: // deal with arrays of other types that weren't objects - t := v.Items.Schema.Type - var s string - var items []string - if v.MinItems != nil { - for range int(*v.MinItems) { - items = append(items, t) + if v.Items.Schema != nil { + t := v.Items.Schema.Type + var s string + var items []string + if v.MinItems != nil { + for range int(*v.MinItems) { + items = append(items, t) + } } - } + s = fmt.Sprintf("[%s] # minItems %d of type %s", strings.Join(items, ","), len(items), t) - s = fmt.Sprintf("[%s] # minItems %d of type %s", strings.Join(items, ","), len(items), t) + return s + } - return s + return "[]" } return v.Type diff --git a/pkg/generate_test.go b/pkg/generate_test.go index f7e9769..b8784a0 100644 --- a/pkg/generate_test.go +++ b/pkg/generate_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" ) @@ -17,35 +17,35 @@ func TestGenerate(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - version := crd.Spec.Versions[0] - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false, true) - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_golden.yaml")) require.NoError(t, err) - assert.Equal(t, golden, buffer.Bytes()) + assert.Equal(t, string(golden), buffer.String()) } func TestGenerateWithTemplateDelimiter(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_template_start_character_default_value.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - version := crd.Spec.Versions[0] - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false, true) - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_template_start_character_default_value_golden.yaml")) require.NoError(t, err) @@ -57,15 +57,15 @@ func TestGenerateWithExample(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_example.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false, true) - version := crd.Spec.Versions[0] - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_example_golden.yaml")) require.NoError(t, err) @@ -77,15 +77,15 @@ func TestGenerateWithComments(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, true, false, true) - version := crd.Spec.Versions[0] - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, true, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_comments_golden.yaml")) require.NoError(t, err) @@ -97,15 +97,15 @@ func TestGenerateMinimal(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, true, true) - version := crd.Spec.Versions[0] - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, true, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_minimal_example_golden.yaml")) require.NoError(t, err) @@ -117,15 +117,15 @@ func TestGenerateMinimalWithExample(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_example.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, true, true) - version := crd.Spec.Versions[0] - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, true, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_minimal_example_with_example_for_field_golden.yaml")) require.NoError(t, err) @@ -137,15 +137,15 @@ func TestGenerateMinimalWithNoRequiredFields(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_minimal_no_required_fields.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, true, true) - version := crd.Spec.Versions[0] - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, true, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_minimal_no_required_fields_golden.yaml")) require.NoError(t, err) @@ -157,15 +157,15 @@ func TestGenerateWithAdditionalProperties(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_additional_properties.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false, true) - version := crd.Spec.Versions[0] - require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_additional_properties_golden.yaml")) require.NoError(t, err) @@ -177,17 +177,17 @@ func TestGenerateWithValidation(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_validation.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) - - parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false, true) - - crd.Spec.Validation.OpenAPIV3Schema.Properties["kind"] = v1beta1.JSONSchemaProps{} - crd.Spec.Validation.OpenAPIV3Schema.Properties["apiVersion"] = v1beta1.JSONSchemaProps{} - require.NoError(t, parser.ParseProperties(crd.Name, buffer, crd.Spec.Validation.OpenAPIV3Schema.Properties)) + nopCloser := &WriteNoOpCloser{w: buffer} + schemaType.Validation.Schema.Properties["kind"] = JSONSchemaProps{} + schemaType.Validation.Schema.Properties["apiVersion"] = JSONSchemaProps{} + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_validation_golden.yaml")) require.NoError(t, err) @@ -209,16 +209,38 @@ func TestGenerateWithMultipleVersionsAndList(t *testing.T) { content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_list_and_multiple_versions.yaml")) require.NoError(t, err) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) var output []byte buffer := bytes.NewBuffer(output) nopCloser := &WriteNoOpCloser{w: buffer} - require.NoError(t, Generate(crd, nopCloser, false, false, true)) + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_list_and_multiple_versions_golden.yaml")) require.NoError(t, err) assert.Equal(t, string(golden), buffer.String()) } + +func TestGenerateWithDifferentCRDType(t *testing.T) { + content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_different_crd_type.yaml")) + require.NoError(t, err) + + crd := &unstructured.Unstructured{} + require.NoError(t, yaml.Unmarshal(content, crd)) + schemaType, err := ExtractSchemaType(crd) + require.NoError(t, err) + + var output []byte + buffer := bytes.NewBuffer(output) + nopCloser := &WriteNoOpCloser{w: buffer} + require.NoError(t, Generate(schemaType, nopCloser, false, false, true)) + + golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_different_crd_type_golden.yaml")) + require.NoError(t, err) + + assert.Equal(t, string(golden), buffer.String()) +} diff --git a/pkg/marshal.go b/pkg/marshal.go new file mode 100644 index 0000000..006ea0c --- /dev/null +++ b/pkg/marshal.go @@ -0,0 +1,294 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pkg + +import ( + "bytes" + "errors" + + cbor "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct" + "k8s.io/apimachinery/pkg/util/json" +) + +var nullLiteral = []byte(`null`) +var jsTrue = []byte("true") +var jsFalse = []byte("false") + +// The CBOR parsing related constants and functions below are not exported so they can be +// easily removed at a future date when the CBOR library provides equivalent functionality. + +type cborMajorType int + +const ( + // https://www.rfc-editor.org/rfc/rfc8949.html#section-3.1 + cborUnsignedInteger cborMajorType = 0 + cborNegativeInteger cborMajorType = 1 + cborByteString cborMajorType = 2 + cborTextString cborMajorType = 3 + cborArray cborMajorType = 4 + cborMap cborMajorType = 5 + cborTag cborMajorType = 6 + cborOther cborMajorType = 7 +) + +const ( + cborFalseValue = 0xf4 + cborTrueValue = 0xf5 + cborNullValue = 0xf6 +) + +func cborType(b byte) cborMajorType { + return cborMajorType(b >> 5) +} + +func (s JSONSchemaPropsOrBool) MarshalJSON() ([]byte, error) { + if s.Schema != nil { + return json.Marshal(s.Schema) + } + + if s.Schema == nil && !s.Allows { + return jsFalse, nil + } + return jsTrue, nil +} + +func (s *JSONSchemaPropsOrBool) UnmarshalJSON(data []byte) error { + var nw JSONSchemaPropsOrBool + switch { + case len(data) == 0: + case data[0] == '{': + var sch JSONSchemaProps + if err := json.Unmarshal(data, &sch); err != nil { + return err + } + nw.Allows = true + nw.Schema = &sch + case len(data) == 4 && string(data) == "true": + nw.Allows = true + case len(data) == 5 && string(data) == "false": + nw.Allows = false + default: + return errors.New("boolean or JSON schema expected") + } + *s = nw + return nil +} + +func (s JSONSchemaPropsOrBool) MarshalCBOR() ([]byte, error) { + if s.Schema != nil { + return cbor.Marshal(s.Schema) + } + return cbor.Marshal(s.Allows) +} + +func (s *JSONSchemaPropsOrBool) UnmarshalCBOR(data []byte) error { + switch { + case len(data) == 0: + // ideally we would avoid modifying *s here, but we are matching the behavior of UnmarshalJSON + *s = JSONSchemaPropsOrBool{} + return nil + case cborType(data[0]) == cborMap: + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrBool{Allows: true, Schema: &p} + return nil + case data[0] == cborTrueValue: + *s = JSONSchemaPropsOrBool{Allows: true} + return nil + case data[0] == cborFalseValue: + *s = JSONSchemaPropsOrBool{Allows: false} + return nil + default: + // ideally, this case would not also capture a null input value, + // but we are matching the behavior of the UnmarshalJSON + return errors.New("boolean or JSON schema expected") + } +} + +func (s JSONSchemaPropsOrStringArray) MarshalJSON() ([]byte, error) { + if len(s.Property) > 0 { + return json.Marshal(s.Property) + } + if s.Schema != nil { + return json.Marshal(s.Schema) + } + return []byte("null"), nil +} + +func (s *JSONSchemaPropsOrStringArray) UnmarshalJSON(data []byte) error { + var first byte + if len(data) > 1 { + first = data[0] + } + var nw JSONSchemaPropsOrStringArray + if first == '{' { + var sch JSONSchemaProps + if err := json.Unmarshal(data, &sch); err != nil { + return err + } + nw.Schema = &sch + } + if first == '[' { + if err := json.Unmarshal(data, &nw.Property); err != nil { + return err + } + } + *s = nw + return nil +} + +func (s JSONSchemaPropsOrStringArray) MarshalCBOR() ([]byte, error) { + if len(s.Property) > 0 { + return cbor.Marshal(s.Property) + } + if s.Schema != nil { + return cbor.Marshal(s.Schema) + } + return cbor.Marshal(nil) +} + +func (s *JSONSchemaPropsOrStringArray) UnmarshalCBOR(data []byte) error { + if len(data) > 0 && cborType(data[0]) == cborArray { + var a []string + if err := cbor.Unmarshal(data, &a); err != nil { + return err + } + *s = JSONSchemaPropsOrStringArray{Property: a} + return nil + } + if len(data) > 0 && cborType(data[0]) == cborMap { + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrStringArray{Schema: &p} + return nil + } + // At this point we either have: empty data, a null value, or an + // unexpected type. In order to match the behavior of the existing + // UnmarshalJSON, no error is returned and *s is overwritten here. + *s = JSONSchemaPropsOrStringArray{} + return nil +} + +func (s JSONSchemaPropsOrArray) MarshalJSON() ([]byte, error) { + if len(s.JSONSchemas) > 0 { + return json.Marshal(s.JSONSchemas) + } + return json.Marshal(s.Schema) +} + +func (s *JSONSchemaPropsOrArray) UnmarshalJSON(data []byte) error { + var nw JSONSchemaPropsOrArray + var first byte + if len(data) > 1 { + first = data[0] + } + if first == '{' { + var sch JSONSchemaProps + if err := json.Unmarshal(data, &sch); err != nil { + return err + } + nw.Schema = &sch + } + if first == '[' { + if err := json.Unmarshal(data, &nw.JSONSchemas); err != nil { + return err + } + } + *s = nw + return nil +} + +func (s JSONSchemaPropsOrArray) MarshalCBOR() ([]byte, error) { + if len(s.JSONSchemas) > 0 { + return cbor.Marshal(s.JSONSchemas) + } + return cbor.Marshal(s.Schema) +} + +func (s *JSONSchemaPropsOrArray) UnmarshalCBOR(data []byte) error { + if len(data) > 0 && cborType(data[0]) == cborMap { + var p JSONSchemaProps + if err := cbor.Unmarshal(data, &p); err != nil { + return err + } + *s = JSONSchemaPropsOrArray{Schema: &p} + return nil + } + if len(data) > 0 && cborType(data[0]) == cborArray { + var a []JSONSchemaProps + if err := cbor.Unmarshal(data, &a); err != nil { + return err + } + *s = JSONSchemaPropsOrArray{JSONSchemas: a} + return nil + } + // At this point we either have: empty data, a null value, or an + // unexpected type. In order to match the behavior of the existing + // UnmarshalJSON, no error is returned and *s is overwritten here. + *s = JSONSchemaPropsOrArray{} + return nil +} + +func (s JSON) MarshalJSON() ([]byte, error) { + if len(s.Raw) > 0 { + return s.Raw, nil + } + return []byte("null"), nil + +} + +func (s *JSON) UnmarshalJSON(data []byte) error { + if len(data) > 0 && !bytes.Equal(data, nullLiteral) { + s.Raw = append(s.Raw[0:0], data...) + } + return nil +} + +func (s JSON) MarshalCBOR() ([]byte, error) { + // Note that non-semantic whitespace is lost during the transcoding performed here. + // We do not forsee this to be a problem given the current known uses of this type. + // Other limitations that arise when roundtripping JSON via dynamic clients also apply + // here, for example: insignificant whitespace handling, number handling, and map key ordering. + if len(s.Raw) == 0 { + return []byte{cborNullValue}, nil + } + var u any + if err := json.Unmarshal(s.Raw, &u); err != nil { + return nil, err + } + return cbor.Marshal(u) +} + +func (s *JSON) UnmarshalCBOR(data []byte) error { + if len(data) == 0 || data[0] == cborNullValue { + return nil + } + var u any + if err := cbor.Unmarshal(data, &u); err != nil { + return err + } + raw, err := json.Marshal(u) + if err != nil { + return err + } + s.Raw = raw + return nil +} diff --git a/pkg/marshal_test.go b/pkg/marshal_test.go new file mode 100644 index 0000000..57e679e --- /dev/null +++ b/pkg/marshal_test.go @@ -0,0 +1,560 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pkg + +import ( + "encoding/json" + "math" + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/google/go-cmp/cmp" +) + +type marshalTestable interface { + json.Marshaler + cbor.Marshaler +} + +type marshalTestCase struct { + name string + input marshalTestable + wantJSONError bool + wantCBORError bool + wantJSON []byte + wantCBOR []byte +} + +type unmarshalTestable interface { + json.Unmarshaler + cbor.Unmarshaler +} + +type unmarshalTestCase struct { + name string + inputJSON []byte + inputCBOR []byte + wantJSONError bool + wantCBORError bool + wantDecoded unmarshalTestable +} + +type roundTripTestable interface { + marshalTestable + unmarshalTestable +} + +type roundTripTestCase struct { + name string + input roundTripTestable + wantJSON []byte + wantCBOR []byte + wantDecoded roundTripTestable +} + +func TestJSONSchemaPropsOrBool(t *testing.T) { + nan := math.NaN() + + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: &JSONSchemaPropsOrBool{ + Schema: &JSONSchemaProps{Maximum: &nan}, + }, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "zero value", + input: &JSONSchemaPropsOrBool{}, + wantDecoded: &JSONSchemaPropsOrBool{}, + wantJSON: []byte(`false`), + wantCBOR: []byte{cborFalseValue}, + }, + { + name: "bool false", + input: &JSONSchemaPropsOrBool{Allows: false}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: false}, + wantJSON: []byte(`false`), + wantCBOR: []byte{cborFalseValue}, + }, + { + name: "bool true", + input: &JSONSchemaPropsOrBool{Allows: true}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true}, + wantJSON: []byte(`true`), + wantCBOR: []byte{cborTrueValue}, + }, + { + name: "with props", + input: &JSONSchemaPropsOrBool{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + }) + } + }) + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "legacy behavior", + inputJSON: []byte(`{}`), + inputCBOR: []byte{0xA0}, + wantDecoded: &JSONSchemaPropsOrBool{Allows: true, Schema: &JSONSchemaProps{}}, + }, + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantJSONError: true, + wantCBORError: true, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrBool{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrBool{})) + }) + } + }) +} + +func TestJSONSchemaPropsOrArray(t *testing.T) { + nan := math.NaN() + + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Maximum: &nan}}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "empty props", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}, + wantDecoded: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{}}, + wantJSON: []byte(`{}`), + wantCBOR: []byte{0xA0}, + }, + { + name: "props", + input: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + { + name: "array with empty props", + input: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}, + wantDecoded: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}}}, + wantJSON: []byte(`[{}]`), + wantCBOR: []byte{0x81, 0xA0}, + }, + { + name: "array with empty props and props", + input: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}, + wantDecoded: &JSONSchemaPropsOrArray{JSONSchemas: []JSONSchemaProps{{}, {Type: "string"}}}, + wantJSON: []byte(`[{},{"type":"string"}]`), + wantCBOR: []byte{0x82, 0xA0, 0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + }) + } + }) + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantDecoded: &JSONSchemaPropsOrArray{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrArray{})) + }) + } + }) +} + +func TestJSONSchemaPropsOrStringArray(t *testing.T) { + nan := math.NaN() + t.Run("Marshal", func(t *testing.T) { + testCases := []marshalTestCase{ + { + name: "unsupported value", + input: JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Maximum: &nan}}, + wantJSONError: true, + wantCBORError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", marshalJSONTest(tc.input, tc.wantJSONError, tc.wantJSON)) + t.Run("cbor", marshalCBORTest(tc.input, tc.wantCBORError, tc.wantCBOR)) + }) + } + }) + + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "empty props", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{}}, + wantJSON: []byte(`{}`), + wantCBOR: []byte{0xA0}, + }, + { + name: "props", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "string"}}, + wantJSON: []byte(`{"type":"string"}`), + wantCBOR: []byte{0xA1, 0x44, 't', 'y', 'p', 'e', 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + + { + name: "empty array", + input: &JSONSchemaPropsOrStringArray{Property: []string{}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "array value", + input: &JSONSchemaPropsOrStringArray{Property: []string{"string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: []string{"string"}}, + wantJSON: []byte(`["string"]`), + wantCBOR: []byte{0x81, 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + { + name: "both props and array", + input: &JSONSchemaPropsOrStringArray{Schema: &JSONSchemaProps{Type: "props"}, Property: []string{"string"}}, + wantDecoded: &JSONSchemaPropsOrStringArray{Schema: nil, Property: []string{"string"}}, + wantJSON: []byte(`["string"]`), + wantCBOR: []byte{0x81, 0x46, 's', 't', 'r', 'i', 'n', 'g'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + }) + } + }) + + t.Run("Unmarshal", func(t *testing.T) { + testCases := []unmarshalTestCase{ + { + name: "empty array", + inputJSON: []byte(`[]`), + inputCBOR: []byte{0x80}, + wantDecoded: &JSONSchemaPropsOrStringArray{Property: []string{}}, + }, + { + name: "null", + inputJSON: []byte(`null`), + inputCBOR: []byte{cborNullValue}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + { + name: "zero len input", + inputJSON: []byte{}, + inputCBOR: []byte{}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + { + name: "unsupported type", + inputJSON: []byte(`42`), + inputCBOR: []byte{0x18, 42}, + wantDecoded: &JSONSchemaPropsOrStringArray{}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", unmarshalJSONTest(tc.inputJSON, tc.wantJSONError, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + t.Run("cbor", unmarshalCBORTest(tc.inputCBOR, tc.wantCBORError, tc.wantDecoded, &JSONSchemaPropsOrStringArray{})) + }) + } + }) + +} + +func TestJSON(t *testing.T) { + t.Run("RoundTrip", func(t *testing.T) { + testCases := []roundTripTestCase{ + { + name: "nil raw", + input: &JSON{Raw: nil}, + wantDecoded: &JSON{Raw: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "zero len raw", + input: &JSON{Raw: []byte{}}, + wantDecoded: &JSON{Raw: nil}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "empty", + input: &JSON{}, + wantDecoded: &JSON{}, + wantJSON: []byte(`null`), + wantCBOR: []byte{cborNullValue}, + }, + { + name: "string", + input: &JSON{Raw: []byte(`"string"`)}, + wantDecoded: &JSON{Raw: []byte(`"string"`)}, + wantJSON: []byte(`"string"`), + wantCBOR: []byte{0x46, 0x73, 0x74, 0x72, 0x69, 0x6E, 0x67}, + }, + { + name: "number", + input: &JSON{Raw: []byte(`42.01`)}, + wantDecoded: &JSON{Raw: []byte(`42.01`)}, + wantJSON: []byte(`42.01`), + wantCBOR: []byte{0xFB, 0x40, 0x45, 0x01, 0x47, 0xAE, 0x14, 0x7A, 0xE1}, + }, + { + name: "bool", + input: &JSON{Raw: []byte(`true`)}, + wantDecoded: &JSON{Raw: []byte(`true`)}, + wantJSON: []byte(`true`), + wantCBOR: []byte{0xF5}, + }, + { + name: "array", + input: &JSON{Raw: []byte(`[1,2,3]`)}, + wantDecoded: &JSON{Raw: []byte(`[1,2,3]`)}, + wantJSON: []byte(`[1,2,3]`), + wantCBOR: []byte{0x83, 1, 2, 3}, + }, + { + name: "map", + input: &JSON{Raw: []byte(`{"foo":"bar"}`)}, + wantDecoded: &JSON{Raw: []byte(`{"foo":"bar"}`)}, + wantJSON: []byte(`{"foo":"bar"}`), + wantCBOR: []byte{0xA1, 0x43, 'f', 'o', 'o', 0x43, 'b', 'a', 'r'}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("json", roundTripJSONTest(tc.input, tc.wantJSON, tc.wantDecoded, &JSON{})) + t.Run("cbor", roundTripCBORTest(tc.input, tc.wantCBOR, tc.wantDecoded, &JSON{})) + }) + } + }) +} + +func marshalJSONTest(input marshalTestable, wantErr bool, expected []byte) func(t *testing.T) { + return func(t *testing.T) { + actual, err := input.MarshalJSON() + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(string(expected), string(actual)); len(diff) > 0 { + t.Fatal(diff) + } + } +} + +func marshalCBORTest(input marshalTestable, wantErr bool, expected []byte) func(t *testing.T) { + return func(t *testing.T) { + actual, err := input.MarshalCBOR() + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expected, actual); len(diff) > 0 { + t.Fatal(diff) + } + } +} + +func unmarshalJSONTest(input []byte, wantErr bool, expectedDecoded unmarshalTestable, actualDecoded unmarshalTestable) func(t *testing.T) { + return func(t *testing.T) { + err := actualDecoded.UnmarshalJSON(input) + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func unmarshalCBORTest(input []byte, wantErr bool, expectedDecoded unmarshalTestable, actualDecoded unmarshalTestable) func(t *testing.T) { + return func(t *testing.T) { + err := actualDecoded.UnmarshalCBOR(input) + if (err != nil) != wantErr { + if wantErr { + t.Fatal("expected error") + } + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + return + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) != 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func roundTripJSONTest(input roundTripTestable, expectedEncoded []byte, expectedDecoded roundTripTestable, actualDecoded roundTripTestable) func(t *testing.T) { + return func(t *testing.T) { + actualEncoded, err := input.MarshalJSON() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(string(expectedEncoded), string(actualEncoded)); len(diff) > 0 { + t.Error("unexpected encoded value") + t.Fatal(diff) + } + err = actualDecoded.UnmarshalJSON(actualEncoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func roundTripCBORTest(input roundTripTestable, expectedEncoded []byte, expectedDecoded roundTripTestable, actualDecoded roundTripTestable) func(t *testing.T) { + return func(t *testing.T) { + actualEncoded, err := input.MarshalCBOR() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedEncoded, actualEncoded); len(diff) > 0 { + t.Error("unexpected encoded value") + t.Fatal(diff) + } + err = actualDecoded.UnmarshalCBOR(actualEncoded) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(expectedDecoded, actualDecoded); len(diff) > 0 { + t.Error("unexpected decoded value") + t.Fatal(diff) + } + } +} + +func TestJSONUnderlyingArrayReuse(t *testing.T) { + const want = `{"foo":"bar"}` + + b := []byte(want) + + var s JSON + if err := s.UnmarshalJSON(b); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Underlying array is modified. + copy(b[2:5], "bar") + copy(b[8:11], "foo") + + // If UnmarshalJSON copied the bytes of its argument, then it should not have been affected + // by the mutation. + if got := string(s.Raw); got != want { + t.Errorf("unexpected mutation, got %s want %s", got, want) + } +} diff --git a/pkg/matches/matchsnapshot/update.go b/pkg/matches/matchsnapshot/update.go index dd57fc7..9076371 100644 --- a/pkg/matches/matchsnapshot/update.go +++ b/pkg/matches/matchsnapshot/update.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" "github.com/Skarlso/crd-to-sample-yaml/pkg" @@ -30,12 +30,16 @@ func (u *Update) Update(sourceTemplateLocation string, targetSnapshotLocation st } baseName := strings.Trim(filepath.Base(sourceTemplateLocation), filepath.Ext(sourceTemplateLocation)) - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} if err := yaml.Unmarshal(sourceTemplate, crd); err != nil { return fmt.Errorf("failed to unmarshal into custom resource definition: %w", err) } + schemaType, err := pkg.ExtractSchemaType(crd) + if err != nil { + return fmt.Errorf("failed to extract schema type: %w", err) + } - for _, version := range crd.Spec.Versions { + for _, version := range schemaType.Versions { name := baseName + "-" + version.Name + ".yaml" if minimal { name = baseName + "-" + version.Name + ".min.yaml" @@ -45,18 +49,20 @@ func (u *Update) Update(sourceTemplateLocation string, targetSnapshotLocation st return fmt.Errorf("failed to open file %s: %w", filepath.Join(targetSnapshotLocation, name), err) } - defer file.Close() + parser := pkg.NewParser(schemaType.Group, schemaType.Kind, false, minimal, false) + if err := parser.ParseProperties(version.Name, file, version.Schema.Properties); err != nil { + _ = file.Close() - parser := pkg.NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, minimal, false) - if err := parser.ParseProperties(version.Name, file, version.Schema.OpenAPIV3Schema.Properties); err != nil { return fmt.Errorf("failed to parse properties: %w", err) } + + _ = file.Close() } - if len(crd.Spec.Versions) == 0 && crd.Spec.Validation != nil { - name := baseName + "-" + crd.Name + ".yaml" + if len(schemaType.Versions) == 0 && schemaType.Validation != nil { + name := baseName + "-" + schemaType.Validation.Name + ".yaml" if minimal { - name = baseName + "-" + crd.Name + ".min.yaml" + name = baseName + "-" + schemaType.Validation.Name + ".min.yaml" } file, err := os.OpenFile(filepath.Join(targetSnapshotLocation, name), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { @@ -65,10 +71,10 @@ func (u *Update) Update(sourceTemplateLocation string, targetSnapshotLocation st defer file.Close() - crd.Spec.Validation.OpenAPIV3Schema.Properties["kind"] = v1beta1.JSONSchemaProps{} - crd.Spec.Validation.OpenAPIV3Schema.Properties["apiVersion"] = v1beta1.JSONSchemaProps{} - parser := pkg.NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, minimal, false) - if err := parser.ParseProperties(crd.Name, file, crd.Spec.Validation.OpenAPIV3Schema.Properties); err != nil { + schemaType.Validation.Schema.Properties["kind"] = pkg.JSONSchemaProps{} + schemaType.Validation.Schema.Properties["apiVersion"] = pkg.JSONSchemaProps{} + parser := pkg.NewParser(schemaType.Group, schemaType.Kind, false, minimal, false) + if err := parser.ParseProperties(schemaType.Validation.Name, file, schemaType.Validation.Schema.Properties); err != nil { return fmt.Errorf("failed to parse properties: %w", err) } } diff --git a/pkg/schema_type.go b/pkg/schema_type.go new file mode 100644 index 0000000..c59a0e8 --- /dev/null +++ b/pkg/schema_type.go @@ -0,0 +1,26 @@ +package pkg + +// SchemaType is a wrapper around any kind of object that provide the following: +// - kind +// - group +// - name +// - openAPIV3Schema. +type SchemaType struct { + Schema *JSONSchemaProps + Versions []*CRDVersion + Validation *Validation + Group string + Kind string +} + +// CRDVersion corresponds to a CRD version. +type CRDVersion struct { + Name string + Schema *JSONSchemaProps +} + +// Validation is a set of validation rules that should be applied to all versions. +type Validation struct { + Name string + Schema *JSONSchemaProps +} diff --git a/pkg/testdata/sample_crd_different_crd_type.yaml b/pkg/testdata/sample_crd_different_crd_type.yaml new file mode 100644 index 0000000..e328e03 --- /dev/null +++ b/pkg/testdata/sample_crd_different_crd_type.yaml @@ -0,0 +1,134 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + # XRDs must be named 'x.' + name: xxtstorageaccounts.crossplane.fnietoga.me +spec: + # This XRD defines an XR in the 'crossplane.fnietoga.me' API group. + # The XR or Claim must use this group together with the spec.versions[0].name as it's apiVersion, like this: + # 'crossplane.fnietoga.me/v1alpha1' + group: crossplane.fnietoga.me + + # XR names should always be prefixed with an 'X' + names: + kind: xXtStorageAccount + plural: xxtstorageaccounts + # This type of XR offers a claim, which should have the same name without the 'X' prefix + claimNames: + kind: XtStorageAccount + plural: xtstorageaccounts + + # default Composition when none is specified (must match metadata.name of a provided Composition) + # e.g. in composition.yaml + defaultCompositionRef: + name: xtstorageaccount-composition + + versions: + - name: v1alpha1 + # Each version can be enabled/disabled by Served flag. + served: true + # Indicates which version of the schema Compositions use. Only one version can be referenceable + referenceable: true + # OpenAPI schema (like the one used by Kubernetes CRDs). Determines what fields + # the XR (and claim) will have. Will be automatically extended by crossplane. + # See https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ + # for full CRD documentation and guide on how to write OpenAPI schemas + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + # We define all needed parameters here one has to provide as XR or Claim spec.parameters + properties: + parameters: + type: object + properties: + resourceGroupName: + type: string + description: Name of the resource group where the resources must be created. Not allowed to be modified after creation. + location: + type: string + description: Specifies the supported Azure location where the resource exists. Valid options are westeurope, northeurope, eastus2, centralus, australiaeast, australiacentral and global. Not allowed to be modified after creation. + enum: + - westeurope + - northeurope + - eastus2 + - centralus + - australiaeast + - australiacentral + - global + environment: + type: string + description: Environment of the resources to be deployed. Valid options are production, staging, preproduction, quality_assurance, test, development, proof_of_concept, disaster_recovery, sandbox and global. + enum: + - production + - staging + - preproduction + - quality_assurance + - test + - development + - proof_of_concept + - disaster_recovery + - sandbox + - global + projectName: + type: string + description: Identifier of the functionality, project or application related to the resource. Must start with letter, only contain letters, numbers, dots, dashes, or underscores. + pattern: '^[a-zA-Z][a-zA-Z\\.\\-\\_0-9]+$' + sequentialNumber: + type: integer + description: Sequential number used for resources with the same name. Defaults to 1. + # x-kubernetes-validations: + # - rule: "self > 0 && self < 100" + # message: "Sequential number must be between 1 and 99." + default: 1 + minimum: 1 + maximum: 99 + replicationType: + type: string + description: Defines the type of replication to use for this storage account. Valid options are LRS, GRS, RAGRS, ZRS, GZRS and RAGZRS. Changing this forces a new resource to be created when types LRS, GRS and RAGRS are changed to ZRS, GZRS or RAGZRS and vice versa. Defaults to ZRS. + default: ZRS + enum: + - LRS + - GRS + - RAGRS + - ZRS + - GZRS + - RAGZRS + kind: + type: string + description: Defines the Kind of account. Valid options are BlobStorage, BlockBlobStorage, FileStorage, Storage and StorageV2. Defaults to StorageV2. + default: StorageV2 + enum: + - BlobStorage + - BlockBlobStorage + - FileStorage + - Storage + - StorageV2 + accessTier: + type: string + description: Defines the access tier for BlobStorage, FileStorage and StorageV2 accounts. Valid options are Hot and Cool. Defaults to Hot. + default: "Hot" + enum: + - Hot + - Cool + sharedAccessKeyEnabled: + type: boolean + description: Indicates whether the storage account permits requests to be authorized with the account access key via Shared Key. If false, then all requests, including shared access signatures, must be authorized with Azure Active Directory (Azure AD). Defaults to false. + default: false + hnsEnabled: + type: boolean + description: Is Hierarchical Namespace enabled? This can be used with Azure Data Lake Storage Gen 2 (see here for more information). Changing this forces a new resource to be created. Defaults to false. + default: false + largeFileShareEnabled: + type: boolean + description: Is Large File Share Enabled?. Defaults to false + default: false + required: + - resourceGroupName + - location + - environment + - projectName + required: + - parameters diff --git a/pkg/testdata/sample_crd_different_crd_type_golden.yaml b/pkg/testdata/sample_crd_different_crd_type_golden.yaml new file mode 100644 index 0000000..6582986 --- /dev/null +++ b/pkg/testdata/sample_crd_different_crd_type_golden.yaml @@ -0,0 +1,15 @@ +apiVersion: crossplane.fnietoga.me/v1alpha1 +kind: xXtStorageAccount +spec: + parameters: + accessTier: "Hot" + environment: "production" + hnsEnabled: false + kind: "StorageV2" + largeFileShareEnabled: false + location: "westeurope" + projectName: string + replicationType: "ZRS" + resourceGroupName: string + sequentialNumber: 1 + sharedAccessKeyEnabled: false diff --git a/pkg/types_jsonschema.go b/pkg/types_jsonschema.go new file mode 100644 index 0000000..fa90e2b --- /dev/null +++ b/pkg/types_jsonschema.go @@ -0,0 +1,419 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pkg + +// FieldValueErrorReason is a machine-readable value providing more detail about why a field failed the validation. +// +enum +type FieldValueErrorReason string + +const ( + // FieldValueRequired is used to report required values that are not + // provided (e.g. empty strings, null values, or empty arrays). + FieldValueRequired FieldValueErrorReason = "FieldValueRequired" + // FieldValueDuplicate is used to report collisions of values that must be + // unique (e.g. unique IDs). + FieldValueDuplicate FieldValueErrorReason = "FieldValueDuplicate" + // FieldValueInvalid is used to report malformed values (e.g. failed regex + // match, too long, out of bounds). + FieldValueInvalid FieldValueErrorReason = "FieldValueInvalid" + // FieldValueForbidden is used to report valid (as per formatting rules) + // values which would be accepted under some conditions, but which are not + // permitted by the current conditions (such as security policy). + FieldValueForbidden FieldValueErrorReason = "FieldValueForbidden" +) + +// JSONSchemaProps is a JSON-Schema following Specification Draft 4 (http://json-schema.org/). +type JSONSchemaProps struct { + ID string `json:"id,omitempty" protobuf:"bytes,1,opt,name=id"` + Schema JSONSchemaURL `json:"$schema,omitempty" protobuf:"bytes,2,opt,name=schema"` + Ref *string `json:"$ref,omitempty" protobuf:"bytes,3,opt,name=ref"` + Description string `json:"description,omitempty" protobuf:"bytes,4,opt,name=description"` + Type string `json:"type,omitempty" protobuf:"bytes,5,opt,name=type"` + + // format is an OpenAPI v3 format string. Unknown formats are ignored. The following formats are validated: + // + // - bsonobjectid: a bson object ID, i.e. a 24 characters hex string + // - uri: an URI as parsed by Golang net/url.ParseRequestURI + // - email: an email address as parsed by Golang net/mail.ParseAddress + // - hostname: a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034]. + // - ipv4: an IPv4 IP as parsed by Golang net.ParseIP + // - ipv6: an IPv6 IP as parsed by Golang net.ParseIP + // - cidr: a CIDR as parsed by Golang net.ParseCIDR + // - mac: a MAC address as parsed by Golang net.ParseMAC + // - uuid: an UUID that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + // - uuid3: an UUID3 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + // - uuid4: an UUID4 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + // - uuid5: an UUID5 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + // - isbn: an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041" + // - isbn10: an ISBN10 number string like "0321751043" + // - isbn13: an ISBN13 number string like "978-0321751041" + // - creditcard: a credit card number defined by the regex ^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$ with any non digit characters mixed in + // - ssn: a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$ + // - hexcolor: an hexadecimal color code like "#FFFFFF: following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ + // - rgbcolor: an RGB color code like rgb like "rgb(255,255,2559" + // - byte: base64 encoded binary data + // - password: any kind of string + // - date: a date string like "2006-01-02" as defined by full-date in RFC3339 + // - duration: a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format + // - datetime: a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339. + Format string `json:"format,omitempty" protobuf:"bytes,6,opt,name=format"` + + Title string `json:"title,omitempty" protobuf:"bytes,7,opt,name=title"` + // default is a default value for undefined object fields. + // Defaulting is a beta feature under the CustomResourceDefaulting feature gate. + // CustomResourceDefinitions with defaults must be created using the v1 (or newer) CustomResourceDefinition API. + Default *JSON `json:"default,omitempty" protobuf:"bytes,8,opt,name=default"` + Maximum *float64 `json:"maximum,omitempty" protobuf:"bytes,9,opt,name=maximum"` + ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty" protobuf:"bytes,10,opt,name=exclusiveMaximum"` + Minimum *float64 `json:"minimum,omitempty" protobuf:"bytes,11,opt,name=minimum"` + ExclusiveMinimum bool `json:"exclusiveMinimum,omitempty" protobuf:"bytes,12,opt,name=exclusiveMinimum"` + MaxLength *int64 `json:"maxLength,omitempty" protobuf:"bytes,13,opt,name=maxLength"` + MinLength *int64 `json:"minLength,omitempty" protobuf:"bytes,14,opt,name=minLength"` + Pattern string `json:"pattern,omitempty" protobuf:"bytes,15,opt,name=pattern"` + MaxItems *int64 `json:"maxItems,omitempty" protobuf:"bytes,16,opt,name=maxItems"` + MinItems *int64 `json:"minItems,omitempty" protobuf:"bytes,17,opt,name=minItems"` + UniqueItems bool `json:"uniqueItems,omitempty" protobuf:"bytes,18,opt,name=uniqueItems"` + MultipleOf *float64 `json:"multipleOf,omitempty" protobuf:"bytes,19,opt,name=multipleOf"` + // +listType=atomic + Enum []JSON `json:"enum,omitempty" protobuf:"bytes,20,rep,name=enum"` + MaxProperties *int64 `json:"maxProperties,omitempty" protobuf:"bytes,21,opt,name=maxProperties"` + MinProperties *int64 `json:"minProperties,omitempty" protobuf:"bytes,22,opt,name=minProperties"` + // +listType=atomic + Required []string `json:"required,omitempty" protobuf:"bytes,23,rep,name=required"` + Items *JSONSchemaPropsOrArray `json:"items,omitempty" protobuf:"bytes,24,opt,name=items"` + // +listType=atomic + AllOf []JSONSchemaProps `json:"allOf,omitempty" protobuf:"bytes,25,rep,name=allOf"` + // +listType=atomic + OneOf []JSONSchemaProps `json:"oneOf,omitempty" protobuf:"bytes,26,rep,name=oneOf"` + // +listType=atomic + AnyOf []JSONSchemaProps `json:"anyOf,omitempty" protobuf:"bytes,27,rep,name=anyOf"` + Not *JSONSchemaProps `json:"not,omitempty" protobuf:"bytes,28,opt,name=not"` + Properties map[string]JSONSchemaProps `json:"properties,omitempty" protobuf:"bytes,29,rep,name=properties"` + AdditionalProperties *JSONSchemaPropsOrBool `json:"additionalProperties,omitempty" protobuf:"bytes,30,opt,name=additionalProperties"` + PatternProperties map[string]JSONSchemaProps `json:"patternProperties,omitempty" protobuf:"bytes,31,rep,name=patternProperties"` + Dependencies JSONSchemaDependencies `json:"dependencies,omitempty" protobuf:"bytes,32,opt,name=dependencies"` + AdditionalItems *JSONSchemaPropsOrBool `json:"additionalItems,omitempty" protobuf:"bytes,33,opt,name=additionalItems"` + Definitions JSONSchemaDefinitions `json:"definitions,omitempty" protobuf:"bytes,34,opt,name=definitions"` + ExternalDocs *ExternalDocumentation `json:"externalDocs,omitempty" protobuf:"bytes,35,opt,name=externalDocs"` + Example *JSON `json:"example,omitempty" protobuf:"bytes,36,opt,name=example"` + Nullable bool `json:"nullable,omitempty" protobuf:"bytes,37,opt,name=nullable"` + + // x-kubernetes-preserve-unknown-fields stops the API server + // decoding step from pruning fields which are not specified + // in the validation schema. This affects fields recursively, + // but switches back to normal pruning behaviour if nested + // properties or additionalProperties are specified in the schema. + // This can either be true or undefined. False is forbidden. + XPreserveUnknownFields *bool `json:"x-kubernetes-preserve-unknown-fields,omitempty" protobuf:"bytes,38,opt,name=xKubernetesPreserveUnknownFields"` + + // x-kubernetes-embedded-resource defines that the value is an + // embedded Kubernetes runtime.Object, with TypeMeta and + // ObjectMeta. The type must be object. It is allowed to further + // restrict the embedded object. kind, apiVersion and metadata + // are validated automatically. x-kubernetes-preserve-unknown-fields + // is allowed to be true, but does not have to be if the object + // is fully specified (up to kind, apiVersion, metadata). + XEmbeddedResource bool `json:"x-kubernetes-embedded-resource,omitempty" protobuf:"bytes,39,opt,name=xKubernetesEmbeddedResource"` + + // x-kubernetes-int-or-string specifies that this value is + // either an integer or a string. If this is true, an empty + // type is allowed and type as child of anyOf is permitted + // if following one of the following patterns: + // + // 1) anyOf: + // - type: integer + // - type: string + // 2) allOf: + // - anyOf: + // - type: integer + // - type: string + // - ... zero or more + XIntOrString bool `json:"x-kubernetes-int-or-string,omitempty" protobuf:"bytes,40,opt,name=xKubernetesIntOrString"` + + // x-kubernetes-list-map-keys annotates an array with the x-kubernetes-list-type `map` by specifying the keys used + // as the index of the map. + // + // This tag MUST only be used on lists that have the "x-kubernetes-list-type" + // extension set to "map". Also, the values specified for this attribute must + // be a scalar typed field of the child structure (no nesting is supported). + // + // The properties specified must either be required or have a default value, + // to ensure those properties are present for all list items. + // + // +optional + // +listType=atomic + XListMapKeys []string `json:"x-kubernetes-list-map-keys,omitempty" protobuf:"bytes,41,rep,name=xKubernetesListMapKeys"` + + // x-kubernetes-list-type annotates an array to further describe its topology. + // This extension must only be used on lists and may have 3 possible values: + // + // 1) `atomic`: the list is treated as a single entity, like a scalar. + // Atomic lists will be entirely replaced when updated. This extension + // may be used on any type of list (struct, scalar, ...). + // 2) `set`: + // Sets are lists that must not have multiple items with the same value. Each + // value must be a scalar, an object with x-kubernetes-map-type `atomic` or an + // array with x-kubernetes-list-type `atomic`. + // 3) `map`: + // These lists are like maps in that their elements have a non-index key + // used to identify them. Order is preserved upon merge. The map tag + // must only be used on a list with elements of type object. + // Defaults to atomic for arrays. + // +optional + XListType *string `json:"x-kubernetes-list-type,omitempty" protobuf:"bytes,42,opt,name=xKubernetesListType"` + + // x-kubernetes-map-type annotates an object to further describe its topology. + // This extension must only be used when type is object and may have 2 possible values: + // + // 1) `granular`: + // These maps are actual maps (key-value pairs) and each fields are independent + // from each other (they can each be manipulated by separate actors). This is + // the default behaviour for all maps. + // 2) `atomic`: the list is treated as a single entity, like a scalar. + // Atomic maps will be entirely replaced when updated. + // +optional + XMapType *string `json:"x-kubernetes-map-type,omitempty" protobuf:"bytes,43,opt,name=xKubernetesMapType"` + + // x-kubernetes-validations describes a list of validation rules written in the CEL expression language. + // +patchMergeKey=rule + // +patchStrategy=merge + // +listType=map + // +listMapKey=rule + XValidations ValidationRules `json:"x-kubernetes-validations,omitempty" patchStrategy:"merge" patchMergeKey:"rule" protobuf:"bytes,44,rep,name=xKubernetesValidations"` +} + +// ValidationRules describes a list of validation rules written in the CEL expression language. +type ValidationRules []ValidationRule + +// ValidationRule describes a validation rule written in the CEL expression language. +type ValidationRule struct { + // Rule represents the expression which will be evaluated by CEL. + // ref: https://github.com/google/cel-spec + // The Rule is scoped to the location of the x-kubernetes-validations extension in the schema. + // The `self` variable in the CEL expression is bound to the scoped value. + // Example: + // - Rule scoped to the root of a resource with a status subresource: {"rule": "self.status.actual <= self.spec.maxDesired"} + // + // If the Rule is scoped to an object with properties, the accessible properties of the object are field selectable + // via `self.field` and field presence can be checked via `has(self.field)`. Null valued fields are treated as + // absent fields in CEL expressions. + // If the Rule is scoped to an object with additionalProperties (i.e. a map) the value of the map + // are accessible via `self[mapKey]`, map containment can be checked via `mapKey in self` and all entries of the map + // are accessible via CEL macros and functions such as `self.all(...)`. + // If the Rule is scoped to an array, the elements of the array are accessible via `self[i]` and also by macros and + // functions. + // If the Rule is scoped to a scalar, `self` is bound to the scalar value. + // Examples: + // - Rule scoped to a map of objects: {"rule": "self.components['Widget'].priority < 10"} + // - Rule scoped to a list of integers: {"rule": "self.values.all(value, value >= 0 && value < 100)"} + // - Rule scoped to a string value: {"rule": "self.startsWith('kube')"} + // + // The `apiVersion`, `kind`, `metadata.name` and `metadata.generateName` are always accessible from the root of the + // object and from any x-kubernetes-embedded-resource annotated objects. No other metadata properties are accessible. + // + // Unknown data preserved in custom resources via x-kubernetes-preserve-unknown-fields is not accessible in CEL + // expressions. This includes: + // - Unknown field values that are preserved by object schemas with x-kubernetes-preserve-unknown-fields. + // - Object properties where the property schema is of an "unknown type". An "unknown type" is recursively defined as: + // - A schema with no type and x-kubernetes-preserve-unknown-fields set to true + // - An array where the items schema is of an "unknown type" + // - An object where the additionalProperties schema is of an "unknown type" + // + // Only property names of the form `[a-zA-Z_.-/][a-zA-Z0-9_.-/]*` are accessible. + // Accessible property names are escaped according to the following rules when accessed in the expression: + // - '__' escapes to '__underscores__' + // - '.' escapes to '__dot__' + // - '-' escapes to '__dash__' + // - '/' escapes to '__slash__' + // - Property names that exactly match a CEL RESERVED keyword escape to '__{keyword}__'. The keywords are: + // "true", "false", "null", "in", "as", "break", "const", "continue", "else", "for", "function", "if", + // "import", "let", "loop", "package", "namespace", "return". + // Examples: + // - Rule accessing a property named "namespace": {"rule": "self.__namespace__ > 0"} + // - Rule accessing a property named "x-prop": {"rule": "self.x__dash__prop > 0"} + // - Rule accessing a property named "redact__d": {"rule": "self.redact__underscores__d > 0"} + // + // Equality on arrays with x-kubernetes-list-type of 'set' or 'map' ignores element order, i.e. [1, 2] == [2, 1]. + // Concatenation on arrays with x-kubernetes-list-type use the semantics of the list type: + // - 'set': `X + Y` performs a union where the array positions of all elements in `X` are preserved and + // non-intersecting elements in `Y` are appended, retaining their partial order. + // - 'map': `X + Y` performs a merge where the array positions of all keys in `X` are preserved but the values + // are overwritten by values in `Y` when the key sets of `X` and `Y` intersect. Elements in `Y` with + // non-intersecting keys are appended, retaining their partial order. + // + // If `rule` makes use of the `oldSelf` variable it is implicitly a + // `transition rule`. + // + // By default, the `oldSelf` variable is the same type as `self`. + // When `optionalOldSelf` is true, the `oldSelf` variable is a CEL optional + // variable whose value() is the same type as `self`. + // See the documentation for the `optionalOldSelf` field for details. + // + // Transition rules by default are applied only on UPDATE requests and are + // skipped if an old value could not be found. You can opt a transition + // rule into unconditional evaluation by setting `optionalOldSelf` to true. + // + Rule string `json:"rule" protobuf:"bytes,1,opt,name=rule"` + // Message represents the message displayed when validation fails. The message is required if the Rule contains + // line breaks. The message must not contain line breaks. + // If unset, the message is "failed rule: {Rule}". + // e.g. "must be a URL with the host matching spec.host" + Message string `json:"message,omitempty" protobuf:"bytes,2,opt,name=message"` + // MessageExpression declares a CEL expression that evaluates to the validation failure message that is returned when this rule fails. + // Since messageExpression is used as a failure message, it must evaluate to a string. + // If both message and messageExpression are present on a rule, then messageExpression will be used if validation + // fails. If messageExpression results in a runtime error, the runtime error is logged, and the validation failure message is produced + // as if the messageExpression field were unset. If messageExpression evaluates to an empty string, a string with only spaces, or a string + // that contains line breaks, then the validation failure message will also be produced as if the messageExpression field were unset, and + // the fact that messageExpression produced an empty string/string with only spaces/string with line breaks will be logged. + // messageExpression has access to all the same variables as the rule; the only difference is the return type. + // Example: + // "x must be less than max ("+string(self.max)+")" + // +optional + MessageExpression string `json:"messageExpression,omitempty" protobuf:"bytes,3,opt,name=messageExpression"` + // reason provides a machine-readable validation failure reason that is returned to the caller when a request fails this validation rule. + // The HTTP status code returned to the caller will match the reason of the reason of the first failed validation rule. + // The currently supported reasons are: "FieldValueInvalid", "FieldValueForbidden", "FieldValueRequired", "FieldValueDuplicate". + // If not set, default to use "FieldValueInvalid". + // All future added reasons must be accepted by clients when reading this value and unknown reasons should be treated as FieldValueInvalid. + // +optional + Reason *FieldValueErrorReason `json:"reason,omitempty" protobuf:"bytes,4,opt,name=reason"` + // fieldPath represents the field path returned when the validation fails. + // It must be a relative JSON path (i.e. with array notation) scoped to the location of this x-kubernetes-validations extension in the schema and refer to an existing field. + // e.g. when validation checks if a specific attribute `foo` under a map `testMap`, the fieldPath could be set to `.testMap.foo` + // If the validation checks two lists must have unique attributes, the fieldPath could be set to either of the list: e.g. `.testList` + // It does not support list numeric index. + // It supports child operation to refer to an existing field currently. Refer to [JSONPath support in Kubernetes](https://kubernetes.io/docs/reference/kubectl/jsonpath/) for more info. + // Numeric index of array is not supported. + // For field name which contains special characters, use `['specialName']` to refer the field name. + // e.g. for attribute `foo.34$` appears in a list `testList`, the fieldPath could be set to `.testList['foo.34$']` + // +optional + FieldPath string `json:"fieldPath,omitempty" protobuf:"bytes,5,opt,name=fieldPath"` + + // optionalOldSelf is used to opt a transition rule into evaluation + // even when the object is first created, or if the old object is + // missing the value. + // + // When enabled `oldSelf` will be a CEL optional whose value will be + // `None` if there is no old value, or when the object is initially created. + // + // You may check for presence of oldSelf using `oldSelf.hasValue()` and + // unwrap it after checking using `oldSelf.value()`. Check the CEL + // documentation for Optional types for more information: + // https://pkg.go.dev/github.com/google/cel-go/cel#OptionalTypes + // + // May not be set unless `oldSelf` is used in `rule`. + // + // +featureGate=CRDValidationRatcheting + // +optional + OptionalOldSelf *bool `json:"optionalOldSelf,omitempty" protobuf:"bytes,6,opt,name=optionalOldSelf"` +} + +// JSON represents any valid JSON value. +// These types are supported: bool, int64, float64, string, []interface{}, map[string]interface{} and nil. +type JSON struct { + Raw []byte `json:"-" protobuf:"bytes,1,opt,name=raw"` +} + +// OpenAPISchemaType is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +// +// See: https://github.com/kubernetes/kube-openapi/tree/master/pkg/generators +func (_ JSON) OpenAPISchemaType() []string { + // TODO: return actual types when anyOf is supported + return nil +} + +// OpenAPISchemaFormat is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +func (_ JSON) OpenAPISchemaFormat() string { return "" } + +// JSONSchemaURL represents a schema url. +type JSONSchemaURL string + +// JSONSchemaPropsOrArray represents a value that can either be a JSONSchemaProps +// or an array of JSONSchemaProps. Mainly here for serialization purposes. +type JSONSchemaPropsOrArray struct { + Schema *JSONSchemaProps `protobuf:"bytes,1,opt,name=schema"` + // +listType=atomic + JSONSchemas []JSONSchemaProps `protobuf:"bytes,2,rep,name=jSONSchemas"` +} + +// OpenAPISchemaType is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +// +// See: https://github.com/kubernetes/kube-openapi/tree/master/pkg/generators +func (_ JSONSchemaPropsOrArray) OpenAPISchemaType() []string { + // TODO: return actual types when anyOf is supported + return nil +} + +// OpenAPISchemaFormat is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +func (_ JSONSchemaPropsOrArray) OpenAPISchemaFormat() string { return "" } + +// JSONSchemaPropsOrBool represents JSONSchemaProps or a boolean value. +// Defaults to true for the boolean property. +type JSONSchemaPropsOrBool struct { + Allows bool `protobuf:"varint,1,opt,name=allows"` + Schema *JSONSchemaProps `protobuf:"bytes,2,opt,name=schema"` +} + +// OpenAPISchemaType is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +// +// See: https://github.com/kubernetes/kube-openapi/tree/master/pkg/generators +func (_ JSONSchemaPropsOrBool) OpenAPISchemaType() []string { + // TODO: return actual types when anyOf is supported + return nil +} + +// OpenAPISchemaFormat is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +func (_ JSONSchemaPropsOrBool) OpenAPISchemaFormat() string { return "" } + +// JSONSchemaDependencies represent a dependencies property. +type JSONSchemaDependencies map[string]JSONSchemaPropsOrStringArray + +// JSONSchemaPropsOrStringArray represents a JSONSchemaProps or a string array. +type JSONSchemaPropsOrStringArray struct { + Schema *JSONSchemaProps `protobuf:"bytes,1,opt,name=schema"` + // +listType=atomic + Property []string `protobuf:"bytes,2,rep,name=property"` +} + +// OpenAPISchemaType is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +// +// See: https://github.com/kubernetes/kube-openapi/tree/master/pkg/generators +func (_ JSONSchemaPropsOrStringArray) OpenAPISchemaType() []string { + // TODO: return actual types when anyOf is supported + return nil +} + +// OpenAPISchemaFormat is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +func (_ JSONSchemaPropsOrStringArray) OpenAPISchemaFormat() string { return "" } + +// JSONSchemaDefinitions contains the models explicitly defined in this spec. +type JSONSchemaDefinitions map[string]JSONSchemaProps + +// ExternalDocumentation allows referencing an external resource for extended documentation. +type ExternalDocumentation struct { + Description string `json:"description,omitempty" protobuf:"bytes,1,opt,name=description"` + URL string `json:"url,omitempty" protobuf:"bytes,2,opt,name=url"` +} diff --git a/wasm/app.go b/wasm/app.go index d942804..3acd917 100644 --- a/wasm/app.go +++ b/wasm/app.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/maxence-charriere/go-app/v10/pkg/app" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" "github.com/Skarlso/crd-to-sample-yaml/pkg" @@ -109,15 +109,20 @@ func (h *crdView) Render() app.UI { return h.buildError(h.preRenderErr) } - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} if err := yaml.Unmarshal(h.content, crd); err != nil { return h.buildError(err) } + schemaType, err := pkg.ExtractSchemaType(crd) + if err != nil { + return h.buildError(err) + } + versions := make([]Version, 0) - parser := pkg.NewParser(crd.Spec.Group, crd.Spec.Names.Kind, h.comment, h.minimal, false) - for _, version := range crd.Spec.Versions { - v, err := h.generate(parser, crd, version.Schema.OpenAPIV3Schema, version.Name) + parser := pkg.NewParser(schemaType.Group, schemaType.Kind, h.comment, h.minimal, false) + for _, version := range schemaType.Versions { + v, err := h.generate(parser, schemaType, version.Schema, version.Name) if err != nil { return h.buildError(err) } @@ -126,11 +131,8 @@ func (h *crdView) Render() app.UI { } // Parse validation instead. - if len(crd.Spec.Versions) == 0 && crd.Spec.Validation != nil { - crd.Spec.Validation.OpenAPIV3Schema.Properties["kind"] = v1beta1.JSONSchemaProps{} - crd.Spec.Validation.OpenAPIV3Schema.Properties["apiVersion"] = v1beta1.JSONSchemaProps{} - - v, err := h.generate(parser, crd, crd.Spec.Validation.OpenAPIV3Schema, crd.Name) + if len(schemaType.Versions) == 0 && schemaType.Validation != nil { + v, err := h.generate(parser, schemaType, schemaType.Validation.Schema, schemaType.Validation.Name) if err != nil { return h.buildError(err) } @@ -203,7 +205,7 @@ func (h *crdView) Render() app.UI { ) } -func (h *crdView) generate(parser *pkg.Parser, crd *v1beta1.CustomResourceDefinition, properties *v1beta1.JSONSchemaProps, name string) (Version, error) { +func (h *crdView) generate(parser *pkg.Parser, crd *pkg.SchemaType, properties *pkg.JSONSchemaProps, name string) (Version, error) { out, err := parseCRD(properties.Properties, name, pkg.RootRequiredFields, h.minimal) if err != nil { return Version{}, err @@ -218,8 +220,8 @@ func (h *crdView) generate(parser *pkg.Parser, crd *v1beta1.CustomResourceDefini return Version{ Version: name, Properties: out, - Kind: crd.Spec.Names.Kind, - Group: crd.Spec.Group, + Kind: crd.Kind, + Group: crd.Group, Description: properties.Description, YAML: buf.String(), }, nil @@ -314,7 +316,7 @@ func render(d app.UI, p []*Property, accordionID string) app.UI { // parseCRD takes the properties and constructs a linked list out of the embedded properties that the recursive // template can call and construct linked divs. -func parseCRD(properties map[string]v1beta1.JSONSchemaProps, version string, requiredList []string, minimal bool) ([]*Property, error) { +func parseCRD(properties map[string]pkg.JSONSchemaProps, version string, requiredList []string, minimal bool) ([]*Property, error) { sortedKeys := make([]string, 0, len(properties)) output := make([]*Property, 0, len(properties)) diff --git a/wasm/index.go b/wasm/index.go index 1b6f862..56c571b 100644 --- a/wasm/index.go +++ b/wasm/index.go @@ -7,7 +7,7 @@ import ( "net/http" "github.com/maxence-charriere/go-app/v10/pkg/app" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" "github.com/Skarlso/crd-to-sample-yaml/pkg" @@ -232,21 +232,28 @@ type editView struct { func (e *editView) OnInput(ctx app.Context, _ app.Event) { content := ctx.JSSrc().Get("value").String() - crd := &v1beta1.CustomResourceDefinition{} + crd := &unstructured.Unstructured{} if err := yaml.Unmarshal([]byte(content), crd); err != nil { e.content = []byte("invalid CRD content") return } + schemaType, err := pkg.ExtractSchemaType(crd) + if err != nil { + e.content = []byte("invalid CRD content") + + return + } + e.content = nil - parser := pkg.NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false, false) - for _, version := range crd.Spec.Versions { + parser := pkg.NewParser(schemaType.Group, schemaType.Kind, false, false, false) + for _, version := range schemaType.Versions { e.content = append(e.content, []byte("---\n")...) var buffer []byte buf := bytes.NewBuffer(buffer) - if err := parser.ParseProperties(version.Name, buf, version.Schema.OpenAPIV3Schema.Properties); err != nil { + if err := parser.ParseProperties(version.Name, buf, version.Schema.Properties); err != nil { e.content = []byte(err.Error()) return diff --git a/wasm/index.html b/wasm/index.html index f242cd9..a3970d8 100644 --- a/wasm/index.html +++ b/wasm/index.html @@ -1,5 +1,5 @@ - + @@ -14,14 +14,14 @@ Preview CRDs - + - - - + + + - + @@ -57,8 +57,8 @@
-