diff --git a/changes/19352-calendar-real-time b/changes/19352-calendar-real-time
new file mode 100644
index 0000000000..dc72e98899
--- /dev/null
+++ b/changes/19352-calendar-real-time
@@ -0,0 +1 @@
+- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past.
diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go
index 938f7d65a6..db7f98cc79 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"
@@ -16,6 +17,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
+ "github.com/google/uuid"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
"google.golang.org/api/calendar/v3"
@@ -35,7 +37,7 @@ const (
endHour = 17
eventLength = 30 * time.Minute
calendarID = "primary"
- mockEmail = "calendar-mock@example.com"
+ MockEmail = "calendar-mock@example.com"
loadEmail = "calendar-load@example.com"
)
@@ -52,6 +54,7 @@ type GoogleCalendarConfig struct {
Context context.Context
IntegrationConfig *fleet.GoogleCalendarIntegration
Logger kitlog.Logger
+ ServerURL string
// Should be nil for production
API GoogleCalendarAPI
}
@@ -71,7 +74,7 @@ func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar {
// Use the provided API.
case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == loadEmail:
config.API = &GoogleCalendarLoadAPI{Logger: config.Logger}
- case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == mockEmail:
+ case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == MockEmail:
config.API = &GoogleCalendarMockAPI{config.Logger}
default:
config.API = &GoogleCalendarLowLevelAPI{logger: config.Logger}
@@ -82,27 +85,33 @@ func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar {
}
type GoogleCalendarAPI interface {
- Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error
+ Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error
GetSetting(name string) (*calendar.Setting, error)
ListEvents(timeMin, timeMax string) (*calendar.Events, error)
CreateEvent(event *calendar.Event) (*calendar.Event, error)
GetEvent(id, eTag string) (*calendar.Event, error)
DeleteEvent(id string) error
+ Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error)
+ Stop(channelID string, resourceID string) error
}
type eventDetails struct {
ID string `json:"id"`
ETag string `json:"etag"`
+ // ChannelID and ResourceID are for watching event changes
+ ChannelID string `json:"channel_id"`
+ ResourceID string `json:"resource_id"`
}
type GoogleCalendarLowLevelAPI struct {
- service *calendar.Service
- logger kitlog.Logger
+ service *calendar.Service
+ logger kitlog.Logger
+ serverURL string
}
// Configure creates a new Google Calendar service using the provided credentials.
func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure(
- ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string,
+ ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string,
) error {
// Create a new calendar service
conf := &jwt.Config{
@@ -118,6 +127,7 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure(
return err
}
lowLevelAPI.service = service
+ lowLevelAPI.serverURL = serverURL
return nil
}
@@ -181,6 +191,38 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
return err
}
+func (lowLevelAPI *GoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
+ 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 {
+ _, 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) {
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 10 * time.Minute
@@ -207,6 +249,7 @@ func (c *GoogleCalendar) Configure(userEmail string) error {
err := c.config.API.Configure(
c.config.Context, c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail],
c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], adjustedUserEmail,
+ c.config.ServerURL,
)
if err != nil {
return ctxerr.Wrap(c.config.Context, err, "creating Google calendar service")
@@ -218,7 +261,7 @@ func (c *GoogleCalendar) Configure(userEmail string) error {
return nil
}
-func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) (
+func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) (body string, ok bool, err error)) (
*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.
@@ -235,6 +278,11 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
// http.StatusNotFound should be very rare -- Google keeps events for a while after they are deleted
case isNotFound(err):
deleted = true
+ // If event was deleted, we need to stop watching it
+ 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)
+ }
case err != nil:
return nil, false, ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event")
}
@@ -294,7 +342,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn
if err != nil {
return nil, false, err
}
- fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent)
+ fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent, event.UUID, details.ChannelID, details.ResourceID)
if err != nil {
return nil, false, err
}
@@ -381,14 +429,15 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet
return &details, nil
}
-func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) {
+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)
}
// 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) string, timeNow func() time.Time,
+ dayOfEvent time.Time, genBodyFn func(conflict bool) (body string, ok bool, err error), timeNow func() time.Time,
) (*fleet.CalendarEvent, error) {
var err error
if c.location == nil {
@@ -482,14 +531,31 @@ func (c *GoogleCalendar) createEvent(
event.Start = &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}
event.End = &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}
event.Summary = eventTitle
- event.Description = genBodyFn(conflict)
+ body, ok, err := genBodyFn(conflict)
+ if err != nil {
+ return nil, ctxerr.Wrap(c.config.Context, err, "generating Google calendar event body")
+ }
+ if !ok {
+ // We don't need to create this event
+ return nil, nil
+ }
+ event.Description = body
event, err = c.config.API.CreateEvent(event)
if err != nil {
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")
+ }
+
// Convert Google event to Fleet event
- fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event)
+ fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelUUID, resourceID)
if err != nil {
return nil, err
}
@@ -539,7 +605,9 @@ func getLocation(tz string, config *GoogleCalendarConfig) *time.Location {
return loc
}
-func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event) (
+func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event, eventUUID string,
+ channelID string,
+ resourceID string) (
*fleet.CalendarEvent, error,
) {
fleetEvent := &fleet.CalendarEvent{}
@@ -547,9 +615,12 @@ func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime ti
fleetEvent.EndTime = endTime
fleetEvent.Email = c.currentUserEmail
fleetEvent.TimeZone = c.location.String()
+ fleetEvent.UUID = eventUUID
details := &eventDetails{
- ID: event.Id,
- ETag: event.Etag,
+ ID: event.Id,
+ ETag: event.Etag,
+ ChannelID: channelID,
+ ResourceID: resourceID,
}
detailsJson, err := json.Marshal(details)
if err != nil {
@@ -564,6 +635,14 @@ 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 {
case isAlreadyDeleted(err):
@@ -573,3 +652,28 @@ func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error {
}
return nil
}
+
+func (c *GoogleCalendar) StopEventChannel(event *fleet.CalendarEvent) error {
+ details, err := c.unmarshalDetails(event)
+ if err != nil {
+ return err
+ }
+ 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)
+ }
+ }
+ return nil
+}
+
+func (c *GoogleCalendar) Get(event *fleet.CalendarEvent, key string) (interface{}, error) {
+ if key == "channelID" {
+ details, err := c.unmarshalDetails(event)
+ if err != nil {
+ return nil, err
+ }
+ return details.ChannelID, nil
+ }
+ return nil, ctxerr.Errorf(c.config.Context, "unknown key: %s", key)
+}
diff --git a/ee/server/calendar/google_calendar_integration_test.go b/ee/server/calendar/google_calendar_integration_test.go
index 7f42b23b22..6e2bce57a2 100644
--- a/ee/server/calendar/google_calendar_integration_test.go
+++ b/ee/server/calendar/google_calendar_integration_test.go
@@ -66,8 +66,8 @@ func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() {
gCal := NewGoogleCalendar(config)
err := gCal.Configure(userEmail)
require.NoError(t, err)
- genBodyFn := func(bool) string {
- return "Test event"
+ genBodyFn := func(bool) (string, bool, error) {
+ return "Test event", true, nil
}
eventDate := time.Now().Add(48 * time.Hour)
event, err := gCal.CreateEvent(eventDate, genBodyFn)
@@ -110,8 +110,8 @@ func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() {
gCal := NewGoogleCalendar(config)
err := gCal.Configure(userEmail)
require.NoError(t, err)
- genBodyFn := func(bool) string {
- return "Test event"
+ genBodyFn := func(bool) (string, bool, error) {
+ return "Test event", true, nil
}
eventDate := time.Now().Add(48 * time.Hour)
event, err := gCal.CreateEvent(eventDate, genBodyFn)
diff --git a/ee/server/calendar/google_calendar_load.go b/ee/server/calendar/google_calendar_load.go
index 8446af20c5..cb9c1bf917 100644
--- a/ee/server/calendar/google_calendar_load.go
+++ b/ee/server/calendar/google_calendar_load.go
@@ -22,10 +22,12 @@ type GoogleCalendarLoadAPI struct {
userToImpersonate string
ctx context.Context
client *http.Client
+ serverURL string
}
// Configure creates a new Google Calendar service using the provided credentials.
-func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string) error {
+func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string,
+ serverURL string) error {
if lowLevelAPI.Logger == nil {
lowLevelAPI.Logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarLoadAPI", "user", userToImpersonate)
}
@@ -35,6 +37,7 @@ func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ strin
if lowLevelAPI.client == nil {
lowLevelAPI.client = fleethttp.NewClient()
}
+ lowLevelAPI.serverURL = serverURL
return nil
}
@@ -232,3 +235,11 @@ func (lowLevelAPI *GoogleCalendarLoadAPI) DeleteEvent(id string) error {
}
return nil
}
+
+func (lowLevelAPI *GoogleCalendarLoadAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
+ return "resourceID", nil
+}
+
+func (lowLevelAPI *GoogleCalendarLoadAPI) Stop(channelID string, resourceID string) error {
+ return nil
+}
diff --git a/ee/server/calendar/google_calendar_mock.go b/ee/server/calendar/google_calendar_mock.go
index 1dd6f16bb4..cc6ab94c35 100644
--- a/ee/server/calendar/google_calendar_mock.go
+++ b/ee/server/calendar/google_calendar_mock.go
@@ -3,6 +3,7 @@ package calendar
import (
"context"
"errors"
+ "github.com/google/uuid"
"net/http"
"os"
"strconv"
@@ -18,16 +19,22 @@ type GoogleCalendarMockAPI struct {
logger kitlog.Logger
}
+type channel struct {
+ channelID string
+ resourceID string
+}
+
var (
- mockEvents = make(map[string]*calendar.Event)
- mu sync.Mutex
- id uint64
+ mockEvents = make(map[string]*calendar.Event)
+ mockChannels = make([]channel, 0)
+ mu sync.Mutex
+ id uint64
)
-const latency = 500 * time.Millisecond
+const latency = 200 * time.Millisecond
// Configure creates a new Google Calendar service using the provided credentials.
-func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error {
+func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string, _ string) error {
if lowLevelAPI.logger == nil {
lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate)
}
@@ -84,6 +91,31 @@ func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error {
return nil
}
+func (lowLevelAPI *GoogleCalendarMockAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
+ time.Sleep(latency)
+ mu.Lock()
+ defer mu.Unlock()
+ resourceID = uuid.New().String()
+ mockChannels = append(mockChannels, channel{
+ channelID: channelID,
+ resourceID: resourceID,
+ })
+ return resourceID, nil
+}
+
+func (lowLevelAPI *GoogleCalendarMockAPI) Stop(channelID string, resourceID string) error {
+ time.Sleep(latency)
+ mu.Lock()
+ defer mu.Unlock()
+ for i, ch := range mockChannels {
+ if ch.channelID == channelID && ch.resourceID == resourceID {
+ mockChannels = append(mockChannels[:i], mockChannels[i+1:]...)
+ return nil
+ }
+ }
+ return errors.New("channel not found")
+}
+
func ListGoogleMockEvents() map[string]*calendar.Event {
return mockEvents
}
@@ -94,6 +126,16 @@ func ClearMockEvents() {
mockEvents = make(map[string]*calendar.Event)
}
+func MockChannelsCount() int {
+ return len(mockChannels)
+}
+
+func ClearMockChannels() {
+ mu.Lock()
+ defer mu.Unlock()
+ mockChannels = make([]channel, 0)
+}
+
func SetMockEventsToNow() {
mu.Lock()
defer mu.Unlock()
diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go
index 8d35cba69e..848be3693b 100644
--- a/ee/server/calendar/google_calendar_test.go
+++ b/ee/server/calendar/google_calendar_test.go
@@ -18,6 +18,7 @@ const (
baseServiceEmail = "service@example.com"
basePrivateKey = "private-key"
baseUserEmail = "user@example.com"
+ baseServerURL = "https://example.com"
)
var (
@@ -26,18 +27,28 @@ var (
)
type MockGoogleCalendarLowLevelAPI struct {
- ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error
+ ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error
GetSettingFunc func(name string) (*calendar.Setting, error)
ListEventsFunc func(timeMin, timeMax string) (*calendar.Events, error)
CreateEventFunc func(event *calendar.Event) (*calendar.Event, error)
GetEventFunc func(id, eTag string) (*calendar.Event, error)
DeleteEventFunc func(id string) error
+ WatchFunc func(eventUUID string, channelID string, ttl uint64) (resourceID string, err error)
+ StopFunc func(channelID string, resourceID string) error
+}
+
+func (m *MockGoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
+ return m.WatchFunc(eventUUID, channelID, ttl)
+}
+
+func (m *MockGoogleCalendarLowLevelAPI) Stop(channelID string, resourceID string) error {
+ return m.StopFunc(channelID, resourceID)
}
func (m *MockGoogleCalendarLowLevelAPI) Configure(
- ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string,
+ ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string,
) error {
- return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail)
+ return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL)
}
func (m *MockGoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) {
@@ -63,11 +74,12 @@ func (m *MockGoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
func TestGoogleCalendar_Configure(t *testing.T) {
t.Parallel()
mockAPI := &MockGoogleCalendarLowLevelAPI{}
- mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
+ mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error {
assert.Equal(t, baseCtx, ctx)
assert.Equal(t, baseServiceEmail, serviceAccountEmail)
assert.Equal(t, basePrivateKey, privateKey)
assert.Equal(t, baseUserEmail, userToImpersonateEmail)
+ assert.Equal(t, baseServerURL, serverURL)
return nil
}
@@ -77,7 +89,7 @@ func TestGoogleCalendar_Configure(t *testing.T) {
assert.NoError(t, err)
// Configure error test
- mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
+ mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error {
return assert.AnError
}
err = cal.Configure(baseUserEmail)
@@ -94,10 +106,11 @@ func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) {
)
email := "user+my_test+email@example.com"
mockAPI := &MockGoogleCalendarLowLevelAPI{}
- mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
+ mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error {
assert.Equal(t, baseCtx, ctx)
assert.Equal(t, baseServiceEmail, serviceAccountEmail)
assert.Equal(t, basePrivateKey, privateKey)
+ assert.Equal(t, baseServerURL, serverURL)
assert.Equal(t, "user@example.com", userToImpersonateEmail)
return nil
}
@@ -109,7 +122,7 @@ func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) {
func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig {
if mockAPI != nil && mockAPI.ConfigureFunc == nil {
- mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
+ mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error {
return nil
}
}
@@ -121,8 +134,9 @@ func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig {
fleet.GoogleCalendarPrivateKey: basePrivateKey,
},
},
- Logger: logger,
- API: mockAPI,
+ Logger: logger,
+ API: mockAPI,
+ ServerURL: baseServerURL,
}
return config
}
@@ -187,6 +201,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
mockAPI := &MockGoogleCalendarLowLevelAPI{}
const baseETag = "event-eTag"
const baseEventID = "event-id"
+ const baseResourceID = "resource-id"
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
assert.Equal(t, baseEventID, id)
assert.Equal(t, baseETag, eTag)
@@ -194,9 +209,9 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
Etag: baseETag, // ETag matches -- no modifications to event
}, nil
}
- genBodyFn := func(bool) string {
+ genBodyFn := func(bool) (string, bool, error) {
t.Error("genBodyFn should not be called")
- return "event-body"
+ return "event-body", false, nil
}
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(baseUserEmail)
@@ -332,14 +347,29 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return &calendar.Events{}, nil
}
- genBodyFn = func(conflict bool) string {
+ mockAPI.StopFunc = func(channelID string, resourceID string) error {
+ details, err := gCal.unmarshalDetails(event)
+ require.NoError(t, err)
+ assert.Equal(t, details.ChannelID, channelID)
+ assert.Equal(t, details.ResourceID, resourceID)
+ return nil
+ }
+ var uuid, channelUUID string
+ mockAPI.WatchFunc = func(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
+ uuid = eventUUID
+ channelUUID = channelID
+ assert.Greater(t, ttl, uint64(60*30-1))
+ return baseResourceID, nil
+ }
+ genBodyFn = func(conflict bool) (string, bool, error) {
assert.False(t, conflict)
- return "event-body"
+ return "event-body", true, nil
}
eventCreated := false
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
assert.Equal(t, eventTitle, event.Summary)
- assert.Equal(t, genBodyFn(false), event.Description)
+ body, _, _ := genBodyFn(false)
+ assert.Equal(t, body, event.Description)
event.Id = baseEventID
event.Etag = baseETag
eventCreated = true
@@ -350,12 +380,17 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
require.NotNil(t, retrievedEvent)
+ assert.Equal(t, uuid, retrievedEvent.UUID)
assert.Equal(t, baseUserEmail, retrievedEvent.Email)
newEventDate := calculateNewEventDate(eventStartTime)
expectedStartTime := time.Date(newEventDate.Year(), newEventDate.Month(), newEventDate.Day(), startHour, 0, 0, 0, time.UTC)
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
assert.True(t, eventCreated)
+ details, err = gCal.unmarshalDetails(retrievedEvent)
+ require.NoError(t, err)
+ assert.Equal(t, channelUUID, details.ChannelID)
+ assert.Equal(t, baseResourceID, details.ResourceID)
// cancelled (deleted)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
@@ -426,6 +461,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
const baseEventID = "event-id"
const baseETag = "event-eTag"
const eventBody = "event-body"
+ const baseResourceID = "resource-id"
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(baseUserEmail)
assert.NoError(t, err)
@@ -444,13 +480,13 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
event.Etag = baseETag
return event, nil
}
- genBodyFn := func(conflict bool) string {
+ genBodyFn := func(conflict bool) (string, bool, error) {
assert.False(t, conflict)
- return eventBody
+ return eventBody, true, nil
}
- genBodyConflictFn := func(conflict bool) string {
+ genBodyConflictFn := func(conflict bool) (string, bool, error) {
assert.True(t, conflict)
- return eventBody
+ return eventBody, true, nil
}
// Happy path test -- empty calendar
@@ -458,8 +494,16 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
location, _ := time.LoadLocation(tzId)
expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
_, expectedOffset := expectedStartTime.Zone()
+ var uuid, channelUUID string
+ mockAPI.WatchFunc = func(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) {
+ uuid = eventUUID
+ channelUUID = channelID
+ assert.Greater(t, ttl, uint64(60*30-1))
+ return baseResourceID, nil
+ }
event, err := cal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
+ assert.Equal(t, uuid, event.UUID)
assert.Equal(t, baseUserEmail, event.Email)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
@@ -472,6 +516,9 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, baseETag, details.ETag)
assert.Equal(t, baseEventID, details.ID)
+ assert.Equal(t, channelUUID, details.ChannelID)
+ assert.Equal(t, baseResourceID, details.ResourceID)
+ assert.Equal(t, tzId, event.TimeZone)
// Workday already ended
date = time.Now().Add(-48 * time.Hour)
diff --git a/ee/server/service/calendar.go b/ee/server/service/calendar.go
new file mode 100644
index 0000000000..eee0e84fa4
--- /dev/null
+++ b/ee/server/service/calendar.go
@@ -0,0 +1,133 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "github.com/fleetdm/fleet/v4/server/authz"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/service/calendar"
+ "github.com/go-kit/log/level"
+ "sync"
+)
+
+func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error {
+
+ appConfig, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ return fmt.Errorf("load app config: %w", err)
+ }
+
+ if len(appConfig.Integrations.GoogleCalendar) == 0 {
+ svc.authz.SkipAuthorization(ctx)
+ level.Warn(svc.logger).Log("msg", "Received calendar callback, but Google Calendar integration is not configured")
+ return nil
+ }
+ googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0]
+
+ if resourceState == "sync" {
+ // This is a sync notification, not a real event
+ svc.authz.SkipAuthorization(ctx)
+ return nil
+ }
+
+ eventDetails, err := svc.ds.GetCalendarEventDetailsByUUID(ctx, eventUUID)
+ if err != nil {
+ svc.authz.SkipAuthorization(ctx)
+ if fleet.IsNotFound(err) {
+ // We could try to stop the channel callbacks here, but that may not be secure since we don't know if the request is legitimate
+ level.Warn(svc.logger).Log("msg", "Received calendar callback, but did not find corresponding event in database", "event_uuid",
+ eventUUID, "channel_id", channelID)
+ return err
+ }
+ return err
+ }
+ if eventDetails.TeamID == nil {
+ // Should not happen
+ return fmt.Errorf("calendar event %s has no team ID", eventUUID)
+ }
+
+ localConfig := &calendar.CalendarConfig{
+ GoogleCalendarIntegration: *googleCalendarIntegrationConfig,
+ ServerURL: appConfig.ServerSettings.ServerURL,
+ }
+ userCalendar := calendar.CreateUserCalendarFromConfig(ctx, localConfig, svc.logger)
+
+ // Authenticate request. We will use the channel ID for authentication.
+ svc.authz.SkipAuthorization(ctx)
+ savedChannelID, err := userCalendar.Get(&eventDetails.CalendarEvent, "channelID")
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get channel ID")
+ }
+ if savedChannelID != channelID {
+ return authz.ForbiddenWithInternal(fmt.Sprintf("calendar channel ID mismatch: %s != %s", savedChannelID, channelID), nil, nil, nil)
+ }
+
+ genBodyFn := func(conflict bool) (body string, ok bool, err error) {
+
+ // This function is called when a new event is being created.
+ var team *fleet.Team
+ team, err = svc.ds.TeamWithoutExtras(ctx, *eventDetails.TeamID)
+ if err != nil {
+ return "", false, err
+ }
+
+ if team.Config.Integrations.GoogleCalendar == nil ||
+ !team.Config.Integrations.GoogleCalendar.Enable {
+ return "", false, nil
+ }
+
+ var policies []fleet.PolicyCalendarData
+ policies, err = svc.ds.GetCalendarPolicies(ctx, team.ID)
+ if err != nil {
+ return "", false, err
+ }
+
+ if len(policies) == 0 {
+ return "", false, nil
+ }
+
+ policyIDs := make([]uint, 0, len(policies))
+ for _, policy := range policies {
+ policyIDs = append(policyIDs, policy.ID)
+ }
+
+ var hosts []fleet.HostPolicyMembershipData
+ hosts, err = svc.ds.GetTeamHostsPolicyMemberships(ctx, googleCalendarIntegrationConfig.Domain, team.ID, policyIDs,
+ &eventDetails.HostID)
+ if err != nil {
+ return "", false, err
+ }
+ if len(hosts) != 1 {
+ return "", false, nil
+ }
+ host := hosts[0]
+ if host.Passing { // host is passing all configured policies
+ return "", false, nil
+ }
+ if host.Email == "" {
+ err = fmt.Errorf("host %d has no associated email", host.HostID)
+ return "", false, err
+ }
+
+ return calendar.GenerateCalendarEventBody(ctx, svc.ds, team.Name, host, &sync.Map{}, conflict, svc.logger), true, nil
+ }
+
+ err = userCalendar.Configure(eventDetails.Email)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "configure calendar")
+ }
+ event, updated, err := userCalendar.GetAndUpdateEvent(&eventDetails.CalendarEvent, genBodyFn)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get and update event")
+ }
+ if updated && event != nil {
+ // 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.ID, fleet.CalendarWebhookStatusNone)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "create or update calendar event")
+ }
+ }
+ return nil
+}
diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go
index 874de3dfbc..c1aaab2217 100644
--- a/server/cron/calendar_cron.go
+++ b/server/cron/calendar_cron.go
@@ -5,15 +5,13 @@ import (
"errors"
"fmt"
"slices"
- "strconv"
- "strings"
"sync"
"time"
- "github.com/fleetdm/fleet/v4/ee/server/calendar"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/service/calendar"
"github.com/fleetdm/fleet/v4/server/service/schedule"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
@@ -21,16 +19,10 @@ import (
)
const (
- calendarConsumers = 18
- defaultDescription = "needs to make sure your device meets the organization's requirements."
- defaultResolution = "During this maintenance window, you can expect updates to be applied automatically. Your device may be unavailable during this time."
+ calendarConsumers = 18
+ reloadFrequency = 12 * time.Hour
)
-type calendarConfig struct {
- config.CalendarConfig
- fleet.GoogleCalendarIntegration
-}
-
func NewCalendarSchedule(
ctx context.Context,
instanceID string,
@@ -86,9 +78,10 @@ func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, serverConfig co
return fmt.Errorf("list teams: %w", err)
}
- localConfig := calendarConfig{
+ localConfig := &calendar.CalendarConfig{
CalendarConfig: serverConfig,
GoogleCalendarIntegration: *googleCalendarIntegrationConfig,
+ ServerURL: appConfig.ServerSettings.ServerURL,
}
for _, team := range teams {
if err := cronCalendarEventsForTeam(
@@ -101,19 +94,10 @@ func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, serverConfig co
return nil
}
-func createUserCalendarFromConfig(ctx context.Context, config *fleet.GoogleCalendarIntegration, logger kitlog.Logger) fleet.UserCalendar {
- googleCalendarConfig := calendar.GoogleCalendarConfig{
- Context: ctx,
- IntegrationConfig: config,
- Logger: log.With(logger, "component", "google_calendar"),
- }
- return calendar.NewGoogleCalendar(&googleCalendarConfig)
-}
-
func cronCalendarEventsForTeam(
ctx context.Context,
ds fleet.Datastore,
- calendarConfig calendarConfig,
+ calendarConfig *calendar.CalendarConfig,
team fleet.Team,
orgName string,
domain string,
@@ -151,7 +135,7 @@ func cronCalendarEventsForTeam(
for _, policy := range policies {
policyIDs = append(policyIDs, policy.ID)
}
- hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs)
+ hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs, nil)
if err != nil {
return fmt.Errorf("get team hosts failing policies: %w", err)
}
@@ -188,7 +172,7 @@ func cronCalendarEventsForTeam(
// policies on one of its hosts, and possibly create a new calendar event if they have
// another failing host on the same team.
start := time.Now()
- removeCalendarEventsFromPassingHosts(ctx, ds, &calendarConfig.GoogleCalendarIntegration, passingHosts, logger)
+ removeCalendarEventsFromPassingHosts(ctx, ds, calendarConfig, passingHosts, logger)
level.Debug(logger).Log(
"msg", "passing_hosts", "took", time.Since(start),
)
@@ -213,7 +197,7 @@ func cronCalendarEventsForTeam(
func processCalendarFailingHosts(
ctx context.Context,
ds fleet.Datastore,
- calendarConfig calendarConfig,
+ calendarConfig *calendar.CalendarConfig,
orgName string,
hosts []fleet.HostPolicyMembershipData,
logger kitlog.Logger,
@@ -260,7 +244,7 @@ func processCalendarFailingHosts(
}
}
- userCalendar := createUserCalendarFromConfig(ctx, &calendarConfig.GoogleCalendarIntegration, logger)
+ userCalendar := calendar.CreateUserCalendarFromConfig(ctx, calendarConfig, logger)
if err := userCalendar.Configure(host.Email); err != nil {
level.Error(logger).Log("msg", "configure user calendar", "err", err)
continue // continue with next host
@@ -319,13 +303,13 @@ func filterHostsWithSameEmail(hosts []fleet.HostPolicyMembershipData) []fleet.Ho
func processFailingHostExistingCalendarEvent(
ctx context.Context,
ds fleet.Datastore,
- calendar fleet.UserCalendar,
+ userCalendar fleet.UserCalendar,
orgName string,
hostCalendarEvent *fleet.HostCalendarEvent,
calendarEvent *fleet.CalendarEvent,
host fleet.HostPolicyMembershipData,
policyIDtoPolicy *sync.Map,
- calendarConfig calendarConfig,
+ calendarConfig *calendar.CalendarConfig,
logger kitlog.Logger,
) error {
updatedEvent := calendarEvent
@@ -334,9 +318,9 @@ func processFailingHostExistingCalendarEvent(
if calendarConfig.AlwaysReloadEvent() || shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) {
var err error
- updatedEvent, _, err = calendar.GetAndUpdateEvent(
- calendarEvent, func(conflict bool) string {
- return generateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger)
+ updatedEvent, _, err = userCalendar.GetAndUpdateEvent(
+ calendarEvent, func(conflict bool) (string, bool, error) {
+ return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil
},
)
if err != nil {
@@ -350,6 +334,7 @@ func processFailingHostExistingCalendarEvent(
if err := ds.UpdateCalendarEvent(
ctx,
calendarEvent.ID,
+ updatedEvent.UUID,
updatedEvent.StartTime,
updatedEvent.EndTime,
updatedEvent.Data,
@@ -395,13 +380,18 @@ func processFailingHostExistingCalendarEvent(
if err := ds.UpdateHostRefetchRequested(ctx, host.HostID, true); err != nil {
return fmt.Errorf("refetch host: %w", err)
}
+
+ // We no longer need to watch the event for changes
+ if err = userCalendar.StopEventChannel(calendarEvent); err != nil {
+ return fmt.Errorf("delete event channel: %w", err)
+ }
return nil
}
func shouldReloadCalendarEvent(now time.Time, calendarEvent *fleet.CalendarEvent, hostCalendarEvent *fleet.HostCalendarEvent) bool {
- // Check the user calendar every 30 minutes (and not every cron run)
+ // Check the user calendar regularly (but not every cron run)
// to reduce load on both Fleet and the calendar service.
- if time.Since(calendarEvent.UpdatedAt) > 30*time.Minute {
+ if time.Since(calendarEvent.UpdatedAt) > reloadFrequency {
return true
}
// If the event is supposed to be happening now, we want to check if the user moved/deleted the
@@ -436,7 +426,8 @@ func processFailingHostCreateCalendarEvent(
return fmt.Errorf("create event on user calendar: %w", err)
}
if _, err := ds.CreateOrUpdateCalendarEvent(
- ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, calendarEvent.TimeZone, host.HostID, fleet.CalendarWebhookStatusNone,
+ ctx, calendarEvent.UUID, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, calendarEvent.TimeZone,
+ host.HostID, fleet.CalendarWebhookStatusNone,
); err != nil {
return fmt.Errorf("create calendar event on db: %w", err)
}
@@ -456,8 +447,8 @@ func attemptCreatingEventOnUserCalendar(
preferredDate := getPreferredCalendarEventDate(year, month, today)
for {
calendarEvent, err := userCalendar.CreateEvent(
- preferredDate, func(conflict bool) string {
- return generateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger)
+ preferredDate, func(conflict bool) (string, bool, error) {
+ return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil
},
)
var dee fleet.DayEndedError
@@ -501,7 +492,7 @@ func addBusinessDay(date time.Time) time.Time {
func removeCalendarEventsFromPassingHosts(
ctx context.Context,
ds fleet.Datastore,
- calendarConfig *fleet.GoogleCalendarIntegration,
+ calendarConfig *calendar.CalendarConfig,
hosts []fleet.HostPolicyMembershipData,
logger kitlog.Logger,
) {
@@ -546,7 +537,7 @@ func removeCalendarEventsFromPassingHosts(
level.Error(logger).Log("msg", "get calendar event from DB", "err", err)
continue
}
- userCalendar := createUserCalendarFromConfig(ctx, calendarConfig, logger)
+ userCalendar := calendar.CreateUserCalendarFromConfig(ctx, calendarConfig, logger)
if err := deleteCalendarEvent(ctx, ds, userCalendar, calendarEvent); err != nil {
level.Error(logger).Log("msg", "delete user calendar event", "err", err)
continue
@@ -582,74 +573,6 @@ func logHostsWithoutAssociatedEmail(
)
}
-func generateCalendarEventBody(
- ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData, policyIDtoPolicy *sync.Map, conflict bool,
- logger kitlog.Logger,
-) string {
- description, resolution := getCalendarEventDescriptionAndResolution(ctx, ds, orgName, host, policyIDtoPolicy, logger)
-
- conflictStr := ""
- if conflict {
- conflictStr = " because there was no remaining availability"
- }
- return fmt.Sprintf(
- `%s reserved this time to make some changes to your work computer%s.
-
-Please leave your device on and connected to power.
-
-Why it matters
-%s
-
-What we'll do
-%s
-`,
- orgName, conflictStr, description, resolution,
- )
-}
-
-func getCalendarEventDescriptionAndResolution(
- ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData, policyIDtoPolicy *sync.Map,
- logger kitlog.Logger,
-) (string, string) {
- getDefaultDescription := func() string {
- return fmt.Sprintf(`%s %s`, orgName, defaultDescription)
- }
-
- var description, resolution string
- policyIDs := strings.Split(host.FailingPolicyIDs, ",")
- if len(policyIDs) == 1 && policyIDs[0] != "" {
- var policy *fleet.PolicyLite
- policyAny, ok := policyIDtoPolicy.Load(policyIDs[0])
- if !ok {
- id, err := strconv.ParseUint(policyIDs[0], 10, 64)
- if err != nil {
- level.Error(logger).Log("msg", "parse policy id", "err", err)
- return getDefaultDescription(), defaultResolution
- }
- policy, err = ds.PolicyLite(ctx, uint(id))
- if err != nil {
- level.Error(logger).Log("msg", "get policy", "err", err)
- return getDefaultDescription(), defaultResolution
- }
- policyIDtoPolicy.Store(policyIDs[0], policy)
- } else {
- policy = policyAny.(*fleet.PolicyLite)
- }
- policyDescription := strings.TrimSpace(policy.Description)
- if policyDescription == "" || policy.Resolution == nil || strings.TrimSpace(*policy.Resolution) == "" {
- description = getDefaultDescription()
- resolution = defaultResolution
- } else {
- description = policyDescription
- resolution = strings.TrimSpace(*policy.Resolution)
- }
- } else {
- description = getDefaultDescription()
- resolution = defaultResolution
- }
- return description, resolution
-}
-
func isHostOnline(ctx context.Context, ds fleet.Datastore, hostID uint) (bool, error) {
hostLite, err := ds.HostLiteByID(ctx, hostID)
if err != nil {
@@ -678,10 +601,13 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k
}
var userCalendar fleet.UserCalendar
- var calendarConfig *fleet.GoogleCalendarIntegration
+ var calConfig *calendar.CalendarConfig
if len(appConfig.Integrations.GoogleCalendar) > 0 {
- calendarConfig = appConfig.Integrations.GoogleCalendar[0]
- userCalendar = createUserCalendarFromConfig(ctx, calendarConfig, logger)
+ calConfig = &calendar.CalendarConfig{
+ GoogleCalendarIntegration: *appConfig.Integrations.GoogleCalendar[0],
+ ServerURL: appConfig.ServerSettings.ServerURL,
+ }
+ userCalendar = calendar.CreateUserCalendarFromConfig(ctx, calConfig, logger)
}
// If global setting is disabled, we remove all calendar events from the DB
@@ -710,7 +636,7 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k
}
for _, team := range teams {
- if err := cleanupTeamCalendarEvents(ctx, ds, calendarConfig, *team, logger); err != nil {
+ if err := cleanupTeamCalendarEvents(ctx, ds, calConfig, *team, logger); err != nil {
level.Info(logger).Log("msg", "delete team calendar events", "team_id", team.ID, "err", err)
}
}
@@ -724,14 +650,14 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k
if err != nil {
return fmt.Errorf("list out of date calendar events: %w", err)
}
- deleteCalendarEventsInParallel(ctx, ds, calendarConfig, outOfDateCalendarEvents, logger)
+ deleteCalendarEventsInParallel(ctx, ds, calConfig, outOfDateCalendarEvents, logger)
return nil
}
func deleteAllCalendarEvents(
ctx context.Context,
ds fleet.Datastore,
- calendarConfig *fleet.GoogleCalendarIntegration,
+ calendarConfig *calendar.CalendarConfig,
teamID *uint,
logger kitlog.Logger,
) error {
@@ -744,7 +670,7 @@ func deleteAllCalendarEvents(
}
func deleteCalendarEventsInParallel(
- ctx context.Context, ds fleet.Datastore, calendarConfig *fleet.GoogleCalendarIntegration, calendarEvents []*fleet.CalendarEvent,
+ ctx context.Context, ds fleet.Datastore, calendarConfig *calendar.CalendarConfig, calendarEvents []*fleet.CalendarEvent,
logger kitlog.Logger,
) {
if len(calendarEvents) > 0 {
@@ -757,7 +683,7 @@ func deleteCalendarEventsInParallel(
for calEvent := range calendarEventCh {
var userCalendar fleet.UserCalendar
if calendarConfig != nil {
- userCalendar = createUserCalendarFromConfig(ctx, calendarConfig, logger)
+ userCalendar = calendar.CreateUserCalendarFromConfig(ctx, calendarConfig, logger)
}
if err := deleteCalendarEvent(ctx, ds, userCalendar, calEvent); err != nil {
level.Error(logger).Log("msg", "delete user calendar event", "err", err)
@@ -777,7 +703,7 @@ func deleteCalendarEventsInParallel(
func cleanupTeamCalendarEvents(
ctx context.Context,
ds fleet.Datastore,
- calendarConfig *fleet.GoogleCalendarIntegration,
+ calendarConfig *calendar.CalendarConfig,
team fleet.Team,
logger kitlog.Logger,
) error {
diff --git a/server/cron/calendar_cron_test.go b/server/cron/calendar_cron_test.go
index e03d633569..85c4761e84 100644
--- a/server/cron/calendar_cron_test.go
+++ b/server/cron/calendar_cron_test.go
@@ -167,7 +167,7 @@ func TestEventForDifferentHost(t *testing.T) {
hostID2 := uint(101)
userEmail1 := "user@example.com"
ds.GetTeamHostsPolicyMembershipsFunc = func(
- ctx context.Context, domain string, teamID uint, policyIDs []uint,
+ ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint,
) ([]fleet.HostPolicyMembershipData, error) {
require.Equal(t, teamID1, teamID)
require.Equal(t, []uint{policyID1}, policyIDs)
@@ -209,6 +209,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) {
logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
t.Cleanup(func() {
calendar.ClearMockEvents()
+ calendar.ClearMockChannels()
})
//
@@ -279,7 +280,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) {
hostID4 := uint(103)
ds.GetTeamHostsPolicyMembershipsFunc = func(
- ctx context.Context, domain string, teamID uint, policyIDs []uint,
+ ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint,
) ([]fleet.HostPolicyMembershipData, error) {
require.Equal(t, "example.com", domain)
require.Equal(t, teamID1, teamID)
@@ -336,6 +337,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) {
hostCalendarEvents := make(map[uint]*fleet.HostCalendarEvent)
ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context,
+ uuid string,
email string,
startTime, endTime time.Time,
data []byte,
@@ -343,6 +345,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) {
hostID uint,
webhookStatus fleet.CalendarWebhookStatus,
) (*fleet.CalendarEvent, error) {
+ assert.NotEmpty(t, uuid)
require.Equal(t, hostID1, hostID)
require.Equal(t, userEmail1, email)
require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus)
@@ -380,8 +383,8 @@ func TestCalendarEventsMultipleHosts(t *testing.T) {
createdCalendarEvents := calendar.ListGoogleMockEvents()
require.Len(t, createdCalendarEvents, 1)
- strings.Contains(createdCalendarEvents["1"].Description, defaultDescription)
- strings.Contains(createdCalendarEvents["1"].Description, defaultResolution)
+ strings.Contains(createdCalendarEvents["1"].Description, fleet.CalendarDefaultDescription)
+ strings.Contains(createdCalendarEvents["1"].Description, fleet.CalendarDefaultResolution)
}
type notFoundErr struct{}
@@ -405,6 +408,7 @@ func TestCalendarEvents1KHosts(t *testing.T) {
}
t.Cleanup(func() {
calendar.ClearMockEvents()
+ calendar.ClearMockChannels()
})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
@@ -594,7 +598,7 @@ func TestCalendarEvents1KHosts(t *testing.T) {
}
ds.GetTeamHostsPolicyMembershipsFunc = func(
- ctx context.Context, domain string, teamID uint, policyIDs []uint,
+ ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint,
) ([]fleet.HostPolicyMembershipData, error) {
var start, end int
switch teamID {
@@ -622,6 +626,7 @@ func TestCalendarEvents1KHosts(t *testing.T) {
eventPerHost := make(map[uint]*fleet.CalendarEvent)
ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context,
+ uuid string,
email string,
startTime, endTime time.Time,
data []byte,
@@ -629,6 +634,7 @@ func TestCalendarEvents1KHosts(t *testing.T) {
hostID uint,
webhookStatus fleet.CalendarWebhookStatus,
) (*fleet.CalendarEvent, error) {
+ assert.NotEmpty(t, uuid)
require.Equal(t, fmt.Sprintf("user%d@example.com", hostID), email)
eventsCreatedMu.Lock()
eventsCreated += 1
@@ -708,6 +714,7 @@ func TestEventDescription(t *testing.T) {
t.Cleanup(
func() {
calendar.ClearMockEvents()
+ calendar.ClearMockChannels()
},
)
@@ -807,7 +814,7 @@ func TestEventDescription(t *testing.T) {
hostID7, userEmail7 := uint(106), "user7@example.com"
ds.GetTeamHostsPolicyMembershipsFunc = func(
- ctx context.Context, domain string, teamID uint, policyIDs []uint,
+ ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint,
) ([]fleet.HostPolicyMembershipData, error) {
require.Equal(t, "example.com", domain)
require.Equal(t, teamID1, teamID)
@@ -900,6 +907,7 @@ func TestEventDescription(t *testing.T) {
ds.CreateOrUpdateCalendarEventFunc = func(
ctx context.Context,
+ uuid string,
email string,
startTime, endTime time.Time,
data []byte,
@@ -907,6 +915,7 @@ func TestEventDescription(t *testing.T) {
hostID uint,
webhookStatus fleet.CalendarWebhookStatus,
) (*fleet.CalendarEvent, error) {
+ assert.NotEmpty(t, uuid)
require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus)
require.NotEmpty(t, data)
require.NotZero(t, startTime)
@@ -948,14 +957,14 @@ func TestEventDescription(t *testing.T) {
err = json.Unmarshal(calendarEvents[hostCalEvent.HostID].Data, &details)
require.NoError(t, err)
description := createdCalendarEvents[details["id"]].Description
- defaultDescriptionWithOrg := fmt.Sprintf("%s %s", orgName, defaultDescription)
+ defaultDescriptionWithOrg := fmt.Sprintf("%s %s", orgName, fleet.CalendarDefaultDescription)
switch hostCalEvent.HostID {
case hostID1, hostID6:
assert.Contains(t, description, "Description for policy 1")
assert.Contains(t, description, "Resolution for policy 1")
default:
assert.Contains(t, description, defaultDescriptionWithOrg)
- assert.Contains(t, description, defaultResolution)
+ assert.Contains(t, description, fleet.CalendarDefaultResolution)
}
}
}
diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go
index 29fb263ada..fa58b8f3af 100644
--- a/server/datastore/mysql/calendar_events.go
+++ b/server/datastore/mysql/calendar_events.go
@@ -3,6 +3,7 @@ package mysql
import (
"context"
"database/sql"
+ "errors"
"fmt"
"time"
@@ -13,6 +14,7 @@ import (
func (ds *Datastore) CreateOrUpdateCalendarEvent(
ctx context.Context,
+ uuid string,
email string,
startTime time.Time,
endTime time.Time,
@@ -25,13 +27,15 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent(
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
const calendarEventsQuery = `
INSERT INTO calendar_events (
+ uuid,
email,
start_time,
end_time,
event,
timezone
- ) VALUES (?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
+ uuid = VALUES(uuid),
start_time = VALUES(start_time),
end_time = VALUES(end_time),
event = VALUES(event),
@@ -41,6 +45,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent(
result, err := tx.ExecContext(
ctx,
calendarEventsQuery,
+ uuid,
email,
startTime,
endTime,
@@ -122,9 +127,29 @@ func (ds *Datastore) GetCalendarEvent(ctx context.Context, email string) (*fleet
return &calendarEvent, nil
}
-func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error {
+func (ds *Datastore) GetCalendarEventDetailsByUUID(ctx context.Context, uuid string) (*fleet.CalendarEventDetails, error) {
+ const calendarEventsByUUIDQuery = `
+ SELECT ce.*, h.team_id as team_id, h.id as host_id FROM calendar_events ce
+ LEFT JOIN host_calendar_events hce ON hce.calendar_event_id = ce.id
+ LEFT JOIN hosts h ON h.id = hce.host_id
+ WHERE ce.uuid = ?;
+ `
+ var calendarEvent fleet.CalendarEventDetails
+ err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsByUUIDQuery, uuid)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("uuid: %s", uuid)))
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get calendar event")
+ }
+ return &calendarEvent, nil
+}
+
+func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time,
+ data []byte, timeZone string) error {
const calendarEventsQuery = `
UPDATE calendar_events SET
+ uuid = ?,
start_time = ?,
end_time = ?,
event = ?,
@@ -132,7 +157,8 @@ func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID ui
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
`
- if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, timeZone, calendarEventID); err != nil {
+ if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, uuid, startTime, endTime, data, timeZone,
+ calendarEventID); err != nil {
return ctxerr.Wrap(ctx, err, "update calendar event")
}
return nil
diff --git a/server/datastore/mysql/calendar_events_test.go b/server/datastore/mysql/calendar_events_test.go
index 2b37d7f826..b4ec9d510a 100644
--- a/server/datastore/mysql/calendar_events_test.go
+++ b/server/datastore/mysql/calendar_events_test.go
@@ -2,6 +2,8 @@ package mysql
import (
"context"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
"testing"
"time"
@@ -56,20 +58,32 @@ func testUpdateCalendarEvent(t *testing.T, ds *Datastore) {
startTime1 := time.Now()
endTime1 := startTime1.Add(30 * time.Minute)
timeZone := "America/Argentina/Buenos_Aires"
- calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
+ eventUUID := uuid.New().String()
+ calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone,
+ host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
time.Sleep(1 * time.Second)
- err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`), timeZone)
+ eventUUIDNew := uuid.New().String()
+ err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, eventUUIDNew, startTime1, endTime1, []byte(`{}`), timeZone)
require.NoError(t, err)
calendarEvent2, err := ds.GetCalendarEvent(ctx, "foo@example.com")
require.NoError(t, err)
require.NotEqual(t, *calendarEvent, *calendarEvent2)
calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt
+ assert.NotEqual(t, calendarEvent.UUID, calendarEvent2.UUID)
+ calendarEvent.UUID = calendarEvent2.UUID
require.Equal(t, *calendarEvent, *calendarEvent2)
+ eventDetails, err := ds.GetCalendarEventDetailsByUUID(ctx, eventUUIDNew)
+ require.NoError(t, err)
+ assert.Equal(t, eventUUIDNew, eventDetails.UUID)
+ assert.Equal(t, *calendarEvent, eventDetails.CalendarEvent)
+ assert.Equal(t, host.ID, eventDetails.HostID)
+ assert.Nil(t, eventDetails.TeamID)
+
// TODO(lucas): Add more tests here.
}
@@ -101,23 +115,30 @@ func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) {
startTime1 := time.Now()
endTime1 := startTime1.Add(30 * time.Minute)
- calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
+ eventUUID := uuid.New().String()
+ calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone,
+ host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
require.Equal(t, calendarEvent.TimeZone, timeZone)
time.Sleep(1 * time.Second)
- calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
+ eventUUID2 := uuid.New().String()
+ calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID2, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone,
+ host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
require.Greater(t, calendarEvent2.UpdatedAt, calendarEvent.UpdatedAt)
calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt
+ assert.NotEqual(t, calendarEvent.UUID, calendarEvent2.UUID)
+ calendarEvent.UUID = calendarEvent2.UUID
require.Equal(t, *calendarEvent, *calendarEvent2)
time.Sleep(1 * time.Second)
startTime2 := startTime1.Add(1 * time.Hour)
endTime2 := startTime1.Add(30 * time.Minute)
- calendarEvent3, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime2, endTime2, []byte(`{"foo": "bar"}`), timeZone, host.ID, fleet.CalendarWebhookStatusPending)
+ calendarEvent3, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID2, "foo@example.com", startTime2, endTime2,
+ []byte(`{"foo": "bar"}`), timeZone, host.ID, fleet.CalendarWebhookStatusPending)
require.NoError(t, err)
require.Greater(t, calendarEvent3.UpdatedAt, calendarEvent2.UpdatedAt)
require.WithinDuration(t, startTime2, calendarEvent3.StartTime, 1*time.Second)
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index 02ce272c8e..8c552448f7 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -9520,7 +9520,8 @@ func testListUpcomingHostMaintenanceWindows(t *testing.T, ds *Datastore) {
startTime := time.Now().UTC().Add(30 * time.Minute)
endTime := startTime.Add(30 * time.Minute)
- calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime, endTime, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
+ calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, uuid.New().String(), "foo@example.com", startTime, endTime, []byte(`{}`),
+ timeZone, host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
require.Equal(t, calendarEvent.TimeZone, timeZone)
diff --git a/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go b/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go
index 34b88fc724..0e289a8c7c 100644
--- a/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go
+++ b/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go
@@ -10,7 +10,7 @@ func init() {
}
func Up_20240626195531(tx *sql.Tx) error {
- if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN timezone VARCHAR(64) NULL`); err != nil {
+ if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN timezone VARCHAR(64) COLLATE utf8mb4_unicode_ci NULL`); err != nil {
return fmt.Errorf("failed to add `timezone` column to `calendar_events` table: %w", err)
}
return nil
diff --git a/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go
new file mode 100644
index 0000000000..11e58a0596
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go
@@ -0,0 +1,34 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20240707134035, Down_20240707134035)
+}
+
+func Up_20240707134035(tx *sql.Tx) error {
+ // UUID is a 36-character string with the most common 8-4-4-4-12 format, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+ // Reference: https://en.wikipedia.org/wiki/Universally_unique_identifier#Textual_representation
+ if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN uuid VARCHAR(36) COLLATE utf8mb4_unicode_ci NOT NULL`); err != nil {
+ return fmt.Errorf("failed to add `uuid` column to `calendar_events` table: %w", err)
+ }
+
+ // Generate UUIDs for existing calendar events, without changing the updated_at timestamp
+ if _, err := tx.Exec(`UPDATE calendar_events SET uuid = UUID(), updated_at = updated_at`); err != nil {
+ return fmt.Errorf("failed to generate UUIDs for existing calendar events: %w", err)
+ }
+
+ // Add unique constraint to uuid column
+ if _, err := tx.Exec(`ALTER TABLE calendar_events ADD CONSTRAINT idx_calendar_events_uuid_unique UNIQUE (uuid)`); err != nil {
+ return fmt.Errorf("failed to add unique constraint to `uuid` column in `calendar_events` table: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20240707134035(_ *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go
new file mode 100644
index 0000000000..851997f251
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go
@@ -0,0 +1,42 @@
+package tables
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20240707134035(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ startTime := time.Now().UTC()
+ endTime := time.Now().UTC().Add(30 * time.Minute)
+ data := []byte("{\"foo\": \"bar\"}")
+ const insertStmt = `INSERT INTO calendar_events (email, start_time, end_time, event) VALUES (?, ?, ?, ?)`
+ event1ID := uint(execNoErrLastID(t, db, insertStmt, "foo@example.com", startTime, endTime, data))
+ event2ID := uint(execNoErrLastID(t, db, insertStmt, "bar@example.com", startTime, endTime, data))
+
+ // Apply current migration.
+ applyNext(t, db)
+
+ // check that it's NULL
+ const selectUUIDStmt = `SELECT uuid FROM calendar_events WHERE id = ?`
+ var uuid1, uuid2 string
+ err := db.Get(&uuid1, selectUUIDStmt, event1ID)
+ require.NoError(t, err)
+ assert.NotEmpty(t, uuid1)
+ err = db.Get(&uuid2, selectUUIDStmt, event2ID)
+ require.NoError(t, err)
+ assert.NotEmpty(t, uuid2)
+ assert.NotEqual(t, uuid1, uuid2)
+
+ const testUUID = "test-uuid"
+ const insertStmtUUID = `INSERT INTO calendar_events (email, start_time, end_time, event, uuid) VALUES (?, ?, ?, ?, ?)`
+ _ = execNoErrLastID(t, db, insertStmtUUID, "bob@example.com", startTime, endTime, data, testUUID)
+ // Try to use the same uuid again
+ _, err = db.Exec(insertStmt, "alice@example.com", startTime, endTime, data, testUUID)
+ assert.Error(t, err)
+
+}
diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go
index 52dc8303b4..4df1f9324a 100644
--- a/server/datastore/mysql/policies.go
+++ b/server/datastore/mysql/policies.go
@@ -1434,6 +1434,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
domain string,
teamID uint,
policyIDs []uint,
+ hostID *uint,
) ([]fleet.HostPolicyMembershipData, error) {
query := `
SELECT
@@ -1459,13 +1460,17 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
) sh ON h.id = sh.host_id
LEFT JOIN host_display_names hdn ON h.id = hdn.host_id
LEFT JOIN host_calendar_events hce ON h.id = hce.host_id
- WHERE h.team_id = ? AND ((pm.passing IS NOT NULL AND NOT pm.passing) OR (COALESCE(pm.passing, 1) AND hce.host_id IS NOT NULL));
+ WHERE h.team_id = ? AND ((pm.passing IS NOT NULL AND NOT pm.passing) OR (COALESCE(pm.passing, 1) AND hce.host_id IS NOT NULL))
`
query, args, err := sqlx.In(query, policyIDs, domain, teamID, teamID)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "build select get team hosts policy memberships query")
}
+ if hostID != nil {
+ query += ` AND h.id = ?`
+ args = append(args, *hostID)
+ }
var hosts []fleet.HostPolicyMembershipData
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing policies")
diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go
index e5437e352c..f505ebc3bb 100644
--- a/server/datastore/mysql/policies_test.go
+++ b/server/datastore/mysql/policies_test.go
@@ -3485,7 +3485,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// Empty teams.
- hostsTeam1, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID})
+ hostsTeam1, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}, nil)
require.NoError(t, err)
require.Empty(t, hostsTeam1)
@@ -3538,12 +3538,12 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// Some domain that doesn't exist on any of the hosts
- hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "not-exists.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID})
+ hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "not-exists.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}, nil)
require.NoError(t, err)
require.Empty(t, hostsTeam1)
// No policy results yet (and no calendar events).
- hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID})
+ hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}, nil)
require.NoError(t, err)
require.Empty(t, hostsTeam1)
@@ -3633,7 +3633,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
require.Len(t, team2Policies, 2)
// Only returns the failing host, because the passing hosts do not have a calendar event.
- hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID})
+ hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}, nil)
require.NoError(t, err)
sort.Slice(hostsTeam1, func(i, j int) bool {
return hostsTeam1[i].HostID < hostsTeam1[j].HostID
@@ -3650,12 +3650,16 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
//
tZ := "America/Argentina/Buenos_Aires"
now := time.Now()
- _, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host1.ID, fleet.CalendarWebhookStatusPending)
+ eventUUID1 := "event-uuid"
+ _, err = ds.CreateOrUpdateCalendarEvent(ctx, eventUUID1, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ,
+ host1.ID, fleet.CalendarWebhookStatusPending)
require.NoError(t, err)
- _, err = ds.CreateOrUpdateCalendarEvent(ctx, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host6.ID, fleet.CalendarWebhookStatusPending)
+ eventUUID2 := "event-uuid2"
+ _, err = ds.CreateOrUpdateCalendarEvent(ctx, eventUUID2, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ,
+ host6.ID, fleet.CalendarWebhookStatusPending)
require.NoError(t, err)
- hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID})
+ hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}, nil)
require.NoError(t, err)
sort.Slice(hostsTeam1, func(i, j int) bool {
return hostsTeam1[i].HostID < hostsTeam1[j].HostID
@@ -3689,7 +3693,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
}, time.Now(), false)
require.NoError(t, err)
- hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID})
+ hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam1, 4)
sort.Slice(hostsTeam1, func(i, j int) bool {
@@ -3720,7 +3724,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
// host3 doesn't have a calendar event so it's not returned by GetTeamHostsPolicyMemberships.
//
- hostsTeam2, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
+ hostsTeam2, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam2, 1)
require.Equal(t, host2.ID, hostsTeam2[0].HostID)
@@ -3733,16 +3737,19 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
// Create a calendar event on host2 and host3.
//
now = time.Now()
- _, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host2.ID, fleet.CalendarWebhookStatusPending)
+ _, err = ds.CreateOrUpdateCalendarEvent(ctx, eventUUID1, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ,
+ host2.ID, fleet.CalendarWebhookStatusPending)
require.NoError(t, err)
- calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, "zoo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host3.ID, fleet.CalendarWebhookStatusPending)
+ eventUUID3 := "event-uuid3"
+ calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID3, "zoo@example.com", now, now.Add(30*time.Minute),
+ []byte(`{"foo": "bar"}`), tZ, host3.ID, fleet.CalendarWebhookStatusPending)
require.NoError(t, err)
//
// Now it should return host3 because it's passing and has a calendar event.
//
- hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
+ hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam2, 2)
sort.Slice(hostsTeam2, func(i, j int) bool {
@@ -3771,7 +3778,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
- hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
+ hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam2, 2)
sort.Slice(
@@ -3800,7 +3807,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
}, time.Now(), false)
require.NoError(t, err)
- hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
+ hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam2, 2)
sort.Slice(hostsTeam2, func(i, j int) bool {
@@ -3817,6 +3824,17 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
require.Equal(t, "serial3", hostsTeam2[1].HostHardwareSerial)
require.Equal(t, "display_name3", hostsTeam2[1].HostDisplayName)
+ // Retrieve the data only for host2.
+ hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID},
+ &host2.ID)
+ require.NoError(t, err)
+ require.Len(t, hostsTeam2, 1)
+ require.Equal(t, host2.ID, hostsTeam2[0].HostID)
+ require.Equal(t, "foo@example.com", hostsTeam2[0].Email)
+ require.True(t, hostsTeam2[0].Passing)
+ require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial)
+ require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName)
+
//
// Delete host3 calendar event
//
@@ -3824,7 +3842,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
err = ds.DeleteCalendarEvent(ctx, calendarEventHost3.ID)
require.NoError(t, err)
- hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
+ hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam2, 1)
require.Equal(t, host2.ID, hostsTeam2[0].HostID)
@@ -3848,7 +3866,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
// We should still get host2 as passing because it has an associated calendar event.
//
- hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
+ hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil)
require.NoError(t, err)
require.Len(t, hostsTeam2, 1)
require.Equal(t, host2.ID, hostsTeam2[0].HostID)
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index 2b5d5bd1b8..391b8ed54b 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -53,8 +53,10 @@ CREATE TABLE `calendar_events` (
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`timezone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+ `uuid` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
- UNIQUE KEY `idx_one_calendar_event_per_email` (`email`)
+ UNIQUE KEY `idx_one_calendar_event_per_email` (`email`),
+ UNIQUE KEY `idx_calendar_events_uuid_unique` (`uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -943,9 +945,9 @@ CREATE TABLE `migration_status_tables` (
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
-) ENGINE=InnoDB AUTO_INCREMENT=278 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) ENGINE=InnoDB AUTO_INCREMENT=279 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01');
+INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mobile_device_management_solutions` (
diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go
index 5711835d24..c25b4a791c 100644
--- a/server/datastore/mysql/teams.go
+++ b/server/datastore/mysql/teams.go
@@ -56,10 +56,14 @@ func (ds *Datastore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team
}
func (ds *Datastore) Team(ctx context.Context, tid uint) (*fleet.Team, error) {
- return teamDB(ctx, ds.reader(ctx), tid)
+ return teamDB(ctx, ds.reader(ctx), tid, true)
}
-func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint) (*fleet.Team, error) {
+func (ds *Datastore) TeamWithoutExtras(ctx context.Context, tid uint) (*fleet.Team, error) {
+ return teamDB(ctx, ds.reader(ctx), tid, false)
+}
+
+func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint, withExtras bool) (*fleet.Team, error) {
stmt := `
SELECT ` + teamColumns + ` FROM teams
WHERE id = ?
@@ -73,18 +77,20 @@ func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint) (*fleet.Team,
return nil, ctxerr.Wrap(ctx, err, "select team")
}
- if err := loadSecretsForTeamsDB(ctx, q, []*fleet.Team{team}); err != nil {
- return nil, ctxerr.Wrap(ctx, err, "getting secrets for teams")
- }
+ if withExtras {
+ if err := loadSecretsForTeamsDB(ctx, q, []*fleet.Team{team}); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting secrets for teams")
+ }
- if err := loadUsersForTeamDB(ctx, q, team); err != nil {
- return nil, err
- }
- if err := loadHostCountForTeamDB(ctx, q, team); err != nil {
- return nil, err
- }
- if err := loadFeaturesForTeamDB(ctx, q, team); err != nil {
- return nil, err
+ if err := loadUsersForTeamDB(ctx, q, team); err != nil {
+ return nil, err
+ }
+ if err := loadHostCountForTeamDB(ctx, q, team); err != nil {
+ return nil, err
+ }
+ if err := loadFeaturesForTeamDB(ctx, q, team); err != nil {
+ return nil, err
+ }
}
return team, nil
diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go
index 5eb4597f44..b9184f52eb 100644
--- a/server/fleet/calendar.go
+++ b/server/fleet/calendar.go
@@ -9,6 +9,11 @@ import (
"github.com/fleetdm/fleet/v4/server"
)
+const (
+ CalendarDefaultDescription = "needs to make sure your device meets the organization's requirements."
+ CalendarDefaultResolution = "During this maintenance window, you can expect updates to be applied automatically. Your device may be unavailable during this time."
+)
+
type DayEndedError struct {
Msg string
}
@@ -22,13 +27,18 @@ 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) string) (event *CalendarEvent, err error)
+ CreateEvent(dateOfEvent time.Time, genBodyFn func(conflict bool) (body string, ok bool, err error)) (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) string) (updatedEvent *CalendarEvent, updated bool, err error)
+ GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) (body string, ok bool, err error)) (updatedEvent *CalendarEvent,
+ updated bool, err error)
// DeleteEvent deletes the event with the given ID.
DeleteEvent(event *CalendarEvent) error
+ // StopEventChannel stops the event's callback channel.
+ StopEventChannel(event *CalendarEvent) error
+ // Get retrieves the value of the given key from the event.
+ Get(event *CalendarEvent, key string) (interface{}, error)
}
type CalendarWebhookPayload struct {
diff --git a/server/fleet/calendar_events.go b/server/fleet/calendar_events.go
index 30cfcd11c6..96d5a9a93f 100644
--- a/server/fleet/calendar_events.go
+++ b/server/fleet/calendar_events.go
@@ -4,6 +4,7 @@ import "time"
type CalendarEvent struct {
ID uint `db:"id"`
+ UUID string `db:"uuid"`
Email string `db:"email"`
StartTime time.Time `db:"start_time"`
EndTime time.Time `db:"end_time"`
@@ -13,6 +14,12 @@ type CalendarEvent struct {
UpdateCreateTimestamps
}
+type CalendarEventDetails struct {
+ CalendarEvent
+ TeamID *uint `db:"team_id"` // Should not be nil, but is nullable in the database
+ HostID uint `db:"host_id"`
+}
+
type CalendarWebhookStatus int
const (
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index b973317e22..da5d4df785 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -490,7 +490,9 @@ type Datastore interface {
SaveTeam(ctx context.Context, team *Team) (*Team, error)
// Team retrieves the Team by ID.
Team(ctx context.Context, tid uint) (*Team, error)
- // Team deletes the Team by ID.
+ // TeamWithoutExtras retrieves the Team by ID without extra fields.
+ TeamWithoutExtras(ctx context.Context, tid uint) (*Team, error)
+ // DeleteTeam deletes the Team by ID.
DeleteTeam(ctx context.Context, tid uint) error
// TeamByName retrieves the Team by Name.
TeamByName(ctx context.Context, name string) (*Team, error)
@@ -656,12 +658,13 @@ type Datastore interface {
PolicyQueriesForHost(ctx context.Context, host *Host) (map[string]string, error)
- // GetTeamHostsPolicyMembmerships returns the hosts that belong to the given team and their pass/fail statuses
+ // GetTeamHostsPolicyMemberships returns the hosts that belong to the given team and their pass/fail statuses
// around the provided policyIDs.
// - Returns hosts of the team that are failing one or more of the provided policies.
// - Returns hosts of the team that are passing all the policies (or are not running any of the provided policies)
// and have a calendar event scheduled.
- GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]HostPolicyMembershipData, error)
+ GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint,
+ hostID *uint) ([]HostPolicyMembershipData, error)
GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error)
// Methods used for async processing of host policy query results.
@@ -686,10 +689,13 @@ type Datastore interface {
///////////////////////////////////////////////////////////////////////////////
// Calendar events
- CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error)
+ CreateOrUpdateCalendarEvent(ctx context.Context, uuid string, email string, startTime time.Time, endTime time.Time, data []byte,
+ timeZone string, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error)
GetCalendarEvent(ctx context.Context, email string) (*CalendarEvent, error)
+ GetCalendarEventDetailsByUUID(ctx context.Context, uuid string) (*CalendarEventDetails, error)
DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error
- UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error
+ UpdateCalendarEvent(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, data []byte,
+ timeZone string) error
GetHostCalendarEvent(ctx context.Context, hostID uint) (*HostCalendarEvent, *CalendarEvent, error)
GetHostCalendarEventByEmail(ctx context.Context, email string) (*HostCalendarEvent, *CalendarEvent, error)
UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status CalendarWebhookStatus) error
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 4ef1619f0f..ac1bbffb9c 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -1058,4 +1058,10 @@ type Service interface {
GetSoftwareInstallerMetadata(ctx context.Context, titleID uint, teamID *uint) (*SoftwareInstaller, error)
DownloadSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) (*DownloadSoftwareInstallerPayload, error)
OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*DownloadSoftwareInstallerPayload, error)
+
+ // /////////////////////////////////////////////////////////////////////////////
+ // Maintenance windows
+
+ // CalendarWebhook handles incoming calendar callback requests.
+ CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error
}
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index b230cf2405..d38323714a 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -361,6 +361,8 @@ type SaveTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, erro
type TeamFunc func(ctx context.Context, tid uint) (*fleet.Team, error)
+type TeamWithoutExtrasFunc func(ctx context.Context, tid uint) (*fleet.Team, error)
+
type DeleteTeamFunc func(ctx context.Context, tid uint) error
type TeamByNameFunc func(ctx context.Context, name string) (*fleet.Team, error)
@@ -477,7 +479,7 @@ type UpdateHostPolicyCountsFunc func(ctx context.Context) error
type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[string]string, error)
-type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]fleet.HostPolicyMembershipData, error)
+type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error)
type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error)
@@ -499,13 +501,15 @@ type DeleteSoftwareVulnerabilitiesFunc func(ctx context.Context, vulnerabilities
type DeleteOutOfDateVulnerabilitiesFunc func(ctx context.Context, source fleet.VulnerabilitySource, duration time.Duration) error
-type CreateOrUpdateCalendarEventFunc func(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error)
+type CreateOrUpdateCalendarEventFunc func(ctx context.Context, uuid string, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error)
type GetCalendarEventFunc func(ctx context.Context, email string) (*fleet.CalendarEvent, error)
+type GetCalendarEventDetailsByUUIDFunc func(ctx context.Context, uuid string) (*fleet.CalendarEventDetails, error)
+
type DeleteCalendarEventFunc func(ctx context.Context, calendarEventID uint) error
-type UpdateCalendarEventFunc func(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error
+type UpdateCalendarEventFunc func(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, data []byte, timeZone string) error
type GetHostCalendarEventFunc func(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error)
@@ -1495,6 +1499,9 @@ type DataStore struct {
TeamFunc TeamFunc
TeamFuncInvoked bool
+ TeamWithoutExtrasFunc TeamWithoutExtrasFunc
+ TeamWithoutExtrasFuncInvoked bool
+
DeleteTeamFunc DeleteTeamFunc
DeleteTeamFuncInvoked bool
@@ -1708,6 +1715,9 @@ type DataStore struct {
GetCalendarEventFunc GetCalendarEventFunc
GetCalendarEventFuncInvoked bool
+ GetCalendarEventDetailsByUUIDFunc GetCalendarEventDetailsByUUIDFunc
+ GetCalendarEventDetailsByUUIDFuncInvoked bool
+
DeleteCalendarEventFunc DeleteCalendarEventFunc
DeleteCalendarEventFuncInvoked bool
@@ -3625,6 +3635,13 @@ func (s *DataStore) Team(ctx context.Context, tid uint) (*fleet.Team, error) {
return s.TeamFunc(ctx, tid)
}
+func (s *DataStore) TeamWithoutExtras(ctx context.Context, tid uint) (*fleet.Team, error) {
+ s.mu.Lock()
+ s.TeamWithoutExtrasFuncInvoked = true
+ s.mu.Unlock()
+ return s.TeamWithoutExtrasFunc(ctx, tid)
+}
+
func (s *DataStore) DeleteTeam(ctx context.Context, tid uint) error {
s.mu.Lock()
s.DeleteTeamFuncInvoked = true
@@ -4031,11 +4048,11 @@ func (s *DataStore) PolicyQueriesForHost(ctx context.Context, host *fleet.Host)
return s.PolicyQueriesForHostFunc(ctx, host)
}
-func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]fleet.HostPolicyMembershipData, error) {
+func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error) {
s.mu.Lock()
s.GetTeamHostsPolicyMembershipsFuncInvoked = true
s.mu.Unlock()
- return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs)
+ return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs, hostID)
}
func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
@@ -4108,11 +4125,11 @@ func (s *DataStore) DeleteOutOfDateVulnerabilities(ctx context.Context, source f
return s.DeleteOutOfDateVulnerabilitiesFunc(ctx, source, duration)
}
-func (s *DataStore) CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) {
+func (s *DataStore) CreateOrUpdateCalendarEvent(ctx context.Context, uuid string, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) {
s.mu.Lock()
s.CreateOrUpdateCalendarEventFuncInvoked = true
s.mu.Unlock()
- return s.CreateOrUpdateCalendarEventFunc(ctx, email, startTime, endTime, data, timeZone, hostID, webhookStatus)
+ return s.CreateOrUpdateCalendarEventFunc(ctx, uuid, email, startTime, endTime, data, timeZone, hostID, webhookStatus)
}
func (s *DataStore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) {
@@ -4122,6 +4139,13 @@ func (s *DataStore) GetCalendarEvent(ctx context.Context, email string) (*fleet.
return s.GetCalendarEventFunc(ctx, email)
}
+func (s *DataStore) GetCalendarEventDetailsByUUID(ctx context.Context, uuid string) (*fleet.CalendarEventDetails, error) {
+ s.mu.Lock()
+ s.GetCalendarEventDetailsByUUIDFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetCalendarEventDetailsByUUIDFunc(ctx, uuid)
+}
+
func (s *DataStore) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error {
s.mu.Lock()
s.DeleteCalendarEventFuncInvoked = true
@@ -4129,11 +4153,11 @@ func (s *DataStore) DeleteCalendarEvent(ctx context.Context, calendarEventID uin
return s.DeleteCalendarEventFunc(ctx, calendarEventID)
}
-func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error {
+func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, data []byte, timeZone string) error {
s.mu.Lock()
s.UpdateCalendarEventFuncInvoked = true
s.mu.Unlock()
- return s.UpdateCalendarEventFunc(ctx, calendarEventID, startTime, endTime, data, timeZone)
+ return s.UpdateCalendarEventFunc(ctx, calendarEventID, uuid, startTime, endTime, data, timeZone)
}
func (s *DataStore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
diff --git a/server/service/calendar.go b/server/service/calendar.go
new file mode 100644
index 0000000000..6f7ff6b26e
--- /dev/null
+++ b/server/service/calendar.go
@@ -0,0 +1,59 @@
+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"
+)
+
+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
+
+ req.googleChannelID = r.Header.Get("X-Goog-Channel-Id")
+ req.googleResourceState = r.Header.Get("X-Goog-Resource-State")
+
+ return &req, nil
+}
+
+type calendarWebhookResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+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
+ }
+
+ 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.
+ svc.authz.SkipAuthorization(ctx)
+ return fleet.ErrMissingLicense
+}
diff --git a/server/service/calendar/calendar.go b/server/service/calendar/calendar.go
new file mode 100644
index 0000000000..1e7eff60d0
--- /dev/null
+++ b/server/service/calendar/calendar.go
@@ -0,0 +1,94 @@
+package calendar
+
+// This package contains common calendar code used by cron and service packages.
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/fleetdm/fleet/v4/ee/server/calendar"
+ "github.com/fleetdm/fleet/v4/server/config"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ kitlog "github.com/go-kit/log"
+ "github.com/go-kit/log/level"
+)
+
+type CalendarConfig struct {
+ config.CalendarConfig
+ fleet.GoogleCalendarIntegration
+ ServerURL string
+}
+
+func CreateUserCalendarFromConfig(ctx context.Context, config *CalendarConfig, logger kitlog.Logger) fleet.UserCalendar {
+ googleCalendarConfig := calendar.GoogleCalendarConfig{
+ Context: ctx,
+ IntegrationConfig: &config.GoogleCalendarIntegration,
+ ServerURL: config.ServerURL,
+ Logger: kitlog.With(logger, "component", "google_calendar"),
+ }
+ return calendar.NewGoogleCalendar(&googleCalendarConfig)
+}
+
+func GenerateCalendarEventBody(ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData,
+ policyIDtoPolicy *sync.Map, conflict bool, logger kitlog.Logger) string {
+ description, resolution := getCalendarEventDescriptionAndResolution(ctx, ds, orgName, host, policyIDtoPolicy, logger)
+
+ conflictStr := ""
+ if conflict {
+ conflictStr = " because there was no remaining availability"
+ }
+ return fmt.Sprintf(`%s reserved this time to make some changes to your work computer%s.
+
+Please leave your device on and connected to power.
+
+Why it matters
+%s
+
+What we'll do
+%s
+`, orgName, conflictStr, description, resolution)
+}
+
+func getCalendarEventDescriptionAndResolution(ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData,
+ policyIDtoPolicy *sync.Map, logger kitlog.Logger) (string, string) {
+ getDefaultDescription := func() string {
+ return fmt.Sprintf(`%s %s`, orgName, fleet.CalendarDefaultDescription)
+ }
+
+ var description, resolution string
+ policyIDs := strings.Split(host.FailingPolicyIDs, ",")
+ if len(policyIDs) == 1 && policyIDs[0] != "" {
+ var policy *fleet.PolicyLite
+ policyAny, ok := policyIDtoPolicy.Load(policyIDs[0])
+ if !ok {
+ id, err := strconv.ParseUint(policyIDs[0], 10, 64)
+ if err != nil {
+ level.Error(logger).Log("msg", "parse policy id", "err", err)
+ return getDefaultDescription(), fleet.CalendarDefaultResolution
+ }
+ policy, err = ds.PolicyLite(ctx, uint(id))
+ if err != nil {
+ level.Error(logger).Log("msg", "get policy", "err", err)
+ return getDefaultDescription(), fleet.CalendarDefaultResolution
+ }
+ policyIDtoPolicy.Store(policyIDs[0], policy)
+ } else {
+ policy = policyAny.(*fleet.PolicyLite)
+ }
+ policyDescription := strings.TrimSpace(policy.Description)
+ if policyDescription == "" || policy.Resolution == nil || strings.TrimSpace(*policy.Resolution) == "" {
+ description = getDefaultDescription()
+ resolution = fleet.CalendarDefaultResolution
+ } else {
+ description = policyDescription
+ resolution = strings.TrimSpace(*policy.Resolution)
+ }
+ } else {
+ description = getDefaultDescription()
+ resolution = fleet.CalendarDefaultResolution
+ }
+ return description, resolution
+}
diff --git a/server/service/handler.go b/server/service/handler.go
index 2d7ad4ded3..c7892d7306 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -938,6 +938,9 @@ 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
+ 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_core_test.go b/server/service/integration_core_test.go
index c6f29aea66..fd66f2227c 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -8293,9 +8293,11 @@ func (s *integrationTestSuite) TestGetHostMaintenanceWindow() {
Data: []byte(`{}`),
// will replace with NULL - db method doesn't allow nil
TimeZone: "",
+ UUID: uuid.New().String(),
}
- dsEvent, err := s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, testEvent.TimeZone, host.ID, fleet.CalendarWebhookStatusNone)
+ dsEvent, err := s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.UUID, testEvent.Email, testEvent.StartTime, testEvent.EndTime,
+ testEvent.Data, testEvent.TimeZone, host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
time.Sleep(1 * time.Second)
@@ -8325,7 +8327,8 @@ func (s *integrationTestSuite) TestGetHostMaintenanceWindow() {
zonedStartsAt := startTime.In(tZLoc).Round(time.Second)
// update the timezone
- _, err = s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, timeZone, host.ID, fleet.CalendarWebhookStatusNone)
+ _, err = s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.UUID, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data,
+ timeZone, host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
time.Sleep(1 * time.Second)
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 39e632d5b5..f8fa76a59b 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -8346,6 +8346,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() {
t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
+ calendar.ClearMockChannels()
})
currentAppCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
@@ -8472,8 +8473,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() {
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
- team2Policy1Calendar.ID: ptr.Bool(true),
- team2Policy2.ID: ptr.Bool(false),
+ team1Policy1Calendar.ID: ptr.Bool(true),
+ team1Policy2.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
@@ -8611,8 +8612,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() {
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
- team2Policy1Calendar.ID: ptr.Bool(true),
- team2Policy2.ID: ptr.Bool(false),
+ team1Policy1Calendar.ID: ptr.Bool(true),
+ team1Policy2.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
@@ -8698,9 +8699,9 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() {
calendar.SetMockEventsToNow()
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
- // Update updated_at so the event gets updated (the event is updated every 30 minutes)
+ // Update updated_at so the event gets updated (the event is updated regularly)
_, err := db.ExecContext(ctx,
- `UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR) WHERE id = ?`, team1CalendarEvents[0].ID)
+ `UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 HOUR) WHERE id = ?`, team1CalendarEvents[0].ID)
if err != nil {
return err
}
@@ -8735,8 +8736,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() {
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host2Team1,
map[uint]*bool{
- team2Policy1Calendar.ID: ptr.Bool(true),
- team2Policy2.ID: ptr.Bool(false),
+ team1Policy1Calendar.ID: ptr.Bool(true),
+ team1Policy2.ID: ptr.Bool(false),
globalPolicy.ID: nil,
},
), http.StatusOK, &distributedResp)
@@ -8785,6 +8786,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEventsTransferringHosts() {
t := s.T()
t.Cleanup(func() {
calendar.ClearMockEvents()
+ calendar.ClearMockChannels()
})
currentAppCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
@@ -10570,3 +10572,300 @@ func (s *integrationEnterpriseTestSuite) TestAutofillPoliciesAuthTeamUser() {
)
}
}
+
+func (s *integrationEnterpriseTestSuite) TestCalendarCallback() {
+ ctx := context.Background()
+ t := s.T()
+ t.Cleanup(func() {
+ calendar.ClearMockEvents()
+ calendar.ClearMockChannels()
+ })
+ currentAppCfg, err := s.ds.AppConfig(ctx)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ err = s.ds.SaveAppConfig(ctx, currentAppCfg)
+ require.NoError(t, err)
+ })
+
+ team1, err := s.ds.NewTeam(ctx, &fleet.Team{
+ Name: "team1",
+ })
+ require.NoError(t, err)
+
+ newHost := func(name string, teamID *uint) *fleet.Host {
+ h, err := s.ds.NewHost(ctx, &fleet.Host{
+ DetailUpdatedAt: time.Now(),
+ LabelUpdatedAt: time.Now(),
+ PolicyUpdatedAt: time.Now(),
+ SeenTime: time.Now().Add(-1 * time.Minute),
+ OsqueryHostID: ptr.String(t.Name() + name),
+ NodeKey: ptr.String(t.Name() + name),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()),
+ Platform: "darwin",
+ TeamID: teamID,
+ })
+ require.NoError(t, err)
+ return h
+ }
+
+ host1Team1 := newHost("host1", &team1.ID)
+ host2Team1 := newHost("host2", &team1.ID)
+ _ = newHost("host5", nil) // global host
+
+ team1Policy1Calendar, err := s.ds.NewTeamPolicy(
+ ctx, team1.ID, nil, fleet.PolicyPayload{
+ Name: "team1Policy1Calendar",
+ Query: "SELECT 1;",
+ CalendarEventsEnabled: true,
+ },
+ )
+ require.NoError(t, err)
+ team1Policy2, err := s.ds.NewTeamPolicy(
+ ctx, team1.ID, nil, fleet.PolicyPayload{
+ Name: "team1Policy2",
+ Query: "SELECT 2;",
+ CalendarEventsEnabled: true,
+ },
+ )
+ require.NoError(t, err)
+ globalPolicy, err := s.ds.NewGlobalPolicy(
+ ctx, nil, fleet.PolicyPayload{
+ Name: "globalPolicy",
+ Query: "SELECT 5;",
+ CalendarEventsEnabled: false,
+ },
+ )
+ require.NoError(t, err)
+
+ genDistributedReqWithPolicyResults := func(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim {
+ var (
+ results = make(map[string]json.RawMessage)
+ statuses = make(map[string]interface{})
+ messages = make(map[string]string)
+ )
+ for policyID, policyResult := range policyResults {
+ distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID)
+ switch {
+ case policyResult == nil:
+ results[distributedQueryName] = json.RawMessage(`[]`)
+ statuses[distributedQueryName] = 1
+ messages[distributedQueryName] = "policy failed execution"
+ case *policyResult:
+ results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`)
+ statuses[distributedQueryName] = 0
+ case !*policyResult:
+ results[distributedQueryName] = json.RawMessage(`[]`)
+ statuses[distributedQueryName] = 0
+ }
+ }
+ return submitDistributedQueryResultsRequestShim{
+ NodeKey: *host.NodeKey,
+ Results: results,
+ Statuses: statuses,
+ Messages: messages,
+ Stats: map[string]*fleet.Stats{},
+ }
+ }
+
+ // host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global).
+ distributedResp := submitDistributedQueryResultsResponse{}
+ s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host1Team1,
+ map[uint]*bool{
+ team1Policy1Calendar.ID: ptr.Bool(false),
+ team1Policy2.ID: ptr.Bool(true),
+ globalPolicy.ID: nil,
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global).
+ s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host2Team1,
+ map[uint]*bool{
+ team1Policy1Calendar.ID: ptr.Bool(true),
+ team1Policy2.ID: ptr.Bool(false),
+ globalPolicy.ID: nil,
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // Set global configuration for the calendar feature.
+ appCfg, err := s.ds.AppConfig(ctx)
+ require.NoError(t, err)
+ appCfg.Integrations.GoogleCalendar = []*fleet.GoogleCalendarIntegration{
+ {
+ Domain: "example.com",
+ ApiKey: map[string]string{
+ fleet.GoogleCalendarEmail: calendar.MockEmail,
+ },
+ },
+ }
+ err = s.ds.SaveAppConfig(ctx, appCfg)
+ require.NoError(t, err)
+ time.Sleep(2 * time.Second) // Wait 2 seconds for the app config cache to clear.
+
+ team1.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{
+ Enable: true,
+ WebhookURL: "https://example.com",
+ }
+ team1, err = s.ds.SaveTeam(ctx, team1)
+ require.NoError(t, err)
+
+ // Add email mapping for host1Team1
+ const user1Email = "user1@example.com"
+ err = s.ds.ReplaceHostDeviceMapping(ctx, host1Team1.ID, []*fleet.HostDeviceMapping{
+ {
+ HostID: host1Team1.ID,
+ Email: user1Email,
+ Source: "google_chrome_profiles",
+ },
+ }, "google_chrome_profiles")
+ require.NoError(t, err)
+ assert.Equal(t, 0, calendar.MockChannelsCount())
+
+ // Trigger the calendar cron, global feature enabled, team1 enabled
+ // and host1Team1 has a domain email associated.
+ triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
+
+ // An event should be generated for host1Team1
+ team1CalendarEvents, err := s.ds.ListCalendarEvents(ctx, &team1.ID)
+ require.NoError(t, err)
+ require.Len(t, team1CalendarEvents, 1)
+ event := team1CalendarEvents[0]
+ require.NotZero(t, event.ID)
+ require.Equal(t, user1Email, event.Email)
+ require.NotZero(t, event.StartTime)
+ require.NotZero(t, event.EndTime)
+ require.NotEmpty(t, event.UUID)
+ assert.Equal(t, 1, calendar.MockChannelsCount())
+
+ // Get channel ID
+ type eventDetails struct {
+ ChannelID string `json:"channel_id"`
+ }
+ var details eventDetails
+ err = json.Unmarshal(event.Data, &details)
+ require.NoError(t, err)
+
+ // Send a sync command
+ _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
+ "X-Goog-Channel-Id": details.ChannelID,
+ "X-Goog-Resource-State": "sync",
+ })
+
+ // Send a regular callback with bad channel ID
+ _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusForbidden, map[string]string{
+ "X-Goog-Channel-Id": "bad",
+ "X-Goog-Resource-State": "exists",
+ })
+
+ // Send a regular callback
+ _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
+ "X-Goog-Channel-Id": details.ChannelID,
+ "X-Goog-Resource-State": "exists",
+ })
+
+ // Delete the event on the calendar
+ calendar.ClearMockEvents()
+
+ // This callback should recreate the event
+ _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{
+ "X-Goog-Channel-Id": details.ChannelID,
+ "X-Goog-Resource-State": "exists",
+ })
+
+ team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
+ require.NoError(t, err)
+ require.Len(t, team1CalendarEvents, 1)
+ eventRecreated := team1CalendarEvents[0]
+ assert.NotZero(t, eventRecreated.ID)
+ assert.Equal(t, user1Email, eventRecreated.Email)
+ assert.NotZero(t, eventRecreated.StartTime)
+ assert.NotZero(t, eventRecreated.EndTime)
+ assert.NotEmpty(t, eventRecreated.UUID)
+ assert.NotEqual(t, event.UUID, eventRecreated.UUID)
+ assert.NotEqual(t, event.StartTime, eventRecreated.StartTime)
+ assert.NotEqual(t, event.EndTime, eventRecreated.EndTime)
+ assert.Equal(t, 1, calendar.MockChannelsCount())
+
+ // The previous event UUID should not work anymore
+ _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusNotFound, map[string]string{
+ "X-Goog-Channel-Id": details.ChannelID,
+ "X-Goog-Resource-State": "exists",
+ })
+
+ err = json.Unmarshal(eventRecreated.Data, &details)
+ require.NoError(t, err)
+
+ // New event callback should work
+ _ = 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",
+ })
+
+ // Update the time of the event
+ events := calendar.ListGoogleMockEvents()
+ require.Len(t, events, 1)
+ for _, e := range events {
+ st, err := time.Parse(time.RFC3339, e.Start.DateTime)
+ require.NoError(t, err)
+ newStartTime := st.Add(5 * time.Minute).Format(time.RFC3339)
+ e.Start.DateTime = newStartTime
+ }
+
+ // New event callback should cause the time to be updated in the DB
+ _ = 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",
+ })
+
+ // Check that the time was updated in the DB
+ team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
+ require.NoError(t, err)
+ require.Len(t, team1CalendarEvents, 1)
+ eventUpdated := team1CalendarEvents[0]
+ assert.NotZero(t, eventUpdated.ID)
+ assert.Equal(t, user1Email, eventUpdated.Email)
+ assert.Equal(t, eventRecreated.UUID, eventUpdated.UUID)
+ assert.Greater(t, eventUpdated.StartTime, eventRecreated.StartTime)
+ assert.Equal(t, eventRecreated.EndTime, eventUpdated.EndTime)
+ assert.Equal(t, 1, calendar.MockChannelsCount())
+
+ // Delete the event on the calendar
+ calendar.ClearMockEvents()
+
+ // Make host1Team1 pass all policies.
+ s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
+ host1Team1,
+ map[uint]*bool{
+ team1Policy1Calendar.ID: ptr.Bool(true),
+ team1Policy2.ID: ptr.Bool(true),
+ globalPolicy.ID: nil,
+ },
+ ), http.StatusOK, &distributedResp)
+
+ // Callback should still work, but only clear the callback channel. Event in DB will be deleted on the next cron run.
+ _ = 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, 0, calendar.MockChannelsCount())
+
+ team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
+ require.NoError(t, err)
+ require.Len(t, team1CalendarEvents, 1)
+ assert.Equal(t, eventUpdated, team1CalendarEvents[0])
+
+ // Trigger calendar should cleanup the events
+ triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second)
+ assert.Equal(t, 0, calendar.MockChannelsCount())
+
+ // Event should be cleaned up from our database.
+ team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID)
+ require.NoError(t, err)
+ assert.Empty(t, team1CalendarEvents)
+
+}
diff --git a/tools/calendar/README.md b/tools/calendar/README.md
index bab2f481b4..b6bb88a4f5 100644
--- a/tools/calendar/README.md
+++ b/tools/calendar/README.md
@@ -4,6 +4,8 @@ To delete all downtime events from a Google Calendar, use `delete-events/delete-
To move all downtime events from multiple Google Calendars to a specific time, use `move-events/move-events.go`
+To use the helper scripts, you must set `FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL` and `FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY` environment variables. The email is the `client_email` from JSON key file. The private key also comes from JSON key file for the service account, and starts with `-----BEGIN PRIVATE KEY-----`.
+
# Calendar server for load testing
Test calendar server that provides a REST API for managing events.
diff --git a/tools/calendar/delete-events/delete-events.go b/tools/calendar/delete-events/delete-events.go
index dea8197dfa..676da9d88f 100644
--- a/tools/calendar/delete-events/delete-events.go
+++ b/tools/calendar/delete-events/delete-events.go
@@ -34,6 +34,8 @@ 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 == "" {
diff --git a/tools/calendar/move-events/move-events.go b/tools/calendar/move-events/move-events.go
index 7e413abaf7..3e22032c99 100644
--- a/tools/calendar/move-events/move-events.go
+++ b/tools/calendar/move-events/move-events.go
@@ -35,6 +35,8 @@ 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")
dateTimeStr := flag.String("datetime", "", "Event time in "+time.RFC3339+" format")
flag.Parse()
diff --git a/tools/calendar/stop-channel/stop-channel.go b/tools/calendar/stop-channel/stop-channel.go
new file mode 100644
index 0000000000..15e61a27e2
--- /dev/null
+++ b/tools/calendar/stop-channel/stop-channel.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "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"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+)
+
+// Stop watching the channel with the given ID. This command only accepts one user.
+// Reference: https://developers.google.com/calendar/api/v3/reference/channels/stop
+
+// Example: go run stop-channel.go --users john@example.com --channel-id 55ebefd7-4271-4295-a80a-97f4dcb01d93 --resource-id Io5ygBoEZ-FmQus7ziNrS_Jjcz4
+
+var (
+ serviceEmail = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL")
+ privateKey = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY")
+)
+
+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")
+ channelIDStr := flag.String("channel-id", "", "Channel ID")
+ resourceIDStr := flag.String("resource-id", "", "Resource ID")
+ flag.Parse()
+ if *userEmails == "" {
+ log.Fatal("--users are required")
+ }
+ if *channelIDStr == "" {
+ log.Fatal("--channel-id is required")
+ }
+ if *resourceIDStr == "" {
+ log.Fatal("--resource-id is required")
+ }
+ userEmailList := strings.Split(*userEmails, ",")
+ if len(userEmailList) == 0 {
+ log.Fatal("No user emails provided")
+ }
+ if len(userEmailList) > 1 {
+ log.Fatal("Only one user email is allowed")
+ }
+
+ ctx := context.Background()
+
+ userEmail := userEmailList[0]
+ 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)
+ }
+
+ _, err = withRetry(
+ func() (any, error) {
+ return nil, service.Channels.Stop(&calendar.Channel{
+ Id: *channelIDStr,
+ ResourceId: *resourceIDStr,
+ }).Do()
+ },
+ )
+
+ if err != nil {
+ log.Fatalf("Unable to stop watching channel: %v", err)
+ }
+ log.Printf("DONE. Stopped watching channel resource for %s", userEmail)
+
+}
+
+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",
+ ))))
+}