mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Add host's next maintenance window to the hosts/{id} and hosts/identifier/{identifier} endpoints, and render that data on the host details page (#19820)
## Addresses full stack for #18554 - Add new `timezone` column to `calendar_events` table - When fetched from Google's API, save calendar user's timezone in this new column along with rest of event data - Implement datastore method to retrieve the start time and timezone for a host's next calendar event as a `HostMaintenanceWindow` - Localize and add UTC offset to the `HostMaintenanceWindow`'s start time according to its `timezone` - Include the processed `HostMaintenanceWindow`, if present, in the response to the `GET` `hosts/{id}` and `hosts/identifier/{identifier}` endpoints - Implement UI on the host details page to display this data - Add new and update existing UI, core integration, datastore, and `fleetctl` tests - Update `date-fns` package to the latest version <img width="1062" alt="Screenshot 2024-06-26 at 1 02 34 PM" src="https://github.com/fleetdm/fleet/assets/61553566/c3ddad97-23da-42c1-b4ed-b7615ec88aed"> # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/` - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified tables for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
parent
f26acee2e1
commit
91b9c4a107
35 changed files with 503 additions and 62 deletions
|
|
@ -0,0 +1,2 @@
|
|||
- Show a host's upcoming scheduled maintenance window, if any, on the host details page of the UI
|
||||
and in host responses from the API.
|
||||
|
|
@ -331,6 +331,9 @@ func TestGetHosts(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) (batteries []*fleet.HostBattery, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
defaultPolicyQuery := "select 1 from osquery_info where start_time > 1;"
|
||||
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
||||
return []*fleet.HostPolicy{
|
||||
|
|
@ -517,6 +520,9 @@ func TestGetHostsMDM(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) (batteries []*fleet.HostBattery, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,9 @@ func TestMDMRunCommand(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -559,6 +562,9 @@ func TestMDMLockCommand(t *testing.T) {
|
|||
return mdmInfo != nil && mdmInfo.Enrolled == true && mdmInfo.Name == fleet.WellKnownMDMFleet, nil
|
||||
}
|
||||
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs()
|
||||
|
||||
successfulOutput := func(ident string) string {
|
||||
|
|
@ -832,7 +838,9 @@ func TestMDMUnlockCommand(t *testing.T) {
|
|||
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
||||
return host.MDM.ConnectedToFleet != nil && *host.MDM.ConnectedToFleet, nil
|
||||
}
|
||||
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs()
|
||||
|
||||
successfulOutput := func(ident string) string {
|
||||
|
|
@ -1179,6 +1187,9 @@ func TestMDMWipeCommand(t *testing.T) {
|
|||
|
||||
return h.mdmInfo, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// This function should only run on linux
|
||||
ds.GetHostOrbitInfoFunc = func(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) {
|
||||
|
|
@ -1340,6 +1351,9 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ func TestRunScriptCommand(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ScriptsDisabled: false}}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -518,21 +518,22 @@ func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time
|
|||
return eventStart, eventEnd, isLastSlot, conflict
|
||||
}
|
||||
|
||||
func getTimezone(gCal *GoogleCalendar) (*time.Location, error) {
|
||||
func getTimezone(gCal *GoogleCalendar) (location *time.Location, err error) {
|
||||
config := gCal.config
|
||||
setting, err := config.API.GetSetting("timezone")
|
||||
// "The ID of the user’s timezone." https://developers.google.com/calendar/api/v3/reference/settings
|
||||
tz, 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
|
||||
return getLocation(tz.Value, config), nil
|
||||
}
|
||||
|
||||
func getLocation(name string, config *GoogleCalendarConfig) *time.Location {
|
||||
loc, err := time.LoadLocation(name)
|
||||
func getLocation(tz string, config *GoogleCalendarConfig) *time.Location {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
// Could not load location, use EST
|
||||
level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", name, "err", err)
|
||||
level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", tz, "err", err)
|
||||
loc, _ = time.LoadLocation("America/New_York")
|
||||
}
|
||||
return loc
|
||||
|
|
@ -545,6 +546,7 @@ func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime ti
|
|||
fleetEvent.StartTime = startTime
|
||||
fleetEvent.EndTime = endTime
|
||||
fleetEvent.Email = c.currentUserEmail
|
||||
fleetEvent.TimeZone = c.location.String()
|
||||
details := &eventDetails{
|
||||
ID: event.Id,
|
||||
ETag: event.Etag,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { abbreviateTimeUnits } from "utilities/helpers";
|
||||
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
|
|
|||
|
|
@ -178,6 +178,11 @@ export interface IHostMdmData {
|
|||
connected_to_fleet?: boolean;
|
||||
}
|
||||
|
||||
export interface IHostMaintenanceWindow {
|
||||
starts_at: string;
|
||||
timezone: string | null;
|
||||
}
|
||||
|
||||
export interface IMunkiIssue {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
@ -315,6 +320,7 @@ export interface IHost {
|
|||
users: IHostUser[];
|
||||
device_users?: IDeviceUser[];
|
||||
munki?: IMunkiData;
|
||||
maintenance_window?: IHostMaintenanceWindow;
|
||||
mdm: IHostMdmData;
|
||||
policies: IHostPolicy[];
|
||||
query_results?: unknown[];
|
||||
|
|
|
|||
|
|
@ -239,4 +239,38 @@ describe("Host Summary section", () => {
|
|||
expect(screen.queryByText("Osquery")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe("Maintenance window data", () => {
|
||||
it("renders maintenance window data with timezone", async () => {
|
||||
const render = createCustomRenderer({
|
||||
context: {
|
||||
app: {
|
||||
isPremiumTier: true,
|
||||
isGlobalAdmin: true,
|
||||
currentUser: createMockUser(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const summaryData = createMockHostSummary({
|
||||
maintenance_window: {
|
||||
starts_at: "3025-06-24T20:48:14-03:00",
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
},
|
||||
});
|
||||
const prettyStartTime = /Jun 24 at 8:48 PM/;
|
||||
|
||||
render(
|
||||
<HostSummary
|
||||
summaryData={summaryData}
|
||||
showRefetchSpinner={false}
|
||||
onRefetchHost={noop}
|
||||
renderActionDropdown={() => null}
|
||||
isPremiumTier
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Scheduled maintenance")).toBeInTheDocument();
|
||||
expect(screen.getByText(prettyStartTime)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React from "react";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import classnames from "classnames";
|
||||
|
||||
import { formatInTimeZone } from "date-fns-tz";
|
||||
import {
|
||||
IHostMdmProfile,
|
||||
BootstrapPackageStatus,
|
||||
isWindowsDiskEncryptionStatus,
|
||||
} from "interfaces/mdm";
|
||||
import { IOSSettings } from "interfaces/host";
|
||||
import { IOSSettings, IHostMaintenanceWindow } from "interfaces/host";
|
||||
import getHostStatusTooltipText from "pages/hosts/helpers";
|
||||
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
|
@ -19,9 +19,11 @@ import StatusIndicator from "components/StatusIndicator";
|
|||
import IssuesIndicator from "pages/hosts/components/IssuesIndicator";
|
||||
import DiskSpaceIndicator from "pages/hosts/components/DiskSpaceIndicator";
|
||||
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
|
||||
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
|
||||
import { humanHostMemory, wrapFleetHelper } from "utilities/helpers";
|
||||
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
|
||||
import {
|
||||
DATE_FNS_FORMAT_STRINGS,
|
||||
DEFAULT_EMPTY_CELL_VALUE,
|
||||
} from "utilities/constants";
|
||||
import { COLORS } from "styles/var/colors";
|
||||
|
||||
import OSSettingsIndicator from "./OSSettingsIndicator";
|
||||
|
|
@ -349,6 +351,43 @@ const HostSummary = ({
|
|||
return <DataSet title="Osquery" value={summaryData.osquery_version} />;
|
||||
};
|
||||
|
||||
const renderMaintenanceWindow = ({
|
||||
starts_at,
|
||||
timezone,
|
||||
}: IHostMaintenanceWindow) => {
|
||||
const formattedStartsAt = formatInTimeZone(
|
||||
starts_at,
|
||||
// since startsAt is already localized and contains offset information, this 2nd parameter is
|
||||
// logically redundant. It's included here to allow use of date-fns-tz.formatInTimeZone instead of date-fns.format, which
|
||||
// allows us to format a UTC datetime without converting to the user-agent local time.
|
||||
timezone || "UTC",
|
||||
DATE_FNS_FORMAT_STRINGS.dateAtTime
|
||||
);
|
||||
|
||||
const tip = timezone ? (
|
||||
<>
|
||||
End user's time zone:
|
||||
<br />
|
||||
(GMT{starts_at.slice(-6)}) {timezone.replace("_", " ")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
End user's timezone unavailable.
|
||||
<br />
|
||||
Displaying in UTC.
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DataSet
|
||||
title="Scheduled maintenance"
|
||||
value={
|
||||
<TooltipWrapper tipContent={tip}>{formattedStartsAt}</TooltipWrapper>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSummary = () => {
|
||||
// for windows hosts we have to manually add a profile for disk encryption
|
||||
// as this is not currently included in the `profiles` value from the API
|
||||
|
|
@ -432,6 +471,11 @@ const HostSummary = ({
|
|||
)}
|
||||
<DataSet title="Operating system" value={summaryData.os_version} />
|
||||
{!isIosOrIpadosHost && renderAgentSummary()}
|
||||
{isPremiumTier &&
|
||||
// TODO - refactor normalizeEmptyValues pattern
|
||||
!!summaryData.maintenance_window &&
|
||||
summaryData.maintenance_window !== "---" &&
|
||||
renderMaintenanceWindow(summaryData.maintenance_window)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -103,4 +103,7 @@
|
|||
color: $ui-fleet-black-50;
|
||||
}
|
||||
}
|
||||
.data-set dd {
|
||||
overflow: initial;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { capitalize } from "lodash";
|
||||
|
||||
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { abbreviateTimeUnits } from "utilities/helpers";
|
||||
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// definitions for the selection row for some reason when we dont really need it.
|
||||
import React from "react";
|
||||
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns";
|
||||
|
||||
// @ts-ignore
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
// disable this rule as it was throwing an error in Header and Cell component
|
||||
// definitions for the selection row for some reason when we dont really need it.
|
||||
import React from "react";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import formatDistanceToNow from "date-fns/formatDistanceToNow";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import PATHS from "router/paths";
|
||||
|
||||
import permissionsUtils from "utilities/permissions";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React from "react";
|
||||
|
||||
import differenceInSeconds from "date-fns/differenceInSeconds";
|
||||
import formatDistance from "date-fns/formatDistance";
|
||||
import add from "date-fns/add";
|
||||
import { add, differenceInSeconds, formatDistance } from "date-fns";
|
||||
|
||||
import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper";
|
||||
import EmptyTable from "components/EmptyTable/EmptyTable";
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ export const HOST_SUMMARY_DATA = [
|
|||
"team_name",
|
||||
"disk_encryption_enabled",
|
||||
"display_name", // Not rendered on my device page
|
||||
"maintenance_window", // Not rendered on my device page
|
||||
];
|
||||
|
||||
export const HOST_ABOUT_DATA = [
|
||||
|
|
@ -406,3 +407,8 @@ export const INVALID_PLATFORMS_REASON =
|
|||
|
||||
export const INVALID_PLATFORMS_FLASH_MESSAGE =
|
||||
"Couldn't save query. Please update platforms and try again.";
|
||||
|
||||
export const DATE_FNS_FORMAT_STRINGS = {
|
||||
dateAtTime: "E, MMM d 'at' p",
|
||||
hoursAndMinutes: "HH:mm",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"ace-builds": "1.4.12",
|
||||
"axios": "1.6.0",
|
||||
"core-js": "3.25.1",
|
||||
"date-fns": "2.28.0",
|
||||
"date-fns": "3.6.0",
|
||||
"date-fns-tz": "3.1.3",
|
||||
"dompurify": "3.0.3",
|
||||
"es6-object-assign": "1.1.0",
|
||||
"es6-promise": "4.2.8",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import (
|
|||
"github.com/go-kit/log/level"
|
||||
)
|
||||
|
||||
const calendarConsumers = 18
|
||||
const defaultDescription = "needs to make sure your device meets the organization's requirements."
|
||||
const defaultResolution = "During this maintenance window, you can expect updates to be applied automatically. Your device may be unavailable during this time."
|
||||
const (
|
||||
calendarConsumers = 18
|
||||
defaultDescription = "needs to make sure your device meets the organization's requirements."
|
||||
defaultResolution = "During this maintenance window, you can expect updates to be applied automatically. Your device may be unavailable during this time."
|
||||
)
|
||||
|
||||
type calendarConfig struct {
|
||||
config.CalendarConfig
|
||||
|
|
@ -351,6 +353,7 @@ func processFailingHostExistingCalendarEvent(
|
|||
updatedEvent.StartTime,
|
||||
updatedEvent.EndTime,
|
||||
updatedEvent.Data,
|
||||
updatedEvent.TimeZone,
|
||||
); err != nil {
|
||||
return fmt.Errorf("updating event calendar on db: %w", err)
|
||||
}
|
||||
|
|
@ -433,7 +436,7 @@ func processFailingHostCreateCalendarEvent(
|
|||
return fmt.Errorf("create event on user calendar: %w", err)
|
||||
}
|
||||
if _, err := ds.CreateOrUpdateCalendarEvent(
|
||||
ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, host.HostID, fleet.CalendarWebhookStatusNone,
|
||||
ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, calendarEvent.TimeZone, host.HostID, fleet.CalendarWebhookStatusNone,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create calendar event on db: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -14,6 +11,10 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/calendar"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
|
|
@ -338,6 +339,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) {
|
|||
email string,
|
||||
startTime, endTime time.Time,
|
||||
data []byte,
|
||||
timeZone string,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
|
|
@ -623,6 +625,7 @@ func TestCalendarEvents1KHosts(t *testing.T) {
|
|||
email string,
|
||||
startTime, endTime time.Time,
|
||||
data []byte,
|
||||
timeZone string,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
|
|
@ -900,6 +903,7 @@ func TestEventDescription(t *testing.T) {
|
|||
email string,
|
||||
startTime, endTime time.Time,
|
||||
data []byte,
|
||||
timeZone string,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent(
|
|||
startTime time.Time,
|
||||
endTime time.Time,
|
||||
data []byte,
|
||||
timeZone string,
|
||||
hostID uint,
|
||||
webhookStatus fleet.CalendarWebhookStatus,
|
||||
) (*fleet.CalendarEvent, error) {
|
||||
|
|
@ -27,12 +28,14 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent(
|
|||
email,
|
||||
start_time,
|
||||
end_time,
|
||||
event
|
||||
) VALUES (?, ?, ?, ?)
|
||||
event,
|
||||
timezone
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
start_time = VALUES(start_time),
|
||||
end_time = VALUES(end_time),
|
||||
event = VALUES(event),
|
||||
timezone = VALUES(timezone),
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
`
|
||||
result, err := tx.ExecContext(
|
||||
|
|
@ -42,6 +45,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent(
|
|||
startTime,
|
||||
endTime,
|
||||
data,
|
||||
timeZone,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert calendar event")
|
||||
|
|
@ -118,16 +122,17 @@ func (ds *Datastore) GetCalendarEvent(ctx context.Context, email string) (*fleet
|
|||
return &calendarEvent, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error {
|
||||
func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error {
|
||||
const calendarEventsQuery = `
|
||||
UPDATE calendar_events SET
|
||||
start_time = ?,
|
||||
end_time = ?,
|
||||
event = ?,
|
||||
timezone = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
`
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, calendarEventID); err != nil {
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, timeZone, calendarEventID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update calendar event")
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -55,12 +55,13 @@ func testUpdateCalendarEvent(t *testing.T, ds *Datastore) {
|
|||
|
||||
startTime1 := time.Now()
|
||||
endTime1 := startTime1.Add(30 * time.Minute)
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
|
||||
timeZone := "America/Argentina/Buenos_Aires"
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`))
|
||||
err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`), timeZone)
|
||||
require.NoError(t, err)
|
||||
|
||||
calendarEvent2, err := ds.GetCalendarEvent(ctx, "foo@example.com")
|
||||
|
|
@ -96,14 +97,17 @@ func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) {
|
|||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
|
||||
timeZone := "America/Argentina/Buenos_Aires"
|
||||
|
||||
startTime1 := time.Now()
|
||||
endTime1 := startTime1.Add(30 * time.Minute)
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, calendarEvent.TimeZone, timeZone)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
|
||||
calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, calendarEvent2.UpdatedAt, calendarEvent.UpdatedAt)
|
||||
calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt
|
||||
|
|
@ -113,7 +117,7 @@ func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) {
|
|||
|
||||
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)
|
||||
calendarEvent3, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime2, endTime2, []byte(`{"foo": "bar"}`), timeZone, host.ID, fleet.CalendarWebhookStatusPending)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, calendarEvent3.UpdatedAt, calendarEvent2.UpdatedAt)
|
||||
require.WithinDuration(t, startTime2, calendarEvent3.StartTime, 1*time.Second)
|
||||
|
|
|
|||
|
|
@ -4923,6 +4923,29 @@ func (ds *Datastore) ListHostBatteries(ctx context.Context, hid uint) ([]*fleet.
|
|||
return batteries, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListUpcomingHostMaintenanceWindows(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
stmt := `
|
||||
SELECT
|
||||
ce.start_time,
|
||||
ce.timezone
|
||||
FROM
|
||||
host_calendar_events hce JOIN calendar_events ce
|
||||
ON
|
||||
hce.calendar_event_id = ce.id
|
||||
WHERE
|
||||
hce.host_id = ?
|
||||
AND
|
||||
ce.start_time > NOW()
|
||||
ORDER BY ce.start_time
|
||||
`
|
||||
|
||||
var mws []*fleet.HostMaintenanceWindow
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &mws, stmt, hid); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows from db")
|
||||
}
|
||||
return mws, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetDiskEncryptionResetStatus(ctx context.Context, hostID uint, status bool) error {
|
||||
const stmt = `
|
||||
INSERT INTO host_disk_encryption_keys (host_id, reset_requested, base64_encrypted)
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ func TestHosts(t *testing.T) {
|
|||
{"HostnamesByIdentifiers", testHostnamesByIdentifiers},
|
||||
{"HostsAddToTeamCleansUpTeamQueryResults", testHostsAddToTeamCleansUpTeamQueryResults},
|
||||
{"UpdateHostIssues", testUpdateHostIssues},
|
||||
{"ListUpcomingHostMaintenanceWindows", testListUpcomingHostMaintenanceWindows},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
|
@ -7309,7 +7310,6 @@ func testHostOrder(t *testing.T, ds *Datastore) {
|
|||
)
|
||||
require.NoError(t, err)
|
||||
chk(hosts, "0003", "0004", "0001")
|
||||
|
||||
}
|
||||
|
||||
func testHostIDsByOSID(t *testing.T, ds *Datastore) {
|
||||
|
|
@ -9396,8 +9396,8 @@ func testUpdateHostIssues(t *testing.T, ds *Datastore) {
|
|||
}
|
||||
assert.Len(t, nonZeroIssues, 6)
|
||||
for i, hostIssue := range nonZeroIssues {
|
||||
var policiesCount = uint64(i + 2)
|
||||
var criticalCount = uint64(0)
|
||||
policiesCount := uint64(i + 2)
|
||||
criticalCount := uint64(0)
|
||||
if i > 1 {
|
||||
criticalCount = uint64(i - 1)
|
||||
}
|
||||
|
|
@ -9485,3 +9485,50 @@ func testUpdateHostIssues(t *testing.T, ds *Datastore) {
|
|||
}
|
||||
assert.True(t, hostIssueFound)
|
||||
}
|
||||
|
||||
func testListUpcomingHostMaintenanceWindows(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)
|
||||
|
||||
// call before any calendare events exist
|
||||
mWs, err := ds.ListUpcomingHostMaintenanceWindows(ctx, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, mWs)
|
||||
|
||||
// create an event
|
||||
timeZone := "America/Argentina/Buenos_Aires"
|
||||
|
||||
startTime := time.Now().UTC().Add(30 * time.Minute)
|
||||
endTime := startTime.Add(30 * time.Minute)
|
||||
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime, endTime, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, calendarEvent.TimeZone, timeZone)
|
||||
|
||||
mWs, err = ds.ListUpcomingHostMaintenanceWindows(ctx, host.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(mWs))
|
||||
mW := mWs[0]
|
||||
// round to match MySQL setting to round to nearest second (as of 6/27/2024)
|
||||
require.Equal(t, startTime.Round(time.Second), mW.StartsAt)
|
||||
require.Equal(t, timeZone, *mW.TimeZone)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240626195531, Down_20240626195531)
|
||||
}
|
||||
|
||||
func Up_20240626195531(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN timezone VARCHAR(64) NULL`); err != nil {
|
||||
return fmt.Errorf("failed to add `timezone` column to `calendar_events` table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20240626195531(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20240626195531(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// insert data to prev schema
|
||||
sampleEvent := fleet.CalendarEvent{
|
||||
Email: "foo@example.com",
|
||||
StartTime: time.Now().UTC(),
|
||||
EndTime: time.Now().UTC().Add(30 * time.Minute),
|
||||
Data: []byte("{\"foo\": \"bar\"}"),
|
||||
}
|
||||
sampleEvent.ID = uint(execNoErrLastID(t, db,
|
||||
`INSERT INTO calendar_events (email, start_time, end_time, event) VALUES (?, ?, ?, ?);`,
|
||||
sampleEvent.Email, sampleEvent.StartTime, sampleEvent.EndTime, sampleEvent.Data,
|
||||
))
|
||||
|
||||
sampleHostEvent := fleet.HostCalendarEvent{
|
||||
HostID: 1,
|
||||
CalendarEventID: sampleEvent.ID,
|
||||
WebhookStatus: fleet.CalendarWebhookStatusPending,
|
||||
}
|
||||
sampleHostEvent.ID = uint(execNoErrLastID(t, db,
|
||||
`INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status) VALUES (?, ?, ?);`,
|
||||
sampleHostEvent.HostID, sampleHostEvent.CalendarEventID, sampleHostEvent.WebhookStatus,
|
||||
))
|
||||
|
||||
// apply migration
|
||||
applyNext(t, db)
|
||||
|
||||
// verify migration
|
||||
// check that it's NULL
|
||||
selectTzStmt := `SELECT timezone FROM calendar_events WHERE id = ?`
|
||||
var dbOutTz string
|
||||
err := db.Get(&dbOutTz, selectTzStmt, sampleEvent.ID)
|
||||
require.Error(t, err) // db.Get returns error if empty result set, which we expect
|
||||
|
||||
// insert a timezone
|
||||
testTz := "America/Argentina/Buenos_Aires"
|
||||
execNoErr(t, db, `UPDATE calendar_events SET timezone = ? WHERE id = ?`, testTz, sampleEvent.ID)
|
||||
|
||||
// check that it comes out unchanged
|
||||
err = db.Get(&dbOutTz, `SELECT timezone FROM calendar_events WHERE id = ?;`, sampleEvent.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testTz, dbOutTz)
|
||||
}
|
||||
|
|
@ -3648,10 +3648,11 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
|
|||
//
|
||||
// Create a calendar event on host1 and host6.
|
||||
//
|
||||
tZ := "America/Argentina/Buenos_Aires"
|
||||
now := time.Now()
|
||||
_, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host1.ID, fleet.CalendarWebhookStatusPending)
|
||||
_, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host1.ID, fleet.CalendarWebhookStatusPending)
|
||||
require.NoError(t, err)
|
||||
_, err = ds.CreateOrUpdateCalendarEvent(ctx, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host6.ID, fleet.CalendarWebhookStatusPending)
|
||||
_, err = ds.CreateOrUpdateCalendarEvent(ctx, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host6.ID, fleet.CalendarWebhookStatusPending)
|
||||
require.NoError(t, err)
|
||||
|
||||
hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID})
|
||||
|
|
@ -3732,9 +3733,9 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
|
|||
// Create a calendar event on host2 and host3.
|
||||
//
|
||||
now = time.Now()
|
||||
_, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host2.ID, fleet.CalendarWebhookStatusPending)
|
||||
_, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host2.ID, fleet.CalendarWebhookStatusPending)
|
||||
require.NoError(t, err)
|
||||
calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, "zoo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host3.ID, fleet.CalendarWebhookStatusPending)
|
||||
calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, "zoo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host3.ID, fleet.CalendarWebhookStatusPending)
|
||||
require.NoError(t, err)
|
||||
|
||||
//
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -8,6 +8,7 @@ type CalendarEvent struct {
|
|||
StartTime time.Time `db:"start_time"`
|
||||
EndTime time.Time `db:"end_time"`
|
||||
Data []byte `db:"event"`
|
||||
TimeZone string `db:"timezone"`
|
||||
|
||||
UpdateCreateTimestamps
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,6 +313,7 @@ type Datastore interface {
|
|||
SetOrUpdateCustomHostDeviceMapping(ctx context.Context, hostID uint, email, source string) ([]*HostDeviceMapping, error)
|
||||
// ListHostBatteries returns the list of batteries for the given host ID.
|
||||
ListHostBatteries(ctx context.Context, id uint) ([]*HostBattery, error)
|
||||
ListUpcomingHostMaintenanceWindows(ctx context.Context, hid uint) ([]*HostMaintenanceWindow, error)
|
||||
|
||||
// LoadHostByDeviceAuthToken loads the host identified by the device auth token.
|
||||
// If the token is invalid or expired it returns a NotFoundError.
|
||||
|
|
@ -685,10 +686,10 @@ type Datastore interface {
|
|||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Calendar events
|
||||
|
||||
CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error)
|
||||
CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error)
|
||||
GetCalendarEvent(ctx context.Context, email string) (*CalendarEvent, error)
|
||||
DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error
|
||||
UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error
|
||||
UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error
|
||||
GetHostCalendarEvent(ctx context.Context, hostID uint) (*HostCalendarEvent, *CalendarEvent, error)
|
||||
GetHostCalendarEventByEmail(ctx context.Context, email string) (*HostCalendarEvent, *CalendarEvent, error)
|
||||
UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status CalendarWebhookStatus) error
|
||||
|
|
|
|||
|
|
@ -720,7 +720,7 @@ func (h Host) AuthzType() string {
|
|||
}
|
||||
|
||||
// HostDetail provides the full host metadata along with associated labels and
|
||||
// packs. It also includes policies, batteries, and MDM profiles, as applicable.
|
||||
// packs. It also includes policies, batteries, maintenance window, and MDM profiles, as applicable.
|
||||
type HostDetail struct {
|
||||
Host
|
||||
// Labels is the list of labels the host is a member of.
|
||||
|
|
@ -732,6 +732,17 @@ type HostDetail struct {
|
|||
// but when unset, it doesn't get marshaled (e.g. we don't return that
|
||||
// information for the List Hosts endpoint).
|
||||
Batteries *[]*HostBattery `json:"batteries,omitempty"`
|
||||
|
||||
// MaintenanceWindow contains the host user's calendar IANA timezone and the start time of the next scheduled maintenance window.
|
||||
MaintenanceWindow *HostMaintenanceWindow `json:"maintenance_window,omitempty"`
|
||||
}
|
||||
|
||||
type HostMaintenanceWindow struct {
|
||||
// StartsAt is the start time of the future maintenance window, retrieved from calendar_events,
|
||||
// represented as a time.Time in the host's associated google calendar user's timezone, which is represented as a time.Location
|
||||
StartsAt time.Time `json:"starts_at" db:"start_time"`
|
||||
// TimeZone is the IANA timezone of the user's google calendar, retrieved from calendar_evants
|
||||
TimeZone *string `json:"timezone" db:"timezone"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ type SetOrUpdateCustomHostDeviceMappingFunc func(ctx context.Context, hostID uin
|
|||
|
||||
type ListHostBatteriesFunc func(ctx context.Context, id uint) ([]*fleet.HostBattery, error)
|
||||
|
||||
type ListUpcomingHostMaintenanceWindowsFunc func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error)
|
||||
|
||||
type LoadHostByDeviceAuthTokenFunc func(ctx context.Context, authToken string, tokenTTL time.Duration) (*fleet.Host, error)
|
||||
|
||||
type SetOrUpdateDeviceAuthTokenFunc func(ctx context.Context, hostID uint, authToken string) error
|
||||
|
|
@ -497,13 +499,13 @@ type DeleteSoftwareVulnerabilitiesFunc func(ctx context.Context, vulnerabilities
|
|||
|
||||
type DeleteOutOfDateVulnerabilitiesFunc func(ctx context.Context, source fleet.VulnerabilitySource, duration time.Duration) error
|
||||
|
||||
type CreateOrUpdateCalendarEventFunc func(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error)
|
||||
type CreateOrUpdateCalendarEventFunc func(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error)
|
||||
|
||||
type GetCalendarEventFunc func(ctx context.Context, email string) (*fleet.CalendarEvent, error)
|
||||
|
||||
type DeleteCalendarEventFunc func(ctx context.Context, calendarEventID uint) error
|
||||
|
||||
type UpdateCalendarEventFunc func(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error
|
||||
type UpdateCalendarEventFunc func(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error
|
||||
|
||||
type GetHostCalendarEventFunc func(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error)
|
||||
|
||||
|
|
@ -1293,6 +1295,9 @@ type DataStore struct {
|
|||
ListHostBatteriesFunc ListHostBatteriesFunc
|
||||
ListHostBatteriesFuncInvoked bool
|
||||
|
||||
ListUpcomingHostMaintenanceWindowsFunc ListUpcomingHostMaintenanceWindowsFunc
|
||||
ListUpcomingHostMaintenanceWindowsFuncInvoked bool
|
||||
|
||||
LoadHostByDeviceAuthTokenFunc LoadHostByDeviceAuthTokenFunc
|
||||
LoadHostByDeviceAuthTokenFuncInvoked bool
|
||||
|
||||
|
|
@ -3153,6 +3158,13 @@ func (s *DataStore) ListHostBatteries(ctx context.Context, id uint) ([]*fleet.Ho
|
|||
return s.ListHostBatteriesFunc(ctx, id)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListUpcomingHostMaintenanceWindows(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
s.mu.Lock()
|
||||
s.ListUpcomingHostMaintenanceWindowsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListUpcomingHostMaintenanceWindowsFunc(ctx, hid)
|
||||
}
|
||||
|
||||
func (s *DataStore) LoadHostByDeviceAuthToken(ctx context.Context, authToken string, tokenTTL time.Duration) (*fleet.Host, error) {
|
||||
s.mu.Lock()
|
||||
s.LoadHostByDeviceAuthTokenFuncInvoked = true
|
||||
|
|
@ -4091,11 +4103,11 @@ func (s *DataStore) DeleteOutOfDateVulnerabilities(ctx context.Context, source f
|
|||
return s.DeleteOutOfDateVulnerabilitiesFunc(ctx, source, duration)
|
||||
}
|
||||
|
||||
func (s *DataStore) CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) {
|
||||
func (s *DataStore) CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) {
|
||||
s.mu.Lock()
|
||||
s.CreateOrUpdateCalendarEventFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.CreateOrUpdateCalendarEventFunc(ctx, email, startTime, endTime, data, hostID, webhookStatus)
|
||||
return s.CreateOrUpdateCalendarEventFunc(ctx, email, startTime, endTime, data, timeZone, hostID, webhookStatus)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) {
|
||||
|
|
@ -4112,11 +4124,11 @@ func (s *DataStore) DeleteCalendarEvent(ctx context.Context, calendarEventID uin
|
|||
return s.DeleteCalendarEventFunc(ctx, calendarEventID)
|
||||
}
|
||||
|
||||
func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error {
|
||||
func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error {
|
||||
s.mu.Lock()
|
||||
s.UpdateCalendarEventFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.UpdateCalendarEventFunc(ctx, calendarEventID, startTime, endTime, data)
|
||||
return s.UpdateCalendarEventFunc(ctx, calendarEventID, startTime, endTime, data, timeZone)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
|
||||
|
|
|
|||
|
|
@ -779,6 +779,9 @@ func TestHostDetailsMDMProfiles(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,26 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
|
|||
return nil, ctxerr.Wrap(ctx, err, "get batteries for host")
|
||||
}
|
||||
|
||||
mws, err := svc.ds.ListUpcomingHostMaintenanceWindows(ctx, host.ID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows")
|
||||
}
|
||||
// we are only interested in the next maintenance window. There should only be one for now, anyway.
|
||||
var nextMw *fleet.HostMaintenanceWindow
|
||||
if len(mws) > 0 {
|
||||
nextMw = mws[0]
|
||||
}
|
||||
|
||||
// nil TimeZone is okay
|
||||
if nextMw != nil && nextMw.TimeZone != nil {
|
||||
// return the start time in the local timezone of the host's associated google calendar user
|
||||
gCalLoc, err := time.LoadLocation(*nextMw.TimeZone)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows - invalid google calendar timezone")
|
||||
}
|
||||
nextMw.StartsAt = nextMw.StartsAt.In(gCalLoc)
|
||||
}
|
||||
|
||||
// Due to a known osquery issue with M1 Macs, we are ignoring the stored value in the db
|
||||
// and replacing it at the service layer with custom values determined by the cycle count.
|
||||
// See https://github.com/fleetdm/fleet/issues/6763.
|
||||
|
|
@ -1191,11 +1211,13 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
|
|||
}
|
||||
|
||||
host.Policies = policies
|
||||
|
||||
return &fleet.HostDetail{
|
||||
Host: *host,
|
||||
Labels: labels,
|
||||
Packs: packs,
|
||||
Batteries: &bats,
|
||||
Host: *host,
|
||||
Labels: labels,
|
||||
Packs: packs,
|
||||
Batteries: &bats,
|
||||
MaintenanceWindow: nextMw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ func TestHostDetails(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
|
||||
return dsBats, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
|
||||
return &fleet.HostLockWipeStatus{}, nil
|
||||
}
|
||||
|
|
@ -107,6 +110,9 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
|
||||
return &fleet.HostLockWipeStatus{}, nil
|
||||
}
|
||||
|
|
@ -381,6 +387,9 @@ func TestHostDetailsOSSettings(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -490,6 +499,9 @@ func TestHostDetailsOSSettingsWindowsOnly(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -581,6 +593,9 @@ func TestHostAuth(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.DeleteHostsFunc = func(ctx context.Context, ids []uint) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1425,6 +1440,9 @@ func TestHostMDMProfileDetail(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -1518,6 +1536,9 @@ func TestLockUnlockWipeHostAuth(t *testing.T) {
|
|||
ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostMaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8259,6 +8259,90 @@ func (s *integrationTestSuite) TestGetHostBatteries() {
|
|||
}, *getHostResp.Host.Batteries)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestGetHostMaintenanceWindow() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
host, err := s.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 = s.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)
|
||||
|
||||
startTime := time.Now().Add(time.Minute).In(time.UTC)
|
||||
endTime := startTime.Add(time.Minute * 30)
|
||||
testEvent := fleet.CalendarEvent{
|
||||
Email: "foo@example.com",
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Data: []byte(`{}`),
|
||||
// will replace with NULL - db method doesn't allow nil
|
||||
TimeZone: "",
|
||||
}
|
||||
|
||||
dsEvent, err := s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, testEvent.TimeZone, host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// DB methods don't allow nil timezone, since we only allow it for the edge case that the db has
|
||||
// just undergone a migration and the calendar_cron has not run to populate the new `time_zone`
|
||||
// column yet. This means we need to manually set the timezone to nil.
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, "UPDATE calendar_events SET timezone = NULL WHERE id = ?", dsEvent.ID)
|
||||
return err
|
||||
})
|
||||
|
||||
// GET host, check maintenance window
|
||||
var getHostResp getHostResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
|
||||
require.Equal(t, host.ID, getHostResp.Host.ID)
|
||||
// Round to account for sub-second precision differences between DB and Go
|
||||
require.Equal(t, testEvent.StartTime.Round(time.Second), getHostResp.Host.MaintenanceWindow.StartsAt)
|
||||
require.Nil(t, getHostResp.Host.MaintenanceWindow.TimeZone)
|
||||
|
||||
timeZone := "America/Argentina/Buenos_Aires"
|
||||
// get a time.Location from the timezone string
|
||||
tZLoc, err := time.LoadLocation(timeZone)
|
||||
require.NoError(t, err)
|
||||
|
||||
// use the time.Location to update the start time for the timezone
|
||||
zonedStartsAt := startTime.In(tZLoc).Round(time.Second)
|
||||
|
||||
// update the timezone
|
||||
_, err = s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, timeZone, host.ID, fleet.CalendarWebhookStatusNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// GET it again
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
|
||||
require.Equal(t, host.ID, getHostResp.Host.ID)
|
||||
require.Equal(t, timeZone, *getHostResp.Host.MaintenanceWindow.TimeZone)
|
||||
|
||||
// for equality comparison with original Go-derived start time, add a Location to the DB-derived start time, which only has an offset
|
||||
respStartsAt := getHostResp.Host.MaintenanceWindow.StartsAt
|
||||
respSAWithLoc, err := time.ParseInLocation("2006-01-02T15:04:05", respStartsAt.Format("2006-01-02T15:04:05"), tZLoc)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, zonedStartsAt, respSAWithLoc)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestHostByIdentifierSoftwareUpdatedAt() {
|
||||
t := s.T()
|
||||
|
||||
|
|
|
|||
13
yarn.lock
13
yarn.lock
|
|
@ -7729,10 +7729,15 @@ data-urls@^3.0.2:
|
|||
whatwg-mimetype "^3.0.0"
|
||||
whatwg-url "^11.0.0"
|
||||
|
||||
date-fns@2.28.0:
|
||||
version "2.28.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2"
|
||||
integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==
|
||||
date-fns-tz@3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-3.1.3.tgz#643dfc7157008a3873cd717973e4074bb802f962"
|
||||
integrity sha512-ZfbMu+nbzW0mEzC8VZrLiSWvUIaI3aRHeq33mTe7Y38UctKukgqPR4nTDwcwS4d64Gf8GghnVsroBuMY3eiTeA==
|
||||
|
||||
date-fns@3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
|
||||
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
|
||||
|
||||
debug@2.6.9, debug@^2.6.8, debug@^2.6.9:
|
||||
version "2.6.9"
|
||||
|
|
|
|||
Loading…
Reference in a new issue