[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:
Julia Bardi 2022-09-19 15:44:33 +02:00 committed by GitHub
parent 0492b125bb
commit 2404c8bce4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 771 additions and 414 deletions

View file

@ -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}

View 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,
};
}

View file

@ -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!);

View file

@ -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,

View file

@ -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(

View file

@ -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 };
}

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -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

View 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');
}
});
});

View file

@ -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) => ({

View 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',
},
]);
});
});
});
}

View file

@ -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');
});
});
});
}

View file

@ -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'));