Repository: crossplane/crossplane

Issue:
Validate should honor default-values
<!--
Thank you for helping to improve Crossplane!

Please be sure to search for open issues before raising a new one. We use issues
for bug reports and feature requests. Please find us at https://slack.crossplane.io
for questions, support, and discussion.
-->

### What problem are you facing?

When running `crossplane betat validate`, i found that it fails for fields with required values being empty even though they have a default value set at the same time:
```
[x] schema validation error aws.platform.upbound.io/v1alpha1, Kind=XNetwork, configuration-aws-network-kcl : spec.parameters.deletionPolicy: Required value
[x] schema validation error aws.platform.upbound.io/v1alpha1, Kind=XNetwork, configuration-aws-network-kcl : spec.parameters.providerConfigName: Required value
[x] schema validation error aws.platform.upbound.io/v1alpha1, Kind=XNetwork, configuration-aws-network-kcl : spec.parameters.deletionPolicy: Required value
[x] schema validation error aws.platform.upbound.io/v1alpha1, Kind=XNetwork, configuration-aws-network-kcl : spec.parameters.providerConfigName: Required value
```

### How could Crossplane help solve your problem?

It would be nice that for a required value crossplane would also check if there is a default set because even though it's right that a required value is not set in the output, it will be set on api-level and validation will closer match what happens in reality.

Discussion:
@github-actions: Crossplane does not currently have enough maintainers to address every issue and pull request. This issue has been automatically marked as `stale` because it has had no activity in the last 90 days. It will be closed in 14 days if no further activity occurs. Leaving a comment **starting with** `/fresh` will mark this issue as not stale.@pfroelke: I'm facing the same issue. Is a fix planned for that?@haarchri: will be implemented with: https://github.com/crossplane/crossplane/pull/6519

Solution patch:
diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml
index 181dad4b5..6339e5285 100644
--- a/.github/workflows/renovate.yml
+++ b/.github/workflows/renovate.yml
@@ -33,7 +33,7 @@ jobs:
 
       - name: Get token
         id: get-github-app-token
