diff --git a/cmd/channel/email/main.go b/cmd/channel/email/main.go index 4441180d0..ac9c16f28 100644 --- a/cmd/channel/email/main.go +++ b/cmd/channel/email/main.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/creasty/defaults" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/google/uuid" @@ -26,7 +27,7 @@ type Email struct { Host string `json:"host"` Port string `json:"port"` SenderName string `json:"sender_name"` - SenderMail string `json:"sender_mail"` + SenderMail string `json:"sender_mail" default:"icinga@example.com"` User string `json:"user"` Password string `json:"password"` Encryption string `json:"encryption"` @@ -95,7 +96,12 @@ func (ch *Email) Send(reversePath string, recipients []string, msg []byte) error } func (ch *Email) SetConfig(jsonStr json.RawMessage) error { - err := json.Unmarshal(jsonStr, ch) + err := defaults.Set(ch) + if err != nil { + return err + } + + err = json.Unmarshal(jsonStr, ch) if err != nil { return fmt.Errorf("failed to load config: %s %w", jsonStr, err) } @@ -108,87 +114,83 @@ func (ch *Email) SetConfig(jsonStr json.RawMessage) error { } func (ch *Email) GetInfo() *plugin.Info { - elements := []*plugin.ConfigOption{ - { - Name: "sender_name", - Type: "string", - Label: map[string]string{ - "en_US": "Sender Name", - "de_DE": "Absendername", - }, - }, - { - Name: "sender_mail", - Type: "string", - Label: map[string]string{ - "en_US": "Sender Address", - "de_DE": "Absenderadresse", + info, err := plugin.NewInfo( + &Email{}, + "Email", + internal.Version.Version, + "Icinga GmbH", + []*plugin.ConfigOption{ + { + Name: "host", + Type: "string", + Required: true, + Label: map[string]string{ + "en_US": "SMTP Host", + "de_DE": "SMTP Host", + }, }, - Default: "icinga@example.com", - }, - { - Name: "host", - Type: "string", - Required: true, - Label: map[string]string{ - "en_US": "SMTP Host", - "de_DE": "SMTP Host", + { + Name: "port", + Type: "number", + Required: true, + Label: map[string]string{ + "en_US": "SMTP Port", + "de_DE": "SMTP Port", + }, + Min: types.Int{NullInt64: sql.NullInt64{Int64: 1, Valid: true}}, + Max: types.Int{NullInt64: sql.NullInt64{Int64: 65535, Valid: true}}, }, - }, - { - Name: "port", - Type: "number", - Required: true, - Label: map[string]string{ - "en_US": "SMTP Port", - "de_DE": "SMTP Port", + { + Name: "sender_name", + Type: "string", + Label: map[string]string{ + "en_US": "Sender Name", + "de_DE": "Absendername", + }, }, - Min: types.Int{NullInt64: sql.NullInt64{Int64: 1, Valid: true}}, - Max: types.Int{NullInt64: sql.NullInt64{Int64: 65535, Valid: true}}, - }, - { - Name: "user", - Type: "string", - Label: map[string]string{ - "en_US": "SMTP User", - "de_DE": "SMTP Benutzer", + { + Name: "sender_mail", + Type: "string", + Label: map[string]string{ + "en_US": "Sender Address", + "de_DE": "Absenderadresse", + }, }, - }, - { - Name: "password", - Type: "secret", - Label: map[string]string{ - "en_US": "SMTP Password", - "de_DE": "SMTP Passwort", + { + Name: "user", + Type: "string", + Label: map[string]string{ + "en_US": "SMTP User", + "de_DE": "SMTP Benutzer", + }, }, - }, - { - Name: "encryption", - Type: "option", - Required: true, - Label: map[string]string{ - "en_US": "SMTP Transport Encryption", - "de_DE": "SMTP Transportverschlüsselung", + { + Name: "password", + Type: "secret", + Label: map[string]string{ + "en_US": "SMTP Password", + "de_DE": "SMTP Passwort", + }, }, - Options: map[string]string{ - EncryptionNone: "None", - EncryptionStartTLS: "STARTTLS", - EncryptionTLS: "TLS", + { + Name: "encryption", + Type: "option", + Required: true, + Label: map[string]string{ + "en_US": "SMTP Transport Encryption", + "de_DE": "SMTP Transportverschlüsselung", + }, + Options: map[string]string{ + EncryptionNone: "None", + EncryptionStartTLS: "STARTTLS", + EncryptionTLS: "TLS", + }, }, - }, - } - - configAttrs, err := json.Marshal(elements) + }) if err != nil { panic(err) } - - return &plugin.Info{ - Name: "Email", - Version: internal.Version.Version, - Author: "Icinga GmbH", - ConfigAttributes: configAttrs, - } + return info } func (ch *Email) GetServer() string { diff --git a/cmd/channel/email/main_test.go b/cmd/channel/email/main_test.go new file mode 100644 index 000000000..cb64b61e6 --- /dev/null +++ b/cmd/channel/email/main_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestEmail_SetConfig(t *testing.T) { + tests := []struct { + name string + jsonMsg string + want *Email + wantErr bool + }{ + { + name: "empty-string", + jsonMsg: ``, + wantErr: true, + }, + { + name: "empty-json-obj", + jsonMsg: `{}`, + want: &Email{SenderMail: "icinga@example.com"}, + }, + { + name: "sender-mail-empty-val", + jsonMsg: `{"sender_mail": ""}`, + want: &Email{SenderMail: ""}, + }, + { + name: "example", + jsonMsg: `{"sender_name":"icinga","sender_mail":"icinga@example.com","host":"smtp.example.com","port":"25","encryption":"none"}`, + want: &Email{ + Host: "smtp.example.com", + Port: "25", + SenderName: "icinga", + SenderMail: "icinga@example.com", + User: "", + Password: "", + Encryption: "none", + }, + }, + { + name: "user-but-missing-pass", + jsonMsg: `{"user": "foo"}`, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &Email{} + err := email.SetConfig(json.RawMessage(tt.jsonMsg)) + if (err != nil) != tt.wantErr { + t.Errorf("SetConfig() error = %v, wantErr %v", err, tt.wantErr) + } else if tt.wantErr { + return + } + + if !reflect.DeepEqual(email, tt.want) { + t.Errorf("Email is\n\t%#v\n, expected\n\t%#v", email, tt.want) + } + }) + } +} diff --git a/cmd/channel/rocketchat/main.go b/cmd/channel/rocketchat/main.go index 143c5b9e0..dc3dff274 100644 --- a/cmd/channel/rocketchat/main.go +++ b/cmd/channel/rocketchat/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/creasty/defaults" "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/pkg/plugin" "net/http" @@ -77,50 +78,51 @@ func (ch *RocketChat) SendNotification(req *plugin.NotificationRequest) error { } func (ch *RocketChat) SetConfig(jsonStr json.RawMessage) error { + err := defaults.Set(ch) + if err != nil { + return err + } + return json.Unmarshal(jsonStr, ch) } func (ch *RocketChat) GetInfo() *plugin.Info { - - elements := []*plugin.ConfigOption{ - { - Name: "url", - Type: "string", - Label: map[string]string{ - "en_US": "Rocket.Chat URL", - "de_DE": "Rocket.Chat URL", + info, err := plugin.NewInfo( + &RocketChat{}, + "Rocket.Chat", + internal.Version.Version, + "Icinga GmbH", + []*plugin.ConfigOption{ + { + Name: "url", + Type: "string", + Label: map[string]string{ + "en_US": "Rocket.Chat URL", + "de_DE": "Rocket.Chat URL", + }, + Required: true, }, - Required: true, - }, - { - Name: "user_id", - Type: "string", - Label: map[string]string{ - "en_US": "User ID", - "de_DE": "Benutzer ID", + { + Name: "user_id", + Type: "string", + Label: map[string]string{ + "en_US": "User ID", + "de_DE": "Benutzer ID", + }, + Required: true, }, - Required: true, - }, - { - Name: "token", - Type: "secret", - Label: map[string]string{ - "en_US": "Personal Access Token", - "de_DE": "Persönliches Zugangstoken", + { + Name: "token", + Type: "secret", + Label: map[string]string{ + "en_US": "Personal Access Token", + "de_DE": "Persönliches Zugangstoken", + }, + Required: true, }, - Required: true, - }, - } - - configAttrs, err := json.Marshal(elements) + }) if err != nil { panic(err) } - - return &plugin.Info{ - Name: "Rocket.Chat", - Version: internal.Version.Version, - Author: "Icinga GmbH", - ConfigAttributes: configAttrs, - } + return info } diff --git a/cmd/channel/webhook/main.go b/cmd/channel/webhook/main.go index a5a24635e..731dbf461 100644 --- a/cmd/channel/webhook/main.go +++ b/cmd/channel/webhook/main.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/creasty/defaults" "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/pkg/plugin" "io" @@ -15,10 +16,10 @@ import ( ) type Webhook struct { - Method string `json:"method"` + Method string `json:"method" default:"POST"` URLTemplate string `json:"url_template"` - RequestBodyTemplate string `json:"request_body_template"` - ResponseStatusCodes string `json:"response_status_codes"` + RequestBodyTemplate string `json:"request_body_template" default:"{{json .}}"` + ResponseStatusCodes string `json:"response_status_codes" default:"200"` tmplUrl *template.Template tmplRequestBody *template.Template @@ -27,76 +28,75 @@ type Webhook struct { } func (ch *Webhook) GetInfo() *plugin.Info { - elements := []*plugin.ConfigOption{ - { - Name: "method", - Type: "string", - Label: map[string]string{ - "en_US": "HTTP Method", - "de_DE": "HTTP-Methode", + info, err := plugin.NewInfo( + &Webhook{}, + "Webhook", + internal.Version.Version, + "Icinga GmbH", + []*plugin.ConfigOption{ + { + Name: "method", + Type: "string", + Label: map[string]string{ + "en_US": "HTTP Method", + "de_DE": "HTTP-Methode", + }, + Help: map[string]string{ + "en_US": "HTTP request method used for the web request.", + "de_DE": "HTTP-Methode für die Anfrage.", + }, }, - Help: map[string]string{ - "en_US": "HTTP request method used for the web request.", - "de_DE": "HTTP-Methode für die Anfrage.", + { + Name: "url_template", + Type: "string", + Label: map[string]string{ + "en_US": "URL Template", + "de_DE": "URL-Template", + }, + Help: map[string]string{ + "en_US": "URL, optionally as a Go template over the current plugin.NotificationRequest.", + "de_DE": "URL, optional als Go-Template über das zu verarbeitende plugin.NotificationRequest.", + }, + Required: true, }, - Default: "POST", - }, - { - Name: "url_template", - Type: "string", - Label: map[string]string{ - "en_US": "URL Template", - "de_DE": "URL-Template", - }, - Help: map[string]string{ - "en_US": "URL, optionally as a Go template over the current plugin.NotificationRequest.", - "de_DE": "URL, optional als Go-Template über das zu verarbeitende plugin.NotificationRequest.", - }, - Required: true, - }, - { - Name: "request_body_template", - Type: "string", - Label: map[string]string{ - "en_US": "Request Body Template", - "de_DE": "Anfragedaten-Template", + { + Name: "request_body_template", + Type: "string", + Label: map[string]string{ + "en_US": "Request Body Template", + "de_DE": "Anfragedaten-Template", + }, + Help: map[string]string{ + "en_US": "Go template applied to the current plugin.NotificationRequest to create an request body.", + "de_DE": "Go-Template über das zu verarbeitende plugin.NotificationRequest zum Erzeugen der mitgesendeten Anfragedaten.", + }, }, - Help: map[string]string{ - "en_US": "Go template applied to the current plugin.NotificationRequest to create an request body.", - "de_DE": "Go-Template über das zu verarbeitende plugin.NotificationRequest zum Erzeugen der mitgesendeten Anfragedaten.", + { + Name: "response_status_codes", + Type: "string", + Label: map[string]string{ + "en_US": "Response Status Codes", + "de_DE": "Antwort-Status-Codes", + }, + Help: map[string]string{ + "en_US": "Comma separated list of expected HTTP response status code, e.g., 200,201,202,208,418", + "de_DE": "Kommaseparierte Liste erwarteter Status-Code der HTTP-Antwort, z.B.: 200,201,202,208,418", + }, }, - Default: `{{json .}}`, - }, - { - Name: "response_status_codes", - Type: "string", - Label: map[string]string{ - "en_US": "Response Status Codes", - "de_DE": "Antwort-Status-Codes", - }, - Help: map[string]string{ - "en_US": "Comma separated list of expected HTTP response status code, e.g., 200,201,202,208,418", - "de_DE": "Kommaseparierte Liste erwarteter Status-Code der HTTP-Antwort, z.B.: 200,201,202,208,418", - }, - Default: "200", - }, - } - - configAttrs, err := json.Marshal(elements) + }) if err != nil { panic(err) } - - return &plugin.Info{ - Name: "Webhook", - Version: internal.Version.Version, - Author: "Icinga GmbH", - ConfigAttributes: configAttrs, - } + return info } func (ch *Webhook) SetConfig(jsonStr json.RawMessage) error { - err := json.Unmarshal(jsonStr, ch) + err := defaults.Set(ch) + if err != nil { + return err + } + + err = json.Unmarshal(jsonStr, ch) if err != nil { return err } diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 9d3d1923a..584b3349d 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/creasty/defaults" "github.com/icinga/icinga-notifications/internal/event" "github.com/icinga/icinga-notifications/internal/utils" "github.com/icinga/icinga-notifications/pkg/rpc" @@ -11,6 +12,7 @@ import ( "io" "log" "os" + "reflect" "sync" "time" ) @@ -48,6 +50,8 @@ type ConfigOption struct { Help map[string]string `json:"help,omitempty"` // Element default: bool for checkbox default value, string for other elements (used as placeholder) + // + // The Default value will be set based on a Plugin struct field "default" tag by the NewInfo function. Default any `json:"default,omitempty"` // Set true if this element is required, omit otherwise @@ -78,6 +82,55 @@ type Info struct { ConfigAttributes json.RawMessage `db:"config_attrs" json:"config_attrs"` // ConfigOption(s) as json-encoded list } +// NewInfo constructs a new Info to be used in Plugin.GetInfo. +// +// The configOptions will be enriched with defaults from the typePtr, being a pointer to the Plugin implementation. +func NewInfo(typePtr interface{}, name, version, author string, configOptions []*ConfigOption) (*Info, error) { + err := defaults.Set(typePtr) + if err != nil { + return nil, err + } + + typeDefaults := make(map[string]any) + typeRefT := reflect.TypeOf(typePtr).Elem() + typeRefV := reflect.ValueOf(typePtr) + for i := 0; i < typeRefT.NumField(); i++ { + if !typeRefT.Field(i).IsExported() { + continue + } + + jsonVal, jsonOk := typeRefT.Field(i).Tag.Lookup("json") + _, defaultOk := typeRefT.Field(i).Tag.Lookup("default") + + if !jsonOk { + return nil, fmt.Errorf("exported field %q misses json struct tag, set to \"-\" to ignore", typeRefT.Field(i).Name) + } + if jsonVal == "-" || !defaultOk { + continue + } + + typeDefaults[jsonVal] = typeRefV.Elem().Field(i).Interface() + } + + for _, configOption := range configOptions { + if defaultVal, ok := typeDefaults[configOption.Name]; ok { + configOption.Default = defaultVal + } + } + + configAttributes, err := json.Marshal(configOptions) + if err != nil { + return nil, err + } + + return &Info{ + Name: name, + Version: version, + Author: author, + ConfigAttributes: configAttributes, + }, nil +} + // TableName implements the contracts.TableNamer interface. func (i *Info) TableName() string { return "available_channel_type" diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 000000000..3db8e5a25 --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -0,0 +1,85 @@ +package plugin + +import ( + "testing" +) + +func TestNewInfoConfigOptions(t *testing.T) { + type TestT0JsonInvalid struct { + Foo string + } + type testT1Simple struct { + Foo string `json:"foo"` + Bar string `json:"bar" default:"test123"` + } + type testT2SimpleInt struct { + Foo string `json:"foo"` + Bar int `json:"bar" default:"23"` + } + type testT3Unexported struct { + Foo string `json:"foo"` + bar string + } + type testT4Renamed struct { + Foo string `json:"fo0" default:"A"` + Bar int `json:"bAaAr" default:"23"` + } + + tests := []struct { + name string + typePtr interface{} + cfg []*ConfigOption + want string + wantErr bool + }{ + { + name: "invalid-struct-tags", + typePtr: &TestT0JsonInvalid{}, + cfg: []*ConfigOption{}, + wantErr: true, + }, + { + name: "simple", + typePtr: &testT1Simple{}, + cfg: []*ConfigOption{{Name: "foo", Type: "string"}, {Name: "bar", Type: "string"}}, + want: `[{"name":"foo","type":"string","label":null,"min":null,"max":null},{"name":"bar","type":"string","label":null,"default":"test123","min":null,"max":null}]`, + }, + { + name: "simple-int", + typePtr: &testT2SimpleInt{}, + cfg: []*ConfigOption{{Name: "foo", Type: "string"}, {Name: "bar", Type: "int"}}, + want: `[{"name":"foo","type":"string","label":null,"min":null,"max":null},{"name":"bar","type":"int","label":null,"default":23,"min":null,"max":null}]`, + }, + { + name: "unexported", + typePtr: &testT3Unexported{}, + cfg: []*ConfigOption{{Name: "foo", Type: "string"}}, + want: `[{"name":"foo","type":"string","label":null,"min":null,"max":null}]`, + }, + { + name: "renamed-key", + typePtr: &testT4Renamed{}, + cfg: []*ConfigOption{{Name: "fo0", Type: "string"}, {Name: "bAaAr", Type: "int"}}, + want: `[{"name":"fo0","type":"string","label":null,"default":"A","min":null,"max":null},{"name":"bAaAr","type":"int","label":null,"default":23,"min":null,"max":null}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := NewInfo(tt.typePtr, "__name", "__version", "__author", tt.cfg) + if (err != nil) != tt.wantErr { + t.Errorf("NewInfo() error = %v, wantErr %v", err, tt.wantErr) + return + } else if tt.wantErr { + return + } + + jsonMsg, err := info.ConfigAttributes.MarshalJSON() + if err != nil { + t.Errorf("Canont marshal JSON, %v", err) + } + if j := string(jsonMsg); j != tt.want { + t.Errorf("ConfigOptions JSON is\n\t%s\n, expected\n\t%s", j, tt.want) + } + }) + } +}