diff --git a/cmd/crossplane/dependency/add.go b/cmd/crossplane/dependency/add.go index 25f0b488..694b2a5a 100644 --- a/cmd/crossplane/dependency/add.go +++ b/cmd/crossplane/dependency/add.go @@ -19,19 +19,14 @@ package dependency import ( "cmp" "context" - "path/filepath" - "strings" - "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/afero" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" - "github.com/crossplane/cli/v2/internal/project/projectfile" "github.com/crossplane/cli/v2/internal/schemas/generator" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" @@ -44,9 +39,12 @@ var addHelp string // addCmd adds a dependency to the current project. type addCmd struct { - Package string `arg:"" help:"Package to add (xpkg OCI reference, k8s:, git repository URL, or HTTP(S) URL)."` - ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + Package string `arg:"" help:"Package to add (xpkg OCI reference, k8s:, git repository URL, or HTTP(S) URL)."` + + ProjectFile string `help:"Path to the project file or package metadata file (crossplane.yaml). Autodetected if not set." optional:"" predictor:"yaml_file" short:"f" type:"path"` + SchemasDir string `help:"Directory for generated schemas, relative to the project file. Overrides the project's schemas path (default: schemas)." name:"schemas-dir"` + SchemaLanguages []string `help:"Comma-separated schema languages to generate (go, json, kcl, python, rust). Overrides the project's schemas.languages; defaults to all." name:"schema-languages"` // Flags for specific dependency types. APIOnly bool `help:"Mark an xpkg dependency as API-only (not a runtime dependency)." name:"api-only"` @@ -62,14 +60,10 @@ func (c *addCmd) Help() string { func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { ctx := context.Background() - projFilePath, err := filepath.Abs(c.ProjectFile) - if err != nil { - return err - } - projDirPath := filepath.Dir(projFilePath) - projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) - - proj, err := projectfile.Parse(projFS, filepath.Base(c.ProjectFile)) + proj, store, projFS, err := loadProject(c.ProjectFile, Overrides{ + SchemasDir: c.SchemasDir, + SchemaLanguages: c.SchemaLanguages, + }) if err != nil { return err } @@ -80,7 +74,7 @@ func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con } m := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(c.ProjectFile), + dependency.WithStore(store), dependency.WithSchemaGenerators(generator.Filter( generator.AllLanguages( generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), @@ -91,10 +85,16 @@ func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con dependency.WithXpkgClient(client), ) - dep, err := c.buildDependency() + dep, err := dependency.ParseSource(c.Package, c.GitRef, c.GitPath) if err != nil { return err } + if c.APIOnly { + if dep.Xpkg == nil { + return errors.New("--api-only is only valid for xpkg dependencies") + } + dep.Xpkg.APIOnly = true + } desc := dependency.GetSourceDescription(dep) logger.Debug("Adding dependency", "dependency", desc) @@ -102,82 +102,3 @@ func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con return m.AddDependency(ctx, &dep) }) } - -func (c *addCmd) buildDependency() (v1alpha1.Dependency, error) { - // k8s dependency: k8s:vX.Y.Z - if version, found := strings.CutPrefix(c.Package, "k8s:"); found { - if version == "" { - return v1alpha1.Dependency{}, errors.New("k8s version is required (e.g., k8s:v1.33.0)") - } - if c.APIOnly { - return v1alpha1.Dependency{}, errors.New("--api-only is only valid for xpkg dependencies") - } - return v1alpha1.Dependency{ - Type: v1alpha1.DependencyTypeK8s, - K8s: &v1alpha1.K8sDependency{ - Version: version, - }, - }, nil - } - - // CRD dependency via git ref. - if c.GitRef != "" { - if c.Package == "" { - return v1alpha1.Dependency{}, errors.New("repository URL is required for git-based CRD dependencies") - } - if c.APIOnly { - return v1alpha1.Dependency{}, errors.New("--api-only is only valid for xpkg dependencies") - } - return v1alpha1.Dependency{ - Type: v1alpha1.DependencyTypeCRD, - Git: &v1alpha1.GitDependency{ - Repository: c.Package, - Ref: c.GitRef, - Path: c.GitPath, - }, - }, nil - } - - // CRD dependency via HTTP URL. - if strings.HasPrefix(c.Package, "http://") || strings.HasPrefix(c.Package, "https://") { - if c.APIOnly { - return v1alpha1.Dependency{}, errors.New("--api-only is only valid for xpkg dependencies") - } - return v1alpha1.Dependency{ - Type: v1alpha1.DependencyTypeCRD, - HTTP: &v1alpha1.HTTPDependency{ - URL: c.Package, - }, - }, nil - } - - // Default: xpkg dependency. We allow three formats: - // - // 1. registry.example.com/repo: - // 2. registry.example.com/repo@ - // 3. registry.example.com/repo (implies a semver constraint of '>=v0.0.0'). - pkg := c.Package - version := ">=v0.0.0" - if ref, err := name.NewDigest(c.Package, name.StrictValidation); err == nil { - pkg = ref.Context().String() - version = ref.DigestStr() - } else if repo, tag, ok := strings.Cut(c.Package, ":"); ok { - // NOTE(adamwg): This doesn't work properly if the dependency has a - // colon in the registry part for a port number (e.g., - // example.com:5000/my-repo:v1.2.3). But there's no easy way to handle - // that correctly in all cases (since we also allow - // `example.com:5000/my-repo, with no tag/constraint), so leave the - // corner case unhandled for now. - pkg = repo - version = tag - } - - return v1alpha1.Dependency{ - Type: v1alpha1.DependencyTypeXpkg, - Xpkg: &v1alpha1.XpkgDependency{ - Package: pkg, - Version: version, - APIOnly: c.APIOnly, - }, - }, nil -} diff --git a/cmd/crossplane/dependency/cache.go b/cmd/crossplane/dependency/cache.go index 326f226d..d3c04146 100644 --- a/cmd/crossplane/dependency/cache.go +++ b/cmd/crossplane/dependency/cache.go @@ -20,7 +20,6 @@ import ( "cmp" "context" "fmt" - "path/filepath" "github.com/alecthomas/kong" "github.com/spf13/afero" @@ -31,7 +30,6 @@ import ( "github.com/crossplane/cli/v2/internal/async" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" - "github.com/crossplane/cli/v2/internal/project/projectfile" "github.com/crossplane/cli/v2/internal/schemas/generator" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" @@ -44,10 +42,13 @@ var updateHelp string // updateCacheCmd updates the dependency cache by regenerating all schemas. type updateCacheCmd struct { - ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"` CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` GitToken string `env:"CROSSPLANE_GIT_TOKEN" help:"Token for git HTTPS authentication."` GitUsername string `default:"x-access-token" env:"CROSSPLANE_GIT_USERNAME" help:"Username for git HTTPS authentication."` + + ProjectFile string `help:"Path to the project file or package metadata file (crossplane.yaml). Autodetected if not set." optional:"" predictor:"yaml_file" short:"f" type:"path"` + SchemasDir string `help:"Directory for generated schemas, relative to the project file. Overrides the project's schemas path (default: schemas)." name:"schemas-dir"` + SchemaLanguages []string `help:"Comma-separated schema languages to generate (go, json, kcl, python, rust). Overrides the project's schemas.languages; defaults to all." name:"schema-languages"` } func (c *updateCacheCmd) Help() string { @@ -58,14 +59,10 @@ func (c *updateCacheCmd) Help() string { func (c *updateCacheCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { ctx := context.Background() - projFilePath, err := filepath.Abs(c.ProjectFile) - if err != nil { - return err - } - projDirPath := filepath.Dir(projFilePath) - projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) - - proj, err := projectfile.Parse(projFS, filepath.Base(c.ProjectFile)) + proj, store, projFS, err := loadProject(c.ProjectFile, Overrides{ + SchemasDir: c.SchemasDir, + SchemaLanguages: c.SchemaLanguages, + }) if err != nil { return err } @@ -76,7 +73,7 @@ func (c *updateCacheCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, } opts := []dependency.ManagerOption{ - dependency.WithProjectFile(c.ProjectFile), + dependency.WithStore(store), dependency.WithSchemaGenerators(generator.Filter( generator.AllLanguages( generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), @@ -107,9 +104,11 @@ var cleanHelp string // cleanCacheCmd removes all generated schemas. type cleanCacheCmd struct { - ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"` CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` KeepPackages bool `help:"Keep cached xpkg package contents; remove only generated schemas." name:"keep-packages"` + + ProjectFile string `help:"Path to the project file or package metadata file (crossplane.yaml). Autodetected if not set." optional:"" predictor:"yaml_file" short:"f" type:"path"` + SchemasDir string `help:"Directory for generated schemas, relative to the project file. Overrides the project's schemas path (default: schemas)." name:"schemas-dir"` } func (c *cleanCacheCmd) Help() string { @@ -118,21 +117,12 @@ func (c *cleanCacheCmd) Help() string { // Run executes the clean-cache command. func (c *cleanCacheCmd) Run(k *kong.Context, _ logging.Logger) error { - projFilePath, err := filepath.Abs(c.ProjectFile) - if err != nil { - return err - } - projDirPath := filepath.Dir(projFilePath) - projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) - - proj, err := projectfile.Parse(projFS, filepath.Base(c.ProjectFile)) + proj, _, projFS, err := loadProject(c.ProjectFile, Overrides{SchemasDir: c.SchemasDir}) if err != nil { return err } - m := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(c.ProjectFile), - ) + m := dependency.NewManager(proj, projFS) if err := m.Clean(); err != nil { return err diff --git a/cmd/crossplane/dependency/dependency.go b/cmd/crossplane/dependency/dependency.go index 75e30eb1..0b19892e 100644 --- a/cmd/crossplane/dependency/dependency.go +++ b/cmd/crossplane/dependency/dependency.go @@ -17,9 +17,105 @@ limitations under the License. // Package dependency contains commands for managing project dependencies. package dependency +import ( + "path/filepath" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project/metafile" + "github.com/crossplane/cli/v2/internal/project/projectfile" +) + // Cmd contains commands for dependency management. type Cmd struct { - Add addCmd `cmd:"" help:"Add a dependency to the current project." novale:"Google.WordList[\"k8s\"],Google.Units,gitlab.Units"` - UpdateCache updateCacheCmd `cmd:"" help:"Update the dependency cache for the current project."` + Add addCmd `cmd:"" help:"Add a dependency to the current project." novale:"Google.WordList[\"k8s\"],Google.Units,gitlab.Units"` + UpdateCache updateCacheCmd `cmd:"" help:"Update the dependency cache for the current project or package."` CleanCache cleanCacheCmd `cmd:"" help:"Clean the dependency cache."` } + +// loadProject finds and loads the project or package metadata file at path, +// probing the working directory when path is empty. +// projFS is rooted at the file's directory; store writes back to the file. +func loadProject(path string, o Overrides) (proj *v1alpha1.Project, store dependency.Store, projFS afero.Fs, err error) { + abs, err := projectfile.Resolve(path) + if err != nil { + return nil, nil, nil, err + } + projFS = afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(abs)) + + proj, store, err = load(projFS, filepath.Base(abs), o) + if err != nil { + return nil, nil, nil, err + } + return proj, store, projFS, nil +} + +// Overrides are settings supplied on the command line that take precedence over the file. +// They exist mainly because package metadata can't express them, but they apply to project files too. +// The file on disk is never changed. +type Overrides struct { + // SchemasDir overrides paths.schemas. It must be a relative subdirectory of the file's directory. + SchemasDir string + // SchemaLanguages overrides schemas.languages. Each must be a supported schema language. + SchemaLanguages []string +} + +// load parses either a project file or a package metadata file (crossplane.yaml), +// applies the overrides, and returns a Project with defaults applied, plus the +// Store that writes dependencies back to the file. +// A metadata file is converted into an in-memory Project whose dependencies are the +// metadata package dependencies. +func load(fs afero.Fs, file string, o Overrides) (*v1alpha1.Project, dependency.Store, error) { + // The schemas directory is removed by clean-cache, so it must be a strict + // subdirectory of the project directory. + if o.SchemasDir != "" && (!filepath.IsLocal(o.SchemasDir) || filepath.Clean(o.SchemasDir) == ".") { + return nil, nil, errors.Errorf("schemas directory %q must be a relative subdirectory of the project directory", o.SchemasDir) + } + + isProject, err := projectfile.IsProjectFile(fs, file) + if err != nil { + return nil, nil, err + } + + var proj *v1alpha1.Project + var store dependency.Store + if isProject { + proj, err = projectfile.ParseWithoutDefaults(fs, file) + store = projectfile.NewStore(fs, file) + } else { + var pkg pkgmetav1.Pkg + pkg, err = metafile.Parse(fs, file) + if err == nil { + proj = metafile.ToProject(pkg) + } + store = metafile.NewStore(fs, file) + } + if err != nil { + return nil, nil, err + } + + if o.SchemasDir != "" { + if proj.Spec.Paths == nil { + proj.Spec.Paths = &v1alpha1.ProjectPaths{} + } + proj.Spec.Paths.Schemas = o.SchemasDir + } + if len(o.SchemaLanguages) > 0 { + if proj.Spec.Schemas == nil { + proj.Spec.Schemas = &v1alpha1.ProjectSchemas{} + } + proj.Spec.Schemas.Languages = o.SchemaLanguages + if err := errors.Join(proj.Spec.Schemas.Validate()...); err != nil { + return nil, nil, err + } + } + proj.Default() + + return proj, store, nil +} diff --git a/cmd/crossplane/dependency/dependency_test.go b/cmd/crossplane/dependency/dependency_test.go new file mode 100644 index 00000000..c5f3b753 --- /dev/null +++ b/cmd/crossplane/dependency/dependency_test.go @@ -0,0 +1,178 @@ +/* +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 dependency + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +const testMeta = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: my-config +spec: + dependsOn: + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-nop + version: ">=v0.2.0" + - function: xpkg.crossplane.io/crossplane-contrib/function-auto-ready + version: v0.5.0 + - configuration: xpkg.crossplane.io/crossplane-contrib/configuration-foo +` + +const testProject = `apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: my-project +spec: + repository: xpkg.crossplane.io/foo/bar + paths: + schemas: my-schemas + schemas: + languages: [go] +` + +func TestLoad(t *testing.T) { + t.Parallel() + + metaDeps := []v1alpha1.Dependency{ + {Type: v1alpha1.DependencyTypeXpkg, Xpkg: &v1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", Kind: "Provider", + Package: "xpkg.crossplane.io/crossplane-contrib/provider-nop", Version: ">=v0.2.0", + }}, + {Type: v1alpha1.DependencyTypeXpkg, Xpkg: &v1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", Kind: "Function", + Package: "xpkg.crossplane.io/crossplane-contrib/function-auto-ready", Version: "v0.5.0", + }}, + {Type: v1alpha1.DependencyTypeXpkg, Xpkg: &v1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", Kind: "Configuration", + Package: "xpkg.crossplane.io/crossplane-contrib/configuration-foo", Version: ">=v0.0.0", + }}, + } + + tcs := map[string]struct { + content string + schemasDir string + languages []string + wantSchemas string + wantLanguages []string + wantDeps []v1alpha1.Dependency + wantErr bool + }{ + "Meta": { + content: testMeta, + wantSchemas: "schemas", + wantDeps: metaDeps, + }, + "MetaProvider": { + content: strings.Replace(testMeta, "kind: Configuration", "kind: Provider", 1), + wantSchemas: "schemas", + wantDeps: metaDeps, + }, + "MetaFunction": { + content: strings.Replace(testMeta, "kind: Configuration", "kind: Function", 1), + wantSchemas: "schemas", + wantDeps: metaDeps, + }, + "MetaFunctionV1beta1": { + content: strings.NewReplacer( + "meta.pkg.crossplane.io/v1", "meta.pkg.crossplane.io/v1beta1", + "kind: Configuration", "kind: Function", + ).Replace(testMeta), + wantSchemas: "schemas", + wantDeps: metaDeps, + }, + "MetaOverrides": { + content: testMeta, + schemasDir: "gen/schemas", + languages: []string{"python"}, + wantSchemas: "gen/schemas", + wantLanguages: []string{"python"}, + wantDeps: metaDeps, + }, + "Project": { + content: testProject, + wantSchemas: "my-schemas", + wantLanguages: []string{"go"}, + }, + "ProjectOverrides": { + content: testProject, + schemasDir: "other", + languages: []string{"python", "json"}, + wantSchemas: "other", + wantLanguages: []string{"python", "json"}, + }, + "SchemasDirEscapes": { + content: testMeta, + schemasDir: "../schemas", + wantErr: true, + }, + "SchemasDirIsRoot": { + content: testMeta, + schemasDir: "./", + wantErr: true, + }, + "UnsupportedLanguage": { + content: testMeta, + languages: []string{"cobol"}, + wantErr: true, + }, + "UnsupportedMetaKind": { + content: "apiVersion: meta.pkg.crossplane.io/v1\nkind: Bogus\n", + wantErr: true, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + if err := afero.WriteFile(fs, "crossplane.yaml", []byte(tc.content), 0o644); err != nil { + t.Fatal(err) + } + + proj, _, err := load(fs, "crossplane.yaml", Overrides{SchemasDir: tc.schemasDir, SchemaLanguages: tc.languages}) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatal(err) + } + + if got := proj.Spec.Paths.Schemas; got != tc.wantSchemas { + t.Errorf("schemas path: want %q, got %q", tc.wantSchemas, got) + } + if diff := cmp.Diff(tc.wantLanguages, proj.Spec.Schemas.GetLanguages()); diff != "" { + t.Errorf("schema languages (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.wantDeps, proj.Spec.Dependencies); diff != "" { + t.Errorf("dependencies (-want +got):\n%s", diff) + } + }) + } +} diff --git a/cmd/crossplane/dependency/help/add.md b/cmd/crossplane/dependency/help/add.md index df9a1bfc..3106e301 100644 --- a/cmd/crossplane/dependency/help/add.md +++ b/cmd/crossplane/dependency/help/add.md @@ -19,6 +19,16 @@ dependencies. Use the `--api-only` flag to add a build-time xpkg dependency. Non-xpkg dependencies are always build-time dependencies. +## Package metadata files + +The command can also add a dependency to package metadata (`crossplane.yaml`) of a Configuration, Provider, or Function. +When `-f` isn't set, it uses `crossplane-project.yaml` in the current directory if present, +otherwise `crossplane.yaml`. +Package metadata only supports runtime xpkg dependencies, which are added to `spec.dependsOn`. +Kubernetes, CRD, and `--api-only` dependencies require a project file. +Use `--schemas-dir` to choose where schemas are generated (default `schemas`), and +`--schema-languages` to generate only some languages (default all). + ## Examples Retrieve the latest available semantic version of `provider-aws-eks`, generate diff --git a/cmd/crossplane/dependency/help/clean-cache.md b/cmd/crossplane/dependency/help/clean-cache.md index 614a838b..e7f9adf2 100644 --- a/cmd/crossplane/dependency/help/clean-cache.md +++ b/cmd/crossplane/dependency/help/clean-cache.md @@ -1,3 +1,7 @@ The `clean-cache` command removes all cached package images from the local cache directory and removes generated schemas. This can help free up disk space, force re-generation of schemas, or resolve issues with corrupted cache entries. + +Like `update-cache`, the command accepts a project file or a package metadata +file (`crossplane.yaml`). +Use `--schemas-dir` to select the schemas directory to remove when it differs from the default. diff --git a/cmd/crossplane/dependency/help/update-cache.md b/cmd/crossplane/dependency/help/update-cache.md index b016009a..36530a11 100644 --- a/cmd/crossplane/dependency/help/update-cache.md +++ b/cmd/crossplane/dependency/help/update-cache.md @@ -2,3 +2,37 @@ The `dependency update-cache` command updates the local dependency cache for the current project. It re-resolves semantic version constraints to specific versions (fetching newer versions if available), caches all dependencies, and re-generates language bindings (schemas) for them if needed. + +## Project and package metadata files + +The command reads dependencies from a project file (`crossplane-project.yaml`) +or from Configuration, Provider, or Function package metadata (`crossplane.yaml`). +When `-f` isn't set, it uses `crossplane-project.yaml` in the current directory if present, +otherwise `crossplane.yaml`. + +For package metadata, the xpkg dependencies listed in `spec.dependsOn` are cached. +Schemas are written to the directory given by `--schemas-dir`, relative +to the file (default `schemas`). +For a project file, `--schemas-dir` overrides `paths.schemas`. + +Schemas are generated for all supported languages unless `--schema-languages` +lists a subset (`go`, `json`, `kcl`, `python`, `rust`). +For a project file, the flag overrides `spec.schemas.languages`. + +Package metadata can't declare Kubernetes core API dependencies. +Use `crossplane schema generate` to generate schemas for them. + +## Examples + +Update the cache and generate schemas for the dependencies of a Configuration +package, writing schemas to `gen/schemas`: + +```shell +crossplane dependency update-cache -f crossplane.yaml --schemas-dir gen/schemas +``` + +Generate only Python schemas: + +```shell +crossplane dependency update-cache -f crossplane.yaml --schema-languages python +``` diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index 271cdc44..cd066633 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -46,6 +46,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/project" renderxr "github.com/crossplane/cli/v2/cmd/crossplane/render/xr" "github.com/crossplane/cli/v2/cmd/crossplane/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/schema" "github.com/crossplane/cli/v2/cmd/crossplane/version" "github.com/crossplane/cli/v2/cmd/crossplane/xpkg" "github.com/crossplane/cli/v2/cmd/crossplane/xr" @@ -94,6 +95,7 @@ type cli struct { Operation operation.Cmd `cmd:"" help:"Work with Crossplane Operations." maturity:"alpha"` Project project.Cmd `cmd:"" help:"Work with control plane Projects." maturity:"beta"` Resource resource.Cmd `cmd:"" help:"Work with Crossplane resources." maturity:"beta"` + Schema schema.Cmd `cmd:"" help:"Generate language bindings (schemas) for packages and APIs." maturity:"alpha"` Version version.Cmd `cmd:"" help:"Print the client and server version information for the current context."` XPKG xpkg.Cmd `cmd:"" help:"Work with Crossplane packages."` XR xr.Cmd `cmd:"" help:"Work with Crossplane Composite Resources (XRs)." maturity:"alpha"` diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 98cabd08..d38a59e3 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -40,7 +40,6 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" - runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" @@ -405,9 +404,9 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return fns, nil } - filePath, err := c.resolveProjectFile() + filePath, err := projectfile.Resolve(c.ProjectFile) if err != nil { - return nil, err + return nil, errors.Wrap(err, "functions argument is required when not in a project or configuration") } dir := filepath.Dir(filePath) @@ -426,28 +425,6 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return c.loadFunctionsFromConfiguration(ctx, log, fs, fileName) } -// resolveProjectFile returns the absolute path of the project or configuration -// file to use. When the user supplied an explicit --project-file, that path is -// used as-is. Otherwise it probes for crossplane-project.yaml and then -// crossplane.yaml in the working directory. -func (c *Cmd) resolveProjectFile() (string, error) { - if c.ProjectFile != "" { - return filepath.Abs(c.ProjectFile) - } - - for _, name := range []string{clixpkg.ProjectFile, runtimexpkg.MetaFile} { - abs, err := filepath.Abs(name) - if err != nil { - return "", errors.Wrapf(err, "cannot determine path for %q", name) - } - if _, err := os.Stat(abs); err == nil { - return abs, nil - } - } - - return "", errors.New("functions argument is required when not in a project or configuration") -} - func (c *Cmd) loadFunctionsFromProject(ctx context.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config, projFS afero.Fs, projFilePath, projFileName string) ([]pkgv1.Function, error) { log.Debug("Loading functions from project", "project-file", projFilePath) diff --git a/cmd/crossplane/schema/generate.go b/cmd/crossplane/schema/generate.go new file mode 100644 index 00000000..198f3cc4 --- /dev/null +++ b/cmd/crossplane/schema/generate.go @@ -0,0 +1,99 @@ +/* +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 schema + +import ( + "cmp" + "context" + "path/filepath" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/config" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" + + _ "embed" +) + +//go:embed help/generate.md +var generateHelp string + +// generateCmd generates schemas for a single source without touching any +// project or package metadata file. +type generateCmd struct { + Source string `arg:"" help:"Source to generate schemas for (xpkg OCI reference, k8s:, git repository URL, or HTTP(S) URL)."` + + OutputDir string `default:"schemas" help:"Directory to write schemas to." short:"o" type:"path"` + Languages []string `help:"Comma-separated schema languages to generate (go, json, kcl, python, rust). Defaults to all."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + GitRef string `help:"Git ref for CRD sources (branch, tag, or commit SHA)." name:"git-ref"` + GitPath string `help:"Path to CRDs in the git repository." name:"git-path"` +} + +func (c *generateCmd) Help() string { + return generateHelp +} + +// Run executes the generate command. +func (c *generateCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { + if err := errors.Join((&v1alpha1.ProjectSchemas{Languages: c.Languages}).Validate()...); err != nil { + return err + } + + dep, err := dependency.ParseSource(c.Source, c.GitRef, c.GitPath) + if err != nil { + return err + } + + outDir, err := filepath.Abs(c.OutputDir) + if err != nil { + return errors.Wrapf(err, "cannot determine path for %q", c.OutputDir) + } + + client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir()))) + if err != nil { + return errors.Wrap(err, "cannot create xpkg client") + } + + // The manager needs a project; an empty defaulted one is never written. + proj := &v1alpha1.Project{} + proj.Default() + m := dependency.NewManager(proj, afero.NewOsFs(), + dependency.WithSchemaFS(afero.NewBasePathFs(afero.NewOsFs(), outDir)), + dependency.WithSchemaGenerators(generator.Filter( + generator.AllLanguages( + generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), + generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), + ), + c.Languages, + )), + dependency.WithXpkgClient(client), + ) + + desc := dependency.GetSourceDescription(dep) + logger.Debug("Generating schemas", "source", desc, "output", outDir) + return sp.WrapWithSuccessSpinner("Generating schemas for "+desc, func() error { + return m.GenerateSchemas(context.Background(), &dep) + }) +} diff --git a/cmd/crossplane/schema/help/generate.md b/cmd/crossplane/schema/help/generate.md new file mode 100644 index 00000000..0b115b18 --- /dev/null +++ b/cmd/crossplane/schema/help/generate.md @@ -0,0 +1,28 @@ +The `schema generate` command generates language bindings (schemas) for a +single source and writes them to a directory. It doesn't read or change any +project or package metadata file. + +The source uses the same formats as `crossplane dependency add`: + +- A Crossplane package from an OCI registry. Schemas are also generated for + the package's own dependencies. +- `k8s:` for the Kubernetes core APIs. +- An HTTP(S) URL to CRD files. +- A Git repository URL, with `--git-ref` and optionally `--git-path`. + +Use it for schemas a package needs that aren't package dependencies, such as +the Kubernetes core APIs. + +## Examples + +Generate Python schemas for the Kubernetes v1.33.0 core APIs into `schemas`: + +```shell +crossplane schema generate k8s:v1.33.0 --languages python +``` + +Generate schemas for a provider into `gen/schemas`: + +```shell +crossplane schema generate xpkg.crossplane.io/crossplane-contrib/provider-nop:v0.4.0 -o gen/schemas +``` diff --git a/cmd/crossplane/schema/schema.go b/cmd/crossplane/schema/schema.go new file mode 100644 index 00000000..a786cccf --- /dev/null +++ b/cmd/crossplane/schema/schema.go @@ -0,0 +1,23 @@ +/* +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 schema contains commands for generating language bindings. +package schema + +// Cmd contains schema subcommands. +type Cmd struct { + Generate generateCmd `cmd:"" help:"Generate schemas for a package, CRD source, or Kubernetes core APIs." novale:"Google.WordList[\"k8s\"],Google.Units,gitlab.Units"` +} diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index 98764ce6..07df3d53 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -49,10 +49,10 @@ import ( // Manager manages dependencies for a Crossplane project, including fetching // packages, extracting CRDs, and generating schemas. type Manager struct { - proj *v1alpha1.Project - projFS afero.Fs - projFile string - schemas *smanager.Manager + proj *v1alpha1.Project + projFS afero.Fs + store Store + schemas *smanager.Manager gitCloner git.Cloner gitAuthProvider git.AuthProvider @@ -67,11 +67,21 @@ type Manager struct { visitedMu sync.Mutex } +// Store records dependencies in the file that declares them. +type Store interface { + // Supports returns an error if the file can't express dep. + Supports(dep v1alpha1.Dependency) error + // Upsert writes dep to the file, replacing any existing entry for the + // same source. + Upsert(dep v1alpha1.Dependency) error +} + // ManagerOption configures the dependency manager. type ManagerOption func(*managerOptions) type managerOptions struct { projFile string + store Store schemaFS afero.Fs schemaGenerators []generator.Interface schemaRunner runner.SchemaRunner @@ -86,6 +96,14 @@ func WithProjectFile(path string) ManagerOption { } } +// WithStore sets where AddDependency records dependencies. It defaults to the +// project file set by WithProjectFile. +func WithStore(s Store) ManagerOption { + return func(opts *managerOptions) { + opts.store = s + } +} + // WithSchemaFS sets the filesystem to use for schemas. func WithSchemaFS(fs afero.Fs) ManagerOption { return func(opts *managerOptions) { @@ -141,6 +159,10 @@ func NewManager(proj *v1alpha1.Project, projFS afero.Fs, opts ...ManagerOption) opt(options) } + if options.store == nil { + options.store = projectfile.NewStore(projFS, options.projFile) + } + schemas := smanager.New( options.schemaFS, options.schemaGenerators, @@ -150,7 +172,7 @@ func NewManager(proj *v1alpha1.Project, projFS afero.Fs, opts ...ManagerOption) return &Manager{ proj: proj, projFS: projFS, - projFile: options.projFile, + store: options.store, schemas: schemas, gitCloner: &git.DefaultCloner{}, gitAuthProvider: options.gitAuthProvider, @@ -300,8 +322,12 @@ func runtimeGVKForPackage(pkg *runtimexpkg.Package) (*schema.GroupVersionKind, e } // AddDependency adds a dependency, generates schemas for it, and persists the -// dependency to the project file. +// dependency using the manager's store. func (m *Manager) AddDependency(ctx context.Context, dep *v1alpha1.Dependency) error { + if err := m.store.Supports(*dep); err != nil { + return err + } + gvk, err := m.addDependencyNoWrite(ctx, dep, false) if err != nil { return err @@ -318,10 +344,8 @@ func (m *Manager) AddDependency(ctx context.Context, dep *v1alpha1.Dependency) e m.updateMutex.Lock() defer m.updateMutex.Unlock() - upsertDependency(m.proj, *dep) - return projectfile.Update(m.projFS, m.projFile, func(p *v1alpha1.Project) { - upsertDependency(p, *dep) - }) + projectfile.UpsertDependency(m.proj, *dep) + return m.store.Upsert(*dep) } // AddAll adds all dependencies configured in the project. If ch is non-nil, @@ -356,6 +380,12 @@ func (m *Manager) addAll(ctx context.Context, ch async.EventChannel, refresh boo return eg.Wait() } +// GenerateSchemas generates schemas for dep without recording it anywhere. +func (m *Manager) GenerateSchemas(ctx context.Context, dep *v1alpha1.Dependency) error { + _, err := m.addDependencyNoWrite(ctx, dep, false) + return err +} + func (m *Manager) addDependencyNoWrite(ctx context.Context, dep *v1alpha1.Dependency, refresh bool) (*schema.GroupVersionKind, error) { switch { case dep.Type == v1alpha1.DependencyTypeXpkg: @@ -414,31 +444,3 @@ func GetSourceDescription(dep v1alpha1.Dependency) string { return "unknown source" } } - -func upsertDependency(proj *v1alpha1.Project, dep v1alpha1.Dependency) { - for i, existing := range proj.Spec.Dependencies { - if matchesDependency(existing, dep) { - proj.Spec.Dependencies[i] = dep - return - } - } - - proj.Spec.Dependencies = append(proj.Spec.Dependencies, dep) -} - -func matchesDependency(a, b v1alpha1.Dependency) bool { - if a.Type != b.Type { - return false - } - switch { - case a.Xpkg != nil && b.Xpkg != nil: - return a.Xpkg.Package == b.Xpkg.Package - case a.Git != nil && b.Git != nil: - return a.Git.Repository == b.Git.Repository - case a.HTTP != nil && b.HTTP != nil: - return a.HTTP.URL == b.HTTP.URL - case a.K8s != nil && b.K8s != nil: - return true // Only one k8s dep makes sense. - } - return false -} diff --git a/internal/dependency/manager_test.go b/internal/dependency/manager_test.go index 53e388f1..4e8f36c4 100644 --- a/internal/dependency/manager_test.go +++ b/internal/dependency/manager_test.go @@ -38,6 +38,7 @@ import ( "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/project/metafile" "github.com/crossplane/cli/v2/internal/schemas/generator" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" ) @@ -1009,3 +1010,65 @@ func TestManager_AddDependency_TransitiveNotPersisted(t *testing.T) { t.Errorf("on-disk project deps (-want +got):\n%s", diff) } } + +func TestManager_AddDependency_PackageMetadata(t *testing.T) { + const ( + prov = "xpkg.example/prov" + digest = "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03" + meta = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: test-config +` + ) + + fc := &fakeClient{ + packages: map[string]*runtimexpkg.Package{ + prov + ":v0.1.0": makePackageWithBody(t, prov, digest, "", providerPackageYAML), + }, + tags: []string{"v0.1.0"}, + } + + projFS := afero.NewMemMapFs() + if err := afero.WriteFile(projFS, "crossplane.yaml", []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + proj := &v1alpha1.Project{} + proj.Default() + + m := NewManager(proj, projFS, + WithStore(metafile.NewStore(projFS, "crossplane.yaml")), + WithSchemaFS(afero.NewMemMapFs()), + WithSchemaGenerators([]generator.Interface{}), + WithXpkgClient(clixpkg.WrapClient(fc)), + ) + + if err := m.AddDependency(t.Context(), xpkgDep(prov, "v0.1.0")); err != nil { + t.Fatalf("AddDependency: %v", err) + } + + // The dependency is persisted to spec.dependsOn and loads back. + gotPkg, err := metafile.Parse(projFS, "crossplane.yaml") + if err != nil { + t.Fatal(err) + } + got := metafile.ToProject(gotPkg) + want := []v1alpha1.Dependency{{ + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: &v1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + Package: prov, + Version: "v0.1.0", + }, + }} + if diff := cmp.Diff(want, got.Spec.Dependencies); diff != "" { + t.Errorf("on-disk deps (-want +got):\n%s", diff) + } + + // Dependencies package metadata can't express are rejected. + k8s := &v1alpha1.Dependency{Type: v1alpha1.DependencyTypeK8s, K8s: &v1alpha1.K8sDependency{Version: "v1.33.0"}} + if err := m.AddDependency(t.Context(), k8s); err == nil { + t.Error("expected error adding k8s dependency to package metadata, got nil") + } +} diff --git a/internal/dependency/source.go b/internal/dependency/source.go new file mode 100644 index 00000000..6fb17608 --- /dev/null +++ b/internal/dependency/source.go @@ -0,0 +1,91 @@ +/* +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 dependency + +import ( + "strings" + + "github.com/google/go-containerregistry/pkg/name" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +// ParseSource builds a dependency from a source string: an xpkg OCI reference, +// k8s:, an HTTP(S) URL, or a git repository URL when gitRef is set. +func ParseSource(source, gitRef, gitPath string) (v1alpha1.Dependency, error) { + if version, found := strings.CutPrefix(source, "k8s:"); found { + if version == "" { + return v1alpha1.Dependency{}, errors.New("k8s version is required (e.g., k8s:v1.33.0)") + } + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeK8s, + K8s: &v1alpha1.K8sDependency{Version: version}, + }, nil + } + + if gitRef != "" { + if source == "" { + return v1alpha1.Dependency{}, errors.New("repository URL is required for git-based CRD dependencies") + } + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeCRD, + Git: &v1alpha1.GitDependency{ + Repository: source, + Ref: gitRef, + Path: gitPath, + }, + }, nil + } + + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeCRD, + HTTP: &v1alpha1.HTTPDependency{URL: source}, + }, nil + } + + // Default: xpkg dependency. We allow three formats: + // + // 1. registry.example.com/repo: + // 2. registry.example.com/repo@ + // 3. registry.example.com/repo (implies a semver constraint of '>=v0.0.0'). + pkg := source + version := ">=v0.0.0" + if ref, err := name.NewDigest(source, name.StrictValidation); err == nil { + pkg = ref.Context().String() + version = ref.DigestStr() + } else if repo, tag, ok := strings.Cut(source, ":"); ok { + // NOTE(adamwg): This doesn't work properly if the dependency has a + // colon in the registry part for a port number (e.g., + // example.com:5000/my-repo:v1.2.3). But there's no easy way to handle + // that correctly in all cases (since we also allow + // `example.com:5000/my-repo, with no tag/constraint), so leave the + // corner case unhandled for now. + pkg = repo + version = tag + } + + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: &v1alpha1.XpkgDependency{ + Package: pkg, + Version: version, + }, + }, nil +} diff --git a/internal/dependency/source_test.go b/internal/dependency/source_test.go new file mode 100644 index 00000000..4ec4d86a --- /dev/null +++ b/internal/dependency/source_test.go @@ -0,0 +1,92 @@ +/* +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 dependency + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +func TestParseSource(t *testing.T) { + t.Parallel() + + const digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + tcs := map[string]struct { + source string + gitRef string + gitPath string + want v1alpha1.Dependency + wantErr bool + }{ + "K8s": { + source: "k8s:v1.33.0", + want: v1alpha1.Dependency{Type: v1alpha1.DependencyTypeK8s, K8s: &v1alpha1.K8sDependency{Version: "v1.33.0"}}, + }, + "K8sNoVersion": { + source: "k8s:", + wantErr: true, + }, + "Git": { + source: "https://github.com/example/repo", + gitRef: "main", + gitPath: "crds", + want: v1alpha1.Dependency{Type: v1alpha1.DependencyTypeCRD, Git: &v1alpha1.GitDependency{ + Repository: "https://github.com/example/repo", Ref: "main", Path: "crds", + }}, + }, + "HTTP": { + source: "https://example.com/crd.yaml", + want: v1alpha1.Dependency{Type: v1alpha1.DependencyTypeCRD, HTTP: &v1alpha1.HTTPDependency{URL: "https://example.com/crd.yaml"}}, + }, + "XpkgNoVersion": { + source: "xpkg.crossplane.io/org/pkg", + want: v1alpha1.Dependency{Type: v1alpha1.DependencyTypeXpkg, Xpkg: &v1alpha1.XpkgDependency{Package: "xpkg.crossplane.io/org/pkg", Version: ">=v0.0.0"}}, + }, + "XpkgConstraint": { + source: "xpkg.crossplane.io/org/pkg:>v1.1.0", + want: v1alpha1.Dependency{Type: v1alpha1.DependencyTypeXpkg, Xpkg: &v1alpha1.XpkgDependency{Package: "xpkg.crossplane.io/org/pkg", Version: ">v1.1.0"}}, + }, + "XpkgDigest": { + source: "xpkg.crossplane.io/org/pkg@" + digest, + want: v1alpha1.Dependency{Type: v1alpha1.DependencyTypeXpkg, Xpkg: &v1alpha1.XpkgDependency{Package: "xpkg.crossplane.io/org/pkg", Version: digest}}, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := ParseSource(tc.source, tc.gitRef, tc.gitPath) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("ParseSource (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/project/metafile/metafile.go b/internal/project/metafile/metafile.go new file mode 100644 index 00000000..f6fb1c62 --- /dev/null +++ b/internal/project/metafile/metafile.go @@ -0,0 +1,220 @@ +/* +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 metafile reads and edits Crossplane package metadata files +// (crossplane.yaml) for dependency management. +package metafile + +import ( + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/runtime/serializer" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/project/projectfile" +) + +// defaultXpkgVersion is the version constraint used for package metadata +// dependencies that don't specify a version. +const defaultXpkgVersion = ">=v0.0.0" + +// Parse parses Configuration, Provider, or Function package metadata of any +// supported apiVersion, converting it to its v1 form. +func Parse(fs afero.Fs, file string) (pkgmetav1.Pkg, error) { + bs, err := afero.ReadFile(fs, file) + if err != nil { + return nil, errors.Wrapf(err, "failed to read package metadata file %q", file) + } + + scheme, err := xpkg.BuildMetaScheme() + if err != nil { + return nil, errors.Wrap(err, "cannot build package meta scheme") + } + obj, _, err := serializer.NewCodecFactory(scheme).UniversalDeserializer().Decode(bs, nil, nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse package metadata file %q", file) + } + pkg, ok := xpkg.TryConvertToPkg(obj, &pkgmetav1.Configuration{}, &pkgmetav1.Provider{}, &pkgmetav1.Function{}) + if !ok { + return nil, errors.Errorf("%q is not Configuration, Provider, or Function package metadata", file) + } + return pkg, nil +} + +// ToProject converts package metadata into a Project +// whose dependencies are the metadata's package dependencies. +// +// TODO: review, we might want this now, or postpone to a separate PR: +// This does a lazy conversion, only the fields needed for dependency mgmt are populated. +// Package annotations, spec.crossplane and capabilities are dropped. +// It can be extended to map those fields when a consumer needs them, potentially a +// `crossplane project init --from crossplane.yaml`, or the same with `crossplane project build`. +func ToProject(pkg pkgmetav1.Pkg) *v1alpha1.Project { + proj := &v1alpha1.Project{} + proj.APIVersion, proj.Kind = projectfile.APIVersion, projectfile.Kind + proj.Name = pkg.GetName() + + for _, dep := range pkg.GetDependencies() { + x, ok := xpkgDependency(dep) + if !ok { + continue + } + proj.Spec.Dependencies = append(proj.Spec.Dependencies, v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: x, + }) + } + + return proj +} + +// upsertDependency adds dep to the dependsOn list of the +// package metadata file, replacing any existing entry for the +// same package. The file is edited as generic YAML so that fields are not +// defaulted; like Update, formatting and comments are not preserved. +func upsertDependency(fs afero.Fs, file string, dep v1alpha1.XpkgDependency) error { + bs, err := afero.ReadFile(fs, file) + if err != nil { + return errors.Wrapf(err, "failed to read package metadata file %q", file) + } + + var doc map[string]any + if err := yaml.Unmarshal(bs, &doc); err != nil { + return errors.Wrap(err, "failed to parse package metadata file") + } + + spec, _ := doc["spec"].(map[string]any) + if spec == nil { + spec = map[string]any{} + doc["spec"] = spec + } + + entry := map[string]any{"package": dep.Package, "version": dep.Version} + if dep.APIVersion != "" { + entry["apiVersion"] = dep.APIVersion + } + if dep.Kind != "" { + entry["kind"] = dep.Kind + } + + deps, _ := spec["dependsOn"].([]any) + replaced := false + for i, existing := range deps { + if m, ok := existing.(map[string]any); ok && sameRepo(metaDependencyRepo(m), dep.Package) { + deps[i] = entry + replaced = true + break + } + } + if !replaced { + deps = append(deps, entry) + } + spec["dependsOn"] = deps + + out, err := yaml.Marshal(doc) + if err != nil { + return errors.Wrap(err, "failed to marshal package metadata") + } + return afero.WriteFile(fs, file, out, 0o644) +} + +// sameRepo reports whether two package references name the same repository, +// ignoring any tag or digest. Unparseable references are compared verbatim. +func sameRepo(a, b string) bool { + ra, errA := name.ParseReference(a) + rb, errB := name.ParseReference(b) + if errA != nil || errB != nil { + return a == b + } + return ra.Context().Name() == rb.Context().Name() +} + +// metaDependencyRepo returns the package repository of a generic dependsOn +// entry, handling both the modern and deprecated field names. +func metaDependencyRepo(m map[string]any) string { + for _, k := range []string{"package", "provider", "configuration", "function"} { + if s, ok := m[k].(string); ok && s != "" { + return s + } + } + return "" +} + +// xpkgDependency converts a package metadata dependency into an xpkg project +// dependency. It returns false if the dependency names no package. +func xpkgDependency(dep pkgmetav1.Dependency) (*v1alpha1.XpkgDependency, bool) { + x := &v1alpha1.XpkgDependency{ + APIVersion: pkgv1.SchemeGroupVersion.String(), + Version: dep.Version, + } + + switch { + case dep.Package != nil: + x.Package = *dep.Package + if dep.APIVersion != nil { + x.APIVersion = *dep.APIVersion + } + if dep.Kind != nil { + x.Kind = *dep.Kind + } + case dep.Provider != nil: + x.Package, x.Kind = *dep.Provider, pkgv1.ProviderKind + case dep.Configuration != nil: + x.Package, x.Kind = *dep.Configuration, pkgv1.ConfigurationKind + case dep.Function != nil: + x.Package, x.Kind = *dep.Function, pkgv1.FunctionKind + default: + return nil, false + } + + if x.Version == "" { + x.Version = defaultXpkgVersion + } + return x, true +} + +// NewStore returns a Store that records dependencies in a package metadata file. +func NewStore(fs afero.Fs, file string) *Store { + return &Store{fs: fs, file: file} +} + +// Store records dependencies in the spec.dependsOn list of a package metadata +// file. +type Store struct { + fs afero.Fs + file string +} + +// Supports accepts only runtime xpkg dependencies, the only kind package +// metadata can express. +func (s *Store) Supports(dep v1alpha1.Dependency) error { + if dep.Xpkg == nil || dep.Xpkg.APIOnly { + return errors.New("package metadata files support only runtime xpkg dependencies; use a project file for k8s, CRD, or API-only dependencies") + } + return nil +} + +// Upsert writes dep to the spec.dependsOn list of the metadata file. +func (s *Store) Upsert(dep v1alpha1.Dependency) error { + return upsertDependency(s.fs, s.file, *dep.Xpkg) +} diff --git a/internal/project/metafile/metafile_test.go b/internal/project/metafile/metafile_test.go new file mode 100644 index 00000000..1562bb50 --- /dev/null +++ b/internal/project/metafile/metafile_test.go @@ -0,0 +1,94 @@ +/* +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 metafile + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +const testMeta = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: my-config +spec: + dependsOn: + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-nop + version: ">=v0.2.0" + - function: xpkg.crossplane.io/crossplane-contrib/function-auto-ready + version: v0.5.0 + - configuration: xpkg.crossplane.io/crossplane-contrib/configuration-foo +` + +func TestUpsertDependency(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + if err := afero.WriteFile(fs, "crossplane.yaml", []byte(testMeta), 0o644); err != nil { + t.Fatal(err) + } + + // Replaces the deprecated-style function entry in place. + if err := upsertDependency(fs, "crossplane.yaml", v1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", Kind: "Function", + Package: "xpkg.crossplane.io/crossplane-contrib/function-auto-ready", Version: "v0.6.0", + }); err != nil { + t.Fatal(err) + } + // Appends a new entry. + if err := upsertDependency(fs, "crossplane.yaml", v1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", Kind: "Provider", + Package: "xpkg.crossplane.io/crossplane-contrib/provider-new", Version: "v1.0.0", + }); err != nil { + t.Fatal(err) + } + + got, err := afero.ReadFile(fs, "crossplane.yaml") + if err != nil { + t.Fatal(err) + } + + want := `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: my-config +spec: + dependsOn: + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-nop + version: '>=v0.2.0' + - apiVersion: pkg.crossplane.io/v1 + kind: Function + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready + version: v0.6.0 + - configuration: xpkg.crossplane.io/crossplane-contrib/configuration-foo + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-new + version: v1.0.0 +` + if diff := cmp.Diff(want, string(got)); diff != "" { + t.Errorf("on-disk contents (-want +got):\n%s", diff) + } +} diff --git a/internal/project/projectfile/projectfile.go b/internal/project/projectfile/projectfile.go index b79fc5a3..2a075f3d 100644 --- a/internal/project/projectfile/projectfile.go +++ b/internal/project/projectfile/projectfile.go @@ -36,7 +36,7 @@ const ( // IsProjectFile reads the TypeMeta from the given YAML file and returns true // when apiVersion and kind match a Crossplane Project. Any other type (e.g. a -// Configuration package metadata file) returns false with no error. +// package metadata file) returns false with no error. func IsProjectFile(fs afero.Fs, filePath string) (bool, error) { bs, err := afero.ReadFile(fs, filePath) if err != nil { diff --git a/internal/project/projectfile/resolve.go b/internal/project/projectfile/resolve.go new file mode 100644 index 00000000..84ed07f2 --- /dev/null +++ b/internal/project/projectfile/resolve.go @@ -0,0 +1,49 @@ +/* +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 projectfile + +import ( + "os" + "path/filepath" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// Resolve returns the absolute path of the project or package metadata file to +// use. An explicit path is returned as-is (made absolute). Otherwise it probes +// for crossplane-project.yaml and then crossplane.yaml in the working +// directory. +func Resolve(path string) (string, error) { + if path != "" { + return filepath.Abs(path) + } + + for _, name := range []string{clixpkg.ProjectFile, runtimexpkg.MetaFile} { + abs, err := filepath.Abs(name) + if err != nil { + return "", errors.Wrapf(err, "cannot determine path for %q", name) + } + if _, err := os.Stat(abs); err == nil { + return abs, nil + } + } + + return "", errors.Errorf("neither %s nor %s found in the current directory", clixpkg.ProjectFile, runtimexpkg.MetaFile) +} diff --git a/internal/project/projectfile/store.go b/internal/project/projectfile/store.go new file mode 100644 index 00000000..52a0dc31 --- /dev/null +++ b/internal/project/projectfile/store.go @@ -0,0 +1,74 @@ +/* +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 projectfile + +import ( + "github.com/spf13/afero" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +// Store records dependencies in a project file. +type Store struct { + fs afero.Fs + file string +} + +// NewStore returns a Store backed by the project file. +func NewStore(fs afero.Fs, file string) *Store { + return &Store{fs: fs, file: file} +} + +// Supports accepts every dependency type. +func (s *Store) Supports(_ v1alpha1.Dependency) error { return nil } + +// Upsert writes dep to the project file. +func (s *Store) Upsert(dep v1alpha1.Dependency) error { + return Update(s.fs, s.file, func(p *v1alpha1.Project) { + UpsertDependency(p, dep) + }) +} + +// UpsertDependency adds dep to the project's dependencies, replacing any +// existing entry for the same source. +func UpsertDependency(proj *v1alpha1.Project, dep v1alpha1.Dependency) { + for i, existing := range proj.Spec.Dependencies { + if matchesDependency(existing, dep) { + proj.Spec.Dependencies[i] = dep + return + } + } + + proj.Spec.Dependencies = append(proj.Spec.Dependencies, dep) +} + +func matchesDependency(a, b v1alpha1.Dependency) bool { + if a.Type != b.Type { + return false + } + switch { + case a.Xpkg != nil && b.Xpkg != nil: + return a.Xpkg.Package == b.Xpkg.Package + case a.Git != nil && b.Git != nil: + return a.Git.Repository == b.Git.Repository + case a.HTTP != nil && b.HTTP != nil: + return a.HTTP.URL == b.HTTP.URL + case a.K8s != nil && b.K8s != nil: + return true // Only one k8s dep makes sense. + } + return false +}