[UII] Support searchAfter and PIT (point-in-time) parameters for get agents list API (#213486)

## Summary

Resolves https://github.com/elastic/kibana/issues/206924.

This PR adds the following query parameters to the agent list API (`GET
/api/fleet/agents`) in order to enable fetching beyond the first 10,000
hits:
```
    searchAfter?: string;
    openPit?: boolean;
    pitId?: string;
    pitKeepAlive?: string;
```

The list agent API response can now include the following properties
```
    // the PIT ID used
    pit?: string;

    // stringified version of the last agent's `sort` field,
    // can be passed as `searchAfter` in the next request
    nextSearchAfter? string;
```

* `searchAfter` can be used with or without a `pitId`. If using
`searchAfter`, `page` parameter is not accepted.

* `searchAfter` expects a stringified array. (Reviewers: I couldn't get
the Kibana request schema to accept a multi-part query param and convert
it to an array... I think this would be better, please let me know if
you know how to get that to work 🙏)

* `pitKeepAlive` duration (i.e. `30s`, `1m`, etc) must be present when
opening a PIT or retrieving results using a PIT ID.

* These can be used with the existing `sortField` and `sortOrder`
params. They default to `enrolled_at` and `desc` respectively.

### Example using only `searchAfter`:

```
# Retrieve the first 10k hits
curl -X GET 'http://<user>:<pass>@<kibana url>/api/fleet/agents?perPage=10000'

# Grab the `nextSearchAfter` param from the response
# Pass it to the new request to retrieve the next page of 10k hits
curl -X GET 'http://<user>:<pass>@<kibana url>/api/fleet/agents?perPage=10000&searchAfter=<nextSearchAfter>'
```

### Example using `searchAfter` with point-in-time parameters:
```
# Retrieve the first 10k hits and open a PIT
curl -X GET 'http://<user>:<pass>@<kibana url>/api/fleet/agents?perPage=10000&openPit=true&pitKeepAlive=5m'

# Grab the `pit` ID from the response
# Grab the `nextSearchAfter` param from the response
# Pass both to the new request to retrieve the next page of 10k hits
curl -X GET 'http://<user>:<pass>@<kibana url>/api/fleet/agents?perPage=10000&searchAfter=<nextSearchAfter>&pitId=<pit id>&pitKeepAlive=5m'
```

## Testing
I recommend using `scripts/create_agents` to generate bulk agents and
testing the above requests. You can generate new agents between PIT
requests to test that using a PIT ID retains the original state. (An API
functional test was added for this)

Note: you may need to add `&showInactive=true` to all requests if your
fake agents become inactive.

TBD

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jen Huang 2025-03-07 16:01:49 -08:00 committed by GitHub
parent c9969e798a
commit 3f90203406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 355 additions and 156 deletions

View file

@ -17292,7 +17292,6 @@
"name": "page",
"required": false,
"schema": {
"default": 1,
"type": "number"
}
},
@ -17368,6 +17367,38 @@
],
"type": "string"
}
},
{
"in": "query",
"name": "searchAfter",
"required": false,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "openPit",
"required": false,
"schema": {
"type": "boolean"
}
},
{
"in": "query",
"name": "pitId",
"required": false,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "pitKeepAlive",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@ -17611,20 +17642,7 @@
"type": "number"
},
"sort": {
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"enum": [],
"nullable": true
}
]
},
"items": {},
"type": "array"
},
"status": {
@ -17777,12 +17795,18 @@
},
"type": "array"
},
"nextSearchAfter": {
"type": "string"
},
"page": {
"type": "number"
},
"perPage": {
"type": "number"
},
"pit": {
"type": "string"
},
"statusSummary": {
"additionalProperties": {
"type": "number"
@ -19665,20 +19689,7 @@
"type": "number"
},
"sort": {
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"enum": [],
"nullable": true
}
]
},
"items": {},
"type": "array"
},
"status": {
@ -20156,20 +20167,7 @@
"type": "number"
},
"sort": {
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"enum": [],
"nullable": true
}
]
},
"items": {},
"type": "array"
},
"status": {

View file

@ -17292,7 +17292,6 @@
"name": "page",
"required": false,
"schema": {
"default": 1,
"type": "number"
}
},
@ -17368,6 +17367,38 @@
],
"type": "string"
}
},
{
"in": "query",
"name": "searchAfter",
"required": false,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "openPit",
"required": false,
"schema": {
"type": "boolean"
}
},
{
"in": "query",
"name": "pitId",
"required": false,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "pitKeepAlive",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@ -17611,20 +17642,7 @@
"type": "number"
},
"sort": {
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"enum": [],
"nullable": true
}
]
},
"items": {},
"type": "array"
},
"status": {
@ -17777,12 +17795,18 @@
},
"type": "array"
},
"nextSearchAfter": {
"type": "string"
},
"page": {
"type": "number"
},
"perPage": {
"type": "number"
},
"pit": {
"type": "string"
},
"statusSummary": {
"additionalProperties": {
"type": "number"
@ -19665,20 +19689,7 @@
"type": "number"
},
"sort": {
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"enum": [],
"nullable": true
}
]
},
"items": {},
"type": "array"
},
"status": {
@ -20156,20 +20167,7 @@
"type": "number"
},
"sort": {
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"enum": [],
"nullable": true
}
]
},
"items": {},
"type": "array"
},
"status": {

View file

@ -18758,7 +18758,6 @@ paths:
name: page
required: false
schema:
default: 1
type: number
- in: query
name: perPage
@ -18808,6 +18807,26 @@ paths:
- asc
- desc
type: string
- in: query
name: searchAfter
required: false
schema:
type: string
- in: query
name: openPit
required: false
schema:
type: boolean
- in: query
name: pitId
required: false
schema:
type: string
- in: query
name: pitKeepAlive
required: false
schema:
type: string
responses:
'200':
content:
@ -18985,12 +19004,7 @@ paths:
nullable: true
type: number
sort:
items:
anyOf:
- type: number
- type: string
- enum: []
nullable: true
items: {}
type: array
status:
enum:
@ -19104,10 +19118,14 @@ paths:
- enrolled_at
- local_metadata
type: array
nextSearchAfter:
type: string
page:
type: number
perPage:
type: number
pit:
type: string
statusSummary:
additionalProperties:
type: number
@ -19445,12 +19463,7 @@ paths:
nullable: true
type: number
sort:
items:
anyOf:
- type: number
- type: string
- enum: []
nullable: true
items: {}
type: array
status:
enum:
@ -19793,12 +19806,7 @@ paths:
nullable: true
type: number
sort:
items:
anyOf:
- type: number
- type: string
- enum: []
nullable: true
items: {}
type: array
status:
enum:

View file

@ -20879,7 +20879,6 @@ paths:
name: page
required: false
schema:
default: 1
type: number
- in: query
name: perPage
@ -20929,6 +20928,26 @@ paths:
- asc
- desc
type: string
- in: query
name: searchAfter
required: false
schema:
type: string
- in: query
name: openPit
required: false
schema:
type: boolean
- in: query
name: pitId
required: false
schema:
type: string
- in: query
name: pitKeepAlive
required: false
schema:
type: string
responses:
'200':
content:
@ -21106,12 +21125,7 @@ paths:
nullable: true
type: number
sort:
items:
anyOf:
- type: number
- type: string
- enum: []
nullable: true
items: {}
type: array
status:
enum:
@ -21225,10 +21239,14 @@ paths:
- enrolled_at
- local_metadata
type: array
nextSearchAfter:
type: string
page:
type: number
perPage:
type: number
pit:
type: string
statusSummary:
additionalProperties:
type: number
@ -21563,12 +21581,7 @@ paths:
nullable: true
type: number
sort:
items:
anyOf:
- type: number
- type: string
- enum: []
nullable: true
items: {}
type: array
status:
enum:
@ -21910,12 +21923,7 @@ paths:
nullable: true
type: number
sort:
items:
anyOf:
- type: number
- type: string
- enum: []
nullable: true
items: {}
type: array
status:
enum:

View file

@ -144,7 +144,7 @@ export interface Agent extends AgentBase {
outputs?: OutputMap;
status?: AgentStatus;
packages: string[];
sort?: Array<number | string | null>;
sort?: any[];
metrics?: AgentMetrics;
}

View file

@ -24,10 +24,16 @@ export interface GetAgentsRequest {
showInactive?: boolean;
showUpgradeable?: boolean;
withMetrics?: boolean;
searchAfter?: string;
openPit?: boolean;
pitId?: string;
pitKeepAlive?: string;
};
}
export interface GetAgentsResponse extends ListResult<Agent> {
pit?: string;
nextSearchAfter?: string;
statusSummary?: Record<AgentStatus, number>;
}

