Check for calendar updates after callbacks from Google (#20156)

#19352 

Video explaining code changes:
https://www.loom.com/share/370200a276b84aa388effd6ebd762e01?sid=038508c4-f3c2-40c0-baf6-6b6df682d1f0

In maintenance windows using Google Calendar, calendar event is now
recreated within 30 seconds if deleted or moved to the past.
- Added new endpoint for Google Calendar:
`/api/_version_/fleet/calendar/webhook/{event_uuid}`
- Added UUID to `calendar_events` table to make webhook lookup more
efficient
- webhook endpoint will only recreate event if needed -- it will not
fire webhook. Webhook is still done by the cron job.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality
  - For Orbit and Fleet Desktop changes:
This commit is contained in:
Victor Lyuboslavsky
2024-07-08 10:20:03 -05:00
committed by GitHub
parent 61e34775d5
commit df141cdfa4
33 changed files with 1303 additions and 235 deletions
+1
View File
@@ -0,0 +1 @@
- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past.
+119 -15
View File
@@ -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)
}
@@ -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)
+12 -1
View File
@@ -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
}
+47 -5
View File
@@ -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()
+65 -18
View File
@@ -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)
+133
View File
@@ -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
}
+41 -115
View File
@@ -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.
<b>Why it matters</b>
%s
<b>What we'll do</b>
%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 {
+17 -8
View File
@@ -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)
}
}
}
+29 -3
View File
@@ -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
+26 -5
View File
@@ -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)
+2 -1
View File
@@ -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)
@@ -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
@@ -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
}
@@ -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)
}
+6 -1
View File
@@ -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")
+34 -16
View File
@@ -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)
File diff suppressed because one or more lines are too long
+19 -13
View File
@@ -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
+12 -2
View File
@@ -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 {
+7
View File
@@ -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 (
+11 -5
View File
@@ -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
+6
View File
@@ -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
}
+33 -9
View File
@@ -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) {
+59
View File
@@ -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
}
+94
View File
@@ -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.
<b>Why it matters</b>
%s
<b>What we'll do</b>
%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
}
+3
View File
@@ -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{})
+5 -2
View File
@@ -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)
+307 -8
View File
@@ -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)
}
+2
View File
@@ -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.
@@ -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 == "" {
@@ -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()
+123
View File
@@ -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",
))))
}