[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:
Jason Rhodes 2023-11-03 09:38:10 -04:00 committed by GitHub
parent 9f3e1f9a01
commit 74509cdc33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1390 additions and 2209 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' } },
],
},
},

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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