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
253 changes: 253 additions & 0 deletions cmd/crossplane/xpkg/crd.go
Original file line number Diff line number Diff line change
@@ -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., <group>/<version>/<kind>.{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:])
}
Comment on lines +116 to +119
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.
// 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
}
Loading