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:
@@ -0,0 +1 @@
|
||||
- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
+42
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
))))
|
||||
}
|
||||
Reference in New Issue
Block a user