mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Asset Manager] Asset client updates (#170184)
## Summary Updates the assets client with new methods, new tests, and better types - found while developing a hosts inventory table POC in a separate PR. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kevin Lacabane <kevin.lacabane@elastic.co>
This commit is contained in:
parent
9f3e1f9a01
commit
74509cdc33
33 changed files with 1390 additions and 2209 deletions
|
@ -17,3 +17,4 @@ export const GET_ASSETS_DIFF = base('/assets/diff');
|
|||
|
||||
export const GET_HOSTS = base('/assets/hosts');
|
||||
export const GET_SERVICES = base('/assets/services');
|
||||
export const GET_CONTAINERS = base('/assets/containers');
|
||||
|
|
|
@ -27,7 +27,6 @@ export const assetKindRT = rt.keyof({
|
|||
pod: null,
|
||||
container: null,
|
||||
service: null,
|
||||
alert: null,
|
||||
});
|
||||
|
||||
export type AssetKind = rt.TypeOf<typeof assetKindRT>;
|
||||
|
@ -166,18 +165,24 @@ export interface K8sCluster extends WithTimestamp {
|
|||
};
|
||||
}
|
||||
|
||||
export interface AssetFilters {
|
||||
type?: AssetType | AssetType[];
|
||||
kind?: AssetKind | AssetKind[];
|
||||
ean?: string | string[];
|
||||
id?: string;
|
||||
typeLike?: string;
|
||||
kindLike?: string;
|
||||
eanLike?: string;
|
||||
collectionVersion?: number | 'latest' | 'all';
|
||||
from?: string | number;
|
||||
to?: string | number;
|
||||
}
|
||||
export const assetFiltersSingleKindRT = rt.exact(
|
||||
rt.partial({
|
||||
type: rt.union([assetTypeRT, rt.array(assetTypeRT)]),
|
||||
ean: rt.union([rt.string, rt.array(rt.string)]),
|
||||
id: rt.string,
|
||||
['cloud.provider']: rt.string,
|
||||
['cloud.region']: rt.string,
|
||||
})
|
||||
);
|
||||
|
||||
export type SingleKindAssetFilters = rt.TypeOf<typeof assetFiltersSingleKindRT>;
|
||||
|
||||
export const assetFiltersRT = rt.intersection([
|
||||
assetFiltersSingleKindRT,
|
||||
rt.partial({ kind: rt.union([assetKindRT, rt.array(assetKindRT)]) }),
|
||||
]);
|
||||
|
||||
export type AssetFilters = rt.TypeOf<typeof assetFiltersRT>;
|
||||
|
||||
export const relationRT = rt.union([
|
||||
rt.literal('ancestors'),
|
||||
|
@ -200,30 +205,53 @@ export const assetDateRT = rt.union([dateRt, datemathStringRt]);
|
|||
/**
|
||||
* Hosts
|
||||
*/
|
||||
export const getHostAssetsQueryOptionsRT = rt.exact(
|
||||
export const getHostAssetsQueryOptionsRT = rt.intersection([
|
||||
rt.strict({ from: assetDateRT }),
|
||||
rt.partial({
|
||||
from: assetDateRT,
|
||||
to: assetDateRT,
|
||||
size: sizeRT,
|
||||
})
|
||||
);
|
||||
stringFilters: rt.string,
|
||||
filters: assetFiltersSingleKindRT,
|
||||
}),
|
||||
]);
|
||||
export type GetHostAssetsQueryOptions = rt.TypeOf<typeof getHostAssetsQueryOptionsRT>;
|
||||
export const getHostAssetsResponseRT = rt.type({
|
||||
hosts: rt.array(assetRT),
|
||||
});
|
||||
export type GetHostAssetsResponse = rt.TypeOf<typeof getHostAssetsResponseRT>;
|
||||
|
||||
/**
|
||||
* Containers
|
||||
*/
|
||||
export const getContainerAssetsQueryOptionsRT = rt.intersection([
|
||||
rt.strict({ from: assetDateRT }),
|
||||
rt.partial({
|
||||
to: assetDateRT,
|
||||
size: sizeRT,
|
||||
stringFilters: rt.string,
|
||||
filters: assetFiltersSingleKindRT,
|
||||
}),
|
||||
]);
|
||||
export type GetContainerAssetsQueryOptions = rt.TypeOf<typeof getContainerAssetsQueryOptionsRT>;
|
||||
export const getContainerAssetsResponseRT = rt.type({
|
||||
containers: rt.array(assetRT),
|
||||
});
|
||||
export type GetContainerAssetsResponse = rt.TypeOf<typeof getContainerAssetsResponseRT>;
|
||||
|
||||
/**
|
||||
* Services
|
||||
*/
|
||||
export const getServiceAssetsQueryOptionsRT = rt.exact(
|
||||
export const getServiceAssetsQueryOptionsRT = rt.intersection([
|
||||
rt.strict({ from: assetDateRT }),
|
||||
rt.partial({
|
||||
from: assetDateRT,
|
||||
to: assetDateRT,
|
||||
size: sizeRT,
|
||||
parent: rt.string,
|
||||
})
|
||||
);
|
||||
stringFilters: rt.string,
|
||||
filters: assetFiltersSingleKindRT,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type GetServiceAssetsQueryOptions = rt.TypeOf<typeof getServiceAssetsQueryOptionsRT>;
|
||||
export const getServiceAssetsResponseRT = rt.type({
|
||||
|
|
|
@ -5,13 +5,22 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export interface SharedAssetsOptionsPublic {
|
||||
import { AssetFilters, SingleKindAssetFilters } from './types_api';
|
||||
|
||||
export interface SharedAssetsOptionsPublic<F = AssetFilters> {
|
||||
from: string;
|
||||
to?: string;
|
||||
filters?: F;
|
||||
stringFilters?: string;
|
||||
}
|
||||
|
||||
export type GetHostsOptionsPublic = SharedAssetsOptionsPublic;
|
||||
// Methods that return only a single "kind" of asset should not accept
|
||||
// a filter of "kind" to filter by asset kinds
|
||||
|
||||
export interface GetServicesOptionsPublic extends SharedAssetsOptionsPublic {
|
||||
export type GetHostsOptionsPublic = SharedAssetsOptionsPublic<SingleKindAssetFilters>;
|
||||
export type GetContainersOptionsPublic = SharedAssetsOptionsPublic<SingleKindAssetFilters>;
|
||||
|
||||
export interface GetServicesOptionsPublic
|
||||
extends SharedAssetsOptionsPublic<SingleKindAssetFilters> {
|
||||
parent?: string;
|
||||
}
|
||||
|
|
|
@ -38,6 +38,18 @@ describe('Public assets client', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should include provided filters, but in string form', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
const filters = { id: '*id-1*' };
|
||||
await client.getHosts({ from: 'x', filters });
|
||||
expect(http.get).toBeCalledWith(routePaths.GET_HOSTS, {
|
||||
query: {
|
||||
from: 'x',
|
||||
stringFilters: JSON.stringify(filters),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the direct results of http.get', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
http.get.mockResolvedValueOnce('my hosts');
|
||||
|
@ -46,6 +58,41 @@ describe('Public assets client', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getContainers', () => {
|
||||
it('should call the REST API', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
await client.getContainers({ from: 'x', to: 'y' });
|
||||
expect(http.get).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should include specified "from" and "to" parameters in http.get query', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
await client.getContainers({ from: 'x', to: 'y' });
|
||||
expect(http.get).toBeCalledWith(routePaths.GET_CONTAINERS, {
|
||||
query: { from: 'x', to: 'y' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should include provided filters, but in string form', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
const filters = { id: '*id-1*' };
|
||||
await client.getContainers({ from: 'x', filters });
|
||||
expect(http.get).toBeCalledWith(routePaths.GET_CONTAINERS, {
|
||||
query: {
|
||||
from: 'x',
|
||||
stringFilters: JSON.stringify(filters),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the direct results of http.get', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
http.get.mockResolvedValueOnce('my hosts');
|
||||
const result = await client.getContainers({ from: 'x', to: 'y' });
|
||||
expect(result).toBe('my hosts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServices', () => {
|
||||
it('should call the REST API', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
|
@ -61,6 +108,18 @@ describe('Public assets client', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should include provided filters, but in string form', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
const filters = { id: '*id-1*' };
|
||||
await client.getServices({ from: 'x', filters });
|
||||
expect(http.get).toBeCalledWith(routePaths.GET_SERVICES, {
|
||||
query: {
|
||||
from: 'x',
|
||||
stringFilters: JSON.stringify(filters),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include specified "parent" parameter in http.get query', async () => {
|
||||
const client = new PublicAssetsClient(http);
|
||||
await client.getServices({ from: 'x', to: 'y', parent: 'container:123' });
|
||||
|
|
|
@ -6,18 +6,40 @@
|
|||
*/
|
||||
|
||||
import { HttpStart } from '@kbn/core/public';
|
||||
import { GetHostsOptionsPublic, GetServicesOptionsPublic } from '../../common/types_client';
|
||||
import { GetHostAssetsResponse, GetServiceAssetsResponse } from '../../common/types_api';
|
||||
import { GET_HOSTS, GET_SERVICES } from '../../common/constants_routes';
|
||||
import {
|
||||
GetContainersOptionsPublic,
|
||||
GetHostsOptionsPublic,
|
||||
GetServicesOptionsPublic,
|
||||
} from '../../common/types_client';
|
||||
import {
|
||||
GetContainerAssetsResponse,
|
||||
GetHostAssetsResponse,
|
||||
GetServiceAssetsResponse,
|
||||
} from '../../common/types_api';
|
||||
import { GET_CONTAINERS, GET_HOSTS, GET_SERVICES } from '../../common/constants_routes';
|
||||
import { IPublicAssetsClient } from '../types';
|
||||
|
||||
export class PublicAssetsClient implements IPublicAssetsClient {
|
||||
constructor(private readonly http: HttpStart) {}
|
||||
|
||||
async getHosts(options: GetHostsOptionsPublic) {
|
||||
const { filters, ...otherOptions } = options;
|
||||
const results = await this.http.get<GetHostAssetsResponse>(GET_HOSTS, {
|
||||
query: {
|
||||
...options,
|
||||
stringFilters: JSON.stringify(filters),
|
||||
...otherOptions,
|
||||
},
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getContainers(options: GetContainersOptionsPublic) {
|
||||
const { filters, ...otherOptions } = options;
|
||||
const results = await this.http.get<GetContainerAssetsResponse>(GET_CONTAINERS, {
|
||||
query: {
|
||||
stringFilters: JSON.stringify(filters),
|
||||
...otherOptions,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -25,9 +47,11 @@ export class PublicAssetsClient implements IPublicAssetsClient {
|
|||
}
|
||||
|
||||
async getServices(options: GetServicesOptionsPublic) {
|
||||
const { filters, ...otherOptions } = options;
|
||||
const results = await this.http.get<GetServiceAssetsResponse>(GET_SERVICES, {
|
||||
query: {
|
||||
...options,
|
||||
stringFilters: JSON.stringify(filters),
|
||||
...otherOptions,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { CoreSetup, CoreStart, PluginInitializerContext } from '@kbn/core/public';
|
||||
import { Logger } from '@kbn/logging';
|
||||
|
||||
import { AssetManagerPluginClass } from './types';
|
||||
import { PublicAssetsClient } from './lib/public_assets_client';
|
||||
import type { AssetManagerPublicConfig } from '../common/config';
|
||||
|
|
|
@ -0,0 +1,357 @@
|
|||
/*
|
||||
* 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { GetApmIndicesMethod } from '../../asset_client_types';
|
||||
import { getContainers } from './get_containers';
|
||||
import {
|
||||
createGetApmIndicesMock,
|
||||
expectToThrowValidationErrorWithStatusCode,
|
||||
} from '../../../test_utils';
|
||||
import { MetricsDataClient, MetricsDataClientMock } from '@kbn/metrics-data-access-plugin/server';
|
||||
import { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
function createBaseOptions({
|
||||
getApmIndicesMock,
|
||||
metricsDataClientMock,
|
||||
}: {
|
||||
getApmIndicesMock: GetApmIndicesMethod;
|
||||
metricsDataClientMock: MetricsDataClient;
|
||||
}) {
|
||||
return {
|
||||
sourceIndices: {
|
||||
logs: 'my-logs*',
|
||||
},
|
||||
getApmIndices: getApmIndicesMock,
|
||||
metricsClient: metricsDataClientMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getHosts', () => {
|
||||
let getApmIndicesMock = createGetApmIndicesMock();
|
||||
let metricsDataClientMock = MetricsDataClientMock.create();
|
||||
let baseOptions = createBaseOptions({ getApmIndicesMock, metricsDataClientMock });
|
||||
let esClientMock = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
|
||||
let soClientMock = savedObjectsClientMock.create();
|
||||
|
||||
function resetMocks() {
|
||||
getApmIndicesMock = createGetApmIndicesMock();
|
||||
metricsDataClientMock = MetricsDataClientMock.create();
|
||||
baseOptions = createBaseOptions({ getApmIndicesMock, metricsDataClientMock });
|
||||
esClientMock = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
|
||||
soClientMock = savedObjectsClientMock.create();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetMocks();
|
||||
|
||||
// ES returns no results, just enough structure to not blow up
|
||||
esClientMock.search.mockResolvedValueOnce({
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
failed: 0,
|
||||
successful: 1,
|
||||
total: 1,
|
||||
},
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should query Elasticsearch correctly', async () => {
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-5d',
|
||||
to: 'now-3d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
});
|
||||
|
||||
expect(metricsDataClientMock.getMetricIndices).toHaveBeenCalledTimes(1);
|
||||
expect(metricsDataClientMock.getMetricIndices).toHaveBeenCalledWith({
|
||||
savedObjectsClient: soClientMock,
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.filter).toEqual([
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: 'now-5d',
|
||||
lte: 'now-3d',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bool?.must).toEqual([
|
||||
{
|
||||
exists: {
|
||||
field: 'container.id',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bool?.should).toEqual([
|
||||
{ exists: { field: 'kubernetes.container.id' } },
|
||||
{ exists: { field: 'kubernetes.pod.uid' } },
|
||||
{ exists: { field: 'kubernetes.node.name' } },
|
||||
{ exists: { field: 'host.hostname' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly include an EAN filter as a container ID term query', async () => {
|
||||
const mockContainerId = '123abc';
|
||||
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `container:${mockContainerId}`,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'container.id',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'container.id': mockContainerId,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should not query ES and return empty if filtering on non-container EAN', async () => {
|
||||
const mockId = 'some-id-123';
|
||||
|
||||
const result = await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `pod:${mockId}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(esClientMock.search).toHaveBeenCalledTimes(0);
|
||||
expect(result).toEqual({ containers: [] });
|
||||
});
|
||||
|
||||
it('should throw an error when an invalid EAN is provided', async () => {
|
||||
try {
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `invalid`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const hasMessage = 'message' in error;
|
||||
expect(hasMessage).toEqual(true);
|
||||
expect(error.message).toEqual('invalid is not a valid EAN');
|
||||
}
|
||||
|
||||
try {
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `invalid:toomany:colons`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const hasMessage = 'message' in error;
|
||||
expect(hasMessage).toEqual(true);
|
||||
expect(error.message).toEqual('invalid:toomany:colons is not a valid EAN');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include a wildcard ID filter when an ID filter is provided with asterisks included', async () => {
|
||||
const mockIdPattern = '*partial-id*';
|
||||
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
id: mockIdPattern,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'container.id',
|
||||
},
|
||||
},
|
||||
{
|
||||
wildcard: {
|
||||
'container.id': mockIdPattern,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should include a term ID filter when an ID filter is provided without asterisks included', async () => {
|
||||
const mockId = 'full-id';
|
||||
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
id: mockId,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'container.id',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'container.id': mockId,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should include a term filter for cloud filters', async () => {
|
||||
const mockCloudProvider = 'gcp';
|
||||
const mockCloudRegion = 'us-central-1';
|
||||
|
||||
await getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
'cloud.provider': mockCloudProvider,
|
||||
'cloud.region': mockCloudRegion,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'container.id',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'cloud.provider': mockCloudProvider,
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'cloud.region': mockCloudRegion,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 for invalid "from" date', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-1zz',
|
||||
to: 'now-3d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 for invalid "to" date', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getContainers({
|
||||
...baseOptions,
|
||||
from: 'now-5d',
|
||||
to: 'now-3fe',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 when "from" is a date that is after "to"', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getContainers({
|
||||
...baseOptions,
|
||||
from: 'now',
|
||||
to: 'now-5d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 when "from" is in the future', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getContainers({
|
||||
...baseOptions,
|
||||
from: 'now+1d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { Asset } from '../../../../common/types_api';
|
||||
import { GetContainersOptionsPublic } from '../../../../common/types_client';
|
||||
import {
|
||||
AssetClientDependencies,
|
||||
AssetClientOptionsWithInjectedValues,
|
||||
} from '../../asset_client_types';
|
||||
import { parseEan } from '../../parse_ean';
|
||||
import { collectContainers } from '../../collectors';
|
||||
import { validateStringDateRange } from '../../validators/validate_date_range';
|
||||
|
||||
export type GetContainersOptions = GetContainersOptionsPublic & AssetClientDependencies;
|
||||
export type GetContainersOptionsInjected =
|
||||
AssetClientOptionsWithInjectedValues<GetContainersOptions>;
|
||||
|
||||
export async function getContainers(
|
||||
options: GetContainersOptionsInjected
|
||||
): Promise<{ containers: Asset[] }> {
|
||||
validateStringDateRange(options.from, options.to);
|
||||
|
||||
const metricsIndices = await options.metricsClient.getMetricIndices({
|
||||
savedObjectsClient: options.savedObjectsClient,
|
||||
});
|
||||
|
||||
const filters: QueryDslQueryContainer[] = [];
|
||||
|
||||
if (options.filters?.ean) {
|
||||
const ean = Array.isArray(options.filters.ean) ? options.filters.ean[0] : options.filters.ean;
|
||||
const { kind, id } = parseEan(ean);
|
||||
|
||||
// if EAN filter isn't targeting a container asset, we don't need to do this query
|
||||
if (kind !== 'container') {
|
||||
return {
|
||||
containers: [],
|
||||
};
|
||||
}
|
||||
|
||||
filters.push({
|
||||
term: {
|
||||
'container.id': id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.filters?.id) {
|
||||
const fn = options.filters.id.includes('*') ? 'wildcard' : 'term';
|
||||
filters.push({
|
||||
[fn]: {
|
||||
'container.id': options.filters.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.filters?.['cloud.provider']) {
|
||||
filters.push({
|
||||
term: {
|
||||
'cloud.provider': options.filters['cloud.provider'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.filters?.['cloud.region']) {
|
||||
filters.push({
|
||||
term: {
|
||||
'cloud.region': options.filters['cloud.region'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { assets } = await collectContainers({
|
||||
client: options.elasticsearchClient,
|
||||
from: options.from,
|
||||
to: options.to || 'now',
|
||||
filters,
|
||||
sourceIndices: {
|
||||
metrics: metricsIndices,
|
||||
logs: options.sourceIndices.logs,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
containers: assets,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,357 @@
|
|||
/*
|
||||
* 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { GetApmIndicesMethod } from '../../asset_client_types';
|
||||
import { getHosts } from './get_hosts';
|
||||
import {
|
||||
createGetApmIndicesMock,
|
||||
expectToThrowValidationErrorWithStatusCode,
|
||||
} from '../../../test_utils';
|
||||
import { MetricsDataClient, MetricsDataClientMock } from '@kbn/metrics-data-access-plugin/server';
|
||||
import { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
function createBaseOptions({
|
||||
getApmIndicesMock,
|
||||
metricsDataClientMock,
|
||||
}: {
|
||||
getApmIndicesMock: GetApmIndicesMethod;
|
||||
metricsDataClientMock: MetricsDataClient;
|
||||
}) {
|
||||
return {
|
||||
sourceIndices: {
|
||||
logs: 'my-logs*',
|
||||
},
|
||||
getApmIndices: getApmIndicesMock,
|
||||
metricsClient: metricsDataClientMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getHosts', () => {
|
||||
let getApmIndicesMock = createGetApmIndicesMock();
|
||||
let metricsDataClientMock = MetricsDataClientMock.create();
|
||||
let baseOptions = createBaseOptions({ getApmIndicesMock, metricsDataClientMock });
|
||||
let esClientMock = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
|
||||
let soClientMock = savedObjectsClientMock.create();
|
||||
|
||||
function resetMocks() {
|
||||
getApmIndicesMock = createGetApmIndicesMock();
|
||||
metricsDataClientMock = MetricsDataClientMock.create();
|
||||
baseOptions = createBaseOptions({ getApmIndicesMock, metricsDataClientMock });
|
||||
esClientMock = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
|
||||
soClientMock = savedObjectsClientMock.create();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetMocks();
|
||||
|
||||
// ES returns no results, just enough structure to not blow up
|
||||
esClientMock.search.mockResolvedValueOnce({
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
failed: 0,
|
||||
successful: 1,
|
||||
total: 1,
|
||||
},
|
||||
hits: {
|
||||
hits: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should query Elasticsearch correctly', async () => {
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-5d',
|
||||
to: 'now-3d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
});
|
||||
|
||||
expect(metricsDataClientMock.getMetricIndices).toHaveBeenCalledTimes(1);
|
||||
expect(metricsDataClientMock.getMetricIndices).toHaveBeenCalledWith({
|
||||
savedObjectsClient: soClientMock,
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.filter).toEqual([
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: 'now-5d',
|
||||
lte: 'now-3d',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bool?.must).toEqual([
|
||||
{
|
||||
exists: {
|
||||
field: 'host.hostname',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bool?.should).toEqual([
|
||||
{ exists: { field: 'kubernetes.node.name' } },
|
||||
{ exists: { field: 'kubernetes.pod.uid' } },
|
||||
{ exists: { field: 'container.id' } },
|
||||
{ exists: { field: 'cloud.provider' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly include an EAN filter as a hostname term query', async () => {
|
||||
const mockHostName = 'some-hostname-123';
|
||||
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `host:${mockHostName}`,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'host.hostname',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'host.hostname': mockHostName,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should not query ES and return empty if filtering on non-host EAN', async () => {
|
||||
const mockId = 'some-id-123';
|
||||
|
||||
const result = await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `container:${mockId}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(esClientMock.search).toHaveBeenCalledTimes(0);
|
||||
expect(result).toEqual({ hosts: [] });
|
||||
});
|
||||
|
||||
it('should throw an error when an invalid EAN is provided', async () => {
|
||||
try {
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `invalid`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const hasMessage = 'message' in error;
|
||||
expect(hasMessage).toEqual(true);
|
||||
expect(error.message).toEqual('invalid is not a valid EAN');
|
||||
}
|
||||
|
||||
try {
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
ean: `invalid:toomany:colons`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const hasMessage = 'message' in error;
|
||||
expect(hasMessage).toEqual(true);
|
||||
expect(error.message).toEqual('invalid:toomany:colons is not a valid EAN');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include a wildcard ID filter when an ID filter is provided with asterisks included', async () => {
|
||||
const mockIdPattern = '*partial-id*';
|
||||
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
id: mockIdPattern,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'host.hostname',
|
||||
},
|
||||
},
|
||||
{
|
||||
wildcard: {
|
||||
'host.hostname': mockIdPattern,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should include a term ID filter when an ID filter is provided without asterisks included', async () => {
|
||||
const mockId = 'full-id';
|
||||
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
id: mockId,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'host.hostname',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'host.hostname': mockId,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should include a term filter for cloud filters', async () => {
|
||||
const mockCloudProvider = 'gcp';
|
||||
const mockCloudRegion = 'us-central-1';
|
||||
|
||||
await getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1h',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
filters: {
|
||||
'cloud.provider': mockCloudProvider,
|
||||
'cloud.region': mockCloudRegion,
|
||||
},
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.must).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
exists: {
|
||||
field: 'host.hostname',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'cloud.provider': mockCloudProvider,
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'cloud.region': mockCloudRegion,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 for invalid "from" date', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-1zz',
|
||||
to: 'now-3d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 for invalid "to" date', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getHosts({
|
||||
...baseOptions,
|
||||
from: 'now-5d',
|
||||
to: 'now-3fe',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 when "from" is a date that is after "to"', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getHosts({
|
||||
...baseOptions,
|
||||
from: 'now',
|
||||
to: 'now-5d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 when "from" is in the future', () => {
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
getHosts({
|
||||
...baseOptions,
|
||||
from: 'now+1d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
});
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { Asset } from '../../../../common/types_api';
|
||||
import { collectHosts } from '../../collectors/hosts';
|
||||
import { GetHostsOptionsPublic } from '../../../../common/types_client';
|
||||
|
@ -12,24 +13,75 @@ import {
|
|||
AssetClientDependencies,
|
||||
AssetClientOptionsWithInjectedValues,
|
||||
} from '../../asset_client_types';
|
||||
import { parseEan } from '../../parse_ean';
|
||||
import { validateStringDateRange } from '../../validators/validate_date_range';
|
||||
|
||||
export type GetHostsOptions = GetHostsOptionsPublic & AssetClientDependencies;
|
||||
export type GetHostsOptionsInjected = AssetClientOptionsWithInjectedValues<GetHostsOptions>;
|
||||
|
||||
export async function getHosts(options: GetHostsOptionsInjected): Promise<{ hosts: Asset[] }> {
|
||||
validateStringDateRange(options.from, options.to);
|
||||
|
||||
const metricsIndices = await options.metricsClient.getMetricIndices({
|
||||
savedObjectsClient: options.savedObjectsClient,
|
||||
});
|
||||
|
||||
const filters: QueryDslQueryContainer[] = [];
|
||||
|
||||
if (options.filters?.ean) {
|
||||
const ean = Array.isArray(options.filters.ean) ? options.filters.ean[0] : options.filters.ean;
|
||||
const { kind, id } = parseEan(ean);
|
||||
|
||||
// if EAN filter isn't targeting a host asset, we don't need to do this query
|
||||
if (kind !== 'host') {
|
||||
return {
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
filters.push({
|
||||
term: {
|
||||
'host.hostname': id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.filters?.id) {
|
||||
const fn = options.filters.id.includes('*') ? 'wildcard' : 'term';
|
||||
filters.push({
|
||||
[fn]: {
|
||||
'host.hostname': options.filters.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.filters?.['cloud.provider']) {
|
||||
filters.push({
|
||||
term: {
|
||||
'cloud.provider': options.filters['cloud.provider'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.filters?.['cloud.region']) {
|
||||
filters.push({
|
||||
term: {
|
||||
'cloud.region': options.filters['cloud.region'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { assets } = await collectHosts({
|
||||
client: options.elasticsearchClient,
|
||||
from: options.from,
|
||||
to: options.to || 'now',
|
||||
filters,
|
||||
sourceIndices: {
|
||||
metrics: metricsIndices,
|
||||
logs: options.sourceIndices.logs,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hosts: assets,
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
AssetClientDependencies,
|
||||
AssetClientOptionsWithInjectedValues,
|
||||
} from '../../asset_client_types';
|
||||
import { validateStringDateRange } from '../../validators/validate_date_range';
|
||||
|
||||
export type GetServicesOptions = GetServicesOptionsPublic & AssetClientDependencies;
|
||||
export type GetServicesOptionsInjected = AssetClientOptionsWithInjectedValues<GetServicesOptions>;
|
||||
|
@ -20,6 +21,8 @@ export type GetServicesOptionsInjected = AssetClientOptionsWithInjectedValues<Ge
|
|||
export async function getServices(
|
||||
options: GetServicesOptionsInjected
|
||||
): Promise<{ services: Asset[] }> {
|
||||
validateStringDateRange(options.from, options.to);
|
||||
|
||||
const filters = [];
|
||||
|
||||
if (options.parent) {
|
||||
|
|
|
@ -16,41 +16,7 @@ import { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
|||
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import { AssetsValidationError } from './validators/validation_error';
|
||||
import { GetApmIndicesMethod } from './asset_client_types';
|
||||
|
||||
// Helper function allows test to verify error was thrown,
|
||||
// verify error is of the right class type, and error has
|
||||
// the expected metadata such as statusCode on it
|
||||
function expectToThrowValidationErrorWithStatusCode(
|
||||
testFn: () => Promise<any>,
|
||||
expectedError: Partial<AssetsValidationError> = {}
|
||||
) {
|
||||
return expect(async () => {
|
||||
try {
|
||||
return await testFn();
|
||||
} catch (error: any) {
|
||||
if (error instanceof AssetsValidationError) {
|
||||
if (expectedError.statusCode) {
|
||||
expect(error.statusCode).toEqual(expectedError.statusCode);
|
||||
}
|
||||
if (expectedError.message) {
|
||||
expect(error.message).toEqual(expect.stringContaining(expectedError.message));
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}).rejects.toThrow(AssetsValidationError);
|
||||
}
|
||||
|
||||
function createGetApmIndicesMock(): jest.Mocked<GetApmIndicesMethod> {
|
||||
return jest.fn(async (client: SavedObjectsClientContract) => ({
|
||||
transaction: 'apm-mock-transaction-indices',
|
||||
span: 'apm-mock-span-indices',
|
||||
error: 'apm-mock-error-indices',
|
||||
metric: 'apm-mock-metric-indices',
|
||||
onboarding: 'apm-mock-onboarding-indices',
|
||||
sourcemap: 'apm-mock-sourcemap-indices',
|
||||
}));
|
||||
}
|
||||
import { createGetApmIndicesMock, expectToThrowValidationErrorWithStatusCode } from '../test_utils';
|
||||
|
||||
function createAssetClient(
|
||||
metricsDataClient: MetricsDataClient,
|
||||
|
@ -100,112 +66,7 @@ describe('Server assets client', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getHosts', () => {
|
||||
it('should query Elasticsearch correctly', async () => {
|
||||
const client = createAssetClient(metricsDataClientMock, getApmIndicesMock);
|
||||
|
||||
await client.getHosts({
|
||||
from: 'now-5d',
|
||||
to: 'now-3d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
});
|
||||
|
||||
expect(metricsDataClientMock.getMetricIndices).toHaveBeenCalledTimes(1);
|
||||
expect(metricsDataClientMock.getMetricIndices).toHaveBeenCalledWith({
|
||||
savedObjectsClient: soClientMock,
|
||||
});
|
||||
|
||||
const dsl = esClientMock.search.mock.lastCall?.[0] as SearchRequest | undefined;
|
||||
const { bool } = dsl?.query || {};
|
||||
expect(bool).toBeDefined();
|
||||
|
||||
expect(bool?.filter).toEqual([
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: 'now-5d',
|
||||
lte: 'now-3d',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bool?.must).toEqual([
|
||||
{
|
||||
exists: {
|
||||
field: 'host.hostname',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(bool?.should).toEqual([
|
||||
{ exists: { field: 'kubernetes.node.name' } },
|
||||
{ exists: { field: 'kubernetes.pod.uid' } },
|
||||
{ exists: { field: 'container.id' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reject with 400 for invalid "from" date', () => {
|
||||
const client = createAssetClient(metricsDataClientMock, getApmIndicesMock);
|
||||
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
client.getHosts({
|
||||
from: 'now-1zz',
|
||||
to: 'now-3d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 for invalid "to" date', () => {
|
||||
const client = createAssetClient(metricsDataClientMock, getApmIndicesMock);
|
||||
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
client.getHosts({
|
||||
from: 'now-5d',
|
||||
to: 'now-3fe',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 when "from" is a date that is after "to"', () => {
|
||||
const client = createAssetClient(metricsDataClientMock, getApmIndicesMock);
|
||||
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
client.getHosts({
|
||||
from: 'now',
|
||||
to: 'now-5d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 when "from" is in the future', () => {
|
||||
const client = createAssetClient(metricsDataClientMock, getApmIndicesMock);
|
||||
|
||||
return expectToThrowValidationErrorWithStatusCode(
|
||||
() =>
|
||||
client.getHosts({
|
||||
from: 'now+1d',
|
||||
elasticsearchClient: esClientMock,
|
||||
savedObjectsClient: soClientMock,
|
||||
}),
|
||||
{ statusCode: 400 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Move this block to the get_services accessor folder
|
||||
describe('getServices', () => {
|
||||
it('should query Elasticsearch correctly', async () => {
|
||||
const client = createAssetClient(metricsDataClientMock, getApmIndicesMock);
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
*/
|
||||
|
||||
import { Asset } from '../../common/types_api';
|
||||
import { getContainers, GetContainersOptions } from './accessors/containers/get_containers';
|
||||
import { getHosts, GetHostsOptions } from './accessors/hosts/get_hosts';
|
||||
import { getServices, GetServicesOptions } from './accessors/services/get_services';
|
||||
import { AssetClientBaseOptions, AssetClientOptionsWithInjectedValues } from './asset_client_types';
|
||||
import { validateStringDateRange } from './validators/validate_date_range';
|
||||
|
||||
export class AssetClient {
|
||||
constructor(private baseOptions: AssetClientBaseOptions) {}
|
||||
|
@ -22,14 +22,17 @@ export class AssetClient {
|
|||
}
|
||||
|
||||
async getHosts(options: GetHostsOptions): Promise<{ hosts: Asset[] }> {
|
||||
validateStringDateRange(options.from, options.to);
|
||||
const withInjected = this.injectOptions(options);
|
||||
return await getHosts(withInjected);
|
||||
}
|
||||
|
||||
async getServices(options: GetServicesOptions): Promise<{ services: Asset[] }> {
|
||||
validateStringDateRange(options.from, options.to);
|
||||
const withInjected = this.injectOptions(options);
|
||||
return await getServices(withInjected);
|
||||
}
|
||||
|
||||
async getContainers(options: GetContainersOptions): Promise<{ containers: Asset[] }> {
|
||||
const withInjected = this.injectOptions(options);
|
||||
return await getContainers(withInjected);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,12 +14,15 @@ export async function collectContainers({
|
|||
from,
|
||||
to,
|
||||
sourceIndices,
|
||||
filters = [],
|
||||
afterKey,
|
||||
}: CollectorOptions) {
|
||||
if (!sourceIndices?.metrics || !sourceIndices?.logs) {
|
||||
throw new Error('missing required metrics/logs indices');
|
||||
}
|
||||
|
||||
const musts = [...filters, { exists: { field: 'container.id' } }];
|
||||
|
||||
const { metrics, logs } = sourceIndices;
|
||||
const dsl: estypes.SearchRequest = {
|
||||
index: [metrics, logs],
|
||||
|
@ -48,6 +51,7 @@ export async function collectContainers({
|
|||
},
|
||||
},
|
||||
],
|
||||
must: musts,
|
||||
should: [
|
||||
{ exists: { field: 'kubernetes.container.id' } },
|
||||
{ exists: { field: 'kubernetes.pod.uid' } },
|
||||
|
|
|
@ -15,12 +15,15 @@ export async function collectHosts({
|
|||
to,
|
||||
sourceIndices,
|
||||
afterKey,
|
||||
filters = [],
|
||||
}: CollectorOptions) {
|
||||
if (!sourceIndices?.metrics || !sourceIndices?.logs) {
|
||||
throw new Error('missing required metrics/logs indices');
|
||||
}
|
||||
|
||||
const { metrics, logs } = sourceIndices;
|
||||
|
||||
const musts = [...filters, { exists: { field: 'host.hostname' } }];
|
||||
const dsl: estypes.SearchRequest = {
|
||||
index: [metrics, logs],
|
||||
size: QUERY_MAX_SIZE,
|
||||
|
@ -47,11 +50,12 @@ export async function collectHosts({
|
|||
},
|
||||
},
|
||||
],
|
||||
must: [{ exists: { field: 'host.hostname' } }],
|
||||
must: musts,
|
||||
should: [
|
||||
{ exists: { field: 'kubernetes.node.name' } },
|
||||
{ exists: { field: 'kubernetes.pod.uid' } },
|
||||
{ exists: { field: 'container.id' } },
|
||||
{ exists: { field: 'cloud.provider' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,570 +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.
|
||||
*/
|
||||
|
||||
jest.mock('./get_assets', () => ({ getAssets: jest.fn() }));
|
||||
jest.mock('./get_indirectly_related_assets', () => ({ getIndirectlyRelatedAssets: jest.fn() }));
|
||||
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AssetWithoutTimestamp } from '../../common/types_api';
|
||||
import { getAssets } from './get_assets'; // Mocked
|
||||
import { getIndirectlyRelatedAssets } from './get_indirectly_related_assets'; // Mocked
|
||||
import { getAllRelatedAssets } from './get_all_related_assets';
|
||||
|
||||
const esClientMock = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
|
||||
|
||||
describe('getAllRelatedAssets', () => {
|
||||
beforeEach(() => {
|
||||
(getAssets as jest.Mock).mockReset();
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
it('throws if it cannot find the primary asset', async () => {
|
||||
const primaryAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-which-does-not-exist',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
// Mock that we cannot find the primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([]);
|
||||
// Ensure maxDistance is respected
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Should respect maxDistance')
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAsset['asset.ean'],
|
||||
relation: 'ancestors',
|
||||
from: new Date().toISOString(),
|
||||
maxDistance: 1,
|
||||
size: 10,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
`Asset with ean (${primaryAsset['asset.ean']}) not found in the provided time range`
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only the primary if it does not have any ancestors', async () => {
|
||||
const primaryAssetWithoutParents: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [],
|
||||
};
|
||||
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAssetWithoutParents]);
|
||||
// Distance 1
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValueOnce([]);
|
||||
// Ensure maxDistance is respected
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Should respect maxDistance')
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAssetWithoutParents['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 1,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAssetWithoutParents,
|
||||
ancestors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the primary and a directly referenced parent', async () => {
|
||||
const parentAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const primaryAssetWithDirectParent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [parentAsset['asset.ean']],
|
||||
};
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAssetWithDirectParent]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([parentAsset]);
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValueOnce([]);
|
||||
// Ensure maxDistance is respected
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Should respect maxDistance')
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAssetWithDirectParent['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 1,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAssetWithDirectParent,
|
||||
ancestors: [
|
||||
{
|
||||
...parentAsset,
|
||||
distance: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the primary and an indirectly referenced parent', async () => {
|
||||
const primaryAssetWithIndirectParent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [],
|
||||
};
|
||||
|
||||
const parentAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.children': [primaryAssetWithIndirectParent['asset.ean']],
|
||||
};
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAssetWithIndirectParent]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([]);
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValueOnce([parentAsset]);
|
||||
// Ensure maxDistance is respected
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Should respect maxDistance')
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAssetWithIndirectParent['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 1,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAssetWithIndirectParent,
|
||||
ancestors: [
|
||||
{
|
||||
...parentAsset,
|
||||
distance: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the primary and all distance 1 parents', async () => {
|
||||
const directlyReferencedParent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'directly-referenced-parent-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.children': [],
|
||||
};
|
||||
|
||||
const primaryAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [directlyReferencedParent['asset.ean']],
|
||||
};
|
||||
|
||||
const indirectlyReferencedParent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'indirectly-referenced-parent-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.children': [primaryAsset['asset.ean']],
|
||||
};
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAsset]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([directlyReferencedParent]);
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValueOnce([indirectlyReferencedParent]);
|
||||
// Ensure maxDistance is respected
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Should respect maxDistance')
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAsset['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 1,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAsset,
|
||||
ancestors: [
|
||||
{
|
||||
...directlyReferencedParent,
|
||||
distance: 1,
|
||||
},
|
||||
{
|
||||
...indirectlyReferencedParent,
|
||||
distance: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the primary and one parent even with a two way relation defined', async () => {
|
||||
const parentAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const primaryAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
primaryAsset['asset.parents'] = [parentAsset['asset.ean']];
|
||||
parentAsset['asset.children'] = [primaryAsset['asset.ean']];
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAsset]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([parentAsset]);
|
||||
// Code should filter out any directly referenced parent from the indirectly referenced parents query
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValueOnce([]);
|
||||
// Ensure maxDistance is respected
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Should respect maxDistance')
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAsset['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 1,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAsset,
|
||||
ancestors: [
|
||||
{
|
||||
...parentAsset,
|
||||
distance: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns relations from 5 jumps', async () => {
|
||||
const distance6Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-5-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const distance5Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-5-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance6Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const distance4Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-4-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance5Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const distance3Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-3-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance4Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const distance2Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-2-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance3Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const distance1Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-1-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance2Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const primaryAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance1Parent['asset.ean']],
|
||||
};
|
||||
|
||||
// Only using directly referenced parents
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAsset]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance1Parent]);
|
||||
// Distance 2
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance2Parent]);
|
||||
// Distance 3
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance3Parent]);
|
||||
// Distance 4
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance4Parent]);
|
||||
// Distance 5
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance5Parent]);
|
||||
// Should not exceed maxDistance
|
||||
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAsset['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 5,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAsset,
|
||||
ancestors: [
|
||||
{
|
||||
...distance1Parent,
|
||||
distance: 1,
|
||||
},
|
||||
{
|
||||
...distance2Parent,
|
||||
distance: 2,
|
||||
},
|
||||
{
|
||||
...distance3Parent,
|
||||
distance: 3,
|
||||
},
|
||||
{
|
||||
...distance4Parent,
|
||||
distance: 4,
|
||||
},
|
||||
{
|
||||
...distance5Parent,
|
||||
distance: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns relations from only 3 jumps if there are no more parents', async () => {
|
||||
const distance3Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-3-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const distance2Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-2-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance3Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const distance1Parent: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-1-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance2Parent['asset.ean']],
|
||||
};
|
||||
|
||||
const primaryAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance1Parent['asset.ean']],
|
||||
};
|
||||
|
||||
// Only using directly referenced parents
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAsset]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance1Parent]);
|
||||
// Distance 2
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance2Parent]);
|
||||
// Distance 3
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance3Parent]);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAsset['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 5,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAsset,
|
||||
ancestors: [
|
||||
{
|
||||
...distance1Parent,
|
||||
distance: 1,
|
||||
},
|
||||
{
|
||||
...distance2Parent,
|
||||
distance: 2,
|
||||
},
|
||||
{
|
||||
...distance3Parent,
|
||||
distance: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns relations by distance even if there are multiple parents in each jump', async () => {
|
||||
const distance2ParentA: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-2-ean-a',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const distance2ParentB: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-2-ean-b',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const distance2ParentC: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-2-ean-c',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const distance2ParentD: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-2-ean-d',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
};
|
||||
|
||||
const distance1ParentA: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-1-ean-a',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance2ParentA['asset.ean'], distance2ParentB['asset.ean']],
|
||||
};
|
||||
|
||||
const distance1ParentB: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'parent-1-ean-b',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance2ParentC['asset.ean'], distance2ParentD['asset.ean']],
|
||||
};
|
||||
|
||||
const primaryAsset: AssetWithoutTimestamp = {
|
||||
'asset.ean': 'primary-ean',
|
||||
'asset.type': 'k8s.pod',
|
||||
'asset.kind': 'pod',
|
||||
'asset.id': uuid(),
|
||||
'asset.parents': [distance1ParentA['asset.ean'], distance1ParentB['asset.ean']],
|
||||
};
|
||||
|
||||
// Only using directly referenced parents
|
||||
(getIndirectlyRelatedAssets as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
// Primary
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAsset]);
|
||||
// Distance 1
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance1ParentA, distance1ParentB]);
|
||||
// Distance 2 (the order matters)
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance2ParentA, distance2ParentB]);
|
||||
(getAssets as jest.Mock).mockResolvedValueOnce([distance2ParentC, distance2ParentD]);
|
||||
|
||||
await expect(
|
||||
getAllRelatedAssets(esClientMock, {
|
||||
ean: primaryAsset['asset.ean'],
|
||||
from: new Date().toISOString(),
|
||||
relation: 'ancestors',
|
||||
maxDistance: 5,
|
||||
size: 10,
|
||||
})
|
||||
).resolves.toStrictEqual({
|
||||
primary: primaryAsset,
|
||||
ancestors: [
|
||||
{
|
||||
...distance1ParentA,
|
||||
distance: 1,
|
||||
},
|
||||
{
|
||||
...distance1ParentB,
|
||||
distance: 1,
|
||||
},
|
||||
{
|
||||
...distance2ParentA,
|
||||
distance: 2,
|
||||
},
|
||||
{
|
||||
...distance2ParentB,
|
||||
distance: 2,
|
||||
},
|
||||
{
|
||||
...distance2ParentC,
|
||||
distance: 2,
|
||||
},
|
||||
{
|
||||
...distance2ParentD,
|
||||
distance: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,155 +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 { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { flatten, without } from 'lodash';
|
||||
import { debug } from '../../common/debug_log';
|
||||
import { Asset, AssetType, AssetKind, Relation, RelationField } from '../../common/types_api';
|
||||
import { getAssets } from './get_assets';
|
||||
import { getIndirectlyRelatedAssets } from './get_indirectly_related_assets';
|
||||
import { AssetNotFoundError } from './errors';
|
||||
import { toArray } from './utils';
|
||||
|
||||
interface GetAllRelatedAssetsOptions {
|
||||
ean: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
relation: Relation;
|
||||
type?: AssetType[];
|
||||
kind?: AssetKind[];
|
||||
maxDistance: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function getAllRelatedAssets(
|
||||
elasticsearchClient: ElasticsearchClient,
|
||||
options: GetAllRelatedAssetsOptions
|
||||
) {
|
||||
// How to put size into this?
|
||||
const { ean, from, to, relation, maxDistance, kind = [] } = options;
|
||||
|
||||
const primary = await findPrimary(elasticsearchClient, { ean, from, to });
|
||||
|
||||
let assetsToFetch = [primary];
|
||||
let currentDistance = 1;
|
||||
const relatedAssets = [];
|
||||
while (currentDistance <= maxDistance) {
|
||||
const queryOptions: FindRelatedAssetsOptions = {
|
||||
relation,
|
||||
from,
|
||||
to,
|
||||
visitedEans: [primary['asset.ean'], ...relatedAssets.map((asset) => asset['asset.ean'])],
|
||||
};
|
||||
// if we enforce the kind filter before the last query we'll miss nodes with
|
||||
// possible edges to the requested kind values
|
||||
if (currentDistance === maxDistance && kind.length) {
|
||||
queryOptions.kind = kind;
|
||||
}
|
||||
|
||||
const results = flatten(
|
||||
await Promise.all(
|
||||
assetsToFetch.map((asset) => findRelatedAssets(elasticsearchClient, asset, queryOptions))
|
||||
)
|
||||
);
|
||||
|
||||
if (results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
relatedAssets.push(...results.map(withDistance(currentDistance)));
|
||||
|
||||
assetsToFetch = results;
|
||||
currentDistance++;
|
||||
}
|
||||
|
||||
return {
|
||||
primary,
|
||||
[relation]: kind.length
|
||||
? relatedAssets.filter((asset) => asset['asset.kind'] && kind.includes(asset['asset.kind']))
|
||||
: relatedAssets,
|
||||
};
|
||||
}
|
||||
|
||||
async function findPrimary(
|
||||
elasticsearchClient: ElasticsearchClient,
|
||||
{ ean, from, to }: Pick<GetAllRelatedAssetsOptions, 'ean' | 'from' | 'to'>
|
||||
): Promise<Asset> {
|
||||
const primaryResults = await getAssets({
|
||||
elasticsearchClient,
|
||||
size: 1,
|
||||
filters: { ean, from, to },
|
||||
});
|
||||
|
||||
if (primaryResults.length === 0) {
|
||||
throw new AssetNotFoundError(ean);
|
||||
}
|
||||
|
||||
if (primaryResults.length > 1) {
|
||||
throw new Error(`Illegal state: Found more than one asset with the same ean (ean=${ean}).`);
|
||||
}
|
||||
|
||||
return primaryResults[0];
|
||||
}
|
||||
|
||||
type FindRelatedAssetsOptions = Pick<
|
||||
GetAllRelatedAssetsOptions,
|
||||
'relation' | 'kind' | 'from' | 'to'
|
||||
> & { visitedEans: string[] };
|
||||
|
||||
async function findRelatedAssets(
|
||||
elasticsearchClient: ElasticsearchClient,
|
||||
primary: Asset,
|
||||
{ relation, from, to, kind, visitedEans }: FindRelatedAssetsOptions
|
||||
): Promise<Asset[]> {
|
||||
const relationField = relationToDirectField(relation);
|
||||
const directlyRelatedEans = toArray<string>(primary[relationField]);
|
||||
|
||||
debug('Directly Related EAN values found on primary asset', directlyRelatedEans);
|
||||
|
||||
let directlyRelatedAssets: Asset[] = [];
|
||||
|
||||
// get the directly related assets we haven't visited already
|
||||
const remainingEansToFind = without(directlyRelatedEans, ...visitedEans);
|
||||
if (remainingEansToFind.length > 0) {
|
||||
directlyRelatedAssets = await getAssets({
|
||||
elasticsearchClient,
|
||||
filters: { ean: remainingEansToFind, from, to, kind },
|
||||
});
|
||||
}
|
||||
|
||||
debug('Directly related assets found:', JSON.stringify(directlyRelatedAssets));
|
||||
|
||||
const indirectlyRelatedAssets = await getIndirectlyRelatedAssets({
|
||||
elasticsearchClient,
|
||||
ean: primary['asset.ean'],
|
||||
excludeEans: visitedEans.concat(directlyRelatedEans),
|
||||
relation,
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
});
|
||||
|
||||
debug('Indirectly related assets found:', JSON.stringify(indirectlyRelatedAssets));
|
||||
|
||||
return [...directlyRelatedAssets, ...indirectlyRelatedAssets];
|
||||
}
|
||||
|
||||
function relationToDirectField(relation: Relation): RelationField {
|
||||
if (relation === 'ancestors') {
|
||||
return 'asset.parents';
|
||||
} else if (relation === 'descendants') {
|
||||
return 'asset.children';
|
||||
} else {
|
||||
return 'asset.references';
|
||||
}
|
||||
}
|
||||
|
||||
function withDistance(
|
||||
distance: number
|
||||
): (value: Asset, index: number, array: Asset[]) => Asset & { distance: number } {
|
||||
return (asset: Asset) => ({ ...asset, distance });
|
||||
}
|
|
@ -1,130 +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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { debug } from '../../common/debug_log';
|
||||
import { Asset, AssetFilters } from '../../common/types_api';
|
||||
import { ASSETS_INDEX_PREFIX } from '../constants';
|
||||
import { ElasticsearchAccessorOptions } from '../types';
|
||||
import { isStringOrNonEmptyArray } from './utils';
|
||||
|
||||
interface GetAssetsOptions extends ElasticsearchAccessorOptions {
|
||||
size?: number;
|
||||
filters?: AssetFilters;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export async function getAssets({
|
||||
elasticsearchClient,
|
||||
size = 100,
|
||||
filters = {},
|
||||
}: GetAssetsOptions): Promise<Asset[]> {
|
||||
// Maybe it makes the most sense to validate the filters here?
|
||||
debug('Get Assets Filters:', JSON.stringify(filters));
|
||||
|
||||
const { from = 'now-24h', to = 'now' } = filters;
|
||||
const must: QueryDslQueryContainer[] = [];
|
||||
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
if (typeof filters.collectionVersion === 'number') {
|
||||
must.push({
|
||||
term: {
|
||||
['asset.collection_version']: filters.collectionVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isStringOrNonEmptyArray(filters.type)) {
|
||||
must.push({
|
||||
terms: {
|
||||
['asset.type']: Array.isArray(filters.type) ? filters.type : [filters.type],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isStringOrNonEmptyArray(filters.kind)) {
|
||||
must.push({
|
||||
terms: {
|
||||
['asset.kind']: Array.isArray(filters.kind) ? filters.kind : [filters.kind],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isStringOrNonEmptyArray(filters.ean)) {
|
||||
must.push({
|
||||
terms: {
|
||||
['asset.ean']: Array.isArray(filters.ean) ? filters.ean : [filters.ean],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.id) {
|
||||
must.push({
|
||||
term: {
|
||||
['asset.id']: filters.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.typeLike) {
|
||||
must.push({
|
||||
wildcard: {
|
||||
['asset.type']: filters.typeLike,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.kindLike) {
|
||||
must.push({
|
||||
wildcard: {
|
||||
['asset.kind']: filters.kindLike,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.eanLike) {
|
||||
must.push({
|
||||
wildcard: {
|
||||
['asset.ean']: filters.eanLike,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dsl = {
|
||||
index: ASSETS_INDEX_PREFIX + '*',
|
||||
size,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
must,
|
||||
},
|
||||
},
|
||||
collapse: {
|
||||
field: 'asset.ean',
|
||||
},
|
||||
sort: {
|
||||
'@timestamp': {
|
||||
order: 'desc',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
debug('Performing Get Assets Query', '\n\n', JSON.stringify(dsl, null, 2));
|
||||
|
||||
const response = await elasticsearchClient.search<Asset>(dsl);
|
||||
return response.hits.hits.map((hit) => hit._source).filter((asset): asset is Asset => !!asset);
|
||||
}
|
|
@ -1,106 +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 { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { debug } from '../../common/debug_log';
|
||||
import { Asset, AssetKind, Relation, RelationField } from '../../common/types_api';
|
||||
import { ASSETS_INDEX_PREFIX } from '../constants';
|
||||
import { ElasticsearchAccessorOptions } from '../types';
|
||||
import { isStringOrNonEmptyArray } from './utils';
|
||||
|
||||
interface GetRelatedAssetsOptions extends ElasticsearchAccessorOptions {
|
||||
size?: number;
|
||||
ean: string;
|
||||
excludeEans?: string[];
|
||||
from?: string;
|
||||
to?: string;
|
||||
relation: Relation;
|
||||
kind?: AssetKind | AssetKind[];
|
||||
}
|
||||
|
||||
export async function getIndirectlyRelatedAssets({
|
||||
elasticsearchClient,
|
||||
size = 100,
|
||||
from = 'now-24h',
|
||||
to = 'now',
|
||||
ean,
|
||||
excludeEans,
|
||||
relation,
|
||||
kind,
|
||||
}: GetRelatedAssetsOptions): Promise<Asset[]> {
|
||||
const relationField = relationToIndirectField(relation);
|
||||
const must: QueryDslQueryContainer[] = [
|
||||
{
|
||||
terms: {
|
||||
[relationField]: [ean],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (isStringOrNonEmptyArray(kind)) {
|
||||
must.push({
|
||||
terms: {
|
||||
['asset.kind']: Array.isArray(kind) ? kind : [kind],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mustNot: QueryDslQueryContainer[] =
|
||||
excludeEans && excludeEans.length
|
||||
? [
|
||||
{
|
||||
terms: {
|
||||
'asset.ean': excludeEans,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const dsl: SearchRequest = {
|
||||
index: ASSETS_INDEX_PREFIX + '*',
|
||||
size,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
must,
|
||||
must_not: mustNot,
|
||||
},
|
||||
},
|
||||
collapse: {
|
||||
field: 'asset.ean',
|
||||
},
|
||||
sort: {
|
||||
'@timestamp': {
|
||||
order: 'desc',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
debug('Performing Indirectly Related Asset Query', '\n\n', JSON.stringify(dsl, null, 2));
|
||||
|
||||
const response = await elasticsearchClient.search<Asset>(dsl);
|
||||
return response.hits.hits.map((hit) => hit._source).filter((asset): asset is Asset => !!asset);
|
||||
}
|
||||
|
||||
function relationToIndirectField(relation: Relation): RelationField {
|
||||
if (relation === 'ancestors') {
|
||||
return 'asset.children';
|
||||
} else if (relation === 'descendants') {
|
||||
return 'asset.parents';
|
||||
} else {
|
||||
return 'asset.references';
|
||||
}
|
||||
}
|
33
x-pack/plugins/asset_manager/server/lib/parse_ean.test.ts
Normal file
33
x-pack/plugins/asset_manager/server/lib/parse_ean.test.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { parseEan } from './parse_ean';
|
||||
|
||||
describe('parseEan function', () => {
|
||||
it('should parse a valid EAN and return the kind and id as separate values', () => {
|
||||
const ean = 'host:some-id-123';
|
||||
const { kind, id } = parseEan(ean);
|
||||
expect(kind).toBe('host');
|
||||
expect(id).toBe('some-id-123');
|
||||
});
|
||||
|
||||
it('should throw an error when the provided EAN does not have enough segments', () => {
|
||||
expect(() => parseEan('invalid-ean')).toThrowError('not a valid EAN');
|
||||
expect(() => parseEan('invalid-ean:')).toThrowError('not a valid EAN');
|
||||
expect(() => parseEan(':invalid-ean')).toThrowError('not a valid EAN');
|
||||
});
|
||||
|
||||
it('should throw an error when the provided EAN has too many segments', () => {
|
||||
const ean = 'host:invalid:segments';
|
||||
expect(() => parseEan(ean)).toThrowError('not a valid EAN');
|
||||
});
|
||||
|
||||
it('should throw an error when the provided EAN includes an unsupported "kind" value', () => {
|
||||
const ean = 'unsupported_kind:some-id-123';
|
||||
expect(() => parseEan(ean)).toThrowError('not a valid EAN');
|
||||
});
|
||||
});
|
|
@ -5,10 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { assetKindRT } from '../../common/types_api';
|
||||
|
||||
export function parseEan(ean: string) {
|
||||
const [kind, id, ...rest] = ean.split(':');
|
||||
|
||||
if (!kind || !id || rest.length > 0) {
|
||||
if (!assetKindRT.is(kind) || !kind || !id || rest.length > 0) {
|
||||
throw new Error(`${ean} is not a valid EAN`);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { createRouteValidationFunction } from '@kbn/io-ts-utils';
|
||||
import { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
|
||||
import {
|
||||
GetContainerAssetsQueryOptions,
|
||||
getContainerAssetsQueryOptionsRT,
|
||||
} from '../../../common/types_api';
|
||||
import { debug } from '../../../common/debug_log';
|
||||
import { SetupRouteOptions } from '../types';
|
||||
import * as routePaths from '../../../common/constants_routes';
|
||||
import { getClientsFromContext, validateStringAssetFilters } from '../utils';
|
||||
import { AssetsValidationError } from '../../lib/validators/validation_error';
|
||||
|
||||
export function containersRoutes<T extends RequestHandlerContext>({
|
||||
router,
|
||||
assetClient,
|
||||
}: SetupRouteOptions<T>) {
|
||||
const validate = createRouteValidationFunction(getContainerAssetsQueryOptionsRT);
|
||||
router.get<unknown, GetContainerAssetsQueryOptions, unknown>(
|
||||
{
|
||||
path: routePaths.GET_CONTAINERS,
|
||||
validate: {
|
||||
query: (q, res) => {
|
||||
const [invalidResponse, validatedFilters] = validateStringAssetFilters(q, res);
|
||||
if (invalidResponse) {
|
||||
return invalidResponse;
|
||||
}
|
||||
if (validatedFilters) {
|
||||
q.filters = validatedFilters;
|
||||
}
|
||||
return validate(q, res);
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
const { from = 'now-24h', to = 'now', filters } = req.query || {};
|
||||
const { elasticsearchClient, savedObjectsClient } = await getClientsFromContext(context);
|
||||
|
||||
try {
|
||||
const response = await assetClient.getContainers({
|
||||
from,
|
||||
to,
|
||||
filters, // safe due to route validation, are there better ways to do this?
|
||||
elasticsearchClient,
|
||||
savedObjectsClient,
|
||||
});
|
||||
|
||||
return res.ok({ body: response });
|
||||
} catch (error: unknown) {
|
||||
debug('Error while looking up CONTAINER asset records', error);
|
||||
|
||||
if (error instanceof AssetsValidationError) {
|
||||
return res.customError({
|
||||
statusCode: error.statusCode,
|
||||
body: {
|
||||
message: `Error while looking up container asset records - ${error.message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return res.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Error while looking up container asset records - ' + `${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -11,28 +11,39 @@ import { GetHostAssetsQueryOptions, getHostAssetsQueryOptionsRT } from '../../..
|
|||
import { debug } from '../../../common/debug_log';
|
||||
import { SetupRouteOptions } from '../types';
|
||||
import * as routePaths from '../../../common/constants_routes';
|
||||
import { getClientsFromContext } from '../utils';
|
||||
import { getClientsFromContext, validateStringAssetFilters } from '../utils';
|
||||
import { AssetsValidationError } from '../../lib/validators/validation_error';
|
||||
|
||||
export function hostsRoutes<T extends RequestHandlerContext>({
|
||||
router,
|
||||
assetClient,
|
||||
}: SetupRouteOptions<T>) {
|
||||
const validate = createRouteValidationFunction(getHostAssetsQueryOptionsRT);
|
||||
router.get<unknown, GetHostAssetsQueryOptions, unknown>(
|
||||
{
|
||||
path: routePaths.GET_HOSTS,
|
||||
validate: {
|
||||
query: createRouteValidationFunction(getHostAssetsQueryOptionsRT),
|
||||
query: (q, res) => {
|
||||
const [invalidResponse, validatedFilters] = validateStringAssetFilters(q, res);
|
||||
if (invalidResponse) {
|
||||
return invalidResponse;
|
||||
}
|
||||
if (validatedFilters) {
|
||||
q.filters = validatedFilters;
|
||||
}
|
||||
return validate(q, res);
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
const { from = 'now-24h', to = 'now' } = req.query || {};
|
||||
const { from = 'now-24h', to = 'now', filters } = req.query || {};
|
||||
const { elasticsearchClient, savedObjectsClient } = await getClientsFromContext(context);
|
||||
|
||||
try {
|
||||
const response = await assetClient.getHosts({
|
||||
from,
|
||||
to,
|
||||
filters, // safe due to route validation, are there better ways to do this?
|
||||
elasticsearchClient,
|
||||
savedObjectsClient,
|
||||
});
|
||||
|
|
|
@ -1,230 +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 { RequestHandlerContext } from '@kbn/core/server';
|
||||
import { differenceBy, intersectionBy } from 'lodash';
|
||||
import * as rt from 'io-ts';
|
||||
import {
|
||||
dateRt,
|
||||
inRangeFromStringRt,
|
||||
datemathStringRt,
|
||||
createRouteValidationFunction,
|
||||
createLiteralValueFromUndefinedRT,
|
||||
} from '@kbn/io-ts-utils';
|
||||
import { debug } from '../../../common/debug_log';
|
||||
import { assetTypeRT, assetKindRT, relationRT } from '../../../common/types_api';
|
||||
import { GET_ASSETS, GET_RELATED_ASSETS, GET_ASSETS_DIFF } from '../../../common/constants_routes';
|
||||
import { getAssets } from '../../lib/get_assets';
|
||||
import { getAllRelatedAssets } from '../../lib/get_all_related_assets';
|
||||
import { SetupRouteOptions } from '../types';
|
||||
import { getClientsFromContext } from '../utils';
|
||||
import { AssetNotFoundError } from '../../lib/errors';
|
||||
import { isValidRange } from '../../lib/utils';
|
||||
|
||||
function maybeArrayRT(t: rt.Mixed) {
|
||||
return rt.union([rt.array(t), t]);
|
||||
}
|
||||
|
||||
const sizeRT = rt.union([inRangeFromStringRt(1, 100), createLiteralValueFromUndefinedRT(10)]);
|
||||
const assetDateRT = rt.union([dateRt, datemathStringRt]);
|
||||
const getAssetsQueryOptionsRT = rt.exact(
|
||||
rt.partial({
|
||||
from: assetDateRT,
|
||||
to: assetDateRT,
|
||||
type: maybeArrayRT(assetTypeRT),
|
||||
kind: maybeArrayRT(assetKindRT),
|
||||
ean: maybeArrayRT(rt.string),
|
||||
size: sizeRT,
|
||||
})
|
||||
);
|
||||
|
||||
const getAssetsDiffQueryOptionsRT = rt.exact(
|
||||
rt.intersection([
|
||||
rt.type({
|
||||
aFrom: assetDateRT,
|
||||
aTo: assetDateRT,
|
||||
bFrom: assetDateRT,
|
||||
bTo: assetDateRT,
|
||||
}),
|
||||
rt.partial({
|
||||
type: maybeArrayRT(assetTypeRT),
|
||||
kind: maybeArrayRT(assetKindRT),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const getRelatedAssetsQueryOptionsRT = rt.exact(
|
||||
rt.intersection([
|
||||
rt.type({
|
||||
from: assetDateRT,
|
||||
ean: rt.string,
|
||||
relation: relationRT,
|
||||
size: sizeRT,
|
||||
maxDistance: rt.union([inRangeFromStringRt(1, 5), createLiteralValueFromUndefinedRT(1)]),
|
||||
}),
|
||||
rt.partial({
|
||||
to: assetDateRT,
|
||||
type: maybeArrayRT(assetTypeRT),
|
||||
kind: maybeArrayRT(assetKindRT),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
export type GetAssetsQueryOptions = rt.TypeOf<typeof getAssetsQueryOptionsRT>;
|
||||
export type GetRelatedAssetsQueryOptions = rt.TypeOf<typeof getRelatedAssetsQueryOptionsRT>;
|
||||
export type GetAssetsDiffQueryOptions = rt.TypeOf<typeof getAssetsDiffQueryOptionsRT>;
|
||||
|
||||
export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupRouteOptions<T>) {
|
||||
// GET /assets
|
||||
router.get<unknown, GetAssetsQueryOptions, unknown>(
|
||||
{
|
||||
path: GET_ASSETS,
|
||||
validate: {
|
||||
query: createRouteValidationFunction(getAssetsQueryOptionsRT),
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
const { size, ...filters } = req.query || {};
|
||||
|
||||
if (filters.type && filters.ean) {
|
||||
return res.badRequest({
|
||||
body: 'Filters "type" and "ean" are mutually exclusive but found both.',
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.kind && filters.ean) {
|
||||
return res.badRequest({
|
||||
body: 'Filters "kind" and "ean" are mutually exclusive but found both.',
|
||||
});
|
||||
}
|
||||
|
||||
const { elasticsearchClient } = await getClientsFromContext(context);
|
||||
|
||||
try {
|
||||
const results = await getAssets({ elasticsearchClient, size, filters });
|
||||
return res.ok({ body: { results } });
|
||||
} catch (error: unknown) {
|
||||
debug('error looking up asset records', error);
|
||||
return res.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Error while looking up asset records - ' + `${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET assets/related
|
||||
router.get<unknown, GetRelatedAssetsQueryOptions, unknown>(
|
||||
{
|
||||
path: GET_RELATED_ASSETS,
|
||||
validate: {
|
||||
query: createRouteValidationFunction(getRelatedAssetsQueryOptionsRT),
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
// Add references into sample data and write integration tests
|
||||
|
||||
const { from, to, ean, relation, maxDistance, size, type, kind } = req.query || {};
|
||||
const { elasticsearchClient } = await getClientsFromContext(context);
|
||||
|
||||
if (to && !isValidRange(from, to)) {
|
||||
return res.badRequest({
|
||||
body: `Time range cannot move backwards in time. "to" (${to}) is before "from" (${from}).`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return res.ok({
|
||||
body: {
|
||||
results: await getAllRelatedAssets(elasticsearchClient, {
|
||||
ean,
|
||||
from,
|
||||
to,
|
||||
type,
|
||||
kind,
|
||||
maxDistance,
|
||||
size,
|
||||
relation,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
debug('error looking up asset records', error);
|
||||
if (error instanceof AssetNotFoundError) {
|
||||
return res.customError({ statusCode: 404, body: error.message });
|
||||
}
|
||||
return res.customError({ statusCode: 500, body: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /assets/diff
|
||||
router.get<unknown, GetAssetsDiffQueryOptions, unknown>(
|
||||
{
|
||||
path: GET_ASSETS_DIFF,
|
||||
validate: {
|
||||
query: createRouteValidationFunction(getAssetsDiffQueryOptionsRT),
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
const { aFrom, aTo, bFrom, bTo, type, kind } = req.query;
|
||||
// const type = toArray<AssetType>(req.query.type);
|
||||
// const kind = toArray<AssetKind>(req.query.kind);
|
||||
|
||||
if (!isValidRange(aFrom, aTo)) {
|
||||
return res.badRequest({
|
||||
body: `Time range cannot move backwards in time. "aTo" (${aTo}) is before "aFrom" (${aFrom}).`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValidRange(bFrom, bTo)) {
|
||||
return res.badRequest({
|
||||
body: `Time range cannot move backwards in time. "bTo" (${bTo}) is before "bFrom" (${bFrom}).`,
|
||||
});
|
||||
}
|
||||
|
||||
const { elasticsearchClient } = await getClientsFromContext(context);
|
||||
|
||||
try {
|
||||
const resultsForA = await getAssets({
|
||||
elasticsearchClient,
|
||||
filters: {
|
||||
from: aFrom,
|
||||
to: aTo,
|
||||
type,
|
||||
kind,
|
||||
},
|
||||
});
|
||||
|
||||
const resultsForB = await getAssets({
|
||||
elasticsearchClient,
|
||||
filters: {
|
||||
from: bFrom,
|
||||
to: bTo,
|
||||
type,
|
||||
kind,
|
||||
},
|
||||
});
|
||||
|
||||
const onlyInA = differenceBy(resultsForA, resultsForB, 'asset.ean');
|
||||
const onlyInB = differenceBy(resultsForB, resultsForA, 'asset.ean');
|
||||
const inBoth = intersectionBy(resultsForA, resultsForB, 'asset.ean');
|
||||
|
||||
return res.ok({
|
||||
body: {
|
||||
onlyInA,
|
||||
onlyInB,
|
||||
inBoth,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
debug('error looking up asset records', error);
|
||||
return res.customError({ statusCode: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -8,18 +8,18 @@
|
|||
import { RequestHandlerContext } from '@kbn/core/server';
|
||||
import { SetupRouteOptions } from './types';
|
||||
import { pingRoute } from './ping';
|
||||
import { assetsRoutes } from './assets';
|
||||
import { sampleAssetsRoutes } from './sample_assets';
|
||||
import { hostsRoutes } from './assets/hosts';
|
||||
import { servicesRoutes } from './assets/services';
|
||||
import { containersRoutes } from './assets/containers';
|
||||
|
||||
export function setupRoutes<T extends RequestHandlerContext>({
|
||||
router,
|
||||
assetClient,
|
||||
}: SetupRouteOptions<T>) {
|
||||
pingRoute<T>({ router, assetClient });
|
||||
assetsRoutes<T>({ router, assetClient });
|
||||
sampleAssetsRoutes<T>({ router, assetClient });
|
||||
hostsRoutes<T>({ router, assetClient });
|
||||
servicesRoutes<T>({ router, assetClient });
|
||||
containersRoutes<T>({ router, assetClient });
|
||||
}
|
||||
|
|
|
@ -5,7 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { RequestHandlerContext } from '@kbn/core/server';
|
||||
import {
|
||||
RequestHandlerContext,
|
||||
RouteValidationError,
|
||||
RouteValidationResultFactory,
|
||||
} from '@kbn/core/server';
|
||||
import { AssetFilters, assetFiltersSingleKindRT } from '../../common/types_api';
|
||||
|
||||
export async function getClientsFromContext<T extends RequestHandlerContext>(context: T) {
|
||||
const coreContext = await context.core;
|
||||
|
@ -16,3 +21,26 @@ export async function getClientsFromContext<T extends RequestHandlerContext>(con
|
|||
savedObjectsClient: coreContext.savedObjects.client,
|
||||
};
|
||||
}
|
||||
|
||||
type ValidateStringAssetFiltersReturn =
|
||||
| [{ error: RouteValidationError }]
|
||||
| [null, AssetFilters | undefined];
|
||||
|
||||
export function validateStringAssetFilters(
|
||||
q: any,
|
||||
res: RouteValidationResultFactory
|
||||
): ValidateStringAssetFiltersReturn {
|
||||
if (typeof q.stringFilters === 'string') {
|
||||
try {
|
||||
const parsedFilters = JSON.parse(q.stringFilters);
|
||||
if (assetFiltersSingleKindRT.is(parsedFilters)) {
|
||||
return [null, parsedFilters];
|
||||
} else {
|
||||
return [res.badRequest(new Error(`Invalid asset filters - ${q.filters}`))];
|
||||
}
|
||||
} catch (err: any) {
|
||||
return [res.badRequest(err)];
|
||||
}
|
||||
}
|
||||
return [null, undefined];
|
||||
}
|
||||
|
|
46
x-pack/plugins/asset_manager/server/test_utils.ts
Normal file
46
x-pack/plugins/asset_manager/server/test_utils.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Helper function allows test to verify error was thrown,
|
||||
// verify error is of the right class type, and error has
|
||||
|
||||
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import { GetApmIndicesMethod } from './lib/asset_client_types';
|
||||
import { AssetsValidationError } from './lib/validators/validation_error';
|
||||
|
||||
// the expected metadata such as statusCode on it
|
||||
export function expectToThrowValidationErrorWithStatusCode(
|
||||
testFn: () => Promise<any>,
|
||||
expectedError: Partial<AssetsValidationError> = {}
|
||||
) {
|
||||
return expect(async () => {
|
||||
try {
|
||||
return await testFn();
|
||||
} catch (error: any) {
|
||||
if (error instanceof AssetsValidationError) {
|
||||
if (expectedError.statusCode) {
|
||||
expect(error.statusCode).toEqual(expectedError.statusCode);
|
||||
}
|
||||
if (expectedError.message) {
|
||||
expect(error.message).toEqual(expect.stringContaining(expectedError.message));
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}).rejects.toThrow(AssetsValidationError);
|
||||
}
|
||||
|
||||
export function createGetApmIndicesMock(): jest.Mocked<GetApmIndicesMethod> {
|
||||
return jest.fn(async (client: SavedObjectsClientContract) => ({
|
||||
transaction: 'apm-mock-transaction-indices',
|
||||
span: 'apm-mock-span-indices',
|
||||
error: 'apm-mock-error-indices',
|
||||
metric: 'apm-mock-metric-indices',
|
||||
onboarding: 'apm-mock-onboarding-indices',
|
||||
sourcemap: 'apm-mock-sourcemap-indices',
|
||||
}));
|
||||
}
|
|
@ -1,275 +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 { AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
import { createSampleAssets, deleteSampleAssets, viewSampleAssetDocs } from './helpers';
|
||||
import { ASSETS_ENDPOINT } from './constants';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('asset management', () => {
|
||||
let sampleAssetDocs: AssetWithoutTimestamp[] = [];
|
||||
before(async () => {
|
||||
sampleAssetDocs = await viewSampleAssetDocs(supertest);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteSampleAssets(supertest);
|
||||
});
|
||||
|
||||
describe('GET /assets', () => {
|
||||
it('should return the full list of assets', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: sampleAssetDocs.length })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(sampleAssetDocs.length);
|
||||
});
|
||||
|
||||
it('should only return one document per asset, even if the asset has been indexed multiple times', async () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1);
|
||||
const twoHoursAgo = new Date(now.getTime() - 1000 * 60 * 60 * 2);
|
||||
await createSampleAssets(supertest, { baseDateTime: twoHoursAgo.toISOString() });
|
||||
await createSampleAssets(supertest, { baseDateTime: oneHourAgo.toISOString() });
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: sampleAssetDocs.length, from: 'now-1d' })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(sampleAssetDocs.length);
|
||||
|
||||
// Also make sure the returned timestamp for the documents is the more recent of the two
|
||||
expect(getResponse.body.results[0]['@timestamp']).to.equal(oneHourAgo.toISOString());
|
||||
});
|
||||
|
||||
// TODO: should allow for sorting? right now the returned subset is somewhat random
|
||||
it('should allow caller to request n assets', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
expect(sampleAssetDocs.length).to.be.greaterThan(5);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: 5, from: 'now-1d' })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(5);
|
||||
});
|
||||
|
||||
it('should return assets filtered by a single type', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const singleSampleType = sampleAssetDocs[0]['asset.type'];
|
||||
const samplesForType = sampleAssetDocs.filter(
|
||||
(doc) => doc['asset.type'] === singleSampleType
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: sampleAssetDocs.length, from: 'now-1d', type: singleSampleType })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(samplesForType.length);
|
||||
});
|
||||
|
||||
it('should return assets filtered by multiple types (OR)', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
// Dynamically grab all types from the sample asset data set
|
||||
const sampleTypeSet: Set<string> = new Set();
|
||||
for (let i = 0; i < sampleAssetDocs.length; i++) {
|
||||
sampleTypeSet.add(sampleAssetDocs[i]['asset.type']!);
|
||||
}
|
||||
const sampleTypes = Array.from(sampleTypeSet);
|
||||
if (sampleTypes.length <= 2) {
|
||||
throw new Error(
|
||||
'Not enough asset type values in sample asset documents, need more than two to test filtering by multiple types'
|
||||
);
|
||||
}
|
||||
|
||||
// Pick the first two unique types from the sample data set
|
||||
const filterByTypes = sampleTypes.slice(0, 2);
|
||||
|
||||
// Track a reference to how many docs should be returned for these two types
|
||||
const samplesForFilteredTypes = sampleAssetDocs.filter((doc) =>
|
||||
filterByTypes.includes(doc['asset.type']!)
|
||||
);
|
||||
|
||||
expect(samplesForFilteredTypes.length).to.be.lessThan(sampleAssetDocs.length);
|
||||
|
||||
// Request assets for multiple types (with a size matching the number of total sample asset docs)
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: sampleAssetDocs.length, from: 'now-1d', type: filterByTypes })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(samplesForFilteredTypes.length);
|
||||
});
|
||||
|
||||
it('should reject requests that try to filter by both type and ean', async () => {
|
||||
const sampleType = sampleAssetDocs[0]['asset.type'];
|
||||
const sampleEan = sampleAssetDocs[0]['asset.ean'];
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ type: sampleType, ean: sampleEan })
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'Filters "type" and "ean" are mutually exclusive but found both.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return assets filtered by a single asset.kind value', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const singleSampleKind = sampleAssetDocs[0]['asset.kind'];
|
||||
const samplesForKind = sampleAssetDocs.filter(
|
||||
(doc) => doc['asset.kind'] === singleSampleKind
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: sampleAssetDocs.length, from: 'now-1d', kind: singleSampleKind })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(samplesForKind.length);
|
||||
});
|
||||
|
||||
it('should return assets filtered by multiple asset.kind values (OR)', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
// Dynamically grab all asset.kind values from the sample asset data set
|
||||
const sampleKindSet: Set<string> = new Set();
|
||||
for (let i = 0; i < sampleAssetDocs.length; i++) {
|
||||
sampleKindSet.add(sampleAssetDocs[i]['asset.kind']!);
|
||||
}
|
||||
const sampleKinds = Array.from(sampleKindSet);
|
||||
if (sampleKinds.length <= 2) {
|
||||
throw new Error(
|
||||
'Not enough asset kind values in sample asset documents, need more than two to test filtering by multiple kinds'
|
||||
);
|
||||
}
|
||||
|
||||
// Pick the first two unique kinds from the sample data set
|
||||
const filterByKinds = sampleKinds.slice(0, 2);
|
||||
|
||||
// Track a reference to how many docs should be returned for these two kinds
|
||||
const samplesForFilteredKinds = sampleAssetDocs.filter((doc) =>
|
||||
filterByKinds.includes(doc['asset.kind']!)
|
||||
);
|
||||
|
||||
expect(samplesForFilteredKinds.length).to.be.lessThan(sampleAssetDocs.length);
|
||||
|
||||
// Request assets for multiple types (with a size matching the number of total sample asset docs)
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: sampleAssetDocs.length, from: 'now-1d', kind: filterByKinds })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(samplesForFilteredKinds.length);
|
||||
});
|
||||
|
||||
it('should reject requests that try to filter by both kind and ean', async () => {
|
||||
const sampleKind = sampleAssetDocs[0]['asset.kind'];
|
||||
const sampleEan = sampleAssetDocs[0]['asset.ean'];
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ kind: sampleKind, ean: sampleEan })
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'Filters "kind" and "ean" are mutually exclusive but found both.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the asset matching a single ean', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const targetAsset = sampleAssetDocs[0];
|
||||
const singleSampleEan = targetAsset['asset.ean'];
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: 5, from: 'now-1d', ean: singleSampleEan })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(1);
|
||||
|
||||
const returnedAsset = getResponse.body.results[0];
|
||||
delete returnedAsset['@timestamp'];
|
||||
expect(returnedAsset).to.eql(targetAsset);
|
||||
});
|
||||
|
||||
it('should return assets matching multiple eans', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const targetAssets = [sampleAssetDocs[0], sampleAssetDocs[2], sampleAssetDocs[4]];
|
||||
const sampleEans = targetAssets.map((asset) => asset['asset.ean']);
|
||||
sampleEans.push('ean-that-does-not-exist');
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ size: 5, from: 'now-1d', ean: sampleEans })
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('results');
|
||||
expect(getResponse.body.results.length).to.equal(3);
|
||||
|
||||
delete getResponse.body.results[0]['@timestamp'];
|
||||
delete getResponse.body.results[1]['@timestamp'];
|
||||
delete getResponse.body.results[2]['@timestamp'];
|
||||
|
||||
// The order of the expected assets is fixed
|
||||
expect(getResponse.body.results).to.eql(targetAssets);
|
||||
});
|
||||
|
||||
it('should reject requests with negative size parameter', async () => {
|
||||
const getResponse = await supertest.get(ASSETS_ENDPOINT).query({ size: -1 }).expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /size: -1 does not match expected type pipe(ToNumber, InRange)\n in /size: "-1" does not match expected type pipe(undefined, BooleanFromString)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests with size parameter greater than 100', async () => {
|
||||
const getResponse = await supertest.get(ASSETS_ENDPOINT).query({ size: 101 }).expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /size: 101 does not match expected type pipe(ToNumber, InRange)\n in /size: "101" does not match expected type pipe(undefined, BooleanFromString)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests with invalid from and to parameters', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(ASSETS_ENDPOINT)
|
||||
.query({ from: 'now_1p', to: 'now_1p' })
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /from: "now_1p" does not match expected type Date\n in /from: "now_1p" does not match expected type datemath\n in /to: "now_1p" does not match expected type Date\n in /to: "now_1p" does not match expected type datemath'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,183 +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 { sortBy } from 'lodash';
|
||||
|
||||
import { AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
import { createSampleAssets, deleteSampleAssets, viewSampleAssetDocs } from './helpers';
|
||||
import { ASSETS_ENDPOINT } from './constants';
|
||||
|
||||
const DIFF_ENDPOINT = `${ASSETS_ENDPOINT}/diff`;
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('asset management', () => {
|
||||
let sampleAssetDocs: AssetWithoutTimestamp[] = [];
|
||||
before(async () => {
|
||||
sampleAssetDocs = await viewSampleAssetDocs(supertest);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteSampleAssets(supertest);
|
||||
});
|
||||
|
||||
describe('GET /assets/diff', () => {
|
||||
it('should reject requests that do not include the two time ranges to compare', async () => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
let getResponse = await supertest.get(DIFF_ENDPOINT).expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/aFrom: undefined does not match expected type Date\n in /0/aFrom: undefined does not match expected type datemath\n in /0/aTo: undefined does not match expected type Date\n in /0/aTo: undefined does not match expected type datemath\n in /0/bFrom: undefined does not match expected type Date\n in /0/bFrom: undefined does not match expected type datemath\n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
|
||||
);
|
||||
|
||||
getResponse = await supertest.get(DIFF_ENDPOINT).query({ aFrom: timestamp }).expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/aTo: undefined does not match expected type Date\n in /0/aTo: undefined does not match expected type datemath\n in /0/bFrom: undefined does not match expected type Date\n in /0/bFrom: undefined does not match expected type datemath\n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
|
||||
);
|
||||
|
||||
getResponse = await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({ aFrom: timestamp, aTo: timestamp })
|
||||
.expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/bFrom: undefined does not match expected type Date\n in /0/bFrom: undefined does not match expected type datemath\n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
|
||||
);
|
||||
|
||||
getResponse = await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({ aFrom: timestamp, aTo: timestamp, bFrom: timestamp })
|
||||
.expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/bTo: undefined does not match expected type Date\n in /0/bTo: undefined does not match expected type datemath'
|
||||
);
|
||||
|
||||
return await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({ aFrom: timestamp, aTo: timestamp, bFrom: timestamp, bTo: timestamp })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should reject requests where either time range is moving backwards in time', async () => {
|
||||
const now = new Date();
|
||||
const isoNow = now.toISOString();
|
||||
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1).toISOString();
|
||||
|
||||
let getResponse = await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({
|
||||
aFrom: isoNow,
|
||||
aTo: oneHourAgo,
|
||||
bFrom: isoNow,
|
||||
bTo: isoNow,
|
||||
})
|
||||
.expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
`Time range cannot move backwards in time. "aTo" (${oneHourAgo}) is before "aFrom" (${isoNow}).`
|
||||
);
|
||||
|
||||
getResponse = await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({
|
||||
aFrom: isoNow,
|
||||
aTo: isoNow,
|
||||
bFrom: isoNow,
|
||||
bTo: oneHourAgo,
|
||||
})
|
||||
.expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
`Time range cannot move backwards in time. "bTo" (${oneHourAgo}) is before "bFrom" (${isoNow}).`
|
||||
);
|
||||
|
||||
return await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({
|
||||
aFrom: oneHourAgo,
|
||||
aTo: isoNow,
|
||||
bFrom: oneHourAgo,
|
||||
bTo: isoNow,
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return the difference in assets present between two time ranges', async () => {
|
||||
const onlyInA = sampleAssetDocs.slice(0, 2); // first two sample assets
|
||||
const onlyInB = sampleAssetDocs.slice(sampleAssetDocs.length - 2); // last two sample assets
|
||||
const inBoth = sampleAssetDocs.slice(2, sampleAssetDocs.length - 2); // everything between first 2, last 2
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1);
|
||||
const twoHoursAgo = new Date(now.getTime() - 1000 * 60 * 60 * 2);
|
||||
|
||||
// Set 1: Two hours ago, excludes inBoth + onlyInB (leaves just onlyInA)
|
||||
await createSampleAssets(supertest, {
|
||||
baseDateTime: twoHoursAgo.toISOString(),
|
||||
excludeEans: inBoth.concat(onlyInB).map((asset) => asset['asset.ean']),
|
||||
});
|
||||
|
||||
// Set 2: One hour ago, excludes onlyInA + onlyInB (leaves just inBoth)
|
||||
await createSampleAssets(supertest, {
|
||||
baseDateTime: oneHourAgo.toISOString(),
|
||||
excludeEans: onlyInA.concat(onlyInB).map((asset) => asset['asset.ean']),
|
||||
});
|
||||
|
||||
// Set 3: Right now, excludes inBoth + onlyInA (leaves just onlyInB)
|
||||
await createSampleAssets(supertest, {
|
||||
excludeEans: inBoth.concat(onlyInA).map((asset) => asset['asset.ean']),
|
||||
});
|
||||
|
||||
const twoHoursAndTenMinuesAgo = new Date(now.getTime() - 1000 * 60 * 130 * 1);
|
||||
const fiftyMinuesAgo = new Date(now.getTime() - 1000 * 60 * 50 * 1);
|
||||
const seventyMinuesAgo = new Date(now.getTime() - 1000 * 60 * 70 * 1);
|
||||
const tenMinutesAfterNow = new Date(now.getTime() + 1000 * 60 * 10);
|
||||
|
||||
// Range A: 2h10m ago - 50m ago (Sets 1 and 2)
|
||||
// Range B: 70m ago - 10m after now (Sets 2 and 3)
|
||||
const getResponse = await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({
|
||||
aFrom: twoHoursAndTenMinuesAgo,
|
||||
aTo: fiftyMinuesAgo,
|
||||
bFrom: seventyMinuesAgo,
|
||||
bTo: tenMinutesAfterNow,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(getResponse.body).to.have.property('onlyInA');
|
||||
expect(getResponse.body).to.have.property('onlyInB');
|
||||
expect(getResponse.body).to.have.property('inBoth');
|
||||
|
||||
getResponse.body.onlyInA.forEach((asset: any) => {
|
||||
delete asset['@timestamp'];
|
||||
});
|
||||
getResponse.body.onlyInB.forEach((asset: any) => {
|
||||
delete asset['@timestamp'];
|
||||
});
|
||||
getResponse.body.inBoth.forEach((asset: any) => {
|
||||
delete asset['@timestamp'];
|
||||
});
|
||||
|
||||
const sortByEan = (assets: any[]) => sortBy(assets, (asset) => asset['asset.ean']);
|
||||
expect(sortByEan(getResponse.body.onlyInA)).to.eql(sortByEan(onlyInA));
|
||||
expect(sortByEan(getResponse.body.onlyInB)).to.eql(sortByEan(onlyInB));
|
||||
expect(sortByEan(getResponse.body.inBoth)).to.eql(sortByEan(inBoth));
|
||||
});
|
||||
|
||||
it('should reject requests with invalid datemath', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(DIFF_ENDPOINT)
|
||||
.query({ aFrom: 'now_1p', aTo: 'now_1p', bFrom: 'now_1p', bTo: 'now_1p' })
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/aFrom: "now_1p" does not match expected type Date\n in /0/aFrom: "now_1p" does not match expected type datemath\n in /0/aTo: "now_1p" does not match expected type Date\n in /0/aTo: "now_1p" does not match expected type datemath\n in /0/bFrom: "now_1p" does not match expected type Date\n in /0/bFrom: "now_1p" does not match expected type datemath\n in /0/bTo: "now_1p" does not match expected type Date\n in /0/bTo: "now_1p" does not match expected type datemath'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,375 +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 { pick } from 'lodash';
|
||||
|
||||
import { Asset, AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
import { createSampleAssets, deleteSampleAssets, viewSampleAssetDocs } from './helpers';
|
||||
import { ASSETS_ENDPOINT } from './constants';
|
||||
|
||||
const RELATED_ASSETS_ENDPOINT = `${ASSETS_ENDPOINT}/related`;
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('asset management', () => {
|
||||
let sampleAssetDocs: AssetWithoutTimestamp[] = [];
|
||||
let relatedAssetBaseQuery = {};
|
||||
|
||||
before(async () => {
|
||||
sampleAssetDocs = await viewSampleAssetDocs(supertest);
|
||||
relatedAssetBaseQuery = {
|
||||
size: sampleAssetDocs.length,
|
||||
from: 'now-1d',
|
||||
maxDistance: 5,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteSampleAssets(supertest);
|
||||
});
|
||||
|
||||
describe('GET /assets/related', () => {
|
||||
describe('basic validation of all relations', () => {
|
||||
const relations = [
|
||||
{
|
||||
name: 'ancestors',
|
||||
ean: 'host:node-101',
|
||||
expectedRelatedEans: ['cluster:cluster-001'],
|
||||
},
|
||||
{
|
||||
name: 'descendants',
|
||||
ean: 'cluster:cluster-001',
|
||||
expectedRelatedEans: ['host:node-101', 'host:node-102', 'host:node-103'],
|
||||
},
|
||||
{
|
||||
name: 'references',
|
||||
ean: 'pod:pod-200xrg1',
|
||||
expectedRelatedEans: ['cluster:cluster-001'],
|
||||
},
|
||||
];
|
||||
|
||||
relations.forEach((relation) => {
|
||||
it(`should return the ${relation.name} assets`, async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
relation: relation.name,
|
||||
size: sampleAssetDocs.length,
|
||||
from: 'now-1d',
|
||||
ean: relation.ean,
|
||||
maxDistance: 1,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const relatedEans = getResponse.body.results[relation.name].map(
|
||||
(asset: Asset) => asset['asset.ean']
|
||||
);
|
||||
expect(relatedEans).to.eql(relation.expectedRelatedEans);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('response validation', () => {
|
||||
it('should return 404 if primary asset not found', async () => {
|
||||
await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: 'non-existing-ean',
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return the primary asset', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const sampleCluster = sampleAssetDocs.find(
|
||||
(asset) => asset['asset.id'] === 'cluster-002'
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: sampleCluster!['asset.ean'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
delete results.primary['@timestamp'];
|
||||
expect(results.primary).to.eql(sampleCluster);
|
||||
});
|
||||
|
||||
it('should return empty assets when none matching', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const sampleCluster = sampleAssetDocs.find(
|
||||
(asset) => asset['asset.id'] === 'cluster-002'
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: sampleCluster!['asset.ean'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
|
||||
expect(results).to.have.property('descendants');
|
||||
expect(results.descendants).to.have.length(0);
|
||||
});
|
||||
|
||||
it('breaks circular dependency', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
// pods reference a node that references the pods
|
||||
const sampleNode = sampleAssetDocs.find((asset) => asset['asset.id'] === 'pod-203ugg5');
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'references',
|
||||
ean: sampleNode!['asset.ean'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
expect(
|
||||
results.references.map((asset: Asset) => pick(asset, ['asset.ean', 'distance']))
|
||||
).to.eql([
|
||||
{ 'asset.ean': 'host:node-203', distance: 1 },
|
||||
{ 'asset.ean': 'pod:pod-203ugg9', distance: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reject requests with negative size parameter', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: 'non-existing-ean',
|
||||
size: -1,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/size: -1 does not match expected type pipe(ToNumber, InRange)\n in /0/size: "-1" does not match expected type pipe(undefined, BooleanFromString)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests with size parameter greater than 100', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: 'non-existing-ean',
|
||||
size: 101,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/size: 101 does not match expected type pipe(ToNumber, InRange)\n in /0/size: "101" does not match expected type pipe(undefined, BooleanFromString)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests with negative maxDistance parameter', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: 'non-existing-ean',
|
||||
maxDistance: -1,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/maxDistance: -1 does not match expected type pipe(ToNumber, InRange)\n in /0/maxDistance: "-1" does not match expected type pipe(undefined, BooleanFromString)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests with size parameter maxDistance is greater than 5', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: 'non-existing-ean',
|
||||
maxDistance: 6,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/maxDistance: 6 does not match expected type pipe(ToNumber, InRange)\n in /0/maxDistance: "6" does not match expected type pipe(undefined, BooleanFromString)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests with invalid from and to parameters', async () => {
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
ean: 'non-existing-ean',
|
||||
from: 'now_1p',
|
||||
to: 'now_1p',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(getResponse.body.message).to.equal(
|
||||
'[request query]: Failed to validate: \n in /0/from: "now_1p" does not match expected type Date\n in /0/from: "now_1p" does not match expected type datemath\n in /1/to: "now_1p" does not match expected type Date\n in /1/to: "now_1p" does not match expected type datemath'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject requests where time range is moving backwards in time', async () => {
|
||||
const now = new Date();
|
||||
const isoNow = now.toISOString();
|
||||
const oneHourAgo = new Date(now.getTime() - 1000 * 60 * 60 * 1).toISOString();
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
...relatedAssetBaseQuery,
|
||||
relation: 'descendants',
|
||||
from: isoNow,
|
||||
to: oneHourAgo,
|
||||
maxDistance: 1,
|
||||
ean: 'non-existing-ean',
|
||||
})
|
||||
.expect(400);
|
||||
expect(getResponse.body.message).to.equal(
|
||||
`Time range cannot move backwards in time. "to" (${oneHourAgo}) is before "from" (${isoNow}).`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no asset.type filters', () => {
|
||||
it('should return all descendants of a provided ean at maxDistance 1', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const sampleCluster = sampleAssetDocs.find(
|
||||
(asset) => asset['asset.id'] === 'cluster-001'
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
relation: 'descendants',
|
||||
size: sampleAssetDocs.length,
|
||||
from: 'now-1d',
|
||||
ean: sampleCluster!['asset.ean'],
|
||||
maxDistance: 1,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
expect(results.descendants).to.have.length(3);
|
||||
expect(results.descendants.every((asset: { distance: number }) => asset.distance === 1));
|
||||
});
|
||||
|
||||
it('should return all descendants of a provided ean at maxDistance 2', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const sampleCluster = sampleAssetDocs.find(
|
||||
(asset) => asset['asset.id'] === 'cluster-001'
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
relation: 'descendants',
|
||||
size: sampleAssetDocs.length,
|
||||
from: 'now-1d',
|
||||
ean: sampleCluster!['asset.ean'],
|
||||
maxDistance: 2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
expect(results.descendants).to.have.length(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with asset.kind filters', () => {
|
||||
it('should filter by the provided asset kind', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const sampleCluster = sampleAssetDocs.find(
|
||||
(asset) => asset['asset.id'] === 'cluster-001'
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
relation: 'descendants',
|
||||
size: sampleAssetDocs.length,
|
||||
from: 'now-1d',
|
||||
ean: sampleCluster!['asset.ean'],
|
||||
maxDistance: 1,
|
||||
kind: ['pod'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
expect(results.descendants).to.have.length(0);
|
||||
});
|
||||
|
||||
it('should return all descendants of a provided ean at maxDistance 2', async () => {
|
||||
await createSampleAssets(supertest);
|
||||
|
||||
const sampleCluster = sampleAssetDocs.find(
|
||||
(asset) => asset['asset.id'] === 'cluster-001'
|
||||
);
|
||||
|
||||
const getResponse = await supertest
|
||||
.get(RELATED_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
relation: 'descendants',
|
||||
size: sampleAssetDocs.length,
|
||||
from: 'now-1d',
|
||||
ean: sampleCluster!['asset.ean'],
|
||||
maxDistance: 2,
|
||||
kind: ['pod'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
body: { results },
|
||||
} = getResponse;
|
||||
expect(results.descendants).to.have.length(9);
|
||||
expect(results.descendants.every((asset: { distance: number }) => asset.distance === 2));
|
||||
expect(results.descendants.every((asset: Asset) => asset['asset.kind'] === 'pod'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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 { timerange, infra } from '@kbn/apm-synthtrace-client';
|
||||
import expect from '@kbn/expect';
|
||||
import { Asset } from '@kbn/assetManager-plugin/common/types_api';
|
||||
import * as routePaths from '@kbn/assetManager-plugin/common/constants_routes';
|
||||
import { FtrProviderContext } from '../types';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const synthtrace = getService('infraSynthtraceEsClient');
|
||||
|
||||
describe(`GET ${routePaths.GET_CONTAINERS}`, () => {
|
||||
const from = new Date(Date.now() - 1000 * 60 * 2).toISOString();
|
||||
const to = new Date().toISOString();
|
||||
|
||||
beforeEach(async () => {
|
||||
await synthtrace.clean();
|
||||
});
|
||||
|
||||
it('should return container assets', async () => {
|
||||
await synthtrace.index(generateContainersData({ from, to, count: 5 }));
|
||||
|
||||
const response = await supertest
|
||||
.get(routePaths.GET_CONTAINERS)
|
||||
.query({
|
||||
from,
|
||||
to,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).to.have.property('containers');
|
||||
expect(response.body.containers.length).to.equal(5);
|
||||
});
|
||||
|
||||
it('should return a specific container asset by EAN', async () => {
|
||||
await synthtrace.index(generateContainersData({ from, to, count: 5 }));
|
||||
const testEan = 'container:container-id-1';
|
||||
|
||||
const response = await supertest
|
||||
.get(routePaths.GET_CONTAINERS)
|
||||
.query({
|
||||
from,
|
||||
to,
|
||||
stringFilters: JSON.stringify({ ean: testEan }),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).to.have.property('containers');
|
||||
expect(response.body.containers.length).to.equal(1);
|
||||
expect(response.body.containers[0]['asset.ean']).to.equal(testEan);
|
||||
});
|
||||
|
||||
it('should return a filtered list of container assets by ID wildcard pattern', async () => {
|
||||
await synthtrace.index(generateContainersData({ from, to, count: 15 }));
|
||||
const testIdPattern = '*id-1*';
|
||||
|
||||
const response = await supertest
|
||||
.get(routePaths.GET_CONTAINERS)
|
||||
.query({
|
||||
from,
|
||||
to,
|
||||
stringFilters: JSON.stringify({ id: testIdPattern }),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).to.have.property('containers');
|
||||
expect(response.body.containers.length).to.equal(6);
|
||||
|
||||
const ids = response.body.containers.map((result: Asset) => result['asset.id'][0]);
|
||||
|
||||
expect(ids).to.eql([
|
||||
'container-id-1',
|
||||
'container-id-10',
|
||||
'container-id-11',
|
||||
'container-id-12',
|
||||
'container-id-13',
|
||||
'container-id-14',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function generateContainersData({
|
||||
from,
|
||||
to,
|
||||
count = 1,
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
count: number;
|
||||
}) {
|
||||
const range = timerange(from, to);
|
||||
|
||||
const containers = Array(count)
|
||||
.fill(0)
|
||||
.map((_, idx) =>
|
||||
infra.container(`container-id-${idx}`, `container-uid-${idx + 1000}`, `node-name-${idx}`)
|
||||
);
|
||||
|
||||
return range
|
||||
.interval('1m')
|
||||
.rate(1)
|
||||
.generator((timestamp) =>
|
||||
containers.map((container) => container.metrics().timestamp(timestamp))
|
||||
);
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { timerange, infra } from '@kbn/apm-synthtrace-client';
|
||||
import expect from '@kbn/expect';
|
||||
import { Asset } from '@kbn/assetManager-plugin/common/types_api';
|
||||
import { ASSETS_ENDPOINT } from './constants';
|
||||
import { FtrProviderContext } from '../types';
|
||||
|
||||
|
@ -17,13 +18,14 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
const synthtrace = getService('infraSynthtraceEsClient');
|
||||
|
||||
describe('GET /assets/hosts', () => {
|
||||
const from = new Date(Date.now() - 1000 * 60 * 2).toISOString();
|
||||
const to = new Date().toISOString();
|
||||
|
||||
beforeEach(async () => {
|
||||
await synthtrace.clean();
|
||||
});
|
||||
|
||||
it('should return hosts', async () => {
|
||||
const from = new Date(Date.now() - 1000 * 60 * 2).toISOString();
|
||||
const to = new Date().toISOString();
|
||||
await synthtrace.index(generateHostsData({ from, to, count: 5 }));
|
||||
|
||||
const response = await supertest
|
||||
|
@ -37,6 +39,52 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
expect(response.body).to.have.property('hosts');
|
||||
expect(response.body.hosts.length).to.equal(5);
|
||||
});
|
||||
|
||||
it('should return a specific host by EAN', async () => {
|
||||
await synthtrace.index(generateHostsData({ from, to, count: 5 }));
|
||||
const testEan = 'host:my-host-1';
|
||||
|
||||
const response = await supertest
|
||||
.get(HOSTS_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
from,
|
||||
to,
|
||||
stringFilters: JSON.stringify({ ean: testEan }),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).to.have.property('hosts');
|
||||
expect(response.body.hosts.length).to.equal(1);
|
||||
expect(response.body.hosts[0]['asset.ean']).to.equal(testEan);
|
||||
});
|
||||
|
||||
it('should return a filtered list of hosts by ID wildcard pattern', async () => {
|
||||
await synthtrace.index(generateHostsData({ from, to, count: 15 }));
|
||||
const testIdPattern = '*host-1*';
|
||||
|
||||
const response = await supertest
|
||||
.get(HOSTS_ASSETS_ENDPOINT)
|
||||
.query({
|
||||
from,
|
||||
to,
|
||||
stringFilters: JSON.stringify({ id: testIdPattern }),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).to.have.property('hosts');
|
||||
expect(response.body.hosts.length).to.equal(6);
|
||||
|
||||
const ids = response.body.hosts.map((result: Asset) => result['asset.id'][0]);
|
||||
|
||||
expect(ids).to.eql([
|
||||
'my-host-1',
|
||||
'my-host-10',
|
||||
'my-host-11',
|
||||
'my-host-12',
|
||||
'my-host-13',
|
||||
'my-host-14',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -9,11 +9,9 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
|
|||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Asset Manager API Endpoints', () => {
|
||||
loadTestFile(require.resolve('./basics'));
|
||||
loadTestFile(require.resolve('./containers'));
|
||||
loadTestFile(require.resolve('./hosts'));
|
||||
loadTestFile(require.resolve('./services'));
|
||||
loadTestFile(require.resolve('./sample_assets'));
|
||||
loadTestFile(require.resolve('./assets'));
|
||||
loadTestFile(require.resolve('./assets_diff'));
|
||||
loadTestFile(require.resolve('./assets_related'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue