Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/config/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ go_library(
"deployment_lambda.go",
"deployment_terraform.go",
"duration.go",
"event_watcher.go",
"image_watcher.go",
"piped.go",
"replicas.go",
Expand All @@ -38,6 +39,7 @@ go_test(
"deployment_kubernetes_test.go",
"deployment_terraform_test.go",
"deployment_test.go",
"event_watcher_test.go",
"image_watcher_test.go",
"piped_test.go",
"replicas_test.go",
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const (
KindAnalysisTemplate Kind = "AnalysisTemplate"
// KindImageWatcher represents configuration for Repo Watcher.
KindImageWatcher Kind = "ImageWatcher"
// KindEventWatcher represents configuration for Event Watcher.
KindEventWatcher Kind = "EventWatcher"
)

var (
Expand All @@ -87,6 +89,7 @@ type Config struct {
ControlPlaneSpec *ControlPlaneSpec
AnalysisTemplateSpec *AnalysisTemplateSpec
ImageWatcherSpec *ImageWatcherSpec
EventWatcherSpec *EventWatcherSpec

SealedSecretSpec *SealedSecretSpec
}
Expand Down Expand Up @@ -150,6 +153,10 @@ func (c *Config) init(kind Kind, apiVersion string) error {
c.ImageWatcherSpec = &ImageWatcherSpec{}
c.spec = c.ImageWatcherSpec

case KindEventWatcher:
c.EventWatcherSpec = &EventWatcherSpec{}
c.spec = c.EventWatcherSpec

default:
return fmt.Errorf("unsupported kind: %s", c.Kind)
}
Expand Down
136 changes: 136 additions & 0 deletions pkg/config/event_watcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2021 The PipeCD Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
)

type EventWatcherSpec struct {
Events []EventWatcherEvent `json:"events"`
}

// EventWatcherEvent defines which file will be replaced when the given event happened.
type EventWatcherEvent struct {
// The event name.
Name string `json:"name"`
// Additional attributes of event. This can make an event definition
// unique even if the one with the same name exists.
Labels map[string]string `json:"labels"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Moving this to just after the Name?
I mean we have 2 parts: conditions for matching: name and labels and actions: replacements

Copy link
Member Author

@nakabonne nakabonne Jan 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't exactly get what you said. Sorry but could you show me some examples of that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to say that our configuration will have 2 groups:

  • first group is about how to match the event. This contains "name" and "labels"
  • second group is about how to handle when the event matches. This contains "replacements"

So I think we should change the order to group them for more readable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense! I placed the Label field at the end because this is optional, but putting after Name is more readable in such case.

// List of places where will be replaced when the new event matches.
Replacements []EventWatcherReplacement `json:"replacements"`
}

type EventWatcherReplacement struct {
// The path to the file to be updated.
File string `json:"file"`
// 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`.
YAMLField string `json:"yamlField"`
// TODO: Support JSONField to replace values in json format
// TODO: Support HCLField to replace values in HCL format
}

// LoadEventWatcher gives back parsed EventWatcher config after merging config files placed under
// the .pipe directory. With "includes" and "excludes", you can filter the files included the result.
// "excludes" are prioritized if both "excludes" and "includes" are given. ErrNotFound is returned if not found.
func LoadEventWatcher(repoRoot string, includes, excludes []string) (*EventWatcherSpec, error) {
dir := filepath.Join(repoRoot, SharedConfigurationDirName)
files, err := ioutil.ReadDir(dir)
if os.IsNotExist(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", dir, err)
}

// Start merging events defined across multiple files.
spec := &EventWatcherSpec{
Events: make([]EventWatcherEvent, 0),
}
filtered, err := filterEventWatcherFiles(files, includes, excludes)
if err != nil {
return nil, fmt.Errorf("failed to filter event watcher files at %s: %w", dir, err)
}
for _, f := range filtered {
if f.IsDir() {
continue
}
path := filepath.Join(dir, f.Name())
cfg, err := LoadFromYAML(path)
if err != nil {
return nil, fmt.Errorf("failed to load config file %s: %w", path, err)
}
if cfg.Kind == KindEventWatcher {
spec.Events = append(spec.Events, cfg.EventWatcherSpec.Events...)
}
}

if err := spec.Validate(); err != nil {
return nil, err
}

return spec, nil
}

// filterEventWatcherFiles filters the given files based on the given Includes and Excludes.
// Excludes are prioritized if both Excludes and Includes are given.
func filterEventWatcherFiles(files []os.FileInfo, includes, excludes []string) ([]os.FileInfo, error) {
if len(includes) == 0 && len(excludes) == 0 {
return files, nil
}

filtered := make([]os.FileInfo, 0, len(files))
useWhitelist := len(includes) != 0 && len(excludes) == 0
if useWhitelist {
whiteList := make(map[string]struct{}, len(includes))
for _, i := range includes {
whiteList[i] = struct{}{}
}
for _, f := range files {
if _, ok := whiteList[f.Name()]; ok {
filtered = append(filtered, f)
}
}
return filtered, nil
}

blackList := make(map[string]struct{}, len(excludes))
for _, e := range excludes {
blackList[e] = struct{}{}
}
for _, f := range files {
if _, ok := blackList[f.Name()]; !ok {
filtered = append(filtered, f)
}
}
return filtered, nil
}

func (s *EventWatcherSpec) Validate() error {
for _, e := range s.Events {
if e.Name == "" {
return fmt.Errorf("event name must not be empty")
}
if len(e.Replacements) == 0 {
return fmt.Errorf("there must be at least one replacement to an event")
}
// TODO: Consider merging events if there are events whose combination of name and labels is the same
}
return nil
}
217 changes: 217 additions & 0 deletions pkg/config/event_watcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright 2021 The PipeCD Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

import (
"os"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestLoadEventWatcher(t *testing.T) {
want := &EventWatcherSpec{Events: []EventWatcherEvent{
{
Name: "app1-image-update",
Replacements: []EventWatcherReplacement{
{
File: "app1/deployment.yaml",
YAMLField: "$.spec.template.spec.containers[0].image",
},
},
},
{
Name: "app2-helm-release",
Labels: map[string]string{
"repoId": "repo-1",
},
Replacements: []EventWatcherReplacement{
{
File: "app2/.pipe.yaml",
YAMLField: "$.spec.input.helmChart.version",
},
},
},
}}

t.Run("valid config files given", func(t *testing.T) {
got, err := LoadEventWatcher("testdata", nil, []string{"README.md"})
assert.NoError(t, err)
assert.Equal(t, want, got)
})
}

func TestEventWatcherValidate(t *testing.T) {
testcases := []struct {
name string
eventWatcherSpec EventWatcherSpec
wantErr bool
}{
{
name: "no name given",
eventWatcherSpec: EventWatcherSpec{
Events: []EventWatcherEvent{
{
Replacements: []EventWatcherReplacement{
{
File: "file",
YAMLField: "$.foo",
},
},
},
},
},
wantErr: true,
},
{
name: "no replacements given",
eventWatcherSpec: EventWatcherSpec{
Events: []EventWatcherEvent{
{
Name: "event-a",
},
},
},
wantErr: true,
},
{
name: "valid config given",
eventWatcherSpec: EventWatcherSpec{
Events: []EventWatcherEvent{
{
Name: "event-a",
Replacements: []EventWatcherReplacement{
{
File: "file",
YAMLField: "$.foo",
},
},
},
},
},
wantErr: false,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
err := tc.eventWatcherSpec.Validate()
assert.Equal(t, tc.wantErr, err != nil)
})
}
}

type fakeFileInfo struct {
name string
}

func (f *fakeFileInfo) Name() string { return f.name }

// Below methods are required to meet the interface.
func (f *fakeFileInfo) Size() int64 { return 0 }
func (f *fakeFileInfo) Mode() os.FileMode { return 0 }
func (f *fakeFileInfo) ModTime() time.Time { return time.Now() }
func (f *fakeFileInfo) IsDir() bool { return false }
func (f *fakeFileInfo) Sys() interface{} { return nil }

func TestFilterEventWatcherFiles(t *testing.T) {
testcases := []struct {
name string
files []os.FileInfo
includes []string
excludes []string
want []os.FileInfo
wantErr bool
}{
{
name: "both includes and excludes aren't given",
files: []os.FileInfo{
&fakeFileInfo{
name: "file-1",
},
},
want: []os.FileInfo{
&fakeFileInfo{
name: "file-1",
},
},
wantErr: false,
},
{
name: "both includes and excludes are given",
files: []os.FileInfo{
&fakeFileInfo{
name: "file-1",
},
},
want: []os.FileInfo{},
includes: []string{"file-1"},
excludes: []string{"file-1"},
wantErr: false,
},
{
name: "includes given",
files: []os.FileInfo{
&fakeFileInfo{
name: "file-1",
},
&fakeFileInfo{
name: "file-2",
},
&fakeFileInfo{
name: "file-3",
},
},
includes: []string{"file-1", "file-3"},
want: []os.FileInfo{
&fakeFileInfo{
name: "file-1",
},
&fakeFileInfo{
name: "file-3",
},
},
wantErr: false,
},
{
name: "excludes given",
files: []os.FileInfo{
&fakeFileInfo{
name: "file-1",
},
&fakeFileInfo{
name: "file-2",
},
&fakeFileInfo{
name: "file-3",
},
},
excludes: []string{"file-1", "file-3"},
want: []os.FileInfo{
&fakeFileInfo{
name: "file-2",
},
},
wantErr: false,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
got, err := filterEventWatcherFiles(tc.files, tc.includes, tc.excludes)
assert.Equal(t, tc.wantErr, err != nil)
assert.Equal(t, tc.want, got)
})
}
}
Loading