Repository: crossplane/crossplane

Issue:
`crossplane beta validate` should accept list inputs
<!--
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?
<!--
Please tell us a little about your use case - it's okay if it's hypothetical!
Leading with this context helps frame the feature request so we can ensure we
implement it sensibly.
--->

Today, `crossplane beta validate` takes "extensions" and "resources" arguments that can be either files, directories, or '-' for standard input. This works OK if you have a single file or directory with all your extensions or resources, but that is not always the case. 

For example, in a configuration package you might define providers in `crossplane.yaml` and XRDs in `apis/`. Since these can't be passed simultaneously, you can't run a full validation on the entire render output in one go.

### How could Crossplane help solve your problem?
<!--
Let us know how you think Crossplane could help with your use case. 
-->

I suggest changing the "extensions" and "resources" arguments to accept comma-separated lists of input files or directories. These inputs are then loaded and merged.

This is similar to how other tools, like `kubectl`, works. I also think it should be backwards compatible, since file names typically don't contain commas.

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.@jbw976: /fresh PR https://github.com/crossplane/crossplane/pull/6323 has traction now 🤓 

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/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/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{}
 



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