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/6] 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/6] 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) } From a6683b2b0986b47d8bf1fb0568a15ded0539dfbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:16:13 +0200 Subject: [PATCH 3/6] fix: address review comments 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> --- .../xpkg/{crd.go => extract_crds.go} | 55 ++++++------------- .../{crd_test.go => extract_crds_test.go} | 4 +- cmd/crossplane/xpkg/help/extract-crds.md | 45 +++++++++++++++ cmd/crossplane/xpkg/xpkg.go | 16 +++--- internal/schemas/generator/json.go | 24 ++++---- 5 files changed, 84 insertions(+), 60 deletions(-) rename cmd/crossplane/xpkg/{crd.go => extract_crds.go} (80%) rename cmd/crossplane/xpkg/{crd_test.go => extract_crds_test.go} (99%) create mode 100644 cmd/crossplane/xpkg/help/extract-crds.md diff --git a/cmd/crossplane/xpkg/crd.go b/cmd/crossplane/xpkg/extract_crds.go similarity index 80% rename from cmd/crossplane/xpkg/crd.go rename to cmd/crossplane/xpkg/extract_crds.go index 4291f4d..dd2ae5d 100644 --- a/cmd/crossplane/xpkg/crd.go +++ b/cmd/crossplane/xpkg/extract_crds.go @@ -17,6 +17,7 @@ limitations under the License. package xpkg import ( + _ "embed" "encoding/json" "fmt" "os" @@ -26,6 +27,7 @@ import ( "github.com/alecthomas/kong" "github.com/spf13/afero" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + runtimeSchema "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -36,10 +38,13 @@ import ( "github.com/crossplane/cli/v2/internal/schemas/generator" ) +//go:embed help/extract-crds.md +var helpExtractCRDs string + const errWriteOutput = "cannot write output" -// Cmd arguments and flags for the crd subcommand. -type crdCmd struct { +// Cmd arguments and flags for the extract-crds subcommand. +type extractCRDsCmd struct { // Arguments. Extensions string `arg:"" help:"Extension sources as a comma-separated list of files, directories, or '-' for standard input."` @@ -56,46 +61,19 @@ type crdCmd struct { 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. - -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 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 - - # 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 -` +// Help prints out the help for the extract-crds command. +func (c *extractCRDsCmd) Help() string { + return helpExtractCRDs } // AfterApply implements kong.AfterApply. -func (c *crdCmd) AfterApply() error { +func (c *extractCRDsCmd) 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 { +func (c *extractCRDsCmd) 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) @@ -149,7 +127,7 @@ 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 { +func (c *extractCRDsCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { for _, crd := range crds { data, err := yaml.Marshal(crd) if err != nil { @@ -193,7 +171,7 @@ func storageVersion(crd *extv1.CustomResourceDefinition) string { // 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 { +func (c *extractCRDsCmd) outputPath(flatName, group, version, kind, ext string) string { if c.Flat { return filepath.Join(c.OutputDir, flatName+ext) } @@ -204,7 +182,7 @@ func (c *crdCmd) outputPath(flatName, group, version, kind, ext string) string { // 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 { +func (c *extractCRDsCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { count := 0 for _, crd := range crds { @@ -216,7 +194,8 @@ func (c *crdCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceD continue } - schema, err := generator.ToJSONSchema(ver.Schema.OpenAPIV3Schema, group, ver.Name, kind) + gvk := runtimeSchema.GroupVersionKind{Group: group, Version: ver.Name, Kind: kind} + schema, err := generator.ToJSONSchema(ver.Schema.OpenAPIV3Schema, gvk) if err != nil { return errors.Wrapf(err, "cannot convert schema for %s/%s %s", group, ver.Name, kind) } diff --git a/cmd/crossplane/xpkg/crd_test.go b/cmd/crossplane/xpkg/extract_crds_test.go similarity index 99% rename from cmd/crossplane/xpkg/crd_test.go rename to cmd/crossplane/xpkg/extract_crds_test.go index a840fc7..d89ee66 100644 --- a/cmd/crossplane/xpkg/crd_test.go +++ b/cmd/crossplane/xpkg/extract_crds_test.go @@ -165,7 +165,7 @@ func TestWriteCRDs(t *testing.T) { } k.Stdout = buf - c := &crdCmd{ + c := &extractCRDsCmd{ OutputDir: tc.args.outputDir, Flat: tc.args.flat, fs: fs, @@ -263,7 +263,7 @@ func TestWriteJSONSchemas(t *testing.T) { } k.Stdout = buf - c := &crdCmd{ + c := &extractCRDsCmd{ OutputDir: tc.args.outputDir, Flat: tc.args.flat, fs: fs, diff --git a/cmd/crossplane/xpkg/help/extract-crds.md b/cmd/crossplane/xpkg/help/extract-crds.md new file mode 100644 index 0000000..9cd7693 --- /dev/null +++ b/cmd/crossplane/xpkg/help/extract-crds.md @@ -0,0 +1,45 @@ +The `xpkg extract-crds` 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. + +By default, files are organized by API group and version (e.g., +`//.{yaml|json}`). Use `--flat` to write all files +directly to the output directory without subfolders. + +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 organized by group: + +```shell +crossplane xpkg extract-crds crossplane.yaml --output-dir ./crds +``` + +Download CRDs as flat files: + +```shell +crossplane xpkg extract-crds crossplane.yaml --output-dir ./crds --flat +``` + +Download JSON Schemas for YAML language server: + +```shell +crossplane xpkg extract-crds crossplane.yaml --output-dir ./schemas --json-schema +``` + +Download CRDs from multiple sources: + +```shell +crossplane xpkg extract-crds crossplane.yaml,providers/ --output-dir ./crds +``` + +Force re-download of cached schemas: + +```shell +crossplane xpkg extract-crds crossplane.yaml --output-dir ./crds --clean-cache +``` diff --git a/cmd/crossplane/xpkg/xpkg.go b/cmd/crossplane/xpkg/xpkg.go index b2ec5f2..a3da36a 100644 --- a/cmd/crossplane/xpkg/xpkg.go +++ b/cmd/crossplane/xpkg/xpkg.go @@ -29,14 +29,14 @@ var helpXpkg string // Cmd contains commands for interacting with xpkgs. 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."` - Update updateCmd `cmd:"" help:"Update a package in a control plane."` - Extract extractCmd `cmd:"" help:"Extract package contents into a Crossplane cache compatible format. Fetches from a remote registry by default."` + Batch batchCmd `cmd:"" help:"Batch build and push a family of provider packages."` + Build buildCmd `cmd:"" help:"Build a new package."` + ExtractCrds extractCRDsCmd `cmd:"" name:"extract-crds" 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."` + Update updateCmd `cmd:"" help:"Update a package in a control plane."` + Extract extractCmd `cmd:"" help:"Extract package contents into a Crossplane cache compatible format. Fetches from a remote registry by default."` } // Help prints out the help for the xpkg command. diff --git a/internal/schemas/generator/json.go b/internal/schemas/generator/json.go index 4fcb62f..8ad4878 100644 --- a/internal/schemas/generator/json.go +++ b/internal/schemas/generator/json.go @@ -27,6 +27,7 @@ import ( "github.com/invopop/jsonschema" "github.com/spf13/afero" + runtimeSchema "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" @@ -63,7 +64,7 @@ func (jsonGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne } for name, schema := range schemas { - jschema, err := ToJSONSchema(schema, "", "", "") + jschema, err := ToJSONSchema(schema, runtimeSchema.GroupVersionKind{}) if err != nil { return nil, errors.Wrapf(err, "failed to generate jsonschema for %s", name) } @@ -82,11 +83,10 @@ func (jsonGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runne return schemaFS, nil } -// 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) { +// 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 gvk is non-empty, it also sets $id and x-kubernetes-group-version-kind metadata. +func ToJSONSchema(s any, gvk runtimeSchema.GroupVersionKind) (*jsonschema.Schema, error) { bs, err := json.Marshal(s) if err != nil { return nil, err @@ -99,14 +99,14 @@ func ToJSONSchema(s any, group, version, kind string) (*jsonschema.Schema, error mutateJSONSchema(&conv) - if group != "" && version != "" && kind != "" { - conv.ID = jsonschema.ID(fmt.Sprintf("%s/%s/%s.json", group, version, strings.ToLower(kind))) + if !gvk.Empty() { + conv.ID = jsonschema.ID(fmt.Sprintf("%s/%s/%s.json", gvk.Group, gvk.Version, strings.ToLower(gvk.Kind))) conv.Extras = map[string]any{ "x-kubernetes-group-version-kind": []map[string]string{ { - "group": group, - "version": version, - "kind": kind, + "group": gvk.Group, + "version": gvk.Version, + "kind": gvk.Kind, }, }, } @@ -208,7 +208,7 @@ func (jsonGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ r } for name, schema := range schemas { - jschema, err := ToJSONSchema(schema, "", "", "") + jschema, err := ToJSONSchema(schema, runtimeSchema.GroupVersionKind{}) if err != nil { return nil, errors.Wrapf(err, "failed to generate jsonschema for %s", name) } From d83f87d0e1c9a810d0a19ad5ef1fa41cc8233908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:36:03 +0200 Subject: [PATCH 4/6] lint 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/extract_crds.go | 3 ++- cmd/crossplane/xpkg/xpkg.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/xpkg/extract_crds.go b/cmd/crossplane/xpkg/extract_crds.go index dd2ae5d..fa315d7 100644 --- a/cmd/crossplane/xpkg/extract_crds.go +++ b/cmd/crossplane/xpkg/extract_crds.go @@ -17,7 +17,6 @@ limitations under the License. package xpkg import ( - _ "embed" "encoding/json" "fmt" "os" @@ -36,6 +35,8 @@ 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" + + _ "embed" ) //go:embed help/extract-crds.md diff --git a/cmd/crossplane/xpkg/xpkg.go b/cmd/crossplane/xpkg/xpkg.go index a3da36a..b83ab01 100644 --- a/cmd/crossplane/xpkg/xpkg.go +++ b/cmd/crossplane/xpkg/xpkg.go @@ -31,7 +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."` - ExtractCrds extractCRDsCmd `cmd:"" name:"extract-crds" help:"Download CRDs from package dependencies."` + ExtractCrds extractCRDsCmd `cmd:"" help:"Download CRDs from package dependencies." name:"extract-crds"` 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 9ff6a069eb4f05600379b8de60b8cb115bd69f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:46:46 +0200 Subject: [PATCH 5/6] chore: rename to get-crds 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> --- .../xpkg/{extract_crds.go => get_crds.go} | 24 +++++++++---------- ...{extract_crds_test.go => get_crds_test.go} | 4 ++-- .../help/{extract-crds.md => get-crds.md} | 12 +++++----- cmd/crossplane/xpkg/xpkg.go | 16 ++++++------- 4 files changed, 28 insertions(+), 28 deletions(-) rename cmd/crossplane/xpkg/{extract_crds.go => get_crds.go} (91%) rename cmd/crossplane/xpkg/{extract_crds_test.go => get_crds_test.go} (99%) rename cmd/crossplane/xpkg/help/{extract-crds.md => get-crds.md} (67%) diff --git a/cmd/crossplane/xpkg/extract_crds.go b/cmd/crossplane/xpkg/get_crds.go similarity index 91% rename from cmd/crossplane/xpkg/extract_crds.go rename to cmd/crossplane/xpkg/get_crds.go index fa315d7..d96350e 100644 --- a/cmd/crossplane/xpkg/extract_crds.go +++ b/cmd/crossplane/xpkg/get_crds.go @@ -39,13 +39,13 @@ import ( _ "embed" ) -//go:embed help/extract-crds.md -var helpExtractCRDs string +//go:embed help/get-crds.md +var helpGetCRDs string const errWriteOutput = "cannot write output" -// Cmd arguments and flags for the extract-crds subcommand. -type extractCRDsCmd struct { +// Cmd arguments and flags for the get-crds subcommand. +type getCRDsCmd struct { // Arguments. Extensions string `arg:"" help:"Extension sources as a comma-separated list of files, directories, or '-' for standard input."` @@ -62,19 +62,19 @@ type extractCRDsCmd struct { fs afero.Fs } -// Help prints out the help for the extract-crds command. -func (c *extractCRDsCmd) Help() string { - return helpExtractCRDs +// Help prints out the help for the get-crds command. +func (c *getCRDsCmd) Help() string { + return helpGetCRDs } // AfterApply implements kong.AfterApply. -func (c *extractCRDsCmd) AfterApply() error { +func (c *getCRDsCmd) AfterApply() error { c.fs = afero.NewOsFs() return nil } // Run downloads CRDs from package dependencies and writes them to the output directory. -func (c *extractCRDsCmd) Run(k *kong.Context, _ logging.Logger) error { +func (c *getCRDsCmd) 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) @@ -128,7 +128,7 @@ func (c *extractCRDsCmd) 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 *extractCRDsCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { +func (c *getCRDsCmd) writeCRDs(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { for _, crd := range crds { data, err := yaml.Marshal(crd) if err != nil { @@ -172,7 +172,7 @@ func storageVersion(crd *extv1.CustomResourceDefinition) string { // 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 *extractCRDsCmd) outputPath(flatName, group, version, kind, ext string) string { +func (c *getCRDsCmd) outputPath(flatName, group, version, kind, ext string) string { if c.Flat { return filepath.Join(c.OutputDir, flatName+ext) } @@ -183,7 +183,7 @@ func (c *extractCRDsCmd) outputPath(flatName, group, version, kind, ext string) // 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 *extractCRDsCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { +func (c *getCRDsCmd) writeJSONSchemas(k *kong.Context, crds []*extv1.CustomResourceDefinition) error { count := 0 for _, crd := range crds { diff --git a/cmd/crossplane/xpkg/extract_crds_test.go b/cmd/crossplane/xpkg/get_crds_test.go similarity index 99% rename from cmd/crossplane/xpkg/extract_crds_test.go rename to cmd/crossplane/xpkg/get_crds_test.go index d89ee66..3017ba4 100644 --- a/cmd/crossplane/xpkg/extract_crds_test.go +++ b/cmd/crossplane/xpkg/get_crds_test.go @@ -165,7 +165,7 @@ func TestWriteCRDs(t *testing.T) { } k.Stdout = buf - c := &extractCRDsCmd{ + c := &getCRDsCmd{ OutputDir: tc.args.outputDir, Flat: tc.args.flat, fs: fs, @@ -263,7 +263,7 @@ func TestWriteJSONSchemas(t *testing.T) { } k.Stdout = buf - c := &extractCRDsCmd{ + c := &getCRDsCmd{ OutputDir: tc.args.outputDir, Flat: tc.args.flat, fs: fs, diff --git a/cmd/crossplane/xpkg/help/extract-crds.md b/cmd/crossplane/xpkg/help/get-crds.md similarity index 67% rename from cmd/crossplane/xpkg/help/extract-crds.md rename to cmd/crossplane/xpkg/help/get-crds.md index 9cd7693..f05e796 100644 --- a/cmd/crossplane/xpkg/help/extract-crds.md +++ b/cmd/crossplane/xpkg/help/get-crds.md @@ -1,4 +1,4 @@ -The `xpkg extract-crds` command downloads CRDs from Crossplane package +The `xpkg get-crds` 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 @@ -17,29 +17,29 @@ Provider/Function/Configuration resources. Download CRDs organized by group: ```shell -crossplane xpkg extract-crds crossplane.yaml --output-dir ./crds +crossplane xpkg get-crds crossplane.yaml --output-dir ./crds ``` Download CRDs as flat files: ```shell -crossplane xpkg extract-crds crossplane.yaml --output-dir ./crds --flat +crossplane xpkg get-crds crossplane.yaml --output-dir ./crds --flat ``` Download JSON Schemas for YAML language server: ```shell -crossplane xpkg extract-crds crossplane.yaml --output-dir ./schemas --json-schema +crossplane xpkg get-crds crossplane.yaml --output-dir ./schemas --json-schema ``` Download CRDs from multiple sources: ```shell -crossplane xpkg extract-crds crossplane.yaml,providers/ --output-dir ./crds +crossplane xpkg get-crds crossplane.yaml,providers/ --output-dir ./crds ``` Force re-download of cached schemas: ```shell -crossplane xpkg extract-crds crossplane.yaml --output-dir ./crds --clean-cache +crossplane xpkg get-crds crossplane.yaml --output-dir ./crds --clean-cache ``` diff --git a/cmd/crossplane/xpkg/xpkg.go b/cmd/crossplane/xpkg/xpkg.go index b83ab01..cfe7c87 100644 --- a/cmd/crossplane/xpkg/xpkg.go +++ b/cmd/crossplane/xpkg/xpkg.go @@ -29,14 +29,14 @@ var helpXpkg string // Cmd contains commands for interacting with xpkgs. 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."` - ExtractCrds extractCRDsCmd `cmd:"" help:"Download CRDs from package dependencies." name:"extract-crds"` - 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."` - Update updateCmd `cmd:"" help:"Update a package in a control plane."` - Extract extractCmd `cmd:"" help:"Extract package contents into a Crossplane cache compatible format. Fetches from a remote registry by default."` + Batch batchCmd `cmd:"" help:"Batch build and push a family of provider packages."` + Build buildCmd `cmd:"" help:"Build a new package."` + GetCrds getCRDsCmd `cmd:"" help:"Download CRDs from package dependencies." name:"get-crds"` + 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."` + Update updateCmd `cmd:"" help:"Update a package in a control plane."` + Extract extractCmd `cmd:"" help:"Extract package contents into a Crossplane cache compatible format. Fetches from a remote registry by default."` } // Help prints out the help for the xpkg command. From e9cad4857c85c34a961c5e3be14f4bba008bb75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:53:47 +0200 Subject: [PATCH 6/6] chore: cleanup 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/help/get-crds.md | 34 ++++++++++++++-------------- cmd/crossplane/xpkg/xpkg.go | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/crossplane/xpkg/help/get-crds.md b/cmd/crossplane/xpkg/help/get-crds.md index f05e796..55c0955 100644 --- a/cmd/crossplane/xpkg/help/get-crds.md +++ b/cmd/crossplane/xpkg/help/get-crds.md @@ -14,32 +14,32 @@ Provider/Function/Configuration resources. ## Examples -Download CRDs organized by group: +- Download CRDs organized by group: -```shell -crossplane xpkg get-crds crossplane.yaml --output-dir ./crds -``` + ```shell + crossplane xpkg get-crds crossplane.yaml --output-dir ./crds + ``` -Download CRDs as flat files: +- Download CRDs as flat files: -```shell -crossplane xpkg get-crds crossplane.yaml --output-dir ./crds --flat -``` + ```shell + crossplane xpkg get-crds crossplane.yaml --output-dir ./crds --flat + ``` -Download JSON Schemas for YAML language server: +- Download JSON Schemas for YAML language server: ```shell crossplane xpkg get-crds crossplane.yaml --output-dir ./schemas --json-schema ``` -Download CRDs from multiple sources: +- Download CRDs from multiple sources: -```shell -crossplane xpkg get-crds crossplane.yaml,providers/ --output-dir ./crds -``` + ```shell + crossplane xpkg get-crds crossplane.yaml,providers/ --output-dir ./crds + ``` -Force re-download of cached schemas: +- Force re-download of cached schemas: -```shell -crossplane xpkg get-crds crossplane.yaml --output-dir ./crds --clean-cache -``` + ```shell + crossplane xpkg get-crds crossplane.yaml --output-dir ./crds --clean-cache + ``` diff --git a/cmd/crossplane/xpkg/xpkg.go b/cmd/crossplane/xpkg/xpkg.go index cfe7c87..5c4f350 100644 --- a/cmd/crossplane/xpkg/xpkg.go +++ b/cmd/crossplane/xpkg/xpkg.go @@ -31,7 +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."` - GetCrds getCRDsCmd `cmd:"" help:"Download CRDs from package dependencies." name:"get-crds"` + GetCrds getCRDsCmd `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."`