Repository: crossplane/crossplane
Pull request #5890 (2024-08-17 21:13:04)

feat(crank): beta validate, get crossplane schemas, extract function pipeline step input
<!--
Thank you for helping to improve Crossplane! Please read the contribution docs
(linked below) if this is your first Crossplane pull request.
-->

### Description of your changes
crank cli local build via: `go build -ldflags="-X 'github.com/crossplane/crossplane/internal/version.version=v1.16.0'"`

In `crossplane beta validate`, a new default option was introduced to validate against built-in CRDs (Compositions, EnvironmentConfigs, StoreConfig etc) directly from the Crossplane image.
By default, the Crossplane image corresponding to the version used to build the binary is utilized. However, you can override this by specifying a different image location. 

```bash
crossplane beta validate crossplane.yaml composition.yaml --crossplane-image=xpkg.upbound.io/upbound/crossplane:v1.16.0-up.1
```

```bash
crossplane beta validate crossplane.yaml composition.yaml --crossplane-image=xpkg.upbound.io/crossplane/crossplane:v1.14.0
```

When a Composition is included in the Resources input parameter, we will verify if it is set to pipeline mode. If it is, we will extract the input for each pipeline step as an additional resource and append it to the Resources list. This allows for validation against the function's input schema if a function is specified (in Extensions input parameter like crossplane.yaml)


#### before this PR

```
crossplane beta validate crossplane.yaml composition.yaml

[!] could not find CRD/XRD for: apiextensions.crossplane.io/v1, Kind=Composition
Total 1 resources: 1 missing schemas, 0 success cases, 0 failure cases
```

```
crossplane beta render xr.yaml composition.yaml functions.yaml | crossplane beta validate crossplane.yaml -

[!] could not find CRD/XRD for: aws.platform.upbound.io/v1alpha1, Kind=XKarpenter
[!] could not find CRD/XRD for: apiextensions.crossplane.io/v1alpha1, Kind=EnvironmentConfig
Total 2 resources: 2 missing schemas, 0 success cases, 0 failure cases

```

#### with this PR
here is a repo with test-cases: https://github.com/haarchri/xp-validate-test-cases

```
crossplane beta validate crossplane.yaml composition.yaml

schemas does not exist, downloading:  xpkg.upbound.io/crossplane/crossplane:v1.16.0
schemas does not exist, downloading:  xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0
[✓] pt.fn.crossplane.io/v1beta1, Kind=Resources,  validated successfully
[✓] apiextensions.crossplane.io/v1, Kind=Composition, pat.xkarpenters.aws.platform.upbound.io validated successfully
Total 2 resources: 0 missing schemas, 2 success cases, 0 failure cases
```


```
crossplane beta render xr.yaml composition.yaml functions.yaml  | crossplane beta validate crossplane.yaml -

[!] could not find CRD/XRD for: aws.platform.upbound.io/v1alpha1, Kind=XKarpenter
[✓] apiextensions.crossplane.io/v1alpha1, Kind=EnvironmentConfig, example-environment validated successfully
Total 2 resources: 1 missing schemas, 1 success cases, 0 failure cases
```

lets validate a bad composition.yaml with patch-and-transform function step:

```
crossplane beta validate crossplane.yaml composition.yaml

schemas does not exist, downloading:  xpkg.upbound.io/crossplane/crossplane:v1.16.0
schemas does not exist, downloading:  xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0
[x] schema validation error pt.fn.crossplane.io/v1beta1, Kind=Resources,  : resources[0].name: Required value
[x] schema validation error pt.fn.crossplane.io/v1beta1, Kind=Resources,  : patches: Invalid value: "patches": unknown field: "patches"
[✓] apiextensions.crossplane.io/v1, Kind=Composition, pat.xkarpenters.aws.platform.upbound.io validated successfully
Total 2 resources: 0 missing schemas, 1 success cases, 1 failure cases
crossplane: error: cannot validate resources: could not validate all resources
```


