diff --git a/cmd/crossplane/dependency/add.go b/cmd/crossplane/dependency/add.go index 6424f473..040cffbb 100644 --- a/cmd/crossplane/dependency/add.go +++ b/cmd/crossplane/dependency/add.go @@ -18,7 +18,6 @@ package dependency import ( "context" - "path/filepath" "strings" "github.com/google/go-containerregistry/pkg/name" @@ -43,9 +42,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). 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"` @@ -61,14 +63,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, projFS, projFile, err := loadProject(c.ProjectFile, projectfile.Overrides{ + SchemasDir: c.SchemasDir, + SchemaLanguages: c.SchemaLanguages, + }) if err != nil { return err } @@ -89,7 +87,7 @@ func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con resolver := clixpkg.NewResolver(client) m := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(c.ProjectFile), + dependency.WithProjectFile(projFile), dependency.WithSchemaGenerators(generator.Filter( generator.AllLanguages( generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), diff --git a/cmd/crossplane/dependency/cache.go b/cmd/crossplane/dependency/cache.go index 3cecc882..76ccb7f6 100644 --- a/cmd/crossplane/dependency/cache.go +++ b/cmd/crossplane/dependency/cache.go @@ -19,7 +19,6 @@ package dependency import ( "context" "fmt" - "path/filepath" "github.com/alecthomas/kong" "github.com/spf13/afero" @@ -42,10 +41,14 @@ 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). Overrides the project's schemas.languages; defaults to all." name:"schema-languages"` + K8sVersion string `help:"Kubernetes version to generate core API schemas for (e.g. v1.33.0). Replaces the project's k8s dependency for this run; never written to the file." name:"k8s-version"` } func (c *updateCacheCmd) Help() string { @@ -56,14 +59,11 @@ 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, projFS, projFile, err := loadProject(c.ProjectFile, projectfile.Overrides{ + SchemasDir: c.SchemasDir, + SchemaLanguages: c.SchemaLanguages, + K8sVersion: c.K8sVersion, + }) if err != nil { return err } @@ -84,7 +84,7 @@ func (c *updateCacheCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, resolver := clixpkg.NewResolver(client) opts := []dependency.ManagerOption{ - dependency.WithProjectFile(c.ProjectFile), + dependency.WithProjectFile(projFile), dependency.WithSchemaGenerators(generator.Filter( generator.AllLanguages( generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), @@ -116,9 +116,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 { @@ -127,20 +129,13 @@ 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, projFile, err := loadProject(c.ProjectFile, projectfile.Overrides{SchemasDir: c.SchemasDir}) if err != nil { return err } m := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(c.ProjectFile), + dependency.WithProjectFile(projFile), ) if err := m.Clean(); err != nil { diff --git a/cmd/crossplane/dependency/dependency.go b/cmd/crossplane/dependency/dependency.go index 75e30eb1..9b2235fe 100644 --- a/cmd/crossplane/dependency/dependency.go +++ b/cmd/crossplane/dependency/dependency.go @@ -17,9 +17,36 @@ limitations under the License. // Package dependency contains commands for managing project dependencies. package dependency +import ( + "path/filepath" + + "github.com/spf13/afero" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "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 and projFile is the file's name within it. +func loadProject(path string, o projectfile.Overrides) (proj *v1alpha1.Project, projFS afero.Fs, projFile string, err error) { + abs, err := projectfile.Resolve(path) + if err != nil { + return nil, nil, "", err + } + projFS = afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(abs)) + projFile = filepath.Base(abs) + + proj, err = projectfile.Load(projFS, projFile, o) + if err != nil { + return nil, nil, "", err + } + return proj, projFS, projFile, nil +} diff --git a/cmd/crossplane/dependency/help/add.md b/cmd/crossplane/dependency/help/add.md index df9a1bfc..9f9fd4e6 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 `Configuration` package metadata (`crossplane.yaml`). +When `-f` is not 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..d3a5eb30 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 `Configuration` +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..0a06d296 100644 --- a/cmd/crossplane/dependency/help/update-cache.md +++ b/cmd/crossplane/dependency/help/update-cache.md @@ -2,3 +2,44 @@ 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 package metadata (`crossplane.yaml`). +When `-f` is not 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`). +For a project file, the flag overrides `spec.schemas.languages`. + +Package metadata can't declare Kubernetes core API dependencies. +Use `--k8s-version` to generate schemas for them (for example `ServiceAccount` or `ConfigMap`). +For a project file, the flag replaces its `k8s` dependency. The file is never changed. + +## 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 +``` + +Also generate Python schemas for the Kubernetes v1.37.0 core APIs: + +```shell +crossplane dependency update-cache -f crossplane.yaml --schema-languages python --k8s-version v1.37.0 +``` diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 0a41121d..75c8bd74 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -399,9 +399,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) @@ -420,28 +420,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) newClientAndResolver(extraOpts ...clixpkg.ClientOption) (runtimexpkg.Client, *clixpkg.Resolver, error) { cacheDir := c.CacheDir if cacheDir == "" { diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index 6e34c251..328fc385 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -311,8 +311,18 @@ 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 to the project file, or to spec.dependsOn when the file is +// Configuration package metadata. func (m *Manager) AddDependency(ctx context.Context, dep *v1alpha1.Dependency) error { + isProject, err := projectfile.IsProjectFile(m.projFS, m.projFile) + if err != nil { + return err + } + // Package metadata can only express runtime xpkg dependencies. + if !isProject && (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") + } + gvk, err := m.addDependencyNoWrite(ctx, dep, false) if err != nil { return err @@ -330,6 +340,9 @@ func (m *Manager) AddDependency(ctx context.Context, dep *v1alpha1.Dependency) e defer m.updateMutex.Unlock() upsertDependency(m.proj, *dep) + if !isProject { + return projectfile.UpsertConfigurationDependency(m.projFS, m.projFile, *dep.Xpkg) + } return projectfile.Update(m.projFS, m.projFile, func(p *v1alpha1.Project) { upsertDependency(p, *dep) }) diff --git a/internal/dependency/manager_test.go b/internal/dependency/manager_test.go index 0d2339d5..60023567 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/projectfile" "github.com/crossplane/cli/v2/internal/schemas/generator" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" ) @@ -1013,3 +1014,67 @@ 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, err := projectfile.Load(projFS, "crossplane.yaml", projectfile.Overrides{}) + if err != nil { + t.Fatal(err) + } + + m := NewManager(proj, projFS, + WithProjectFile("crossplane.yaml"), + WithSchemaFS(afero.NewMemMapFs()), + WithSchemaGenerators([]generator.Interface{}), + WithXpkgClient(fc), + WithResolver(clixpkg.NewResolver(fc)), + ) + + if err := m.AddDependency(context.Background(), xpkgDep(prov, "v0.1.0")); err != nil { + t.Fatalf("AddDependency: %v", err) + } + + // The dependency is persisted to spec.dependsOn and loads back. + got, err := projectfile.Load(projFS, "crossplane.yaml", projectfile.Overrides{}) + if err != nil { + t.Fatal(err) + } + 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(context.Background(), k8s); err == nil { + t.Error("expected error adding k8s dependency to package metadata, got nil") + } +} diff --git a/internal/project/projectfile/configuration.go b/internal/project/projectfile/configuration.go new file mode 100644 index 00000000..3a4278fc --- /dev/null +++ b/internal/project/projectfile/configuration.go @@ -0,0 +1,254 @@ +/* +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" + "slices" + + "github.com/spf13/afero" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + runtimexpkg "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" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// defaultXpkgVersion is the version constraint used for package metadata +// dependencies that don't specify a version. +const defaultXpkgVersion = ">=v0.0.0" + +// 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) +} + +// 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 { + // K8sVersion replaces any k8s dependency with one for this Kubernetes version, or adds one if there is none. + K8sVersion string + // 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 Configuration package metadata file (crossplane.yaml), +// applies the overrides, and returns a Project with defaults applied. +// 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, 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, errors.Errorf("schemas directory %q must be a relative subdirectory of the project directory", o.SchemasDir) + } + + isProject, err := IsProjectFile(fs, file) + if err != nil { + return nil, err + } + + var proj *v1alpha1.Project + if isProject { + proj, err = ParseWithoutDefaults(fs, file) + } else { + var cfg *pkgmetav1.Configuration + cfg, err = clixpkg.ParseConfiguration(fs, file) + if err == nil { + proj = FromConfiguration(cfg) + } + } + if err != nil { + return 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, err + } + } + if o.K8sVersion != "" { + // Only one k8s dependency makes sense, so replace any existing one. + proj.Spec.Dependencies = slices.DeleteFunc(proj.Spec.Dependencies, func(d v1alpha1.Dependency) bool { + return d.Type == v1alpha1.DependencyTypeK8s + }) + proj.Spec.Dependencies = append(proj.Spec.Dependencies, v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeK8s, + K8s: &v1alpha1.K8sDependency{Version: o.K8sVersion}, + }) + } + proj.Default() + + return proj, nil +} + +// FromConfiguration converts Configuration 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 FromConfiguration(cfg *pkgmetav1.Configuration) *v1alpha1.Project { + proj := &v1alpha1.Project{} + proj.APIVersion, proj.Kind = APIVersion, Kind + proj.Name = cfg.Name + + for _, dep := range cfg.Spec.DependsOn { + x, ok := xpkgDependency(dep) + if !ok { + continue + } + proj.Spec.Dependencies = append(proj.Spec.Dependencies, v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: x, + }) + } + + return proj +} + +// UpsertConfigurationDependency adds dep to the dependsOn list of the +// Configuration 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 UpsertConfigurationDependency(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 && 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) +} + +// 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 +} diff --git a/internal/project/projectfile/configuration_test.go b/internal/project/projectfile/configuration_test.go new file mode 100644 index 00000000..174501f6 --- /dev/null +++ b/internal/project/projectfile/configuration_test.go @@ -0,0 +1,285 @@ +/* +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 ( + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + "sigs.k8s.io/yaml" + + "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 +` + +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 { + file string + content string + schemasDir string + languages []string + k8sVersion string + wantSchemas string + wantLanguages []string + wantDeps []v1alpha1.Dependency + wantErr bool + }{ + "MetaDefaultSchemas": { + file: "crossplane.yaml", + content: testMeta, + wantSchemas: "schemas", + wantDeps: metaDeps, + }, + "MetaSchemasFlag": { + file: "crossplane.yaml", + content: testMeta, + schemasDir: "gen/schemas", + wantSchemas: "gen/schemas", + wantDeps: metaDeps, + }, + "ProjectKeepsSchemasPath": { + file: "crossplane-project.yaml", + content: testProject, + wantSchemas: "my-schemas", + }, + "ProjectSchemasFlagOverrides": { + file: "crossplane-project.yaml", + content: testProject, + schemasDir: "other", + wantSchemas: "other", + }, + "SchemasDirEscapes": { + file: "crossplane.yaml", + content: testMeta, + schemasDir: "../schemas", + wantErr: true, + }, + "SchemasDirAbsolute": { + file: "crossplane.yaml", + content: testMeta, + schemasDir: "/tmp/schemas", + wantErr: true, + }, + "SchemasDirIsRoot": { + file: "crossplane.yaml", + content: testMeta, + schemasDir: "./", + wantErr: true, + }, + "MetaLanguagesFlag": { + file: "crossplane.yaml", + content: testMeta, + languages: []string{"python"}, + wantSchemas: "schemas", + wantLanguages: []string{"python"}, + wantDeps: metaDeps, + }, + "ProjectLanguagesFlagOverrides": { + file: "crossplane-project.yaml", + content: testProject + " schemas:\n languages: [go]\n", + languages: []string{"python", "json"}, + wantSchemas: "my-schemas", + wantLanguages: []string{"python", "json"}, + }, + "ProjectKeepsLanguages": { + file: "crossplane-project.yaml", + content: testProject + " schemas:\n languages: [go]\n", + wantSchemas: "my-schemas", + wantLanguages: []string{"go"}, + }, + "MetaK8sVersionAdds": { + file: "crossplane.yaml", + content: testMeta, + k8sVersion: "v1.33.0", + wantSchemas: "schemas", + wantDeps: append(slices.Clone(metaDeps), v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeK8s, K8s: &v1alpha1.K8sDependency{Version: "v1.33.0"}, + }), + }, + "ProjectK8sVersionReplaces": { + file: "crossplane-project.yaml", + content: testProject + ` dependencies: + - type: k8s + k8s: + version: v1.31.0 + - type: xpkg + xpkg: + apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-nop + version: v0.4.0 +`, + k8sVersion: "v1.33.0", + wantSchemas: "my-schemas", + wantDeps: []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.4.0", + }}, + {Type: v1alpha1.DependencyTypeK8s, K8s: &v1alpha1.K8sDependency{Version: "v1.33.0"}}, + }, + }, + "UnsupportedLanguage": { + file: "crossplane.yaml", + content: testMeta, + languages: []string{"rust"}, + wantErr: true, + }, + "NotAConfiguration": { + file: "crossplane.yaml", + content: "apiVersion: meta.pkg.crossplane.io/v1\nkind: Provider\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, tc.file, []byte(tc.content), 0o644); err != nil { + t.Fatal(err) + } + + proj, err := Load(fs, tc.file, Overrides{SchemasDir: tc.schemasDir, SchemaLanguages: tc.languages, K8sVersion: tc.k8sVersion}) + 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) + } + }) + } +} + +func TestUpsertConfigurationDependency(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 := UpsertConfigurationDependency(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 := UpsertConfigurationDependency(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) + } + + // The result must still parse as a Configuration. + var doc map[string]any + if err := yaml.Unmarshal(got, &doc); err != nil { + t.Fatal(err) + } + if _, err := Load(fs, "crossplane.yaml", Overrides{}); err != nil { + t.Fatalf("updated file no longer loads: %v", err) + } +}