mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
d341cc3157
commit
050343b6ab
12 changed files with 299 additions and 16 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 } });
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue