From 27f0d48c3324dd705ec09be1512bec33eb1c4eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:07:31 +0200 Subject: [PATCH 1/7] feat: allow composition render subcommand read from configuration package metadata file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/render/xr/cmd.go | 51 +++++++- cmd/crossplane/render/xr/help/render.md | 18 +++ internal/xpkg/configuration.go | 94 +++++++++++++++ internal/xpkg/configuration_test.go | 154 ++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 internal/xpkg/configuration.go create mode 100644 internal/xpkg/configuration_test.go diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 7da457d5..2bd986e1 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -86,11 +86,12 @@ type Cmd struct { FunctionCredentials string `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` FunctionAnnotations []string `help:"Override function annotations for all functions. Provide multiple annotations by repeating the argument." placeholder:"KEY=VALUE" short:"a"` - 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"` 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"` + PkgMetaFile string `default:"crossplane.yaml" help:"Path to a package metadata file (crossplane.yaml). Used as fallback when no project file is found." name:"pkg-meta-file" optional:"" predictor:"yaml_file" type:"path"` + 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."` - 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 @@ -405,7 +406,7 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal projDir := filepath.Dir(projFilePath) if _, err := os.Stat(projFilePath); err != nil { - return nil, errors.New("functions argument is required when not in a project") + return c.loadFunctionsFromConfiguration(ctx, log) } log.Debug("Loading functions from project", "project-file", projFilePath) @@ -493,3 +494,45 @@ 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) ([]pkgv1.Function, error) { + cfgFilePath, err := filepath.Abs(c.PkgMetaFile) + if err != nil { + return nil, errors.Wrap(err, "cannot determine configuration file path") + } + + if _, err := os.Stat(cfgFilePath); err != nil { + return nil, errors.New("functions argument is required when not in a project or configuration") + } + + log.Debug("Loading functions from configuration file", "configuration-file", cfgFilePath) + + cfgDir := filepath.Dir(cfgFilePath) + cfgFS := afero.NewBasePathFs(afero.NewOsFs(), cfgDir) + + cfg, err := clixpkg.ParseConfiguration(cfgFS, filepath.Base(cfgFilePath)) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse configuration file %q", cfgFilePath) + } + + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + xpkgClient, err := clixpkg.NewClient( + clixpkg.NewRemoteFetcher(), + clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), + ) + if err != nil { + return nil, errors.Wrap(err, "cannot create xpkg client") + } + resolver := clixpkg.NewResolver(xpkgClient) + + fns, err := clixpkg.ResolveConfigurationFunctions(ctx, cfg, 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..17a39dd3 100644 --- a/cmd/crossplane/render/xr/help/render.md +++ b/cmd/crossplane/render/xr/help/render.md @@ -41,6 +41,17 @@ 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 + +When no project file is found, `render` looks for a Configuration package +metadata file (`crossplane.yaml` by default). If found, it extracts function +dependencies from the `spec.dependsOn` list and resolves their version +constraints to concrete OCI references. This lets you omit the functions file +argument in directories that contain a `crossplane.yaml` with +`kind: Configuration`. + +Use `--pkg-meta-file` to specify a custom path to the package metadata file. + ## Function context The `--context-files` and `--context-values` flags pass data to each Function's @@ -155,3 +166,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 \ + --pkg-meta-file=crossplane.yaml +``` diff --git a/internal/xpkg/configuration.go b/internal/xpkg/configuration.go new file mode 100644 index 00000000..4ea7d1cc --- /dev/null +++ b/internal/xpkg/configuration.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 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" +) + +// 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 tm metav1.TypeMeta + if err := yaml.Unmarshal(bs, &tm); err != nil { + return nil, errors.Wrap(err, "failed to parse configuration file") + } + + wantAPIVersion := pkgmetav1.SchemeGroupVersion.String() + if tm.APIVersion != wantAPIVersion { + return nil, errors.Errorf("unsupported configuration apiVersion %q, expected %q", tm.APIVersion, wantAPIVersion) + } + if tm.Kind != pkgmetav1.ConfigurationKind { + return nil, errors.Errorf("unsupported configuration kind %q, expected %q", tm.Kind, pkgmetav1.ConfigurationKind) + } + + var cfg pkgmetav1.Configuration + if err := yaml.Unmarshal(bs, &cfg); err != nil { + return nil, errors.Wrap(err, "failed to parse configuration file") + } + + 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 { + if dep.Function == nil { + continue + } + + ref := *dep.Function + 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 +} diff --git a/internal/xpkg/configuration_test.go b/internal/xpkg/configuration_test.go new file mode 100644 index 00000000..865ead6f --- /dev/null +++ b/internal/xpkg/configuration_test.go @@ -0,0 +1,154 @@ +/* +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" + + tests := []struct { + name string + deps []pkgmetav1.Dependency + want []pkgv1.Function + }{ + { + name: "FiltersFunctionsOnly", + 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: "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) + } + }) + } +} From ac7dc7723ffe4e996b05a35875d100a8649a02e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:27:53 +0200 Subject: [PATCH 2/7] fix: tagalign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/render/xr/cmd.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 2bd986e1..39774d0f 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -86,12 +86,12 @@ type Cmd struct { FunctionCredentials string `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` FunctionAnnotations []string `help:"Override function annotations for all functions. Provide multiple annotations by repeating the argument." placeholder:"KEY=VALUE" short:"a"` - 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"` MaxConcurrency uint `default:"8" help:"Maximum concurrency for building embedded functions."` - PkgMetaFile string `default:"crossplane.yaml" help:"Path to a package metadata file (crossplane.yaml). Used as fallback when no project file is found." name:"pkg-meta-file" optional:"" predictor:"yaml_file" type:"path"` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to the project file. Optional." optional:"" predictor:"yaml_file" short:"f" type:"path"` + PkgMetaFile string `default:"crossplane.yaml" help:"Path to a package metadata file (crossplane.yaml). Used as fallback when no project file is found." name:"pkg-meta-file" optional:"" predictor:"yaml_file" type:"path"` + 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."` - 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 From 56e47247c0912d1bf35e229af6149a45c2fb30d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:15:29 +0200 Subject: [PATCH 3/7] chore: move under the existing project-file flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/render/xr/cmd.go | 105 +++++++++--------- cmd/crossplane/render/xr/help/render.md | 16 ++- internal/project/projectfile/projectfile.go | 17 +++ .../project/projectfile/projectfile_test.go | 73 ++++++++++++ 4 files changed, 149 insertions(+), 62 deletions(-) diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index eb6bd6ff..36bfa359 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" @@ -86,12 +87,11 @@ type Cmd struct { FunctionCredentials string `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` FunctionAnnotations []string `help:"Override function annotations for all functions. Provide multiple annotations by repeating the argument." placeholder:"KEY=VALUE" short:"a"` - 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"` MaxConcurrency uint `default:"8" help:"Maximum concurrency for building embedded functions."` - PkgMetaFile string `default:"crossplane.yaml" help:"Path to a package metadata file (crossplane.yaml). Used as fallback when no project file is found." name:"pkg-meta-file" optional:"" predictor:"yaml_file" type:"path"` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to the project file. Optional." optional:"" predictor:"yaml_file" short:"f" type:"path"` + ProjectFile string `default:"crossplane-project.yaml" help:"Path to the project file or package metadata file (crossplane.yaml). Auto-detects 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 @@ -399,48 +399,71 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return fns, nil } - projFilePath, err := filepath.Abs(c.ProjectFile) + filePath, err := filepath.Abs(c.ProjectFile) if err != nil { return nil, errors.Wrap(err, "cannot determine project file path") } - projDir := filepath.Dir(projFilePath) - if _, err := os.Stat(projFilePath); err != nil { - return c.loadFunctionsFromConfiguration(ctx, log) + if _, err := os.Stat(filePath); err != nil { + // Fall back to crossplane.yaml in the same directory when the + // default project file is not found. + fallback := filepath.Join(filepath.Dir(filePath), "crossplane.yaml") + if _, ferr := os.Stat(fallback); ferr != nil { + return nil, errors.New("functions argument is required when not in a project or configuration") + } + filePath = fallback } - log.Debug("Loading functions from project", "project-file", projFilePath) + dir := filepath.Dir(filePath) + fs := afero.NewBasePathFs(afero.NewOsFs(), dir) + fileName := filepath.Base(filePath) - projFS := afero.NewBasePathFs(afero.NewOsFs(), projDir) - proj, err := projectfile.Parse(projFS, filepath.Base(projFilePath)) + isProject, err := projectfile.IsProjectFile(fs, fileName) if err != nil { - return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) + return nil, errors.Wrapf(err, "cannot detect file type of %q", filePath) + } + + if isProject { + return c.loadFunctionsFromProject(ctx, log, sp, cfg, fs, filePath, fileName) } + return c.loadFunctionsFromConfiguration(ctx, log, fs, fileName) +} + +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, 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, errors.Wrap(err, "cannot create xpkg client") + 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), @@ -460,9 +483,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") @@ -496,41 +516,20 @@ 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) ([]pkgv1.Function, error) { - cfgFilePath, err := filepath.Abs(c.PkgMetaFile) - if err != nil { - return nil, errors.Wrap(err, "cannot determine configuration file path") - } - - if _, err := os.Stat(cfgFilePath); err != nil { - return nil, errors.New("functions argument is required when not in a project or configuration") - } - - log.Debug("Loading functions from configuration file", "configuration-file", cfgFilePath) +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) - cfgDir := filepath.Dir(cfgFilePath) - cfgFS := afero.NewBasePathFs(afero.NewOsFs(), cfgDir) - - cfg, err := clixpkg.ParseConfiguration(cfgFS, filepath.Base(cfgFilePath)) + cfgMeta, err := clixpkg.ParseConfiguration(cfgFS, cfgFileName) if err != nil { - return nil, errors.Wrapf(err, "cannot parse configuration file %q", cfgFilePath) + return nil, errors.Wrapf(err, "cannot parse configuration file %q", cfgFileName) } - cacheDir := c.CacheDir - if cacheDir == "" { - cacheDir = dependency.DefaultCacheDir() - } - - xpkgClient, err := clixpkg.NewClient( - clixpkg.NewRemoteFetcher(), - clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), - ) + _, resolver, err := c.newClientAndResolver() if err != nil { - return nil, errors.Wrap(err, "cannot create xpkg client") + return nil, err } - resolver := clixpkg.NewResolver(xpkgClient) - fns, err := clixpkg.ResolveConfigurationFunctions(ctx, cfg, resolver) + fns, err := clixpkg.ResolveConfigurationFunctions(ctx, cfgMeta, resolver) if err != nil { return nil, errors.Wrap(err, "cannot resolve function dependencies from configuration file") } diff --git a/cmd/crossplane/render/xr/help/render.md b/cmd/crossplane/render/xr/help/render.md index 17a39dd3..f2b41e0e 100644 --- a/cmd/crossplane/render/xr/help/render.md +++ b/cmd/crossplane/render/xr/help/render.md @@ -43,14 +43,12 @@ metadata and embedded functions from the project. ## Configuration package support -When no project file is found, `render` looks for a Configuration package -metadata file (`crossplane.yaml` by default). If found, it extracts function -dependencies from the `spec.dependsOn` list and resolves their version -constraints to concrete OCI references. This lets you omit the functions file -argument in directories that contain a `crossplane.yaml` with -`kind: Configuration`. - -Use `--pkg-meta-file` to specify a custom path to the package metadata file. +The `--project-file` (`-f`) flag also accepts a Configuration package metadata +file (`crossplane.yaml`). +The file type is auto-detected 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 @@ -171,5 +169,5 @@ Render using functions from a Configuration package metadata file: ```shell crossplane composition render xr.yaml composition.yaml \ - --pkg-meta-file=crossplane.yaml + -f crossplane.yaml ``` 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() From fd77ac81fea21d8ecccfdc3c743f0a6cd95cb4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:29:42 +0200 Subject: [PATCH 4/7] fix: pr review, refactor project/meta files to be centralized as constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/composition/generate.go | 2 +- cmd/crossplane/dependency/add.go | 2 +- cmd/crossplane/dependency/cache.go | 4 +-- cmd/crossplane/function/generate.go | 2 +- cmd/crossplane/main.go | 6 ++++ cmd/crossplane/project/build.go | 2 +- cmd/crossplane/project/init.go | 7 ++--- cmd/crossplane/project/push.go | 2 +- cmd/crossplane/project/run.go | 2 +- cmd/crossplane/project/stop.go | 2 +- cmd/crossplane/render/op/cmd.go | 2 +- cmd/crossplane/render/xr/cmd.go | 42 +++++++++++++++++--------- cmd/crossplane/xrd/generate.go | 2 +- internal/dependency/manager.go | 2 +- internal/xpkg/configuration.go | 38 +++++++++++++++-------- internal/xpkg/configuration_test.go | 24 ++++++++++++++- 16 files changed, 96 insertions(+), 45 deletions(-) diff --git a/cmd/crossplane/composition/generate.go b/cmd/crossplane/composition/generate.go index 5d96bea6..32559285 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..d7b146c8 100644 --- a/cmd/crossplane/dependency/add.go +++ b/cmd/crossplane/dependency/add.go @@ -44,7 +44,7 @@ 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"` + 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. diff --git a/cmd/crossplane/dependency/cache.go b/cmd/crossplane/dependency/cache.go index e3e377fc..7d09ad0c 100644 --- a/cmd/crossplane/dependency/cache.go +++ b/cmd/crossplane/dependency/cache.go @@ -42,7 +42,7 @@ 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"` + 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."` @@ -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..5d51d122 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -71,7 +71,7 @@ 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"` + 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..4c4894cf 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" @@ -46,6 +47,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/xr" "github.com/crossplane/cli/v2/cmd/crossplane/xrd" "github.com/crossplane/cli/v2/internal/config" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" "github.com/crossplane/cli/v2/internal/maturity" "github.com/crossplane/cli/v2/internal/terminal" @@ -128,6 +130,10 @@ 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), + kong.Vars{ + "project_file": clixpkg.ProjectFile, + "package_metadata_file": runtimexpkg.MetaFile, + }, kong.Help(helpPrinter), kong.UsageOnError()) diff --git a/cmd/crossplane/project/build.go b/cmd/crossplane/project/build.go index 9f62d420..cd872654 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..e1d7c2ba 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..64a67d92 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -62,7 +62,7 @@ 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"` + 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"` diff --git a/cmd/crossplane/project/stop.go b/cmd/crossplane/project/stop.go index 91e00221..47563c71 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..be1dca19 100644 --- a/cmd/crossplane/render/op/cmd.go +++ b/cmd/crossplane/render/op/cmd.go @@ -79,7 +79,7 @@ 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 `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 36bfa359..9070c5cd 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -87,11 +87,11 @@ type Cmd struct { FunctionCredentials string `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` FunctionAnnotations []string `help:"Override function annotations for all functions. Provide multiple annotations by repeating the argument." placeholder:"KEY=VALUE" short:"a"` - 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"` MaxConcurrency uint `default:"8" help:"Maximum concurrency for building embedded functions."` - ProjectFile string `default:"crossplane-project.yaml" help:"Path to the project file or package metadata file (crossplane.yaml). Auto-detects the file type." 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 @@ -399,19 +399,9 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return fns, nil } - filePath, err := filepath.Abs(c.ProjectFile) + filePath, err := c.resolveProjectFile() if err != nil { - return nil, errors.Wrap(err, "cannot determine project file path") - } - - if _, err := os.Stat(filePath); err != nil { - // Fall back to crossplane.yaml in the same directory when the - // default project file is not found. - fallback := filepath.Join(filepath.Dir(filePath), "crossplane.yaml") - if _, ferr := os.Stat(fallback); ferr != nil { - return nil, errors.New("functions argument is required when not in a project or configuration") - } - filePath = fallback + return nil, err } dir := filepath.Dir(filePath) @@ -430,6 +420,28 @@ 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/cmd/crossplane/xrd/generate.go b/cmd/crossplane/xrd/generate.go index ee229628..b30aa294 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/xpkg/configuration.go b/internal/xpkg/configuration.go index 4ea7d1cc..1f154c15 100644 --- a/internal/xpkg/configuration.go +++ b/internal/xpkg/configuration.go @@ -31,6 +31,10 @@ import ( 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) @@ -38,22 +42,17 @@ func ParseConfiguration(fs afero.Fs, filePath string) (*pkgmetav1.Configuration, return nil, errors.Wrapf(err, "failed to read configuration file %q", filePath) } - var tm metav1.TypeMeta - if err := yaml.Unmarshal(bs, &tm); err != nil { + 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 tm.APIVersion != wantAPIVersion { - return nil, errors.Errorf("unsupported configuration apiVersion %q, expected %q", tm.APIVersion, wantAPIVersion) + if cfg.APIVersion != wantAPIVersion { + return nil, errors.Errorf("unsupported configuration apiVersion %q, expected %q", cfg.APIVersion, wantAPIVersion) } - if tm.Kind != pkgmetav1.ConfigurationKind { - return nil, errors.Errorf("unsupported configuration kind %q, expected %q", tm.Kind, pkgmetav1.ConfigurationKind) - } - - var cfg pkgmetav1.Configuration - if err := yaml.Unmarshal(bs, &cfg); err != nil { - return nil, errors.Wrap(err, "failed to parse configuration file") + if cfg.Kind != pkgmetav1.ConfigurationKind { + return nil, errors.Errorf("unsupported configuration kind %q, expected %q", cfg.Kind, pkgmetav1.ConfigurationKind) } return &cfg, nil @@ -64,11 +63,11 @@ func ParseConfiguration(fs afero.Fs, filePath string) (*pkgmetav1.Configuration, 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 { - if dep.Function == nil { + ref, ok := functionDepRef(dep) + if !ok { continue } - ref := *dep.Function if dep.Version != "" { ref = fmt.Sprintf("%s:%s", ref, dep.Version) } @@ -92,3 +91,16 @@ func ResolveConfigurationFunctions(ctx context.Context, cfg *pkgmetav1.Configura 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 index 865ead6f..abd7f526 100644 --- a/internal/xpkg/configuration_test.go +++ b/internal/xpkg/configuration_test.go @@ -99,6 +99,10 @@ func TestResolveConfigurationFunctions(t *testing.T) { 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 @@ -106,7 +110,7 @@ func TestResolveConfigurationFunctions(t *testing.T) { want []pkgv1.Function }{ { - name: "FiltersFunctionsOnly", + name: "DeprecatedStyle", deps: []pkgmetav1.Dependency{ {Function: &fnA, Version: "v1.0.0"}, {Provider: &provider, Version: "v2.0.0"}, @@ -123,6 +127,24 @@ func TestResolveConfigurationFunctions(t *testing.T) { }, }, }, + { + 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, From b0c50b29c9653b734199f0c1e7807cb579d59682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:24:17 +0200 Subject: [PATCH 5/7] chore: fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/composition/generate.go | 2 +- cmd/crossplane/dependency/add.go | 6 +++--- cmd/crossplane/dependency/cache.go | 10 +++++----- cmd/crossplane/function/generate.go | 6 +++--- cmd/crossplane/main.go | 4 ++-- cmd/crossplane/project/build.go | 2 +- cmd/crossplane/project/push.go | 2 +- cmd/crossplane/project/run.go | 8 ++++---- cmd/crossplane/project/stop.go | 2 +- cmd/crossplane/render/op/cmd.go | 8 ++++---- cmd/crossplane/render/xr/cmd.go | 2 +- cmd/crossplane/xrd/generate.go | 2 +- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cmd/crossplane/composition/generate.go b/cmd/crossplane/composition/generate.go index 32559285..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:"${project_file}" 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 d7b146c8..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:"${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"` + 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 7d09ad0c..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:"${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 `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:"${project_file}" 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 5d51d122..3d1fc20a 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -68,9 +68,9 @@ 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"` + 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 diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index 4c4894cf..c0e7c3da 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -47,9 +47,9 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/xr" "github.com/crossplane/cli/v2/cmd/crossplane/xrd" "github.com/crossplane/cli/v2/internal/config" - clixpkg "github.com/crossplane/cli/v2/internal/xpkg" "github.com/crossplane/cli/v2/internal/maturity" "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" _ "embed" ) @@ -131,7 +131,7 @@ func main() { // Bind the loaded config so commands can read feature flags at runtime. kong.Bind(cfg), kong.Vars{ - "project_file": clixpkg.ProjectFile, + "project_file": clixpkg.ProjectFile, "package_metadata_file": runtimexpkg.MetaFile, }, kong.Help(helpPrinter), diff --git a/cmd/crossplane/project/build.go b/cmd/crossplane/project/build.go index cd872654..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:"${project_file}" 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/push.go b/cmd/crossplane/project/push.go index e1d7c2ba..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:"${project_file}" 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 64a67d92..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:"${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"` + 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 47563c71..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:"${project_file}" 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 be1dca19..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:"${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."` + 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 9070c5cd..0a41121d 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -89,7 +89,7 @@ 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 `help:"Path to the project file or package metadata file (crossplane.yaml). Autodetects the file type." 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"` diff --git a/cmd/crossplane/xrd/generate.go b/cmd/crossplane/xrd/generate.go index b30aa294..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:"${project_file}" 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 From 4edfcc05c2c38bd0d9a8cd9735ba67e59c2f2ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:56:42 +0200 Subject: [PATCH 6/7] fix: missing kong.Must at init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/main.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index c0e7c3da..1afd29ba 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -57,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 @@ -130,10 +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), - kong.Vars{ - "project_file": clixpkg.ProjectFile, - "package_metadata_file": runtimexpkg.MetaFile, - }, + kongVars, kong.Help(helpPrinter), kong.UsageOnError()) From bfd6f0fe35b89068ffafa47d7a017fe2650a3821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Fern=C3=A1ndez?= <7312236+fernandezcuesta@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:08:47 +0200 Subject: [PATCH 7/7] fix: doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesús Fernández <7312236+fernandezcuesta@users.noreply.github.com> --- cmd/crossplane/render/xr/help/render.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/crossplane/render/xr/help/render.md b/cmd/crossplane/render/xr/help/render.md index f2b41e0e..7bcce965 100644 --- a/cmd/crossplane/render/xr/help/render.md +++ b/cmd/crossplane/render/xr/help/render.md @@ -45,7 +45,7 @@ metadata and embedded functions from the project. The `--project-file` (`-f`) flag also accepts a Configuration package metadata file (`crossplane.yaml`). -The file type is auto-detected from `apiVersion` and `kind`. +`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.