View file

@ -185,6 +185,21 @@ export const getAgentsHandler: FleetRequestHandler<
const { agentClient } = fleetContext;
const esClientCurrentUser = coreContext.elasticsearch.client.asCurrentUser;
// Unwrap searchAfter from request query
let searchAfter: any[] | undefined;
if (request.query.searchAfter) {
try {
const searchAfterArray = JSON.parse(request.query.searchAfter);
if (!Array.isArray(searchAfterArray) || searchAfterArray.length === 0) {
response.badRequest({ body: { message: 'searchAfter must be a non-empty array' } });
} else {
searchAfter = searchAfterArray;
}
} catch (e) {
response.badRequest({ body: { message: 'searchAfter must be a non-empty array' } });
}
}
const agentRes = await agentClient.asCurrentUser.listAgents({
page: request.query.page,
perPage: request.query.perPage,
@ -193,10 +208,14 @@ export const getAgentsHandler: FleetRequestHandler<
kuery: request.query.kuery,
sortField: request.query.sortField,
sortOrder: request.query.sortOrder,
searchAfter,
openPit: request.query.openPit,
pitId: request.query.pitId,
pitKeepAlive: request.query.pitKeepAlive,
getStatusSummary: request.query.getStatusSummary,
});
const { total, page, perPage, statusSummary } = agentRes;
const { total, page, perPage, statusSummary, pit } = agentRes;
let { agents } = agentRes;
// Assign metrics
@ -204,11 +223,16 @@ export const getAgentsHandler: FleetRequestHandler<
agents = await fetchAndAssignAgentMetrics(esClientCurrentUser, agents);
}
// Retrieve last agent to use for nextSearchAfter
const lastAgent = agents.length > 0 ? agents[agents.length - 1] : undefined;
const body: GetAgentsResponse = {
items: agents,
total,
page,
perPage,
...(lastAgent && lastAgent.sort ? { nextSearchAfter: JSON.stringify(lastAgent.sort) } : {}),
...(pit ? { pit } : {}),
...(statusSummary ? { statusSummary } : {}),
};
return response.ok({ body });

