diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 85d17cf67cfd17..b3847ac8c6892b 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -93,6 +93,13 @@ export interface AgentSOAttributes extends AgentBase { packages?: string[]; } +export interface CurrentUpgrade { + actionId: string; + complete: boolean; + nbAgents: number; + nbAgentsAck: number; +} + // Generated from FleetServer schema.json /** 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 aa256db95634ad..7a8b7b918c1e3f 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { Agent, AgentAction, NewAgentAction } from '../models'; +import type { Agent, AgentAction, CurrentUpgrade, NewAgentAction } from '../models'; import type { ListResult, ListWithKuery } from './common'; @@ -174,3 +174,7 @@ export interface IncomingDataList { export interface GetAgentIncomingDataResponse { items: IncomingDataList[]; } + +export interface GetCurrentUpgradesResponse { + items: CurrentUpgrade[]; +} diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index 4f26f099442525..b0191f07e1a2a5 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -41,7 +41,11 @@ import { postCancelActionHandlerBuilder, } from './actions_handlers'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; -import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; +import { + getCurrentUpgradesHandler, + postAgentUpgradeHandler, + postBulkAgentsUpgradeHandler, +} from './upgrade_handler'; export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigType) => { // Get one @@ -197,6 +201,18 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT }, postBulkAgentsUpgradeHandler ); + // Current upgrades + router.get( + { + path: AGENT_API_ROUTES.CURRENT_UPGRADES_PATTERN, + validate: false, + fleetAuthz: { + fleet: { all: true }, + }, + }, + getCurrentUpgradesHandler + ); + // Bulk reassign router.post( { diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index 32c8276a9e5f8f..546b6d54be4888 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -12,7 +12,11 @@ import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/ import semverCoerce from 'semver/functions/coerce'; import semverGt from 'semver/functions/gt'; -import type { PostAgentUpgradeResponse, PostBulkAgentUpgradeResponse } from '../../../common/types'; +import type { + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, + GetCurrentUpgradesResponse, +} from '../../../common/types'; import type { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; @@ -135,6 +139,19 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< } }; +export const getCurrentUpgradesHandler: RequestHandler = async (context, request, response) => { + const coreContext = await context.core; + const esClient = coreContext.elasticsearch.client.asInternalUser; + + try { + const upgrades = await AgentService.getCurrentBulkUpgrades(esClient); + const body: GetCurrentUpgradesResponse = { items: upgrades }; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + export const checkKibanaVersion = (version: string, kibanaVersion: string) => { // get version number only in case "-SNAPSHOT" is in it const kibanaVersionNumber = semverCoerce(kibanaVersion)?.version; diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index f1bd60d1eba949..1914ea79cb5589 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -7,8 +7,9 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import moment from 'moment'; +import pMap from 'p-map'; -import type { Agent, BulkActionResult } from '../../types'; +import type { Agent, BulkActionResult, FleetServerAgentAction, CurrentUpgrade } from '../../types'; import { agentPolicyService } from '..'; import { AgentReassignmentError, @@ -17,6 +18,7 @@ import { } from '../../errors'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common'; import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; @@ -207,3 +209,134 @@ export async function sendUpgradeAgentsActions( return { items: orderedOut }; } + +/** + * Return current bulk upgrades (non completed or cancelled) + */ +export async function getCurrentBulkUpgrades( + esClient: ElasticsearchClient, + now = new Date().toISOString() +): Promise { + // Fetch all non expired actions + const [_upgradeActions, cancelledActionIds] = await Promise.all([ + _getUpgradeActions(esClient, now), + _getCancelledActionId(esClient, now), + ]); + + let upgradeActions = _upgradeActions.filter( + (action) => cancelledActionIds.indexOf(action.actionId) < 0 + ); + + // Fetch acknowledged result for every upgrade action + upgradeActions = await pMap( + upgradeActions, + async (upgradeAction) => { + const { count } = await esClient.count({ + index: AGENT_ACTIONS_RESULTS_INDEX, + ignore_unavailable: true, + query: { + bool: { + must: [ + { + term: { + action_id: upgradeAction.actionId, + }, + }, + ], + }, + }, + }); + + return { + ...upgradeAction, + nbAgentsAck: count, + complete: upgradeAction.nbAgents < count, + }; + }, + { concurrency: 20 } + ); + + upgradeActions = upgradeActions.filter((action) => !action.complete); + + return upgradeActions; +} + +async function _getCancelledActionId( + esClient: ElasticsearchClient, + now = new Date().toISOString() +) { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + type: 'CANCEL', + }, + }, + { + exists: { + field: 'agents', + }, + }, + { + range: { + expiration: { gte: now }, + }, + }, + ], + }, + }, + }); + + return res.hits.hits.map((hit) => hit._source?.data?.target_id as string); +} + +async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date().toISOString()) { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + type: 'UPGRADE', + }, + }, + { + exists: { + field: 'agents', + }, + }, + { + range: { + expiration: { gte: now }, + }, + }, + ], + }, + }, + }); + + return Object.values( + res.hits.hits.reduce((acc, hit) => { + if (!hit._source || !hit._source.action_id) { + return acc; + } + + if (!acc[hit._source.action_id]) { + acc[hit._source.action_id] = { + actionId: hit._source.action_id, + nbAgents: 0, + complete: false, + nbAgentsAck: 0, + }; + } + + acc[hit._source.action_id].nbAgents += hit._source.agents?.length ?? 0; + + return acc; + }, {} as { [k: string]: CurrentUpgrade }) + ); +} diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 37dde581d4b8f0..10a00393f80756 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -12,6 +12,7 @@ export type { AgentStatus, AgentType, AgentAction, + CurrentUpgrade, PackagePolicy, PackagePolicyInput, PackagePolicyInputStream, diff --git a/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts b/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts new file mode 100644 index 00000000000000..5c8b0ab7cf91e5 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/agents/current_upgrades.ts @@ -0,0 +1,165 @@ +/* + * 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 moment from 'moment'; +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'; + +const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('Agent current upgrades API', () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents'); + }); + setupFleetAndAgents(providerContext); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); + }); + + describe('GET /api/fleet/agents/current_upgrades', () => { + before(async () => { + // Action 1 non expired and non complete + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action1', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + + // action 2 non expired and non complete + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action2', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action2', + agents: ['agent4', 'agent5'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + // Action 3 complete + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action3', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + await es.index( + { + refresh: 'wait_for', + index: AGENT_ACTIONS_RESULTS_INDEX, + document: { + action_id: 'action3', + '@timestamp': new Date().toISOString(), + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + }, + }, + ES_INDEX_OPTIONS + ); + await es.index( + { + refresh: 'wait_for', + index: AGENT_ACTIONS_RESULTS_INDEX, + document: { + action_id: 'action3', + '@timestamp': new Date().toISOString(), + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + }, + }, + ES_INDEX_OPTIONS + ); + + // Action 4 expired + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action4', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().subtract(1, 'day').toISOString(), + }, + }); + + // Action 5 cancelled + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'UPGRADE', + action_id: 'action5', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + }, + }); + await es.index({ + refresh: 'wait_for', + index: AGENT_ACTIONS_INDEX, + document: { + type: 'CANCEL', + action_id: 'cancelaction1', + agents: ['agent1', 'agent2', 'agent3'], + expiration: moment().add(1, 'day').toISOString(), + data: { + target_id: 'action5', + }, + }, + }); + }); + it('should respond 200 and the current upgrades', async () => { + // const kibanaVersion = await kibanaServer.version.get(); + // await es.update({ + // id: 'agent1', + // refresh: 'wait_for', + // index: AGENTS_INDEX, + // body: { + // doc: { + // local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + // }, + // }, + // }); + const res = await supertest.get(`/api/fleet/agents/current_upgrades`).expect(200); + const actionIds = res.body.items.map((item: any) => item.actionId); + expect(actionIds).length(2); + expect(actionIds).contain('action1'); + expect(actionIds).contain('action2'); + }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/agents/index.js b/x-pack/test/fleet_api_integration/apis/agents/index.js new file mode 100644 index 00000000000000..dbfcbf66928d97 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/agents/index.js @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export default function loadTests({ loadTestFile }) { + describe('Fleet Endpoints', () => { + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./list')); + loadTestFile(require.resolve('./unenroll')); + loadTestFile(require.resolve('./actions')); + loadTestFile(require.resolve('./upgrade')); + loadTestFile(require.resolve('./current_upgrades')); + loadTestFile(require.resolve('./reassign')); + loadTestFile(require.resolve('./status')); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/index.js b/x-pack/test/fleet_api_integration/apis/index.js index 1c528e719e2e82..4f9d2026bc5312 100644 --- a/x-pack/test/fleet_api_integration/apis/index.js +++ b/x-pack/test/fleet_api_integration/apis/index.js @@ -20,13 +20,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./fleet_setup')); // Agents - loadTestFile(require.resolve('./agents/delete')); - loadTestFile(require.resolve('./agents/list')); - loadTestFile(require.resolve('./agents/unenroll')); - loadTestFile(require.resolve('./agents/actions')); - loadTestFile(require.resolve('./agents/upgrade')); - loadTestFile(require.resolve('./agents/reassign')); - loadTestFile(require.resolve('./agents/status')); + loadTestFile(require.resolve('./agents')); // Enrollment API keys loadTestFile(require.resolve('./enrollment_api_keys/crud'));