From fcdab3563a4a6b886f88cd8c0b9e1d8e083e5843 Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Mon, 5 Oct 2020 11:33:41 -0400 Subject: [PATCH] [Ingest Manager] Upgrade Agents in Fleet (#78810) * add kibanaVersion context and hook, add upgrade available indications * add agent upgrade modals and action buttons * fix import * add bulk actions api and remove source_uri as required * add upgrading to AgentHealth status * buildKueryForUpgradingAgents * bulk actions UI * remove source_uri * add release type to agent details * don't allow upgrade of unenrolled/unenrolling agent * hide upgradeable button when not upgradeable * fix test * add udpating agent status * remove upgrade available filter button for now * update isUpgradeAvailable to use local_metadata upgradeable * add UPDATING to agent event subtype * use saved object for updating agent status * add updating badge type label * add upgrade available button and update agent list endpoint to accept showUpgradeable * add schema and type for UPDATING * fix type * dont try to upgrade local_metadata * exclude from AAD upgrade_started_at and upgraded_at Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../ingest_manager/common/constants/routes.ts | 1 + .../common/services/agent_status.ts | 10 +- .../ingest_manager/common/services/index.ts | 1 + .../services/is_agent_upgradeable.test.ts | 100 ++++++++++++ .../common/services/is_agent_upgradeable.ts | 21 +++ .../ingest_manager/common/services/routes.ts | 3 + .../common/types/models/agent.ts | 9 +- .../common/types/rest_spec/agent.ts | 25 ++- .../ingest_manager/hooks/index.ts | 1 + .../hooks/use_kibana_version.ts | 17 ++ .../hooks/use_request/agents.ts | 29 ++++ .../applications/ingest_manager/index.tsx | 15 +- .../components/actions_menu.tsx | 36 ++++- .../components/agent_details.tsx | 42 ++++- .../components/type_labels.tsx | 8 + .../fleet/agent_details_page/index.tsx | 43 +++++ .../components/bulk_actions.tsx | 38 ++++- .../sections/fleet/agent_list_page/index.tsx | 84 +++++++++- .../fleet/components/agent_health.tsx | 10 ++ .../components/agent_upgrade_modal/index.tsx | 126 +++++++++++++++ .../sections/fleet/components/index.tsx | 1 + .../ingest_manager/services/index.ts | 1 + .../ingest_manager/types/index.ts | 4 + .../plugins/ingest_manager/public/plugin.ts | 5 +- .../server/routes/agent/handlers.ts | 1 + .../server/routes/agent/index.ts | 12 +- .../server/routes/agent/upgrade_handler.ts | 62 +++++++- .../server/saved_objects/index.ts | 2 + .../server/services/agents/crud.ts | 13 +- .../server/services/agents/upgrade.ts | 69 ++++++-- .../server/types/models/agent.ts | 1 + .../server/types/rest_spec/agent.ts | 16 +- .../server/types/rest_spec/common.ts | 1 + .../apis/fleet/agents/acks.ts | 2 +- .../apis/fleet/agents/upgrade.ts | 149 ++++++++++++++++-- 35 files changed, 901 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 69672dfb9ec6cc..2b1d24f14874f9 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -91,6 +91,7 @@ export const AGENT_API_ROUTES = { BULK_REASSIGN_PATTERN: `${FLEET_API_ROOT}/agents/bulk_reassign`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, UPGRADE_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/upgrade`, + BULK_UPGRADE_PATTERN: `${FLEET_API_ROOT}/agents/bulk_upgrade`, }; export const ENROLLMENT_API_KEY_ROUTES = { diff --git a/x-pack/plugins/ingest_manager/common/services/agent_status.ts b/x-pack/plugins/ingest_manager/common/services/agent_status.ts index 70f4d7f9344f97..cd990d70c36121 100644 --- a/x-pack/plugins/ingest_manager/common/services/agent_status.ts +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -19,9 +19,6 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (!agent.last_checkin) { return 'enrolling'; } - if (agent.upgrade_started_at && !agent.upgraded_at) { - return 'upgrading'; - } const msLastCheckIn = new Date(lastCheckIn || 0).getTime(); const msSinceLastCheckIn = new Date().getTime() - msLastCheckIn; @@ -33,6 +30,9 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (agent.last_checkin_status === 'degraded') { return 'degraded'; } + if (agent.upgrade_started_at && !agent.upgraded_at) { + return 'updating'; + } if (intervalsSinceLastCheckIn >= 4) { return 'offline'; } @@ -61,3 +61,7 @@ export function buildKueryForOfflineAgents() { (4 * AGENT_POLLING_THRESHOLD_MS) / 1000 }s AND not (${buildKueryForErrorAgents()})`; } + +export function buildKueryForUpdatingAgents() { + return `${AGENT_SAVED_OBJECT_TYPE}.upgrade_started_at:*`; +} diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index 4bffa01ad5ee25..19285e921e9318 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -13,3 +13,4 @@ export { decodeCloudId } from './decode_cloud_id'; export { isValidNamespace } from './is_valid_namespace'; export { isDiffPathProtocol } from './is_diff_path_protocol'; export { LicenseService } from './license'; +export { isAgentUpgradeable } from './is_agent_upgradeable'; diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts new file mode 100644 index 00000000000000..cb087a3b8f805b --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isAgentUpgradeable } from './is_agent_upgradeable'; +import { Agent } from '../types/models/agent'; + +const getAgent = (version: string, upgradeable: boolean): Agent => { + const agent: Agent = { + id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', + active: true, + policy_id: '63a284b0-0334-11eb-a4e0-09883c57114b', + type: 'PERMANENT', + enrolled_at: '2020-09-30T20:24:08.347Z', + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', + version, + snapshot: false, + 'build.original': + '8.0.0 (build: e2ef4fc375a5ece83d5d38f57b2977d7866b5819 at 2020-09-30 20:21:35 +0000 UTC)', + }, + }, + host: { + architecture: 'x86_64', + hostname: 'Sandras-MBP.fios-router.home', + name: 'Sandras-MBP.fios-router.home', + id: '1112D0AD-526D-5268-8E86-765D35A0F484', + ip: [ + '127.0.0.1/8', + '::1/128', + 'fe80::1/64', + 'fe80::aede:48ff:fe00:1122/64', + 'fe80::4fc:2526:7d51:19cc/64', + '192.168.1.161/24', + 'fe80::3083:5ff:fe30:4b00/64', + 'fe80::3083:5ff:fe30:4b00/64', + 'fe80::f7fb:518e:2c3c:7815/64', + 'fe80::2abd:20e3:9bc3:c054/64', + 'fe80::531a:20ab:1f38:7f9/64', + ], + mac: [ + 'a6:83:e7:b0:1a:d2', + 'ac:de:48:00:11:22', + 'a4:83:e7:b0:1a:d2', + '82:c5:c2:25:b0:01', + '82:c5:c2:25:b0:00', + '82:c5:c2:25:b0:05', + '82:c5:c2:25:b0:04', + '82:c5:c2:25:b0:01', + '06:83:e7:b0:1a:d2', + '32:83:05:30:4b:00', + '32:83:05:30:4b:00', + ], + }, + os: { + family: 'darwin', + kernel: '19.4.0', + platform: 'darwin', + version: '10.15.4', + name: 'Mac OS X', + full: 'Mac OS X(10.15.4)', + }, + }, + access_api_key_id: 'A_6v4HQBEEDXi-A9vxPE', + default_api_key_id: 'BP6v4HQBEEDXi-A95xMk', + policy_revision: 1, + packages: ['system'], + last_checkin: '2020-10-01T14:43:27.255Z', + current_error_events: [], + status: 'online', + }; + if (upgradeable) { + agent.local_metadata.elastic.agent.upgradeable = true; + } + return agent; +}; +describe('Ingest Manager - isAgentUpgradeable', () => { + it('returns false if agent reports not upgradeable with agent version < kibana version', () => { + expect(isAgentUpgradeable(getAgent('7.9.0', false), '8.0.0')).toBe(false); + }); + it('returns false if agent reports not upgradeable with agent version > kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', false), '7.9.0')).toBe(false); + }); + it('returns false if agent reports not upgradeable with agent version === kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', false), '8.0.0')).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version === kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', true), '8.0.0')).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version > kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', true), '7.9.0')).toBe(false); + }); + it('returns true if agent reports upgradeable, with agent version < kibana version', () => { + expect(isAgentUpgradeable(getAgent('7.9.0', true), '8.0.0')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts new file mode 100644 index 00000000000000..5f96e108e61844 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import semver from 'semver'; +import { Agent } from '../types'; + +export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) { + let agentVersion: string; + if (typeof agent?.local_metadata?.elastic?.agent?.version === 'string') { + agentVersion = agent.local_metadata.elastic.agent.version; + } else { + return false; + } + const kibanaVersionParsed = semver.parse(kibanaVersion); + const agentVersionParsed = semver.parse(agentVersion); + if (!agentVersionParsed || !kibanaVersionParsed) return false; + if (!agent.local_metadata.elastic.agent.upgradeable) return false; + return semver.lt(agentVersionParsed, kibanaVersionParsed); +} diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 3c3534926908ae..c709794f2ce551 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -135,6 +135,9 @@ export const agentRouteService = { getReassignPath: (agentId: string) => AGENT_API_ROUTES.REASSIGN_PATTERN.replace('{agentId}', agentId), getBulkReassignPath: () => AGENT_API_ROUTES.BULK_REASSIGN_PATTERN, + getUpgradePath: (agentId: string) => + AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), + getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, }; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 6ac783820ce829..215764939d3d11 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -19,7 +19,7 @@ export type AgentStatus = | 'warning' | 'enrolling' | 'unenrolling' - | 'upgrading' + | 'updating' | 'degraded'; export type AgentActionType = 'POLICY_CHANGE' | 'UNENROLL' | 'UPGRADE'; @@ -89,6 +89,7 @@ export interface NewAgentEvent { | 'STOPPING' | 'STOPPED' | 'DEGRADED' + | 'UPDATING' // Action results | 'DATA_DUMP' // Actions @@ -109,10 +110,8 @@ export interface AgentEvent extends NewAgentEvent { export type AgentEventSOAttributes = NewAgentEvent; -type MetadataValue = string | AgentMetadata; - export interface AgentMetadata { - [x: string]: MetadataValue; + [x: string]: any; } interface AgentBase { type: AgentType; @@ -129,7 +128,7 @@ interface AgentBase { policy_id?: string; policy_revision?: number | null; last_checkin?: string; - last_checkin_status?: 'error' | 'online' | 'degraded'; + last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating'; user_provided_metadata: AgentMetadata; local_metadata: AgentMetadata; } diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index ab4c372c4e1d61..da7d126c4ecd3f 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -20,6 +20,7 @@ export interface GetAgentsRequest { perPage: number; kuery?: string; showInactive: boolean; + showUpgradeable?: boolean; }; } @@ -113,22 +114,38 @@ export interface PostAgentUnenrollRequest { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUnenrollResponse {} +export interface PostBulkAgentUnenrollRequest { + body: { + agents: string[] | string; + force?: boolean; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PostBulkAgentUnenrollResponse {} + export interface PostAgentUpgradeRequest { params: { agentId: string; }; + body: { + source_uri?: string; + version: string; + }; } -export interface PostBulkAgentUnenrollRequest { + +export interface PostBulkAgentUpgradeRequest { body: { agents: string[] | string; - force?: boolean; + source_uri?: string; + version: string; }; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PostBulkAgentUpgradeResponse {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUpgradeResponse {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUnenrollResponse {} export interface PutAgentReassignRequest { params: { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts index 64434e163f043b..29843f6a3e5b1d 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts @@ -7,6 +7,7 @@ export { useCapabilities } from './use_capabilities'; export { useCore } from './use_core'; export { useConfig, ConfigContext } from './use_config'; +export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; export { useSetupDeps, useStartDeps, DepsContext } from './use_deps'; export { licenseService, useLicense } from './use_license'; export { useBreadcrumbs } from './use_breadcrumbs'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts new file mode 100644 index 00000000000000..a5113ca10d439c --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +export const KibanaVersionContext = React.createContext(null); + +export function useKibanaVersion() { + const version = useContext(KibanaVersionContext); + if (version === null) { + throw new Error('KibanaVersionContext is not initialized'); + } + return version; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts index 41967fd068e0b5..564e7b225cf455 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts @@ -22,6 +22,10 @@ import { GetAgentsResponse, GetAgentStatusRequest, GetAgentStatusResponse, + PostAgentUpgradeRequest, + PostBulkAgentUpgradeRequest, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, } from '../../types'; type RequestOptions = Pick, 'pollIntervalMs'>; @@ -126,3 +130,28 @@ export function sendPostBulkAgentUnenroll( ...options, }); } + +export function sendPostAgentUpgrade( + agentId: string, + body: PostAgentUpgradeRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getUpgradePath(agentId), + method: 'post', + body, + ...options, + }); +} + +export function sendPostBulkAgentUpgrade( + body: PostBulkAgentUpgradeRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getBulkUpgradePath(), + method: 'post', + body, + ...options, + }); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 0bef3c20ddd1a7..c61a290cf2470e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -30,6 +30,7 @@ import { sendSetup, sendGetPermissionsCheck, licenseService, + KibanaVersionContext, } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { FleetStatusProvider } from './hooks/use_fleet_status'; @@ -235,6 +236,7 @@ const IngestManagerApp = ({ startDeps, config, history, + kibanaVersion, }: { basepath: string; coreStart: CoreStart; @@ -242,6 +244,7 @@ const IngestManagerApp = ({ startDeps: IngestManagerStartDeps; config: IngestManagerConfigType; history: AppMountParameters['history']; + kibanaVersion: string; }) => { const isDarkMode = useObservable(coreStart.uiSettings.get$('theme:darkMode')); return ( @@ -249,9 +252,11 @@ const IngestManagerApp = ({ - - - + + + + + @@ -264,7 +269,8 @@ export function renderApp( { element, appBasePath, history }: AppMountParameters, setupDeps: IngestManagerSetupDeps, startDeps: IngestManagerStartDeps, - config: IngestManagerConfigType + config: IngestManagerConfigType, + kibanaVersion: string ) { ReactDOM.render( , element ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx index ea5dcce8c05bbc..9ed464401fdc68 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx @@ -7,10 +7,15 @@ import React, { memo, useState, useMemo } from 'react'; import { EuiPortal, EuiContextMenuItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; -import { useCapabilities } from '../../../../hooks'; +import { useCapabilities, useKibanaVersion } from '../../../../hooks'; import { ContextMenuActions } from '../../../../components'; -import { AgentUnenrollAgentModal, AgentReassignAgentPolicyFlyout } from '../../components'; +import { + AgentUnenrollAgentModal, + AgentReassignAgentPolicyFlyout, + AgentUpgradeAgentModal, +} from '../../components'; import { useAgentRefresh } from '../hooks'; +import { isAgentUpgradeable } from '../../../../services'; export const AgentDetailsActionMenu: React.FunctionComponent<{ agent: Agent; @@ -18,9 +23,11 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ onCancelReassign?: () => void; }> = memo(({ agent, assignFlyoutOpenByDefault = false, onCancelReassign }) => { const hasWriteCapabilites = useCapabilities().write; + const kibanaVersion = useKibanaVersion(); const refreshAgent = useAgentRefresh(); const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(assignFlyoutOpenByDefault); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); const isUnenrolling = agent.status === 'unenrolling'; const onClose = useMemo(() => { @@ -51,6 +58,19 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ /> )} + {isUpgradeModalOpen && ( + + { + setIsUpgradeModalOpen(false); + refreshAgent(); + }} + /> + + )} )} , + { + setIsUpgradeModalOpen(true); + }} + > + + , ]} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx index 68abb43abac184..2493fda3317d20 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -13,15 +13,20 @@ import { EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Agent, AgentPolicy } from '../../../../types'; -import { useLink } from '../../../../hooks'; +import { useKibanaVersion, useLink } from '../../../../hooks'; import { AgentHealth } from '../../components'; +import { isAgentUpgradeable } from '../../../../services'; export const AgentDetailsContent: React.FunctionComponent<{ agent: Agent; agentPolicy?: AgentPolicy; }> = memo(({ agent, agentPolicy }) => { const { getHref } = useLink(); + const kibanaVersion = useKibanaVersion(); return ( {[ @@ -69,8 +74,39 @@ export const AgentDetailsContent: React.FunctionComponent<{ description: typeof agent.local_metadata.elastic === 'object' && typeof agent.local_metadata.elastic.agent === 'object' && - typeof agent.local_metadata.elastic.agent.version === 'string' - ? agent.local_metadata.elastic.agent.version + typeof agent.local_metadata.elastic.agent.version === 'string' ? ( + + + {agent.local_metadata.elastic.agent.version} + + {isAgentUpgradeable(agent, kibanaVersion) ? ( + + + +   + + + + ) : null} + + ) : ( + '-' + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.releaseLabel', { + defaultMessage: 'Agent release', + }), + description: + typeof agent.local_metadata.elastic === 'object' && + typeof agent.local_metadata.elastic.agent === 'object' && + typeof agent.local_metadata.elastic.agent.snapshot === 'boolean' + ? agent.local_metadata.elastic.agent.snapshot === true + ? 'snapshot' + : 'stable' : '-', }, { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx index 56af9519bc1dac..f597b9c72ab028 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx @@ -119,6 +119,14 @@ export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { /> ), + UPDATING: ( + + + + ), UNKNOWN: ( { params: { agentId, tabId = '' }, } = useRouteMatch<{ agentId: string; tabId?: string }>(); const { getHref } = useLink(); + const kibanaVersion = useKibanaVersion(); const { isLoading, isInitialRequest, @@ -144,6 +148,45 @@ export const AgentDetailsPage: React.FunctionComponent = () => { ), }, { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.agentDetails.agentVersionLabel', { + defaultMessage: 'Agent version', + }), + content: + typeof agentData.item.local_metadata.elastic === 'object' && + typeof agentData.item.local_metadata.elastic.agent === 'object' && + typeof agentData.item.local_metadata.elastic.agent.version === 'string' ? ( + + + {agentData.item.local_metadata.elastic.agent.version} + + {isAgentUpgradeable(agentData.item, kibanaVersion) ? ( + + + + ) : null} + + ) : ( + '-' + ), + }, + { isDivider: true }, { content: ( { + const kibanaVersion = useKibanaVersion(); // Bulk actions menu states const [isMenuOpen, setIsMenuOpen] = useState(false); const closeMenu = () => setIsMenuOpen(false); @@ -67,6 +73,7 @@ export const AgentBulkActions: React.FunctionComponent<{ // Actions states const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); // Check if user is working with only inactive agents const atLeastOneActiveAgentSelected = @@ -106,6 +113,20 @@ export const AgentBulkActions: React.FunctionComponent<{ setIsUnenrollModalOpen(true); }, }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setIsUpgradeModalOpen(true); + }, + }, { name: ( )} + {isUpgradeModalOpen && ( + + { + setIsUpgradeModalOpen(false); + refreshAgents(); + }} + /> + + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 0bc463ce985905..83cbb9ccb728c0 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -35,14 +35,16 @@ import { useLink, useBreadcrumbs, useLicense, + useKibanaVersion, } from '../../../hooks'; import { SearchBar, ContextMenuActions } from '../../../components'; -import { AgentStatusKueryHelper } from '../../../services'; +import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { AgentReassignAgentPolicyFlyout, AgentHealth, AgentUnenrollAgentModal, + AgentUpgradeAgentModal, } from '../components'; import { AgentBulkActions, SelectionMode } from './components/bulk_actions'; @@ -68,6 +70,12 @@ const statusFilters = [ defaultMessage: 'Error', }), }, + { + status: 'updating', + label: i18n.translate('xpack.ingestManager.agentList.statusUpdatingFilterText', { + defaultMessage: 'Updating', + }), + }, ] as Array<{ label: string; status: string }>; const RowActions = React.memo<{ @@ -75,11 +83,13 @@ const RowActions = React.memo<{ refresh: () => void; onReassignClick: () => void; onUnenrollClick: () => void; -}>(({ agent, refresh, onReassignClick, onUnenrollClick }) => { + onUpgradeClick: () => void; +}>(({ agent, refresh, onReassignClick, onUnenrollClick, onUpgradeClick }) => { const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; const isUnenrolling = agent.status === 'unenrolling'; + const kibanaVersion = useKibanaVersion(); const [isMenuOpen, setIsMenuOpen] = useState(false); return ( )} , + { + onUpgradeClick(); + }} + > + + , ]} /> ); @@ -146,10 +168,11 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; const hasWriteCapabilites = useCapabilities().write; const isGoldPlus = useLicense().isGoldPlus(); + const kibanaVersion = useKibanaVersion(); // Agent data states const [showInactive, setShowInactive] = useState(false); - + const [showUpgradeable, setShowUpgradeable] = useState(false); // Table and search states const [search, setSearch] = useState(defaultKuery); const [selectionMode, setSelectionMode] = useState('manual'); @@ -189,6 +212,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Agent actions states const [agentToReassign, setAgentToReassign] = useState(undefined); const [agentToUnenroll, setAgentToUnenroll] = useState(undefined); + const [agentToUpgrade, setAgentToUpgrade] = useState(undefined); let kuery = search.trim(); if (selectedAgentPolicies.length) { @@ -199,7 +223,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { .map((agentPolicy) => `"${agentPolicy}"`) .join(' or ')})`; } - if (selectedStatus.length) { const kueryStatus = selectedStatus .map((status) => { @@ -208,6 +231,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return AgentStatusKueryHelper.buildKueryForOnlineAgents(); case 'offline': return AgentStatusKueryHelper.buildKueryForOfflineAgents(); + case 'updating': + return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); case 'error': return AgentStatusKueryHelper.buildKueryForErrorAgents(); } @@ -229,6 +254,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { perPage: pagination.pageSize, kuery: kuery && kuery !== '' ? kuery : undefined, showInactive, + showUpgradeable, }, { pollIntervalMs: REFRESH_INTERVAL_MS, @@ -329,11 +355,29 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '100px', + width: '200px', name: i18n.translate('xpack.ingestManager.agentList.versionTitle', { defaultMessage: 'Version', }), - render: (version: string, agent: Agent) => safeMetadata(version), + render: (version: string, agent: Agent) => ( + + + {safeMetadata(version)} + + {isAgentUpgradeable(agent, kibanaVersion) ? ( + + + +   + + + + ) : null} + + ), }, { field: 'last_checkin', @@ -356,6 +400,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { refresh={() => agentsRequest.resendRequest()} onReassignClick={() => setAgentToReassign(agent)} onUnenrollClick={() => setAgentToUnenroll(agent)} + onUpgradeClick={() => setAgentToUpgrade(agent)} /> ); }, @@ -421,6 +466,20 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} + {agentToUpgrade && ( + + { + setAgentToUpgrade(undefined); + agentsRequest.resendRequest(); + }} + version={kibanaVersion} + /> + + )} + {/* Search and filter bar */} @@ -519,13 +578,24 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { ))} + { + setShowUpgradeable(!showUpgradeable); + }} + > + + setShowInactive(!showInactive)} > diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx index 7c6c95cab420fd..a16d4e7347ad1a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx @@ -77,6 +77,14 @@ const Status = { /> ), + Upgrading: ( + + + + ), }; function getStatusComponent(agent: Agent): React.ReactElement { @@ -95,6 +103,8 @@ function getStatusComponent(agent: Agent): React.ReactElement { return Status.Unenrolling; case 'enrolling': return Status.Enrolling; + case 'updating': + return Status.Upgrading; default: return Status.Online; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx new file mode 100644 index 00000000000000..a59f503d2994b7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Agent } from '../../../../types'; +import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useCore } from '../../../../hooks'; + +interface Props { + onClose: () => void; + agents: Agent[] | string; + agentCount: number; + version: string; +} + +export const AgentUpgradeAgentModal: React.FunctionComponent = ({ + onClose, + agents, + agentCount, + version, +}) => { + const { notifications } = useCore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + async function onSubmit() { + try { + setIsSubmitting(true); + const { error } = isSingleAgent + ? await sendPostAgentUpgrade((agents[0] as Agent).id, { + version, + }) + : await sendPostBulkAgentUpgrade({ + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + version, + }); + if (error) { + throw error; + } + setIsSubmitting(false); + const successMessage = isSingleAgent + ? i18n.translate('xpack.ingestManager.upgradeAgents.successSingleNotificationTitle', { + defaultMessage: 'Upgrading agent', + }) + : i18n.translate('xpack.ingestManager.upgradeAgents.successMultiNotificationTitle', { + defaultMessage: 'Upgrading agents', + }); + notifications.toasts.addSuccess(successMessage); + onClose(); + } catch (error) { + setIsSubmitting(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.ingestManager.upgradeAgents.fatalErrorNotificationTitle', { + defaultMessage: 'Error upgrading {count, plural, one {agent} other {agents}}', + values: { count: agentCount }, + }), + }); + } + } + + return ( + + + ) : ( + + ) + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( + + ) : ( + + ) + } + > +

+ {isSingleAgent ? ( + + ) : ( + + )} +

+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx index eea4ed3b712b1c..3dd04b4f5b0b76 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx @@ -9,3 +9,4 @@ export * from './agent_reassign_policy_flyout'; export * from './agent_enrollment_flyout'; export * from './agent_health'; export * from './agent_unenroll_modal'; +export * from './agent_upgrade_modal'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts index ed6ba5c891a0b6..ee976d40402ccc 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts @@ -26,4 +26,5 @@ export { doesAgentPolicyAlreadyIncludePackage, isValidNamespace, LicenseService, + isAgentUpgradeable, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index e825448f359d63..386ffa5649cc23 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -53,6 +53,10 @@ export { PostAgentUnenrollResponse, PostBulkAgentUnenrollRequest, PostBulkAgentUnenrollResponse, + PostAgentUpgradeRequest, + PostBulkAgentUpgradeRequest, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, GetOneAgentEventsRequest, GetOneAgentEventsResponse, GetAgentStatusRequest, diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index 59741ce79dd8bd..cb1d59b698f0a4 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -60,13 +60,16 @@ export class IngestManagerPlugin implements Plugin { private config: IngestManagerConfigType; + private kibanaVersion: string; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.kibanaVersion = initializerContext.env.packageInfo.version; } public setup(core: CoreSetup, deps: IngestManagerSetupDeps) { const config = this.config; + const kibanaVersion = this.kibanaVersion; // Set up http client setHttpClient(core.http); @@ -88,7 +91,7 @@ export class IngestManagerPlugin IngestManagerStart ]; const { renderApp, teardownIngestManager } = await import('./applications/ingest_manager'); - const unmount = renderApp(coreStart, params, deps, startDeps, config); + const unmount = renderApp(coreStart, params, deps, startDeps, config, kibanaVersion); return () => { unmount(); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index fb867af513fdc4..5e075cbbcdf5ee 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -239,6 +239,7 @@ export const getAgentsHandler: RequestHandler< page: request.query.page, perPage: request.query.perPage, showInactive: request.query.showInactive, + showUpgradeable: request.query.showUpgradeable, kuery: request.query.kuery, }); const totalInactive = request.query.showInactive diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 73ed276ba02e74..12116d3f037e00 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -30,6 +30,7 @@ import { PostBulkAgentReassignRequestSchema, PostAgentEnrollRequestBodyJSONSchema, PostAgentUpgradeRequestSchema, + PostBulkAgentUpgradeRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -49,7 +50,7 @@ import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { appContextService } from '../../services'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; import { IngestManagerConfigType } from '../..'; -import { postAgentUpgradeHandler } from './upgrade_handler'; +import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; const ajv = new Ajv({ coerceTypes: true, @@ -226,6 +227,15 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) }, postAgentUpgradeHandler ); + // bulk upgrade + router.post( + { + path: AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + validate: PostBulkAgentUpgradeRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postBulkAgentsUpgradeHandler + ); // Bulk reassign router.post( { diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts index e5d7a44c007682..c4aa33999cf221 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts @@ -6,11 +6,16 @@ import { RequestHandler } from 'src/core/server'; import { TypeOf } from '@kbn/config-schema'; -import { PostAgentUpgradeResponse } from '../../../common/types'; -import { PostAgentUpgradeRequestSchema } from '../../types'; +import { + AgentSOAttributes, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, +} from '../../../common/types'; +import { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; export const postAgentUpgradeHandler: RequestHandler< TypeOf, @@ -30,6 +35,18 @@ export const postAgentUpgradeHandler: RequestHandler< }, }); } + const agent = await soClient.get( + AGENT_SAVED_OBJECT_TYPE, + request.params.agentId + ); + if (agent.attributes.unenrollment_started_at || agent.attributes.unenrolled_at) { + return response.customError({ + statusCode: 400, + body: { + message: `cannot upgrade an unenrolling or unenrolled agent`, + }, + }); + } try { await AgentService.sendUpgradeAgentAction({ @@ -45,3 +62,44 @@ export const postAgentUpgradeHandler: RequestHandler< return defaultIngestErrorHandler({ error, response }); } }; + +export const postBulkAgentsUpgradeHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const { version, source_uri: sourceUri, agents } = request.body; + + // temporarily only allow upgrading to the same version as the installed kibana version + const kibanaVersion = appContextService.getKibanaVersion(); + if (kibanaVersion !== version) { + return response.customError({ + statusCode: 400, + body: { + message: `cannot upgrade agent to ${version} because it is different than the installed kibana version ${kibanaVersion}`, + }, + }); + } + + try { + if (Array.isArray(agents)) { + await AgentService.sendUpgradeAgentsActions(soClient, { + agentIds: agents, + sourceUri, + version, + }); + } else { + await AgentService.sendUpgradeAgentsActions(soClient, { + kuery: agents, + sourceUri, + version, + }); + } + + const body: PostBulkAgentUpgradeResponse = {}; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 95433f896b9a35..34dee7f47399ed 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -370,6 +370,8 @@ export function registerEncryptedSavedObjects( 'unenrolled_at', 'unenrollment_started_at', 'packages', + 'upgraded_at', + 'upgrade_started_at', ]), }); encryptedSavedObjects.registerType({ diff --git a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts index c941b0512e5973..90db6c4b177136 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts @@ -5,10 +5,12 @@ */ import Boom from 'boom'; import { SavedObjectsClientContract } from 'src/core/server'; +import { isAgentUpgradeable } from '../../../common'; import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants'; import { AgentSOAttributes, Agent, AgentEventSOAttributes, ListWithKuery } from '../../types'; import { escapeSearchQueryPhrase, normalizeKuery, findAllSOs } from '../saved_object'; import { savedObjectToAgent } from './saved_objects'; +import { appContextService } from '../../services'; const ACTIVE_AGENT_CONDITION = `${AGENT_SAVED_OBJECT_TYPE}.attributes.active:true`; const INACTIVE_AGENT_CONDITION = `NOT (${ACTIVE_AGENT_CONDITION})`; @@ -41,6 +43,7 @@ export async function listAgents( sortOrder = 'desc', kuery, showInactive = false, + showUpgradeable, } = options; const filters = []; @@ -52,7 +55,7 @@ export async function listAgents( filters.push(ACTIVE_AGENT_CONDITION); } - const { saved_objects: agentSOs, total } = await soClient.find({ + let { saved_objects: agentSOs, total } = await soClient.find({ type: AGENT_SAVED_OBJECT_TYPE, filter: _joinFilters(filters), sortField, @@ -60,6 +63,14 @@ export async function listAgents( page, perPage, }); + // filtering for a range on the version string will not work, + // nor does filtering on a flattened field (local_metadata), so filter here + if (showUpgradeable) { + agentSOs = agentSOs.filter((agent) => + isAgentUpgradeable(savedObjectToAgent(agent), appContextService.getKibanaVersion()) + ); + total = agentSOs.length; + } return { agents: agentSOs.map(savedObjectToAgent), diff --git a/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts b/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts index cee3bc69f25dba..612ebf9c11ab36 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts @@ -7,7 +7,8 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { AgentSOAttributes, AgentAction, AgentActionSOAttributes } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { createAgentAction } from './actions'; +import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { getAgents, listAllAgents } from './crud'; export async function sendUpgradeAgentAction({ soClient, @@ -18,7 +19,7 @@ export async function sendUpgradeAgentAction({ soClient: SavedObjectsClientContract; agentId: string; version: string; - sourceUri: string; + sourceUri: string | undefined; }) { const now = new Date().toISOString(); const data = { @@ -50,12 +51,62 @@ export async function ackAgentUpgraded( if (!version) throw new Error('version missing from UPGRADE action'); await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentAction.agent_id, { upgraded_at: new Date().toISOString(), - local_metadata: { - elastic: { - agent: { - version, - }, - }, - }, + upgrade_started_at: undefined, }); } + +export async function sendUpgradeAgentsActions( + soClient: SavedObjectsClientContract, + options: + | { + agentIds: string[]; + sourceUri: string | undefined; + version: string; + } + | { + kuery: string; + sourceUri: string | undefined; + version: string; + } +) { + // Filter out agents currently unenrolling, agents unenrolled + const agents = + 'agentIds' in options + ? await getAgents(soClient, options.agentIds) + : ( + await listAllAgents(soClient, { + kuery: options.kuery, + showInactive: false, + }) + ).agents; + const agentsToUpdate = agents.filter( + (agent) => !agent.unenrollment_started_at && !agent.unenrolled_at + ); + const now = new Date().toISOString(); + const data = { + version: options.version, + source_uri: options.sourceUri, + }; + // Create upgrade action for each agent + await bulkCreateAgentActions( + soClient, + agentsToUpdate.map((agent) => ({ + agent_id: agent.id, + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + })) + ); + + return await soClient.bulkUpdate( + agentsToUpdate.map((agent) => ({ + type: AGENT_SAVED_OBJECT_TYPE, + id: agent.id, + attributes: { + upgraded_at: undefined, + upgrade_started_at: now, + }, + })) + ); +} diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index 87e9257b7189c4..24ac1970cb225f 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -31,6 +31,7 @@ const AgentEventBase = { schema.literal('STOPPING'), schema.literal('STOPPED'), schema.literal('DEGRADED'), + schema.literal('UPDATING'), ]), // Action results schema.literal('DATA_DUMP'), diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 3866ef095563e4..4fd1f3f3e15734 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -13,6 +13,7 @@ export const GetAgentsRequestSchema = { perPage: schema.number({ defaultValue: 20 }), kuery: schema.maybe(schema.string()), showInactive: schema.boolean({ defaultValue: false }), + showUpgradeable: schema.boolean({ defaultValue: false }), }), }; @@ -58,6 +59,7 @@ export const PostAgentCheckinRequestBodyJSONSchema = { 'DEGRADED', 'DATA_DUMP', 'ACKNOWLEDGED', + 'UPDATING', 'UNKNOWN', ], }, @@ -172,20 +174,28 @@ export const PostAgentUnenrollRequestSchema = { ), }; +export const PostBulkAgentUnenrollRequestSchema = { + body: schema.object({ + agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + force: schema.maybe(schema.boolean()), + }), +}; + export const PostAgentUpgradeRequestSchema = { params: schema.object({ agentId: schema.string(), }), body: schema.object({ - source_uri: schema.string(), + source_uri: schema.maybe(schema.string()), version: schema.string(), }), }; -export const PostBulkAgentUnenrollRequestSchema = { +export const PostBulkAgentUpgradeRequestSchema = { body: schema.object({ agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), - force: schema.maybe(schema.boolean()), + source_uri: schema.maybe(schema.string()), + version: schema.string(), }), }; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts index dc0f111680490d..cdb23da5b6b114 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts @@ -10,6 +10,7 @@ export const ListWithKuerySchema = schema.object({ perPage: schema.maybe(schema.number({ defaultValue: 20 })), sortField: schema.maybe(schema.string()), sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), + showUpgradeable: schema.maybe(schema.boolean()), kuery: schema.maybe(schema.string()), }); diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts index 360b91203dfc87..b119e6d58dc35a 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts @@ -218,7 +218,7 @@ export default function (providerContext: FtrProviderContext) { .send({ action: { type: 'UPGRADE', - ack_data: { version: '8.0.0', source_uri: 'http://localhost:8000' }, + ack_data: { version: '8.0.0' }, }, }) .expect(200); diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts index a783f806c03ee8..04e32b2b80f564 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts @@ -5,9 +5,11 @@ */ import expect from '@kbn/expect/expect.js'; +import semver from 'semver'; import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; import { setupIngest } from './services'; import { skipIfNoDockerRegistry } from '../../../helpers'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../../../plugins/ingest_manager/common'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -18,16 +20,15 @@ export default function (providerContext: FtrProviderContext) { describe('fleet upgrade agent', () => { skipIfNoDockerRegistry(providerContext); setupIngest(providerContext); - before(async () => { + beforeEach(async () => { await esArchiver.loadIfNeeded('fleet/agents'); }); - after(async () => { + afterEach(async () => { await esArchiver.unload('fleet/agents'); }); it('should respond 200 to upgrade agent and update the agent SO', async () => { - const kibanaVersionAccessor = kibanaServer.version; - const kibanaVersion = await kibanaVersionAccessor.get(); + const kibanaVersion = await kibanaServer.version.get(); await supertest .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) .set('kbn-xsrf', 'xxx') @@ -36,22 +37,150 @@ export default function (providerContext: FtrProviderContext) { source_uri: 'http://path/to/download', }) .expect(200); - const res = await kibanaServer.savedObjects.get({ - type: 'fleet-agents', - id: 'agent1', - }); - expect(res.attributes.upgrade_started_at).to.be.ok(); + + const res = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xxx'); + expect(typeof res.body.item.upgrade_started_at).to.be('string'); + }); + it('should respond 200 to upgrade agent and update the agent SO without source_uri', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + }) + .expect(200); + const res = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xxx'); + expect(typeof res.body.item.upgrade_started_at).to.be('string'); }); it('should respond 400 if trying to upgrade to a version that does not match installed kibana version', async () => { + const kibanaVersion = await kibanaServer.version.get(); + const higherVersion = semver.inc(kibanaVersion, 'patch'); await supertest .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) .set('kbn-xsrf', 'xxx') .send({ - version: '8.0.1', + version: higherVersion, source_uri: 'http://path/to/download', }) .expect(400); }); + it('should respond 400 if trying to upgrade an agent that is unenrolling', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + force: true, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + }) + .expect(400); + }); + it('should respond 400 if trying to upgrade an agent that is unenrolled', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { unenrolled_at: new Date().toISOString() }, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + }) + .expect(400); + }); + + it('should respond 200 to bulk upgrade agents and update the agent SOs', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersion, + agents: ['agent1', 'agent2'], + }) + .expect(200); + + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('string'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); + + it('should allow to upgrade multiple agents by kuery', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: 'fleet-agents.active: true', + version: kibanaVersion, + }) + .expect(200); + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('string'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); + + it('should not upgrade an unenrolling agent during bulk_upgrade', async () => { + const kibanaVersion = await kibanaServer.version.get(); + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + force: true, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent1', 'agent2'], + version: kibanaVersion, + }); + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); + it('should not upgrade an unenrolled agent during bulk_upgrade', async () => { + const kibanaVersion = await kibanaServer.version.get(); + kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { unenrolled_at: new Date().toISOString() }, + }); + await supertest + .post(`/api/ingest_manager/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + agents: ['agent1', 'agent2'], + version: kibanaVersion, + }); + const [agent1data, agent2data] = await Promise.all([ + supertest.get(`/api/ingest_manager/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'), + supertest.get(`/api/ingest_manager/fleet/agents/agent2`).set('kbn-xsrf', 'xxx'), + ]); + expect(typeof agent1data.body.item.upgrade_started_at).to.be('undefined'); + expect(typeof agent2data.body.item.upgrade_started_at).to.be('string'); + }); }); }