Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/crossplane/validate/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
249 changes: 249 additions & 0 deletions cmd/crossplane/xpkg/crd.go
Original file line number Diff line number Diff line change
@@ -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:])
}
Comment on lines +114 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="cmd/crossplane/xpkg/crd.go"

# Show the surrounding lines for the ~/ cache-dir expansion
nl -ba "$FILE" | sed -n '90,150p'

# Show imports to confirm which errors package is used in this file
echo "---- imports ----"
sed -n '1,80p' "$FILE"

# Search for UserHomeDir usage in this file/repo for consistency
rg -n "UserHomeDir\(" -S "$FILE" || true
rg -n "cannot resolve home directory|cache-dir to an absolute path" -S "$FILE" || true

# Check if errors.Wrap/Wrapf from crossplane-runtime is used around return-early patterns
rg -n "errors\.Wrapf?\(" "$FILE" | head -n 50

Repository: crossplane/cli

Length of output: 101


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="cmd/crossplane/xpkg/crd.go"

# Print lines 90-150 with line numbers via awk (since nl isn't available)
echo "---- snippet (lines 90-150) ----"
awk -v start=90 -v end=150 'NR>=start && NR<=end {printf "%4d:%s\n", NR, $0}' "$FILE"

echo "---- imports (lines 1-80) ----"
sed -n '1,80p' "$FILE"

echo "---- UserHomeDir usage in this file ----"
rg -n "UserHomeDir\(" "$FILE" || true

echo "---- errors.Wrap/Wrapf usage in this file ----"
rg -n "errors\.Wrapf?\(" "$FILE" || true

echo "---- any mention of cache-dir / CacheDir in this file ----"
rg -n "cache-dir|CacheDir" "$FILE" || true

Repository: crossplane/cli

Length of output: 7670


Handle os.UserHomeDir() failure when expanding ~/ in --cache-dir — currently the error is ignored, so a bad/empty home dir can produce a broken cache path without user feedback. Should crossplane xpkg crd fail fast here with guidance to pass an absolute --cache-dir?

Proposed change
 	} else if strings.HasPrefix(c.CacheDir, "~/") {
-		homeDir, _ := os.UserHomeDir()
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return errors.Wrap(err, "cannot resolve home directory; set --cache-dir to an absolute path")
+		}
 		c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:])
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if strings.HasPrefix(c.CacheDir, "~/") {
homeDir, _ := os.UserHomeDir()
c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:])
}
} else if strings.HasPrefix(c.CacheDir, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return errors.Wrap(err, "cannot resolve home directory; set --cache-dir to an absolute path")
}
c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/crossplane/xpkg/crd.go` around lines 114 - 117, The expansion of
c.CacheDir when it starts with "~/" currently ignores the error from
os.UserHomeDir(), which can produce an invalid path; update the code that
handles c.CacheDir (the branch checking strings.HasPrefix(c.CacheDir, "~/")) to
check the returned error from os.UserHomeDir(), and on error return/fail fast
with a clear error message that includes the underlying error and guidance to
provide an absolute --cache-dir. Ensure the change surfaces the error to the
caller (rather than silently using an empty homeDir) so callers of the function
receive the failure.


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
}
Loading