diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 7da457d5..39774d0f 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) + } + }) + } +}