Calendar interface updates and mock calendar (#17701)

- Updated calendar interface to use updated `genBodyFn`
- The mock calendar is enabled by specifying `calendar-mock@example.com`
as the service account email.
This commit is contained in:
Victor Lyuboslavsky 2024-03-19 16:19:38 -05:00 committed by Victor Lyuboslavsky
parent 9a8ac02bc1
commit 196d8ce5b7
No known key found for this signature in database
5 changed files with 166 additions and 38 deletions

View file

@ -231,9 +231,10 @@ func processFailingHostExistingCalendarEvent(
calendarEvent *fleet.CalendarEvent,
host fleet.HostPolicyMembershipData,
) error {
updatedEvent, updated, err := calendar.GetAndUpdateEvent(calendarEvent, func() string {
return generateCalendarEventBody(orgName, host.HostDisplayName)
})
updatedEvent, updated, err := calendar.GetAndUpdateEvent(
calendarEvent, func(bool) string {
return generateCalendarEventBody(orgName, host.HostDisplayName)
})
if err != nil {
return fmt.Errorf("get event calendar on db: %w", err)
}
@ -325,9 +326,12 @@ func attemptCreatingEventOnUserCalendar(
// - If its the 3rd Tuesday, Weds, Thurs, etc. of the month and its past the last slot, schedule the call for the next business day.
year, month, today := time.Now().Date()
preferredDate := getPreferredCalendarEventDate(year, month, today)
body := generateCalendarEventBody(orgName, host.HostDisplayName)
for {
calendarEvent, err := userCalendar.CreateEvent(preferredDate, body)
calendarEvent, err := userCalendar.CreateEvent(
preferredDate, func(bool) string {
return generateCalendarEventBody(orgName, host.HostDisplayName)
},
)
var dee fleet.DayEndedError
switch {
case err == nil:

View file

@ -50,8 +50,12 @@ type GoogleCalendar struct {
func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar {
if config.API == nil {
var lowLevelAPI GoogleCalendarAPI = &GoogleCalendarLowLevelAPI{}
config.API = lowLevelAPI
if config.IntegrationConfig.Email == "calendar-mock@example.com" {
// Assumes that only 1 Fleet server accesses the calendar, since all mock events are held in memory
config.API = &GoogleCalendarMockAPI{}
} else {
config.API = &GoogleCalendarLowLevelAPI{}
}
}
return &GoogleCalendar{
config: config,
@ -136,7 +140,9 @@ func (c *GoogleCalendar) Configure(userEmail string) error {
return nil
}
func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func() string) (*fleet.CalendarEvent, bool, error) {
func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) (
*fleet.CalendarEvent, bool, error,
) {
// We assume that the Fleet event has not already ended. We will simply return it if it has not been modified.
details, err := c.unmarshalDetails(event)
if err != nil {
@ -167,6 +173,10 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
if gEvent.End.DateTime == "" {
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
// We won't handle all-day events at this time, and treat the event as deleted.
err = c.DeleteEvent(event)
if err != nil {
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
}
deleted = true
}
@ -194,6 +204,10 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
if gEvent.Start.DateTime == "" {
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
// We won't handle all-day events at this time, and treat the event as deleted.
err = c.DeleteEvent(event)
if err != nil {
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
}
deleted = true
}
}
@ -212,7 +226,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
newStartDate := calculateNewEventDate(event.StartTime)
fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn())
fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn)
if err != nil {
return nil, false, err
}
@ -269,13 +283,15 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet
return &details, nil
}
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, body string) (*fleet.CalendarEvent, error) {
return c.createEvent(dayOfEvent, body, time.Now)
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) {
return c.createEvent(dayOfEvent, genBodyFn, time.Now)
}
// createEvent creates a new event on the calendar on the given date. timeNow is a function that returns the current time.
// timeNow can be overwritten for testing
func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow func() time.Time) (*fleet.CalendarEvent, error) {
func (c *GoogleCalendar) createEvent(
dayOfEvent time.Time, genBodyFn func(conflict bool) string, timeNow func() time.Time,
) (*fleet.CalendarEvent, error) {
if c.timezoneOffset == nil {
err := getTimezone(c)
if err != nil {
@ -310,6 +326,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
if err != nil {
return nil, ctxerr.Wrap(c.config.Context, err, "listing Google calendar events")
}
var conflict bool
for _, gEvent := range events.Items {
// Ignore cancelled events
if gEvent.Status == "cancelled" {
@ -353,7 +370,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
if startTime.Before(eventEnd) {
// Event occurs during our event, so we need to adjust.
var isLastSlot bool
eventStart, eventEnd, isLastSlot = adjustEventTimes(*endTime, dayEnd)
eventStart, eventEnd, isLastSlot, conflict = adjustEventTimes(*endTime, dayEnd)
if isLastSlot {
break
}
@ -367,7 +384,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
event.Start = &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}
event.End = &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}
event.Summary = eventTitle
event.Description = body
event.Description = genBodyFn(conflict)
event, err = c.config.API.CreateEvent(event)
if err != nil {
return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event")
@ -383,7 +400,7 @@ func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow
return fleetEvent, nil
}
func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool) {
func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool, conflict bool) {
eventStart = endTime.Truncate(eventLength)
if eventStart.Before(endTime) {
eventStart = eventStart.Add(eventLength)
@ -394,11 +411,11 @@ func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time
eventEnd = dayEnd
eventStart = eventEnd.Add(-eventLength)
isLastSlot = true
}
if eventEnd.Equal(dayEnd) {
conflict = true
} else if eventEnd.Equal(dayEnd) {
isLastSlot = true
}
return eventStart, eventEnd, isLastSlot
return eventStart, eventEnd, isLastSlot, conflict
}
func getTimezone(gCal *GoogleCalendar) error {
@ -444,9 +461,6 @@ func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime ti
}
func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error {
if c.config == nil {
return errors.New("the Google calendar is not connected. Please call Configure first")
}
details, err := c.unmarshalDetails(event)
if err != nil {
return err

View file

@ -0,0 +1,81 @@
package calendar
import (
"context"
"errors"
kitlog "github.com/go-kit/log"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/googleapi"
"net/http"
"os"
"strconv"
"sync"
"time"
)
type GoogleCalendarMockAPI struct {
logger kitlog.Logger
}
var events = make(map[string]*calendar.Event)
var mu sync.Mutex
var id uint64
const latency = 500 * time.Millisecond
// Configure creates a new Google Calendar service using the provided credentials.
func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error {
if lowLevelAPI.logger == nil {
lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate)
}
return nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) GetSetting(name string) (*calendar.Setting, error) {
time.Sleep(latency)
lowLevelAPI.logger.Log("msg", "GetSetting", "name", name)
if name == "timezone" {
return &calendar.Setting{
Id: "timezone",
Value: "America/Chicago",
}, nil
}
return nil, errors.New("setting not supported")
}
func (lowLevelAPI *GoogleCalendarMockAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
mu.Lock()
defer mu.Unlock()
id += 1
event.Id = strconv.FormatUint(id, 10)
lowLevelAPI.logger.Log("msg", "CreateEvent", "id", event.Id, "start", event.Start.DateTime)
events[event.Id] = event
return event, nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) GetEvent(id, _ string) (*calendar.Event, error) {
time.Sleep(latency)
mu.Lock()
defer mu.Unlock()
event, ok := events[id]
if !ok {
return nil, &googleapi.Error{Code: http.StatusNotFound}
}
lowLevelAPI.logger.Log("msg", "GetEvent", "id", id, "start", event.Start.DateTime)
return event, nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) ListEvents(string, string) (*calendar.Events, error) {
time.Sleep(latency)
lowLevelAPI.logger.Log("msg", "ListEvents")
return &calendar.Events{}, nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error {
time.Sleep(latency)
mu.Lock()
defer mu.Unlock()
lowLevelAPI.logger.Log("msg", "DeleteEvent", "id", id)
delete(events, id)
return nil
}

View file

@ -162,7 +162,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
Etag: baseETag, // ETag matches -- no modifications to event
}, nil
}
genBodyFn := func() string {
genBodyFn := func(bool) string {
t.Error("genBodyFn should not be called")
return "event-body"
}
@ -300,13 +300,14 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return &calendar.Events{}, nil
}
genBodyFn = func() string {
genBodyFn = func(conflict bool) string {
assert.False(t, conflict)
return "event-body"
}
eventCreated := false
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
assert.Equal(t, eventTitle, event.Summary)
assert.Equal(t, genBodyFn(), event.Description)
assert.Equal(t, genBodyFn(false), event.Description)
event.Id = baseEventID
event.Etag = baseETag
eventCreated = true
@ -345,6 +346,10 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
assert.True(t, eventCreated)
// all day event (deleted)
mockAPI.DeleteEventFunc = func(id string) error {
assert.Equal(t, baseEventID, id)
return nil
}
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
@ -373,10 +378,6 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
}, nil
}
eventCreated = false
mockAPI.DeleteEventFunc = func(id string) error {
assert.Equal(t, baseEventID, id)
return nil
}
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.True(t, updated)
@ -411,13 +412,21 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
event.Etag = baseETag
return event, nil
}
genBodyFn := func(conflict bool) string {
assert.False(t, conflict)
return eventBody
}
genBodyConflictFn := func(conflict bool) string {
assert.True(t, conflict)
return eventBody
}
// Happy path test -- empty calendar
date := time.Now().Add(48 * time.Hour)
location, _ := time.LoadLocation(tzId)
expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
_, expectedOffset := expectedStartTime.Zone()
event, err := cal.CreateEvent(date, eventBody)
event, err := cal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
assert.Equal(t, baseUserEmail, event.Email)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
@ -434,7 +443,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
// Workday already ended
date = time.Now().Add(-48 * time.Hour)
_, err = cal.CreateEvent(date, eventBody)
_, err = cal.CreateEvent(date, genBodyFn)
assert.ErrorAs(t, err, &fleet.DayEndedError{})
// There is no time left in the day to schedule an event
@ -443,7 +452,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location)
return now
}
_, err = gCal.createEvent(date, eventBody, timeNow)
_, err = gCal.createEvent(date, genBodyFn, timeNow)
assert.ErrorAs(t, err, &fleet.DayEndedError{})
// Workday already started
@ -452,7 +461,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
timeNow = func() time.Time {
return expectedStartTime
}
event, err = gCal.createEvent(date, eventBody, timeNow)
event, err = gCal.createEvent(date, genBodyFn, timeNow)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@ -545,7 +554,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
return gEvents, nil
}
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location)
event, err = gCal.CreateEvent(date, eventBody)
event, err = gCal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@ -565,7 +574,27 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
return gEvents, nil
}
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
event, err = gCal.CreateEvent(date, eventBody)
event, err = gCal.CreateEvent(date, genBodyConflictFn)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
// Almost full schedule -- pick the last slot
date = time.Now().Add(48 * time.Hour)
dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
gEvents = &calendar.Events{}
gEvent = &calendar.Event{
Id: "9-to-4-30-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
}
gEvents.Items = append(gEvents.Items, gEvent)
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return gEvents, nil
}
expectedStartTime = dayEnd
event, err = gCal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@ -574,7 +603,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return nil, assert.AnError
}
_, err = gCal.CreateEvent(date, eventBody)
_, err = gCal.CreateEvent(date, genBodyFn)
assert.ErrorIs(t, err, assert.AnError)
// API error in CreateEvent
@ -584,6 +613,6 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
return nil, assert.AnError
}
_, err = gCal.CreateEvent(date, eventBody)
_, err = gCal.CreateEvent(date, genBodyFn)
assert.ErrorIs(t, err, assert.AnError)
}

View file

@ -21,11 +21,11 @@ type UserCalendar interface {
// CreateEvent, GetAndUpdateEvent and DeleteEvent reference the user's calendar.
Configure(userEmail string) error
// CreateEvent creates a new event on the calendar on the given date. DayEndedError is returned if there is no time left on the given date to schedule event.
CreateEvent(dateOfEvent time.Time, body string) (event *CalendarEvent, err error)
CreateEvent(dateOfEvent time.Time, genBodyFn func(conflict bool) string) (event *CalendarEvent, err error)
// GetAndUpdateEvent retrieves the event from the calendar.
// If the event has been modified, it returns the updated event.
// If the event has been deleted, it schedules a new event with given body callback and returns the new event.
GetAndUpdateEvent(event *CalendarEvent, genBodyFn func() string) (updatedEvent *CalendarEvent, updated bool, err error)
GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) string) (updatedEvent *CalendarEvent, updated bool, err error)
// DeleteEvent deletes the event with the given ID.
DeleteEvent(event *CalendarEvent) error
}