View file

@ -326,7 +326,9 @@ async function getHostNames(esClient: ElasticsearchClient, agentIds: string[]) {
_source: ['local_metadata.host.name'],
});
const hostNames = agentsRes.hits.hits.reduce((acc: { [key: string]: string }, curr) => {
acc[curr._id!] = (curr._source as any).local_metadata.host.name;
if ((curr._source as any).local_metadata?.host?.name) {
acc[curr._id!] = (curr._source as any).local_metadata.host.name;
}
return acc;
}, {});

View file

@ -92,7 +92,9 @@ export interface AgentClient {
showInactive: boolean;
aggregations?: Record<string, AggregationsAggregationContainer>;
searchAfter?: SortResults;
openPit?: boolean;
pitId?: string;
pitKeepAlive?: string;
getStatusSummary?: boolean;
}
): Promise<{
@ -100,6 +102,7 @@ export interface AgentClient {
total: number;
page: number;
perPage: number;
pit?: string;
statusSummary?: Record<AgentStatus, number>;
aggregations?: Record<string, estypes.AggregationsAggregate>;
}>;
@ -133,6 +136,11 @@ class AgentClientImpl implements AgentClient {
options: ListWithKuery & {
showInactive: boolean;
aggregations?: Record<string, AggregationsAggregationContainer>;
searchAfter?: SortResults;
openPit?: boolean;
pitId?: string;
pitKeepAlive?: string;
getStatusSummary?: boolean;
}
) {
await this.#runPreflight();

View file

@ -511,10 +511,10 @@ describe('Agents CRUD test', () => {
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
esClient.openPointInTime.mockResolvedValueOnce({ id: 'test-pit' } as any);
await openPointInTime(esClient, AGENTS_INDEX);
await openPointInTime(esClient);
expect(mockedAuditLoggingService.writeCustomAuditLog).toHaveBeenCalledWith({
message: `User opened point in time query [index=${AGENTS_INDEX}] [pitId=test-pit]`,
message: `User opened point in time query [index=${AGENTS_INDEX}] [keepAlive=10m] [pitId=test-pit]`,
});
});
});

View file

