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);