mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Fleet] added task to check result of bulk action, saving error action results (#140833)
* added task to check result of bulk action, saving error action results * added api test for action_status * fixed tests * hide tour on finish * added upgrade unit test, refactored common action mocks to one place * fixed test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
0492b125bb
commit
2404c8bce4
14 changed files with 771 additions and 414 deletions
|
@ -61,7 +61,7 @@ export const AgentActivityButton: React.FC<{
|
|||
</EuiText>
|
||||
}
|
||||
isStepOpen={agentActivityTourState.isOpen}
|
||||
onFinish={() => setAgentActivityTourState({ isOpen: false })}
|
||||
onFinish={onFinish}
|
||||
minWidth={360}
|
||||
maxWidth={360}
|
||||
step={1}
|
||||
|
|
138
x-pack/plugins/fleet/server/services/agents/action.mock.ts
Normal file
138
x-pack/plugins/fleet/server/services/agents/action.mock.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* 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, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
|
||||
import type { SavedObject } from '@kbn/core-saved-objects-common';
|
||||
|
||||
import type { AgentPolicy } from '../../types';
|
||||
|
||||
export function createClientMock() {
|
||||
const agentInHostedDoc = {
|
||||
_id: 'agent-in-hosted-policy',
|
||||
_source: {
|
||||
policy_id: 'hosted-agent-policy',
|
||||
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
|
||||
},
|
||||
};
|
||||
const agentInHostedDoc2 = {
|
||||
_id: 'agent-in-hosted-policy2',
|
||||
_source: {
|
||||
policy_id: 'hosted-agent-policy',
|
||||
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
|
||||
},
|
||||
};
|
||||
const agentInRegularDoc = {
|
||||
_id: 'agent-in-regular-policy',
|
||||
_source: {
|
||||
policy_id: 'regular-agent-policy',
|
||||
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
|
||||
},
|
||||
};
|
||||
const agentInRegularDoc2 = {
|
||||
_id: 'agent-in-regular-policy2',
|
||||
_source: {
|
||||
policy_id: 'regular-agent-policy',
|
||||
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
|
||||
},
|
||||
};
|
||||
const regularAgentPolicySO = {
|
||||
id: 'regular-agent-policy',
|
||||
attributes: { is_managed: false },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
const regularAgentPolicySO2 = {
|
||||
id: 'regular-agent-policy-2',
|
||||
attributes: { is_managed: false },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
const hostedAgentPolicySO = {
|
||||
id: 'hosted-agent-policy',
|
||||
attributes: { is_managed: true },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
|
||||
const soClientMock = savedObjectsClientMock.create();
|
||||
|
||||
soClientMock.get.mockImplementation(async (_, id) => {
|
||||
switch (id) {
|
||||
case regularAgentPolicySO.id:
|
||||
return regularAgentPolicySO;
|
||||
case hostedAgentPolicySO.id:
|
||||
return hostedAgentPolicySO;
|
||||
case regularAgentPolicySO2.id:
|
||||
return regularAgentPolicySO2;
|
||||
default:
|
||||
throw new Error('not found');
|
||||
}
|
||||
});
|
||||
|
||||
soClientMock.bulkGet.mockImplementation(async (options) => {
|
||||
return {
|
||||
saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))),
|
||||
};
|
||||
});
|
||||
|
||||
const esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser;
|
||||
// @ts-expect-error
|
||||
esClientMock.get.mockResponseImplementation(({ id }) => {
|
||||
switch (id) {
|
||||
case agentInHostedDoc._id:
|
||||
return { body: agentInHostedDoc };
|
||||
case agentInHostedDoc2._id:
|
||||
return { body: agentInHostedDoc2 };
|
||||
case agentInRegularDoc2._id:
|
||||
return { body: agentInRegularDoc2 };
|
||||
case agentInRegularDoc._id:
|
||||
return { body: agentInRegularDoc };
|
||||
default:
|
||||
throw new Error('not found');
|
||||
}
|
||||
});
|
||||
esClientMock.bulk.mockResponse(
|
||||
// @ts-expect-error not full interface
|
||||
{ items: [] }
|
||||
);
|
||||
|
||||
esClientMock.mget.mockResponseImplementation((params) => {
|
||||
// @ts-expect-error
|
||||
const docs = params?.body.docs.map(({ _id }) => {
|
||||
let result;
|
||||
switch (_id) {
|
||||
case agentInHostedDoc._id:
|
||||
result = agentInHostedDoc;
|
||||
break;
|
||||
case agentInHostedDoc2._id:
|
||||
result = agentInHostedDoc2;
|
||||
break;
|
||||
case agentInRegularDoc2._id:
|
||||
result = agentInRegularDoc2;
|
||||
break;
|
||||
case agentInRegularDoc._id:
|
||||
result = agentInRegularDoc;
|
||||
break;
|
||||
default:
|
||||
throw new Error('not found');
|
||||
}
|
||||
return { found: true, ...result };
|
||||
});
|
||||
return {
|
||||
body: {
|
||||
docs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
soClient: soClientMock,
|
||||
esClient: esClientMock,
|
||||
agentInHostedDoc,
|
||||
agentInHostedDoc2,
|
||||
agentInRegularDoc,
|
||||
agentInRegularDoc2,
|
||||
regularAgentPolicySO,
|
||||
hostedAgentPolicySO,
|
||||
regularAgentPolicySO2,
|
||||
};
|
||||
}
|
|
@ -12,12 +12,15 @@ import { withSpan } from '@kbn/apm-utils';
|
|||
|
||||
import { isResponseError } from '@kbn/es-errors';
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import type { Agent, BulkActionResult } from '../../types';
|
||||
import { appContextService } from '..';
|
||||
import { SO_SEARCH_LIMIT } from '../../../common/constants';
|
||||
|
||||
import { getAgentActions } from './actions';
|
||||
import { closePointInTime, getAgentsByKuery } from './crud';
|
||||
import type { BulkActionsResolver } from './bulk_actions_resolver';
|
||||
|
||||
export interface ActionParams {
|
||||
kuery: string;
|
||||
|
@ -43,6 +46,9 @@ export abstract class ActionRunner {
|
|||
protected actionParams: ActionParams;
|
||||
protected retryParams: RetryParams;
|
||||
|
||||
private bulkActionsResolver?: BulkActionsResolver;
|
||||
private checkTaskId?: string;
|
||||
|
||||
constructor(
|
||||
esClient: ElasticsearchClient,
|
||||
soClient: SavedObjectsClientContract,
|
||||
|
@ -74,46 +80,72 @@ export abstract class ActionRunner {
|
|||
`Running action asynchronously, actionId: ${this.actionParams.actionId}, total agents: ${this.actionParams.total}`
|
||||
);
|
||||
|
||||
withSpan({ name: this.getActionType(), type: 'action' }, () =>
|
||||
this.processAgentsInBatches().catch(async (error) => {
|
||||
// 404 error comes when PIT query is closed
|
||||
if (isResponseError(error) && error.statusCode === 404) {
|
||||
const errorMessage =
|
||||
'404 error from elasticsearch, not retrying. Error: ' + error.message;
|
||||
appContextService.getLogger().warn(errorMessage);
|
||||
return;
|
||||
}
|
||||
if (this.retryParams.retryCount) {
|
||||
appContextService
|
||||
.getLogger()
|
||||
.error(
|
||||
`Retry #${this.retryParams.retryCount} of task ${this.retryParams.taskId} failed: ${error.message}`
|
||||
);
|
||||
if (!this.bulkActionsResolver) {
|
||||
this.bulkActionsResolver = await appContextService.getBulkActionsResolver();
|
||||
}
|
||||
|
||||
if (this.retryParams.retryCount === 3) {
|
||||
const errorMessage = 'Stopping after 3rd retry. Error: ' + error.message;
|
||||
// create task to check result with some delay, this runs in case of kibana crash too
|
||||
this.checkTaskId = await this.createCheckResultTask();
|
||||
|
||||
withSpan({ name: this.getActionType(), type: 'action' }, () =>
|
||||
this.processAgentsInBatches()
|
||||
.then(() => {
|
||||
if (this.checkTaskId) {
|
||||
// no need for check task, action succeeded
|
||||
this.bulkActionsResolver!.removeIfExists(this.checkTaskId);
|
||||
}
|
||||
})
|
||||
.catch(async (error) => {
|
||||
// 404 error comes when PIT query is closed
|
||||
if (isResponseError(error) && error.statusCode === 404) {
|
||||
const errorMessage =
|
||||
'404 error from elasticsearch, not retrying. Error: ' + error.message;
|
||||
appContextService.getLogger().warn(errorMessage);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
appContextService.getLogger().error(`Action failed: ${error.message}`);
|
||||
}
|
||||
const taskId = await appContextService.getBulkActionsResolver()!.run(
|
||||
this.actionParams,
|
||||
{
|
||||
...this.retryParams,
|
||||
retryCount: (this.retryParams.retryCount ?? 0) + 1,
|
||||
},
|
||||
this.getTaskType()
|
||||
);
|
||||
if (this.retryParams.retryCount) {
|
||||
appContextService
|
||||
.getLogger()
|
||||
.error(
|
||||
`Retry #${this.retryParams.retryCount} of task ${this.retryParams.taskId} failed: ${error.message}`
|
||||
);
|
||||
|
||||
appContextService.getLogger().info(`Retrying in task: ${taskId}`);
|
||||
})
|
||||
if (this.retryParams.retryCount === 3) {
|
||||
const errorMessage = 'Stopping after 3rd retry. Error: ' + error.message;
|
||||
appContextService.getLogger().warn(errorMessage);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
appContextService.getLogger().error(`Action failed: ${error.message}`);
|
||||
}
|
||||
const taskId = await this.bulkActionsResolver!.run(
|
||||
this.actionParams,
|
||||
{
|
||||
...this.retryParams,
|
||||
retryCount: (this.retryParams.retryCount ?? 0) + 1,
|
||||
},
|
||||
this.getTaskType()
|
||||
);
|
||||
|
||||
appContextService.getLogger().info(`Retrying in task: ${taskId}`);
|
||||
})
|
||||
);
|
||||
|
||||
return { items: [], actionId: this.actionParams.actionId! };
|
||||
}
|
||||
|
||||
private async createCheckResultTask() {
|
||||
return await this.bulkActionsResolver!.run(
|
||||
this.actionParams,
|
||||
{
|
||||
...this.retryParams,
|
||||
retryCount: 1,
|
||||
},
|
||||
this.getTaskType(),
|
||||
moment(new Date()).add(5, 'm').toDate()
|
||||
);
|
||||
}
|
||||
|
||||
private async processBatch(agents: Agent[]): Promise<{ items: BulkActionResult[] }> {
|
||||
if (this.retryParams.retryCount) {
|
||||
try {
|
||||
|
@ -176,6 +208,11 @@ export abstract class ActionRunner {
|
|||
const currentResults = await this.processBatch(currentAgents);
|
||||
results = { items: results.items.concat(currentResults.items) };
|
||||
allAgentsProcessed += currentAgents.length;
|
||||
if (this.checkTaskId) {
|
||||
// updating check task with latest checkpoint (this.retryParams.searchAfter)
|
||||
this.bulkActionsResolver?.removeIfExists(this.checkTaskId);
|
||||
this.checkTaskId = await this.createCheckResultTask();
|
||||
}
|
||||
}
|
||||
|
||||
await closePointInTime(this.esClient, pitId!);
|
||||
|
|
|
@ -14,7 +14,11 @@ import type {
|
|||
NewAgentAction,
|
||||
FleetServerAgentAction,
|
||||
} from '../../../common/types/models';
|
||||
import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants';
|
||||
import {
|
||||
AGENT_ACTIONS_INDEX,
|
||||
AGENT_ACTIONS_RESULTS_INDEX,
|
||||
SO_SEARCH_LIMIT,
|
||||
} from '../../../common/constants';
|
||||
import { AgentActionNotFoundError } from '../../errors';
|
||||
|
||||
import { bulkUpdateAgents } from './crud';
|
||||
|
@ -97,6 +101,42 @@ export async function bulkCreateAgentActions(
|
|||
return actions;
|
||||
}
|
||||
|
||||
export async function bulkCreateAgentActionResults(
|
||||
esClient: ElasticsearchClient,
|
||||
results: Array<{
|
||||
actionId: string;
|
||||
agentId: string;
|
||||
error: string;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bulkBody = results.flatMap((result) => {
|
||||
const body = {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
action_id: result.actionId,
|
||||
agent_id: result.agentId,
|
||||
error: result.error,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
create: {
|
||||
_id: uuid.v4(),
|
||||
},
|
||||
},
|
||||
body,
|
||||
];
|
||||
});
|
||||
|
||||
await esClient.bulk({
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
body: bulkBody,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAgentActions(esClient: ElasticsearchClient, actionId: string) {
|
||||
const res = await esClient.search<FleetServerAgentAction>({
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
|
|
|
@ -117,9 +117,14 @@ export class BulkActionsResolver {
|
|||
.add(Math.pow(3, retryParams.retryCount ?? 1), 's')
|
||||
.toDate(),
|
||||
});
|
||||
appContextService.getLogger().info('Running task ' + taskId);
|
||||
appContextService.getLogger().info('Scheduling task ' + taskId);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public async removeIfExists(taskId: string) {
|
||||
appContextService.getLogger().info('Removing task ' + taskId);
|
||||
await this.taskManager?.removeIfExists(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRetryTask(
|
||||
|
|
|
@ -4,44 +4,19 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { SavedObject } from '@kbn/core/server';
|
||||
|
||||
import type { AgentPolicy } from '../../types';
|
||||
import { HostedAgentPolicyRestrictionRelatedError } from '../../errors';
|
||||
|
||||
import { reassignAgent, reassignAgents } from './reassign';
|
||||
import { appContextService } from '../app_context';
|
||||
import { createAppContextStartContractMock } from '../../mocks';
|
||||
|
||||
const agentInHostedDoc = {
|
||||
_id: 'agent-in-hosted-policy',
|
||||
_source: { policy_id: 'hosted-agent-policy' },
|
||||
};
|
||||
const agentInHostedDoc2 = {
|
||||
_id: 'agent-in-hosted-policy2',
|
||||
_source: { policy_id: 'hosted-agent-policy' },
|
||||
};
|
||||
const agentInRegularDoc = {
|
||||
_id: 'agent-in-regular-policy',
|
||||
_source: { policy_id: 'regular-agent-policy' },
|
||||
};
|
||||
const regularAgentPolicySO = {
|
||||
id: 'regular-agent-policy',
|
||||
attributes: { is_managed: false },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
const regularAgentPolicySO2 = {
|
||||
id: 'regular-agent-policy-2',
|
||||
attributes: { is_managed: false },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
const hostedAgentPolicySO = {
|
||||
id: 'hosted-agent-policy',
|
||||
attributes: { is_managed: true },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
import { reassignAgent, reassignAgents } from './reassign';
|
||||
import { createClientMock } from './action.mock';
|
||||
|
||||
describe('reassignAgent (singular)', () => {
|
||||
it('can reassign from regular agent policy to regular', async () => {
|
||||
const { soClient, esClient } = createClientsMock();
|
||||
const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO } = createClientMock();
|
||||
await reassignAgent(soClient, esClient, agentInRegularDoc._id, regularAgentPolicySO.id);
|
||||
|
||||
// calls ES update with correct values
|
||||
|
@ -55,7 +30,7 @@ describe('reassignAgent (singular)', () => {
|
|||
});
|
||||
|
||||
it('cannot reassign from regular agent policy to hosted', async () => {
|
||||
const { soClient, esClient } = createClientsMock();
|
||||
const { soClient, esClient, agentInRegularDoc, hostedAgentPolicySO } = createClientMock();
|
||||
await expect(
|
||||
reassignAgent(soClient, esClient, agentInRegularDoc._id, hostedAgentPolicySO.id)
|
||||
).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError);
|
||||
|
@ -65,7 +40,8 @@ describe('reassignAgent (singular)', () => {
|
|||
});
|
||||
|
||||
it('cannot reassign from hosted agent policy', async () => {
|
||||
const { soClient, esClient } = createClientsMock();
|
||||
const { soClient, esClient, agentInHostedDoc, hostedAgentPolicySO, regularAgentPolicySO } =
|
||||
createClientMock();
|
||||
await expect(
|
||||
reassignAgent(soClient, esClient, agentInHostedDoc._id, regularAgentPolicySO.id)
|
||||
).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError);
|
||||
|
@ -81,8 +57,22 @@ describe('reassignAgent (singular)', () => {
|
|||
});
|
||||
|
||||
describe('reassignAgents (plural)', () => {
|
||||
beforeEach(async () => {
|
||||
appContextService.start(createAppContextStartContractMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
appContextService.stop();
|
||||
});
|
||||
it('agents in hosted policies are not updated', async () => {
|
||||
const { soClient, esClient } = createClientsMock();
|
||||
const {
|
||||
soClient,
|
||||
esClient,
|
||||
agentInRegularDoc,
|
||||
agentInHostedDoc,
|
||||
agentInHostedDoc2,
|
||||
regularAgentPolicySO2,
|
||||
} = createClientMock();
|
||||
const idsToReassign = [agentInRegularDoc._id, agentInHostedDoc._id, agentInHostedDoc2._id];
|
||||
await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, regularAgentPolicySO2.id);
|
||||
|
||||
|
@ -92,65 +82,18 @@ describe('reassignAgents (plural)', () => {
|
|||
expect((calledWith as estypes.BulkRequest).body?.length).toBe(2);
|
||||
// @ts-expect-error
|
||||
expect(calledWith.body[0].update._id).toEqual(agentInRegularDoc._id);
|
||||
|
||||
// hosted policy is updated in action results with error
|
||||
const calledWithActionResults = esClient.bulk.mock.calls[1][0] as estypes.BulkRequest;
|
||||
// bulk write two line per create
|
||||
expect(calledWithActionResults.body?.length).toBe(4);
|
||||
const expectedObject = expect.objectContaining({
|
||||
'@timestamp': expect.anything(),
|
||||
action_id: expect.anything(),
|
||||
agent_id: 'agent-in-hosted-policy',
|
||||
error:
|
||||
'Cannot reassign an agent from hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.',
|
||||
});
|
||||
expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject);
|
||||
});
|
||||
});
|
||||
|
||||
function createClientsMock() {
|
||||
const soClientMock = savedObjectsClientMock.create();
|
||||
|
||||
// need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in reassignAgent(s)
|
||||
// @ts-expect-error
|
||||
soClientMock.create.mockResolvedValue({ attributes: { agent_id: 'test' } });
|
||||
soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => {
|
||||
return {
|
||||
saved_objects: [await soClientMock.create(type, attributes)],
|
||||
};
|
||||
});
|
||||
soClientMock.bulkUpdate.mockResolvedValue({
|
||||
saved_objects: [],
|
||||
});
|
||||
soClientMock.get.mockImplementation(async (_, id) => {
|
||||
switch (id) {
|
||||
case regularAgentPolicySO.id:
|
||||
return regularAgentPolicySO;
|
||||
case hostedAgentPolicySO.id:
|
||||
return hostedAgentPolicySO;
|
||||
case regularAgentPolicySO2.id:
|
||||
return regularAgentPolicySO2;
|
||||
default:
|
||||
throw new Error(`${id} not found`);
|
||||
}
|
||||
});
|
||||
soClientMock.bulkGet.mockImplementation(async (options) => {
|
||||
return {
|
||||
saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))),
|
||||
};
|
||||
});
|
||||
|
||||
const esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser;
|
||||
// @ts-expect-error
|
||||
esClientMock.mget.mockResponseImplementation(() => {
|
||||
return {
|
||||
body: {
|
||||
docs: [agentInHostedDoc, agentInRegularDoc, agentInHostedDoc2],
|
||||
},
|
||||
};
|
||||
});
|
||||
// @ts-expect-error
|
||||
esClientMock.get.mockResponseImplementation(({ id }) => {
|
||||
switch (id) {
|
||||
case agentInHostedDoc._id:
|
||||
return { body: agentInHostedDoc };
|
||||
case agentInRegularDoc._id:
|
||||
return { body: agentInRegularDoc };
|
||||
default:
|
||||
throw new Error(`${id} not found`);
|
||||
}
|
||||
});
|
||||
esClientMock.bulk.mockResponse(
|
||||
// @ts-expect-error not full interface
|
||||
{ items: [] }
|
||||
);
|
||||
|
||||
return { soClient: soClientMock, esClient: esClientMock };
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import uuid from 'uuid';
|
||||
import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
import type { Agent, BulkActionResult } from '../../types';
|
||||
|
@ -16,7 +16,7 @@ import { appContextService } from '../app_context';
|
|||
import { ActionRunner } from './action_runner';
|
||||
|
||||
import { errorsToResults, bulkUpdateAgents } from './crud';
|
||||
import { createAgentAction } from './actions';
|
||||
import { bulkCreateAgentActionResults, createAgentAction } from './actions';
|
||||
import { getHostedPolicies, isHostedAgent } from './hosted_agent';
|
||||
import { BulkActionTaskType } from './bulk_actions_resolver';
|
||||
|
||||
|
@ -62,7 +62,7 @@ export async function reassignBatch(
|
|||
const agentsToUpdate = givenAgents.reduce<Agent[]>((agents, agent) => {
|
||||
if (agent.policy_id === options.newAgentPolicyId) {
|
||||
errors[agent.id] = new AgentReassignmentError(
|
||||
`${agent.id} is already assigned to ${options.newAgentPolicyId}`
|
||||
`Agent ${agent.id} is already assigned to agent policy ${options.newAgentPolicyId}`
|
||||
);
|
||||
} else if (isHostedAgent(hostedPolicies, agent)) {
|
||||
errors[agent.id] = new HostedAgentPolicyRestrictionRelatedError(
|
||||
|
@ -95,17 +95,39 @@ export async function reassignBatch(
|
|||
}))
|
||||
);
|
||||
|
||||
const actionId = options.actionId ?? uuid();
|
||||
const errorCount = Object.keys(errors).length;
|
||||
const total = options.total ?? agentsToUpdate.length + errorCount;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await createAgentAction(esClient, {
|
||||
id: options.actionId,
|
||||
id: actionId,
|
||||
agents: agentsToUpdate.map((agent) => agent.id),
|
||||
created_at: now,
|
||||
type: 'POLICY_REASSIGN',
|
||||
total: options.total,
|
||||
total,
|
||||
data: {
|
||||
policy_id: options.newAgentPolicyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (errorCount > 0) {
|
||||
appContextService
|
||||
.getLogger()
|
||||
.info(
|
||||
`Skipping ${errorCount} agents, as failed validation (already assigned or assigned to hosted policy)`
|
||||
);
|
||||
|
||||
// writing out error result for those agents that failed validation, so the action is not going to stay in progress forever
|
||||
await bulkCreateAgentActionResults(
|
||||
esClient,
|
||||
Object.keys(errors).map((agentId) => ({
|
||||
agentId,
|
||||
actionId,
|
||||
error: errors[agentId].message,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -5,44 +5,25 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import type { SavedObject } from '@kbn/core/server';
|
||||
|
||||
import type { AgentPolicy } from '../../types';
|
||||
import { HostedAgentPolicyRestrictionRelatedError } from '../../errors';
|
||||
import { invalidateAPIKeys } from '../api_keys';
|
||||
|
||||
import { appContextService } from '../app_context';
|
||||
|
||||
import { createAppContextStartContractMock } from '../../mocks';
|
||||
|
||||
import { unenrollAgent, unenrollAgents } from './unenroll';
|
||||
import { invalidateAPIKeysForAgents } from './unenroll_action_runner';
|
||||
import { createClientMock } from './action.mock';
|
||||
|
||||
jest.mock('../api_keys');
|
||||
|
||||
const mockedInvalidateAPIKeys = invalidateAPIKeys as jest.MockedFunction<typeof invalidateAPIKeys>;
|
||||
|
||||
const agentInHostedDoc = {
|
||||
_id: 'agent-in-hosted-policy',
|
||||
_source: { policy_id: 'hosted-agent-policy' },
|
||||
};
|
||||
const agentInRegularDoc = {
|
||||
_id: 'agent-in-regular-policy',
|
||||
_source: { policy_id: 'regular-agent-policy' },
|
||||
};
|
||||
const agentInRegularDoc2 = {
|
||||
_id: 'agent-in-regular-policy2',
|
||||
_source: { policy_id: 'regular-agent-policy' },
|
||||
};
|
||||
const regularAgentPolicySO = {
|
||||
id: 'regular-agent-policy',
|
||||
attributes: { is_managed: false },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
const hostedAgentPolicySO = {
|
||||
id: 'hosted-agent-policy',
|
||||
attributes: { is_managed: true },
|
||||
} as SavedObject<AgentPolicy>;
|
||||
|
||||
describe('unenrollAgent (singular)', () => {
|
||||
it('can unenroll from regular agent policy', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInRegularDoc } = createClientMock();
|
||||
await unenrollAgent(soClient, esClient, agentInRegularDoc._id);
|
||||
|
||||
// calls ES update with correct values
|
||||
|
@ -55,7 +36,7 @@ describe('unenrollAgent (singular)', () => {
|
|||
});
|
||||
|
||||
it('cannot unenroll from hosted agent policy by default', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc } = createClientMock();
|
||||
await expect(unenrollAgent(soClient, esClient, agentInHostedDoc._id)).rejects.toThrowError(
|
||||
HostedAgentPolicyRestrictionRelatedError
|
||||
);
|
||||
|
@ -64,7 +45,7 @@ describe('unenrollAgent (singular)', () => {
|
|||
});
|
||||
|
||||
it('cannot unenroll from hosted agent policy with revoke=true', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc } = createClientMock();
|
||||
await expect(
|
||||
unenrollAgent(soClient, esClient, agentInHostedDoc._id, { revoke: true })
|
||||
).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError);
|
||||
|
@ -73,7 +54,7 @@ describe('unenrollAgent (singular)', () => {
|
|||
});
|
||||
|
||||
it('can unenroll from hosted agent policy with force=true', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc } = createClientMock();
|
||||
await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true });
|
||||
// calls ES update with correct values
|
||||
expect(esClient.update).toBeCalledTimes(1);
|
||||
|
@ -85,7 +66,7 @@ describe('unenrollAgent (singular)', () => {
|
|||
});
|
||||
|
||||
it('can unenroll from hosted agent policy with force=true and revoke=true', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc } = createClientMock();
|
||||
await unenrollAgent(soClient, esClient, agentInHostedDoc._id, { force: true, revoke: true });
|
||||
// calls ES update with correct values
|
||||
expect(esClient.update).toBeCalledTimes(1);
|
||||
|
@ -96,8 +77,15 @@ describe('unenrollAgent (singular)', () => {
|
|||
});
|
||||
|
||||
describe('unenrollAgents (plural)', () => {
|
||||
beforeEach(async () => {
|
||||
appContextService.start(createAppContextStartContractMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
appContextService.stop();
|
||||
});
|
||||
it('can unenroll from an regular agent policy', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock();
|
||||
const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id];
|
||||
await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll });
|
||||
|
||||
|
@ -115,14 +103,15 @@ describe('unenrollAgents (plural)', () => {
|
|||
}
|
||||
});
|
||||
it('cannot unenroll from a hosted agent policy by default', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } =
|
||||
createClientMock();
|
||||
|
||||
const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id];
|
||||
await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll });
|
||||
|
||||
// calls ES update with correct values
|
||||
const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id];
|
||||
const calledWith = esClient.bulk.mock.calls[0][0];
|
||||
const calledWith = esClient.bulk.mock.calls[1][0];
|
||||
const ids = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.update !== undefined)
|
||||
.map((i: any) => i.update._id);
|
||||
|
@ -133,10 +122,24 @@ describe('unenrollAgents (plural)', () => {
|
|||
for (const doc of docs!) {
|
||||
expect(doc).toHaveProperty('unenrollment_started_at');
|
||||
}
|
||||
|
||||
// hosted policy is updated in action results with error
|
||||
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-hosted-policy',
|
||||
error:
|
||||
'Cannot unenroll agent-in-hosted-policy from a hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.',
|
||||
});
|
||||
expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject);
|
||||
});
|
||||
|
||||
it('cannot unenroll from a hosted agent policy with revoke=true', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } =
|
||||
createClientMock();
|
||||
|
||||
const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id];
|
||||
|
||||
|
@ -176,7 +179,8 @@ describe('unenrollAgents (plural)', () => {
|
|||
});
|
||||
|
||||
it('can unenroll from hosted agent policy with force=true', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } =
|
||||
createClientMock();
|
||||
const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id];
|
||||
await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true });
|
||||
|
||||
|
@ -195,7 +199,8 @@ describe('unenrollAgents (plural)', () => {
|
|||
});
|
||||
|
||||
it('can unenroll from hosted agent policy with force=true and revoke=true', async () => {
|
||||
const { soClient, esClient } = createClientMock();
|
||||
const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } =
|
||||
createClientMock();
|
||||
|
||||
const idsToUnenroll = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id];
|
||||
|
||||
|
@ -265,66 +270,3 @@ describe('invalidateAPIKeysForAgents', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createClientMock() {
|
||||
const soClientMock = savedObjectsClientMock.create();
|
||||
|
||||
soClientMock.get.mockImplementation(async (_, id) => {
|
||||
switch (id) {
|
||||
case regularAgentPolicySO.id:
|
||||
return regularAgentPolicySO;
|
||||
case hostedAgentPolicySO.id:
|
||||
return hostedAgentPolicySO;
|
||||
default:
|
||||
throw new Error('not found');
|
||||
}
|
||||
});
|
||||
|
||||
soClientMock.bulkGet.mockImplementation(async (options) => {
|
||||
return {
|
||||
saved_objects: await Promise.all(options!.map(({ type, id }) => soClientMock.get(type, id))),
|
||||
};
|
||||
});
|
||||
|
||||
const esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser;
|
||||
// @ts-expect-error
|
||||
esClientMock.get.mockResponseImplementation(({ id }) => {
|
||||
switch (id) {
|
||||
case agentInHostedDoc._id:
|
||||
return { body: agentInHostedDoc };
|
||||
case agentInRegularDoc2._id:
|
||||
return { body: agentInRegularDoc2 };
|
||||
case agentInRegularDoc._id:
|
||||
return { body: agentInRegularDoc };
|
||||
default:
|
||||
throw new Error('not found');
|
||||
}
|
||||
});
|
||||
esClientMock.bulk.mockResponse(
|
||||
// @ts-expect-error not full interface
|
||||
{ items: [] }
|
||||
);
|
||||
|
||||
esClientMock.mget.mockResponseImplementation((params) => {
|
||||
// @ts-expect-error
|
||||
const docs = params?.body.docs.map(({ _id }) => {
|
||||
switch (_id) {
|
||||
case agentInHostedDoc._id:
|
||||
return agentInHostedDoc;
|
||||
case agentInRegularDoc2._id:
|
||||
return agentInRegularDoc2;
|
||||
case agentInRegularDoc._id:
|
||||
return agentInRegularDoc;
|
||||
default:
|
||||
throw new Error('not found');
|
||||
}
|
||||
});
|
||||
return {
|
||||
body: {
|
||||
docs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { soClient: soClientMock, esClient: esClientMock };
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import uuid from 'uuid';
|
||||
import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
import type { Agent, BulkActionResult } from '../../types';
|
||||
|
@ -13,10 +13,12 @@ import { HostedAgentPolicyRestrictionRelatedError } from '../../errors';
|
|||
|
||||
import { invalidateAPIKeys } from '../api_keys';
|
||||
|
||||
import { appContextService } from '../app_context';
|
||||
|
||||
import { ActionRunner } from './action_runner';
|
||||
|
||||
import { errorsToResults, bulkUpdateAgents } from './crud';
|
||||
import { createAgentAction } from './actions';
|
||||
import { bulkCreateAgentActionResults, createAgentAction } from './actions';
|
||||
import { getHostedPolicies, isHostedAgent } from './hosted_agent';
|
||||
import { BulkActionTaskType } from './bulk_actions_resolver';
|
||||
|
||||
|
@ -72,6 +74,10 @@ export async function unenrollBatch(
|
|||
return agents;
|
||||
}, []);
|
||||
|
||||
const actionId = options.actionId ?? uuid();
|
||||
const errorCount = Object.keys(outgoingErrors).length;
|
||||
const total = options.total ?? agentsToUpdate.length + errorCount;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (options.revoke) {
|
||||
// Get all API keys that need to be invalidated
|
||||
|
@ -79,12 +85,30 @@ export async function unenrollBatch(
|
|||
} else {
|
||||
// Create unenroll action for each agent
|
||||
await createAgentAction(esClient, {
|
||||
id: options.actionId,
|
||||
id: actionId,
|
||||
agents: agentsToUpdate.map((agent) => agent.id),
|
||||
created_at: now,
|
||||
type: 'UNENROLL',
|
||||
total: options.total,
|
||||
total,
|
||||
});
|
||||
|
||||
if (errorCount > 0) {
|
||||
appContextService
|
||||
.getLogger()
|
||||
.info(
|
||||
`Skipping ${errorCount} agents, as failed validation (cannot unenroll from a hosted policy)`
|
||||
);
|
||||
|
||||
// writing out error result for those agents that failed validation, so the action is not going to stay in progress forever
|
||||
await bulkCreateAgentActionResults(
|
||||
esClient,
|
||||
Object.keys(outgoingErrors).map((agentId) => ({
|
||||
agentId,
|
||||
actionId,
|
||||
error: outgoingErrors[agentId].message,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the necessary agents
|
||||
|
|
104
x-pack/plugins/fleet/server/services/agents/upgrade.test.ts
Normal file
104
x-pack/plugins/fleet/server/services/agents/upgrade.test.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { appContextService } from '../app_context';
|
||||
|
||||
import { createAppContextStartContractMock } from '../../mocks';
|
||||
|
||||
import { sendUpgradeAgentsActions } from './upgrade';
|
||||
import { createClientMock } from './action.mock';
|
||||
|
||||
describe('sendUpgradeAgentsActions (plural)', () => {
|
||||
beforeEach(async () => {
|
||||
appContextService.start(createAppContextStartContractMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
appContextService.stop();
|
||||
});
|
||||
it('can upgrade from an regular agent policy', async () => {
|
||||
const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock();
|
||||
const idsToAction = [agentInRegularDoc._id, agentInRegularDoc2._id];
|
||||
await sendUpgradeAgentsActions(soClient, esClient, { agentIds: idsToAction, version: '8.5.0' });
|
||||
|
||||
// calls ES update with correct values
|
||||
const calledWith = esClient.bulk.mock.calls[0][0];
|
||||
const ids = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.update !== undefined)
|
||||
.map((i: any) => i.update._id);
|
||||
const docs = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.doc)
|
||||
.map((i: any) => i.doc);
|
||||
expect(ids).toEqual(idsToAction);
|
||||
for (const doc of docs!) {
|
||||
expect(doc).toHaveProperty('upgrade_started_at');
|
||||
expect(doc.upgrade_status).toEqual('started');
|
||||
}
|
||||
});
|
||||
it('cannot upgrade from a hosted agent policy by default', async () => {
|
||||
const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } =
|
||||
createClientMock();
|
||||
|
||||
const idsToAction = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id];
|
||||
await sendUpgradeAgentsActions(soClient, esClient, { agentIds: idsToAction, version: '8.5.0' });
|
||||
|
||||
// calls ES update with correct values
|
||||
const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id];
|
||||
const calledWith = esClient.bulk.mock.calls[1][0];
|
||||
const ids = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.update !== undefined)
|
||||
.map((i: any) => i.update._id);
|
||||
const docs = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.doc)
|
||||
.map((i: any) => i.doc);
|
||||
expect(ids).toEqual(onlyRegular);
|
||||
for (const doc of docs!) {
|
||||
expect(doc).toHaveProperty('upgrade_started_at');
|
||||
expect(doc.upgrade_status).toEqual('started');
|
||||
}
|
||||
|
||||
// hosted policy is updated in action results with error
|
||||
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-hosted-policy',
|
||||
error:
|
||||
'Cannot upgrade agent in hosted agent policy hosted-agent-policy in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.',
|
||||
});
|
||||
expect(calledWithActionResults.body?.[1] as any).toEqual(expectedObject);
|
||||
});
|
||||
|
||||
it('can upgrade from hosted agent policy with force=true', async () => {
|
||||
const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } =
|
||||
createClientMock();
|
||||
const idsToAction = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id];
|
||||
await sendUpgradeAgentsActions(soClient, esClient, {
|
||||
agentIds: idsToAction,
|
||||
force: true,
|
||||
version: '8.5.0',
|
||||
});
|
||||
|
||||
// calls ES update with correct values
|
||||
const calledWith = esClient.bulk.mock.calls[0][0];
|
||||
const ids = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.update !== undefined)
|
||||
.map((i: any) => i.update._id);
|
||||
const docs = (calledWith as estypes.BulkRequest)?.body
|
||||
?.filter((i: any) => i.doc)
|
||||
.map((i: any) => i.doc);
|
||||
expect(ids).toEqual(idsToAction);
|
||||
for (const doc of docs!) {
|
||||
expect(doc).toHaveProperty('upgrade_started_at');
|
||||
expect(doc.upgrade_status).toEqual('started');
|
||||
}
|
||||
});
|
||||
});
|
|
@ -8,6 +8,7 @@
|
|||
import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
import moment from 'moment';
|
||||
import uuid from 'uuid';
|
||||
|
||||
import { isAgentUpgradeable } from '../../../common/services';
|
||||
|
||||
|
@ -21,7 +22,7 @@ import { ActionRunner } from './action_runner';
|
|||
|
||||
import type { GetAgentsOptions } from './crud';
|
||||
import { errorsToResults, bulkUpdateAgents } from './crud';
|
||||
import { createAgentAction } from './actions';
|
||||
import { bulkCreateAgentActionResults, createAgentAction } from './actions';
|
||||
import { getHostedPolicies, isHostedAgent } from './hosted_agent';
|
||||
import { BulkActionTaskType } from './bulk_actions_resolver';
|
||||
|
||||
|
@ -80,7 +81,7 @@ export async function upgradeBatch(
|
|||
const isNotAllowed =
|
||||
!options.force && !isAgentUpgradeable(agent, kibanaVersion, options.version);
|
||||
if (isNotAllowed) {
|
||||
throw new FleetError(`${agent.id} is not upgradeable`);
|
||||
throw new FleetError(`Agent ${agent.id} is not upgradeable`);
|
||||
}
|
||||
|
||||
if (!options.force && isHostedAgent(hostedPolicies, agent)) {
|
||||
|
@ -115,17 +116,39 @@ export async function upgradeBatch(
|
|||
options.upgradeDurationSeconds
|
||||
);
|
||||
|
||||
const actionId = options.actionId ?? uuid();
|
||||
const errorCount = Object.keys(errors).length;
|
||||
const total = options.total ?? agentsToUpdate.length + errorCount;
|
||||
|
||||
await createAgentAction(esClient, {
|
||||
id: options.actionId,
|
||||
id: actionId,
|
||||
created_at: now,
|
||||
data,
|
||||
ack_data: data,
|
||||
type: 'UPGRADE',
|
||||
total: options.total,
|
||||
total,
|
||||
agents: agentsToUpdate.map((agent) => agent.id),
|
||||
...rollingUpgradeOptions,
|
||||
});
|
||||
|
||||
if (errorCount > 0) {
|
||||
appContextService
|
||||
.getLogger()
|
||||
.info(
|
||||
`Skipping ${errorCount} agents, as failed validation (cannot upgrade hosted agent or agent not upgradeable)`
|
||||
);
|
||||
|
||||
// writing out error result for those agents that failed validation, so the action is not going to stay in progress forever
|
||||
await bulkCreateAgentActionResults(
|
||||
esClient,
|
||||
Object.keys(errors).map((agentId) => ({
|
||||
agentId,
|
||||
actionId,
|
||||
error: errors[agentId].message,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await bulkUpdateAgents(
|
||||
esClient,
|
||||
agentsToUpdate.map((agent) => ({
|
||||
|
|
255
x-pack/test/fleet_api_integration/apis/agents/action_status.ts
Normal file
255
x-pack/test/fleet_api_integration/apis/agents/action_status.ts
Normal file
|
@ -0,0 +1,255 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
|
||||
import { setupFleetAndAgents } from './services';
|
||||
import { skipIfNoDockerRegistry } from '../../helpers';
|
||||
|
||||
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
|
||||
|
||||
export default function (providerContext: FtrProviderContext) {
|
||||
const { getService } = providerContext;
|
||||
const supertest = getService('supertest');
|
||||
const es = getService('es');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('action_status_api', () => {
|
||||
skipIfNoDockerRegistry(providerContext);
|
||||
before(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents');
|
||||
});
|
||||
setupFleetAndAgents(providerContext);
|
||||
|
||||
after(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/agents/action_status', () => {
|
||||
before(async () => {
|
||||
await es.deleteByQuery({
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
q: '*',
|
||||
});
|
||||
try {
|
||||
await es.deleteByQuery(
|
||||
{
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
q: '*',
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
} catch (error) {
|
||||
// swallowing error if does not exist
|
||||
}
|
||||
|
||||
// action 2 non expired and non complete
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action2',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
'@timestamp': '2022-09-15T10:00:00.000Z',
|
||||
start_time: '2022-09-15T10:00:00.000Z',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
data: {
|
||||
version: '8.5.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action2',
|
||||
agents: ['agent4', 'agent5'],
|
||||
'@timestamp': '2022-09-15T10:00:00.000Z',
|
||||
start_time: '2022-09-15T10:00:00.000Z',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
// Action 3 complete
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action3',
|
||||
agents: ['agent1', 'agent2'],
|
||||
'@timestamp': '2022-09-15T10:00:00.000Z',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
await es.index(
|
||||
{
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
document: {
|
||||
action_id: 'action3',
|
||||
agent_id: 'agent1',
|
||||
'@timestamp': '2022-09-15T11:00:00.000Z',
|
||||
},
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
await es.index(
|
||||
{
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
document: {
|
||||
action_id: 'action3',
|
||||
agent_id: 'agent2',
|
||||
'@timestamp': '2022-09-15T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
|
||||
// Action 4 expired
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UNENROLL',
|
||||
action_id: 'action4',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
'@timestamp': '2022-09-15T10:00:00.000Z',
|
||||
expiration: '2022-09-14T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
// Action 5 cancelled
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action5',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
'@timestamp': '2022-09-15T10:00:00.000Z',
|
||||
start_time: '2022-09-15T10:00:00.000Z',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
'@timestamp': '2022-09-15T11:00:00.000Z',
|
||||
type: 'CANCEL',
|
||||
action_id: 'cancelaction1',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
data: {
|
||||
target_id: 'action5',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action 7 failed
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'POLICY_REASSIGN',
|
||||
action_id: 'action7',
|
||||
agents: ['agent1'],
|
||||
'@timestamp': '2022-09-15T10:00:00.000Z',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
data: {
|
||||
policy_id: 'policy1',
|
||||
},
|
||||
},
|
||||
});
|
||||
await es.index(
|
||||
{
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
document: {
|
||||
action_id: 'action7',
|
||||
agent_id: 'agent1',
|
||||
'@timestamp': '2022-09-15T11:00:00.000Z',
|
||||
error: 'agent already assigned',
|
||||
},
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
});
|
||||
it('should respond 200 and the action statuses', async () => {
|
||||
const res = await supertest.get(`/api/fleet/agents/action_status`).expect(200);
|
||||
expect(res.body.items).to.eql([
|
||||
{
|
||||
actionId: 'action2',
|
||||
nbAgentsActionCreated: 5,
|
||||
nbAgentsAck: 0,
|
||||
version: '8.5.0',
|
||||
startTime: '2022-09-15T10:00:00.000Z',
|
||||
type: 'UPGRADE',
|
||||
nbAgentsActioned: 5,
|
||||
status: 'IN_PROGRESS',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
creationTime: '2022-09-15T10:00:00.000Z',
|
||||
nbAgentsFailed: 0,
|
||||
},
|
||||
{
|
||||
actionId: 'action3',
|
||||
nbAgentsActionCreated: 2,
|
||||
nbAgentsAck: 2,
|
||||
type: 'UPGRADE',
|
||||
nbAgentsActioned: 2,
|
||||
status: 'COMPLETE',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
creationTime: '2022-09-15T10:00:00.000Z',
|
||||
nbAgentsFailed: 0,
|
||||
completionTime: '2022-09-15T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
actionId: 'action4',
|
||||
nbAgentsActionCreated: 3,
|
||||
nbAgentsAck: 0,
|
||||
type: 'UNENROLL',
|
||||
nbAgentsActioned: 3,
|
||||
status: 'EXPIRED',
|
||||
expiration: '2022-09-14T10:00:00.000Z',
|
||||
creationTime: '2022-09-15T10:00:00.000Z',
|
||||
nbAgentsFailed: 0,
|
||||
},
|
||||
{
|
||||
actionId: 'action5',
|
||||
nbAgentsActionCreated: 3,
|
||||
nbAgentsAck: 0,
|
||||
startTime: '2022-09-15T10:00:00.000Z',
|
||||
type: 'UPGRADE',
|
||||
nbAgentsActioned: 3,
|
||||
status: 'CANCELLED',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
creationTime: '2022-09-15T10:00:00.000Z',
|
||||
nbAgentsFailed: 0,
|
||||
cancellationTime: '2022-09-15T11:00:00.000Z',
|
||||
},
|
||||
{
|
||||
actionId: 'action7',
|
||||
nbAgentsActionCreated: 1,
|
||||
nbAgentsAck: 0,
|
||||
type: 'POLICY_REASSIGN',
|
||||
nbAgentsActioned: 1,
|
||||
status: 'FAILED',
|
||||
expiration: '2099-09-16T10:00:00.000Z',
|
||||
newPolicyId: 'policy1',
|
||||
creationTime: '2022-09-15T10:00:00.000Z',
|
||||
nbAgentsFailed: 1,
|
||||
completionTime: '2022-09-15T11:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,176 +0,0 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import moment from 'moment';
|
||||
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
|
||||
import { FtrProviderContext } from '../../../api_integration/ftr_provider_context';
|
||||
import { setupFleetAndAgents } from './services';
|
||||
import { skipIfNoDockerRegistry } from '../../helpers';
|
||||
|
||||
const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } };
|
||||
|
||||
export default function (providerContext: FtrProviderContext) {
|
||||
const { getService } = providerContext;
|
||||
const supertest = getService('supertest');
|
||||
const es = getService('es');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('Agent current upgrades API', () => {
|
||||
skipIfNoDockerRegistry(providerContext);
|
||||
before(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/fleet/agents');
|
||||
});
|
||||
setupFleetAndAgents(providerContext);
|
||||
|
||||
after(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/agents/current_upgrades', () => {
|
||||
before(async () => {
|
||||
await es.deleteByQuery({
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
q: '*',
|
||||
});
|
||||
// Action 1 non expired and non complete
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action1',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
start_time: moment().toISOString(),
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// action 2 non expired and non complete
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action2',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
start_time: moment().toISOString(),
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action2',
|
||||
agents: ['agent4', 'agent5'],
|
||||
start_time: moment().toISOString(),
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
// Action 3 complete
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action3',
|
||||
agents: ['agent1', 'agent2'],
|
||||
start_time: moment().toISOString(),
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
await es.index(
|
||||
{
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
document: {
|
||||
action_id: 'action3',
|
||||
'@timestamp': new Date().toISOString(),
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
await es.index(
|
||||
{
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_RESULTS_INDEX,
|
||||
document: {
|
||||
action_id: 'action3',
|
||||
'@timestamp': new Date().toISOString(),
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
ES_INDEX_OPTIONS
|
||||
);
|
||||
|
||||
// Action 4 expired
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action4',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
expiration: moment().subtract(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// Action 5 cancelled
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action5',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
start_time: moment().toISOString(),
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'CANCEL',
|
||||
action_id: 'cancelaction1',
|
||||
agents: ['agent1', 'agent2', 'agent3'],
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
data: {
|
||||
target_id: 'action5',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action 6 1 agent with not start time
|
||||
await es.index({
|
||||
refresh: 'wait_for',
|
||||
index: AGENT_ACTIONS_INDEX,
|
||||
document: {
|
||||
type: 'UPGRADE',
|
||||
action_id: 'action6',
|
||||
agents: ['agent1'],
|
||||
expiration: moment().add(1, 'day').toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should respond 200 and the current upgrades', async () => {
|
||||
const res = await supertest.get(`/api/fleet/agents/current_upgrades`).expect(200);
|
||||
const actionIds = res.body.items.map((item: any) => item.actionId);
|
||||
expect(actionIds).length(3);
|
||||
expect(actionIds).contain('action1');
|
||||
expect(actionIds).contain('action2');
|
||||
expect(actionIds).contain('action6');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -12,7 +12,7 @@ export default function loadTests({ loadTestFile }) {
|
|||
loadTestFile(require.resolve('./unenroll'));
|
||||
loadTestFile(require.resolve('./actions'));
|
||||
loadTestFile(require.resolve('./upgrade'));
|
||||
loadTestFile(require.resolve('./current_upgrades'));
|
||||
loadTestFile(require.resolve('./action_status'));
|
||||
loadTestFile(require.resolve('./reassign'));
|
||||
loadTestFile(require.resolve('./status'));
|
||||
loadTestFile(require.resolve('./update'));
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue