kibana/x-pack/plugins/fleet/server/services/agents/crud.ts
John Schulz 1cc4725901
[Fleet] Add test/fix for invalid/missing ids in bulk agent reassign (#94632) (#94774)
## Problem
While working on changes for bulk reassign https://github.com/elastic/kibana/issues/90437, I found that the server has a runtime error and returns a 500 if given an invalid or missing id.

<details><summary>server error stack trace</summary>

```
   │ proc [kibana] server    log   [12:21:48.953] [error][fleet][plugins] TypeError: Cannot read property 'policy_revision_idx' of undefined
   │ proc [kibana]     at map (/Users/jfsiii/work/kibana/x-pack/plugins/fleet/server/services/agents/helpers.ts:15:34)
   │ proc [kibana]     at Array.map (<anonymous>)
   │ proc [kibana]     at getAgents (/Users/jfsiii/work/kibana/x-pack/plugins/fleet/server/services/agents/crud.ts:191:32)
   │ proc [kibana]     at runMicrotasks (<anonymous>)
   │ proc [kibana]     at processTicksAndRejections (internal/process/task_queues.js:93:5)
   │ proc [kibana]     at Object.reassignAgents (/Users/jfsiii/work/kibana/x-pack/plugins/fleet/server/services/agents/reassign.ts:91:9)
   │ proc [kibana]     at postBulkAgentsReassignHandler (/Users/jfsiii/work/kibana/x-pack/plugins/fleet/server/routes/agent/handlers.ts:314:21)
   │ proc [kibana]     at Router.handle (/Users/jfsiii/work/kibana/src/core/server/http/router/router.ts:272:30)
   │ proc [kibana]     at handler (/Users/jfsiii/work/kibana/src/core/server/http/router/router.ts:227:11)
   │ proc [kibana]     at exports.Manager.execute (/Users/jfsiii/work/kibana/node_modules/@hapi/hapi/lib/toolkit.js:60:28)
   │ proc [kibana]     at Object.internals.handler (/Users/jfsiii/work/kibana/node_modules/@hapi/hapi/lib/handler.js:46:20)
   │ proc [kibana]     at exports.execute (/Users/jfsiii/work/kibana/node_modules/@hapi/hapi/lib/handler.js:31:20)
   │ proc [kibana]     at Request._lifecycle (/Users/jfsiii/work/kibana/node_modules/@hapi/hapi/lib/request.js:370:32)
   │ proc [kibana]     at Request._execute (/Users/jfsiii/work/kibana/node_modules/@hapi/hapi/lib/request.js:279:9)
```
</details>

<details><summary>see test added in this PR fail on master</summary>

```
1)    Fleet Endpoints
       reassign agent(s)
         bulk reassign agents
           should allow to reassign multiple agents by id -- some invalid:

      Error: expected 200 "OK", got 500 "Internal Server Error"
```
</details>

## Root cause
Debugging runtime error in `searchHitToAgent` found some TS type mismatches for the ES values being returned. Perhaps from one or more of the recent changes to ES client & Fleet Server. Based on `test:jest` and `test:ftr`, it appears the possible types are `GetResponse` or `SearchResponse`, instead of only an `ESSearchHit`.

https://github.com/elastic/kibana/pull/94632/files#diff-254d0f427979efc3b442f78762302eb28fb9c8857df68ea04f8d411e052f939cL11

While a `.search` result will include return matched values, a `.get` or `.mget` will return a row for each input and a `found: boolean`. e.g. `{ _id: "does-not-exist", found: false }`. The error occurs when [`searchHitToAgent`](1702cf98f0/x-pack/plugins/fleet/server/services/agents/helpers.ts (L11)) is run on a get miss instead of a search hit.

## PR Changes
* Added a test to ensure it doesn't fail if invalid or missing IDs are given
* Moved the `bulk_reassign` tests to their own test section
* Filter out any missing results before calling `searchHitToAgent`, to match current behavior
* Consolidate repeated arguments into and code for getting agents into single [function](https://github.com/elastic/kibana/pull/94632/files#diff-f7377ed9ad56eaa8ea188b64e957e771ccc7a7652fd1eaf44251c25b930f8448R70-R87):  and [TS type](https://github.com/elastic/kibana/pull/94632/files#diff-f7377ed9ad56eaa8ea188b64e957e771ccc7a7652fd1eaf44251c25b930f8448R61-R68)
* Rename some agent service functions to be more explicit (IMO) but behavior maintained. Same API names exported.

This moves toward the "one result (success or error) per given id" approach for https://github.com/elastic/kibana/issues/90437

### Checklist
- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios

# Conflicts:
#	x-pack/plugins/fleet/server/services/agents/crud.ts
#	x-pack/plugins/fleet/server/services/index.ts
2021-03-16 23:50:21 -04:00

340 lines
8.9 KiB
TypeScript

/*
* 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 Boom from '@hapi/boom';
import type { SearchResponse, MGetResponse, GetResponse } from 'elasticsearch';
import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server';
import type { AgentSOAttributes, Agent, ListWithKuery } from '../../types';
import { appContextService, agentPolicyService } from '../../services';
import type { FleetServerAgent } from '../../../common';
import { isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common';
import { AGENT_SAVED_OBJECT_TYPE, AGENTS_INDEX } from '../../constants';
import { escapeSearchQueryPhrase, normalizeKuery } from '../saved_object';
import type { KueryNode } from '../../../../../../src/plugins/data/server';
import { esKuery } from '../../../../../../src/plugins/data/server';
import { IngestManagerError, isESClientError, AgentNotFoundError } from '../../errors';
import { searchHitToAgent, agentSOAttributesToFleetServerAgentDoc } from './helpers';
const ACTIVE_AGENT_CONDITION = 'active:true';
const INACTIVE_AGENT_CONDITION = `NOT (${ACTIVE_AGENT_CONDITION})`;
function _joinFilters(filters: Array<string | undefined | KueryNode>): KueryNode | undefined {
try {
return filters
.filter((filter) => filter !== undefined)
.reduce((acc: KueryNode | undefined, kuery: string | KueryNode | undefined):
| KueryNode
| undefined => {
if (kuery === undefined) {
return acc;
}
const kueryNode: KueryNode =
typeof kuery === 'string'
? esKuery.fromKueryExpression(removeSOAttributes(kuery))
: kuery;
if (!acc) {
return kueryNode;
}
return {
type: 'function',
function: 'and',
arguments: [acc, kueryNode],
};
}, undefined as KueryNode | undefined);
} catch (err) {
throw new IngestManagerError(`Kuery is malformed: ${err.message}`);
}
}
export function removeSOAttributes(kuery: string) {
return kuery.replace(/attributes\./g, '').replace(/fleet-agents\./g, '');
}
export type GetAgentsOptions =
| {
agentIds: string[];
}
| {
kuery: string;
showInactive?: boolean;
};
export async function getAgents(esClient: ElasticsearchClient, options: GetAgentsOptions) {
let initialResults = [];
if ('agentIds' in options) {
initialResults = await getAgentsById(esClient, options.agentIds);
} else if ('kuery' in options) {
initialResults = (
await getAllAgentsByKuery(esClient, {
kuery: options.kuery,
showInactive: options.showInactive ?? false,
})
).agents;
} else {
throw new IngestManagerError('Cannot get agents');
}
return initialResults;
}
export async function getAgentsByKuery(
esClient: ElasticsearchClient,
options: ListWithKuery & {
showInactive: boolean;
}
): Promise<{
agents: Agent[];
total: number;
page: number;
perPage: number;
}> {
const {
page = 1,
perPage = 20,
sortField = 'enrolled_at',
sortOrder = 'desc',
kuery,
showInactive = false,
showUpgradeable,
} = options;
const filters = [];
if (kuery && kuery !== '') {
filters.push(kuery);
}
if (showInactive === false) {
filters.push(ACTIVE_AGENT_CONDITION);
}
const kueryNode = _joinFilters(filters);
const body = kueryNode ? { query: esKuery.toElasticsearchQuery(kueryNode) } : {};
const res = await esClient.search<SearchResponse<FleetServerAgent>>({
index: AGENTS_INDEX,
from: (page - 1) * perPage,
size: perPage,
sort: `${sortField}:${sortOrder}`,
track_total_hits: true,
body,
});
let agents = res.body.hits.hits.map(searchHitToAgent);
// filtering for a range on the version string will not work,
// nor does filtering on a flattened field (local_metadata), so filter here
if (showUpgradeable) {
agents = agents.filter((agent) =>
isAgentUpgradeable(agent, appContextService.getKibanaVersion())
);
}
return {
agents,
total: agents.length,
page,
perPage,
};
}
export async function getAllAgentsByKuery(
esClient: ElasticsearchClient,
options: Omit<ListWithKuery, 'page' | 'perPage'> & {
showInactive: boolean;
}
): Promise<{
agents: Agent[];
total: number;
}> {
const res = await getAgentsByKuery(esClient, { ...options, page: 1, perPage: SO_SEARCH_LIMIT });
return {
agents: res.agents,
total: res.total,
};
}
export async function countInactiveAgents(
esClient: ElasticsearchClient,
options: Pick<ListWithKuery, 'kuery'>
): Promise<number> {
const { kuery } = options;
const filters = [INACTIVE_AGENT_CONDITION];
if (kuery && kuery !== '') {
filters.push(normalizeKuery(AGENT_SAVED_OBJECT_TYPE, kuery));
}
const kueryNode = _joinFilters(filters);
const body = kueryNode ? { query: esKuery.toElasticsearchQuery(kueryNode) } : {};
const res = await esClient.search({
index: AGENTS_INDEX,
size: 0,
track_total_hits: true,
body,
});
return res.body.hits.total.value;
}
export async function getAgentById(esClient: ElasticsearchClient, agentId: string) {
const agentNotFoundError = new AgentNotFoundError(`Agent ${agentId} not found`);
try {
const agentHit = await esClient.get<GetResponse<FleetServerAgent>>({
index: AGENTS_INDEX,
id: agentId,
});
if (agentHit.body.found === false) {
throw agentNotFoundError;
}
const agent = searchHitToAgent(agentHit.body);
return agent;
} catch (err) {
if (isESClientError(err) && err.meta.statusCode === 404) {
throw agentNotFoundError;
}
throw err;
}
}
async function getAgentDocuments(
esClient: ElasticsearchClient,
agentIds: string[]
): Promise<Array<GetResponse<FleetServerAgent>>> {
const res = await esClient.mget<MGetResponse<FleetServerAgent>>({
index: AGENTS_INDEX,
body: { docs: agentIds.map((_id) => ({ _id })) },
});
return res.body.docs || [];
}
export async function getAgentsById(
esClient: ElasticsearchClient,
agentIds: string[],
options: { includeMissing?: boolean } = { includeMissing: false }
): Promise<Agent[]> {
const allDocs = await getAgentDocuments(esClient, agentIds);
const agentDocs = options.includeMissing
? allDocs
: allDocs.filter((res) => res._id && res._source);
const agents = agentDocs.map((doc) => searchHitToAgent(doc));
return agents;
}
export async function getAgentByAccessAPIKeyId(
esClient: ElasticsearchClient,
accessAPIKeyId: string
): Promise<Agent> {
const res = await esClient.search<SearchResponse<FleetServerAgent>>({
index: AGENTS_INDEX,
q: `access_api_key_id:${escapeSearchQueryPhrase(accessAPIKeyId)}`,
});
const agent = searchHitToAgent(res.body.hits.hits[0]);
if (!agent) {
throw new AgentNotFoundError('Agent not found');
}
if (agent.access_api_key_id !== accessAPIKeyId) {
throw new Error('Agent api key id is not matching');
}
if (!agent.active) {
throw Boom.forbidden('Agent inactive');
}
return agent;
}
export async function updateAgent(
esClient: ElasticsearchClient,
agentId: string,
data: Partial<AgentSOAttributes>
) {
await esClient.update({
id: agentId,
index: AGENTS_INDEX,
body: { doc: agentSOAttributesToFleetServerAgentDoc(data) },
refresh: 'wait_for',
});
}
export async function bulkUpdateAgents(
esClient: ElasticsearchClient,
updateData: Array<{
agentId: string;
data: Partial<AgentSOAttributes>;
}>
) {
if (updateData.length === 0) {
return { items: [] };
}
const body = updateData.flatMap(({ agentId, data }) => [
{
update: {
_id: agentId,
},
},
{
doc: { ...agentSOAttributesToFleetServerAgentDoc(data) },
},
]);
const res = await esClient.bulk({
body,
index: AGENTS_INDEX,
refresh: 'wait_for',
});
return {
items: res.body.items.map((item: { update: { _id: string; error?: Error } }) => ({
id: item.update._id,
success: !item.update.error,
error: item.update.error,
})),
};
}
export async function deleteAgent(esClient: ElasticsearchClient, agentId: string) {
try {
await esClient.update({
id: agentId,
index: AGENTS_INDEX,
body: {
doc: { active: false },
},
});
} catch (err) {
if (isESClientError(err) && err.meta.statusCode === 404) {
throw new AgentNotFoundError('Agent not found');
}
throw err;
}
}
export async function getAgentPolicyForAgent(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
agentId: string
) {
const agent = await getAgentById(esClient, agentId);
if (!agent.policy_id) {
return;
}
const agentPolicy = await agentPolicyService.get(soClient, agent.policy_id, false);
if (agentPolicy) {
return agentPolicy;
}
}