diff --git a/database/redis/contact.go b/database/redis/contact.go index b58a8f501..64c78de37 100644 --- a/database/redis/contact.go +++ b/database/redis/contact.go @@ -16,18 +16,22 @@ import ( // GetContact returns contact data by given id, if no value, return database.ErrNil error. func (connector *DbConnector) GetContact(id string) (moira.ContactData, error) { c := *connector.client + ctx := connector.context var contact moira.ContactData - result := c.Get(connector.context, contactKey(id)) + result := c.Get(ctx, contactKey(id)) if errors.Is(result.Err(), redis.Nil) { return contact, database.ErrNil } + contact, err := reply.Contact(result) if err != nil { - return contact, err + return contact, fmt.Errorf("failed to deserialize contact '%s': %w", id, err) } + contact.ID = id + return contact, nil } @@ -37,19 +41,22 @@ func (connector *DbConnector) GetContacts(contactIDs []string) ([]*moira.Contact results := make([]*redis.StringCmd, 0, len(contactIDs)) c := *connector.client + ctx := connector.context + pipe := c.TxPipeline() for _, id := range contactIDs { - result := pipe.Get(connector.context, contactKey(id)) + result := pipe.Get(ctx, contactKey(id)) results = append(results, result) } - _, err := pipe.Exec(connector.context) + + _, err := pipe.Exec(ctx) if err != nil && !errors.Is(err, redis.Nil) { - return nil, err + return nil, fmt.Errorf("failed to get contacts by id: %w", err) } contacts, err := reply.Contacts(results) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to reply contacts: %w", err) } for i := range contacts { @@ -57,6 +64,7 @@ func (connector *DbConnector) GetContacts(contactIDs []string) ([]*moira.Contact contacts[i].ID = contactIDs[i] } } + return contacts, nil } @@ -79,6 +87,7 @@ func getContactsKeysOnRedisNode(ctx context.Context, client redis.UniversalClien break } } + return keys, nil } @@ -91,6 +100,7 @@ func (connector *DbConnector) GetAllContacts() ([]*moira.ContactData, error) { if err != nil { return err } + keys = append(keys, keysResult...) return nil }) @@ -102,6 +112,7 @@ func (connector *DbConnector) GetAllContacts() ([]*moira.ContactData, error) { for _, key := range keys { contactIDs = append(contactIDs, strings.TrimPrefix(key, contactKey(""))) } + return connector.GetContacts(contactIDs) } @@ -109,33 +120,40 @@ func (connector *DbConnector) GetAllContacts() ([]*moira.ContactData, error) { func (connector *DbConnector) SaveContact(contact *moira.ContactData) error { existing, getContactErr := connector.GetContact(contact.ID) if getContactErr != nil && !errors.Is(getContactErr, database.ErrNil) { - return getContactErr + return fmt.Errorf("failed to get contact '%s': %w", contact.ID, getContactErr) } - contactString, err := json.Marshal(contact) + + contactStr, err := json.Marshal(contact) if err != nil { - return err + return fmt.Errorf("failed to marshal contact '%s': %w", contact.ID, err) } c := *connector.client + ctx := connector.context pipe := c.TxPipeline() - pipe.Set(connector.context, contactKey(contact.ID), contactString, redis.KeepTTL) + pipe.Set(ctx, contactKey(contact.ID), contactStr, redis.KeepTTL) if !errors.Is(getContactErr, database.ErrNil) && contact.User != existing.User { - pipe.SRem(connector.context, userContactsKey(existing.User), contact.ID) + pipe.SRem(ctx, userContactsKey(existing.User), contact.ID) } + if !errors.Is(getContactErr, database.ErrNil) && contact.Team != existing.Team { - pipe.SRem(connector.context, teamContactsKey(existing.Team), contact.ID) + pipe.SRem(ctx, teamContactsKey(existing.Team), contact.ID) } + if contact.User != "" { - pipe.SAdd(connector.context, userContactsKey(contact.User), contact.ID) + pipe.SAdd(ctx, userContactsKey(contact.User), contact.ID) } + if contact.Team != "" { - pipe.SAdd(connector.context, teamContactsKey(contact.Team), contact.ID) + pipe.SAdd(ctx, teamContactsKey(contact.Team), contact.ID) } - _, err = pipe.Exec(connector.context) + + _, err = pipe.Exec(ctx) if err != nil { - return fmt.Errorf("failed to EXEC: %s", err.Error()) + return fmt.Errorf("failed to save contact '%s': %w", contact.ID, err) } + return nil } @@ -143,40 +161,58 @@ func (connector *DbConnector) SaveContact(contact *moira.ContactData) error { func (connector *DbConnector) RemoveContact(contactID string) error { existing, err := connector.GetContact(contactID) if err != nil && !errors.Is(err, database.ErrNil) { - return err + return fmt.Errorf("failed to get contact '%s': %w", contactID, err) + } + + emergencyContact, getEmergencyContactErr := connector.GetEmergencyContact(contactID) + if getEmergencyContactErr != nil && !errors.Is(getEmergencyContactErr, database.ErrNil) { + return fmt.Errorf("failed to get emergency contact '%s': %w", contactID, err) } + c := *connector.client + ctx := connector.context pipe := c.TxPipeline() - pipe.Del(connector.context, contactKey(contactID)) - pipe.SRem(connector.context, userContactsKey(existing.User), contactID) - pipe.SRem(connector.context, teamContactsKey(existing.Team), contactID) - _, err = pipe.Exec(connector.context) + pipe.Del(ctx, contactKey(contactID)) + pipe.SRem(ctx, userContactsKey(existing.User), contactID) + pipe.SRem(ctx, teamContactsKey(existing.Team), contactID) + + if !errors.Is(getEmergencyContactErr, database.ErrNil) { + addRemoveEmergencyContactToPipe(ctx, pipe, emergencyContact) + } + + _, err = pipe.Exec(ctx) if err != nil { - return fmt.Errorf("failed to EXEC: %s", err.Error()) + return fmt.Errorf("failed to remove contact '%s': %w", contactID, err) } + return nil } // GetUserContactIDs returns contacts ids by given login. func (connector *DbConnector) GetUserContactIDs(login string) ([]string, error) { c := *connector.client + ctx := connector.context - contacts, err := c.SMembers(connector.context, userContactsKey(login)).Result() + contactIDs, err := c.SMembers(ctx, userContactsKey(login)).Result() if err != nil { - return nil, fmt.Errorf("failed to get contacts for user login %s: %s", login, err.Error()) + return nil, fmt.Errorf("failed to get contact IDs for user login '%s': %w", login, err) } - return contacts, nil + + return contactIDs, nil } // GetTeamContactIDs returns contacts ids by given team. func (connector *DbConnector) GetTeamContactIDs(login string) ([]string, error) { c := *connector.client - contacts, err := c.SMembers(connector.context, teamContactsKey(login)).Result() + ctx := connector.context + + contactIDs, err := c.SMembers(ctx, teamContactsKey(login)).Result() if err != nil { - return nil, fmt.Errorf("failed to get contacts for team login %s: %s", login, err.Error()) + return nil, fmt.Errorf("failed to get contact IDs for team login '%s': %w", login, err) } - return contacts, nil + + return contactIDs, nil } func contactKey(id string) string { diff --git a/database/redis/contact_test.go b/database/redis/contact_test.go index 938cf0ad2..b283b312b 100644 --- a/database/redis/contact_test.go +++ b/database/redis/contact_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/moira-alert/moira/database" + "github.com/moira-alert/moira/datatypes" "github.com/moira-alert/moira" logging "github.com/moira-alert/moira/logging/zerolog_adapter" @@ -171,6 +172,9 @@ func TestContacts(t *testing.T) { err := dataBase.SaveContact(contact2) So(err, ShouldBeNil) + err = dataBase.SaveEmergencyContact(user2EmergencyContacts[0]) + So(err, ShouldBeNil) + actual, err := dataBase.GetContact(contact2.ID) So(err, ShouldBeNil) So(actual, ShouldResemble, *contact2) @@ -179,9 +183,17 @@ func TestContacts(t *testing.T) { So(err, ShouldBeNil) So(actual1, ShouldHaveLength, 1) + emergencyContact, err := dataBase.GetEmergencyContact(contact2.ID) + So(err, ShouldBeNil) + So(emergencyContact, ShouldResemble, user2EmergencyContacts[0]) + err = dataBase.RemoveContact(contact2.ID) So(err, ShouldBeNil) + emergencyContact, err = dataBase.GetEmergencyContact(contact2.ID) + So(err, ShouldResemble, database.ErrNil) + So(emergencyContact, ShouldResemble, datatypes.EmergencyContact{}) + err = dataBase.SaveContact(contact1) So(err, ShouldBeNil) @@ -315,6 +327,9 @@ func TestContacts(t *testing.T) { err := dataBase.SaveContact(contact2) So(err, ShouldBeNil) + err = dataBase.SaveEmergencyContact(team2EmergencyContacts[0]) + So(err, ShouldBeNil) + actual, err := dataBase.GetContact(contact2.ID) So(err, ShouldBeNil) So(actual, ShouldResemble, *contact2) @@ -323,9 +338,17 @@ func TestContacts(t *testing.T) { So(err, ShouldBeNil) So(actual1, ShouldHaveLength, 1) + emergencyContact, err := dataBase.GetEmergencyContact(contact2.ID) + So(err, ShouldBeNil) + So(emergencyContact, ShouldResemble, team2EmergencyContacts[0]) + err = dataBase.RemoveContact(contact2.ID) So(err, ShouldBeNil) + emergencyContact, err = dataBase.GetEmergencyContact(contact2.ID) + So(err, ShouldResemble, database.ErrNil) + So(emergencyContact, ShouldResemble, datatypes.EmergencyContact{}) + err = dataBase.SaveContact(contact1) So(err, ShouldBeNil) @@ -519,6 +542,13 @@ var user2Contacts = []*moira.ContactData{ }, } +var user2EmergencyContacts = []datatypes.EmergencyContact{ + { + ContactID: "ContactID-000000000000003", + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatNotifierOff}, + }, +} + var team1Contacts = []*moira.ContactData{ { ID: "TeamContactID-000000000000001", @@ -572,3 +602,10 @@ var team2Contacts = []*moira.ContactData{ Team: team2, }, } + +var team2EmergencyContacts = []datatypes.EmergencyContact{ + { + ContactID: "TeamContactID-000000000000003", + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatNotifierOff}, + }, +} diff --git a/database/redis/emergency_contact.go b/database/redis/emergency_contact.go new file mode 100644 index 000000000..366ef2f5c --- /dev/null +++ b/database/redis/emergency_contact.go @@ -0,0 +1,187 @@ +package redis + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/go-redis/redis/v8" + "github.com/moira-alert/moira/database" + "github.com/moira-alert/moira/database/redis/reply" + "github.com/moira-alert/moira/datatypes" +) + +// GetEmergencyContact method to retrieve an emergency contact from the database. +func (connector *DbConnector) GetEmergencyContact(contactID string) (datatypes.EmergencyContact, error) { + c := *connector.client + ctx := connector.context + + cmd := c.Get(ctx, emergencyContactsKey(contactID)) + + if errors.Is(cmd.Err(), redis.Nil) { + return datatypes.EmergencyContact{}, database.ErrNil + } + + return reply.EmergencyContact(cmd) +} + +// GetEmergencyContacts method to retrieve all emergency contacts from the database. +func (connector *DbConnector) GetEmergencyContacts() ([]*datatypes.EmergencyContact, error) { + emergencyContactIDs, err := connector.getEmergencyContactIDs() + if err != nil { + return nil, fmt.Errorf("failed to get emergency contact IDs: %w", err) + } + + return connector.GetEmergencyContactsByIDs(emergencyContactIDs) +} + +// GetEmergencyContactsByIDs method to retrieve all emergency contacts from the database by their identifiers. +func (connector *DbConnector) GetEmergencyContactsByIDs(contactIDs []string) ([]*datatypes.EmergencyContact, error) { + c := *connector.client + ctx := connector.context + + pipe := c.TxPipeline() + for _, contactID := range contactIDs { + pipe.Get(ctx, emergencyContactsKey(contactID)) + } + + cmds, err := pipe.Exec(ctx) + if err != nil && !errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("failed to get emergency contacts by IDs: %w", err) + } + + emergencyContactCmds := make([]*redis.StringCmd, 0, len(cmds)) + for _, cmd := range cmds { + emergencyContactCmd, ok := cmd.(*redis.StringCmd) + if !ok { + return nil, fmt.Errorf("failed to convert cmd to emergency contact cmd") + } + + emergencyContactCmds = append(emergencyContactCmds, emergencyContactCmd) + } + + return reply.EmergencyContacts(emergencyContactCmds) +} + +func (connector *DbConnector) getEmergencyContactIDs() ([]string, error) { + c := *connector.client + ctx := connector.context + + var emergencyContactIDs []string + + iter := c.Scan(ctx, 0, emergencyContactsKey("*"), 0).Iterator() + for iter.Next(ctx) { + key := iter.Val() + + emergencyContactID := strings.TrimPrefix(key, emergencyContactsKey("")) + emergencyContactIDs = append(emergencyContactIDs, emergencyContactID) + } + + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("failed to scan emergency contacts: %w", err) + } + + return emergencyContactIDs, nil +} + +// GetHeartbeatTypeContactIDs a method for obtaining contact IDs by specific emergency type. +func (connector *DbConnector) GetHeartbeatTypeContactIDs(heartbeatType datatypes.HeartbeatType) ([]string, error) { + c := *connector.client + ctx := connector.context + + contactIDs, err := c.SMembers(ctx, heartbeatTypeContactsKey(heartbeatType)).Result() + if err != nil { + return nil, fmt.Errorf("failed to get heartbeat type contact IDs '%s': %w", heartbeatType, err) + } + + return contactIDs, nil +} + +func (connector *DbConnector) saveEmergencyContacts(emergencyContacts []datatypes.EmergencyContact) error { + c := *connector.client + ctx := connector.context + + pipe := c.TxPipeline() + for _, emergencyContact := range emergencyContacts { + if err := addSaveEmergencyContactToPipe(ctx, pipe, emergencyContact); err != nil { + return fmt.Errorf("failed to add save emergency contact '%s' to pipe: %w", emergencyContact.ContactID, err) + } + } + + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("failed to save emergency contacts: %w", err) + } + + return nil +} + +// SaveEmergencyContact a method for saving emergency contact. +func (connector *DbConnector) SaveEmergencyContact(emergencyContact datatypes.EmergencyContact) error { + c := *connector.client + ctx := connector.context + + pipe := c.TxPipeline() + + if err := addSaveEmergencyContactToPipe(ctx, pipe, emergencyContact); err != nil { + return err + } + + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("failed to save emergency contact '%s': %w", emergencyContact.ContactID, err) + } + + return nil +} + +// RemoveEmergencyContact method for removing emergency contact. +func (connector *DbConnector) RemoveEmergencyContact(contactID string) error { + c := *connector.client + ctx := connector.context + + emergencyContact, err := connector.GetEmergencyContact(contactID) + if err != nil { + return fmt.Errorf("failed to get emergency contact '%s': %w", contactID, err) + } + + pipe := c.TxPipeline() + + addRemoveEmergencyContactToPipe(ctx, pipe, emergencyContact) + + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("failed to remove emergency contact '%s': %w", contactID, err) + } + + return nil +} + +func addSaveEmergencyContactToPipe(ctx context.Context, pipe redis.Pipeliner, emergencyContact datatypes.EmergencyContact) error { + emergencyContactBytes, err := reply.GetEmergencyContactBytes(emergencyContact) + if err != nil { + return fmt.Errorf("failed to get emergency contact '%s' bytes: %w", emergencyContact.ContactID, err) + } + + pipe.Set(ctx, emergencyContactsKey(emergencyContact.ContactID), emergencyContactBytes, redis.KeepTTL) + + for _, heartbeatType := range emergencyContact.HeartbeatTypes { + pipe.SAdd(ctx, heartbeatTypeContactsKey(heartbeatType), emergencyContact.ContactID) + } + + return nil +} + +func addRemoveEmergencyContactToPipe(ctx context.Context, pipe redis.Pipeliner, emergencyContact datatypes.EmergencyContact) { + pipe.Del(ctx, emergencyContactsKey(emergencyContact.ContactID)) + + for _, heartbeatType := range emergencyContact.HeartbeatTypes { + pipe.SRem(ctx, heartbeatTypeContactsKey(heartbeatType), emergencyContact.ContactID) + } +} + +func emergencyContactsKey(contactID string) string { + return "moira-emergency-contacts:" + contactID +} + +func heartbeatTypeContactsKey(heartbeatType datatypes.HeartbeatType) string { + return "moira-heartbeat-type-contacts:" + string(heartbeatType) +} diff --git a/database/redis/emergency_contact_test.go b/database/redis/emergency_contact_test.go new file mode 100644 index 000000000..b86cb139c --- /dev/null +++ b/database/redis/emergency_contact_test.go @@ -0,0 +1,268 @@ +package redis + +import ( + "errors" + "testing" + + moiradb "github.com/moira-alert/moira/database" + "github.com/moira-alert/moira/datatypes" + logging "github.com/moira-alert/moira/logging/zerolog_adapter" + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" +) + +var ( + testContactID = "test-contact-id" + testContactID2 = "test-contact-id2" + testContactID3 = "test-contact-id3" + + testEmergencyContact = datatypes.EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatNotifierOff}, + } + + testEmergencyContact2 = datatypes.EmergencyContact{ + ContactID: testContactID2, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatNotifierOff}, + } + + testEmergencyContact3 = datatypes.EmergencyContact{ + ContactID: testContactID3, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HearbeatTypeNotSet}, + } +) + +func TestGetEmergencyContact(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test GetEmergencyContact", t, func() { + Convey("With unknown emergency contact", func() { + emergencyContact, err := database.GetEmergencyContact(testContactID) + So(err, ShouldResemble, moiradb.ErrNil) + So(emergencyContact, ShouldResemble, datatypes.EmergencyContact{}) + }) + + Convey("With some emergency contact", func() { + err := database.SaveEmergencyContact(testEmergencyContact) + So(err, ShouldBeNil) + + emergencyContact, err := database.GetEmergencyContact(testContactID) + So(err, ShouldBeNil) + So(emergencyContact, ShouldResemble, testEmergencyContact) + }) + }) +} + +func TestGetEmergencyContacts(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test GetEmergencyContacts", t, func() { + Convey("Without emergency contacts", func() { + emergencyContacts, err := database.GetEmergencyContacts() + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, []*datatypes.EmergencyContact{}) + }) + + Convey("With some emergency contacts", func() { + database.saveEmergencyContacts([]datatypes.EmergencyContact{ + testEmergencyContact, + testEmergencyContact2, + testEmergencyContact3, + }) + + expectedEmergencyContacts := []*datatypes.EmergencyContact{ + &testEmergencyContact, + &testEmergencyContact2, + &testEmergencyContact3, + } + + emergencyContacts, err := database.GetEmergencyContacts() + So(err, ShouldBeNil) + assert.ElementsMatch(t, emergencyContacts, expectedEmergencyContacts) + }) + }) +} + +func TestGetEmergencyContactsByIDs(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test GetEmergencyContactsByIDs", t, func() { + Convey("With empty contact ids", func() { + contactIDs := []string{} + emergencyContacts, err := database.GetEmergencyContactsByIDs(contactIDs) + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, []*datatypes.EmergencyContact{}) + }) + + Convey("With some saved contact ids", func() { + database.saveEmergencyContacts([]datatypes.EmergencyContact{ + testEmergencyContact, + testEmergencyContact2, + }) + + expectedEmergencyContacts := []*datatypes.EmergencyContact{ + &testEmergencyContact, + &testEmergencyContact2, + } + + contactIDs := []string{testContactID, testContactID2} + emergencyContacts, err := database.GetEmergencyContactsByIDs(contactIDs) + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, expectedEmergencyContacts) + }) + + Convey("With one saved and one not saved contact ids", func() { + database.Flush() + database.saveEmergencyContacts([]datatypes.EmergencyContact{ + testEmergencyContact, + }) + + expectedEmergencyContacts := []*datatypes.EmergencyContact{ + &testEmergencyContact, + nil, + } + + contactIDs := []string{testContactID, testContactID2} + emergencyContacts, err := database.GetEmergencyContactsByIDs(contactIDs) + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, expectedEmergencyContacts) + }) + }) +} + +func TestGetHeartbeatTypeContactIDs(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test GetHeartbeatTypeContactIDs", t, func() { + Convey("Without any emergency contacts by heartbeat type", func() { + emergencyContactIDs, err := database.GetHeartbeatTypeContactIDs(datatypes.HeartbeatNotifierOff) + So(err, ShouldBeNil) + So(emergencyContactIDs, ShouldBeEmpty) + }) + + Convey("With some emergency contacts by type", func() { + database.saveEmergencyContacts([]datatypes.EmergencyContact{ + testEmergencyContact, + testEmergencyContact2, + testEmergencyContact3, + }) + + emergencyContactIDs, err := database.GetHeartbeatTypeContactIDs(datatypes.HeartbeatNotifierOff) + So(err, ShouldBeNil) + assert.ElementsMatch(t, emergencyContactIDs, []string{ + testContactID, + testContactID2, + }) + + emergencyContactIDs, err = database.GetHeartbeatTypeContactIDs(datatypes.HearbeatTypeNotSet) + So(err, ShouldBeNil) + assert.ElementsMatch(t, emergencyContactIDs, []string{ + testContactID3, + }) + }) + }) +} + +func TestSaveEmergencyContact(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test SaveEmergencyContact", t, func() { + Convey("With some emergency contact", func() { + expectedEmergencyContacts := []*datatypes.EmergencyContact{&testEmergencyContact} + expectedEmergencyContactIDs := []string{testContactID} + + emergencyContacts, err := database.GetEmergencyContacts() + So(err, ShouldBeNil) + So(emergencyContacts, ShouldBeEmpty) + + err = database.SaveEmergencyContact(testEmergencyContact) + So(err, ShouldBeNil) + + emergencyContacts, err = database.GetEmergencyContacts() + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, expectedEmergencyContacts) + + emergencyContactIDs, err := database.GetHeartbeatTypeContactIDs(datatypes.HeartbeatNotifierOff) + So(err, ShouldBeNil) + So(emergencyContactIDs, ShouldResemble, expectedEmergencyContactIDs) + }) + }) +} + +func TestSaveEmergencyContacts(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test saveEmergencyContacts", t, func() { + Convey("With some emergency contacts", func() { + expectedEmergencyContacts := []*datatypes.EmergencyContact{&testEmergencyContact, &testEmergencyContact2, &testEmergencyContact3} + expectedEmergencyContactIDs := []string{testContactID, testContactID2} + + emergencyContacts, err := database.GetEmergencyContacts() + So(err, ShouldBeNil) + So(emergencyContacts, ShouldBeEmpty) + + err = database.saveEmergencyContacts([]datatypes.EmergencyContact{ + testEmergencyContact, + testEmergencyContact2, + testEmergencyContact3, + }) + So(err, ShouldBeNil) + + emergencyContacts, err = database.GetEmergencyContacts() + So(err, ShouldBeNil) + assert.ElementsMatch(t, emergencyContacts, expectedEmergencyContacts) + + emergencyContactIDs, err := database.GetHeartbeatTypeContactIDs(datatypes.HeartbeatNotifierOff) + So(err, ShouldBeNil) + assert.ElementsMatch(t, emergencyContactIDs, expectedEmergencyContactIDs) + }) + }) +} + +func TestRemoveEmergencyContact(t *testing.T) { + logger, _ := logging.GetLogger("database") + database := NewTestDatabase(logger) + database.Flush() + defer database.Flush() + + Convey("Test RemoveEmergencyContact", t, func() { + Convey("With unknown emergency contact", func() { + err := database.RemoveEmergencyContact(testContactID) + So(errors.Is(err, moiradb.ErrNil), ShouldBeTrue) + }) + + Convey("With some emergency contact", func() { + err := database.SaveEmergencyContact(testEmergencyContact) + So(err, ShouldBeNil) + + emergencyContact, err := database.GetEmergencyContact(testContactID) + So(err, ShouldBeNil) + So(emergencyContact, ShouldResemble, testEmergencyContact) + + err = database.RemoveEmergencyContact(testContactID) + So(err, ShouldResemble, nil) + + emergencyContact, err = database.GetEmergencyContact(testContactID) + So(errors.Is(err, moiradb.ErrNil), ShouldBeTrue) + So(emergencyContact, ShouldResemble, datatypes.EmergencyContact{}) + }) + }) +} diff --git a/database/redis/reply/contact.go b/database/redis/reply/contact.go index 5f3a7f290..b8d438c3b 100644 --- a/database/redis/reply/contact.go +++ b/database/redis/reply/contact.go @@ -17,12 +17,13 @@ func unmarshalContact(bytes []byte, err error) (moira.ContactData, error) { if errors.Is(err, redis.Nil) { return contact, database.ErrNil } - return contact, fmt.Errorf("failed to read contact: %s", err.Error()) + + return contact, fmt.Errorf("failed to read contact: %w", err) } err = json.Unmarshal(bytes, &contact) if err != nil { - return contact, fmt.Errorf("failed to parse contact json %s: %s", string(bytes), err.Error()) + return contact, fmt.Errorf("failed to parse contact json %s: %w", string(bytes), err) } return contact, nil @@ -39,13 +40,15 @@ func Contacts(rep []*redis.StringCmd) ([]*moira.ContactData, error) { for i, value := range rep { contact, err := unmarshalContact(value.Bytes()) if err != nil && !errors.Is(err, database.ErrNil) { - return nil, err + return nil, fmt.Errorf("failed to unmarshal contact: %w", err) } + if errors.Is(err, database.ErrNil) { contacts[i] = nil } else { contacts[i] = &contact } } + return contacts, nil } diff --git a/database/redis/reply/emergency_contact.go b/database/redis/reply/emergency_contact.go new file mode 100644 index 000000000..8e8f16ce1 --- /dev/null +++ b/database/redis/reply/emergency_contact.go @@ -0,0 +1,96 @@ +package reply + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/go-redis/redis/v8" + "github.com/moira-alert/moira/database" + "github.com/moira-alert/moira/datatypes" +) + +type emergencyContactStorageElement struct { + ContactID string `json:"contact_id"` + HeartbeatTypes []datatypes.HeartbeatType `json:"heartbeat_types"` +} + +func (se emergencyContactStorageElement) toEmergencyContact() datatypes.EmergencyContact { + return datatypes.EmergencyContact{ + ContactID: se.ContactID, + HeartbeatTypes: se.HeartbeatTypes, + } +} + +func toEmergencyContactStorageElement(emergencyContact datatypes.EmergencyContact) emergencyContactStorageElement { + return emergencyContactStorageElement{ + ContactID: emergencyContact.ContactID, + HeartbeatTypes: emergencyContact.HeartbeatTypes, + } +} + +// GetEmergencyContactBytes a method to get bytes of the emergency contact structure stored in Redis. +func GetEmergencyContactBytes(emergencyContact datatypes.EmergencyContact) ([]byte, error) { + emergencyContactSE := toEmergencyContactStorageElement(emergencyContact) + bytes, err := json.Marshal(emergencyContactSE) + if err != nil { + return nil, fmt.Errorf("failed to marshal emergency contact storage element: %w", err) + } + + return bytes, nil +} + +func unmarshalEmergencyContact(bytes []byte, err error) (datatypes.EmergencyContact, error) { + if err != nil { + if errors.Is(err, redis.Nil) { + return datatypes.EmergencyContact{}, database.ErrNil + } + + return datatypes.EmergencyContact{}, fmt.Errorf("failed to read emergency contact: %w", err) + } + + emergencyContactSE := emergencyContactStorageElement{} + if err = json.Unmarshal(bytes, &emergencyContactSE); err != nil { + return datatypes.EmergencyContact{}, fmt.Errorf("failed to parse emergency contact json %s: %w", string(bytes), err) + } + + return emergencyContactSE.toEmergencyContact(), nil +} + +// EmergencyContacts converts redis DB reply to moira.EmergencyContact objects array. +func EmergencyContacts(rep []*redis.StringCmd) ([]*datatypes.EmergencyContact, error) { + if rep == nil { + return []*datatypes.EmergencyContact{}, nil + } + + emergencyContacts := make([]*datatypes.EmergencyContact, len(rep)) + + for i, val := range rep { + emergencyContact, err := unmarshalEmergencyContact(val.Bytes()) + if err != nil && !errors.Is(err, database.ErrNil) { + return nil, fmt.Errorf("failed to unmarshal emergency contact: %w", err) + } + + if errors.Is(err, database.ErrNil) { + emergencyContacts[i] = nil + } else { + emergencyContacts[i] = &emergencyContact + } + } + + return emergencyContacts, nil +} + +// EmergencyContacts converts redis DB reply to moira.EmergencyContact object. +func EmergencyContact(rep *redis.StringCmd) (datatypes.EmergencyContact, error) { + if rep == nil || errors.Is(rep.Err(), redis.Nil) { + return datatypes.EmergencyContact{}, database.ErrNil + } + + emergencyContact, err := unmarshalEmergencyContact(rep.Bytes()) + if err != nil { + return datatypes.EmergencyContact{}, fmt.Errorf("failed to unmarshal emergency contact: %w", err) + } + + return emergencyContact, nil +} diff --git a/database/redis/reply/emergency_contacts_test.go b/database/redis/reply/emergency_contacts_test.go new file mode 100644 index 000000000..4916a3c94 --- /dev/null +++ b/database/redis/reply/emergency_contacts_test.go @@ -0,0 +1,109 @@ +package reply + +import ( + "testing" + + "github.com/go-redis/redis/v8" + "github.com/moira-alert/moira/database" + "github.com/moira-alert/moira/datatypes" + . "github.com/smartystreets/goconvey/convey" +) + +const ( + testEmergencyContactVal = `{"contact_id":"test-contact-id","heartbeat_types":["notifier_off"]}` + testEmptyEmergencyContactVal = `{"contact_id":"","heartbeat_types":null}` +) + +var ( + testEmergencyContact = datatypes.EmergencyContact{ + ContactID: "test-contact-id", + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatNotifierOff}, + } + testEmptyEmergencyContact = datatypes.EmergencyContact{} +) + +func TestGetEmergencyContactBytes(t *testing.T) { + Convey("Test GetEmergencyContactBytes", t, func() { + Convey("With empty emergency contact", func() { + emergencyContact := datatypes.EmergencyContact{} + expectedEmergencyContactStr := testEmptyEmergencyContactVal + bytes, err := GetEmergencyContactBytes(emergencyContact) + So(err, ShouldBeNil) + So(string(bytes), ShouldResemble, expectedEmergencyContactStr) + }) + + Convey("With test emergency contact", func() { + expectedEmergencyContactStr := testEmergencyContactVal + bytes, err := GetEmergencyContactBytes(testEmergencyContact) + So(err, ShouldBeNil) + So(string(bytes), ShouldResemble, expectedEmergencyContactStr) + }) + }) +} + +func TestEmergencyContact(t *testing.T) { + Convey("Test EmergencyContact", t, func() { + Convey("With nil emergency contact rep", func() { + emergencyContact, err := EmergencyContact(nil) + So(emergencyContact, ShouldResemble, datatypes.EmergencyContact{}) + So(err, ShouldResemble, database.ErrNil) + }) + + Convey("With redis.Nil error in rep", func() { + rep := &redis.StringCmd{} + rep.SetErr(redis.Nil) + emergencyContact, err := EmergencyContact(rep) + So(emergencyContact, ShouldResemble, datatypes.EmergencyContact{}) + So(err, ShouldResemble, database.ErrNil) + }) + + Convey("With test rep", func() { + rep := &redis.StringCmd{} + testVal := testEmergencyContactVal + rep.SetVal(testVal) + emergencyContact, err := EmergencyContact(rep) + So(emergencyContact, ShouldResemble, testEmergencyContact) + So(err, ShouldBeNil) + }) + }) +} + +func TestEmergencyContacts(t *testing.T) { + Convey("Test EmergencyContacts", t, func() { + Convey("With nil emergency contact rep", func() { + emergencyContacts, err := EmergencyContacts(nil) + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, []*datatypes.EmergencyContact{}) + }) + + Convey("With test emergency contacts rep", func() { + rep := make([]*redis.StringCmd, 2) + rep[0] = &redis.StringCmd{} + rep[0].SetVal(testEmergencyContactVal) + rep[1] = &redis.StringCmd{} + rep[1].SetVal(testEmptyEmergencyContactVal) + expectedEmergencyContacts := []*datatypes.EmergencyContact{ + &testEmergencyContact, + &testEmptyEmergencyContact, + } + emergencyContacts, err := EmergencyContacts(rep) + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, expectedEmergencyContacts) + }) + + Convey("With test emergency contacts rep and one redis.Nil err", func() { + rep := make([]*redis.StringCmd, 2) + rep[0] = &redis.StringCmd{} + rep[0].SetVal(testEmergencyContactVal) + rep[1] = &redis.StringCmd{} + rep[1].SetErr(redis.Nil) + expectedEmergencyContacts := []*datatypes.EmergencyContact{ + &testEmergencyContact, + nil, + } + emergencyContacts, err := EmergencyContacts(rep) + So(err, ShouldBeNil) + So(emergencyContacts, ShouldResemble, expectedEmergencyContacts) + }) + }) +} diff --git a/datatypes/emergency_contact.go b/datatypes/emergency_contact.go new file mode 100644 index 000000000..408242e02 --- /dev/null +++ b/datatypes/emergency_contact.go @@ -0,0 +1,25 @@ +package datatypes + +// HeartbeatType are Moira's special internal types of problems. +type HeartbeatType string + +const ( + HearbeatTypeNotSet HeartbeatType = "type_not_set" + HeartbeatNotifierOff HeartbeatType = "notifier_off" +) + +// IsValid checks if such an heartbeat type exists. +func (heartbeatType HeartbeatType) IsValid() bool { + switch heartbeatType { + case HeartbeatNotifierOff: + return true + default: + return false + } +} + +// EmergencyContact is the structure for contacts to which notifications will go in the event of special internal Moira problems. +type EmergencyContact struct { + ContactID string + HeartbeatTypes []HeartbeatType +} diff --git a/datatypes/emergency_contact_test.go b/datatypes/emergency_contact_test.go new file mode 100644 index 000000000..ec0389b32 --- /dev/null +++ b/datatypes/emergency_contact_test.go @@ -0,0 +1,32 @@ +package datatypes + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestIsValidHeartbeatType(t *testing.T) { + Convey("Test IsValid heartbeat type", t, func() { + Convey("Test valid cases", func() { + testcases := []HeartbeatType{ + HeartbeatNotifierOff, + } + + for _, testcase := range testcases { + So(testcase.IsValid(), ShouldBeTrue) + } + }) + + Convey("Test invalid cases", func() { + testcases := []HeartbeatType{ + "notifier_on", + "checker_off", + } + + for _, testcase := range testcases { + So(testcase.IsValid(), ShouldBeFalse) + } + }) + }) +} diff --git a/interfaces.go b/interfaces.go index b6e4bcd18..07da723b9 100644 --- a/interfaces.go +++ b/interfaces.go @@ -4,6 +4,7 @@ import ( "time" "github.com/moira-alert/go-chart" + "github.com/moira-alert/moira/datatypes" "github.com/moira-alert/moira/logging" "gopkg.in/tomb.v2" ) @@ -75,6 +76,14 @@ type Database interface { GetUserContactIDs(userLogin string) ([]string, error) GetTeamContactIDs(teamID string) ([]string, error) + // EmergencyContact storing + GetEmergencyContacts() ([]*datatypes.EmergencyContact, error) + GetEmergencyContactsByIDs(contactIDs []string) ([]*datatypes.EmergencyContact, error) + GetEmergencyContact(contactID string) (datatypes.EmergencyContact, error) + GetHeartbeatTypeContactIDs(heartbeatType datatypes.HeartbeatType) ([]string, error) + SaveEmergencyContact(emergencyContact datatypes.EmergencyContact) error + RemoveEmergencyContact(contactID string) error + // SubscriptionData storing GetSubscription(id string) (SubscriptionData, error) GetSubscriptions(subscriptionIDs []string) ([]*SubscriptionData, error) diff --git a/mock/moira-alert/database.go b/mock/moira-alert/database.go index a9953446c..d71a4c248 100644 --- a/mock/moira-alert/database.go +++ b/mock/moira-alert/database.go @@ -14,6 +14,7 @@ import ( time "time" moira "github.com/moira-alert/moira" + datatypes "github.com/moira-alert/moira/datatypes" gomock "go.uber.org/mock/gomock" tomb "gopkg.in/tomb.v2" ) @@ -416,6 +417,66 @@ func (mr *MockDatabaseMockRecorder) GetContacts(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContacts", reflect.TypeOf((*MockDatabase)(nil).GetContacts), arg0) } +// GetEmergencyContact mocks base method. +func (m *MockDatabase) GetEmergencyContact(arg0 string) (datatypes.EmergencyContact, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmergencyContact", arg0) + ret0, _ := ret[0].(datatypes.EmergencyContact) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmergencyContact indicates an expected call of GetEmergencyContact. +func (mr *MockDatabaseMockRecorder) GetEmergencyContact(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmergencyContact", reflect.TypeOf((*MockDatabase)(nil).GetEmergencyContact), arg0) +} + +// GetEmergencyContacts mocks base method. +func (m *MockDatabase) GetEmergencyContacts() ([]*datatypes.EmergencyContact, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmergencyContacts") + ret0, _ := ret[0].([]*datatypes.EmergencyContact) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmergencyContacts indicates an expected call of GetEmergencyContacts. +func (mr *MockDatabaseMockRecorder) GetEmergencyContacts() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmergencyContacts", reflect.TypeOf((*MockDatabase)(nil).GetEmergencyContacts)) +} + +// GetEmergencyContactsByIDs mocks base method. +func (m *MockDatabase) GetEmergencyContactsByIDs(arg0 []string) ([]*datatypes.EmergencyContact, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmergencyContactsByIDs", arg0) + ret0, _ := ret[0].([]*datatypes.EmergencyContact) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmergencyContactsByIDs indicates an expected call of GetEmergencyContactsByIDs. +func (mr *MockDatabaseMockRecorder) GetEmergencyContactsByIDs(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmergencyContactsByIDs", reflect.TypeOf((*MockDatabase)(nil).GetEmergencyContactsByIDs), arg0) +} + +// GetHeartbeatTypeContactIDs mocks base method. +func (m *MockDatabase) GetHeartbeatTypeContactIDs(arg0 datatypes.HeartbeatType) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetHeartbeatTypeContactIDs", arg0) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetHeartbeatTypeContactIDs indicates an expected call of GetHeartbeatTypeContactIDs. +func (mr *MockDatabaseMockRecorder) GetHeartbeatTypeContactIDs(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHeartbeatTypeContactIDs", reflect.TypeOf((*MockDatabase)(nil).GetHeartbeatTypeContactIDs), arg0) +} + // GetMetricRetention mocks base method. func (m *MockDatabase) GetMetricRetention(arg0 string) (int64, error) { m.ctrl.T.Helper() @@ -1162,6 +1223,20 @@ func (mr *MockDatabaseMockRecorder) RemoveContact(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveContact", reflect.TypeOf((*MockDatabase)(nil).RemoveContact), arg0) } +// RemoveEmergencyContact mocks base method. +func (m *MockDatabase) RemoveEmergencyContact(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveEmergencyContact", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveEmergencyContact indicates an expected call of RemoveEmergencyContact. +func (mr *MockDatabaseMockRecorder) RemoveEmergencyContact(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveEmergencyContact", reflect.TypeOf((*MockDatabase)(nil).RemoveEmergencyContact), arg0) +} + // RemoveMetricRetention mocks base method. func (m *MockDatabase) RemoveMetricRetention(arg0 string) error { m.ctrl.T.Helper() @@ -1388,6 +1463,20 @@ func (mr *MockDatabaseMockRecorder) SaveContact(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveContact", reflect.TypeOf((*MockDatabase)(nil).SaveContact), arg0) } +// SaveEmergencyContact mocks base method. +func (m *MockDatabase) SaveEmergencyContact(arg0 datatypes.EmergencyContact) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveEmergencyContact", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveEmergencyContact indicates an expected call of SaveEmergencyContact. +func (mr *MockDatabaseMockRecorder) SaveEmergencyContact(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveEmergencyContact", reflect.TypeOf((*MockDatabase)(nil).SaveEmergencyContact), arg0) +} + // SaveMetrics mocks base method. func (m *MockDatabase) SaveMetrics(arg0 map[string]*moira.MatchedMetric) error { m.ctrl.T.Helper()