+ {canManageCustomHostVitals && !hasErrors && (
+
+ router.push(
+ getPathWithQueryParams(
+ PATHS.CONTROLS_VARIABLES_CUSTOM_HOST_VITALS,
+ { fleet_id: teamIdForApi }
+ )
+ )
+ }
+ className={`${baseClass}__custom-host-vitals`}
+ variant="inverse"
+ >
+
+ Custom host vitals
+
+ )}
{canEnrollHosts && !hasErrors && (
setShowEnrollSecretModal(true)}
className={`${baseClass}__enroll-hosts button`}
variant="inverse"
>
- Manage enroll secret
+
+ Enroll secrets
)}
{showAddHostsButton && (
diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
index 3b403378e3..c53b0065ff 100644
--- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
+++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
@@ -22,6 +22,7 @@ import teamAPI, { ILoadTeamsResponse } from "services/entities/teams";
import commandAPI from "services/entities/command";
import { IHost, IMacadminsResponse, IHostResponse } from "interfaces/host";
+import { IHostCustomVital } from "interfaces/custom_host_vitals";
import { ILabel } from "interfaces/label";
import { IListSort } from "interfaces/list_options";
import { IHostPolicy } from "interfaces/policy";
@@ -148,6 +149,7 @@ import HostHeader from "../cards/HostHeader";
import InventoryVersionsModal from "../modals/InventoryVersionsModal";
import UpdateEndUserModal from "../cards/User/components/UpdateEndUserModal";
import LocationModal from "../modals/LocationModal";
+import EditHostVitalModal from "../modals/EditHostVitalModal";
import MDMStatusModal from "../modals/MDMStatusModal";
import ClearPasscodeModal from "./modals/ClearPasscodeModal";
@@ -257,6 +259,11 @@ const HostDetailsPage = ({
const [showClearPasscodeModal, setShowClearPasscodeModal] = useState(false);
+ const [
+ editingCustomHostVital,
+ setEditingCustomHostVital,
+ ] = useState
(null);
+
// General-use updating state
const [isUpdating, setIsUpdating] = useState(false);
@@ -1317,6 +1324,12 @@ const HostDetailsPage = ({
// had one — so we don't gate visibility on orbit/MDM state.
const canViewMyDeviceLink = isGlobalAdmin;
+ const canEditCustomHostVitals =
+ isGlobalAdmin ||
+ isGlobalMaintainer ||
+ isHostTeamAdmin ||
+ isHostTeamMaintainer;
+
const showSoftwareLibraryTab = isPremiumTier;
const showReportsEmptyState = host.mdm?.enrollment_status === "Pending";
const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost;
@@ -1515,6 +1528,12 @@ const HostDetailsPage = ({
)}
toggleLocationModal={toggleLocationModal}
toggleMDMStatusModal={toggleMDMStatusModal}
+ customHostVitals={host.custom_host_vitals}
+ onEditCustomHostVital={
+ canEditCustomHostVitals
+ ? setEditingCustomHostVital
+ : undefined
+ }
/>
)}
+ {editingCustomHostVital && (
+ setEditingCustomHostVital(null)}
+ onSave={() => {
+ refetchHostDetails();
+ refetchPastActivities();
+ setEditingCustomHostVital(null);
+ }}
+ />
+ )}
{showMDMStatusModal && host.mdm.enrollment_status && (
{
+ const activity = createMockHostPastActivity({
+ actor_full_name: "Test User",
+ type: ActivityType.EditedCustomHostVitalValue,
+ details: { custom_host_vital_name: "Asset tag" },
+ });
+
+ it("renders the activity content", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Test User")).toBeVisible();
+ expect(screen.getByText("Asset tag")).toBeVisible();
+ });
+
+ it("does not render the cancel icon", () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument();
+ });
+
+ it("does not render the show details icon", () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId("info-outline-icon")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx
new file mode 100644
index 0000000000..244e4c6403
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/EditedCustomHostVitalValueActivityItem.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+
+import ActivityItem from "components/ActivityItem";
+
+import { IHostActivityItemComponentProps } from "../../ActivityConfig";
+
+const baseClass = "edited-custom-host-vital-value-activity-item";
+
+const EditedCustomHostVitalValueActivityItem = ({
+ activity,
+}: IHostActivityItemComponentProps) => {
+ return (
+
+ {activity.actor_full_name} edited the value for custom host vital{" "}
+ {activity.details?.custom_host_vital_name} .
+
+ );
+};
+
+export default EditedCustomHostVitalValueActivityItem;
diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts
new file mode 100644
index 0000000000..ddf94b8855
--- /dev/null
+++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/EditedCustomHostVitalValueActivityItem/index.ts
@@ -0,0 +1 @@
+export { default } from "./EditedCustomHostVitalValueActivityItem";
diff --git a/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx b/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx
index ac9611f24b..c7eb996a07 100644
--- a/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx
+++ b/frontend/pages/hosts/details/cards/Vitals/Vitals.tests.tsx
@@ -615,3 +615,60 @@ describe("Disk space field visibility", () => {
expect(screen.queryByText("Disk space available")).not.toBeInTheDocument();
});
});
+
+describe("Custom host vitals", () => {
+ const customHostVitals = [
+ { custom_host_vital_id: 1, name: "Asset tag", value: "FLEET-001234" },
+ { custom_host_vital_id: 2, name: "Purchase date", value: "" },
+ ];
+
+ it("renders each custom host vital as a name/value row, falling back to the empty cell value when unset", () => {
+ const mockHost = createMockHost({ platform: "darwin" });
+
+ render(
+
+ );
+
+ expect(screen.getByText("Asset tag")).toBeInTheDocument();
+ expect(screen.getByText("FLEET-001234")).toBeInTheDocument();
+ expect(screen.getByText("Purchase date")).toBeInTheDocument();
+ // The vital with no value falls back to the default empty cell value.
+ expect(screen.getByText(DEFAULT_EMPTY_CELL_VALUE)).toBeInTheDocument();
+ });
+
+ it("renders values as plain text (no edit affordance) when no edit handler is provided", () => {
+ const mockHost = createMockHost({ platform: "darwin" });
+
+ render(
+
+ );
+
+ expect(
+ screen.queryByRole("button", { name: "Edit Asset tag" })
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("FLEET-001234")).toBeInTheDocument();
+ });
+
+ it("renders an edit pencil next to the label and calls the edit handler on click", async () => {
+ const mockHost = createMockHost({ platform: "darwin" });
+ const onEditCustomHostVital = jest.fn();
+ const customRender = createCustomRenderer({});
+
+ const { user } = customRender(
+
+ );
+
+ expect(screen.getByText("FLEET-001234")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "FLEET-001234" })
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Edit Asset tag" }));
+
+ expect(onEditCustomHostVital).toHaveBeenCalledWith(customHostVitals[0]);
+ });
+});
diff --git a/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx b/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx
index 8ad1798579..6895245ff7 100644
--- a/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx
+++ b/frontend/pages/hosts/details/cards/Vitals/Vitals.tsx
@@ -2,6 +2,7 @@ import React from "react";
import classnames from "classnames";
import { IAppleDeviceUpdates } from "interfaces/config";
+import { IHostCustomVital } from "interfaces/custom_host_vitals";
import { IHostMdmData, IMunkiData } from "interfaces/host";
import {
isAndroid,
@@ -54,6 +55,8 @@ interface IVitalsProps {
* the My device page) so the row renders as plain text instead of a link.
*/
toggleMDMStatusModal?: () => void;
+ customHostVitals?: IHostCustomVital[];
+ onEditCustomHostVital?: (vital: IHostCustomVital) => void;
}
type VitalForSort = { sortKey: string; element: React.ReactNode };
@@ -127,6 +130,8 @@ const Vitals = ({
className,
toggleLocationModal,
toggleMDMStatusModal,
+ customHostVitals,
+ onEditCustomHostVital,
}: IVitalsProps) => {
const isIosOrIpadosHost = isIPadOrIPhone(vitalsData.platform);
const isAndroidHost = isAndroid(vitalsData.platform);
@@ -631,6 +636,38 @@ const Vitals = ({
});
}
+ customHostVitals?.forEach((vital) => {
+ const displayValue =
+ vital.value === "" ? DEFAULT_EMPTY_CELL_VALUE : vital.value;
+ const title = onEditCustomHostVital ? (
+
+ {vital.name}
+ onEditCustomHostVital(vital)}
+ ariaLabel={`Edit ${vital.name}`}
+ >
+
+
+
+ ) : (
+ vital.name
+ );
+
+ vitals.push({
+ sortKey: vital.name,
+ element: (
+
+ ),
+ });
+ });
+
// Sort alphabetically by title and render
return (
<>
diff --git a/frontend/pages/hosts/details/cards/Vitals/_styles.scss b/frontend/pages/hosts/details/cards/Vitals/_styles.scss
index 637656c01f..b00f826bcd 100644
--- a/frontend/pages/hosts/details/cards/Vitals/_styles.scss
+++ b/frontend/pages/hosts/details/cards/Vitals/_styles.scss
@@ -105,6 +105,12 @@
}
}
+ &__custom-vital-title {
+ display: inline-flex;
+ align-items: center;
+ gap: $pad-xsmall;
+ }
+
.text-muted {
color: $ui-fleet-black-50;
}
diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx
new file mode 100644
index 0000000000..8b96f533e9
--- /dev/null
+++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tests.tsx
@@ -0,0 +1,112 @@
+import React from "react";
+
+import { screen, waitFor } from "@testing-library/react";
+import { createCustomRenderer } from "test/test-utils";
+
+import customHostVitalsAPI from "services/entities/custom_host_vitals";
+
+import EditHostVitalModal from "./EditHostVitalModal";
+
+jest.mock("services/entities/custom_host_vitals");
+
+const vital = {
+ custom_host_vital_id: 5,
+ name: "Asset tag",
+ value: "FLEET-001234",
+};
+
+describe("EditHostVitalModal", () => {
+ const render = createCustomRenderer({ withBackendMock: true });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it("renders the static title with the vital name as the field label, prefilled with its value", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Edit host vital")).toBeVisible();
+ expect(screen.getByRole("textbox", { name: "Asset tag" })).toHaveValue(
+ "FLEET-001234"
+ );
+ });
+
+ it("saves the edited value and calls onSave on success", async () => {
+ (customHostVitalsAPI.updateHostCustomHostVitalValue as jest.Mock).mockResolvedValue(
+ undefined
+ );
+ const onSave = jest.fn();
+
+ const { user } = render(
+
+ );
+
+ const input = screen.getByRole("textbox", { name: "Asset tag" });
+ await user.clear(input);
+ await user.type(input, "FLEET-999");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(
+ customHostVitalsAPI.updateHostCustomHostVitalValue
+ ).toHaveBeenCalledWith(7, 5, "FLEET-999");
+ });
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalled();
+ });
+ });
+
+ it("does not call onSave when the update errors", async () => {
+ (customHostVitalsAPI.updateHostCustomHostVitalValue as jest.Mock).mockRejectedValue(
+ new Error("boom")
+ );
+ const onSave = jest.fn();
+
+ const { user } = render(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(
+ customHostVitalsAPI.updateHostCustomHostVitalValue
+ ).toHaveBeenCalledWith(7, 5, "FLEET-001234");
+ });
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("calls onCancel when the Cancel button is clicked", async () => {
+ const onCancel = jest.fn();
+
+ const { user } = render(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onCancel).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx
new file mode 100644
index 0000000000..3fdb080145
--- /dev/null
+++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/EditHostVitalModal.tsx
@@ -0,0 +1,79 @@
+import React, { useState } from "react";
+import { useMutation } from "react-query";
+
+import { IHostCustomVital } from "interfaces/custom_host_vitals";
+import { getErrorReason } from "interfaces/errors";
+import customHostVitalsAPI from "services/entities/custom_host_vitals";
+
+import Modal from "components/Modal";
+import Button from "components/buttons/Button";
+import InputField from "components/forms/fields/InputField";
+import { notify } from "components/ToastNotification";
+
+const baseClass = "edit-host-vital-modal";
+
+interface IEditHostVitalModalProps {
+ hostId: number;
+ vital: IHostCustomVital;
+ onCancel: () => void;
+ onSave: () => void;
+}
+
+const EditHostVitalModal = ({
+ hostId,
+ vital,
+ onCancel,
+ onSave,
+}: IEditHostVitalModalProps) => {
+ const [value, setValue] = useState(vital.value);
+
+ const { mutate: saveValue, isLoading: isSaving } = useMutation(
+ () =>
+ customHostVitalsAPI.updateHostCustomHostVitalValue(
+ hostId,
+ vital.custom_host_vital_id,
+ value
+ ),
+ {
+ onSuccess: () => {
+ notify.success("Successfully updated custom host vital.");
+ onSave();
+ },
+ onError: (error) => {
+ notify.error(
+ getErrorReason(error) ||
+ "Couldn't update custom host vital. Please try again.",
+ { response: error }
+ );
+ },
+ }
+ );
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ saveValue();
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default EditHostVitalModal;
diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss b/frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss
new file mode 100644
index 0000000000..5e44171027
--- /dev/null
+++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/_styles.scss
@@ -0,0 +1,5 @@
+.edit-host-vital-modal {
+ &__form {
+ @include vertical-modal-layout;
+ }
+}
diff --git a/frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts b/frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts
new file mode 100644
index 0000000000..e484fe7acf
--- /dev/null
+++ b/frontend/pages/hosts/details/modals/EditHostVitalModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./EditHostVitalModal";
diff --git a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx
index bc29b01d16..ab947a337d 100644
--- a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx
+++ b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx
@@ -10,6 +10,9 @@ import PATHS from "router/paths";
import targetsAPI, { ITargetsSearchResponse } from "services/entities/targets";
import idpAPI from "services/entities/idp";
import labelsAPI from "services/entities/labels";
+import customHostVitalsAPI, {
+ IListCustomHostVitalsApiParams,
+} from "services/entities/custom_host_vitals";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
// TODO - move this table config near here once expanded this logic to encompass editing and
@@ -27,6 +30,7 @@ import useToggleSidePanel from "hooks/useToggleSidePanel";
import { RouteComponentProps } from "react-router";
import {
+ CUSTOM_HOST_VITAL_CRITERION,
LabelHostVitalsCriterion,
LabelMembershipType,
} from "interfaces/label";
@@ -47,12 +51,24 @@ import Icon from "components/Icon";
import TargetsInput from "components/TargetsInput";
import Radio from "components/forms/fields/Radio";
import PlatformField from "../components/PlatformField";
-import { validateNewLabelFormData, INewLabelFormValidation } from "./helpers";
+import {
+ validateNewLabelFormData,
+ INewLabelFormValidation,
+ buildCriterionOptionValue,
+ parseCriterionOptionValue,
+ getVitalValuePlaceholder,
+ getCriterionHelpText,
+} from "./helpers";
-const availableCriteria: {
+interface ICriterionOption {
label: string;
- value: LabelHostVitalsCriterion;
-}[] = [
+ // Dropdown value: an IdP criterion's stable enum value, or a synthetic
+ // `custom_host_vital:` value for a custom host vital (see
+ // buildCriterionOptionValue / parseCriterionOptionValue).
+ value: string;
+}
+
+const IDP_CRITERIA: ICriterionOption[] = [
{ label: "Identity provider (IdP) group", value: "end_user_idp_group" },
{ label: "IdP department", value: "end_user_idp_department" },
];
@@ -81,6 +97,9 @@ export interface INewLabelFormData {
// host vitals
vital: LabelHostVitalsCriterion; // TODO - make use of recursive `LabelHostVitalsCriteria` type in future iterations to support logical combinations of different criteria
vitalValue: string;
+ // Set only when `vital === CUSTOM_HOST_VITAL_CRITERION`; identifies the
+ // selected custom host vital definition.
+ customHostVitalId?: number;
// manual
targetedHosts: IHost[];
@@ -143,6 +162,7 @@ const NewLabelPage = ({
platform,
vital,
vitalValue,
+ customHostVitalId,
targetedHosts,
} = formData;
@@ -205,29 +225,50 @@ const NewLabelPage = ({
);
const idpConfigured = !!scimIdPDetails?.last_request?.requested_at;
+ // Custom host vitals are a Fleet Free feature, so this query runs on all
+ // tiers. We fetch the full list (no search/pagination) to both gate the
+ // "Host vitals" label type and populate the criteria selector.
+ const customHostVitalsParams: IListCustomHostVitalsApiParams = {};
+ const { data: customHostVitalsData } = useQuery(
+ ["custom_host_vitals", customHostVitalsParams],
+ () => customHostVitalsAPI.getCustomHostVitals(customHostVitalsParams),
+ {
+ ...DEFAULT_USE_QUERY_OPTIONS,
+ }
+ );
+ const customHostVitals = customHostVitalsData?.custom_host_vitals ?? [];
+ const hasCustomHostVitals = customHostVitals.length > 0;
+
+ // Host vitals labels can be based on IdP groups/departments (Premium, once an
+ // IdP is configured) OR custom host vitals (any tier). The type is disabled
+ // only when neither source is available.
let hostVitalsTooltipContent: React.ReactNode;
- if (!isPremiumTier) {
- hostVitalsTooltipContent = (
+ if (!idpConfigured && !hasCustomHostVitals) {
+ // IdP criteria are Premium-only, so a Free-tier admin can't "configure your
+ // IdP" — point them at the custom host vital path instead.
+ hostVitalsTooltipContent = isPremiumTier ? (
<>
- Currently, host vitals labels are based on
-
- identity provider (IdP) groups or departments.
-
- IdP integration available in Fleet Premium.
+ To use host vitals labels, configure your IdP in integration settings or
+ add a custom host vital.
>
- );
- } else if (!idpConfigured) {
- hostVitalsTooltipContent = (
+ ) : (
<>
- Currently, host vitals labels are based on
-
- identity provider (IdP) groups or departments.
-
- IdP has not been configured in integration settings.
+ To use host vitals labels, add a custom host vital. Identity provider
+ (IdP) group and department criteria are available in Fleet Premium.
>
);
}
+ // Each custom host vital becomes its own selectable criterion. IdP criteria
+ // only appear when an IdP is configured.
+ const criterionOptions: ICriterionOption[] = [
+ ...(idpConfigured ? IDP_CRITERIA : []),
+ ...customHostVitals.map((customHostVital) => ({
+ label: customHostVital.name,
+ value: buildCriterionOptionValue(customHostVital.id),
+ })),
+ ];
+
useEffect(() => {
if (location.pathname.includes("dynamic")) {
router.replace(PATHS.NEW_LABEL);
@@ -289,13 +330,65 @@ const NewLabelPage = ({
});
};
- const onTypeChange = (value: string): void => {
- const newFormData = {
+ // The criteria dropdown carries a synthetic value for custom host vitals, so
+ // it can't reuse the generic `onInputChange` (which would set `vital` to the
+ // encoded string). Decode it back into `vital` + `customHostVitalId`.
+ const onCriterionChange = (optionValue: string): void => {
+ const {
+ vital: nextVital,
+ customHostVitalId: nextId,
+ } = parseCriterionOptionValue(optionValue);
+
+ const newFormData: INewLabelFormData = {
...formData,
- type: value as LabelMembershipType,
+ vital: nextVital,
+ customHostVitalId: nextId,
};
setFormData(newFormData);
+ const fullValidation = validateNewLabelFormData(newFormData);
+ setFormErrors((prev) => {
+ const next: INewLabelFormValidation = { ...prev, isValid: true };
+
+ if (prev.name) next.name = prev.name;
+ if (prev.description) next.description = prev.description;
+ if (prev.labelQuery) next.labelQuery = prev.labelQuery;
+ if (prev.criteria && fullValidation.criteria?.isValid) {
+ next.criteria = undefined;
+ } else if (prev.criteria) {
+ next.criteria = prev.criteria;
+ }
+
+ const fields = [
+ next.name,
+ next.description,
+ next.labelQuery,
+ next.criteria,
+ ];
+ next.isValid = fields.every((f) => !f || f.isValid);
+
+ return next;
+ });
+ };
+
+ const onTypeChange = (value: string): void => {
+ const nextType = value as LabelMembershipType;
+ const newFormData: INewLabelFormData = {
+ ...formData,
+ type: nextType,
+ };
+
+ // When switching to "host vitals", ensure the selected criterion is one the
+ // dropdown actually offers: the default `end_user_idp_group` is invalid when
+ // no IdP is configured (custom-host-vital-only case), so fall back to the
+ // first custom host vital.
+ if (nextType === "host_vitals" && !idpConfigured && hasCustomHostVitals) {
+ newFormData.vital = CUSTOM_HOST_VITAL_CRITERION;
+ newFormData.customHostVitalId = customHostVitals[0].id;
+ }
+
+ setFormData(newFormData);
+
const fullValidation = validateNewLabelFormData(newFormData);
setFormErrors((prev) => {
@@ -474,7 +567,16 @@ const NewLabelPage = ({
>
);
- case "host_vitals":
+ case "host_vitals": {
+ // The selected criterion is identified by the dropdown's string value:
+ // IdP criteria use their stable enum value; each custom host vital uses
+ // a synthetic `custom_host_vital:` value so multiple custom vitals
+ // are distinguishable in a single dropdown.
+ const selectedCriterionValue =
+ vital === CUSTOM_HOST_VITAL_CRITERION && customHostVitalId != null
+ ? buildCriterionOptionValue(customHostVitalId)
+ : vital;
+
return (
@@ -483,11 +585,10 @@ const NewLabelPage = ({
@@ -499,17 +600,16 @@ const NewLabelPage = ({
onBlur={onInputBlur}
value={vitalValue}
inputClassName={`${baseClass}__vital-value`}
- placeholder={
- vital === "end_user_idp_group" ? "IT admins" : "Engineering"
- }
+ placeholder={getVitalValuePlaceholder(vital)}
parseTarget
/>
- Currently, label criteria can be IdP group or department.
+ {getCriterionHelpText(vital)}
);
+ }
case "manual":
return (
diff --git a/frontend/pages/labels/NewLabelPage/helpers.tests.ts b/frontend/pages/labels/NewLabelPage/helpers.tests.ts
new file mode 100644
index 0000000000..5f15873919
--- /dev/null
+++ b/frontend/pages/labels/NewLabelPage/helpers.tests.ts
@@ -0,0 +1,75 @@
+import { CUSTOM_HOST_VITAL_CRITERION } from "interfaces/label";
+
+import {
+ buildCriterionOptionValue,
+ parseCriterionOptionValue,
+ getVitalValuePlaceholder,
+ getCriterionHelpText,
+} from "./helpers";
+
+describe("NewLabelPage helpers", () => {
+ describe("buildCriterionOptionValue / parseCriterionOptionValue", () => {
+ it("encodes a custom host vital id into the option value", () => {
+ expect(buildCriterionOptionValue(5)).toBe("custom_host_vital:5");
+ });
+
+ it("decodes a custom host vital option value back to vital + id", () => {
+ expect(parseCriterionOptionValue("custom_host_vital:5")).toEqual({
+ vital: CUSTOM_HOST_VITAL_CRITERION,
+ customHostVitalId: 5,
+ });
+ });
+
+ it("decodes an IdP option value with no custom id", () => {
+ expect(parseCriterionOptionValue("end_user_idp_group")).toEqual({
+ vital: "end_user_idp_group",
+ });
+ expect(parseCriterionOptionValue("end_user_idp_department")).toEqual({
+ vital: "end_user_idp_department",
+ });
+ });
+
+ it("round-trips any custom host vital id", () => {
+ [1, 42, 1000, 999999].forEach((id) => {
+ const parsed = parseCriterionOptionValue(buildCriterionOptionValue(id));
+ expect(parsed.vital).toBe(CUSTOM_HOST_VITAL_CRITERION);
+ expect(parsed.customHostVitalId).toBe(id);
+ });
+ });
+
+ it("does not treat an IdP value as a custom vital", () => {
+ const parsed = parseCriterionOptionValue("end_user_idp_group");
+ expect(parsed.vital).toBe("end_user_idp_group");
+ expect(parsed.customHostVitalId).toBeUndefined();
+ });
+ });
+
+ describe("getVitalValuePlaceholder", () => {
+ it("returns IdP-specific placeholders", () => {
+ expect(getVitalValuePlaceholder("end_user_idp_group")).toBe("IT admins");
+ expect(getVitalValuePlaceholder("end_user_idp_department")).toBe(
+ "Engineering"
+ );
+ });
+
+ it("returns a generic placeholder for custom host vitals", () => {
+ expect(getVitalValuePlaceholder(CUSTOM_HOST_VITAL_CRITERION)).toBe(
+ "Value"
+ );
+ });
+ });
+
+ describe("getCriterionHelpText", () => {
+ it("is specific to the selected criterion", () => {
+ expect(getCriterionHelpText("end_user_idp_group")).toBe(
+ "Label criteria is based on the end user's IdP group."
+ );
+ expect(getCriterionHelpText("end_user_idp_department")).toBe(
+ "Label criteria is based on the end user's IdP department."
+ );
+ expect(getCriterionHelpText(CUSTOM_HOST_VITAL_CRITERION)).toBe(
+ "Label criteria is based on the selected custom host vital."
+ );
+ });
+ });
+});
diff --git a/frontend/pages/labels/NewLabelPage/helpers.ts b/frontend/pages/labels/NewLabelPage/helpers.ts
index 9934cfc62f..29e2ef7147 100644
--- a/frontend/pages/labels/NewLabelPage/helpers.ts
+++ b/frontend/pages/labels/NewLabelPage/helpers.ts
@@ -1,5 +1,51 @@
+import {
+ CUSTOM_HOST_VITAL_CRITERION,
+ LabelHostVitalsCriterion,
+} from "interfaces/label";
+
import { INewLabelFormData } from "./NewLabelPage";
+// The criteria dropdown needs a single string per option, but custom host
+// vitals all share the `custom_host_vital` criterion value, so the definition
+// id is encoded into the option value and decoded on selection.
+export const buildCriterionOptionValue = (customHostVitalId: number) =>
+ `${CUSTOM_HOST_VITAL_CRITERION}:${customHostVitalId}`;
+
+export const parseCriterionOptionValue = (
+ optionValue: string
+): { vital: LabelHostVitalsCriterion; customHostVitalId?: number } => {
+ if (optionValue.startsWith(`${CUSTOM_HOST_VITAL_CRITERION}:`)) {
+ // Guard against a malformed/missing id: an unparseable value would become
+ // NaN, later pass `!= null`, and serialize to `null` in the request body.
+ const parsedId = Number(optionValue.split(":")[1]);
+ return {
+ vital: CUSTOM_HOST_VITAL_CRITERION,
+ customHostVitalId: Number.isFinite(parsedId) ? parsedId : undefined,
+ };
+ }
+ return { vital: optionValue as LabelHostVitalsCriterion };
+};
+
+export const getVitalValuePlaceholder = (vital: LabelHostVitalsCriterion) => {
+ if (vital === "end_user_idp_group") {
+ return "IT admins";
+ }
+ if (vital === "end_user_idp_department") {
+ return "Engineering";
+ }
+ return "Value";
+};
+
+export const getCriterionHelpText = (vital: LabelHostVitalsCriterion) => {
+ if (vital === "end_user_idp_group") {
+ return "Label criteria is based on the end user's IdP group.";
+ }
+ if (vital === "end_user_idp_department") {
+ return "Label criteria is based on the end user's IdP department.";
+ }
+ return "Label criteria is based on the selected custom host vital.";
+};
+
export interface INewLabelFormValidation {
isValid: boolean;
name?: { isValid: boolean; message?: string };
@@ -85,6 +131,20 @@ const FORM_VALIDATIONS: IFormValidations = {
},
message: "Label criteria must be completed",
},
+ {
+ // A custom-vital criterion is incomplete without a selected definition id.
+ name: "customVitalRequiresId",
+ isValid: (formData) => {
+ if (
+ formData.type !== "host_vitals" ||
+ formData.vital !== CUSTOM_HOST_VITAL_CRITERION
+ ) {
+ return true;
+ }
+ return formData.customHostVitalId != null;
+ },
+ message: "Label criteria must be completed",
+ },
],
},
};
diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx
index 599e2331da..72e2aed36d 100644
--- a/frontend/router/index.tsx
+++ b/frontend/router/index.tsx
@@ -350,6 +350,7 @@ const routes = (
+
`${URL_PREFIX}/controls/scripts/progress/${batchExecutionId}`,
CONTROLS_VARIABLES: `${URL_PREFIX}/controls/variables`,
+ CONTROLS_VARIABLES_GLOBAL_VARIABLES: `${URL_PREFIX}/controls/variables/global-variables`,
+ CONTROLS_VARIABLES_CUSTOM_HOST_VITALS: `${URL_PREFIX}/controls/variables/custom-host-vitals`,
// Dashboard pages
DASHBOARD: `${URL_PREFIX}/dashboard`,
diff --git a/frontend/services/entities/custom_host_vitals.ts b/frontend/services/entities/custom_host_vitals.ts
new file mode 100644
index 0000000000..6f29a1d9bf
--- /dev/null
+++ b/frontend/services/entities/custom_host_vitals.ts
@@ -0,0 +1,61 @@
+import {
+ ICustomHostVital,
+ ICustomHostVitalFormData,
+} from "interfaces/custom_host_vitals";
+import sendRequest from "services";
+import { getPathWithQueryParams } from "utilities/url";
+import endpoints from "utilities/endpoints";
+
+export interface IListCustomHostVitalsApiParams {
+ page?: number;
+ per_page?: number;
+ query?: string;
+ order_key?: string;
+ order_direction?: "asc" | "desc";
+}
+
+export interface IListCustomHostVitalsResponse {
+ custom_host_vitals: ICustomHostVital[] | null;
+ meta: {
+ has_next_results: boolean;
+ has_previous_results: boolean;
+ };
+ count: number;
+}
+
+export default {
+ getCustomHostVitals(
+ params: IListCustomHostVitalsApiParams
+ ): Promise {
+ const { CUSTOM_HOST_VITALS } = endpoints;
+ const path = getPathWithQueryParams(CUSTOM_HOST_VITALS, {
+ page: params.page,
+ per_page: params.per_page,
+ query: params.query,
+ order_key: params.order_key,
+ order_direction: params.order_direction,
+ });
+
+ return sendRequest("GET", path);
+ },
+
+ addCustomHostVital(vital: ICustomHostVitalFormData) {
+ const { CUSTOM_HOST_VITALS } = endpoints;
+ return sendRequest("POST", CUSTOM_HOST_VITALS, vital);
+ },
+
+ updateCustomHostVital(id: number, vital: ICustomHostVitalFormData) {
+ const { CUSTOM_HOST_VITALS } = endpoints;
+ return sendRequest("PATCH", `${CUSTOM_HOST_VITALS}/${id}`, vital);
+ },
+
+ deleteCustomHostVital(id: number) {
+ const { CUSTOM_HOST_VITALS } = endpoints;
+ return sendRequest("DELETE", `${CUSTOM_HOST_VITALS}/${id}`);
+ },
+
+ updateHostCustomHostVitalValue(hostId: number, id: number, value: string) {
+ const { HOST_CUSTOM_HOST_VITAL } = endpoints;
+ return sendRequest("PUT", HOST_CUSTOM_HOST_VITAL(hostId, id), { value });
+ },
+};
diff --git a/frontend/services/entities/labels.ts b/frontend/services/entities/labels.ts
index 83805490ce..e584c4ad44 100644
--- a/frontend/services/entities/labels.ts
+++ b/frontend/services/entities/labels.ts
@@ -2,7 +2,11 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import helpers from "utilities/helpers";
-import { ILabel, ILabelSummary } from "interfaces/label";
+import {
+ CUSTOM_HOST_VITAL_CRITERION,
+ ILabel,
+ ILabelSummary,
+} from "interfaces/label";
import { IDynamicLabelFormData } from "pages/labels/components/DynamicLabelForm/DynamicLabelForm";
import { IManualLabelFormData } from "pages/labels/components/ManualLabelForm/ManualLabelForm";
import { IHost } from "interfaces/host";
@@ -66,9 +70,15 @@ const generateCreateLabelBody = (formData: INewLabelFormData) => {
return {
name: formData.name,
description: formData.description,
+ // `custom_host_vital_id` is only sent for the custom-vital path
criteria: {
vital: formData.vital,
value: formData.vitalValue,
+ ...(formData.vital === CUSTOM_HOST_VITAL_CRITERION &&
+ formData.customHostVitalId != null &&
+ Number.isFinite(formData.customHostVitalId)
+ ? { custom_host_vital_id: formData.customHostVitalId }
+ : {}),
},
};
default:
diff --git a/frontend/services/entities/variables.ts b/frontend/services/entities/variables.ts
index 6e8a18513a..5f35ca36ba 100644
--- a/frontend/services/entities/variables.ts
+++ b/frontend/services/entities/variables.ts
@@ -21,8 +21,8 @@ export default {
getVariables(
params: IListVariablesApiParams
): Promise {
- const { VARIABLES } = endpoints;
- const path = `${VARIABLES}?${buildQueryStringFromParams({
+ const { GLOBAL_VARIABLES } = endpoints;
+ const path = `${GLOBAL_VARIABLES}?${buildQueryStringFromParams({
page: params.page,
per_page: params.per_page,
})}`;
@@ -31,12 +31,12 @@ export default {
},
addVariable(variable: IVariableFormData) {
- const { VARIABLES } = endpoints;
- return sendRequest("POST", VARIABLES, variable);
+ const { GLOBAL_VARIABLES } = endpoints;
+ return sendRequest("POST", GLOBAL_VARIABLES, variable);
},
deleteVariable(variableId: number) {
- const { VARIABLES } = endpoints;
- return sendRequest("DELETE", `${VARIABLES}/${variableId}`);
+ const { GLOBAL_VARIABLES } = endpoints;
+ return sendRequest("DELETE", `${GLOBAL_VARIABLES}/${variableId}`);
},
};
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index 20eb6d7ef2..2688d28c25 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -94,6 +94,8 @@ export default {
HOSTS_REPORT: `/${API_VERSION}/fleet/hosts/report`,
HOSTS_TRANSFER: `/${API_VERSION}/fleet/hosts/transfer`,
HOSTS_TRANSFER_BY_FILTER: `/${API_VERSION}/fleet/hosts/transfer/filter`,
+ HOST_CUSTOM_HOST_VITAL: (hostId: number, vitalId: number) =>
+ `/${API_VERSION}/fleet/hosts/${hostId}/custom_host_vitals/${vitalId}`,
HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`,
HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`,
HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`,
@@ -366,6 +368,8 @@ export default {
CERTIFICATE_AUTHORITY_REQUEST_CERT: (id: number) => {
return `/${API_VERSION}/fleet/certificate_authorities/${id}/request_certificate`;
},
- // custom variables endpoints
- VARIABLES: `/${API_VERSION}/fleet/custom_variables`,
+ // global variables endpoints
+ GLOBAL_VARIABLES: `/${API_VERSION}/fleet/custom_variables`,
+ // custom host vitals endpoints
+ CUSTOM_HOST_VITALS: `/${API_VERSION}/fleet/custom_host_vitals`,
};
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index 90d1f70344..945ae44561 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -381,6 +381,10 @@ type GitOps struct {
Labels []*fleet.LabelSpec
LabelChangesSummary LabelChangesSummary
+ // CustomHostVitals are the custom host vital definitions (names only; per-host
+ // values are never set via GitOps). Global-only: cannot be set on a team/fleet file.
+ CustomHostVitals []fleet.CustomHostVital
+
// Software is only allowed on teams, not on global config.
Software GitOpsSoftware
// FleetSecrets is a map of secret names to their values, extracted from FLEET_SECRET_ environment variables used in profiles and scripts.
@@ -392,6 +396,15 @@ type GitOps struct {
SoftwarePresent bool
// SecretsPresent indicates that the `secrets:` key was explicitly present in the YAML file.
SecretsPresent bool
+ // CustomHostVitalsPresent indicates that the `custom_host_vitals:` key was explicitly present in the YAML file.
+ CustomHostVitalsPresent bool
+}
+
+// GitOpsCustomHostVital defines the valid keys for an item in the top-level
+// `custom_host_vitals:` list. Definitions only (a name) -- per-host values are
+// never set via GitOps.
+type GitOpsCustomHostVital struct {
+ Name string `json:"name"`
}
type GitOpsSoftware struct {
@@ -465,7 +478,7 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
result := &GitOps{}
result.FleetSecrets = make(map[string]string)
- topKeys := []string{"name", "settings", "org_settings", "agent_options", "controls", "policies", "reports", "software", "labels"}
+ topKeys := []string{"name", "settings", "org_settings", "agent_options", "controls", "policies", "reports", "software", "labels", "custom_host_vitals"}
for k := range top {
if !slices.Contains(topKeys, k) {
multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k))
@@ -528,9 +541,12 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
for _, topKey := range topKeys {
// "name" is handled later with special logic based on the filename.
- // "labels" and "software" are special cases where omitting may be a no-op (based on exception settings),
- // rather than a directive to clear settings. settings keys were handled above.
- if topKey == "name" || topKey == "labels" || topKey == "software" || topKey == "settings" || topKey == "org_settings" {
+ // "labels" and "software" are special cases where omitting may be a no-op (based on
+ // exception settings), rather than a directive to clear settings.
+ // "custom_host_vitals" has no exception setting -- omitting it always means clear-all -- but still needs its own
+ // presence tracking (parseCustomHostVitals below), so it's excluded from the generic
+ // null-default handling too. settings keys were handled above.
+ if topKey == "name" || topKey == "labels" || topKey == "software" || topKey == "custom_host_vitals" || topKey == "settings" || topKey == "org_settings" {
continue
}
// "controls" can be set on _either_ global or "no team" file, and we can't say which it is if both
@@ -558,6 +574,11 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig
multiError = parseLabels(top, result, baseDir, logFn, filePath, multiError)
}
}
+ // Get the custom host vitals. CustomHostVitalsPresent tracks whether the key was in the YAML.
+ if _, ok := top["custom_host_vitals"]; ok {
+ result.CustomHostVitalsPresent = true
+ multiError = parseCustomHostVitals(top, result, filePath, multiError)
+ }
// Get other top-level entities.
multiError = parseControls(top, result, logFn, filePath, multiError)
multiError = parseAgentOptions(top, result, baseDir, logFn, filePath, multiError)
@@ -1067,6 +1088,40 @@ func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Erro
return multiError
}
+// parseCustomHostVitals parses the top-level `custom_host_vitals:` key.
+// Global-only: custom host vital definitions aren't team-scoped, so the key
+// isn't valid on a team file. An empty (or explicitly null) list is a
+// declarative clear-all, same as an absent secrets/yara_rules list.
+func parseCustomHostVitals(top map[string]json.RawMessage, result *GitOps, filePath string, multiError *multierror.Error) *multierror.Error {
+ raw := top["custom_host_vitals"]
+
+ if !result.global() {
+ return multierror.Append(multiError, errors.New("'custom_host_vitals' cannot be set on a team file"))
+ }
+
+ result.CustomHostVitals = []fleet.CustomHostVital{}
+ if len(raw) == 0 || string(raw) == "null" {
+ return multiError
+ }
+
+ var vitals []GitOpsCustomHostVital
+ if err := json.Unmarshal(raw, &vitals); err != nil {
+ return multierror.Append(multiError, MaybeParseTypeError(filePath, []string{"custom_host_vitals"}, err))
+ }
+ // Validate unknown keys in the custom_host_vitals section.
+ multiError = multierror.Append(multiError, validateRawKeys(raw, reflect.TypeFor[[]GitOpsCustomHostVital](), filePath, []string{"custom_host_vitals"})...)
+
+ for _, v := range vitals {
+ if err := fleet.ValidateCustomHostVitalName(v.Name); err != nil {
+ multiError = multierror.Append(multiError, fmt.Errorf("'custom_host_vitals': %w", err))
+ continue
+ }
+ result.CustomHostVitals = append(result.CustomHostVitals, fleet.CustomHostVital{Name: v.Name})
+ }
+
+ return multiError
+}
+
func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, filePath string, multiError *multierror.Error) *multierror.Error {
agentOptionsRaw, ok := top["agent_options"]
if result.IsNoTeam() {
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
index 0f71d8b3ca..e2701172d1 100644
--- a/pkg/spec/gitops_test.go
+++ b/pkg/spec/gitops_test.go
@@ -4948,6 +4948,88 @@ name: TestTeam
require.NoError(t, err)
assert.False(t, gitops.SoftwarePresent)
})
+
+ t.Run("custom host vitals present", func(t *testing.T) {
+ gitops, err := gitOpsFromString(t, `
+org_settings:
+ server_settings:
+ server_url: https://example.com
+ org_info:
+ org_name: Test
+custom_host_vitals:
+ - name: Asset tag
+ - name: Department
+`)
+ require.NoError(t, err)
+ assert.True(t, gitops.CustomHostVitalsPresent)
+ assert.ElementsMatch(t, []fleet.CustomHostVital{{Name: "Asset tag"}, {Name: "Department"}}, gitops.CustomHostVitals)
+ })
+
+ t.Run("custom host vitals absent", func(t *testing.T) {
+ gitops, err := gitOpsFromString(t, `
+org_settings:
+ server_settings:
+ server_url: https://example.com
+ org_info:
+ org_name: Test
+`)
+ require.NoError(t, err)
+ assert.False(t, gitops.CustomHostVitalsPresent)
+ assert.Nil(t, gitops.CustomHostVitals, "absent custom_host_vitals should be nil")
+ })
+
+ t.Run("custom host vitals present but empty", func(t *testing.T) {
+ gitops, err := gitOpsFromString(t, `
+org_settings:
+ server_settings:
+ server_url: https://example.com
+ org_info:
+ org_name: Test
+custom_host_vitals:
+`)
+ require.NoError(t, err)
+ assert.True(t, gitops.CustomHostVitalsPresent)
+ assert.Empty(t, gitops.CustomHostVitals)
+ })
+}
+
+func TestGitOpsCustomHostVitals(t *testing.T) {
+ t.Run("rejected on a team file", func(t *testing.T) {
+ path, basePath := createTempFile(t, "", `
+name: TestTeam
+custom_host_vitals:
+ - name: Asset tag
+`)
+ _, err := GitOpsFromFile(path, basePath, nil, nopLogf)
+ require.ErrorContains(t, err, "'custom_host_vitals' cannot be set on a team file")
+ })
+
+ t.Run("rejects an invalid name", func(t *testing.T) {
+ _, err := gitOpsFromString(t, `
+org_settings:
+ server_settings:
+ server_url: https://example.com
+ org_info:
+ org_name: Test
+custom_host_vitals:
+ - name: " Asset tag"
+`)
+ require.ErrorContains(t, err, "custom host vital name cannot have leading or trailing whitespace")
+ })
+
+ t.Run("rejects an unknown key", func(t *testing.T) {
+ _, err := gitOpsFromString(t, `
+org_settings:
+ server_settings:
+ server_url: https://example.com
+ org_info:
+ org_name: Test
+custom_host_vitals:
+ - name: Asset tag
+ id: 1
+`)
+ require.Error(t, err)
+ })
}
func TestGitOpsFMACategoriesPresence(t *testing.T) {
diff --git a/server/authz/policy.rego b/server/authz/policy.rego
index 3445327b7a..4417d39add 100644
--- a/server/authz/policy.rego
+++ b/server/authz/policy.rego
@@ -1307,6 +1307,51 @@ allow {
action == read
}
+##
+# Custom host vitals
+##
+
+# Global admins, maintainers, and gitops can write custom host vital definitions.
+allow {
+ object.type == "custom_vital"
+ subject.global_role == [admin, maintainer, gitops][_]
+ action == write
+}
+
+# Any global user can read custom host vital definitions.
+allow {
+ object.type == "custom_vital"
+ subject.global_role == [admin, maintainer, gitops, technician, observer_plus, observer][_]
+ action == read
+}
+
+# Any team user can read custom host vital definitions for hosts in its team.
+allow {
+ object.type == "custom_vital"
+ team_role(subject, subject.teams[_].id) == [admin, maintainer, gitops, technician, observer_plus, observer][_]
+ action == read
+}
+
+##
+# Host custom host vital values (per-host)
+##
+
+# Global admins and maintainers can set a host's custom host vital value (not
+# gitops — setting a host value is not a fleetctl gitops operation).
+allow {
+ object.type == "host_custom_vital"
+ subject.global_role == [admin, maintainer][_]
+ action == write
+}
+
+# Team admins and maintainers can set the value for hosts in their team.
+allow {
+ object.type == "host_custom_vital"
+ not is_null(object.team_id)
+ team_role(subject, object.team_id) == [admin, maintainer][_]
+ action == write
+}
+
##
# Android
##
diff --git a/server/authz/policy_test.go b/server/authz/policy_test.go
index 9b4ef960ea..4d92e039ce 100644
--- a/server/authz/policy_test.go
+++ b/server/authz/policy_test.go
@@ -3355,6 +3355,49 @@ func TestAuthorizeSecretVariables(t *testing.T) {
})
}
+func TestAuthorizeCustomHostVitals(t *testing.T) {
+ t.Parallel()
+
+ customHostVital := &fleet.CustomHostVital{}
+ runTestCases(t, []authTestCase{
+ {user: nil, object: customHostVital, action: read, allow: false},
+
+ {user: test.UserNoRoles, object: customHostVital, action: read, allow: false},
+
+ // Global admins, maintainers, and gitops can read/write.
+ {user: test.UserAdmin, object: customHostVital, action: read, allow: true},
+ {user: test.UserAdmin, object: customHostVital, action: write, allow: true},
+ {user: test.UserMaintainer, object: customHostVital, action: read, allow: true},
+ {user: test.UserMaintainer, object: customHostVital, action: write, allow: true},
+ {user: test.UserGitOps, object: customHostVital, action: read, allow: true},
+ {user: test.UserGitOps, object: customHostVital, action: write, allow: true},
+
+ // Global observers and observer_plus can read but cannot write.
+ {user: test.UserObserver, object: customHostVital, action: read, allow: true},
+ {user: test.UserObserver, object: customHostVital, action: write, allow: false},
+ {user: test.UserObserverPlus, object: customHostVital, action: read, allow: true},
+ {user: test.UserObserverPlus, object: customHostVital, action: write, allow: false},
+
+ // Global technicians can read but cannot write.
+ {user: test.UserTechnician, object: customHostVital, action: read, allow: true},
+ {user: test.UserTechnician, object: customHostVital, action: write, allow: false},
+
+ // Team users can read but cannot write.
+ {user: test.UserTeamAdminTeam1, object: customHostVital, action: read, allow: true},
+ {user: test.UserTeamAdminTeam1, object: customHostVital, action: write, allow: false},
+ {user: test.UserTeamMaintainerTeam1, object: customHostVital, action: read, allow: true},
+ {user: test.UserTeamMaintainerTeam1, object: customHostVital, action: write, allow: false},
+ {user: test.UserTeamGitOpsTeam1, object: customHostVital, action: read, allow: true},
+ {user: test.UserTeamGitOpsTeam1, object: customHostVital, action: write, allow: false},
+ {user: test.UserTeamObserverPlusTeam1, object: customHostVital, action: read, allow: true},
+ {user: test.UserTeamObserverPlusTeam1, object: customHostVital, action: write, allow: false},
+ {user: test.UserTeamObserverTeam1, object: customHostVital, action: read, allow: true},
+ {user: test.UserTeamObserverTeam1, object: customHostVital, action: write, allow: false},
+ {user: test.UserTeamTechnicianTeam1, object: customHostVital, action: read, allow: true},
+ {user: test.UserTeamTechnicianTeam1, object: customHostVital, action: write, allow: false},
+ })
+}
+
func TestAuthorizeAPIEndpoint(t *testing.T) {
t.Parallel()
diff --git a/server/datastore/mysql/apple_mdm_batched.go b/server/datastore/mysql/apple_mdm_batched.go
index b52804af86..7f4132b169 100644
--- a/server/datastore/mysql/apple_mdm_batched.go
+++ b/server/datastore/mysql/apple_mdm_batched.go
@@ -657,6 +657,29 @@ func (ds *Datastore) listAppleDeclarationsForReconcileTransaction(ctx context.Co
}
}
+ // Custom host vitals ($FLEET_HOST_VITAL_) are a separate variable
+ // namespace that isn't recorded in mdm_configuration_profile_variables, so
+ // detect them by scanning the declaration body. Marking them
+ // HasFleetVariables makes the reconciler stamp variables_updated_at, which
+ // (a) lets handleDeclarationItems load raw_json and drop declarations it
+ // can't resolve for a host from the manifest, and (b) cache-busts the DDM
+ // token so a per-host value change is re-delivered. INSTR matches the
+ // prefix without the leading '$' so it catches both $FOO and ${FOO} forms.
+ const vitalsStmt = `SELECT declaration_uuid FROM mdm_apple_declarations WHERE declaration_uuid IN (?) AND INSTR(raw_json, ?) > 0`
+ q, args, err = sqlx.In(vitalsStmt, declUUIDs, fleet.CustomHostVitalPrefix)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "build apple declaration custom host vitals query")
+ }
+ var withVitals []string
+ if err := sqlx.SelectContext(ctx, tx, &withVitals, q, args...); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "select apple declarations with custom host vitals")
+ }
+ for _, u := range withVitals {
+ if d, ok := byUUID[u]; ok {
+ d.HasFleetVariables = true
+ }
+ }
+
// For declarations that reference DDM assets, load the most recent
// uploaded_at across their referenced assets. The reconciler stamps this
// onto host_mdm_apple_declarations.assets_updated_at so that editing an
diff --git a/server/datastore/mysql/custom_host_vitals.go b/server/datastore/mysql/custom_host_vitals.go
new file mode 100644
index 0000000000..32aacfaf74
--- /dev/null
+++ b/server/datastore/mysql/custom_host_vitals.go
@@ -0,0 +1,623 @@
+package mysql
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
+ "github.com/jmoiron/sqlx"
+ "golang.org/x/text/unicode/norm"
+)
+
+var customHostVitalAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
+ "name": "name",
+ "id": "id",
+ "updated_at": "updated_at",
+}
+
+func (ds *Datastore) CreateCustomHostVital(ctx context.Context, name string) (fleet.CustomHostVital, error) {
+ res, err := ds.writer(ctx).ExecContext(ctx,
+ `INSERT INTO custom_host_vitals (name) VALUES (?)`,
+ name,
+ )
+ if err != nil {
+ if IsDuplicate(err) {
+ return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, alreadyExists("name", name), "found duplicate")
+ }
+ return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "insert custom host vital")
+ }
+ id, _ := res.LastInsertId()
+ return fleet.CustomHostVital{ID: uint(id), Name: name}, nil //nolint:gosec // dismiss G115
+}
+
+func (ds *Datastore) ListCustomHostVitals(ctx context.Context, opt fleet.ListOptions) (
+ customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error,
+) {
+ stmt := `SELECT id, name, created_at, updated_at FROM custom_host_vitals WHERE true`
+
+ // normalize the name for full Unicode support (Unicode equivalence).
+ // Search matches the name OR the variable name (the derived
+ // `$FLEET_HOST_VITAL_` token). The second column is a hardcoded SQL
+ // expression (not user input); searchLike escapes the LIKE pattern.
+ normMatch := norm.NFC.String(opt.MatchQuery)
+ whereClauses, args := searchLike("", nil, normMatch, "name", `CONCAT('$FLEET_HOST_VITAL_', id)`)
+ stmt += whereClauses
+
+ // perform a second query to grab the count
+ // build the count statement before adding pagination constraints
+ countStmt := fmt.Sprintf("SELECT COUNT(DISTINCT id) FROM (%s) AS s", stmt)
+
+ stmt, args, err = appendListOptionsWithCursorToSQLSecure(stmt, args, &opt, customHostVitalAllowedOrderKeys)
+ if err != nil {
+ return nil, nil, 0, ctxerr.Wrap(ctx, err, "apply list options")
+ }
+
+ dbReader := ds.reader(ctx)
+ if err := sqlx.SelectContext(ctx, dbReader, &customHostVitals, stmt, args...); err != nil {
+ return nil, nil, 0, ctxerr.Wrap(ctx, err, "listing custom host vitals")
+ }
+ if err := sqlx.GetContext(ctx, dbReader, &count, countStmt, args...); err != nil {
+ return nil, nil, 0, ctxerr.Wrap(ctx, err, "get custom host vitals count")
+ }
+
+ if opt.IncludeMetadata {
+ meta = &fleet.PaginationMetadata{
+ HasPreviousResults: opt.Page > 0,
+ TotalResults: uint(count), //nolint:gosec // dismiss G115
+ }
+ // `appendListOptionsWithCursorToSQL` used above to build the query statement will cause this discrepancy.
+ if len(customHostVitals) > int(opt.PerPage) { //nolint:gosec // dismiss G115
+ meta.HasNextResults = true
+ customHostVitals = customHostVitals[:len(customHostVitals)-1]
+ }
+ }
+
+ return customHostVitals, meta, count, nil
+}
+
+func (ds *Datastore) UpdateCustomHostVital(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) {
+ res, err := ds.writer(ctx).ExecContext(ctx,
+ `UPDATE custom_host_vitals SET name = ? WHERE id = ?`,
+ name, id,
+ )
+ if err != nil {
+ if IsDuplicate(err) {
+ return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, alreadyExists("name", name), "found duplicate")
+ }
+ return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "update custom host vital")
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ // No rows affected can mean the id was not found, or the name is unchanged.
+ // Distinguish the two so a no-op rename doesn't surface as NotFound.
+ // Check on the writer: the UPDATE above targeted the primary, so a replica
+ // lagging behind it could otherwise report a false NotFound.
+ var exists bool
+ if err := sqlx.GetContext(ctx, ds.writer(ctx), &exists,
+ `SELECT 1 FROM custom_host_vitals WHERE id = ?`, id); err != nil {
+ if err == sql.ErrNoRows {
+ return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, notFound("CustomHostVital").WithID(id))
+ }
+ return fleet.CustomHostVital{}, ctxerr.Wrap(ctx, err, "check custom host vital exists")
+ }
+ }
+ return fleet.CustomHostVital{ID: id, Name: name}, nil
+}
+
+func (ds *Datastore) DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error) {
+ if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ err := sqlx.GetContext(ctx, tx, &name, `SELECT name FROM custom_host_vitals WHERE id = ?`, id)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return ctxerr.Wrap(ctx, notFound("CustomHostVital").WithID(id))
+ }
+ return ctxerr.Wrap(ctx, err, "getting name of custom host vital to delete")
+ }
+
+ // Refuse to delete a definition still referenced by a script/profile.
+ if usedByInfo, err := ds.customHostVitalUsedBy(ctx, tx, id, name); err != nil {
+ return ctxerr.Wrap(ctx, err, "checking custom host vital references")
+ } else if usedByInfo != nil {
+ return ctxerr.Wrap(ctx, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: *usedByInfo}, "found custom host vital in use")
+ }
+
+ if _, err := tx.ExecContext(ctx, `DELETE FROM custom_host_vitals WHERE id = ?`, id); err != nil {
+ return ctxerr.Wrap(ctx, err, "delete custom host vital")
+ }
+ return nil
+ }); err != nil {
+ return "", ctxerr.Wrap(ctx, err, "delete custom host vital")
+ }
+
+ return name, nil
+}
+
+// customHostVitalRefEntity is a script or profile scanned for $FLEET_HOST_VITAL_
+// references during delete-protection.
+type customHostVitalRefEntity struct {
+ // Type is the entity type, "script", "apple_profile", "apple_declaration", or "windows_profile".
+ Type string `db:"entity"`
+ // Name is the name of the entity.
+ Name string `db:"name"`
+ // FleetName is the name of the fleet (team) the entity belongs to.
+ FleetName string `db:"team_name"`
+ // Contents is the content of the entity (script's/profile's body).
+ Contents string `db:"contents"`
+}
+
+// customHostVitalUsedBy scans script_contents, Apple configuration profiles,
+// Apple declarations, and Windows configuration profiles for a
+// $FLEET_HOST_VITAL_ (or ${FLEET_HOST_VITAL_}) reference to the given
+// vital id. It returns a *fleet.CustomHostVitalUsedInfo describing the first
+// referencing entity found, or nil if unreferenced. Mirrors the scan structure
+// of DeleteSecretVariable. The second return is a real DB error.
+func (ds *Datastore) customHostVitalUsedBy(ctx context.Context, tx sqlx.ExtContext, id uint, name string) (*fleet.CustomHostVitalUsedInfo, error) {
+ // The token embeds the numeric id (survives renames), so match by id, not name.
+ token := fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, id)
+
+ // Each scan mirrors DeleteSecretVariable: pull the content column of every
+ // script/profile/declaration and check for the token in Go (os.Expand-based,
+ // so it matches both $VAR and ${VAR} forms).
+ scans := []struct {
+ desc string
+ stmt string
+ }{
+ {
+ desc: "get script contents",
+ stmt: `SELECT 'script' AS entity, s.name,
+ COALESCE(t.name, 'Unassigned') AS team_name, sc.contents
+ FROM script_contents sc
+ JOIN scripts s ON s.script_content_id = sc.id
+ LEFT JOIN teams t ON t.id = s.team_id;`,
+ },
+ {
+ desc: "get apple profile contents",
+ stmt: `SELECT 'apple_profile' AS entity, p.name,
+ COALESCE(t.name, 'Unassigned') AS team_name, p.mobileconfig AS contents
+ FROM mdm_apple_configuration_profiles p
+ LEFT JOIN teams t ON t.id = p.team_id;`,
+ },
+ {
+ desc: "get apple declaration contents",
+ stmt: `SELECT 'apple_declaration' AS entity, d.name,
+ COALESCE(t.name, 'Unassigned') AS team_name, d.raw_json AS contents
+ FROM mdm_apple_declarations d
+ LEFT JOIN teams t ON t.id = d.team_id;`,
+ },
+ {
+ desc: "get windows profile contents",
+ stmt: `SELECT 'windows_profile' AS entity, p.name,
+ COALESCE(t.name, 'Unassigned') AS team_name, p.syncml AS contents
+ FROM mdm_windows_configuration_profiles p
+ LEFT JOIN teams t ON t.id = p.team_id;`,
+ },
+ // Software installer and setup-experience scripts exceed secret-variable
+ // delete-protection (which doesn't scan them), so a vital can't be deleted
+ // while a script that runs it would silently start failing on hosts.
+ {
+ desc: "get software installer script contents",
+ stmt: `SELECT 'software_installer' AS entity, COALESCE(st.name, si.filename) AS name,
+ COALESCE(t.name, 'Unassigned') AS team_name, sc.contents
+ FROM software_installers si
+ JOIN script_contents sc ON sc.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id)
+ LEFT JOIN software_titles st ON st.id = si.title_id
+ LEFT JOIN teams t ON t.id = si.team_id;`,
+ },
+ {
+ desc: "get setup experience script contents",
+ stmt: `SELECT 'setup_experience_script' AS entity, ses.name,
+ COALESCE(t.name, 'Unassigned') AS team_name, sc.contents
+ FROM setup_experience_scripts ses
+ JOIN script_contents sc ON sc.id = ses.script_content_id
+ LEFT JOIN teams t ON t.id = ses.team_id;`,
+ },
+ }
+
+ for _, scan := range scans {
+ var entities []customHostVitalRefEntity
+ if err := sqlx.SelectContext(ctx, tx, &entities, scan.stmt); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, scan.desc)
+ }
+ for _, e := range entities {
+ if fleet.ContainsVar(e.Contents, token) {
+ return &fleet.CustomHostVitalUsedInfo{
+ CustomHostVitalID: id,
+ CustomHostVitalName: name,
+ Entity: fleet.EntityUsingCustomHostVital{
+ Type: fleet.CustomHostVitalEntity(e.Type),
+ Name: e.Name,
+ FleetName: e.FleetName,
+ },
+ }, nil
+ }
+ }
+ }
+
+ // Host-vitals labels reference the vital by id inside their criteria JSON
+ // (not by the $FLEET_HOST_VITAL_ token), so they need a structured check
+ // rather than the content-token scan above.
+ var labels []struct {
+ Name string `db:"name"`
+ FleetName string `db:"team_name"`
+ Criteria json.RawMessage `db:"criteria"`
+ }
+ labelStmt := `SELECT l.name, COALESCE(t.name, 'Unassigned') AS team_name, l.criteria
+ FROM labels l
+ LEFT JOIN teams t ON t.id = l.team_id
+ WHERE l.label_membership_type = ? AND l.criteria IS NOT NULL`
+ if err := sqlx.SelectContext(ctx, tx, &labels, labelStmt, fleet.LabelMembershipTypeHostVitals); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get host vitals label criteria")
+ }
+ for _, l := range labels {
+ var criteria fleet.HostVitalCriteria
+ // A label with malformed criteria is already broken; skip it rather than
+ // block the delete on it.
+ if err := json.Unmarshal(l.Criteria, &criteria); err != nil {
+ ds.logger.WarnContext(ctx, "skipping host vitals label with unparseable criteria during custom host vital delete-protection scan",
+ "label", l.Name, "error", err)
+ continue
+ }
+ if criteria.CustomHostVitalID != nil && *criteria.CustomHostVitalID == id {
+ return &fleet.CustomHostVitalUsedInfo{
+ CustomHostVitalID: id,
+ CustomHostVitalName: name,
+ Entity: fleet.EntityUsingCustomHostVital{
+ Type: fleet.CustomHostVitalEntityLabel,
+ Name: l.Name,
+ FleetName: l.FleetName,
+ },
+ }, nil
+ }
+ }
+
+ return nil, nil
+}
+
+func (ds *Datastore) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error {
+ return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ if _, err := tx.ExecContext(ctx, `
+ INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE value = VALUES(value)`,
+ hostID, vitalID, value,
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "set host custom host vital value")
+ }
+
+ // Re-queue any MDM profiles/declarations already delivered to this host
+ // that reference the vital, so the reconcilers re-expand
+ // $FLEET_HOST_VITAL_ with the new value. Runs in the same transaction
+ // as the value write so the reconciler never reads a stale value.
+ if err := resendMDMProfilesForCustomHostVital(ctx, tx, hostID, vitalID); err != nil {
+ return ctxerr.Wrap(ctx, err, "resend mdm profiles for custom host vital value change")
+ }
+ return nil
+ })
+}
+
+// resendMDMProfilesForCustomHostVital resets the status of the Apple/Windows
+// configuration profiles and Apple DDM declarations already delivered to the
+// host that reference $FLEET_HOST_VITAL_, so the reconcilers resend
+// them with the host's newly-set value. Mirrors triggerResendProfilesUsingVariables,
+// but matches by profile/declaration content because custom host vitals aren't
+// tracked in mdm_configuration_profile_variables. Declarations only reset status
+// (the DDM reconciler re-stamps variables_updated_at, cache-busting the token).
+//
+// Unlike the IdP resend, this deliberately omits certificate templates and
+// Android managed configs: a vital can't reach either (cert templates only take
+// fleet_variables, and Android rejects $FLEET_HOST_VITAL_ at upload), so there's
+// nothing on those surfaces to resend.
+func resendMDMProfilesForCustomHostVital(ctx context.Context, tx sqlx.ExtContext, hostID, vitalID uint) error {
+ var hostUUID string
+ if err := sqlx.GetContext(ctx, tx, &hostUUID, `SELECT uuid FROM hosts WHERE id = ?`, hostID); err != nil {
+ if err == sql.ErrNoRows {
+ return nil
+ }
+ return ctxerr.Wrap(ctx, err, "get host uuid for custom host vital resend")
+ }
+
+ // varName is the exact token (id included) matched precisely, id-boundary and
+ // ${...}-aware, by ContainsVar in Go. The INSTR prefix filter in SQL only
+ // narrows candidates; it deliberately over-matches (ignores the id) so the
+ // Go pass does the authoritative match.
+ varName := fmt.Sprintf("%s%d", fleet.CustomHostVitalPrefix, vitalID)
+
+ // These SELECTs filter on host_uuid first — the leftmost column of each
+ // host-profile table's PRIMARY KEY (host_uuid, {profile,declaration}_uuid) —
+ // so the INSTR content match only evaluates this one host's rows, not the
+ // whole table; cost is independent of fleet size.
+ const (
+ customHostVitalResendAppleProfilesSelectStmt = `SELECT hmap.profile_uuid AS uuid, macp.mobileconfig AS contents
+ FROM host_mdm_apple_profiles hmap
+ JOIN mdm_apple_configuration_profiles macp ON macp.profile_uuid = hmap.profile_uuid
+ WHERE hmap.host_uuid = ? AND hmap.operation_type = ? AND hmap.status IS NOT NULL AND INSTR(macp.mobileconfig, ?) > 0`
+
+ customHostVitalResendWindowsProfilesSelectStmt = `SELECT hmwp.profile_uuid AS uuid, mwcp.syncml AS contents
+ FROM host_mdm_windows_profiles hmwp
+ JOIN mdm_windows_configuration_profiles mwcp ON mwcp.profile_uuid = hmwp.profile_uuid
+ WHERE hmwp.host_uuid = ? AND hmwp.operation_type = ? AND hmwp.status IS NOT NULL AND INSTR(mwcp.syncml, ?) > 0`
+
+ customHostVitalResendAppleDeclarationsSelectStmt = `SELECT hmad.declaration_uuid AS uuid, mad.raw_json AS contents
+ FROM host_mdm_apple_declarations hmad
+ JOIN mdm_apple_declarations mad ON mad.declaration_uuid = hmad.declaration_uuid
+ WHERE hmad.host_uuid = ? AND hmad.operation_type = ? AND hmad.status IS NOT NULL AND INSTR(mad.raw_json, ?) > 0`
+ )
+
+ targets := []struct {
+ desc string
+ selectStmt string
+ updateStmt string
+ }{
+ {
+ desc: "apple profiles",
+ selectStmt: customHostVitalResendAppleProfilesSelectStmt,
+ updateStmt: `UPDATE host_mdm_apple_profiles
+ SET status = NULL, detail = NULL, command_uuid = ''
+ WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`,
+ },
+ {
+ desc: "windows profiles",
+ selectStmt: customHostVitalResendWindowsProfilesSelectStmt,
+ updateStmt: `UPDATE host_mdm_windows_profiles
+ SET status = NULL, detail = NULL, command_uuid = ''
+ WHERE host_uuid = ? AND operation_type = ? AND profile_uuid IN (?)`,
+ },
+ {
+ desc: "apple declarations",
+ selectStmt: customHostVitalResendAppleDeclarationsSelectStmt,
+ updateStmt: `UPDATE host_mdm_apple_declarations
+ SET status = NULL, detail = NULL
+ WHERE host_uuid = ? AND operation_type = ? AND declaration_uuid IN (?)`,
+ },
+ }
+
+ for _, tgt := range targets {
+ var rows []struct {
+ UUID string `db:"uuid"`
+ Contents string `db:"contents"`
+ }
+ if err := sqlx.SelectContext(ctx, tx, &rows, tgt.selectStmt,
+ hostUUID, fleet.MDMOperationTypeInstall, fleet.CustomHostVitalPrefix); err != nil {
+ return ctxerr.Wrap(ctx, err, "select "+tgt.desc+" referencing custom host vital")
+ }
+
+ var uuids []string
+ for _, r := range rows {
+ if fleet.ContainsVar(r.Contents, varName) {
+ uuids = append(uuids, r.UUID)
+ }
+ }
+ if len(uuids) == 0 {
+ continue
+ }
+
+ stmt, args, err := sqlx.In(tgt.updateStmt, hostUUID, fleet.MDMOperationTypeInstall, uuids)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "build resend update for "+tgt.desc)
+ }
+ if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
+ return ctxerr.Wrap(ctx, err, "reset "+tgt.desc+" for custom host vital resend")
+ }
+ }
+
+ return nil
+}
+
+func (ds *Datastore) GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ var vitals []fleet.HostCustomHostVital
+ err := sqlx.SelectContext(ctx, ds.reader(ctx), &vitals, `
+ SELECT chv.id AS custom_host_vital_id, chv.name, COALESCE(hchv.value, '') AS value
+ FROM custom_host_vitals chv
+ LEFT JOIN host_custom_host_vitals hchv
+ ON hchv.custom_host_vital_id = chv.id AND hchv.host_id = ?
+ ORDER BY chv.name`,
+ hostID,
+ )
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get host custom host vitals")
+ }
+ return vitals, nil
+}
+
+// ExpandCustomHostVitals substitutes $FLEET_HOST_VITAL_ tokens in the
+// document with the given host's stored values, applying format-aware escaping
+// (JSON/XML) like expandEmbeddedSecrets. If a referenced vital has no value for
+// the host (no row, or an empty value), it returns a MissingCustomHostVitalValueError
+// so delivery fails rather than substituting an empty value (product decision).
+func (ds *Datastore) ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error) {
+ refIDs := fleet.ContainsCustomHostVitalIDs(document)
+ if len(refIDs) == 0 {
+ return document, nil
+ }
+
+ vitals, err := ds.GetHostCustomHostVitals(ctx, hostID)
+ if err != nil {
+ return "", ctxerr.Wrap(ctx, err, "expanding custom host vitals")
+ }
+
+ // A vital with an empty value counts as missing: we refuse to ship an empty
+ // substitution.
+ valueByID := make(map[uint]string, len(vitals))
+ for _, v := range vitals {
+ if v.Value == "" {
+ continue
+ }
+ valueByID[v.CustomHostVitalID] = v.Value
+ }
+
+ var missingIDs []uint
+ for _, id := range refIDs {
+ if _, ok := valueByID[id]; !ok {
+ missingIDs = append(missingIDs, id)
+ }
+ }
+ if len(missingIDs) > 0 {
+ // The vital exists (validated on upload); the host just has no value for it.
+ return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: missingIDs}
+ }
+
+ expanded := expandDocumentVars(document, func(s string) (string, bool) {
+ if !strings.HasPrefix(s, fleet.CustomHostVitalPrefix) {
+ return "", false
+ }
+ id, parseErr := strconv.ParseUint(strings.TrimPrefix(s, fleet.CustomHostVitalPrefix), 10, strconv.IntSize)
+ if parseErr != nil {
+ return "", false
+ }
+ val, ok := valueByID[uint(id)]
+ return val, ok
+ })
+
+ return expanded, nil
+}
+
+// ValidateReferencedCustomHostVitals parses $FLEET_HOST_VITAL_ tokens from
+// the given documents and verifies every referenced id resolves to a definition.
+// Mirrors ValidateEmbeddedSecrets. Returns a MissingCustomHostVitalsError listing
+// any unknown ids.
+func (ds *Datastore) ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error {
+ wantIDs := make(map[uint]struct{})
+ var malformed []string
+ seenMalformed := make(map[string]struct{})
+ for _, document := range documents {
+ // A $FLEET_HOST_VITAL_ token whose isn't a valid ID (e.g. a typo like
+ // $FLEET_HOST_VITAL_asset_tag) is rejected rather than silently delivered as
+ // a literal token, matching how $FLEET_VAR_*/$FLEET_SECRET_* reject unknowns.
+ for _, ref := range fleet.ContainsMalformedCustomHostVitalRefs(document) {
+ if _, ok := seenMalformed[ref]; ok {
+ continue
+ }
+ seenMalformed[ref] = struct{}{}
+ malformed = append(malformed, ref)
+ }
+ for _, id := range fleet.ContainsCustomHostVitalIDs(document) {
+ wantIDs[id] = struct{}{}
+ }
+ }
+ if len(malformed) > 0 {
+ return &fleet.InvalidCustomHostVitalRefError{Refs: malformed}
+ }
+ if len(wantIDs) == 0 {
+ return nil
+ }
+
+ wantIDsList := make([]uint, 0, len(wantIDs))
+ for id := range wantIDs {
+ wantIDsList = append(wantIDsList, id)
+ }
+
+ dbVitals, err := ds.GetCustomHostVitals(ctx, wantIDsList)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "validating document referenced custom host vitals")
+ }
+
+ haveIDs := make(map[uint]struct{}, len(dbVitals))
+ for _, v := range dbVitals {
+ haveIDs[v.ID] = struct{}{}
+ }
+
+ var missingIDs []uint
+ for id := range wantIDs {
+ if _, ok := haveIDs[id]; !ok {
+ missingIDs = append(missingIDs, id)
+ }
+ }
+ if len(missingIDs) > 0 {
+ return &fleet.MissingCustomHostVitalsError{MissingIDs: missingIDs}
+ }
+ return nil
+}
+
+func (ds *Datastore) GetCustomHostVitals(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ stmt, args, err := sqlx.In(`
+ SELECT id, name, created_at, updated_at
+ FROM custom_host_vitals
+ WHERE id IN (?)`, ids)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "build custom host vitals query")
+ }
+
+ var vitals []fleet.CustomHostVital
+ if err := sqlx.SelectContext(ctx, ds.reader(ctx), &vitals, stmt, args...); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get custom host vitals")
+ }
+ return vitals, nil
+}
+
+func (ds *Datastore) UpsertCustomHostVitals(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error) {
+ incomingNames := make(map[string]struct{}, len(vitals))
+ for _, v := range vitals {
+ incomingNames[v.Name] = struct{}{}
+ }
+
+ err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
+ created, deleted = nil, nil
+
+ var existing []fleet.CustomHostVital
+ if err := sqlx.SelectContext(ctx, tx, &existing, `SELECT id, name FROM custom_host_vitals`); err != nil {
+ return ctxerr.Wrap(ctx, err, "list existing custom host vitals")
+ }
+
+ existingNames := make(map[string]struct{}, len(existing))
+ for _, e := range existing {
+ existingNames[e.Name] = struct{}{}
+ if _, ok := incomingNames[e.Name]; !ok {
+ deleted = append(deleted, e)
+ }
+ }
+
+ var toInsert []string
+ for _, v := range vitals {
+ if _, ok := existingNames[v.Name]; !ok {
+ toInsert = append(toInsert, v.Name)
+ }
+ }
+
+ for _, v := range deleted {
+ usedByInfo, err := ds.customHostVitalUsedBy(ctx, tx, v.ID, v.Name)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "checking custom host vital references")
+ }
+ if usedByInfo != nil {
+ return ctxerr.Wrap(ctx, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: *usedByInfo}, "found custom host vital in use")
+ }
+ }
+
+ if len(deleted) > 0 {
+ ids := make([]uint, 0, len(deleted))
+ for _, v := range deleted {
+ ids = append(ids, v.ID)
+ }
+ stmt, args, err := sqlx.In(`DELETE FROM custom_host_vitals WHERE id IN (?)`, ids)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "build delete custom host vitals query")
+ }
+ if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
+ return ctxerr.Wrap(ctx, err, "delete custom host vitals")
+ }
+ }
+
+ // Inserted one at a time (rather than a single multi-row INSERT) so each
+ // row's LastInsertId can be captured for the returned `created` list.
+ for _, name := range toInsert {
+ res, err := tx.ExecContext(ctx, `INSERT INTO custom_host_vitals (name) VALUES (?)`, name)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "insert custom host vital")
+ }
+ id, _ := res.LastInsertId()
+ created = append(created, fleet.CustomHostVital{ID: uint(id), Name: name}) //nolint:gosec // dismiss G115
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return created, deleted, nil
+}
diff --git a/server/datastore/mysql/custom_host_vitals_test.go b/server/datastore/mysql/custom_host_vitals_test.go
new file mode 100644
index 0000000000..76141fb8ce
--- /dev/null
+++ b/server/datastore/mysql/custom_host_vitals_test.go
@@ -0,0 +1,667 @@
+package mysql
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/test"
+ "github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCustomHostVitals(t *testing.T) {
+ ds := CreateMySQLDS(t)
+
+ cases := []struct {
+ name string
+ fn func(t *testing.T, ds *Datastore)
+ }{
+ {"CreateCustomHostVital", testCreateCustomHostVital},
+ {"UpsertCustomHostVitals", testUpsertCustomHostVitals},
+ {"ListCustomHostVitals", testListCustomHostVitals},
+ {"UpdateCustomHostVital", testUpdateCustomHostVital},
+ {"SetAndGetHostCustomHostVitals", testSetAndGetHostCustomHostVitals},
+ {"GetCustomHostVitals", testGetCustomHostVitals},
+ {"DeleteCustomHostVital", testDeleteCustomHostVital},
+ {"DeleteUsedCustomHostVital", testDeleteUsedCustomHostVital},
+ {"SetHostValueResendsReferencingProfiles", testSetHostCustomHostVitalValueResendsProfiles},
+ {"ReconcileSnapshotMarksVitalDeclarations", testReconcileSnapshotMarksVitalDeclarations},
+ {"ValidateReferencedCustomHostVitalsRejectsMalformed", testValidateReferencedCustomHostVitalsRejectsMalformed},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ defer TruncateTables(t, ds)
+ c.fn(t, ds)
+ })
+ }
+}
+
+// createCustomHostVital is a test helper that creates a definition and returns its id.
+func createCustomHostVital(t *testing.T, ds *Datastore, name string) uint {
+ v, err := ds.CreateCustomHostVital(t.Context(), name)
+ require.NoError(t, err)
+ return v.ID
+}
+
+func testCreateCustomHostVital(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ vital, err := ds.CreateCustomHostVital(ctx, "Asset tag")
+ require.NoError(t, err)
+ require.NotZero(t, vital.ID)
+ require.Equal(t, "Asset tag", vital.Name)
+
+ // Duplicate name surfaces AlreadyExistsError.
+ dup, err := ds.CreateCustomHostVital(ctx, "Asset tag")
+ require.Error(t, err)
+ var aee fleet.AlreadyExistsError
+ require.ErrorAs(t, err, &aee)
+ require.Zero(t, dup.ID)
+}
+
+// vitalNames is a test helper that extracts the Name of each vital, for use with require.ElementsMatch.
+func vitalNames(vitals []fleet.CustomHostVital) []string {
+ out := make([]string, 0, len(vitals))
+ for _, v := range vitals {
+ out = append(out, v.Name)
+ }
+ return out
+}
+
+func testUpsertCustomHostVitals(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ list := func() []fleet.CustomHostVital {
+ vitals, _, _, err := ds.ListCustomHostVitals(ctx, fleet.ListOptions{})
+ require.NoError(t, err)
+ return vitals
+ }
+
+ // Empty incoming set against no existing definitions is a no-op.
+ created, deleted, err := ds.UpsertCustomHostVitals(ctx, nil)
+ require.NoError(t, err)
+ require.Empty(t, created)
+ require.Empty(t, deleted)
+ require.Empty(t, list())
+
+ // Initial apply creates all named vitals.
+ created, deleted, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Department"}})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []string{"Function", "Department"}, vitalNames(created))
+ require.Empty(t, deleted)
+ require.ElementsMatch(t, []string{"Function", "Department"}, vitalNames(list()))
+
+ byName := make(map[string]uint)
+ for _, v := range list() {
+ byName[v.Name] = v.ID
+ }
+ funcID := byName["Function"]
+
+ // Re-applying the same set keeps the existing rows (matched by name), not
+ // recreated, and reports no created/deleted vitals.
+ created, deleted, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Department"}})
+ require.NoError(t, err)
+ require.Empty(t, created)
+ require.Empty(t, deleted)
+ for _, v := range list() {
+ if v.Name == "Function" {
+ require.Equal(t, funcID, v.ID)
+ }
+ }
+
+ // A name absent from the incoming set ("Department") is deleted; a new name
+ // ("Role") is inserted.
+ created, deleted, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Role"}})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []string{"Role"}, vitalNames(created))
+ require.ElementsMatch(t, []string{"Department"}, vitalNames(deleted))
+ require.ElementsMatch(t, []string{"Function", "Role"}, vitalNames(list()))
+
+ // Dropping a still-referenced name errors the whole call and leaves state unchanged.
+ script, err := ds.NewScript(ctx, &fleet.Script{
+ Name: "collect.sh",
+ ScriptContents: fmt.Sprintf("echo $%s%d", fleet.CustomHostVitalPrefix, funcID),
+ })
+ require.NoError(t, err)
+
+ _, _, err = ds.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Role"}})
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, funcID, useErr.CustomHostVitalID) //nolint:nilaway // cannot be nil due to require.ErrorAs above
+ require.Equal(t, "Function", useErr.CustomHostVitalName) //nolint:nilaway // cannot be nil due to require.ErrorAs above
+ require.Equal(t, fleet.CustomHostVitalEntityScript, useErr.Entity.Type) //nolint:nilaway // cannot be nil due to require.ErrorAs above
+ require.ElementsMatch(t, []string{"Function", "Role"}, vitalNames(list()))
+
+ require.NoError(t, ds.DeleteScript(ctx, script.ID))
+
+ // An empty incoming set (the absent-key GitOps case) clears all definitions.
+ created, deleted, err = ds.UpsertCustomHostVitals(ctx, nil)
+ require.NoError(t, err)
+ require.Empty(t, created)
+ require.ElementsMatch(t, []string{"Function", "Role"}, vitalNames(deleted))
+ require.Empty(t, list())
+}
+
+func testListCustomHostVitals(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ funcID := createCustomHostVital(t, ds, "Function")
+ deptID := createCustomHostVital(t, ds, "Department")
+
+ list := func(opt fleet.ListOptions) []fleet.CustomHostVital {
+ vitals, _, _, err := ds.ListCustomHostVitals(ctx, opt)
+ require.NoError(t, err)
+ return vitals
+ }
+
+ names := func(vitals []fleet.CustomHostVital) []string {
+ out := make([]string, 0, len(vitals))
+ for _, v := range vitals {
+ out = append(out, v.Name)
+ }
+ return out
+ }
+
+ // No filter: both definitions returned.
+ require.ElementsMatch(t, []string{"Function", "Department"}, names(list(fleet.ListOptions{})))
+
+ // Count is returned.
+ _, _, count, err := ds.ListCustomHostVitals(ctx, fleet.ListOptions{})
+ require.NoError(t, err)
+ require.Equal(t, 2, count)
+
+ // Search by name (case-insensitive via the collation, substring).
+ require.ElementsMatch(t, []string{"Function"}, names(list(fleet.ListOptions{MatchQuery: "func"})))
+ require.ElementsMatch(t, []string{"Department"}, names(list(fleet.ListOptions{MatchQuery: "depart"})))
+
+ // Search by the derived $FLEET_HOST_VITAL_ variable token. The token is
+ // not stored; ListCustomHostVitals matches CONCAT('$FLEET_HOST_VITAL_', id).
+ funcToken := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, funcID)
+ require.ElementsMatch(t, []string{"Function"}, names(list(fleet.ListOptions{MatchQuery: funcToken})))
+ deptToken := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, deptID)
+ require.ElementsMatch(t, []string{"Department"}, names(list(fleet.ListOptions{MatchQuery: deptToken})))
+
+ // A partial token prefix (the shared namespace) matches both.
+ require.ElementsMatch(t, []string{"Function", "Department"},
+ names(list(fleet.ListOptions{MatchQuery: "$" + fleet.CustomHostVitalPrefix})))
+
+ // A token for a non-existent id matches nothing.
+ require.Empty(t, list(fleet.ListOptions{MatchQuery: fmt.Sprintf("$%s999999", fleet.CustomHostVitalPrefix)}))
+}
+
+func testUpdateCustomHostVital(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ id := createCustomHostVital(t, ds, "Function")
+ createCustomHostVital(t, ds, "Other")
+
+ // Rename succeeds and returns the updated definition.
+ updated, err := ds.UpdateCustomHostVital(ctx, id, "Role")
+ require.NoError(t, err)
+ require.Equal(t, id, updated.ID)
+ require.Equal(t, "Role", updated.Name)
+ vitals, err := ds.GetCustomHostVitals(ctx, []uint{id})
+ require.NoError(t, err)
+ require.Len(t, vitals, 1)
+ require.Equal(t, "Role", vitals[0].Name)
+
+ // No-op rename (same name) is not treated as NotFound.
+ _, err = ds.UpdateCustomHostVital(ctx, id, "Role")
+ require.NoError(t, err)
+
+ // Renaming to an existing name surfaces AlreadyExistsError.
+ _, err = ds.UpdateCustomHostVital(ctx, id, "Other")
+ require.Error(t, err)
+ var aee fleet.AlreadyExistsError
+ require.ErrorAs(t, err, &aee)
+
+ // Updating a non-existent id surfaces NotFoundError.
+ _, err = ds.UpdateCustomHostVital(ctx, 999999, "Whatever")
+ require.Error(t, err)
+ var nfe fleet.NotFoundError
+ require.ErrorAs(t, err, &nfe)
+}
+
+func testSetAndGetHostCustomHostVitals(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ host, err := ds.NewHost(ctx, &fleet.Host{
+ Hostname: "chv-host",
+ UUID: "chv-host-uuid",
+ OsqueryHostID: new("chv-host-osquery-id"),
+ NodeKey: new("chv-host-node-key"),
+ DetailUpdatedAt: time.Now(),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+
+ funcID := createCustomHostVital(t, ds, "Function")
+ deptID := createCustomHostVital(t, ds, "Department")
+
+ // With no per-host values set yet, every definition is still returned for
+ // the host with an empty value.
+ got, err := ds.GetHostCustomHostVitals(ctx, host.ID)
+ require.NoError(t, err)
+ require.Len(t, got, 2)
+ for _, v := range got {
+ require.Empty(t, v.Value)
+ }
+
+ // Insert two values.
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, funcID, "engineering"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, deptID, "R&D"))
+
+ got, err = ds.GetHostCustomHostVitals(ctx, host.ID)
+ require.NoError(t, err)
+ byID := make(map[uint]fleet.HostCustomHostVital, len(got))
+ for _, v := range got {
+ byID[v.CustomHostVitalID] = v
+ }
+ require.Len(t, byID, 2)
+ require.Equal(t, "Function", byID[funcID].Name)
+ require.Equal(t, "engineering", byID[funcID].Value)
+ require.Equal(t, "Department", byID[deptID].Name)
+ require.Equal(t, "R&D", byID[deptID].Value)
+
+ // Upsert overwrites the existing value for (host, vital).
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, funcID, "sales"))
+ got, err = ds.GetHostCustomHostVitals(ctx, host.ID)
+ require.NoError(t, err)
+ require.Len(t, got, 2)
+ for _, v := range got {
+ if v.CustomHostVitalID == funcID {
+ require.Equal(t, "sales", v.Value)
+ }
+ }
+}
+
+func testGetCustomHostVitals(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ funcID := createCustomHostVital(t, ds, "Function")
+ deptID := createCustomHostVital(t, ds, "Department")
+
+ // Known ids resolve; an unknown id is silently omitted.
+ got, err := ds.GetCustomHostVitals(ctx, []uint{funcID, deptID, 999999})
+ require.NoError(t, err)
+ names := make([]string, 0, len(got))
+ for _, v := range got {
+ names = append(names, v.Name)
+ }
+ require.ElementsMatch(t, []string{"Function", "Department"}, names)
+}
+
+func testDeleteCustomHostVital(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ host, err := ds.NewHost(ctx, &fleet.Host{
+ Hostname: "chv-del-host",
+ UUID: "chv-del-host-uuid",
+ OsqueryHostID: new("chv-del-host-osquery-id"),
+ NodeKey: new("chv-del-host-node-key"),
+ DetailUpdatedAt: time.Now(),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+
+ id := createCustomHostVital(t, ds, "Function")
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, id, "engineering"))
+
+ name, err := ds.DeleteCustomHostVital(ctx, id)
+ require.NoError(t, err)
+ require.Equal(t, "Function", name)
+
+ got, err := ds.GetHostCustomHostVitals(ctx, host.ID)
+ require.NoError(t, err)
+ require.Empty(t, got)
+
+ // Deleting a non-existent id surfaces NotFoundError.
+ _, err = ds.DeleteCustomHostVital(ctx, 999999)
+ require.Error(t, err)
+ var nfe fleet.NotFoundError
+ require.ErrorAs(t, err, &nfe)
+}
+
+func testDeleteUsedCustomHostVital(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ foobarTeam, err := ds.NewTeam(ctx, &fleet.Team{Name: "Foobar"})
+ require.NoError(t, err)
+
+ id := createCustomHostVital(t, ds, "FUNCTION")
+ id2 := createCustomHostVital(t, ds, "OTHER")
+
+ // $FLEET_HOST_VITAL_ token that references FUNCTION.
+ token := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, id)
+ // ${FLEET_HOST_VITAL_} braced form.
+ bracedToken := fmt.Sprintf("${%s%d}", fleet.CustomHostVitalPrefix, id)
+
+ t.Run("apple configuration profiles", func(t *testing.T) {
+ appleProfile, err := ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{
+ Name: "Name0",
+ Identifier: "Identifier0",
+ Mobileconfig: []byte(token),
+ }, nil)
+ require.NoError(t, err)
+
+ _, err = ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntityAppleProfile, useErr.Entity.Type)
+ require.Equal(t, "Name0", useErr.Entity.Name)
+ require.Equal(t, "Unassigned", useErr.Entity.FleetName)
+
+ // Deleting an unreferenced vital is allowed.
+ _, err = ds.DeleteCustomHostVital(ctx, id2)
+ require.NoError(t, err)
+ // Recreate for later subtests.
+ id2 = createCustomHostVital(t, ds, "OTHER")
+
+ require.NoError(t, ds.DeleteMDMAppleConfigProfile(ctx, appleProfile.ProfileUUID))
+ })
+
+ t.Run("apple declarations", func(t *testing.T) {
+ decl, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
+ Identifier: "decl-1",
+ Name: "decl-1",
+ RawJSON: json.RawMessage(fmt.Sprintf(`{"Identifier": "%s"}`, bracedToken)),
+ TeamID: &foobarTeam.ID,
+ }, nil)
+ require.NoError(t, err)
+
+ _, err = ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntityAppleDeclaration, useErr.Entity.Type)
+ require.Equal(t, "decl-1", useErr.Entity.Name)
+ require.Equal(t, "Foobar", useErr.Entity.FleetName)
+
+ require.NoError(t, ds.DeleteMDMAppleDeclaration(ctx, decl.DeclarationUUID))
+ })
+
+ t.Run("windows profiles", func(t *testing.T) {
+ winProfile, err := ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
+ Name: "zoo",
+ SyncML: []byte(fmt.Sprintf("%s ", token)),
+ }, nil)
+ require.NoError(t, err)
+
+ _, err = ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntityWindowsProfile, useErr.Entity.Type)
+ require.Equal(t, "zoo", useErr.Entity.Name)
+ require.Equal(t, "Unassigned", useErr.Entity.FleetName)
+
+ require.NoError(t, ds.DeleteMDMWindowsConfigProfile(ctx, winProfile.ProfileUUID))
+ })
+
+ t.Run("scripts", func(t *testing.T) {
+ script, err := ds.NewScript(ctx, &fleet.Script{
+ Name: "collect.sh",
+ ScriptContents: fmt.Sprintf("echo %s", token),
+ TeamID: &foobarTeam.ID,
+ })
+ require.NoError(t, err)
+
+ _, err = ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntityScript, useErr.Entity.Type)
+ require.Equal(t, "collect.sh", useErr.Entity.Name)
+ require.Equal(t, "Foobar", useErr.Entity.FleetName)
+
+ require.NoError(t, ds.DeleteScript(ctx, script.ID))
+ })
+
+ t.Run("software installers", func(t *testing.T) {
+ user := test.NewUser(t, ds, "Installer Author", "chv-del-installer@example.com", true)
+ tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
+ require.NoError(t, err)
+ installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: fmt.Sprintf("install %s", token),
+ UninstallScript: "uninstall",
+ InstallerFile: tfr,
+ StorageID: "chv-del-storage",
+ Filename: "chv-del.pkg",
+ Title: "chv-del-title",
+ Version: "1.0",
+ Source: "apps",
+ TeamID: &foobarTeam.ID,
+ UserID: user.ID,
+ ValidatedLabels: &fleet.LabelIdentsWithScope{},
+ })
+ require.NoError(t, err)
+
+ _, err = ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntitySoftwareInstaller, useErr.Entity.Type)
+ require.Equal(t, "chv-del-title", useErr.Entity.Name)
+ require.Equal(t, "Foobar", useErr.Entity.FleetName)
+
+ require.NoError(t, ds.DeleteSoftwareInstaller(ctx, installerID))
+ })
+
+ t.Run("setup experience scripts", func(t *testing.T) {
+ require.NoError(t, ds.SetSetupExperienceScript(ctx, &fleet.Script{
+ Name: "setup.sh",
+ ScriptContents: fmt.Sprintf("echo %s", token),
+ TeamID: &foobarTeam.ID,
+ }))
+
+ _, err := ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntitySetupExperienceScript, useErr.Entity.Type)
+ require.Equal(t, "setup.sh", useErr.Entity.Name)
+ require.Equal(t, "Foobar", useErr.Entity.FleetName)
+
+ require.NoError(t, ds.DeleteSetupExperienceScript(ctx, &foobarTeam.ID))
+ })
+
+ t.Run("host vitals labels", func(t *testing.T) {
+ // A host-vitals label references the vital by id in its criteria JSON,
+ // not via the $FLEET_HOST_VITAL_ token.
+ criteria, err := json.Marshal(&fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ CustomHostVitalID: &id,
+ })
+ require.NoError(t, err)
+ label, err := ds.NewLabel(ctx, &fleet.Label{
+ Name: "chv-del-label",
+ LabelType: fleet.LabelTypeRegular,
+ LabelMembershipType: fleet.LabelMembershipTypeHostVitals,
+ HostVitalsCriteria: new(json.RawMessage(criteria)),
+ })
+ require.NoError(t, err)
+
+ _, err = ds.DeleteCustomHostVital(ctx, id)
+ require.Error(t, err)
+ var useErr *fleet.CustomHostVitalUsedError
+ require.ErrorAs(t, err, &useErr)
+ require.Equal(t, id, useErr.CustomHostVitalID)
+ require.Equal(t, "FUNCTION", useErr.CustomHostVitalName)
+ require.Equal(t, fleet.CustomHostVitalEntityLabel, useErr.Entity.Type)
+ require.Equal(t, "chv-del-label", useErr.Entity.Name)
+ require.Equal(t, "Unassigned", useErr.Entity.FleetName)
+
+ require.NoError(t, ds.DeleteLabel(ctx, label.Name, fleet.TeamFilter{User: test.UserAdmin}))
+ })
+
+ // With all references removed, delete now succeeds.
+ name, err := ds.DeleteCustomHostVital(ctx, id)
+ require.NoError(t, err)
+ require.Equal(t, "FUNCTION", name)
+}
+
+// Setting a host's value for a vital must re-queue the MDM profiles and DDM
+// declarations already delivered to that host that reference the vital, so the
+// reconcilers re-expand $FLEET_HOST_VITAL_ with the new value. Profiles
+// referencing a different vital (or none), and other hosts, must be untouched.
+func testSetHostCustomHostVitalValueResendsProfiles(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ host := test.NewHost(t, ds, "mac", "1", "mackey", "macuuid", time.Now())
+ winHost := test.NewHost(t, ds, "win", "2", "winkey", "winuuid", time.Now(), test.WithPlatform("windows"))
+
+ vitalID := createCustomHostVital(t, ds, "FUNCTION")
+ otherID := createCustomHostVital(t, ds, "OTHER")
+ token := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, vitalID)
+ otherToken := fmt.Sprintf("$%s%d", fleet.CustomHostVitalPrefix, otherID)
+
+ // generateAppleCP/generateWindowsCP embed name+identifier in the profile
+ // body, so passing the token there puts the reference in the content the
+ // resend scan matches on.
+ profVital, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("pv", token, 0), nil)
+ require.NoError(t, err)
+ profOther, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("po", otherToken, 0), nil)
+ require.NoError(t, err)
+ profNone, err := ds.NewMDMAppleConfigProfile(ctx, *generateAppleCP("pn", "plain", 0), nil)
+ require.NoError(t, err)
+
+ profWVital, err := ds.NewMDMWindowsConfigProfile(ctx, *generateWindowsCP("wv", token, 0), nil)
+ require.NoError(t, err)
+ profWNone, err := ds.NewMDMWindowsConfigProfile(ctx, *generateWindowsCP("wn", "plain", 0), nil)
+ require.NoError(t, err)
+
+ forceSetAppleHostProfileStatus(t, ds, host.UUID, profVital, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
+ forceSetAppleHostProfileStatus(t, ds, host.UUID, profOther, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
+ forceSetAppleHostProfileStatus(t, ds, host.UUID, profNone, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
+ forceSetWindowsHostProfileStatus(t, ds, winHost.UUID, profWVital, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
+ forceSetWindowsHostProfileStatus(t, ds, winHost.UUID, profWNone, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
+
+ // DDM declaration referencing the vital, delivered (verifying) to the mac host.
+ bracedToken := fmt.Sprintf("${%s%d}", fleet.CustomHostVitalPrefix, vitalID)
+ declVital, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
+ Identifier: "decl-vital", Name: "decl-vital",
+ RawJSON: json.RawMessage(fmt.Sprintf(`{"note":"%s"}`, bracedToken)),
+ }, nil)
+ require.NoError(t, err)
+ forceSetAppleHostDeclarationStatus(t, ds, host.UUID, declVital, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
+
+ // Set the value on both hosts; only referencing entities on the same host reset.
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vitalID, "Engineering"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, winHost.ID, vitalID, "Engineering"))
+
+ // A reset row has NULL status, which assertHostProfileStatus reports as
+ // pending. The declaration surfaces through GetHostMDMAppleProfiles too, so
+ // it's included here; it should also be reset.
+ assertHostProfileStatus(t, ds, host.UUID,
+ hostProfileStatus{profVital.ProfileUUID, fleet.MDMDeliveryPending},
+ hostProfileStatus{profOther.ProfileUUID, fleet.MDMDeliveryVerifying},
+ hostProfileStatus{profNone.ProfileUUID, fleet.MDMDeliveryVerifying},
+ hostProfileStatus{declVital.DeclarationUUID, fleet.MDMDeliveryPending})
+ assertHostProfileStatus(t, ds, winHost.UUID,
+ hostProfileStatus{profWVital.ProfileUUID, fleet.MDMDeliveryPending},
+ hostProfileStatus{profWNone.ProfileUUID, fleet.MDMDeliveryVerifying})
+
+ var declStatus *fleet.MDMDeliveryStatus
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q, &declStatus,
+ `SELECT status FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_uuid = ?`,
+ host.UUID, declVital.DeclarationUUID)
+ })
+ require.Nil(t, declStatus, "declaration should be reset (NULL status) so the DDM reconciler re-delivers it")
+}
+
+// The DDM reconcile snapshot must flag declarations that reference a custom host
+// vital as HasFleetVariables, so the reconciler stamps variables_updated_at on
+// the host declaration row — the signal handleDeclarationItems relies on to load
+// raw_json, drop unresolvable declarations from the manifest, and cache-bust the
+// DDM token. Custom host vitals aren't in mdm_configuration_profile_variables, so
+// this depends on the body scan rather than the variables join.
+func testReconcileSnapshotMarksVitalDeclarations(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ // A macOS host must be MDM-enrolled to enter the reconcile window; otherwise
+ // the snapshot skips loading declarations entirely.
+ host := test.NewHost(t, ds, "macos-1", "1", "macos-1-key", "macos-1-uuid", time.Now())
+ nanoEnroll(t, ds, host, false)
+
+ vitalID := createCustomHostVital(t, ds, "FUNCTION")
+ bracedToken := fmt.Sprintf("${%s%d}", fleet.CustomHostVitalPrefix, vitalID)
+
+ declVital, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
+ Identifier: "decl-vital", Name: "decl-vital",
+ RawJSON: json.RawMessage(fmt.Sprintf(`{"note":"%s"}`, bracedToken)),
+ }, nil)
+ require.NoError(t, err)
+ declPlain, err := ds.NewMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
+ Identifier: "decl-plain", Name: "decl-plain",
+ RawJSON: json.RawMessage(`{"note":"static"}`),
+ }, nil)
+ require.NoError(t, err)
+
+ _, allDecls, _, _, err := ds.GetAppleDeclarationReconcileSnapshot(ctx, "", 100)
+ require.NoError(t, err)
+
+ byUUID := make(map[string]*fleet.AppleDeclarationForReconcile, len(allDecls))
+ for _, d := range allDecls {
+ byUUID[d.DeclarationUUID] = d
+ }
+ vitalDecl := byUUID[declVital.DeclarationUUID]
+ plainDecl := byUUID[declPlain.DeclarationUUID]
+ require.NotNil(t, vitalDecl, "vital declaration missing from reconcile snapshot")
+ require.NotNil(t, plainDecl, "plain declaration missing from reconcile snapshot")
+ assert.True(t, vitalDecl.HasFleetVariables, //nolint:nilaway // cannot be nil due to require.NotNil above
+ "declaration referencing a custom host vital should be marked HasFleetVariables")
+ assert.False(t, plainDecl.HasFleetVariables, //nolint:nilaway // cannot be nil due to require.NotNil above
+ "declaration without any variables should not be marked")
+}
+
+// A $FLEET_HOST_VITAL_ token whose suffix isn't a valid vital ID must be rejected on upload
+func testValidateReferencedCustomHostVitalsRejectsMalformed(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+ vitalID := createCustomHostVital(t, ds, "Function")
+
+ // Valid numeric reference to an existing vital passes.
+ require.NoError(t, ds.ValidateReferencedCustomHostVitals(ctx,
+ []string{fmt.Sprintf("echo $%s%d", fleet.CustomHostVitalPrefix, vitalID)}))
+
+ // Non-numeric suffix is a malformed reference -> InvalidCustomHostVitalRefError.
+ var invalidErr *fleet.InvalidCustomHostVitalRefError
+ err := ds.ValidateReferencedCustomHostVitals(ctx,
+ []string{fmt.Sprintf("echo $%sasset_tag", fleet.CustomHostVitalPrefix)})
+ require.ErrorAs(t, err, &invalidErr)
+
+ // Braced malformed form -> also InvalidCustomHostVitalRefError.
+ invalidErr = nil
+ err = ds.ValidateReferencedCustomHostVitals(ctx,
+ []string{fmt.Sprintf("echo ${%sFOOBAR}", fleet.CustomHostVitalPrefix)})
+ require.ErrorAs(t, err, &invalidErr)
+
+ // Numeric-but-nonexistent id -> MissingCustomHostVitalsError (distinct from malformed).
+ var missingErr *fleet.MissingCustomHostVitalsError
+ err = ds.ValidateReferencedCustomHostVitals(ctx,
+ []string{fmt.Sprintf("echo $%s999999", fleet.CustomHostVitalPrefix)})
+ require.ErrorAs(t, err, &missingErr)
+
+ // No token at all -> no error.
+ require.NoError(t, ds.ValidateReferencedCustomHostVitals(ctx, []string{"echo hello"}))
+}
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go
index 3182a23fcf..0b7ac14a07 100644
--- a/server/datastore/mysql/hosts.go
+++ b/server/datastore/mysql/hosts.go
@@ -609,6 +609,7 @@ var hostRefs = []string{
"host_vpp_software_installs",
"host_last_known_locations",
"host_issues",
+ "host_custom_host_vitals",
}
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index 725b83783c..f29a63455b 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -9735,6 +9735,17 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
err = ds.UpdateHostIssuesFailingPoliciesForSingleHost(ctx, host.ID)
require.NoError(t, err)
+ // Insert into host_custom_host_vitals table (no host FK, cleaned up via hostRefs).
+ vitalRes, err := ds.writer(context.Background()).Exec(`INSERT INTO custom_host_vitals (name) VALUES (?)`, "delete-host-vital")
+ require.NoError(t, err)
+ vitalID, err := vitalRes.LastInsertId()
+ require.NoError(t, err)
+ _, err = ds.writer(context.Background()).Exec(
+ `INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, ?)`,
+ host.ID, vitalID, "engineering",
+ )
+ require.NoError(t, err)
+
// Check there's an entry for the host in all the associated tables.
for _, hostRef := range hostRefs {
var ok bool
diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go
index a5b3020849..8bd36343c8 100644
--- a/server/datastore/mysql/labels_test.go
+++ b/server/datastore/mysql/labels_test.go
@@ -102,6 +102,7 @@ func TestLabels(t *testing.T) {
{"ApplyLabelSpecsWithPlatformChange", testApplyLabelSpecsWithPlatformChange},
{"UpdateLabelMembershipByHostCriteria", testUpdateLabelMembershipByHostCriteria},
{"UpdateLabelMembershipByHostCriteriaIDP", testUpdateLabelMembershipByHostCriteriaIDP},
+ {"UpdateLabelMembershipByHostCriteriaCustomHostVital", testUpdateLabelMembershipByHostCriteriaCustomHostVital},
{"TeamLabels", testTeamLabels},
{"UpdateLabelMembershipForTransferredHost", testUpdateLabelMembershipForTransferredHost},
{"SetAsideLabels", testSetAsideLabels},
@@ -2972,6 +2973,105 @@ func testUpdateLabelMembershipByHostCriteriaIDP(t *testing.T, ds *Datastore) {
_ = team2 // team2 is used only to give host2 an out-of-team membership.
}
+// testUpdateLabelMembershipByHostCriteriaCustomHostVital exercises the real
+// custom-host-vital query path: membership is a host's stored value for a
+// specific custom_host_vital_id matching the criterion value. It verifies
+// multi-host isolation (one host's value doesn't leak to another), that the
+// criterion is scoped to its vital id (a matching value on a different vital
+// does not count), and that team-scoped labels only include in-team hosts.
+func testUpdateLabelMembershipByHostCriteriaCustomHostVital(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "chv-team1"})
+ require.NoError(t, err)
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "chv-team2"})
+ require.NoError(t, err)
+
+ // host1 -> team1, host2 -> team2, host3 -> global, host4 -> global.
+ hosts := make([]*fleet.Host, 4)
+ teamIDs := []*uint{&team1.ID, &team2.ID, nil, nil}
+ for i := range 4 {
+ host, err := ds.NewHost(ctx, &fleet.Host{
+ OsqueryHostID: new(fmt.Sprintf("chv-%d", i)),
+ NodeKey: new(fmt.Sprintf("chv-%d", i)),
+ UUID: fmt.Sprintf("chv-uuid%d", i),
+ Hostname: fmt.Sprintf("chv-host%d.local", i),
+ HardwareSerial: fmt.Sprintf("chv-hwd%d", i),
+ Platform: "darwin",
+ TeamID: teamIDs[i],
+ })
+ require.NoError(t, err)
+ hosts[i] = host
+ }
+
+ vitalA, err := ds.CreateCustomHostVital(ctx, "Department")
+ require.NoError(t, err)
+ vitalB, err := ds.CreateCustomHostVital(ctx, "Function")
+ require.NoError(t, err)
+
+ // host1 & host4: vitalA = "Engineering" (should match).
+ // host2: vitalA = "Sales" (wrong value -> excluded).
+ // host3: vitalB = "Engineering" (right value but wrong vital -> excluded),
+ // and vitalA = "Sales" so it also has a value for the target vital.
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[0].ID, vitalA.ID, "Engineering"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[3].ID, vitalA.ID, "Engineering"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[1].ID, vitalA.ID, "Sales"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[2].ID, vitalA.ID, "Sales"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[2].ID, vitalB.ID, "Engineering"))
+
+ criteria, err := json.Marshal(&fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ CustomHostVitalID: &vitalA.ID,
+ })
+ require.NoError(t, err)
+
+ newCHVLabel := func(name string, teamID *uint) *fleet.Label {
+ lbl, err := ds.NewLabel(ctx, &fleet.Label{
+ Name: name,
+ TeamID: teamID,
+ LabelType: fleet.LabelTypeRegular,
+ LabelMembershipType: fleet.LabelMembershipTypeHostVitals,
+ HostVitalsCriteria: new(json.RawMessage(criteria)),
+ })
+ require.NoError(t, err)
+ return lbl
+ }
+
+ filter := fleet.TeamFilter{User: test.UserAdmin}
+
+ // Global label: host1 and host4 (vitalA = "Engineering"). host2 has the
+ // wrong value, host3 matches the value only on vitalB.
+ globalLabel := newCHVLabel("chv-global", nil)
+ updated, err := ds.UpdateLabelMembershipByHostCriteria(ctx, globalLabel)
+ require.NoError(t, err)
+ require.Equal(t, 2, updated.HostCount)
+ globalHosts, err := ds.ListHostsInLabel(ctx, filter, globalLabel.ID, fleet.HostListOptions{})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []uint{hosts[0].ID, hosts[3].ID}, hostIDs(globalHosts))
+
+ // Team1 label: only host1, even though host4 also matches (it's global).
+ team1Label := newCHVLabel("chv-team1-label", &team1.ID)
+ updated, err = ds.UpdateLabelMembershipByHostCriteria(ctx, team1Label)
+ require.NoError(t, err)
+ require.Equal(t, 1, updated.HostCount)
+ team1Hosts, err := ds.ListHostsInLabel(ctx, filter, team1Label.ID, fleet.HostListOptions{})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []uint{hosts[0].ID}, hostIDs(team1Hosts))
+
+ // Changing a host's value re-computes membership: host4 leaves, host2 joins.
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[3].ID, vitalA.ID, "Sales"))
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, hosts[1].ID, vitalA.ID, "Engineering"))
+ updated, err = ds.UpdateLabelMembershipByHostCriteria(ctx, globalLabel)
+ require.NoError(t, err)
+ require.Equal(t, 2, updated.HostCount)
+ globalHosts, err = ds.ListHostsInLabel(ctx, filter, globalLabel.ID, fleet.HostListOptions{})
+ require.NoError(t, err)
+ require.ElementsMatch(t, []uint{hosts[0].ID, hosts[1].ID}, hostIDs(globalHosts))
+
+ _ = team2 // team2 only gives host2 an out-of-team membership.
+}
+
func hostIDs(hosts []*fleet.Host) []uint {
ids := make([]uint, 0, len(hosts))
for _, h := range hosts {
diff --git a/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go
new file mode 100644
index 0000000000..14389c2ae2
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals.go
@@ -0,0 +1,53 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20260715144547, Down_20260715144547)
+}
+
+func Up_20260715144547(tx *sql.Tx) error {
+ _, err := tx.Exec(`
+ CREATE TABLE custom_host_vitals (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+ -- Using DATETIME instead of TIMESTAMP to prevent future Y2K38 issues.
+ created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6),
+ PRIMARY KEY (id),
+ CONSTRAINT idx_custom_host_vitals_name UNIQUE (name)
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create custom_host_vitals table: %w", err)
+ }
+
+ _, err = tx.Exec(`
+ CREATE TABLE host_custom_host_vitals (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ host_id INT UNSIGNED NOT NULL,
+ custom_host_vital_id INT UNSIGNED NOT NULL,
+ value TEXT COLLATE utf8mb4_unicode_ci NOT NULL,
+ created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6),
+ PRIMARY KEY (id),
+ CONSTRAINT idx_host_custom_host_vitals_host_vital UNIQUE (host_id, custom_host_vital_id),
+ -- No FK on host_id (see handbook/engineering/scaling-fleet.md): rows are
+ -- cleaned up on host deletion via the hostRefs list instead.
+ CONSTRAINT fk_host_custom_host_vitals_custom_host_vital_id
+ FOREIGN KEY (custom_host_vital_id) REFERENCES custom_host_vitals (id) ON DELETE CASCADE
+ ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to create host_custom_host_vitals table: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20260715144547(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go
new file mode 100644
index 0000000000..d520af0b64
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20260715144547_CustomHostVitals_test.go
@@ -0,0 +1,53 @@
+package tables
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20260715144547(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ // Seed a host so we can attach a per-host value.
+ res, err := db.Exec(`
+ INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid)
+ VALUES (?, ?, ?, ?)`,
+ "host-1-osquery-id", "host-1-node-key", "host-1", "host-1-uuid",
+ )
+ require.NoError(t, err)
+ hostIDInt, err := res.LastInsertId()
+ require.NoError(t, err)
+ hostID := uint(hostIDInt) //nolint:gosec
+
+ // Apply current migration.
+ applyNext(t, db)
+
+ // Insert a definition; the name is unique.
+ res, err = db.Exec(`INSERT INTO custom_host_vitals (name) VALUES ('Asset tag')`)
+ require.NoError(t, err)
+ vitalIDInt, err := res.LastInsertId()
+ require.NoError(t, err)
+ vitalID := uint(vitalIDInt) //nolint:gosec
+
+ _, err = db.Exec(`INSERT INTO custom_host_vitals (name) VALUES ('Asset tag')`)
+ require.Error(t, err, "duplicate name should be rejected")
+
+ // value is NOT NULL.
+ _, err = db.Exec(`INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, NULL)`, hostID, vitalID)
+ require.Error(t, err, "NULL value should be rejected")
+
+ // Insert a per-host value; (host_id, custom_host_vital_id) is unique.
+ _, err = db.Exec(`INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, 'engineering')`, hostID, vitalID)
+ require.NoError(t, err)
+ _, err = db.Exec(`INSERT INTO host_custom_host_vitals (host_id, custom_host_vital_id, value) VALUES (?, ?, 'other')`, hostID, vitalID)
+ require.Error(t, err, "duplicate (host_id, custom_host_vital_id) should be rejected")
+
+ // Deleting the definition cascades to the per-host value.
+ _, err = db.Exec(`DELETE FROM custom_host_vitals WHERE id = ?`, vitalID)
+ require.NoError(t, err)
+ var count int
+ err = db.QueryRow(`SELECT COUNT(*) FROM host_custom_host_vitals WHERE custom_host_vital_id = ?`, vitalID).Scan(&count)
+ require.NoError(t, err)
+ require.Zero(t, count)
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index 7fa24eacbe..4e83e02ba7 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -433,6 +433,17 @@ CREATE TABLE `cron_stats` (
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `custom_host_vitals` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_custom_host_vitals_name` (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `cve_meta` (
`cve` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
`cvss_score` double DEFAULT NULL,
@@ -664,6 +675,21 @@ CREATE TABLE `host_conditional_access` (
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
+CREATE TABLE `host_custom_host_vitals` (
+ `id` int unsigned NOT NULL AUTO_INCREMENT,
+ `host_id` int unsigned NOT NULL,
+ `custom_host_vital_id` int unsigned NOT NULL,
+ `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_host_custom_host_vitals_host_vital` (`host_id`,`custom_host_vital_id`),
+ KEY `fk_host_custom_host_vitals_custom_host_vital_id` (`custom_host_vital_id`),
+ CONSTRAINT `fk_host_custom_host_vitals_custom_host_vital_id` FOREIGN KEY (`custom_host_vital_id`) REFERENCES `custom_host_vitals` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `host_dep_assignments` (
`host_id` int unsigned NOT NULL,
`added_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -2156,9 +2182,9 @@ CREATE TABLE `migration_status_tables` (
`is_applied` tinyint(1) NOT NULL,
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
-) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=569 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=570 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260704213830,1,'2020-01-01 01:01:01'),(561,20260706174522,1,'2020-01-01 01:01:01'),(562,20260707071142,1,'2020-01-01 01:01:01'),(563,20260707140752,1,'2020-01-01 01:01:01'),(564,20260708153912,1,'2020-01-01 01:01:01'),(565,20260708185958,1,'2020-01-01 01:01:01'),(566,20260708190008,1,'2020-01-01 01:01:01'),(567,20260708192536,1,'2020-01-01 01:01:01'),(568,20260713115453,1,'2020-01-01 01:01:01');
+INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260704213830,1,'2020-01-01 01:01:01'),(561,20260706174522,1,'2020-01-01 01:01:01'),(562,20260707071142,1,'2020-01-01 01:01:01'),(563,20260707140752,1,'2020-01-01 01:01:01'),(564,20260708153912,1,'2020-01-01 01:01:01'),(565,20260708185958,1,'2020-01-01 01:01:01'),(566,20260708190008,1,'2020-01-01 01:01:01'),(567,20260708192536,1,'2020-01-01 01:01:01'),(568,20260713115453,1,'2020-01-01 01:01:01'),(569,20260715144547,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `mobile_device_management_solutions` (
diff --git a/server/datastore/mysql/secret_variables.go b/server/datastore/mysql/secret_variables.go
index 57f7966d97..7dd3e663c9 100644
--- a/server/datastore/mysql/secret_variables.go
+++ b/server/datastore/mysql/secret_variables.go
@@ -154,7 +154,7 @@ func (ds *Datastore) GetSecretVariables(ctx context.Context, names []string) ([]
func (ds *Datastore) ListSecretVariables(ctx context.Context, opt fleet.ListOptions) (
secretVariables []fleet.SecretVariableIdentifier, meta *fleet.PaginationMetadata, count int, err error,
) {
- stmt := `SELECT id, name, updated_at FROM secret_variables WHERE true`
+ stmt := `SELECT id, name, created_at, updated_at FROM secret_variables WHERE true`
// normalize the name for full Unicode support (Unicode equivalence).
normMatch := norm.NFC.String(opt.MatchQuery)
@@ -392,34 +392,47 @@ func (ds *Datastore) expandEmbeddedSecrets(ctx context.Context, document string)
return "", nil, fleet.MissingSecretsError{MissingSecrets: missingSecrets}
}
- // Detect document format so we can escape the secret value appropriately.
+ expanded := expandDocumentVars(document, func(s string) (string, bool) {
+ if !strings.HasPrefix(s, fleet.ServerSecretPrefix) {
+ return "", false
+ }
+ val, ok := secretMap[strings.TrimPrefix(s, fleet.ServerSecretPrefix)]
+ return val, ok
+ })
+
+ return expanded, secrets, nil
+}
+
+// expandDocumentVars runs fleet.MaybeExpand over document, resolving each
+// variable name via resolve and escaping the substituted value for the
+// document's format (JSON or XML) so an injected value can't break the
+// surrounding profile. resolve returns the raw (unescaped) value and whether the
+// variable was handled; unhandled variables are left in place. Shared by
+// ExpandEmbeddedSecrets ($FLEET_SECRET_) and ExpandCustomHostVitals
+// ($FLEET_HOST_VITAL_) so the format escaping has a single implementation.
+func expandDocumentVars(document string, resolve func(name string) (string, bool)) string {
// XML detection is aggressive because Windows profiles do not begin with 0 {
+ return errors.New("Couldn't edit profile. Custom host vitals aren't supported in Android configuration profiles.")
+ }
+
found := variables.Find(string(rawJSON))
if len(found) == 0 {
return nil
diff --git a/server/fleet/android_test.go b/server/fleet/android_test.go
index 6d91d54d38..5655673128 100644
--- a/server/fleet/android_test.go
+++ b/server/fleet/android_test.go
@@ -220,6 +220,18 @@ func TestValidateUserProvided_FleetVariables(t *testing.T) {
wantErr: true,
errSubstr: "Invalid JSON payload",
},
+ {
+ name: "custom host vital is not supported on Android",
+ rawJSON: `{"name": "$FLEET_HOST_VITAL_7"}`,
+ wantErr: true,
+ errSubstr: "Custom host vitals aren't supported in Android configuration profiles",
+ },
+ {
+ name: "custom host vital rejected even alongside a supported variable",
+ rawJSON: `{"name": "$FLEET_VAR_HOST_UUID $FLEET_HOST_VITAL_7"}`,
+ wantErr: true,
+ errSubstr: "Custom host vitals aren't supported in Android configuration profiles",
+ },
}
for _, tt := range tests {
diff --git a/server/fleet/api_custom_host_vitals.go b/server/fleet/api_custom_host_vitals.go
new file mode 100644
index 0000000000..a3b71e625a
--- /dev/null
+++ b/server/fleet/api_custom_host_vitals.go
@@ -0,0 +1,97 @@
+package fleet
+
+//////////////////////////////////////////////////////////////////////////////////
+// List custom host vitals
+//////////////////////////////////////////////////////////////////////////////////
+
+type ListCustomHostVitalsRequest struct {
+ ListOptions ListOptions `url:"list_options"`
+}
+
+type ListCustomHostVitalsResponse struct {
+ CustomHostVitals []CustomHostVital `json:"custom_host_vitals"`
+ Meta *PaginationMetadata `json:"meta"`
+ Count int `json:"count"`
+
+ Err error `json:"error,omitempty"`
+}
+
+func (r ListCustomHostVitalsResponse) Error() error { return r.Err }
+
+//////////////////////////////////////////////////////////////////////////////////
+// Create custom host vital
+//////////////////////////////////////////////////////////////////////////////////
+
+type CreateCustomHostVitalRequest struct {
+ Name string `json:"name"`
+}
+
+type CreateCustomHostVitalResponse struct {
+ CustomHostVital *CustomHostVital `json:"custom_host_vital,omitempty"`
+
+ Err error `json:"error,omitempty"`
+}
+
+func (r CreateCustomHostVitalResponse) Error() error { return r.Err }
+
+//////////////////////////////////////////////////////////////////////////////////
+// Update (rename) custom host vital
+//////////////////////////////////////////////////////////////////////////////////
+
+type UpdateCustomHostVitalRequest struct {
+ ID uint `url:"id"`
+ Name string `json:"name"`
+}
+
+type UpdateCustomHostVitalResponse struct {
+ CustomHostVital *CustomHostVital `json:"custom_host_vital,omitempty"`
+
+ Err error `json:"error,omitempty"`
+}
+
+func (r UpdateCustomHostVitalResponse) Error() error { return r.Err }
+
+//////////////////////////////////////////////////////////////////////////////////
+// Delete custom host vital
+//////////////////////////////////////////////////////////////////////////////////
+
+type DeleteCustomHostVitalRequest struct {
+ ID uint `url:"id"`
+}
+
+type DeleteCustomHostVitalResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r DeleteCustomHostVitalResponse) Error() error { return r.Err }
+
+//////////////////////////////////////////////////////////////////////////////////
+// Set host custom host vital value
+//////////////////////////////////////////////////////////////////////////////////
+
+type SetHostCustomHostVitalValueRequest struct {
+ HostID uint `url:"host_id"`
+ ID uint `url:"id"`
+ Value string `json:"value"`
+}
+
+type SetHostCustomHostVitalValueResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r SetHostCustomHostVitalValueResponse) Error() error { return r.Err }
+
+//////////////////////////////////////////////////////////////////////////////////
+// Upsert custom host vitals (spec)
+//////////////////////////////////////////////////////////////////////////////////
+
+type UpsertCustomHostVitalsRequest struct {
+ DryRun bool `json:"dry_run"`
+ CustomHostVitals []CustomHostVital `json:"custom_host_vitals"`
+}
+
+type UpsertCustomHostVitalsResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r UpsertCustomHostVitalsResponse) Error() error { return r.Err }
diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go
index 1b537df571..dd4e809801 100644
--- a/server/fleet/apple_mdm.go
+++ b/server/fleet/apple_mdm.go
@@ -537,9 +537,12 @@ type AppleDeclarationForReconcile struct {
IncludeMode AppleProfileIncludeMode
IncludeLabels []AppleProfileLabelRef
ExcludeLabels []AppleProfileLabelRef
- // HasFleetVariables is true if the declaration references any $FLEET_VAR_*.
- // The reconciler sets VariablesUpdatedAt on the host declaration row so the
- // host knows to re-deliver when variable values change.
+ // HasFleetVariables is true if the declaration references any per-host Fleet
+ // variable that is expanded at delivery time: a $FLEET_VAR_* or a custom host
+ // vital ($FLEET_HOST_VITAL_*). Both behave identically here — the reconciler
+ // sets VariablesUpdatedAt on the host declaration row so the host knows to
+ // re-deliver when a variable value changes — so they share this flag even
+ // though custom host vitals are a separate variable namespace.
//
// This does not cover $FLEET_SECRET_* variables and SecretsUpdatedAt as that is handled at upload time
// where we extract the secrets and their last update time.
diff --git a/server/fleet/custom_host_vitals.go b/server/fleet/custom_host_vitals.go
new file mode 100644
index 0000000000..9555bab3e8
--- /dev/null
+++ b/server/fleet/custom_host_vitals.go
@@ -0,0 +1,204 @@
+package fleet
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+)
+
+const CustomHostVitalPrefix = "FLEET_HOST_VITAL_"
+
+const customHostVitalNameMaxNameLen = 255
+
+type CustomHostVital struct {
+ ID uint `json:"id" db:"id"`
+ Name string `json:"name" db:"name"`
+ CreatedAt string `json:"created_at" db:"created_at"`
+ UpdatedAt string `json:"updated_at" db:"updated_at"`
+}
+
+func (h CustomHostVital) AuthzType() string {
+ return "custom_vital"
+}
+
+// HostCustomHostVital is a single host's value for a custom host vital.
+type HostCustomHostVital struct {
+ CustomHostVitalID uint `json:"custom_host_vital_id" db:"custom_host_vital_id"`
+ Name string `json:"name" db:"name"`
+ Value string `json:"value" db:"value"`
+}
+
+// HostCustomHostVitalValue is the authz subject for setting a host's custom host vital value.
+type HostCustomHostVitalValue struct {
+ TeamID *uint `json:"team_id" renameto:"fleet_id"`
+}
+
+func (HostCustomHostVitalValue) AuthzType() string {
+ return "host_custom_vital"
+}
+
+type MissingCustomHostVitalsError struct {
+ MissingIDs []uint
+}
+
+func (e MissingCustomHostVitalsError) Error() string {
+ tokens := make([]string, 0, len(e.MissingIDs))
+ for _, id := range e.MissingIDs {
+ tokens = append(tokens, fmt.Sprintf("\"$%s%d\"", CustomHostVitalPrefix, id))
+ }
+ plural := ""
+ if len(tokens) > 1 {
+ plural = "s"
+ }
+ return fmt.Sprintf("Couldn't add. Custom host vital%s %s is not defined", plural, strings.Join(tokens, ", "))
+}
+
+// InvalidCustomHostVitalRefError is returned on upload when a document contains a
+// $FLEET_HOST_VITAL_ token whose is not a valid custom host vital ID
+// (a positive integer) — e.g. a typo like $FLEET_HOST_VITAL_asset_tag.
+type InvalidCustomHostVitalRefError struct {
+ // Refs are the offending tokens without the leading '$', e.g. "FLEET_HOST_VITAL_asset_tag".
+ Refs []string
+}
+
+func (e InvalidCustomHostVitalRefError) Error() string {
+ tokens := make([]string, 0, len(e.Refs))
+ for _, r := range e.Refs {
+ tokens = append(tokens, fmt.Sprintf("\"$%s\"", r))
+ }
+ plural := ""
+ if len(tokens) > 1 {
+ plural = "s"
+ }
+ return fmt.Sprintf(
+ "Couldn't add. Invalid custom host vital reference%s %s; the value after $%s must be a custom host vital ID",
+ plural, strings.Join(tokens, ", "), CustomHostVitalPrefix,
+ )
+}
+
+// MissingCustomHostVitalValueError is returned when expanding $FLEET_HOST_VITAL_
+// at delivery time for a host that has no value set for that (existing) vital.
+// Distinct from MissingCustomHostVitalsError (upload-time: the id doesn't exist)
+// so the delivery failure detail shown to admins names the real cause.
+type MissingCustomHostVitalValueError struct {
+ MissingIDs []uint
+}
+
+func (e MissingCustomHostVitalValueError) Error() string {
+ tokens := make([]string, 0, len(e.MissingIDs))
+ for _, id := range e.MissingIDs {
+ tokens = append(tokens, fmt.Sprintf("\"$%s%d\"", CustomHostVitalPrefix, id))
+ }
+ plural := ""
+ if len(tokens) > 1 {
+ plural = "s"
+ }
+ return fmt.Sprintf("Couldn't populate custom host vital%s %s: no value set for this host", plural, strings.Join(tokens, ", "))
+}
+
+// CustomHostVitalEntity identifies the kind of entity that can reference a custom host vital.
+type CustomHostVitalEntity string
+
+const (
+ CustomHostVitalEntityScript CustomHostVitalEntity = "script"
+ CustomHostVitalEntityAppleProfile CustomHostVitalEntity = "apple_profile"
+ CustomHostVitalEntityAppleDeclaration CustomHostVitalEntity = "apple_declaration"
+ CustomHostVitalEntityWindowsProfile CustomHostVitalEntity = "windows_profile"
+ CustomHostVitalEntitySoftwareInstaller CustomHostVitalEntity = "software_installer"
+ CustomHostVitalEntitySetupExperienceScript CustomHostVitalEntity = "setup_experience_script"
+ CustomHostVitalEntityLabel CustomHostVitalEntity = "label"
+)
+
+// Describes an entity that references a custom host vital.
+type EntityUsingCustomHostVital struct {
+ Type CustomHostVitalEntity
+ // Name is the name of the entity.
+ Name string
+ // FleetName is the name of the fleet (team) the entity belongs to.
+ FleetName string
+}
+
+// CustomHostVitalUsedInfo describes a script/profile/declaration that references a custom host vital.
+type CustomHostVitalUsedInfo struct {
+ CustomHostVitalID uint
+ CustomHostVitalName string
+ Entity EntityUsingCustomHostVital
+}
+
+// Message returns the human-readable "X is used by Y" explanation.
+func (i CustomHostVitalUsedInfo) Message() string {
+ noun, action := "configuration profile", "Please delete the configuration profile and try again."
+ switch i.Entity.Type {
+ case CustomHostVitalEntityScript:
+ noun, action = "script", "Please edit or delete the script and try again."
+ case CustomHostVitalEntitySoftwareInstaller:
+ noun, action = "software", "Please edit or delete the software and try again."
+ case CustomHostVitalEntitySetupExperienceScript:
+ noun, action = "setup experience script", "Please edit or delete the setup experience script and try again."
+ case CustomHostVitalEntityLabel:
+ noun, action = "label", "Please edit or delete the label and try again."
+ }
+ return fmt.Sprintf(
+ "Custom host vital %q (used as $%s%d) is used by the %q %s in the %q fleet. %s",
+ i.CustomHostVitalName, CustomHostVitalPrefix, i.CustomHostVitalID, i.Entity.Name, noun, i.Entity.FleetName, action,
+ )
+}
+
+// CustomHostVitalUsedError wraps CustomHostVitalUsedInfo as an error, returned when
+// a custom host vital can't be deleted because it is still referenced.
+type CustomHostVitalUsedError struct {
+ CustomHostVitalUsedInfo
+}
+
+func (e *CustomHostVitalUsedError) Error() string {
+ return e.Message()
+}
+
+func ValidateCustomHostVitalName(name string) error {
+ if len(name) == 0 {
+ return NewInvalidArgumentError("name", "custom host vital name cannot be empty")
+ }
+ if strings.TrimSpace(name) != name {
+ return NewInvalidArgumentError("name", "custom host vital name cannot have leading or trailing whitespace")
+ }
+ if utf8.RuneCountInString(name) > customHostVitalNameMaxNameLen {
+ return NewInvalidArgumentError("name", fmt.Sprintf("custom host vital name is too long: %s", name))
+ }
+ return nil
+}
+
+func ContainsCustomHostVitalIDs(text string) []uint {
+ suffixes := ContainsPrefixVars(text, CustomHostVitalPrefix)
+ if len(suffixes) == 0 {
+ return nil
+ }
+ seen := make(map[uint]struct{}, len(suffixes))
+ ids := make([]uint, 0, len(suffixes))
+ for _, s := range suffixes {
+ id, err := strconv.ParseUint(s, 10, strconv.IntSize)
+ if err != nil || id == 0 {
+ continue
+ }
+ if _, ok := seen[uint(id)]; ok {
+ continue
+ }
+ seen[uint(id)] = struct{}{}
+ ids = append(ids, uint(id))
+ }
+ return ids
+}
+
+// ContainsMalformedCustomHostVitalRefs returns the $FLEET_HOST_VITAL_ tokens in
+// text whose is not a valid custom host vital ID (a positive integer), e.g. a
+// typo like $FLEET_HOST_VITAL_asset_tag.
+// Returned tokens omit the leading '$' (e.g. "FLEET_HOST_VITAL_asset_tag").
+func ContainsMalformedCustomHostVitalRefs(text string) []string {
+ var malformed []string
+ for _, s := range ContainsPrefixVars(text, CustomHostVitalPrefix) {
+ if id, err := strconv.ParseUint(s, 10, strconv.IntSize); err != nil || id == 0 {
+ malformed = append(malformed, CustomHostVitalPrefix+s)
+ }
+ }
+ return malformed
+}
diff --git a/server/fleet/custom_host_vitals_test.go b/server/fleet/custom_host_vitals_test.go
new file mode 100644
index 0000000000..2465692509
--- /dev/null
+++ b/server/fleet/custom_host_vitals_test.go
@@ -0,0 +1,59 @@
+package fleet
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestContainsCustomHostVitalIDs(t *testing.T) {
+ t.Run("both token forms and dedupe", func(t *testing.T) {
+ doc := `
+#!/bin/sh
+echo $FLEET_HOST_VITAL_1
+echo words${FLEET_HOST_VITAL_2}words
+echo $FLEET_HOST_VITAL_1 again
+`
+ ids := ContainsCustomHostVitalIDs(doc)
+ require.ElementsMatch(t, []uint{1, 2}, ids)
+ })
+
+ t.Run("ignores non-numeric and zero suffixes", func(t *testing.T) {
+ doc := `$FLEET_HOST_VITAL_ABC ${FLEET_HOST_VITAL_} $FLEET_HOST_VITAL_0 $FLEET_HOST_VITAL_12X $FLEET_HOST_VITAL_7`
+ ids := ContainsCustomHostVitalIDs(doc)
+ require.Equal(t, []uint{7}, ids)
+ })
+
+ t.Run("no tokens", func(t *testing.T) {
+ require.Empty(t, ContainsCustomHostVitalIDs("no vitals here $FLEET_SECRET_FOO $FLEET_VAR_HOST_UUID"))
+ })
+
+ t.Run("does not match a longer variable that starts with the prefix name", func(t *testing.T) {
+ // FLEET_VAR_ prefix should not be caught.
+ require.Empty(t, ContainsCustomHostVitalIDs("$FLEET_VAR_HOST_VITAL_1"))
+ })
+}
+
+func TestMissingCustomHostVitalsError(t *testing.T) {
+ single := MissingCustomHostVitalsError{MissingIDs: []uint{5}}
+ require.Contains(t, single.Error(), `"$FLEET_HOST_VITAL_5"`)
+ require.Contains(t, single.Error(), "Custom host vital ")
+
+ multi := MissingCustomHostVitalsError{MissingIDs: []uint{5, 9}}
+ require.Contains(t, multi.Error(), "Custom host vitals")
+ require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_5"`)
+ require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_9"`)
+}
+
+func TestMissingCustomHostVitalValueError(t *testing.T) {
+ single := MissingCustomHostVitalValueError{MissingIDs: []uint{5}}
+ require.Contains(t, single.Error(), `"$FLEET_HOST_VITAL_5"`)
+ require.Contains(t, single.Error(), "no value set for this host")
+ // Distinct from the upload-time "is not defined" wording.
+ require.NotContains(t, single.Error(), "is not defined")
+
+ multi := MissingCustomHostVitalValueError{MissingIDs: []uint{5, 9}}
+ require.Contains(t, multi.Error(), "custom host vitals")
+ require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_5"`)
+ require.Contains(t, multi.Error(), `"$FLEET_HOST_VITAL_9"`)
+}
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 2533f2b128..70ebe0772a 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -3259,6 +3259,28 @@ type Datastore interface {
// like recovery lock passwords.
ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error)
+ // /////////////////////////////////////////////////////////////////////////////
+ // Custom host vitals
+ CreateCustomHostVital(ctx context.Context, name string) (CustomHostVital, error)
+ ListCustomHostVitals(ctx context.Context, opt ListOptions) (customHostVitals []CustomHostVital, meta *PaginationMetadata, count int, err error)
+ UpdateCustomHostVital(ctx context.Context, id uint, name string) (CustomHostVital, error)
+ DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error)
+ SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error
+ GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]HostCustomHostVital, error)
+ GetCustomHostVitals(ctx context.Context, ids []uint) ([]CustomHostVital, error)
+ // ValidateReferencedCustomHostVitals parses $FLEET_HOST_VITAL_ tokens from
+ // the given documents and checks that every referenced id exists. Returns a
+ // MissingCustomHostVitalsError if any referenced id is unknown.
+ ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error
+
+ // ExpandCustomHostVitals substitutes $FLEET_HOST_VITAL_ tokens in the
+ // document with the given host's stored values (format-aware escaping).
+ // Returns a MissingCustomHostVitalValueError if a referenced vital has no value
+ // for the host.
+ ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error)
+
+ UpsertCustomHostVitals(ctx context.Context, vitals []CustomHostVital) (created []CustomHostVital, deleted []CustomHostVital, err error)
+
// /////////////////////////////////////////////////////////////////////////////
// Android
diff --git a/server/fleet/embedded_variables.go b/server/fleet/embedded_variables.go
new file mode 100644
index 0000000000..12c359f5e0
--- /dev/null
+++ b/server/fleet/embedded_variables.go
@@ -0,0 +1,16 @@
+package fleet
+
+import "context"
+
+// ValidateEmbeddedSecretsAndCustomHostVitals validates the database-backed
+// variables a script or profile can embed: $FLEET_SECRET_* secrets and
+// $FLEET_HOST_VITAL_ custom host vitals. Callers run it on upload so a
+// document referencing a non-existent secret or vital is rejected up front. It
+// lives in the fleet package (rather than a secrets- or vitals-specific file)
+// because it spans both domains.
+func ValidateEmbeddedSecretsAndCustomHostVitals(ctx context.Context, ds Datastore, documents []string) error {
+ if err := ds.ValidateEmbeddedSecrets(ctx, documents); err != nil {
+ return err
+ }
+ return ds.ValidateReferencedCustomHostVitals(ctx, documents)
+}
diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go
index 5e5eda90f1..3ededef63a 100644
--- a/server/fleet/hosts.go
+++ b/server/fleet/hosts.go
@@ -485,6 +485,7 @@ const (
HostVitalTypeDomestic HostVitalType = iota // Domestic vitals are those that are stored in the host table
HostVitalTypeForeign // Foreign vitals are those that are stored in a separate table and joined to the host table
HostVitalTypeAdditional // Additional vitals are those that are stored in the host_additional table as a JSON blob
+ HostVitalTypeCustom // Custom vitals are stored per-host in host_custom_host_vitals, scoped by a custom_host_vital_id
)
type HostVital struct {
@@ -528,6 +529,15 @@ var hostVitals = map[string]HostVital{
ForeignVitalGroup: ptr.String("idp"),
Path: "scim_users.department",
},
+ // custom_host_vital does not self-identify which vital (unlike the IDP enum
+ // values); the criterion's custom_host_vital_id selects it and scopes the
+ // per-host value join built in parseHostVitalCriteria.
+ "custom_host_vital": {
+ Name: "Custom host vital",
+ VitalType: HostVitalTypeCustom,
+ DataType: "string",
+ Path: "host_custom_host_vitals.value",
+ },
}
type AndroidHost struct {
@@ -1104,6 +1114,8 @@ type HostDetail struct {
MaintenanceWindow *HostMaintenanceWindow `json:"maintenance_window,omitempty"`
EndUsers []HostEndUser `json:"end_users,omitempty"`
+ CustomHostVitals []HostCustomHostVital `json:"custom_host_vitals,omitempty"`
+
LastMDMEnrolledAt *time.Time `json:"last_mdm_enrolled_at"`
LastMDMCheckedInAt *time.Time `json:"last_mdm_checked_in_at"`
diff --git a/server/fleet/labels.go b/server/fleet/labels.go
index 95f0c34fed..7024e20761 100644
--- a/server/fleet/labels.go
+++ b/server/fleet/labels.go
@@ -31,11 +31,14 @@ const (
)
type HostVitalCriteria struct {
- Vital *string `json:"vital,omitempty"`
- Value *string `json:"value,omitempty"`
- Operator *HostVitalOperator `json:"operator,omitempty"`
- And []HostVitalCriteria `json:"and,omitempty"`
- Or []HostVitalCriteria `json:"or,omitempty"`
+ Vital *string `json:"vital,omitempty"`
+ Value *string `json:"value,omitempty"`
+ Operator *HostVitalOperator `json:"operator,omitempty"`
+ // CustomHostVitalID is required when Vital is "custom_host_vital": that name
+ // alone doesn't identify which custom vital to match, so the id selects it.
+ CustomHostVitalID *uint `json:"custom_host_vital_id,omitempty"`
+ And []HostVitalCriteria `json:"and,omitempty"`
+ Or []HostVitalCriteria `json:"or,omitempty"`
}
type LabelPayload struct {
@@ -497,13 +500,30 @@ func parseHostVitalCriteria(criteria *HostVitalCriteria, foreignVitalsGroups map
if !ok {
return "", fmt.Errorf("unknown vital %s", *criteria.Vital)
}
- // If the vital is a foreign vitals group, add it to the list of foreign vitals groups.
- if vital.VitalType == HostVitalTypeForeign {
+ switch vital.VitalType {
+ case HostVitalTypeForeign:
+ // If the vital is a foreign vitals group, add it to the list of foreign vitals groups.
foreignVitalsGroup, ok := hostForeignVitalGroups[*vital.ForeignVitalGroup]
if !ok {
return "", fmt.Errorf("unknown foreign vital group %s", *vital.ForeignVitalGroup)
}
foreignVitalsGroups[&foreignVitalsGroup] = struct{}{}
+ case HostVitalTypeCustom:
+ if criteria.CustomHostVitalID == nil {
+ return "", errors.New("custom_host_vital criteria must have a custom_host_vital_id")
+ }
+ // Join only this vital's per-host rows. The id is appended to values
+ // before the criterion value below because the join is concatenated
+ // ahead of the WHERE clause in CalculateHostVitalsQuery, so its
+ // placeholder must bind first. A fresh group per call is fine: only a
+ // single criterion is supported (And/Or are rejected above), so at most
+ // one parameterized join exists.
+ group := HostForeignVitalGroup{
+ Name: "custom_host_vital",
+ Query: "JOIN host_custom_host_vitals ON (hosts.id = host_custom_host_vitals.host_id AND host_custom_host_vitals.custom_host_vital_id = ?)",
+ }
+ foreignVitalsGroups[&group] = struct{}{}
+ *values = append(*values, *criteria.CustomHostVitalID)
}
*values = append(*values, *criteria.Value)
diff --git a/server/fleet/secret_variables.go b/server/fleet/secret_variables.go
index 5e501bb1a0..f6e3d26e22 100644
--- a/server/fleet/secret_variables.go
+++ b/server/fleet/secret_variables.go
@@ -39,5 +39,6 @@ func ValidateSecretVariableName(name string) error {
type SecretVariableIdentifier struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" name:"name"`
+ CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 252881d30f..816571c842 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -1505,6 +1505,15 @@ type Service interface {
// Returns a NotFoundError error if there's no secret variable with such ID.
DeleteSecretVariable(ctx context.Context, id uint) error
+ ListCustomHostVitals(ctx context.Context, opts ListOptions) (customHostVitals []CustomHostVital, meta *PaginationMetadata, count int, err error)
+ CreateCustomHostVital(ctx context.Context, name string) (*CustomHostVital, error)
+ UpdateCustomHostVital(ctx context.Context, id uint, name string) (*CustomHostVital, error)
+ DeleteCustomHostVital(ctx context.Context, id uint) error
+ SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error
+ // UpsertCustomHostVitals declaratively reconciles custom host vital definitions (GitOps):
+ // names present are upserted, names absent from customHostVitals are deleted.
+ UpsertCustomHostVitals(ctx context.Context, customHostVitals []CustomHostVital, dryRun bool) error
+
// ListAPIEndpoints returns all API endpoints
ListAPIEndpoints(ctx context.Context) (endpoints []APIEndpoint, err error)
diff --git a/server/mdm/apple/profile_processor.go b/server/mdm/apple/profile_processor.go
index 2eef129f18..446242c2f2 100644
--- a/server/mdm/apple/profile_processor.go
+++ b/server/mdm/apple/profile_processor.go
@@ -187,10 +187,11 @@ func preprocessProfileContents(
continue
}
- // Check if Fleet variables are present.
+ // Check if Fleet variables or custom host vitals are present.
contentsStr := string(contents)
fleetVars := variables.Find(contentsStr)
- if len(fleetVars) == 0 {
+ hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(contentsStr)) > 0
+ if len(fleetVars) == 0 && !hasHostVitals {
continue
}
@@ -651,6 +652,44 @@ func preprocessProfileContents(
// This was handled in the above switch statement, so we should never reach this case
}
}
+
+ // Expand per-host custom host vitals ($FLEET_HOST_VITAL_). This is a
+ // top-level prefix not handled by the FLEET_VAR_ loop above. On a
+ // missing/empty value for this host, mark the profile failed with a
+ // detail rather than shipping a blank substitution.
+ if !failed && hasHostVitals {
+ hostForVitals, ok, err := profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "hydrating host for custom host vitals")
+ }
+ if !ok {
+ // onMismatchedHostCount already marked the profile failed.
+ failed = true
+ } else {
+ hostLite = hostForVitals
+ expanded, err := ds.ExpandCustomHostVitals(ctx, hostLite.ID, hostContents)
+ if err != nil {
+ var missing *fleet.MissingCustomHostVitalValueError
+ if !errors.As(err, &missing) {
+ return ctxerr.Wrap(ctx, err, "expanding custom host vitals")
+ }
+ if updErr := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
+ CommandUUID: target.CmdUUID,
+ HostUUID: hostUUID,
+ Status: &fleet.MDMDeliveryFailed,
+ Detail: missing.Error(),
+ OperationType: fleet.MDMOperationTypeInstall,
+ VariablesUpdatedAt: variablesUpdatedAt,
+ }); updErr != nil {
+ return ctxerr.Wrap(ctx, updErr, "marking profile failed for missing custom host vital")
+ }
+ failed = true
+ } else {
+ hostContents = expanded
+ }
+ }
+ }
+
if !failed {
addedTargets[tempProfUUID] = &fleet.CmdTarget{
CmdUUID: tempCmdUUID,
diff --git a/server/mdm/microsoft/custom_host_vitals_test.go b/server/mdm/microsoft/custom_host_vitals_test.go
new file mode 100644
index 0000000000..21051df72a
--- /dev/null
+++ b/server/mdm/microsoft/custom_host_vitals_test.go
@@ -0,0 +1,65 @@
+package microsoft_mdm
+
+import (
+ "context"
+ "log/slog"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/contexts/license"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPreprocessWindowsProfileContentsCustomHostVitals(t *testing.T) {
+ ds := new(mock.Store)
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
+ return []*fleet.Host{{ID: 55, UUID: "host-uuid-55"}}, nil
+ }
+
+ ctx := license.NewContext(t.Context(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
+ appConfig, err := ds.AppConfig(ctx)
+ require.NoError(t, err)
+
+ newDeps := func() ProfilePreprocessDependencies {
+ return ProfilePreprocessDependencies{
+ Context: ctx,
+ Logger: slog.New(slog.DiscardHandler),
+ DataStore: ds,
+ HostIDForUUIDCache: map[string]uint{},
+ AppConfig: appConfig,
+ ManagedCertificatePayloads: &[]*fleet.MDMManagedCertificate{},
+ }
+ }
+
+ profile := `$FLEET_HOST_VITAL_3 `
+
+ t.Run("substitutes the host's value with XML escaping", func(t *testing.T) {
+ ds.ExpandCustomHostVitalsFunc = func(ctx context.Context, hostID uint, doc string) (string, error) {
+ require.Equal(t, uint(55), hostID)
+ // simulate escaping of a value containing an XML-special char
+ return `a & b `, nil
+ }
+ result, err := PreprocessWindowsProfileContentsForDeployment(newDeps(), ProfilePreprocessParams{
+ HostUUID: "host-uuid-55", ProfileUUID: "prof-1",
+ }, profile)
+ require.NoError(t, err)
+ require.Equal(t, `a & b `, result)
+ })
+
+ t.Run("missing/empty value marks the profile failed with detail", func(t *testing.T) {
+ ds.ExpandCustomHostVitalsFunc = func(ctx context.Context, hostID uint, doc string) (string, error) {
+ return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: []uint{3}}
+ }
+ _, err := PreprocessWindowsProfileContentsForDeployment(newDeps(), ProfilePreprocessParams{
+ HostUUID: "host-uuid-55", ProfileUUID: "prof-1",
+ }, profile)
+ require.Error(t, err)
+ var procErr *MicrosoftProfileProcessingError
+ require.ErrorAs(t, err, &procErr)
+ require.Contains(t, procErr.Error(), "FLEET_HOST_VITAL_3")
+ })
+}
diff --git a/server/mdm/microsoft/profile_variables.go b/server/mdm/microsoft/profile_variables.go
index ec4e4797ac..dd74827203 100644
--- a/server/mdm/microsoft/profile_variables.go
+++ b/server/mdm/microsoft/profile_variables.go
@@ -2,6 +2,7 @@ package microsoft_mdm
import (
"context"
+ "errors"
"fmt"
"log/slog"
"slices"
@@ -78,9 +79,10 @@ type ProfilePreprocessParams struct {
// implementation and to the interface if it's required for both verification and deployment. For new dependencies that
// vary profile-to-profile, add them to ProfilePreprocessParams.
func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params ProfilePreprocessParams, profileContents string) (string, error) {
- // Check if Fleet variables are present
+ // Check if Fleet variables or custom host vitals are present.
fleetVars := variables.Find(profileContents)
- if len(fleetVars) == 0 {
+ hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(profileContents)) > 0
+ if len(fleetVars) == 0 && !hasHostVitals {
// No variables to replace, return original content
return profileContents, nil
}
@@ -186,5 +188,26 @@ func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params
}
}
+ // Expand per-host custom host vitals. On a missing/empty value the datastore
+ // returns a MissingCustomHostVitalValueError, which we surface as a
+ // MicrosoftProfileProcessingError so the caller marks the profile failed with
+ // this detail rather than shipping a blank substitution.
+ if hasHostVitals {
+ hostLite, _, err := profiles.HydrateHost(deps.Context, deps.DataStore, fleet.Host{UUID: params.HostUUID}, func(hostCount int) error {
+ return &MicrosoftProfileProcessingError{message: fmt.Sprintf("Found %d hosts with UUID %s. Custom host vital substitution requires exactly one host.", hostCount, params.HostUUID)}
+ })
+ if err != nil {
+ return profileContents, err
+ }
+ expanded, err := deps.DataStore.ExpandCustomHostVitals(deps.Context, hostLite.ID, result)
+ if err != nil {
+ if missing, ok := errors.AsType[*fleet.MissingCustomHostVitalValueError](err); ok {
+ return profileContents, &MicrosoftProfileProcessingError{message: missing.Error()}
+ }
+ return profileContents, err
+ }
+ result = expanded
+ }
+
return result, nil
}
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 4b14c222e3..31b88e07c4 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -1848,6 +1848,26 @@ type ExpandEmbeddedSecretsAndUpdatedAtFunc func(ctx context.Context, document st
type ExpandHostSecretsFunc func(ctx context.Context, document string, enrollmentID string) (string, error)
+type CreateCustomHostVitalFunc func(ctx context.Context, name string) (fleet.CustomHostVital, error)
+
+type ListCustomHostVitalsFunc func(ctx context.Context, opt fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error)
+
+type UpdateCustomHostVitalFunc func(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error)
+
+type DeleteCustomHostVitalFunc func(ctx context.Context, id uint) (name string, err error)
+
+type SetHostCustomHostVitalValueFunc func(ctx context.Context, hostID uint, vitalID uint, value string) error
+
+type GetHostCustomHostVitalsFunc func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error)
+
+type GetCustomHostVitalsFunc func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error)
+
+type ValidateReferencedCustomHostVitalsFunc func(ctx context.Context, documents []string) error
+
+type ExpandCustomHostVitalsFunc func(ctx context.Context, hostID uint, document string) (string, error)
+
+type UpsertCustomHostVitalsFunc func(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error)
+
type CreateEnterpriseFunc func(ctx context.Context, userID uint) (uint, error)
type GetEnterpriseByIDFunc func(ctx context.Context, id uint) (*android.EnterpriseDetails, error)
@@ -4935,6 +4955,36 @@ type DataStore struct {
ExpandHostSecretsFunc ExpandHostSecretsFunc
ExpandHostSecretsFuncInvoked bool
+ CreateCustomHostVitalFunc CreateCustomHostVitalFunc
+ CreateCustomHostVitalFuncInvoked bool
+
+ ListCustomHostVitalsFunc ListCustomHostVitalsFunc
+ ListCustomHostVitalsFuncInvoked bool
+
+ UpdateCustomHostVitalFunc UpdateCustomHostVitalFunc
+ UpdateCustomHostVitalFuncInvoked bool
+
+ DeleteCustomHostVitalFunc DeleteCustomHostVitalFunc
+ DeleteCustomHostVitalFuncInvoked bool
+
+ SetHostCustomHostVitalValueFunc SetHostCustomHostVitalValueFunc
+ SetHostCustomHostVitalValueFuncInvoked bool
+
+ GetHostCustomHostVitalsFunc GetHostCustomHostVitalsFunc
+ GetHostCustomHostVitalsFuncInvoked bool
+
+ GetCustomHostVitalsFunc GetCustomHostVitalsFunc
+ GetCustomHostVitalsFuncInvoked bool
+
+ ValidateReferencedCustomHostVitalsFunc ValidateReferencedCustomHostVitalsFunc
+ ValidateReferencedCustomHostVitalsFuncInvoked bool
+
+ ExpandCustomHostVitalsFunc ExpandCustomHostVitalsFunc
+ ExpandCustomHostVitalsFuncInvoked bool
+
+ UpsertCustomHostVitalsFunc UpsertCustomHostVitalsFunc
+ UpsertCustomHostVitalsFuncInvoked bool
+
CreateEnterpriseFunc CreateEnterpriseFunc
CreateEnterpriseFuncInvoked bool
@@ -11847,6 +11897,76 @@ func (s *DataStore) ExpandHostSecrets(ctx context.Context, document string, enro
return s.ExpandHostSecretsFunc(ctx, document, enrollmentID)
}
+func (s *DataStore) CreateCustomHostVital(ctx context.Context, name string) (fleet.CustomHostVital, error) {
+ s.mu.Lock()
+ s.CreateCustomHostVitalFuncInvoked = true
+ s.mu.Unlock()
+ return s.CreateCustomHostVitalFunc(ctx, name)
+}
+
+func (s *DataStore) ListCustomHostVitals(ctx context.Context, opt fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) {
+ s.mu.Lock()
+ s.ListCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.ListCustomHostVitalsFunc(ctx, opt)
+}
+
+func (s *DataStore) UpdateCustomHostVital(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) {
+ s.mu.Lock()
+ s.UpdateCustomHostVitalFuncInvoked = true
+ s.mu.Unlock()
+ return s.UpdateCustomHostVitalFunc(ctx, id, name)
+}
+
+func (s *DataStore) DeleteCustomHostVital(ctx context.Context, id uint) (name string, err error) {
+ s.mu.Lock()
+ s.DeleteCustomHostVitalFuncInvoked = true
+ s.mu.Unlock()
+ return s.DeleteCustomHostVitalFunc(ctx, id)
+}
+
+func (s *DataStore) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error {
+ s.mu.Lock()
+ s.SetHostCustomHostVitalValueFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetHostCustomHostVitalValueFunc(ctx, hostID, vitalID, value)
+}
+
+func (s *DataStore) GetHostCustomHostVitals(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ s.mu.Lock()
+ s.GetHostCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetHostCustomHostVitalsFunc(ctx, hostID)
+}
+
+func (s *DataStore) GetCustomHostVitals(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ s.mu.Lock()
+ s.GetCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetCustomHostVitalsFunc(ctx, ids)
+}
+
+func (s *DataStore) ValidateReferencedCustomHostVitals(ctx context.Context, documents []string) error {
+ s.mu.Lock()
+ s.ValidateReferencedCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.ValidateReferencedCustomHostVitalsFunc(ctx, documents)
+}
+
+func (s *DataStore) ExpandCustomHostVitals(ctx context.Context, hostID uint, document string) (string, error) {
+ s.mu.Lock()
+ s.ExpandCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.ExpandCustomHostVitalsFunc(ctx, hostID, document)
+}
+
+func (s *DataStore) UpsertCustomHostVitals(ctx context.Context, vitals []fleet.CustomHostVital) (created []fleet.CustomHostVital, deleted []fleet.CustomHostVital, err error) {
+ s.mu.Lock()
+ s.UpsertCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.UpsertCustomHostVitalsFunc(ctx, vitals)
+}
+
func (s *DataStore) CreateEnterprise(ctx context.Context, userID uint) (uint, error) {
s.mu.Lock()
s.CreateEnterpriseFuncInvoked = true
diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go
index b0fa616518..f103c33706 100644
--- a/server/mock/service/service_mock.go
+++ b/server/mock/service/service_mock.go
@@ -912,6 +912,18 @@ type ListSecretVariablesFunc func(ctx context.Context, opts fleet.ListOptions) (
type DeleteSecretVariableFunc func(ctx context.Context, id uint) error
+type ListCustomHostVitalsFunc func(ctx context.Context, opts fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error)
+
+type CreateCustomHostVitalFunc func(ctx context.Context, name string) (*fleet.CustomHostVital, error)
+
+type UpdateCustomHostVitalFunc func(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error)
+
+type DeleteCustomHostVitalFunc func(ctx context.Context, id uint) error
+
+type SetHostCustomHostVitalValueFunc func(ctx context.Context, hostID uint, vitalID uint, value string) error
+
+type UpsertCustomHostVitalsFunc func(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error
+
type ListAPIEndpointsFunc func(ctx context.Context) (endpoints []fleet.APIEndpoint, err error)
type ScimDetailsFunc func(ctx context.Context) (fleet.ScimDetails, error)
@@ -2307,6 +2319,24 @@ type Service struct {
DeleteSecretVariableFunc DeleteSecretVariableFunc
DeleteSecretVariableFuncInvoked bool
+ ListCustomHostVitalsFunc ListCustomHostVitalsFunc
+ ListCustomHostVitalsFuncInvoked bool
+
+ CreateCustomHostVitalFunc CreateCustomHostVitalFunc
+ CreateCustomHostVitalFuncInvoked bool
+
+ UpdateCustomHostVitalFunc UpdateCustomHostVitalFunc
+ UpdateCustomHostVitalFuncInvoked bool
+
+ DeleteCustomHostVitalFunc DeleteCustomHostVitalFunc
+ DeleteCustomHostVitalFuncInvoked bool
+
+ SetHostCustomHostVitalValueFunc SetHostCustomHostVitalValueFunc
+ SetHostCustomHostVitalValueFuncInvoked bool
+
+ UpsertCustomHostVitalsFunc UpsertCustomHostVitalsFunc
+ UpsertCustomHostVitalsFuncInvoked bool
+
ListAPIEndpointsFunc ListAPIEndpointsFunc
ListAPIEndpointsFuncInvoked bool
@@ -5516,6 +5546,48 @@ func (s *Service) DeleteSecretVariable(ctx context.Context, id uint) error {
return s.DeleteSecretVariableFunc(ctx, id)
}
+func (s *Service) ListCustomHostVitals(ctx context.Context, opts fleet.ListOptions) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) {
+ s.mu.Lock()
+ s.ListCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.ListCustomHostVitalsFunc(ctx, opts)
+}
+
+func (s *Service) CreateCustomHostVital(ctx context.Context, name string) (*fleet.CustomHostVital, error) {
+ s.mu.Lock()
+ s.CreateCustomHostVitalFuncInvoked = true
+ s.mu.Unlock()
+ return s.CreateCustomHostVitalFunc(ctx, name)
+}
+
+func (s *Service) UpdateCustomHostVital(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error) {
+ s.mu.Lock()
+ s.UpdateCustomHostVitalFuncInvoked = true
+ s.mu.Unlock()
+ return s.UpdateCustomHostVitalFunc(ctx, id, name)
+}
+
+func (s *Service) DeleteCustomHostVital(ctx context.Context, id uint) error {
+ s.mu.Lock()
+ s.DeleteCustomHostVitalFuncInvoked = true
+ s.mu.Unlock()
+ return s.DeleteCustomHostVitalFunc(ctx, id)
+}
+
+func (s *Service) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error {
+ s.mu.Lock()
+ s.SetHostCustomHostVitalValueFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetHostCustomHostVitalValueFunc(ctx, hostID, vitalID, value)
+}
+
+func (s *Service) UpsertCustomHostVitals(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error {
+ s.mu.Lock()
+ s.UpsertCustomHostVitalsFuncInvoked = true
+ s.mu.Unlock()
+ return s.UpsertCustomHostVitalsFunc(ctx, customHostVitals, dryRun)
+}
+
func (s *Service) ListAPIEndpoints(ctx context.Context) (endpoints []fleet.APIEndpoint, err error) {
s.mu.Lock()
s.ListAPIEndpointsFuncInvoked = true
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 9f598e6d95..483330eb67 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -424,6 +424,10 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, d
return nil, ctxerr.Wrap(ctx, err, "validating fleet variables")
}
+ if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil {
+ return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
+ }
+
cp, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), &teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
@@ -1001,6 +1005,11 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, dat
return nil, ctxerr.Wrap(ctx, err, "validating declaration Fleet variables")
}
+ // Validate custom host vital references (top-level $FLEET_HOST_VITAL_).
+ if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(data)}); err != nil {
+ return nil, fleet.NewInvalidArgumentError("profile", err.Error())
+ }
+
varNames := make([]fleet.FleetVarName, 0, len(declVars))
for _, v := range declVars {
varNames = append(varNames, fleet.FleetVarName(v))
@@ -1280,8 +1289,12 @@ func jsonEscapeString(s string) string {
func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables(
ctx context.Context, contents string, hostUUID string,
) (string, error) {
+ // variables.Find only detects $FLEET_VAR_; custom host vitals are a separate
+ // top-level prefix, so gate on both so a declaration referencing only custom
+ // host vitals is still expanded.
fleetVars := variables.Find(contents)
- if len(fleetVars) == 0 {
+ hasHostVitals := len(fleet.ContainsCustomHostVitalIDs(contents)) > 0
+ if len(fleetVars) == 0 && !hasHostVitals {
return contents, nil
}
@@ -1402,6 +1415,23 @@ func (svc *MDMAppleDDMService) replaceDeclarationFleetVariables(
contents = variables.Replace(contents, fleetVar, jsonEscapeString(value))
}
+ // Expand custom host vitals last, after the Fleet-var pass. variables.Replace
+ // is a blind global string replace, so expanding vitals earlier would let a
+ // vital value that happens to contain a literal $FLEET_VAR_ be rewritten
+ // by that pass. Doing it last makes the vital value the terminal substitution.
+ // On a missing/empty value the caller marks the declaration failed with this
+ // error's Detail.
+ if hasHostVitals {
+ if err := hydrateHost(); err != nil {
+ return "", err
+ }
+ expanded, err := svc.ds.ExpandCustomHostVitals(ctx, hostLite.ID, contents)
+ if err != nil {
+ return "", err
+ }
+ contents = expanded
+ }
+
return contents, nil
}
@@ -3051,6 +3081,9 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()),
"missing fleet secrets")
}
+ if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{string(prof)}); err != nil {
+ return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
+ }
mdmProf, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), tmID)
if err != nil {
return ctxerr.Wrap(ctx,
diff --git a/server/service/apple_mdm_ddm_test.go b/server/service/apple_mdm_ddm_test.go
index fc0df60ebe..2f9a219803 100644
--- a/server/service/apple_mdm_ddm_test.go
+++ b/server/service/apple_mdm_ddm_test.go
@@ -17,6 +17,43 @@ import (
"github.com/stretchr/testify/require"
)
+// Custom host vital values are arbitrary admin/external strings, so one may
+// contain a literal $FLEET_VAR_. variables.Replace is a blind global
+// string replace, so vitals must be expanded after the Fleet-var pass — else a
+// $FLEET_VAR_ token embedded in a vital value would be rewritten by that pass.
+func TestReplaceDeclarationFleetVariablesExpandsVitalsLast(t *testing.T) {
+ ctx := t.Context()
+ ds := mysqltest.CreateMySQLDS(t)
+ svc := MDMAppleDDMService{
+ ds: ds,
+ logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ }
+
+ host, err := ds.NewHost(ctx, &fleet.Host{
+ UUID: "vital-order-uuid",
+ Hostname: "vital-order-host",
+ HardwareSerial: "SERIAL123",
+ OsqueryHostID: new("vital-order"),
+ NodeKey: new("vital-order"),
+ DetailUpdatedAt: time.Now(),
+ })
+ require.NoError(t, err)
+
+ vital, err := ds.CreateCustomHostVital(ctx, "asset_tag")
+ require.NoError(t, err)
+ // The vital's value deliberately embeds a literal $FLEET_VAR_ token.
+ require.NoError(t, ds.SetHostCustomHostVitalValue(ctx, host.ID, vital.ID, "tag-$FLEET_VAR_HOST_HARDWARE_SERIAL"))
+
+ contents := fmt.Sprintf(`{"vital":"$FLEET_HOST_VITAL_%d","serial":"$FLEET_VAR_HOST_HARDWARE_SERIAL"}`, vital.ID)
+ out, err := svc.replaceDeclarationFleetVariables(ctx, contents, host.UUID)
+ require.NoError(t, err)
+
+ // The genuine $FLEET_VAR_HOST_HARDWARE_SERIAL reference expands to the serial,
+ // but the identical token inside the vital's value survives intact because
+ // vitals are expanded last (variables.Replace never sees it).
+ require.JSONEq(t, `{"vital":"tag-$FLEET_VAR_HOST_HARDWARE_SERIAL","serial":"SERIAL123"}`, out)
+}
+
func TestDeclarativeManagement_DeclarationItems(t *testing.T) {
ctx := t.Context()
ds := mysqltest.CreateMySQLDS(t)
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index 7ea488bc2b..925121823b 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -1296,6 +1296,9 @@ func TestHostDetailsMDMProfiles(t *testing.T) {
ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
diff --git a/server/service/client.go b/server/service/client.go
index 269fd6d7c6..47e9bffcb7 100644
--- a/server/service/client.go
+++ b/server/service/client.go
@@ -2469,6 +2469,14 @@ func (c *Client) DoGitOps(
}
}
+ // Custom host vitals are global-only and fully declarative: an absent
+ // `custom_host_vitals:` key clears all existing definitions. Runs last in
+ // this branch, after all local-only validation above, since it fetches
+ // current server state over the network to compute the diff.
+ if err := c.doGitOpsCustomHostVitals(incoming, logFn, dryRun); err != nil {
+ return nil, err
+ }
+
} else if !incoming.IsNoTeam() {
team = make(map[string]interface{})
team["name"] = *incoming.TeamName
@@ -3222,6 +3230,72 @@ func (c *Client) doGitOpsLabels(
return c.ApplyLabels(config.Labels, config.TeamID, namesToMove)
}
+// doGitOpsCustomHostVitals reconciles custom host vital definitions against
+// config.CustomHostVitals (an absent key clears all, per parseCustomHostVitals).
+// Global-only, so this is a no-op on a team file (config.CustomHostVitals is
+// always empty there).
+func (c *Client) doGitOpsCustomHostVitals(config *spec.GitOps, logFn func(format string, args ...any), dryRun bool) error {
+ if config.TeamName != nil {
+ return nil
+ }
+
+ desired := config.CustomHostVitals
+ if !config.CustomHostVitalsPresent {
+ desired = []fleet.CustomHostVital{}
+ }
+
+ existing, err := c.listAllCustomHostVitals()
+ if err != nil {
+ return err
+ }
+
+ existingNames := make(map[string]struct{}, len(existing))
+ for _, v := range existing {
+ existingNames[v.Name] = struct{}{}
+ }
+ desiredNames := make(map[string]struct{}, len(desired))
+ for _, v := range desired {
+ desiredNames[v.Name] = struct{}{}
+ }
+
+ var toDelete []string
+ for _, v := range existing {
+ if _, ok := desiredNames[v.Name]; !ok {
+ toDelete = append(toDelete, v.Name)
+ }
+ }
+ var toAdd []string
+ for _, v := range desired {
+ if _, ok := existingNames[v.Name]; !ok {
+ toAdd = append(toAdd, v.Name)
+ }
+ }
+
+ if dryRun {
+ if len(toDelete) > 0 {
+ logFn("[-] would've deleted %s\n", numberWithPluralization(len(toDelete), "custom host vital", "custom host vitals"))
+ }
+ for _, name := range toDelete {
+ logFn("[-] would've deleted custom host vital '%s'\n", name)
+ }
+ if len(toAdd) > 0 {
+ logFn("[+] would've created %s\n", numberWithPluralization(len(toAdd), "custom host vital", "custom host vitals"))
+ }
+ return c.SaveCustomHostVitals(desired, true)
+ }
+
+ if len(toDelete) > 0 {
+ logFn("[-] deleting %s\n", numberWithPluralization(len(toDelete), "custom host vital", "custom host vitals"))
+ }
+ for _, name := range toDelete {
+ logFn("[-] deleting custom host vital '%s'\n", name)
+ }
+ if len(toAdd) > 0 {
+ logFn("[+] creating %s\n", numberWithPluralization(len(toAdd), "custom host vital", "custom host vitals"))
+ }
+ return c.SaveCustomHostVitals(desired, false)
+}
+
// resolvePolicySoftwareTitleID attempts to resolve the software title ID for a
// policy by trying each available identifier in order: URL, App Store ID, hash,
// then FMA slug. Returns the resolved title ID and true if found, or 0 and
diff --git a/server/service/client_custom_host_vitals.go b/server/service/client_custom_host_vitals.go
new file mode 100644
index 0000000000..469b8b18e5
--- /dev/null
+++ b/server/service/client_custom_host_vitals.go
@@ -0,0 +1,44 @@
+package service
+
+import (
+ "fmt"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+)
+
+func (c *Client) SaveCustomHostVitals(customHostVitals []fleet.CustomHostVital, dryRun bool) error {
+ verb, path := "PUT", "/api/latest/fleet/spec/custom_host_vitals"
+ params := fleet.UpsertCustomHostVitalsRequest{
+ CustomHostVitals: customHostVitals,
+ DryRun: dryRun,
+ }
+ var responseBody fleet.UpsertCustomHostVitalsResponse
+ return c.authenticatedRequest(params, verb, path, &responseBody)
+}
+
+// ListCustomHostVitals returns a page of custom host vital definitions.
+func (c *Client) ListCustomHostVitals(query string) ([]fleet.CustomHostVital, error) {
+ verb, path := "GET", "/api/latest/fleet/custom_host_vitals"
+ var responseBody fleet.ListCustomHostVitalsResponse
+ err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
+ if err != nil {
+ return nil, err
+ }
+ return responseBody.CustomHostVitals, nil
+}
+
+// listAllCustomHostVitals pages through ListCustomHostVitals to return every definition.
+func (c *Client) listAllCustomHostVitals() ([]fleet.CustomHostVital, error) {
+ const perPage = 1000
+ var all []fleet.CustomHostVital
+ for page := 0; ; page++ {
+ pageVitals, err := c.ListCustomHostVitals(fmt.Sprintf("per_page=%d&page=%d", perPage, page))
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, pageVitals...)
+ if len(pageVitals) < perPage {
+ return all, nil
+ }
+ }
+}
diff --git a/server/service/custom_host_vitals.go b/server/service/custom_host_vitals.go
new file mode 100644
index 0000000000..6abc0ba43a
--- /dev/null
+++ b/server/service/custom_host_vitals.go
@@ -0,0 +1,308 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/fleetdm/fleet/v4/server/authz"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
+ "golang.org/x/text/unicode/norm"
+)
+
+//////////////////////////////////////////////////////////////////////////////////
+// List custom host vitals
+//////////////////////////////////////////////////////////////////////////////////
+
+func listCustomHostVitalsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
+ req := request.(*fleet.ListCustomHostVitalsRequest)
+ vitals, meta, count, err := svc.ListCustomHostVitals(ctx, req.ListOptions)
+ return fleet.ListCustomHostVitalsResponse{
+ CustomHostVitals: vitals,
+ Meta: meta,
+ Count: count,
+ Err: err,
+ }, nil
+}
+
+func (svc *Service) ListCustomHostVitals(
+ ctx context.Context,
+ opts fleet.ListOptions,
+) (customHostVitals []fleet.CustomHostVital, meta *fleet.PaginationMetadata, count int, err error) {
+ if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionRead); err != nil {
+ return nil, nil, 0, err
+ }
+
+ // Always include pagination info.
+ opts.IncludeMetadata = true
+ if opts.OrderKey == "" {
+ opts.OrderKey = "name"
+ opts.OrderDirection = fleet.OrderAscending
+ }
+
+ customHostVitals, meta, count, err = svc.ds.ListCustomHostVitals(ctx, opts)
+ if err != nil {
+ return nil, nil, 0, ctxerr.Wrap(ctx, err, "list custom host vitals")
+ }
+ return customHostVitals, meta, count, nil
+}
+
+//////////////////////////////////////////////////////////////////////////////////
+// Create custom host vital
+//////////////////////////////////////////////////////////////////////////////////
+
+func createCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
+ req := request.(*fleet.CreateCustomHostVitalRequest)
+ vital, err := svc.CreateCustomHostVital(ctx, req.Name)
+ if err != nil {
+ return fleet.CreateCustomHostVitalResponse{Err: err}, nil
+ }
+ return fleet.CreateCustomHostVitalResponse{CustomHostVital: vital}, nil
+}
+
+func (svc *Service) CreateCustomHostVital(ctx context.Context, name string) (*fleet.CustomHostVital, error) {
+ if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
+
+ if err := fleet.ValidateCustomHostVitalName(name); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "validate custom host vital name")
+ }
+
+ vital, err := svc.ds.CreateCustomHostVital(ctx, name)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "creating custom host vital")
+ }
+
+ if err := svc.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeCreatedCustomHostVital{
+ CustomHostVitalID: vital.ID,
+ CustomHostVitalName: vital.Name,
+ },
+ ); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "create activity for custom host vital creation")
+ }
+
+ return &vital, nil
+}
+
+//////////////////////////////////////////////////////////////////////////////////
+// Update (rename) custom host vital
+//////////////////////////////////////////////////////////////////////////////////
+
+func updateCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
+ req := request.(*fleet.UpdateCustomHostVitalRequest)
+ vital, err := svc.UpdateCustomHostVital(ctx, req.ID, req.Name)
+ if err != nil {
+ return fleet.UpdateCustomHostVitalResponse{Err: err}, nil
+ }
+ return fleet.UpdateCustomHostVitalResponse{CustomHostVital: vital}, nil
+}
+
+func (svc *Service) UpdateCustomHostVital(ctx context.Context, id uint, name string) (*fleet.CustomHostVital, error) {
+ if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
+ return nil, err
+ }
+
+ if err := fleet.ValidateCustomHostVitalName(name); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "validate custom host vital name")
+ }
+
+ vital, err := svc.ds.UpdateCustomHostVital(ctx, id, name)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "updating custom host vital")
+ }
+
+ if err := svc.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeEditedCustomHostVital{
+ CustomHostVitalID: vital.ID,
+ CustomHostVitalName: vital.Name,
+ },
+ ); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "create activity for custom host vital edit")
+ }
+
+ return &vital, nil
+}
+
+//////////////////////////////////////////////////////////////////////////////////
+// Delete custom host vital
+//////////////////////////////////////////////////////////////////////////////////
+
+func deleteCustomHostVitalEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
+ req := request.(*fleet.DeleteCustomHostVitalRequest)
+ err := svc.DeleteCustomHostVital(ctx, req.ID)
+ return fleet.DeleteCustomHostVitalResponse{Err: err}, nil
+}
+
+func (svc *Service) DeleteCustomHostVital(ctx context.Context, id uint) error {
+ if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
+ return err
+ }
+
+ name, err := svc.ds.DeleteCustomHostVital(ctx, id)
+ if err != nil {
+ if usedErr, ok := errors.AsType[*fleet.CustomHostVitalUsedError](err); ok {
+ return ctxerr.Wrap(ctx, &fleet.ConflictError{
+ Message: fmt.Sprintf("Couldn't delete. %s", usedErr.Error()),
+ }, "delete custom host vital")
+ }
+ return ctxerr.Wrap(ctx, err, "delete custom host vital")
+ }
+
+ if err := svc.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeDeletedCustomHostVital{
+ CustomHostVitalID: id,
+ CustomHostVitalName: name,
+ },
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "create activity for custom host vital deletion")
+ }
+
+ return nil
+}
+
+//////////////////////////////////////////////////////////////////////////////////
+// Set host custom host vital value
+//////////////////////////////////////////////////////////////////////////////////
+
+func setHostCustomHostVitalValueEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
+ req := request.(*fleet.SetHostCustomHostVitalValueRequest)
+ err := svc.SetHostCustomHostVitalValue(ctx, req.HostID, req.ID, req.Value)
+ return fleet.SetHostCustomHostVitalValueResponse{Err: err}, nil
+}
+
+func (svc *Service) SetHostCustomHostVitalValue(ctx context.Context, hostID uint, vitalID uint, value string) error {
+ // Authorize against the host so team-scoped roles are enforced (host-write pattern).
+ if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
+ return err
+ }
+
+ host, err := svc.ds.HostLite(ctx, hostID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "find host for setting custom host vital value")
+ }
+
+ if err := svc.authz.Authorize(ctx, &fleet.HostCustomHostVitalValue{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
+ return err
+ }
+
+ vital, err := svc.customHostVitalByID(ctx, vitalID)
+ if err != nil {
+ return err
+ }
+
+ if err := svc.ds.SetHostCustomHostVitalValue(ctx, hostID, vitalID, value); err != nil {
+ return ctxerr.Wrap(ctx, err, "set host custom host vital value")
+ }
+
+ if err := svc.NewActivity(
+ ctx,
+ authz.UserFromContext(ctx),
+ fleet.ActivityTypeEditedCustomHostVitalValue{
+ HostID: hostID,
+ HostDisplayName: host.DisplayName(),
+ CustomHostVitalID: vitalID,
+ CustomHostVitalName: vital.Name,
+ },
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "create activity for custom host vital value edit")
+ }
+
+ return nil
+}
+
+func (svc *Service) customHostVitalByID(ctx context.Context, id uint) (*fleet.CustomHostVital, error) {
+ vitals, err := svc.ds.GetCustomHostVitals(ctx, []uint{id})
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get custom host vital by id")
+ }
+ if len(vitals) == 0 {
+ return nil, ctxerr.Wrap(ctx, common_mysql.NotFound("CustomHostVital").WithID(id))
+ }
+ return &vitals[0], nil
+}
+
+//////////////////////////////////////////////////////////////////////////////////
+// Upsert custom host vitals (spec)
+//////////////////////////////////////////////////////////////////////////////////
+
+func upsertCustomHostVitalsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
+ req := request.(*fleet.UpsertCustomHostVitalsRequest)
+ err := svc.UpsertCustomHostVitals(ctx, req.CustomHostVitals, req.DryRun)
+ return fleet.UpsertCustomHostVitalsResponse{Err: err}, nil
+}
+
+func (svc *Service) UpsertCustomHostVitals(ctx context.Context, customHostVitals []fleet.CustomHostVital, dryRun bool) error {
+ if err := svc.authz.Authorize(ctx, &fleet.CustomHostVital{}, fleet.ActionWrite); err != nil {
+ return err
+ }
+
+ // Names are unique in the database under the utf8mb4_unicode_ci collation
+ // (case-insensitive), so dedupe on that same basis rather than exact string
+ // equality -- otherwise two names differing only by case would pass this
+ // check and then fail as a raw DB duplicate-key error at insert time.
+ seen := make(map[string]string, len(customHostVitals)) // collation key -> original name
+ for _, vital := range customHostVitals {
+ if err := fleet.ValidateCustomHostVitalName(vital.Name); err != nil {
+ return ctxerr.Wrap(ctx, err, "validate custom host vital name")
+ }
+ key := norm.NFC.String(strings.ToLower(vital.Name))
+ if prev, ok := seen[key]; ok {
+ return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("custom_host_vitals",
+ fmt.Sprintf("duplicate custom host vital names: %q and %q must differ by more than letter case", prev, vital.Name)))
+ }
+ seen[key] = vital.Name
+ }
+
+ if dryRun {
+ return nil
+ }
+
+ created, deleted, err := svc.ds.UpsertCustomHostVitals(ctx, customHostVitals)
+ if err != nil {
+ if usedErr, ok := errors.AsType[*fleet.CustomHostVitalUsedError](err); ok {
+ return ctxerr.Wrap(ctx, &fleet.ConflictError{
+ Message: fmt.Sprintf("Couldn't delete. %s", usedErr.Error()),
+ }, "upsert custom host vitals")
+ }
+ return ctxerr.Wrap(ctx, err, "upsert custom host vitals")
+ }
+
+ user := authz.UserFromContext(ctx)
+ for _, vital := range created {
+ if err := svc.NewActivity(
+ ctx,
+ user,
+ fleet.ActivityTypeCreatedCustomHostVital{
+ CustomHostVitalID: vital.ID,
+ CustomHostVitalName: vital.Name,
+ },
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "create activity for custom host vital creation")
+ }
+ }
+ for _, vital := range deleted {
+ if err := svc.NewActivity(
+ ctx,
+ user,
+ fleet.ActivityTypeDeletedCustomHostVital{
+ CustomHostVitalID: vital.ID,
+ CustomHostVitalName: vital.Name,
+ },
+ ); err != nil {
+ return ctxerr.Wrap(ctx, err, "create activity for custom host vital deletion")
+ }
+ }
+
+ return nil
+}
diff --git a/server/service/custom_host_vitals_resolution_test.go b/server/service/custom_host_vitals_resolution_test.go
new file mode 100644
index 0000000000..89238788ce
--- /dev/null
+++ b/server/service/custom_host_vitals_resolution_test.go
@@ -0,0 +1,125 @@
+package service
+
+import (
+ "context"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeExpandCustomHostVitals mimics the datastore's ExpandCustomHostVitals
+// behavior (missing/empty value -> MissingCustomHostVitalValueError) for the given
+// per-host value map, so service-layer tests don't need a real DB.
+func fakeExpandCustomHostVitals(valueByID map[uint]string) func(context.Context, uint, string) (string, error) {
+ return func(_ context.Context, _ uint, document string) (string, error) {
+ refIDs := fleet.ContainsCustomHostVitalIDs(document)
+ if len(refIDs) == 0 {
+ return document, nil
+ }
+ var missing []uint
+ for _, id := range refIDs {
+ if v, ok := valueByID[id]; !ok || v == "" {
+ missing = append(missing, id)
+ }
+ }
+ if len(missing) > 0 {
+ return "", &fleet.MissingCustomHostVitalValueError{MissingIDs: missing}
+ }
+ expanded := fleet.MaybeExpand(document, func(s string, _, _ int) (string, bool) {
+ if !strings.HasPrefix(s, fleet.CustomHostVitalPrefix) {
+ return "", false
+ }
+ id, err := strconv.ParseUint(strings.TrimPrefix(s, fleet.CustomHostVitalPrefix), 10, 64)
+ if err != nil {
+ return "", false
+ }
+ v, ok := valueByID[uint(id)]
+ return v, ok
+ })
+ return expanded, nil
+ }
+}
+
+func TestGetHostScriptExpandsCustomHostVitals(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+
+ host := &fleet.Host{ID: 42, UUID: "host-uuid-42", OrbitNodeKey: new("nk")}
+
+ ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, doc string) (string, error) {
+ return doc, nil
+ }
+
+ t.Run("substitutes the host's value", func(t *testing.T) {
+ ds.GetHostScriptExecutionResultFunc = func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
+ return &fleet.HostScriptResult{HostID: host.ID, ExecutionID: execID, ScriptContents: "echo $FLEET_HOST_VITAL_7"}, nil
+ }
+ ds.ExpandCustomHostVitalsFunc = fakeExpandCustomHostVitals(map[uint]string{7: "engineering"})
+
+ hctx := hostctx.NewContext(ctx, host)
+ res, err := svc.GetHostScript(hctx, "exec-1")
+ require.NoError(t, err)
+ require.Equal(t, "echo engineering", res.ScriptContents)
+ })
+
+ t.Run("empty/missing value fails the script fetch", func(t *testing.T) {
+ ds.GetHostScriptExecutionResultFunc = func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) {
+ return &fleet.HostScriptResult{HostID: host.ID, ExecutionID: execID, ScriptContents: "echo $FLEET_HOST_VITAL_9"}, nil
+ }
+ // host 42 has no value for vital 9
+ ds.ExpandCustomHostVitalsFunc = fakeExpandCustomHostVitals(map[uint]string{7: "engineering"})
+
+ hctx := hostctx.NewContext(ctx, host)
+ _, err := svc.GetHostScript(hctx, "exec-2")
+ require.Error(t, err)
+ var missing *fleet.MissingCustomHostVitalValueError
+ require.ErrorAs(t, err, &missing)
+ require.Equal(t, []uint{9}, missing.MissingIDs)
+ })
+}
+
+func TestCreateScriptValidatesCustomHostVitals(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}})
+
+ ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { return nil }
+
+ // Simulate the real datastore: unknown ids -> MissingCustomHostVitalsError.
+ ds.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error {
+ want := map[uint]struct{}{}
+ for _, d := range documents {
+ for _, id := range fleet.ContainsCustomHostVitalIDs(d) {
+ want[id] = struct{}{}
+ }
+ }
+ // only id 1 exists
+ var missing []uint
+ for id := range want {
+ if id != 1 {
+ missing = append(missing, id)
+ }
+ }
+ if len(missing) > 0 {
+ return &fleet.MissingCustomHostVitalsError{MissingIDs: missing}
+ }
+ return nil
+ }
+
+ // Unknown id (999) should be rejected.
+ _, err := svc.NewScript(ctx, nil, "myscript.sh", strings.NewReader("#!/bin/sh\necho $FLEET_HOST_VITAL_999\n"))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "FLEET_HOST_VITAL_999")
+
+ // ValidateReferencedCustomHostVitals must actually have been called.
+ require.True(t, ds.ValidateReferencedCustomHostVitalsFuncInvoked)
+}
diff --git a/server/service/custom_host_vitals_test.go b/server/service/custom_host_vitals_test.go
new file mode 100644
index 0000000000..241bc28e7e
--- /dev/null
+++ b/server/service/custom_host_vitals_test.go
@@ -0,0 +1,265 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCustomHostVitalsAuth(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil)
+
+ ds.CreateCustomHostVitalFunc = func(ctx context.Context, name string) (fleet.CustomHostVital, error) {
+ return fleet.CustomHostVital{ID: 1, Name: name}, nil
+ }
+ ds.UpdateCustomHostVitalFunc = func(ctx context.Context, id uint, name string) (fleet.CustomHostVital, error) {
+ return fleet.CustomHostVital{ID: id, Name: name}, nil
+ }
+ ds.DeleteCustomHostVitalFunc = func(ctx context.Context, id uint) (string, error) {
+ return "Asset tag", nil
+ }
+ ds.ListCustomHostVitalsFunc = func(ctx context.Context, opt fleet.ListOptions) ([]fleet.CustomHostVital, *fleet.PaginationMetadata, int, error) {
+ return nil, &fleet.PaginationMetadata{}, 0, nil
+ }
+ ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ return []fleet.CustomHostVital{{ID: 1, Name: "Asset tag"}}, nil
+ }
+ ds.SetHostCustomHostVitalValueFunc = func(ctx context.Context, hostID, vitalID uint, value string) error {
+ return nil
+ }
+ ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{ID: id}, nil
+ }
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ return nil, nil, nil
+ }
+
+ globalRoles := []struct {
+ name string
+ user *fleet.User
+ readOK bool
+ writeOK bool
+ }{
+ {"global admin", &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}, true, true},
+ {"global maintainer", &fleet.User{ID: 2, GlobalRole: new(fleet.RoleMaintainer)}, true, true},
+ {"global gitops", &fleet.User{ID: 3, GlobalRole: new(fleet.RoleGitOps)}, true, true},
+ {"global observer", &fleet.User{ID: 4, GlobalRole: new(fleet.RoleObserver)}, true, false},
+ {"global observer+", &fleet.User{ID: 5, GlobalRole: new(fleet.RoleObserverPlus)}, true, false},
+ {"team admin", &fleet.User{ID: 6, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, true, false},
+ {"team maintainer", &fleet.User{ID: 7, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, true, false},
+ {"team gitops", &fleet.User{ID: 8, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}}, true, false},
+ {"team observer", &fleet.User{ID: 9, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true, false},
+ }
+
+ for _, tt := range globalRoles {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
+
+ _, _, _, err := svc.ListCustomHostVitals(ctx, fleet.ListOptions{})
+ checkAuthErr(t, !tt.readOK, err)
+
+ _, err = svc.CreateCustomHostVital(ctx, "Asset tag")
+ checkAuthErr(t, !tt.writeOK, err)
+
+ _, err = svc.UpdateCustomHostVital(ctx, 1, "Asset tag")
+ checkAuthErr(t, !tt.writeOK, err)
+
+ err = svc.DeleteCustomHostVital(ctx, 1)
+ checkAuthErr(t, !tt.writeOK, err)
+
+ err = svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Asset tag"}}, false)
+ checkAuthErr(t, !tt.writeOK, err)
+ })
+ }
+}
+
+func TestListCustomHostVitalsPassesSearchQuery(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil)
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}})
+
+ var gotOpts fleet.ListOptions
+ ds.ListCustomHostVitalsFunc = func(ctx context.Context, opt fleet.ListOptions) ([]fleet.CustomHostVital, *fleet.PaginationMetadata, int, error) {
+ gotOpts = opt
+ return nil, &fleet.PaginationMetadata{}, 0, nil
+ }
+
+ _, _, _, err := svc.ListCustomHostVitals(ctx, fleet.ListOptions{MatchQuery: "asset"})
+ require.NoError(t, err)
+ require.True(t, ds.ListCustomHostVitalsFuncInvoked)
+ // MatchQuery is forwarded to the datastore (search by name or variable name).
+ assert.Equal(t, "asset", gotOpts.MatchQuery)
+}
+
+func TestSetHostCustomHostVitalValueAuth(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil)
+
+ hostTeamID := uint(1)
+ ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return &fleet.Host{ID: id, TeamID: &hostTeamID}, nil
+ }
+ ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ return []fleet.CustomHostVital{{ID: 1, Name: "Asset tag"}}, nil
+ }
+ ds.SetHostCustomHostVitalValueFunc = func(ctx context.Context, hostID, vitalID uint, value string) error {
+ return nil
+ }
+
+ // Per-host value is a host-scoped write (authz type host_custom_vital): global
+ // admin/maintainer and admins/maintainers of the host's team can set it;
+ // observers, gitops (blocked at the host-list gate), and users of another team
+ // cannot.
+ testCases := []struct {
+ name string
+ user *fleet.User
+ shouldFail bool
+ }{
+ {"global admin", &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}, false},
+ {"global maintainer", &fleet.User{ID: 2, GlobalRole: new(fleet.RoleMaintainer)}, false},
+ {"global gitops", &fleet.User{ID: 3, GlobalRole: new(fleet.RoleGitOps)}, true},
+ {"global observer", &fleet.User{ID: 4, GlobalRole: new(fleet.RoleObserver)}, true},
+ {"team admin (host team)", &fleet.User{ID: 5, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, false},
+ {"team maintainer (host team)", &fleet.User{ID: 6, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, false},
+ {"team observer (host team)", &fleet.User{ID: 7, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true},
+ {"team maintainer (other team)", &fleet.User{ID: 8, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}}, true},
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
+ err := svc.SetHostCustomHostVitalValue(ctx, 42, 1, "engineering")
+ checkAuthErr(t, tt.shouldFail, err)
+ })
+ }
+}
+
+func TestCustomHostVitalNameValidation(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil)
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}})
+
+ ds.CreateCustomHostVitalFunc = func(ctx context.Context, name string) (fleet.CustomHostVital, error) {
+ return fleet.CustomHostVital{ID: 1, Name: name}, nil
+ }
+
+ invalidNames := []struct {
+ name string
+ value string
+ }{
+ {"empty", ""},
+ {"leading space", " Asset tag"},
+ {"trailing space", "Asset tag "},
+ {"leading tab", "\tAsset tag"},
+ {"trailing newline", "Asset tag\n"},
+ }
+ for _, tt := range invalidNames {
+ t.Run("reject "+tt.name, func(t *testing.T) {
+ ds.CreateCustomHostVitalFuncInvoked = false
+ _, err := svc.CreateCustomHostVital(ctx, tt.value)
+ require.Error(t, err)
+ assert.False(t, ds.CreateCustomHostVitalFuncInvoked)
+ })
+ }
+
+ validNames := []struct {
+ name string
+ value string
+ }{
+ {"internal spaces", "Asset tag"},
+ {"lowercase", "asset tag"},
+ {"mixed case with digits", "Rack 12B Location"},
+ }
+ for _, tt := range validNames {
+ t.Run("accept "+tt.name, func(t *testing.T) {
+ vital, err := svc.CreateCustomHostVital(ctx, tt.value)
+ require.NoError(t, err)
+ require.NotNil(t, vital)
+ })
+ }
+}
+
+func TestUpsertCustomHostVitals(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ opts := &TestServerOpts{}
+ svc, ctx := newTestService(t, ds, nil, nil, opts)
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}})
+
+ t.Run("rejects invalid names without persisting", func(t *testing.T) {
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ t.Fatal("UpsertCustomHostVitals should not be called for an invalid name")
+ return nil, nil, nil
+ }
+ err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: " bad"}}, false)
+ require.Error(t, err)
+ })
+
+ t.Run("rejects duplicate names within the same payload without persisting", func(t *testing.T) {
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ t.Fatal("UpsertCustomHostVitals should not be called for a duplicate name")
+ return nil, nil, nil
+ }
+ err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "Function"}}, false)
+ require.Error(t, err)
+ })
+
+ t.Run("rejects names that are duplicates under the case-insensitive collation", func(t *testing.T) {
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ t.Fatal("UpsertCustomHostVitals should not be called for a case-only duplicate name")
+ return nil, nil, nil
+ }
+ err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}, {Name: "function"}}, false)
+ require.Error(t, err)
+ })
+
+ t.Run("dry run validates without persisting", func(t *testing.T) {
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ t.Fatal("UpsertCustomHostVitals should not be called on a dry run")
+ return nil, nil, nil
+ }
+ err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}}, true)
+ require.NoError(t, err)
+ })
+
+ t.Run("emits an activity per created and deleted vital", func(t *testing.T) {
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ require.Equal(t, []fleet.CustomHostVital{{Name: "Function"}}, vitals)
+ return []fleet.CustomHostVital{{ID: 2, Name: "Function"}}, []fleet.CustomHostVital{{ID: 1, Name: "Department"}}, nil
+ }
+ var activities []activity_api.ActivityDetails
+ opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
+ activities = append(activities, activity)
+ return nil
+ }
+ err := svc.UpsertCustomHostVitals(ctx, []fleet.CustomHostVital{{Name: "Function"}}, false)
+ require.NoError(t, err)
+ require.Len(t, activities, 2)
+ require.IsType(t, fleet.ActivityTypeCreatedCustomHostVital{}, activities[0])
+ require.IsType(t, fleet.ActivityTypeDeletedCustomHostVital{}, activities[1])
+ })
+
+ t.Run("surfaces a still-referenced vital as a conflict", func(t *testing.T) {
+ ds.UpsertCustomHostVitalsFunc = func(ctx context.Context, vitals []fleet.CustomHostVital) ([]fleet.CustomHostVital, []fleet.CustomHostVital, error) {
+ return nil, nil, &fleet.CustomHostVitalUsedError{CustomHostVitalUsedInfo: fleet.CustomHostVitalUsedInfo{
+ CustomHostVitalID: 1,
+ CustomHostVitalName: "Department",
+ Entity: fleet.EntityUsingCustomHostVital{Type: fleet.CustomHostVitalEntityScript, Name: "collect.sh", FleetName: "Unassigned"},
+ }}
+ }
+ err := svc.UpsertCustomHostVitals(ctx, nil, false)
+ require.Error(t, err)
+ var conflictErr *fleet.ConflictError
+ require.ErrorAs(t, err, &conflictErr)
+ })
+}
diff --git a/server/service/devices_endpoint_test.go b/server/service/devices_endpoint_test.go
index d15a85083d..d374142397 100644
--- a/server/service/devices_endpoint_test.go
+++ b/server/service/devices_endpoint_test.go
@@ -96,6 +96,9 @@ func TestGetDeviceHostEndpointScrubbing(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
// Inject host into context
ctx = host.NewContext(ctx, h)
@@ -227,6 +230,9 @@ func TestGetDeviceHostEndpointNoScrubbingForMacOS(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
// Inject host into context
ctx = host.NewContext(ctx, h)
@@ -376,6 +382,9 @@ func TestGetDeviceHostEndpointConditionalAccessBypass(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
// Inject host into context
ctx = host.NewContext(ctx, h)
diff --git a/server/service/handler.go b/server/service/handler.go
index 655e506eb9..377e0bfba7 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -606,6 +606,14 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/custom_variables", listSecretVariablesEndpoint, fleet.ListSecretVariablesRequest{})
ue.DELETE("/api/_version_/fleet/custom_variables/{id:[0-9]+}", deleteSecretVariableEndpoint, fleet.DeleteSecretVariableRequest{})
+ // Custom host vitals
+ ue.GET("/api/_version_/fleet/custom_host_vitals", listCustomHostVitalsEndpoint, fleet.ListCustomHostVitalsRequest{})
+ ue.POST("/api/_version_/fleet/custom_host_vitals", createCustomHostVitalEndpoint, fleet.CreateCustomHostVitalRequest{})
+ ue.PATCH("/api/_version_/fleet/custom_host_vitals/{id:[0-9]+}", updateCustomHostVitalEndpoint, fleet.UpdateCustomHostVitalRequest{})
+ ue.DELETE("/api/_version_/fleet/custom_host_vitals/{id:[0-9]+}", deleteCustomHostVitalEndpoint, fleet.DeleteCustomHostVitalRequest{})
+ ue.PUT("/api/_version_/fleet/hosts/{host_id:[0-9]+}/custom_host_vitals/{id:[0-9]+}", setHostCustomHostVitalValueEndpoint, fleet.SetHostCustomHostVitalValueRequest{})
+ ue.PUT("/api/_version_/fleet/spec/custom_host_vitals", upsertCustomHostVitalsEndpoint, fleet.UpsertCustomHostVitalsRequest{})
+
// API end-points
ue.GET("/api/_version_/fleet/rest_api", listAPIEndpointsEndpoint, listAPIEndpointsRequest{})
diff --git a/server/service/hosts.go b/server/service/hosts.go
index 6992517ae0..0715227bbf 100644
--- a/server/service/hosts.go
+++ b/server/service/hosts.go
@@ -1980,6 +1980,11 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
}
conditionalAccessBypassed := conditionalAccessBypassedAt != nil
+ customHostVitals, err := svc.ds.GetHostCustomHostVitals(ctx, host.ID)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get custom host vitals for host")
+ }
+
return &fleet.HostDetail{
Host: *host,
Labels: labels,
@@ -1987,6 +1992,7 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
Batteries: &bats,
MaintenanceWindow: nextMw,
EndUsers: endUsers,
+ CustomHostVitals: customHostVitals,
LastMDMEnrolledAt: mdmLastEnrollment,
LastMDMCheckedInAt: mdmLastCheckedIn,
MDMEnrollmentHardwareAttested: mdmHardwareAttested,
diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go
index defef77ccf..ecec822b6a 100644
--- a/server/service/hosts_test.go
+++ b/server/service/hosts_test.go
@@ -94,12 +94,12 @@ func TestHostDetails(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
- ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
- return nil, nil
- }
opts := fleet.HostDetailOptions{
IncludeCVEScores: false,
@@ -152,6 +152,9 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
@@ -453,6 +456,9 @@ func TestHostDetailsMDMTimestamps(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMAppleProfile, error) {
return nil, nil
}
@@ -572,6 +578,9 @@ func TestHostDetailsOSSettings(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
@@ -749,6 +758,9 @@ func TestHostDetailsOSSettingsWindowsOnly(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
@@ -817,6 +829,9 @@ func TestHostDetailsRecoveryLockPasswordStatus(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
@@ -910,6 +925,9 @@ func TestHostDetailsHostNameStatus(t *testing.T) {
ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil }
ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { return nil, nil }
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) { return nil, nil }
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { return false, nil }
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
@@ -1123,6 +1141,9 @@ func TestHostAuth(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetCategoriesForSoftwareTitlesFunc = func(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) {
return map[uint][]string{}, nil
}
@@ -3381,6 +3402,9 @@ func TestHostMDMProfileDetail(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
@@ -3531,6 +3555,9 @@ func TestHostMDMProfileScopes(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
@@ -4363,6 +4390,9 @@ func TestGetHostDetailsExcludeSoftwareFlag(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
+ ds.GetHostCustomHostVitalsFunc = func(ctx context.Context, hostID uint) ([]fleet.HostCustomHostVital, error) {
+ return nil, nil
+ }
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
diff --git a/server/service/integration_custom_host_vitals_test.go b/server/service/integration_custom_host_vitals_test.go
new file mode 100644
index 0000000000..7ba24f064f
--- /dev/null
+++ b/server/service/integration_custom_host_vitals_test.go
@@ -0,0 +1,120 @@
+package service
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCustomHostVitalsCRUD exercises the full lifecycle through the HTTP stack:
+// list (empty) -> create -> list/search -> update -> set a host value ->
+// host detail surfaces it -> delete (cascades) -> list (empty), asserting the
+// activity emitted at each mutating step.
+func (s *integrationTestSuite) TestCustomHostVitalsCRUD() {
+ t := s.T()
+
+ // Initially empty.
+ var listResp fleet.ListCustomHostVitalsResponse
+ s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp)
+ require.Empty(t, listResp.CustomHostVitals)
+ require.Equal(t, 0, listResp.Count)
+
+ // Create.
+ var createResp fleet.CreateCustomHostVitalResponse
+ s.DoJSON("POST", "/api/latest/fleet/custom_host_vitals", fleet.CreateCustomHostVitalRequest{Name: "Asset tag"}, http.StatusOK, &createResp)
+ require.NotNil(t, createResp.CustomHostVital)
+ require.NotZero(t, createResp.CustomHostVital.ID)
+ require.Equal(t, "Asset tag", createResp.CustomHostVital.Name)
+ vitalID := createResp.CustomHostVital.ID
+ s.lastActivityMatches(
+ fleet.ActivityTypeCreatedCustomHostVital{}.ActivityName(),
+ fmt.Sprintf(`{"custom_host_vital_id": %d, "custom_host_vital_name": "Asset tag"}`, vitalID),
+ 0,
+ )
+
+ // List shows the created definition.
+ listResp = fleet.ListCustomHostVitalsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp)
+ require.Len(t, listResp.CustomHostVitals, 1)
+ require.Equal(t, 1, listResp.Count)
+ require.Equal(t, "Asset tag", listResp.CustomHostVitals[0].Name)
+
+ // Duplicate name is rejected with a conflict.
+ s.Do("POST", "/api/latest/fleet/custom_host_vitals", fleet.CreateCustomHostVitalRequest{Name: "Asset tag"}, http.StatusConflict)
+
+ // Update (rename).
+ var updateResp fleet.UpdateCustomHostVitalResponse
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/custom_host_vitals/%d", vitalID), fleet.UpdateCustomHostVitalRequest{Name: "Asset ID"}, http.StatusOK, &updateResp)
+ require.NotNil(t, updateResp.CustomHostVital)
+ require.Equal(t, vitalID, updateResp.CustomHostVital.ID)
+ require.Equal(t, "Asset ID", updateResp.CustomHostVital.Name)
+ s.lastActivityMatches(
+ fleet.ActivityTypeEditedCustomHostVital{}.ActivityName(),
+ fmt.Sprintf(`{"custom_host_vital_id": %d, "custom_host_vital_name": "Asset ID"}`, vitalID),
+ 0,
+ )
+
+ // Search matches by name; a non-matching query returns nothing.
+ listResp = fleet.ListCustomHostVitalsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp, "query", "asset")
+ require.Len(t, listResp.CustomHostVitals, 1)
+ listResp = fleet.ListCustomHostVitalsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp, "query", "nomatch")
+ require.Empty(t, listResp.CustomHostVitals)
+
+ // Before any value is set, the host detail still surfaces every definition
+ // with an empty value.
+ host := s.createHosts(t)[0]
+ var preSetResp getHostResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &preSetResp)
+ require.Len(t, preSetResp.Host.CustomHostVitals, 1)
+ assert.Equal(t, vitalID, preSetResp.Host.CustomHostVitals[0].CustomHostVitalID)
+ assert.Equal(t, "Asset ID", preSetResp.Host.CustomHostVitals[0].Name)
+ assert.Empty(t, preSetResp.Host.CustomHostVitals[0].Value)
+
+ // Set a value for the vital on a host.
+ s.Do("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/custom_host_vitals/%d", host.ID, vitalID), fleet.SetHostCustomHostVitalValueRequest{Value: "engineering"}, http.StatusOK)
+ s.lastActivityMatches(
+ fleet.ActivityTypeEditedCustomHostVitalValue{}.ActivityName(),
+ fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "custom_host_vital_id": %d, "custom_host_vital_name": "Asset ID"}`, host.ID, host.DisplayName(), vitalID),
+ 0,
+ )
+
+ // Host detail surfaces the per-host value.
+ var hostResp getHostResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
+ require.Len(t, hostResp.Host.CustomHostVitals, 1)
+ assert.Equal(t, vitalID, hostResp.Host.CustomHostVitals[0].CustomHostVitalID)
+ assert.Equal(t, "Asset ID", hostResp.Host.CustomHostVitals[0].Name)
+ assert.Equal(t, "engineering", hostResp.Host.CustomHostVitals[0].Value)
+
+ // Clearing the value (Save empty) is accepted and persists as an empty string.
+ s.Do("PUT", fmt.Sprintf("/api/latest/fleet/hosts/%d/custom_host_vitals/%d", host.ID, vitalID), fleet.SetHostCustomHostVitalValueRequest{Value: ""}, http.StatusOK)
+ hostResp = getHostResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
+ require.Len(t, hostResp.Host.CustomHostVitals, 1)
+ assert.Equal(t, vitalID, hostResp.Host.CustomHostVitals[0].CustomHostVitalID)
+ assert.Empty(t, hostResp.Host.CustomHostVitals[0].Value)
+
+ // Delete the definition; the per-host value cascades away.
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/custom_host_vitals/%d", vitalID), nil, http.StatusOK)
+ s.lastActivityMatches(
+ fleet.ActivityTypeDeletedCustomHostVital{}.ActivityName(),
+ fmt.Sprintf(`{"custom_host_vital_id": %d, "custom_host_vital_name": "Asset ID"}`, vitalID),
+ 0,
+ )
+
+ // List is empty again.
+ listResp = fleet.ListCustomHostVitalsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/custom_host_vitals", nil, http.StatusOK, &listResp)
+ require.Empty(t, listResp.CustomHostVitals)
+ require.Equal(t, 0, listResp.Count)
+
+ // Host detail no longer surfaces the value.
+ hostResp = getHostResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
+ require.Empty(t, hostResp.Host.CustomHostVitals)
+}
diff --git a/server/service/labels.go b/server/service/labels.go
index 8f79c863f8..edd2fc0686 100644
--- a/server/service/labels.go
+++ b/server/service/labels.go
@@ -80,6 +80,9 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.
if err != nil {
return nil, nil, fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error()))
}
+ if err := svc.validateCustomHostVitalCriteria(ctx, label.HostVitalsCriteria); err != nil {
+ return nil, nil, err
+ }
} else {
if p.Query != "" && (len(p.Hosts) > 0 || len(p.HostIDs) > 0) {
return nil, nil, fleet.NewInvalidArgumentError("query", `Only one of "criteria", "query" or "hosts/host_ids" can be included in the request.`)
@@ -149,6 +152,33 @@ func (svc *Service) NewLabel(ctx context.Context, p fleet.LabelPayload) (*fleet.
return label, nil, nil
}
+// validateCustomHostVitalCriteria verifies that a custom_host_vital criterion
+// references a custom host vital that actually exists. Without this a label
+// could be created against a stale or made-up id, which would silently match
+// zero hosts (the membership join finds no rows) instead of erroring.
+func (svc *Service) validateCustomHostVitalCriteria(ctx context.Context, raw *json.RawMessage) error {
+ if raw == nil {
+ return nil
+ }
+ var criteria fleet.HostVitalCriteria
+ if err := json.Unmarshal(*raw, &criteria); err != nil {
+ return fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error()))
+ }
+ if criteria.CustomHostVitalID == nil {
+ return nil
+ }
+ id := *criteria.CustomHostVitalID
+
+ existing, err := svc.ds.GetCustomHostVitals(ctx, []uint{id})
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "validate custom host vital criteria")
+ }
+ if len(existing) == 0 {
+ return fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("custom host vital %d does not exist", id))
+ }
+ return nil
+}
+
// authorizeWriteLabelOnHosts verifies that the caller is authorized to write
// labels (the write_host_label action) to every host in hostIDs. It returns a
// permission error if the caller lacks write access to any of them, which
@@ -681,6 +711,18 @@ func (svc *Service) ApplyLabelSpecs(ctx context.Context, specs []*fleet.LabelSpe
if err := fleet.ValidateLabelMembershipFields(spec); err != nil {
return err.WithStatus(http.StatusUnprocessableEntity)
}
+ // Validate host vitals criteria structurally (unknown vital, missing
+ // custom_host_vital_id, etc.) and that any referenced custom vital
+ // exists, mirroring the checks in NewLabel so a bad spec fails at apply
+ // rather than silently matching no hosts at cron evaluation time.
+ if spec.LabelMembershipType == fleet.LabelMembershipTypeHostVitals {
+ if _, _, err := (&fleet.Label{HostVitalsCriteria: spec.HostVitalsCriteria}).CalculateHostVitalsQuery(); err != nil {
+ return fleet.NewInvalidArgumentError("criteria", fmt.Sprintf("invalid criteria: %s", err.Error())).WithStatus(http.StatusUnprocessableEntity)
+ }
+ if err := svc.validateCustomHostVitalCriteria(ctx, spec.HostVitalsCriteria); err != nil {
+ return err
+ }
+ }
if spec.LabelType == fleet.LabelTypeBuiltIn {
// We allow specs to contain built-in labels as long as they are not being modified.
// This allows the user to do the following workflow without manually removing built-in labels:
diff --git a/server/service/labels_test.go b/server/service/labels_test.go
index e2372a0efd..9319a69d89 100644
--- a/server/service/labels_test.go
+++ b/server/service/labels_test.go
@@ -530,6 +530,46 @@ func TestApplyLabelSpecsWithBuiltInLabels(t *testing.T) {
assert.ErrorIs(t, err, assert.AnError)
}
+func TestApplyLabelSpecsCustomHostVitalCriteria(t *testing.T) {
+ t.Parallel()
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil)
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}})
+
+ specWithCriteria := func(criteria fleet.HostVitalCriteria) *fleet.LabelSpec {
+ raw, err := json.Marshal(&criteria)
+ require.NoError(t, err)
+ return &fleet.LabelSpec{
+ Name: "custom-vital-spec",
+ LabelType: fleet.LabelTypeRegular,
+ LabelMembershipType: fleet.LabelMembershipTypeHostVitals,
+ HostVitalsCriteria: new(json.RawMessage(raw)),
+ }
+ }
+
+ // A custom_host_vital criterion without an id fails structural validation
+ // before any datastore call.
+ err := svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{specWithCriteria(fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ })}, nil, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "custom_host_vital_id")
+
+ // A criterion referencing a non-existent custom vital is rejected.
+ ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ return nil, nil
+ }
+ err = svc.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{specWithCriteria(fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ CustomHostVitalID: new(uint(999)),
+ })}, nil, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "does not exist")
+ require.True(t, ds.GetCustomHostVitalsFuncInvoked)
+}
+
func TestLabelsWithReplica(t *testing.T) {
opts := &testing_utils.DatastoreTestOptions{DummyReplica: true}
ds := mysqltest.CreateMySQLDSWithOptions(t, opts)
@@ -1212,6 +1252,60 @@ func TestNewHostVitalsLabel(t *testing.T) {
assert.Equal(t, "SELECT %s FROM %s JOIN host_scim_user ON (hosts.id = host_scim_user.host_id) JOIN scim_users ON (host_scim_user.scim_user_id = scim_users.id) LEFT JOIN scim_user_group ON (host_scim_user.scim_user_id = scim_user_group.scim_user_id) LEFT JOIN scim_groups ON (scim_user_group.group_id = scim_groups.id) WHERE scim_groups.display_name = ? GROUP BY hosts.id", query)
assert.Equal(t, `["admin"]`, string(queryValuesJson))
})
+
+ t.Run("create custom host vital label", func(t *testing.T) {
+ ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ return []fleet.CustomHostVital{{ID: 7, Name: "Department"}}, nil
+ }
+ ds.GetCustomHostVitalsFuncInvoked = false
+
+ lbl, _, err := svc.NewLabel(ctx, fleet.LabelPayload{
+ Name: "custom-vital-label",
+ Criteria: &fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ CustomHostVitalID: new(uint(7)),
+ },
+ })
+ require.NoError(t, err)
+ assert.True(t, ds.GetCustomHostVitalsFuncInvoked)
+ assert.Equal(t, fleet.LabelMembershipTypeHostVitals, lbl.LabelMembershipType)
+
+ query, queryValues, err := lbl.CalculateHostVitalsQuery()
+ require.NoError(t, err)
+ queryValuesJson, err := json.Marshal(queryValues)
+ require.NoError(t, err)
+ assert.Equal(t, "SELECT %s FROM %s JOIN host_custom_host_vitals ON (hosts.id = host_custom_host_vitals.host_id AND host_custom_host_vitals.custom_host_vital_id = ?) WHERE host_custom_host_vitals.value = ? GROUP BY hosts.id", query)
+ assert.JSONEq(t, `[7,"Engineering"]`, string(queryValuesJson))
+ })
+
+ t.Run("custom host vital label missing id is rejected", func(t *testing.T) {
+ _, _, err := svc.NewLabel(ctx, fleet.LabelPayload{
+ Name: "custom-vital-no-id",
+ Criteria: &fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ },
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "custom_host_vital_id")
+ })
+
+ t.Run("custom host vital label with unknown id is rejected", func(t *testing.T) {
+ ds.GetCustomHostVitalsFunc = func(ctx context.Context, ids []uint) ([]fleet.CustomHostVital, error) {
+ return nil, nil
+ }
+ _, _, err := svc.NewLabel(ctx, fleet.LabelPayload{
+ Name: "custom-vital-bad-id",
+ Criteria: &fleet.HostVitalCriteria{
+ Vital: new("custom_host_vital"),
+ Value: new("Engineering"),
+ CustomHostVitalID: new(uint(999)),
+ },
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "does not exist")
+ })
}
func TestNewLabelFieldValidation(t *testing.T) {
diff --git a/server/service/mdm.go b/server/service/mdm.go
index 3138419301..0c45bf213b 100644
--- a/server/service/mdm.go
+++ b/server/service/mdm.go
@@ -2249,6 +2249,22 @@ func (svc *Service) BatchSetMDMProfiles(
return ctxerr.Wrap(ctx, err, "validating profiles")
}
+ // Only Apple and Windows profiles expand $FLEET_HOST_VITAL_ tokens at delivery.
+ // Android has no expansion path and is rejected outright in getAndroidProfiles
+ // (MDMAndroidConfigProfile.ValidateUserProvided) below; skip it here so an Android
+ // profile referencing a vital gets that clear "not supported" error rather than a
+ // misleading "missing from database" one from this existence check.
+ customHostVitalDocs := make([]string, 0, len(profiles))
+ for _, p := range profiles {
+ if mdm.GetRawProfilePlatform(p.Contents) == "android" {
+ continue
+ }
+ customHostVitalDocs = append(customHostVitalDocs, string(p.Contents))
+ }
+ if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, customHostVitalDocs); err != nil {
+ return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profiles", err.Error()))
+ }
+
appleProfiles, appleDecls, err := getAppleProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap, svc.config.MDM)
if err != nil {
return ctxerr.Wrap(ctx, err, "validating macOS profiles")
diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go
index 322484eef7..5149fcc08b 100644
--- a/server/service/microsoft_mdm.go
+++ b/server/service/microsoft_mdm.go
@@ -3550,6 +3550,13 @@ func ReconcileWindowsProfilesForEnrollingHost(ctx context.Context, ds fleet.Data
return executeWindowsProfileReconcileBatch(ctx, ds, logger, appConfig, toInstall, toRemove, desiredByHost)
}
+// windowsProfileNeedsPerHostProcessing reports whether a Windows profile must be
+// processed per-host at delivery time — i.e. it references any FLEET_VAR_ variable
+// or any $FLEET_HOST_VITAL_ custom host vital.
+func windowsProfileNeedsPerHostProcessing(syncML []byte) bool {
+ return variables.ContainsBytes(syncML) || len(fleet.ContainsCustomHostVitalIDs(string(syncML))) > 0
+}
+
// ReconcileWindowsProfiles applies configuration profiles to Windows MDM hosts.
//
// It walks every enrolled Windows host via a host_uuid cursor (persisted in Redis through the mysqlredis wrapper), loading a
@@ -3958,7 +3965,7 @@ func executeWindowsProfileReconcileBatch(
continue
}
p, ok := profileContents[profUUID]
- if !ok || variables.ContainsBytes(p.SyncML) {
+ if !ok || windowsProfileNeedsPerHostProcessing(p.SyncML) {
continue // variable profiles get per-host commands, can't pre-build
}
command, err := buildCommandFromProfileBytes(p.SyncML, target.cmdUUID)
@@ -4007,7 +4014,7 @@ func executeWindowsProfileReconcileBatch(
continue
}
- if !variables.ContainsBytes(p.SyncML) {
+ if !windowsProfileNeedsPerHostProcessing(p.SyncML) {
// No Fleet variables, send the same command to all hosts
payloads, ok := batchProfileCmdsMap[target.cmdUUID]
if !ok {
diff --git a/server/service/orbit.go b/server/service/orbit.go
index a9f46d0c25..f0f7e9dbdf 100644
--- a/server/service/orbit.go
+++ b/server/service/orbit.go
@@ -1117,6 +1117,11 @@ func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.Ho
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expand embedded secrets for host %d and script %s", host.ID, execID))
}
+ script.ScriptContents, err = svc.ds.ExpandCustomHostVitals(ctx, host.ID, script.ScriptContents)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expand custom host vitals for host %d and script %s", host.ID, execID))
+ }
+
return script, nil
}
diff --git a/server/service/scripts.go b/server/service/scripts.go
index c8778dc3b3..d5d46757ad 100644
--- a/server/service/scripts.go
+++ b/server/service/scripts.go
@@ -128,7 +128,7 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript
}
if request.ScriptContents != "" {
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{request.ScriptContents}); err != nil {
+ if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{request.ScriptContents}); err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("script", err.Error())
}
@@ -410,7 +410,7 @@ func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r
ScriptContents: file.Dos2UnixNewlines(string(b)),
}
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{script.ScriptContents}); err != nil {
+ if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{script.ScriptContents}); err != nil {
return nil, fleet.NewInvalidArgumentError("script", err.Error())
}
@@ -613,7 +613,7 @@ func (svc *Service) UpdateScript(ctx context.Context, scriptID uint, r io.Reader
scriptContents := file.Dos2UnixNewlines(string(b))
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{scriptContents}); err != nil {
+ if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{scriptContents}); err != nil {
return nil, fleet.NewInvalidArgumentError("script", err.Error())
}
@@ -766,7 +766,7 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT
return nil, nil
}
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, scriptContents); err != nil {
+ if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, scriptContents); err != nil {
return nil, fleet.NewInvalidArgumentError("script", err.Error())
}
diff --git a/server/service/testing_utils_test.go b/server/service/testing_utils_test.go
index 40e50863cb..864bdacc0b 100644
--- a/server/service/testing_utils_test.go
+++ b/server/service/testing_utils_test.go
@@ -90,6 +90,16 @@ func newTestService(t *testing.T, ds fleet.Datastore, rs fleet.QueryResultStore,
}
func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig config.FleetConfig, rs fleet.QueryResultStore, lq fleet.LiveQueryStore, opts ...*TestServerOpts) (fleet.Service, context.Context) {
+ // Custom host vital reference validation is wired into all script/profile
+ // upload paths. Provide a permissive default so tests that don't reference
+ // $FLEET_HOST_VITAL_ don't need to stub it (the real datastore no-ops
+ // when the document has no such tokens). Tests that assert on it can override.
+ if mockDS, ok := ds.(*fleet_mock.Store); ok {
+ if mockDS.ValidateReferencedCustomHostVitalsFunc == nil {
+ mockDS.ValidateReferencedCustomHostVitalsFunc = func(ctx context.Context, documents []string) error { return nil }
+ }
+ }
+
lic := &fleet.LicenseInfo{Tier: fleet.TierFree}
logger := slog.New(slog.DiscardHandler)
writer, err := logging.NewFilesystemLogWriter(t.Context(), fleetConfig.Filesystem.StatusLogFile, logger, fleetConfig.Filesystem.EnableLogRotation,
diff --git a/server/service/windows_mdm_profiles.go b/server/service/windows_mdm_profiles.go
index f7242be56f..11a3a7f6f8 100644
--- a/server/service/windows_mdm_profiles.go
+++ b/server/service/windows_mdm_profiles.go
@@ -92,7 +92,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint,
}
cp.LabelsExcludeAny = excludeLabels
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{string(cp.SyncML)}); err != nil {
+ if err := fleet.ValidateEmbeddedSecretsAndCustomHostVitals(ctx, svc.ds, []string{string(cp.SyncML)}); err != nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
}
From e6eb37988ff33d77820246b33fcd85a3224dedf3 Mon Sep 17 00:00:00 2001
From: Eric
Date: Wed, 15 Jul 2026 17:36:56 -0500
Subject: [PATCH 4/4] Website: update testimonials and logo carousel (#49313)
Changes:
- Removed "Former" from job titles in testimonials
- Brought back the Uber logo on two testimonials and the logo carousel
component.
---
handbook/company/testimonials.yml | 12 +++++++-----
.../assets/images/logos/logo-uber-65x32@2x.png | Bin 0 -> 789 bytes
.../images/social-proof-logo-uber-71x24@2x.png | Bin 0 -> 829 bytes
.../js/components/logo-carousel.component.js | 12 ++++++++----
4 files changed, 15 insertions(+), 9 deletions(-)
create mode 100644 website/assets/images/logos/logo-uber-65x32@2x.png
create mode 100644 website/assets/images/social-proof-logo-uber-71x24@2x.png
diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml
index aee1575275..bb1e93ec88 100644
--- a/handbook/company/testimonials.yml
+++ b/handbook/company/testimonials.yml
@@ -27,7 +27,8 @@
quoteAuthorName: Luis Madrigal
quoteAuthorProfileImageFilename: testimonial-author-luis-madrigal-100x100@2x.png
quoteLinkUrl: https://www.linkedin.com/in/luismadrigal/
- quoteAuthorJobTitle: Former Engineering Leader, Uber
+ quoteImageFilename: social-proof-logo-uber-71x24@2x.png
+ quoteAuthorJobTitle: Engineering Leader, Uber
productCategories: [Device management, Observability, Software management]
- quote: Yes Sir. Great tools for the everyday open-source geeks 💯
quoteAuthorName: Alvaro Gutierrez
@@ -60,7 +61,7 @@
quoteLinkUrl: https://www.linkedin.com/in/danielgrzelak/
quoteAuthorName: Dan Grzelak
quoteAuthorProfileImageFilename: testimonial-author-daniel-grzelak-48x48@2x.png
- quoteAuthorJobTitle: Former Security Chief of Staff
+ quoteAuthorJobTitle: Security Chief of Staff
productCategories: [Observability, Software management, Device management]
- quote: We can build it exactly the way we want it. Which is just not possible on other platforms.
quoteAuthorName: Austin Anderson
@@ -72,8 +73,9 @@
- quote: Exciting. This is a team that listens to feedback.
quoteLinkUrl: https://www.linkedin.com/in/eriknicolasgomez/
quoteAuthorName: Erik Gomez
+ quoteImageFilename: social-proof-logo-uber-71x24@2x.png
quoteAuthorProfileImageFilename: testimonial-author-erik-gomez-48x48@2x.png
- quoteAuthorJobTitle: Former Staff Client Platform Engineer
+ quoteAuthorJobTitle: Staff Client Platform Engineer
productCategories: [Observability, Device management, Software management]
- quote: Context is king for device data, and Fleet provides a way to surface that information to our other teams and partners.
quoteAuthorName: Nick Fohs
@@ -87,14 +89,14 @@
quoteLinkUrl: https://www.linkedin.com/in/nwaisman/
quoteAuthorName: Nico Waisman
quoteAuthorProfileImageFilename: testimonial-author-nico-waisman-48x48@2x.png
- quoteAuthorJobTitle: Former CISO of Lyft
+ quoteAuthorJobTitle: CISO of Lyft
productCategories: [Observability, Software management]
- quote: Having the freedom to take full advantage of the product is one of the reasons why I always support open-source products with a commercially-backed company, like Fleet.
quoteImageFilename: social-proof-logo-lyft-47x32@2x.png
quoteLinkUrl: https://www.linkedin.com/posts/nwaisman_movingtofleet-activity-7156319785981509632-bk_W
quoteAuthorName: nico waisman # Note: this name is lowercased here so we can display only one Nico Waisman quote on the testimonials page (which does not filter quotes by product category) (The name will be capitalized via CSS)
quoteAuthorProfileImageFilename: testimonial-author-nico-waisman-48x48@2x.png
- quoteAuthorJobTitle: Former CISO of Lyft
+ quoteAuthorJobTitle: CISO of Lyft
productCategories: [Device management] # « explanation: https://github.com/fleetdm/fleet/blob/f412b1f02fb6d6f36bdc0776252fff72fc0fc2ea/website/views/pages/device-management.ejs#L32
- quote: I had to answer some really complex questions for a compliance audit, and I was able to do it in about 15 minutes by munging some data together via a few queries into a csv. It took me longer to remember how to use `xsv` than to actually put together the report. If you aren't using osquery in your environment, you should be.
quoteAuthorName: Charles Zaffery
diff --git a/website/assets/images/logos/logo-uber-65x32@2x.png b/website/assets/images/logos/logo-uber-65x32@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..368c7b3430cb616297d09a33395ec8af839210e6
GIT binary patch
literal 789
zcmV+w1M2*VP)kfMu^ql}NDjE|y=kE4%|uZNGHi;$v=kD`l@qKuEDiI1a;kD`yU
zz^niO013$Fi=ocVuwcn00MnUL_t(&-tCvswxb{nMTY>1
z20RUpy=F`GU<#Dmrb|B1_G7C2?(}mXKO}kWLT<11IO=N))9q@IH
zAq2t-Id>r#%u@E$0+1G1PY8x`9|E3RprkKZ)pSIF+=swI?-P>7ur!|F6X!WbVD%#4
z5{qF=H56)&*#l&E&+2Q$oDtAY&bbZ*S{lm+?SEt+8aW$yM4+Fpb3tx^I-fANDJdR2
z&|o@lN*{vUf^lE5&5uDmdRF2#I3CYV1kOCYeRw`koN-mjvAqw$Yc_5(UYH`4PBFsL
zjo=hptec`XWRspy-UK7KUIb8mSnF^5%aSSx#0aJrA*sHl*0=eC=Kn{q(!@jfZ1)G5
zy9nU}g1CQu7Xt5Csq`hn){nrYNgp$T;J=v#vX@e4A#p^WIbZIGR69A^4E
zlzn$DnDe=?+(6(6DddoEo{rUpHxPIpbI>YFgzT>G2+mpVA=r!BYYBh6Dd&Ap6yED+`n8f@F{){PsHnqc~2a
z7JU}@Wl6!R+?0}5Zs=8bc-jR5%ZjuV)yvv=(E@Q)NKf`ro(m$)f~cz?h#%0utc`E0
zQD90Hffp^}V-e6#h1s~5%Hm6=#3%n83Ie~BfL<*jJd}cqZ9MKphlYlRhW@F)q34H=
Txp_Wi00000NkvXXu0mjfftF!8
literal 0
HcmV?d00001
diff --git a/website/assets/images/social-proof-logo-uber-71x24@2x.png b/website/assets/images/social-proof-logo-uber-71x24@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..7b3232bec378d3a3d6415567e75487c85f199df6
GIT binary patch
literal 829
zcmeAS@N?(olHy`uVBq!ia0vp^eL!r$!3-onJlKZ6LR^6~sbDdy+ex4vfs!D<
zU7shROn%*93yS(u2`hTxa8_<{^P};FKSBm{QwenR{z767vz6Y
z_7@bH{vuNq_bwJ&c{usbg-+*G-Q}*%Sv`Lum-lsBnXQ${`7~QX^hLvM
zD^0z7{@&_6{mk>?c2@qHyISKy>5GG@b6d_HS>KlySZwk{DalE_aA(E41+F(Qmgv3o
zlic`bVRLOkth)d57@M=r=U=#ee0jm~&8~AzM@~MsG!-plVA?5Da^mi#mtID;=4)4!
zFG*gv@Njr(LD7u!dk?&IIk;hA-|nTsS2J#%4LhDL*<9Lin|H2f@25Pa+r`Y9N5Z8o
zrsls)v3+&%o0!SNxqg2pzP`R~yR=xx(Hc*6ZL6yx>O50ix0LP)mi!;xzUJvm)45k=
zPL}vDPt5deTIT#LC@#_Mq3yq3zpZHtoSv^2KD~RJG@m%LZ@QdYXi({*%Ml-o+mb)r
zwwipAoj+7vJbC8ZdX-DxOJ(-HT|D7};McF7`Zp!yqSYUzX;x3LDw0n+r+A@I*G_Qv
z+Tcw!X%(-mo*dos>iYVyl*LbUWlN&}-rK~tl6n4u9^R&mEtQpbPv*Q#=oI*EpK8b-
z>t1-AyJ$+UkTr{Rk&}C^ve(3!$xbID+1J?E?wb3oMO>Ht&uY$93vHSd)y`~P)jZR@
zv5&X%D5R=rZExoz5}SX2G{ch-R}SAXvbubnhqIyUTWlx?j0>lLia
z4=7Df|1HrHu(a97AisGI-@~JO8*eL`i!XoqqGev)cmK6TmD6%|{N4U?<@xN>$xp)4
l-}Bg9cj2Ef`F~IKNA7vr)3u}*9O?#T0#8>zmvv4FO#q@daV`J=
literal 0
HcmV?d00001
diff --git a/website/assets/js/components/logo-carousel.component.js b/website/assets/js/components/logo-carousel.component.js
index c7555349d4..fd59377660 100644
--- a/website/assets/js/components/logo-carousel.component.js
+++ b/website/assets/js/components/logo-carousel.component.js
@@ -54,7 +54,8 @@ parasails.registerComponent('logoCarousel', {
-
+
+
@@ -96,7 +97,8 @@ parasails.registerComponent('logoCarousel', {
-
+
+
@@ -148,7 +150,8 @@ parasails.registerComponent('logoCarousel', {
-
+
+
@@ -186,7 +189,8 @@ parasails.registerComponent('logoCarousel', {
-
+
+