<!--
Briefly describe what this pull request does, and how it is covered by tests.
Be proactive - direct your reviewers' attention to anything that needs special
consideration.

We love pull requests that fix an open issue. If yours does, use the below line
to indicate which issue it fixes, for example "Fixes #500".
-->

Implementes parts of #5861
Fixes: #5859 #5094

I have: <!--You MUST either [x] check or [ ] ~strike through~ every item.-->

- [x] Read and followed Crossplane's [contribution process].
- [x] Run `earthly +reviewable` to ensure this PR is ready for review.
- [x] Added or updated unit tests.
- [x] Added or updated e2e tests.
- [x] Linked a PR or a [docs tracking issue] to [document this change].
- [x] Added `backport release-x.y` labels to auto-backport this PR.

Need help with this checklist? See the [cheat sheet].

[contribution process]: https://github.com/crossplane/crossplane/tree/master/contributing
[docs tracking issue]: https://github.com/crossplane/docs/issues/new
[document this change]: https://docs.crossplane.io/contribute/contribute
[cheat sheet]: https://github.com/crossplane/crossplane/tree/master/contributing#checklist-cheat-sheet



Patch:
diff --git a/cmd/crank/beta/validate/cmd.go b/cmd/crank/beta/validate/cmd.go
index 642ff7826..783268dce 100644
--- a/cmd/crank/beta/validate/cmd.go
+++ b/cmd/crank/beta/validate/cmd.go
@@ -18,6 +18,7 @@ limitations under the License.
 package validate
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
@@ -27,6 +28,9 @@ import (
 
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
 	"github.com/crossplane/crossplane-runtime/pkg/logging"
+
+	"github.com/crossplane/crossplane/internal/version"
+	"github.com/crossplane/crossplane/internal/xpkg"
 )
 
 // Cmd arguments and flags for render subcommand.
@@ -36,9 +40,10 @@ type Cmd struct {
 	Resources  string `arg:"" help:"Resources source which can be a file, directory, or '-' for standard input."`
 
 	// Flags. Keep them in alphabetical order.
-	CacheDir           string `default:"~/.crossplane/cache"                                        help:"Absolute path to the cache directory where downloaded schemas are stored."`
+	CacheDir           string `default:"~/.crossplane/cache"                                                       help:"Absolute path to the cache directory where downloaded schemas are stored."`
 	CleanCache         bool   `help:"Clean the cache directory before downloading package schemas."`
 	SkipSuccessResults bool   `help:"Skip printing success results."`
+	CrossplaneImage    string `help:"Specify the Crossplane image to be used for validating the built-in schemas."`
 
 	fs afero.Fs
 }
