[Fleet] Add per-policy inactivity timeout + use runtime fields for agent status (#147552)

## Summary

Part of #143455 

Previously agents would be unenrolled after a given time by the fleet
server. Instead, they'll be considered `Inactive`. Agents in an
`Inactive` state are hidden from the UI by default, but their API keys
remain active. This allows these agents to check in again at any time
without requesting new API keys.`inactivity_timeout` defaults to 10
minutes or can be configured on a per policy basis.

Agents that are manually unenrolled will go into the new `Unenrolled`
status.


![image](https://user-images.githubusercontent.com/6766512/200406081-78a945bc-861a-4a5e-949c-33af59222558.png)

These changes mean that we now need to get agent policies before knowing
the agents status, we have used a runtime field to calculate the status
at search time, this allows us to easily filter and aggregate on the
status.

### Performance
For 120 agents (20 of each main status):
- filter call with filters: 90ms
- agent status summary call: 83ms

For 12k agents (2k of each main status):
- filter call with filters: 455ms
- agent status summary call: 500ms

For 120k agents (20k of each main status):

- filter call with filters: 2.2s
- agent status summary call: 2.1s

### Manual Testing

the create agent script can be used to test this at scale e.g create 10k
agents of each of the given statuses:

```bash
cd x-pack/plugins/fleet
node scripts/create_agents --count 10000  --kibana http://localhost:5601/myprefix--status offline,online,inactive,error,updating,unenrolled  --inactivityTimeout 360 --delete
```

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Mark Hopkin 2022-12-21 22:09:05 +00:00 committed by GitHub
parent efb7cdd49e
commit a9166da678
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 1435 additions and 1097 deletions

View file

@ -1178,6 +1178,9 @@
"inactive": {
"type": "integer"
},
"unenrolled": {
"type": "integer"
},
"offline": {
"type": "integer"
},

View file

@ -735,6 +735,8 @@ paths:
type: integer
inactive:
type: integer
unenrolled:
type: integer
offline:
type: integer
online:

View file

@ -15,6 +15,8 @@ get:
type: integer
inactive:
type: integer
unenrolled:
type: integer
offline:
type: integer
online:

View file

@ -5,55 +5,8 @@
* 2.0.
*/
import { AGENT_POLLING_THRESHOLD_MS } from '../constants';
import type { Agent, AgentStatus, FleetServerAgent } from '../types';
const offlineTimeoutIntervalCount = 10; // 30s*10 = 5m timeout
export function getAgentStatus(agent: Agent | FleetServerAgent): AgentStatus {
const { last_checkin: lastCheckIn } = agent;
if (!agent.active) {
return 'inactive';
}
if (!agent.last_checkin) {
return 'enrolling';
}
const msLastCheckIn = new Date(lastCheckIn || 0).getTime();
const msSinceLastCheckIn = new Date().getTime() - msLastCheckIn;
const intervalsSinceLastCheckIn = Math.floor(msSinceLastCheckIn / AGENT_POLLING_THRESHOLD_MS);
if (intervalsSinceLastCheckIn >= offlineTimeoutIntervalCount) {
return 'offline';
}
if (agent.unenrollment_started_at && !agent.unenrolled_at) {
return 'unenrolling';
}
if (agent.last_checkin_status?.toLowerCase() === 'error') {
return 'error';
}
if (agent.last_checkin_status?.toLowerCase() === 'degraded') {
return 'degraded';
}
const policyRevision =
'policy_revision' in agent
? agent.policy_revision
: 'policy_revision_idx' in agent
? agent.policy_revision_idx
: undefined;
if (!policyRevision || (agent.upgrade_started_at && !agent.upgraded_at)) {
return 'updating';
}
return 'online';
}
export function getPreviousAgentStatusForOfflineAgents(
agent: Agent | FleetServerAgent
): AgentStatus | undefined {
@ -80,55 +33,26 @@ export function getPreviousAgentStatusForOfflineAgents(
}
}
export function buildKueryForEnrollingAgents(path: string = ''): string {
return `not (${path}last_checkin:*)`;
export function buildKueryForUnenrolledAgents(): string {
return 'status:unenrolled';
}
export function buildKueryForUnenrollingAgents(path: string = ''): string {
return `${path}unenrollment_started_at:*`;
export function buildKueryForOnlineAgents(): string {
return 'status:online';
}
export function buildKueryForOnlineAgents(path: string = ''): string {
return `${path}last_checkin:* ${addExclusiveKueryFilter(
[buildKueryForOfflineAgents, buildKueryForUpdatingAgents, buildKueryForErrorAgents],
path
)}`;
export function buildKueryForErrorAgents(): string {
return '(status:error or status:degraded)';
}
export function buildKueryForErrorAgents(path: string = ''): string {
return `(${path}last_checkin_status:error or ${path}last_checkin_status:degraded or ${path}last_checkin_status:DEGRADED or ${path}last_checkin_status:ERROR) ${addExclusiveKueryFilter(
[buildKueryForOfflineAgents, buildKueryForUnenrollingAgents],
path
)}`;
export function buildKueryForOfflineAgents(): string {
return 'status:offline';
}
export function buildKueryForOfflineAgents(path: string = ''): string {
return `${path}last_checkin < now-${
(offlineTimeoutIntervalCount * AGENT_POLLING_THRESHOLD_MS) / 1000
}s`;
export function buildKueryForUpdatingAgents(): string {
return '(status:updating or status:unenrolling or status:enrolling)';
}
export function buildKueryForUpgradingAgents(path: string = ''): string {
return `(${path}upgrade_started_at:*) and not (${path}upgraded_at:*)`;
}
export function buildKueryForUpdatingAgents(path: string = ''): string {
return `((${buildKueryForUpgradingAgents(path)}) or (${buildKueryForEnrollingAgents(
path
)}) or (${buildKueryForUnenrollingAgents(
path
)}) or (not ${path}policy_revision_idx:*)) ${addExclusiveKueryFilter(
[buildKueryForOfflineAgents, buildKueryForErrorAgents],
path
)}`;
}
export function buildKueryForInactiveAgents(path: string = '') {
return `${path}active:false`;
}
function addExclusiveKueryFilter(kueryBuilders: Array<(path?: string) => string>, path?: string) {
return ` AND not (${kueryBuilders
.map((kueryBuilder) => `(${kueryBuilder(path)})`)
.join(' or ')})`;
export function buildKueryForInactiveAgents() {
return 'status:inactive';
}

View file

@ -24,10 +24,17 @@ export type AgentStatus =
| 'inactive'
| 'enrolling'
| 'unenrolling'
| 'unenrolled'
| 'updating'
| 'degraded';
export type SimplifiedAgentStatus = 'healthy' | 'unhealthy' | 'updating' | 'offline' | 'inactive';
export type SimplifiedAgentStatus =
| 'healthy'
| 'unhealthy'
| 'updating'
| 'offline'
| 'inactive'
| 'unenrolled';
export type AgentActionType =
| 'UNENROLL'

View file

@ -186,6 +186,8 @@ export interface GetAgentStatusResponse {
offline: number;
other: number;
updating: number;
inactive: number;
unenrolled: number;
};
}

View file

@ -166,9 +166,17 @@ describe('View agents list', () => {
});
describe('Agent status filter', () => {
const clearFilters = () => {
cy.getBySel(FLEET_AGENT_LIST_PAGE.STATUS_FILTER).click();
cy.get('button').contains('Healthy').click();
cy.get('button').contains('Unhealthy').click();
cy.get('button').contains('Updating').click();
cy.get('button').contains('Offline').click();
cy.getBySel(FLEET_AGENT_LIST_PAGE.STATUS_FILTER).click();
};
it('should filter on healthy (16 result)', () => {
cy.visit('/app/fleet/agents');
clearFilters();
cy.getBySel(FLEET_AGENT_LIST_PAGE.STATUS_FILTER).click();
cy.get('button').contains('Healthy').click();
@ -179,7 +187,7 @@ describe('View agents list', () => {
it('should filter on unhealthy (1 result)', () => {
cy.visit('/app/fleet/agents');
clearFilters();
cy.getBySel(FLEET_AGENT_LIST_PAGE.STATUS_FILTER).click();
cy.get('button').contains('Unhealthy').click();
@ -190,6 +198,7 @@ describe('View agents list', () => {
it('should filter on inactive (0 result)', () => {
cy.visit('/app/fleet/agents');
clearFilters();
cy.getBySel(FLEET_AGENT_LIST_PAGE.STATUS_FILTER).click();
@ -200,6 +209,7 @@ describe('View agents list', () => {
it('should filter on healthy and unhealthy', () => {
cy.visit('/app/fleet/agents');
clearFilters();
cy.getBySel(FLEET_AGENT_LIST_PAGE.STATUS_FILTER).click();

View file

@ -63,6 +63,12 @@ const statusFilters = [
defaultMessage: 'Inactive',
}),
},
{
status: 'unenrolled',
label: i18n.translate('xpack.fleet.agentList.statusUnenrolledFilterText', {
defaultMessage: 'Unenrolled',
}),
},
];
const ClearAllTagsFilterItem = styled(EuiFilterSelectItem)`

View file

@ -20,7 +20,9 @@ export const AgentStatusBadges: React.FC<{
agentStatus: { [k in SimplifiedAgentStatus]: number };
}> = memo(({ agentStatus, showInactive }) => {
const agentStatuses = useMemo(() => {
return AGENT_STATUSES.filter((status) => (showInactive ? true : status !== 'inactive'));
return AGENT_STATUSES.filter((status) =>
showInactive ? true : status !== 'inactive' && status !== 'unenrolled'
);
}, [showInactive]);
return (

View file

@ -87,7 +87,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
const [selectedAgentPolicies, setSelectedAgentPolicies] = useState<string[]>([]);
// Status for filtering
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string[]>([
'healthy',
'unhealthy',
'updating',
'offline',
]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@ -183,6 +188,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
return AgentStatusKueryHelper.buildKueryForUpdatingAgents();
case 'inactive':
return AgentStatusKueryHelper.buildKueryForInactiveAgents();
case 'unenrolled':
return AgentStatusKueryHelper.buildKueryForUnenrolledAgents();
}
return undefined;
@ -201,7 +208,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
}, [search, selectedAgentPolicies, selectedTags, selectedStatus]);
const showInactive = useMemo(() => {
return selectedStatus.includes('inactive');
return selectedStatus.some((status) => status === 'inactive' || status === 'unenrolled');
}, [selectedStatus]);
const [agents, setAgents] = useState<Agent[]>([]);
@ -309,7 +316,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => {
unhealthy: agentsStatusResponse.data.results.error,
offline: agentsStatusResponse.data.results.offline,
updating: agentsStatusResponse.data.results.updating,
inactive: agentsResponse.data.totalInactive,
inactive: agentsStatusResponse.data.results.inactive,
unenrolled: agentsStatusResponse.data.results.unenrolled,
});
const newAllTags = agentTagsResponse.data.items;

View file

@ -36,6 +36,14 @@ const Status = {
<FormattedMessage id="xpack.fleet.agentHealth.inactiveStatusText" defaultMessage="Inactive" />
</EuiBadge>
),
Unenrolled: (
<EuiBadge color="hollow">
<FormattedMessage
id="xpack.fleet.agentHealth.unenrolledStatusText"
defaultMessage="Unenrolled"
/>
</EuiBadge>
),
Unhealthy: (
<EuiBadge color="warning">
<FormattedMessage
@ -64,6 +72,8 @@ function getStatusComponent(status: Agent['status']): React.ReactElement {
case 'enrolling':
case 'updating':
return Status.Updating;
case 'unenrolled':
return Status.Unenrolled;
default:
return Status.Healthy;
}

View file

@ -21,6 +21,7 @@ const colorToHexMap = {
warning: visColors[5],
danger: visColors[9],
inactive: euiLightVars.euiColorDarkShade,
lightest: euiLightVars.euiColorDisabled,
};
export const AGENT_STATUSES: SimplifiedAgentStatus[] = [
@ -29,6 +30,7 @@ export const AGENT_STATUSES: SimplifiedAgentStatus[] = [
'updating',
'offline',
'inactive',
'unenrolled',
];
export function getColorForAgentStatus(agentStatus: SimplifiedAgentStatus): string {
@ -43,6 +45,8 @@ export function getColorForAgentStatus(agentStatus: SimplifiedAgentStatus): stri
return colorToHexMap.warning;
case 'updating':
return colorToHexMap.primary;
case 'unenrolled':
return colorToHexMap.lightest;
default:
throw new Error(`Unsupported Agent status ${agentStatus}`);
}
@ -62,6 +66,10 @@ export function getLabelForAgentStatus(agentStatus: SimplifiedAgentStatus): stri
return i18n.translate('xpack.fleet.agentStatus.inactiveLabel', {
defaultMessage: 'Inactive',
});
case 'unenrolled':
return i18n.translate('xpack.fleet.agentStatus.unenrolledLabel', {
defaultMessage: 'Unenrolled',
});
case 'unhealthy':
return i18n.translate('xpack.fleet.agentStatus.unhealthyLabel', {
defaultMessage: 'Unhealthy',

View file

@ -31,14 +31,18 @@ const DEFAULT_KIBANA_URL = 'http://localhost:5601';
const DEFAULT_KIBANA_USERNAME = 'elastic';
const DEFAULT_KIBANA_PASSWORD = 'changeme';
const DEFAULT_UNENROLL_TIMEOUT = 300; // 5 minutes
const ES_URL = 'http://localhost:9200';
const ES_SUPERUSER = 'fleet_superuser';
const ES_PASSWORD = 'password';
const DEFAULT_AGENT_COUNT = 50000;
const INDEX_BULK_OP = '{ "index":{ } }\n';
const {
delete: deleteAgentsFirst = false,
inactivityTimeout: inactivityTimeoutArg,
status: statusArg = 'online',
count: countArg,
kibana: kibanaUrl = DEFAULT_KIBANA_URL,
@ -52,7 +56,10 @@ const {
} = yargs(process.argv.slice(2)).argv;
const statusesArg = (statusArg as string).split(',') as AgentStatus[];
const count = countArg ? Number(countArg).valueOf() : 50000;
const inactivityTimeout = inactivityTimeoutArg
? Number(inactivityTimeoutArg).valueOf()
: DEFAULT_UNENROLL_TIMEOUT;
const count = countArg ? Number(countArg).valueOf() : DEFAULT_AGENT_COUNT;
const kbnAuth = 'Basic ' + Buffer.from(kbnUsername + ':' + kbnPassword).toString('base64');
const logger = new ToolingLog({
@ -63,7 +70,7 @@ const logger = new ToolingLog({
function setAgentStatus(agent: any, status: AgentStatus) {
switch (status) {
case 'inactive':
agent.active = false;
agent.last_checkin = new Date(new Date().getTime() - inactivityTimeout * 1000).toISOString();
break;
case 'enrolling':
agent.last_checkin = null;
@ -78,6 +85,10 @@ function setAgentStatus(agent: any, status: AgentStatus) {
case 'unenrolling':
agent.unenrollment_started_at = new Date().toISOString();
break;
case 'unenrolled':
agent.unenrolled_at = new Date().toISOString();
agent.active = false;
break;
case 'error':
agent.last_checkin_status = 'ERROR';
break;
@ -141,7 +152,7 @@ function createAgentWithStatus({
user_provided_metadata: {},
enrolled_at: new Date().toISOString(),
last_checkin: new Date().toISOString(),
tags: ['script_create_agents'],
tags: ['script_create_agents', status],
};
return setAgentStatus(baseAgent, status);
@ -248,6 +259,7 @@ async function createAgentPolicy(id: string) {
namespace: 'default',
description: '',
monitoring_enabled: ['logs'],
inactivity_timeout: inactivityTimeout,
}),
headers: {
Authorization: kbnAuth,
@ -304,7 +316,7 @@ export async function run() {
logger.info('Creating agent policy');
const agentPolicyId = uuid();
const agentPolicyId = 'script-create-agent-' + uuid();
const agentPolicy = await createAgentPolicy(agentPolicyId);
logger.info(`Created agent policy ${agentPolicy.item.id}`);

View file

@ -16,6 +16,8 @@ export interface AgentUsage {
healthy: number;
unhealthy: number;
offline: number;
inactive: number;
unenrolled: number;
total_all_statuses: number;
updating: number;
}
@ -31,19 +33,23 @@ export const getAgentUsage = async (
healthy: 0,
unhealthy: 0,
offline: 0,
inactive: 0,
unenrolled: 0,
total_all_statuses: 0,
updating: 0,
};
}
const { total, inactive, online, error, offline, updating } =
await AgentService.getAgentStatusForAgentPolicy(esClient);
const { total, inactive, online, error, offline, updating, unenrolled } =
await AgentService.getAgentStatusForAgentPolicy(esClient, soClient);
return {
total_enrolled: total,
healthy: online,
unhealthy: error,
offline,
total_all_statuses: total + inactive,
inactive,
unenrolled,
total_all_statuses: total + unenrolled,
updating,
};
};

View file

@ -70,6 +70,7 @@ export const getFleetServerUsage = async (
const { total, inactive, online, error, updating, offline } = await getAgentStatusForAgentPolicy(
esClient,
soClient,
undefined,
Array.from(policyIds)
.map((policyId) => `(policy_id:"${policyId}")`)

View file

@ -34,7 +34,7 @@ export const getAllFleetServerAgents = async (
let agentsResponse;
try {
agentsResponse = await getAgentsByKuery(esClient, {
agentsResponse = await getAgentsByKuery(esClient, soClient, {
showInactive: false,
perPage: SO_SEARCH_LIMIT,
kuery: `${AGENTS_PREFIX}.policy_id:${agentPoliciesIds.map((id) => `"${id}"`).join(' or ')}`,

View file

@ -137,6 +137,18 @@ export function registerFleetUsageCollector(
description: 'The total number of enrolled agents currently offline',
},
},
inactive: {
type: 'long',
_meta: {
description: 'The total number of of enrolled agents currently inactive',
},
},
unenrolled: {
type: 'long',
_meta: {
description: 'The total number of agents currently unenrolled',
},
},
total_all_statuses: {
type: 'long',
_meta: {

View file

@ -9,6 +9,7 @@ import type { PluginInitializerContext } from '@kbn/core/server';
import { FleetPlugin } from './plugin';
export { buildAgentStatusRuntimeField } from './services/agents/build_status_runtime_field';
export type {
AgentService,
AgentClient,

View file

@ -147,7 +147,7 @@ describe('fleet usage telemetry', () => {
},
{
create: {
_id: 'inactive',
_id: 'unenrolled',
},
},
{
@ -251,6 +251,8 @@ describe('fleet usage telemetry', () => {
total_enrolled: 2,
healthy: 0,
unhealthy: 0,
inactive: 0,
unenrolled: 1,
offline: 2,
total_all_statuses: 3,
updating: 0,

View file

@ -346,7 +346,7 @@ export class FleetPlugin
const coreContext = await context.core;
const authz = await getAuthzFromRequest(request);
const esClient = coreContext.elasticsearch.client;
const soClient = coreContext.savedObjects.getClient();
const routeRequiredAuthz = getRouteRequiredAuthz(request.route.method, request.route.path);
const routeAuthz = routeRequiredAuthz
? calculateRouteAuthz(authz, routeRequiredAuthz)
@ -359,7 +359,7 @@ export class FleetPlugin
return {
get agentClient() {
const agentService = plugin.setupAgentService(esClient.asInternalUser);
const agentService = plugin.setupAgentService(esClient.asInternalUser, soClient);
return {
asCurrentUser: agentService.asScoped(request),
@ -502,6 +502,7 @@ export class FleetPlugin
}
})();
const internalSoClient = new SavedObjectsClient(core.savedObjects.createInternalRepository());
return {
authz: {
fromRequest: getAuthzFromRequest,
@ -510,9 +511,12 @@ export class FleetPlugin
esIndexPatternService: new ESIndexPatternSavedObjectService(),
packageService: this.setupPackageService(
core.elasticsearch.client.asInternalUser,
new SavedObjectsClient(core.savedObjects.createInternalRepository())
internalSoClient
),
agentService: this.setupAgentService(
core.elasticsearch.client.asInternalUser,
internalSoClient
),
agentService: this.setupAgentService(core.elasticsearch.client.asInternalUser),
agentPolicyService: {
get: agentPolicyService.get,
list: agentPolicyService.list,
@ -536,12 +540,15 @@ export class FleetPlugin
this.fleetStatus$.complete();
}
private setupAgentService(internalEsClient: ElasticsearchClient): AgentService {
private setupAgentService(
internalEsClient: ElasticsearchClient,
internalSoClient: SavedObjectsClientContract
): AgentService {
if (this.agentService) {
return this.agentService;
}
this.agentService = new AgentServiceImpl(internalEsClient);
this.agentService = new AgentServiceImpl(internalEsClient, internalSoClient);
return this.agentService;
}

View file

@ -27,9 +27,11 @@ export const postNewAgentActionHandlerBuilder = function (
> {
return async (context, request, response) => {
try {
const esClient = (await context.core).elasticsearch.client.asInternalUser;
const core = await context.core;
const esClient = core.elasticsearch.client.asInternalUser;
const soClient = core.savedObjects.client;
const agent = await actionsService.getAgent(esClient, request.params.agentId);
const agent = await actionsService.getAgent(esClient, soClient, request.params.agentId);
const newAgentAction = request.body.action;

View file

@ -52,10 +52,11 @@ export const getAgentHandler: RequestHandler<
> = async (context, request, response) => {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;
try {
const body: GetOneAgentResponse = {
item: await AgentService.getAgentById(esClient, request.params.agentId),
item: await AgentService.getAgentById(esClient, soClient, request.params.agentId),
};
return response.ok({ body });
@ -103,6 +104,7 @@ export const updateAgentHandler: RequestHandler<
> = async (context, request, response) => {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;
const partialAgent: any = {};
if (request.body.user_provided_metadata) {
@ -115,7 +117,7 @@ export const updateAgentHandler: RequestHandler<
try {
await AgentService.updateAgent(esClient, request.params.agentId, partialAgent);
const body = {
item: await AgentService.getAgentById(esClient, request.params.agentId),
item: await AgentService.getAgentById(esClient, soClient, request.params.agentId),
};
return response.ok({ body });
@ -163,9 +165,16 @@ export const getAgentsHandler: RequestHandler<
> = async (context, request, response) => {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;
try {
const { agents, total, page, perPage } = await AgentService.getAgentsByKuery(esClient, {
const {
agents,
total,
page,
perPage,
totalInactive = 0,
} = await AgentService.getAgentsByKuery(esClient, soClient, {
page: request.query.page,
perPage: request.query.perPage,
showInactive: request.query.showInactive,
@ -173,12 +182,8 @@ export const getAgentsHandler: RequestHandler<
kuery: request.query.kuery,
sortField: request.query.sortField,
sortOrder: request.query.sortOrder,
getTotalInactive: true,
});
const totalInactive = request.query.showInactive
? await AgentService.countInactiveAgents(esClient, {
kuery: request.query.kuery,
})
: 0;
const body: GetAgentsResponse = {
list: agents, // deprecated
@ -271,9 +276,11 @@ export const getAgentStatusForAgentPolicyHandler: RequestHandler<
> = async (context, request, response) => {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;
try {
const results = await AgentService.getAgentStatusForAgentPolicy(
esClient,
soClient,
request.query.policyId,
request.query.kuery
);

View file

@ -46,7 +46,7 @@ export const postAgentUpgradeHandler: RequestHandler<
});
}
try {
const agent = await getAgentById(esClient, request.params.agentId);
const agent = await getAgentById(esClient, soClient, request.params.agentId);
const fleetServerAgents = await getAllFleetServerAgents(soClient, esClient);
const agentIsFleetServer = fleetServerAgents.some(

View file

@ -6,7 +6,12 @@
*/
import type { TypeOf } from '@kbn/config-schema';
import type { RequestHandler, ResponseHeaders, ElasticsearchClient } from '@kbn/core/server';
import type {
RequestHandler,
ResponseHeaders,
ElasticsearchClient,
SavedObjectsClientContract,
} from '@kbn/core/server';
import pMap from 'p-map';
import { safeDump } from 'js-yaml';
@ -46,12 +51,13 @@ import { createAgentPolicyWithPackages } from '../../services/agent_policy_creat
async function populateAssignedAgentsCount(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
agentPolicies: AgentPolicy[]
) {
await pMap(
agentPolicies,
(agentPolicy: GetAgentPoliciesResponseItem) =>
getAgentsByKuery(esClient, {
getAgentsByKuery(esClient, soClient, {
showInactive: false,
perPage: 0,
page: 1,
@ -83,7 +89,7 @@ export const getAgentPoliciesHandler: FleetRequestHandler<
perPage,
};
await populateAssignedAgentsCount(esClient, items);
await populateAssignedAgentsCount(esClient, soClient, items);
return response.ok({ body });
} catch (error) {
@ -110,7 +116,7 @@ export const bulkGetAgentPoliciesHandler: FleetRequestHandler<
items,
};
await populateAssignedAgentsCount(esClient, items);
await populateAssignedAgentsCount(esClient, soClient, items);
return response.ok({ body });
} catch (error) {

View file

@ -569,4 +569,59 @@ describe('agent policy', () => {
});
});
});
describe('getInactivityTimeouts', () => {
const createPolicySO = (id: string, inactivityTimeout: number) => ({
id,
type: AGENT_POLICY_SAVED_OBJECT_TYPE,
attributes: { inactivity_timeout: inactivityTimeout },
references: [],
score: 1,
});
const createMockSoClientThatReturns = (policies: Array<ReturnType<typeof createPolicySO>>) => {
const mockSoClient = savedObjectsClientMock.create();
mockSoClient.find.mockResolvedValue({
saved_objects: policies,
page: 1,
per_page: 10,
total: policies.length,
});
return mockSoClient;
};
it('should return empty array if no policies with inactivity timeouts', async () => {
const mockSoClient = createMockSoClientThatReturns([]);
expect(await agentPolicyService.getInactivityTimeouts(mockSoClient)).toEqual([]);
});
it('should return single inactivity timeout', async () => {
const mockSoClient = createMockSoClientThatReturns([createPolicySO('policy1', 1000)]);
expect(await agentPolicyService.getInactivityTimeouts(mockSoClient)).toEqual([
{ inactivityTimeout: 1000, policyIds: ['policy1'] },
]);
});
it('should return group policies with same inactivity timeout', async () => {
const mockSoClient = createMockSoClientThatReturns([
createPolicySO('policy1', 1000),
createPolicySO('policy2', 1000),
]);
expect(await agentPolicyService.getInactivityTimeouts(mockSoClient)).toEqual([
{ inactivityTimeout: 1000, policyIds: ['policy1', 'policy2'] },
]);
});
it('should return handle single and grouped policies', async () => {
const mockSoClient = createMockSoClientThatReturns([
createPolicySO('policy1', 1000),
createPolicySO('policy2', 1000),
createPolicySO('policy3', 2000),
]);
expect(await agentPolicyService.getInactivityTimeouts(mockSoClient)).toEqual([
{ inactivityTimeout: 1000, policyIds: ['policy1', 'policy2'] },
{ inactivityTimeout: 2000, policyIds: ['policy3'] },
]);
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { omit, isEqual, keyBy } from 'lodash';
import { omit, isEqual, keyBy, groupBy } from 'lodash';
import uuidv5 from 'uuid/v5';
import { safeDump } from 'js-yaml';
import pMap from 'p-map';
@ -658,7 +658,7 @@ class AgentPolicyService {
throw new HostedAgentPolicyRestrictionRelatedError(`Cannot delete hosted agent policy ${id}`);
}
const { total } = await getAgentsByKuery(esClient, {
const { total } = await getAgentsByKuery(esClient, soClient, {
showInactive: false,
perPage: 0,
page: 1,
@ -1031,6 +1031,25 @@ class AgentPolicyService {
return res;
}
public async getInactivityTimeouts(
soClient: SavedObjectsClientContract
): Promise<Array<{ policyIds: string[]; inactivityTimeout: number }>> {
const findRes = await soClient.find<AgentPolicySOAttributes>({
type: SAVED_OBJECT_TYPE,
page: 1,
perPage: SO_SEARCH_LIMIT,
filter: `${SAVED_OBJECT_TYPE}.attributes.inactivity_timeout: *`,
fields: [`inactivity_timeout`],
});
const groupedResults = groupBy(findRes.saved_objects, (so) => so.attributes.inactivity_timeout);
return Object.entries(groupedResults).map(([inactivityTimeout, policies]) => ({
inactivityTimeout: parseInt(inactivityTimeout, 10),
policyIds: policies.map((policy) => policy.id),
}));
}
}
export const agentPolicyService = new AgentPolicyService();

View file

@ -14,31 +14,47 @@ import type { AgentPolicy } from '../../types';
export function createClientMock() {
const agentInHostedDoc = {
_id: 'agent-in-hosted-policy',
_index: 'index',
_source: {
policy_id: 'hosted-agent-policy',
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
},
fields: {
status: ['online'],
},
};
const agentInHostedDoc2 = {
_id: 'agent-in-hosted-policy2',
_index: 'index',
_source: {
policy_id: 'hosted-agent-policy',
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
},
fields: {
status: ['online'],
},
};
const agentInRegularDoc = {
_id: 'agent-in-regular-policy',
_index: 'index',
_source: {
policy_id: 'regular-agent-policy',
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
},
fields: {
status: ['online'],
},
};
const agentInRegularDoc2 = {
_id: 'agent-in-regular-policy2',
_index: 'index',
_source: {
policy_id: 'regular-agent-policy',
local_metadata: { elastic: { agent: { version: '8.4.0', upgradeable: true } } },
},
fields: {
status: ['online'],
},
};
const regularAgentPolicySO = {
id: 'regular-agent-policy',
@ -74,6 +90,13 @@ export function createClientMock() {
};
});
soClientMock.find.mockResolvedValue({
saved_objects: [],
total: 0,
per_page: 10,
page: 1,
});
const esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser;
// @ts-expect-error
esClientMock.get.mockResponseImplementation(({ id }) => {
@ -124,7 +147,24 @@ export function createClientMock() {
};
});
esClientMock.search.mockResolvedValue({ hits: { hits: [] } } as any);
esClientMock.search.mockImplementation(() =>
Promise.resolve({
took: 1,
timed_out: false,
_shards: {
failed: 0,
successful: 1,
total: 1,
},
hits: {
hits: [agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2],
total: {
value: 3,
relation: 'eq',
},
},
})
);
return {
soClient: soClientMock,

View file

@ -191,7 +191,7 @@ export abstract class ActionRunner {
const perPage = this.actionParams.batchSize ?? SO_SEARCH_LIMIT;
const getAgents = () =>
getAgentsByKuery(this.esClient, {
getAgentsByKuery(this.esClient, this.soClient, {
kuery: this.actionParams.kuery,
showInactive: this.actionParams.showInactive ?? false,
page: 1,

View file

@ -6,7 +6,7 @@
*/
import uuid from 'uuid';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import { appContextService } from '../app_context';
import type {
@ -287,7 +287,11 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId:
}
export interface ActionsService {
getAgent: (esClient: ElasticsearchClient, agentId: string) => Promise<Agent>;
getAgent: (
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
agentId: string
) => Promise<Agent>;
cancelAgentAction: (esClient: ElasticsearchClient, actionId: string) => Promise<AgentAction>;

View file

@ -9,8 +9,12 @@ jest.mock('../security');
jest.mock('./crud');
jest.mock('./status');
import type { ElasticsearchClient } from '@kbn/core/server';
import { elasticsearchServiceMock, httpServerMock } from '@kbn/core/server/mocks';
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import {
elasticsearchServiceMock,
httpServerMock,
savedObjectsClientMock,
} from '@kbn/core/server/mocks';
import { FleetUnauthorizedError } from '../../errors';
@ -36,7 +40,8 @@ describe('AgentService', () => {
describe('asScoped', () => {
describe('without required privilege', () => {
const agentClient = new AgentServiceImpl(
elasticsearchServiceMock.createElasticsearchClient()
elasticsearchServiceMock.createElasticsearchClient(),
savedObjectsClientMock.create()
).asScoped(httpServerMock.createKibanaRequest());
beforeEach(() =>
@ -99,7 +104,8 @@ describe('AgentService', () => {
describe('with required privilege', () => {
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const agentClient = new AgentServiceImpl(mockEsClient).asScoped(
const mockSoClient = savedObjectsClientMock.create();
const agentClient = new AgentServiceImpl(mockEsClient, mockSoClient).asScoped(
httpServerMock.createKibanaRequest()
);
@ -128,20 +134,22 @@ describe('AgentService', () => {
)
);
expectApisToCallServicesSuccessfully(mockEsClient, agentClient);
expectApisToCallServicesSuccessfully(mockEsClient, mockSoClient, agentClient);
});
});
describe('asInternalUser', () => {
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const agentClient = new AgentServiceImpl(mockEsClient).asInternalUser;
const mockSoClient = savedObjectsClientMock.create();
const agentClient = new AgentServiceImpl(mockEsClient, mockSoClient).asInternalUser;
expectApisToCallServicesSuccessfully(mockEsClient, agentClient);
expectApisToCallServicesSuccessfully(mockEsClient, mockSoClient, agentClient);
});
});
function expectApisToCallServicesSuccessfully(
mockEsClient: ElasticsearchClient,
mockSoClient: jest.Mocked<SavedObjectsClientContract>,
agentClient: AgentClient
) {
test('client.listAgents calls getAgentsByKuery and returns results', async () => {
@ -149,7 +157,7 @@ function expectApisToCallServicesSuccessfully(
await expect(agentClient.listAgents({ showInactive: true })).resolves.toEqual(
'getAgentsByKuery success'
);
expect(mockGetAgentsByKuery).toHaveBeenCalledWith(mockEsClient, {
expect(mockGetAgentsByKuery).toHaveBeenCalledWith(mockEsClient, mockSoClient, {
showInactive: true,
});
});
@ -157,7 +165,7 @@ function expectApisToCallServicesSuccessfully(
test('client.getAgent calls getAgentById and returns results', async () => {
mockGetAgentById.mockResolvedValue('getAgentById success');
await expect(agentClient.getAgent('foo-id')).resolves.toEqual('getAgentById success');
expect(mockGetAgentById).toHaveBeenCalledWith(mockEsClient, 'foo-id');
expect(mockGetAgentById).toHaveBeenCalledWith(mockEsClient, mockSoClient, 'foo-id');
});
test('client.getAgentStatusById calls getAgentStatusById and returns results', async () => {
@ -165,7 +173,7 @@ function expectApisToCallServicesSuccessfully(
await expect(agentClient.getAgentStatusById('foo-id')).resolves.toEqual(
'getAgentStatusById success'
);
expect(mockGetAgentStatusById).toHaveBeenCalledWith(mockEsClient, 'foo-id');
expect(mockGetAgentStatusById).toHaveBeenCalledWith(mockEsClient, mockSoClient, 'foo-id');
});
test('client.getAgentStatusForAgentPolicy calls getAgentStatusForAgentPolicy and returns results', async () => {
@ -175,6 +183,7 @@ function expectApisToCallServicesSuccessfully(
);
expect(mockGetAgentStatusForAgentPolicy).toHaveBeenCalledWith(
mockEsClient,
mockSoClient,
'foo-id',
'foo-filter'
);

View file

@ -7,7 +7,11 @@
/* eslint-disable max-classes-per-file */
import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server';
import type {
ElasticsearchClient,
KibanaRequest,
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { AgentStatus, ListWithKuery } from '../../types';
import type { Agent, GetAgentStatusResponse } from '../../../common/types';
@ -82,6 +86,7 @@ export interface AgentClient {
class AgentClientImpl implements AgentClient {
constructor(
private readonly internalEsClient: ElasticsearchClient,
private readonly soClient: SavedObjectsClientContract,
private readonly preflightCheck?: () => void | Promise<void>
) {}
@ -91,22 +96,27 @@ class AgentClientImpl implements AgentClient {
}
) {
await this.#runPreflight();
return getAgentsByKuery(this.internalEsClient, options);
return getAgentsByKuery(this.internalEsClient, this.soClient, options);
}
public async getAgent(agentId: string) {
await this.#runPreflight();
return getAgentById(this.internalEsClient, agentId);
return getAgentById(this.internalEsClient, this.soClient, agentId);
}
public async getAgentStatusById(agentId: string) {
await this.#runPreflight();
return getAgentStatusById(this.internalEsClient, agentId);
return getAgentStatusById(this.internalEsClient, this.soClient, agentId);
}
public async getAgentStatusForAgentPolicy(agentPolicyId?: string, filterKuery?: string) {
await this.#runPreflight();
return getAgentStatusForAgentPolicy(this.internalEsClient, agentPolicyId, filterKuery);
return getAgentStatusForAgentPolicy(
this.internalEsClient,
this.soClient,
agentPolicyId,
filterKuery
);
}
#runPreflight = async () => {
@ -120,7 +130,10 @@ class AgentClientImpl implements AgentClient {
* @internal
*/
export class AgentServiceImpl implements AgentService {
constructor(private readonly internalEsClient: ElasticsearchClient) {}
constructor(
private readonly internalEsClient: ElasticsearchClient,
private readonly soClient: SavedObjectsClientContract
) {}
public asScoped(req: KibanaRequest) {
const preflightCheck = async () => {
@ -132,10 +145,10 @@ export class AgentServiceImpl implements AgentService {
}
};
return new AgentClientImpl(this.internalEsClient, preflightCheck);
return new AgentClientImpl(this.internalEsClient, this.soClient, preflightCheck);
}
public get asInternalUser() {
return new AgentClientImpl(this.internalEsClient);
return new AgentClientImpl(this.internalEsClient, this.soClient);
}
}

View file

@ -0,0 +1,301 @@
/*
* 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 { InactivityTimeouts } from './build_status_runtime_field';
import { _buildStatusRuntimeField } from './build_status_runtime_field';
describe('buildStatusRuntimeField', () => {
const now = 1234567890123;
beforeAll(() => {
global.Date.now = jest.fn(() => now);
});
it('should build the correct runtime field if there are no inactivity timeouts', () => {
const inactivityTimeouts: InactivityTimeouts = [];
const runtimeField = _buildStatusRuntimeField(inactivityTimeouts);
expect(runtimeField).toMatchInlineSnapshot(`
Object {
"status": Object {
"script": Object {
"lang": "painless",
"source": "
long lastCheckinMillis = doc['last_checkin'].size() > 0
? doc['last_checkin'].value.toInstant().toEpochMilli()
: -1;
if (doc['active'].size() > 0 && doc['active'].value == false) {
emit('unenrolled');
} else if (lastCheckinMillis > 0 && doc['policy_id'].size() > 0 && false) {
emit('inactive');
} else if (
lastCheckinMillis > 0
&& lastCheckinMillis
< (1234567590123L)
) {
emit('offline');
} else if (
doc['policy_revision_idx'].size() == 0 || (
doc['upgrade_started_at'].size() > 0 &&
doc['upgraded_at'].size() == 0
)
) {
emit('updating');
} else if (doc['last_checkin'].size() == 0) {
emit('enrolling');
} else if (doc['unenrollment_started_at'].size() > 0) {
emit('unenrolling');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'error'
) {
emit('error');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'degraded'
) {
emit('degraded');
} else {
emit('online');
}",
},
"type": "keyword",
},
}
`);
});
it('should build the correct runtime field if there are no inactivity timeouts (prefix)', () => {
const inactivityTimeouts: InactivityTimeouts = [];
const runtimeField = _buildStatusRuntimeField(inactivityTimeouts, 'my.prefix.');
expect(runtimeField).toMatchInlineSnapshot(`
Object {
"status": Object {
"script": Object {
"lang": "painless",
"source": "
long lastCheckinMillis = doc['my.prefix.last_checkin'].size() > 0
? doc['my.prefix.last_checkin'].value.toInstant().toEpochMilli()
: -1;
if (doc['my.prefix.active'].size() > 0 && doc['my.prefix.active'].value == false) {
emit('unenrolled');
} else if (lastCheckinMillis > 0 && doc['my.prefix.policy_id'].size() > 0 && false) {
emit('inactive');
} else if (
lastCheckinMillis > 0
&& lastCheckinMillis
< (1234567590123L)
) {
emit('offline');
} else if (
doc['my.prefix.policy_revision_idx'].size() == 0 || (
doc['my.prefix.upgrade_started_at'].size() > 0 &&
doc['my.prefix.upgraded_at'].size() == 0
)
) {
emit('updating');
} else if (doc['my.prefix.last_checkin'].size() == 0) {
emit('enrolling');
} else if (doc['my.prefix.unenrollment_started_at'].size() > 0) {
emit('unenrolling');
} else if (
doc['my.prefix.last_checkin_status'].size() > 0 &&
doc['my.prefix.last_checkin_status'].value.toLowerCase() == 'error'
) {
emit('error');
} else if (
doc['my.prefix.last_checkin_status'].size() > 0 &&
doc['my.prefix.last_checkin_status'].value.toLowerCase() == 'degraded'
) {
emit('degraded');
} else {
emit('online');
}",
},
"type": "keyword",
},
}
`);
});
it('should build the correct runtime field if there is one inactivity timeout', () => {
const inactivityTimeouts: InactivityTimeouts = [
{
inactivityTimeout: 300,
policyIds: ['policy-1'],
},
];
const runtimeField = _buildStatusRuntimeField(inactivityTimeouts);
expect(runtimeField).toMatchInlineSnapshot(`
Object {
"status": Object {
"script": Object {
"lang": "painless",
"source": "
long lastCheckinMillis = doc['last_checkin'].size() > 0
? doc['last_checkin'].value.toInstant().toEpochMilli()
: -1;
if (doc['active'].size() > 0 && doc['active'].value == false) {
emit('unenrolled');
} else if (lastCheckinMillis > 0 && doc['policy_id'].size() > 0 && (doc['policy_id'].value == 'policy-1') && lastCheckinMillis < 1234567590123L) {
emit('inactive');
} else if (
lastCheckinMillis > 0
&& lastCheckinMillis
< (1234567590123L)
) {
emit('offline');
} else if (
doc['policy_revision_idx'].size() == 0 || (
doc['upgrade_started_at'].size() > 0 &&
doc['upgraded_at'].size() == 0
)
) {
emit('updating');
} else if (doc['last_checkin'].size() == 0) {
emit('enrolling');
} else if (doc['unenrollment_started_at'].size() > 0) {
emit('unenrolling');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'error'
) {
emit('error');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'degraded'
) {
emit('degraded');
} else {
emit('online');
}",
},
"type": "keyword",
},
}
`);
});
it('should build the correct runtime field if there are multiple inactivity timeouts with same timeout', () => {
const inactivityTimeouts: InactivityTimeouts = [
{
inactivityTimeout: 300,
policyIds: ['policy-1', 'policy-2'],
},
];
const runtimeField = _buildStatusRuntimeField(inactivityTimeouts);
expect(runtimeField).toMatchInlineSnapshot(`
Object {
"status": Object {
"script": Object {
"lang": "painless",
"source": "
long lastCheckinMillis = doc['last_checkin'].size() > 0
? doc['last_checkin'].value.toInstant().toEpochMilli()
: -1;
if (doc['active'].size() > 0 && doc['active'].value == false) {
emit('unenrolled');
} else if (lastCheckinMillis > 0 && doc['policy_id'].size() > 0 && (doc['policy_id'].value == 'policy-1' || doc['policy_id'].value == 'policy-2') && lastCheckinMillis < 1234567590123L) {
emit('inactive');
} else if (
lastCheckinMillis > 0
&& lastCheckinMillis
< (1234567590123L)
) {
emit('offline');
} else if (
doc['policy_revision_idx'].size() == 0 || (
doc['upgrade_started_at'].size() > 0 &&
doc['upgraded_at'].size() == 0
)
) {
emit('updating');
} else if (doc['last_checkin'].size() == 0) {
emit('enrolling');
} else if (doc['unenrollment_started_at'].size() > 0) {
emit('unenrolling');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'error'
) {
emit('error');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'degraded'
) {
emit('degraded');
} else {
emit('online');
}",
},
"type": "keyword",
},
}
`);
});
it('should build the correct runtime field if there are multiple inactivity timeouts with different timeout', () => {
const inactivityTimeouts: InactivityTimeouts = [
{
inactivityTimeout: 300,
policyIds: ['policy-1', 'policy-2'],
},
{
inactivityTimeout: 400,
policyIds: ['policy-3'],
},
];
const runtimeField = _buildStatusRuntimeField(inactivityTimeouts);
expect(runtimeField).toMatchInlineSnapshot(`
Object {
"status": Object {
"script": Object {
"lang": "painless",
"source": "
long lastCheckinMillis = doc['last_checkin'].size() > 0
? doc['last_checkin'].value.toInstant().toEpochMilli()
: -1;
if (doc['active'].size() > 0 && doc['active'].value == false) {
emit('unenrolled');
} else if (lastCheckinMillis > 0 && doc['policy_id'].size() > 0 && (doc['policy_id'].value == 'policy-1' || doc['policy_id'].value == 'policy-2') && lastCheckinMillis < 1234567590123L || (doc['policy_id'].value == 'policy-3') && lastCheckinMillis < 1234567490123L) {
emit('inactive');
} else if (
lastCheckinMillis > 0
&& lastCheckinMillis
< (1234567590123L)
) {
emit('offline');
} else if (
doc['policy_revision_idx'].size() == 0 || (
doc['upgrade_started_at'].size() > 0 &&
doc['upgraded_at'].size() == 0
)
) {
emit('updating');
} else if (doc['last_checkin'].size() == 0) {
emit('enrolling');
} else if (doc['unenrollment_started_at'].size() > 0) {
emit('unenrolling');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'error'
) {
emit('error');
} else if (
doc['last_checkin_status'].size() > 0 &&
doc['last_checkin_status'].value.toLowerCase() == 'degraded'
) {
emit('degraded');
} else {
emit('online');
}",
},
"type": "keyword",
},
}
`);
});
it('should build the same runtime field if path ends with. or not', () => {
const inactivityTimeouts: InactivityTimeouts = [];
const runtimeFieldWithDot = _buildStatusRuntimeField(inactivityTimeouts, 'my.prefix.');
const runtimeFieldNoDot = _buildStatusRuntimeField(inactivityTimeouts, 'my.prefix');
expect(runtimeFieldWithDot).toEqual(runtimeFieldNoDot);
});
});

View file

@ -0,0 +1,113 @@
/*
* 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/types';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import { AGENT_POLLING_THRESHOLD_MS } from '../../constants';
import { agentPolicyService } from '../agent_policy';
const MISSED_INTERVALS_BEFORE_OFFLINE = 10;
const MS_BEFORE_OFFLINE = MISSED_INTERVALS_BEFORE_OFFLINE * AGENT_POLLING_THRESHOLD_MS;
export type InactivityTimeouts = Awaited<
ReturnType<typeof agentPolicyService['getInactivityTimeouts']>
>;
const _buildInactiveClause = (
now: number,
inactivityTimeouts: InactivityTimeouts,
field: (path: string) => string
) => {
const policyClauses = inactivityTimeouts
.map(({ inactivityTimeout, policyIds }) => {
const inactivityTimeoutMs = inactivityTimeout * 1000;
const policyOrs = policyIds
.map((policyId) => `${field('policy_id')}.value == '${policyId}'`)
.join(' || ');
return `(${policyOrs}) && lastCheckinMillis < ${now - inactivityTimeoutMs}L`;
})
.join(' || ');
const agentIsInactive = policyClauses.length ? `${policyClauses}` : 'false'; // if no policies have inactivity timeouts, then no agents are inactive
return `lastCheckinMillis > 0 && ${field('policy_id')}.size() > 0 && ${agentIsInactive}`;
};
function _buildSource(inactivityTimeouts: InactivityTimeouts, pathPrefix?: string) {
const normalizedPrefix = pathPrefix ? `${pathPrefix}${pathPrefix.endsWith('.') ? '' : '.'}` : '';
const field = (path: string) => `doc['${normalizedPrefix + path}']`;
const now = Date.now();
return `
long lastCheckinMillis = ${field('last_checkin')}.size() > 0
? ${field('last_checkin')}.value.toInstant().toEpochMilli()
: -1;
if (${field('active')}.size() > 0 && ${field('active')}.value == false) {
emit('unenrolled');
} else if (${_buildInactiveClause(now, inactivityTimeouts, field)}) {
emit('inactive');
} else if (
lastCheckinMillis > 0
&& lastCheckinMillis
< (${now - MS_BEFORE_OFFLINE}L)
) {
emit('offline');
} else if (
${field('policy_revision_idx')}.size() == 0 || (
${field('upgrade_started_at')}.size() > 0 &&
${field('upgraded_at')}.size() == 0
)
) {
emit('updating');
} else if (${field('last_checkin')}.size() == 0) {
emit('enrolling');
} else if (${field('unenrollment_started_at')}.size() > 0) {
emit('unenrolling');
} else if (
${field('last_checkin_status')}.size() > 0 &&
${field('last_checkin_status')}.value.toLowerCase() == 'error'
) {
emit('error');
} else if (
${field('last_checkin_status')}.size() > 0 &&
${field('last_checkin_status')}.value.toLowerCase() == 'degraded'
) {
emit('degraded');
} else {
emit('online');
}`;
}
// exported for testing
export function _buildStatusRuntimeField(
inactivityTimeouts: InactivityTimeouts,
pathPrefix?: string
): NonNullable<estypes.SearchRequest['runtime_mappings']> {
const source = _buildSource(inactivityTimeouts, pathPrefix);
return {
status: {
type: 'keyword',
script: {
lang: 'painless',
source,
},
},
};
}
// Build the runtime field to return the agent status
// pathPrefix is used to prefix the field path in the source
// pathPrefix is used by the endpoint team currently to run
// agent queries against the endpoint metadata index
export async function buildAgentStatusRuntimeField(
soClient: SavedObjectsClientContract,
pathPrefix?: string
) {
const inactivityTimeouts = await agentPolicyService.getInactivityTimeouts(soClient);
return _buildStatusRuntimeField(inactivityTimeouts, pathPrefix);
}

View file

@ -6,6 +6,7 @@
*/
import { errors } from '@elastic/elasticsearch';
import type { ElasticsearchClient } from '@kbn/core/server';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import type { Agent } from '../../types';
@ -16,11 +17,13 @@ jest.mock('../../../common/services/is_agent_upgradeable', () => ({
}));
describe('Agents CRUD test', () => {
const soClientMock = savedObjectsClientMock.create();
let esClientMock: ElasticsearchClient;
let searchMock: jest.Mock;
beforeEach(() => {
searchMock = jest.fn();
soClientMock.find = jest.fn().mockResolvedValue({ saved_objects: [] });
esClientMock = {
search: searchMock,
openPointInTime: jest.fn().mockResolvedValue({ id: '1' }),
@ -35,6 +38,9 @@ describe('Agents CRUD test', () => {
hits: ids.map((id: string) => ({
_id: id,
_source: {},
fields: {
status: ['inactive'],
},
})),
},
};
@ -54,7 +60,7 @@ describe('Agents CRUD test', () => {
expect(searchMock).toHaveBeenCalledWith({
aggs: { tags: { terms: { field: 'tags', size: 10000 } } },
body: {
query: { bool: { minimum_should_match: 1, should: [{ match: { active: true } }] } },
query: expect.any(Object),
},
index: '.fleet-agents',
size: 0,
@ -122,7 +128,7 @@ describe('Agents CRUD test', () => {
.mockImplementationOnce(() =>
Promise.resolve(getEsResponse(['1', '2', '3', '4', '5', 'up', '7'], 7))
);
const result = await getAgentsByKuery(esClientMock, {
const result = await getAgentsByKuery(esClientMock, soClientMock, {
showUpgradeable: true,
showInactive: false,
page: 1,
@ -151,7 +157,7 @@ describe('Agents CRUD test', () => {
.mockImplementationOnce(() =>
Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5', 'up2', '7'], 7))
);
const result = await getAgentsByKuery(esClientMock, {
const result = await getAgentsByKuery(esClientMock, soClientMock, {
showUpgradeable: true,
showInactive: false,
page: 1,
@ -187,7 +193,7 @@ describe('Agents CRUD test', () => {
.mockImplementationOnce(() =>
Promise.resolve(getEsResponse(['up1', 'up2', 'up3', 'up4', 'up5', 'up6', '7'], 7))
);
const result = await getAgentsByKuery(esClientMock, {
const result = await getAgentsByKuery(esClientMock, soClientMock, {
showUpgradeable: true,
showInactive: false,
page: 2,
@ -214,7 +220,7 @@ describe('Agents CRUD test', () => {
searchMock.mockImplementationOnce(() =>
Promise.resolve(getEsResponse(['1', '2', '3', 'up', '5'], 10001))
);
const result = await getAgentsByKuery(esClientMock, {
const result = await getAgentsByKuery(esClientMock, soClientMock, {
showUpgradeable: true,
showInactive: false,
page: 1,
@ -239,7 +245,7 @@ describe('Agents CRUD test', () => {
it('should return second page', async () => {
searchMock.mockImplementationOnce(() => Promise.resolve(getEsResponse(['6', '7'], 7)));
const result = await getAgentsByKuery(esClientMock, {
const result = await getAgentsByKuery(esClientMock, soClientMock, {
showUpgradeable: false,
showInactive: false,
page: 2,
@ -271,11 +277,11 @@ describe('Agents CRUD test', () => {
it('should pass secondary sort for default sort', async () => {
searchMock.mockImplementationOnce(() => Promise.resolve(getEsResponse(['1', '2'], 2)));
await getAgentsByKuery(esClientMock, {
await getAgentsByKuery(esClientMock, soClientMock, {
showInactive: false,
});
expect(searchMock.mock.calls[searchMock.mock.calls.length - 1][0].body.sort).toEqual([
expect(searchMock.mock.calls.at(-1)[0].sort).toEqual([
{ enrolled_at: { order: 'desc' } },
{ 'local_metadata.host.hostname.keyword': { order: 'asc' } },
]);
@ -283,13 +289,11 @@ describe('Agents CRUD test', () => {
it('should not pass secondary sort for non-default sort', async () => {
searchMock.mockImplementationOnce(() => Promise.resolve(getEsResponse(['1', '2'], 2)));
await getAgentsByKuery(esClientMock, {
await getAgentsByKuery(esClientMock, soClientMock, {
showInactive: false,
sortField: 'policy_id',
});
expect(searchMock.mock.calls[searchMock.mock.calls.length - 1][0].body.sort).toEqual([
{ policy_id: { order: 'desc' } },
]);
expect(searchMock.mock.calls.at(-1)[0].sort).toEqual([{ policy_id: { order: 'desc' } }]);
});
});
});

View file

@ -16,14 +16,15 @@ import { appContextService, agentPolicyService } from '..';
import type { FleetServerAgent } from '../../../common/types';
import { SO_SEARCH_LIMIT } from '../../../common/constants';
import { isAgentUpgradeable } from '../../../common/services';
import { AGENTS_PREFIX, AGENTS_INDEX } from '../../constants';
import { escapeSearchQueryPhrase, normalizeKuery } from '../saved_object';
import { AGENTS_INDEX } from '../../constants';
import { FleetError, isESClientError, AgentNotFoundError } from '../../errors';
import { searchHitToAgent, agentSOAttributesToFleetServerAgentDoc } from './helpers';
const ACTIVE_AGENT_CONDITION = 'active:true';
const INACTIVE_AGENT_CONDITION = `NOT (${ACTIVE_AGENT_CONDITION})`;
import { buildAgentStatusRuntimeField } from './build_status_runtime_field';
const INACTIVE_AGENT_CONDITION = `status:inactive OR status:unenrolled`;
const ACTIVE_AGENT_CONDITION = `NOT (${INACTIVE_AGENT_CONDITION})`;
function _joinFilters(filters: Array<string | undefined | KueryNode>): KueryNode | undefined {
try {
@ -71,13 +72,19 @@ export type GetAgentsOptions =
perPage?: number;
};
export async function getAgents(esClient: ElasticsearchClient, options: GetAgentsOptions) {
export async function getAgents(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
options: GetAgentsOptions
) {
let agents: Agent[] = [];
if ('agentIds' in options) {
agents = await getAgentsById(esClient, options.agentIds);
agents = (await getAgentsById(esClient, soClient, options.agentIds)).filter(
(maybeAgent) => !('notFound' in maybeAgent)
) as Agent[];
} else if ('kuery' in options) {
agents = (
await getAllAgentsByKuery(esClient, {
await getAllAgentsByKuery(esClient, soClient, {
kuery: options.kuery,
showInactive: options.showInactive ?? false,
})
@ -180,8 +187,10 @@ export function getElasticsearchQuery(
export async function getAgentsByKuery(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
options: ListWithKuery & {
showInactive: boolean;
getTotalInactive?: boolean;
sortField?: string;
sortOrder?: 'asc' | 'desc';
pitId?: string;
@ -192,6 +201,7 @@ export async function getAgentsByKuery(
total: number;
page: number;
perPage: number;
totalInactive?: number;
}> {
const {
page = 1,
@ -203,6 +213,7 @@ export async function getAgentsByKuery(
showUpgradeable,
searchAfter,
pitId,
getTotalInactive = false,
} = options;
const filters = [];
@ -215,22 +226,24 @@ export async function getAgentsByKuery(
}
const kueryNode = _joinFilters(filters);
const body = kueryNode ? { query: toElasticsearchQuery(kueryNode) } : {};
const runtimeFields = await buildAgentStatusRuntimeField(soClient);
const isDefaultSort = sortField === 'enrolled_at' && sortOrder === 'desc';
// if using default sorting (enrolled_at), adding a secondary sort on hostname, so that the results are not changing randomly in case many agents were enrolled at the same time
const secondarySort: estypes.Sort = isDefaultSort
? [{ 'local_metadata.host.hostname.keyword': { order: 'asc' } }]
: [];
const queryAgents = async (from: number, size: number) =>
esClient.search<FleetServerAgent, {}>({
esClient.search<FleetServerAgent, { totalInactive?: { doc_count: number } }>({
from,
size,
track_total_hits: true,
rest_total_hits_as_int: true,
body: {
...body,
sort: [{ [sortField]: { order: sortOrder } }, ...secondarySort],
},
runtime_mappings: runtimeFields,
fields: Object.keys(runtimeFields),
sort: [{ [sortField]: { order: sortOrder } }, ...secondarySort],
post_filter: kueryNode ? toElasticsearchQuery(kueryNode) : undefined,
...(pitId
? {
pit: {
@ -243,11 +256,28 @@ export async function getAgentsByKuery(
ignore_unavailable: true,
}),
...(pitId && searchAfter ? { search_after: searchAfter, from: 0 } : {}),
...(getTotalInactive && {
aggregations: {
totalInactive: {
filter: { bool: { must: { terms: { status: ['inactive', 'unenrolled'] } } } },
},
},
}),
});
const res = await queryAgents((page - 1) * perPage, perPage);
let res;
try {
res = await queryAgents((page - 1) * perPage, perPage);
} catch (err) {
appContextService.getLogger().error(`Error getting agents by kuery: ${JSON.stringify(err)}`);
throw err;
}
let agents = res.hits.hits.map(searchHitToAgent);
let total = res.hits.total as number;
let totalInactive = 0;
if (getTotalInactive && res.aggregations) {
totalInactive = res.aggregations?.totalInactive?.doc_count ?? 0;
}
// filtering for a range on the version string will not work,
// nor does filtering on a flattened field (local_metadata), so filter here
if (showUpgradeable) {
@ -274,11 +304,13 @@ export async function getAgentsByKuery(
total,
page,
perPage,
...(getTotalInactive && { totalInactive }),
};
}
export async function getAllAgentsByKuery(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
options: Omit<ListWithKuery, 'page' | 'perPage'> & {
showInactive: boolean;
}
@ -286,7 +318,11 @@ export async function getAllAgentsByKuery(
agents: Agent[];
total: number;
}> {
const res = await getAgentsByKuery(esClient, { ...options, page: 1, perPage: SO_SEARCH_LIMIT });
const res = await getAgentsByKuery(esClient, soClient, {
...options,
page: 1,
perPage: SO_SEARCH_LIMIT,
});
return {
agents: res.agents,
@ -294,104 +330,108 @@ export async function getAllAgentsByKuery(
};
}
export async function countInactiveAgents(
export async function getAgentById(
esClient: ElasticsearchClient,
options: Pick<ListWithKuery, 'kuery'>
): Promise<number> {
const { kuery } = options;
const filters = [INACTIVE_AGENT_CONDITION];
soClient: SavedObjectsClientContract,
agentId: string
) {
const [agentHit] = await getAgentsById(esClient, soClient, [agentId]);
if (kuery && kuery !== '') {
filters.push(normalizeKuery(AGENTS_PREFIX, kuery));
if ('notFound' in agentHit) {
throw new AgentNotFoundError(`Agent ${agentId} not found`);
}
const kueryNode = _joinFilters(filters);
const body = kueryNode ? { query: toElasticsearchQuery(kueryNode) } : {};
const res = await esClient.search({
index: AGENTS_INDEX,
size: 0,
track_total_hits: true,
rest_total_hits_as_int: true,
filter_path: 'hits.total',
ignore_unavailable: true,
body,
});
return (res.hits.total as number) || 0;
return agentHit;
}
export async function getAgentById(esClient: ElasticsearchClient, agentId: string) {
const agentNotFoundError = new AgentNotFoundError(`Agent ${agentId} not found`);
async function _filterAgents(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
query: estypes.QueryDslQueryContainer,
options: {
page?: number;
perPage?: number;
sortField?: string;
sortOrder?: 'asc' | 'desc';
} = {}
): Promise<{
agents: Agent[];
total: number;
page: number;
perPage: number;
}> {
const { page = 1, perPage = 20, sortField = 'enrolled_at', sortOrder = 'desc' } = options;
const runtimeFields = await buildAgentStatusRuntimeField(soClient);
let res;
try {
const agentHit = await esClient.get<FleetServerAgent>({
res = await esClient.search<FleetServerAgent, {}>({
from: (page - 1) * perPage,
size: perPage,
track_total_hits: true,
rest_total_hits_as_int: true,
runtime_mappings: runtimeFields,
fields: Object.keys(runtimeFields),
sort: [{ [sortField]: { order: sortOrder } }],
query: { bool: { filter: query } },
index: AGENTS_INDEX,
id: agentId,
ignore_unavailable: true,
});
if (agentHit.found === false) {
throw agentNotFoundError;
}
return searchHitToAgent(agentHit);
} catch (err) {
if (isESClientError(err) && err.meta.statusCode === 404) {
throw agentNotFoundError;
}
appContextService.getLogger().error(`Error querying agents: ${JSON.stringify(err)}`);
throw err;
}
}
export function isAgentDocument(
maybeDocument: any
): maybeDocument is estypes.MgetResponseItem<FleetServerAgent> {
return '_id' in maybeDocument && '_source' in maybeDocument;
}
const agents = res.hits.hits.map(searchHitToAgent);
const total = res.hits.total as number;
export type ESAgentDocumentResult = estypes.MgetResponseItem<FleetServerAgent>;
export async function getAgentDocuments(
esClient: ElasticsearchClient,
agentIds: string[]
): Promise<ESAgentDocumentResult[]> {
const res = await esClient.mget<FleetServerAgent>({
index: AGENTS_INDEX,
body: { docs: agentIds.map((_id) => ({ _id })) },
});
return res.docs || [];
return {
agents,
total,
page,
perPage,
};
}
export async function getAgentsById(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
agentIds: string[]
): Promise<Agent[]> {
const allDocs = await getAgentDocuments(esClient, agentIds);
const agents = allDocs.reduce<Agent[]>((results, doc) => {
if (isAgentDocument(doc)) {
results.push(searchHitToAgent(doc));
}
): Promise<Array<Agent | { id: string; notFound: true }>> {
if (!agentIds.length) {
return [];
}
return results;
}, []);
const idsQuery = {
terms: {
_id: agentIds,
},
};
const { agents } = await _filterAgents(esClient, soClient, idsQuery, {
perPage: agentIds.length,
});
return agents;
// return agents in the same order as agentIds
return agentIds.map(
(agentId) => agents.find((agent) => agent.id === agentId) || { id: agentId, notFound: true }
);
}
export async function getAgentByAccessAPIKeyId(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
accessAPIKeyId: string
): Promise<Agent> {
const res = await esClient.search<FleetServerAgent>({
index: AGENTS_INDEX,
ignore_unavailable: true,
q: `access_api_key_id:${escapeSearchQueryPhrase(accessAPIKeyId)}`,
});
const query = {
term: {
access_api_key_id: accessAPIKeyId,
},
};
const { agents } = await _filterAgents(esClient, soClient, query);
const searchHit = res.hits.hits[0];
const agent = searchHit && searchHitToAgent(searchHit);
const agent = agents.length ? agents[0] : null;
if (!searchHit || !agent) {
if (!agent) {
throw new AgentNotFoundError('Agent not found');
}
if (agent.access_api_key_id !== accessAPIKeyId) {
@ -472,12 +512,31 @@ export async function deleteAgent(esClient: ElasticsearchClient, agentId: string
}
}
async function _getAgentDocById(esClient: ElasticsearchClient, agentId: string) {
try {
const res = await esClient.get<FleetServerAgent>({
id: agentId,
index: AGENTS_INDEX,
});
if (!res._source) {
throw new AgentNotFoundError(`Agent ${agentId} not found`);
}
return res._source;
} catch (err) {
if (isESClientError(err) && err.meta.statusCode === 404) {
throw new AgentNotFoundError(`Agent ${agentId} not found`);
}
throw err;
}
}
export async function getAgentPolicyForAgent(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
agentId: string
) {
const agent = await getAgentById(esClient, agentId);
const agent = await _getAgentDocById(esClient, agentId);
if (!agent.policy_id) {
return;
}

View file

@ -9,15 +9,21 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { SortResults } from '@elastic/elasticsearch/lib/api/types';
import type { SearchHit } from '@kbn/es-types';
import type { Agent, AgentSOAttributes, FleetServerAgent } from '../../types';
import { getAgentStatus } from '../../../common/services/agent_status';
import { appContextService } from '..';
import type { Agent, AgentSOAttributes, AgentStatus, FleetServerAgent } from '../../types';
type FleetServerAgentESResponse =
| estypes.GetGetResult<FleetServerAgent>
| estypes.SearchResponse<FleetServerAgent>['hits']['hits'][0]
| SearchHit<FleetServerAgent>;
export function searchHitToAgent(hit: FleetServerAgentESResponse & { sort?: SortResults }): Agent {
export function searchHitToAgent(
hit: FleetServerAgentESResponse & {
sort?: SortResults;
fields?: { status?: AgentStatus[] };
}
): Agent {
// @ts-expect-error @elastic/elasticsearch MultiGetHit._source is optional
const agent: Agent = {
id: hit._id,
@ -29,7 +35,16 @@ export function searchHitToAgent(hit: FleetServerAgentESResponse & { sort?: Sort
sort: hit.sort,
};
agent.status = getAgentStatus(agent);
if (!hit.fields?.status?.length) {
appContextService
.getLogger()
.error(
'Agent status runtime field is missing, unable to get agent status for agent ' + agent.id
);
} else {
agent.status = hit.fields.status[0];
}
return agent;
}

View file

@ -73,6 +73,13 @@ describe('reassignAgents (plural)', () => {
agentInHostedDoc2,
regularAgentPolicySO2,
} = createClientMock();
esClient.search.mockResponse({
hits: {
hits: [agentInRegularDoc, agentInHostedDoc, agentInHostedDoc2],
},
} as any);
const idsToReassign = [agentInRegularDoc._id, agentInHostedDoc._id, agentInHostedDoc2._id];
await reassignAgents(soClient, esClient, { agentIds: idsToReassign }, regularAgentPolicySO2.id);

View file

@ -4,7 +4,6 @@
* 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 type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server';
import Boom from '@hapi/boom';
@ -15,7 +14,7 @@ import { AgentReassignmentError, HostedAgentPolicyRestrictionRelatedError } from
import { SO_SEARCH_LIMIT } from '../../constants';
import {
getAgentDocuments,
getAgentsById,
getAgentPolicyForAgent,
updateAgent,
getAgentsByKuery,
@ -23,7 +22,6 @@ import {
} from './crud';
import type { GetAgentsOptions } from '.';
import { createAgentAction } from './actions';
import { searchHitToAgent } from './helpers';
import { ReassignActionRunner, reassignBatch } from './reassign_action_runner';
@ -78,9 +76,6 @@ export async function reassignAgentIsAllowed(
return true;
}
function isMgetDoc(doc?: estypes.MgetResponseItem<unknown>): doc is estypes.GetGetResult {
return Boolean(doc && 'found' in doc);
}
export async function reassignAgents(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
@ -105,19 +100,19 @@ export async function reassignAgents(
if ('agents' in options) {
givenAgents = options.agents;
} else if ('agentIds' in options) {
const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds);
for (const agentResult of givenAgentsResults) {
if (isMgetDoc(agentResult) && agentResult.found === false) {
outgoingErrors[agentResult._id] = new AgentReassignmentError(
`Cannot find agent ${agentResult._id}`
const maybeAgents = await getAgentsById(esClient, soClient, options.agentIds);
for (const maybeAgent of maybeAgents) {
if ('notFound' in maybeAgent) {
outgoingErrors[maybeAgent.id] = new AgentReassignmentError(
`Cannot find agent ${maybeAgent.id}`
);
} else {
givenAgents.push(searchHitToAgent(agentResult));
givenAgents.push(maybeAgent);
}
}
} else if ('kuery' in options) {
const batchSize = options.batchSize ?? SO_SEARCH_LIMIT;
const res = await getAgentsByKuery(esClient, {
const res = await getAgentsByKuery(esClient, soClient, {
kuery: options.kuery,
showInactive: options.showInactive ?? false,
page: 1,

View file

@ -38,19 +38,19 @@ export async function bulkRequestDiagnostics(
}
): Promise<{ actionId: string }> {
if ('agentIds' in options) {
const givenAgents = await getAgents(esClient, options);
const givenAgents = await getAgents(esClient, soClient, options);
return await requestDiagnosticsBatch(esClient, givenAgents, {});
}
const batchSize = options.batchSize ?? SO_SEARCH_LIMIT;
const res = await getAgentsByKuery(esClient, {
const res = await getAgentsByKuery(esClient, soClient, {
kuery: options.kuery,
showInactive: false,
page: 1,
perPage: batchSize,
});
if (res.total <= batchSize) {
const givenAgents = await getAgents(esClient, options);
const givenAgents = await getAgents(esClient, soClient, options);
return await requestDiagnosticsBatch(esClient, givenAgents, {});
} else {
return await new RequestDiagnosticsActionRunner(

View file

@ -1,105 +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 { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { AGENT_POLLING_THRESHOLD_MS } from '../../../common/constants';
import { getAgentStatusById } from './status';
describe('Agent status service', () => {
it('should return inactive when agent is not active', async () => {
const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockElasticsearchClient.get.mockResponse(
// @ts-expect-error not full interface
{
_id: 'id',
_source: {
active: false,
local_metadata: {},
user_provided_metadata: {},
},
}
);
const status = await getAgentStatusById(mockElasticsearchClient, 'id');
expect(status).toEqual('inactive');
});
it('should return online when agent is active', async () => {
const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockElasticsearchClient.get.mockResponse(
// @ts-expect-error not full interface
{
_id: 'id',
_source: {
active: true,
policy_revision_idx: 1,
last_checkin: new Date().toISOString(),
local_metadata: {},
user_provided_metadata: {},
},
}
);
const status = await getAgentStatusById(mockElasticsearchClient, 'id');
expect(status).toEqual('online');
});
it('should return enrolling when agent is active but never checkin', async () => {
const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockElasticsearchClient.get.mockResponse(
// @ts-expect-error not full interface
{
_id: 'id',
_source: {
active: true,
local_metadata: {},
user_provided_metadata: {},
},
}
);
const status = await getAgentStatusById(mockElasticsearchClient, 'id');
expect(status).toEqual('enrolling');
});
it('should return unenrolling when agent is unenrolling', async () => {
const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockElasticsearchClient.get.mockResponse(
// @ts-expect-error not full interface
{
_id: 'id',
_source: {
active: true,
last_checkin: new Date().toISOString(),
unenrollment_started_at: new Date().toISOString(),
local_metadata: {},
user_provided_metadata: {},
},
}
);
const status = await getAgentStatusById(mockElasticsearchClient, 'id');
expect(status).toEqual('unenrolling');
});
it('should return offline when agent has not checked in for 10 intervals', async () => {
const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
mockElasticsearchClient.get.mockResponse(
// @ts-expect-error not full interface
{
_id: 'id',
_source: {
active: true,
last_checkin: new Date(Date.now() - 10 * AGENT_POLLING_THRESHOLD_MS - 1000).toISOString(),
policy_revision_idx: 2,
local_metadata: {},
user_provided_metadata: {},
},
}
);
const status = await getAgentStatusById(mockElasticsearchClient, 'id');
expect(status).toEqual('offline');
});
});

View file

@ -5,120 +5,142 @@
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core/server';
import pMap from 'p-map';
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import type { KueryNode } from '@kbn/es-query';
import { toElasticsearchQuery } from '@kbn/es-query';
import { fromKueryExpression } from '@kbn/es-query';
import { AGENTS_PREFIX } from '../../constants';
import type {
AggregationsTermsAggregateBase,
AggregationsTermsBucketBase,
QueryDslQueryContainer,
} from '@elastic/elasticsearch/lib/api/types';
import { AGENTS_INDEX } from '../../constants';
import type { AgentStatus } from '../../types';
import { AgentStatusKueryHelper } from '../../../common/services';
import { FleetUnauthorizedError } from '../../errors';
import { appContextService } from '../app_context';
import {
closePointInTime,
getAgentById,
getAgentsByKuery,
openPointInTime,
removeSOAttributes,
} from './crud';
import { getAgentById, removeSOAttributes } from './crud';
import { buildAgentStatusRuntimeField } from './build_status_runtime_field';
interface AggregationsStatusTermsBucketKeys extends AggregationsTermsBucketBase {
key: AgentStatus;
}
const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*,traces-*-*,synthetics-*-*';
const MAX_AGENT_DATA_PREVIEW_SIZE = 20;
export async function getAgentStatusById(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
agentId: string
): Promise<AgentStatus> {
return (await getAgentById(esClient, agentId)).status!;
}
export const getAgentStatus = AgentStatusKueryHelper.getAgentStatus;
function joinKuerys(...kuerys: Array<string | undefined>) {
return kuerys
.filter((kuery) => kuery !== undefined)
.reduce((acc: KueryNode | undefined, kuery: string | undefined): KueryNode | undefined => {
if (kuery === undefined) {
return acc;
}
const normalizedKuery: KueryNode = fromKueryExpression(removeSOAttributes(kuery || ''));
if (!acc) {
return normalizedKuery;
}
return {
type: 'function',
function: 'and',
arguments: [acc, normalizedKuery],
};
}, undefined as KueryNode | undefined);
return (await getAgentById(esClient, soClient, agentId)).status!;
}
export async function getAgentStatusForAgentPolicy(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
agentPolicyId?: string,
filterKuery?: string
) {
let pitId: string | undefined;
const logger = appContextService.getLogger();
const runtimeFields = await buildAgentStatusRuntimeField(soClient);
const clauses: QueryDslQueryContainer[] = [];
if (filterKuery) {
const kueryAsElasticsearchQuery = toElasticsearchQuery(
fromKueryExpression(removeSOAttributes(filterKuery))
);
clauses.push(kueryAsElasticsearchQuery);
}
if (agentPolicyId) {
clauses.push({
term: {
policy_id: agentPolicyId,
},
});
}
const query =
clauses.length > 0
? {
bool: {
must: clauses,
},
}
: undefined;
const statuses: Record<AgentStatus, number> = {
online: 0,
error: 0,
inactive: 0,
offline: 0,
updating: 0,
unenrolled: 0,
degraded: 0,
enrolling: 0,
unenrolling: 0,
};
let response;
try {
pitId = await openPointInTime(esClient);
response = await esClient.search<
null,
{ status: AggregationsTermsAggregateBase<AggregationsStatusTermsBucketKeys> }
>({
index: AGENTS_INDEX,
size: 0,
query,
fields: Object.keys(runtimeFields),
runtime_mappings: runtimeFields,
aggregations: {
status: {
terms: {
field: 'status',
size: Object.keys(statuses).length,
},
},
},
});
} catch (error) {
if (error.statusCode === 404) {
appContextService
.getLogger()
.debug('Index .fleet-agents does not exist yet, skipping point in time.');
} else {
throw error;
}
}
const [all, allActive, online, error, offline, updating] = await pMap(
[
undefined, // All agents, including inactive
undefined, // All active agents
AgentStatusKueryHelper.buildKueryForOnlineAgents(),
AgentStatusKueryHelper.buildKueryForErrorAgents(),
AgentStatusKueryHelper.buildKueryForOfflineAgents(),
AgentStatusKueryHelper.buildKueryForUpdatingAgents(),
],
(kuery, index) =>
getAgentsByKuery(esClient, {
showInactive: index === 0,
perPage: 0,
page: 1,
pitId,
kuery: joinKuerys(
...[
kuery,
filterKuery,
agentPolicyId ? `${AGENTS_PREFIX}.policy_id:"${agentPolicyId}"` : undefined,
]
),
}),
{
concurrency: 1,
}
);
if (pitId) {
await closePointInTime(esClient, pitId);
logger.error(`Error getting agent statuses: ${error}`);
throw error;
}
const result = {
total: allActive.total,
inactive: all.total - allActive.total,
online: online.total,
error: error.total,
offline: offline.total,
updating: updating.total,
other: all.total - online.total - error.total - offline.total,
const buckets = (response?.aggregations?.status?.buckets ||
[]) as AggregationsStatusTermsBucketKeys[];
buckets.forEach((bucket) => {
if (statuses[bucket.key] !== undefined) {
statuses[bucket.key] = bucket.doc_count;
}
});
const combinedStatuses = {
online: statuses.online,
error: statuses.error + statuses.degraded,
inactive: statuses.inactive,
offline: statuses.offline,
updating: statuses.updating + statuses.enrolling + statuses.unenrolling,
unenrolled: statuses.unenrolled,
};
return {
...combinedStatuses,
/* @deprecated no agents will have other status */
other: 0,
/* @deprecated Agent events do not exists anymore */
events: 0,
total:
Object.values(statuses).reduce((acc, val) => acc + val, 0) -
combinedStatuses.unenrolled -
combinedStatuses.inactive,
};
return result;
}
export async function getIncomingDataByAgentsId(
esClient: ElasticsearchClient,

View file

@ -6,7 +6,7 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { AGENT_ACTIONS_INDEX } from '../../../common';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common';
import { HostedAgentPolicyRestrictionRelatedError } from '../../errors';
import { invalidateAPIKeys } from '../api_keys';
@ -144,24 +144,33 @@ describe('unenrollAgents (plural)', () => {
it('force unenroll updates in progress unenroll actions', async () => {
const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock();
esClient.search.mockReset();
esClient.search.mockImplementation((request) =>
Promise.resolve(
request?.index === AGENT_ACTIONS_INDEX
? ({
hits: {
hits: [
{
_source: {
agents: ['agent-in-regular-policy'],
action_id: 'other-action',
},
},
],
esClient.search.mockImplementation(async (request) => {
if (request?.index === AGENT_ACTIONS_INDEX) {
return {
hits: {
hits: [
{
_source: {
agents: ['agent-in-regular-policy'],
action_id: 'other-action',
},
},
} as any)
: { hits: { hits: [] } }
)
);
],
},
} as any;
}
if (request?.index === AGENT_ACTIONS_RESULTS_INDEX) {
return {
hits: {
hits: [],
},
};
}
return { hits: { hits: [agentInRegularDoc, agentInRegularDoc2] } };
});
const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id];
await unenrollAgents(soClient, esClient, {
@ -178,30 +187,34 @@ describe('unenrollAgents (plural)', () => {
it('force unenroll should not update completed unenroll actions', async () => {
const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock();
esClient.search.mockReset();
esClient.search.mockImplementation((request) =>
Promise.resolve(
request?.index === AGENT_ACTIONS_INDEX
? ({
hits: {
hits: [
{
_source: {
agents: ['agent-in-regular-policy'],
action_id: 'other-action1',
},
},
],
esClient.search.mockImplementation(async (request) => {
if (request?.index === AGENT_ACTIONS_INDEX) {
return {
hits: {
hits: [
{
_source: {
agents: ['agent-in-regular-policy'],
action_id: 'other-action1',
},
},
} as any)
: {
hits: {
hits: [
{ _source: { action_id: 'other-action1', agent_id: 'agent-in-regular-policy' } },
],
},
}
)
);
],
},
} as any;
}
if (request?.index === AGENT_ACTIONS_RESULTS_INDEX) {
return {
hits: {
hits: [
{ _source: { action_id: 'other-action1', agent_id: 'agent-in-regular-policy' } },
],
},
};
}
return { hits: { hits: [agentInRegularDoc, agentInRegularDoc2] } };
});
const idsToUnenroll = [agentInRegularDoc._id, agentInRegularDoc2._id];
await unenrollAgents(soClient, esClient, {

View file

@ -53,7 +53,7 @@ export async function unenrollAgent(
await unenrollAgentIsAllowed(soClient, esClient, agentId);
}
if (options?.revoke) {
return forceUnenrollAgent(esClient, agentId);
return forceUnenrollAgent(esClient, soClient, agentId);
}
const now = new Date().toISOString();
await createAgentAction(esClient, {
@ -76,19 +76,19 @@ export async function unenrollAgents(
}
): Promise<{ actionId: string }> {
if ('agentIds' in options) {
const givenAgents = await getAgents(esClient, options);
const givenAgents = await getAgents(esClient, soClient, options);
return await unenrollBatch(soClient, esClient, givenAgents, options);
}
const batchSize = options.batchSize ?? SO_SEARCH_LIMIT;
const res = await getAgentsByKuery(esClient, {
const res = await getAgentsByKuery(esClient, soClient, {
kuery: options.kuery,
showInactive: options.showInactive ?? false,
page: 1,
perPage: batchSize,
});
if (res.total <= batchSize) {
const givenAgents = await getAgents(esClient, options);
const givenAgents = await getAgents(esClient, soClient, options);
return await unenrollBatch(soClient, esClient, givenAgents, options);
} else {
return await new UnenrollActionRunner(
@ -106,11 +106,12 @@ export async function unenrollAgents(
export async function forceUnenrollAgent(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
agentIdOrAgent: string | Agent
) {
const agent =
typeof agentIdOrAgent === 'string'
? await getAgentById(esClient, agentIdOrAgent)
? await getAgentById(esClient, soClient, agentIdOrAgent)
: agentIdOrAgent;
await invalidateAPIKeysForAgents([agent]);

View file

@ -20,7 +20,7 @@ export async function unenrollForAgentPolicyId(
let hasMore = true;
let page = 1;
while (hasMore) {
const { agents } = await getAgentsByKuery(esClient, {
const { agents } = await getAgentsByKuery(esClient, soClient, {
kuery: `${AGENTS_PREFIX}.policy_id:"${policyId}"`,
page: page++,
perPage: 1000,

View file

@ -28,6 +28,7 @@ jest.mock('../app_context', () => {
jest.mock('../agent_policy', () => {
return {
agentPolicyService: {
getInactivityTimeouts: jest.fn().mockResolvedValue([]),
getByIDs: jest.fn().mockResolvedValue([{ id: 'hosted-agent-policy', is_managed: true }]),
list: jest.fn().mockResolvedValue({ items: [] }),
},
@ -49,16 +50,21 @@ describe('update_agent_tags', () => {
beforeEach(() => {
esClient = elasticsearchServiceMock.createInternalClient();
soClient = savedObjectsClientMock.create();
esClient.mget.mockResolvedValue({
docs: [
{
_id: 'agent1',
_source: {
tags: ['one', 'two', 'three'],
esClient.search.mockResolvedValue({
hits: {
hits: [
{
_id: 'agent1',
_source: {
tags: ['one', 'two', 'three'],
},
fields: {
status: 'online',
},
},
} as any,
],
});
],
},
} as any);
esClient.bulk.mockReset();
esClient.bulk.mockResolvedValue({
items: [],
@ -257,18 +263,65 @@ describe('update_agent_tags', () => {
);
const updateByQuery = esClient.updateByQuery.mock.calls[0][0] as any;
expect(updateByQuery.query).toEqual({
bool: {
filter: [
{ bool: { minimum_should_match: 1, should: [{ match: { active: true } }] } },
{
bool: {
must_not: { bool: { minimum_should_match: 1, should: [{ match: { tags: 'new' } }] } },
expect(updateByQuery.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"must_not": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"status": "inactive",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"status": "unenrolled",
},
},
],
},
},
],
},
},
},
},
},
],
},
});
Object {
"bool": Object {
"must_not": Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"tags": "new",
},
},
],
},
},
},
},
],
},
}
`);
});
it('should add tags filter if only one tag to remove', async () => {

View file

@ -5,21 +5,15 @@
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import type { Agent } from '../../types';
import { AgentReassignmentError } from '../../errors';
import { getAgentDocuments } from './crud';
import { getAgentsById } from './crud';
import type { GetAgentsOptions } from '.';
import { searchHitToAgent } from './helpers';
import { UpdateAgentTagsActionRunner, updateTagsBatch } from './update_agent_tags_action_runner';
function isMgetDoc(doc?: estypes.MgetResponseItem<unknown>): doc is estypes.GetGetResult {
return Boolean(doc && 'found' in doc);
}
export async function updateAgentTags(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
@ -31,14 +25,14 @@ export async function updateAgentTags(
const givenAgents: Agent[] = [];
if ('agentIds' in options) {
const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds);
for (const agentResult of givenAgentsResults) {
if (isMgetDoc(agentResult) && agentResult.found === false) {
outgoingErrors[agentResult._id] = new AgentReassignmentError(
`Cannot find agent ${agentResult._id}`
const maybeAgents = await getAgentsById(esClient, soClient, options.agentIds);
for (const maybeAgent of maybeAgents) {
if ('notFound' in maybeAgent) {
outgoingErrors[maybeAgent.id] = new AgentReassignmentError(
`Cannot find agent ${maybeAgent.id}`
);
} else {
givenAgents.push(searchHitToAgent(agentResult));
givenAgents.push(maybeAgent);
}
}
} else if ('kuery' in options) {

View file

@ -4,7 +4,6 @@
* 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 type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import type { Agent } from '../../types';
@ -15,14 +14,9 @@ import { createAgentAction } from './actions';
import type { GetAgentsOptions } from './crud';
import { openPointInTime } from './crud';
import { getAgentsByKuery } from './crud';
import { getAgentDocuments, updateAgent, getAgentPolicyForAgent } from './crud';
import { searchHitToAgent } from './helpers';
import { getAgentsById, updateAgent, getAgentPolicyForAgent } from './crud';
import { UpgradeActionRunner, upgradeBatch } from './upgrade_action_runner';
function isMgetDoc(doc?: estypes.MgetResponseItem<unknown>): doc is estypes.GetGetResult {
return Boolean(doc && 'found' in doc);
}
export async function sendUpgradeAgentAction({
soClient,
esClient,
@ -80,19 +74,19 @@ export async function sendUpgradeAgentsActions(
if ('agents' in options) {
givenAgents = options.agents;
} else if ('agentIds' in options) {
const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds);
for (const agentResult of givenAgentsResults) {
if (!isMgetDoc(agentResult) || agentResult.found === false) {
outgoingErrors[agentResult._id] = new AgentReassignmentError(
`Cannot find agent ${agentResult._id}`
const maybeAgents = await getAgentsById(esClient, soClient, options.agentIds);
for (const maybeAgent of maybeAgents) {
if ('notFound' in maybeAgent) {
outgoingErrors[maybeAgent.id] = new AgentReassignmentError(
`Cannot find agent ${maybeAgent.id}`
);
} else {
givenAgents.push(searchHitToAgent(agentResult));
givenAgents.push(maybeAgent);
}
}
} else if ('kuery' in options) {
const batchSize = options.batchSize ?? SO_SEARCH_LIMIT;
const res = await getAgentsByKuery(esClient, {
const res = await getAgentsByKuery(esClient, soClient, {
kuery: options.kuery,
showInactive: options.showInactive ?? false,
page: 1,

View file

@ -154,7 +154,7 @@ async function _deleteExistingData(
}
// unenroll all the agents enroled in this policies
const { agents } = await getAgentsByKuery(esClient, {
const { agents } = await getAgentsByKuery(esClient, soClient, {
showInactive: false,
perPage: SO_SEARCH_LIMIT,
kuery: existingPolicies.map((policy) => `policy_id:"${policy.id}"`).join(' or '),
@ -163,7 +163,7 @@ async function _deleteExistingData(
// Delete
if (agents.length > 0) {
logger.info(`Force unenrolling ${agents.length} agents`);
await pMap(agents, (agent) => forceUnenrollAgent(esClient, agent.id), {
await pMap(agents, (agent) => forceUnenrollAgent(esClient, soClient, agent.id), {
concurrency: 20,
});
}

View file

@ -196,8 +196,8 @@ export class FleetAgentGenerator extends BaseDataGenerator<Agent> {
) {
const esHit = this.generateEsHit(overrides);
// Basically: reverse engineer the Fleet `getAgentStatus()` utility:
// https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L13-L44
// Basically: reverse engineer the Fleet agent status runtime field:
// https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/server/services/agents/build_status_runtime_field.ts
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fleetServerAgent = esHit._source!;
@ -218,10 +218,9 @@ export class FleetAgentGenerator extends BaseDataGenerator<Agent> {
fleetServerAgent.last_checkin_status = 'error';
break;
// not able to generate agents with inactive status without a valid agent policy
// with inactivity_timeout set
case 'inactive':
fleetServerAgent.active = false;
break;
case 'offline':
// current fleet timeout interface for offline is 5 minutes
// https://github.com/elastic/kibana/blob/main/x-pack/plugins/fleet/common/services/agent_status.ts#L11

View file

@ -58,7 +58,10 @@ export interface PolicyDetailsState {
/** artifacts namespace inside policy details page */
artifacts: PolicyArtifactsState;
/** A summary of stats for the agents associated with a given Fleet Agent Policy */
agentStatusSummary?: Omit<GetAgentStatusResponse['results'], 'updating'>;
agentStatusSummary?: Omit<
GetAgentStatusResponse['results'],
'updating' | 'inactive' | 'unenrolled'
>;
/** Status of an update to the policy */
updateStatus?: {
success: boolean;

View file

@ -65,6 +65,7 @@ export function getMetadataListRequestHandler(
const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService();
const fleetServices = endpointAppContext.service.getInternalFleetServices();
const esClient = (await context.core).elasticsearch.client.asInternalUser;
const soClient = (await context.core).savedObjects.client;
let doesUnitedIndexExist = false;
let didUnitedIndexError = false;
@ -107,6 +108,7 @@ export function getMetadataListRequestHandler(
try {
const { data, total } = await endpointMetadataService.getHostMetadataList(
esClient,
soClient,
fleetServices,
request.query
);

View file

@ -219,7 +219,12 @@ describe('test endpoint routes', () => {
kuery: 'not host.ip:10.140.73.246',
},
});
mockSavedObjectClient.find.mockResolvedValueOnce({
total: 0,
saved_objects: [],
page: 1,
per_page: 10,
});
mockAgentClient.getAgentStatusById.mockResolvedValue('error');
mockAgentClient.listAgents.mockResolvedValue(noUnenrolledAgent);
mockAgentPolicyService.getByIds = jest.fn().mockResolvedValueOnce([]);
@ -267,187 +272,27 @@ describe('test endpoint routes', () => {
},
{
bool: {
filter: [
should: [
{
bool: {
should: [
{
bool: {
filter: [
{
bool: {
should: [
{ exists: { field: 'united.agent.upgrade_started_at' } },
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [{ exists: { field: 'united.agent.upgraded_at' } }],
minimum_should_match: 1,
},
},
},
},
],
},
},
{
bool: {
must_not: {
bool: {
should: [{ exists: { field: 'united.agent.last_checkin' } }],
minimum_should_match: 1,
},
},
},
},
{
bool: {
should: [
{ exists: { field: 'united.agent.unenrollment_started_at' } },
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{ exists: { field: 'united.agent.policy_revision_idx' } },
],
minimum_should_match: 1,
},
},
},
},
],
should: [{ match: { status: 'updating' } }],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
should: [
{ range: { 'united.agent.last_checkin': { lt: 'now-300s' } } },
],
minimum_should_match: 1,
},
},
{
bool: {
filter: [
{
bool: {
should: [
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status': 'error',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status': 'degraded',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status': 'DEGRADED',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status': 'ERROR',
},
},
],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
should: [
{
range: {
'united.agent.last_checkin': {
lt: 'now-300s',
},
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
exists: {
field:
'united.agent.unenrollment_started_at',
},
},
],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
},
},
],
},
},
],
minimum_should_match: 1,
},
},
should: [{ match: { status: 'unenrolling' } }],
minimum_should_match: 1,
},
},
{
bool: {
should: [{ match: { status: 'enrolling' } }],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
{

View file

@ -17,326 +17,26 @@ export const expectedCompleteUnitedIndexQuery = {
],
},
},
filter: [
{ terms: { 'united.agent.policy_id': ['test-endpoint-policy-id'] } },
{ exists: { field: 'united.endpoint.agent.id' } },
{ exists: { field: 'united.agent.agent.id' } },
{ term: { 'united.agent.active': { value: true } } },
],
},
},
{
bool: {
filter: [
{
bool: {
should: [{ exists: { field: 'united.agent.last_checkin' } }],
minimum_should_match: 1,
terms: {
'united.agent.policy_id': ['test-endpoint-policy-id'],
},
},
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
should: [{ range: { 'united.agent.last_checkin': { lt: 'now-300s' } } }],
minimum_should_match: 1,
},
},
{
bool: {
filter: [
{
bool: {
should: [
{
bool: {
filter: [
{
bool: {
should: [
{
exists: {
field: 'united.agent.upgrade_started_at',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{ exists: { field: 'united.agent.upgraded_at' } },
],
minimum_should_match: 1,
},
},
},
},
],
},
},
{
bool: {
must_not: {
bool: {
should: [
{ exists: { field: 'united.agent.last_checkin' } },
],
minimum_should_match: 1,
},
},
},
},
{
bool: {
should: [
{
exists: { field: 'united.agent.unenrollment_started_at' },
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
exists: { field: 'united.agent.policy_revision_idx' },
},
],
minimum_should_match: 1,
},
},
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
should: [
{
range: {
'united.agent.last_checkin': { lt: 'now-300s' },
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
filter: [
{
bool: {
should: [
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status':
'error',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status':
'degraded',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status':
'DEGRADED',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'united.agent.last_checkin_status':
'ERROR',
},
},
],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
should: [
{
range: {
'united.agent.last_checkin': {
lt: 'now-300s',
},
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
exists: {
field:
'united.agent.unenrollment_started_at',
},
},
],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
},
},
],
},
},
],
minimum_should_match: 1,
},
},
},
},
],
},
},
{
bool: {
filter: [
{
bool: {
should: [
{
bool: {
should: [
{ match: { 'united.agent.last_checkin_status': 'error' } },
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: { 'united.agent.last_checkin_status': 'degraded' },
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: { 'united.agent.last_checkin_status': 'DEGRADED' },
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{ match: { 'united.agent.last_checkin_status': 'ERROR' } },
],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
should: [
{
range: {
'united.agent.last_checkin': { lt: 'now-300s' },
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
exists: {
field: 'united.agent.unenrollment_started_at',
},
},
],
minimum_should_match: 1,
},
},
],
minimum_should_match: 1,
},
},
},
},
],
},
},
],
minimum_should_match: 1,
},
exists: {
field: 'united.endpoint.agent.id',
},
},
{
exists: {
field: 'united.agent.agent.id',
},
},
{
term: {
'united.agent.active': {
value: true,
},
},
},
@ -345,7 +45,25 @@ export const expectedCompleteUnitedIndexQuery = {
},
{
bool: {
should: [{ exists: { field: 'united.endpoint.host.os.name' } }],
should: [
{
match: {
status: 'online',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
exists: {
field: 'united.endpoint.host.os.name',
},
},
],
minimum_should_match: 1,
},
},

View file

@ -13,6 +13,7 @@ import {
import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constants';
import { get } from 'lodash';
import { expectedCompleteUnitedIndexQuery } from './query_builders.fixtures';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
describe('query builder', () => {
describe('MetadataListESQuery', () => {
@ -194,7 +195,16 @@ describe('query builder', () => {
describe('buildUnitedIndexQuery', () => {
it('correctly builds empty query', async () => {
const soClient = savedObjectsClientMock.create();
soClient.find.mockResolvedValue({
saved_objects: [],
total: 0,
per_page: 0,
page: 0,
});
const query = await buildUnitedIndexQuery(
soClient,
{ page: 1, pageSize: 10, hostStatuses: [], kuery: '' },
[]
);
@ -238,7 +248,16 @@ describe('query builder', () => {
});
it('correctly builds query', async () => {
const soClient = savedObjectsClientMock.create();
soClient.find.mockResolvedValue({
saved_objects: [],
total: 0,
per_page: 0,
page: 0,
});
const query = await buildUnitedIndexQuery(
soClient,
{
page: 1,
pageSize: 10,

View file

@ -7,6 +7,8 @@
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { buildAgentStatusRuntimeField } from '@kbn/fleet-plugin/server';
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import {
ENDPOINT_DEFAULT_PAGE,
ENDPOINT_DEFAULT_PAGE_SIZE,
@ -205,6 +207,8 @@ interface BuildUnitedIndexQueryResponse {
query: Record<string, unknown>;
track_total_hits: boolean;
sort: estypes.SortCombinations[];
runtime_mappings: Record<string, unknown>;
fields?: string[];
};
from: number;
size: number;
@ -212,6 +216,7 @@ interface BuildUnitedIndexQueryResponse {
}
export async function buildUnitedIndexQuery(
soClient: SavedObjectsClientContract,
queryOptions: GetMetadataListRequestQuery,
endpointPolicyIds: string[] = []
): Promise<BuildUnitedIndexQueryResponse> {
@ -273,11 +278,15 @@ export async function buildUnitedIndexQuery(
};
}
const runtimeMappings = await buildAgentStatusRuntimeField(soClient, 'united.agent.');
const fields = Object.keys(runtimeMappings);
return {
body: {
query,
track_total_hits: true,
sort: MetadataSortMethod,
fields,
runtime_mappings: runtimeMappings,
},
from: page * pageSize,
size: pageSize,

View file

@ -92,45 +92,46 @@ describe('test filtering endpoint hosts by agent status', () => {
it('correctly builds kuery for healthy status', () => {
const status = ['healthy'];
const kuery = buildStatusesKuery(status);
expect(kuery).toMatchInlineSnapshot(
`"(united.agent.last_checkin:* AND not ((united.agent.last_checkin < now-300s) or ((((united.agent.upgrade_started_at:*) and not (united.agent.upgraded_at:*)) or (not (united.agent.last_checkin:*)) or (united.agent.unenrollment_started_at:*) or (not united.agent.policy_revision_idx:*)) AND not ((united.agent.last_checkin < now-300s) or ((united.agent.last_checkin_status:error or united.agent.last_checkin_status:degraded or united.agent.last_checkin_status:DEGRADED or united.agent.last_checkin_status:ERROR) AND not ((united.agent.last_checkin < now-300s) or (united.agent.unenrollment_started_at:*))))) or ((united.agent.last_checkin_status:error or united.agent.last_checkin_status:degraded or united.agent.last_checkin_status:DEGRADED or united.agent.last_checkin_status:ERROR) AND not ((united.agent.last_checkin < now-300s) or (united.agent.unenrollment_started_at:*)))))"`
);
expect(kuery).toMatchInlineSnapshot(`"(status:online)"`);
});
it('correctly builds kuery for offline status', () => {
const status = ['offline'];
const kuery = buildStatusesKuery(status);
expect(kuery).toMatchInlineSnapshot(`"(united.agent.last_checkin < now-300s)"`);
expect(kuery).toMatchInlineSnapshot(`"(status:offline)"`);
});
it('correctly builds kuery for unhealthy status', () => {
const status = ['unhealthy'];
const kuery = buildStatusesKuery(status);
expect(kuery).toMatchInlineSnapshot(
`"((united.agent.last_checkin_status:error or united.agent.last_checkin_status:degraded or united.agent.last_checkin_status:DEGRADED or united.agent.last_checkin_status:ERROR) AND not ((united.agent.last_checkin < now-300s) or (united.agent.unenrollment_started_at:*)))"`
);
expect(kuery).toMatchInlineSnapshot(`"((status:error or status:degraded))"`);
});
it('correctly builds kuery for updating status', () => {
const status = ['updating'];
const kuery = buildStatusesKuery(status);
expect(kuery).toMatchInlineSnapshot(
`"((((united.agent.upgrade_started_at:*) and not (united.agent.upgraded_at:*)) or (not (united.agent.last_checkin:*)) or (united.agent.unenrollment_started_at:*) or (not united.agent.policy_revision_idx:*)) AND not ((united.agent.last_checkin < now-300s) or ((united.agent.last_checkin_status:error or united.agent.last_checkin_status:degraded or united.agent.last_checkin_status:DEGRADED or united.agent.last_checkin_status:ERROR) AND not ((united.agent.last_checkin < now-300s) or (united.agent.unenrollment_started_at:*)))))"`
`"((status:updating or status:unenrolling or status:enrolling))"`
);
});
it('correctly builds kuery for inactive status', () => {
const status = ['inactive'];
const kuery = buildStatusesKuery(status);
const expected = '(united.agent.active:false)';
expect(kuery).toEqual(expected);
expect(kuery).toMatchInlineSnapshot(`"(status:inactive)"`);
});
it('correctly builds kuery for unenrolled status', () => {
const status = ['unenrolled'];
const kuery = buildStatusesKuery(status);
expect(kuery).toMatchInlineSnapshot(`"(status:unenrolled)"`);
});
it('correctly builds kuery for multiple statuses', () => {
const statuses = ['offline', 'unhealthy'];
const kuery = buildStatusesKuery(statuses);
expect(kuery).toMatchInlineSnapshot(
`"(united.agent.last_checkin < now-300s OR (united.agent.last_checkin_status:error or united.agent.last_checkin_status:degraded or united.agent.last_checkin_status:DEGRADED or united.agent.last_checkin_status:ERROR) AND not ((united.agent.last_checkin < now-300s) or (united.agent.unenrollment_started_at:*)))"`
`"(status:offline OR (status:error or status:degraded))"`
);
});
});

View file

@ -10,20 +10,19 @@ import { AgentStatusKueryHelper } from '@kbn/fleet-plugin/common/services';
import type { Agent } from '@kbn/fleet-plugin/common/types/models';
import { HostStatus } from '../../../../../common/endpoint/types';
const getStatusQueryMap = (path: string = '') =>
new Map([
[HostStatus.HEALTHY.toString(), AgentStatusKueryHelper.buildKueryForOnlineAgents(path)],
[HostStatus.OFFLINE.toString(), AgentStatusKueryHelper.buildKueryForOfflineAgents(path)],
[HostStatus.UNHEALTHY.toString(), AgentStatusKueryHelper.buildKueryForErrorAgents(path)],
[HostStatus.UPDATING.toString(), AgentStatusKueryHelper.buildKueryForUpdatingAgents(path)],
[HostStatus.INACTIVE.toString(), AgentStatusKueryHelper.buildKueryForInactiveAgents(path)],
]);
const STATUS_QUERY_MAP = new Map([
[HostStatus.HEALTHY.toString(), AgentStatusKueryHelper.buildKueryForOnlineAgents()],
[HostStatus.OFFLINE.toString(), AgentStatusKueryHelper.buildKueryForOfflineAgents()],
[HostStatus.UNHEALTHY.toString(), AgentStatusKueryHelper.buildKueryForErrorAgents()],
[HostStatus.UPDATING.toString(), AgentStatusKueryHelper.buildKueryForUpdatingAgents()],
[HostStatus.INACTIVE.toString(), AgentStatusKueryHelper.buildKueryForInactiveAgents()],
[HostStatus.UNENROLLED.toString(), AgentStatusKueryHelper.buildKueryForUnenrolledAgents()],
]);
export function buildStatusesKuery(statusesToFilter: string[]): string | undefined {
if (!statusesToFilter.length) {
return;
}
const STATUS_QUERY_MAP = getStatusQueryMap('united.agent.');
const statusQueries = statusesToFilter.map((status) => STATUS_QUERY_MAP.get(status));
if (!statusQueries.length) {
return;
@ -40,7 +39,6 @@ export async function findAgentIdsByStatus(
if (!statuses.length) {
return [];
}
const STATUS_QUERY_MAP = getStatusQueryMap();
const helpers = statuses.map((s) => STATUS_QUERY_MAP.get(s));
const searchOptions = (pageNum: number) => {
return {

View file

@ -47,7 +47,8 @@ export function legacyMetadataSearchResponseMock(
export function unitedMetadataSearchResponseMock(
hostMetadata: HostMetadata = {} as HostMetadata,
agent: Agent = {} as Agent
agent: Agent = {} as Agent,
agentStatus: Agent['status'] = 'online'
): estypes.SearchResponse<UnitedAgentMetadata> {
return {
took: 15,
@ -83,6 +84,9 @@ export function unitedMetadataSearchResponseMock(
},
},
},
fields: {
status: [agentStatus],
},
sort: [1588337587997],
},
]

View file

@ -7,7 +7,7 @@
import type { EndpointMetadataServiceTestContextMock } from './mocks';
import { createEndpointMetadataServiceTestContextMock } from './mocks';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import {
legacyMetadataSearchResponseMock,
@ -22,11 +22,13 @@ import type { HostMetadata } from '../../../../common/endpoint/types';
import type { Agent, PackagePolicy } from '@kbn/fleet-plugin/common';
import type { AgentPolicyServiceInterface } from '@kbn/fleet-plugin/server/services';
import { EndpointError } from '../../../../common/endpoint/errors';
import type { SavedObjectsClientContract } from '@kbn/core/server';
describe('EndpointMetadataService', () => {
let testMockedContext: EndpointMetadataServiceTestContextMock;
let metadataService: EndpointMetadataServiceTestContextMock['endpointMetadataService'];
let esClient: ElasticsearchClientMock;
let soClient: SavedObjectsClientContract;
let endpointDocGenerator: EndpointDocGenerator;
beforeEach(() => {
@ -34,6 +36,8 @@ describe('EndpointMetadataService', () => {
testMockedContext = createEndpointMetadataServiceTestContextMock();
metadataService = testMockedContext.endpointMetadataService;
esClient = elasticsearchServiceMock.createScopedClusterClient().asInternalUser;
soClient = savedObjectsClientMock.create();
soClient.find = jest.fn().mockResolvedValue({ saved_objects: [] });
});
describe('#findHostMetadataForFleetAgents()', () => {
@ -106,6 +110,7 @@ describe('EndpointMetadataService', () => {
esClient.search.mockRejectedValue({});
const metadataListResponse = metadataService.getHostMetadataList(
esClient,
soClient,
testMockedContext.fleetServices,
{
page: 0,
@ -163,10 +168,19 @@ describe('EndpointMetadataService', () => {
const queryOptions = { page: 1, pageSize: 10, kuery: '', hostStatuses: [] };
const metadataListResponse = await metadataService.getHostMetadataList(
esClient,
soClient,
testMockedContext.fleetServices,
queryOptions
);
const unitedIndexQuery = await buildUnitedIndexQuery(queryOptions, packagePolicyIds);
const unitedIndexQuery = await buildUnitedIndexQuery(
soClient,
queryOptions,
packagePolicyIds
);
expect(unitedIndexQuery.body.runtime_mappings.status).toBeDefined();
// @ts-expect-error runtime_mappings is not typed
unitedIndexQuery.body.runtime_mappings.status.script.source = expect.any(String);
expect(esClient.search).toBeCalledWith(unitedIndexQuery);
expect(agentPolicyServiceMock.getByIds).toBeCalledWith(expect.anything(), agentPolicyIds);
@ -174,7 +188,7 @@ describe('EndpointMetadataService', () => {
data: [
{
metadata: endpointMetadataDoc,
host_status: 'inactive',
host_status: 'healthy',
policy_info: {
agent: {
applied: {

View file

@ -13,10 +13,9 @@ import type {
} from '@kbn/core/server';
import type { SearchTotalHits, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type { Agent, AgentPolicy, PackagePolicy } from '@kbn/fleet-plugin/common';
import type { Agent, AgentPolicy, AgentStatus, PackagePolicy } from '@kbn/fleet-plugin/common';
import type { AgentPolicyServiceInterface, PackagePolicyClient } from '@kbn/fleet-plugin/server';
import { AgentNotFoundError } from '@kbn/fleet-plugin/server';
import { getAgentStatus } from '@kbn/fleet-plugin/common/services/agent_status';
import type {
HostInfo,
HostMetadata,
@ -203,7 +202,7 @@ export class EndpointMetadataService {
* If undefined, it will be retrieved from Fleet using the ID in the endpointMetadata.
* If passing in an `Agent` record that was retrieved from the Endpoint Unified transform index,
* ensure that its `.status` property is properly set to the calculated value done by
* fleet `getAgentStatus()` method.
* fleet.
*/
_fleetAgent?: MaybeImmutable<Agent>,
/** If undefined, it will be retrieved from Fleet using data from the endpointMetadata */
@ -272,7 +271,6 @@ export class EndpointMetadataService {
this.logger?.error(error);
}
}
return {
metadata: endpointMetadata,
host_status: fleetAgent
@ -396,12 +394,13 @@ export class EndpointMetadataService {
*/
async getHostMetadataList(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
fleetServices: EndpointFleetServicesInterface,
queryOptions: GetMetadataListRequestQuery
): Promise<Pick<MetadataListResponse, 'data' | 'total'>> {
const endpointPolicies = await this.getAllEndpointPackagePolicies();
const endpointPolicyIds = endpointPolicies.map((policy) => policy.policy_id);
const unitedIndexQuery = await buildUnitedIndexQuery(queryOptions, endpointPolicyIds);
const unitedIndexQuery = await buildUnitedIndexQuery(soClient, queryOptions, endpointPolicyIds);
let unitedMetadataQueryResponse: SearchResponse<UnitedAgentMetadata>;
@ -443,22 +442,17 @@ export class EndpointMetadataService {
for (const doc of docs) {
const { endpoint: metadata, agent: _agent } = doc?._source?.united ?? {};
if (metadata && _agent) {
// `_agent: Agent` here is the record stored in the unified index, whose `status` **IS NOT** the
// calculated status returned by the normal fleet API/Service. So lets calculated it before
// passing this on to other methods that expect an `Agent` type
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const agentPolicy = agentPoliciesMap[_agent.policy_id!];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const endpointPolicy = endpointPoliciesMap[_agent.policy_id!];
// add the agent status from the fleet runtime field to
// the agent object
const agent: typeof _agent = {
..._agent,
// Casting below necessary to remove `Immutable<>` from the type
status: getAgentStatus(_agent as Agent),
status: doc?.fields?.status?.[0] as AgentStatus,
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const agentPolicy = agentPoliciesMap[agent.policy_id!];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const endpointPolicy = endpointPoliciesMap[agent.policy_id!];
hosts.push(
await this.enrichHostMetadata(fleetServices, metadata, agent, agentPolicy, endpointPolicy)
);

View file

@ -5204,6 +5204,18 @@
"description": "The total number of enrolled agents currently offline"
}
},
"inactive": {
"type": "long",
"_meta": {
"description": "The total number of of enrolled agents currently inactive"
}
},
"unenrolled": {
"type": "long",
"_meta": {
"description": "The total number of agents currently unenrolled"
}
},
"total_all_statuses": {
"type": "long",
"_meta": {

View file

@ -18,6 +18,29 @@ export default function ({ getService }: FtrProviderContext) {
describe('fleet_agents_status', () => {
before(async () => {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/fleet/agents');
await es.create({
id: 'ingest-agent-policies:policy-inactivity-timeout',
index: '.kibana',
refresh: 'wait_for',
document: {
type: 'ingest-agent-policies',
'ingest-agent-policies': {
name: 'Test policy',
namespace: 'default',
description: 'Policy with inactivity timeout',
status: 'active',
is_default: true,
monitoring_enabled: ['logs', 'metrics'],
revision: 2,
updated_at: '2020-05-07T19:34:42.533Z',
updated_by: 'system',
inactivity_timeout: 60,
},
migrationVersion: {
'ingest-agent-policies': '7.10.0',
},
},
});
// 2 agents online
await es.update({
id: 'agent1',
@ -53,22 +76,33 @@ export default function ({ getService }: FtrProviderContext) {
},
},
});
// 1 agent upgrading
// 1 agents inactive
await es.update({
id: 'agent4',
refresh: 'wait_for',
index: AGENTS_INDEX,
body: {
doc: {
policy_id: 'policy-inactivity-timeout',
policy_revision_idx: 1,
last_checkin: new Date().toISOString(),
upgrade_started_at: new Date().toISOString(),
last_checkin: new Date(Date.now() - 1000 * 60).toISOString(), // policy timeout 1 min
},
},
});
// 1 agent upgrading
await es.create({
id: 'agent5',
refresh: 'wait_for',
index: AGENTS_INDEX,
document: {
policy_revision_idx: 1,
last_checkin: new Date().toISOString(),
upgrade_started_at: new Date().toISOString(),
},
});
// 1 agent reassigned to a new policy
await es.create({
id: 'agent5',
id: 'agent6',
refresh: 'wait_for',
index: AGENTS_INDEX,
document: {
@ -84,9 +118,9 @@ export default function ({ getService }: FtrProviderContext) {
},
});
// 1 agent inactive
// 1 agent unenrolled
await es.create({
id: 'agent6',
id: 'agent7',
refresh: 'wait_for',
index: AGENTS_INDEX,
document: {
@ -98,9 +132,46 @@ export default function ({ getService }: FtrProviderContext) {
local_metadata: { host: { hostname: 'host6' } },
user_provided_metadata: {},
enrolled_at: '2022-06-21T12:17:25Z',
unenrolled_at: '2022-06-21T12:29:29Z',
last_checkin: '2022-06-27T12:29:29Z',
},
});
// 1 agent error
await es.create({
id: 'agent8',
refresh: 'wait_for',
index: AGENTS_INDEX,
document: {
active: true,
access_api_key_id: 'api-key-4',
policy_id: 'policy1',
type: 'PERMANENT',
policy_revision_idx: 1,
local_metadata: { host: { hostname: 'host6' } },
user_provided_metadata: {},
enrolled_at: '2022-06-21T12:17:25Z',
last_checkin: new Date().toISOString(),
last_checkin_status: 'ERROR',
},
});
// 1 agent degraded (error category)
await es.create({
id: 'agent9',
refresh: 'wait_for',
index: AGENTS_INDEX,
document: {
active: true,
access_api_key_id: 'api-key-4',
policy_id: 'policy1',
type: 'PERMANENT',
policy_revision_idx: 1,
local_metadata: { host: { hostname: 'host6' } },
user_provided_metadata: {},
enrolled_at: '2022-06-21T12:17:25Z',
last_checkin: new Date().toISOString(),
last_checkin_status: 'DEGRADED',
},
});
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
@ -111,13 +182,14 @@ export default function ({ getService }: FtrProviderContext) {
expect(apiResponse).to.eql({
results: {
events: 0,
total: 5,
other: 0,
total: 7,
online: 2,
error: 0,
error: 2,
offline: 1,
updating: 2,
other: 3,
inactive: 1,
unenrolled: 1,
},
});
});

View file

@ -136,6 +136,8 @@ export default function (providerContext: FtrProviderContext) {
healthy: 3,
unhealthy: 3,
offline: 1,
unenrolled: 0,
inactive: 0,
updating: 1,
total_all_statuses: 8,
});