diff --git a/docs/content/en/docs/user-guide/configuration-reference.md b/docs/content/en/docs/user-guide/configuration-reference.md index 11c447adaf..399f989b3c 100644 --- a/docs/content/en/docs/user-guide/configuration-reference.md +++ b/docs/content/en/docs/user-guide/configuration-reference.md @@ -148,11 +148,13 @@ spec: | replacements | [][EventWatcherReplacement](/docs/user-guide/configuration-reference/#eventwatcherreplacement) | List of places where will be replaced when the new event matches. | Yes | ## EventWatcherReplacement +One of `yamlField` or `regex` is required. | Field | Type | Description | Required | |-|-|-|-| | file | string | The path to the file to be updated. | Yes | -| yamlField | string | The yaml path to the field to be updated. It requires to start with `$` which represents the root element. e.g. `$.foo.bar[0].baz`. | Yes | +| yamlField | string | The yaml path to the field to be updated. It requires to start with `$` which represents the root element. e.g. `$.foo.bar[0].baz`. | No | +| regex | string | The regex string that specify what should be replaced. The only first capturing group enclosed by `()` will be replaced with the new value. e.g. `host.xz/foo/bar:(v[0-9].[0-9].[0-9])` | No | ## CommitMatcher diff --git a/pkg/app/piped/eventwatcher/BUILD.bazel b/pkg/app/piped/eventwatcher/BUILD.bazel index 15506e2ebb..203af22517 100644 --- a/pkg/app/piped/eventwatcher/BUILD.bazel +++ b/pkg/app/piped/eventwatcher/BUILD.bazel @@ -9,6 +9,7 @@ go_library( "//pkg/config:go_default_library", "//pkg/git:go_default_library", "//pkg/model:go_default_library", + "//pkg/regexpool:go_default_library", "//pkg/yamlprocessor:go_default_library", "@org_uber_go_zap//:go_default_library", ], diff --git a/pkg/app/piped/eventwatcher/eventwatcher.go b/pkg/app/piped/eventwatcher/eventwatcher.go index b8c2fc4201..4481d03dd9 100644 --- a/pkg/app/piped/eventwatcher/eventwatcher.go +++ b/pkg/app/piped/eventwatcher/eventwatcher.go @@ -24,6 +24,7 @@ import ( "io/ioutil" "os" "path/filepath" + "regexp/syntax" "strconv" "sync" "time" @@ -33,6 +34,7 @@ import ( "github.com/pipe-cd/pipe/pkg/config" "github.com/pipe-cd/pipe/pkg/git" "github.com/pipe-cd/pipe/pkg/model" + "github.com/pipe-cd/pipe/pkg/regexpool" "github.com/pipe-cd/pipe/pkg/yamlprocessor" ) @@ -239,6 +241,8 @@ func (w *watcher) commitFiles(ctx context.Context, eventCfg config.EventWatcherE // TODO: Empower Event watcher to parse JSON format case r.HCLField != "": // TODO: Empower Event watcher to parse HCL format + case r.Regex != "": + newContent, upToDate, err = modifyText(path, r.Regex, latestEvent.Data) } if err != nil { return err @@ -269,7 +273,7 @@ func (w *watcher) commitFiles(ctx context.Context, eventCfg config.EventWatcherE // modifyYAML returns a new YAML content as a first returned value if the value of given // field was outdated. True as a second returned value means it's already up-to-date. func modifyYAML(path, field, newValue string) ([]byte, bool, error) { - yml, err := ioutil.ReadFile(path) + yml, err := os.ReadFile(path) if err != nil { return nil, false, fmt.Errorf("failed to read file: %w", err) } @@ -320,3 +324,55 @@ func convertStr(value interface{}) (out string, err error) { } return } + +// modifyText returns a new text replacing all matches of the given regex with the newValue. +// The only first capturing group enclosed by `()` will be replaced. +// True as a second returned value means it's already up-to-date. +func modifyText(path, regexText, newValue string) ([]byte, bool, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, false, fmt.Errorf("failed to read file: %w", err) + } + + pool := regexpool.DefaultPool() + regex, err := pool.Get(regexText) + if err != nil { + return nil, false, fmt.Errorf("failed to compile regex text (%s): %w", regexText, err) + } + + // Extract the first capturing group. + firstGroup := "" + re, err := syntax.Parse(regexText, syntax.Perl) + if err != nil { + return nil, false, fmt.Errorf("failed to parse the first capturing group regex: %w", err) + } + for _, s := range re.Sub { + if s.Op == syntax.OpCapture { + firstGroup = s.String() + break + } + } + if firstGroup == "" { + return nil, false, fmt.Errorf("capturing group not found in the given regex") + } + subRegex, err := pool.Get(firstGroup) + if err != nil { + return nil, false, fmt.Errorf("failed to compile the first capturing group: %w", err) + } + + var touched, outDated bool + newText := regex.ReplaceAllFunc(content, func(match []byte) []byte { + touched = true + outDated = string(subRegex.Find(match)) != newValue + // Return text replacing the only first capturing group with the newValue. + return subRegex.ReplaceAll(match, []byte(newValue)) + }) + if !touched { + return nil, false, fmt.Errorf("the content of %s doesn't match %s", path, regexText) + } + if !outDated { + return nil, true, nil + } + + return newText, false, nil +} diff --git a/pkg/app/piped/eventwatcher/eventwatcher_test.go b/pkg/app/piped/eventwatcher/eventwatcher_test.go index 207c1594a8..47d887cc19 100644 --- a/pkg/app/piped/eventwatcher/eventwatcher_test.go +++ b/pkg/app/piped/eventwatcher/eventwatcher_test.go @@ -117,3 +117,122 @@ func TestModifyYAML(t *testing.T) { }) } } + +func TestModifyText(t *testing.T) { + testcases := []struct { + name string + path string + regex string + newValue string + want []byte + wantUpToDate bool + wantErr bool + }{ + { + name: "invalid regex given", + path: "testdata/with-template.yaml", + regex: "[", + newValue: "v0.2.0", + want: nil, + wantUpToDate: false, + wantErr: true, + }, + { + name: "no capturing group given", + path: "testdata/with-template.yaml", + regex: "image: gcr.io/pipecd/foo:v[0-9].[0-9].[0-9]", + newValue: "v0.2.0", + want: nil, + wantUpToDate: false, + wantErr: true, + }, + { + name: "invalid capturing group given", + path: "testdata/with-template.yaml", + regex: "image: gcr.io/pipecd/foo:([)", + newValue: "v0.2.0", + want: nil, + wantUpToDate: false, + wantErr: true, + }, + { + name: "the file doesn't match regex", + path: "testdata/with-template.yaml", + regex: "abcdefg", + newValue: "v0.1.0", + want: nil, + wantUpToDate: false, + wantErr: true, + }, + { + name: "the file is up-to-date", + path: "testdata/with-template.yaml", + regex: "image: gcr.io/pipecd/foo:(v[0-9].[0-9].[0-9])", + newValue: "v0.1.0", + want: nil, + wantUpToDate: true, + wantErr: false, + }, + { + name: "replace a part of text", + path: "testdata/with-template.yaml", + regex: "image: gcr.io/pipecd/foo:(v[0-9].[0-9].[0-9])", + newValue: "v0.2.0", + want: []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: foo +spec: + template: + spec: + containers: + - name: foo + image: gcr.io/pipecd/foo:v0.2.0 + ports: + - containerPort: 9085 + env: + - name: FOO + value: {{ .encryptedSecrets.foo }} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bar +spec: + template: + spec: + containers: + - name: bar + image: gcr.io/pipecd/bar:v0.1.0 + ports: + - containerPort: 9085 + env: + - name: BAR + value: {{ .encryptedSecrets.bar }} +`), + wantUpToDate: false, + wantErr: false, + }, + { + name: "replace text", + path: "testdata/kustomization.yaml", + regex: "newTag: (v[0-9].[0-9].[0-9])", + newValue: "v0.2.0", + want: []byte(`images: +- name: gcr.io/pipecd/foo + newTag: v0.2.0 +`), + wantUpToDate: false, + wantErr: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, gotUpToDate, err := modifyText(tc.path, tc.regex, tc.newValue) + assert.Equal(t, tc.wantErr, err != nil) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantUpToDate, gotUpToDate) + }) + } +} diff --git a/pkg/app/piped/eventwatcher/testdata/kustomization.yaml b/pkg/app/piped/eventwatcher/testdata/kustomization.yaml new file mode 100644 index 0000000000..301d0b985f --- /dev/null +++ b/pkg/app/piped/eventwatcher/testdata/kustomization.yaml @@ -0,0 +1,3 @@ +images: +- name: gcr.io/pipecd/foo + newTag: v0.1.0 diff --git a/pkg/app/piped/eventwatcher/testdata/with-template.yaml b/pkg/app/piped/eventwatcher/testdata/with-template.yaml new file mode 100644 index 0000000000..16f24dd176 --- /dev/null +++ b/pkg/app/piped/eventwatcher/testdata/with-template.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: foo +spec: + template: + spec: + containers: + - name: foo + image: gcr.io/pipecd/foo:v0.1.0 + ports: + - containerPort: 9085 + env: + - name: FOO + value: {{ .encryptedSecrets.foo }} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bar +spec: + template: + spec: + containers: + - name: bar + image: gcr.io/pipecd/bar:v0.1.0 + ports: + - containerPort: 9085 + env: + - name: BAR + value: {{ .encryptedSecrets.bar }} diff --git a/pkg/config/event_watcher.go b/pkg/config/event_watcher.go index aadfd2a37f..5a73167deb 100644 --- a/pkg/config/event_watcher.go +++ b/pkg/config/event_watcher.go @@ -50,6 +50,10 @@ type EventWatcherReplacement struct { JSONField string `json:"jsonField"` // The HCL path to the field to be updated. HCLField string `json:"HCLField"` + // The regex string specifying what should be replaced. + // Only the first capturing group enclosed by `()` will be replaced with the new value. + // e.g. "host.xz/foo/bar:(v[0-9].[0-9].[0-9])" + Regex string `json:"regex"` } // LoadEventWatcher gives back parsed EventWatcher config after merging config files placed under @@ -172,6 +176,9 @@ func (e *EventWatcherEvent) Validate() error { if r.HCLField != "" { count++ } + if r.Regex != "" { + count++ + } if count == 0 { return fmt.Errorf("event %q has a replacement with no field", e.Name) }