@@ -46,15 +51,15 @@ type Cmd struct {
 // Help prints out the help for the validate command.
 func (c *Cmd) Help() string {
 	return `
-This command validates the provided Crossplane resources against the schemas of the provided extensions like XRDs, 
-CRDs, providers, and configurations. The output of the "crossplane render" command can be 
+This command validates the provided Crossplane resources against the schemas of the provided extensions like XRDs,
+CRDs, providers, and configurations. The output of the "crossplane render" command can be
 piped to this validate command in order to rapidly validate on the outputs of the composition development experience.
 
 If providers or configurations are provided as extensions, they will be downloaded and loaded as CRDs before performing
-validation. If the cache directory is not provided, it will default to "~/.crossplane/cache". 
+validation. If the cache directory is not provided, it will default to "~/.crossplane/cache".
 Cache directory can be cleaned before downloading schemas by setting the "clean-cache" flag.
 
-All validation is performed offline locally using the Kubernetes API server's validation library, so it does not require 
+All validation is performed offline locally using the Kubernetes API server's validation library, so it does not require
 any Crossplane instance or control plane to be running or configured.
 
 Examples:
@@ -62,10 +67,13 @@ Examples:
   # Validate all resources in the resources.yaml file against the extensions in the extensions.yaml file
   crossplane beta validate extensions.yaml resources.yaml
 
-  # Validate all resources in the resourceDir folder against the extensions in the extensionsDir folder and skip 
+  # Validate all resources in the resources.yaml file against the extensions in the extensions.yaml file using a specific Crossplane image version
+  crossplane beta validate extensions.yaml resources.yaml --crossplane-image=xpkg.upbound.io/crossplane/crossplane:v1.16.0
+
+  # Validate all resources in the resourceDir folder against the extensions in the extensionsDir folder and skip
   # success logs
   crossplane beta validate extensionsDir/ resourceDir/ --skip-success-results
- 
+
   # Validate the output of the render command against the extensions in the extensionsDir folder
   crossplane render xr.yaml composition.yaml func.yaml --include-full-xr | crossplane beta validate extensionsDir/ -
 
@@ -87,6 +95,10 @@ func (c *Cmd) Run(k *kong.Context, _ logging.Logger) error {
 		return errors.New("cannot use stdin for both extensions and resources")
 	}
 
+	if len(c.CrossplaneImage) < 1 {
+		c.CrossplaneImage = fmt.Sprintf("%s/crossplane/crossplane:%s", xpkg.DefaultRegistry, version.New().GetVersionString())
+	}
+
 	// Load all extensions
 	extensionLoader, err := NewLoader(c.Extensions)
 	if err != nil {
@@ -114,7 +126,7 @@ func (c *Cmd) Run(k *kong.Context, _ logging.Logger) error {
 		c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:])
 	}
 
-	m := NewManager(c.CacheDir, c.fs, k.Stdout)
+	m := NewManager(c.CacheDir, c.fs, k.Stdout, WithCrossplaneImage(c.CrossplaneImage))
 
 	// Convert XRDs/CRDs to CRDs and add package dependencies
 	if err := m.PrepExtensions(extensions); err != nil {
diff --git a/cmd/crank/beta/validate/image.go b/cmd/crank/beta/validate/image.go
index 8cc0e50e1..c33408d2f 100644
--- a/cmd/crank/beta/validate/image.go
+++ b/cmd/crank/beta/validate/image.go
@@ -17,7 +17,12 @@ limitations under the License.
 package validate
 
 import (
+	"archive/tar"
 	"fmt"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
 	"sort"
 	"strings"
 
@@ -29,25 +34,59 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
 )
 
+const maxDecompressedSize = 200 * 1024 * 1024 // 200 MB
+
 // ImageFetcher defines an interface for fetching images.
 type ImageFetcher interface {
 	FetchBaseLayer(image string) (*conregv1.Layer, error)
+	FetchImage(image string) ([]conregv1.Layer, error)
 }
 
 // Fetcher implements the ImageFetcher interface.
 type Fetcher struct{}
 
+// FetchImage pulls the full image and extracts the CRDs folder to fetch .yaml files.
+func (f *Fetcher) FetchImage(image string) ([]conregv1.Layer, error) {
+	image, err := prepareImageReference(image)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to prepare image reference")
+	}
+
+	// Pull the image
+	img, err := crane.Pull(image)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to pull image")
+	}
+
+	// Extract the layers of the image into the temporary directory
+	layers, err := img.Layers()
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to get image layers")
+	}
+
+	return layers, nil
+}
+
+// ErrBaseLayerNotFound is returned when the base layer of the image could not be found.
+type ErrBaseLayerNotFound struct {
+	error
+}
+
+// NewErrBaseLayerNotFound returns a new ErrBaseLayerNotFound error.
+func NewErrBaseLayerNotFound(image string) error {
+	return &ErrBaseLayerNotFound{errors.Errorf("no base layer found for image %s", image)}
+}
+
+// IsErrBaseLayerNotFound checks if the error is of type ErrBaseLayerNotFound.
+func IsErrBaseLayerNotFound(err error) bool {
+	return errors.Is(err, &ErrBaseLayerNotFound{})
+}
+
 // FetchBaseLayer fetches the base layer of the image which contains the 'package.yaml' file.
 func (f *Fetcher) FetchBaseLayer(image string) (*conregv1.Layer, error) {
-	if strings.Contains(image, "@") {
-		// Strip the digest before fetching the image
-		image = strings.SplitN(image, "@", 2)[0]
-	} else if strings.Contains(image, ":") {
-		var err error
-		image, err = findImageTagForVersionConstraint(image)
-		if err != nil {
-			return nil, errors.Wrapf(err, "cannot find image tag for version constraint")
-		}
+	image, err := prepareImageReference(image)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to prepare image reference")
 	}
 
 	cBytes, err := crane.Config(image)
@@ -72,6 +111,9 @@ func (f *Fetcher) FetchBaseLayer(image string) (*conregv1.Layer, error) {
 			label = v // e.g.: io.crossplane.xpkg:sha256:0158764f65dc2a68728fdffa6ee6f2c9ef158f2dfed35abbd4f5bef8973e4b59
 		}
 	}
+	if label == "" {
+		return nil, NewErrBaseLayerNotFound(image)
+	}
 
 	lDigest := strings.SplitN(label, ":", 2)[1] // e.g.: sha256:0158764f65dc2a68728fdffa6ee6f2c9ef158f2dfed35abbd4f5bef8973e4b59
 
@@ -165,3 +207,129 @@ func extractPackageContent(layer conregv1.Layer) ([][]byte, []byte, error) {
 	// the last obj is not yaml, so we need to remove it
 	return objs[1 : len(objs)-1], []byte(metaStr), nil
 }
+
+func extractPackageCRDs(layers []conregv1.Layer) ([][]byte, error) {
+	// Create a temporary directory to extract the files
+	tmpDir, err := os.MkdirTemp("", "image-extract")
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to create temporary directory")
+	}
+	defer func() {
+		if err := os.RemoveAll(tmpDir); err != nil {
+			log.Printf("Failed to remove temporary directory: %v", err)
+		}
+	}()
+
+	for _, layer := range layers {
+		if err := extractLayer(layer, tmpDir); err != nil {
+			return nil, errors.Wrapf(err, "failed to extract layer")
+		}
+	}
+
+	// Search for .yaml files in the "crds" directory
+	var yamlFiles [][]byte
+	err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		// Check if the file is in the "crds" directory and has a .yaml extension
+		if strings.Contains(path, "/crds/") && strings.HasSuffix(info.Name(), ".yaml") {
+			content, err := os.ReadFile(filepath.Clean(path))
+			if err != nil {
+				return errors.Wrapf(err, "failed to read file: %s", path)
+			}
+			yamlFiles = append(yamlFiles, content)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to walk through extracted files")
+	}
+
+	return yamlFiles, nil
+}
+
+// extractLayer extracts the contents of a layer to the specified directory.
+func extractLayer(layer conregv1.Layer, destDir string) error { //nolint:gocognit // no extra func
+	r, err := layer.Uncompressed()
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := r.Close(); err != nil {
+			log.Printf("Failed to close reader: %v", err)
+		}
+	}()
+
+	tr := tar.NewReader(r)
+
+	for {
+		hdr, err := tr.Next()
+		if errors.Is(err, io.EOF) {
+			break // End of tar archive
+		}
+		if err != nil {
+			return err
+		}
+
+		// Resolve the target path
+		target := filepath.Join(destDir, filepath.Clean(hdr.Name))
+		targetPath, err := filepath.Abs(target)
+		if err != nil {
+			return errors.Wrap(err, "failed to get absolute path")
+		}
+
+		// Skip entries that are the same as the destination directory or just "./"
+		if targetPath == filepath.Clean(destDir) || hdr.Name == "./" {
+			continue
+		}
+
+		// Ensure the target path is within the destination directory
+		if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) {
+			return errors.Errorf("invalid file path: %s", targetPath)
+		}
+
+		// Create the file or directory
+		switch hdr.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(targetPath, 0o750); err != nil {
+				return errors.Wrapf(err, "cannot create directory: %s", targetPath)
+			}
+		case tar.TypeReg:
+			dir := filepath.Dir(targetPath)
+			if err := os.MkdirAll(dir, 0o750); err != nil {
+				return errors.Wrapf(err, "cannot create directory: %s", dir)
+			}
+			file, err := os.Create(filepath.Clean(targetPath))
+			if err != nil {
+				return errors.Wrapf(err, "cannot create file: %s", targetPath)
+			}
+			defer func() {
+				if err := file.Close(); err != nil {
+					log.Printf("Failed to close file: %v", err)
+				}
+			}()
+
+			// Limit the decompression size to avoid DoS attacks
+			limitedReader := io.LimitReader(tr, maxDecompressedSize)
+			if _, err := io.Copy(file, limitedReader); err != nil {
+				return errors.Wrapf(err, "cannot decompress file: %s", targetPath)
+			}
+		}
+	}
+
+	return nil
+}
+
+// prepareImageReference prepares the image reference by stripping the digest or resolving the tag if necessary.
+func prepareImageReference(image string) (string, error) {
+	if strings.Contains(image, "@") {
+		return strings.SplitN(image, "@", 2)[0], nil
+	}
+	if strings.Contains(image, ":") {
+		return findImageTagForVersionConstraint(image)
+	}
+	return image, nil
+}
diff --git a/cmd/crank/beta/validate/loader.go b/cmd/crank/beta/validate/loader.go
index 6b610779b..f186bc83f 100644
--- a/cmd/crank/beta/validate/loader.go
+++ b/cmd/crank/beta/validate/loader.go
@@ -18,14 +18,18 @@ package validate
 
 import (
 	"bufio"
+	"encoding/json"
 	"io"
 	"os"
 	"path/filepath"
 
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/yaml"
 
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
+
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 )
 
 // Loader interface defines the contract for different input sources.
@@ -152,6 +156,31 @@ func streamToUnstructured(stream [][]byte) ([]*unstructured.Unstructured, error)
 		if err := yaml.Unmarshal(y, u); err != nil {
 			return nil, errors.Wrap(err, "cannot parse YAML manifest")
 		}
+		// extract pipeline input resources
+		if u.GetObjectKind().GroupVersionKind() == v1.CompositionGroupVersionKind {
+			// Convert the unstructured resource to a Composition
+			var comp v1.Composition
+			err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &comp)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to convert unstructured to Composition")
+			}
+			// Iterate over each step in the pipeline
+			for _, step := range comp.Spec.Pipeline {
+				// Create a new resource based on the input (we can use it for validation)
+				if step.Input != nil && step.Input.Raw != nil {
+					var inputMap map[string]interface{}
+					err := json.Unmarshal(step.Input.Raw, &inputMap)
+					if err != nil {
+						return nil, errors.Wrap(err, "failed to unmarshal raw input")
+					}
+					newInputResource := &unstructured.Unstructured{
+						Object: inputMap,
+					}
+					// Add the input as new manifest to the manifests slice that we can validate
+					manifests = append(manifests, newInputResource)
+				}
+			}
+		}
 		manifests = append(manifests, u)
 	}
 
diff --git a/cmd/crank/beta/validate/manager.go b/cmd/crank/beta/validate/manager.go
index 5dae3ce7a..2654d5b56 100644
--- a/cmd/crank/beta/validate/manager.go
+++ b/cmd/crank/beta/validate/manager.go
@@ -53,8 +53,21 @@ type Manager struct {
 	confs map[string]*metav1.Configuration // Configuration images
 }
 
+// Option defines an option for the Manager.
+type Option func(*Manager)
+
+// WithCrossplaneImage sets the Crossplane image to use for fetching CRDs.
+func WithCrossplaneImage(image string) Option {
+	return func(m *Manager) {
+		if image == "" {
+			return
+		}
+		m.deps[image] = true
+	}
+}
+
 // NewManager returns a new Manager.
-func NewManager(cacheDir string, fs afero.Fs, w io.Writer) *Manager {
+func NewManager(cacheDir string, fs afero.Fs, w io.Writer, opts ...Option) *Manager {
 	m := &Manager{}
 
 	m.cache = &LocalCache{
@@ -68,6 +81,10 @@ func NewManager(cacheDir string, fs afero.Fs, w io.Writer) *Manager {
 	m.deps = make(map[string]bool)
 	m.confs = make(map[string]*metav1.Configuration)
 
+	for _, opt := range opts {
+		opt(m)
+	}
+
 	return m
 }
 
@@ -248,23 +265,37 @@ func (m *Manager) cacheDependencies() error {
 			continue
 		}
 
-		if _, err := fmt.Fprintln(m.writer, "package schemas does not exist, downloading: ", image); err != nil {
+		if _, err := fmt.Fprintln(m.writer, "schemas does not exist, downloading: ", image); err != nil {
 			return errors.Wrapf(err, errWriteOutput)
 		}
 
+		var schemas [][]byte
+		// handling for packages
 		layer, err := m.fetcher.FetchBaseLayer(image)
-		if err != nil {
+		switch {
+		case IsErrBaseLayerNotFound(err):
+			// We fall back to fetching the image if the base layer is not found
+			layers, err := m.fetcher.FetchImage(image)
+			if err != nil {
+				return errors.Wrapf(err, "cannot extract crds")
+			}
+			schemas, err = extractPackageCRDs(layers)
+			if err != nil {
+				return errors.Wrapf(err, "cannot find crds")
+			}
+		case err != nil:
 			return errors.Wrapf(err, "cannot download package %s", image)
-		}
-
-		schemas, _, err := extractPackageContent(*layer)
-		if err != nil {
-			return errors.Wrapf(err, "cannot extract package file and meta")
+		default:
+			schemas, _, err = extractPackageContent(*layer)
+			if err != nil {
+				return errors.Wrapf(err, "cannot extract package file and meta")
+			}
 		}
 
 		if err := m.cache.Store(schemas, path); err != nil {
 			return errors.Wrapf(err, "cannot store base layer")
 		}
+		return nil
 	}
 
 	return nil


Test patch:
diff --git a/cmd/crank/beta/validate/loader_test.go b/cmd/crank/beta/validate/loader_test.go
index d45b72400..e4f2e5985 100644
--- a/cmd/crank/beta/validate/loader_test.go
+++ b/cmd/crank/beta/validate/loader_test.go
@@ -213,7 +213,96 @@ func TestStreamToUnstructured(t *testing.T) {
 				err:       cmpopts.AnyError,
 			},
 		},
+		"CompositionWithPipelineResources": {
+			reason: "Successfully parse Composition with pipeline input resources to unstructured resources",
+			args: args{
+				stream: [][]byte{
+					[]byte(`
+apiVersion: apiextensions.crossplane.io/v1
+kind: Composition
+metadata:
+  name: example-composition
+spec:
+  compositeTypeRef:
+    apiVersion: example.crossplane.io/v1alpha1
+    kind: ExampleComposite
+  pipeline:
+    - step: patch-and-transform
+      functionRef:
+        name: example-function
+      input:
+        apiVersion: pt.fn.crossplane.io/v1beta1
+        kind: Resources
+        resources:
+          - name: instanceNodeRole
+            base:
+              apiVersion: iam.aws.upbound.io/v1beta1
+              kind: Role
+              spec: {}
+`),
+				},
+			},
+			want: want{
+				resources: []*unstructured.Unstructured{
+					{
+						Object: map[string]interface{}{
+							"apiVersion": "pt.fn.crossplane.io/v1beta1",
+							"kind":       "Resources",
+							"resources": []interface{}{
+								map[string]interface{}{
+									"name": "instanceNodeRole",
+									"base": map[string]interface{}{
+										"apiVersion": "iam.aws.upbound.io/v1beta1",
+										"kind":       "Role",
+										"spec":       map[string]interface{}{},
+									},
+								},
+							},
+						},
+					},
+					{
+						Object: map[string]interface{}{
+							"apiVersion": "apiextensions.crossplane.io/v1",
+							"kind":       "Composition",
+							"metadata": map[string]interface{}{
+								"name": "example-composition",
+							},
+							"spec": map[string]interface{}{
+								"compositeTypeRef": map[string]interface{}{
+									"apiVersion": "example.crossplane.io/v1alpha1",
+									"kind":       "ExampleComposite",
+								},
+								"pipeline": []interface{}{
+									map[string]interface{}{
+										"step": "patch-and-transform",
+										"functionRef": map[string]interface{}{
+											"name": "example-function",
+										},
+										"input": map[string]interface{}{
+											"apiVersion": "pt.fn.crossplane.io/v1beta1",
+											"kind":       "Resources",
+											"resources": []interface{}{
+												map[string]interface{}{
+													"name": "instanceNodeRole",
+													"base": map[string]interface{}{
+														"apiVersion": "iam.aws.upbound.io/v1beta1",
+														"kind":       "Role",
+														"spec":       map[string]interface{}{},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				err: nil,
+			},
+		},
 	}
+
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
 			got, err := streamToUnstructured(tc.args.stream)
diff --git a/cmd/crank/beta/validate/manager_test.go b/cmd/crank/beta/validate/manager_test.go
index 5b8ac70d1..fd35c2161 100644
--- a/cmd/crank/beta/validate/manager_test.go
+++ b/cmd/crank/beta/validate/manager_test.go
@@ -236,7 +236,7 @@ func TestConfigurationTypeSupport(t *testing.T) {
 
 		m := NewManager("", fs, w)
 		t.Run(name, func(t *testing.T) {
-			m.fetcher = &MockFetcher{tc.args.fetchMock}
+			m.fetcher = &MockFetcher{tc.args.fetchMock, nil}
 			err := m.PrepExtensions(tc.args.extensions)
 
 			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
@@ -263,6 +263,7 @@ func TestAddDependencies(t *testing.T) {
 	cd2 := static.NewLayer(configDep2Yaml, types.OCILayer)
 	pd1 := static.NewLayer(providerYaml, types.OCILayer)
 	fd1 := static.NewLayer(funcYaml, types.OCILayer)
+	crossplaneLayer := static.NewLayer([]byte("crossplane content"), types.OCILayer)
 
 	fetchMockFunc := func(image string) (*conregv1.Layer, error) {
 		switch image {
@@ -274,14 +275,26 @@ func TestAddDependencies(t *testing.T) {
 			return &pd1, nil
 		case "function-dep-1:v1.3.0":
 			return &fd1, nil
+		case "xpkg.upbound.io/crossplane/crossplane:v1.16.0":
+			return &crossplaneLayer, nil
+		default:
+			return nil, fmt.Errorf("unknown image: %s", image)
+		}
+	}
+
+	fetchImageMockFunc := func(image string) ([]conregv1.Layer, error) {
+		switch image {
+		case "xpkg.upbound.io/crossplane/crossplane:v1.16.0":
+			return []conregv1.Layer{crossplaneLayer}, nil
 		default:
 			return nil, fmt.Errorf("unknown image: %s", image)
 		}
 	}
 
 	type args struct {
-		extensions []*unstructured.Unstructured
-		fetchMock  func(image string) (*conregv1.Layer, error)
+		extensions      []*unstructured.Unstructured
+		fetcher         ImageFetcher
+		crossplaneImage string
 	}
 	type want struct {
 		confs int
@@ -293,14 +306,48 @@ func TestAddDependencies(t *testing.T) {
 		args   args
 		want   want
 	}{
-		"SuccessfulDependenciesAddition": {
+		"SuccessfulDependenciesAdditionWithCrossplaneImage": {
 			// config-dep-1
 			// └─►config-dep-2
 			//   ├─►provider-dep-1
 			//   └─►function-dep-1
-			reason: "All dependencies should be successfully fetched and added",
+			// └─►crossplaneImage (xpkg.upbound.io/crossplane/crossplane:v1.16.0)
+			reason: "All dependencies including the crossplane image should be successfully fetched and added",
 			args: args{
-				fetchMock: fetchMockFunc,
+				fetcher: &MockFetcher{
+					fetchBaseLayer: fetchMockFunc,
+					fetchImage:     fetchImageMockFunc,
+				},
+				extensions: []*unstructured.Unstructured{
+					{
+						Object: map[string]interface{}{
+							"apiVersion": "pkg.crossplane.io/v1alpha1",
+							"kind":       "Configuration",
+							"metadata": map[string]interface{}{
+								"name": "config-dep-1",
+							},
+							"spec": map[string]interface{}{
+								"package": "config-dep-1:v1.3.0",
+							},
+						},
+					},
+				},
+				crossplaneImage: "xpkg.upbound.io/crossplane/crossplane:v1.16.0",
+			},
+			want: want{
+				confs: 2, // 1 Base configuration (config-dep-1), 1 child configuration (config-dep-2)
+				deps:  5, // 2 configurations (config-dep-1, config-dep-2), 1 provider (provider-dep-1), 1 function (function-dep-1), 1 crossplaneImage
+				err:   nil,
+			},
+		},
+		"SuccessfulDependenciesAdditionWithoutCrossplaneImage": {
+			// config-dep-1
+			// └─►config-dep-2
+			//   ├─►provider-dep-1
+			//   └─►function-dep-1
+			reason: "All dependencies should be successfully fetched and added without specifying a crossplane image",
+			args: args{
+				fetcher: &MockFetcher{fetchMockFunc, nil},
 				extensions: []*unstructured.Unstructured{
 					{
 						Object: map[string]interface{}{
@@ -315,6 +362,7 @@ func TestAddDependencies(t *testing.T) {
 						},
 					},
 				},
+				crossplaneImage: "",
 			},
 			want: want{
 				confs: 2, // 1 Base configuration (config-dep-1), 1 child configuration (config-dep-2)
@@ -328,10 +376,10 @@ func TestAddDependencies(t *testing.T) {
 			fs := afero.NewMemMapFs()
 			w := &bytes.Buffer{}
 
-			m := NewManager("", fs, w)
+			m := NewManager("", fs, w, WithCrossplaneImage(tc.args.crossplaneImage))
 			_ = m.PrepExtensions(tc.args.extensions)
 
-			m.fetcher = &MockFetcher{tc.args.fetchMock}
+			m.fetcher = tc.args.fetcher
 			err := m.addDependencies(m.confs)
 
 			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
@@ -348,9 +396,17 @@ func TestAddDependencies(t *testing.T) {
 }
 
 type MockFetcher struct {
-	fetch func(image string) (*conregv1.Layer, error)
+	fetchBaseLayer func(image string) (*conregv1.Layer, error)
+	fetchImage     func(image string) ([]conregv1.Layer, error)
 }
 
 func (m *MockFetcher) FetchBaseLayer(image string) (*conregv1.Layer, error) {
-	return m.fetch(image)
+	return m.fetchBaseLayer(image)
+}
+
+func (m *MockFetcher) FetchImage(image string) ([]conregv1.Layer, error) {
+	if m.fetchImage != nil {
+		return m.fetchImage(image)
+	}
+	return nil, nil // or a sensible default/mock behavior
 }