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."`