[Fleet] Disable Request Diagnostics for agents < 8.7 and added API validation (#150555)

## Summary

Fixes https://github.com/elastic/kibana/issues/150300

Disable button on single agent Request Diagnostics action if agent does
not support it (<8.7 version).
Added a tooltip as well on Diagnostics tab button.

Added validation to single and bulk API to reject actions where agent is
older.

<img width="1522" alt="image"
src="https://user-images.githubusercontent.com/90178898/217548484-39b2e86b-52b3-41e1-bc54-210e836b68c8.png">
<img width="1236" alt="image"
src="https://user-images.githubusercontent.com/90178898/217548683-e103650c-c239-458f-965d-01cae7cda769.png">

When bulk actioning with one new and one old version, one of the agents
will fail (visible in Agent activity)

<img width="649" alt="image"
src="https://user-images.githubusercontent.com/90178898/217548843-2a128bc8-b733-4682-a382-0800fadced0c.png">


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [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

---------

Co-authored-by: Kyle Pollich <kpollich1@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Bardi 2023-02-09 13:43:52 +01:00 committed by GitHub
parent d341cc3157
commit 050343b6ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 299 additions and 16 deletions

View file

@ -20,6 +20,10 @@ export { isValidNamespace, INVALID_NAMESPACE_CHARACTERS } from './is_valid_names
export { isDiffPathProtocol } from './is_diff_path_protocol';
export { LicenseService } from './license';
export { isAgentUpgradeable } from './is_agent_upgradeable';
export {
isAgentRequestDiagnosticsSupported,
MINIMUM_DIAGNOSTICS_AGENT_VERSION,
} from './is_agent_request_diagnostics_supported';
export {
isInputOnlyPolicyTemplate,
isIntegrationPolicyTemplate,

View file

@ -0,0 +1,42 @@
/*
* 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 type { Agent } from '../types/models/agent';
import { isAgentRequestDiagnosticsSupported } from '.';
const getAgent = ({ version }: { version: string }): Agent => {
const agent: Agent = {
id: 'agent1',
active: true,
type: 'PERMANENT',
enrolled_at: '2023-02-08T20:24:08.347Z',
user_provided_metadata: {},
local_metadata: {
elastic: {
agent: {
version,
},
},
},
packages: ['system'],
};
return agent;
};
describe('Fleet - isAgentRequestDiagnosticsSupported', () => {
it('returns false if agent version < 8.7', () => {
expect(isAgentRequestDiagnosticsSupported(getAgent({ version: '7.9.0' }))).toBe(false);
});
it('returns true if agent version is 8.7', () => {
expect(isAgentRequestDiagnosticsSupported(getAgent({ version: '8.7.0' }))).toBe(true);
});
it('returns true if agent version > 8.7', () => {
expect(isAgentRequestDiagnosticsSupported(getAgent({ version: '8.8.0' }))).toBe(true);
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 semverGte from 'semver/functions/gte';
import type { Agent } from '../types';
export const MINIMUM_DIAGNOSTICS_AGENT_VERSION = '8.7.0';
export function isAgentRequestDiagnosticsSupported(agent: Agent) {
if (typeof agent?.local_metadata?.elastic?.agent?.version !== 'string') {
return false;
}
const agentVersion = agent.local_metadata.elastic.agent.version;
return semverGte(agentVersion, MINIMUM_DIAGNOSTICS_AGENT_VERSION);
}

View file

@ -9,6 +9,8 @@ import React, { memo, useState, useMemo } from 'react';
import { EuiPortal, EuiContextMenuItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isAgentRequestDiagnosticsSupported } from '../../../../../../../common/services';
import type { Agent, AgentPolicy } from '../../../../types';
import { useAuthz, useKibanaVersion } from '../../../../hooks';
import { ContextMenuActions } from '../../../../components';
@ -99,7 +101,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{
menuItems.push(
<EuiContextMenuItem
icon="download"
disabled={!hasFleetAllPrivileges}
disabled={!hasFleetAllPrivileges || !isAgentRequestDiagnosticsSupported(agent)}
onClick={() => {
setIsRequestDiagnosticsModalOpen(true);
}}

View file

@ -25,6 +25,11 @@ import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
isAgentRequestDiagnosticsSupported,
MINIMUM_DIAGNOSTICS_AGENT_VERSION,
} from '../../../../../../../../common/services';
import {
sendGetAgentUploads,
sendPostRequestDiagnostics,
@ -215,6 +220,20 @@ export const AgentDiagnosticsTab: React.FunctionComponent<AgentDiagnosticsProps>
}
}
const requestDiagnosticsButton = (
<EuiButton
fill
size="m"
onClick={onSubmit}
disabled={isSubmitting || !isAgentRequestDiagnosticsSupported(agent)}
>
<FormattedMessage
id="xpack.fleet.agentList.diagnosticsOneButton"
defaultMessage="Request diagnostics .zip"
/>
</EuiButton>
);
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexItem>
@ -235,12 +254,21 @@ export const AgentDiagnosticsTab: React.FunctionComponent<AgentDiagnosticsProps>
</EuiCallOut>
</EuiFlexItem>
<FlexStartEuiFlexItem>
<EuiButton fill size="m" onClick={onSubmit} disabled={isSubmitting}>
<FormattedMessage
id="xpack.fleet.agentList.diagnosticsOneButton"
defaultMessage="Request diagnostics .zip"
/>
</EuiButton>
{isAgentRequestDiagnosticsSupported(agent) ? (
requestDiagnosticsButton
) : (
<EuiToolTip
content={
<FormattedMessage
id="xpack.fleet.requestDiagnostics.notSupportedTooltip"
defaultMessage="Requesting agent diagnostics is not supported for agents before version {version}."
values={{ version: MINIMUM_DIAGNOSTICS_AGENT_VERSION }}
/>
}
>
{requestDiagnosticsButton}
</EuiToolTip>
)}
</FlexStartEuiFlexItem>
<EuiFlexItem>
{isLoading ? (

View file

@ -9,6 +9,8 @@ import React, { useState } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isAgentRequestDiagnosticsSupported } from '../../../../../../../common/services';
import type { Agent, AgentPolicy } from '../../../../types';
import { useAuthz, useLink, useKibanaVersion } from '../../../../hooks';
import { ContextMenuActions } from '../../../../components';
@ -114,7 +116,7 @@ export const TableRowActions: React.FunctionComponent<{
menuItems.push(
<EuiContextMenuItem
icon="download"
disabled={!hasFleetAllPrivileges}
disabled={!hasFleetAllPrivileges || !isAgentRequestDiagnosticsSupported(agent)}
onClick={() => {
onRequestDiagnosticsClick();
}}

View file

@ -0,0 +1,78 @@
/*
* 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 type {
ElasticsearchClient,
KibanaResponseFactory,
RequestHandlerContext,
SavedObjectsClientContract,
KibanaRequest,
} from '@kbn/core/server';
import {
elasticsearchServiceMock,
savedObjectsClientMock,
httpServerMock,
} from '@kbn/core/server/mocks';
import { getAgentById } from '../../services/agents';
import * as AgentService from '../../services/agents';
import { requestDiagnosticsHandler } from './request_diagnostics_handler';
jest.mock('../../services/agents');
const mockGetAgentById = getAgentById as jest.Mock;
describe('request diagnostics handler', () => {
let mockResponse: jest.Mocked<KibanaResponseFactory>;
let mockSavedObjectsClient: jest.Mocked<SavedObjectsClientContract>;
let mockElasticsearchClient: jest.Mocked<ElasticsearchClient>;
let mockContext: RequestHandlerContext;
let mockRequest: KibanaRequest<{ agentId: string }, undefined, undefined, any>;
beforeEach(() => {
mockSavedObjectsClient = savedObjectsClientMock.create();
mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockResponse = httpServerMock.createResponseFactory();
jest.spyOn(AgentService, 'requestDiagnostics').mockResolvedValue({ actionId: '1' });
mockContext = {
core: {
savedObjects: {
client: mockSavedObjectsClient,
},
elasticsearch: {
client: {
asInternalUser: mockElasticsearchClient,
},
},
},
} as unknown as RequestHandlerContext;
mockRequest = httpServerMock.createKibanaRequest({ params: { agentId: 'agent1' } });
});
it('should return ok if agent supports request diagnostics', async () => {
mockGetAgentById.mockResolvedValueOnce({
local_metadata: { elastic: { agent: { version: '8.7.0' } } },
});
await requestDiagnosticsHandler(mockContext, mockRequest, mockResponse);
expect(mockResponse.ok).toHaveBeenCalledWith({ body: { actionId: '1' } });
});
it('should retur error if agent does not support request diagnostics', async () => {
mockGetAgentById.mockResolvedValueOnce({
local_metadata: { elastic: { agent: { version: '8.6.0' } } },
});
await requestDiagnosticsHandler(mockContext, mockRequest, mockResponse);
expect(mockResponse.customError).toHaveBeenCalledWith({
body: { message: 'Agent agent1 does not support request diagnostics action.' },
statusCode: 400,
});
});
});

View file

@ -8,12 +8,15 @@
import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { isAgentRequestDiagnosticsSupported } from '../../../common/services';
import * as AgentService from '../../services/agents';
import type {
PostBulkRequestDiagnosticsActionRequestSchema,
PostRequestDiagnosticsActionRequestSchema,
} from '../../types';
import { defaultFleetErrorHandler } from '../../errors';
import { getAgentById } from '../../services/agents';
export const requestDiagnosticsHandler: RequestHandler<
TypeOf<typeof PostRequestDiagnosticsActionRequestSchema.params>,
@ -22,7 +25,19 @@ export const requestDiagnosticsHandler: RequestHandler<
> = async (context, request, response) => {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;
try {
const agent = await getAgentById(esClient, soClient, request.params.agentId);
if (!isAgentRequestDiagnosticsSupported(agent)) {
return response.customError({
statusCode: 400,
body: {
message: `Agent ${request.params.agentId} does not support request diagnostics action.`,
},
});
}
const result = await AgentService.requestDiagnostics(esClient, request.params.agentId);
return response.ok({ body: { actionId: result.actionId } });

View file

@ -56,6 +56,28 @@ export function createClientMock() {
status: ['online'],
},
};
const agentInRegularDocNewer = {
_id: 'agent-in-regular-policy-newer',
_index: 'index',
_source: {
policy_id: 'regular-agent-policy',
local_metadata: { elastic: { agent: { version: '8.7.0', upgradeable: true } } },
},
fields: {
status: ['online'],
},
};
const agentInRegularDocNewer2 = {
_id: 'agent-in-regular-policy-newer2',
_index: 'index',
_source: {
policy_id: 'regular-agent-policy',
local_metadata: { elastic: { agent: { version: '8.7.0', upgradeable: true } } },
},
fields: {
status: ['online'],
},
};
const regularAgentPolicySO = {
id: 'regular-agent-policy',
attributes: { is_managed: false },
@ -109,6 +131,10 @@ export function createClientMock() {
return { body: agentInRegularDoc2 };
case agentInRegularDoc._id:
return { body: agentInRegularDoc };
case agentInRegularDocNewer._id:
return { body: agentInRegularDocNewer };
case agentInRegularDocNewer2._id:
return { body: agentInRegularDocNewer2 };
default:
throw new Error('not found');
}
@ -135,6 +161,12 @@ export function createClientMock() {
case agentInRegularDoc._id:
result = agentInRegularDoc;
break;
case agentInRegularDocNewer._id:
result = agentInRegularDocNewer;
break;
case agentInRegularDocNewer2._id:
result = agentInRegularDocNewer2;
break;
default:
throw new Error('not found');
}
@ -157,9 +189,15 @@ export function createClientMock() {
total: 1,
},
hits: {
hits: [agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2],
hits: [
agentInHostedDoc,
agentInRegularDoc,
agentInRegularDoc2,
agentInRegularDocNewer,
agentInRegularDocNewer2,
],
total: {
value: 3,
value: 5,
relation: 'eq',
},
},
@ -173,6 +211,8 @@ export function createClientMock() {
agentInHostedDoc2,
agentInRegularDoc,
agentInRegularDoc2,
agentInRegularDocNewer,
agentInRegularDocNewer2,
regularAgentPolicySO,
hostedAgentPolicySO,
regularAgentPolicySO2,

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { appContextService } from '../app_context';
import { createAppContextStartContractMock } from '../../mocks';
@ -37,18 +38,45 @@ describe('requestDiagnostics (plural)', () => {
appContextService.stop();
});
it('can request diagnostics for multiple agents', async () => {
const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock();
const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id];
await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToUnenroll });
const { soClient, esClient, agentInRegularDocNewer, agentInRegularDocNewer2 } =
createClientMock();
const idsToAction = [agentInRegularDocNewer._id, agentInRegularDocNewer2._id];
await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToAction });
expect(esClient.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
agents: ['agent-in-regular-policy', 'agent-in-regular-policy2'],
agents: ['agent-in-regular-policy-newer', 'agent-in-regular-policy-newer2'],
type: 'REQUEST_DIAGNOSTICS',
}),
index: '.fleet-actions',
})
);
});
it('should report error when diagnostics for older agent', async () => {
const { soClient, esClient, agentInRegularDoc, agentInRegularDocNewer } = createClientMock();
const idsToAction = [agentInRegularDocNewer._id, agentInRegularDoc._id];
await bulkRequestDiagnostics(esClient, soClient, { agentIds: idsToAction });
expect(esClient.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
agents: ['agent-in-regular-policy-newer', 'agent-in-regular-policy'],
type: 'REQUEST_DIAGNOSTICS',
}),
index: '.fleet-actions',
})
);
const calledWithActionResults = esClient.bulk.mock.calls[0][0] as estypes.BulkRequest;
// bulk write two line per create
expect(calledWithActionResults.body?.length).toBe(2);
const expectedObject = expect.objectContaining({
'@timestamp': expect.anything(),
action_id: expect.anything(),
agent_id: 'agent-in-regular-policy',
error: 'Agent agent-in-regular-policy does not support request diagnostics action.',
});
expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject);
});
});

View file

@ -8,10 +8,14 @@
import { v4 as uuidv4 } from 'uuid';
import type { ElasticsearchClient } from '@kbn/core/server';
import { isAgentRequestDiagnosticsSupported } from '../../../common/services';
import type { Agent } from '../../types';
import { FleetError } from '../../errors';
import { ActionRunner } from './action_runner';
import { createAgentAction } from './actions';
import { createAgentAction, createErrorActionResults } from './actions';
import { BulkActionTaskType } from './bulk_action_types';
export class RequestDiagnosticsActionRunner extends ActionRunner {
@ -36,11 +40,20 @@ export async function requestDiagnosticsBatch(
total?: number;
}
): Promise<{ actionId: string }> {
const errors: Record<Agent['id'], Error> = {};
const now = new Date().toISOString();
const actionId = options.actionId ?? uuidv4();
const total = options.total ?? givenAgents.length;
givenAgents.forEach((agent: Agent) => {
if (!isAgentRequestDiagnosticsSupported(agent)) {
errors[agent.id] = new FleetError(
`Agent ${agent.id} does not support request diagnostics action.`
);
}
});
const agentIds = givenAgents.map((agent) => agent.id);
await createAgentAction(esClient, {
@ -51,6 +64,13 @@ export async function requestDiagnosticsBatch(
total,
});
await createErrorActionResults(
esClient,
actionId,
errors,
'agent does not support request diagnostics action'
);
return {
actionId,
};

View file

@ -8,7 +8,11 @@
"active": true,
"policy_id": "policy1",
"type": "PERMANENT",
"local_metadata": { "host": {"hostname": "host1"}},
"local_metadata": { "host": {"hostname": "host1"}, "elastic": {
"agent": {
"version": "8.7.0"
}
}},
"user_provided_metadata": {},
"enrolled_at": "2022-06-21T12:14:25Z",
"last_checkin": "2022-06-27T12:26:29Z",