Skip to content

Commit

Permalink
[Fleet] Allow to cancel agent actions
Browse files Browse the repository at this point in the history
  • Loading branch information
nchaulet committed May 12, 2022
1 parent 1f91501 commit 4dd84c2
Show file tree
Hide file tree
Showing 10 changed files with 312 additions and 86 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/fleet/common/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const AGENT_API_ROUTES = {
CHECKIN_PATTERN: `${API_ROOT}/agents/{agentId}/checkin`,
ACKS_PATTERN: `${API_ROOT}/agents/{agentId}/acks`,
ACTIONS_PATTERN: `${API_ROOT}/agents/{agentId}/actions`,
CANCEL_ACTIONS_PATTERN: `${API_ROOT}/agents/actions/{actionId}/cancel`,
UNENROLL_PATTERN: `${API_ROOT}/agents/{agentId}/unenroll`,
BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`,
REASSIGN_PATTERN: `${API_ROOT}/agents/{agentId}/reassign`,
Expand All @@ -117,6 +118,7 @@ export const AGENT_API_ROUTES = {
STATUS_PATTERN_DEPRECATED: `${API_ROOT}/agent-status`,
UPGRADE_PATTERN: `${API_ROOT}/agents/{agentId}/upgrade`,
BULK_UPGRADE_PATTERN: `${API_ROOT}/agents/bulk_upgrade`,
CURRENT_UPGRADES_PATTERN: `${API_ROOT}/agents/current_upgrades`,
};

export const ENROLLMENT_API_KEY_ROUTES = {
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/fleet/common/types/models/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export type AgentActionType =
| 'UNENROLL'
| 'UPGRADE'
| 'SETTINGS'
| 'POLICY_REASSIGN';
| 'POLICY_REASSIGN'
| 'CANCEL';

export interface NewAgentAction {
type: AgentActionType;
Expand Down
4 changes: 4 additions & 0 deletions x-pack/plugins/fleet/server/errors/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { appContextService } from '../services';

import {
AgentNotFoundError,
AgentActionNotFoundError,
AgentPolicyNameExistsError,
ConcurrentInstallOperationError,
IngestManagerError,
Expand Down Expand Up @@ -65,6 +66,9 @@ const getHTTPResponseCode = (error: IngestManagerError): number => {
if (error instanceof AgentNotFoundError) {
return 404;
}
if (error instanceof AgentActionNotFoundError) {
return 404;
}
return 400; // Bad Request
};

Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/fleet/server/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class PackageOutdatedError extends IngestManagerError {}
export class AgentPolicyError extends IngestManagerError {}
export class AgentPolicyNotFoundError extends IngestManagerError {}
export class AgentNotFoundError extends IngestManagerError {}
export class AgentActionNotFoundError extends IngestManagerError {}
export class AgentPolicyNameExistsError extends AgentPolicyError {}
export class PackageUnsupportedMediaTypeError extends IngestManagerError {}
export class PackageInvalidArchiveError extends IngestManagerError {}
Expand Down
25 changes: 24 additions & 1 deletion x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';

import type { PostNewAgentActionRequestSchema } from '../../types/rest_spec';
import type {
PostNewAgentActionRequestSchema,
PostCancelActionRequestSchema,
} from '../../types/rest_spec';
import type { ActionsService } from '../../services/agents';
import type { PostNewAgentActionResponse } from '../../../common/types/rest_spec';
import { defaultIngestErrorHandler } from '../../errors';
Expand Down Expand Up @@ -46,3 +49,23 @@ export const postNewAgentActionHandlerBuilder = function (
}
};
};

export const postCancelActionHandlerBuilder = function (
actionsService: ActionsService
): RequestHandler<TypeOf<typeof PostCancelActionRequestSchema.params>, undefined, undefined> {
return async (context, request, response) => {
try {
const esClient = (await context.core).elasticsearch.client.asInternalUser;

const action = await actionsService.cancelAgentAction(esClient, request.params.actionId);

const body: PostNewAgentActionResponse = {
item: action,
};

return response.ok({ body });
} catch (error) {
return defaultIngestErrorHandler({ error, response });
}
};
};
22 changes: 21 additions & 1 deletion x-pack/plugins/fleet/server/routes/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
PostBulkAgentReassignRequestSchema,
PostAgentUpgradeRequestSchema,
PostBulkAgentUpgradeRequestSchema,
PostCancelActionRequestSchema,
} from '../../types';
import * as AgentService from '../../services/agents';
import type { FleetConfigType } from '../..';
Expand All @@ -35,7 +36,10 @@ import {
postBulkAgentsReassignHandler,
getAgentDataHandler,
} from './handlers';
import { postNewAgentActionHandlerBuilder } from './actions_handlers';
import {
postNewAgentActionHandlerBuilder,
postCancelActionHandlerBuilder,
} from './actions_handlers';
import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler';
import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler';

Expand Down Expand Up @@ -96,6 +100,22 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT
},
postNewAgentActionHandlerBuilder({
getAgent: AgentService.getAgentById,
cancelAgentAction: AgentService.cancelAgentAction,
createAgentAction: AgentService.createAgentAction,
})
);

router.post(
{
path: AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN,
validate: PostCancelActionRequestSchema,
fleetAuthz: {
fleet: { all: true },
},
},
postCancelActionHandlerBuilder({
getAgent: AgentService.getAgentById,
cancelAgentAction: AgentService.cancelAgentAction,
createAgentAction: AgentService.createAgentAction,
})
);
Expand Down
71 changes: 71 additions & 0 deletions x-pack/plugins/fleet/server/services/agents/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core/server/mocks';

import { cancelAgentAction } from './actions';

describe('Agent actions', () => {
describe('cancelAgentAction', () => {
it('throw if the target action is not found', async () => {
const esClient = elasticsearchServiceMock.createInternalClient();
esClient.search.mockResolvedValue({
hits: {
hits: [],
},
} as any);
await expect(() => cancelAgentAction(esClient, 'i-do-not-exists')).rejects.toThrowError(
/Action not found/
);
});

it('should create one CANCEL action for each action found', async () => {
const esClient = elasticsearchServiceMock.createInternalClient();
esClient.search.mockResolvedValue({
hits: {
hits: [
{
_source: {
action_id: 'action1',
agents: ['agent1', 'agent2'],
expiration: '2022-05-12T18:16:18.019Z',
},
},
{
_source: {
action_id: 'action1',
agents: ['agent3', 'agent4'],
expiration: '2022-05-12T18:16:18.019Z',
},
},
],
},
} as any);
await cancelAgentAction(esClient, 'action1');

expect(esClient.create).toBeCalledTimes(2);
expect(esClient.create).toBeCalledWith(
expect.objectContaining({
body: expect.objectContaining({
type: 'CANCEL',
data: { target_id: 'action1' },
agents: ['agent1', 'agent2'],
}),
})
);
expect(esClient.create).toBeCalledWith(
expect.objectContaining({
body: expect.objectContaining({
type: 'CANCEL',
data: { target_id: 'action1' },
agents: ['agent3', 'agent4'],
}),
})
);
});
});
});
59 changes: 54 additions & 5 deletions x-pack/plugins/fleet/server/services/agents/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,22 @@ import type {
NewAgentAction,
FleetServerAgentAction,
} from '../../../common/types/models';
import { AGENT_ACTIONS_INDEX } from '../../../common/constants';
import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants';
import { AgentActionNotFoundError } from '../../errors';

const ONE_MONTH_IN_MS = 2592000000;

export async function createAgentAction(
esClient: ElasticsearchClient,
newAgentAction: NewAgentAction
): Promise<AgentAction> {
const id = newAgentAction.id ?? uuid.v4();
const actionId = newAgentAction.id ?? uuid.v4();
const timestamp = new Date().toISOString();
const body: FleetServerAgentAction = {
'@timestamp': timestamp,
expiration: newAgentAction.expiration ?? new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(),
agents: newAgentAction.agents,
action_id: id,
action_id: actionId,
data: newAgentAction.data,
type: newAgentAction.type,
start_time: newAgentAction.start_time,
Expand All @@ -37,13 +38,13 @@ export async function createAgentAction(

await esClient.create({
index: AGENT_ACTIONS_INDEX,
id,
id: uuid.v4(),
body,
refresh: 'wait_for',
});

return {
id,
id: actionId,
...newAgentAction,
created_at: timestamp,
};
Expand Down Expand Up @@ -93,9 +94,57 @@ export async function bulkCreateAgentActions(
return actions;
}

export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) {
const res = await esClient.search<FleetServerAgentAction>({
index: AGENT_ACTIONS_INDEX,
query: {
bool: {
must: [
{
term: {
action_id: actionId,
},
},
],
},
},
size: SO_SEARCH_LIMIT,
});

if (res.hits.hits.length === 0) {
throw new AgentActionNotFoundError('Action not found');
}

const cancelActionId = uuid.v4();
const now = new Date().toISOString();
for (const hit of res.hits.hits) {
if (!hit._source || !hit._source.agents || !hit._source.action_id) {
continue;
}
await createAgentAction(esClient, {
id: cancelActionId,
type: 'CANCEL',
agents: hit._source.agents,
data: {
target_id: hit._source.action_id,
},
created_at: now,
expiration: hit._source.expiration,
});
}

return {
created_at: now,
id: cancelActionId,
type: 'CANCEL',
} as AgentAction;
}

export interface ActionsService {
getAgent: (esClient: ElasticsearchClient, agentId: string) => Promise<Agent>;

cancelAgentAction: (esClient: ElasticsearchClient, actionId: string) => Promise<AgentAction>;

createAgentAction: (
esClient: ElasticsearchClient,
newAgentAction: Omit<AgentAction, 'id'>
Expand Down
6 changes: 6 additions & 0 deletions x-pack/plugins/fleet/server/types/rest_spec/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const PostNewAgentActionRequestSchema = {
}),
};

export const PostCancelActionRequestSchema = {
params: schema.object({
actionId: schema.string(),
}),
};

export const PostAgentUnenrollRequestSchema = {
params: schema.object({
agentId: schema.string(),
Expand Down
Loading

0 comments on commit 4dd84c2

Please sign in to comment.