From 2364696b05a31f04c0d9d608fd987db66d0376b5 Mon Sep 17 00:00:00 2001
From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com>
Date: Wed, 24 Jul 2024 13:36:50 +0200
Subject: [PATCH 01/11] Update calendar preview example in UI (#20572)
Update date in preview calendar example to match article and be
realistic since it will happen every Tuesday by default.
Related to: #19031
---
.../CalendarEventPreviewModal/CalendarEventPreviewModal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx
index f6b5379675..0199d029c9 100644
--- a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx
+++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx
@@ -51,7 +51,7 @@ const CalendarEventPreviewModal = ({
💻 🚫 Scheduled maintenance
- Friday, April 5
+ Tuesday, June 18⋅5-5:30pm
From c1a5e3b7b698f5de4c5a162fd9184aa76e094102 Mon Sep 17 00:00:00 2001
From: Victor Lyuboslavsky
Date: Wed, 24 Jul 2024 13:40:33 +0200
Subject: [PATCH 02/11] Fix calendar duplicated events and other issues
(#20443)
#19352
Includes the following changes:
- Re-enable calendar callback
- Introduced a new Redis key that indicates event was updated by
calendar callback. In that case, we ignore subsequent callbacks for 10
seconds.
- This reduces the amount of Google API calls, including handling of the
unneeded callback generated by our own event change.
- Read event from DB after acquiring lock. This is critical since we get
the updated ETag of the Google Calendar event from our DB. Using the
previous ETag when fetching event sometimes returns stale data,
resulting in duplicate events.
- Fixed bug in getCalendarLock where calendar cron would always think it
got the lock
- Do not refetch timezone during calendar callback to reduce Google API
load
- Watch for calendar event changes for 1 week after event end (to
account for user moving event into the future)
- #20442: Speculative improvement for Google callback latency by keeping
the same notification channel (callback URL).
- processCalendarAsync now takes at least 1 sec to process all events,
to reduce CPU/Redis load
- Increased lock expiration time from 1 minute to 20 minutes to account
for potential Google API retries, fixing occasional duplicate events.
- Added `get-events.go` helper script that gets maintenance events from
user calendars, and checks for duplicates
# Checklist for submitter
- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
---
changes/19352-calendar-real-time | 3 +
ee/server/calendar/google_calendar.go | 160 +++++++-------
.../google_calendar_integration_test.go | 42 +++-
ee/server/calendar/google_calendar_test.go | 46 ++--
ee/server/service/calendar.go | 129 ++++++++++--
ee/server/service/calendar_test.go | 108 ++++++++++
server/cron/calendar_cron.go | 41 +++-
server/fleet/calendar.go | 19 +-
server/service/calendar.go | 72 +++----
server/service/calendar/calendar.go | 20 ++
server/service/handler.go | 3 +-
server/service/integration_enterprise_test.go | 29 ++-
server/service/testing_utils.go | 2 +-
tools/calendar/get-events/get-events.go | 196 ++++++++++++++++++
14 files changed, 690 insertions(+), 180 deletions(-)
create mode 100644 changes/19352-calendar-real-time
create mode 100644 ee/server/service/calendar_test.go
create mode 100644 tools/calendar/get-events/get-events.go
diff --git a/changes/19352-calendar-real-time b/changes/19352-calendar-real-time
new file mode 100644
index 0000000000..d96cf1fa11
--- /dev/null
+++ b/changes/19352-calendar-real-time
@@ -0,0 +1,3 @@
+- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past.
+ - Fleet server watches for potential changes for up to 1 week after original event time. If event is moved forward more than 1 week, then after 1 week Fleet server will check for event changes once every 30 minutes.
+ - These near real-time updates may add additional load to the Google Calendar API, so it is recommended to use API usage alerts or other monitoring methods.
diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go
index d0d6477d28..c527cec3a2 100644
--- a/ee/server/calendar/google_calendar.go
+++ b/ee/server/calendar/google_calendar.go
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"regexp"
+ "strconv"
"strings"
"time"
@@ -191,41 +192,35 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
- // Disabling this feature to address bugs
- return "", nil
-
- // resp, err := lowLevelAPI.withRetry(
- // func() (any, error) {
- // return lowLevelAPI.service.Events.Watch(calendarID, &calendar.Channel{
- // Id: channelID, // channelID is also used for authentication -- it should be a random value
- // Type: "web_hook",
- // Address: fmt.Sprintf("%s/api/v1/fleet/calendar/webhook/%s",
- // lowLevelAPI.serverURL, eventUUID),
- // Params: map[string]string{
- // "ttl": strconv.FormatUint(ttl, 10),
- // },
- // }).EventTypes("default").Do()
- // },
- // )
- // if err != nil {
- // return "", err
- // }
- // return resp.(*calendar.Channel).ResourceId, nil
+ resp, err := lowLevelAPI.withRetry(
+ func() (any, error) {
+ return lowLevelAPI.service.Events.Watch(calendarID, &calendar.Channel{
+ Id: channelID, // channelID is also used for authentication -- it should be a random value
+ Type: "web_hook",
+ Address: fmt.Sprintf("%s/api/v1/fleet/calendar/webhook/%s",
+ lowLevelAPI.serverURL, eventUUID),
+ Params: map[string]string{
+ "ttl": strconv.FormatUint(ttl, 10),
+ },
+ }).EventTypes("default").Do()
+ },
+ )
+ if err != nil {
+ return "", err
+ }
+ return resp.(*calendar.Channel).ResourceId, nil
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) Stop(channelID string, resourceID string) error {
- // Disabling this feature to address bugs
- return nil
-
- // _, err := lowLevelAPI.withRetry(
- // func() (any, error) {
- // return nil, lowLevelAPI.service.Channels.Stop(&calendar.Channel{
- // Id: channelID,
- // ResourceId: resourceID,
- // }).Do()
- // },
- // )
- // return err
+ _, err := lowLevelAPI.withRetry(
+ func() (any, error) {
+ return nil, lowLevelAPI.service.Channels.Stop(&calendar.Channel{
+ Id: channelID,
+ ResourceId: resourceID,
+ }).Do()
+ },
+ )
+ return err
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) withRetry(fn func() (any, error)) (any, error) {
@@ -266,7 +261,8 @@ func (c *GoogleCalendar) Configure(userEmail string) error {
return nil
}
-func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) (body string, updated bool, err error)) (
+func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) (body string, updated bool, err error),
+ opts fleet.CalendarGetAndUpdateEventOpts) (
*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.
@@ -275,18 +271,28 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
return nil, false, err
}
- // Set current calendar instance timezone to the latest from google calendar.
- c.location, err = getTimezone(c)
- if err != nil {
- return nil, false, err
+ // Set current calendar instance timezone to the latest from Google calendar.
+ var tzUpdated bool
+ var latestTzName string
+ updateTimezone := func() error {
+ c.location, err = getTimezone(c)
+ if err != nil {
+ return err
+ }
+ latestTzName = c.location.String()
+ // nil if cal event created before Fleet tracked timezone
+ tzUpdated = event.TimeZone == nil || (latestTzName != *event.TimeZone)
+ return nil
+ }
+ if opts.UpdateTimezone {
+ err = updateTimezone()
+ if err != nil {
+ return nil, false, err
+ }
}
- latestTzName := c.location.String()
- // nil if cal event created before Fleet tracked timezone
- tzUpdated := event.TimeZone == nil || (latestTzName != *event.TimeZone)
gEvent, err := c.config.API.GetEvent(details.ID, details.ETag)
-
- var deleted, channelStopped bool
+ var deleted bool
switch {
// http.StatusNotModified is returned sometimes, but not always, so we need to check ETag explicitly later
case googleapi.IsNotModified(err):
@@ -327,7 +333,6 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
}
deleted = true
- channelStopped = true
}
var endTime *time.Time
@@ -344,7 +349,6 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which is in the past", "err", err)
}
deleted = true
- channelStopped = true
}
}
if !deleted {
@@ -360,10 +364,16 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
}
deleted = true
- channelStopped = true
}
}
if !deleted {
+ if c.location == nil {
+ // When we are updating the event, also update the timezone if needed
+ err = updateTimezone()
+ if err != nil {
+ return nil, false, err
+ }
+ }
startTime, err := c.parseDateTime(gEvent.Start)
if err != nil {
return nil, false, err
@@ -376,17 +386,19 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
}
}
- // If event was deleted/cancelled, we need to stop watching it
- if !channelStopped {
- err = c.config.API.Stop(details.ChannelID, details.ResourceID)
- if err != nil {
- level.Warn(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", err)
- }
- }
-
+ // When calculating the new event date, we don't check if the user's calendar timezone has changed.
+ // If the user went across international dateline into "tomorrow", then their event may be re-scheduled for the same day.
+ // Since this rare corner case does not cause any harm, we will ignore it.
newStartDate := calculateNewEventDate(event.StartTime)
- fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn)
+ var createOpts fleet.CalendarCreateEventOpts
+ // Check for backward compatibility, for events created before we introduced notification channels
+ if details.ChannelID != "" && details.ResourceID != "" {
+ createOpts.EventUUID = event.UUID
+ createOpts.ChannelID = details.ChannelID
+ createOpts.ResourceID = details.ResourceID
+ }
+ fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn, createOpts)
if err != nil {
return nil, false, err
}
@@ -464,14 +476,16 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet
}
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time,
- genBodyFn func(conflict bool) (body string, ok bool, err error)) (*fleet.CalendarEvent, error) {
- return c.createEvent(dayOfEvent, genBodyFn, time.Now)
+ genBodyFn func(conflict bool) (body string, ok bool, err error),
+ opts fleet.CalendarCreateEventOpts) (*fleet.CalendarEvent, error) {
+ return c.createEvent(dayOfEvent, genBodyFn, time.Now, opts)
}
// 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, genBodyFn func(conflict bool) (body string, ok bool, err error), timeNow func() time.Time,
+ opts fleet.CalendarCreateEventOpts,
) (*fleet.CalendarEvent, error) {
var err error
if c.location == nil {
@@ -579,17 +593,26 @@ func (c *GoogleCalendar) createEvent(
return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event")
}
- // Watch for event changes
- secondsToEventEnd := eventEnd.Sub(now).Milliseconds() / 1000
- eventUUID := uuid.New().String()
- channelUUID := uuid.New().String()
- resourceID, err := c.config.API.Watch(eventUUID, channelUUID, uint64(secondsToEventEnd))
- if err != nil {
- return nil, ctxerr.Wrap(c.config.Context, err, "watching Google calendar event")
+ // Watch for event changes, if not already watching.
+ var eventUUID, channelID, resourceID string
+ if opts.EventUUID == "" || opts.ChannelID == "" || opts.ResourceID == "" {
+ // Watch for changes until the end of the event, plus 1 more week. The extra time is to handle cases when end user moves the event forward.
+ // We don't support watching events longer than 1 week from the original event time.
+ secondsToEventEnd := (eventEnd.Sub(now).Milliseconds() / 1000) + (7 * 24 * 60 * 60)
+ eventUUID = strings.ToUpper(uuid.New().String()) // Standardize on uppercase UUIDs since that's how they come from DB
+ channelID = uuid.New().String()
+ resourceID, err = c.config.API.Watch(eventUUID, channelID, uint64(secondsToEventEnd))
+ if err != nil {
+ return nil, ctxerr.Wrap(c.config.Context, err, "watching Google calendar event")
+ }
+ } else {
+ eventUUID = opts.EventUUID
+ channelID = opts.ChannelID
+ resourceID = opts.ResourceID
}
// Convert Google event to Fleet event
- fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelUUID, resourceID)
+ fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelID, resourceID)
if err != nil {
return nil, err
}
@@ -670,13 +693,6 @@ func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error {
if err != nil {
return err
}
- // Stop watching the event before deleting the event so that we don't get a callback for the deletion
- if details.ChannelID != "" && details.ResourceID != "" {
- stopErr := c.config.API.Stop(details.ChannelID, details.ResourceID)
- if stopErr != nil {
- level.Warn(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", stopErr)
- }
- }
// Delete the event
err = c.config.API.DeleteEvent(details.ID)
switch {
@@ -696,7 +712,7 @@ func (c *GoogleCalendar) StopEventChannel(event *fleet.CalendarEvent) error {
if details.ChannelID != "" && details.ResourceID != "" {
stopErr := c.config.API.Stop(details.ChannelID, details.ResourceID)
if stopErr != nil {
- level.Warn(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", stopErr)
+ level.Info(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", stopErr)
}
}
return nil
diff --git a/ee/server/calendar/google_calendar_integration_test.go b/ee/server/calendar/google_calendar_integration_test.go
index 6e2bce57a2..0efe396167 100644
--- a/ee/server/calendar/google_calendar_integration_test.go
+++ b/ee/server/calendar/google_calendar_integration_test.go
@@ -2,16 +2,17 @@ package calendar
import (
"context"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
"github.com/fleetdm/fleet/v4/ee/server/calendar/load_test"
"github.com/fleetdm/fleet/v4/server/fleet"
kitlog "github.com/go-kit/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
- "net/http/httptest"
- "os"
- "testing"
- "time"
)
type googleCalendarIntegrationTestSuite struct {
@@ -70,12 +71,20 @@ func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() {
return "Test event", true, nil
}
eventDate := time.Now().Add(48 * time.Hour)
- event, err := gCal.CreateEvent(eventDate, genBodyFn)
+ event, err := gCal.CreateEvent(eventDate, genBodyFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, startHour, event.StartTime.Hour())
assert.Equal(t, 0, event.StartTime.Minute())
+ details, err := gCal.unmarshalDetails(event)
+ require.NoError(t, err)
+ eventUUID := event.UUID
+ channelID := details.ChannelID
+ resourceID := details.ResourceID
+ assert.NotEmpty(t, eventUUID)
+ assert.NotEmpty(t, channelID)
+ assert.NotEmpty(t, resourceID)
- eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn)
+ eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
require.NoError(t, err)
assert.False(t, updated)
assert.Equal(t, event, eventRsp)
@@ -87,10 +96,25 @@ func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() {
assert.NoError(t, err)
// Try to get deleted event
- eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn)
+ eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
require.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event.StartTime.UTC().Truncate(24*time.Hour), eventRsp.StartTime.UTC().Truncate(24*time.Hour))
+
+ opts := fleet.CalendarCreateEventOpts{
+ ChannelID: channelID,
+ ResourceID: resourceID,
+ EventUUID: eventUUID,
+ }
+ event, err = gCal.CreateEvent(eventDate, genBodyFn, opts)
+ require.NoError(t, err)
+ assert.Equal(t, startHour, event.StartTime.Hour())
+ assert.Equal(t, 0, event.StartTime.Minute())
+ details, err = gCal.unmarshalDetails(event)
+ require.NoError(t, err)
+ assert.Equal(t, channelID, details.ChannelID)
+ assert.Equal(t, resourceID, details.ResourceID)
+ assert.Equal(t, eventUUID, event.UUID)
}
func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() {
@@ -114,7 +138,7 @@ func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() {
return "Test event", true, nil
}
eventDate := time.Now().Add(48 * time.Hour)
- event, err := gCal.CreateEvent(eventDate, genBodyFn)
+ event, err := gCal.CreateEvent(eventDate, genBodyFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, startHour, event.StartTime.Hour())
assert.Equal(t, 0, event.StartTime.Minute())
@@ -124,7 +148,7 @@ func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() {
if !(currentEventTime.Hour() == endHour-1 && currentEventTime.Minute() == 30) {
currentEventTime = currentEventTime.Add(30 * time.Minute)
}
- event, err = gCal.CreateEvent(eventDate, genBodyFn)
+ event, err = gCal.CreateEvent(eventDate, genBodyFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, currentEventTime.UTC(), event.StartTime.UTC())
}
diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go
index 2aaaefa1b5..79d541152f 100644
--- a/ee/server/calendar/google_calendar_test.go
+++ b/ee/server/calendar/google_calendar_test.go
@@ -232,7 +232,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
}
// ETag matches
- retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.NoError(t, err)
assert.False(t, updated)
assert.Equal(t, event, retrievedEvent)
@@ -241,7 +241,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return nil, &googleapi.Error{Code: http.StatusNotModified}
}
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.NoError(t, err)
assert.False(t, updated)
assert.Equal(t, event, retrievedEvent)
@@ -252,14 +252,14 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
EndTime: time.Now().Add(time.Hour),
Data: []byte(`{"bozo`),
}
- _, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn)
+ _, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.Error(t, err)
// API error test
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return nil, assert.AnError
}
- _, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.ErrorIs(t, err, assert.AnError)
// Event has been modified
@@ -273,7 +273,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
}, nil
}
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
@@ -296,7 +296,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
End: &calendar.EventDateTime{DateTime: ""},
}, nil
}
- _, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.Error(t, err)
// missing start time
@@ -307,7 +307,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
}, nil
}
- _, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.Error(t, err)
// Bad time format
@@ -319,7 +319,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
End: &calendar.EventDateTime{DateTime: "bozo"},
}, nil
}
- _, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
assert.Error(t, err)
// Event has been modified, with custom timezone.
@@ -338,7 +338,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339), TimeZone: newTzName},
}, nil
}
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{UpdateTimezone: true})
assert.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
@@ -382,7 +382,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
eventCreated = true
return event, nil
}
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
require.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
@@ -410,7 +410,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
}, nil
}
eventCreated = false
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
require.NoError(t, err)
assert.True(t, updated)
require.NotNil(t, retrievedEvent)
@@ -433,7 +433,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
}, nil
}
eventCreated = false
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
require.NoError(t, err)
assert.True(t, updated)
require.NotNil(t, retrievedEvent)
@@ -446,13 +446,13 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
- Etag: "new-eTag",
+ Etag: "new-eTag in past",
Start: &calendar.EventDateTime{DateTime: startTime.Add(-2 * time.Hour).Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: endTime.Add(-2 * time.Hour).Format(time.RFC3339)},
}, nil
}
eventCreated = false
- retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
+ retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
require.NoError(t, err)
assert.True(t, updated)
require.NotNil(t, retrievedEvent)
@@ -508,7 +508,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
assert.Greater(t, ttl, uint64(60*30-1))
return baseResourceID, nil
}
- event, err := cal.CreateEvent(date, genBodyFn)
+ event, err := cal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, uuid, event.UUID)
assert.Equal(t, baseUserEmail, event.Email)
@@ -529,7 +529,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
// Workday already ended
date = time.Now().Add(-48 * time.Hour)
- _, err = cal.CreateEvent(date, genBodyFn)
+ _, err = cal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{})
assert.ErrorAs(t, err, &fleet.DayEndedError{})
// There is no time left in the day to schedule an event
@@ -538,7 +538,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, genBodyFn, timeNow)
+ _, err = gCal.createEvent(date, genBodyFn, timeNow, fleet.CalendarCreateEventOpts{})
assert.ErrorAs(t, err, &fleet.DayEndedError{})
// Workday already started
@@ -547,7 +547,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
timeNow = func() time.Time {
return expectedStartTime
}
- event, err = gCal.createEvent(date, genBodyFn, timeNow)
+ event, err = gCal.createEvent(date, genBodyFn, timeNow, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@@ -640,7 +640,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, genBodyFn)
+ event, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@@ -660,7 +660,7 @@ 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, genBodyConflictFn)
+ event, err = gCal.CreateEvent(date, genBodyConflictFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@@ -680,7 +680,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
return gEvents, nil
}
expectedStartTime = dayEnd
- event, err = gCal.CreateEvent(date, genBodyFn)
+ event, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{})
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@@ -689,7 +689,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return nil, assert.AnError
}
- _, err = gCal.CreateEvent(date, genBodyFn)
+ _, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{})
assert.ErrorIs(t, err, assert.AnError)
// API error in CreateEvent
@@ -699,6 +699,6 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
return nil, assert.AnError
}
- _, err = gCal.CreateEvent(date, genBodyFn)
+ _, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{})
assert.ErrorIs(t, err, assert.AnError)
}
diff --git a/ee/server/service/calendar.go b/ee/server/service/calendar.go
index 34b4e5daba..b6762ae90b 100644
--- a/ee/server/service/calendar.go
+++ b/ee/server/service/calendar.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sync"
+ "time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@@ -39,6 +40,30 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann
return nil
}
+ // If the event was updated recently, we will ignore the callback.
+ // If this was a legitimate update, then it will be caught by the next cron job run (or a future callback).
+ recent, err := svc.distributedLock.Get(ctx, calendar.RecentUpdateKeyPrefix+eventUUID)
+ if err != nil {
+ return err
+ }
+ if recent != nil && *recent == calendar.RecentCalendarUpdateValue {
+ svc.authz.SkipAuthorization(ctx)
+ return nil
+ }
+
+ // In the common case, we get the lock right away and process the event.
+ // Otherwise, we do additional validation to see if we need to process the event.
+ lockValue, reserved, err := svc.getCalendarLock(ctx, eventUUID, false)
+ if err != nil {
+ return err
+ }
+ unlocked := false
+ defer func() {
+ if !unlocked && lockValue != "" {
+ svc.releaseCalendarLock(ctx, eventUUID, lockValue)
+ }
+ }()
+
eventDetails, err := svc.ds.GetCalendarEventDetailsByUUID(ctx, eventUUID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
@@ -71,10 +96,28 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann
return authz.ForbiddenWithInternal(fmt.Sprintf("calendar channel ID mismatch: %s != %s", savedChannelID, channelID), nil, nil, nil)
}
- lockValue, reserved, err := svc.getCalendarLock(ctx, eventUUID, true)
- if err != nil {
- return err
+ // Now that we fully validated the request, try to get the lock again if we didn't get it the first time.
+ // This time the event will be added to the queue if needed.
+ if lockValue == "" {
+ lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, true)
+ if err != nil {
+ return err
+ }
+ if lockValue != "" {
+ // We got the lock, so we can process the event. We need to refetch the event from DB, since it may have changed since the last fetch.
+ eventDetails, err = svc.ds.GetCalendarEventDetailsByUUID(ctx, eventUUID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ // We found the event the first time, but it was deleted before we got the lock.
+ level.Info(svc.logger).Log("msg", "Received calendar callback, but the event was just deleted", "event_uuid",
+ eventUUID, "channel_id", channelID)
+ return nil
+ }
+ return err
+ }
+ }
}
+
// If lock has been reserved by cron, we will need to re-process this event in case the calendar event was changed after the cron job read it.
if lockValue == "" && !reserved {
// We did not get a lock, so there is nothing to do here
@@ -82,13 +125,6 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann
}
if !reserved {
- unlocked := false
- defer func() {
- if !unlocked {
- svc.releaseCalendarLock(ctx, eventUUID, lockValue)
- }
- }()
-
// Remove event from the queue so that we don't process this event again.
// Note: This item can be added back to the queue while we are processing it.
err = svc.distributedLock.RemoveFromSet(ctx, calendar.QueueKey, eventUUID)
@@ -125,6 +161,8 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann
func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *fleet.CalendarEventDetails,
googleCalendarIntegrationConfig *fleet.GoogleCalendarIntegration, userCalendar fleet.UserCalendar) error {
+ // This flag indicates that calendar event should no longer exist, and we can stop watching it.
+ stopChannel := false
genBodyFn := func(conflict bool) (body string, ok bool, err error) {
// This function is called when a new event is being created.
@@ -136,6 +174,7 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee
if team.Config.Integrations.GoogleCalendar == nil ||
!team.Config.Integrations.GoogleCalendar.Enable {
+ stopChannel = true
return "", false, nil
}
@@ -146,6 +185,7 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee
}
if len(policies) == 0 {
+ stopChannel = true
return "", false, nil
}
@@ -161,10 +201,12 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee
return "", false, err
}
if len(hosts) != 1 {
+ stopChannel = true
return "", false, nil
}
host := hosts[0]
if host.Passing { // host is passing all configured policies
+ stopChannel = true
return "", false, nil
}
if host.Email == "" {
@@ -179,17 +221,36 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee
if err != nil {
return ctxerr.Wrap(ctx, err, "configure calendar")
}
- event, updated, err := userCalendar.GetAndUpdateEvent(&eventDetails.CalendarEvent, genBodyFn)
+ event, updated, err := userCalendar.GetAndUpdateEvent(&eventDetails.CalendarEvent, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{})
if err != nil {
return ctxerr.Wrap(ctx, err, "get and update event")
}
if updated && event != nil {
+ // Event was updated, so we set a flag.
+ _, err = svc.distributedLock.AcquireLock(ctx, calendar.RecentUpdateKeyPrefix+event.UUID, calendar.RecentCalendarUpdateValue,
+ uint64(calendar.RecentCalendarUpdateDuration.Milliseconds()))
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "set recent update flag")
+ }
// Event was updated, so we need to save it
_, err = svc.ds.CreateOrUpdateCalendarEvent(ctx, event.UUID, event.Email, event.StartTime, event.EndTime, event.Data,
event.TimeZone, eventDetails.HostID, fleet.CalendarWebhookStatusNone)
if err != nil {
return ctxerr.Wrap(ctx, err, "create or update calendar event")
}
+ // Remove event from the queue (again) so that we don't process this event again in case we got a callback from the event change which we ourselves made.
+ err = svc.distributedLock.RemoveFromSet(ctx, calendar.QueueKey, event.UUID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "remove calendar event from queue")
+ }
+
+ }
+ if stopChannel {
+ // The cron job could have already stopped the channel. For example, if calendar was disabled.
+ err = userCalendar.StopEventChannel(&eventDetails.CalendarEvent)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "stop event channel")
+ }
}
return nil
@@ -202,7 +263,7 @@ func (svc *Service) releaseCalendarLock(ctx context.Context, eventUUID string, l
}
if !ok {
// If the lock was not released, it will expire on its own.
- level.Warn(svc.logger).Log("msg", "Failed to release calendar lock")
+ level.Error(svc.logger).Log("msg", "Failed to release calendar lock", "event uuid", eventUUID, "lockValue", lockValue)
}
}
@@ -221,7 +282,8 @@ func (svc *Service) getCalendarLock(ctx context.Context, eventUUID string, addTo
if !reserved {
// Try to acquire the lock
lockValue = uuid.New().String()
- lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0)
+ lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue,
+ calendar.DistributedLockExpireMs)
if err != nil {
return "", false, ctxerr.Wrap(ctx, err, "acquire calendar lock")
}
@@ -240,15 +302,15 @@ func (svc *Service) getCalendarLock(ctx context.Context, eventUUID string, addTo
}
// Try to acquire the lock again in case it was released while we were adding the event to the queue.
- lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0)
+ lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue,
+ calendar.DistributedLockExpireMs)
if err != nil {
return "", false, ctxerr.Wrap(ctx, err, "acquire calendar lock again")
}
-
- if !lockAcquired {
- // We could not acquire the lock, so we are done here.
- return "", reserved, nil
- }
+ }
+ if !lockAcquired {
+ // We could not acquire the lock, so we are done here.
+ return "", reserved, nil
}
return lockValue, false, nil
}
@@ -259,15 +321,24 @@ func (svc *Service) processCalendarAsync(ctx context.Context, eventIDs []string)
asyncCalendarProcessing = false
asyncMutex.Unlock()
}()
+ const minLoopTime = time.Second
+ runTime := minLoopTime
for {
if len(eventIDs) == 0 {
return
}
+ // We want to make sure we don't run this too often to reduce load on CPU/Redis, so we wait at least a second between runs.
+ if runTime < minLoopTime && runTime > 0 {
+ time.Sleep(minLoopTime - runTime)
+ }
+ start := svc.clock.Now()
for _, eventUUID := range eventIDs {
if ok := svc.processCalendarEventAsync(ctx, eventUUID); !ok {
return
}
}
+ end := svc.clock.Now()
+ runTime = end.Sub(start)
// Now we check whether there are any more events in the queue.
var err error
@@ -280,6 +351,22 @@ func (svc *Service) processCalendarAsync(ctx context.Context, eventIDs []string)
}
func (svc *Service) processCalendarEventAsync(ctx context.Context, eventUUID string) bool {
+ // If the event was updated recently, we will ignore it.
+ // If this was a legitimate update, then it will be caught by the next cron job run (or a future callback).
+ recent, err := svc.distributedLock.Get(ctx, calendar.RecentUpdateKeyPrefix+eventUUID)
+ if err != nil {
+ level.Error(svc.logger).Log("msg", "Failed to get recent update flag", "err", err)
+ return false
+ }
+ if recent != nil && *recent == calendar.RecentCalendarUpdateValue {
+ err = svc.distributedLock.RemoveFromSet(ctx, calendar.QueueKey, eventUUID)
+ if err != nil {
+ level.Error(svc.logger).Log("msg", "Failed to remove calendar event from queue", "err", err)
+ return false
+ }
+ return true
+ }
+
lockValue, _, err := svc.getCalendarLock(ctx, eventUUID, false)
if err != nil {
level.Error(svc.logger).Log("msg", "Failed to get calendar lock", "err", err)
@@ -289,7 +376,9 @@ func (svc *Service) processCalendarEventAsync(ctx context.Context, eventUUID str
// We did not get a lock, so there is nothing to do here
return true
}
- defer svc.releaseCalendarLock(ctx, eventUUID, lockValue)
+ defer func() {
+ svc.releaseCalendarLock(ctx, eventUUID, lockValue)
+ }()
// Remove event from the queue so that we don't process this event again.
// Note: This item can be added back to the queue while we are processing it.
diff --git a/ee/server/service/calendar_test.go b/ee/server/service/calendar_test.go
new file mode 100644
index 0000000000..499112f5dd
--- /dev/null
+++ b/ee/server/service/calendar_test.go
@@ -0,0 +1,108 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Implement fleet.Lock interface
+type mockLock struct {
+ AcquireLockFn func(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error)
+ GetFn func(ctx context.Context, key string) (*string, error)
+ AddToSetFn func(ctx context.Context, key string, value string) error
+}
+
+func (m *mockLock) AcquireLock(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) {
+ return m.AcquireLockFn(ctx, key, value, expireMs)
+}
+
+func (m *mockLock) ReleaseLock(ctx context.Context, key string, value string) (ok bool, err error) {
+ panic("implement me")
+}
+
+func (m *mockLock) Get(ctx context.Context, key string) (*string, error) {
+ return m.GetFn(ctx, key)
+}
+
+func (m *mockLock) AddToSet(ctx context.Context, key string, value string) error {
+ return m.AddToSetFn(ctx, key, value)
+}
+
+func (m *mockLock) RemoveFromSet(ctx context.Context, key string, value string) error {
+ panic("implement me")
+}
+
+func (m *mockLock) GetSet(ctx context.Context, key string) ([]string, error) {
+ panic("implement me")
+}
+
+var calendarTestSetup = func(t *testing.T) (*mockLock, *Service) {
+ lock := &mockLock{}
+ svc := &Service{
+ distributedLock: lock,
+ }
+ return lock, svc
+}
+
+func TestGetCalendarLock(t *testing.T) {
+ lock, svc := calendarTestSetup(t)
+ ctx := context.Background()
+ eventUUID := "testUUID"
+ lock.AcquireLockFn = func(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) {
+ return true, nil
+ }
+ lock.GetFn = func(ctx context.Context, key string) (*string, error) {
+ // not reserved
+ return nil, nil
+ }
+ lockValue, reserved, err := svc.getCalendarLock(ctx, eventUUID, false)
+ require.NoError(t, err)
+ assert.False(t, reserved)
+ assert.NotEmpty(t, lockValue)
+
+ // Make sure lock value is empty if we don't acquire the lock.
+ lock.AcquireLockFn = func(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) {
+ return false, nil
+ }
+ lock.GetFn = func(ctx context.Context, key string) (*string, error) {
+ value := "value"
+ return &value, nil
+ }
+ lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, false)
+ require.NoError(t, err)
+ assert.True(t, reserved)
+ assert.Empty(t, lockValue)
+
+ addedToSet := false
+ lock.AddToSetFn = func(ctx context.Context, key string, value string) error {
+ addedToSet = true
+ return nil
+ }
+ lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, true)
+ require.NoError(t, err)
+ assert.True(t, reserved)
+ assert.Empty(t, lockValue)
+ assert.True(t, addedToSet)
+
+ addedToSet = false
+ lock.GetFn = func(ctx context.Context, key string) (*string, error) {
+ // not reserved
+ return nil, nil
+ }
+ lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, false)
+ require.NoError(t, err)
+ assert.False(t, reserved)
+ assert.Empty(t, lockValue)
+ assert.False(t, addedToSet)
+
+ addedToSet = false
+ lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, true)
+ require.NoError(t, err)
+ assert.False(t, reserved)
+ assert.Empty(t, lockValue)
+ assert.True(t, addedToSet)
+
+}
diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go
index 9d85ad0028..a16f3355ac 100644
--- a/server/cron/calendar_cron.go
+++ b/server/cron/calendar_cron.go
@@ -323,15 +323,16 @@ func processFailingHostExistingCalendarEvent(
// Try to acquire the lock. Lock is needed to ensure calendar callback is not processed for this event at the same time.
eventUUID := calendarEvent.UUID
lockValue := uuid.New().String()
- lockAcquired, err := distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0)
+ lockAcquired, err := distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, calendar.DistributedLockExpireMs)
if err != nil {
return fmt.Errorf("acquire calendar lock: %w", err)
}
+
lockReserved := false
if !lockAcquired {
// Lock was not acquired. We reserve the lock and try to acquire it until we do.
- var timeoutMs uint64 = 2 * 60 * 1000
- lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.ReservedLockKeyPrefix+eventUUID, lockValue, timeoutMs)
+ lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.ReservedLockKeyPrefix+eventUUID, lockValue,
+ calendar.ReserveLockExpireMs)
if err != nil {
return fmt.Errorf("reserve calendar lock: %w", err)
}
@@ -344,12 +345,13 @@ func processFailingHostExistingCalendarEvent(
go func() {
for {
// Keep trying to get the lock.
- lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0)
+ lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue,
+ calendar.DistributedLockExpireMs)
if err != nil || lockAcquired {
done <- struct{}{}
return
}
- time.Sleep(100 * time.Millisecond)
+ time.Sleep(200 * time.Millisecond)
}
}()
select {
@@ -358,7 +360,7 @@ func processFailingHostExistingCalendarEvent(
if err != nil {
return fmt.Errorf("try to acquire calendar lock: %w", err)
}
- case <-time.After(time.Duration(timeoutMs) * time.Millisecond):
+ case <-time.After(time.Duration(calendar.ReserveLockExpireMs) * time.Millisecond):
// We couldn't acquire the lock in time.
return errors.New("could not acquire calendar lock in time")
}
@@ -372,7 +374,7 @@ func processFailingHostExistingCalendarEvent(
}
if !ok {
// If the lock was not released, it will expire on its own.
- level.Warn(logger).Log("msg", "Failed to release calendar reserve lock")
+ level.Error(logger).Log("msg", "Failed to release calendar reserve lock", "event uuid", eventUUID, "lockValue", lockValue)
}
}
ok, err := distributedLock.ReleaseLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue)
@@ -380,8 +382,8 @@ func processFailingHostExistingCalendarEvent(
level.Error(logger).Log("msg", "Failed to release calendar lock", "err", err)
}
if !ok {
- // If the lock was not released, it will expire on its own.
- level.Warn(logger).Log("msg", "Failed to release calendar lock")
+ // If the lock was not released, it will expire on its own. However, we should adjust expiration time or something else to make sure we don't get here.
+ level.Error(logger).Log("msg", "Failed to release calendar lock", "event uuid", eventUUID, "lockValue", lockValue)
}
}()
@@ -390,11 +392,23 @@ func processFailingHostExistingCalendarEvent(
now := time.Now()
if calendarConfig.AlwaysReloadEvent() || shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) {
- var err error
+ // Refetch the event since it may have updated since we got the lock.
+ // We need the latest event data (ETag) to make sure that we get correct data from the calendar service.
+ calendarEvent, err = ds.GetCalendarEvent(ctx, calendarEvent.Email)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ // Event was deleted while we were processing it. It will be recreated if needed on the next cron run
+ return nil
+ }
+ return fmt.Errorf("get calendar event from db: %w", err)
+ }
+ // We could check the updated_at timestamp and avoid updating the event if it was updated recently.
+
updatedEvent, _, err = userCalendar.GetAndUpdateEvent(
calendarEvent, func(conflict bool) (string, bool, error) {
return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil
},
+ fleet.CalendarGetAndUpdateEventOpts{UpdateTimezone: true},
)
if err != nil {
return fmt.Errorf("get event calendar on db: %w", err)
@@ -417,6 +431,7 @@ func processFailingHostExistingCalendarEvent(
}
// Remove event from the queue so that we don't process this event again.
+ // If we just modified the event in the calendar, calendar will send a callback, and we don't need to process that callback.
err = distributedLock.RemoveFromSet(ctx, calendar.QueueKey, eventUUID)
if err != nil {
return fmt.Errorf("remove calendar event from queue: %w", err)
@@ -528,7 +543,7 @@ func attemptCreatingEventOnUserCalendar(
calendarEvent, err := userCalendar.CreateEvent(
preferredDate, func(conflict bool) (string, bool, error) {
return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil
- },
+ }, fleet.CalendarCreateEventOpts{},
)
var dee fleet.DayEndedError
switch {
@@ -817,6 +832,10 @@ func deleteCalendarEvent(
return fmt.Errorf("delete calendar event: %w", err)
}
}
+ // Stop watching for calendar changes
+ if err := userCalendar.StopEventChannel(calendarEvent); err != nil {
+ return fmt.Errorf("stop event channel: %w", err)
+ }
}
if err := ds.DeleteCalendarEvent(ctx, calendarEvent.ID); err != nil {
return fmt.Errorf("delete db calendar event: %w", err)
diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go
index e4a76354d8..f168e26620 100644
--- a/server/fleet/calendar.go
+++ b/server/fleet/calendar.go
@@ -28,11 +28,16 @@ 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, genBodyFn func(conflict bool) (body string, ok bool, err error)) (event *CalendarEvent, err error)
+ CreateEvent(
+ dateOfEvent time.Time,
+ genBodyFn func(conflict bool) (body string, ok bool, err error),
+ opts CalendarCreateEventOpts,
+ ) (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(conflict bool) (body string, ok bool, err error)) (updatedEvent *CalendarEvent,
+ GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) (body string, ok bool, err error),
+ opts CalendarGetAndUpdateEventOpts) (updatedEvent *CalendarEvent,
updated bool, err error)
// DeleteEvent deletes the event with the given ID.
DeleteEvent(event *CalendarEvent) error
@@ -61,6 +66,16 @@ type Lock interface {
GetSet(ctx context.Context, key string) ([]string, error)
}
+type CalendarCreateEventOpts struct {
+ EventUUID string
+ ChannelID string
+ ResourceID string
+}
+
+type CalendarGetAndUpdateEventOpts struct {
+ UpdateTimezone bool
+}
+
type CalendarWebhookPayload struct {
Timestamp time.Time `json:"timestamp"`
HostID uint `json:"host_id"`
diff --git a/server/service/calendar.go b/server/service/calendar.go
index ce89db6779..6f7ff6b26e 100644
--- a/server/service/calendar.go
+++ b/server/service/calendar.go
@@ -2,53 +2,55 @@ package service
import (
"context"
+ "net/http"
+ "net/url"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/gorilla/mux"
)
-// Disabling the calendarWebhookEndpoint to address bugs
-
-// type calendarWebhookRequest struct {
-// eventUUID string
-// googleChannelID string
-// googleResourceState string
-// }
+type calendarWebhookRequest struct {
+ eventUUID string
+ googleChannelID string
+ googleResourceState string
+}
// DecodeRequest implement requestDecoder interface to take full control of decoding the request
-// func (calendarWebhookRequest) DecodeRequest(_ context.Context, r *http.Request) (interface{}, error) {
-// var req calendarWebhookRequest
-// eventUUID, ok := mux.Vars(r)["event_uuid"]
-// if !ok {
-// return nil, errBadRoute
-// }
-// unescaped, err := url.PathUnescape(eventUUID)
-// if err != nil {
-// return "", ctxerr.Wrap(r.Context(), err, "unescape value in path")
-// }
-// req.eventUUID = unescaped
+func (calendarWebhookRequest) DecodeRequest(_ context.Context, r *http.Request) (interface{}, error) {
+ var req calendarWebhookRequest
+ eventUUID, ok := mux.Vars(r)["event_uuid"]
+ if !ok {
+ return nil, errBadRoute
+ }
+ unescaped, err := url.PathUnescape(eventUUID)
+ if err != nil {
+ return "", ctxerr.Wrap(r.Context(), err, "unescape value in path")
+ }
+ req.eventUUID = unescaped
-// req.googleChannelID = r.Header.Get("X-Goog-Channel-Id")
-// req.googleResourceState = r.Header.Get("X-Goog-Resource-State")
+ req.googleChannelID = r.Header.Get("X-Goog-Channel-Id")
+ req.googleResourceState = r.Header.Get("X-Goog-Resource-State")
-// return &req, nil
-// }
+ return &req, nil
+}
-// type calendarWebhookResponse struct {
-// Err error `json:"error,omitempty"`
-// }
+type calendarWebhookResponse struct {
+ Err error `json:"error,omitempty"`
+}
-// func (r calendarWebhookResponse) error() error { return r.Err }
+func (r calendarWebhookResponse) error() error { return r.Err }
-// func calendarWebhookEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
-// req := request.(*calendarWebhookRequest)
-// err := svc.CalendarWebhook(ctx, req.eventUUID, req.googleChannelID, req.googleResourceState)
-// if err != nil {
-// return calendarWebhookResponse{Err: err}, err
-// }
+func calendarWebhookEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*calendarWebhookRequest)
+ err := svc.CalendarWebhook(ctx, req.eventUUID, req.googleChannelID, req.googleResourceState)
+ if err != nil {
+ return calendarWebhookResponse{Err: err}, err
+ }
-// resp := calendarWebhookResponse{}
-// return resp, nil
-// }
+ resp := calendarWebhookResponse{}
+ return resp, nil
+}
func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error {
// skipauth: No authorization check needed due to implementation returning only license error.
diff --git a/server/service/calendar/calendar.go b/server/service/calendar/calendar.go
index a125ddb7a3..d6ec6bdb1b 100644
--- a/server/service/calendar/calendar.go
+++ b/server/service/calendar/calendar.go
@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
"github.com/fleetdm/fleet/v4/ee/server/calendar"
"github.com/fleetdm/fleet/v4/server/config"
@@ -19,9 +20,28 @@ import (
const (
LockKeyPrefix = "calendar:lock:"
ReservedLockKeyPrefix = "calendar:reserved:"
+ RecentUpdateKeyPrefix = "calendar:recent_update:"
QueueKey = "calendar:queue"
+
+ // DistributedLockExpireMs is the time Redis will hold the lock before automatically releasing it.
+ // Our current max retry time for calendar API is 10 minutes, and multiple API calls (with their own retry timing) can be made during event processing.
+ // If a Fleet server gets the lock and is then shut down before releasing the lock, the next server may need to wait this long
+ // before getting the lock.
+ DistributedLockExpireMs = 20 * 60 * 1000
+ // ReserveLockExpireMs is used by cron job to guarantee that it gets the next lock.
+ ReserveLockExpireMs = 2 * DistributedLockExpireMs
+
+ // RecentCalendarUpdateValue is the value stored in Redis to indicate that a calendar event was recently updated.
+ RecentCalendarUpdateValue = "1"
)
+// RecentCalendarUpdateDuration is the duration during which we will ignore a calendar event callback if the event in DB was just updated by a previous callback.
+// This reduces CPU load and Google API load. If we update the event, Google calendar may send a callback which we don't need to process.
+// We are using Redis instead of updated_at timestamp in DB because the calendar cron job may update the timestamp even when the event did not change, which could
+// cause us to miss a legitimate update.
+// This variable is exposed so that it can be modified by unit tests.
+var RecentCalendarUpdateDuration = 10 * time.Second
+
type Config struct {
config.CalendarConfig
fleet.GoogleCalendarIntegration
diff --git a/server/service/handler.go b/server/service/handler.go
index 48fe34c6ec..66709d72f5 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -941,8 +941,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ne.HEAD("/api/fleet/orbit/ping", orbitPingEndpoint, orbitPingRequest{})
// This is a callback endpoint for calendar integration -- it is called to notify an event change in a user calendar
- // Disabling the calendarWebhookEndpoint to address bugs
- // ne.POST("/api/_version_/fleet/calendar/webhook/{event_uuid}", calendarWebhookEndpoint, calendarWebhookRequest{})
+ ne.POST("/api/_version_/fleet/calendar/webhook/{event_uuid}", calendarWebhookEndpoint, calendarWebhookRequest{})
neAppleMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})).
POST("/api/_version_/fleet/mdm/sso", initiateMDMAppleSSOEndpoint, initiateMDMAppleSSORequest{})
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index b93e8b23ad..dc110cdc40 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -10983,9 +10983,8 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() {
}
func (s *integrationEnterpriseTestSuite) TestCalendarCallback() {
- t := s.T()
- t.Skip("disabled calendar callbacks to address bugs")
ctx := context.Background()
+ t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
calendar.ClearMockChannels()
@@ -10997,6 +10996,12 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() {
require.NoError(t, err)
})
+ origRecentUpdateDuration := commonCalendar.RecentCalendarUpdateDuration
+ commonCalendar.RecentCalendarUpdateDuration = 1 * time.Millisecond
+ t.Cleanup(func() {
+ commonCalendar.RecentCalendarUpdateDuration = origRecentUpdateDuration
+ })
+
team1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "team1",
})
@@ -11241,8 +11246,9 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() {
time.Sleep(100 * time.Millisecond)
team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
require.NoError(t, err)
- require.Len(t, team1CalendarEvents, 1)
- if event.UUID != team1CalendarEvents[0].UUID {
+ // Event should be rescheduled on a future date/time
+ if len(team1CalendarEvents) == 1 && team1CalendarEvents[0].UUID == event.UUID &&
+ team1CalendarEvents[0].StartTime.After(event.StartTime) {
done <- struct{}{}
return
}
@@ -11392,7 +11398,20 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() {
},
), http.StatusOK, &distributedResp)
- // Callback should still work, but only clear the callback channel. Event in DB will be deleted on the next cron run.
+ // We set a flag that event was updated recently. Callback shouldn't do anything since event was updated recently
+ _, err = distributedLock.AcquireLock(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue,
+ 1000)
+ require.NoError(t, err)
+ _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
+ map[string]string{
+ "X-Goog-Channel-Id": details.ChannelID,
+ "X-Goog-Resource-State": "exists",
+ })
+ assert.Equal(t, 1, calendar.MockChannelsCount())
+
+ // Callback should work, but only clear the callback channel. Event in DB will be deleted on the next cron run.
+ _, err = distributedLock.ReleaseLock(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue)
+ require.NoError(t, err)
_ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK,
map[string]string{
"X-Goog-Channel-Id": details.ChannelID,
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index df5fa5c219..ce7a82ea9e 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -188,7 +188,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
svc, err = eeservice.NewService(
svc,
ds,
- kitlog.NewNopLogger(),
+ logger,
fleetConfig,
mailer,
c,
diff --git a/tools/calendar/get-events/get-events.go b/tools/calendar/get-events/get-events.go
new file mode 100644
index 0000000000..46ce64cd4a
--- /dev/null
+++ b/tools/calendar/get-events/get-events.go
@@ -0,0 +1,196 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "golang.org/x/oauth2/google"
+ "golang.org/x/oauth2/jwt"
+ "google.golang.org/api/calendar/v3"
+ "google.golang.org/api/googleapi"
+ "google.golang.org/api/option"
+)
+
+// Get all events with eventTitle from the primary calendar of the specified users.
+// Example: go run delete-events.go --users john@example.com,jane@example.com
+
+var (
+ serviceEmail = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL")
+ privateKey = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY")
+)
+
+const (
+ eventTitle = "💻🚫 Scheduled maintenance"
+)
+
+var regexMachineName = regexp.MustCompile(`your work computer (because there was no remaining availability )?\((?P.*)\)\.`)
+
+func main() {
+ if serviceEmail == "" || privateKey == "" {
+ log.Fatal("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL and FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY must be set")
+ }
+ // Strip newlines from private key
+ privateKey = strings.Replace(privateKey, "\\n", "\n", -1)
+ userEmails := flag.String("users", "", "Comma-separated list of user emails to impersonate")
+ flag.Parse()
+ if *userEmails == "" {
+ log.Fatal("--users are required")
+ }
+ userEmailList := strings.Split(*userEmails, ",")
+ if len(userEmailList) == 0 {
+ log.Fatal("No user emails provided")
+ }
+
+ ctx := context.Background()
+
+ var wg sync.WaitGroup
+
+ type summary struct {
+ total int
+ totalByDate map[string]int
+ duplicates map[string]struct{}
+ }
+ summaryByUser := make(map[string]summary)
+
+ for _, userEmail := range userEmailList {
+ wg.Add(1)
+ go func(userEmail string) {
+ defer wg.Done()
+ conf := &jwt.Config{
+ Email: serviceEmail,
+ Scopes: []string{
+ "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.settings.readonly",
+ },
+ PrivateKey: []byte(privateKey),
+ TokenURL: google.JWTTokenURL,
+ Subject: userEmail,
+ }
+ client := conf.Client(ctx)
+ // Create a new calendar service
+ service, err := calendar.NewService(ctx, option.WithHTTPClient(client))
+ if err != nil {
+ log.Fatalf("Unable to create Calendar service: %v", err)
+ }
+ var maxResults int64 = 1000
+ pageToken := ""
+ var total = 0
+ var totalByDate = make(map[string]int)
+ var machines = make(map[string]struct{})
+ var duplicates = make(map[string]struct{})
+ for {
+ list, err := withRetry(
+ func() (any, error) {
+ return service.Events.List("primary").
+ EventTypes("default").
+ MaxResults(maxResults).
+ OrderBy("startTime").
+ SingleEvents(true).
+ ShowDeleted(false).
+ Q(eventTitle).
+ PageToken(pageToken).
+ Do()
+ },
+ )
+ if err != nil {
+ log.Fatalf("Unable to retrieve list of events: %v", err)
+ }
+ for _, item := range list.(*calendar.Events).Items {
+ if item.Summary == eventTitle {
+ created, err := time.Parse(time.RFC3339, item.Created)
+ if err != nil {
+ log.Fatalf("Unable to parse event created time: %v", err)
+ }
+ var startTime time.Time
+ if item.Start != nil {
+ startTime, err = time.Parse(time.RFC3339, item.Start.DateTime)
+ if err != nil {
+ log.Fatalf("Unable to parse event start time: %v", err)
+ }
+ }
+ matches := regexMachineName.FindStringSubmatch(item.Description)
+ machineName := "NOT_FOUND"
+ if matches != nil {
+ machineName = matches[regexMachineName.SubexpIndex("machine")]
+ if _, ok := machines[machineName]; ok {
+ duplicates[machineName] = struct{}{}
+ }
+ machines[machineName] = struct{}{}
+ }
+ total += 1
+ dateStr := startTime.Format("2006-01-02")
+ totalByDate[dateStr] += 1
+ fmt.Printf("%s created_at:%s user:%s machine:%s\n", startTime.Format(time.RFC3339), created.Format(time.RFC3339),
+ userEmail, machineName)
+ }
+ }
+ pageToken = list.(*calendar.Events).NextPageToken
+ if pageToken == "" || len(list.(*calendar.Events).Items) == 0 {
+ summaryByUser[userEmail] = summary{total: total, totalByDate: totalByDate, duplicates: duplicates}
+ break
+ }
+ }
+ }(userEmail)
+ }
+
+ // Wait for all goroutines to finish
+ wg.Wait()
+
+ fmt.Printf("Summary:\n")
+ for userEmail, s := range summaryByUser {
+ fmt.Printf("User: %s, Total: %d\n", userEmail, s.total)
+ for date, count := range s.totalByDate {
+ fmt.Printf("User: %s, Date: %s, Count: %d\n", userEmail, date, count)
+ }
+ if len(s.duplicates) > 0 {
+ dups := make([]string, 0, len(s.duplicates))
+ for k := range s.duplicates {
+ dups = append(dups, k)
+ }
+ fmt.Printf("User: %s, Machines with multiple events: %v\n", userEmail, dups)
+ }
+ }
+
+}
+
+func withRetry(fn func() (any, error)) (any, error) {
+ retryStrategy := backoff.NewExponentialBackOff()
+ retryStrategy.MaxElapsedTime = 60 * time.Minute
+ var result any
+ err := backoff.Retry(
+ func() error {
+ var err error
+ result, err = fn()
+ if err != nil {
+ if isRateLimited(err) {
+ return err
+ }
+ return backoff.Permanent(err)
+ }
+ return nil
+ }, retryStrategy,
+ )
+ return result, err
+}
+
+func isRateLimited(err error) bool {
+ if err == nil {
+ return false
+ }
+ var ae *googleapi.Error
+ ok := errors.As(err, &ae)
+ return ok && (ae.Code == http.StatusTooManyRequests ||
+ (ae.Code == http.StatusForbidden &&
+ (ae.Message == "Rate Limit Exceeded" || ae.Message == "User Rate Limit Exceeded" || ae.Message == "Calendar usage limits exceeded." || strings.HasPrefix(
+ ae.Message, "Quota exceeded",
+ ))))
+}
From fb6b263fb0c294230f83feb6a55b700905f04d71 Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Wed, 24 Jul 2024 12:08:38 -0400
Subject: [PATCH 03/11] =?UTF-8?q?Frontend=20refactor:=20To=20typescript,?=
=?UTF-8?q?=20remove=20unused=20testing=20stubs,=20functi=E2=80=A6=20(#203?=
=?UTF-8?q?06)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../{TargetDetails.jsx => TargetDetails.tsx} | 130 +++++-----
.../TargetDetails/{index.js => index.ts} | 0
.../TargetOption/TargetIcon.jsx | 32 ---
.../TargetOption/TargetIcon.tsx | 36 +++
.../TargetOption/TargetOption.jsx | 79 ------
...ption.tests.jsx => TargetOption.tests.tsx} | 48 +++-
.../TargetOption/TargetOption.tsx | 81 ++++++
.../TargetOption/{index.js => index.ts} | 0
.../fields/SelectTargetsDropdown/helpers.ts | 24 ++
.../components/icons/FleetIcon/FleetIcon.jsx | 32 ---
...leetIcon.tests.jsx => FleetIcon.tests.tsx} | 0
.../components/icons/FleetIcon/FleetIcon.tsx | 29 +++
.../icons/FleetIcon/{index.js => index.ts} | 0
.../icons/PlatformIcon/PlatformIcon.jsx | 40 ---
...mIcon.tests.jsx => PlatformIcon.tests.tsx} | 0
.../icons/PlatformIcon/PlatformIcon.tsx | 43 ++++
.../icons/PlatformIcon/{index.js => index.ts} | 0
frontend/interfaces/label.ts | 4 +-
frontend/interfaces/target.ts | 4 +
.../helpers/userManagementHelpers.tests.ts | 7 +-
frontend/test/stubs.ts | 239 ------------------
21 files changed, 322 insertions(+), 506 deletions(-)
rename frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/{TargetDetails.jsx => TargetDetails.tsx} (73%)
rename frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/{index.js => index.ts} (100%)
delete mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.jsx
create mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.tsx
delete mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.jsx
rename frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/{TargetOption.tests.jsx => TargetOption.tests.tsx} (52%)
create mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tsx
rename frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/{index.js => index.ts} (100%)
create mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/helpers.ts
delete mode 100644 frontend/components/icons/FleetIcon/FleetIcon.jsx
rename frontend/components/icons/FleetIcon/{FleetIcon.tests.jsx => FleetIcon.tests.tsx} (100%)
create mode 100644 frontend/components/icons/FleetIcon/FleetIcon.tsx
rename frontend/components/icons/FleetIcon/{index.js => index.ts} (100%)
delete mode 100644 frontend/components/icons/PlatformIcon/PlatformIcon.jsx
rename frontend/components/icons/PlatformIcon/{PlatformIcon.tests.jsx => PlatformIcon.tests.tsx} (100%)
create mode 100644 frontend/components/icons/PlatformIcon/PlatformIcon.tsx
rename frontend/components/icons/PlatformIcon/{index.js => index.ts} (100%)
diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.jsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx
similarity index 73%
rename from frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.jsx
rename to frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx
index 9b0765ed4c..d3780901c6 100644
--- a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.jsx
+++ b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx
@@ -1,28 +1,35 @@
-import React, { Component } from "react";
-import PropTypes from "prop-types";
+import React from "react";
import { noop } from "lodash";
import AceEditor from "react-ace";
import classnames from "classnames";
import { humanHostMemory } from "utilities/helpers";
+// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
+// @ts-ignore
import PlatformIcon from "components/icons/PlatformIcon";
-import targetInterface from "interfaces/target";
+import { ISelectHost, ISelectLabel, ISelectTeam } from "interfaces/target";
+
+import { isTargetHost, isTargetTeam, isTargetLabel } from "../helpers";
const baseClass = "target-details";
-class TargetDetails extends Component {
- static propTypes = {
- target: targetInterface,
- className: PropTypes.string,
- handleBackToResults: PropTypes.func,
- };
+interface ITargetDetailsProps {
+ target: ISelectHost | ISelectTeam | ISelectLabel; // Replace with Target
+ className?: string;
+ handleBackToResults?: () => void;
+}
- static defaultProps = {
- handleBackToResults: noop,
- };
-
- onlineHosts = (labelBaseClass, count, online) => {
+const TargetDetails = ({
+ target,
+ className = "",
+ handleBackToResults = noop,
+}: ITargetDetailsProps): JSX.Element => {
+ const onlineHosts = (
+ labelBaseClass: string,
+ count: number,
+ online: number
+ ) => {
const offline = count - online;
const percentCount = ((count - offline) / count) * 100;
const percentOnline = parseFloat(percentCount.toFixed(2));
@@ -39,8 +46,7 @@ class TargetDetails extends Component {
return false;
};
- renderHost = () => {
- const { className, handleBackToResults, target } = this.props;
+ const renderHost = (hostTarget: ISelectHost) => {
const {
display_text: displayText,
primary_mac: hostMac,
@@ -50,7 +56,7 @@ class TargetDetails extends Component {
os_version: osVersion,
platform,
status,
- } = target;
+ } = hostTarget;
const hostBaseClass = "host-target";
const isOnline = status === "online";
const isOffline = status === "offline";
@@ -131,19 +137,17 @@ class TargetDetails extends Component {
);
};
- renderLabel = () => {
- const { onlineHosts } = this;
- const { handleBackToResults, className, target } = this.props;
+ const renderLabel = (labelTarget: ISelectLabel) => {
const {
count,
description,
display_text: displayText,
label_type: labelType,
- online,
+ // online,
query,
- } = target;
+ } = labelTarget;
const labelBaseClass = "label-target";
-
+ console.log("ERROR 1: labelTarget", labelTarget);
return (