From c6e96ffd74b3150aa922dcd455b2631d6f4f1743 Mon Sep 17 00:00:00 2001 From: Pavel Zavora Date: Fri, 10 Jul 2020 08:14:57 +0200 Subject: [PATCH] feat(notification/teams): add endpoint and rule --- notification/endpoint/endpoint.go | 2 + notification/endpoint/endpoint_test.go | 113 +++++++++ notification/endpoint/teams.go | 72 ++++++ notification/rule/rule.go | 1 + notification/rule/rule_test.go | 36 +++ notification/rule/teams.go | 127 ++++++++++ notification/rule/teams_test.go | 335 +++++++++++++++++++++++++ 7 files changed, 686 insertions(+) create mode 100644 notification/endpoint/teams.go create mode 100644 notification/rule/teams.go create mode 100644 notification/rule/teams_test.go diff --git a/notification/endpoint/endpoint.go b/notification/endpoint/endpoint.go index 05956ba247e..6656cef1e11 100644 --- a/notification/endpoint/endpoint.go +++ b/notification/endpoint/endpoint.go @@ -13,6 +13,7 @@ const ( PagerDutyType = "pagerduty" HTTPType = "http" TelegramType = "telegram" + TeamsType = "teams" ) var typeToEndpoint = map[string]func() influxdb.NotificationEndpoint{ @@ -20,6 +21,7 @@ var typeToEndpoint = map[string]func() influxdb.NotificationEndpoint{ PagerDutyType: func() influxdb.NotificationEndpoint { return &PagerDuty{} }, HTTPType: func() influxdb.NotificationEndpoint { return &HTTP{} }, TelegramType: func() influxdb.NotificationEndpoint { return &Telegram{} }, + TeamsType: func() influxdb.NotificationEndpoint { return &Teams{} }, } // UnmarshalJSON will convert the bytes to notification endpoint. diff --git a/notification/endpoint/endpoint_test.go b/notification/endpoint/endpoint_test.go index db16e9130fb..06e352d8ca7 100644 --- a/notification/endpoint/endpoint_test.go +++ b/notification/endpoint/endpoint_test.go @@ -193,6 +193,24 @@ func TestValidEndpoint(t *testing.T) { }, err: nil, }, + { + name: "empty teams url", + src: &endpoint.Teams{ + Base: goodBase, + }, + err: &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "teams: empty URL", + }, + }, + { + name: "empty teams SecretURLSuffix", + src: &endpoint.Teams{ + Base: goodBase, + URL: "http://localhost", + }, + err: nil, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -305,6 +323,39 @@ func TestJSON(t *testing.T) { Token: influxdb.SecretField{Key: "token-key-1"}, }, }, + { + name: "teams with secretURLSuffix", + src: &endpoint.Teams{ + Base: endpoint.Base{ + ID: influxTesting.MustIDBase16Ptr(id1), + Name: "name1", + OrgID: influxTesting.MustIDBase16Ptr(id3), + Status: influxdb.Active, + CRUDLog: influxdb.CRUDLog{ + CreatedAt: timeGen1.Now(), + UpdatedAt: timeGen2.Now(), + }, + }, + URL: "https://outlook.office.com/webhook/", + SecretURLSuffix: influxdb.SecretField{Key: "token-key-1"}, + }, + }, + { + name: "teams without secretURLSuffix", + src: &endpoint.Teams{ + Base: endpoint.Base{ + ID: influxTesting.MustIDBase16Ptr(id1), + Name: "name1", + OrgID: influxTesting.MustIDBase16Ptr(id3), + Status: influxdb.Active, + CRUDLog: influxdb.CRUDLog{ + CreatedAt: timeGen1.Now(), + UpdatedAt: timeGen2.Now(), + }, + }, + URL: "https://outlook.office.com/webhook/0acbc9c2-c262-11ea-b3de-0242ac130004", + }, + }, } for _, c := range cases { b, err := json.Marshal(c.src) @@ -478,6 +529,42 @@ func TestBackFill(t *testing.T) { }, }, }, + { + name: "simple Teams", + src: &endpoint.Teams{ + Base: endpoint.Base{ + ID: influxTesting.MustIDBase16Ptr(id1), + Name: "name1", + OrgID: influxTesting.MustIDBase16Ptr(id3), + Status: influxdb.Active, + CRUDLog: influxdb.CRUDLog{ + CreatedAt: timeGen1.Now(), + UpdatedAt: timeGen2.Now(), + }, + }, + URL: "https://outlook.office.com/webhook/", + SecretURLSuffix: influxdb.SecretField{ + Value: strPtr("token-value"), + }, + }, + target: &endpoint.Teams{ + Base: endpoint.Base{ + ID: influxTesting.MustIDBase16Ptr(id1), + Name: "name1", + OrgID: influxTesting.MustIDBase16Ptr(id3), + Status: influxdb.Active, + CRUDLog: influxdb.CRUDLog{ + CreatedAt: timeGen1.Now(), + UpdatedAt: timeGen2.Now(), + }, + }, + URL: "https://outlook.office.com/webhook/", + SecretURLSuffix: influxdb.SecretField{ + Key: id1 + "-token", + Value: strPtr("token-value"), + }, + }, + }, } for _, c := range cases { c.src.BackfillSecretKeys() @@ -605,6 +692,32 @@ func TestSecretFields(t *testing.T) { }, }, }, + { + name: "simple Teams", + src: &endpoint.Teams{ + Base: endpoint.Base{ + ID: influxTesting.MustIDBase16Ptr(id1), + Name: "name1", + OrgID: influxTesting.MustIDBase16Ptr(id3), + Status: influxdb.Active, + CRUDLog: influxdb.CRUDLog{ + CreatedAt: timeGen1.Now(), + UpdatedAt: timeGen2.Now(), + }, + }, + URL: "https://outlook.office.com/webhook/", + SecretURLSuffix: influxdb.SecretField{ + Key: id1 + "-token", + Value: strPtr("token-value"), + }, + }, + secrets: []influxdb.SecretField{ + { + Key: id1 + "-token", + Value: strPtr("token-value"), + }, + }, + }, } for _, c := range cases { secretFields := c.src.SecretFields() diff --git a/notification/endpoint/teams.go b/notification/endpoint/teams.go new file mode 100644 index 00000000000..8e428d613be --- /dev/null +++ b/notification/endpoint/teams.go @@ -0,0 +1,72 @@ +package endpoint + +import ( + "encoding/json" + + "github.com/influxdata/influxdb/v2" +) + +var _ influxdb.NotificationEndpoint = &Teams{} + +const teamsSecretSuffix = "-token" + +// Teams is the notification endpoint config of Microdoft teams. +type Teams struct { + Base + // URL is the teams incoming webhook URL, see https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#setting-up-a-custom-incoming-webhook , + // for example: https://outlook.office.com/webhook/0acbc9c2-c262-11ea-b3de-0242ac130004 + URL string `json:"url"` + // SecretURLSuffix is an optional secret suffix that is added to URL , + // for example: 0acbc9c2-c262-11ea-b3de-0242ac130004 is the secret part that is added to https://outlook.office.com/webhook/ + SecretURLSuffix influxdb.SecretField `json:"secretURLSuffix"` +} + +// BackfillSecretKeys fill back the secret field key during the unmarshalling +// if value of that secret field is not nil. +func (s *Teams) BackfillSecretKeys() { + if s.SecretURLSuffix.Key == "" && s.SecretURLSuffix.Value != nil { + s.SecretURLSuffix.Key = s.idStr() + teamsSecretSuffix + } +} + +// SecretFields return available secret fields. +func (s Teams) SecretFields() []influxdb.SecretField { + arr := []influxdb.SecretField{} + if s.SecretURLSuffix.Key != "" { + arr = append(arr, s.SecretURLSuffix) + } + return arr +} + +// Valid returns error if some configuration is invalid +func (s Teams) Valid() error { + if err := s.Base.valid(); err != nil { + return err + } + if s.URL == "" { + return &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "teams: empty URL", + } + } + return nil +} + +type teamsAlias Teams + +// MarshalJSON implement json.Marshaler interface. +func (s Teams) MarshalJSON() ([]byte, error) { + return json.Marshal( + struct { + teamsAlias + Type string `json:"type"` + }{ + teamsAlias: teamsAlias(s), + Type: s.Type(), + }) +} + +// Type returns the type. +func (s Teams) Type() string { + return TeamsType +} diff --git a/notification/rule/rule.go b/notification/rule/rule.go index 7482f3f07a8..f4023e05904 100644 --- a/notification/rule/rule.go +++ b/notification/rule/rule.go @@ -17,6 +17,7 @@ var typeToRule = map[string](func() influxdb.NotificationRule){ "pagerduty": func() influxdb.NotificationRule { return &PagerDuty{} }, "http": func() influxdb.NotificationRule { return &HTTP{} }, "telegram": func() influxdb.NotificationRule { return &Telegram{} }, + "teams": func() influxdb.NotificationRule { return &Teams{} }, } // UnmarshalJSON will convert diff --git a/notification/rule/rule_test.go b/notification/rule/rule_test.go index 91475cd950b..e0fc8c90d96 100644 --- a/notification/rule/rule_test.go +++ b/notification/rule/rule_test.go @@ -356,6 +356,42 @@ func TestJSON(t *testing.T) { MessageTemplate: "blah", }, }, + { + name: "simple teams", + src: &rule.Teams{ + Base: rule.Base{ + ID: influxTesting.MustIDBase16(id1), + OwnerID: influxTesting.MustIDBase16(id2), + Name: "name1", + OrgID: influxTesting.MustIDBase16(id3), + RunbookLink: "runbooklink1", + SleepUntil: &time3, + Every: mustDuration("1h"), + TagRules: []notification.TagRule{ + { + Tag: influxdb.Tag{ + Key: "k1", + Value: "v1", + }, + Operator: influxdb.NotEqual, + }, + { + Tag: influxdb.Tag{ + Key: "k2", + Value: "v2", + }, + Operator: influxdb.RegexEqual, + }, + }, + CRUDLog: influxdb.CRUDLog{ + CreatedAt: timeGen1.Now(), + UpdatedAt: timeGen2.Now(), + }, + }, + Title: "my title", + MessageTemplate: "msg1", + }, + }, } for _, c := range cases { b, err := json.Marshal(c.src) diff --git a/notification/rule/teams.go b/notification/rule/teams.go new file mode 100644 index 00000000000..71be3d8b19c --- /dev/null +++ b/notification/rule/teams.go @@ -0,0 +1,127 @@ +package rule + +import ( + "encoding/json" + "fmt" + + "github.com/influxdata/flux/ast" + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/notification/endpoint" + "github.com/influxdata/influxdb/v2/notification/flux" +) + +// Teams is the notification rule config of Microsoft Teams. +type Teams struct { + Base + Title string `json:"title"` + MessageTemplate string `json:"messageTemplate"` + Summary string `json:"summary"` +} + +// GenerateFlux generates a flux script for the teams notification rule. +func (s *Teams) GenerateFlux(e influxdb.NotificationEndpoint) (string, error) { + teamsEndpoint, ok := e.(*endpoint.Teams) + if !ok { + return "", fmt.Errorf("endpoint provided is a %s, not a Teams endpoint", e.Type()) + } + p, err := s.GenerateFluxAST(teamsEndpoint) + if err != nil { + return "", err + } + return ast.Format(p), nil +} + +// GenerateFluxAST generates a flux AST for the teams notification rule. +func (s *Teams) GenerateFluxAST(e *endpoint.Teams) (*ast.Package, error) { + f := flux.File( + s.Name, + flux.Imports("influxdata/influxdb/monitor", "contrib/sranka/teams", "influxdata/influxdb/secrets", "experimental"), + s.generateFluxASTBody(e), + ) + return &ast.Package{Package: "main", Files: []*ast.File{f}}, nil +} + +func (s *Teams) generateFluxASTBody(e *endpoint.Teams) []ast.Statement { + var statements []ast.Statement + statements = append(statements, s.generateTaskOption()) + statements = append(statements, s.generateFluxASTSecrets(e)) + statements = append(statements, s.generateFluxASTEndpoint(e)) + statements = append(statements, s.generateFluxASTNotificationDefinition(e)) + statements = append(statements, s.generateFluxASTStatuses()) + statements = append(statements, s.generateLevelChecks()...) + statements = append(statements, s.generateFluxASTNotifyPipe(e)) + + return statements +} + +func (s *Teams) generateFluxASTSecrets(e *endpoint.Teams) ast.Statement { + if e.SecretURLSuffix.Key != "" { + call := flux.Call(flux.Member("secrets", "get"), flux.Object(flux.Property("key", flux.String(e.SecretURLSuffix.Key)))) + return flux.DefineVariable("teams_url_suffix", call) + } + return flux.DefineVariable("teams_url_suffix", flux.String("")) +} + +func (s *Teams) generateFluxASTEndpoint(e *endpoint.Teams) ast.Statement { + props := []*ast.Property{} + props = append(props, flux.Property("url", flux.String(e.URL+"${teams_url_suffix}"))) + call := flux.Call(flux.Member("teams", "endpoint"), flux.Object(props...)) + + return flux.DefineVariable("teams_endpoint", call) +} + +func (s *Teams) generateFluxASTNotifyPipe(e *endpoint.Teams) ast.Statement { + endpointProps := []*ast.Property{} + endpointProps = append(endpointProps, flux.Property("title", flux.String(s.Title))) + endpointProps = append(endpointProps, flux.Property("text", flux.String(s.MessageTemplate))) + endpointProps = append(endpointProps, flux.Property("summary", flux.String(s.Summary))) + endpointFn := flux.Function(flux.FunctionParams("r"), flux.Object(endpointProps...)) + + props := []*ast.Property{} + props = append(props, flux.Property("data", flux.Identifier("notification"))) + props = append(props, flux.Property("endpoint", + flux.Call(flux.Identifier("teams_endpoint"), flux.Object(flux.Property("mapFn", endpointFn))))) + + call := flux.Call(flux.Member("monitor", "notify"), flux.Object(props...)) + + return flux.ExpressionStatement(flux.Pipe(flux.Identifier("all_statuses"), call)) +} + +type teamsAlias Teams + +// MarshalJSON implement json.Marshaler interface. +func (s Teams) MarshalJSON() ([]byte, error) { + return json.Marshal( + struct { + teamsAlias + Type string `json:"type"` + }{ + teamsAlias: teamsAlias(s), + Type: s.Type(), + }) +} + +// Valid returns where the config is valid. +func (s Teams) Valid() error { + if err := s.Base.valid(); err != nil { + return err + } + if s.Title == "" { + return &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "teams: empty title", + } + } + if s.MessageTemplate == "" { + return &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "teams: empty messageTemplate", + } + } + return nil +} + +// Type returns the type of the rule config. +func (s Teams) Type() string { + return endpoint.TeamsType +} diff --git a/notification/rule/teams_test.go b/notification/rule/teams_test.go new file mode 100644 index 00000000000..fe92e1b4e31 --- /dev/null +++ b/notification/rule/teams_test.go @@ -0,0 +1,335 @@ +package rule_test + +import ( + "testing" + + "github.com/andreyvit/diff" + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/notification" + "github.com/influxdata/influxdb/v2/notification/endpoint" + "github.com/influxdata/influxdb/v2/notification/rule" + influxTesting "github.com/influxdata/influxdb/v2/testing" +) + +var _ influxdb.NotificationRule = &rule.Teams{} + +func TestTeams_GenerateFlux(t *testing.T) { + tests := []struct { + name string + rule *rule.Teams + endpoint influxdb.NotificationEndpoint + script string + }{ + { + name: "incompatible with endpoint", + endpoint: &endpoint.Slack{ + Base: endpoint.Base{ + ID: idPtr(3), + Name: "foo", + }, + URL: "http://whatever", + }, + rule: &rule.Teams{ + Title: "blah", + MessageTemplate: "blah", + Base: rule.Base{ + ID: 1, + EndpointID: 3, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Critical, + }, + }, + TagRules: []notification.TagRule{ + { + Tag: influxdb.Tag{ + Key: "foo", + Value: "bar", + }, + Operator: influxdb.Equal, + }, + { + Tag: influxdb.Tag{ + Key: "baz", + Value: "bang", + }, + Operator: influxdb.Equal, + }, + }, + }, + }, + script: "", //no script generated because of incompatible endpoint + }, + { + name: "notify on crit", + endpoint: &endpoint.Teams{ + Base: endpoint.Base{ + ID: idPtr(3), + Name: "foo", + }, + URL: "http://whatever", + }, + rule: &rule.Teams{ + Title: "bleh", + MessageTemplate: "blah", + Base: rule.Base{ + ID: 1, + EndpointID: 3, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Critical, + }, + }, + TagRules: []notification.TagRule{ + { + Tag: influxdb.Tag{ + Key: "foo", + Value: "bar", + }, + Operator: influxdb.Equal, + }, + { + Tag: influxdb.Tag{ + Key: "baz", + Value: "bang", + }, + Operator: influxdb.Equal, + }, + }, + }, + }, + script: `package main +// foo +import "influxdata/influxdb/monitor" +import "contrib/sranka/teams" +import "influxdata/influxdb/secrets" +import "experimental" + +option task = {name: "foo", every: 1h} + +teams_url_suffix = "" +teams_endpoint = teams["endpoint"](url: "http://whatever${teams_url_suffix}") +notification = { + _notification_rule_id: "0000000000000001", + _notification_rule_name: "foo", + _notification_endpoint_id: "0000000000000003", + _notification_endpoint_name: "foo", +} +statuses = monitor["from"](start: -2h, fn: (r) => + (r["foo"] == "bar" and r["baz"] == "bang")) +crit = statuses + |> filter(fn: (r) => + (r["_level"] == "crit")) +all_statuses = crit + |> filter(fn: (r) => + (r["_time"] >= experimental["subDuration"](from: now(), d: 1h))) + +all_statuses + |> monitor["notify"](data: notification, endpoint: teams_endpoint(mapFn: (r) => + ({title: "bleh", text: "blah", summary: ""})))`, + }, + { + name: "with SecretUrlSuffix", + endpoint: &endpoint.Teams{ + Base: endpoint.Base{ + ID: idPtr(3), + Name: "foo", + }, + URL: "http://whatever", + SecretURLSuffix: influxdb.SecretField{Key: "3-token"}, + }, + rule: &rule.Teams{ + Title: "bleh", + MessageTemplate: "blah", + Base: rule.Base{ + ID: 1, + EndpointID: 3, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Any, + }, + }, + TagRules: []notification.TagRule{ + { + Tag: influxdb.Tag{ + Key: "foo", + Value: "bar", + }, + Operator: influxdb.Equal, + }, + { + Tag: influxdb.Tag{ + Key: "baz", + Value: "bang", + }, + Operator: influxdb.Equal, + }, + }, + }, + }, + script: `package main +// foo +import "influxdata/influxdb/monitor" +import "contrib/sranka/teams" +import "influxdata/influxdb/secrets" +import "experimental" + +option task = {name: "foo", every: 1h} + +teams_url_suffix = secrets["get"](key: "3-token") +teams_endpoint = teams["endpoint"](url: "http://whatever${teams_url_suffix}") +notification = { + _notification_rule_id: "0000000000000001", + _notification_rule_name: "foo", + _notification_endpoint_id: "0000000000000003", + _notification_endpoint_name: "foo", +} +statuses = monitor["from"](start: -2h, fn: (r) => + (r["foo"] == "bar" and r["baz"] == "bang")) +any = statuses + |> filter(fn: (r) => + (true)) +all_statuses = any + |> filter(fn: (r) => + (r["_time"] >= experimental["subDuration"](from: now(), d: 1h))) + +all_statuses + |> monitor["notify"](data: notification, endpoint: teams_endpoint(mapFn: (r) => + ({title: "bleh", text: "blah", summary: ""})))`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + script, err := tt.rule.GenerateFlux(tt.endpoint) + if err != nil { + if script != "" { + t.Errorf("Failed to generate flux: %v", err) + } + return + } + + if got, want := script, tt.script; got != want { + t.Errorf("\n\nStrings do not match:\n\n%s", diff.LineDiff(got, want)) + } + }) + } +} + +func TestTeams_Valid(t *testing.T) { + cases := []struct { + name string + rule *rule.Teams + err error + }{ + { + name: "valid template", + rule: &rule.Teams{ + Title: "abc", + MessageTemplate: "blah", + Base: rule.Base{ + ID: 1, + EndpointID: 3, + OwnerID: 4, + OrgID: 5, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Critical, + }, + }, + TagRules: []notification.TagRule{}, + }, + }, + err: nil, + }, + { + name: "missing MessageTemplate", + rule: &rule.Teams{ + Title: "abc", + MessageTemplate: "", + Base: rule.Base{ + ID: 1, + EndpointID: 3, + OwnerID: 4, + OrgID: 5, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Critical, + }, + }, + TagRules: []notification.TagRule{}, + }, + }, + err: &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "teams: empty messageTemplate", + }, + }, + { + name: "missing Title", + rule: &rule.Teams{ + Title: "", + MessageTemplate: "abc", + Base: rule.Base{ + ID: 1, + EndpointID: 3, + OwnerID: 4, + OrgID: 5, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Critical, + }, + }, + TagRules: []notification.TagRule{}, + }, + }, + err: &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "teams: empty title", + }, + }, + { + name: "missing EndpointID", + rule: &rule.Teams{ + MessageTemplate: "", + Base: rule.Base{ + ID: 1, + // EndpointID: 3, + OwnerID: 4, + OrgID: 5, + Name: "foo", + Every: mustDuration("1h"), + StatusRules: []notification.StatusRule{ + { + CurrentLevel: notification.Critical, + }, + }, + TagRules: []notification.TagRule{}, + }, + }, + err: &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "Notification Rule EndpointID is invalid", + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.rule.Valid() + influxTesting.ErrorsEqual(t, got, c.err) + }) + } + +}