diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index eb08f5d..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,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. 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 @@ -398,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 nil, errors.New("functions argument is required when not in a project") + 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, errors.Wrap(err, "cannot create xpkg client") + return nil, nil, errors.Wrap(err, "cannot create xpkg client") + } + return xpkgClient, clixpkg.NewResolver(xpkgClient), nil +} + +func (c *Cmd) loadFunctionsFromProject(ctx context.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config, projFS afero.Fs, projFilePath, projFileName string) ([]pkgv1.Function, error) { + log.Debug("Loading functions from project", "project-file", projFilePath) + + proj, err := projectfile.Parse(projFS, projFileName) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) + } + + xpkgClient, resolver, err := c.newClientAndResolver(clixpkg.WithImageConfigs(proj.Spec.ImageConfigs)) + if err != nil { + return nil, err } - resolver := clixpkg.NewResolver(xpkgClient) - // Built here rather than alongside the schema manager below so the - // dependency manager generates dependency schemas the same way. generators := generator.AllLanguages( generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), ) depMgr := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(filepath.Base(projFilePath)), + dependency.WithProjectFile(projFileName), dependency.WithSchemaGenerators(generators), dependency.WithXpkgClient(xpkgClient), dependency.WithResolver(resolver), @@ -459,9 +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") @@ -494,3 +515,24 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal return fns, nil } + +func (c *Cmd) loadFunctionsFromConfiguration(ctx context.Context, log logging.Logger, cfgFS afero.Fs, cfgFileName string) ([]pkgv1.Function, error) { + log.Debug("Loading functions from configuration file", "configuration-file", cfgFileName) + + cfgMeta, err := clixpkg.ParseConfiguration(cfgFS, cfgFileName) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse configuration file %q", cfgFileName) + } + + _, resolver, err := c.newClientAndResolver() + if err != nil { + return nil, err + } + + fns, err := clixpkg.ResolveConfigurationFunctions(ctx, cfgMeta, resolver) + if err != nil { + return nil, errors.Wrap(err, "cannot resolve function dependencies from configuration file") + } + + return fns, nil +} diff --git a/cmd/crossplane/render/xr/help/render.md b/cmd/crossplane/render/xr/help/render.md index fef823f..f2b41e0 100644 --- a/cmd/crossplane/render/xr/help/render.md +++ b/cmd/crossplane/render/xr/help/render.md @@ -41,6 +41,15 @@ When running `render` in a Crossplane Project (any directory containing a file argument in favor of using function dependencies defined in the project metadata and embedded functions from the project. +## Configuration package support + +The `--project-file` (`-f`) flag also accepts a Configuration package metadata +file (`crossplane.yaml`). +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 The `--context-files` and `--context-values` flags pass data to each Function's @@ -155,3 +164,10 @@ crossplane composition render xr.yaml composition.yaml functions.yaml \ -a render.crossplane.io/runtime=Development \ -a render.crossplane.io/runtime-development-target=localhost:9444 ``` + +Render using functions from a Configuration package metadata file: + +```shell +crossplane composition render xr.yaml composition.yaml \ + -f crossplane.yaml +``` diff --git a/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() 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) + } + }) + } +}