diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index 9617173bd0c7b..c374cbb3bb146 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -3520,7 +3520,17 @@ ] } }, - "/fleet/agents/unenroll": { + "/fleet/agents/{agentId}/unenroll": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], "post": { "summary": "Fleet - Agent - Unenroll", "tags": [], @@ -3530,7 +3540,26 @@ { "$ref": "#/components/parameters/xsrfHeader" } - ] + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { "type": "boolean" } + } + }, + "examples": { + "example-1": { + "value": { + "force": true + } + } + } + } + } + } } }, "/fleet/config/{configId}/agent-status": { @@ -4096,6 +4125,12 @@ "enrolled_at": { "type": "string" }, + "unenrolled_at": { + "type": "string" + }, + "unenrollment_started_at": { + "type": "string" + }, "shared_id": { "type": "string" }, 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 cc1c2da710516..b1d92d3a78e65 100644 --- a/x-pack/plugins/ingest_manager/common/services/agent_status.ts +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -21,6 +21,9 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (!agent.active) { return 'inactive'; } + if (agent.unenrollment_started_at && !agent.unenrolled_at) { + return 'unenrolling'; + } if (agent.current_error_events.length > 0) { return 'error'; } 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 d2a2a3f5705ae..27f0c61685fd4 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -11,10 +11,10 @@ export type AgentType = | typeof AGENT_TYPE_PERMANENT | typeof AGENT_TYPE_TEMPORARY; -export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; - +export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning' | 'unenrolling'; +export type AgentActionType = 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE' | 'UNENROLL'; export interface NewAgentAction { - type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; + type: AgentActionType; data?: any; sent_at?: string; } @@ -26,7 +26,7 @@ export interface AgentAction extends NewAgentAction { } export interface AgentActionSOAttributes { - type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; + type: AgentActionType; sent_at?: string; timestamp?: string; created_at: string; @@ -73,6 +73,8 @@ interface AgentBase { type: AgentType; active: boolean; enrolled_at: string; + unenrolled_at?: string; + unenrollment_started_at?: string; shared_id?: string; access_api_key_id?: string; default_api_key?: string; 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 75d0556755149..6d04f63702c64 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 @@ -236,7 +236,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'active', - width: '100px', + width: '120px', name: i18n.translate('xpack.ingestManager.agentList.statusColumnTitle', { defaultMessage: 'Status', }), 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 181ebe3504222..e4dfa520259eb 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 @@ -53,6 +53,14 @@ const Status = { /> ), + Unenrolling: ( + + + + ), }; function getStatusComponent(agent: Agent): React.ReactElement { @@ -65,6 +73,8 @@ function getStatusComponent(agent: Agent): React.ReactElement { return Status.Offline; case 'warning': return Status.Warning; + case 'unenrolling': + return Status.Unenrolling; default: return Status.Online; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx index fec2253c0dd56..90d8ff545341d 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx @@ -74,7 +74,7 @@ export const AgentUnenrollProvider: React.FunctionComponent = ({ children const successMessage = i18n.translate( 'xpack.ingestManager.unenrollAgents.successSingleNotificationTitle', { - defaultMessage: "Unenrolled agent '{id}'", + defaultMessage: "Unenrolling agent '{id}'", values: { id: agentId }, } ); 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 d31498599a2b6..d9a9572237126 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -13,7 +13,6 @@ import { GetOneAgentEventsResponse, PostAgentCheckinResponse, PostAgentEnrollResponse, - PostAgentUnenrollResponse, GetAgentStatusResponse, PutAgentReassignResponse, } from '../../../common/types'; @@ -25,7 +24,6 @@ import { GetOneAgentEventsRequestSchema, PostAgentCheckinRequestSchema, PostAgentEnrollRequestSchema, - PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, PutAgentReassignRequestSchema, } from '../../types'; @@ -302,25 +300,6 @@ export const getAgentsHandler: RequestHandler< } }; -export const postAgentsUnenrollHandler: RequestHandler> = async (context, request, response) => { - const soClient = context.core.savedObjects.client; - try { - await AgentService.unenrollAgent(soClient, request.params.agentId); - - const body: PostAgentUnenrollResponse = { - success: true, - }; - return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); - } -}; - export const putAgentsReassignHandler: RequestHandler< TypeOf, undefined, 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 eaab46c7b455c..d7eec50eac3cf 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -33,7 +33,6 @@ import { getAgentEventsHandler, postAgentCheckinHandler, postAgentEnrollHandler, - postAgentsUnenrollHandler, getAgentStatusForConfigHandler, putAgentsReassignHandler, } from './handlers'; @@ -41,6 +40,7 @@ import { postAgentAcksHandlerBuilder } from './acks_handlers'; import * as AgentService from '../../services/agents'; import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { appContextService } from '../../services'; +import { postAgentsUnenrollHandler } from './unenroll_handler'; export const registerRoutes = (router: IRouter) => { // Get one diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts new file mode 100644 index 0000000000000..d1e54fe4fb3a1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts @@ -0,0 +1,36 @@ +/* + * 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 { RequestHandler } from 'src/core/server'; +import { TypeOf } from '@kbn/config-schema'; +import { PostAgentUnenrollResponse } from '../../../common/types'; +import { PostAgentUnenrollRequestSchema } from '../../types'; +import * as AgentService from '../../services/agents'; + +export const postAgentsUnenrollHandler: RequestHandler< + TypeOf, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + if (request.body?.force === true) { + await AgentService.forceUnenrollAgent(soClient, request.params.agentId); + } else { + await AgentService.unenrollAgent(soClient, request.params.agentId); + } + + const body: PostAgentUnenrollResponse = { + success: true, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; 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 9819a4fa5d750..b47cf4f7e7c3b 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -54,6 +54,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { type: { type: 'keyword' }, active: { type: 'boolean' }, enrolled_at: { type: 'date' }, + unenrolled_at: { type: 'date' }, + unenrollment_started_at: { type: 'date' }, access_api_key_id: { type: 'keyword' }, version: { type: 'keyword' }, user_provided_metadata: { type: 'flattened' }, @@ -313,6 +315,9 @@ export function registerEncryptedSavedObjects( 'config_newest_revision', 'updated_at', 'current_error_events', + 'unenrolled_at', + 'unenrollment_started_at', + 'packages', ]), }); encryptedSavedObjects.registerType({ diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts index c59bac6a5469a..1dfe4e067dafe 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts @@ -26,6 +26,7 @@ import { AGENT_ACTION_SAVED_OBJECT_TYPE, } from '../../constants'; import { getAgentActionByIds } from './actions'; +import { forceUnenrollAgent } from './unenroll'; const ALLOWED_ACKNOWLEDGEMENT_TYPE: string[] = ['ACTION_RESULT']; @@ -63,6 +64,12 @@ export async function acknowledgeAgentActions( if (actions.length === 0) { return []; } + + const isAgentUnenrolled = actions.some((action) => action.type === 'UNENROLL'); + if (isAgentUnenrolled) { + await forceUnenrollAgent(soClient, agent.id); + } + const config = getLatestConfigIfUpdated(agent, actions); await soClient.bulkUpdate([ diff --git a/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts index ee7e08d741035..e0ac2620cafd3 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts @@ -9,8 +9,21 @@ import { AgentSOAttributes } from '../../types'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; import { getAgent } from './crud'; import * as APIKeyService from '../api_keys'; +import { createAgentAction } from './actions'; export async function unenrollAgent(soClient: SavedObjectsClientContract, agentId: string) { + const now = new Date().toISOString(); + await createAgentAction(soClient, { + agent_id: agentId, + created_at: now, + type: 'UNENROLL', + }); + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + unenrollment_started_at: now, + }); +} + +export async function forceUnenrollAgent(soClient: SavedObjectsClientContract, agentId: string) { const agent = await getAgent(soClient, agentId); await Promise.all([ @@ -21,7 +34,9 @@ export async function unenrollAgent(soClient: SavedObjectsClientContract, agentI ? APIKeyService.invalidateAPIKey(soClient, agent.default_api_key_id) : undefined, ]); + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { active: false, + unenrolled_at: new Date().toISOString(), }); } 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 5526e889124f9..a508c33e0347b 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 @@ -70,6 +70,11 @@ export const PostAgentUnenrollRequestSchema = { params: schema.object({ agentId: schema.string(), }), + body: schema.nullable( + schema.object({ + force: schema.boolean(), + }) + ), }; export const PutAgentReassignRequestSchema = { diff --git a/x-pack/test/api_integration/apis/fleet/agent_flow.ts b/x-pack/test/api_integration/apis/fleet/agent_flow.ts index a6a4003a554fc..e14a85d6e30c1 100644 --- a/x-pack/test/api_integration/apis/fleet/agent_flow.ts +++ b/x-pack/test/api_integration/apis/fleet/agent_flow.ts @@ -90,7 +90,7 @@ export default function (providerContext: FtrProviderContext) { events: [ { type: 'ACTION_RESULT', - subtype: 'CONFIG', + subtype: 'ACKNOWLEDGED', timestamp: '2019-01-04T14:32:03.36764-05:00', action_id: configChangeAction.id, agent_id: enrollmentResponse.item.id, @@ -132,7 +132,43 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(unenrollResponse.success).to.eql(true); - // Checkin after unenrollment + // Checkin after unenrollment + const { body: checkinAfterUnenrollResponse } = await supertestWithoutAuth + .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) + .set('kbn-xsrf', 'xx') + .set('Authorization', `ApiKey ${agentAccessAPIKey}`) + .send({ + events: [], + }) + .expect(200); + + expect(checkinAfterUnenrollResponse.success).to.eql(true); + expect(checkinAfterUnenrollResponse.actions).length(1); + expect(checkinAfterUnenrollResponse.actions[0].type).be('UNENROLL'); + const unenrollAction = checkinAfterUnenrollResponse.actions[0]; + + // ack unenroll actions + const { body: ackUnenrollApiResponse } = await supertestWithoutAuth + .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/acks`) + .set('Authorization', `ApiKey ${agentAccessAPIKey}`) + .set('kbn-xsrf', 'xx') + .send({ + events: [ + { + type: 'ACTION_RESULT', + subtype: 'ACKNOWLEDGED', + timestamp: '2019-01-04T14:32:03.36764-05:00', + action_id: unenrollAction.id, + agent_id: enrollmentResponse.item.id, + message: 'hello', + payload: 'payload', + }, + ], + }) + .expect(200); + expect(ackUnenrollApiResponse.success).to.eql(true); + + // Checkin after unenrollment acknowledged await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) .set('kbn-xsrf', 'xx') diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts index 898b97e081c89..bfe9170d0305e 100644 --- a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts +++ b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts @@ -68,7 +68,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') .send({ - ids: ['agent1'], + force: true, }) .expect(200); @@ -81,7 +81,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') .send({ - ids: ['agent1'], + force: true, }) .expect(200);