mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Regenerate schema.sql
This commit is contained in:
commit
b449900602
1
changes/17065-null-smtp_settings
Normal file
1
changes/17065-null-smtp_settings
Normal file
@ -0,0 +1 @@
|
||||
- Fix a bug where `null` or excluded `smtp_settings` caused a UI 500.
|
5
changes/17230-fleet-in-your-calendar
Normal file
5
changes/17230-fleet-in-your-calendar
Normal file
@ -0,0 +1,5 @@
|
||||
Added integration with Google Calendar.
|
||||
- Fleet admins can enable Google Calendar integration by using a Google service account with domain-wide delegation.
|
||||
- Calendar integration is enabled at the team level for specific team policies.
|
||||
- If the policy is failing, a calendar event will be put on the host user's calendar for the 3rd Tuesday of the month.
|
||||
- During the event, Fleet will fire a webhook. IT admins should use this webhook to trigger a script or MDM command that will remediate the issue.
|
1
changes/17692-enrollment-state-3.md
Normal file
1
changes/17692-enrollment-state-3.md
Normal file
@ -0,0 +1 @@
|
||||
- Fix a bug where valid MDM enrollments would show up as unmanaged (EnrollmentState 3)
|
699
cmd/fleet/calendar_cron.go
Normal file
699
cmd/fleet/calendar_cron.go
Normal file
@ -0,0 +1,699 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/calendar"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service/schedule"
|
||||
"github.com/go-kit/log"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
)
|
||||
|
||||
func newCalendarSchedule(
|
||||
ctx context.Context,
|
||||
instanceID string,
|
||||
ds fleet.Datastore,
|
||||
logger kitlog.Logger,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronCalendar)
|
||||
defaultInterval = 5 * time.Minute
|
||||
)
|
||||
logger = kitlog.With(logger, "cron", name)
|
||||
s := schedule.New(
|
||||
ctx, name, instanceID, defaultInterval, ds, ds,
|
||||
schedule.WithAltLockID("calendar"),
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob(
|
||||
"calendar_events_cleanup",
|
||||
func(ctx context.Context) error {
|
||||
return cronCalendarEventsCleanup(ctx, ds, logger)
|
||||
},
|
||||
),
|
||||
schedule.WithJob(
|
||||
"calendar_events",
|
||||
func(ctx context.Context) error {
|
||||
return cronCalendarEvents(ctx, ds, logger)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error {
|
||||
appConfig, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load app config: %w", err)
|
||||
}
|
||||
|
||||
if len(appConfig.Integrations.GoogleCalendar) == 0 {
|
||||
return nil
|
||||
}
|
||||
googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0]
|
||||
domain := googleCalendarIntegrationConfig.Domain
|
||||
|
||||
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{
|
||||
User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
},
|
||||
}, fleet.ListOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list teams: %w", err)
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if err := cronCalendarEventsForTeam(
|
||||
ctx, ds, googleCalendarIntegrationConfig, *team, appConfig.OrgInfo.OrgName, domain, logger,
|
||||
); err != nil {
|
||||
level.Info(logger).Log("msg", "events calendar cron", "team_id", team.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 *fleet.GoogleCalendarIntegration,
|
||||
team fleet.Team,
|
||||
orgName string,
|
||||
domain string,
|
||||
logger kitlog.Logger,
|
||||
) error {
|
||||
if team.Config.Integrations.GoogleCalendar == nil ||
|
||||
!team.Config.Integrations.GoogleCalendar.Enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
policies, err := ds.GetCalendarPolicies(ctx, team.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get calendar policy ids: %w", err)
|
||||
}
|
||||
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger = kitlog.With(logger, "team_id", team.ID)
|
||||
|
||||
//
|
||||
// NOTEs:
|
||||
// - We ignore hosts that are passing all policies and do not have an associated email.
|
||||
// - We get only one host per email that's failing policies (the one with lower host id).
|
||||
// - On every host, we get only the first email that matches the domain (sorted lexicographically).
|
||||
//
|
||||
|
||||
policyIDs := make([]uint, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
policyIDs = append(policyIDs, policy.ID)
|
||||
}
|
||||
hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get team hosts failing policies: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
passingHosts []fleet.HostPolicyMembershipData
|
||||
failingHosts []fleet.HostPolicyMembershipData
|
||||
failingHostsWithoutAssociatedEmail []fleet.HostPolicyMembershipData
|
||||
)
|
||||
for _, host := range hosts {
|
||||
if host.Passing { // host is passing all configured policies
|
||||
if host.Email != "" {
|
||||
passingHosts = append(passingHosts, host)
|
||||
}
|
||||
} else { // host is failing some of the configured policies
|
||||
if host.Email == "" {
|
||||
failingHostsWithoutAssociatedEmail = append(failingHostsWithoutAssociatedEmail, host)
|
||||
} else {
|
||||
failingHosts = append(failingHosts, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
level.Debug(logger).Log(
|
||||
"msg", "summary",
|
||||
"team_id", team.ID,
|
||||
"passing_hosts", len(passingHosts),
|
||||
"failing_hosts", len(failingHosts),
|
||||
"failing_hosts_without_associated_email", len(failingHostsWithoutAssociatedEmail),
|
||||
)
|
||||
|
||||
// Remove calendar events from hosts that are passing the calendar policies.
|
||||
//
|
||||
// We execute this first to remove any calendar events for a user that is now passing
|
||||
// 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, passingHosts, logger)
|
||||
level.Debug(logger).Log(
|
||||
"msg", "passing_hosts", "took", time.Since(start),
|
||||
)
|
||||
|
||||
// Process hosts that are failing calendar policies.
|
||||
start = time.Now()
|
||||
processCalendarFailingHosts(ctx, ds, calendarConfig, orgName, failingHosts, logger)
|
||||
level.Debug(logger).Log(
|
||||
"msg", "failing_hosts", "took", time.Since(start),
|
||||
)
|
||||
|
||||
// At last we want to log the hosts that are failing and don't have an associated email.
|
||||
logHostsWithoutAssociatedEmail(
|
||||
domain,
|
||||
failingHostsWithoutAssociatedEmail,
|
||||
logger,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processCalendarFailingHosts(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
calendarConfig *fleet.GoogleCalendarIntegration,
|
||||
orgName string,
|
||||
hosts []fleet.HostPolicyMembershipData,
|
||||
logger kitlog.Logger,
|
||||
) {
|
||||
hosts = filterHostsWithSameEmail(hosts)
|
||||
|
||||
const consumers = 20
|
||||
hostsCh := make(chan fleet.HostPolicyMembershipData)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < consumers; i++ {
|
||||
wg.Add(+1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for host := range hostsCh {
|
||||
logger := log.With(logger, "host_id", host.HostID)
|
||||
|
||||
hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEventByEmail(ctx, host.Email)
|
||||
|
||||
expiredEvent := false
|
||||
if err == nil {
|
||||
if hostCalendarEvent.HostID != host.HostID {
|
||||
// This calendar event belongs to another host with this associated email,
|
||||
// thus we skip this entry.
|
||||
continue // continue with next host
|
||||
}
|
||||
if hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusPending {
|
||||
// This can happen if the host went offline (and never returned results)
|
||||
// after setting the webhook as pending.
|
||||
continue // continue with next host
|
||||
}
|
||||
now := time.Now()
|
||||
webhookAlreadyFired := hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusSent
|
||||
if webhookAlreadyFired && sameDate(now, calendarEvent.StartTime) {
|
||||
// If the webhook already fired today and the policies are still failing
|
||||
// we give a grace period of one day for the host before we schedule a new event.
|
||||
continue // continue with next host
|
||||
}
|
||||
if calendarEvent.EndTime.Before(now) {
|
||||
expiredEvent = true
|
||||
}
|
||||
}
|
||||
|
||||
userCalendar := 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
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil && !expiredEvent:
|
||||
if err := processFailingHostExistingCalendarEvent(
|
||||
ctx, ds, userCalendar, orgName, hostCalendarEvent, calendarEvent, host,
|
||||
); err != nil {
|
||||
level.Info(logger).Log("msg", "process failing host existing calendar event", "err", err)
|
||||
continue // continue with next host
|
||||
}
|
||||
case fleet.IsNotFound(err) || expiredEvent:
|
||||
if err := processFailingHostCreateCalendarEvent(
|
||||
ctx, ds, userCalendar, orgName, host,
|
||||
); err != nil {
|
||||
level.Info(logger).Log("msg", "process failing host create calendar event", "err", err)
|
||||
continue // continue with next host
|
||||
}
|
||||
default:
|
||||
level.Error(logger).Log("msg", "get calendar event from db", "err", err)
|
||||
continue // continue with next host
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
hostsCh <- host
|
||||
}
|
||||
close(hostsCh)
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func filterHostsWithSameEmail(hosts []fleet.HostPolicyMembershipData) []fleet.HostPolicyMembershipData {
|
||||
minHostPerEmail := make(map[string]fleet.HostPolicyMembershipData)
|
||||
for _, host := range hosts {
|
||||
minHost, ok := minHostPerEmail[host.Email]
|
||||
if !ok {
|
||||
minHostPerEmail[host.Email] = host
|
||||
continue
|
||||
}
|
||||
if host.HostID < minHost.HostID {
|
||||
minHostPerEmail[host.Email] = host
|
||||
}
|
||||
}
|
||||
filtered := make([]fleet.HostPolicyMembershipData, 0, len(minHostPerEmail))
|
||||
for _, host := range minHostPerEmail {
|
||||
filtered = append(filtered, host)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func processFailingHostExistingCalendarEvent(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
calendar fleet.UserCalendar,
|
||||
orgName string,
|
||||
hostCalendarEvent *fleet.HostCalendarEvent,
|
||||
calendarEvent *fleet.CalendarEvent,
|
||||
host fleet.HostPolicyMembershipData,
|
||||
) error {
|
||||
updatedEvent := calendarEvent
|
||||
updated := false
|
||||
now := time.Now()
|
||||
|
||||
if shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) {
|
||||
var err error
|
||||
updatedEvent, _, err = calendar.GetAndUpdateEvent(calendarEvent, func(conflict bool) string {
|
||||
return generateCalendarEventBody(orgName, host.HostDisplayName, conflict)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get event calendar on db: %w", err)
|
||||
}
|
||||
// Even if fields haven't changed we want to update the calendar_events.updated_at below.
|
||||
updated = true
|
||||
//
|
||||
// TODO(lucas): Check changing updatedEvent to UTC before consuming.
|
||||
//
|
||||
}
|
||||
|
||||
if updated {
|
||||
if err := ds.UpdateCalendarEvent(ctx,
|
||||
calendarEvent.ID,
|
||||
updatedEvent.StartTime,
|
||||
updatedEvent.EndTime,
|
||||
updatedEvent.Data,
|
||||
); err != nil {
|
||||
return fmt.Errorf("updating event calendar on db: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
eventInFuture := now.Before(updatedEvent.StartTime)
|
||||
if eventInFuture {
|
||||
// Nothing else to do as event is in the future.
|
||||
return nil
|
||||
}
|
||||
if now.After(updatedEvent.EndTime) {
|
||||
return fmt.Errorf(
|
||||
"unexpected event in the past: now=%s, start_time=%s, end_time=%s",
|
||||
now, updatedEvent.StartTime, updatedEvent.EndTime,
|
||||
)
|
||||
}
|
||||
|
||||
//
|
||||
// Event happening now.
|
||||
//
|
||||
|
||||
if hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusSent {
|
||||
return nil
|
||||
}
|
||||
|
||||
online, err := isHostOnline(ctx, ds, host.HostID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("host online check: %w", err)
|
||||
}
|
||||
if !online {
|
||||
// If host is offline then there's nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.UpdateHostCalendarWebhookStatus(ctx, host.HostID, fleet.CalendarWebhookStatusPending); err != nil {
|
||||
return fmt.Errorf("update host calendar webhook status: %w", err)
|
||||
}
|
||||
|
||||
// TODO(lucas): If this doesn't work at scale, then implement a special refetch
|
||||
// for policies only.
|
||||
if err := ds.UpdateHostRefetchRequested(ctx, host.HostID, true); err != nil {
|
||||
return fmt.Errorf("refetch host: %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)
|
||||
// to reduce load on both Fleet and the calendar service.
|
||||
if time.Since(calendarEvent.UpdatedAt) > 30*time.Minute {
|
||||
return true
|
||||
}
|
||||
// If the event is supposed to be happening now, we want to check if the user moved/deleted the
|
||||
// event on the last minute.
|
||||
if eventHappeningNow(now, calendarEvent) && hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusNone {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func eventHappeningNow(now time.Time, calendarEvent *fleet.CalendarEvent) bool {
|
||||
return !now.Before(calendarEvent.StartTime) && now.Before(calendarEvent.EndTime)
|
||||
}
|
||||
|
||||
func sameDate(t1 time.Time, t2 time.Time) bool {
|
||||
y1, m1, d1 := t1.Date()
|
||||
y2, m2, d2 := t2.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
}
|
||||
|
||||
func processFailingHostCreateCalendarEvent(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
userCalendar fleet.UserCalendar,
|
||||
orgName string,
|
||||
host fleet.HostPolicyMembershipData,
|
||||
) error {
|
||||
calendarEvent, err := attemptCreatingEventOnUserCalendar(orgName, host, userCalendar)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create event on user calendar: %w", err)
|
||||
}
|
||||
if _, err := ds.CreateOrUpdateCalendarEvent(ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, host.HostID, fleet.CalendarWebhookStatusNone); err != nil {
|
||||
return fmt.Errorf("create calendar event on db: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attemptCreatingEventOnUserCalendar(
|
||||
orgName string,
|
||||
host fleet.HostPolicyMembershipData,
|
||||
userCalendar fleet.UserCalendar,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
year, month, today := time.Now().Date()
|
||||
preferredDate := getPreferredCalendarEventDate(year, month, today)
|
||||
for {
|
||||
calendarEvent, err := userCalendar.CreateEvent(
|
||||
preferredDate, func(conflict bool) string {
|
||||
return generateCalendarEventBody(orgName, host.HostDisplayName, conflict)
|
||||
},
|
||||
)
|
||||
var dee fleet.DayEndedError
|
||||
switch {
|
||||
case err == nil:
|
||||
return calendarEvent, nil
|
||||
case errors.As(err, &dee):
|
||||
preferredDate = addBusinessDay(preferredDate)
|
||||
continue
|
||||
default:
|
||||
return nil, fmt.Errorf("create event on user calendar: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getPreferredCalendarEventDate(year int, month time.Month, today int) time.Time {
|
||||
const (
|
||||
// 3rd Tuesday of Month
|
||||
preferredWeekDay = time.Tuesday
|
||||
preferredOrdinal = 3
|
||||
)
|
||||
|
||||
firstDayOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
offset := int(preferredWeekDay - firstDayOfMonth.Weekday())
|
||||
if offset < 0 {
|
||||
offset += 7
|
||||
}
|
||||
preferredDate := firstDayOfMonth.AddDate(0, 0, offset+(7*(preferredOrdinal-1)))
|
||||
if today > preferredDate.Day() {
|
||||
// We are past the preferred date, so we move to next month and calculate again.
|
||||
month := month + 1
|
||||
if month == 13 {
|
||||
month = 1
|
||||
year += 1
|
||||
}
|
||||
return getPreferredCalendarEventDate(year, month, 1)
|
||||
}
|
||||
return preferredDate
|
||||
}
|
||||
|
||||
func addBusinessDay(date time.Time) time.Time {
|
||||
nextBusinessDay := 1
|
||||
switch weekday := date.Weekday(); weekday {
|
||||
case time.Friday:
|
||||
nextBusinessDay += 2
|
||||
case time.Saturday:
|
||||
nextBusinessDay += 1
|
||||
}
|
||||
return date.AddDate(0, 0, nextBusinessDay)
|
||||
}
|
||||
|
||||
func removeCalendarEventsFromPassingHosts(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
calendarConfig *fleet.GoogleCalendarIntegration,
|
||||
hosts []fleet.HostPolicyMembershipData,
|
||||
logger kitlog.Logger,
|
||||
) {
|
||||
hostIDsByEmail := make(map[string][]uint)
|
||||
for _, host := range hosts {
|
||||
hostIDsByEmail[host.Email] = append(hostIDsByEmail[host.Email], host.HostID)
|
||||
}
|
||||
type emailWithHosts struct {
|
||||
email string
|
||||
hostIDs []uint
|
||||
}
|
||||
emails := make([]emailWithHosts, 0, len(hostIDsByEmail))
|
||||
for email, hostIDs := range hostIDsByEmail {
|
||||
emails = append(emails, emailWithHosts{
|
||||
email: email,
|
||||
hostIDs: hostIDs,
|
||||
})
|
||||
}
|
||||
|
||||
const consumers = 20
|
||||
emailsCh := make(chan emailWithHosts)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < consumers; i++ {
|
||||
wg.Add(+1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for email := range emailsCh {
|
||||
|
||||
hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEventByEmail(ctx, email.email)
|
||||
switch {
|
||||
case err == nil:
|
||||
if ok := slices.Contains(email.hostIDs, hostCalendarEvent.HostID); !ok {
|
||||
// None of the hosts belong to this calendar event.
|
||||
continue
|
||||
}
|
||||
case fleet.IsNotFound(err):
|
||||
continue
|
||||
default:
|
||||
level.Error(logger).Log("msg", "get calendar event from DB", "err", err)
|
||||
continue
|
||||
}
|
||||
userCalendar := 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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, emailWithHostIDs := range emails {
|
||||
emailsCh <- emailWithHostIDs
|
||||
}
|
||||
close(emailsCh)
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func logHostsWithoutAssociatedEmail(
|
||||
domain string,
|
||||
hosts []fleet.HostPolicyMembershipData,
|
||||
logger kitlog.Logger,
|
||||
) {
|
||||
if len(hosts) == 0 {
|
||||
return
|
||||
}
|
||||
var hostIDs []uint
|
||||
for _, host := range hosts {
|
||||
hostIDs = append(hostIDs, host.HostID)
|
||||
}
|
||||
// Logging as debug because this might get logged every 5 minutes.
|
||||
level.Debug(logger).Log(
|
||||
"msg", fmt.Sprintf("no %s Google account associated with the hosts", domain),
|
||||
"host_ids", fmt.Sprintf("%+v", hostIDs),
|
||||
)
|
||||
}
|
||||
|
||||
func generateCalendarEventBody(orgName, hostDisplayName string, conflict bool) string {
|
||||
conflictStr := ""
|
||||
if conflict {
|
||||
conflictStr = " because there was no remaining availability"
|
||||
}
|
||||
return fmt.Sprintf(`Please leave your computer on and connected to power.
|
||||
|
||||
Expect an automated restart.
|
||||
|
||||
%s reserved this time to fix %s%s.`, orgName, hostDisplayName, conflictStr,
|
||||
)
|
||||
}
|
||||
|
||||
func isHostOnline(ctx context.Context, ds fleet.Datastore, hostID uint) (bool, error) {
|
||||
hostLite, err := ds.HostLiteByID(ctx, hostID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get host lite: %w", err)
|
||||
}
|
||||
status := (&fleet.Host{
|
||||
DistributedInterval: hostLite.DistributedInterval,
|
||||
ConfigTLSRefresh: hostLite.ConfigTLSRefresh,
|
||||
SeenTime: hostLite.SeenTime,
|
||||
}).Status(time.Now())
|
||||
|
||||
switch status {
|
||||
case fleet.StatusOnline, fleet.StatusNew:
|
||||
return true, nil
|
||||
case fleet.StatusOffline, fleet.StatusMIA, fleet.StatusMissing:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown host status: %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error {
|
||||
appConfig, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load app config: %w", err)
|
||||
}
|
||||
|
||||
var userCalendar fleet.UserCalendar
|
||||
if len(appConfig.Integrations.GoogleCalendar) > 0 {
|
||||
googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0]
|
||||
userCalendar = createUserCalendarFromConfig(ctx, googleCalendarIntegrationConfig, logger)
|
||||
}
|
||||
|
||||
// If global setting is disabled, we remove all calendar events from the DB
|
||||
// (we cannot delete the events from the user calendar because there's no configuration anymore).
|
||||
if userCalendar == nil {
|
||||
if err := deleteAllCalendarEvents(ctx, ds, nil, nil); err != nil {
|
||||
return fmt.Errorf("delete all calendar events: %w", err)
|
||||
}
|
||||
// We've deleted all calendar events, nothing else to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
//
|
||||
// Feature is configured globally, but now we have to check team by team.
|
||||
//
|
||||
|
||||
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{
|
||||
User: &fleet.User{
|
||||
GlobalRole: ptr.String(fleet.RoleAdmin),
|
||||
},
|
||||
}, fleet.ListOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list teams: %w", err)
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if err := deleteTeamCalendarEvents(ctx, ds, userCalendar, *team); err != nil {
|
||||
level.Info(logger).Log("msg", "delete team calendar events", "team_id", team.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Delete calendar events from DB that haven't been updated for a while
|
||||
// (e.g. host was transferred to another team or global).
|
||||
//
|
||||
|
||||
outOfDateCalendarEvents, err := ds.ListOutOfDateCalendarEvents(ctx, time.Now().Add(-48*time.Hour))
|
||||
if err != nil {
|
||||
return fmt.Errorf("list out of date calendar events: %w", err)
|
||||
}
|
||||
for _, outOfDateCalendarEvent := range outOfDateCalendarEvents {
|
||||
if err := deleteCalendarEvent(ctx, ds, userCalendar, outOfDateCalendarEvent); err != nil {
|
||||
return fmt.Errorf("delete user calendar event: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteAllCalendarEvents(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
userCalendar fleet.UserCalendar,
|
||||
teamID *uint,
|
||||
) error {
|
||||
calendarEvents, err := ds.ListCalendarEvents(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list calendar events: %w", err)
|
||||
}
|
||||
for _, calendarEvent := range calendarEvents {
|
||||
if err := deleteCalendarEvent(ctx, ds, userCalendar, calendarEvent); err != nil {
|
||||
return fmt.Errorf("delete user calendar event: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteTeamCalendarEvents(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
userCalendar fleet.UserCalendar,
|
||||
team fleet.Team,
|
||||
) error {
|
||||
if team.Config.Integrations.GoogleCalendar != nil &&
|
||||
team.Config.Integrations.GoogleCalendar.Enable {
|
||||
// Feature is enabled, nothing to cleanup.
|
||||
return nil
|
||||
}
|
||||
return deleteAllCalendarEvents(ctx, ds, userCalendar, &team.ID)
|
||||
}
|
||||
|
||||
func deleteCalendarEvent(ctx context.Context, ds fleet.Datastore, userCalendar fleet.UserCalendar, calendarEvent *fleet.CalendarEvent) error {
|
||||
if userCalendar != nil {
|
||||
// Only delete events from the user's calendar if the event is in the future.
|
||||
if eventInFuture := time.Now().Before(calendarEvent.StartTime); eventInFuture {
|
||||
if err := userCalendar.Configure(calendarEvent.Email); err != nil {
|
||||
return fmt.Errorf("connect to user calendar: %w", err)
|
||||
}
|
||||
if err := userCalendar.DeleteEvent(calendarEvent); err != nil {
|
||||
return fmt.Errorf("delete calendar event: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ds.DeleteCalendarEvent(ctx, calendarEvent.ID); err != nil {
|
||||
return fmt.Errorf("delete db calendar event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
637
cmd/fleet/calendar_cron_test.go
Normal file
637
cmd/fleet/calendar_cron_test.go
Normal file
@ -0,0 +1,637 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/calendar"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
kitlog "github.com/go-kit/log"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetPreferredCalendarEventDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
date := func(year int, month time.Month, day int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
year int
|
||||
month time.Month
|
||||
daysStart int
|
||||
daysEnd int
|
||||
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "March 2024 (before 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 3,
|
||||
daysStart: 1,
|
||||
daysEnd: 19,
|
||||
|
||||
expected: date(2024, 3, 19),
|
||||
},
|
||||
{
|
||||
name: "March 2024 (past 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 3,
|
||||
daysStart: 20,
|
||||
daysEnd: 31,
|
||||
|
||||
expected: date(2024, 4, 16),
|
||||
},
|
||||
{
|
||||
name: "April 2024 (before 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 4,
|
||||
daysStart: 1,
|
||||
daysEnd: 16,
|
||||
|
||||
expected: date(2024, 4, 16),
|
||||
},
|
||||
{
|
||||
name: "April 2024 (after 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 4,
|
||||
daysStart: 17,
|
||||
daysEnd: 30,
|
||||
|
||||
expected: date(2024, 5, 21),
|
||||
},
|
||||
{
|
||||
name: "May 2024 (before 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 5,
|
||||
daysStart: 1,
|
||||
daysEnd: 21,
|
||||
|
||||
expected: date(2024, 5, 21),
|
||||
},
|
||||
{
|
||||
name: "May 2024 (after 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 5,
|
||||
daysStart: 22,
|
||||
daysEnd: 31,
|
||||
|
||||
expected: date(2024, 6, 18),
|
||||
},
|
||||
{
|
||||
name: "Dec 2024 (before 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 12,
|
||||
daysStart: 1,
|
||||
daysEnd: 17,
|
||||
|
||||
expected: date(2024, 12, 17),
|
||||
},
|
||||
{
|
||||
name: "Dec 2024 (after 3rd Tuesday)",
|
||||
year: 2024,
|
||||
month: 12,
|
||||
daysStart: 18,
|
||||
daysEnd: 31,
|
||||
|
||||
expected: date(2025, 1, 21),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for day := tc.daysStart; day <= tc.daysEnd; day++ {
|
||||
actual := getPreferredCalendarEventDate(tc.year, tc.month, day)
|
||||
require.NotEqual(t, actual.Weekday(), time.Saturday)
|
||||
require.NotEqual(t, actual.Weekday(), time.Sunday)
|
||||
require.Equal(t, tc.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventForDifferentHost tests case when event exists, but for a different host. Nothing should happen.
|
||||
// The old event will eventually be cleaned up by the cleanup job, and afterward a new event will be created.
|
||||
func TestEventForDifferentHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
ds := new(mock.Store)
|
||||
ctx := context.Background()
|
||||
logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{
|
||||
{},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
teamID1 := uint(1)
|
||||
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
|
||||
return []*fleet.Team{
|
||||
{
|
||||
ID: teamID1,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
policyID1 := uint(10)
|
||||
ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
|
||||
require.Equal(t, teamID1, teamID)
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID1,
|
||||
Name: "Policy 1",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
hostID1 := uint(100)
|
||||
hostID2 := uint(101)
|
||||
userEmail1 := "user@example.com"
|
||||
ds.GetTeamHostsPolicyMembershipsFunc = func(
|
||||
ctx context.Context, domain string, teamID uint, policyIDs []uint,
|
||||
) ([]fleet.HostPolicyMembershipData, error) {
|
||||
require.Equal(t, teamID1, teamID)
|
||||
require.Equal(t, []uint{policyID1}, policyIDs)
|
||||
return []fleet.HostPolicyMembershipData{
|
||||
{
|
||||
HostID: hostID1,
|
||||
Email: userEmail1,
|
||||
Passing: false,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
// Return an existing event, but for a different host
|
||||
eventTime := time.Now().Add(time.Hour)
|
||||
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
require.Equal(t, userEmail1, email)
|
||||
calEvent := &fleet.CalendarEvent{
|
||||
ID: 1,
|
||||
Email: email,
|
||||
StartTime: eventTime,
|
||||
EndTime: eventTime,
|
||||
}
|
||||
hcEvent := &fleet.HostCalendarEvent{
|
||||
ID: 1,
|
||||
HostID: hostID2,
|
||||
CalendarEventID: 1,
|
||||
WebhookStatus: fleet.CalendarWebhookStatusNone,
|
||||
}
|
||||
return hcEvent, calEvent, nil
|
||||
}
|
||||
|
||||
err := cronCalendarEvents(ctx, ds, logger)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCalendarEventsMultipleHosts(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
ctx := context.Background()
|
||||
logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
|
||||
t.Cleanup(func() {
|
||||
calendar.ClearMockEvents()
|
||||
})
|
||||
|
||||
// TODO(lucas): Test!
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "POST", r.Method)
|
||||
requestBodyBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("webhook request: %s\n", requestBodyBytes)
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
webhookServer.Close()
|
||||
})
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{
|
||||
{
|
||||
Domain: "example.com",
|
||||
ApiKey: map[string]string{
|
||||
fleet.GoogleCalendarEmail: "calendar-mock@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
teamID1 := uint(1)
|
||||
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
|
||||
return []*fleet.Team{
|
||||
{
|
||||
ID: teamID1,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
policyID1 := uint(10)
|
||||
policyID2 := uint(11)
|
||||
ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
|
||||
require.Equal(t, teamID1, teamID)
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID1,
|
||||
Name: "Policy 1",
|
||||
},
|
||||
{
|
||||
ID: policyID2,
|
||||
Name: "Policy 2",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
hostID1, userEmail1 := uint(100), "user1@example.com"
|
||||
hostID2, userEmail2 := uint(101), "user2@example.com"
|
||||
hostID3, userEmail3 := uint(102), "user3@other.com"
|
||||
hostID4, userEmail4 := uint(103), "user4@other.com"
|
||||
|
||||
ds.GetTeamHostsPolicyMembershipsFunc = func(
|
||||
ctx context.Context, domain string, teamID uint, policyIDs []uint,
|
||||
) ([]fleet.HostPolicyMembershipData, error) {
|
||||
require.Equal(t, teamID1, teamID)
|
||||
require.Equal(t, []uint{policyID1, policyID2}, policyIDs)
|
||||
return []fleet.HostPolicyMembershipData{
|
||||
{
|
||||
HostID: hostID1,
|
||||
Email: userEmail1,
|
||||
Passing: false,
|
||||
},
|
||||
{
|
||||
HostID: hostID2,
|
||||
Email: userEmail2,
|
||||
Passing: true,
|
||||
},
|
||||
{
|
||||
HostID: hostID3,
|
||||
Email: userEmail3,
|
||||
Passing: false,
|
||||
},
|
||||
{
|
||||
HostID: hostID4,
|
||||
Email: userEmail4,
|
||||
Passing: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
return nil, nil, notFoundErr{}
|
||||
}
|
||||
|
||||
ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context,
|
||||
email string,
|
||||
startTime, endTime time.Time,
|
||||
data []byte,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
switch email {
|
||||
case userEmail1:
|
||||
require.Equal(t, hostID1, hostID)
|
||||
case userEmail2:
|
||||
require.Equal(t, hostID2, hostID)
|
||||
case userEmail3:
|
||||
require.Equal(t, hostID3, hostID)
|
||||
case userEmail4:
|
||||
require.Equal(t, hostID4, hostID)
|
||||
}
|
||||
require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus)
|
||||
require.NotEmpty(t, data)
|
||||
require.NotZero(t, startTime)
|
||||
require.NotZero(t, endTime)
|
||||
// Currently, the returned calendar event is unused.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err := cronCalendarEvents(ctx, ds, logger)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
type notFoundErr struct{}
|
||||
|
||||
func (n notFoundErr) IsNotFound() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (n notFoundErr) Error() string {
|
||||
return "not found"
|
||||
}
|
||||
|
||||
func TestCalendarEvents1KHosts(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
ctx := context.Background()
|
||||
var logger kitlog.Logger
|
||||
if os.Getenv("CALENDAR_TEST_LOGGING") != "" {
|
||||
logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
|
||||
} else {
|
||||
logger = kitlog.NewNopLogger()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
calendar.ClearMockEvents()
|
||||
})
|
||||
|
||||
// TODO(lucas): Use for the test.
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "POST", r.Method)
|
||||
requestBodyBytes, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("webhook request: %s\n", requestBodyBytes)
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
webhookServer.Close()
|
||||
})
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{
|
||||
{
|
||||
Domain: "example.com",
|
||||
ApiKey: map[string]string{
|
||||
fleet.GoogleCalendarEmail: "calendar-mock@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
teamID1 := uint(1)
|
||||
teamID2 := uint(2)
|
||||
teamID3 := uint(3)
|
||||
teamID4 := uint(4)
|
||||
teamID5 := uint(5)
|
||||
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
|
||||
return []*fleet.Team{
|
||||
{
|
||||
ID: teamID1,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: teamID2,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: teamID3,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: teamID4,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: teamID5,
|
||||
Config: fleet.TeamConfig{
|
||||
Integrations: fleet.TeamIntegrations{
|
||||
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
policyID1 := uint(10)
|
||||
policyID2 := uint(11)
|
||||
policyID3 := uint(12)
|
||||
policyID4 := uint(13)
|
||||
policyID5 := uint(14)
|
||||
policyID6 := uint(15)
|
||||
policyID7 := uint(16)
|
||||
policyID8 := uint(17)
|
||||
policyID9 := uint(18)
|
||||
policyID10 := uint(19)
|
||||
ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
|
||||
switch teamID {
|
||||
case teamID1:
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID1,
|
||||
Name: "Policy 1",
|
||||
},
|
||||
{
|
||||
ID: policyID2,
|
||||
Name: "Policy 2",
|
||||
},
|
||||
}, nil
|
||||
case teamID2:
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID3,
|
||||
Name: "Policy 3",
|
||||
},
|
||||
{
|
||||
ID: policyID4,
|
||||
Name: "Policy 4",
|
||||
},
|
||||
}, nil
|
||||
case teamID3:
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID5,
|
||||
Name: "Policy 5",
|
||||
},
|
||||
{
|
||||
ID: policyID6,
|
||||
Name: "Policy 6",
|
||||
},
|
||||
}, nil
|
||||
case teamID4:
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID7,
|
||||
Name: "Policy 7",
|
||||
},
|
||||
{
|
||||
ID: policyID8,
|
||||
Name: "Policy 8",
|
||||
},
|
||||
}, nil
|
||||
case teamID5:
|
||||
return []fleet.PolicyCalendarData{
|
||||
{
|
||||
ID: policyID9,
|
||||
Name: "Policy 9",
|
||||
},
|
||||
{
|
||||
ID: policyID10,
|
||||
Name: "Policy 10",
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, notFoundErr{}
|
||||
}
|
||||
}
|
||||
|
||||
hosts := make([]fleet.HostPolicyMembershipData, 0, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
hosts = append(hosts, fleet.HostPolicyMembershipData{
|
||||
Email: fmt.Sprintf("user%d@example.com", i),
|
||||
Passing: i%2 == 0,
|
||||
HostID: uint(i),
|
||||
HostDisplayName: fmt.Sprintf("display_name%d", i),
|
||||
HostHardwareSerial: fmt.Sprintf("serial%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
ds.GetTeamHostsPolicyMembershipsFunc = func(
|
||||
ctx context.Context, domain string, teamID uint, policyIDs []uint,
|
||||
) ([]fleet.HostPolicyMembershipData, error) {
|
||||
var start, end int
|
||||
switch teamID {
|
||||
case teamID1:
|
||||
start, end = 0, 200
|
||||
case teamID2:
|
||||
start, end = 200, 400
|
||||
case teamID3:
|
||||
start, end = 400, 600
|
||||
case teamID4:
|
||||
start, end = 600, 800
|
||||
case teamID5:
|
||||
start, end = 800, 1000
|
||||
}
|
||||
return hosts[start:end], nil
|
||||
}
|
||||
|
||||
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
return nil, nil, notFoundErr{}
|
||||
}
|
||||
|
||||
eventsCreated := 0
|
||||
var eventsCreatedMu sync.Mutex
|
||||
|
||||
eventPerHost := make(map[uint]*fleet.CalendarEvent)
|
||||
|
||||
ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context,
|
||||
email string,
|
||||
startTime, endTime time.Time,
|
||||
data []byte,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
require.Equal(t, fmt.Sprintf("user%d@example.com", hostID), email)
|
||||
eventsCreatedMu.Lock()
|
||||
eventsCreated += 1
|
||||
eventPerHost[hostID] = &fleet.CalendarEvent{
|
||||
ID: hostID,
|
||||
Email: email,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Data: data,
|
||||
UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{
|
||||
CreateTimestamp: fleet.CreateTimestamp{
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
UpdateTimestamp: fleet.UpdateTimestamp{
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
eventsCreatedMu.Unlock()
|
||||
require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus)
|
||||
require.NotEmpty(t, data)
|
||||
require.NotZero(t, startTime)
|
||||
require.NotZero(t, endTime)
|
||||
// Currently, the returned calendar event is unused.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err := cronCalendarEvents(ctx, ds, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
createdCalendarEvents := calendar.ListGoogleMockEvents()
|
||||
require.Equal(t, eventsCreated, 500)
|
||||
require.Len(t, createdCalendarEvents, 500)
|
||||
|
||||
hosts = make([]fleet.HostPolicyMembershipData, 0, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
hosts = append(hosts, fleet.HostPolicyMembershipData{
|
||||
Email: fmt.Sprintf("user%d@example.com", i),
|
||||
Passing: true,
|
||||
HostID: uint(i),
|
||||
HostDisplayName: fmt.Sprintf("display_name%d", i),
|
||||
HostHardwareSerial: fmt.Sprintf("serial%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
hostID, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(email, "user"), "@example.com"))
|
||||
require.NoError(t, err)
|
||||
if hostID%2 == 0 {
|
||||
return nil, nil, notFoundErr{}
|
||||
}
|
||||
require.Contains(t, eventPerHost, uint(hostID))
|
||||
return &fleet.HostCalendarEvent{
|
||||
ID: uint(hostID),
|
||||
HostID: uint(hostID),
|
||||
CalendarEventID: uint(hostID),
|
||||
WebhookStatus: fleet.CalendarWebhookStatusNone,
|
||||
}, eventPerHost[uint(hostID)], nil
|
||||
}
|
||||
|
||||
ds.DeleteCalendarEventFunc = func(ctx context.Context, calendarEventID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = cronCalendarEvents(ctx, ds, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
createdCalendarEvents = calendar.ListGoogleMockEvents()
|
||||
require.Len(t, createdCalendarEvents, 0)
|
||||
}
|
@ -768,6 +768,18 @@ the way that the Fleet server works.
|
||||
}
|
||||
}
|
||||
|
||||
if license.IsPremium() {
|
||||
if err := cronSchedules.StartCronSchedule(
|
||||
func() (fleet.CronSchedule, error) {
|
||||
return newCalendarSchedule(
|
||||
ctx, instanceID, ds, logger,
|
||||
)
|
||||
},
|
||||
); err != nil {
|
||||
initFatal(err, "failed to register calendar schedule")
|
||||
}
|
||||
}
|
||||
|
||||
level.Info(logger).Log("msg", fmt.Sprintf("started cron schedules: %s", strings.Join(cronSchedules.ScheduleNames(), ", ")))
|
||||
|
||||
// StartCollectors starts a goroutine per collector, using ctx to cancel.
|
||||
|
@ -145,7 +145,13 @@ func TestApplyTeamSpecs(t *testing.T) {
|
||||
|
||||
agentOpts := json.RawMessage(`{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}}`)
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{AgentOptions: &agentOpts, MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
|
||||
return &fleet.AppConfig{
|
||||
AgentOptions: &agentOpts,
|
||||
MDM: fleet.MDM{EnabledAndConfigured: true},
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
@ -459,6 +465,46 @@ spec:
|
||||
HostPercentage: 25,
|
||||
}, *teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook,
|
||||
)
|
||||
|
||||
// Apply calendar integration
|
||||
filename = writeTmpYml(
|
||||
t, `
|
||||
apiVersion: v1
|
||||
kind: team
|
||||
spec:
|
||||
team:
|
||||
name: team1
|
||||
integrations:
|
||||
google_calendar:
|
||||
enable_calendar_events: true
|
||||
webhook_url: https://example.com/webhook
|
||||
`,
|
||||
)
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
|
||||
require.NotNil(t, teamsByName["team1"].Config.Integrations.GoogleCalendar)
|
||||
assert.Equal(
|
||||
t, fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: "https://example.com/webhook",
|
||||
}, *teamsByName["team1"].Config.Integrations.GoogleCalendar,
|
||||
)
|
||||
|
||||
// Apply calendar integration -- invalid webhook destination
|
||||
filename = writeTmpYml(
|
||||
t, `
|
||||
apiVersion: v1
|
||||
kind: team
|
||||
spec:
|
||||
team:
|
||||
name: team1
|
||||
integrations:
|
||||
google_calendar:
|
||||
enable_calendar_events: true
|
||||
webhook_url: bozo
|
||||
`,
|
||||
)
|
||||
_, err = runAppNoChecks([]string{"apply", "-f", filename})
|
||||
assert.ErrorContains(t, err, "invalid URI for request")
|
||||
}
|
||||
|
||||
func writeTmpYml(t *testing.T, contents string) string {
|
||||
|
@ -331,29 +331,31 @@ func TestGetHosts(t *testing.T) {
|
||||
return []*fleet.HostPolicy{
|
||||
{
|
||||
PolicyData: fleet.PolicyData{
|
||||
ID: 1,
|
||||
Name: "query1",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "Some description",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: ptr.String("Some resolution"),
|
||||
TeamID: ptr.Uint(1),
|
||||
ID: 1,
|
||||
Name: "query1",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "Some description",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: ptr.String("Some resolution"),
|
||||
TeamID: ptr.Uint(1),
|
||||
CalendarEventsEnabled: true,
|
||||
},
|
||||
Response: "passes",
|
||||
},
|
||||
{
|
||||
PolicyData: fleet.PolicyData{
|
||||
ID: 2,
|
||||
Name: "query2",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: nil,
|
||||
TeamID: nil,
|
||||
ID: 2,
|
||||
Name: "query2",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: nil,
|
||||
TeamID: nil,
|
||||
CalendarEventsEnabled: false,
|
||||
},
|
||||
Response: "fails",
|
||||
},
|
||||
|
@ -360,6 +360,8 @@ func TestFullGlobalGitOps(t *testing.T) {
|
||||
assert.Len(t, appliedScripts, 1)
|
||||
assert.Len(t, appliedMacProfiles, 1)
|
||||
assert.Len(t, appliedWinProfiles, 1)
|
||||
require.Len(t, savedAppConfig.Integrations.GoogleCalendar, 1)
|
||||
assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"])
|
||||
}
|
||||
|
||||
func TestFullTeamGitOps(t *testing.T) {
|
||||
@ -389,6 +391,9 @@ func TestFullTeamGitOps(t *testing.T) {
|
||||
EnabledAndConfigured: true,
|
||||
WindowsEnabledAndConfigured: true,
|
||||
},
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -536,6 +541,8 @@ func TestFullTeamGitOps(t *testing.T) {
|
||||
assert.Len(t, appliedWinProfiles, 1)
|
||||
assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable)
|
||||
assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
|
||||
require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
|
||||
assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
|
||||
|
||||
// Now clear the settings
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
@ -569,6 +576,9 @@ team_settings:
|
||||
assert.Equal(t, secret, enrolledSecrets[0].Secret)
|
||||
assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable)
|
||||
assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
|
||||
assert.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
|
||||
assert.False(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
|
||||
assert.Empty(t, savedTeam.Config.Integrations.GoogleCalendar)
|
||||
assert.Empty(t, savedTeam.Config.MDM.MacOSSettings.CustomSettings)
|
||||
assert.Empty(t, savedTeam.Config.MDM.WindowsSettings.CustomSettings.Value)
|
||||
assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.Deadline.Value)
|
||||
|
@ -79,7 +79,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"mdm": {
|
||||
"apple_bm_terms_expired": false,
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -120,7 +120,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"update_interval": {
|
||||
"osquery_detail": "1h0m0s",
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -22,7 +22,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": true,
|
||||
@ -93,7 +94,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": false,
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_updates:
|
||||
@ -50,6 +52,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 15
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_updates:
|
||||
|
@ -76,7 +76,8 @@
|
||||
"team_id": 1,
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"critical": false
|
||||
"critical": false,
|
||||
"calendar_events_enabled": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@ -91,7 +92,8 @@
|
||||
"team_id": null,
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"critical": false
|
||||
"critical": false,
|
||||
"calendar_events_enabled": false
|
||||
}
|
||||
],
|
||||
"status": "offline",
|
||||
|
@ -62,6 +62,7 @@ spec:
|
||||
created_at: "0001-01-01T00:00:00Z"
|
||||
updated_at: "0001-01-01T00:00:00Z"
|
||||
critical: false
|
||||
calendar_events_enabled: true
|
||||
- author_email: "alice@example.com"
|
||||
author_id: 1
|
||||
author_name: Alice
|
||||
@ -75,6 +76,7 @@ spec:
|
||||
created_at: "0001-01-01T00:00:00Z"
|
||||
updated_at: "0001-01-01T00:00:00Z"
|
||||
critical: false
|
||||
calendar_events_enabled: false
|
||||
policy_updated_at: "0001-01-01T00:00:00Z"
|
||||
public_ip: ""
|
||||
primary_ip: ""
|
||||
|
@ -137,6 +137,12 @@ org_settings:
|
||||
integrations:
|
||||
jira: []
|
||||
zendesk: []
|
||||
google_calendar:
|
||||
- domain: example.com
|
||||
api_key_json: {
|
||||
"client_email": "service@example.com",
|
||||
"private_key": "google_calendar_private_key",
|
||||
}
|
||||
mdm:
|
||||
apple_bm_default_team: ""
|
||||
end_user_authentication:
|
||||
|
@ -15,6 +15,10 @@ team_settings:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
integrations:
|
||||
google_calendar:
|
||||
enable_calendar_events: true
|
||||
webhook_url: https://example.com/google_calendar_webhook
|
||||
agent_options:
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
@ -89,6 +93,7 @@ policies:
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
calendar_events_enabled: true
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
@ -41,6 +43,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
@ -41,6 +43,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
features:
|
||||
enable_host_users: false
|
||||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
573
ee/server/calendar/google_calendar.go
Normal file
573
ee/server/calendar/google_calendar.go
Normal file
@ -0,0 +1,573 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/go-kit/log/level"
|
||||
"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"
|
||||
)
|
||||
|
||||
// The calendar package has the following features for testing:
|
||||
// 1. High level UserCalendar interface and Low level GoogleCalendarAPI interface can have a custom implementations.
|
||||
// 2. Setting "client_email" to "calendar-mock@example.com" in the API key will use a mock in-memory implementation GoogleCalendarMockAPI of GoogleCalendarAPI.
|
||||
// 3. Setting FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING environment variable to "1" will strip the "plus addressing" from the user email, effectively allowing a single user
|
||||
// to create multiple events in the same calendar. This is useful for load testing. For example: john+test@example.com becomes john@example.com
|
||||
|
||||
const (
|
||||
eventTitle = "💻🚫Downtime"
|
||||
startHour = 9
|
||||
endHour = 17
|
||||
eventLength = 30 * time.Minute
|
||||
calendarID = "primary"
|
||||
mockEmail = "calendar-mock@example.com"
|
||||
loadEmail = "calendar-load@example.com"
|
||||
)
|
||||
|
||||
var (
|
||||
calendarScopes = []string{
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.settings.readonly",
|
||||
}
|
||||
plusAddressing = os.Getenv("FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING") == "1"
|
||||
plusAddressingRegex = regexp.MustCompile(`\+.*@`)
|
||||
)
|
||||
|
||||
type GoogleCalendarConfig struct {
|
||||
Context context.Context
|
||||
IntegrationConfig *fleet.GoogleCalendarIntegration
|
||||
Logger kitlog.Logger
|
||||
// Should be nil for production
|
||||
API GoogleCalendarAPI
|
||||
}
|
||||
|
||||
// GoogleCalendar is an implementation of the UserCalendar interface that uses the
|
||||
// Google Calendar API to manage events.
|
||||
type GoogleCalendar struct {
|
||||
config *GoogleCalendarConfig
|
||||
currentUserEmail string
|
||||
adjustedUserEmail string
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar {
|
||||
switch {
|
||||
case config.API != nil:
|
||||
// Use the provided API.
|
||||
case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == loadEmail:
|
||||
config.API = &GoogleCalendarLoadAPI{Logger: config.Logger}
|
||||
case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == mockEmail:
|
||||
config.API = &GoogleCalendarMockAPI{config.Logger}
|
||||
default:
|
||||
config.API = &GoogleCalendarLowLevelAPI{logger: config.Logger}
|
||||
}
|
||||
return &GoogleCalendar{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
type GoogleCalendarAPI interface {
|
||||
Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail 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
|
||||
}
|
||||
|
||||
type eventDetails struct {
|
||||
ID string `json:"id"`
|
||||
ETag string `json:"etag"`
|
||||
}
|
||||
|
||||
type GoogleCalendarLowLevelAPI struct {
|
||||
service *calendar.Service
|
||||
logger kitlog.Logger
|
||||
}
|
||||
|
||||
// Configure creates a new Google Calendar service using the provided credentials.
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure(
|
||||
ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string,
|
||||
) error {
|
||||
// Create a new calendar service
|
||||
conf := &jwt.Config{
|
||||
Email: serviceAccountEmail,
|
||||
Scopes: calendarScopes,
|
||||
PrivateKey: []byte(privateKey),
|
||||
TokenURL: google.JWTTokenURL,
|
||||
Subject: userToImpersonateEmail,
|
||||
}
|
||||
client := conf.Client(ctx)
|
||||
service, err := calendar.NewService(ctx, option.WithHTTPClient(client))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lowLevelAPI.service = service
|
||||
return nil
|
||||
}
|
||||
|
||||
func adjustEmail(email string) string {
|
||||
if plusAddressing {
|
||||
return plusAddressingRegex.ReplaceAllString(email, "@")
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) {
|
||||
result, err := lowLevelAPI.withRetry(
|
||||
func() (any, error) {
|
||||
return lowLevelAPI.service.Settings.Get(name).Do()
|
||||
},
|
||||
)
|
||||
return result.(*calendar.Setting), err
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
|
||||
result, err := lowLevelAPI.withRetry(
|
||||
func() (any, error) {
|
||||
return lowLevelAPI.service.Events.Insert(calendarID, event).Do()
|
||||
},
|
||||
)
|
||||
return result.(*calendar.Event), err
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) {
|
||||
result, err := lowLevelAPI.withRetry(
|
||||
func() (any, error) {
|
||||
return lowLevelAPI.service.Events.Get(calendarID, id).IfNoneMatch(eTag).Do()
|
||||
},
|
||||
)
|
||||
return result.(*calendar.Event), err
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
result, err := lowLevelAPI.withRetry(
|
||||
func() (any, error) {
|
||||
// Default maximum number of events returned is 250, which should be sufficient for most calendars.
|
||||
return lowLevelAPI.service.Events.List(calendarID).
|
||||
EventTypes("default").
|
||||
OrderBy("startTime").
|
||||
SingleEvents(true).
|
||||
TimeMin(timeMin).
|
||||
TimeMax(timeMax).
|
||||
ShowDeleted(false).
|
||||
Do()
|
||||
},
|
||||
)
|
||||
return result.(*calendar.Events), err
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
|
||||
_, err := lowLevelAPI.withRetry(
|
||||
func() (any, error) {
|
||||
return nil, lowLevelAPI.service.Events.Delete(calendarID, id).Do()
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLowLevelAPI) withRetry(fn func() (any, error)) (any, error) {
|
||||
retryStrategy := backoff.NewExponentialBackOff()
|
||||
retryStrategy.MaxElapsedTime = 10 * time.Minute
|
||||
var result any
|
||||
err := backoff.Retry(
|
||||
func() error {
|
||||
var err error
|
||||
result, err = fn()
|
||||
if err != nil {
|
||||
if isRateLimited(err) {
|
||||
level.Debug(lowLevelAPI.logger).Log("msg", "rate limited by Google calendar API", "err", err)
|
||||
return err
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
return nil
|
||||
}, retryStrategy,
|
||||
)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) Configure(userEmail string) error {
|
||||
adjustedUserEmail := adjustEmail(userEmail)
|
||||
err := c.config.API.Configure(
|
||||
c.config.Context, c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail],
|
||||
c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], adjustedUserEmail,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(c.config.Context, err, "creating Google calendar service")
|
||||
}
|
||||
c.currentUserEmail = userEmail
|
||||
c.adjustedUserEmail = adjustedUserEmail
|
||||
// Clear the timezone offset so that it will be recalculated
|
||||
c.location = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) (
|
||||
*fleet.CalendarEvent, bool, error,
|
||||
) {
|
||||
// We assume that the Fleet event has not already ended. We will simply return it if it has not been modified.
|
||||
details, err := c.unmarshalDetails(event)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
gEvent, err := c.config.API.GetEvent(details.ID, details.ETag)
|
||||
var deleted bool
|
||||
switch {
|
||||
// http.StatusNotModified is returned sometimes, but not always, so we need to check ETag explicitly later
|
||||
case googleapi.IsNotModified(err):
|
||||
return event, false, nil
|
||||
// http.StatusNotFound should be very rare -- Google keeps events for a while after they are deleted
|
||||
case isNotFound(err):
|
||||
deleted = true
|
||||
case err != nil:
|
||||
return nil, false, ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event")
|
||||
}
|
||||
if !deleted && gEvent.Status != "cancelled" {
|
||||
if details.ETag != "" && details.ETag == gEvent.Etag {
|
||||
// Event was not modified
|
||||
return event, false, nil
|
||||
}
|
||||
if gEvent.End == nil || (gEvent.End.DateTime == "" && gEvent.End.Date == "") {
|
||||
// We should not see this error. If we do, we can work around by treating event as deleted.
|
||||
return nil, false, ctxerr.Errorf(c.config.Context, "missing end date/time for Google calendar event: %s", gEvent.Id)
|
||||
}
|
||||
|
||||
if gEvent.End.DateTime == "" {
|
||||
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
|
||||
// We won't handle all-day events at this time, and treat the event as deleted.
|
||||
err = c.DeleteEvent(event)
|
||||
if err != nil {
|
||||
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
|
||||
}
|
||||
deleted = true
|
||||
}
|
||||
|
||||
var endTime *time.Time
|
||||
if !deleted {
|
||||
endTime, err = c.parseDateTime(gEvent.End)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !endTime.After(time.Now()) {
|
||||
// If event already ended, it is effectively deleted
|
||||
// Delete this event to prevent confusion. This operation should be rare.
|
||||
err = c.DeleteEvent(event)
|
||||
if err != nil {
|
||||
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which is in the past", "err", err)
|
||||
}
|
||||
deleted = true
|
||||
}
|
||||
}
|
||||
if !deleted {
|
||||
if gEvent.Start == nil || (gEvent.Start.DateTime == "" && gEvent.Start.Date == "") {
|
||||
// We should not see this error. If we do, we can work around by treating event as deleted.
|
||||
return nil, false, ctxerr.Errorf(c.config.Context, "missing start date/time for Google calendar event: %s", gEvent.Id)
|
||||
}
|
||||
if gEvent.Start.DateTime == "" {
|
||||
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
|
||||
// We won't handle all-day events at this time, and treat the event as deleted.
|
||||
err = c.DeleteEvent(event)
|
||||
if err != nil {
|
||||
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
|
||||
}
|
||||
deleted = true
|
||||
}
|
||||
}
|
||||
if !deleted {
|
||||
startTime, err := c.parseDateTime(gEvent.Start)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return fleetEvent, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
newStartDate := calculateNewEventDate(event.StartTime)
|
||||
|
||||
fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return fleetEvent, true, nil
|
||||
}
|
||||
|
||||
func calculateNewEventDate(oldStartDate time.Time) time.Time {
|
||||
// Note: we do not handle time changes (daylight savings time, etc.) -- assuming 1 day is always 24 hours.
|
||||
newStartDate := oldStartDate.Add(24 * time.Hour)
|
||||
if newStartDate.Weekday() == time.Saturday {
|
||||
newStartDate = newStartDate.Add(48 * time.Hour)
|
||||
} else if newStartDate.Weekday() == time.Sunday {
|
||||
newStartDate = newStartDate.Add(24 * time.Hour)
|
||||
}
|
||||
return newStartDate
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) parseDateTime(eventDateTime *calendar.EventDateTime) (*time.Time, error) {
|
||||
var t time.Time
|
||||
var err error
|
||||
if eventDateTime.TimeZone != "" {
|
||||
loc := getLocation(eventDateTime.TimeZone, c.config)
|
||||
t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc)
|
||||
} else {
|
||||
t, err = time.Parse(time.RFC3339, eventDateTime.DateTime)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(
|
||||
c.config.Context, err, fmt.Sprintf("parsing Google calendar event time: %s", eventDateTime.DateTime),
|
||||
)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var ae *googleapi.Error
|
||||
ok := errors.As(err, &ae)
|
||||
return ok && ae.Code == http.StatusNotFound
|
||||
}
|
||||
|
||||
func isAlreadyDeleted(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var ae *googleapi.Error
|
||||
ok := errors.As(err, &ae)
|
||||
return ok && ae.Code == http.StatusGone
|
||||
}
|
||||
|
||||
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"))))
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDetails, error) {
|
||||
var details eventDetails
|
||||
err := json.Unmarshal(event.Data, &details)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(c.config.Context, err, "unmarshaling Google calendar event details")
|
||||
}
|
||||
if details.ID == "" {
|
||||
return nil, ctxerr.Errorf(c.config.Context, "missing Google calendar event ID")
|
||||
}
|
||||
// ETag is optional, but we need it to check if the event was modified
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) {
|
||||
return c.createEvent(dayOfEvent, genBodyFn, time.Now)
|
||||
}
|
||||
|
||||
// createEvent creates a new event on the calendar on the given date. timeNow is a function that returns the current time.
|
||||
// timeNow can be overwritten for testing
|
||||
func (c *GoogleCalendar) createEvent(
|
||||
dayOfEvent time.Time, genBodyFn func(conflict bool) string, timeNow func() time.Time,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
var err error
|
||||
if c.location == nil {
|
||||
c.location, err = getTimezone(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
dayStart := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), startHour, 0, 0, 0, c.location)
|
||||
dayEnd := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), endHour, 0, 0, 0, c.location)
|
||||
|
||||
now := timeNow().In(c.location)
|
||||
if dayEnd.Before(now) {
|
||||
// The workday has already ended.
|
||||
return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "cannot schedule an event for a day that has already ended"})
|
||||
}
|
||||
|
||||
// Adjust day start if workday already started
|
||||
if !dayStart.After(now) {
|
||||
dayStart = now.Truncate(eventLength)
|
||||
if dayStart.Before(now) {
|
||||
dayStart = dayStart.Add(eventLength)
|
||||
}
|
||||
if !dayStart.Before(dayEnd) {
|
||||
return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "no time available for event"})
|
||||
}
|
||||
}
|
||||
eventStart := dayStart
|
||||
eventEnd := dayStart.Add(eventLength)
|
||||
|
||||
events, err := c.config.API.ListEvents(dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(c.config.Context, err, "listing Google calendar events")
|
||||
}
|
||||
var conflict bool
|
||||
for _, gEvent := range events.Items {
|
||||
// Ignore cancelled events
|
||||
if gEvent.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore all day events
|
||||
if gEvent.Start == nil || gEvent.Start.DateTime == "" || gEvent.End == nil || gEvent.End.DateTime == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore events that the user has declined
|
||||
var declined bool
|
||||
for _, attendee := range gEvent.Attendees {
|
||||
if attendee.Email == c.adjustedUserEmail {
|
||||
// The user has declined the event, so this time is open for scheduling
|
||||
if attendee.ResponseStatus == "declined" {
|
||||
declined = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if declined {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore events that will end before our event
|
||||
endTime, err := c.parseDateTime(gEvent.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !endTime.After(eventStart) {
|
||||
continue
|
||||
}
|
||||
|
||||
startTime, err := c.parseDateTime(gEvent.Start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if startTime.Before(eventEnd) {
|
||||
// Event occurs during our event, so we need to adjust.
|
||||
var isLastSlot bool
|
||||
eventStart, eventEnd, isLastSlot, conflict = adjustEventTimes(*endTime, dayEnd)
|
||||
if isLastSlot {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Since events are sorted by startTime, all subsequent events are after our event, so we can stop processing
|
||||
break
|
||||
}
|
||||
|
||||
event := &calendar.Event{}
|
||||
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)
|
||||
event, err = c.config.API.CreateEvent(event)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event")
|
||||
}
|
||||
|
||||
// Convert Google event to Fleet event
|
||||
fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
level.Debug(c.config.Logger).Log(
|
||||
"msg", "created Google calendar event", "user", c.adjustedUserEmail, "startTime", eventStart, "timezone", c.location.String(),
|
||||
)
|
||||
|
||||
return fleetEvent, nil
|
||||
}
|
||||
|
||||
func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool, conflict bool) {
|
||||
eventStart = endTime.Truncate(eventLength)
|
||||
if eventStart.Before(endTime) {
|
||||
eventStart = eventStart.Add(eventLength)
|
||||
}
|
||||
eventEnd = eventStart.Add(eventLength)
|
||||
// If we are at the end of the day, pick the last slot
|
||||
if eventEnd.After(dayEnd) {
|
||||
eventEnd = dayEnd
|
||||
eventStart = eventEnd.Add(-eventLength)
|
||||
isLastSlot = true
|
||||
conflict = true
|
||||
} else if eventEnd.Equal(dayEnd) {
|
||||
isLastSlot = true
|
||||
}
|
||||
return eventStart, eventEnd, isLastSlot, conflict
|
||||
}
|
||||
|
||||
func getTimezone(gCal *GoogleCalendar) (*time.Location, error) {
|
||||
config := gCal.config
|
||||
setting, err := config.API.GetSetting("timezone")
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(config.Context, err, "retrieving Google calendar timezone")
|
||||
}
|
||||
|
||||
return getLocation(setting.Value, config), nil
|
||||
}
|
||||
|
||||
func getLocation(name string, config *GoogleCalendarConfig) *time.Location {
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
// Could not load location, use EST
|
||||
level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", name, "err", err)
|
||||
loc, _ = time.LoadLocation("America/New_York")
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event) (
|
||||
*fleet.CalendarEvent, error,
|
||||
) {
|
||||
fleetEvent := &fleet.CalendarEvent{}
|
||||
fleetEvent.StartTime = startTime
|
||||
fleetEvent.EndTime = endTime
|
||||
fleetEvent.Email = c.currentUserEmail
|
||||
details := &eventDetails{
|
||||
ID: event.Id,
|
||||
ETag: event.Etag,
|
||||
}
|
||||
detailsJson, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(c.config.Context, err, "marshaling Google calendar event details")
|
||||
}
|
||||
fleetEvent.Data = detailsJson
|
||||
return fleetEvent, nil
|
||||
}
|
||||
|
||||
func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error {
|
||||
details, err := c.unmarshalDetails(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.config.API.DeleteEvent(details.ID)
|
||||
switch {
|
||||
case isAlreadyDeleted(err):
|
||||
return nil
|
||||
case err != nil:
|
||||
return ctxerr.Wrap(c.config.Context, err, "deleting Google calendar event")
|
||||
}
|
||||
return nil
|
||||
}
|
132
ee/server/calendar/google_calendar_integration_test.go
Normal file
132
ee/server/calendar/google_calendar_integration_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/calendar/load_test"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type googleCalendarIntegrationTestSuite struct {
|
||||
suite.Suite
|
||||
server *httptest.Server
|
||||
dbFile *os.File
|
||||
}
|
||||
|
||||
func (s *googleCalendarIntegrationTestSuite) SetupSuite() {
|
||||
dbFile, err := os.CreateTemp("", "calendar.db")
|
||||
s.Require().NoError(err)
|
||||
handler, err := calendartest.Configure(dbFile.Name())
|
||||
s.Require().NoError(err)
|
||||
server := httptest.NewUnstartedServer(handler)
|
||||
server.Listener.Addr()
|
||||
server.Start()
|
||||
s.server = server
|
||||
}
|
||||
|
||||
func (s *googleCalendarIntegrationTestSuite) TearDownSuite() {
|
||||
if s.dbFile != nil {
|
||||
s.dbFile.Close()
|
||||
_ = os.Remove(s.dbFile.Name())
|
||||
}
|
||||
if s.server != nil {
|
||||
s.server.Close()
|
||||
}
|
||||
calendartest.Close()
|
||||
}
|
||||
|
||||
// TestGoogleCalendarIntegration tests should be able to be run in parallel, but this is not natively supported by suites: https://github.com/stretchr/testify/issues/187
|
||||
// There are workarounds that can be explored.
|
||||
func TestGoogleCalendarIntegration(t *testing.T) {
|
||||
testingSuite := new(googleCalendarIntegrationTestSuite)
|
||||
suite.Run(t, testingSuite)
|
||||
}
|
||||
|
||||
func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() {
|
||||
t := s.T()
|
||||
userEmail := "user1@example.com"
|
||||
config := &GoogleCalendarConfig{
|
||||
Context: context.Background(),
|
||||
IntegrationConfig: &fleet.GoogleCalendarIntegration{
|
||||
Domain: "example.com",
|
||||
ApiKey: map[string]string{
|
||||
"client_email": loadEmail,
|
||||
"private_key": s.server.URL,
|
||||
},
|
||||
},
|
||||
Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)),
|
||||
}
|
||||
gCal := NewGoogleCalendar(config)
|
||||
err := gCal.Configure(userEmail)
|
||||
require.NoError(t, err)
|
||||
genBodyFn := func(bool) string {
|
||||
return "Test event"
|
||||
}
|
||||
eventDate := time.Now().Add(48 * time.Hour)
|
||||
event, err := gCal.CreateEvent(eventDate, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, startHour, event.StartTime.Hour())
|
||||
assert.Equal(t, 0, event.StartTime.Minute())
|
||||
|
||||
eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated)
|
||||
assert.Equal(t, event, eventRsp)
|
||||
|
||||
err = gCal.DeleteEvent(event)
|
||||
assert.NoError(t, err)
|
||||
// delete again
|
||||
err = gCal.DeleteEvent(event)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get deleted event
|
||||
eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
assert.NotEqual(t, event.StartTime.UTC().Truncate(24*time.Hour), eventRsp.StartTime.UTC().Truncate(24*time.Hour))
|
||||
}
|
||||
|
||||
func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() {
|
||||
t := s.T()
|
||||
userEmail := "user2@example.com"
|
||||
config := &GoogleCalendarConfig{
|
||||
Context: context.Background(),
|
||||
IntegrationConfig: &fleet.GoogleCalendarIntegration{
|
||||
Domain: "example.com",
|
||||
ApiKey: map[string]string{
|
||||
"client_email": loadEmail,
|
||||
"private_key": s.server.URL,
|
||||
},
|
||||
},
|
||||
Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)),
|
||||
}
|
||||
gCal := NewGoogleCalendar(config)
|
||||
err := gCal.Configure(userEmail)
|
||||
require.NoError(t, err)
|
||||
genBodyFn := func(bool) string {
|
||||
return "Test event"
|
||||
}
|
||||
eventDate := time.Now().Add(48 * time.Hour)
|
||||
event, err := gCal.CreateEvent(eventDate, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, startHour, event.StartTime.Hour())
|
||||
assert.Equal(t, 0, event.StartTime.Minute())
|
||||
|
||||
currentEventTime := event.StartTime
|
||||
for i := 0; i < 20; i++ {
|
||||
if !(currentEventTime.Hour() == endHour-1 && currentEventTime.Minute() == 30) {
|
||||
currentEventTime = currentEventTime.Add(30 * time.Minute)
|
||||
}
|
||||
event, err = gCal.CreateEvent(eventDate, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, currentEventTime.UTC(), event.StartTime.UTC())
|
||||
}
|
||||
|
||||
}
|
234
ee/server/calendar/google_calendar_load.go
Normal file
234
ee/server/calendar/google_calendar_load.go
Normal file
@ -0,0 +1,234 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/googleapi"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
)
|
||||
|
||||
// GoogleCalendarLoadAPI is used for load testing.
|
||||
type GoogleCalendarLoadAPI struct {
|
||||
Logger kitlog.Logger
|
||||
baseUrl string
|
||||
userToImpersonate string
|
||||
ctx context.Context
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// Configure creates a new Google Calendar service using the provided credentials.
|
||||
func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string) error {
|
||||
if lowLevelAPI.Logger == nil {
|
||||
lowLevelAPI.Logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarLoadAPI", "user", userToImpersonate)
|
||||
}
|
||||
lowLevelAPI.baseUrl = privateKey
|
||||
lowLevelAPI.userToImpersonate = userToImpersonate
|
||||
lowLevelAPI.ctx = ctx
|
||||
if lowLevelAPI.client == nil {
|
||||
lowLevelAPI.client = fleethttp.NewClient()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLoadAPI) GetSetting(name string) (*calendar.Setting, error) {
|
||||
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/settings")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Set("name", name)
|
||||
query.Set("email", lowLevelAPI.userToImpersonate)
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp, err := lowLevelAPI.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rsp.Body.Close()
|
||||
}()
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
var data []byte
|
||||
if rsp.Body != nil {
|
||||
data, _ = io.ReadAll(rsp.Body)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
|
||||
}
|
||||
var setting calendar.Setting
|
||||
body, err := io.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(body, &setting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLoadAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/add")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Set("email", lowLevelAPI.userToImpersonate)
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "POST", reqUrl.String(), bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rsp, err := lowLevelAPI.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rsp.Body.Close()
|
||||
}()
|
||||
if rsp.StatusCode != http.StatusCreated {
|
||||
var data []byte
|
||||
if rsp.Body != nil {
|
||||
data, _ = io.ReadAll(rsp.Body)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
|
||||
}
|
||||
var rspEvent calendar.Event
|
||||
body, err = io.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(body, &rspEvent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rspEvent, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLoadAPI) GetEvent(id, _ string) (*calendar.Event, error) {
|
||||
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Set("id", id)
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp, err := lowLevelAPI.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rsp.Body.Close()
|
||||
}()
|
||||
if rsp.StatusCode == http.StatusNotFound {
|
||||
return nil, &googleapi.Error{Code: http.StatusNotFound}
|
||||
}
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
var data []byte
|
||||
if rsp.Body != nil {
|
||||
data, _ = io.ReadAll(rsp.Body)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
|
||||
}
|
||||
var rspEvent calendar.Event
|
||||
body, err := io.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(body, &rspEvent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rspEvent, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLoadAPI) ListEvents(timeMin string, timeMax string) (*calendar.Events, error) {
|
||||
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Set("timemin", timeMin)
|
||||
query.Set("timemax", timeMax)
|
||||
query.Set("email", lowLevelAPI.userToImpersonate)
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp, err := lowLevelAPI.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rsp.Body.Close()
|
||||
}()
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
var data []byte
|
||||
if rsp.Body != nil {
|
||||
data, _ = io.ReadAll(rsp.Body)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
|
||||
}
|
||||
var events calendar.Events
|
||||
body, err := io.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(body, &events)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &events, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarLoadAPI) DeleteEvent(id string) error {
|
||||
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Set("id", id)
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "DELETE", reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp, err := lowLevelAPI.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = rsp.Body.Close()
|
||||
}()
|
||||
if rsp.StatusCode == http.StatusGone {
|
||||
return &googleapi.Error{Code: http.StatusGone}
|
||||
}
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
var data []byte
|
||||
if rsp.Body != nil {
|
||||
data, _ = io.ReadAll(rsp.Body)
|
||||
}
|
||||
return fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
|
||||
}
|
||||
return nil
|
||||
}
|
95
ee/server/calendar/google_calendar_mock.go
Normal file
95
ee/server/calendar/google_calendar_mock.go
Normal file
@ -0,0 +1,95 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
kitlog "github.com/go-kit/log"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
type GoogleCalendarMockAPI struct {
|
||||
logger kitlog.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
mockEvents = make(map[string]*calendar.Event)
|
||||
mu sync.Mutex
|
||||
id uint64
|
||||
)
|
||||
|
||||
const latency = 500 * time.Millisecond
|
||||
|
||||
// Configure creates a new Google Calendar service using the provided credentials.
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error {
|
||||
if lowLevelAPI.logger == nil {
|
||||
lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) GetSetting(name string) (*calendar.Setting, error) {
|
||||
time.Sleep(latency)
|
||||
lowLevelAPI.logger.Log("msg", "GetSetting", "name", name)
|
||||
if name == "timezone" {
|
||||
return &calendar.Setting{
|
||||
Id: "timezone",
|
||||
Value: "America/Chicago",
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("setting not supported")
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
|
||||
time.Sleep(latency)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
id += 1
|
||||
event.Id = strconv.FormatUint(id, 10)
|
||||
lowLevelAPI.logger.Log("msg", "CreateEvent", "id", event.Id, "start", event.Start.DateTime)
|
||||
mockEvents[event.Id] = event
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) GetEvent(id, _ string) (*calendar.Event, error) {
|
||||
time.Sleep(latency)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
event, ok := mockEvents[id]
|
||||
if !ok {
|
||||
return nil, &googleapi.Error{Code: http.StatusNotFound}
|
||||
}
|
||||
lowLevelAPI.logger.Log("msg", "GetEvent", "id", id, "start", event.Start.DateTime)
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) ListEvents(string, string) (*calendar.Events, error) {
|
||||
time.Sleep(latency)
|
||||
lowLevelAPI.logger.Log("msg", "ListEvents")
|
||||
return &calendar.Events{}, nil
|
||||
}
|
||||
|
||||
func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error {
|
||||
time.Sleep(latency)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lowLevelAPI.logger.Log("msg", "DeleteEvent", "id", id)
|
||||
delete(mockEvents, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListGoogleMockEvents() map[string]*calendar.Event {
|
||||
return mockEvents
|
||||
}
|
||||
|
||||
func ClearMockEvents() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
mockEvents = make(map[string]*calendar.Event)
|
||||
}
|
650
ee/server/calendar/google_calendar_test.go
Normal file
650
ee/server/calendar/google_calendar_test.go
Normal file
@ -0,0 +1,650 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-kit/kit/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/googleapi"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
baseServiceEmail = "service@example.com"
|
||||
basePrivateKey = "private-key"
|
||||
baseUserEmail = "user@example.com"
|
||||
)
|
||||
|
||||
var (
|
||||
baseCtx = context.Background()
|
||||
logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout))
|
||||
)
|
||||
|
||||
type MockGoogleCalendarLowLevelAPI struct {
|
||||
ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail 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
|
||||
}
|
||||
|
||||
func (m *MockGoogleCalendarLowLevelAPI) Configure(
|
||||
ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string,
|
||||
) error {
|
||||
return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail)
|
||||
}
|
||||
|
||||
func (m *MockGoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) {
|
||||
return m.GetSettingFunc(name)
|
||||
}
|
||||
|
||||
func (m *MockGoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return m.ListEventsFunc(timeMin, timeMax)
|
||||
}
|
||||
|
||||
func (m *MockGoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
|
||||
return m.CreateEventFunc(event)
|
||||
}
|
||||
|
||||
func (m *MockGoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) {
|
||||
return m.GetEventFunc(id, eTag)
|
||||
}
|
||||
|
||||
func (m *MockGoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
|
||||
return m.DeleteEventFunc(id)
|
||||
}
|
||||
|
||||
func TestGoogleCalendar_Configure(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockAPI := &MockGoogleCalendarLowLevelAPI{}
|
||||
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
|
||||
assert.Equal(t, baseCtx, ctx)
|
||||
assert.Equal(t, baseServiceEmail, serviceAccountEmail)
|
||||
assert.Equal(t, basePrivateKey, privateKey)
|
||||
assert.Equal(t, baseUserEmail, userToImpersonateEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Happy path test
|
||||
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
|
||||
err := cal.Configure(baseUserEmail)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Configure error test
|
||||
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
|
||||
return assert.AnError
|
||||
}
|
||||
err = cal.Configure(baseUserEmail)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) {
|
||||
// Do not run this test in t.Parallel(), since it involves modifying a global variable
|
||||
plusAddressing = true
|
||||
t.Cleanup(
|
||||
func() {
|
||||
plusAddressing = false
|
||||
},
|
||||
)
|
||||
email := "user+my_test+email@example.com"
|
||||
mockAPI := &MockGoogleCalendarLowLevelAPI{}
|
||||
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
|
||||
assert.Equal(t, baseCtx, ctx)
|
||||
assert.Equal(t, baseServiceEmail, serviceAccountEmail)
|
||||
assert.Equal(t, basePrivateKey, privateKey)
|
||||
assert.Equal(t, "user@example.com", userToImpersonateEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
|
||||
err := cal.Configure(email)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig {
|
||||
if mockAPI != nil && mockAPI.ConfigureFunc == nil {
|
||||
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
config := &GoogleCalendarConfig{
|
||||
Context: context.Background(),
|
||||
IntegrationConfig: &fleet.GoogleCalendarIntegration{
|
||||
ApiKey: map[string]string{
|
||||
fleet.GoogleCalendarEmail: baseServiceEmail,
|
||||
fleet.GoogleCalendarPrivateKey: basePrivateKey,
|
||||
},
|
||||
},
|
||||
Logger: logger,
|
||||
API: mockAPI,
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func TestGoogleCalendar_DeleteEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockAPI := &MockGoogleCalendarLowLevelAPI{}
|
||||
mockAPI.DeleteEventFunc = func(id string) error {
|
||||
assert.Equal(t, "event-id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Happy path test
|
||||
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
|
||||
err := cal.Configure(baseUserEmail)
|
||||
assert.NoError(t, err)
|
||||
err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// API error test
|
||||
mockAPI.DeleteEventFunc = func(id string) error {
|
||||
return assert.AnError
|
||||
}
|
||||
err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)})
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
// Event already deleted
|
||||
mockAPI.DeleteEventFunc = func(id string) error {
|
||||
return &googleapi.Error{Code: http.StatusGone}
|
||||
}
|
||||
err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGoogleCalendar_unmarshalDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gCal = NewGoogleCalendar(makeConfig(&MockGoogleCalendarLowLevelAPI{}))
|
||||
err := gCal.Configure(baseUserEmail)
|
||||
assert.NoError(t, err)
|
||||
details, err := gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id","etag":"event-eTag"}`)})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "event-id", details.ID)
|
||||
assert.Equal(t, "event-eTag", details.ETag)
|
||||
|
||||
// Missing ETag is OK
|
||||
details, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id"}`)})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "event-id", details.ID)
|
||||
assert.Equal(t, "", details.ETag)
|
||||
|
||||
// Bad JSON
|
||||
_, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"bozo`)})
|
||||
assert.Error(t, err)
|
||||
|
||||
// Missing id
|
||||
_, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"myId":"event-id","etag":"event-eTag"}`)})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockAPI := &MockGoogleCalendarLowLevelAPI{}
|
||||
const baseETag = "event-eTag"
|
||||
const baseEventID = "event-id"
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
assert.Equal(t, baseEventID, id)
|
||||
assert.Equal(t, baseETag, eTag)
|
||||
return &calendar.Event{
|
||||
Etag: baseETag, // ETag matches -- no modifications to event
|
||||
}, nil
|
||||
}
|
||||
genBodyFn := func(bool) string {
|
||||
t.Error("genBodyFn should not be called")
|
||||
return "event-body"
|
||||
}
|
||||
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
|
||||
err := cal.Configure(baseUserEmail)
|
||||
assert.NoError(t, err)
|
||||
|
||||
eventStartTime := time.Now().UTC()
|
||||
event := &fleet.CalendarEvent{
|
||||
StartTime: eventStartTime,
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
Data: []byte(`{"ID":"` + baseEventID + `","ETag":"` + baseETag + `"}`),
|
||||
}
|
||||
|
||||
// ETag matches
|
||||
retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, updated)
|
||||
assert.Equal(t, event, retrievedEvent)
|
||||
|
||||
// http.StatusNotModified response (ETag matches)
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return nil, &googleapi.Error{Code: http.StatusNotModified}
|
||||
}
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, updated)
|
||||
assert.Equal(t, event, retrievedEvent)
|
||||
|
||||
// Cannot unmarshal details
|
||||
eventBadDetails := &fleet.CalendarEvent{
|
||||
StartTime: time.Now(),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
Data: []byte(`{"bozo`),
|
||||
}
|
||||
_, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn)
|
||||
assert.Error(t, err)
|
||||
|
||||
// API error test
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
// Event has been modified
|
||||
startTime := time.Now().Add(time.Minute).Truncate(time.Second)
|
||||
endTime := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
|
||||
}, nil
|
||||
}
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
assert.NotEqual(t, event, retrievedEvent)
|
||||
require.NotNil(t, retrievedEvent)
|
||||
assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC())
|
||||
assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC())
|
||||
assert.Equal(t, baseUserEmail, retrievedEvent.Email)
|
||||
gCal, _ := cal.(*GoogleCalendar)
|
||||
details, err := gCal.unmarshalDetails(retrievedEvent)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-eTag", details.ETag)
|
||||
assert.Equal(t, baseEventID, details.ID)
|
||||
|
||||
// missing end time
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: ""},
|
||||
}, nil
|
||||
}
|
||||
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.Error(t, err)
|
||||
|
||||
// missing start time
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
|
||||
}, nil
|
||||
}
|
||||
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Bad time format
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: "bozo"},
|
||||
}, nil
|
||||
}
|
||||
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Event has been modified, with custom timezone.
|
||||
tzId := "Africa/Kinshasa"
|
||||
location, _ := time.LoadLocation(tzId)
|
||||
startTime = time.Now().Add(time.Minute).Truncate(time.Second).In(location)
|
||||
endTime = time.Now().Add(time.Hour).Truncate(time.Second).In(location)
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{DateTime: startTime.UTC().Format(time.RFC3339), TimeZone: tzId},
|
||||
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339), TimeZone: tzId},
|
||||
}, nil
|
||||
}
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
assert.NotEqual(t, event, retrievedEvent)
|
||||
require.NotNil(t, retrievedEvent)
|
||||
assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC())
|
||||
assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC())
|
||||
assert.Equal(t, baseUserEmail, retrievedEvent.Email)
|
||||
|
||||
// 404 response (deleted)
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return nil, &googleapi.Error{Code: http.StatusNotFound}
|
||||
}
|
||||
mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) {
|
||||
return &calendar.Setting{Value: "UTC"}, nil
|
||||
}
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return &calendar.Events{}, nil
|
||||
}
|
||||
genBodyFn = func(conflict bool) string {
|
||||
assert.False(t, conflict)
|
||||
return "event-body"
|
||||
}
|
||||
eventCreated := false
|
||||
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
|
||||
assert.Equal(t, eventTitle, event.Summary)
|
||||
assert.Equal(t, genBodyFn(false), event.Description)
|
||||
event.Id = baseEventID
|
||||
event.Etag = baseETag
|
||||
eventCreated = true
|
||||
return event, nil
|
||||
}
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
assert.NotEqual(t, event, retrievedEvent)
|
||||
require.NotNil(t, retrievedEvent)
|
||||
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)
|
||||
|
||||
// cancelled (deleted)
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
|
||||
Status: "cancelled",
|
||||
}, nil
|
||||
}
|
||||
eventCreated = false
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
require.NotNil(t, retrievedEvent)
|
||||
assert.NotEqual(t, event, retrievedEvent)
|
||||
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
|
||||
assert.True(t, eventCreated)
|
||||
|
||||
// all day event (deleted)
|
||||
mockAPI.DeleteEventFunc = func(id string) error {
|
||||
assert.Equal(t, baseEventID, id)
|
||||
return nil
|
||||
}
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{Date: startTime.Format("2006-01-02")},
|
||||
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
|
||||
}, nil
|
||||
}
|
||||
eventCreated = false
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
require.NotNil(t, retrievedEvent)
|
||||
assert.NotEqual(t, event, retrievedEvent)
|
||||
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
|
||||
assert.True(t, eventCreated)
|
||||
|
||||
// moved in the past event (deleted)
|
||||
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
|
||||
return &calendar.Event{
|
||||
Id: baseEventID,
|
||||
Etag: "new-eTag",
|
||||
Start: &calendar.EventDateTime{DateTime: startTime.Add(-2 * time.Hour).Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: endTime.Add(-2 * time.Hour).Format(time.RFC3339)},
|
||||
}, nil
|
||||
}
|
||||
eventCreated = false
|
||||
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, updated)
|
||||
require.NotNil(t, retrievedEvent)
|
||||
assert.NotEqual(t, event, retrievedEvent)
|
||||
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
|
||||
assert.True(t, eventCreated)
|
||||
}
|
||||
|
||||
func TestGoogleCalendar_CreateEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockAPI := &MockGoogleCalendarLowLevelAPI{}
|
||||
const baseEventID = "event-id"
|
||||
const baseETag = "event-eTag"
|
||||
const eventBody = "event-body"
|
||||
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
|
||||
err := cal.Configure(baseUserEmail)
|
||||
assert.NoError(t, err)
|
||||
|
||||
tzId := "Africa/Kinshasa"
|
||||
mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) {
|
||||
return &calendar.Setting{Value: tzId}, nil
|
||||
}
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return &calendar.Events{}, nil
|
||||
}
|
||||
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
|
||||
assert.Equal(t, eventTitle, event.Summary)
|
||||
assert.Equal(t, eventBody, event.Description)
|
||||
event.Id = baseEventID
|
||||
event.Etag = baseETag
|
||||
return event, nil
|
||||
}
|
||||
genBodyFn := func(conflict bool) string {
|
||||
assert.False(t, conflict)
|
||||
return eventBody
|
||||
}
|
||||
genBodyConflictFn := func(conflict bool) string {
|
||||
assert.True(t, conflict)
|
||||
return eventBody
|
||||
}
|
||||
|
||||
// Happy path test -- empty calendar
|
||||
date := time.Now().Add(48 * time.Hour)
|
||||
location, _ := time.LoadLocation(tzId)
|
||||
expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
|
||||
_, expectedOffset := expectedStartTime.Zone()
|
||||
event, err := cal.CreateEvent(date, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, baseUserEmail, event.Email)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
_, offset := event.StartTime.Zone()
|
||||
assert.Equal(t, expectedOffset, offset)
|
||||
_, offset = event.EndTime.Zone()
|
||||
assert.Equal(t, expectedOffset, offset)
|
||||
gCal, _ := cal.(*GoogleCalendar)
|
||||
details, err := gCal.unmarshalDetails(event)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, baseETag, details.ETag)
|
||||
assert.Equal(t, baseEventID, details.ID)
|
||||
|
||||
// Workday already ended
|
||||
date = time.Now().Add(-48 * time.Hour)
|
||||
_, err = cal.CreateEvent(date, genBodyFn)
|
||||
assert.ErrorAs(t, err, &fleet.DayEndedError{})
|
||||
|
||||
// There is no time left in the day to schedule an event
|
||||
date = time.Now().Add(48 * time.Hour)
|
||||
timeNow := func() time.Time {
|
||||
now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location)
|
||||
return now
|
||||
}
|
||||
_, err = gCal.createEvent(date, genBodyFn, timeNow)
|
||||
assert.ErrorAs(t, err, &fleet.DayEndedError{})
|
||||
|
||||
// Workday already started
|
||||
date = time.Now().Add(48 * time.Hour)
|
||||
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
|
||||
timeNow = func() time.Time {
|
||||
return expectedStartTime
|
||||
}
|
||||
event, err = gCal.createEvent(date, genBodyFn, timeNow)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
||||
// Busy calendar
|
||||
date = time.Now().Add(48 * time.Hour)
|
||||
dayStart := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
|
||||
dayEnd := time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location)
|
||||
gEvents := &calendar.Events{}
|
||||
// Cancelled event
|
||||
gEvent := &calendar.Event{
|
||||
Id: "cancelled-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
|
||||
Status: "cancelled",
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
// All day events
|
||||
gEvent = &calendar.Event{
|
||||
Id: "all-day-event-id",
|
||||
Start: &calendar.EventDateTime{Date: dayStart.Format(time.DateOnly)},
|
||||
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
gEvent = &calendar.Event{
|
||||
Id: "all-day2-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{Date: dayEnd.Format(time.DateOnly)},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
// User-declined event
|
||||
gEvent = &calendar.Event{
|
||||
Id: "user-declined-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
|
||||
Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "declined"}},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
// Event before day
|
||||
gEvent = &calendar.Event{
|
||||
Id: "before-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Add(-time.Hour).Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: dayStart.Add(-30 * time.Minute).Format(time.RFC3339)},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
|
||||
// Event from 6am to 11am
|
||||
eventStart := time.Date(date.Year(), date.Month(), date.Day(), 6, 0, 0, 0, location)
|
||||
eventEnd := time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location)
|
||||
gEvent = &calendar.Event{
|
||||
Id: "6-to-11-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
|
||||
Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "accepted"}},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
|
||||
// Event from 10am to 10:30am
|
||||
eventStart = time.Date(date.Year(), date.Month(), date.Day(), 10, 0, 0, 0, location)
|
||||
eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 10, 30, 0, 0, location)
|
||||
gEvent = &calendar.Event{
|
||||
Id: "10-to-10-30-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
|
||||
Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
// Event from 11am to 11:45am
|
||||
eventStart = time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location)
|
||||
eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 11, 45, 0, 0, location)
|
||||
gEvent = &calendar.Event{
|
||||
Id: "11-to-11-45-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
|
||||
Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
|
||||
// Event after day
|
||||
eventStart = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location)
|
||||
eventEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 45, 0, 0, location)
|
||||
gEvent = &calendar.Event{
|
||||
Id: "after-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
|
||||
Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return gEvents, nil
|
||||
}
|
||||
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location)
|
||||
event, err = gCal.CreateEvent(date, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
||||
// Full schedule -- pick the last slot
|
||||
date = time.Now().Add(48 * time.Hour)
|
||||
dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
|
||||
dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location)
|
||||
gEvents = &calendar.Events{}
|
||||
gEvent = &calendar.Event{
|
||||
Id: "9-to-5-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return gEvents, nil
|
||||
}
|
||||
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
|
||||
event, err = gCal.CreateEvent(date, genBodyConflictFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
||||
// Almost full schedule -- pick the last slot
|
||||
date = time.Now().Add(48 * time.Hour)
|
||||
dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
|
||||
dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
|
||||
gEvents = &calendar.Events{}
|
||||
gEvent = &calendar.Event{
|
||||
Id: "9-to-4-30-event-id",
|
||||
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
|
||||
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
|
||||
}
|
||||
gEvents.Items = append(gEvents.Items, gEvent)
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return gEvents, nil
|
||||
}
|
||||
expectedStartTime = dayEnd
|
||||
event, err = gCal.CreateEvent(date, genBodyFn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
|
||||
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
|
||||
|
||||
// API error in ListEvents
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err = gCal.CreateEvent(date, genBodyFn)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
|
||||
// API error in CreateEvent
|
||||
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
|
||||
return &calendar.Events{}, nil
|
||||
}
|
||||
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
_, err = gCal.CreateEvent(date, genBodyFn)
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
343
ee/server/calendar/load_test/calendar_http_handler.go
Normal file
343
ee/server/calendar/load_test/calendar_http_handler.go
Normal file
@ -0,0 +1,343 @@
|
||||
// Package calendartest is not imported in production code, so it will not be compiled for Fleet server.
|
||||
package calendartest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5" //nolint:gosec // (only used in testing)
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This calendar does not support all-day events.
|
||||
|
||||
var db *sql.DB
|
||||
var timezones = []string{
|
||||
"America/Chicago",
|
||||
"America/New_York",
|
||||
"America/Los_Angeles",
|
||||
"America/Anchorage",
|
||||
"Pacific/Honolulu",
|
||||
"America/Argentina/Buenos_Aires",
|
||||
"Asia/Kolkata",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Australia/Sydney",
|
||||
}
|
||||
|
||||
func Configure(dbPath string) (http.Handler, error) {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
logger := log.New(os.Stdout, "", log.LstdFlags)
|
||||
logger.Println("Server is starting...")
|
||||
|
||||
// Initialize the database schema if needed
|
||||
err = initializeSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("/settings", getSetting)
|
||||
router.HandleFunc("/events", getEvent)
|
||||
router.HandleFunc("/events/list", getEvents)
|
||||
router.HandleFunc("/events/add", addEvent)
|
||||
router.HandleFunc("/events/delete", deleteEvent)
|
||||
return logging(logger)(router), nil
|
||||
}
|
||||
|
||||
func logging(logger *log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
logger.Println(r.Method, r.URL.String(), r.RemoteAddr)
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func Close() {
|
||||
_ = db.Close()
|
||||
}
|
||||
|
||||
func getSetting(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
http.Error(w, "missing name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if name != "timezone" {
|
||||
http.Error(w, "unsupported setting", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
email := r.URL.Query().Get("email")
|
||||
if email == "" {
|
||||
http.Error(w, "missing email", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
timezone := getTimezone(email)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
setting := calendar.Setting{Value: timezone}
|
||||
err := json.NewEncoder(w).Encode(setting)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The timezone is determined by the user's email address
|
||||
func getTimezone(email string) string {
|
||||
index := hash(email) % uint32(len(timezones))
|
||||
timezone := timezones[index]
|
||||
return timezone
|
||||
}
|
||||
|
||||
func hash(s string) uint32 {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(s))
|
||||
return h.Sum32()
|
||||
}
|
||||
|
||||
// getEvent handles GET /events?id=123
|
||||
func getEvent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "missing id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sqlStmt := "SELECT email, start, end, summary, description, status FROM events WHERE id = ?"
|
||||
var start, end int64
|
||||
var email, summary, description, status string
|
||||
err := db.QueryRow(sqlStmt, id).Scan(&email, &start, &end, &summary, &description, &status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
timezone := getTimezone(email)
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
calEvent := calendar.Event{}
|
||||
calEvent.Id = id
|
||||
calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)}
|
||||
calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)}
|
||||
calEvent.Summary = summary
|
||||
calEvent.Description = description
|
||||
calEvent.Status = status
|
||||
calEvent.Etag = computeETag(start, end, summary, description, status)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
err = json.NewEncoder(w).Encode(calEvent)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func getEvents(w http.ResponseWriter, r *http.Request) {
|
||||
email := r.URL.Query().Get("email")
|
||||
if email == "" {
|
||||
http.Error(w, "missing email", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
timeMin := r.URL.Query().Get("timemin")
|
||||
if email == "" {
|
||||
http.Error(w, "missing timemin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
timeMax := r.URL.Query().Get("timemax")
|
||||
if email == "" {
|
||||
http.Error(w, "missing timemax", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
minTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMin})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
maxTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMax})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sqlStmt := "SELECT id, start, end, summary, description, status FROM events WHERE email = ? AND end > ? AND start < ?"
|
||||
rows, err := db.Query(sqlStmt, email, minTime.Unix(), maxTime.Unix())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
timezone := getTimezone(email)
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
events := calendar.Events{}
|
||||
events.Items = make([]*calendar.Event, 0)
|
||||
for rows.Next() {
|
||||
var id, start, end int64
|
||||
var summary, description, status string
|
||||
err = rows.Scan(&id, &start, &end, &summary, &description, &status)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
calEvent := calendar.Event{}
|
||||
calEvent.Id = fmt.Sprintf("%d", id)
|
||||
calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)}
|
||||
calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)}
|
||||
calEvent.Summary = summary
|
||||
calEvent.Description = description
|
||||
calEvent.Status = status
|
||||
calEvent.Etag = computeETag(start, end, summary, description, status)
|
||||
events.Items = append(events.Items, &calEvent)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
err = json.NewEncoder(w).Encode(events)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// addEvent handles POST /events/add?email=user@example.com
|
||||
func addEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var event calendar.Event
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(body, &event)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := r.URL.Query().Get("email")
|
||||
if email == "" {
|
||||
http.Error(w, "missing email", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
start, err := parseDateTime(r.Context(), event.Start)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
end, err := parseDateTime(r.Context(), event.End)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status := "confirmed"
|
||||
sqlStmt := `INSERT INTO events (email, start, end, summary, description, status) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
result, err := db.Exec(sqlStmt, email, start.Unix(), end.Unix(), event.Summary, event.Description, status)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
event.Id = fmt.Sprintf("%d", id)
|
||||
event.Etag = computeETag(start.Unix(), end.Unix(), event.Summary, event.Description, status)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(w).Encode(event)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func computeETag(args ...any) string {
|
||||
h := md5.New() //nolint:gosec // (only used for tests)
|
||||
_, _ = fmt.Fprint(h, args...)
|
||||
checksum := h.Sum(nil)
|
||||
return hex.EncodeToString(checksum)
|
||||
}
|
||||
|
||||
// deleteEvent handles DELETE /events/delete?id=123
|
||||
func deleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "missing id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sqlStmt := "DELETE FROM events WHERE id = ?"
|
||||
_, err := db.Exec(sqlStmt, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "not found", http.StatusGone)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func initializeSchema() error {
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS events (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"start" INTEGER NOT NULL,
|
||||
"end" INTEGER NOT NULL,
|
||||
"summary" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL
|
||||
);`
|
||||
_, err := db.Exec(createTableSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDateTime(ctx context.Context, eventDateTime *calendar.EventDateTime) (*time.Time, error) {
|
||||
var t time.Time
|
||||
var err error
|
||||
if eventDateTime.TimeZone != "" {
|
||||
var loc *time.Location
|
||||
loc, err = time.LoadLocation(eventDateTime.TimeZone)
|
||||
if err == nil {
|
||||
t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc)
|
||||
}
|
||||
} else {
|
||||
t, err = time.Parse(time.RFC3339, eventDateTime.DateTime)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(
|
||||
ctx, err, fmt.Sprintf("parsing calendar event time: %s", eventDateTime.DateTime),
|
||||
)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
@ -196,18 +197,29 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T
|
||||
}
|
||||
|
||||
if payload.Integrations != nil {
|
||||
// the team integrations must reference an existing global config integration.
|
||||
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
if payload.Integrations.Jira != nil || payload.Integrations.Zendesk != nil {
|
||||
// the team integrations must reference an existing global config integration.
|
||||
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
|
||||
// integrations must be unique
|
||||
if err := payload.Integrations.Validate(); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
// integrations must be unique
|
||||
if err := payload.Integrations.Validate(); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
|
||||
team.Config.Integrations.Jira = payload.Integrations.Jira
|
||||
team.Config.Integrations.Zendesk = payload.Integrations.Zendesk
|
||||
team.Config.Integrations.Jira = payload.Integrations.Jira
|
||||
team.Config.Integrations.Zendesk = payload.Integrations.Zendesk
|
||||
}
|
||||
// Only update the calendar integration if it's not nil
|
||||
if payload.Integrations.GoogleCalendar != nil {
|
||||
invalid := &fleet.InvalidArgumentError{}
|
||||
_ = svc.validateTeamCalendarIntegrations(payload.Integrations.GoogleCalendar, appCfg, invalid)
|
||||
if invalid.HasErrors() {
|
||||
return nil, ctxerr.Wrap(ctx, invalid)
|
||||
}
|
||||
team.Config.Integrations.GoogleCalendar = payload.Integrations.GoogleCalendar
|
||||
}
|
||||
}
|
||||
|
||||
if payload.WebhookSettings != nil || payload.Integrations != nil {
|
||||
@ -1081,6 +1093,15 @@ func (svc *Service) editTeamFromSpec(
|
||||
fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid)
|
||||
team.Config.WebhookSettings.HostStatusWebhook = spec.WebhookSettings.HostStatusWebhook
|
||||
}
|
||||
|
||||
if spec.Integrations.GoogleCalendar != nil {
|
||||
err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, invalid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validate team calendar integrations")
|
||||
}
|
||||
team.Config.Integrations.GoogleCalendar = spec.Integrations.GoogleCalendar
|
||||
}
|
||||
|
||||
if invalid.HasErrors() {
|
||||
return ctxerr.Wrap(ctx, invalid)
|
||||
}
|
||||
@ -1137,7 +1158,9 @@ func (svc *Service) editTeamFromSpec(
|
||||
}
|
||||
|
||||
if didUpdateMacOSEndUserAuth {
|
||||
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil {
|
||||
if err := svc.updateMacOSSetupEnableEndUserAuth(
|
||||
ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -1145,6 +1168,26 @@ func (svc *Service) editTeamFromSpec(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) validateTeamCalendarIntegrations(
|
||||
calendarIntegration *fleet.TeamGoogleCalendarIntegration,
|
||||
appCfg *fleet.AppConfig, invalid *fleet.InvalidArgumentError,
|
||||
) error {
|
||||
if !calendarIntegration.Enable {
|
||||
return nil
|
||||
}
|
||||
// Check that global configs exist
|
||||
if len(appCfg.Integrations.GoogleCalendar) == 0 {
|
||||
invalid.Append("integrations.google_calendar.enable_calendar_events", "global Google Calendar integration is not configured")
|
||||
}
|
||||
// Validate URL
|
||||
if u, err := url.ParseRequestURI(calendarIntegration.WebhookURL); err != nil {
|
||||
invalid.Append("integrations.google_calendar.webhook_url", err.Error())
|
||||
} else if u.Scheme != "https" && u.Scheme != "http" {
|
||||
invalid.Append("integrations.google_calendar.webhook_url", "webhook_url must be https or http")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.TeamSpec, applyUpon *fleet.MacOSSettings) error {
|
||||
oldCustomSettings := applyUpon.CustomSettings
|
||||
setFields, err := applyUpon.FromMap(spec.MDM.MacOSSettings)
|
||||
|
@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
|
||||
integrations: {
|
||||
jira: [],
|
||||
zendesk: [],
|
||||
google_calendar: [],
|
||||
},
|
||||
logging: {
|
||||
debug: false,
|
||||
|
@ -22,6 +22,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = {
|
||||
webhook: "Off",
|
||||
has_run: true,
|
||||
next_update_ms: 3600000,
|
||||
calendar_events_enabled: true,
|
||||
};
|
||||
|
||||
const createMockPolicy = (overrides?: Partial<IPolicyStats>): IPolicyStats => {
|
||||
|
@ -51,12 +51,7 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
font-weight: $regular;
|
||||
vertical-align: top;
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
@ -122,9 +117,6 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
}
|
||||
|
@ -40,10 +40,7 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
&__action-overlay {
|
||||
|
@ -3,6 +3,7 @@ import classnames from "classnames";
|
||||
import { isEmpty } from "lodash";
|
||||
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import { PlacesType } from "react-tooltip-5";
|
||||
|
||||
// all form-field styles are defined in _global.scss, which apply here and elsewhere
|
||||
const baseClass = "form-field";
|
||||
@ -16,6 +17,7 @@ export interface IFormFieldProps {
|
||||
name: string;
|
||||
type: string;
|
||||
tooltip?: React.ReactNode;
|
||||
labelTooltipPosition?: PlacesType;
|
||||
}
|
||||
|
||||
const FormField = ({
|
||||
@ -27,6 +29,7 @@ const FormField = ({
|
||||
name,
|
||||
type,
|
||||
tooltip,
|
||||
labelTooltipPosition,
|
||||
}: IFormFieldProps): JSX.Element => {
|
||||
const renderLabel = () => {
|
||||
const labelWrapperClasses = classnames(`${baseClass}__label`, {
|
||||
@ -45,7 +48,10 @@ const FormField = ({
|
||||
>
|
||||
{error ||
|
||||
(tooltip ? (
|
||||
<TooltipWrapper tipContent={tooltip}>
|
||||
<TooltipWrapper
|
||||
tipContent={tooltip}
|
||||
position={labelTooltipPosition || "top-start"}
|
||||
>
|
||||
{label as string}
|
||||
</TooltipWrapper>
|
||||
) : (
|
||||
|
@ -33,6 +33,7 @@ class InputField extends Component {
|
||||
]).isRequired,
|
||||
parseTarget: PropTypes.bool,
|
||||
tooltip: PropTypes.string,
|
||||
labelTooltipPosition: PropTypes.string,
|
||||
helpText: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
@ -55,6 +56,7 @@ class InputField extends Component {
|
||||
value: "",
|
||||
parseTarget: false,
|
||||
tooltip: "",
|
||||
labelTooltipPosition: "",
|
||||
helpText: "",
|
||||
enableCopy: false,
|
||||
ignore1password: false,
|
||||
@ -124,6 +126,7 @@ class InputField extends Component {
|
||||
"error",
|
||||
"name",
|
||||
"tooltip",
|
||||
"labelTooltipPosition",
|
||||
]);
|
||||
|
||||
const copyValue = (e) => {
|
||||
|
@ -43,9 +43,6 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,10 @@ import FormField from "components/forms/FormField";
|
||||
import { IFormFieldProps } from "components/forms/FormField/FormField";
|
||||
|
||||
interface ISliderProps {
|
||||
onChange: () => void;
|
||||
onChange: (newValue?: {
|
||||
name: string;
|
||||
value: string | number | boolean;
|
||||
}) => void;
|
||||
value: boolean;
|
||||
inactiveText: string;
|
||||
activeText: string;
|
||||
|
1184
frontend/components/graphics/CalendarEventPreview.tsx
Normal file
1184
frontend/components/graphics/CalendarEventPreview.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@ import EmptyTeams from "./EmptyTeams";
|
||||
import EmptyPacks from "./EmptyPacks";
|
||||
import EmptySchedule from "./EmptySchedule";
|
||||
import CollectingResults from "./CollectingResults";
|
||||
import CalendarEventPreview from "./CalendarEventPreview";
|
||||
|
||||
export const GRAPHIC_MAP = {
|
||||
// Empty state graphics
|
||||
@ -41,6 +42,7 @@ export const GRAPHIC_MAP = {
|
||||
"file-pem": FilePem,
|
||||
// Other graphics
|
||||
"collecting-results": CollectingResults,
|
||||
"calendar-event-preview": CalendarEventPreview,
|
||||
};
|
||||
|
||||
export type GraphicNames = keyof typeof GRAPHIC_MAP;
|
||||
|
36
frontend/hooks/useCheckboxListStateManagement.tsx
Normal file
36
frontend/hooks/useCheckboxListStateManagement.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { IPolicy } from "interfaces/policy";
|
||||
|
||||
interface ICheckedPolicy {
|
||||
name?: string;
|
||||
id: number;
|
||||
isChecked: boolean;
|
||||
}
|
||||
|
||||
const useCheckboxListStateManagement = (
|
||||
allPolicies: IPolicy[],
|
||||
automatedPolicies: number[] | undefined
|
||||
) => {
|
||||
const [policyItems, setPolicyItems] = useState<ICheckedPolicy[]>(() => {
|
||||
return allPolicies.map(({ name, id }) => ({
|
||||
name,
|
||||
id,
|
||||
isChecked: !!automatedPolicies?.includes(id),
|
||||
}));
|
||||
});
|
||||
|
||||
const updatePolicyItems = (policyId: number) => {
|
||||
setPolicyItems((prevItems) =>
|
||||
prevItems.map((policy) =>
|
||||
policy.id !== policyId
|
||||
? policy
|
||||
: { ...policy, isChecked: !policy.isChecked }
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return { policyItems, updatePolicyItems };
|
||||
};
|
||||
|
||||
export default useCheckboxListStateManagement;
|
@ -4,7 +4,7 @@ import {
|
||||
IWebhookFailingPolicies,
|
||||
IWebhookSoftwareVulnerabilities,
|
||||
} from "interfaces/webhook";
|
||||
import { IIntegrations } from "./integration";
|
||||
import { IGlobalIntegrations } from "./integration";
|
||||
|
||||
export interface ILicense {
|
||||
tier: string;
|
||||
@ -123,7 +123,7 @@ export interface IConfig {
|
||||
};
|
||||
sandbox_enabled: boolean;
|
||||
server_settings: IConfigServerSettings;
|
||||
smtp_settings: {
|
||||
smtp_settings?: {
|
||||
enable_smtp: boolean;
|
||||
configured: boolean;
|
||||
sender_address: string;
|
||||
@ -176,7 +176,7 @@ export interface IConfig {
|
||||
// databases_path: string;
|
||||
// };
|
||||
webhook_settings: IWebhookSettings;
|
||||
integrations: IIntegrations;
|
||||
integrations: IGlobalIntegrations;
|
||||
logging: {
|
||||
debug: boolean;
|
||||
json: boolean;
|
||||
|
@ -60,7 +60,32 @@ export interface IIntegrationFormErrors {
|
||||
enableSoftwareVulnerabilities?: boolean;
|
||||
}
|
||||
|
||||
export interface IIntegrations {
|
||||
export interface IGlobalCalendarIntegration {
|
||||
domain: string;
|
||||
api_key_json: string;
|
||||
}
|
||||
|
||||
interface ITeamCalendarSettings {
|
||||
enable_calendar_events: boolean;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
// zendesk and jira fields are coupled – if one is present, the other needs to be present. If
|
||||
// one is present and the other is null/missing, the other will be nullified. google_calendar is
|
||||
// separated – it can be present without the other 2 without nullifying them.
|
||||
// TODO: Update these types to reflect this.
|
||||
|
||||
export interface IZendeskJiraIntegrations {
|
||||
zendesk: IZendeskIntegration[];
|
||||
jira: IJiraIntegration[];
|
||||
}
|
||||
|
||||
// reality is that IZendeskJiraIntegrations are optional – should be something like `extends
|
||||
// Partial<IZendeskJiraIntegrations>`, but that leads to a mess of types to resolve.
|
||||
export interface IGlobalIntegrations extends IZendeskJiraIntegrations {
|
||||
google_calendar?: IGlobalCalendarIntegration[] | null;
|
||||
}
|
||||
|
||||
export interface ITeamIntegrations extends IZendeskJiraIntegrations {
|
||||
google_calendar?: ITeamCalendarSettings | null;
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ export interface IPolicy {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
critical: boolean;
|
||||
calendar_events_enabled: boolean;
|
||||
}
|
||||
|
||||
// Used on the manage hosts page and other places where aggregate stats are displayed
|
||||
@ -90,6 +91,7 @@ export interface IPolicyFormData {
|
||||
query?: string | number | boolean | undefined;
|
||||
team_id?: number;
|
||||
id?: number;
|
||||
calendar_events_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IPolicyNew {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { IConfigFeatures, IWebhookSettings } from "./config";
|
||||
import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret";
|
||||
import { IIntegrations } from "./integration";
|
||||
import { ITeamIntegrations } from "./integration";
|
||||
import { UserRole } from "./user";
|
||||
|
||||
export default PropTypes.shape({
|
||||
@ -83,7 +83,7 @@ export type ITeamWebhookSettings = Pick<
|
||||
*/
|
||||
export interface ITeamAutomationsConfig {
|
||||
webhook_settings: ITeamWebhookSettings;
|
||||
integrations: IIntegrations;
|
||||
integrations: ITeamIntegrations;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,12 +31,7 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
font-weight: $regular;
|
||||
vertical-align: top;
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
&__secret-download-icon {
|
||||
|
@ -91,7 +91,11 @@ const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => {
|
||||
await usersAPI.update(currentUser.id, updated);
|
||||
let accountUpdatedFlashMessage = "Account updated";
|
||||
if (updated.email) {
|
||||
accountUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${updated.email}`;
|
||||
// always present, this for typing
|
||||
const senderAddressMessage = config?.smtp_settings?.sender_address
|
||||
? ` from ${config?.smtp_settings?.sender_address}`
|
||||
: "";
|
||||
accountUpdatedFlashMessage += `: A confirmation email was sent${senderAddressMessage} to ${updated.email}`;
|
||||
setPendingEmail(updated.email);
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
IJiraIntegration,
|
||||
IZendeskIntegration,
|
||||
IIntegrations,
|
||||
IZendeskJiraIntegrations,
|
||||
} from "interfaces/integration";
|
||||
import { ITeamConfig } from "interfaces/team";
|
||||
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
|
||||
@ -186,7 +186,9 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
const vulnWebhookSettings =
|
||||
softwareConfig?.webhook_settings?.vulnerabilities_webhook;
|
||||
const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
|
||||
const isVulnIntegrationEnabled = (integrations?: IIntegrations) => {
|
||||
const isVulnIntegrationEnabled = (
|
||||
integrations?: IZendeskJiraIntegrations
|
||||
) => {
|
||||
return (
|
||||
!!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
|
||||
!!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
IJiraIntegration,
|
||||
IZendeskIntegration,
|
||||
IIntegration,
|
||||
IIntegrations,
|
||||
IGlobalIntegrations,
|
||||
IIntegrationType,
|
||||
} from "interfaces/integration";
|
||||
import {
|
||||
@ -124,7 +124,7 @@ const ManageAutomationsModal = ({
|
||||
}
|
||||
}, [destinationUrl]);
|
||||
|
||||
const { data: integrations } = useQuery<IConfig, Error, IIntegrations>(
|
||||
const { data: integrations } = useQuery<IConfig, Error, IGlobalIntegrations>(
|
||||
["integrations"],
|
||||
() => configAPI.loadAll(),
|
||||
{
|
||||
|
@ -4,32 +4,34 @@ import { ISideNavItem } from "../components/SideNav/SideNav";
|
||||
import Integrations from "./cards/Integrations";
|
||||
import Mdm from "./cards/MdmSettings/MdmSettings";
|
||||
import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment";
|
||||
import Calendars from "./cards/Calendars/Calendars";
|
||||
|
||||
const getFilteredIntegrationSettingsNavItems = (
|
||||
isSandboxMode = false
|
||||
): ISideNavItem<any>[] => {
|
||||
return [
|
||||
// TODO: types
|
||||
{
|
||||
title: "Ticket destinations",
|
||||
urlSection: "ticket-destinations",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS,
|
||||
Card: Integrations,
|
||||
},
|
||||
{
|
||||
title: "Mobile device management (MDM)",
|
||||
urlSection: "mdm",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_MDM,
|
||||
Card: Mdm,
|
||||
exclude: isSandboxMode,
|
||||
},
|
||||
{
|
||||
title: "Automatic enrollment",
|
||||
urlSection: "automatic-enrollment",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT,
|
||||
Card: AutomaticEnrollment,
|
||||
},
|
||||
].filter((navItem) => !navItem.exclude);
|
||||
};
|
||||
const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
||||
// TODO: types
|
||||
{
|
||||
title: "Ticket destinations",
|
||||
urlSection: "ticket-destinations",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS,
|
||||
Card: Integrations,
|
||||
},
|
||||
{
|
||||
title: "Mobile device management (MDM)",
|
||||
urlSection: "mdm",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_MDM,
|
||||
Card: Mdm,
|
||||
},
|
||||
{
|
||||
title: "Automatic enrollment",
|
||||
urlSection: "automatic-enrollment",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT,
|
||||
Card: AutomaticEnrollment,
|
||||
},
|
||||
{
|
||||
title: "Calendars",
|
||||
urlSection: "calendars",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
|
||||
Card: Calendars,
|
||||
},
|
||||
];
|
||||
|
||||
export default getFilteredIntegrationSettingsNavItems;
|
||||
export default integrationSettingsNavItems;
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { AppContext } from "context/app";
|
||||
import React, { useContext } from "react";
|
||||
import React from "react";
|
||||
import { InjectedRouter, Params } from "react-router/lib/Router";
|
||||
|
||||
import SideNav from "../components/SideNav";
|
||||
import getFilteredIntegrationSettingsNavItems from "./IntegrationNavItems";
|
||||
import integrationSettingsNavItems from "./IntegrationNavItems";
|
||||
|
||||
const baseClass = "integrations";
|
||||
|
||||
@ -16,9 +15,8 @@ const IntegrationsPage = ({
|
||||
router,
|
||||
params,
|
||||
}: IIntegrationSettingsPageProps) => {
|
||||
const { isSandboxMode } = useContext(AppContext);
|
||||
const { section } = params;
|
||||
const navItems = getFilteredIntegrationSettingsNavItems(isSandboxMode);
|
||||
const navItems = integrationSettingsNavItems;
|
||||
const DEFAULT_SETTINGS_SECTION = navItems[0];
|
||||
const currentSection =
|
||||
navItems.find((item) => item.urlSection === section) ??
|
||||
|
@ -0,0 +1,428 @@
|
||||
import React, { useState, useContext, useCallback } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { IConfig } from "interfaces/config";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { AppContext } from "context/app";
|
||||
import configAPI from "services/entities/config";
|
||||
// @ts-ignore
|
||||
import { stringToClipboard } from "utilities/copy_text";
|
||||
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Button from "components/buttons/Button";
|
||||
import SectionHeader from "components/SectionHeader";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Spinner from "components/Spinner";
|
||||
import DataError from "components/DataError";
|
||||
import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage";
|
||||
import Icon from "components/Icon";
|
||||
|
||||
const CREATING_SERVICE_ACCOUNT =
|
||||
"https://www.fleetdm.com/learn-more-about/creating-service-accounts";
|
||||
const GOOGLE_WORKSPACE_DOMAINS =
|
||||
"https://www.fleetdm.com/learn-more-about/google-workspace-domains";
|
||||
const DOMAIN_WIDE_DELEGATION =
|
||||
"https://www.fleetdm.com/learn-more-about/domain-wide-delegation";
|
||||
const ENABLING_CALENDAR_API =
|
||||
"fleetdm.com/learn-more-about/enabling-calendar-api";
|
||||
const OAUTH_SCOPES =
|
||||
"https://www.googleapis.com/auth/calendar.events,https://www.googleapis.com/auth/calendar.settings.readonly";
|
||||
|
||||
const API_KEY_JSON_PLACEHOLDER = `{
|
||||
"type": "service_account",
|
||||
"project_id": "fleet-in-your-calendar",
|
||||
"private_key_id": "<private key id>",
|
||||
"private_key": "-----BEGIN PRIVATE KEY----\\n<private key>\\n-----END PRIVATE KEY-----\\n",
|
||||
"client_email": "fleet-calendar-events@fleet-in-your-calendar.iam.gserviceaccount.com",
|
||||
"client_id": "<client id>",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/fleet-calendar-events%40fleet-in-your-calendar.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}`;
|
||||
|
||||
interface IFormField {
|
||||
name: string;
|
||||
value: string | boolean | number;
|
||||
}
|
||||
|
||||
interface ICalendarsFormErrors {
|
||||
domain?: string | null;
|
||||
apiKeyJson?: string | null;
|
||||
}
|
||||
|
||||
interface ICalendarsFormData {
|
||||
domain?: string;
|
||||
apiKeyJson?: string;
|
||||
}
|
||||
|
||||
// Used to surface error.message in UI of unknown error type
|
||||
type ErrorWithMessage = {
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => {
|
||||
return (error as ErrorWithMessage).message !== undefined;
|
||||
};
|
||||
|
||||
const baseClass = "calendars-integration";
|
||||
|
||||
const Calendars = (): JSX.Element => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
|
||||
const [formData, setFormData] = useState<ICalendarsFormData>({
|
||||
domain: "",
|
||||
apiKeyJson: "",
|
||||
});
|
||||
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
|
||||
const [formErrors, setFormErrors] = useState<ICalendarsFormErrors>({});
|
||||
const [copyMessage, setCopyMessage] = useState<string>("");
|
||||
|
||||
const {
|
||||
isLoading: isLoadingAppConfig,
|
||||
refetch: refetchConfig,
|
||||
error: errorAppConfig,
|
||||
} = useQuery<IConfig, Error, IConfig>(["config"], () => configAPI.loadAll(), {
|
||||
select: (data: IConfig) => data,
|
||||
onSuccess: (data) => {
|
||||
if (data.integrations.google_calendar) {
|
||||
setFormData({
|
||||
domain: data.integrations.google_calendar[0].domain,
|
||||
// Formats string for better UI readability
|
||||
apiKeyJson: JSON.stringify(
|
||||
data.integrations.google_calendar[0].api_key_json,
|
||||
null,
|
||||
"\t"
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { apiKeyJson, domain } = formData;
|
||||
|
||||
const validateForm = (curFormData: ICalendarsFormData) => {
|
||||
const errors: ICalendarsFormErrors = {};
|
||||
|
||||
// Must set all keys or no keys at all
|
||||
if (!curFormData.apiKeyJson && !!curFormData.domain) {
|
||||
errors.apiKeyJson = "API key JSON must be present";
|
||||
}
|
||||
if (!curFormData.domain && !!curFormData.apiKeyJson) {
|
||||
errors.domain = "Domain must be present";
|
||||
}
|
||||
if (curFormData.apiKeyJson) {
|
||||
try {
|
||||
JSON.parse(curFormData.apiKeyJson);
|
||||
} catch (e: unknown) {
|
||||
if (isErrorWithMessage(e)) {
|
||||
errors.apiKeyJson = e.message.toString();
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }: IFormField) => {
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
setFormData(newFormData);
|
||||
setFormErrors(validateForm(newFormData));
|
||||
},
|
||||
[formData]
|
||||
);
|
||||
|
||||
const onFormSubmit = async (evt: React.MouseEvent<HTMLFormElement>) => {
|
||||
setIsUpdatingSettings(true);
|
||||
|
||||
evt.preventDefault();
|
||||
|
||||
// Format for API
|
||||
const formDataToSubmit =
|
||||
formData.apiKeyJson === "" && formData.domain === ""
|
||||
? [] // Send empty array if no keys are set
|
||||
: [
|
||||
{
|
||||
domain: formData.domain,
|
||||
api_key_json:
|
||||
(formData.apiKeyJson && JSON.parse(formData.apiKeyJson)) ||
|
||||
null,
|
||||
},
|
||||
];
|
||||
|
||||
// Update integrations.google_calendar only
|
||||
const destination = {
|
||||
google_calendar: formDataToSubmit,
|
||||
};
|
||||
|
||||
try {
|
||||
await configAPI.update({ integrations: destination });
|
||||
renderFlash(
|
||||
"success",
|
||||
"Successfully saved calendar integration settings"
|
||||
);
|
||||
refetchConfig();
|
||||
} catch (e) {
|
||||
renderFlash("error", "Could not save calendar integration settings");
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderOauthLabel = () => {
|
||||
const onCopyOauthScopes = (evt: React.MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
|
||||
stringToClipboard(OAUTH_SCOPES)
|
||||
.then(() => setCopyMessage(() => "Copied!"))
|
||||
.catch(() => setCopyMessage(() => "Copy failed"));
|
||||
|
||||
// Clear message after 1 second
|
||||
setTimeout(() => setCopyMessage(() => ""), 1000);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${baseClass}__oauth-scopes-copy-icon-wrapper`}>
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className={`${baseClass}__oauth-scopes-copy-icon`}
|
||||
onClick={onCopyOauthScopes}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
</Button>
|
||||
{copyMessage && (
|
||||
<span className={`${baseClass}__copy-message`}>{copyMessage}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Calendars" />
|
||||
<p className={`${baseClass}__page-description`}>
|
||||
To create calendar events for end users with failing policies,
|
||||
you'll need to configure a dedicated Google Workspace service
|
||||
account.
|
||||
</p>
|
||||
<div className={`${baseClass}__section-instructions`}>
|
||||
<p>
|
||||
1. Go to the <b>Service Accounts</b> page in Google Cloud Platform.{" "}
|
||||
<CustomLink
|
||||
text="View page"
|
||||
url={CREATING_SERVICE_ACCOUNT}
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
2. Create a new project for your service account.
|
||||
<ul>
|
||||
<li>
|
||||
Click <b>Create project</b>.
|
||||
</li>
|
||||
<li>
|
||||
Enter "Fleet calendar events" as the project name.
|
||||
</li>
|
||||
<li>
|
||||
For "Organization" and "Location", select
|
||||
your calendar's organization.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
3. Create the service account.
|
||||
<ul>
|
||||
<li>
|
||||
Click <b>Create service account</b>.
|
||||
</li>
|
||||
<li>
|
||||
Set the service account name to "Fleet calendar
|
||||
events".
|
||||
</li>
|
||||
<li>
|
||||
Set the service account ID to "fleet-calendar-events".
|
||||
</li>
|
||||
<li>
|
||||
Click <b>Create and continue</b>.
|
||||
</li>
|
||||
<li>
|
||||
Click <b>Done</b> at the bottom of the form. (No need to
|
||||
complete the optional steps.)
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
4. Create an API key.{" "}
|
||||
<ul>
|
||||
<li>
|
||||
Click the <b>Actions</b> menu for your new service account.
|
||||
</li>
|
||||
<li>
|
||||
Select <b>Manage keys</b>.
|
||||
</li>
|
||||
<li>
|
||||
Click <b>Add key > Create new key</b>.
|
||||
</li>
|
||||
<li>Select the JSON key type.</li>
|
||||
<li>
|
||||
Click <b>Create</b> to create the key & download a JSON file.
|
||||
</li>
|
||||
<li className={`${baseClass}__configuration`}>
|
||||
Configure your service account integration in Fleet using the
|
||||
form below:
|
||||
<form onSubmit={onFormSubmit} autoComplete="off">
|
||||
<InputField
|
||||
label="API key JSON"
|
||||
onChange={onInputChange}
|
||||
name="apiKeyJson"
|
||||
value={apiKeyJson}
|
||||
parseTarget
|
||||
type="textarea"
|
||||
tooltip={
|
||||
<>
|
||||
Paste the full contents of the JSON file downloaded{" "}
|
||||
<br />
|
||||
when creating your service account API key.
|
||||
</>
|
||||
}
|
||||
placeholder={API_KEY_JSON_PLACEHOLDER}
|
||||
ignore1password
|
||||
inputClassName={`${baseClass}__api-key-json`}
|
||||
error={formErrors.apiKeyJson}
|
||||
/>
|
||||
<InputField
|
||||
label="Primary domain"
|
||||
onChange={onInputChange}
|
||||
name="domain"
|
||||
value={domain}
|
||||
parseTarget
|
||||
tooltip={
|
||||
<>
|
||||
If the end user is signed into multiple Google accounts,
|
||||
this will be used to identify their work calendar.
|
||||
</>
|
||||
}
|
||||
placeholder="example.com"
|
||||
helpText={
|
||||
<>
|
||||
You can find your primary domain in Google Workspace{" "}
|
||||
<CustomLink
|
||||
url={GOOGLE_WORKSPACE_DOMAINS}
|
||||
text="here"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
}
|
||||
error={formErrors.domain}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
disabled={Object.keys(formErrors).length > 0}
|
||||
className="save-loading"
|
||||
isLoading={isUpdatingSettings}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
5. Authorize the service account via domain-wide delegation.
|
||||
<ul>
|
||||
<li>
|
||||
In Google Workspace, go to{" "}
|
||||
<b>
|
||||
Security > Access and data control > API controls >
|
||||
Manage Domain Wide Delegation
|
||||
</b>
|
||||
.{" "}
|
||||
<CustomLink
|
||||
url={DOMAIN_WIDE_DELEGATION}
|
||||
text="View page"
|
||||
newTab
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
Under <b>API clients</b>, click <b>Add new</b>.
|
||||
</li>
|
||||
<li>
|
||||
Enter the client ID for the service account. You can find this
|
||||
in your downloaded API key JSON file (
|
||||
<span className={`${baseClass}__code`}>client_id</span>
|
||||
), or under <b>Advanced Settings</b> when viewing the service
|
||||
account.
|
||||
</li>
|
||||
<li>
|
||||
For the OAuth scopes, paste the following value:
|
||||
<InputField
|
||||
disabled
|
||||
inputWrapperClass={`${baseClass}__oauth-scopes`}
|
||||
name="oauth-scopes"
|
||||
label={renderOauthLabel()}
|
||||
type="textarea"
|
||||
value={OAUTH_SCOPES}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
Click <b>Authorize</b>.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
6. Enable the Google Calendar API.
|
||||
<ul>
|
||||
<li>
|
||||
In the Google Cloud console API library, go to the Google
|
||||
Calendar API.{" "}
|
||||
<CustomLink
|
||||
url={ENABLING_CALENDAR_API}
|
||||
text="View page"
|
||||
newTab
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
Make sure the "Fleet calendar events" project is
|
||||
selected at the top of the page.
|
||||
</li>
|
||||
<li>
|
||||
Click <b>Enable</b>.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
You're ready to automatically schedule calendar events for end
|
||||
users.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isPremiumTier) return <PremiumFeatureMessage />;
|
||||
|
||||
if (isLoadingAppConfig) {
|
||||
<div className={baseClass}>
|
||||
<Spinner includeContainer={false} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (errorAppConfig) {
|
||||
return <DataError />;
|
||||
}
|
||||
|
||||
return <div className={baseClass}>{renderForm()}</div>;
|
||||
};
|
||||
|
||||
export default Calendars;
|
@ -0,0 +1,62 @@
|
||||
.calendars-integration {
|
||||
&__page-description {
|
||||
font-size: $x-small;
|
||||
color: $core-fleet-black;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: $pad-large 0;
|
||||
}
|
||||
|
||||
ui {
|
||||
margin-block-start: $pad-small;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: $pad-small 0;
|
||||
}
|
||||
|
||||
form {
|
||||
margin-top: $pad-large;
|
||||
}
|
||||
|
||||
&__configuration {
|
||||
button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__api-key-json {
|
||||
min-width: 100%; // resize vertically only
|
||||
height: 294px;
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
#oauth-scopes {
|
||||
font-family: "SourceCodePro", $monospace;
|
||||
color: $core-fleet-black;
|
||||
min-height: 80px;
|
||||
padding: $pad-medium;
|
||||
padding-right: $pad-xxlarge;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
&__oauth-scopes-copy-icon-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
top: 36px;
|
||||
right: 16px;
|
||||
height: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-family: "SourceCodePro", $monospace;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./Calendars";
|
@ -8,7 +8,7 @@ import {
|
||||
IZendeskIntegration,
|
||||
IIntegration,
|
||||
IIntegrationTableData,
|
||||
IIntegrations,
|
||||
IGlobalIntegrations,
|
||||
} from "interfaces/integration";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
|
||||
@ -69,7 +69,7 @@ const Integrations = (): JSX.Element => {
|
||||
isLoading: isLoadingIntegrations,
|
||||
error: loadingIntegrationsError,
|
||||
refetch: refetchIntegrations,
|
||||
} = useQuery<IConfig, Error, IIntegrations>(
|
||||
} = useQuery<IConfig, Error, IGlobalIntegrations>(
|
||||
["integrations"],
|
||||
() => configAPI.loadAll(),
|
||||
{
|
||||
@ -133,9 +133,15 @@ const Integrations = (): JSX.Element => {
|
||||
// Updates either integrations.jira or integrations.zendesk
|
||||
const destination = () => {
|
||||
if (integrationDestination === "jira") {
|
||||
return { jira: integrationSubmitData, zendesk: zendeskIntegrations };
|
||||
return {
|
||||
jira: integrationSubmitData,
|
||||
zendesk: zendeskIntegrations,
|
||||
};
|
||||
}
|
||||
return { zendesk: integrationSubmitData, jira: jiraIntegrations };
|
||||
return {
|
||||
zendesk: integrationSubmitData,
|
||||
jira: jiraIntegrations,
|
||||
};
|
||||
};
|
||||
|
||||
setTestingConnection(true);
|
||||
|
@ -4,7 +4,7 @@ import Modal from "components/Modal";
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import { IIntegration, IIntegrations } from "interfaces/integration";
|
||||
import { IIntegration, IZendeskJiraIntegrations } from "interfaces/integration";
|
||||
import IntegrationForm from "../IntegrationForm";
|
||||
|
||||
const baseClass = "add-integration-modal";
|
||||
@ -17,7 +17,7 @@ interface IAddIntegrationModalProps {
|
||||
) => void;
|
||||
serverErrors?: { base: string; email: string };
|
||||
backendValidators: { [key: string]: string };
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
testingConnection: boolean;
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import Modal from "components/Modal";
|
||||
import Spinner from "components/Spinner";
|
||||
import {
|
||||
IIntegration,
|
||||
IIntegrations,
|
||||
IZendeskJiraIntegrations,
|
||||
IIntegrationTableData,
|
||||
} from "interfaces/integration";
|
||||
import IntegrationForm from "../IntegrationForm";
|
||||
@ -15,7 +15,7 @@ interface IEditIntegrationModalProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: (jiraIntegrationSubmitData: IIntegration[]) => void;
|
||||
backendValidators: { [key: string]: string };
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
integrationEditing?: IIntegrationTableData;
|
||||
testingConnection: boolean;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
IIntegrationFormData,
|
||||
IIntegrationTableData,
|
||||
IIntegration,
|
||||
IIntegrations,
|
||||
IZendeskJiraIntegrations,
|
||||
IIntegrationType,
|
||||
} from "interfaces/integration";
|
||||
|
||||
@ -26,7 +26,7 @@ interface IIntegrationFormProps {
|
||||
integrationDestination: string
|
||||
) => void;
|
||||
integrationEditing?: IIntegrationTableData;
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
integrationEditingUrl?: string;
|
||||
integrationEditingUsername?: string;
|
||||
integrationEditingEmail?: string;
|
||||
|
@ -20,9 +20,9 @@ const Advanced = ({
|
||||
isUpdatingSettings,
|
||||
}: IAppConfigFormProps): JSX.Element => {
|
||||
const [formData, setFormData] = useState({
|
||||
domain: appConfig.smtp_settings.domain || "",
|
||||
verifySSLCerts: appConfig.smtp_settings.verify_ssl_certs || false,
|
||||
enableStartTLS: appConfig.smtp_settings.enable_start_tls,
|
||||
domain: appConfig.smtp_settings?.domain || "",
|
||||
verifySSLCerts: appConfig.smtp_settings?.verify_ssl_certs || false,
|
||||
enableStartTLS: appConfig.smtp_settings?.enable_start_tls,
|
||||
enableHostExpiry:
|
||||
appConfig.host_expiry_settings.host_expiry_enabled || false,
|
||||
hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0,
|
||||
@ -74,16 +74,16 @@ const Advanced = ({
|
||||
scripts_disabled: disableScripts,
|
||||
},
|
||||
smtp_settings: {
|
||||
enable_smtp: appConfig.smtp_settings.enable_smtp || false,
|
||||
sender_address: appConfig.smtp_settings.sender_address || "",
|
||||
server: appConfig.smtp_settings.server || "",
|
||||
port: Number(appConfig.smtp_settings.port),
|
||||
authentication_type: appConfig.smtp_settings.authentication_type || "",
|
||||
user_name: appConfig.smtp_settings.user_name || "",
|
||||
password: appConfig.smtp_settings.password || "",
|
||||
enable_ssl_tls: appConfig.smtp_settings.enable_ssl_tls || false,
|
||||
enable_smtp: appConfig.smtp_settings?.enable_smtp || false,
|
||||
sender_address: appConfig.smtp_settings?.sender_address || "",
|
||||
server: appConfig.smtp_settings?.server || "",
|
||||
port: Number(appConfig.smtp_settings?.port),
|
||||
authentication_type: appConfig.smtp_settings?.authentication_type || "",
|
||||
user_name: appConfig.smtp_settings?.user_name || "",
|
||||
password: appConfig.smtp_settings?.password || "",
|
||||
enable_ssl_tls: appConfig.smtp_settings?.enable_ssl_tls || false,
|
||||
authentication_method:
|
||||
appConfig.smtp_settings.authentication_method || "",
|
||||
appConfig.smtp_settings?.authentication_method || "",
|
||||
domain,
|
||||
verify_ssl_certs: verifySSLCerts,
|
||||
enable_start_tls: enableStartTLS,
|
||||
|
@ -31,16 +31,16 @@ const Smtp = ({
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
|
||||
const [formData, setFormData] = useState<any>({
|
||||
enableSMTP: appConfig.smtp_settings.enable_smtp || false,
|
||||
smtpSenderAddress: appConfig.smtp_settings.sender_address || "",
|
||||
smtpServer: appConfig.smtp_settings.server || "",
|
||||
smtpPort: appConfig.smtp_settings.port,
|
||||
smtpEnableSSLTLS: appConfig.smtp_settings.enable_ssl_tls || false,
|
||||
smtpAuthenticationType: appConfig.smtp_settings.authentication_type || "",
|
||||
smtpUsername: appConfig.smtp_settings.user_name || "",
|
||||
smtpPassword: appConfig.smtp_settings.password || "",
|
||||
enableSMTP: appConfig.smtp_settings?.enable_smtp || false,
|
||||
smtpSenderAddress: appConfig.smtp_settings?.sender_address || "",
|
||||
smtpServer: appConfig.smtp_settings?.server || "",
|
||||
smtpPort: appConfig.smtp_settings?.port,
|
||||
smtpEnableSSLTLS: appConfig.smtp_settings?.enable_ssl_tls || false,
|
||||
smtpAuthenticationType: appConfig.smtp_settings?.authentication_type || "",
|
||||
smtpUsername: appConfig.smtp_settings?.user_name || "",
|
||||
smtpPassword: appConfig.smtp_settings?.password || "",
|
||||
smtpAuthenticationMethod:
|
||||
appConfig.smtp_settings.authentication_method || "",
|
||||
appConfig.smtp_settings?.authentication_method || "",
|
||||
});
|
||||
|
||||
const {
|
||||
@ -116,9 +116,9 @@ const Smtp = ({
|
||||
password: smtpPassword,
|
||||
enable_ssl_tls: smtpEnableSSLTLS,
|
||||
authentication_method: smtpAuthenticationMethod,
|
||||
domain: appConfig.smtp_settings.domain || "",
|
||||
verify_ssl_certs: appConfig.smtp_settings.verify_ssl_certs || false,
|
||||
enable_start_tls: appConfig.smtp_settings.enable_start_tls,
|
||||
domain: appConfig.smtp_settings?.domain || "",
|
||||
verify_ssl_certs: appConfig.smtp_settings?.verify_ssl_certs || false,
|
||||
enable_start_tls: appConfig.smtp_settings?.enable_start_tls,
|
||||
},
|
||||
};
|
||||
|
||||
@ -282,13 +282,13 @@ const Smtp = ({
|
||||
!sesConfigured ? (
|
||||
<small
|
||||
className={`smtp-options smtp-options--${
|
||||
appConfig.smtp_settings.configured
|
||||
appConfig.smtp_settings?.configured
|
||||
? "configured"
|
||||
: "notconfigured"
|
||||
}`}
|
||||
>
|
||||
<em>
|
||||
{appConfig.smtp_settings.configured
|
||||
{appConfig.smtp_settings?.configured
|
||||
? "CONFIGURED"
|
||||
: "NOT CONFIGURED"}
|
||||
</em>
|
||||
|
@ -217,9 +217,12 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => {
|
||||
inviteAPI
|
||||
.create(requestData)
|
||||
.then(() => {
|
||||
const senderAddressMessage = config?.smtp_settings?.sender_address
|
||||
? ` from ${config?.smtp_settings?.sender_address}`
|
||||
: "";
|
||||
renderFlash(
|
||||
"success",
|
||||
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.`
|
||||
`An invitation email was sent${senderAddressMessage} to ${formData.email}.`
|
||||
);
|
||||
refetchUsers();
|
||||
toggleCreateUserModal();
|
||||
|
@ -225,9 +225,12 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
|
||||
invitesAPI
|
||||
.create(requestData)
|
||||
.then(() => {
|
||||
const senderAddressMessage = config?.smtp_settings?.sender_address
|
||||
? ` from ${config?.smtp_settings?.sender_address}`
|
||||
: "";
|
||||
renderFlash(
|
||||
"success",
|
||||
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.`
|
||||
`An invitation email was sent${senderAddressMessage} to ${formData.email}.`
|
||||
);
|
||||
toggleCreateUserModal();
|
||||
refetchInvites();
|
||||
@ -302,7 +305,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
|
||||
|
||||
let userUpdatedFlashMessage = `Successfully edited ${formData.name}`;
|
||||
if (userData?.email !== formData.email) {
|
||||
userUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}`;
|
||||
const senderAddressMessage = config?.smtp_settings?.sender_address
|
||||
? ` from ${config?.smtp_settings?.sender_address}`
|
||||
: "";
|
||||
userUpdatedFlashMessage += `: A confirmation email was sent${senderAddressMessage} to ${formData.email}`;
|
||||
}
|
||||
const userUpdatedEmailError =
|
||||
"A user with this email address already exists";
|
||||
@ -463,7 +469,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
|
||||
onSubmit={onEditUser}
|
||||
availableTeams={teams || []}
|
||||
isPremiumTier={isPremiumTier || false}
|
||||
smtpConfigured={config?.smtp_settings.configured || false}
|
||||
smtpConfigured={config?.smtp_settings?.configured || false}
|
||||
sesConfigured={config?.email?.backend === "ses" || false}
|
||||
canUseSso={config?.sso_settings.enable_sso || false}
|
||||
isSsoEnabled={userData?.sso_enabled}
|
||||
@ -486,7 +492,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
|
||||
defaultGlobalRole="observer"
|
||||
defaultTeams={[]}
|
||||
isPremiumTier={isPremiumTier || false}
|
||||
smtpConfigured={config?.smtp_settings.configured || false}
|
||||
smtpConfigured={config?.smtp_settings?.configured || false}
|
||||
sesConfigured={config?.email?.backend === "ses" || false}
|
||||
canUseSso={config?.sso_settings.enable_sso || false}
|
||||
isUpdatingUsers={isUpdatingUsers}
|
||||
|
@ -12,7 +12,6 @@ export interface ISideNavItem<T> {
|
||||
urlSection: string;
|
||||
path: string;
|
||||
Card: (props: T) => JSX.Element;
|
||||
exclude?: boolean;
|
||||
}
|
||||
|
||||
interface ISideNavProps {
|
||||
|
@ -1,104 +1,10 @@
|
||||
.host-actions-dropdown {
|
||||
.form-field {
|
||||
margin: 0;
|
||||
@include button-dropdown;
|
||||
color: $core-fleet-black;
|
||||
.Select-multi-value-wrapper {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.Select {
|
||||
position: relative;
|
||||
border: 0;
|
||||
height: auto;
|
||||
|
||||
&.is-focused,
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.is-focused:not(.is-open) {
|
||||
.Select-control {
|
||||
background-color: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-control {
|
||||
display: flex;
|
||||
background-color: initial;
|
||||
height: auto;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:hover .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
color: $core-fleet-black;
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
padding-left: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.Select-input {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.Select-arrow-zone {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-multi-value-wrapper {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Select-menu-outer {
|
||||
margin-top: $pad-xsmall;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
border-radius: $border-radius;
|
||||
z-index: 6;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
width: 188px;
|
||||
left: unset;
|
||||
top: unset;
|
||||
max-height: none;
|
||||
padding: $pad-small;
|
||||
position: absolute;
|
||||
left: -120px;
|
||||
|
||||
.Select-menu {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&:not(.is-open) {
|
||||
.Select-control:hover .Select-arrow {
|
||||
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
.Select-control .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
.Select > .Select-menu-outer {
|
||||
left: -120px;
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,6 @@ import { QueryContext } from "context/query";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import activitiesAPI, {
|
||||
IActivitiesResponse,
|
||||
IPastActivitiesResponse,
|
||||
IUpcomingActivitiesResponse,
|
||||
} from "services/entities/activities";
|
||||
|
@ -2,7 +2,9 @@ import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router/lib/Router";
|
||||
import PATHS from "router/paths";
|
||||
import { noop, isEqual } from "lodash";
|
||||
import { noop, isEqual, uniqueId } from "lodash";
|
||||
|
||||
import { Tooltip as ReactTooltip5 } from "react-tooltip-5";
|
||||
|
||||
import { getNextLocationPath } from "utilities/helpers";
|
||||
|
||||
@ -12,7 +14,7 @@ import { TableContext } from "context/table";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
import { IConfig, IWebhookSettings } from "interfaces/config";
|
||||
import { IIntegrations } from "interfaces/integration";
|
||||
import { IZendeskJiraIntegrations } from "interfaces/integration";
|
||||
import {
|
||||
IPolicyStats,
|
||||
ILoadAllPoliciesResponse,
|
||||
@ -34,6 +36,8 @@ import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
|
||||
|
||||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
import Button from "components/buttons/Button";
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import Spinner from "components/Spinner";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
@ -41,9 +45,11 @@ import TableDataError from "components/DataError";
|
||||
import MainContent from "components/MainContent";
|
||||
|
||||
import PoliciesTable from "./components/PoliciesTable";
|
||||
import ManagePolicyAutomationsModal from "./components/ManagePolicyAutomationsModal";
|
||||
import OtherWorkflowsModal from "./components/OtherWorkflowsModal";
|
||||
import AddPolicyModal from "./components/AddPolicyModal";
|
||||
import DeletePolicyModal from "./components/DeletePolicyModal";
|
||||
import CalendarEventsModal from "./components/CalendarEventsModal";
|
||||
import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal";
|
||||
|
||||
interface IManagePoliciesPageProps {
|
||||
router: InjectedRouter;
|
||||
@ -125,13 +131,15 @@ const ManagePolicyPage = ({
|
||||
|
||||
const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false);
|
||||
const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false);
|
||||
const [
|
||||
updatingPolicyEnabledCalendarEvents,
|
||||
setUpdatingPolicyEnabledCalendarEvents,
|
||||
] = useState(false);
|
||||
const [selectedPolicyIds, setSelectedPolicyIds] = useState<number[]>([]);
|
||||
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
|
||||
false
|
||||
);
|
||||
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
|
||||
const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false);
|
||||
const [showAddPolicyModal, setShowAddPolicyModal] = useState(false);
|
||||
const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false);
|
||||
const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false);
|
||||
|
||||
const [teamPolicies, setTeamPolicies] = useState<IPolicyStats[]>();
|
||||
const [inheritedPolicies, setInheritedPolicies] = useState<IPolicyStats[]>();
|
||||
@ -474,18 +482,30 @@ const ManagePolicyPage = ({
|
||||
] // Other dependencies can cause infinite re-renders as URL is source of truth
|
||||
);
|
||||
|
||||
const toggleManageAutomationsModal = () =>
|
||||
setShowManageAutomationsModal(!showManageAutomationsModal);
|
||||
|
||||
const togglePreviewPayloadModal = useCallback(() => {
|
||||
setShowPreviewPayloadModal(!showPreviewPayloadModal);
|
||||
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
|
||||
const toggleOtherWorkflowsModal = () =>
|
||||
setShowOtherWorkflowsModal(!showOtherWorkflowsModal);
|
||||
|
||||
const toggleAddPolicyModal = () => setShowAddPolicyModal(!showAddPolicyModal);
|
||||
|
||||
const toggleDeletePolicyModal = () =>
|
||||
setShowDeletePolicyModal(!showDeletePolicyModal);
|
||||
|
||||
const toggleCalendarEventsModal = () => {
|
||||
setShowCalendarEventsModal(!showCalendarEventsModal);
|
||||
};
|
||||
|
||||
const onSelectAutomationOption = (option: string) => {
|
||||
switch (option) {
|
||||
case "calendar_events":
|
||||
toggleCalendarEventsModal();
|
||||
break;
|
||||
case "other_workflows":
|
||||
toggleOtherWorkflowsModal();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowInheritedPolicies = () => {
|
||||
// URL source of truth
|
||||
const locationPath = getNextLocationPath({
|
||||
@ -499,9 +519,9 @@ const ManagePolicyPage = ({
|
||||
router?.replace(locationPath);
|
||||
};
|
||||
|
||||
const handleUpdateAutomations = async (requestBody: {
|
||||
const handleUpdateOtherWorkflows = async (requestBody: {
|
||||
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
}) => {
|
||||
setIsUpdatingAutomations(true);
|
||||
try {
|
||||
@ -515,13 +535,79 @@ const ManagePolicyPage = ({
|
||||
"Could not update policy automations. Please try again."
|
||||
);
|
||||
} finally {
|
||||
toggleManageAutomationsModal();
|
||||
toggleOtherWorkflowsModal();
|
||||
setIsUpdatingAutomations(false);
|
||||
refetchConfig();
|
||||
isAnyTeamSelected && refetchTeamConfig();
|
||||
}
|
||||
};
|
||||
|
||||
const updatePolicyEnabledCalendarEvents = async (
|
||||
formData: ICalendarEventsFormData
|
||||
) => {
|
||||
setUpdatingPolicyEnabledCalendarEvents(true);
|
||||
|
||||
try {
|
||||
// update team config if either field has been changed
|
||||
const responses: Promise<any>[] = [];
|
||||
if (
|
||||
formData.enabled !==
|
||||
teamConfig?.integrations.google_calendar?.enable_calendar_events ||
|
||||
formData.url !== teamConfig?.integrations.google_calendar?.webhook_url
|
||||
) {
|
||||
responses.push(
|
||||
teamsAPI.update(
|
||||
{
|
||||
integrations: {
|
||||
google_calendar: {
|
||||
enable_calendar_events: formData.enabled,
|
||||
webhook_url: formData.url,
|
||||
},
|
||||
// These fields will never actually be changed here. See comment above
|
||||
// IGlobalIntegrations definition.
|
||||
zendesk: teamConfig?.integrations.zendesk || [],
|
||||
jira: teamConfig?.integrations.jira || [],
|
||||
},
|
||||
},
|
||||
teamIdForApi
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// update changed policies calendar events enabled
|
||||
const changedPolicies = formData.policies.filter((formPolicy) => {
|
||||
const prevPolicyState = teamPolicies?.find(
|
||||
(policy) => policy.id === formPolicy.id
|
||||
);
|
||||
return (
|
||||
formPolicy.isChecked !== prevPolicyState?.calendar_events_enabled
|
||||
);
|
||||
});
|
||||
|
||||
responses.concat(
|
||||
changedPolicies.map((changedPolicy) => {
|
||||
return teamPoliciesAPI.update(changedPolicy.id, {
|
||||
calendar_events_enabled: changedPolicy.isChecked,
|
||||
team_id: teamIdForApi,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(responses);
|
||||
renderFlash("success", "Successfully updated policy automations.");
|
||||
} catch {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Could not update policy automations. Please try again."
|
||||
);
|
||||
} finally {
|
||||
toggleCalendarEventsModal();
|
||||
setUpdatingPolicyEnabledCalendarEvents(false);
|
||||
refetchTeamPolicies();
|
||||
refetchTeamConfig();
|
||||
}
|
||||
};
|
||||
|
||||
const onAddPolicyClick = () => {
|
||||
setLastEditedQueryName("");
|
||||
setLastEditedQueryDescription("");
|
||||
@ -687,6 +773,70 @@ const ManagePolicyPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getAutomationsDropdownOptions = () => {
|
||||
const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1;
|
||||
let calEventsLabel: React.ReactNode = "Calendar events";
|
||||
if (!isPremiumTier) {
|
||||
const tipId = uniqueId();
|
||||
calEventsLabel = (
|
||||
<span>
|
||||
<div className="label-text" data-tooltip-id={tipId}>
|
||||
Calendar events
|
||||
</div>
|
||||
<ReactTooltip5
|
||||
id={tipId}
|
||||
place="left"
|
||||
positionStrategy="fixed"
|
||||
offset={24}
|
||||
disableStyleInjection
|
||||
>
|
||||
Available in Fleet Premium
|
||||
</ReactTooltip5>
|
||||
</span>
|
||||
);
|
||||
} else if (isAllTeams) {
|
||||
const tipId = uniqueId();
|
||||
calEventsLabel = (
|
||||
<span>
|
||||
<div className="label-text" data-tooltip-id={tipId}>
|
||||
Calendar events
|
||||
</div>
|
||||
<ReactTooltip5
|
||||
id={tipId}
|
||||
place="left"
|
||||
positionStrategy="fixed"
|
||||
offset={24}
|
||||
disableStyleInjection
|
||||
>
|
||||
Select a team to manage
|
||||
<br />
|
||||
calendar events.
|
||||
</ReactTooltip5>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: calEventsLabel,
|
||||
value: "calendar_events",
|
||||
disabled: !isPremiumTier || isAllTeams,
|
||||
helpText: "Automatically reserve time to resolve failing policies.",
|
||||
},
|
||||
{
|
||||
label: "Other workflows",
|
||||
value: "other_workflows",
|
||||
disabled: false,
|
||||
helpText: "Create tickets or fire webhooks for failing policies.",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const isCalEventsConfigured =
|
||||
(config?.integrations.google_calendar &&
|
||||
config?.integrations.google_calendar.length > 0) ??
|
||||
false;
|
||||
|
||||
return (
|
||||
<MainContent className={baseClass}>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
@ -714,18 +864,15 @@ const ManagePolicyPage = ({
|
||||
{showCtaButtons && (
|
||||
<div className={`${baseClass} button-wrap`}>
|
||||
{canManageAutomations && automationsConfig && (
|
||||
<Button
|
||||
onClick={toggleManageAutomationsModal}
|
||||
className={`${baseClass}__manage-automations button`}
|
||||
variant="inverse"
|
||||
disabled={
|
||||
isAnyTeamSelected
|
||||
? isFetchingTeamPolicies
|
||||
: isFetchingGlobalPolicies
|
||||
}
|
||||
>
|
||||
<span>Manage automations</span>
|
||||
</Button>
|
||||
<div className={`${baseClass}__manage-automations-wrapper`}>
|
||||
<Dropdown
|
||||
className={`${baseClass}__manage-automations-dropdown`}
|
||||
onChange={onSelectAutomationOption}
|
||||
placeholder="Manage automations"
|
||||
searchable={false}
|
||||
options={getAutomationsDropdownOptions()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canAddOrDeletePolicy && (
|
||||
<div className={`${baseClass}__action-button-container`}>
|
||||
@ -795,16 +942,14 @@ const ManagePolicyPage = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{config && automationsConfig && showManageAutomationsModal && (
|
||||
<ManagePolicyAutomationsModal
|
||||
{config && automationsConfig && showOtherWorkflowsModal && (
|
||||
<OtherWorkflowsModal
|
||||
automationsConfig={automationsConfig}
|
||||
availableIntegrations={config.integrations}
|
||||
availablePolicies={availablePoliciesForAutomation}
|
||||
isUpdatingAutomations={isUpdatingAutomations}
|
||||
showPreviewPayloadModal={showPreviewPayloadModal}
|
||||
onExit={toggleManageAutomationsModal}
|
||||
handleSubmit={handleUpdateAutomations}
|
||||
togglePreviewPayloadModal={togglePreviewPayloadModal}
|
||||
onExit={toggleOtherWorkflowsModal}
|
||||
handleSubmit={handleUpdateOtherWorkflows}
|
||||
/>
|
||||
)}
|
||||
{showAddPolicyModal && (
|
||||
@ -822,6 +967,22 @@ const ManagePolicyPage = ({
|
||||
onSubmit={onDeletePolicySubmit}
|
||||
/>
|
||||
)}
|
||||
{showCalendarEventsModal && (
|
||||
<CalendarEventsModal
|
||||
onExit={toggleCalendarEventsModal}
|
||||
updatePolicyEnabledCalendarEvents={
|
||||
updatePolicyEnabledCalendarEvents
|
||||
}
|
||||
configured={isCalEventsConfigured}
|
||||
enabled={
|
||||
teamConfig?.integrations.google_calendar
|
||||
?.enable_calendar_events ?? false
|
||||
}
|
||||
url={teamConfig?.integrations.google_calendar?.webhook_url || ""}
|
||||
policies={teamPolicies || []}
|
||||
isUpdating={updatingPolicyEnabledCalendarEvents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MainContent>
|
||||
);
|
||||
|
@ -8,13 +8,57 @@
|
||||
.button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 266px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__manage-automations {
|
||||
padding: $pad-small;
|
||||
margin-right: $pad-small;
|
||||
&__manage-automations-wrapper {
|
||||
@include button-dropdown;
|
||||
.Select-multi-value-wrapper {
|
||||
width: 146px;
|
||||
}
|
||||
.Select > .Select-menu-outer {
|
||||
left: -186px;
|
||||
width: 360px;
|
||||
.dropdown__help-text {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
.is-disabled * {
|
||||
color: $ui-fleet-black-25;
|
||||
.label-text {
|
||||
font-style: normal;
|
||||
// increase height to allow for broader tooltip activation area
|
||||
position: absolute;
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
}
|
||||
.dropdown__help-text {
|
||||
// compensate for absolute label-text height
|
||||
margin-top: 20px;
|
||||
}
|
||||
.react-tooltip {
|
||||
@include tooltip-text;
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.Select-control {
|
||||
margin-top: 0;
|
||||
gap: 6px;
|
||||
.Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
font-weight: $bold;
|
||||
}
|
||||
.dropdown__custom-arrow .dropdown__icon {
|
||||
svg {
|
||||
path {
|
||||
stroke: $core-vibrant-blue-over;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
|
@ -0,0 +1,314 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import { IPolicy } from "interfaces/policy";
|
||||
|
||||
import validURL from "components/forms/validators/valid_url";
|
||||
|
||||
import Button from "components/buttons/Button";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import Slider from "components/forms/fields/Slider";
|
||||
// @ts-ignore
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Graphic from "components/Graphic";
|
||||
import Modal from "components/Modal";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import { syntaxHighlight } from "utilities/helpers";
|
||||
|
||||
const baseClass = "calendar-events-modal";
|
||||
|
||||
interface IFormPolicy {
|
||||
name: string;
|
||||
id: number;
|
||||
isChecked: boolean;
|
||||
}
|
||||
export interface ICalendarEventsFormData {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
policies: IFormPolicy[];
|
||||
}
|
||||
|
||||
interface ICalendarEventsModal {
|
||||
onExit: () => void;
|
||||
updatePolicyEnabledCalendarEvents: (
|
||||
formData: ICalendarEventsFormData
|
||||
) => void;
|
||||
isUpdating: boolean;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
policies: IPolicy[];
|
||||
}
|
||||
|
||||
// allows any policy name to be the name of a form field, one of the checkboxes
|
||||
type FormNames = string;
|
||||
|
||||
const CalendarEventsModal = ({
|
||||
onExit,
|
||||
updatePolicyEnabledCalendarEvents,
|
||||
isUpdating,
|
||||
configured,
|
||||
enabled,
|
||||
url,
|
||||
policies,
|
||||
}: ICalendarEventsModal) => {
|
||||
const [formData, setFormData] = useState<ICalendarEventsFormData>({
|
||||
enabled,
|
||||
url,
|
||||
policies: policies.map((policy) => ({
|
||||
name: policy.name,
|
||||
id: policy.id,
|
||||
isChecked: policy.calendar_events_enabled || false,
|
||||
})),
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string | null>>(
|
||||
{}
|
||||
);
|
||||
const [showPreviewCalendarEvent, setShowPreviewCalendarEvent] = useState(
|
||||
false
|
||||
);
|
||||
const [showExamplePayload, setShowExamplePayload] = useState(false);
|
||||
|
||||
const validateCalendarEventsFormData = (
|
||||
curFormData: ICalendarEventsFormData
|
||||
) => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (curFormData.enabled) {
|
||||
const { url: curUrl } = curFormData;
|
||||
if (!validURL({ url: curUrl })) {
|
||||
const errorPrefix = curUrl ? `${curUrl} is not` : "Please enter";
|
||||
errors.url = `${errorPrefix} a valid resolution webhook URL`;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
// two onChange handlers to handle different levels of nesting in the form data
|
||||
const onFeatureEnabledOrUrlChange = useCallback(
|
||||
(newVal: { name: "enabled" | "url"; value: string | boolean }) => {
|
||||
const { name, value } = newVal;
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
setFormData(newFormData);
|
||||
setFormErrors(validateCalendarEventsFormData(newFormData));
|
||||
},
|
||||
[formData]
|
||||
);
|
||||
const onPolicyEnabledChange = useCallback(
|
||||
(newVal: { name: FormNames; value: boolean }) => {
|
||||
const { name, value } = newVal;
|
||||
const newFormPolicies = formData.policies.map((formPolicy) => {
|
||||
if (formPolicy.name === name) {
|
||||
return { ...formPolicy, isChecked: value };
|
||||
}
|
||||
return formPolicy;
|
||||
});
|
||||
const newFormData = { ...formData, policies: newFormPolicies };
|
||||
setFormData(newFormData);
|
||||
setFormErrors(validateCalendarEventsFormData(newFormData));
|
||||
},
|
||||
[formData]
|
||||
);
|
||||
|
||||
const togglePreviewCalendarEvent = () => {
|
||||
setShowPreviewCalendarEvent(!showPreviewCalendarEvent);
|
||||
};
|
||||
|
||||
const renderExamplePayload = () => {
|
||||
return (
|
||||
<>
|
||||
<pre>POST https://server.com/example</pre>
|
||||
<pre
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: syntaxHighlight({
|
||||
timestamp: "0000-00-00T00:00:00Z",
|
||||
host_id: 1,
|
||||
host_display_name: "Anna's MacBook Pro",
|
||||
host_serial_number: "ABCD1234567890",
|
||||
failing_policies: [
|
||||
{
|
||||
id: 123,
|
||||
name: "macOS - Disable guest account",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPolicies = () => {
|
||||
return (
|
||||
<div className="form-field">
|
||||
<div className="form-field__label">Policies:</div>
|
||||
{formData.policies.map((policy) => {
|
||||
const { isChecked, name, id } = policy;
|
||||
return (
|
||||
<div key={id}>
|
||||
<Checkbox
|
||||
value={isChecked}
|
||||
name={name}
|
||||
// can't use parseTarget as value needs to be set to !currentValue
|
||||
onChange={() => {
|
||||
onPolicyEnabledChange({ name, value: !isChecked });
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="form-field__help-text">
|
||||
A calendar event will be created for end users if one of their hosts
|
||||
fail any of these policies.{" "}
|
||||
<CustomLink
|
||||
url="https://www.fleetdm.com/learn-more-about/calendar-events"
|
||||
text="Learn more"
|
||||
newTab
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderPreviewCalendarEventModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title="Calendar event preview"
|
||||
width="large"
|
||||
onExit={togglePreviewCalendarEvent}
|
||||
className="calendar-event-preview"
|
||||
>
|
||||
<>
|
||||
<p>A similar event will appear in the end user's calendar:</p>
|
||||
<Graphic name="calendar-event-preview" />
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={togglePreviewCalendarEvent} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPlaceholderModal = () => {
|
||||
return (
|
||||
<div className="placeholder">
|
||||
<a href="https://www.fleetdm.com/learn-more-about/calendar-events">
|
||||
<Graphic name="calendar-event-preview" />
|
||||
</a>
|
||||
<div>
|
||||
To create calendar events for end users if their hosts fail policies,
|
||||
you must first connect Fleet to your Google Workspace service account.
|
||||
</div>
|
||||
<div>
|
||||
This can be configured in{" "}
|
||||
<b>Settings > Integrations > Calendars.</b>
|
||||
</div>
|
||||
<CustomLink
|
||||
url="https://www.fleetdm.com/learn-more-about/calendar-events"
|
||||
text="Learn more"
|
||||
newTab
|
||||
/>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onExit} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderConfiguredModal = () => (
|
||||
<div className={`${baseClass} form`}>
|
||||
<div className="form-header">
|
||||
<Slider
|
||||
value={formData.enabled}
|
||||
onChange={() => {
|
||||
onFeatureEnabledOrUrlChange({
|
||||
name: "enabled",
|
||||
value: !formData.enabled,
|
||||
});
|
||||
}}
|
||||
inactiveText="Disabled"
|
||||
activeText="Enabled"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text-link"
|
||||
onClick={togglePreviewCalendarEvent}
|
||||
>
|
||||
Preview calendar event
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`form ${formData.enabled ? "" : "form-fields--disabled"}`}
|
||||
>
|
||||
<InputField
|
||||
placeholder="https://server.com/example"
|
||||
label="Resolution webhook URL"
|
||||
onChange={onFeatureEnabledOrUrlChange}
|
||||
name="url"
|
||||
value={formData.url}
|
||||
parseTarget
|
||||
error={formErrors.url}
|
||||
tooltip="Provide a URL to deliver a webhook request to."
|
||||
labelTooltipPosition="top-start"
|
||||
helpText="A request will be sent to this URL during the calendar event. Use it to trigger auto-remidiation."
|
||||
/>
|
||||
<RevealButton
|
||||
isShowing={showExamplePayload}
|
||||
className={`${baseClass}__show-example-payload-toggle`}
|
||||
hideText="Hide example payload"
|
||||
showText="Show example payload"
|
||||
caretPosition="after"
|
||||
onClick={() => {
|
||||
setShowExamplePayload(!showExamplePayload);
|
||||
}}
|
||||
/>
|
||||
{showExamplePayload && renderExamplePayload()}
|
||||
{renderPolicies()}
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
onClick={() => {
|
||||
updatePolicyEnabledCalendarEvents(formData);
|
||||
}}
|
||||
className="save-loading"
|
||||
isLoading={isUpdating}
|
||||
disabled={Object.keys(formErrors).length > 0}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onExit} variant="inverse">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (showPreviewCalendarEvent) {
|
||||
return renderPreviewCalendarEventModal();
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
title="Calendar events"
|
||||
onExit={onExit}
|
||||
onEnter={
|
||||
configured
|
||||
? () => {
|
||||
updatePolicyEnabledCalendarEvents(formData);
|
||||
}
|
||||
: onExit
|
||||
}
|
||||
className={baseClass}
|
||||
width="large"
|
||||
>
|
||||
{configured ? renderConfiguredModal() : renderPlaceholderModal()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarEventsModal;
|
@ -0,0 +1,35 @@
|
||||
.calendar-events-modal {
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
line-height: 150%;
|
||||
.modal-cta-wrap {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.button--text-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
&--disabled {
|
||||
@include disabled;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-event-preview {
|
||||
p {
|
||||
margin: 24px 0;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./CalendarEventsModal";
|
@ -0,0 +1,64 @@
|
||||
import React, { useContext } from "react";
|
||||
import { syntaxHighlight } from "utilities/helpers";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { IPolicyWebhookPreviewPayload } from "interfaces/policy";
|
||||
|
||||
const baseClass = "example-payload";
|
||||
|
||||
interface IHostPreview {
|
||||
id: number;
|
||||
display_name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IExamplePayload {
|
||||
timestamp: string;
|
||||
policy: IPolicyWebhookPreviewPayload;
|
||||
hosts: IHostPreview[];
|
||||
}
|
||||
|
||||
const ExamplePayload = (): JSX.Element => {
|
||||
const { isFreeTier } = useContext(AppContext);
|
||||
|
||||
const json: IExamplePayload = {
|
||||
timestamp: "0000-00-00T00:00:00Z",
|
||||
policy: {
|
||||
id: 1,
|
||||
name: "Is Gatekeeper enabled?",
|
||||
query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;",
|
||||
description: "Checks if gatekeeper is enabled on macOS devices.",
|
||||
author_id: 1,
|
||||
author_name: "John",
|
||||
author_email: "john@example.com",
|
||||
resolution: "Turn on Gatekeeper feature in System Preferences.",
|
||||
passing_host_count: 2000,
|
||||
failing_host_count: 300,
|
||||
critical: false,
|
||||
},
|
||||
hosts: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "macbook-1",
|
||||
url: "https://fleet.example.com/hosts/1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
display_name: "macbbook-2",
|
||||
url: "https://fleet.example.com/hosts/2",
|
||||
},
|
||||
],
|
||||
};
|
||||
if (isFreeTier) {
|
||||
delete json.policy.critical;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<pre>POST https://server.com/example</pre>
|
||||
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamplePayload;
|
@ -0,0 +1,9 @@
|
||||
.example-payload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./ExamplePayload";
|
@ -1,28 +1,24 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
|
||||
import { IIntegrationType } from "interfaces/integration";
|
||||
|
||||
import Card from "components/Card";
|
||||
import JiraPreview from "../../../../../../assets/images/jira-policy-automation-preview-400x419@2x.png";
|
||||
import ZendeskPreview from "../../../../../../assets/images/zendesk-policy-automation-preview-400x515@2x.png";
|
||||
import JiraPreviewPremium from "../../../../../../assets/images/jira-policy-automation-preview-premium-400x316@2x.png";
|
||||
import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-policy-automation-preview-premium-400x483@2x.png";
|
||||
|
||||
const baseClass = "preview-ticket-modal";
|
||||
const baseClass = "example-ticket";
|
||||
|
||||
interface IPreviewTicketModalProps {
|
||||
interface IExampleTicketProps {
|
||||
integrationType?: IIntegrationType;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PreviewTicketModal = ({
|
||||
const ExampleTicket = ({
|
||||
integrationType,
|
||||
onCancel,
|
||||
}: IPreviewTicketModalProps): JSX.Element => {
|
||||
}: IExampleTicketProps): JSX.Element => {
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
|
||||
const screenshot =
|
||||
@ -41,30 +37,10 @@ const PreviewTicketModal = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Example ticket"
|
||||
onExit={onCancel}
|
||||
className={baseClass}
|
||||
width="large"
|
||||
>
|
||||
<div className={`${baseClass}`}>
|
||||
<p className="automations-learn-more">
|
||||
Want to learn more about how automations in Fleet work?{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/docs/using-fleet/automations"
|
||||
text=" Check out the Fleet documentation"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
<div className={`${baseClass}__example`}>{screenshot}</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Card className={baseClass} color="gray">
|
||||
{screenshot}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewTicketModal;
|
||||
export default ExampleTicket;
|
@ -0,0 +1,10 @@
|
||||
.example-ticket {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__screenshot {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./ExampleTicket";
|
@ -1 +0,0 @@
|
||||
export { default } from "./ManagePolicyAutomationsModal";
|
@ -3,7 +3,12 @@ import { Link } from "react-router";
|
||||
import { isEmpty, noop, omit } from "lodash";
|
||||
|
||||
import { IAutomationsConfig, IWebhookSettings } from "interfaces/config";
|
||||
import { IIntegration, IIntegrations } from "interfaces/integration";
|
||||
import {
|
||||
IGlobalIntegrations,
|
||||
IIntegration,
|
||||
IZendeskJiraIntegrations,
|
||||
ITeamIntegrations,
|
||||
} from "interfaces/integration";
|
||||
import { IPolicy } from "interfaces/policy";
|
||||
import { ITeamAutomationsConfig } from "interfaces/team";
|
||||
import PATHS from "router/paths";
|
||||
@ -19,22 +24,21 @@ import Dropdown from "components/forms/fields/Dropdown";
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import validUrl from "components/forms/validators/valid_url";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import ExampleTicket from "../ExampleTicket";
|
||||
import ExamplePayload from "../ExamplePayload";
|
||||
|
||||
import PreviewPayloadModal from "../PreviewPayloadModal";
|
||||
import PreviewTicketModal from "../PreviewTicketModal";
|
||||
|
||||
interface IManagePolicyAutomationsModalProps {
|
||||
interface IOtherWorkflowsModalProps {
|
||||
automationsConfig: IAutomationsConfig | ITeamAutomationsConfig;
|
||||
availableIntegrations: IIntegrations;
|
||||
availableIntegrations: IGlobalIntegrations | ITeamIntegrations;
|
||||
availablePolicies: IPolicy[];
|
||||
isUpdatingAutomations: boolean;
|
||||
showPreviewPayloadModal: boolean;
|
||||
onExit: () => void;
|
||||
handleSubmit: (formData: {
|
||||
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
|
||||
integrations: IIntegrations;
|
||||
integrations: IGlobalIntegrations | ITeamIntegrations;
|
||||
}) => void;
|
||||
togglePreviewPayloadModal: () => void;
|
||||
}
|
||||
|
||||
interface ICheckedPolicy {
|
||||
@ -43,7 +47,10 @@ interface ICheckedPolicy {
|
||||
isChecked: boolean;
|
||||
}
|
||||
|
||||
const findEnabledIntegration = ({ jira, zendesk }: IIntegrations) => {
|
||||
const findEnabledIntegration = ({
|
||||
jira,
|
||||
zendesk,
|
||||
}: IZendeskJiraIntegrations) => {
|
||||
return (
|
||||
jira?.find((j) => j.enable_failing_policies) ||
|
||||
zendesk?.find((z) => z.enable_failing_policies)
|
||||
@ -83,18 +90,16 @@ const useCheckboxListStateManagement = (
|
||||
return { policyItems, updatePolicyItems };
|
||||
};
|
||||
|
||||
const baseClass = "manage-policy-automations-modal";
|
||||
const baseClass = "other-workflows-modal";
|
||||
|
||||
const ManagePolicyAutomationsModal = ({
|
||||
const OtherWorkflowsModal = ({
|
||||
automationsConfig,
|
||||
availableIntegrations,
|
||||
availablePolicies,
|
||||
isUpdatingAutomations,
|
||||
showPreviewPayloadModal,
|
||||
onExit,
|
||||
handleSubmit,
|
||||
togglePreviewPayloadModal: togglePreviewModal,
|
||||
}: IManagePolicyAutomationsModalProps): JSX.Element => {
|
||||
}: IOtherWorkflowsModalProps): JSX.Element => {
|
||||
const {
|
||||
webhook_settings: { failing_policies_webhook: webhook },
|
||||
} = automationsConfig;
|
||||
@ -131,6 +136,9 @@ const ManagePolicyAutomationsModal = ({
|
||||
IIntegration | undefined
|
||||
>(serverEnabledIntegration);
|
||||
|
||||
const [showExamplePayload, setShowExamplePayload] = useState(false);
|
||||
const [showExampleTicket, setShowExampleTicket] = useState(false);
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
const { policyItems, updatePolicyItems } = useCheckboxListStateManagement(
|
||||
@ -218,13 +226,6 @@ const ManagePolicyAutomationsModal = ({
|
||||
z.group_id === selectedIntegration?.group_id,
|
||||
})) || null;
|
||||
|
||||
// if (
|
||||
// !isPolicyAutomationsEnabled ||
|
||||
// (!isWebhookEnabled && !selectedIntegration)
|
||||
// ) {
|
||||
// newPolicyIds = [];
|
||||
// }
|
||||
|
||||
const updatedEnabledPoliciesAcrossPages = () => {
|
||||
if (webhook.policy_ids) {
|
||||
// Array of policy ids on the page
|
||||
@ -263,6 +264,7 @@ const ManagePolicyAutomationsModal = ({
|
||||
integrations: {
|
||||
jira: newJira,
|
||||
zendesk: newZendesk,
|
||||
google_calendar: null, // When null, the backend does not update google_calendar
|
||||
},
|
||||
});
|
||||
|
||||
@ -297,34 +299,52 @@ const ManagePolicyAutomationsModal = ({
|
||||
placeholder="https://server.com/example"
|
||||
tooltip="Provide a URL to deliver a webhook request to."
|
||||
/>
|
||||
<Button type="button" variant="text-link" onClick={togglePreviewModal}>
|
||||
Preview payload
|
||||
</Button>
|
||||
<RevealButton
|
||||
isShowing={showExamplePayload}
|
||||
className={baseClass}
|
||||
hideText="Hide example payload"
|
||||
showText="Show example payload"
|
||||
caretPosition="after"
|
||||
onClick={() => setShowExamplePayload(!showExamplePayload)}
|
||||
/>
|
||||
{showExamplePayload && <ExamplePayload />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderIntegrations = () => {
|
||||
return jira?.length || zendesk?.length ? (
|
||||
<div className={`${baseClass}__integrations`}>
|
||||
<Dropdown
|
||||
options={dropdownOptions}
|
||||
onChange={onSelectIntegration}
|
||||
placeholder="Select integration"
|
||||
value={
|
||||
selectedIntegration?.group_id || selectedIntegration?.project_key
|
||||
}
|
||||
label="Integration"
|
||||
error={errors.integration}
|
||||
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
|
||||
hint={
|
||||
"For each policy, Fleet will create a ticket with a list of the failing hosts."
|
||||
}
|
||||
<>
|
||||
<div className={`${baseClass}__integrations`}>
|
||||
<Dropdown
|
||||
options={dropdownOptions}
|
||||
onChange={onSelectIntegration}
|
||||
placeholder="Select integration"
|
||||
value={
|
||||
selectedIntegration?.group_id || selectedIntegration?.project_key
|
||||
}
|
||||
label="Integration"
|
||||
error={errors.integration}
|
||||
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
|
||||
hint={
|
||||
"For each policy, Fleet will create a ticket with a list of the failing hosts."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<RevealButton
|
||||
isShowing={showExampleTicket}
|
||||
className={baseClass}
|
||||
hideText={"Hide example ticket"}
|
||||
showText={"Show example ticket"}
|
||||
caretPosition="after"
|
||||
onClick={() => setShowExampleTicket(!showExampleTicket)}
|
||||
/>
|
||||
<Button type="button" variant="text-link" onClick={togglePreviewModal}>
|
||||
Preview ticket
|
||||
</Button>
|
||||
</div>
|
||||
{showExampleTicket && (
|
||||
<ExampleTicket
|
||||
integrationType={getIntegrationType(selectedIntegration)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={`form-field ${baseClass}__no-integrations`}>
|
||||
<div className="form-field__label">You have no integrations.</div>
|
||||
@ -338,22 +358,10 @@ const ManagePolicyAutomationsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreview = () =>
|
||||
!isWebhookEnabled ? (
|
||||
<PreviewTicketModal
|
||||
integrationType={getIntegrationType(selectedIntegration)}
|
||||
onCancel={togglePreviewModal}
|
||||
/>
|
||||
) : (
|
||||
<PreviewPayloadModal onCancel={togglePreviewModal} />
|
||||
);
|
||||
|
||||
return showPreviewPayloadModal ? (
|
||||
renderPreview()
|
||||
) : (
|
||||
return (
|
||||
<Modal
|
||||
onExit={onExit}
|
||||
title="Manage automations"
|
||||
title="Other workflows"
|
||||
className={baseClass}
|
||||
width="large"
|
||||
>
|
||||
@ -372,12 +380,32 @@ const ManagePolicyAutomationsModal = ({
|
||||
isPolicyAutomationsEnabled ? "enabled" : "disabled"
|
||||
}`}
|
||||
>
|
||||
<div className={`form-field ${baseClass}__workflow`}>
|
||||
<div className="form-field__label">Workflow</div>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Ticket"
|
||||
id="ticket-radio-btn"
|
||||
checked={!isWebhookEnabled}
|
||||
value="ticket"
|
||||
name="ticket"
|
||||
onChange={onChangeRadio}
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Webhook"
|
||||
id="webhook-radio-btn"
|
||||
checked={isWebhookEnabled}
|
||||
value="webhook"
|
||||
name="webhook"
|
||||
onChange={onChangeRadio}
|
||||
/>
|
||||
</div>
|
||||
{isWebhookEnabled ? renderWebhook() : renderIntegrations()}
|
||||
<div className="form-field">
|
||||
{availablePolicies?.length ? (
|
||||
<>
|
||||
<div className="form-field__label">
|
||||
Choose which policies you would like to listen to:
|
||||
</div>
|
||||
<div className="form-field__label">Policies:</div>
|
||||
{policyItems &&
|
||||
policyItems.map((policyItem) => {
|
||||
const { isChecked, name, id } = policyItem;
|
||||
@ -405,28 +433,14 @@ const ManagePolicyAutomationsModal = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`form-field ${baseClass}__workflow`}>
|
||||
<div className="form-field__label">Workflow</div>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Ticket"
|
||||
id="ticket-radio-btn"
|
||||
checked={!isWebhookEnabled}
|
||||
value="ticket"
|
||||
name="ticket"
|
||||
onChange={onChangeRadio}
|
||||
<p className={`${baseClass}__help-text`}>
|
||||
The workflow will be triggered when hosts fail these policies.{" "}
|
||||
<CustomLink
|
||||
url="https://www.fleetdm.com/learn-more-about/policy-automations"
|
||||
text="Learn more"
|
||||
newTab
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Webhook"
|
||||
id="webhook-radio-btn"
|
||||
checked={isWebhookEnabled}
|
||||
value="webhook"
|
||||
name="webhook"
|
||||
onChange={onChangeRadio}
|
||||
/>
|
||||
</div>
|
||||
{isWebhookEnabled ? renderWebhook() : renderIntegrations()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
@ -447,4 +461,4 @@ const ManagePolicyAutomationsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePolicyAutomationsModal;
|
||||
export default OtherWorkflowsModal;
|
@ -1,12 +1,9 @@
|
||||
.manage-policy-automations-modal {
|
||||
.other-workflows-modal {
|
||||
pre,
|
||||
code {
|
||||
background-color: $ui-off-white;
|
||||
color: $core-fleet-blue;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: $border-radius;
|
||||
padding: 7px $pad-medium;
|
||||
margin: $pad-large 0 0 44px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
@ -22,4 +19,8 @@
|
||||
&__no-integrations a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__help-text {
|
||||
@include help-text;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./OtherWorkflowsModal";
|
@ -284,16 +284,6 @@ const generateTableHeaders = (
|
||||
];
|
||||
|
||||
if (tableType !== "inheritedPolicies") {
|
||||
tableHeaders.push({
|
||||
title: "Automations",
|
||||
Header: "Automations",
|
||||
disableSortBy: true,
|
||||
accessor: "webhook",
|
||||
Cell: (cellProps: ICellProps): JSX.Element => (
|
||||
<StatusIndicator value={cellProps.cell.value} />
|
||||
),
|
||||
});
|
||||
|
||||
if (!canAddOrDeletePolicy) {
|
||||
return tableHeaders;
|
||||
}
|
||||
|
@ -1,97 +0,0 @@
|
||||
import React, { useContext } from "react";
|
||||
import { syntaxHighlight } from "utilities/helpers";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import { AppContext } from "context/app";
|
||||
import { IPolicyWebhookPreviewPayload } from "interfaces/policy";
|
||||
|
||||
const baseClass = "preview-data-modal";
|
||||
|
||||
interface IPreviewPayloadModalProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface IHostPreview {
|
||||
id: number;
|
||||
display_name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IPreviewPayload {
|
||||
timestamp: string;
|
||||
policy: IPolicyWebhookPreviewPayload;
|
||||
hosts: IHostPreview[];
|
||||
}
|
||||
|
||||
const PreviewPayloadModal = ({
|
||||
onCancel,
|
||||
}: IPreviewPayloadModalProps): JSX.Element => {
|
||||
const { isFreeTier } = useContext(AppContext);
|
||||
|
||||
const json: IPreviewPayload = {
|
||||
timestamp: "0000-00-00T00:00:00Z",
|
||||
policy: {
|
||||
id: 1,
|
||||
name: "Is Gatekeeper enabled?",
|
||||
query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;",
|
||||
description: "Checks if gatekeeper is enabled on macOS devices.",
|
||||
author_id: 1,
|
||||
author_name: "John",
|
||||
author_email: "john@example.com",
|
||||
resolution: "Turn on Gatekeeper feature in System Preferences.",
|
||||
passing_host_count: 2000,
|
||||
failing_host_count: 300,
|
||||
critical: false,
|
||||
},
|
||||
hosts: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "macbook-1",
|
||||
url: "https://fleet.example.com/hosts/1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
display_name: "macbbook-2",
|
||||
url: "https://fleet.example.com/hosts/2",
|
||||
},
|
||||
],
|
||||
};
|
||||
if (isFreeTier) {
|
||||
delete json.policy.critical;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Example payload"
|
||||
onExit={onCancel}
|
||||
onEnter={onCancel}
|
||||
className={baseClass}
|
||||
>
|
||||
<div className={`${baseClass}__preview-modal`}>
|
||||
<p>
|
||||
Want to learn more about how automations in Fleet work?{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/docs/using-fleet/automations"
|
||||
text="Check out the Fleet documentation"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
<div className={`${baseClass}__payload-request-preview`}>
|
||||
<pre>POST https://server.com/example</pre>
|
||||
</div>
|
||||
<div className={`${baseClass}__payload-webhook-preview`}>
|
||||
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel} variant="brand">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewPayloadModal;
|
@ -1,54 +0,0 @@
|
||||
.preview-payload-modal {
|
||||
&__sandbox-info {
|
||||
margin-top: $pad-medium;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
margin-bottom: $pad-medium;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__info-header {
|
||||
font-weight: $bold;
|
||||
}
|
||||
|
||||
&__advanced-options-button {
|
||||
margin: $pad-medium 0;
|
||||
color: $core-vibrant-blue;
|
||||
font-weight: $bold;
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
.downcaret {
|
||||
&::after {
|
||||
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
|
||||
transform: scale(0.5);
|
||||
width: 16px;
|
||||
border-radius: 0px;
|
||||
padding: 0px;
|
||||
padding-left: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.upcaret {
|
||||
&::after {
|
||||
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
|
||||
transform: scale(0.5) rotate(180deg);
|
||||
width: 16px;
|
||||
border-radius: 0px;
|
||||
padding: 0px;
|
||||
padding-left: 2px;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-value-label {
|
||||
font-size: $small;
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./PreviewPayloadModal";
|
@ -1,13 +0,0 @@
|
||||
.preview-ticket-modal {
|
||||
&__example {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__screenshot {
|
||||
width: 400px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
filter: drop-shadow(0px 4px 16px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./PreviewTicketModal";
|
@ -33,6 +33,7 @@ export default {
|
||||
ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`,
|
||||
ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`,
|
||||
ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`,
|
||||
ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`,
|
||||
ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`,
|
||||
|
@ -87,6 +87,7 @@ export default {
|
||||
resolution,
|
||||
platform,
|
||||
critical,
|
||||
calendar_events_enabled,
|
||||
} = data;
|
||||
const { TEAMS } = endpoints;
|
||||
const path = `${TEAMS}/${team_id}/policies/${id}`;
|
||||
@ -98,6 +99,7 @@ export default {
|
||||
resolution,
|
||||
platform,
|
||||
critical,
|
||||
calendar_events_enabled,
|
||||
});
|
||||
},
|
||||
destroy: (teamId: number | undefined, ids: number[]) => {
|
||||
|
@ -5,7 +5,7 @@ import { pick } from "lodash";
|
||||
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import { IEnrollSecret } from "interfaces/enroll_secret";
|
||||
import { IIntegrations } from "interfaces/integration";
|
||||
import { ITeamIntegrations } from "interfaces/integration";
|
||||
import {
|
||||
API_NO_TEAM_ID,
|
||||
INewTeamUsersBody,
|
||||
@ -39,7 +39,7 @@ export interface ITeamFormData {
|
||||
export interface IUpdateTeamFormData {
|
||||
name: string;
|
||||
webhook_settings: Partial<ITeamWebhookSettings>;
|
||||
integrations: IIntegrations;
|
||||
integrations: ITeamIntegrations;
|
||||
mdm: {
|
||||
macos_updates?: {
|
||||
minimum_version: string;
|
||||
@ -118,7 +118,7 @@ export default {
|
||||
requestBody.webhook_settings = webhook_settings;
|
||||
}
|
||||
if (integrations) {
|
||||
const { jira, zendesk } = integrations;
|
||||
const { jira, zendesk, google_calendar } = integrations;
|
||||
const teamIntegrationProps = [
|
||||
"enable_failing_policies",
|
||||
"group_id",
|
||||
@ -128,6 +128,7 @@ export default {
|
||||
requestBody.integrations = {
|
||||
jira: jira?.map((j) => pick(j, teamIntegrationProps)),
|
||||
zendesk: zendesk?.map((z) => pick(z, teamIntegrationProps)),
|
||||
google_calendar,
|
||||
};
|
||||
}
|
||||
if (mdm) {
|
||||
|
@ -182,6 +182,15 @@ $max-width: 2560px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin copy-message {
|
||||
font-weight: $regular;
|
||||
vertical-align: top;
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
@mixin color-contrasted-sections {
|
||||
background-color: $ui-off-white;
|
||||
.section {
|
||||
@ -227,3 +236,102 @@ $max-width: 2560px;
|
||||
// compensate in layout for extra clickable area button height
|
||||
margin: -8px 0;
|
||||
}
|
||||
|
||||
@mixin button-dropdown {
|
||||
.form-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Select {
|
||||
position: relative;
|
||||
border: 0;
|
||||
height: auto;
|
||||
|
||||
&.is-focused,
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.is-focused:not(.is-open) {
|
||||
.Select-control {
|
||||
background-color: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-control {
|
||||
display: flex;
|
||||
background-color: initial;
|
||||
height: auto;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:hover .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
padding-left: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.Select-input {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.Select-arrow-zone {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Select-menu-outer {
|
||||
margin-top: $pad-xsmall;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
border-radius: $border-radius;
|
||||
z-index: 6;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
width: 188px;
|
||||
left: unset;
|
||||
top: unset;
|
||||
max-height: none;
|
||||
padding: $pad-small;
|
||||
position: absolute;
|
||||
|
||||
.Select-menu {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&:not(.is-open) {
|
||||
.Select-control:hover .Select-arrow {
|
||||
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
.Select-control .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -283,7 +283,6 @@ You can confirm that the device has been ordered correctly by following these st
|
||||
- Use the device serial number to find the device.
|
||||
- Note: if the device cannot be found, you will need to manually enroll the device.
|
||||
- View device settings and ensure the "MDM Server" selected is "Fleet Dogfood".
|
||||
<img width="143" alt="Screenshot 2023-11-21 at 11 08 50 AM" src="https://github.com/fleetdm/confidential/assets/47070608/512dc629-76dd-4090-bf86-9c4582286d1d">
|
||||
|
||||
On occasion there will be a need to manually enroll a macOS host in dogfood. This could be due to a BYOD arrangement, or because the Fleetie getting the device is in a country when DEP (automatic enrollment) isn't supported. To manually enroll a macOS host in dogfood, follow these steps:
|
||||
- If you have physical access to the macOS host, use Apple Configurator (docs are [here](https://support.apple.com/guide/apple-business-manager/add-devices-from-apple-configurator-axm200a54d59/web)).
|
||||
|
@ -131,7 +131,7 @@ Besides the exceptions above, Fleet does not use any other repositories. Other
|
||||
## Why not continuously generate REST API reference docs from javadoc-style code comments?
|
||||
Here are a few of the drawbacks that we have experienced when generating docs via tools like Swagger or OpenAPI, and some of the advantages of doing it by hand with Markdown.
|
||||
|
||||
- Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we present the information to the end-user.
|
||||
- Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we [present the information to the end-user](https://x.com/wesleytodd/status/1769810305448616185?s=46&t=4_cwTxqV5IXDLBvCm8KI6Q).
|
||||
- Markdown is more accessible. Anyone can edit Fleet's docs directly from our website without needing coding experience.
|
||||
- A single Markdown file reduces the amount of surface area to manage that comes from spreading code comments across multiple files throughout the codebase. (see ["Why do we use one repo?"](#why-do-we-use-one-repo)).
|
||||
- Autogenerated docs can become just as outdated as handmade docs, except since they are siloed, they require more skills to edit.
|
||||
@ -247,7 +247,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/
|
||||
|
||||
|
||||
## Why don't we sell like everyone else?
|
||||
Many companies encourage salespeople to "spray and pray" email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach:
|
||||
Many companies encourage salespeople to ["spray and pray"](https://www.linkedin.com/posts/amstech_the-rampant-abuse-of-linkedin-connections-activity-7178412289413246978-Ci0I?utm_source=share&utm_medium=member_ios) email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach:
|
||||
- **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md).
|
||||
- **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win.
|
||||
- **Engineers first.** We always talk to engineers first, and learn how it's going. Security and IT engineers are the people closest to the work, and the people best positioned to know what their organizations need.
|
||||
|
8
it-and-security/lib/windows-install-bitdefender.ps1
Normal file
8
it-and-security/lib/windows-install-bitdefender.ps1
Normal file
@ -0,0 +1,8 @@
|
||||
$ResolveWingetPath = Resolve-Path "C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe"
|
||||
if ($ResolveWingetPath){
|
||||
$WingetPath = $ResolveWingetPath[-1].Path
|
||||
}
|
||||
|
||||
$config
|
||||
Set-Location $wingetpath
|
||||
.\winget.exe install --id=Bitdefender.Bitdefender -e -h --accept-package-agreements --accept-source-agreements --disable-interactivity
|
@ -53,6 +53,7 @@ controls:
|
||||
- path: ../lib/macos-remove-old-nudge.sh
|
||||
- path: ../lib/windows-remove-fleetd.ps1
|
||||
- path: ../lib/windows-turn-off-mdm.ps1
|
||||
- path: ../lib/windows-install-bitdefender.ps1
|
||||
policies:
|
||||
- path: ../lib/macos-device-health.policies.yml
|
||||
- path: ../lib/windows-device-health.policies.yml
|
||||
|
238
server/datastore/mysql/calendar_events.go
Normal file
238
server/datastore/mysql/calendar_events.go
Normal file
@ -0,0 +1,238 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func (ds *Datastore) CreateOrUpdateCalendarEvent(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
startTime time.Time,
|
||||
endTime time.Time,
|
||||
data []byte,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
var id int64
|
||||
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
const calendarEventsQuery = `
|
||||
INSERT INTO calendar_events (
|
||||
email,
|
||||
start_time,
|
||||
end_time,
|
||||
event
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
start_time = VALUES(start_time),
|
||||
end_time = VALUES(end_time),
|
||||
event = VALUES(event),
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
`
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
calendarEventsQuery,
|
||||
email,
|
||||
startTime,
|
||||
endTime,
|
||||
data,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert calendar event")
|
||||
}
|
||||
|
||||
if insertOnDuplicateDidInsert(result) {
|
||||
id, _ = result.LastInsertId()
|
||||
} else {
|
||||
stmt := `SELECT id FROM calendar_events WHERE email = ?`
|
||||
if err := sqlx.GetContext(ctx, tx, &id, stmt, email); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "query mdm solution id")
|
||||
}
|
||||
}
|
||||
|
||||
const hostCalendarEventsQuery = `
|
||||
INSERT INTO host_calendar_events (
|
||||
host_id,
|
||||
calendar_event_id,
|
||||
webhook_status
|
||||
) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
webhook_status = VALUES(webhook_status),
|
||||
calendar_event_id = VALUES(calendar_event_id);
|
||||
`
|
||||
result, err = tx.ExecContext(
|
||||
ctx,
|
||||
hostCalendarEventsQuery,
|
||||
hostID,
|
||||
id,
|
||||
webhookStatus,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert host calendar event")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
calendarEvent, err := getCalendarEventByID(ctx, ds.writer(ctx), uint(id))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get created calendar event by id")
|
||||
}
|
||||
return calendarEvent, nil
|
||||
}
|
||||
|
||||
func getCalendarEventByID(ctx context.Context, q sqlx.QueryerContext, id uint) (*fleet.CalendarEvent, error) {
|
||||
const calendarEventsQuery = `
|
||||
SELECT * FROM calendar_events WHERE id = ?;
|
||||
`
|
||||
var calendarEvent fleet.CalendarEvent
|
||||
err := sqlx.GetContext(ctx, q, &calendarEvent, calendarEventsQuery, id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithID(id))
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "get calendar event")
|
||||
}
|
||||
return &calendarEvent, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) {
|
||||
const calendarEventsQuery = `
|
||||
SELECT * FROM calendar_events WHERE email = ?;
|
||||
`
|
||||
var calendarEvent fleet.CalendarEvent
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, email)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("email: %s", email)))
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "get calendar event")
|
||||
}
|
||||
return &calendarEvent, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error {
|
||||
const calendarEventsQuery = `
|
||||
UPDATE calendar_events SET
|
||||
start_time = ?,
|
||||
end_time = ?,
|
||||
event = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
`
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, calendarEventID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update calendar event")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error {
|
||||
const calendarEventsQuery = `
|
||||
DELETE FROM calendar_events WHERE id = ?;
|
||||
`
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, calendarEventID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "delete calendar event")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
const hostCalendarEventsQuery = `
|
||||
SELECT * FROM host_calendar_events WHERE host_id = ?
|
||||
`
|
||||
var hostCalendarEvent fleet.HostCalendarEvent
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostCalendarEvent, hostCalendarEventsQuery, hostID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, ctxerr.Wrap(ctx, notFound("HostCalendarEvent").WithMessage(fmt.Sprintf("host_id: %d", hostID)))
|
||||
}
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get host calendar event")
|
||||
}
|
||||
const calendarEventsQuery = `
|
||||
SELECT * FROM calendar_events WHERE id = ?
|
||||
`
|
||||
var calendarEvent fleet.CalendarEvent
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, hostCalendarEvent.CalendarEventID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithID(hostCalendarEvent.CalendarEventID))
|
||||
}
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get calendar event")
|
||||
}
|
||||
return &hostCalendarEvent, &calendarEvent, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostCalendarEventByEmail(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
const calendarEventsQuery = `
|
||||
SELECT * FROM calendar_events WHERE email = ?
|
||||
`
|
||||
var calendarEvent fleet.CalendarEvent
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, email); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("email: %s", email)))
|
||||
}
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get calendar event")
|
||||
}
|
||||
const hostCalendarEventsQuery = `
|
||||
SELECT * FROM host_calendar_events WHERE calendar_event_id = ?
|
||||
`
|
||||
var hostCalendarEvent fleet.HostCalendarEvent
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostCalendarEvent, hostCalendarEventsQuery, calendarEvent.ID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, ctxerr.Wrap(ctx, notFound("HostCalendarEvent").WithID(calendarEvent.ID))
|
||||
}
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get host calendar event")
|
||||
}
|
||||
return &hostCalendarEvent, &calendarEvent, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status fleet.CalendarWebhookStatus) error {
|
||||
const calendarEventsQuery = `
|
||||
UPDATE host_calendar_events SET
|
||||
webhook_status = ?
|
||||
WHERE host_id = ?;
|
||||
`
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, status, hostID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update host calendar event webhook status")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListCalendarEvents(ctx context.Context, teamID *uint) ([]*fleet.CalendarEvent, error) {
|
||||
calendarEventsQuery := `
|
||||
SELECT ce.* FROM calendar_events ce
|
||||
`
|
||||
|
||||
var args []interface{}
|
||||
if teamID != nil {
|
||||
// TODO(lucas): Should we add a team_id column to calendar_events?
|
||||
calendarEventsQuery += ` JOIN host_calendar_events hce ON ce.id=hce.calendar_event_id
|
||||
JOIN hosts h ON h.id=hce.host_id WHERE h.team_id = ?`
|
||||
args = append(args, *teamID)
|
||||
}
|
||||
|
||||
var calendarEvents []*fleet.CalendarEvent
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &calendarEvents, calendarEventsQuery, args...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "get all calendar events")
|
||||
}
|
||||
return calendarEvents, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListOutOfDateCalendarEvents(ctx context.Context, t time.Time) ([]*fleet.CalendarEvent, error) {
|
||||
calendarEventsQuery := `
|
||||
SELECT ce.* FROM calendar_events ce WHERE updated_at < ?
|
||||
`
|
||||
var calendarEvents []*fleet.CalendarEvent
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &calendarEvents, calendarEventsQuery, t); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get all calendar events")
|
||||
}
|
||||
return calendarEvents, nil
|
||||
}
|
128
server/datastore/mysql/calendar_events_test.go
Normal file
128
server/datastore/mysql/calendar_events_test.go
Normal file
@ -0,0 +1,128 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCalendarEvents(t *testing.T) {
|
||||
ds := CreateMySQLDS(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, ds *Datastore)
|
||||
}{
|
||||
{"UpdateCalendarEvent", testUpdateCalendarEvent},
|
||||
{"CreateOrUpdateCalendarEvent", testCreateOrUpdateCalendarEvent},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer TruncateTables(t, ds)
|
||||
|
||||
c.fn(t, ds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testUpdateCalendarEvent(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := ds.NewHost(context.Background(), &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
NodeKey: ptr.String("1"),
|
||||
UUID: "1",
|
||||
Hostname: "foo.local",
|
||||
PrimaryIP: "192.168.1.1",
|
||||
PrimaryMac: "30-65-EC-6F-C4-58",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{
|
||||
{
|
||||
HostID: host.ID,
|
||||
Email: "foo@example.com",
|
||||
Source: "google_chrome_profiles",
|
||||
},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
|
||||
startTime1 := time.Now()
|
||||
endTime1 := startTime1.Add(30 * time.Minute)
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`))
|
||||
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
|
||||
require.Equal(t, *calendarEvent, *calendarEvent2)
|
||||
|
||||
// TODO(lucas): Add more tests here.
|
||||
}
|
||||
|
||||
func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := ds.NewHost(context.Background(), &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
NodeKey: ptr.String("1"),
|
||||
UUID: "1",
|
||||
Hostname: "foo.local",
|
||||
PrimaryIP: "192.168.1.1",
|
||||
PrimaryMac: "30-65-EC-6F-C4-58",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{
|
||||
{
|
||||
HostID: host.ID,
|
||||
Email: "foo@example.com",
|
||||
Source: "google_chrome_profiles",
|
||||
},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
|
||||
startTime1 := time.Now()
|
||||
endTime1 := startTime1.Add(30 * time.Minute)
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, calendarEvent2.UpdatedAt, calendarEvent.UpdatedAt)
|
||||
calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt
|
||||
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"}`), host.ID, fleet.CalendarWebhookStatusPending)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, calendarEvent3.UpdatedAt, calendarEvent2.UpdatedAt)
|
||||
require.WithinDuration(t, startTime2, calendarEvent3.StartTime, 1*time.Second)
|
||||
require.WithinDuration(t, endTime2, calendarEvent3.EndTime, 1*time.Second)
|
||||
require.Equal(t, string(calendarEvent3.Data), `{"foo": "bar"}`)
|
||||
|
||||
calendarEvent3b, err := ds.GetCalendarEvent(ctx, "foo@example.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, calendarEvent3, calendarEvent3b)
|
||||
|
||||
// TODO(lucas): Add more tests here.
|
||||
}
|
@ -502,6 +502,7 @@ var hostRefs = []string{
|
||||
"query_results",
|
||||
"host_activities",
|
||||
"host_mdm_actions",
|
||||
"host_calendar_events",
|
||||
}
|
||||
|
||||
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not
|
||||
|
@ -2554,7 +2554,6 @@ func testHostLiteByIdentifierAndID(t *testing.T, ds *Datastore) {
|
||||
h, err = ds.HostLiteByID(context.Background(), 0)
|
||||
assert.ErrorIs(t, err, sql.ErrNoRows)
|
||||
assert.Nil(t, h)
|
||||
|
||||
}
|
||||
|
||||
func testHostsAddToTeam(t *testing.T, ds *Datastore) {
|
||||
@ -2795,7 +2794,6 @@ func testHostsTotalAndUnseenSince(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, 2, total)
|
||||
require.Len(t, unseen, 1)
|
||||
assert.Equal(t, host3.ID, unseen[0])
|
||||
|
||||
}
|
||||
|
||||
func testHostsListByPolicy(t *testing.T, ds *Datastore) {
|
||||
@ -6577,6 +6575,23 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
||||
`, host.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a calendar event for the host.
|
||||
_, err = ds.writer(context.Background()).Exec(`
|
||||
INSERT INTO calendar_events (email, start_time, end_time, event)
|
||||
VALUES ('foobar@example.com', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, '{}');
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
var calendarEventID int
|
||||
err = ds.writer(context.Background()).Get(&calendarEventID, `
|
||||
SELECT id FROM calendar_events WHERE email = 'foobar@example.com';
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
_, err = ds.writer(context.Background()).Exec(`
|
||||
INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status)
|
||||
VALUES (?, ?, 1);
|
||||
`, host.ID, calendarEventID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check there's an entry for the host in all the associated tables.
|
||||
for _, hostRef := range hostRefs {
|
||||
var ok bool
|
||||
|
@ -0,0 +1,54 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240314085226, Down_20240314085226)
|
||||
}
|
||||
|
||||
func Up_20240314085226(tx *sql.Tx) error {
|
||||
// TODO(lucas): Check if we need more indexes.
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
end_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
event JSON NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY idx_one_calendar_event_per_email (email)
|
||||
);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create calendar_events table: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_calendar_events (
|
||||
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
host_id INT(10) UNSIGNED NOT NULL,
|
||||
calendar_event_id INT(10) UNSIGNED NOT NULL,
|
||||
webhook_status TINYINT NOT NULL,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY idx_one_calendar_event_per_host (host_id),
|
||||
FOREIGN KEY (calendar_event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create host_calendar_events table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20240314085226(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user