diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index d48e07223cdf9..8f08c41a7565f 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -127,6 +127,8 @@ export const AGENT_API_ROUTES = { BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`, REASSIGN_PATTERN: `${API_ROOT}/agents/{agentId}/reassign`, BULK_REASSIGN_PATTERN: `${API_ROOT}/agents/bulk_reassign`, + REQUEST_DIAGNOSTICS_PATTERN: `${API_ROOT}/agents/{agentId}/request_diagnostics`, + BULK_REQUEST_DIAGNOSTICS_PATTERN: `${API_ROOT}/agents/bulk_request_diagnostics`, AVAILABLE_VERSIONS_PATTERN: `${API_ROOT}/agents/available_versions`, STATUS_PATTERN: `${API_ROOT}/agent_status`, DATA_PATTERN: `${API_ROOT}/agent_status/data`, @@ -137,6 +139,8 @@ export const AGENT_API_ROUTES = { CURRENT_UPGRADES_PATTERN: `${API_ROOT}/agents/current_upgrades`, ACTION_STATUS_PATTERN: `${API_ROOT}/agents/action_status`, LIST_TAGS_PATTERN: `${API_ROOT}/agents/tags`, + LIST_UPLOADS_PATTERN: `${API_ROOT}/agents/{agentId}/uploads`, + GET_UPLOAD_FILE_PATTERN: `${API_ROOT}/agents/files/{fileId}/{fileName}`, }; export const ENROLLMENT_API_KEY_ROUTES = { diff --git a/x-pack/plugins/fleet/common/experimental_features.ts b/x-pack/plugins/fleet/common/experimental_features.ts index 8926a1092fca2..2c55ce4a2a08e 100644 --- a/x-pack/plugins/fleet/common/experimental_features.ts +++ b/x-pack/plugins/fleet/common/experimental_features.ts @@ -15,6 +15,7 @@ export const allowedExperimentalValues = Object.freeze({ createPackagePolicyMultiPageLayout: true, packageVerification: true, showDevtoolsRequest: true, + showRequestDiagnostics: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index 4c8f053b56cf9..80b9e35430066 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -206,6 +206,16 @@ export const agentRouteService = { AGENT_API_ROUTES.ACTIONS_PATTERN.replace('{agentId}', agentId), getListTagsPath: () => AGENT_API_ROUTES.LIST_TAGS_PATTERN, getAvailableVersionsPath: () => AGENT_API_ROUTES.AVAILABLE_VERSIONS_PATTERN, + getRequestDiagnosticsPath: (agentId: string) => + AGENT_API_ROUTES.REQUEST_DIAGNOSTICS_PATTERN.replace('{agentId}', agentId), + getBulkRequestDiagnosticsPath: () => AGENT_API_ROUTES.BULK_REQUEST_DIAGNOSTICS_PATTERN, + getListAgentUploads: (agentId: string) => + AGENT_API_ROUTES.LIST_UPLOADS_PATTERN.replace('{agentId}', agentId), + getAgentFileDownloadLink: (fileId: string, fileName: string) => + AGENT_API_ROUTES.GET_UPLOAD_FILE_PATTERN.replace('{fileId}', fileId).replace( + '{fileName}', + fileName + ), }; export const outputRoutesService = { diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 9e47451aa02bb..e272c889a83b6 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -37,7 +37,8 @@ export type AgentActionType = | 'POLICY_REASSIGN' | 'CANCEL' | 'FORCE_UNENROLL' - | 'UPDATE_TAGS'; + | 'UPDATE_TAGS' + | 'REQUEST_DIAGNOSTICS'; type FleetServerAgentComponentStatusTuple = typeof FleetServerAgentComponentStatuses; export type FleetServerAgentComponentStatus = FleetServerAgentComponentStatusTuple[number]; @@ -142,6 +143,15 @@ export interface ActionStatus { creationTime: string; } +export interface AgentDiagnostics { + id: string; + name: string; + createTime: string; + filePath: string; + status: 'READY' | 'AWAITING_UPLOAD' | 'DELETED' | 'IN_PROGRESS'; + actionId: string; +} + // Generated from FleetServer schema.json export interface FleetServerAgentComponentUnit { id: string; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 9a18613d95834..8af5ee9abe1e3 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -7,7 +7,14 @@ import type { SearchHit } from '@kbn/es-types'; -import type { Agent, AgentAction, ActionStatus, CurrentUpgrade, NewAgentAction } from '../models'; +import type { + Agent, + AgentAction, + ActionStatus, + CurrentUpgrade, + NewAgentAction, + AgentDiagnostics, +} from '../models'; import type { ListResult, ListWithKuery } from './common'; @@ -38,6 +45,10 @@ export interface GetOneAgentResponse { item: Agent; } +export interface GetAgentUploadsResponse { + items: AgentDiagnostics[]; +} + export interface PostNewAgentActionRequest { body: { action: Omit; @@ -121,6 +132,16 @@ export interface PostBulkAgentReassignRequest { }; } +export type PostRequestDiagnosticsResponse = BulkAgentAction; +export type PostBulkRequestDiagnosticsResponse = BulkAgentAction; + +export interface PostRequestBulkDiagnosticsRequest { + body: { + agents: string[] | string; + batchSize?: number; + }; +} + export type PostBulkAgentReassignResponse = BulkAgentAction; export type PostBulkUpdateAgentTagsResponse = BulkAgentAction; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index c57347894003d..b52a0401b8650 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -19,6 +19,8 @@ import { } from '../../components'; import { useAgentRefresh } from '../hooks'; import { isAgentUpgradeable, policyHasFleetServer } from '../../../../services'; +import { AgentRequestDiagnosticsModal } from '../../components/agent_request_diagnostics_modal'; +import { ExperimentalFeaturesService } from '../../../../services'; export const AgentDetailsActionMenu: React.FunctionComponent<{ agent: Agent; @@ -32,9 +34,11 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(assignFlyoutOpenByDefault); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); + const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] = useState(false); const isUnenrolling = agent.status === 'unenrolling'; const hasFleetServer = agentPolicy && policyHasFleetServer(agentPolicy); + const { showRequestDiagnostics } = ExperimentalFeaturesService.get(); const onClose = useMemo(() => { if (onCancelReassign) { @@ -44,6 +48,70 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ } }, [onCancelReassign, setIsReassignFlyoutOpen]); + const menuItems = [ + { + setIsReassignFlyoutOpen(true); + }} + disabled={!agent.active} + key="reassignPolicy" + > + + , + { + setIsUnenrollModalOpen(true); + }} + > + {isUnenrolling ? ( + + ) : ( + + )} + , + { + setIsUpgradeModalOpen(true); + }} + > + + , + ]; + + if (showRequestDiagnostics) { + menuItems.push( + { + setIsRequestDiagnosticsModalOpen(true); + }} + > + + + ); + } + return ( <> {isReassignFlyoutOpen && ( @@ -77,6 +145,17 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ /> )} + {isRequestDiagnosticsModalOpen && ( + + { + setIsRequestDiagnosticsModalOpen(false); + }} + /> + + )} ), }} - items={[ - { - setIsReassignFlyoutOpen(true); - }} - disabled={!agent.active} - key="reassignPolicy" - > - - , - { - setIsUnenrollModalOpen(true); - }} - > - {isUnenrolling ? ( - - ) : ( - - )} - , - { - setIsUpgradeModalOpen(true); - }} - > - - , - ]} + items={menuItems} /> ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_diagnostics/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_diagnostics/index.tsx new file mode 100644 index 0000000000000..7f9bc76799ed3 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_diagnostics/index.tsx @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiTableFieldDataColumnType } from '@elastic/eui'; +import { + EuiBasicTable, + EuiButton, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiLoadingContent, + EuiLoadingSpinner, + EuiText, + formatDate, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import { + sendGetAgentUploads, + sendPostRequestDiagnostics, + useLink, + useStartServices, +} from '../../../../../hooks'; +import type { AgentDiagnostics, Agent } from '../../../../../../../../common/types/models'; + +const FlexStartEuiFlexItem = styled(EuiFlexItem)` + align-self: flex-start; +`; + +export interface AgentDiagnosticsProps { + agent: Agent; +} + +export const AgentDiagnosticsTab: React.FunctionComponent = ({ agent }) => { + const { notifications } = useStartServices(); + const { getAbsolutePath } = useLink(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [diagnosticsEntries, setDiagnosticEntries] = useState([]); + const [prevDiagnosticsEntries, setPrevDiagnosticEntries] = useState([]); + + const loadData = useCallback(async () => { + try { + const uploadsResponse = await sendGetAgentUploads(agent.id); + const error = uploadsResponse.error; + if (error) { + throw error; + } + if (!uploadsResponse.data) { + throw new Error('No data'); + } + setDiagnosticEntries(uploadsResponse.data.items); + setIsLoading(false); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate( + 'xpack.fleet.requestDiagnostics.errorLoadingUploadsNotificationTitle', + { + defaultMessage: 'Error loading diagnostics uploads', + } + ), + }); + } + }, [agent.id, notifications.toasts]); + + useEffect(() => { + loadData(); + const interval: ReturnType | null = setInterval(async () => { + loadData(); + }, 10000); + + const cleanup = () => { + if (interval) { + clearInterval(interval); + } + }; + + return cleanup; + }, [loadData]); + + useEffect(() => { + setPrevDiagnosticEntries(diagnosticsEntries); + if (prevDiagnosticsEntries.length > 0) { + diagnosticsEntries + .filter((newEntry) => { + const oldEntry = prevDiagnosticsEntries.find((entry) => entry.id === newEntry.id); + return newEntry.status === 'READY' && (!oldEntry || oldEntry?.status !== 'READY'); + }) + .forEach((entry) => { + notifications.toasts.addSuccess( + { + title: i18n.translate('xpack.fleet.requestDiagnostics.readyNotificationTitle', { + defaultMessage: 'Agent diagnostics {name} ready', + values: { + name: entry.name, + }, + }), + }, + { toastLifeTimeMs: 5000 } + ); + }); + } + }, [prevDiagnosticsEntries, diagnosticsEntries, notifications.toasts]); + + const columns: Array> = [ + { + field: 'id', + name: 'File', + render: (id: string) => { + const currentItem = diagnosticsEntries.find((item) => item.id === id); + return currentItem?.status === 'READY' ? ( + +   {currentItem?.name} + + ) : currentItem?.status === 'IN_PROGRESS' || currentItem?.status === 'AWAITING_UPLOAD' ? ( + +   + + + ) : ( + +   + {currentItem?.name} + + ); + }, + }, + { + field: 'id', + name: 'Date', + dataType: 'date', + render: (id: string) => { + const currentItem = diagnosticsEntries.find((item) => item.id === id); + return ( + + {formatDate(currentItem?.createTime, 'll')} + + ); + }, + }, + ]; + + async function onSubmit() { + try { + setIsSubmitting(true); + const { error } = await sendPostRequestDiagnostics(agent.id); + if (error) { + throw error; + } + setIsSubmitting(false); + const successMessage = i18n.translate( + 'xpack.fleet.requestDiagnostics.successSingleNotificationTitle', + { + defaultMessage: 'Request diagnostics submitted', + } + ); + notifications.toasts.addSuccess(successMessage); + } catch (error) { + setIsSubmitting(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.requestDiagnostics.fatalErrorNotificationTitle', { + defaultMessage: + 'Error requesting diagnostics {count, plural, one {agent} other {agents}}', + values: { count: 1 }, + }), + }); + } + } + + return ( + + + + } + > + + + + + + + + + + {isLoading ? ( + + ) : ( + items={diagnosticsEntries} columns={columns} /> + )} + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts index 5a806d52d3af9..858bb31033716 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/index.ts @@ -9,3 +9,4 @@ export { AgentLogs } from './agent_logs'; export { AgentDetailsActionMenu } from './actions_menu'; export { AgentDetailsContent } from './agent_details'; export { AgentDashboardLink } from './agent_dashboard_link'; +export { AgentDiagnosticsTab } from './agent_diagnostics'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx index 5654f4a18d1d3..b2e9bd76194e4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx @@ -25,12 +25,15 @@ import { } from '../../../hooks'; import { WithHeaderLayout } from '../../../layouts'; +import { ExperimentalFeaturesService } from '../../../services'; + import { AgentRefreshContext } from './hooks'; import { AgentLogs, AgentDetailsActionMenu, AgentDetailsContent, AgentDashboardLink, + AgentDiagnosticsTab, } from './components'; export const AgentDetailsPage: React.FunctionComponent = () => { @@ -65,6 +68,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { navigateToApp(routeState.onDoneNavigateTo[0], routeState.onDoneNavigateTo[1]); } }, [routeState, navigateToApp]); + const { showRequestDiagnostics } = ExperimentalFeaturesService.get(); const host = agentData?.item?.local_metadata?.host; @@ -134,7 +138,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { ); const headerTabs = useMemo(() => { - return [ + const tabs = [ { id: 'details', name: i18n.translate('xpack.fleet.agentDetails.subTabs.detailsTab', { @@ -152,7 +156,18 @@ export const AgentDetailsPage: React.FunctionComponent = () => { isSelected: tabId === 'logs', }, ]; - }, [getHref, agentId, tabId]); + if (showRequestDiagnostics) { + tabs.push({ + id: 'diagnostics', + name: i18n.translate('xpack.fleet.agentDetails.subTabs.diagnosticsTab', { + defaultMessage: 'Diagnostics', + }), + href: getHref('agent_details_diagnostics', { agentId, tabId: 'diagnostics' }), + isSelected: tabId === 'diagnostics', + }); + } + return tabs; + }, [getHref, agentId, tabId, showRequestDiagnostics]); return ( ; }} /> + { + return ; + }} + /> { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx index d40948f2323c6..98d66d7c6dcd8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx @@ -254,6 +254,11 @@ const actionNames: { cancelledText: 'update tags', }, CANCEL: { inProgressText: 'Cancelling', completedText: 'cancelled', cancelledText: '' }, + REQUEST_DIAGNOSTICS: { + inProgressText: 'Requesting diagnostics for', + completedText: 'requested diagnostics', + cancelledText: 'request diagnostics', + }, SETTINGS: { inProgressText: 'Updating settings of', completedText: 'updated settings', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index 356753a0d0045..aa53c3d8f4040 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -25,9 +25,12 @@ import { } from '../../components'; import { useLicense } from '../../../../hooks'; import { LICENSE_FOR_SCHEDULE_UPGRADE } from '../../../../../../../common/constants'; +import { ExperimentalFeaturesService } from '../../../../services'; import { getCommonTags } from '../utils'; +import { AgentRequestDiagnosticsModal } from '../../components/agent_request_diagnostics_modal'; + import type { SelectionMode } from './types'; import { TagsAddRemove } from './tags_add_remove'; @@ -67,6 +70,8 @@ export const AgentBulkActions: React.FunctionComponent = ({ const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); const [updateModalState, setUpgradeModalState] = useState({ isOpen: false, isScheduled: false }); const [isTagAddVisible, setIsTagAddVisible] = useState(false); + const [isRequestDiagnosticsModalOpen, setIsRequestDiagnosticsModalOpen] = + useState(false); // Check if user is working with only inactive agents const atLeastOneActiveAgentSelected = @@ -77,96 +82,120 @@ export const AgentBulkActions: React.FunctionComponent = ({ const agentCount = selectionMode === 'manual' ? selectedAgents.length : totalActiveAgents; const agents = selectionMode === 'manual' ? selectedAgents : currentQuery; const [tagsPopoverButton, setTagsPopoverButton] = useState(); + const { showRequestDiagnostics } = ExperimentalFeaturesService.get(); + + const menuItems = [ + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: (event: any) => { + setTagsPopoverButton((event.target as Element).closest('button')!); + setIsTagAddVisible(!isTagAddVisible); + }, + }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setIsReassignFlyoutOpen(true); + }, + }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setIsUnenrollModalOpen(true); + }, + }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setUpgradeModalState({ isOpen: true, isScheduled: false }); + }, + }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected || !isLicenceAllowingScheduleUpgrade, + onClick: () => { + closeMenu(); + setUpgradeModalState({ isOpen: true, isScheduled: true }); + }, + }, + ]; + + if (showRequestDiagnostics) { + menuItems.push({ + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setIsRequestDiagnosticsModalOpen(true); + }, + }); + } const panels = [ { id: 0, - items: [ - { - name: ( - - ), - icon: , - disabled: !atLeastOneActiveAgentSelected, - onClick: (event: any) => { - setTagsPopoverButton((event.target as Element).closest('button')!); - setIsTagAddVisible(!isTagAddVisible); - }, - }, - { - name: ( - - ), - icon: , - disabled: !atLeastOneActiveAgentSelected, - onClick: () => { - closeMenu(); - setIsReassignFlyoutOpen(true); - }, - }, - { - name: ( - - ), - icon: , - disabled: !atLeastOneActiveAgentSelected, - onClick: () => { - closeMenu(); - setIsUnenrollModalOpen(true); - }, - }, - { - name: ( - - ), - icon: , - disabled: !atLeastOneActiveAgentSelected, - onClick: () => { - closeMenu(); - setUpgradeModalState({ isOpen: true, isScheduled: false }); - }, - }, - { - name: ( - - ), - icon: , - disabled: !atLeastOneActiveAgentSelected || !isLicenceAllowingScheduleUpgrade, - onClick: () => { - closeMenu(); - setUpgradeModalState({ isOpen: true, isScheduled: true }); - }, - }, - ], + items: menuItems, }, ]; @@ -228,6 +257,17 @@ export const AgentBulkActions: React.FunctionComponent = ({ }} /> )} + {isRequestDiagnosticsModalOpen && ( + + { + setIsRequestDiagnosticsModalOpen(false); + }} + /> + + )} ( ); describe('SearchAndFilterBar', () => { + beforeAll(() => { + ExperimentalFeaturesService.init({ + createPackagePolicyMultiPageLayout: true, + packageVerification: true, + showDevtoolsRequest: false, + showRequestDiagnostics: false, + }); + }); it('should show no Actions button when no agent is selected', async () => { const selectedAgents: Agent[] = []; const props: any = { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx index 901d03ab7e371..ad9c9bdbf3f7b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_row_actions.tsx @@ -13,6 +13,7 @@ import type { Agent, AgentPolicy } from '../../../../types'; import { useAuthz, useLink, useKibanaVersion } from '../../../../hooks'; import { ContextMenuActions } from '../../../../components'; import { isAgentUpgradeable } from '../../../../services'; +import { ExperimentalFeaturesService } from '../../../../services'; export const TableRowActions: React.FunctionComponent<{ agent: Agent; @@ -21,6 +22,7 @@ export const TableRowActions: React.FunctionComponent<{ onUnenrollClick: () => void; onUpgradeClick: () => void; onAddRemoveTagsClick: (button: HTMLElement) => void; + onRequestDiagnosticsClick: () => void; }> = ({ agent, agentPolicy, @@ -28,6 +30,7 @@ export const TableRowActions: React.FunctionComponent<{ onUnenrollClick, onUpgradeClick, onAddRemoveTagsClick, + onRequestDiagnosticsClick, }) => { const { getHref } = useLink(); const hasFleetAllPrivileges = useAuthz().fleet.all; @@ -35,6 +38,7 @@ export const TableRowActions: React.FunctionComponent<{ const isUnenrolling = agent.status === 'unenrolling'; const kibanaVersion = useKibanaVersion(); const [isMenuOpen, setIsMenuOpen] = useState(false); + const { showRequestDiagnostics } = ExperimentalFeaturesService.get(); const menuItems = [ ); + + if (showRequestDiagnostics) { + menuItems.push( + { + onRequestDiagnosticsClick(); + }} + > + + + ); + } } return ( = () => { const [agentToAddRemoveTags, setAgentToAddRemoveTags] = useState(undefined); const [tagsPopoverButton, setTagsPopoverButton] = useState(); const [showTagsAddRemove, setShowTagsAddRemove] = useState(false); + const [agentToRequestDiagnostics, setAgentToRequestDiagnostics] = useState( + undefined + ); // Kuery const kuery = useMemo(() => { @@ -538,6 +543,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { setAgentToAddRemoveTags(agent); setShowTagsAddRemove(!showTagsAddRemove); }} + onRequestDiagnosticsClick={() => setAgentToRequestDiagnostics(agent)} /> ); }, @@ -612,6 +618,17 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { /> )} + {agentToRequestDiagnostics && ( + + { + setAgentToRequestDiagnostics(undefined); + }} + /> + + )} {showTagsAddRemove && ( void; + agents: Agent[] | string; + agentCount: number; +} + +export const AgentRequestDiagnosticsModal: React.FunctionComponent = ({ + onClose, + agents, + agentCount, +}) => { + const { notifications } = useStartServices(); + const [isSubmitting, setIsSubmitting] = useState(false); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + const { getPath } = useLink(); + const history = useHistory(); + + async function onSubmit() { + try { + setIsSubmitting(true); + + const { error } = isSingleAgent + ? await sendPostRequestDiagnostics((agents[0] as Agent).id) + : await sendPostBulkRequestDiagnostics({ + agents: typeof agents === 'string' ? agents : agents.map((agent) => agent.id), + }); + if (error) { + throw error; + } + setIsSubmitting(false); + const successMessage = i18n.translate( + 'xpack.fleet.requestDiagnostics.successSingleNotificationTitle', + { + defaultMessage: 'Request diagnostics submitted', + } + ); + notifications.toasts.addSuccess(successMessage); + + if (isSingleAgent) { + const path = getPath('agent_details_diagnostics', { agentId: (agents[0] as Agent).id }); + history.push(path); + } + onClose(); + } catch (error) { + setIsSubmitting(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.requestDiagnostics.fatalErrorNotificationTitle', { + defaultMessage: + 'Error requesting diagnostics {count, plural, one {agent} other {agents}}', + values: { count: agentCount }, + }), + }); + } + } + + return ( + + ) : ( + + ) + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( + + ) : ( + + ) + } + buttonColor="primary" + > +

+ +

+
+ ); +}; diff --git a/x-pack/plugins/fleet/public/constants/page_paths.ts b/x-pack/plugins/fleet/public/constants/page_paths.ts index d276d777661ed..4eba2304777da 100644 --- a/x-pack/plugins/fleet/public/constants/page_paths.ts +++ b/x-pack/plugins/fleet/public/constants/page_paths.ts @@ -41,6 +41,7 @@ export type DynamicPage = | 'agent_list' | 'agent_details' | 'agent_details_logs' + | 'agent_details_diagnostics' | 'settings_edit_outputs' | 'settings_edit_download_sources' | 'settings_edit_fleet_server_hosts'; @@ -61,6 +62,7 @@ export const FLEET_ROUTING_PATHS = { agents: '/agents', agent_details: '/agents/:agentId/:tabId?', agent_details_logs: '/agents/:agentId/logs', + agent_details_diagnostics: '/agents/:agentId/diagnostics', policies: '/policies', policies_list: '/policies', policy_details: '/policies/:policyId/:tabId?', @@ -199,6 +201,7 @@ export const pagePathGetters: { `/agents/${agentId}${tabId ? `/${tabId}` : ''}${logQuery ? `?_q=${logQuery}` : ''}`, ], agent_details_logs: ({ agentId }) => [FLEET_BASE_PATH, `/agents/${agentId}/logs`], + agent_details_diagnostics: ({ agentId }) => [FLEET_BASE_PATH, `/agents/${agentId}/diagnostics`], enrollment_tokens: () => [FLEET_BASE_PATH, '/enrollment-tokens'], data_streams: () => [FLEET_BASE_PATH, '/data-streams'], settings: () => [FLEET_BASE_PATH, FLEET_ROUTING_PATHS.settings], diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index 13f687e321e54..0ebb99508bd84 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -8,7 +8,11 @@ import type { GetActionStatusResponse, GetAgentTagsResponse, + GetAgentUploadsResponse, + PostBulkRequestDiagnosticsResponse, PostBulkUpdateAgentTagsRequest, + PostRequestBulkDiagnosticsRequest, + PostRequestDiagnosticsResponse, UpdateAgentRequest, } from '../../../common/types'; @@ -171,6 +175,42 @@ export function sendPostAgentUpgrade( }); } +export function sendPostRequestDiagnostics(agentId: string, options?: RequestOptions) { + return sendRequest({ + path: agentRouteService.getRequestDiagnosticsPath(agentId), + method: 'post', + ...options, + }); +} + +export function sendPostBulkRequestDiagnostics( + body: PostRequestBulkDiagnosticsRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getBulkRequestDiagnosticsPath(), + method: 'post', + body, + ...options, + }); +} + +export function sendGetAgentUploads(agentId: string, options?: RequestOptions) { + return sendRequest({ + path: agentRouteService.getListAgentUploads(agentId), + method: 'get', + ...options, + }); +} + +export const useGetAgentUploads = (agentId: string, options?: RequestOptions) => { + return useRequest({ + path: agentRouteService.getListAgentUploads(agentId), + method: 'get', + ...options, + }); +}; + export function sendPostAgentAction( agentId: string, body: PostNewAgentActionRequest['body'], diff --git a/x-pack/plugins/fleet/public/mock/create_test_renderer.tsx b/x-pack/plugins/fleet/public/mock/create_test_renderer.tsx index 51ff2cb0f8245..f4cd7f9403280 100644 --- a/x-pack/plugins/fleet/public/mock/create_test_renderer.tsx +++ b/x-pack/plugins/fleet/public/mock/create_test_renderer.tsx @@ -68,6 +68,7 @@ export const createFleetTestRendererMock = (): TestRenderer => { createPackagePolicyMultiPageLayout: true, packageVerification: true, showDevtoolsRequest: false, + showRequestDiagnostics: false, }); const HookWrapper = memo(({ children }) => { diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 86f202381119f..85bcce7df69ba 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -28,6 +28,7 @@ import type { GetAgentTagsResponse, GetAvailableVersionsResponse, GetActionStatusResponse, + GetAgentUploadsResponse, } from '../../../common/types'; import type { GetAgentsRequestSchema, @@ -41,6 +42,7 @@ import type { PostBulkAgentReassignRequestSchema, PostBulkUpdateAgentTagsRequestSchema, GetActionStatusRequestSchema, + GetAgentUploadFileRequestSchema, } from '../../types'; import { defaultFleetErrorHandler } from '../../errors'; import * as AgentService from '../../services/agents'; @@ -362,3 +364,37 @@ export const getActionStatusHandler: RequestHandler< return defaultFleetErrorHandler({ error, response }); } }; + +export const getAgentUploadsHandler: RequestHandler< + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + try { + const body: GetAgentUploadsResponse = { + items: await AgentService.getAgentUploads(esClient, request.params.agentId), + }; + + return response.ok({ body }); + } catch (error) { + return defaultFleetErrorHandler({ error, response }); + } +}; + +export const getAgentUploadFileHandler: RequestHandler< + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + try { + const resp = await AgentService.getAgentUploadFile( + esClient, + request.params.fileId, + request.params.fileName + ); + + return response.ok(resp); + } catch (error) { + return defaultFleetErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index a6ee368a775ed..e8ac9a2de6749 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -23,6 +23,10 @@ import { PostBulkAgentUpgradeRequestSchema, PostCancelActionRequestSchema, GetActionStatusRequestSchema, + PostRequestDiagnosticsActionRequestSchema, + PostBulkRequestDiagnosticsActionRequestSchema, + ListAgentUploadsRequestSchema, + GetAgentUploadFileRequestSchema, } from '../../types'; import * as AgentService from '../../services/agents'; import type { FleetConfigType } from '../..'; @@ -43,6 +47,8 @@ import { bulkUpdateAgentTagsHandler, getAvailableVersionsHandler, getActionStatusHandler, + getAgentUploadsHandler, + getAgentUploadFileHandler, } from './handlers'; import { postNewAgentActionHandlerBuilder, @@ -54,6 +60,10 @@ import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler, } from './upgrade_handler'; +import { + bulkRequestDiagnosticsHandler, + requestDiagnosticsHandler, +} from './request_diagnostics_handler'; export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => { // Get one @@ -178,6 +188,50 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT putAgentsReassignHandler ); + router.post( + { + path: AGENT_API_ROUTES.REQUEST_DIAGNOSTICS_PATTERN, + validate: PostRequestDiagnosticsActionRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + requestDiagnosticsHandler + ); + + router.post( + { + path: AGENT_API_ROUTES.BULK_REQUEST_DIAGNOSTICS_PATTERN, + validate: PostBulkRequestDiagnosticsActionRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + bulkRequestDiagnosticsHandler + ); + + router.get( + { + path: AGENT_API_ROUTES.LIST_UPLOADS_PATTERN, + validate: ListAgentUploadsRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + getAgentUploadsHandler + ); + + router.get( + { + path: AGENT_API_ROUTES.GET_UPLOAD_FILE_PATTERN, + validate: GetAgentUploadFileRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + getAgentUploadFileHandler + ); + // Get agent status for policy router.get( { diff --git a/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts b/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts new file mode 100644 index 0000000000000..74452b0e055c7 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/agent/request_diagnostics_handler.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RequestHandler } from '@kbn/core/server'; +import type { TypeOf } from '@kbn/config-schema'; + +import * as AgentService from '../../services/agents'; +import type { + PostBulkRequestDiagnosticsActionRequestSchema, + PostRequestDiagnosticsActionRequestSchema, +} from '../../types'; +import { defaultFleetErrorHandler } from '../../errors'; + +export const requestDiagnosticsHandler: RequestHandler< + TypeOf, + undefined, + undefined +> = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + try { + const result = await AgentService.requestDiagnostics(esClient, request.params.agentId); + + return response.ok({ body: { actionId: result.actionId } }); + } catch (error) { + return defaultFleetErrorHandler({ error, response }); + } +}; + +export const bulkRequestDiagnosticsHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + const soClient = coreContext.savedObjects.client; + const agentOptions = Array.isArray(request.body.agents) + ? { agentIds: request.body.agents } + : { kuery: request.body.agents }; + try { + const result = await AgentService.bulkRequestDiagnostics(esClient, soClient, { + ...agentOptions, + batchSize: request.body.batchSize, + }); + + return response.ok({ body: { actionId: result.actionId } }); + } catch (error) { + return defaultFleetErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/fleet/server/services/agents/bulk_actions_resolver.ts b/x-pack/plugins/fleet/server/services/agents/bulk_actions_resolver.ts index df776937e3719..b6ec1d082b39a 100644 --- a/x-pack/plugins/fleet/server/services/agents/bulk_actions_resolver.ts +++ b/x-pack/plugins/fleet/server/services/agents/bulk_actions_resolver.ts @@ -20,12 +20,14 @@ import { UpgradeActionRunner } from './upgrade_action_runner'; import { UpdateAgentTagsActionRunner } from './update_agent_tags_action_runner'; import { UnenrollActionRunner } from './unenroll_action_runner'; import type { ActionParams, RetryParams } from './action_runner'; +import { RequestDiagnosticsActionRunner } from './request_diagnostics_action_runner'; export enum BulkActionTaskType { REASSIGN_RETRY = 'fleet:reassign_action:retry', UNENROLL_RETRY = 'fleet:unenroll_action:retry', UPGRADE_RETRY = 'fleet:upgrade_action:retry', UPDATE_AGENT_TAGS_RETRY = 'fleet:update_agent_tags:retry', + REQUEST_DIAGNOSTICS_RETRY = 'fleet:request_diagnostics:retry', } /** @@ -49,6 +51,7 @@ export class BulkActionsResolver { [BulkActionTaskType.REASSIGN_RETRY]: ReassignActionRunner, [BulkActionTaskType.UPDATE_AGENT_TAGS_RETRY]: UpdateAgentTagsActionRunner, [BulkActionTaskType.UPGRADE_RETRY]: UpgradeActionRunner, + [BulkActionTaskType.REQUEST_DIAGNOSTICS_RETRY]: RequestDiagnosticsActionRunner, }; return createRetryTask( diff --git a/x-pack/plugins/fleet/server/services/agents/index.ts b/x-pack/plugins/fleet/server/services/agents/index.ts index 302790cf6ae6d..c5e1c199e6b82 100644 --- a/x-pack/plugins/fleet/server/services/agents/index.ts +++ b/x-pack/plugins/fleet/server/services/agents/index.ts @@ -15,6 +15,8 @@ export * from './reassign'; export * from './setup'; export * from './update_agent_tags'; export * from './action_status'; +export * from './request_diagnostics'; +export { getAgentUploads, getAgentUploadFile } from './uploads'; export { AgentServiceImpl } from './agent_service'; export type { AgentClient, AgentService } from './agent_service'; export { BulkActionsResolver } from './bulk_actions_resolver'; diff --git a/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts b/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts new file mode 100644 index 0000000000000..05aa81be27612 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/request_diagnostics.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { appContextService } from '../app_context'; +import { createAppContextStartContractMock } from '../../mocks'; + +import { createClientMock } from './action.mock'; +import { bulkRequestDiagnostics, requestDiagnostics } from './request_diagnostics'; + +describe('requestDiagnostics (singular)', () => { + it('can request diagnostics for single agent', async () => { + const { esClient, agentInRegularDoc } = createClientMock(); + await requestDiagnostics(esClient, agentInRegularDoc._id); + + expect(esClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agents: ['agent-in-regular-policy'], + type: 'REQUEST_DIAGNOSTICS', + }), + index: '.fleet-actions', + }) + ); + }); +}); + +describe('requestDiagnostics (plural)', () => { + beforeEach(async () => { + appContextService.start(createAppContextStartContractMock()); + }); + + afterEach(() => { + appContextService.stop(); + }); + it('can request diagnostics for multiple agents', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; + await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToUnenroll }); + + expect(esClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agents: ['agent-in-regular-policy', 'agent-in-regular-policy2'], + type: 'REQUEST_DIAGNOSTICS', + }), + index: '.fleet-actions', + }) + ); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agents/request_diagnostics.ts b/x-pack/plugins/fleet/server/services/agents/request_diagnostics.ts new file mode 100644 index 0000000000000..efb9812180580 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/request_diagnostics.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; + +import { SO_SEARCH_LIMIT } from '../../constants'; + +import type { GetAgentsOptions } from '.'; +import { getAgents, getAgentsByKuery } from './crud'; +import { createAgentAction } from './actions'; +import { openPointInTime } from './crud'; +import { + RequestDiagnosticsActionRunner, + requestDiagnosticsBatch, +} from './request_diagnostics_action_runner'; + +export async function requestDiagnostics( + esClient: ElasticsearchClient, + agentId: string +): Promise<{ actionId: string }> { + const response = await createAgentAction(esClient, { + agents: [agentId], + created_at: new Date().toISOString(), + type: 'REQUEST_DIAGNOSTICS', + }); + return { actionId: response.id }; +} + +export async function bulkRequestDiagnostics( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, + options: GetAgentsOptions & { + batchSize?: number; + } +): Promise<{ actionId: string }> { + if ('agentIds' in options) { + const givenAgents = await getAgents(esClient, options); + return await requestDiagnosticsBatch(esClient, givenAgents, {}); + } + + const batchSize = options.batchSize ?? SO_SEARCH_LIMIT; + const res = await getAgentsByKuery(esClient, { + kuery: options.kuery, + showInactive: false, + page: 1, + perPage: batchSize, + }); + if (res.total <= batchSize) { + const givenAgents = await getAgents(esClient, options); + return await requestDiagnosticsBatch(esClient, givenAgents, {}); + } else { + return await new RequestDiagnosticsActionRunner( + esClient, + soClient, + { + ...options, + batchSize, + total: res.total, + }, + { pitId: await openPointInTime(esClient) } + ).runActionAsyncWithRetry(); + } +} diff --git a/x-pack/plugins/fleet/server/services/agents/request_diagnostics_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/request_diagnostics_action_runner.ts new file mode 100644 index 0000000000000..4b3cca06d061c --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/request_diagnostics_action_runner.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import type { ElasticsearchClient } from '@kbn/core/server'; + +import type { Agent } from '../../types'; + +import { ActionRunner } from './action_runner'; +import { createAgentAction } from './actions'; +import { BulkActionTaskType } from './bulk_actions_resolver'; + +export class RequestDiagnosticsActionRunner extends ActionRunner { + protected async processAgents(agents: Agent[]): Promise<{ actionId: string }> { + return await requestDiagnosticsBatch(this.esClient, agents, this.actionParams!); + } + + protected getTaskType() { + return BulkActionTaskType.REQUEST_DIAGNOSTICS_RETRY; + } + + protected getActionType() { + return 'REQUEST_DIAGNOSTICS'; + } +} + +export async function requestDiagnosticsBatch( + esClient: ElasticsearchClient, + givenAgents: Agent[], + options: { + actionId?: string; + total?: number; + } +): Promise<{ actionId: string }> { + const now = new Date().toISOString(); + + const actionId = options.actionId ?? uuid(); + const total = options.total ?? givenAgents.length; + + const agentIds = givenAgents.map((agent) => agent.id); + + await createAgentAction(esClient, { + id: actionId, + agents: agentIds, + created_at: now, + type: 'REQUEST_DIAGNOSTICS', + total, + }); + + return { + actionId, + }; +} diff --git a/x-pack/plugins/fleet/server/services/agents/uploads.ts b/x-pack/plugins/fleet/server/services/agents/uploads.ts new file mode 100644 index 0000000000000..2269a080e41b9 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/uploads.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Readable } from 'stream'; + +import moment from 'moment'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; + +import { createEsFileClient } from '@kbn/files-plugin/server'; + +import type { ResponseHeaders } from '@kbn/core-http-server'; + +import type { AgentDiagnostics } from '../../../common/types/models'; +import { appContextService } from '../app_context'; +import { + AGENT_ACTIONS_INDEX, + agentRouteService, + AGENT_ACTIONS_RESULTS_INDEX, +} from '../../../common'; + +import { SO_SEARCH_LIMIT } from '../../constants'; + +const FILE_STORAGE_METADATA_AGENT_INDEX = '.fleet-agent-files'; +const FILE_STORAGE_DATA_AGENT_INDEX = '.fleet-agent-file-data'; + +export async function getAgentUploads( + esClient: ElasticsearchClient, + agentId: string +): Promise { + const getFile = async (fileId: string) => { + if (!fileId) return; + try { + const file = await esClient.get({ + index: FILE_STORAGE_METADATA_AGENT_INDEX, + id: fileId, + }); + return { + id: file._id, + ...(file._source as any)?.file, + }; + } catch (err) { + if (err.statusCode === 404) { + appContextService.getLogger().debug(err); + return; + } else { + throw err; + } + } + }; + + const actions = await _getRequestDiagnosticsActions(esClient, agentId); + + const results = []; + for (const action of actions) { + const file = await getFile(action.fileId); + const fileName = file?.name ?? `${moment(action.timestamp!).format('YYYY-MM-DD HH:mm:ss')}.zip`; + const filePath = file ? agentRouteService.getAgentFileDownloadLink(file.id, file.name) : ''; + const result = { + actionId: action.actionId, + id: file?.id ?? action.actionId, + status: file?.Status ?? 'IN_PROGRESS', + name: fileName, + createTime: action.timestamp!, + filePath, + }; + results.push(result); + } + + return results; +} + +async function _getRequestDiagnosticsActions( + esClient: ElasticsearchClient, + agentId: string +): Promise> { + const agentActionRes = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, + size: SO_SEARCH_LIMIT, + query: { + bool: { + must: [ + { + term: { + type: 'REQUEST_DIAGNOSTICS', + }, + }, + { + term: { + agents: agentId, + }, + }, + ], + }, + }, + }); + + const agentActionIds = agentActionRes.hits.hits.map((hit) => hit._source?.action_id as string); + + if (agentActionIds.length === 0) { + return []; + } + + try { + const actionResults = await esClient.search({ + index: AGENT_ACTIONS_RESULTS_INDEX, + ignore_unavailable: true, + size: SO_SEARCH_LIMIT, + query: { + bool: { + must: [ + { + terms: { + action_id: agentActionIds, + }, + }, + { + term: { + agent_id: agentId, + }, + }, + ], + }, + }, + }); + return actionResults.hits.hits.map((hit) => ({ + actionId: hit._source?.action_id as string, + timestamp: hit._source?.['@timestamp'], + fileId: hit._source?.data?.file_id as string, + })); + } catch (err) { + if (err.statusCode === 404) { + // .fleet-actions-results does not yet exist + appContextService.getLogger().debug(err); + return []; + } else { + throw err; + } + } +} + +export async function getAgentUploadFile( + esClient: ElasticsearchClient, + id: string, + fileName: string +): Promise<{ body: Readable; headers: ResponseHeaders }> { + try { + const fileClient = createEsFileClient({ + blobStorageIndex: FILE_STORAGE_DATA_AGENT_INDEX, + metadataIndex: FILE_STORAGE_METADATA_AGENT_INDEX, + elasticsearchClient: esClient, + logger: appContextService.getLogger(), + }); + + const file = await fileClient.get({ + id, + }); + + return { + body: await file.downloadContent(), + headers: getDownloadHeadersForFile(fileName), + }; + } catch (error) { + appContextService.getLogger().error(error); + throw error; + } +} + +export function getDownloadHeadersForFile(fileName: string): ResponseHeaders { + return { + 'content-type': 'application/octet-stream', + // Note, this name can be overridden by the client if set via a "download" attribute on the HTML tag. + 'content-disposition': `attachment; filename="${fileName}"`, + 'cache-control': 'max-age=31536000, immutable', + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + 'x-content-type-options': 'nosniff', + }; +} diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index d87031cdad9d6..709e8e8d27159 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -113,6 +113,32 @@ export const PutAgentReassignRequestSchema = { }), }; +export const PostRequestDiagnosticsActionRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), +}; + +export const PostBulkRequestDiagnosticsActionRequestSchema = { + body: schema.object({ + agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + batchSize: schema.maybe(schema.number()), + }), +}; + +export const ListAgentUploadsRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), +}; + +export const GetAgentUploadFileRequestSchema = { + params: schema.object({ + fileId: schema.string(), + fileName: schema.string(), + }), +}; + export const PostBulkAgentReassignRequestSchema = { body: schema.object({ policy_id: schema.string(), diff --git a/x-pack/test/fleet_api_integration/apis/agents/index.js b/x-pack/test/fleet_api_integration/apis/agents/index.js index 0ce4413f7c2cf..783775d53f265 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/index.js +++ b/x-pack/test/fleet_api_integration/apis/agents/index.js @@ -18,5 +18,7 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_agent_tags')); loadTestFile(require.resolve('./available_versions')); + loadTestFile(require.resolve('./request_diagnostics')); + loadTestFile(require.resolve('./uploads')); }); } diff --git a/x-pack/test/fleet_api_integration/apis/agents/request_diagnostics.ts b/x-pack/test/fleet_api_integration/apis/agents/request_diagnostics.ts new file mode 100644 index 0000000000000..7f5ad510723d3 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/agents/request_diagnostics.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from './services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_request_diagnostics', () => { + skipIfNoDockerRegistry(providerContext); + setupFleetAndAgents(providerContext); + beforeEach(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents'); + await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send(); + }); + afterEach(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); + await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + }); + + async function verifyActionResult(agentCount: number) { + const { body } = await supertest + .get(`/api/fleet/agents/action_status`) + .set('kbn-xsrf', 'xxx'); + const actionStatus = body.items[0]; + + expect(actionStatus.nbAgentsActionCreated).to.eql(agentCount); + } + + it('/agents/{agent_id}/request_diagnostics should work', async () => { + await supertest + .post(`/api/fleet/agents/agent1/request_diagnostics`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + verifyActionResult(1); + }); + + it('/agents/bulk_request_diagnostics should work for multiple agents by id', async () => { + await supertest + .post(`/api/fleet/agents/bulk_request_diagnostics`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent2', 'agent3'], + }); + + verifyActionResult(2); + }); + + it('/agents/bulk_request_diagnostics should work for multiple agents by kuery', async () => { + await supertest + .post(`/api/fleet/agents/bulk_request_diagnostics`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: '', + }) + .expect(200); + + verifyActionResult(4); + }); + + it('/agents/bulk_request_diagnostics should work for multiple agents by kuery in batches async', async () => { + const { body } = await supertest + .post(`/api/fleet/agents/bulk_request_diagnostics`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: '', + batchSize: 2, + }) + .expect(200); + + const actionId = body.actionId; + + await new Promise((resolve, reject) => { + let attempts = 0; + const intervalId = setInterval(async () => { + if (attempts > 2) { + clearInterval(intervalId); + reject('action timed out'); + } + ++attempts; + const { + body: { items: actionStatuses }, + } = await supertest.get(`/api/fleet/agents/action_status`).set('kbn-xsrf', 'xxx'); + + const action = actionStatuses?.find((a: any) => a.actionId === actionId); + if (action && action.nbAgentsActioned === action.nbAgentsActionCreated) { + clearInterval(intervalId); + resolve({}); + } + }, 1000); + }).catch((e) => { + throw e; + }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/agents/uploads.ts b/x-pack/test/fleet_api_integration/apis/agents/uploads.ts new file mode 100644 index 0000000000000..fc56b4fc46c18 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/agents/uploads.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from './services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + const esClient = getService('es'); + + const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; + + describe('fleet_uploads', () => { + skipIfNoDockerRegistry(providerContext); + setupFleetAndAgents(providerContext); + + before(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + await getService('supertest').post(`/api/fleet/setup`).set('kbn-xsrf', 'xxx').send(); + + await esClient.create({ + index: AGENT_ACTIONS_INDEX, + id: new Date().toISOString(), + refresh: true, + body: { + type: 'REQUEST_DIAGNOSTICS', + action_id: 'action1', + agents: ['agent1'], + '@timestamp': '2022-10-07T11:00:00.000Z', + }, + }); + + await esClient.create( + { + index: AGENT_ACTIONS_RESULTS_INDEX, + id: new Date().toISOString(), + refresh: true, + body: { + action_id: 'action1', + agent_id: 'agent1', + '@timestamp': '2022-10-07T12:00:00.000Z', + data: { + file_id: 'file1', + }, + }, + }, + ES_INDEX_OPTIONS + ); + + await esClient.update({ + index: '.fleet-agent-files', + id: 'file1', + refresh: true, + body: { + doc_as_upsert: true, + doc: { + file: { + ChunkSize: 4194304, + extension: 'zip', + hash: {}, + mime_type: 'application/zip', + mode: '0644', + name: 'elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip', + path: '/agent/elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip', + size: 24917, + Status: 'READY', + type: 'file', + }, + }, + }, + }); + }); + after(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + }); + + it('should get agent uploads', async () => { + const { body } = await supertest + .get(`/api/fleet/agents/agent1/uploads`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(body.items[0]).to.eql({ + actionId: 'action1', + createTime: '2022-10-07T12:00:00.000Z', + filePath: + '/api/fleet/agents/files/file1/elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip', + id: 'file1', + name: 'elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip', + status: 'READY', + }); + }); + + it('should get agent uploaded file', async () => { + await esClient.update({ + index: '.fleet-agent-file-data', + id: 'file1.0', + refresh: true, + body: { + doc_as_upsert: true, + doc: { + last: true, + bid: 'file1', + data: 'test', + }, + }, + }); + + const { header } = await supertest + .get(`/api/fleet/agents/files/file1/elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(header['content-type']).to.eql('application/octet-stream'); + expect(header['content-disposition']).to.eql( + 'attachment; filename="elastic-agent-diagnostics-2022-10-07T12-00-00Z-00.zip"' + ); + }); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 518d36bbdc9a5..1bdfda2f63863 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -114,6 +114,7 @@ export default function ({ getService }: FtrProviderContext) { 'endpoint:user-artifact-packager', 'fleet:check-deleted-files-task', 'fleet:reassign_action:retry', + 'fleet:request_diagnostics:retry', 'fleet:unenroll_action:retry', 'fleet:update_agent_tags:retry', 'fleet:upgrade_action:retry',