diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 7415ad01ee0c90..809e32c78c1b8a 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -30,7 +30,13 @@ export type AgentStatus = export type SimplifiedAgentStatus = 'healthy' | 'unhealthy' | 'updating' | 'offline' | 'inactive'; -export type AgentActionType = 'UNENROLL' | 'UPGRADE' | 'SETTINGS' | 'POLICY_REASSIGN' | 'CANCEL'; +export type AgentActionType = + | 'UNENROLL' + | 'UPGRADE' + | 'SETTINGS' + | 'POLICY_REASSIGN' + | 'CANCEL' + | 'FORCE_UNENROLL'; type FleetServerAgentComponentStatusTuple = typeof FleetServerAgentComponentStatuses; export type FleetServerAgentComponentStatus = FleetServerAgentComponentStatusTuple[number]; 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 e0cc1ae9846713..a2fdf6d145d556 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 @@ -143,7 +143,7 @@ export const AgentActivityFlyout: React.FunctionComponent<{ body={ } /> @@ -238,6 +238,11 @@ const actionNames: { completedText: 'unenrolled', cancelledText: 'unenrollment', }, + FORCE_UNENROLL: { + inProgressText: 'Force unenrolling', + completedText: 'force unenrolled', + cancelledText: 'force unenrollment', + }, CANCEL: { inProgressText: 'Cancelling', completedText: 'cancelled', cancelledText: '' }, ACTION: { inProgressText: 'Actioning', completedText: 'actioned', cancelledText: 'action' }, }; diff --git a/x-pack/plugins/fleet/server/services/agents/action.mock.ts b/x-pack/plugins/fleet/server/services/agents/action.mock.ts index 015914a1016ccf..8d0bcb8b4ee4e5 100644 --- a/x-pack/plugins/fleet/server/services/agents/action.mock.ts +++ b/x-pack/plugins/fleet/server/services/agents/action.mock.ts @@ -124,6 +124,8 @@ export function createClientMock() { }; }); + esClientMock.search.mockResolvedValue({ hits: { hits: [] } } as any); + return { soClient: soClientMock, esClient: esClientMock, diff --git a/x-pack/plugins/fleet/server/services/agents/action_status.ts b/x-pack/plugins/fleet/server/services/agents/action_status.ts index 2f5e6ff80cd9ed..0db6d6db9d86c0 100644 --- a/x-pack/plugins/fleet/server/services/agents/action_status.ts +++ b/x-pack/plugins/fleet/server/services/agents/action_status.ts @@ -36,7 +36,7 @@ export async function getActionStatuses( size: 0, aggs: { ack_counts: { - terms: { field: 'action_id' }, + terms: { field: 'action_id', size: actions.length || 10 }, aggs: { max_timestamp: { max: { field: '@timestamp' } }, }, @@ -61,7 +61,7 @@ export async function getActionStatuses( const nbAgentsAck = matchingBucket?.doc_count ?? 0; const completionTime = (matchingBucket?.max_timestamp as any)?.value_as_string; const nbAgentsActioned = action.nbAgentsActioned || action.nbAgentsActionCreated; - const complete = nbAgentsAck === nbAgentsActioned; + const complete = nbAgentsAck >= nbAgentsActioned; const cancelledAction = cancelledActions.find((a) => a.actionId === action.actionId); let errorCount = 0; @@ -161,13 +161,6 @@ async function _getActions( }, }, ], - must: [ - { - exists: { - field: 'agents', - }, - }, - ], }, }, body: { diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index f215e6dffee7eb..20cb2fb94e51d7 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -106,7 +106,7 @@ export async function bulkCreateAgentActionResults( results: Array<{ actionId: string; agentId: string; - error: string; + error?: string; }> ): Promise { if (results.length === 0) { @@ -164,6 +164,41 @@ export async function getAgentActions(esClient: ElasticsearchClient, actionId: s })); } +export async function getUnenrollAgentActions( + esClient: ElasticsearchClient +): Promise { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + type: 'UNENROLL', + }, + }, + { + exists: { + field: 'agents', + }, + }, + { + range: { + expiration: { gte: new Date().toISOString() }, + }, + }, + ], + }, + }, + size: SO_SEARCH_LIMIT, + }); + + return res.hits.hits.map((hit) => ({ + ...hit._source, + id: hit._id, + })); +} + export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index f3bbb2036954f0..d96e8b89d2fc13 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -6,6 +6,8 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { AGENT_ACTIONS_INDEX } from '../../../common'; + import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; import { invalidateAPIKeys } from '../api_keys'; @@ -13,8 +15,10 @@ import { appContextService } from '../app_context'; import { createAppContextStartContractMock } from '../../mocks'; +import type { Agent } from '../../types'; + import { unenrollAgent, unenrollAgents } from './unenroll'; -import { invalidateAPIKeysForAgents } from './unenroll_action_runner'; +import { invalidateAPIKeysForAgents, isAgentUnenrolled } from './unenroll_action_runner'; import { createClientMock } from './action.mock'; jest.mock('../api_keys'); @@ -137,6 +141,78 @@ describe('unenrollAgents (plural)', () => { expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject); }); + it('force unenroll updates in progress unenroll actions', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + esClient.search.mockReset(); + esClient.search.mockImplementation((request) => + Promise.resolve( + request?.index === AGENT_ACTIONS_INDEX + ? ({ + hits: { + hits: [ + { + _source: { + agents: ['agent-in-regular-policy'], + action_id: 'other-action', + }, + }, + ], + }, + } as any) + : { hits: { hits: [] } } + ) + ); + + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + }); + + expect(esClient.bulk.mock.calls.length).toEqual(3); + const bulkBody = (esClient.bulk.mock.calls[1][0] as estypes.BulkRequest)?.body?.[1] as any; + expect(bulkBody.agent_id).toEqual(agentInRegularDoc._id); + expect(bulkBody.action_id).toEqual('other-action'); + }); + + it('force unenroll should not update completed unenroll actions', async () => { + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + esClient.search.mockReset(); + esClient.search.mockImplementation((request) => + Promise.resolve( + request?.index === AGENT_ACTIONS_INDEX + ? ({ + hits: { + hits: [ + { + _source: { + agents: ['agent-in-regular-policy'], + action_id: 'other-action1', + }, + }, + ], + }, + } as any) + : { + hits: { + hits: [ + { _source: { action_id: 'other-action1', agent_id: 'agent-in-regular-policy' } }, + ], + }, + } + ) + ); + + const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id]; + await unenrollAgents(soClient, esClient, { + agentIds: idsToUnenroll, + revoke: true, + }); + + // agent and force unenroll results updated, no other action results + expect(esClient.bulk.mock.calls.length).toEqual(2); + }); + it('cannot unenroll from a hosted agent policy with revoke=true', async () => { const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); @@ -165,7 +241,7 @@ describe('unenrollAgents (plural)', () => { // calls ES update with correct values const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[0][0]; + const calledWith = esClient.bulk.mock.calls[2][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -176,6 +252,21 @@ describe('unenrollAgents (plural)', () => { for (const doc of docs!) { expect(doc).toHaveProperty('unenrolled_at'); } + + const errorResults = esClient.bulk.mock.calls[1][0]; + const errorIds = (errorResults as estypes.BulkRequest)?.body + ?.filter((i: any) => i.agent_id) + .map((i: any) => i.agent_id); + expect(errorIds).toEqual([agentInHostedDoc._id]); + + const actionResults = esClient.bulk.mock.calls[0][0]; + const resultIds = (actionResults as estypes.BulkRequest)?.body + ?.filter((i: any) => i.agent_id) + .map((i: any) => i.agent_id); + expect(resultIds).toEqual(onlyRegular); + + const action = esClient.create.mock.calls[0][0] as any; + expect(action.body.type).toEqual('FORCE_UNENROLL'); }); it('can unenroll from hosted agent policy with force=true', async () => { @@ -226,7 +317,7 @@ describe('unenrollAgents (plural)', () => { ]); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[0][0]; + const calledWith = esClient.bulk.mock.calls[1][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -237,6 +328,15 @@ describe('unenrollAgents (plural)', () => { for (const doc of docs!) { expect(doc).toHaveProperty('unenrolled_at'); } + + const actionResults = esClient.bulk.mock.calls[0][0]; + const resultIds = (actionResults as estypes.BulkRequest)?.body + ?.filter((i: any) => i.agent_id) + .map((i: any) => i.agent_id); + expect(resultIds).toEqual(idsToUnenroll); + + const action = esClient.create.mock.calls[0][0] as any; + expect(action.body.type).toEqual('FORCE_UNENROLL'); }); }); @@ -270,3 +370,31 @@ describe('invalidateAPIKeysForAgents', () => { ]); }); }); + +describe('isAgentUnenrolled', () => { + it('should return true if revoke and unenrolled_at set', () => { + expect(isAgentUnenrolled({ unenrolled_at: '2022-09-21' } as Agent, true)).toBe(true); + }); + + it('should return false if revoke and unenrolled_at null', () => { + expect( + isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: '2022-09-21' } as any, true) + ).toBe(false); + }); + + it('should return true if unenrolled_at set', () => { + expect(isAgentUnenrolled({ unenrolled_at: '2022-09-21' } as Agent)).toBe(true); + }); + + it('should return true if unenrollment_started_at set and unenrolled_at null', () => { + expect( + isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: '2022-09-21' } as any) + ).toBe(true); + }); + + it('should return false if unenrollment_started_at null and unenrolled_at null', () => { + expect(isAgentUnenrolled({ unenrolled_at: null, unenrollment_started_at: null } as any)).toBe( + false + ); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 941e1260894b46..f7c49f3efcf1c0 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -7,6 +7,8 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; +import uuid from 'uuid'; + import type { Agent, BulkActionResult } from '../../types'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; import { SO_SEARCH_LIMIT } from '../../constants'; @@ -20,6 +22,7 @@ import { invalidateAPIKeysForAgents, UnenrollActionRunner, unenrollBatch, + updateActionsForForceUnenroll, } from './unenroll_action_runner'; async function unenrollAgentIsAllowed( @@ -50,7 +53,7 @@ export async function unenrollAgent( await unenrollAgentIsAllowed(soClient, esClient, agentId); } if (options?.revoke) { - return forceUnenrollAgent(soClient, esClient, agentId); + return forceUnenrollAgent(esClient, agentId); } const now = new Date().toISOString(); await createAgentAction(esClient, { @@ -102,7 +105,6 @@ export async function unenrollAgents( } export async function forceUnenrollAgent( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, agentIdOrAgent: string | Agent ) { @@ -116,4 +118,5 @@ export async function forceUnenrollAgent( active: false, unenrolled_at: new Date().toISOString(), }); + await updateActionsForForceUnenroll(esClient, [agent.id], uuid(), 1); } diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/unenroll_action_runner.ts index 70cfcece227bac..dfa7fac5e0e4ca 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll_action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll_action_runner.ts @@ -7,9 +7,13 @@ import uuid from 'uuid'; import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import { intersection } from 'lodash'; + +import { AGENT_ACTIONS_RESULTS_INDEX } from '../../../common'; + import type { Agent, BulkActionResult } from '../../types'; -import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; +import { FleetError, HostedAgentPolicyRestrictionRelatedError } from '../../errors'; import { invalidateAPIKeys } from '../api_keys'; @@ -18,7 +22,11 @@ import { appContextService } from '../app_context'; import { ActionRunner } from './action_runner'; import { errorsToResults, bulkUpdateAgents } from './crud'; -import { bulkCreateAgentActionResults, createAgentAction } from './actions'; +import { + bulkCreateAgentActionResults, + createAgentAction, + getUnenrollAgentActions, +} from './actions'; import { getHostedPolicies, isHostedAgent } from './hosted_agent'; import { BulkActionTaskType } from './bulk_actions_resolver'; @@ -36,6 +44,13 @@ export class UnenrollActionRunner extends ActionRunner { } } +export function isAgentUnenrolled(agent: Agent, revoke?: boolean): boolean { + return Boolean( + (revoke && agent.unenrolled_at) || + (!revoke && (agent.unenrollment_started_at || agent.unenrolled_at)) + ); +} + export async function unenrollBatch( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -48,23 +63,16 @@ export async function unenrollBatch( }, skipSuccess?: boolean ): Promise<{ items: BulkActionResult[] }> { - // Filter to those not already unenrolled, or unenrolling - const agentsEnrolled = givenAgents.filter((agent) => { - if (options.revoke) { - return !agent.unenrolled_at; - } - return !agent.unenrollment_started_at && !agent.unenrolled_at; - }); - - const hostedPolicies = await getHostedPolicies(soClient, agentsEnrolled); - + const hostedPolicies = await getHostedPolicies(soClient, givenAgents); const outgoingErrors: Record = {}; // And which are allowed to unenroll const agentsToUpdate = options.force - ? agentsEnrolled - : agentsEnrolled.reduce((agents, agent) => { - if (isHostedAgent(hostedPolicies, agent)) { + ? givenAgents + : givenAgents.reduce((agents, agent) => { + if (isAgentUnenrolled(agent, options.revoke)) { + outgoingErrors[agent.id] = new FleetError(`Agent ${agent.id} already unenrolled`); + } else if (isHostedAgent(hostedPolicies, agent)) { outgoingErrors[agent.id] = new HostedAgentPolicyRestrictionRelatedError( `Cannot unenroll ${agent.id} from a hosted agent policy ${agent.policy_id}` ); @@ -76,39 +84,43 @@ export async function unenrollBatch( const actionId = options.actionId ?? uuid(); const errorCount = Object.keys(outgoingErrors).length; - const total = options.total ?? agentsToUpdate.length + errorCount; + const total = options.total ?? givenAgents.length; + + const agentIds = agentsToUpdate.map((agent) => agent.id); const now = new Date().toISOString(); if (options.revoke) { // Get all API keys that need to be invalidated await invalidateAPIKeysForAgents(agentsToUpdate); + + await updateActionsForForceUnenroll(esClient, agentIds, actionId, total); } else { // Create unenroll action for each agent await createAgentAction(esClient, { id: actionId, - agents: agentsToUpdate.map((agent) => agent.id), + agents: agentIds, created_at: now, type: 'UNENROLL', total, }); + } - if (errorCount > 0) { - appContextService - .getLogger() - .info( - `Skipping ${errorCount} agents, as failed validation (cannot unenroll from a hosted policy)` - ); - - // writing out error result for those agents that failed validation, so the action is not going to stay in progress forever - await bulkCreateAgentActionResults( - esClient, - Object.keys(outgoingErrors).map((agentId) => ({ - agentId, - actionId, - error: outgoingErrors[agentId].message, - })) + if (errorCount > 0) { + appContextService + .getLogger() + .info( + `Skipping ${errorCount} agents, as failed validation (cannot unenroll from a hosted policy or already unenrolled)` ); - } + + // writing out error result for those agents that failed validation, so the action is not going to stay in progress forever + await bulkCreateAgentActionResults( + esClient, + Object.keys(outgoingErrors).map((agentId) => ({ + agentId, + actionId, + error: outgoingErrors[agentId].message, + })) + ); } // Update the necessary agents @@ -126,6 +138,82 @@ export async function unenrollBatch( }; } +export async function updateActionsForForceUnenroll( + esClient: ElasticsearchClient, + agentIds: string[], + actionId: string, + total: number +) { + // creating an unenroll so that force unenroll shows up in activity + await createAgentAction(esClient, { + id: actionId, + agents: [], + created_at: new Date().toISOString(), + type: 'FORCE_UNENROLL', + total, + }); + await bulkCreateAgentActionResults( + esClient, + agentIds.map((agentId) => ({ + agentId, + actionId, + })) + ); + + // updating action results for those agents that are there in a pending unenroll action + const unenrollActions = await getUnenrollAgentActions(esClient); + for (const action of unenrollActions) { + const commonAgents = intersection(action.agents, agentIds); + if (commonAgents.length > 0) { + // filtering out agents with action results + const agentsToUpdate = await getAgentsWithoutActionResults( + esClient, + action.action_id!, + commonAgents + ); + if (agentsToUpdate.length > 0) { + await bulkCreateAgentActionResults( + esClient, + agentsToUpdate.map((agentId) => ({ + agentId, + actionId: action.action_id!, + })) + ); + } + } + } +} + +async function getAgentsWithoutActionResults( + esClient: ElasticsearchClient, + actionId: string, + commonAgents: string[] +): Promise { + try { + const res = await esClient.search({ + index: AGENT_ACTIONS_RESULTS_INDEX, + query: { + bool: { + must: [{ term: { action_id: actionId } }, { terms: { agent_id: commonAgents } }], + }, + }, + size: commonAgents.length, + }); + const agentsToUpdate = commonAgents.filter( + (agentId) => !res.hits.hits.find((hit) => (hit._source as any)?.agent_id === agentId) + ); + return agentsToUpdate; + } catch (err) { + if (err.statusCode === 404) { + // .fleet-actions-results does not yet exist + appContextService.getLogger().debug(err); + } else { + throw err; + } + } + return commonAgents; +} + export async function invalidateAPIKeysForAgents(agents: Agent[]) { const apiKeys = agents.reduce((keys, agent) => { if (agent.access_api_key_id) { diff --git a/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts b/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts index 6d456a01d551fa..9375c3d82b7cb9 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration/reset_agent_policies.ts @@ -163,7 +163,7 @@ async function _deleteExistingData( // Delete if (agents.length > 0) { logger.info(`Force unenrolling ${agents.length} agents`); - await pMap(agents, (agent) => forceUnenrollAgent(soClient, esClient, agent.id), { + await pMap(agents, (agent) => forceUnenrollAgent(esClient, agent.id), { concurrency: 20, }); }