mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
c9969e798a
commit
3f90203406
15 changed files with 355 additions and 156 deletions
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -144,7 +144,7 @@ export interface Agent extends AgentBase {
|
|||
outputs?: OutputMap;
|
||||
status?: AgentStatus;
|
||||
packages: string[];
|
||||
sort?: Array<number | string | null>;
|
||||
sort?: any[];
|
||||
metrics?: AgentMetrics;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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;
|
||||
}, {});
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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]`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 } : {}),
|
||||
};
|
||||
|
|
|
@ -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())),
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue