From 73e1dbd209fdad43c7079a9f52404c3f2b0e732c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Fri, 22 May 2026 08:56:40 +0200 Subject: [PATCH 1/2] feat: add resource crd subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/validate/manager.go | 5 + cmd/crossplane/xpkg/crd.go | 249 ++++++++++++++++++++ cmd/crossplane/xpkg/crd_test.go | 352 +++++++++++++++++++++++++++++ cmd/crossplane/xpkg/xpkg.go | 5 +- 4 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 cmd/crossplane/xpkg/crd.go create mode 100644 cmd/crossplane/xpkg/crd_test.go diff --git a/cmd/crossplane/validate/manager.go b/cmd/crossplane/validate/manager.go index 27e027c..3cef832 100644 --- a/cmd/crossplane/validate/manager.go +++ b/cmd/crossplane/validate/manager.go @@ -77,6 +77,11 @@ func WithUpdateCache(update bool) Option { } } +// CRDs returns the collected CRDs. +func (m *Manager) CRDs() []*extv1.CustomResourceDefinition { + return m.crds +} + // NewManager returns a new Manager. func NewManager(cacheDir string, fs afero.Fs, w io.Writer, opts ...Option) *Manager { m := &Manager{} diff --git a/cmd/crossplane/xpkg/crd.go b/cmd/crossplane/xpkg/crd.go new file mode 100644 index 0000000..436db48 --- /dev/null +++ b/cmd/crossplane/xpkg/crd.go @@ -0,0 +1,249 @@ +/* +Copyright 2026 The Crossplane 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 xpkg + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/alecthomas/kong" + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/load" + "github.com/crossplane/cli/v2/cmd/crossplane/validate" +) + +const ( + errWriteOutput = "cannot write output" + jsonSchemaDraft07 = "http://json-schema.org/draft-07/schema#" +) + +// Cmd arguments and flags for the crd subcommand. +type crdCmd struct { + // Arguments. + Extensions string `arg:"" help:"Extension sources as a comma-separated list of files, directories, or '-' for standard input."` + + // Flags. Keep them in alphabetical order. + CacheDir string `default:"~/.crossplane/cache" help:"Absolute path to the cache directory where downloaded schemas are stored." predictor:"directory"` + CleanCache bool `help:"Clean the cache directory before downloading package schemas."` + CrossplaneImage string `help:"Specify the Crossplane image to be used for fetching the built-in schemas."` + JSONSchema bool `help:"Write JSON Schema files instead of CRDs. Useful for YAML language server integration." name:"json-schema"` + NoCache bool `help:"Disable caching entirely. Schemas are downloaded every time and not stored."` + OutputDir string `default:"." help:"Directory where CRD or JSON Schema files will be written. Defaults to current directory." name:"output-dir" short:"o"` + UpdateCache bool `default:"false" help:"Update cached schemas by downloading the latest version that satisfies a constraint."` + + fs afero.Fs +} + +// Help prints out the help for the crd command. +func (c *crdCmd) Help() string { + return ` +This command downloads CRDs from Crossplane package dependencies (providers, functions, configurations) and writes +them as YAML files to the specified output directory. With --json-schema, it extracts the OpenAPI v3 schemas from +CRDs and writes them as JSON Schema files suitable for use with YAML language servers. + +It accepts the same extension sources as the validate command: crossplane.yaml files, directories containing package +manifests, or Provider/Function/Configuration resources. + +Examples: + + # Download CRDs from a crossplane.yaml to the current directory + crossplane xpkg crd crossplane.yaml + + # Download CRDs to a specific directory + crossplane xpkg crd crossplane.yaml --output-dir ./crds + + # Download JSON Schemas for YAML language server + crossplane xpkg crd crossplane.yaml --output-dir ./schemas --json-schema + + # Download CRDs from multiple sources + crossplane xpkg crd crossplane.yaml,providers/ --output-dir ./crds + + # Force re-download of cached schemas + crossplane xpkg crd crossplane.yaml --output-dir ./crds --clean-cache +` +} + +// AfterApply implements kong.AfterApply. +func (c *crdCmd) AfterApply() error { + c.fs = afero.NewOsFs() + return nil +} + +// Run downloads CRDs from package dependencies and writes them to the output directory. +func (c *crdCmd) Run(k *kong.Context, _ logging.Logger) error { + extensionLoader, err := load.NewLoader(c.Extensions) + if err != nil { + return errors.Wrapf(err, "cannot load extensions from %q", c.Extensions) + } + + extensions, err := extensionLoader.Load() + if err != nil { + return errors.Wrapf(err, "cannot load extensions from %q", c.Extensions) + } + + if c.NoCache { + tmpCache, err := afero.TempDir(c.fs, "", "crossplane-crd-*") + if err != nil { + return errors.Wrap(err, "cannot create temporary cache directory") + } + defer c.fs.RemoveAll(tmpCache) //nolint:errcheck // best-effort cleanup + c.CacheDir = tmpCache + } else if strings.HasPrefix(c.CacheDir, "~/") { + homeDir, _ := os.UserHomeDir() + c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:]) + } + + opts := []validate.Option{ + validate.WithUpdateCache(c.UpdateCache), + } + if c.CrossplaneImage != "" { + opts = append(opts, validate.WithCrossplaneImage(c.CrossplaneImage)) + } + + m := validate.NewManager(c.CacheDir, c.fs, k.Stdout, opts...) + + if err := m.PrepExtensions(extensions); err != nil { + return errors.Wrap(err, "cannot prepare extensions") + } + + if err := m.CacheAndLoad(c.CleanCache); err != nil { + return errors.Wrap(err, "cannot download and load schemas") + } + + if err := c.fs.MkdirAll(c.OutputDir, 0o755); err != nil { + return errors.Wrapf(err, "cannot create output directory %q", c.OutputDir) + } + + if c.JSONSchema { + return c.writeJSONSchemas(k, m.CRDs()) + } + + return c.writeCRDs(k, m.CRDs()) +} + +// writeCRDs marshals each CRD to YAML and writes it to the output directory. +func (c *crdCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { + for _, crd := range crds { + data, err := yaml.Marshal(crd) + if err != nil { + return errors.Wrapf(err, "cannot marshal CRD %q", crd.GetName()) + } + + filename := crd.GetName() + ".yaml" + outPath := filepath.Join(c.OutputDir, filename) + + if err := afero.WriteFile(c.fs, outPath, data, 0o644); err != nil { + return errors.Wrapf(err, "cannot write CRD to %q", outPath) + } + + if _, err := fmt.Fprintf(k.Stdout, "wrote %s\n", outPath); err != nil { + return errors.Wrap(err, errWriteOutput) + } + } + + if _, err := fmt.Fprintf(k.Stdout, "Total %d CRDs written to %s\n", len(crds), c.OutputDir); err != nil { + return errors.Wrap(err, errWriteOutput) + } + + return nil +} + +// writeJSONSchemas extracts OpenAPI v3 schemas from CRD versions and writes +// them as JSON Schema files organized by group and version. +func (c *crdCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { + count := 0 + + for _, crd := range crds { + group := crd.Spec.Group + kind := crd.Spec.Names.Kind + + for _, ver := range crd.Spec.Versions { + if ver.Schema == nil || ver.Schema.OpenAPIV3Schema == nil { + continue + } + + schema, err := openAPIToJSONSchema(ver.Schema.OpenAPIV3Schema, group, ver.Name, kind) + if err != nil { + return errors.Wrapf(err, "cannot convert schema for %s/%s %s", group, ver.Name, kind) + } + + data, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return errors.Wrapf(err, "cannot marshal JSON Schema for %s/%s %s", group, ver.Name, kind) + } + + dir := filepath.Join(c.OutputDir, group, ver.Name) + if err := c.fs.MkdirAll(dir, 0o755); err != nil { + return errors.Wrapf(err, "cannot create directory %q", dir) + } + + filename := strings.ToLower(kind) + ".json" + outPath := filepath.Join(dir, filename) + + if err := afero.WriteFile(c.fs, outPath, data, 0o644); err != nil { + return errors.Wrapf(err, "cannot write JSON Schema to %q", outPath) + } + + if _, err := fmt.Fprintf(k.Stdout, "wrote %s\n", outPath); err != nil { + return errors.Wrap(err, errWriteOutput) + } + + count++ + } + } + + if _, err := fmt.Fprintf(k.Stdout, "Total %d JSON Schemas written to %s\n", count, c.OutputDir); err != nil { + return errors.Wrap(err, errWriteOutput) + } + + return nil +} + +// openAPIToJSONSchema converts an OpenAPI v3 schema to a JSON Schema draft-07 +// document with Kubernetes group-version-kind metadata. +func openAPIToJSONSchema(props *extv1.JSONSchemaProps, group, version, kind string) (map[string]any, error) { + raw, err := json.Marshal(props) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal OpenAPI schema") + } + + schema := map[string]any{} + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal OpenAPI schema") + } + + schema["$schema"] = jsonSchemaDraft07 + schema["$id"] = fmt.Sprintf("%s/%s/%s.json", group, version, strings.ToLower(kind)) + schema["x-kubernetes-group-version-kind"] = []map[string]string{ + { + "group": group, + "version": version, + "kind": kind, + }, + } + + return schema, nil +} diff --git a/cmd/crossplane/xpkg/crd_test.go b/cmd/crossplane/xpkg/crd_test.go new file mode 100644 index 0000000..7e82bd8 --- /dev/null +++ b/cmd/crossplane/xpkg/crd_test.go @@ -0,0 +1,352 @@ +/* +Copyright 2026 The Crossplane 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 xpkg + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" +) + +var testCRD = &extv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tests.example.org", + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + }, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +func TestOpenAPIToJSONSchema(t *testing.T) { + type args struct { + props *extv1.JSONSchemaProps + group string + version string + kind string + } + + type want struct { + schema map[string]any + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "BasicSchema": { + reason: "Should convert a basic OpenAPI schema to JSON Schema with correct metadata", + args: args{ + props: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + }, + }, + group: "example.org", + version: "v1alpha1", + kind: "Test", + }, + want: want{ + schema: map[string]any{ + "$schema": jsonSchemaDraft07, + "$id": "example.org/v1alpha1/test.json", + "type": "object", + "properties": map[string]any{ + "replicas": map[string]any{ + "type": "integer", + }, + }, + "x-kubernetes-group-version-kind": []map[string]string{ + { + "group": "example.org", + "version": "v1alpha1", + "kind": "Test", + }, + }, + }, + }, + }, + "EmptySchema": { + reason: "Should handle an empty schema with only type", + args: args{ + props: &extv1.JSONSchemaProps{Type: "object"}, + group: "test.io", + version: "v1", + kind: "Foo", + }, + want: want{ + schema: map[string]any{ + "$schema": jsonSchemaDraft07, + "$id": "test.io/v1/foo.json", + "type": "object", + "x-kubernetes-group-version-kind": []map[string]string{ + { + "group": "test.io", + "version": "v1", + "kind": "Foo", + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := openAPIToJSONSchema(tc.args.props, tc.args.group, tc.args.version, tc.args.kind) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nopenAPIToJSONSchema(...): -want error, +got error:\n%s", tc.reason, diff) + } + + // Compare via JSON to normalize types (float64 vs int, etc.) + wantJSON, _ := json.Marshal(tc.want.schema) + gotJSON, _ := json.Marshal(got) + + if diff := cmp.Diff(string(wantJSON), string(gotJSON)); diff != "" { + t.Errorf("%s\nopenAPIToJSONSchema(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestWriteCRDs(t *testing.T) { + type args struct { + crds []*extv1.CustomResourceDefinition + outputDir string + } + + type want struct { + files []string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SingleCRD": { + reason: "Should write a single CRD as a YAML file", + args: args{ + crds: []*extv1.CustomResourceDefinition{testCRD}, + outputDir: "/out", + }, + want: want{ + files: []string{"/out/tests.example.org.yaml"}, + }, + }, + "MultipleCRDs": { + reason: "Should write multiple CRDs as separate YAML files", + args: args{ + crds: []*extv1.CustomResourceDefinition{ + testCRD, + { + ObjectMeta: metav1.ObjectMeta{Name: "foos.example.org"}, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.org", + Names: extv1.CustomResourceDefinitionNames{Kind: "Foo"}, + }, + }, + }, + outputDir: "/out", + }, + want: want{ + files: []string{ + "/out/tests.example.org.yaml", + "/out/foos.example.org.yaml", + }, + }, + }, + "EmptyList": { + reason: "Should handle empty CRD list gracefully", + args: args{ + crds: []*extv1.CustomResourceDefinition{}, + outputDir: "/out", + }, + want: want{ + files: []string{}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll(tc.args.outputDir, 0o755) + + buf := &bytes.Buffer{} + app, err := kong.New(&struct{}{}) + if err != nil { + t.Fatalf("cannot create kong app: %v", err) + } + k, err := app.Parse([]string{}) + if err != nil { + t.Fatalf("cannot parse kong: %v", err) + } + k.Stdout = buf + + c := &crdCmd{ + OutputDir: tc.args.outputDir, + fs: fs, + } + + err = c.writeCRDs(k, tc.args.crds) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nwriteCRDs(...): -want error, +got error:\n%s", tc.reason, diff) + } + + for _, f := range tc.want.files { + exists, _ := afero.Exists(fs, f) + if !exists { + t.Errorf("%s\nwriteCRDs(...): expected file %s to exist", tc.reason, f) + } + } + }) + } +} + +func TestWriteJSONSchemas(t *testing.T) { + type args struct { + crds []*extv1.CustomResourceDefinition + outputDir string + } + + type want struct { + files []string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SingleVersion": { + reason: "Should write a JSON Schema file for a single version CRD", + args: args{ + crds: []*extv1.CustomResourceDefinition{testCRD}, + outputDir: "/schemas", + }, + want: want{ + files: []string{"/schemas/example.org/v1alpha1/test.json"}, + }, + }, + "NoSchema": { + reason: "Should skip versions without OpenAPI schema", + args: args{ + crds: []*extv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{Name: "nils.example.org"}, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.org", + Names: extv1.CustomResourceDefinitionNames{Kind: "Nil"}, + Versions: []extv1.CustomResourceDefinitionVersion{ + {Name: "v1", Schema: nil}, + }, + }, + }, + }, + outputDir: "/schemas", + }, + want: want{ + files: []string{}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + + buf := &bytes.Buffer{} + app, err := kong.New(&struct{}{}) + if err != nil { + t.Fatalf("cannot create kong app: %v", err) + } + k, err := app.Parse([]string{}) + if err != nil { + t.Fatalf("cannot parse kong: %v", err) + } + k.Stdout = buf + + c := &crdCmd{ + OutputDir: tc.args.outputDir, + fs: fs, + } + + err = c.writeJSONSchemas(k, tc.args.crds) + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nwriteJSONSchemas(...): -want error, +got error:\n%s", tc.reason, diff) + } + + for _, f := range tc.want.files { + exists, _ := afero.Exists(fs, f) + if !exists { + t.Errorf("%s\nwriteJSONSchemas(...): expected file %s to exist", tc.reason, f) + } + + data, _ := afero.ReadFile(fs, f) + var schema map[string]any + if err := json.Unmarshal(data, &schema); err != nil { + t.Errorf("%s\nwriteJSONSchemas(...): file %s is not valid JSON: %v", tc.reason, f, err) + } + + if schema["$schema"] != jsonSchemaDraft07 { + t.Errorf("%s\nwriteJSONSchemas(...): file %s missing $schema field", tc.reason, f) + } + } + }) + } +} diff --git a/cmd/crossplane/xpkg/xpkg.go b/cmd/crossplane/xpkg/xpkg.go index 35f6e38..b2ec5f2 100644 --- a/cmd/crossplane/xpkg/xpkg.go +++ b/cmd/crossplane/xpkg/xpkg.go @@ -17,7 +17,9 @@ limitations under the License. // Package xpkg contains Crossplane packaging commands. package xpkg -import _ "embed" +import ( + _ "embed" +) //go:embed help/xpkg.md var helpXpkg string @@ -29,6 +31,7 @@ type Cmd struct { // Keep subcommands sorted alphabetically. Batch batchCmd `cmd:"" help:"Batch build and push a family of provider packages."` Build buildCmd `cmd:"" help:"Build a new package."` + CRD crdCmd `cmd:"" help:"Download CRDs from package dependencies."` Init initCmd `cmd:"" help:"Initialize a new package from a template."` Install installCmd `cmd:"" help:"Install a package in a control plane."` Push pushCmd `cmd:"" help:"Push a package to a registry."` From 97c1b1ace91906f17be0045bfab929558aae8ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Thu, 28 May 2026 00:44:15 +0200 Subject: [PATCH 2/2] chore: refactor to address code reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/xpkg/crd.go | 92 ++++++++-------- cmd/crossplane/xpkg/crd_test.go | 170 ++++++++++------------------- internal/schemas/generator/json.go | 31 +++++- 3 files changed, 130 insertions(+), 163 deletions(-) diff --git a/cmd/crossplane/xpkg/crd.go b/cmd/crossplane/xpkg/crd.go index 436db48..4291f4d 100644 --- a/cmd/crossplane/xpkg/crd.go +++ b/cmd/crossplane/xpkg/crd.go @@ -33,12 +33,10 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/common/load" "github.com/crossplane/cli/v2/cmd/crossplane/validate" + "github.com/crossplane/cli/v2/internal/schemas/generator" ) -const ( - errWriteOutput = "cannot write output" - jsonSchemaDraft07 = "http://json-schema.org/draft-07/schema#" -) +const errWriteOutput = "cannot write output" // Cmd arguments and flags for the crd subcommand. type crdCmd struct { @@ -49,6 +47,7 @@ type crdCmd struct { CacheDir string `default:"~/.crossplane/cache" help:"Absolute path to the cache directory where downloaded schemas are stored." predictor:"directory"` CleanCache bool `help:"Clean the cache directory before downloading package schemas."` CrossplaneImage string `help:"Specify the Crossplane image to be used for fetching the built-in schemas."` + Flat bool `help:"Write files to a flat directory instead of organizing by group and version."` JSONSchema bool `help:"Write JSON Schema files instead of CRDs. Useful for YAML language server integration." name:"json-schema"` NoCache bool `help:"Disable caching entirely. Schemas are downloaded every time and not stored."` OutputDir string `default:"." help:"Directory where CRD or JSON Schema files will be written. Defaults to current directory." name:"output-dir" short:"o"` @@ -64,17 +63,20 @@ This command downloads CRDs from Crossplane package dependencies (providers, fun them as YAML files to the specified output directory. With --json-schema, it extracts the OpenAPI v3 schemas from CRDs and writes them as JSON Schema files suitable for use with YAML language servers. +By default, files are organized by API group and version (e.g., //.{yaml|json} for CRDs +or JSON schemas). Use --flat to not create subfolders and write all files directly to the output directory. + It accepts the same extension sources as the validate command: crossplane.yaml files, directories containing package manifests, or Provider/Function/Configuration resources. Examples: - # Download CRDs from a crossplane.yaml to the current directory - crossplane xpkg crd crossplane.yaml - - # Download CRDs to a specific directory + # Download CRDs organized by group crossplane xpkg crd crossplane.yaml --output-dir ./crds + # Download CRDs as flat files + crossplane xpkg crd crossplane.yaml --output-dir ./crds --flat + # Download JSON Schemas for YAML language server crossplane xpkg crd crossplane.yaml --output-dir ./schemas --json-schema @@ -145,6 +147,8 @@ func (c *crdCmd) Run(k *kong.Context, _ logging.Logger) error { } // writeCRDs marshals each CRD to YAML and writes it to the output directory. +// By default, files are organized by group and version. With --flat, files are +// written directly to the output directory using the CRD name. func (c *crdCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { for _, crd := range crds { data, err := yaml.Marshal(crd) @@ -152,8 +156,11 @@ func (c *crdCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefiniti return errors.Wrapf(err, "cannot marshal CRD %q", crd.GetName()) } - filename := crd.GetName() + ".yaml" - outPath := filepath.Join(c.OutputDir, filename) + outPath := c.outputPath(crd.GetName(), crd.Spec.Group, storageVersion(crd), crd.Spec.Names.Kind, ".yaml") + + if err := c.fs.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return errors.Wrapf(err, "cannot create directory for %q", outPath) + } if err := afero.WriteFile(c.fs, outPath, data, 0o644); err != nil { return errors.Wrapf(err, "cannot write CRD to %q", outPath) @@ -171,8 +178,32 @@ func (c *crdCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefiniti return nil } +func storageVersion(crd *extv1.CustomResourceDefinition) string { + for _, v := range crd.Spec.Versions { + if v.Storage { + return v.Name + } + } + if len(crd.Spec.Versions) > 0 { + return crd.Spec.Versions[0].Name + } + return "" +} + +// outputPath returns the file path for a resource. flatName is used as the +// filename in --flat mode. In structured mode, files are organized by group +// and version. +func (c *crdCmd) outputPath(flatName, group, version, kind, ext string) string { + if c.Flat { + return filepath.Join(c.OutputDir, flatName+ext) + } + return filepath.Join(c.OutputDir, group, version, strings.ToLower(kind)+ext) +} + // writeJSONSchemas extracts OpenAPI v3 schemas from CRD versions and writes -// them as JSON Schema files organized by group and version. +// them as JSON Schema files organized by group and version. It applies the +// shared schema mutations from internal/schemas/generator for YAML language +// server compatibility (additionalProperties: false on object types, etc.). func (c *crdCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { count := 0 @@ -185,7 +216,7 @@ func (c *crdCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceD continue } - schema, err := openAPIToJSONSchema(ver.Schema.OpenAPIV3Schema, group, ver.Name, kind) + schema, err := generator.ToJSONSchema(ver.Schema.OpenAPIV3Schema, group, ver.Name, kind) if err != nil { return errors.Wrapf(err, "cannot convert schema for %s/%s %s", group, ver.Name, kind) } @@ -195,13 +226,12 @@ func (c *crdCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceD return errors.Wrapf(err, "cannot marshal JSON Schema for %s/%s %s", group, ver.Name, kind) } - dir := filepath.Join(c.OutputDir, group, ver.Name) - if err := c.fs.MkdirAll(dir, 0o755); err != nil { - return errors.Wrapf(err, "cannot create directory %q", dir) - } + flatName := fmt.Sprintf("%s_%s_%s", group, ver.Name, strings.ToLower(kind)) + outPath := c.outputPath(flatName, group, ver.Name, kind, ".json") - filename := strings.ToLower(kind) + ".json" - outPath := filepath.Join(dir, filename) + if err := c.fs.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return errors.Wrapf(err, "cannot create directory for %q", outPath) + } if err := afero.WriteFile(c.fs, outPath, data, 0o644); err != nil { return errors.Wrapf(err, "cannot write JSON Schema to %q", outPath) @@ -221,29 +251,3 @@ func (c *crdCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceD return nil } - -// openAPIToJSONSchema converts an OpenAPI v3 schema to a JSON Schema draft-07 -// document with Kubernetes group-version-kind metadata. -func openAPIToJSONSchema(props *extv1.JSONSchemaProps, group, version, kind string) (map[string]any, error) { - raw, err := json.Marshal(props) - if err != nil { - return nil, errors.Wrap(err, "cannot marshal OpenAPI schema") - } - - schema := map[string]any{} - if err := json.Unmarshal(raw, &schema); err != nil { - return nil, errors.Wrap(err, "cannot unmarshal OpenAPI schema") - } - - schema["$schema"] = jsonSchemaDraft07 - schema["$id"] = fmt.Sprintf("%s/%s/%s.json", group, version, strings.ToLower(kind)) - schema["x-kubernetes-group-version-kind"] = []map[string]string{ - { - "group": group, - "version": version, - "kind": kind, - }, - } - - return schema, nil -} diff --git a/cmd/crossplane/xpkg/crd_test.go b/cmd/crossplane/xpkg/crd_test.go index 7e82bd8..a840fc7 100644 --- a/cmd/crossplane/xpkg/crd_test.go +++ b/cmd/crossplane/xpkg/crd_test.go @@ -23,6 +23,7 @@ import ( "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" + "github.com/invopop/jsonschema" "github.com/spf13/afero" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,24 +31,36 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/test" ) +const schemaTypeObject = "object" + var testCRD = &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, ObjectMeta: metav1.ObjectMeta{ Name: "tests.example.org", }, Spec: extv1.CustomResourceDefinitionSpec{ Group: "example.org", Names: extv1.CustomResourceDefinitionNames{ - Kind: "Test", + Kind: "Test", + Plural: "tests", + Singular: "test", + ListKind: "TestList", }, + Scope: extv1.NamespaceScoped, Versions: []extv1.CustomResourceDefinitionVersion{ { - Name: "v1alpha1", + Name: "v1alpha1", + Served: true, + Storage: true, Schema: &extv1.CustomResourceValidation{ OpenAPIV3Schema: &extv1.JSONSchemaProps{ - Type: "object", + Type: schemaTypeObject, Properties: map[string]extv1.JSONSchemaProps{ "spec": { - Type: "object", + Type: schemaTypeObject, Properties: map[string]extv1.JSONSchemaProps{ "replicas": { Type: "integer", @@ -62,106 +75,10 @@ var testCRD = &extv1.CustomResourceDefinition{ }, } -func TestOpenAPIToJSONSchema(t *testing.T) { - type args struct { - props *extv1.JSONSchemaProps - group string - version string - kind string - } - - type want struct { - schema map[string]any - err error - } - - cases := map[string]struct { - reason string - args args - want want - }{ - "BasicSchema": { - reason: "Should convert a basic OpenAPI schema to JSON Schema with correct metadata", - args: args{ - props: &extv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]extv1.JSONSchemaProps{ - "replicas": { - Type: "integer", - }, - }, - }, - group: "example.org", - version: "v1alpha1", - kind: "Test", - }, - want: want{ - schema: map[string]any{ - "$schema": jsonSchemaDraft07, - "$id": "example.org/v1alpha1/test.json", - "type": "object", - "properties": map[string]any{ - "replicas": map[string]any{ - "type": "integer", - }, - }, - "x-kubernetes-group-version-kind": []map[string]string{ - { - "group": "example.org", - "version": "v1alpha1", - "kind": "Test", - }, - }, - }, - }, - }, - "EmptySchema": { - reason: "Should handle an empty schema with only type", - args: args{ - props: &extv1.JSONSchemaProps{Type: "object"}, - group: "test.io", - version: "v1", - kind: "Foo", - }, - want: want{ - schema: map[string]any{ - "$schema": jsonSchemaDraft07, - "$id": "test.io/v1/foo.json", - "type": "object", - "x-kubernetes-group-version-kind": []map[string]string{ - { - "group": "test.io", - "version": "v1", - "kind": "Foo", - }, - }, - }, - }, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - got, err := openAPIToJSONSchema(tc.args.props, tc.args.group, tc.args.version, tc.args.kind) - - if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { - t.Errorf("%s\nopenAPIToJSONSchema(...): -want error, +got error:\n%s", tc.reason, diff) - } - - // Compare via JSON to normalize types (float64 vs int, etc.) - wantJSON, _ := json.Marshal(tc.want.schema) - gotJSON, _ := json.Marshal(got) - - if diff := cmp.Diff(string(wantJSON), string(gotJSON)); diff != "" { - t.Errorf("%s\nopenAPIToJSONSchema(...): -want, +got:\n%s", tc.reason, diff) - } - }) - } -} - func TestWriteCRDs(t *testing.T) { type args struct { crds []*extv1.CustomResourceDefinition + flat bool outputDir string } @@ -175,18 +92,29 @@ func TestWriteCRDs(t *testing.T) { args args want want }{ - "SingleCRD": { - reason: "Should write a single CRD as a YAML file", + "Structured": { + reason: "Should write CRDs organized by group and storage version", args: args{ crds: []*extv1.CustomResourceDefinition{testCRD}, outputDir: "/out", }, + want: want{ + files: []string{"/out/example.org/v1alpha1/test.yaml"}, + }, + }, + "Flat": { + reason: "Should write CRDs as flat files when --flat is set", + args: args{ + crds: []*extv1.CustomResourceDefinition{testCRD}, + flat: true, + outputDir: "/out", + }, want: want{ files: []string{"/out/tests.example.org.yaml"}, }, }, "MultipleCRDs": { - reason: "Should write multiple CRDs as separate YAML files", + reason: "Should write multiple CRDs organized by group and version", args: args{ crds: []*extv1.CustomResourceDefinition{ testCRD, @@ -195,6 +123,9 @@ func TestWriteCRDs(t *testing.T) { Spec: extv1.CustomResourceDefinitionSpec{ Group: "example.org", Names: extv1.CustomResourceDefinitionNames{Kind: "Foo"}, + Versions: []extv1.CustomResourceDefinitionVersion{ + {Name: "v1beta1", Storage: true}, + }, }, }, }, @@ -202,8 +133,8 @@ func TestWriteCRDs(t *testing.T) { }, want: want{ files: []string{ - "/out/tests.example.org.yaml", - "/out/foos.example.org.yaml", + "/out/example.org/v1alpha1/test.yaml", + "/out/example.org/v1beta1/foo.yaml", }, }, }, @@ -222,7 +153,6 @@ func TestWriteCRDs(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { fs := afero.NewMemMapFs() - _ = fs.MkdirAll(tc.args.outputDir, 0o755) buf := &bytes.Buffer{} app, err := kong.New(&struct{}{}) @@ -237,6 +167,7 @@ func TestWriteCRDs(t *testing.T) { c := &crdCmd{ OutputDir: tc.args.outputDir, + Flat: tc.args.flat, fs: fs, } @@ -259,6 +190,7 @@ func TestWriteCRDs(t *testing.T) { func TestWriteJSONSchemas(t *testing.T) { type args struct { crds []*extv1.CustomResourceDefinition + flat bool outputDir string } @@ -272,8 +204,8 @@ func TestWriteJSONSchemas(t *testing.T) { args args want want }{ - "SingleVersion": { - reason: "Should write a JSON Schema file for a single version CRD", + "Structured": { + reason: "Should write JSON Schema files organized by group and version", args: args{ crds: []*extv1.CustomResourceDefinition{testCRD}, outputDir: "/schemas", @@ -282,6 +214,17 @@ func TestWriteJSONSchemas(t *testing.T) { files: []string{"/schemas/example.org/v1alpha1/test.json"}, }, }, + "Flat": { + reason: "Should write JSON Schema files as flat files when --flat is set", + args: args{ + crds: []*extv1.CustomResourceDefinition{testCRD}, + flat: true, + outputDir: "/schemas", + }, + want: want{ + files: []string{"/schemas/example.org_v1alpha1_test.json"}, + }, + }, "NoSchema": { reason: "Should skip versions without OpenAPI schema", args: args{ @@ -322,6 +265,7 @@ func TestWriteJSONSchemas(t *testing.T) { c := &crdCmd{ OutputDir: tc.args.outputDir, + Flat: tc.args.flat, fs: fs, } @@ -338,13 +282,9 @@ func TestWriteJSONSchemas(t *testing.T) { } data, _ := afero.ReadFile(fs, f) - var schema map[string]any + var schema jsonschema.Schema if err := json.Unmarshal(data, &schema); err != nil { - t.Errorf("%s\nwriteJSONSchemas(...): file %s is not valid JSON: %v", tc.reason, f, err) - } - - if schema["$schema"] != jsonSchemaDraft07 { - t.Errorf("%s\nwriteJSONSchemas(...): file %s missing $schema field", tc.reason, f) + t.Errorf("%s\nwriteJSONSchemas(...): file %s is not valid JSON Schema: %v", tc.reason, f, err) } } }) diff --git a/internal/schemas/generator/json.go b/internal/schemas/generator/json.go index cee0d07..4fcb62f 100644 --- a/internal/schemas/generator/json.go +++ b/internal/schemas/generator/json.go @@ -19,6 +19,7 @@ package generator import ( "context" "encoding/json" + "fmt" "io/fs" "maps" "path/filepath" @@ -62,7 +63,7 @@ func (jsonGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne } for name, schema := range schemas { - jschema, err := oapiSchemaToJSONSchema(schema) + jschema, err := ToJSONSchema(schema, "", "", "") if err != nil { return nil, errors.Wrapf(err, "failed to generate jsonschema for %s", name) } @@ -81,7 +82,11 @@ func (jsonGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne return schemaFS, nil } -func oapiSchemaToJSONSchema(s *spec.Schema) (*jsonschema.Schema, error) { +// ToJSONSchema converts any JSON-compatible schema (e.g., *spec.Schema or +// *extv1.JSONSchemaProps) to a *jsonschema.Schema with YAML language server +// compatibility fixes applied. When group, version, and kind are non-empty, it +// also sets $id and x-kubernetes-group-version-kind metadata. +func ToJSONSchema(s any, group, version, kind string) (*jsonschema.Schema, error) { bs, err := json.Marshal(s) if err != nil { return nil, err @@ -92,9 +97,27 @@ func oapiSchemaToJSONSchema(s *spec.Schema) (*jsonschema.Schema, error) { return nil, err } - return mutateJSONSchema(&conv), nil + mutateJSONSchema(&conv) + + if group != "" && version != "" && kind != "" { + conv.ID = jsonschema.ID(fmt.Sprintf("%s/%s/%s.json", group, version, strings.ToLower(kind))) + conv.Extras = map[string]any{ + "x-kubernetes-group-version-kind": []map[string]string{ + { + "group": group, + "version": version, + "kind": kind, + }, + }, + } + } + + return &conv, nil } +// mutateJSONSchema applies YAML language server compatibility fixes to a JSON +// Schema: sets additionalProperties to false on object types and rewrites +// component $ref paths to file references. func mutateJSONSchema(s *jsonschema.Schema) *jsonschema.Schema { if s.Type == "object" && s.AdditionalProperties == nil { s.AdditionalProperties = jsonschema.FalseSchema @@ -185,7 +208,7 @@ func (jsonGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ r } for name, schema := range schemas { - jschema, err := oapiSchemaToJSONSchema(schema) + jschema, err := ToJSONSchema(schema, "", "", "") if err != nil { return nil, errors.Wrapf(err, "failed to generate jsonschema for %s", name) }