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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 18 additions & 97 deletions cmd/crossplane/dependency/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:<version>, 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:<version>, 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"`
Expand All @@ -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
}
Expand All @@ -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),
Expand All @@ -91,93 +85,20 @@ 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)
return sp.WrapWithSuccessSpinner("Adding "+desc, func() error {
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:<tag / semver constraint>
// 2. registry.example.com/repo@<digest>
// 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
}
38 changes: 14 additions & 24 deletions cmd/crossplane/dependency/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"cmp"
"context"
"fmt"
"path/filepath"

"github.com/alecthomas/kong"
"github.com/spf13/afero"
Expand All @@ -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"
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
100 changes: 98 additions & 2 deletions cmd/crossplane/dependency/dependency.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading