Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change schedule configuration to support new rotations #204

Merged
merged 8 commits into from
Jun 12, 2024
135 changes: 90 additions & 45 deletions internal/config/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"context"
"github.com/icinga/icinga-notifications/internal/recipient"
"github.com/icinga/icinga-notifications/internal/timeperiod"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)
Expand All @@ -27,28 +28,93 @@ func (r *RuntimeConfig) fetchSchedules(ctx context.Context, tx *sqlx.Tx) error {
zap.String("name", g.Name))
}

var memberPtr *recipient.ScheduleMemberRow
stmt = r.db.BuildSelectStmt(memberPtr, memberPtr)
var rotationPtr *recipient.Rotation
stmt = r.db.BuildSelectStmt(rotationPtr, rotationPtr)
r.logger.Debugf("Executing query %q", stmt)

var members []*recipient.ScheduleMemberRow
var rotations []*recipient.Rotation
if err := tx.SelectContext(ctx, &rotations, stmt); err != nil {
r.logger.Errorln(err)
return err
}

rotationsById := make(map[int64]*recipient.Rotation)
for _, rotation := range rotations {
rotationLogger := r.logger.With(zap.Object("rotation", rotation))

if schedule := schedulesById[rotation.ScheduleID]; schedule == nil {
rotationLogger.Warnw("ignoring schedule rotation for unknown schedule_id")
} else {
rotationsById[rotation.ID] = rotation
schedule.Rotations = append(schedule.Rotations, rotation)

rotationLogger.Debugw("loaded schedule rotation")
}
}

var rotationMemberPtr *recipient.RotationMember
stmt = r.db.BuildSelectStmt(rotationMemberPtr, rotationMemberPtr)
r.logger.Debugf("Executing query %q", stmt)

var members []*recipient.RotationMember
if err := tx.SelectContext(ctx, &members, stmt); err != nil {
r.logger.Errorln(err)
return err
}

rotationMembersById := make(map[int64]*recipient.RotationMember)
for _, member := range members {
memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, member)
memberLogger := r.logger.With(zap.Object("rotation_member", member))

if s := schedulesById[member.ScheduleID]; s == nil {
memberLogger.Warnw("ignoring schedule member for unknown schedule_id")
if rotation := rotationsById[member.RotationID]; rotation == nil {
memberLogger.Warnw("ignoring rotation member for unknown rotation_member_id")
} else {
s.MemberRows = append(s.MemberRows, member)
member.TimePeriodEntries = make(map[int64]*timeperiod.Entry)
rotation.Members = append(rotation.Members, member)
rotationMembersById[member.ID] = member

memberLogger.Debugw("member")
memberLogger.Debugw("loaded schedule rotation member")
}
}

var entryPtr *timeperiod.Entry
stmt = r.db.BuildSelectStmt(entryPtr, entryPtr) + " WHERE rotation_member_id IS NOT NULL"
r.logger.Debugf("Executing query %q", stmt)

var entries []*timeperiod.Entry
if err := tx.SelectContext(ctx, &entries, stmt); err != nil {
r.logger.Errorln(err)
return err
}

for _, entry := range entries {
var member *recipient.RotationMember
if entry.RotationMemberID.Valid {
member = rotationMembersById[entry.RotationMemberID.Int64]
}

if member == nil {
r.logger.Warnw("ignoring entry for unknown rotation_member_id",
zap.Int64("timeperiod_entry_id", entry.ID),
zap.Int64("timeperiod_id", entry.TimePeriodID))
continue
}

err := entry.Init()
if err != nil {
r.logger.Warnw("ignoring time period entry",
zap.Object("entry", entry),
zap.Error(err))
continue
}

member.TimePeriodEntries[entry.ID] = entry
}

for _, schedule := range schedulesById {
schedule.RefreshRotations()
}

if r.Schedules != nil {
// mark no longer existing schedules for deletion
for id := range r.Schedules {
Expand All @@ -72,38 +138,26 @@ func (r *RuntimeConfig) applyPendingSchedules() {
if pendingSchedule == nil {
delete(r.Schedules, id)
} else {
for _, memberRow := range pendingSchedule.MemberRows {
memberLogger := makeScheduleMemberLogger(r.logger.SugaredLogger, memberRow)

period := r.TimePeriods[memberRow.TimePeriodID]
if period == nil {
memberLogger.Warnw("ignoring schedule member for unknown timeperiod_id")
continue
}

var contact *recipient.Contact
if memberRow.ContactID.Valid {
contact = r.Contacts[memberRow.ContactID.Int64]
if contact == nil {
memberLogger.Warnw("ignoring schedule member for unknown contact_id")
continue
for _, rotation := range pendingSchedule.Rotations {
for _, member := range rotation.Members {
memberLogger := r.logger.With(
zap.Object("rotation", rotation),
zap.Object("rotation_member", member))

if member.ContactID.Valid {
member.Contact = r.Contacts[member.ContactID.Int64]
if member.Contact == nil {
memberLogger.Warnw("rotation member has an unknown contact_id")
}
}
}

var group *recipient.Group
if memberRow.GroupID.Valid {
group = r.Groups[memberRow.GroupID.Int64]
if group == nil {
memberLogger.Warnw("ignoring schedule member for unknown contactgroup_id")
continue
if member.ContactGroupID.Valid {
member.ContactGroup = r.Groups[member.ContactGroupID.Int64]
if member.ContactGroup == nil {
memberLogger.Warnw("rotation member has an unknown contactgroup_id")
}
}
}

pendingSchedule.Members = append(pendingSchedule.Members, &recipient.Member{
TimePeriod: period,
Contact: contact,
ContactGroup: group,
})
}

if currentSchedule := r.Schedules[id]; currentSchedule != nil {
Expand All @@ -116,12 +170,3 @@ func (r *RuntimeConfig) applyPendingSchedules() {

r.pending.Schedules = nil
}

func makeScheduleMemberLogger(logger *zap.SugaredLogger, member *recipient.ScheduleMemberRow) *zap.SugaredLogger {
return logger.With(
zap.Int64("schedule_id", member.ScheduleID),
zap.Int64("timeperiod_id", member.TimePeriodID),
zap.Int64("contact_id", member.ContactID.Int64),
zap.Int64("contactgroup_id", member.GroupID.Int64),
)
}
60 changes: 11 additions & 49 deletions internal/config/timeperiod.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ package config

import (
"context"
"database/sql"
"fmt"
"github.com/icinga/icinga-go-library/types"
"github.com/icinga/icinga-notifications/internal/timeperiod"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
"time"
)

func (r *RuntimeConfig) fetchTimePeriods(ctx context.Context, tx *sqlx.Tx) error {
Expand All @@ -26,77 +23,42 @@ func (r *RuntimeConfig) fetchTimePeriods(ctx context.Context, tx *sqlx.Tx) error
timePeriodsById[period.ID] = period
}

type TimeperiodEntry struct {
ID int64 `db:"id"`
TimePeriodID int64 `db:"timeperiod_id"`
StartTime types.UnixMilli `db:"start_time"`
EndTime types.UnixMilli `db:"end_time"`
Timezone string `db:"timezone"`
RRule sql.NullString `db:"rrule"`
Description sql.NullString `db:"description"`
}

var entryPtr *TimeperiodEntry
var entryPtr *timeperiod.Entry
stmt = r.db.BuildSelectStmt(entryPtr, entryPtr)
r.logger.Debugf("Executing query %q", stmt)

var entries []*TimeperiodEntry
var entries []*timeperiod.Entry
if err := tx.SelectContext(ctx, &entries, stmt); err != nil {
r.logger.Errorln(err)
return err
}

for _, row := range entries {
p := timePeriodsById[row.TimePeriodID]
for _, entry := range entries {
p := timePeriodsById[entry.TimePeriodID]
if p == nil {
r.logger.Warnw("ignoring entry for unknown timeperiod_id",
zap.Int64("timeperiod_entry_id", row.ID),
zap.Int64("timeperiod_id", row.TimePeriodID))
zap.Int64("timeperiod_entry_id", entry.ID),
zap.Int64("timeperiod_id", entry.TimePeriodID))
continue
}

if p.Name == "" {
p.Name = fmt.Sprintf("Time Period #%d", row.TimePeriodID)
if row.Description.Valid {
p.Name += fmt.Sprintf(" (%s)", row.Description.String)
}
}

loc, err := time.LoadLocation(row.Timezone)
if err != nil {
r.logger.Warnw("ignoring time period entry with unknown timezone",
zap.Int64("timeperiod_entry_id", row.ID),
zap.String("timezone", row.Timezone),
zap.Error(err))
continue
}

entry := &timeperiod.Entry{
Start: row.StartTime.Time().Truncate(time.Second).In(loc),
End: row.EndTime.Time().Truncate(time.Second).In(loc),
TimeZone: row.Timezone,
}

if row.RRule.Valid {
entry.RecurrenceRule = row.RRule.String
p.Name = fmt.Sprintf("Time Period #%d", entry.TimePeriodID)
}

err = entry.Init()
err := entry.Init()
if err != nil {
r.logger.Warnw("ignoring time period entry",
zap.Int64("timeperiod_entry_id", row.ID),
zap.String("rrule", entry.RecurrenceRule),
zap.Object("entry", entry),
zap.Error(err))
continue
}

p.Entries = append(p.Entries, entry)

r.logger.Debugw("loaded time period entry",
zap.String("timeperiod", p.Name),
zap.Time("start", entry.Start),
zap.Time("end", entry.End),
zap.String("rrule", entry.RecurrenceRule))
zap.Object("timeperiod", p),
zap.Object("entry", entry))
}

for _, p := range timePeriodsById {
Expand Down
40 changes: 17 additions & 23 deletions internal/config/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,34 +199,28 @@ func (r *RuntimeConfig) debugVerifySchedule(id int64, schedule *recipient.Schedu
return fmt.Errorf("schedule %p is inconsistent with RuntimeConfig.Schedules[%d] = %p", schedule, id, other)
}

for i, member := range schedule.Members {
if member == nil {
return fmt.Errorf("Members[%d] is nil", i)
}

if member.TimePeriod == nil {
return fmt.Errorf("Members[%d].TimePeriod is nil", i)
for i, rotation := range schedule.Rotations {
if rotation == nil {
return fmt.Errorf("Rotations[%d] is nil", i)
}

if member.Contact == nil && member.ContactGroup == nil {
return fmt.Errorf("Members[%d] has neither Contact nor ContactGroup set", i)
}

if member.Contact != nil && member.ContactGroup != nil {
return fmt.Errorf("Members[%d] has both Contact and ContactGroup set", i)
}
for j, member := range rotation.Members {
if member == nil {
return fmt.Errorf("Rotations[%d].Members[%d] is nil", i, j)
}

if member.Contact != nil {
err := r.debugVerifyContact(member.Contact.ID, member.Contact)
if err != nil {
return fmt.Errorf("Contact: %w", err)
if member.Contact != nil {
err := r.debugVerifyContact(member.ContactID.Int64, member.Contact)
if err != nil {
return fmt.Errorf("Contact: %w", err)
}
}
}

if member.ContactGroup != nil {
err := r.debugVerifyGroup(member.ContactGroup.ID, member.ContactGroup)
if err != nil {
return fmt.Errorf("ContactGroup: %w", err)
if member.ContactGroup != nil {
err := r.debugVerifyGroup(member.ContactGroupID.Int64, member.ContactGroup)
if err != nil {
return fmt.Errorf("ContactGroup: %w", err)
}
}
}
}
Expand Down
17 changes: 13 additions & 4 deletions internal/incident/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,20 @@ func (i *Incident) getRecipientsChannel(t time.Time) rule.ContactChannels {
}

if i.IsNotifiable(state.Role) {
for _, contact := range r.GetContactsAt(t) {
if contactChs[contact] == nil {
contactChs[contact] = make(map[int64]bool)
contactChs[contact][contact.DefaultChannelID] = true
contacts := r.GetContactsAt(t)
if len(contacts) > 0 {
i.logger.Debugw("Expanded recipient to contacts",
zap.Object("recipient", r),
zap.Objects("contacts", contacts))

for _, contact := range contacts {
if contactChs[contact] == nil {
contactChs[contact] = make(map[int64]bool)
contactChs[contact][contact.DefaultChannelID] = true
}
}
} else {
i.logger.Warnw("Recipient expanded to no contacts", zap.Object("recipient", r))
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions internal/listener/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func NewListener(db *database.DB, runtimeConfig *config.RuntimeConfig, logs *log
l.mux.HandleFunc("/process-event", l.ProcessEvent)
l.mux.HandleFunc("/dump-config", l.DumpConfig)
l.mux.HandleFunc("/dump-incidents", l.DumpIncidents)
l.mux.HandleFunc("/dump-schedules", l.DumpSchedules)
return l
}

Expand Down Expand Up @@ -220,3 +221,32 @@ func (l *Listener) DumpIncidents(w http.ResponseWriter, r *http.Request) {
enc.SetIndent("", " ")
_ = enc.Encode(encodedIncidents)
}

func (l *Listener) DumpSchedules(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = fmt.Fprintln(w, "GET required")
return
}

if !l.checkDebugPassword(w, r) {
return
}

l.runtimeConfig.RLock()
defer l.runtimeConfig.RUnlock()

for _, schedule := range l.runtimeConfig.Schedules {
fmt.Fprintf(w, "[id=%d] %q:\n", schedule.ID, schedule.Name)

// Iterate in 30 minute steps as this is the granularity Icinga Notifications Web allows in the configuration.
// Truncation to seconds happens only for a more readable output.
step := 30 * time.Minute
start := time.Now().Truncate(time.Second)
oxzi marked this conversation as resolved.
Show resolved Hide resolved
for t := start; t.Before(start.Add(48 * time.Hour)); t = t.Add(step) {
fmt.Fprintf(w, "\t%v: %v\n", t, schedule.GetContactsAt(t))
}

fmt.Fprintln(w)
}
}
Loading
Loading