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:
jacobshandling 2024-06-28 10:51:13 -07:00 committed by GitHub
parent f26acee2e1
commit 91b9c4a107
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 503 additions and 62 deletions

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 users 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,

View file

@ -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";

View file

@ -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[];

View file

@ -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();
});
});
});

View file

@ -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&apos;s time zone:
<br />
(GMT{starts_at.slice(-6)}) {timezone.replace("_", " ")}
</>
) : (
<>
End user&apos;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>
);
};

View file

@ -103,4 +103,7 @@
color: $ui-fleet-black-50;
}
}
.data-set dd {
overflow: initial;
}
}

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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",
};

View file

@ -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",

View file

@ -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)
}

View file

@ -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) {

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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 (

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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"