diff --git a/constants/constants.go b/constants/constants.go index e18be1da..dce3de1a 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -15,3 +15,6 @@ const ( EnvSlackBotName = "SLACK_BOT_NAME" EnvSlackChannels = "SLACK_CHANNELS" ) + +// EnvNotificationLevel - minimum level for notifications, defaults to info +const EnvNotificationLevel = "NOTIFICATION_LEVEL" diff --git a/extension/notification/notification.go b/extension/notification/notification.go index a41dfce0..e40fd121 100644 --- a/extension/notification/notification.go +++ b/extension/notification/notification.go @@ -32,6 +32,7 @@ var ( // notifiers. type Config struct { Attempts int + Level types.Level Params map[string]interface{} `yaml:",inline"` } @@ -76,6 +77,7 @@ func RegisterSender(name string, s Sender) { type DefaultNotificationSender struct { config *Config stopper *stopper.Stopper + level types.Level } // New - create new sender @@ -118,6 +120,10 @@ func (m *DefaultNotificationSender) Senders() map[string]Sender { // Send - send notifications through all configured senders func (m *DefaultNotificationSender) Send(event types.EventNotification) error { + if event.Level < m.config.Level { + return nil + } + sendersM.RLock() defer sendersM.RUnlock() diff --git a/extension/notification/notification_test.go b/extension/notification/notification_test.go new file mode 100644 index 00000000..1629d98f --- /dev/null +++ b/extension/notification/notification_test.go @@ -0,0 +1,129 @@ +package notification + +import ( + "context" + "fmt" + "testing" + + "github.com/rusenask/keel/types" +) + +type fakeSender struct { + sent *types.EventNotification + + shouldConfigure bool + shouldError error +} + +func (s *fakeSender) Configure(*Config) (bool, error) { + return s.shouldConfigure, nil +} + +func (s *fakeSender) Send(event types.EventNotification) error { + s.sent = &event + fmt.Println("sending event") + return s.shouldError +} + +func TestSend(t *testing.T) { + sndr := New(context.Background()) + + sndr.Configure(&Config{ + Level: types.LevelDebug, + Attempts: 1, + }) + + fs := &fakeSender{ + shouldConfigure: true, + shouldError: nil, + } + + RegisterSender("fakeSender", fs) + defer sndr.UnregisterSender("fakeSender") + + err := sndr.Send(types.EventNotification{ + Level: types.LevelInfo, + Type: types.NotificationPreDeploymentUpdate, + Message: "foo", + }) + + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if fs.sent.Message != "foo" { + t.Errorf("unexpected notification message: %s", fs.sent.Message) + } + + if fs.sent.Level != types.LevelInfo { + t.Errorf("unexpected level: %s", fs.sent.Level) + } +} + +// test when configured level is higher than the event +func TestSendLevelNotificationA(t *testing.T) { + sndr := New(context.Background()) + + sndr.Configure(&Config{ + Level: types.LevelInfo, + Attempts: 1, + }) + + fs := &fakeSender{ + shouldConfigure: true, + shouldError: nil, + } + + RegisterSender("fakeSender", fs) + defer sndr.UnregisterSender("fakeSender") + + err := sndr.Send(types.EventNotification{ + Level: types.LevelDebug, + Type: types.NotificationPreDeploymentUpdate, + Message: "foo", + }) + + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if fs.sent != nil { + t.Errorf("didn't expect to find sent even for this level") + } +} + +// event level is higher than the configured +func TestSendLevelNotificationB(t *testing.T) { + sndr := New(context.Background()) + + sndr.Configure(&Config{ + Level: types.LevelInfo, + Attempts: 1, + }) + + fs := &fakeSender{ + shouldConfigure: true, + shouldError: nil, + } + + RegisterSender("fakeSender", fs) + defer sndr.UnregisterSender("fakeSender") + + err := sndr.Send(types.EventNotification{ + Level: types.LevelSuccess, + Type: types.NotificationPreDeploymentUpdate, + Message: "foo", + }) + + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if fs.sent.Message != "foo" { + t.Errorf("unexpected notification message: %s", fs.sent.Message) + } + + if fs.sent.Level != types.LevelSuccess { + t.Errorf("unexpected level: %s", fs.sent.Level) + } +} diff --git a/extension/notification/webhook/webhook_test.go b/extension/notification/webhook/webhook_test.go index e0f6b776..b2820c2f 100644 --- a/extension/notification/webhook/webhook_test.go +++ b/extension/notification/webhook/webhook_test.go @@ -25,7 +25,7 @@ func TestWebhookRequest(t *testing.T) { t.Errorf("missing deployment type") } - if !strings.Contains(bodyStr, "LevelDebug") { + if !strings.Contains(bodyStr, "debug") { t.Errorf("missing level") } diff --git a/main.go b/main.go index cf89196e..00766328 100644 --- a/main.go +++ b/main.go @@ -74,8 +74,21 @@ func main() { ctx, cancel := netContext.WithCancel(context.Background()) defer cancel() + notificationLevel := types.LevelInfo + if os.Getenv(constants.EnvNotificationLevel) != "" { + parsedLevel, err := types.ParseLevel(os.Getenv(constants.EnvNotificationLevel)) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Errorf("main: got error while parsing notification level, defaulting to: %s", notificationLevel) + } else { + notificationLevel = parsedLevel + } + } + notifCfg := ¬ification.Config{ Attempts: 10, + Level: notificationLevel, } sender := notification.New(ctx) diff --git a/secrets/secrets.go b/secrets/secrets.go index 573c95bf..3ab686d7 100644 --- a/secrets/secrets.go +++ b/secrets/secrets.go @@ -18,7 +18,9 @@ import ( ) // const dockerConfigJSONKey = ".dockerconfigjson" -const dockerConfigJSONKey = ".dockercfg" +const dockerConfigKey = ".dockercfg" + +const dockerConfigJSONKey = ".dockerconfigjson" // common errors var ( @@ -129,7 +131,58 @@ func (g *DefaultGetter) getCredentialsFromSecret(image *types.TrackedImage) (*ty continue } - if secret.Type != v1.SecretTypeDockercfg { + dockerCfg := make(DockerCfg) + + switch secret.Type { + case v1.SecretTypeDockercfg: + secretDataBts, ok := secret.Data[dockerConfigKey] + if !ok { + log.WithFields(log.Fields{ + "image": image.Image.Repository(), + "namespace": image.Namespace, + "secret_ref": secretRef, + "type": secret.Type, + "data": secret.Data, + }).Warn("secrets.defaultGetter: secret is missing key '.dockerconfig', ensure that key exists") + continue + } + dockerCfg, err = decodeSecret(secretDataBts) + if err != nil { + log.WithFields(log.Fields{ + "image": image.Image.Repository(), + "namespace": image.Namespace, + "secret_ref": secretRef, + "secret_data": string(secretDataBts), + "error": err, + }).Error("secrets.defaultGetter: failed to decode secret") + continue + } + case v1.SecretTypeDockerConfigJson: + secretDataBts, ok := secret.Data[dockerConfigJSONKey] + if !ok { + log.WithFields(log.Fields{ + "image": image.Image.Repository(), + "namespace": image.Namespace, + "secret_ref": secretRef, + "type": secret.Type, + "data": secret.Data, + }).Warn("secrets.defaultGetter: secret is missing key '.dockerconfigjson', ensure that key exists") + continue + } + + dockerCfg, err = decodeJSONSecret(secretDataBts) + if err != nil { + log.WithFields(log.Fields{ + "image": image.Image.Repository(), + "namespace": image.Namespace, + "secret_ref": secretRef, + "secret_data": string(secretDataBts), + "error": err, + }).Error("secrets.defaultGetter: failed to decode secret") + continue + } + + default: log.WithFields(log.Fields{ "image": image.Image.Repository(), "namespace": image.Namespace, @@ -139,29 +192,6 @@ func (g *DefaultGetter) getCredentialsFromSecret(image *types.TrackedImage) (*ty continue } - secretDataBts, ok := secret.Data[dockerConfigJSONKey] - if !ok { - log.WithFields(log.Fields{ - "image": image.Image.Repository(), - "namespace": image.Namespace, - "secret_ref": secretRef, - "type": secret.Type, - "data": secret.Data, - }).Warn("secrets.defaultGetter: secret is missing key '.dockerconfigjson', ensure that key exists") - continue - } - dockerCfg, err := decodeSecret(secretDataBts) - if err != nil { - log.WithFields(log.Fields{ - "image": image.Image.Repository(), - "namespace": image.Namespace, - "secret_ref": secretRef, - "secret_data": string(secretDataBts), - "error": err, - }).Error("secrets.defaultGetter: failed to decode secret") - continue - } - // looking for our registry for registry, auth := range dockerCfg { h, err := hostname(registry) @@ -246,11 +276,15 @@ func decodeBase64Secret(authSecret string) (username, password string, err error } func hostname(registry string) (string, error) { - u, err := url.Parse(registry) - if err != nil { - return "", err + if strings.HasPrefix(registry, "http://") || strings.HasPrefix(registry, "https://") { + u, err := url.Parse(registry) + if err != nil { + return "", err + } + return u.Hostname(), nil } - return u.Hostname(), nil + + return registry, nil } func decodeSecret(data []byte) (DockerCfg, error) { @@ -262,6 +296,20 @@ func decodeSecret(data []byte) (DockerCfg, error) { return cfg, nil } +func decodeJSONSecret(data []byte) (DockerCfg, error) { + var cfg DockerCfgJSON + err := json.Unmarshal(data, &cfg) + if err != nil { + return nil, err + } + return cfg.Auths, nil +} + +// DockerCfgJSON - secret structure when dockerconfigjson is used +type DockerCfgJSON struct { + Auths DockerCfg `json:"auths"` +} + // DockerCfg - registry_name=auth type DockerCfg map[string]*Auth diff --git a/secrets/secrets_test.go b/secrets/secrets_test.go index 347ff40e..e374bb41 100644 --- a/secrets/secrets_test.go +++ b/secrets/secrets_test.go @@ -12,6 +12,14 @@ import ( ) var secretDataPayload = `{"https://index.docker.io/v1/":{"username":"user-x","password":"pass-x","email":"karolis.rusenas@gmail.com","auth":"somethinghere"}}` +var secretDockerConfigJSONPayload = `{ + "auths": { + "quay.io": { + "auth": "a2VlbHVzZXIra2VlbHRlc3Q6U05NR0lIVlRHUkRLSTZQMTdPTkVWUFBDQUpON1g5Sk1XUDg2ODJLWDA1RDdUQU5SWDRXMDhIUEw5QldRTDAxSg==", + "email": "" + } + } + }` func mustEncode(data string) string { return base64.StdEncoding.EncodeToString([]byte(data)) @@ -23,7 +31,7 @@ func TestGetSecret(t *testing.T) { impl := &testutil.FakeK8sImplementer{ AvailableSecret: &v1.Secret{ Data: map[string][]byte{ - dockerConfigJSONKey: []byte(secretDataPayload), + dockerConfigKey: []byte(secretDataPayload), }, Type: v1.SecretTypeDockercfg, }, @@ -51,6 +59,40 @@ func TestGetSecret(t *testing.T) { } } +func TestGetDockerConfigJSONSecret(t *testing.T) { + imgRef, _ := image.Parse("quay.io/karolisr/webhook-demo:0.0.11") + + impl := &testutil.FakeK8sImplementer{ + AvailableSecret: &v1.Secret{ + Data: map[string][]byte{ + dockerConfigJSONKey: []byte(secretDockerConfigJSONPayload), + }, + Type: v1.SecretTypeDockerConfigJson, + }, + } + + getter := NewGetter(impl) + + trackedImage := &types.TrackedImage{ + Image: imgRef, + Namespace: "default", + Secrets: []string{"myregistrysecret"}, + } + + creds, err := getter.Get(trackedImage) + if err != nil { + t.Errorf("failed to get creds: %s", err) + } + + if creds.Username != "keeluser+keeltest" { + t.Errorf("unexpected username: %s", creds.Username) + } + + if creds.Password != "SNMGIHVTGRDKI6P17ONEVPPCAJN7X9JMWP8682KX05D7TANRX4W08HPL9BWQL01J" { + t.Errorf("unexpected pass: %s", creds.Password) + } +} + func TestGetSecretNotFound(t *testing.T) { imgRef, _ := image.Parse("karolisr/webhook-demo:0.0.11") @@ -100,7 +142,7 @@ func TestLookupHelmSecret(t *testing.T) { }, AvailableSecret: &v1.Secret{ Data: map[string][]byte{ - dockerConfigJSONKey: []byte(fmt.Sprintf(secretDataPayloadEncoded, mustEncode("user-y:pass-y"))), + dockerConfigKey: []byte(fmt.Sprintf(secretDataPayloadEncoded, mustEncode("user-y:pass-y"))), }, Type: v1.SecretTypeDockercfg, }, @@ -146,7 +188,7 @@ func TestLookupHelmEncodedSecret(t *testing.T) { }, AvailableSecret: &v1.Secret{ Data: map[string][]byte{ - dockerConfigJSONKey: []byte(secretDataPayload), + dockerConfigKey: []byte(secretDataPayload), }, Type: v1.SecretTypeDockercfg, }, @@ -265,3 +307,40 @@ func Test_decodeBase64Secret(t *testing.T) { }) } } + +func Test_hostname(t *testing.T) { + type args struct { + registry string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "dockerhub", + args: args{registry: "https://index.docker.io/v1/"}, + want: "index.docker.io", + wantErr: false, + }, + { + name: "quay", + args: args{registry: "quay.io"}, + want: "quay.io", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := hostname(tt.args.registry) + if (err != nil) != tt.wantErr { + t.Errorf("hostname() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("hostname() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/types/types.go b/types/types.go index 46ca34f9..a3b39437 100644 --- a/types/types.go +++ b/types/types.go @@ -9,6 +9,7 @@ package types import ( "bytes" "fmt" + "strings" "time" ) @@ -224,6 +225,46 @@ const ( LevelFatal ) +// ParseLevel takes a string level and returns notification level constant. +func ParseLevel(lvl string) (Level, error) { + switch strings.ToLower(lvl) { + case "fatal": + return LevelFatal, nil + case "error": + return LevelError, nil + case "warn", "warning": + return LevelWarn, nil + case "info": + return LevelInfo, nil + case "success": + return LevelSuccess, nil + case "debug": + return LevelDebug, nil + } + + var l Level + return l, fmt.Errorf("not a valid notification Level: %q", lvl) +} + +func (l Level) String() string { + switch l { + case LevelDebug: + return "debug" + case LevelInfo: + return "info" + case LevelSuccess: + return "success" + case LevelWarn: + return "warn" + case LevelError: + return "error" + case LevelFatal: + return "fatal" + default: + return "unknown" + } +} + // Color - used to assign different colors for events func (l Level) Color() string { switch l {