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/3] 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 7da457d..2bd986e 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 fef823f..17a39dd 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 0000000..4ea7d1c --- /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 0000000..865ead6 --- /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/3] 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 2bd986e..39774d0 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/3] 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 eb6bd6f..36bfa35 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 17a39dd..f2b41e0 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 7e86213..b79fc5a 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 0db9255..431051d 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()