From 9595b1a95e0a1b937523ea2afe298e38c5b89e83 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Tue, 12 Mar 2024 16:07:01 -0700 Subject: [PATCH] yaml/v2: Detect file patterns automatically (#2873) ### Proposed changes This PR teaches `ConfigGroup` to detect whether a given file path is a glob pattern, to make the handling of non-existent files be consistent w.r.t `ConfigFile`. In other words, `*.yaml` MAY match a file whereas `manifest.yaml` MUST match a file. The detection code looks for special characters '*', '?', and '[' and respects the escape syntax. Intended to be consistent with: [https://pkg.go.dev/path/filepath#Match](https://pkg.go.dev/path/filepath#Match) An alternative to doing pattern detection would be: 1. to expose a `glob` property to toggle globbing, or 2. a `patterns` property alongside the `files` property for patterns and non-patterns respectively ### Related issues (optional) Closes #2871 --- provider/pkg/provider/yaml/v2/yaml.go | 15 ++++-- provider/pkg/provider/yaml/v2/yaml_test.go | 61 +++++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/provider/pkg/provider/yaml/v2/yaml.go b/provider/pkg/provider/yaml/v2/yaml.go index 4bb13f5458..58e973cff2 100644 --- a/provider/pkg/provider/yaml/v2/yaml.go +++ b/provider/pkg/provider/yaml/v2/yaml.go @@ -24,6 +24,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "github.com/pkg/errors" @@ -70,10 +71,10 @@ func ParseDecodeYamlFiles(ctx *pulumi.Context, args *ParseArgs, glob bool, clien } yamls = append(yamls, string(yaml)) } else { - // Otherwise, assume this is a path to a file on disk. If globbing is enabled, we might have - // multiple files -- otherwise just read a singular file. + // Otherwise, assume this is a path to a file on disk. If globbing is enabled and a pattern is provided, we might have + // multiple files -- otherwise just read a singular file and fail if it doesn't exist. var files []string - if glob { + if glob && isGlobPattern(file) { files, err = filepath.Glob(file) if err != nil { return pulumi.ArrayOutput{}, errors.Wrapf(err, "expanding glob") @@ -246,3 +247,11 @@ func printUnstructured(obj *unstructured.Unstructured) string { bytes, _ := obj.MarshalJSON() return truncate(strings.TrimSpace(string(bytes)), 100) } + +// globPatternRegexp is a regular expression that matches any of the special characters in a glob pattern. +// see: https://pkg.go.dev/path/filepath#Match +var globPatternRegexp = regexp.MustCompile(`(?:^|[^\\])[*?\[]`) + +func isGlobPattern(pattern string) bool { + return globPatternRegexp.Match([]byte(pattern)) +} diff --git a/provider/pkg/provider/yaml/v2/yaml_test.go b/provider/pkg/provider/yaml/v2/yaml_test.go index c059695608..3cfed20896 100644 --- a/provider/pkg/provider/yaml/v2/yaml_test.go +++ b/provider/pkg/provider/yaml/v2/yaml_test.go @@ -18,6 +18,7 @@ import ( "context" "os" "path/filepath" + "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -26,6 +27,7 @@ import ( . "github.com/pulumi/pulumi-kubernetes/tests/v4/gomega" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "github.com/pulumi/pulumi/sdk/v3/go/pulumi/internals" + "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -195,20 +197,34 @@ var _ = Describe("ParseDecodeYamlFiles", func() { }) Describe("files", func() { - Context("when the file doesn't exist (glob mode)", func() { + Describe("globbing", func() { BeforeEach(func() { glob = true - args.Files = []string{"nosuchfile.yaml"} }) - It("should do nothing", func(ctx context.Context) { - _, err := parse(ctx) - Expect(err).ShouldNot(HaveOccurred()) + + Context("when the pattern matches no files", func() { + BeforeEach(func() { + args.Files = []string{"nosuchfile-*.yaml"} + }) + It("should do nothing", func(ctx context.Context) { + _, err := parse(ctx) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) + + Context("when the pattern matches some files", func() { + BeforeEach(func() { + tempDir := GinkgoTB().TempDir() + err := os.WriteFile(filepath.Join(tempDir, "manifest.yaml"), []byte(manifest), 0o600) + Expect(err).ShouldNot(HaveOccurred()) + args.Files = []string{filepath.Join(tempDir, "*.yaml")} + }) + commonAssertions() }) }) - Context("when the file doesn't exist (non-glob mode)", func() { + Context("when the file doesn't exist", func() { BeforeEach(func() { - glob = false args.Files = []string{"nosuchfile.yaml"} }) It("should fail", func(ctx context.Context) { @@ -330,3 +346,34 @@ var _ = Describe("ParseDecodeYamlFiles", func() { }) }) }) + +func TestIsGlobPattern(t *testing.T) { + t.Parallel() + + tests := []struct { + pattern string + expected bool + }{ + {pattern: `manifest.yaml`, expected: false}, + {pattern: `*.yaml`, expected: true}, + {pattern: `*`, expected: true}, + {pattern: `test-?.yaml`, expected: true}, + {pattern: `ba[rz].yaml`, expected: true}, + {pattern: `escaped-\*.yaml`, expected: false}, + {pattern: `\*.yaml`, expected: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.pattern, func(t *testing.T) { + t.Parallel() + + isPattern := isGlobPattern(tt.pattern) + if tt.expected { + assert.Truef(t, isPattern, "expected %q to be a pattern", tt.pattern) + } else { + assert.Falsef(t, isPattern, "expected %q to not be a pattern", tt.pattern) + } + }) + } +}