diff --git a/cmd/crossplane/composition/generate.go b/cmd/crossplane/composition/generate.go index 5d96bea6..dbf7c797 100644 --- a/cmd/crossplane/composition/generate.go +++ b/cmd/crossplane/composition/generate.go @@ -59,7 +59,7 @@ type generateCmd struct { Name string `help:"Name prefix for the composition." optional:""` Plural string `help:"Custom plural for the referenced kind." optional:""` Path string `help:"Output file." optional:""` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + 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"` projFS afero.Fs diff --git a/cmd/crossplane/dependency/add.go b/cmd/crossplane/dependency/add.go index b5dd5d84..6424f473 100644 --- a/cmd/crossplane/dependency/add.go +++ b/cmd/crossplane/dependency/add.go @@ -43,9 +43,9 @@ 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:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` - 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 `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"` // Flags for specific dependency types. APIOnly bool `help:"Mark an xpkg dependency as API-only (not a runtime dependency)." name:"api-only"` diff --git a/cmd/crossplane/dependency/cache.go b/cmd/crossplane/dependency/cache.go index e3e377fc..3cecc882 100644 --- a/cmd/crossplane/dependency/cache.go +++ b/cmd/crossplane/dependency/cache.go @@ -42,10 +42,10 @@ var updateHelp string // updateCacheCmd updates the dependency cache by regenerating all schemas. type updateCacheCmd struct { - ProjectFile string `default:"crossplane-project.yaml" 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 `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."` } func (c *updateCacheCmd) Help() string { @@ -116,7 +116,7 @@ var cleanHelp string // cleanCacheCmd removes all generated schemas. type cleanCacheCmd struct { - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + 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"` } diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 616917a8..3d1fc20a 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -68,10 +68,10 @@ var ( ) type generateCmd struct { - Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."` - PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""` - Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."` + PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""` + Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"` + ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"` projFS afero.Fs functionsFS afero.Fs diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index 1184cdec..1afd29ba 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" "github.com/crossplane/cli/v2/cmd/crossplane/cluster" "github.com/crossplane/cli/v2/cmd/crossplane/completion" @@ -48,6 +49,7 @@ import ( "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/maturity" "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" _ "embed" ) @@ -55,7 +57,12 @@ import ( //go:embed help.md var helpDescription string -var _ = kong.Must(&cli{}) +var kongVars = kong.Vars{ //nolint:gochecknoglobals // We treat these as constants. + "project_file": clixpkg.ProjectFile, + "package_metadata_file": runtimexpkg.MetaFile, +} + +var _ = kong.Must(&cli{}, kongVars) type ( verboseFlag bool @@ -128,6 +135,7 @@ func main() { kong.BindTo(configcmd.ConfigPath(cfgPath), (*configcmd.ConfigPath)(nil)), // Bind the loaded config so commands can read feature flags at runtime. kong.Bind(cfg), + kongVars, kong.Help(helpPrinter), kong.UsageOnError()) diff --git a/cmd/crossplane/project/build.go b/cmd/crossplane/project/build.go index 9f62d420..06222ae6 100644 --- a/cmd/crossplane/project/build.go +++ b/cmd/crossplane/project/build.go @@ -50,7 +50,7 @@ var buildHelp string // buildCmd builds a project into Crossplane packages. type buildCmd struct { - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` Repository string `help:"Override the repository in the project file." optional:""` OutputDir string `default:"_output" help:"Output directory for packages." short:"o"` MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` diff --git a/cmd/crossplane/project/init.go b/cmd/crossplane/project/init.go index 54c65875..3c740960 100644 --- a/cmd/crossplane/project/init.go +++ b/cmd/crossplane/project/init.go @@ -28,6 +28,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" _ "embed" ) @@ -35,8 +36,6 @@ import ( //go:embed help/init.md var initHelp string -const projectFileName = "crossplane-project.yaml" - // initCmd initializes a new project. type initCmd struct { Name string `arg:"" help:"The name of the new project."` @@ -82,7 +81,7 @@ func (c *initCmd) Run(sp terminal.SpinnerPrinter) error { } // Write a minimal crossplane-project.yaml. - projFile := filepath.Join(c.Directory, projectFileName) + projFile := filepath.Join(c.Directory, clixpkg.ProjectFile) content := fmt.Sprintf(`apiVersion: dev.crossplane.io/v1alpha1 kind: Project metadata: @@ -92,7 +91,7 @@ spec: `, c.Name, r.String()) if err := os.WriteFile(projFile, []byte(content), 0o600); err != nil { - return errors.Wrapf(err, "failed to write %s", projectFileName) + return errors.Wrapf(err, "failed to write %s", clixpkg.ProjectFile) } // Create default subdirectories. diff --git a/cmd/crossplane/project/push.go b/cmd/crossplane/project/push.go index da308368..a562cdd1 100644 --- a/cmd/crossplane/project/push.go +++ b/cmd/crossplane/project/push.go @@ -46,7 +46,7 @@ var pushHelp string // pushCmd pushes a built project to an OCI registry. type pushCmd struct { - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` Repository string `help:"Override the repository in the project file." optional:""` Tag string `default:"" help:"Tag for the pushed package. Defaults to a time-based semver-like tag." short:"t"` PackageFile string `help:"Package file to push. Defaults to /.xpkg." optional:""` diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index 5b829656..73ba0ad7 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -62,10 +62,10 @@ var runHelp string // runCmd builds a project and runs it in a local dev control plane. type runCmd struct { - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` - Repository string `help:"Override the repository." optional:""` - MaxConcurrency uint `default:"8" help:"Max concurrent builds."` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` + Repository string `help:"Override the repository." optional:""` + MaxConcurrency uint `default:"8" help:"Max concurrent builds."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` ControlPlaneName string `help:"Name of the dev control plane. Defaults to project name."` CrossplaneVersion string `help:"Version of Crossplane to install."` diff --git a/cmd/crossplane/project/stop.go b/cmd/crossplane/project/stop.go index 91e00221..ae82f4b6 100644 --- a/cmd/crossplane/project/stop.go +++ b/cmd/crossplane/project/stop.go @@ -38,7 +38,7 @@ var stopHelp string // stopCmd tears down a local dev control plane. type stopCmd struct { - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` ControlPlaneName string `help:"Name of the dev control plane. Defaults to project name."` RegistryDir string `help:"Directory for local registry images."` } diff --git a/cmd/crossplane/render/op/cmd.go b/cmd/crossplane/render/op/cmd.go index 70c3b4be..2274af1d 100644 --- a/cmd/crossplane/render/op/cmd.go +++ b/cmd/crossplane/render/op/cmd.go @@ -77,10 +77,10 @@ type Cmd struct { RequiredSchemas string `help:"A directory of JSON files specifying OpenAPI schemas to pass to the function pipeline." placeholder:"DIR" predictor:"directory" type:"path"` WatchedResource string `help:"A YAML file specifying the watched resource for WatchOperation rendering. The resource is also added to required resources." placeholder:"PATH" predictor:"yaml_file" short:"w" type:"existingfile"` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` - MaxConcurrency uint `default:"8" help:"Maximum concurrency for building embedded functions."` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to the project file. Optional." optional:"" predictor:"yaml_file" short:"f" type:"path"` - Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + MaxConcurrency uint `default:"8" help:"Maximum concurrency for building embedded functions."` + ProjectFile string `default:"${project_file}" help:"Path to the project file. Optional." optional:"" predictor:"yaml_file" short:"f" type:"path"` + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` fs afero.Fs diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index eb08f5d2..0a41121d 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -39,6 +39,7 @@ 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" @@ -88,9 +89,9 @@ type Cmd struct { CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` MaxConcurrency uint `default:"8" help:"Maximum concurrency for building embedded functions."` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to the project file. Optional." optional:"" predictor:"yaml_file" short:"f" type:"path"` + ProjectFile string `help:"Path to the project file or package metadata file (crossplane.yaml). Autodetects the file type." optional:"" predictor:"yaml_file" short:"f" type:"path"` Timeout time.Duration `default:"1m" help:"How long to run before timing out."` - XRD string `help:"A YAML file specifying the CompositeResourceDefinition (XRD) that defines the XR's schema and properties." optional:"" placeholder:"PATH" type:"existingfile"` + XRD string `help:"A YAML file specifying the CompositeResourceDefinition (XRD) that defines the XR's schema and properties." optional:"" placeholder:"PATH" type:"existingfile"` fs afero.Fs @@ -398,48 +399,83 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return fns, nil } - projFilePath, err := filepath.Abs(c.ProjectFile) + filePath, err := c.resolveProjectFile() if err != nil { - return nil, errors.Wrap(err, "cannot determine project file path") + return nil, err } - projDir := filepath.Dir(projFilePath) - if _, err := os.Stat(projFilePath); err != nil { - return nil, errors.New("functions argument is required when not in a project") + dir := filepath.Dir(filePath) + fs := afero.NewBasePathFs(afero.NewOsFs(), dir) + fileName := filepath.Base(filePath) + + isProject, err := projectfile.IsProjectFile(fs, fileName) + if err != nil { + return nil, errors.Wrapf(err, "cannot detect file type of %q", filePath) } - log.Debug("Loading functions from project", "project-file", projFilePath) + if isProject { + return c.loadFunctionsFromProject(ctx, log, sp, cfg, fs, filePath, fileName) + } - projFS := afero.NewBasePathFs(afero.NewOsFs(), projDir) - proj, err := projectfile.Parse(projFS, filepath.Base(projFilePath)) - if err != nil { - return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) + 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 == "" { cacheDir = dependency.DefaultCacheDir() } - xpkgClient, err := clixpkg.NewClient( - clixpkg.NewRemoteFetcher(), - clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), - clixpkg.WithImageConfigs(proj.Spec.ImageConfigs), - ) + opts := append([]clixpkg.ClientOption{clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir)}, extraOpts...) + xpkgClient, err := clixpkg.NewClient(clixpkg.NewRemoteFetcher(), opts...) if err != nil { - return nil, errors.Wrap(err, "cannot create xpkg client") + return nil, nil, errors.Wrap(err, "cannot create xpkg client") + } + return xpkgClient, clixpkg.NewResolver(xpkgClient), nil +} + +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) + + proj, err := projectfile.Parse(projFS, projFileName) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) + } + + xpkgClient, resolver, err := c.newClientAndResolver(clixpkg.WithImageConfigs(proj.Spec.ImageConfigs)) + if err != nil { + return nil, err } - resolver := clixpkg.NewResolver(xpkgClient) - // Built here rather than alongside the schema manager below so the - // dependency manager generates dependency schemas the same way. generators := generator.AllLanguages( generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), ) depMgr := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(filepath.Base(projFilePath)), + dependency.WithProjectFile(projFileName), dependency.WithSchemaGenerators(generators), dependency.WithXpkgClient(xpkgClient), dependency.WithResolver(resolver), @@ -459,9 +495,6 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(proj.Spec.ImageConfigs)) schemaMgr := manager.New(schemasFS, generators, schemaRunner) - // The builder may decompress function runtime tarballs into this - // directory; the built images read from it lazily, so we remove it only - // after they have been written to the daemon below. tempDir, err := os.MkdirTemp("", "crossplane-build-") if err != nil { return errors.Wrap(err, "failed to create temporary build directory") @@ -494,3 +527,24 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return fns, nil } + +func (c *Cmd) loadFunctionsFromConfiguration(ctx context.Context, log logging.Logger, cfgFS afero.Fs, cfgFileName string) ([]pkgv1.Function, error) { + log.Debug("Loading functions from configuration file", "configuration-file", cfgFileName) + + cfgMeta, err := clixpkg.ParseConfiguration(cfgFS, cfgFileName) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse configuration file %q", cfgFileName) + } + + _, resolver, err := c.newClientAndResolver() + if err != nil { + return nil, err + } + + fns, err := clixpkg.ResolveConfigurationFunctions(ctx, cfgMeta, resolver) + if err != nil { + return nil, errors.Wrap(err, "cannot resolve function dependencies from configuration file") + } + + return fns, nil +} diff --git a/cmd/crossplane/render/xr/help/render.md b/cmd/crossplane/render/xr/help/render.md index fef823f4..7bcce965 100644 --- a/cmd/crossplane/render/xr/help/render.md +++ b/cmd/crossplane/render/xr/help/render.md @@ -41,6 +41,15 @@ When running `render` in a Crossplane Project (any directory containing a file argument in favor of using function dependencies defined in the project metadata and embedded functions from the project. +## Configuration package support + +The `--project-file` (`-f`) flag also accepts a Configuration package metadata +file (`crossplane.yaml`). +`render` detects the file type automatically from `apiVersion` and `kind`. +When pointing to a Configuration, `render` extracts function dependencies from +`spec.dependsOn` and resolves their version constraints to concrete OCI +references. + ## Function context The `--context-files` and `--context-values` flags pass data to each Function's @@ -155,3 +164,10 @@ crossplane composition render xr.yaml composition.yaml functions.yaml \ -a render.crossplane.io/runtime=Development \ -a render.crossplane.io/runtime-development-target=localhost:9444 ``` + +Render using functions from a Configuration package metadata file: + +```shell +crossplane composition render xr.yaml composition.yaml \ + -f crossplane.yaml +``` diff --git a/cmd/crossplane/xrd/generate.go b/cmd/crossplane/xrd/generate.go index ee229628..487851d1 100644 --- a/cmd/crossplane/xrd/generate.go +++ b/cmd/crossplane/xrd/generate.go @@ -60,7 +60,7 @@ type generateCmd struct { Path string `help:"Output path." optional:""` Replace bool `help:"Replaces the existing definition file" optional:""` Plural string `help:"Custom plural form for the XRD." optional:""` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` projFS afero.Fs apisFS afero.Fs diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index a0ab9a09..6e34c251 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -138,7 +138,7 @@ func WithResolver(r *clixpkg.Resolver) ManagerOption { // in the user's config. func NewManager(proj *v1alpha1.Project, projFS afero.Fs, opts ...ManagerOption) *Manager { options := &managerOptions{ - projFile: "crossplane-project.yaml", + projFile: clixpkg.ProjectFile, schemaFS: afero.NewBasePathFs(projFS, proj.Spec.Paths.Schemas), schemaGenerators: generator.AllLanguages(), schemaRunner: runner.NewRealSchemaRunner( diff --git a/internal/project/projectfile/projectfile.go b/internal/project/projectfile/projectfile.go index 7e862133..b79fc5a3 100644 --- a/internal/project/projectfile/projectfile.go +++ b/internal/project/projectfile/projectfile.go @@ -34,6 +34,23 @@ const ( Kind = "Project" ) +// 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. +func IsProjectFile(fs afero.Fs, filePath string) (bool, error) { + bs, err := afero.ReadFile(fs, filePath) + if err != nil { + return false, errors.Wrapf(err, "failed to read file %q", filePath) + } + + var tm metav1.TypeMeta + if err := yaml.Unmarshal(bs, &tm); err != nil { + return false, errors.Wrapf(err, "failed to parse file %q", filePath) + } + + return tm.APIVersion == APIVersion && tm.Kind == Kind, nil +} + // Parse parses and validates the project file, returning a Project with // defaults applied. func Parse(projFS afero.Fs, projFilePath string) (*v1alpha1.Project, error) { diff --git a/internal/project/projectfile/projectfile_test.go b/internal/project/projectfile/projectfile_test.go index 0db92553..431051d4 100644 --- a/internal/project/projectfile/projectfile_test.go +++ b/internal/project/projectfile/projectfile_test.go @@ -28,6 +28,79 @@ import ( "github.com/crossplane/cli/v2/apis/dev/v1alpha1" ) +func TestIsProjectFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want bool + wantErr bool + }{ + { + name: "Project", + content: `apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: test +`, + want: true, + }, + { + name: "Configuration", + content: `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: test +`, + want: false, + }, + { + name: "WrongAPIVersion", + content: `apiVersion: foo.example.com/v1 +kind: Project +`, + want: false, + }, + { + name: "InvalidYAML", + content: `: bad`, + wantErr: true, + }, + { + name: "FileNotFound", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + if tt.content != "" { + if err := afero.WriteFile(fs, "/file.yaml", []byte(tt.content), os.ModePerm); err != nil { + t.Fatal(err) + } + } + + got, err := IsProjectFile(fs, "/file.yaml") + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("IsProjectFile() = %v, want %v", got, tt.want) + } + }) + } +} + func TestParse(t *testing.T) { t.Parallel() diff --git a/internal/xpkg/configuration.go b/internal/xpkg/configuration.go new file mode 100644 index 00000000..1f154c15 --- /dev/null +++ b/internal/xpkg/configuration.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "fmt" + "path" + + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +// ProjectFile is the conventional name for a Crossplane project definition +// file (crossplane-project.yaml). +const ProjectFile = "crossplane-project.yaml" + +// ParseConfiguration parses a Configuration package metadata file and returns the Configuration. +func ParseConfiguration(fs afero.Fs, filePath string) (*pkgmetav1.Configuration, error) { + bs, err := afero.ReadFile(fs, filePath) + if err != nil { + return nil, errors.Wrapf(err, "failed to read configuration file %q", filePath) + } + + var cfg pkgmetav1.Configuration + if err := yaml.Unmarshal(bs, &cfg); err != nil { + return nil, errors.Wrap(err, "failed to parse configuration file") + } + + wantAPIVersion := pkgmetav1.SchemeGroupVersion.String() + if cfg.APIVersion != wantAPIVersion { + return nil, errors.Errorf("unsupported configuration apiVersion %q, expected %q", cfg.APIVersion, wantAPIVersion) + } + if cfg.Kind != pkgmetav1.ConfigurationKind { + return nil, errors.Errorf("unsupported configuration kind %q, expected %q", cfg.Kind, pkgmetav1.ConfigurationKind) + } + + return &cfg, nil +} + +// ResolveConfigurationFunctions extracts Function dependencies from a Configuration and resolves +// their version constraints to concrete OCI references. +func ResolveConfigurationFunctions(ctx context.Context, cfg *pkgmetav1.Configuration, resolver *Resolver) ([]pkgv1.Function, error) { + fns := make([]pkgv1.Function, 0, len(cfg.Spec.DependsOn)) + for _, dep := range cfg.Spec.DependsOn { + ref, ok := functionDepRef(dep) + if !ok { + continue + } + + if dep.Version != "" { + ref = fmt.Sprintf("%s:%s", ref, dep.Version) + } + + resolved, _, err := resolver.Resolve(ctx, ref) + if err != nil { + return nil, errors.Wrapf(err, "cannot resolve function dependency %q", ref) + } + + fns = append(fns, pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: path.Base(resolved.Context().RepositoryStr()), + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: resolved.Name(), + }, + }, + }) + } + + return fns, nil +} + +// functionDepRef returns the OCI image ref for a function dependency, +// handling both the modern style (APIVersion + Kind + Package) and the +// deprecated style (Function field). +func functionDepRef(dep pkgmetav1.Dependency) (string, bool) { + if dep.Kind != nil && *dep.Kind == pkgv1.FunctionKind && dep.Package != nil { + return *dep.Package, true + } + if dep.Function != nil { + return *dep.Function, true + } + return "", false +} diff --git a/internal/xpkg/configuration_test.go b/internal/xpkg/configuration_test.go new file mode 100644 index 00000000..abd7f526 --- /dev/null +++ b/internal/xpkg/configuration_test.go @@ -0,0 +1,176 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +func TestParseConfiguration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expectErr bool + }{ + { + name: "ValidConfiguration", + content: ` +apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: my-config +spec: + dependsOn: + - function: ghcr.io/example/function-a + version: "v1.0.0" +`, + }, + { + name: "WrongAPIVersion", + content: "apiVersion: wrong.api/v1\nkind: Configuration\nspec: {}", + expectErr: true, + }, + { + name: "WrongKind", + content: "apiVersion: meta.pkg.crossplane.io/v1\nkind: Provider\nspec: {}", + expectErr: true, + }, + { + name: "InvalidYAML", + content: "not: valid: yaml: [", + expectErr: true, + }, + { + name: "FileNotFound", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + if tt.content != "" { + if err := afero.WriteFile(fs, "/crossplane.yaml", []byte(tt.content), os.ModePerm); err != nil { + t.Fatal(err) + } + } + + cfg, err := ParseConfiguration(fs, "/crossplane.yaml") + if (err != nil) != tt.expectErr { + t.Fatalf("ParseConfiguration() error = %v, expectErr %v", err, tt.expectErr) + } + if err == nil && cfg.Name != "my-config" { + t.Errorf("name = %q, want %q", cfg.Name, "my-config") + } + }) + } +} + +func TestResolveConfigurationFunctions(t *testing.T) { + t.Parallel() + + fnA := "ghcr.io/example/function-a" + fnB := "ghcr.io/example/function-b" + provider := "ghcr.io/example/provider-x" + fnKind := "Function" + providerKind := "Provider" + pkgAPIVersion := "pkg.crossplane.io/v1beta1" + providerAPIVersion := "pkg.crossplane.io/v1" + + tests := []struct { + name string + deps []pkgmetav1.Dependency + want []pkgv1.Function + }{ + { + name: "DeprecatedStyle", + deps: []pkgmetav1.Dependency{ + {Function: &fnA, Version: "v1.0.0"}, + {Provider: &provider, Version: "v2.0.0"}, + {Function: &fnB, Version: "v0.5.0"}, + }, + want: []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-a"}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: "ghcr.io/example/function-a:v1.0.0"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "function-b"}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: "ghcr.io/example/function-b:v0.5.0"}}, + }, + }, + }, + { + name: "ModernStyle", + deps: []pkgmetav1.Dependency{ + {APIVersion: &pkgAPIVersion, Kind: &fnKind, Package: &fnA, Version: "v1.0.0"}, + {APIVersion: &providerAPIVersion, Kind: &providerKind, Package: &provider, Version: "v2.0.0"}, + {APIVersion: &pkgAPIVersion, Kind: &fnKind, Package: &fnB, Version: "v0.5.0"}, + }, + want: []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-a"}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: "ghcr.io/example/function-a:v1.0.0"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "function-b"}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: "ghcr.io/example/function-b:v0.5.0"}}, + }, + }, + }, + { + name: "Empty", + deps: nil, + want: []pkgv1.Function{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := &pkgmetav1.Configuration{ + Spec: pkgmetav1.ConfigurationSpec{ + MetaSpec: pkgmetav1.MetaSpec{DependsOn: tt.deps}, + }, + } + + resolver := NewResolver(&fakeClient{tags: []string{"v1.0.0", "v0.5.0", "latest"}}) + got, err := ResolveConfigurationFunctions(context.Background(), cfg, resolver) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("ResolveConfigurationFunctions (-want +got):\n%s", diff) + } + }) + } +}