-        uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1
+        uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
         with:
           app-id: ${{ secrets.RENOVATE_GITHUB_APP_ID }}
           private-key: ${{ secrets.RENOVATE_GITHUB_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index d79d0b391..367939f22 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -15,7 +15,7 @@ jobs:
   stale:
     runs-on: ubuntu-22.04
     steps:
-    - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9
+    - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
       with:
         # This action uses ~2 operations per stale issue per run to determine
         # whether it's still stale. It also uses 2-3 operations to mark an issue
diff --git a/.golangci.yml b/.golangci.yml
index dc1aa045f..0e270438d 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -10,12 +10,8 @@ output:
 linters:
   enable-all: true
   fast: false
-  
-  disable:
-    # These linters are all deprecated. We disable them explicitly to avoid the
-    # linter logging deprecation warnings.
-    - execinquery
 
+  disable:
     # These are linters we'd like to enable, but that will be labor intensive to
     # make existing code compliant.
     - wrapcheck
@@ -23,7 +19,6 @@ linters:
     - testpackage
     - paralleltest
     - nilnil
-    - gomnd
 
     # Below are linters that lint for things we don't value. Each entry below
     # this line must have a comment explaining the rationale.
@@ -31,7 +26,7 @@ linters:
     # These linters add whitespace in an attempt to make code more readable.
     # This isn't a widely accepted Go best practice, and would be laborious to
     # apply to existing code.
-    - wsl 
+    - wsl
     - nlreturn
 
     # Warns about uses of fmt.Sprintf that are less performant than alternatives
@@ -83,7 +78,7 @@ linters:
     # Warns about returning interfaces rather than concrete types. We do think
     # it's best to avoid returning interfaces where possible. However, at the
     # time of writing enabling this linter would only catch the (many) cases
-    # where we must return an interface. 
+    # where we must return an interface.
     - ireturn
 
     # Warns about returning named variables. We do think it's best to avoid
@@ -189,7 +184,7 @@ linters-settings:
   nolintlint:
     require-explanation: true
     require-specific: true
-  
+
   depguard:
     rules:
       no_third_party_test_libraries:
@@ -206,7 +201,7 @@ linters-settings:
 
   interfacebloat:
     max: 5
-  
+
   tagliatelle:
     case:
       rules:
@@ -237,7 +232,7 @@ issues:
       text: "(unnamedResult|exitAfterDefer)"
       linters:
         - gocritic
-    
+
     # It's idiomatic to register Kubernetes types with a package scoped
     # SchemeBuilder using an init function.
     - path: apis/
@@ -282,7 +277,7 @@ issues:
       linters:
       - gosec
       - gas
-    
+
     # Some k8s dependencies do not have JSON tags on all fields in structs.
     - path: k8s.io/
       linters:
diff --git a/cmd/crank/beta/validate/cmd.go b/cmd/crank/beta/validate/cmd.go
index 62f866d86..332e034a1 100644
--- a/cmd/crank/beta/validate/cmd.go
+++ b/cmd/crank/beta/validate/cmd.go
@@ -36,8 +36,8 @@ import (
 // Cmd arguments and flags for render subcommand.
 type Cmd struct {
 	// Arguments.
-	Extensions string `arg:"" help:"Extensions source which can be a file, directory, or '-' for standard input."`
-	Resources  string `arg:"" help:"Resources source which can be a file, directory, or '-' for standard input."`
+	Extensions string `arg:"" help:"Extension sources as a comma-separated list of files, directories, or '-' for standard input."`
+	Resources  string `arg:"" help:"Resource sources as a comma-separated list of files, directories, 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." predictor:"directory"`
@@ -67,6 +67,9 @@ 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 crossplane.yaml file and extensionsDir folder
+  crossplane beta validate crossplane.yaml,extensionsDir/ resourceDir/
+
   # 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.crossplane.io/crossplane/crossplane:v1.20.0
 
diff --git a/cmd/crank/beta/validate/image.go b/cmd/crank/beta/validate/image.go
index 2b486b7a8..b638bb58a 100644
--- a/cmd/crank/beta/validate/image.go
+++ b/cmd/crank/beta/validate/image.go
@@ -28,6 +28,7 @@ import (
 
 	"github.com/Masterminds/semver"
 	"github.com/google/go-containerregistry/pkg/crane"
+	"github.com/google/go-containerregistry/pkg/name"
 	conregv1 "github.com/google/go-containerregistry/pkg/v1"
 	"k8s.io/apimachinery/pkg/util/yaml"
 
@@ -95,6 +96,12 @@ func (f *Fetcher) FetchBaseLayer(image string) (*conregv1.Layer, error) {
 		return nil, errors.Wrap(err, "failed to prepare image reference")
 	}
 
+	ref, err := name.ParseReference(image)
+	if err != nil {
+		return nil, errors.Wrapf(err, "invalid image reference: %s", image)
+	}
+	repoName := ref.Context().Name()
+
 	cBytes, err := crane.Config(image)
 	if err != nil {
 		return nil, errors.Wrapf(err, "cannot get config")
@@ -123,7 +130,7 @@ func (f *Fetcher) FetchBaseLayer(image string) (*conregv1.Layer, error) {
 
 	lDigest := strings.SplitN(label, ":", 2)[1] // e.g.: sha256:0158764f65dc2a68728fdffa6ee6f2c9ef158f2dfed35abbd4f5bef8973e4b59
 
-	ll, err := crane.PullLayer(fmt.Sprintf(refFmt, image, lDigest))
+	ll, err := crane.PullLayer(fmt.Sprintf(refFmt, repoName, lDigest))
 	if err != nil {
 		return nil, errors.Wrapf(err, "cannot pull base layer %s", lDigest)
 	}
@@ -332,7 +339,7 @@ func extractLayer(layer conregv1.Layer, destDir string) error { //nolint:gocogni
 // 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
+		return image, nil
 	}
 	if strings.Contains(image, ":") {
 		return findImageTagForVersionConstraint(image)
diff --git a/cmd/crank/beta/validate/loader.go b/cmd/crank/beta/validate/loader.go
index f186bc83f..4139f0d07 100644
--- a/cmd/crank/beta/validate/loader.go
+++ b/cmd/crank/beta/validate/loader.go
@@ -19,9 +19,11 @@ package validate
 import (
 	"bufio"
 	"encoding/json"
+	"fmt"
 	"io"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -39,6 +41,26 @@ type Loader interface {
 
 // NewLoader returns a Loader based on the input source.
 func NewLoader(input string) (Loader, error) {
+	sources := strings.Split(input, ",")
+
+	if len(sources) == 1 {
+		return newLoader(sources[0])
+	}
+
+	loaders := make([]Loader, 0, len(sources))
+
+	for _, source := range sources {
+		loader, err := newLoader(source)
+		if err != nil {
+			return nil, errors.Wrap(err, fmt.Sprintf("cannot create loader for %q", source))
+		}
+		loaders = append(loaders, loader)
+	}
+
+	return &MultiLoader{loaders: loaders}, nil
+}
+
+func newLoader(input string) (Loader, error) {
 	if input == "-" {
 		return &StdinLoader{}, nil
 	}
@@ -55,6 +77,27 @@ func NewLoader(input string) (Loader, error) {
 	return &FileLoader{path: input}, nil
 }
 
+// MultiLoader implements the Loader interface for reading from multiple other loaders.
+type MultiLoader struct {
+	loaders []Loader
+}
+
+// Load reads and merges the content from the loaders.
+func (m *MultiLoader) Load() ([]*unstructured.Unstructured, error) {
+	var manifests []*unstructured.Unstructured
+
+	for i, loader := range m.loaders {
+		output, err := loader.Load()
+		if err != nil {
+			return nil, errors.Wrap(err, fmt.Sprintf("cannot load source at position %d", i))
+		}
+
+		manifests = append(manifests, output...)
+	}
+
+	return manifests, nil
+}
+
 // StdinLoader implements the Loader interface for reading from stdin.
 type StdinLoader struct{}
 
diff --git a/cmd/crank/beta/validate/manager.go b/cmd/crank/beta/validate/manager.go
index 9229daebf..d097befff 100644
--- a/cmd/crank/beta/validate/manager.go
+++ b/cmd/crank/beta/validate/manager.go
@@ -20,6 +20,7 @@ import (
 	"fmt"
 	"io"
 
+	regv1 "github.com/google/go-containerregistry/pkg/v1"
 	"github.com/spf13/afero"
 	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -207,7 +208,7 @@ func (m *Manager) CacheAndLoad(cleanCache bool) error {
 	return m.PrepExtensions(schemas)
 }
 
-func (m *Manager) addDependencies(confs map[string]*metav1.Configuration) error {
+func (m *Manager) addDependencies(confs map[string]*metav1.Configuration) error { //nolint:gocognit // no extra func
 	if len(confs) == 0 {
 		return nil
 	}
@@ -249,7 +250,13 @@ func (m *Manager) addDependencies(confs map[string]*metav1.Configuration) error
 			}
 
 			if len(image) > 0 {
-				image = fmt.Sprintf(imageFmt, image, dep.Version)
+				if _, err := regv1.NewHash(dep.Version); err == nil {
+					// digest
+					image = fmt.Sprintf(refFmt, image, dep.Version)
+				} else {
+					// tag
+					image = fmt.Sprintf(imageFmt, image, dep.Version)
+				}
 				m.deps[image] = true
 
 				if _, ok := m.confs[image]; !ok && dep.Configuration != nil {
@@ -265,7 +272,7 @@ func (m *Manager) addDependencies(confs map[string]*metav1.Configuration) error
 
 func (m *Manager) cacheDependencies() error {
 	if err := m.cache.Init(); err != nil {
-		return errors.Wrapf(err, "cannot initialize  cache directory")
+		return errors.Wrapf(err, "cannot initialize cache directory")
 	}
 
 	for image := range m.deps {
diff --git a/cmd/crank/beta/validate/validate.go b/cmd/crank/beta/validate/validate.go
index 6ee4ee886..e65c3e9fa 100644
--- a/cmd/crank/beta/validate/validate.go
+++ b/cmd/crank/beta/validate/validate.go
@@ -25,6 +25,7 @@ import (
 	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
 	"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
+	structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting"
 	"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	runtimeschema "k8s.io/apimachinery/pkg/runtime/schema"
@@ -100,7 +101,7 @@ func SchemaValidation(resources []*unstructured.Unstructured, crds []*extv1.Cust
 
 	failure, missingSchemas := 0, 0
 
-	for i, r := range resources {
+	for _, r := range resources {
 		gvk := r.GetObjectKind().GroupVersionKind()
 		sv, ok := schemaValidators[gvk]
 		s := structurals[gvk] // if we have a schema validator, we should also have a structural
@@ -113,6 +114,12 @@ func SchemaValidation(resources []*unstructured.Unstructured, crds []*extv1.Cust
 			continue
 		}
 
+		if err := applyDefaults(r, gvk, crds); err != nil {
+			if _, err := fmt.Fprintf(w, "[!] failed to apply defaults for %s, %s: %v\n", r.GroupVersionKind().String(), getResourceName(r), err); err != nil {
+				return errors.Wrap(err, errWriteOutput)
+			}
+		}
+
 		rf := 0
 		re := field.ErrorList{}
 		for _, v := range sv {
@@ -126,7 +133,7 @@ func SchemaValidation(resources []*unstructured.Unstructured, crds []*extv1.Cust
 			}
 
 			celValidator := cel.NewValidator(s, true, celconfig.PerCallLimit)
-			re, _ = celValidator.Validate(context.TODO(), nil, s, resources[i].Object, nil, celconfig.PerCallLimit)
+			re, _ = celValidator.Validate(context.TODO(), nil, s, r.Object, nil, celconfig.PerCallLimit)
 			for _, e := range re {
 				rf++
 				if _, err := fmt.Fprintf(w, "[x] CEL validation error %s, %s : %s\n", r.GroupVersionKind().String(), getResourceName(r), e.Error()); err != nil {
@@ -167,3 +174,49 @@ func getResourceName(r *unstructured.Unstructured) string {
 	// fallback to composition resource name
 	return r.GetAnnotations()[composite.AnnotationKeyCompositionResourceName]
 }
+
+// applyDefaults applies default values from the CRD schema to the unstructured resource.
+func applyDefaults(resource *unstructured.Unstructured, gvk runtimeschema.GroupVersionKind, crds []*extv1.CustomResourceDefinition) error {
+	var matchingCRD *extv1.CustomResourceDefinition
+	for _, crd := range crds {
+		if crd.Spec.Group == gvk.Group && crd.Spec.Names.Kind == gvk.Kind {
+			matchingCRD = crd
+			break
+		}
+	}
+
+	if matchingCRD == nil {
+		// no CRD found for applying defaults, skip defaulting
+		return nil
+	}
+
+	var schemaProps *extv1.JSONSchemaProps
+	for _, v := range matchingCRD.Spec.Versions {
+		if v.Name == gvk.Version {
+			if v.Schema != nil && v.Schema.OpenAPIV3Schema != nil {
+				schemaProps = v.Schema.OpenAPIV3Schema
+			}
+			break
+		}
+	}
+
+	if schemaProps == nil {
+		return fmt.Errorf("no schema found for version %s in CRD %s", gvk.Version, matchingCRD.Name)
+	}
+
+	var apiExtSchema ext.JSONSchemaProps
+	err := extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(schemaProps, &apiExtSchema, nil)
+	if err != nil {
+		return fmt.Errorf("failed to convert schema: %w", err)
+	}
+
+	structural, err := schema.NewStructural(&apiExtSchema)
+	if err != nil {
+		return fmt.Errorf("failed to create structural schema: %w", err)
+	}
+
+	obj := resource.UnstructuredContent()
+	structuraldefaulting.Default(obj, structural)
+
+	return nil
+}
diff --git a/go.mod b/go.mod
index c099ae9b2..2ee97fc03 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,7 @@ module github.com/crossplane/crossplane
 
 go 1.23.8
 
-toolchain go1.23.9
+toolchain go1.23.10
 
 require (
 	dario.cat/mergo v1.0.1



Test patch:
diff --git a/cmd/crank/beta/validate/loader_test.go b/cmd/crank/beta/validate/loader_test.go
index e4f2e5985..41eb971bf 100644
--- a/cmd/crank/beta/validate/loader_test.go
+++ b/cmd/crank/beta/validate/loader_test.go
@@ -53,6 +53,178 @@ var (
 	}
 )
 
+func TestNewLoader(t *testing.T) {
+	type args struct {
+		input string
+	}
+	type want struct {
+		loader Loader
+		err    error
+	}
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"SucessWithStdin": {
+			reason: "Successfully create loader from stdin",
+			args: args{
+				input: "-",
+			},
+			want: want{
+				loader: &StdinLoader{},
+			},
+		},
+		"SucessWithFile": {
+			reason: "Successfully create loader from file",
+			args: args{
+				input: "testdata/resources.yaml",
+			},
+			want: want{
+				loader: &FileLoader{path: "testdata/resources.yaml"},
+			},
+		},
+		"SucessWithDirectory": {
+			reason: "Successfully create loader from directory",
+			args: args{
+				input: "testdata/folder",
+			},
+			want: want{
+				loader: &FolderLoader{path: "testdata/folder"},
+			},
+		},
+		"SucessWithMultiple": {
+			reason: "Successfully create loader from multiple sources",
+			args: args{
+				input: "testdata/resources.yaml,testdata/folder",
+			},
+			want: want{
+				loader: &MultiLoader{loaders: []Loader{
+					&FileLoader{path: "testdata/resources.yaml"},
+					&FolderLoader{path: "testdata/folder"},
+				}},
+			},
+		},
+		"ErrorWithFile": {
+			reason: "Error creating loader from file that does not exist",
+			args: args{
+				input: "testdata/does-not-exist.yaml",
+			},
+			want: want{
+				err: cmpopts.AnyError,
+			},
+		},
+		"ErrorWithFolder": {
+			reason: "Error creating loader from folder that does not exist",
+			args: args{
+				input: "testdata/does-not-exist",
+			},
+			want: want{
+				err: cmpopts.AnyError,
+			},
+		},
+		"ErrorWithMultiple": {
+			reason: "Error creating loader from multiple sources that does not exist",
+			args: args{
+				input: "testdata/does-not-exist.yaml,testdata/does-not-exist",
+			},
+			want: want{
+				err: cmpopts.AnyError,
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got, err := NewLoader(tc.args.input)
+			if diff := cmp.Diff(
+				tc.want.loader,
+				got,
+				cmpopts.IgnoreUnexported(FileLoader{}, FolderLoader{}, MultiLoader{}),
+			); diff != "" {
+				t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff)
+			}
+
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func TestMultiLoaderLoad(t *testing.T) {
+	type args struct {
+		loaders []Loader
+	}
+	type want struct {
+		resources []*unstructured.Unstructured
+		err       error
+	}
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"Success": {
+			reason: "Successfully load resources from file and folder loaders",
+			args: args{
+				loaders: []Loader{
+					&FileLoader{
+						path: "testdata/resources.yaml",
+					},
+					&FolderLoader{
+						path: "testdata/folder",
+					},
+				},
+			},
+			want: want{
+				resources: []*unstructured.Unstructured{
+					{
+						Object: coolResource,
+					},
+					{
+						Object: coolerResource,
+					},
+					{
+						Object: coolResource,
+					},
+					{
+						Object: coolerResource,
+					},
+				},
+			},
+		},
+		"Error": {
+			reason: "Error loading resources from invalid loader",
+			args: args{
+				[]Loader{
+					&FileLoader{
+						path: "testdata/does-not-exist.yaml",
+					},
+				},
+			},
+			want: want{
+				resources: nil,
+				err:       cmpopts.AnyError,
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			f := &MultiLoader{
+				loaders: tc.args.loaders,
+			}
+			got, err := f.Load()
+			if diff := cmp.Diff(tc.want.resources, got); diff != "" {
+				t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff)
+			}
+
+			if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" {
+				t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
 func TestFileLoaderLoad(t *testing.T) {
 	type args struct {
 		Path string
diff --git a/cmd/crank/beta/validate/validate_test.go b/cmd/crank/beta/validate/validate_test.go
index 0d3a589f5..b60e32293 100644
--- a/cmd/crank/beta/validate/validate_test.go
+++ b/cmd/crank/beta/validate/validate_test.go
@@ -25,6 +25,7 @@ import (
 	"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	runtimeschema "k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/validation/field"
 	"k8s.io/utils/ptr"
 
@@ -1349,3 +1350,353 @@ func TestValidateUnknownFields(t *testing.T) {
 		})
 	}
 }
+
+func TestApplyDefaults(t *testing.T) {
+	type args struct {
+		resource *unstructured.Unstructured
+		gvk      runtimeschema.GroupVersionKind
+		crds     []*extv1.CustomResourceDefinition
+	}
+	type want struct {
+		resource *unstructured.Unstructured
+		err      error
+	}
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"NoCRDFound": {
+			reason: "Should return nil when no matching CRD is found (skip defaulting)",
+			args: args{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"replicas": 3,
+						},
+					},
+				},
+				gvk: runtimeschema.GroupVersionKind{
+					Group:   "test.org",
+					Version: "v1alpha1",
+					Kind:    "Test",
+				},
+				crds: []*extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"replicas": 3,
+						},
+					},
+				},
+				err: nil,
+			},
+		},
+		"ApplySimpleDefault": {
+			reason: "Should apply default value to missing property",
+			args: args{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"replicas": 3,
+						},
+					},
+				},
+				gvk: runtimeschema.GroupVersionKind{
+					Group:   "test.org",
+					Version: "v1alpha1",
+					Kind:    "Test",
+				},
+				crds: []*extv1.CustomResourceDefinition{
+					{
+						Spec: extv1.CustomResourceDefinitionSpec{
+							Group: "test.org",
+							Names: extv1.CustomResourceDefinitionNames{
+								Kind: "Test",
+							},
+							Versions: []extv1.CustomResourceDefinitionVersion{
+								{
+									Name: "v1alpha1",
+									Schema: &extv1.CustomResourceValidation{
+										OpenAPIV3Schema: &extv1.JSONSchemaProps{
+											Type: "object",
+											Properties: map[string]extv1.JSONSchemaProps{
+												"spec": {
+													Type: "object",
+													Properties: map[string]extv1.JSONSchemaProps{
+														"replicas": {
+															Type: "integer",
+														},
+														"deletionPolicy": {
+															Type:    "string",
+															Default: &extv1.JSON{Raw: []byte(`"Delete"`)},
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			want: want{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"replicas":       3,
+							"deletionPolicy": "Delete",
+						},
+					},
+				},
+				err: nil,
+			},
+		},
+		"DoNotOverrideExisting": {
+			reason: "Should not override existing values with defaults",
+			args: args{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"replicas":       3,
+							"deletionPolicy": "Retain",
+						},
+					},
+				},
+				gvk: runtimeschema.GroupVersionKind{
+					Group:   "test.org",
+					Version: "v1alpha1",
+					Kind:    "Test",
+				},
+				crds: []*extv1.CustomResourceDefinition{
+					{
+						Spec: extv1.CustomResourceDefinitionSpec{
+							Group: "test.org",
+							Names: extv1.CustomResourceDefinitionNames{
+								Kind: "Test",
+							},
+							Versions: []extv1.CustomResourceDefinitionVersion{
+								{
+									Name: "v1alpha1",
+									Schema: &extv1.CustomResourceValidation{
+										OpenAPIV3Schema: &extv1.JSONSchemaProps{
+											Type: "object",
+											Properties: map[string]extv1.JSONSchemaProps{
+												"spec": {
+													Type: "object",
+													Properties: map[string]extv1.JSONSchemaProps{
+														"replicas": {
+															Type: "integer",
+														},
+														"deletionPolicy": {
+															Type:    "string",
+															Default: &extv1.JSON{Raw: []byte(`"Delete"`)},
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			want: want{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"replicas":       3,
+							"deletionPolicy": "Retain",
+						},
+					},
+				},
+				err: nil,
+			},
+		},
+		"NestedDefaults": {
+			reason: "Should apply defaults to nested objects",
+			args: args{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"forProvider": map[string]interface{}{
+								"region": "us-east-1",
+							},
+						},
+					},
+				},
+				gvk: runtimeschema.GroupVersionKind{
+					Group:   "test.org",
+					Version: "v1alpha1",
+					Kind:    "Test",
+				},
+				crds: []*extv1.CustomResourceDefinition{
+					{
+						Spec: extv1.CustomResourceDefinitionSpec{
+							Group: "test.org",
+							Names: extv1.CustomResourceDefinitionNames{
+								Kind: "Test",
+							},
+							Versions: []extv1.CustomResourceDefinitionVersion{
+								{
+									Name: "v1alpha1",
+									Schema: &extv1.CustomResourceValidation{
+										OpenAPIV3Schema: &extv1.JSONSchemaProps{
+											Type: "object",
+											Properties: map[string]extv1.JSONSchemaProps{
+												"spec": {
+													Type: "object",
+													Properties: map[string]extv1.JSONSchemaProps{
+														"forProvider": {
+															Type: "object",
+															Properties: map[string]extv1.JSONSchemaProps{
+																"region": {
+																	Type: "string",
+																},
+																"instanceType": {
+																	Type:    "string",
+																	Default: &extv1.JSON{Raw: []byte(`"t3.micro"`)},
+																},
+															},
+														},
+														"deletionPolicy": {
+															Type:    "string",
+															Default: &extv1.JSON{Raw: []byte(`"Delete"`)},
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			want: want{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"forProvider": map[string]interface{}{
+								"region":       "us-east-1",
+								"instanceType": "t3.micro",
+							},
+							"deletionPolicy": "Delete",
+						},
+					},
+				},
+				err: nil,
+			},
+		},
+		"ComplexDefaults": {
+			reason: "Should apply complex default values (objects, arrays)",
+			args: args{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"name": "test",
+						},
+					},
+				},
+				gvk: runtimeschema.GroupVersionKind{
+					Group:   "test.org",
+					Version: "v1alpha1",
+					Kind:    "Test",
+				},
+				crds: []*extv1.CustomResourceDefinition{
+					{
+						Spec: extv1.CustomResourceDefinitionSpec{
+							Group: "test.org",
+							Names: extv1.CustomResourceDefinitionNames{
+								Kind: "Test",
+							},
+							Versions: []extv1.CustomResourceDefinitionVersion{
+								{
+									Name: "v1alpha1",
+									Schema: &extv1.CustomResourceValidation{
+										OpenAPIV3Schema: &extv1.JSONSchemaProps{
+											Type: "object",
+											Properties: map[string]extv1.JSONSchemaProps{
+												"spec": {
+													Type: "object",
+													Properties: map[string]extv1.JSONSchemaProps{
+														"name": {
+															Type: "string",
+														},
+														"metadata": {
+															Type:    "object",
+															Default: &extv1.JSON{Raw: []byte(`{"labels":{"app":"default-app"}}`)},
+														},
+														"tags": {
+															Type:    "array",
+															Default: &extv1.JSON{Raw: []byte(`["default","tag"]`)},
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			want: want{
+				resource: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"apiVersion": "test.org/v1alpha1",
+						"kind":       "Test",
+						"spec": map[string]interface{}{
+							"name": "test",
+							"metadata": map[string]interface{}{
+								"labels": map[string]interface{}{
+									"app": "default-app",
+								},
+							},
+							"tags": []interface{}{"default", "tag"},
+						},
+					},
+				},
+				err: nil,
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			err := applyDefaults(tc.args.resource, tc.args.gvk, tc.args.crds)
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("%s\napplyDefaults(...): -want err, +got err:\n%s", tc.reason, diff)
+			}
+			if diff := cmp.Diff(tc.want.resource, tc.args.resource); diff != "" {
+				t.Errorf("%s\napplyDefaults(...): -want resource, +got resource:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}