@ -118,16 +118,16 @@ export async function getAgents(
export async function openPointInTime(
esClient: ElasticsearchClient,
keepAlive: string = '10m',
index: string = AGENTS_INDEX
): Promise<string> {
const pitKeepAlive = '10m';
const pitRes = await esClient.openPointInTime({
index,
keep_alive: pitKeepAlive,
keep_alive: keepAlive,
});
auditLoggingService.writeCustomAuditLog({
message: `User opened point in time query [index=${index}] [pitId=${pitRes.id}]`,
message: `User opened point in time query [index=${index}] [keepAlive=${keepAlive}] [pitId=${pitRes.id}]`,
});
return pitRes.id;
@ -203,8 +203,10 @@ export async function getAgentsByKuery(
getStatusSummary?: boolean;
sortField?: string;
sortOrder?: 'asc' | 'desc';
pitId?: string;
searchAfter?: SortResults;
openPit?: boolean;
pitId?: string;
pitKeepAlive?: string;
aggregations?: Record<string, AggregationsAggregationContainer>;
}
): Promise<{
@ -225,7 +227,9 @@ export async function getAgentsByKuery(
getStatusSummary = false,
showUpgradeable,
searchAfter,
openPit,
pitId,
pitKeepAlive = '1m',
aggregations,
spaceId,
} = options;
@ -262,7 +266,11 @@ export async function getAgentsByKuery(
uninstalled: 0,
};
const queryAgents = async (from: number, size: number) => {
const pitIdToUse = pitId || (openPit ? await openPointInTime(esClient, pitKeepAlive) : undefined);
const queryAgents = async (
queryOptions: { from: number; size: number } | { searchAfter: SortResults; size: number }
) => {
const aggs = {
...(aggregations || getStatusSummary
? {
@ -286,32 +294,38 @@ export async function getAgentsByKuery(
FleetServerAgent,
{ status: { buckets: Array<{ key: AgentStatus; doc_count: number }> } }
>({
from,
size,
...('from' in queryOptions
? { from: queryOptions.from }
: {
search_after: queryOptions.searchAfter,
}),
size: queryOptions.size,
track_total_hits: true,
rest_total_hits_as_int: true,
runtime_mappings: runtimeFields,
fields: Object.keys(runtimeFields),
sort,
query: kueryNode ? toElasticsearchQuery(kueryNode) : undefined,
...(pitId
...(pitIdToUse
? {
pit: {
id: pitId,
keep_alive: '1m',
id: pitIdToUse,
keep_alive: pitKeepAlive,
},
}
: {
index: AGENTS_INDEX,
ignore_unavailable: true,
}),
...(pitId && searchAfter ? { search_after: searchAfter, from: 0 } : {}),
...aggs,
});
};
let res;
try {
res = await queryAgents((page - 1) * perPage, perPage);
res = await queryAgents(
searchAfter ? { searchAfter, size: perPage } : { from: (page - 1) * perPage, size: perPage }
);
} catch (err) {
appContextService.getLogger().error(`Error getting agents by kuery: ${JSON.stringify(err)}`);
throw err;
@ -327,7 +341,7 @@ export async function getAgentsByKuery(
// query all agents, then filter upgradeable, and return the requested page and correct total
// if there are more than SO_SEARCH_LIMIT agents, the logic falls back to same as before
if (total < SO_SEARCH_LIMIT) {
const response = await queryAgents(0, SO_SEARCH_LIMIT);
const response = await queryAgents({ from: 0, size: SO_SEARCH_LIMIT });
agents = response.hits.hits
.map(searchHitToAgent)
.filter((agent) => isAgentUpgradeAvailable(agent, latestAgentVersion));
@ -358,8 +372,9 @@ export async function getAgentsByKuery(
return {
agents,
total,
page,
...(searchAfter ? { page: 0 } : { page }),
perPage,
...(pitIdToUse ? { pit: pitIdToUse } : {}),
...(aggregations ? { aggregations: res.aggregations } : {}),
...(getStatusSummary ? { statusSummary } : {}),
};

View file

@ -21,7 +21,7 @@ import { ListResponseSchema } from '../../routes/schema/utils';
export const GetAgentsRequestSchema = {
query: schema.object(
{
page: schema.number({ defaultValue: 1 }),
page: schema.maybe(schema.number()),
perPage: schema.number({ defaultValue: 20 }),
kuery: schema.maybe(
schema.string({
@ -39,12 +39,49 @@ export const GetAgentsRequestSchema = {
getStatusSummary: schema.boolean({ defaultValue: false }),
sortField: schema.maybe(schema.string()),
sortOrder: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])),
searchAfter: schema.maybe(schema.string()),
openPit: schema.maybe(schema.boolean()),
pitId: schema.maybe(schema.string()),
pitKeepAlive: schema.maybe(schema.string()),
},
{
validate: (request) => {
if (request.page * request.perPage > SO_SEARCH_LIMIT) {
const usingSearchAfter = !!request.searchAfter;
const usingPIT = !!(request.openPit || request.pitId || request.pitKeepAlive);
// If using PIT search, ensure that all required PIT parameters are provided
if (usingPIT) {
if (request.openPit && request.pitId) {
return 'You cannot request to open a new point-in-time with an existing pitId';
}
if (!request.pitKeepAlive) {
return 'You must provide pitKeepAlive when using point-in-time parameters';
}
}
// Ensure that pagination parameters are not over the search limit
if ((request.page || 1) * request.perPage > SO_SEARCH_LIMIT) {
return `You cannot use page and perPage page over ${SO_SEARCH_LIMIT} agents`;
}
// If using searchAfter:
// 1. ensure that incompatible pagination parameters are not used
// 2. ensure that searchAfter is an array
if (usingSearchAfter) {
if (request.page) {
return 'You cannot use page parameter when using searchAfter';
}
// ensure that searchAfter is an array after parsing json
try {
const searchAfterArray = JSON.parse(request.searchAfter);
if (!Array.isArray(searchAfterArray) || searchAfterArray.length === 0) {
return 'searchAfter must be a non-empty array';
}
} catch (e) {
return 'searchAfter must be a non-empty array';
}
}
},
}
),
@ -119,9 +156,7 @@ export const AgentResponseSchema = schema.object({
),
status: schema.maybe(AgentStatusSchema),
packages: schema.arrayOf(schema.string()),
sort: schema.maybe(
schema.arrayOf(schema.oneOf([schema.number(), schema.string(), schema.literal(null)]))
),
sort: schema.maybe(schema.arrayOf(schema.any())), // ES can return many different types for `sort` array values, including unsafe numbers
metrics: schema.maybe(
schema.object({
cpu_avg: schema.maybe(schema.number()),
@ -226,6 +261,8 @@ export const AgentResponseSchema = schema.object({
});
export const GetAgentsResponseSchema = ListResponseSchema(AgentResponseSchema).extends({
pit: schema.maybe(schema.string()),
nextSearchAfter: schema.maybe(schema.string()),
statusSummary: schema.maybe(schema.recordOf(AgentStatusSchema, schema.number())),
});

View file

@ -38,10 +38,14 @@ export default function ({ getService }: FtrProviderContext) {
await supertest
.delete(`/api/fleet/epm/packages/${FLEET_ELASTIC_AGENT_PACKAGE}/${elasticAgentpkgVersion}`)
.set('kbn-xsrf', 'xxxx');
await es.transport.request({
method: 'DELETE',
path: `/_data_stream/metrics-elastic_agent.elastic_agent-default`,
});
try {
await es.transport.request({
method: 'DELETE',
path: `/_data_stream/metrics-elastic_agent.elastic_agent-default`,
});
} catch (e) {
// ignore
}
});
it('should return the list of agents when requesting as admin', async () => {
@ -262,5 +266,89 @@ export default function ({ getService }: FtrProviderContext) {
uninstalled: 0,
});
});
describe('advanced search params', () => {
afterEach(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents');
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/fleet/agents');
});
it('should return correct results with searchAfter parameter', async () => {
const { body: apiResponse } = await supertest.get(
'/api/fleet/agents?perPage=1&sortField=agent.id&sortOrder=desc'
);
expect(apiResponse.page).to.eql(1);
expect(apiResponse.nextSearchAfter).to.eql(JSON.stringify(apiResponse.items[0].sort));
expect(apiResponse.items.map(({ agent }: any) => agent.id)).to.eql(['agent4']);
const { body: apiResponse2 } = await supertest
.get(
`/api/fleet/agents?perPage=2&sortField=agent.id&sortOrder=desc&searchAfter=${apiResponse.nextSearchAfter}`
)
.expect(200);
expect(apiResponse2.page).to.eql(0);
expect(apiResponse2.nextSearchAfter).to.eql(JSON.stringify(apiResponse2.items[1].sort));
expect(apiResponse2.items.map(({ agent }: any) => agent.id)).to.eql(['agent3', 'agent2']);
});
it('should return a pit ID when openPit is true', async () => {
const { body: apiResponse } = await supertest
.get('/api/fleet/agents?perPage=1&openPit=true&pitKeepAlive=1s')
.expect(200);
expect(apiResponse).to.have.keys('page', 'total', 'items', 'pit');
expect(apiResponse.items.length).to.eql(1);
expect(apiResponse.pit).to.be.a('string');
expect(apiResponse.nextSearchAfter).to.eql(JSON.stringify(apiResponse.items[0].sort));
});
it('should use pit to return correct results', async () => {
const { body: apiResponse } = await supertest
.get(
'/api/fleet/agents?perPage=1&sortField=agent.id&sortOrder=desc&openPit=true&pitKeepAlive=1m'
)
.expect(200);
expect(apiResponse.pit).to.be.a('string');
expect(apiResponse.nextSearchAfter).to.eql(JSON.stringify(apiResponse.items[0].sort));
expect(apiResponse.items.map(({ agent }: any) => agent.id)).to.eql(['agent4']);
// update ES document to change the order by changing agent.id of agent2 to agent9
await es.transport.request({
method: 'POST',
path: `/.fleet-agents/_update/agent2`,
body: {
doc: { agent: { id: 'agent9' } },
},
});
await es.transport.request({
method: 'POST',
path: `/.fleet-agents/_refresh`,
});
// check that non-pit query returns the new order
// new order is [agent9, agent4, agent3, agent1]
const { body: apiResponse2 } = await supertest
.get(`/api/fleet/agents?sortField=agent.id&sortOrder=desc`)
.expect(200);
expect(apiResponse2.items.map(({ agent }: any) => agent.id)).to.eql([
'agent9',
'agent4',
'agent3',
'agent1',
]);
// check that the pit query returns the old order
// old order saved by PIT is [agent4, agent3, agent2, agent1]
const { body: apiResponse3 } = await supertest
.get(
`/api/fleet/agents?perPage=2&sortField=agent.id&sortOrder=desc&searchAfter=${apiResponse.nextSearchAfter}&pitId=${apiResponse.pit}&pitKeepAlive=1m`
)
.expect(200);
expect(apiResponse3.items.map(({ agent }: any) => agent.id)).to.eql(['agent3', 'agent2']);
expect(apiResponse3.nextSearchAfter).to.eql(JSON.stringify(apiResponse3.items[1].sort));
});
});
});
}

View file

@ -109,11 +109,14 @@ export async function generateAgent(
id,
document: {
id,
type: 'PERMANENT',
active: true,
enrolled_at: new Date().toISOString(),
last_checkin: new Date().toISOString(),
policy_id: policyId,
policy_revision: 1,
agent: {
id,
version,
},
local_metadata: {

View file

@ -16,7 +16,8 @@
"user_provided_metadata": {},
"enrolled_at": "2022-06-21T12:14:25Z",
"last_checkin": "2022-06-27T12:26:29Z",
"tags": ["existingTag", "tag1"]
"tags": ["existingTag", "tag1"],
"agent": {"id": "agent1", "version": "9.0.0"}
}
}
}
@ -35,7 +36,8 @@
"user_provided_metadata": {},
"enrolled_at": "2022-06-21T12:14:25Z",
"last_checkin": "2022-06-27T12:27:29Z",
"tags": ["existingTag"]
"tags": ["existingTag"],
"agent": {"id": "agent2", "version": "9.0.0"}
}
}
}
@ -55,7 +57,8 @@
"enrolled_at": "2022-06-21T12:14:25Z",
"last_checkin": "2022-06-27T12:28:29Z",
"last_checkin": "2022-06-27T12:28:29Z",
"tags": ["tag1"]
"tags": ["tag1"],
"agent": {"id": "agent3", "version": "9.0.0"}
}
}
}
@ -73,7 +76,8 @@
"local_metadata": { "host": {"hostname": "host4"}},
"user_provided_metadata": {},
"enrolled_at": "2022-06-21T12:17:25Z",
"last_checkin": "2022-06-27T12:29:29Z"
"last_checkin": "2022-06-27T12:29:29Z",
"agent": {"id": "agent4", "version": "9.0.0"}
}
}
}