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..4291f4d --- /dev/null +++ b/cmd/crossplane/xpkg/crd.go @@ -0,0 +1,253 @@ +/* +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" + "github.com/crossplane/cli/v2/internal/schemas/generator" +) + +const errWriteOutput = "cannot write output" + +// 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."` + 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"` + 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. + +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 +` +} + +// 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. +// 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) + if err != nil { + return errors.Wrapf(err, "cannot marshal CRD %q", crd.GetName()) + } + + 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) + } + + 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 +} + +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. 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 + + 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 := 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) + } + + data, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return errors.Wrapf(err, "cannot marshal JSON Schema for %s/%s %s", group, ver.Name, kind) + } + + flatName := fmt.Sprintf("%s_%s_%s", group, ver.Name, strings.ToLower(kind)) + outPath := c.outputPath(flatName, group, ver.Name, kind, ".json") + + 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) + } + + 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 +} diff --git a/cmd/crossplane/xpkg/crd_test.go b/cmd/crossplane/xpkg/crd_test.go new file mode 100644 index 0000000..a840fc7 --- /dev/null +++ b/cmd/crossplane/xpkg/crd_test.go @@ -0,0 +1,292 @@ +/* +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/invopop/jsonschema" + "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" +) + +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", + Plural: "tests", + Singular: "test", + ListKind: "TestList", + }, + Scope: extv1.NamespaceScoped, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: true, + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: schemaTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: schemaTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +func TestWriteCRDs(t *testing.T) { + type args struct { + crds []*extv1.CustomResourceDefinition + flat bool + outputDir string + } + + type want struct { + files []string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "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 organized by group and version", + args: args{ + crds: []*extv1.CustomResourceDefinition{ + testCRD, + { + ObjectMeta: metav1.ObjectMeta{Name: "foos.example.org"}, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.org", + Names: extv1.CustomResourceDefinitionNames{Kind: "Foo"}, + Versions: []extv1.CustomResourceDefinitionVersion{ + {Name: "v1beta1", Storage: true}, + }, + }, + }, + }, + outputDir: "/out", + }, + want: want{ + files: []string{ + "/out/example.org/v1alpha1/test.yaml", + "/out/example.org/v1beta1/foo.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() + + 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, + Flat: tc.args.flat, + 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 + flat bool + outputDir string + } + + type want struct { + files []string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Structured": { + reason: "Should write JSON Schema files organized by group and version", + args: args{ + crds: []*extv1.CustomResourceDefinition{testCRD}, + outputDir: "/schemas", + }, + want: want{ + 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{ + 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, + Flat: tc.args.flat, + 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 jsonschema.Schema + if err := json.Unmarshal(data, &schema); err != nil { + t.Errorf("%s\nwriteJSONSchemas(...): file %s is not valid JSON Schema: %v", tc.reason, f, err) + } + } + }) + } +} 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."` 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) }