[asset_manager] Add /related endpoint (#154541)

Exposes a `/related` endpoint that can be called to retrieve any
supported asset relationship (`ancestors|descendants|references`).

Note that this is a first draft to get a functioning endpoint available.
Further optimizations (performances, typing..) will be implemented as
follow ups or when we get feedback from actual use cases.

Follow ups:
- We're currently doing two sequential requests to retrieve the related
assets, one for the _directly_ referenced of the primary (in
`assets.children|parents..`) and another for _indirectly_ referenced
that lists the primary in `asset.children|parents...`. These two
predicates can be packed in a single query
- The size is difficult to enforce at the query level if a `type` filter
is provided. If we're looking at a `depth > 1` and we apply the size
limit to the queries at `depth < maxDistance` we may miss edges to the
requested type. Similarly the `type` filter can't be enforced at `depth
< maxDistance`

To do:

- [x] Add filtering by type
- [ ] ~Limit by size~
- [x] Add sample assets which use references
- [x] Add integration tests that validate each type of relation query
- [x] Add documentation and sample requests/responses for each relation
type
- [x] Handle circular references. In what situations can that happens,
references ?

Closes #153471
Closes #153473
Closes #153482

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: klacabane <kevin.lacabane@elastic.co>
Co-authored-by: Kevin Lacabane <klacabane@gmail.com>
Co-authored-by: Carlos Crespo <crespocarlos@users.noreply.github.com>
This commit is contained in:
Milton Hultgren 2023-04-28 13:28:22 +02:00 committed by GitHub
parent dab1409fef
commit 9b2562e5db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1532 additions and 24 deletions

View file

@ -5,8 +5,16 @@
* 2.0.
*/
import { schema } from '@kbn/config-schema';
export const assetTypeRT = schema.oneOf([
schema.literal('k8s.pod'),
schema.literal('k8s.cluster'),
schema.literal('k8s.node'),
]);
export type AssetType = typeof assetTypeRT.type;
export type AssetKind = 'unknown' | 'node';
export type AssetType = 'k8s.pod' | 'k8s.cluster' | 'k8s.node';
export type AssetStatus =
| 'CREATING'
| 'ACTIVE'
@ -52,6 +60,7 @@ export interface Asset extends ECSDocument {
'asset.status'?: AssetStatus;
'asset.parents'?: string | string[];
'asset.children'?: string | string[];
'asset.references'?: string | string[];
'asset.namespace'?: string;
}
@ -120,3 +129,15 @@ export interface AssetFilters {
from?: string;
to?: string;
}
export const relationRT = schema.oneOf([
schema.literal('ancestors'),
schema.literal('descendants'),
schema.literal('references'),
]);
export type Relation = typeof relationRT.type;
export type RelationField = keyof Pick<
Asset,
'asset.children' | 'asset.parents' | 'asset.references'
>;

View file

@ -78,7 +78,7 @@ _Notes:_
<summary>Invalid request with type and ean both specified</summary>
```curl
GET /assets?from=2023-03-25T17:44:44.000Z&type=k8s.pod&ean=k8s.pod:123
GET kbn:/api/asset-manager/assets?from=2023-03-25T17:44:44.000Z&type=k8s.pod&ean=k8s.pod:123
{
"statusCode": 400,
@ -94,7 +94,7 @@ GET /assets?from=2023-03-25T17:44:44.000Z&type=k8s.pod&ean=k8s.pod:123
<summary>All assets JSON response</summary>
```curl
GET /assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z
GET kbn:/api/asset-manager/assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z
{
"results": [
@ -273,7 +273,7 @@ GET /assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z
<summary>Assets by type JSON response</summary>
```curl
GET /assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z&type=k8s.pod
GET kbn:/api/asset-manager/assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z&type=k8s.pod
{
"results": [
@ -378,7 +378,7 @@ GET /assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z&type=k8s.p
<summary>Assets by EAN JSON response</summary>
```curl
GET /assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z&ean=k8s.node:node-101&ean=k8s.pod:pod-6r1z
GET kbn:/api/asset-manager/assets?from=2023-03-25T17:44:44.000Z&to=2023-03-25T18:44:44.000Z&ean=k8s.node:node-101&ean=k8s.pod:pod-6r1z
{
"results": [
@ -427,7 +427,7 @@ Returns assets found in the two time ranges, split by what occurs in only either
<summary>Request where comparison range is missing assets that are found in the baseline range</summary>
```curl
GET /assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T02:00:00.000Z
GET kbn:/api/asset-manager/assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T02:00:00.000Z
{
"onlyInA": [
@ -589,7 +589,7 @@ GET /assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFr
<summary>Request where baseline range is missing assets that are found in the comparison range</summary>
```curl
GET /assets/diff?aFrom=2022-02-07T01:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T03:00:00.000Z
GET kbn:/api/asset-manager/assets/diff?aFrom=2022-02-07T01:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T03:00:00.000Z
{
"onlyInA": [],
@ -751,7 +751,7 @@ GET /assets/diff?aFrom=2022-02-07T01:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFr
<summary>Request where each range is missing assets found in the other range</summary>
```curl
GET /assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T03:00:00.000Z
GET kbn:/api/asset-manager/assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T03:00:00.000Z
{
"onlyInA": [
@ -934,7 +934,7 @@ GET /assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFr
<summary>Request where each range is missing assets found in the other range, but restricted by type</summary>
```curl
GET /assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T03:00:00.000Z&type=k8s.pod
GET kbn:/api/asset-manager/assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFrom=2022-02-07T01:00:00.000Z&bTo=2022-02-07T03:00:00.000Z&type=k8s.pod
{
"onlyInA": [
@ -1038,6 +1038,326 @@ GET /assets/diff?aFrom=2022-02-07T00:00:00.000Z&aTo=2022-02-07T01:30:00.000Z&bFr
</details>
#### GET /assets/related
Returns assets related to the provided ean. The relation can be one of ancestors, descendants or references.
#### Request
| Option | Type | Required? | Default | Description |
| :--- | :--- | :--- | :--- | :--- |
| relation | string | Yes | N/A | The type of related assets we're looking for. One of (ancestors|descendants|references) |
| from | RangeDate | Yes | N/A | Starting point for date range to search for assets within |
| to | RangeDate | No | "now" | End point for date range to search for assets |
| ean | AssetEan | Yes | N/A | Single Elastic Asset Name representing the asset for which the related assets are being requested |
| type | AssetType[] | No | all | Restrict results to one or more asset.type value |
| maxDistance | number (1-5) | No | 1 | Maximum number of "hops" to search away from specified asset |
#### Responses
<details>
<summary>Request looking for ancestors</summary>
```curl
GET kbn:/api/asset-manager/assets/related?ean=k8s.node:node-101&relation=ancestors&maxDistance=1&from=2023-04-18T13:10:13.111Z&to=2023-04-18T15:10:13.111Z
{
"results": {
"primary": {
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.node",
"asset.id": "node-101",
"asset.name": "k8s-node-101-aws",
"asset.ean": "k8s.node:node-101",
"asset.parents": [
"k8s.cluster:cluster-001"
],
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks"
},
"ancestors": [
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.cluster",
"asset.id": "cluster-001",
"asset.name": "Cluster 001 (AWS EKS)",
"asset.ean": "k8s.cluster:cluster-001",
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks",
"distance": 1
}
]
}
}
```
</details>
<details>
<summary>Request looking for descendants</summary>
```curl
GET kbn:/api/asset-manager/assets/related?ean=k8s.cluster:cluster-001&relation=descendants&maxDistance=1&from=2023-04-18T13:10:13.111Z&to=2023-04-18T15:10:13.111Z
{
"results": {
"primary": {
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.cluster",
"asset.id": "cluster-001",
"asset.name": "Cluster 001 (AWS EKS)",
"asset.ean": "k8s.cluster:cluster-001",
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks"
},
"descendants": [
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.node",
"asset.id": "node-101",
"asset.name": "k8s-node-101-aws",
"asset.ean": "k8s.node:node-101",
"asset.parents": [
"k8s.cluster:cluster-001"
],
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks",
"distance": 1
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.node",
"asset.id": "node-102",
"asset.name": "k8s-node-102-aws",
"asset.ean": "k8s.node:node-102",
"asset.parents": [
"k8s.cluster:cluster-001"
],
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks",
"distance": 1
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.node",
"asset.id": "node-103",
"asset.name": "k8s-node-103-aws",
"asset.ean": "k8s.node:node-103",
"asset.parents": [
"k8s.cluster:cluster-001"
],
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks",
"distance": 1
}
]
}
}
```
</details>
<details>
<summary>Request looking for references</summary>
```curl
GET kbn:/api/asset-manager/assets/related?ean=k8s.pod:pod-200xrg1&relation=references&maxDistance=1&from=2023-04-18T13:10:13.111Z&to=2023-04-18T15:10:13.111Z
{
"results": {
"primary": {
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200xrg1",
"asset.name": "k8s-pod-200xrg1-aws",
"asset.ean": "k8s.pod:pod-200xrg1",
"asset.parents": [
"k8s.node:node-101"
],
"asset.references": [
"k8s.cluster:cluster-001"
]
},
"references": [
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.cluster",
"asset.id": "cluster-001",
"asset.name": "Cluster 001 (AWS EKS)",
"asset.ean": "k8s.cluster:cluster-001",
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks",
"distance": 1
}
]
}
}
```
</details>
<details>
<summary>Request with type filter and non-default maxDistance</summary>
```curl
GET kbn:/api/asset-manager/assets/related?ean=k8s.cluster:cluster-001&relation=descendants&maxDistance=2&from=2023-04-18T13:10:13.111Z&to=2023-04-18T15:10:13.111Z&type=k8s.pod
{
"results": {
"primary": {
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.cluster",
"asset.id": "cluster-001",
"asset.name": "Cluster 001 (AWS EKS)",
"asset.ean": "k8s.cluster:cluster-001",
"orchestrator.type": "kubernetes",
"orchestrator.cluster.name": "Cluster 001 (AWS EKS)",
"orchestrator.cluster.id": "cluster-001",
"cloud.provider": "aws",
"cloud.region": "us-east-1",
"cloud.service.name": "eks"
},
"descendants": [
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200xrg1",
"asset.name": "k8s-pod-200xrg1-aws",
"asset.ean": "k8s.pod:pod-200xrg1",
"asset.parents": [
"k8s.node:node-101"
],
"asset.references": [
"k8s.cluster:cluster-001"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200dfp2",
"asset.name": "k8s-pod-200dfp2-aws",
"asset.ean": "k8s.pod:pod-200dfp2",
"asset.parents": [
"k8s.node:node-101"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200wwc3",
"asset.name": "k8s-pod-200wwc3-aws",
"asset.ean": "k8s.pod:pod-200wwc3",
"asset.parents": [
"k8s.node:node-101"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200naq4",
"asset.name": "k8s-pod-200naq4-aws",
"asset.ean": "k8s.pod:pod-200naq4",
"asset.parents": [
"k8s.node:node-102"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200ohr5",
"asset.name": "k8s-pod-200ohr5-aws",
"asset.ean": "k8s.pod:pod-200ohr5",
"asset.parents": [
"k8s.node:node-102"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200yyx6",
"asset.name": "k8s-pod-200yyx6-aws",
"asset.ean": "k8s.pod:pod-200yyx6",
"asset.parents": [
"k8s.node:node-103"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200psd7",
"asset.name": "k8s-pod-200psd7-aws",
"asset.ean": "k8s.pod:pod-200psd7",
"asset.parents": [
"k8s.node:node-103"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200wmc8",
"asset.name": "k8s-pod-200wmc8-aws",
"asset.ean": "k8s.pod:pod-200wmc8",
"asset.parents": [
"k8s.node:node-103"
],
"distance": 2
},
{
"@timestamp": "2023-04-18T14:10:13.111Z",
"asset.type": "k8s.pod",
"asset.id": "pod-200ugg9",
"asset.name": "k8s-pod-200ugg9-aws",
"asset.ean": "k8s.pod:pod-200ugg9",
"asset.parents": [
"k8s.node:node-103"
],
"distance": 2
}
]
}
}
```
</details>
#### GET /assets/sample
Returns the list of pre-defined sample asset documents that would be indexed

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/x-pack/plugins/asset_manager'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/asset_manager',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/asset_manager/{common,public,server}/**/*.{js,ts,tsx}',
],
};

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export class AssetNotFoundError extends Error {
constructor(ean: string) {
super(`Asset with ean (${ean}) not found in the provided time range`);
Object.setPrototypeOf(this, new.target.prototype);
this.name = 'AssetNotFoundError';
}
}

View file

@ -0,0 +1,529 @@
/*
* 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_related_assets', () => ({ getRelatedAssets: 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 { getRelatedAssets } from './get_related_assets'; // Mocked
import { getAllRelatedAssets } from './get_all_related_assets';
const esClientMock = elasticsearchClientMock.createScopedClusterClient().asCurrentUser;
describe('getAllRelatedAssets', () => {
beforeEach(() => {
(getAssets as jest.Mock).mockReset();
(getRelatedAssets 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.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'));
(getRelatedAssets 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.id': uuid(),
'asset.parents': [],
};
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAssetWithoutParents]);
// Distance 1
(getRelatedAssets as jest.Mock).mockResolvedValueOnce([]);
// Ensure maxDistance is respected
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
(getRelatedAssets 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': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
};
const primaryAssetWithDirectParent: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [parentAsset['asset.ean']],
};
// Primary
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAssetWithDirectParent]);
// Distance 1
(getAssets as jest.Mock).mockResolvedValueOnce([parentAsset]);
(getRelatedAssets as jest.Mock).mockResolvedValueOnce([]);
// Ensure maxDistance is respected
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
(getRelatedAssets 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.id': uuid(),
'asset.parents': [],
};
const parentAsset: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.children': [primaryAssetWithIndirectParent['asset.ean']],
};
// Primary
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAssetWithIndirectParent]);
// Distance 1
(getAssets as jest.Mock).mockResolvedValueOnce([]);
(getRelatedAssets as jest.Mock).mockResolvedValueOnce([parentAsset]);
// Ensure maxDistance is respected
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
(getRelatedAssets 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.id': uuid(),
'asset.children': [],
};
const primaryAsset: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [directlyReferencedParent['asset.ean']],
};
const indirectlyReferencedParent: AssetWithoutTimestamp = {
'asset.ean': 'indirectly-referenced-parent-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.children': [primaryAsset['asset.ean']],
};
// Primary
(getAssets as jest.Mock).mockResolvedValueOnce([primaryAsset]);
// Distance 1
(getAssets as jest.Mock).mockResolvedValueOnce([directlyReferencedParent]);
(getRelatedAssets as jest.Mock).mockResolvedValueOnce([indirectlyReferencedParent]);
// Ensure maxDistance is respected
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
(getRelatedAssets 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.id': uuid(),
};
const primaryAsset: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.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
(getRelatedAssets as jest.Mock).mockResolvedValueOnce([]);
// Ensure maxDistance is respected
(getAssets as jest.Mock).mockRejectedValueOnce(new Error('Should respect maxDistance'));
(getRelatedAssets 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.id': uuid(),
};
const distance5Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-5-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance6Parent['asset.ean']],
};
const distance4Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-4-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance5Parent['asset.ean']],
};
const distance3Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-3-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance4Parent['asset.ean']],
};
const distance2Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-2-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance3Parent['asset.ean']],
};
const distance1Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-1-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance2Parent['asset.ean']],
};
const primaryAsset: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance1Parent['asset.ean']],
};
// Only using directly referenced parents
(getRelatedAssets 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.id': uuid(),
};
const distance2Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-2-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance3Parent['asset.ean']],
};
const distance1Parent: AssetWithoutTimestamp = {
'asset.ean': 'parent-1-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance2Parent['asset.ean']],
};
const primaryAsset: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance1Parent['asset.ean']],
};
// Only using directly referenced parents
(getRelatedAssets 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.id': uuid(),
};
const distance2ParentB: AssetWithoutTimestamp = {
'asset.ean': 'parent-2-ean-b',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
};
const distance2ParentC: AssetWithoutTimestamp = {
'asset.ean': 'parent-2-ean-c',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
};
const distance2ParentD: AssetWithoutTimestamp = {
'asset.ean': 'parent-2-ean-d',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
};
const distance1ParentA: AssetWithoutTimestamp = {
'asset.ean': 'parent-1-ean-a',
'asset.type': 'k8s.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.id': uuid(),
'asset.parents': [distance2ParentC['asset.ean'], distance2ParentD['asset.ean']],
};
const primaryAsset: AssetWithoutTimestamp = {
'asset.ean': 'primary-ean',
'asset.type': 'k8s.pod',
'asset.id': uuid(),
'asset.parents': [distance1ParentA['asset.ean'], distance1ParentB['asset.ean']],
};
// Only using directly referenced parents
(getRelatedAssets 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

@ -0,0 +1,145 @@
/*
* 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 { Asset, AssetType, Relation, RelationField } from '../../common/types_api';
import { getAssets } from './get_assets';
import { getRelatedAssets } from './get_related_assets';
import { AssetNotFoundError } from './errors';
import { toArray } from './utils';
interface GetAllRelatedAssetsOptions {
ean: string;
from: string;
to?: string;
relation: Relation;
type?: AssetType[];
maxDistance: number;
size: number;
}
export async function getAllRelatedAssets(
esClient: ElasticsearchClient,
options: GetAllRelatedAssetsOptions
) {
// How to put size into this?
const { ean, from, to, relation, maxDistance, type = [] } = options;
const primary = await findPrimary(esClient, { 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 type filter before the last query we'll miss nodes with
// possible edges to the requested types
if (currentDistance === maxDistance && type.length) {
queryOptions.type = type;
}
const results = flatten(
await Promise.all(
assetsToFetch.map((asset) => findRelatedAssets(esClient, asset, queryOptions))
)
);
if (results.length === 0) {
break;
}
relatedAssets.push(...results.map(withDistance(currentDistance)));
assetsToFetch = results;
currentDistance++;
}
return {
primary,
[relation]: type.length
? relatedAssets.filter((asset) => type.includes(asset['asset.type']))
: relatedAssets,
};
}
async function findPrimary(
esClient: ElasticsearchClient,
{ ean, from, to }: Pick<GetAllRelatedAssetsOptions, 'ean' | 'from' | 'to'>
): Promise<Asset> {
const primaryResults = await getAssets({
esClient,
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' | 'type' | 'from' | 'to'
> & { visitedEans: string[] };
async function findRelatedAssets(
esClient: ElasticsearchClient,
primary: Asset,
{ relation, from, to, type, visitedEans }: FindRelatedAssetsOptions
): Promise<Asset[]> {
const relationField = relationToDirectField(relation);
const directlyRelatedEans = toArray(primary[relationField]);
let directlyRelatedAssets: Asset[] = [];
if (directlyRelatedEans.length) {
// get the directly related assets we haven't visited already
directlyRelatedAssets = await getAssets({
esClient,
filters: { ean: without(directlyRelatedEans, ...visitedEans), from, to, type },
});
}
const indirectlyRelatedAssets = await getRelatedAssets({
esClient,
ean: primary['asset.ean'],
excludeEans: visitedEans.concat(directlyRelatedEans),
relation,
from,
to,
type,
});
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

@ -23,6 +23,8 @@ export async function getAssets({
size = 100,
filters = {},
}: GetAssetsOptions): Promise<Asset[]> {
// Maybe it makes the most sense to validate the filters here?
const { from = 'now-24h', to = 'now' } = filters;
const dsl: SearchRequest = {
index: ASSETS_INDEX_PREFIX + '*',
@ -62,7 +64,7 @@ export async function getAssets({
});
}
if (filters.type) {
if (filters.type?.length) {
musts.push({
terms: {
['asset.type']: Array.isArray(filters.type) ? filters.type : [filters.type],

View file

@ -0,0 +1,105 @@
/*
* 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, AssetType, Relation, RelationField } from '../../common/types_api';
import { ASSETS_INDEX_PREFIX } from '../constants';
import { ElasticsearchAccessorOptions } from '../types';
interface GetRelatedAssetsOptions extends ElasticsearchAccessorOptions {
size?: number;
ean: string;
excludeEans?: string[];
from?: string;
to?: string;
relation: Relation;
type?: AssetType[];
}
export async function getRelatedAssets({
esClient,
size = 100,
from = 'now-24h',
to = 'now',
ean,
excludeEans,
relation,
type,
}: GetRelatedAssetsOptions): Promise<Asset[]> {
const relationField = relationToIndirectField(relation);
const must: QueryDslQueryContainer[] = [
{
terms: {
[relationField]: [ean],
},
},
];
if (type?.length) {
must.push({
terms: {
['asset.type']: type,
},
});
}
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 Asset Query', '\n\n', JSON.stringify(dsl, null, 2));
const response = await esClient.search(dsl);
return response.hits.hits.map((hit) => hit._source as 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

@ -103,6 +103,7 @@ const sampleK8sPods: AssetWithoutTimestamp[] = [
'asset.name': 'k8s-pod-200xrg1-aws',
'asset.ean': 'k8s.pod:pod-200xrg1',
'asset.parents': ['k8s.node:node-101'],
'asset.references': ['k8s.cluster:cluster-001'],
},
{
'asset.type': 'k8s.pod',
@ -162,8 +163,39 @@ const sampleK8sPods: AssetWithoutTimestamp[] = [
},
];
const sampleCircularReferences: AssetWithoutTimestamp[] = [
{
'asset.type': 'k8s.node',
'asset.id': 'node-203',
'asset.name': 'k8s-node-203-aws',
'asset.ean': 'k8s.node:node-203',
'orchestrator.type': 'kubernetes',
'orchestrator.cluster.name': 'Cluster 001 (AWS EKS)',
'orchestrator.cluster.id': 'cluster-001',
'cloud.provider': 'aws',
'cloud.region': 'us-east-1',
'cloud.service.name': 'eks',
'asset.references': ['k8s.pod:pod-203ugg9', 'k8s.pod:pod-203ugg5'],
},
{
'asset.type': 'k8s.pod',
'asset.id': 'pod-203ugg5',
'asset.name': 'k8s-pod-203ugg5-aws',
'asset.ean': 'k8s.pod:pod-203ugg5',
'asset.references': ['k8s.node:node-203'],
},
{
'asset.type': 'k8s.pod',
'asset.id': 'pod-203ugg9',
'asset.name': 'k8s-pod-203ugg9-aws',
'asset.ean': 'k8s.pod:pod-203ugg9',
'asset.references': ['k8s.node:node-203'],
},
];
export const sampleAssets: AssetWithoutTimestamp[] = [
...sampleK8sClusters,
...sampleK8sNodes,
...sampleK8sPods,
...sampleCircularReferences,
];

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
export function toArray<T>(maybeArray: T | T[] | undefined): T[] {
if (!maybeArray) {
return [];
}
if (Array.isArray(maybeArray)) {
return maybeArray;
}
return [maybeArray];
}

View file

@ -9,10 +9,14 @@ import { schema } from '@kbn/config-schema';
import { RequestHandlerContext } from '@kbn/core/server';
import { differenceBy, intersectionBy } from 'lodash';
import { debug } from '../../common/debug_log';
import { AssetType, assetTypeRT, relationRT } from '../../common/types_api';
import { ASSET_MANAGER_API_BASE } from '../constants';
import { getAssets } from '../lib/get_assets';
import { getAllRelatedAssets } from '../lib/get_all_related_assets';
import { SetupRouteOptions } from './types';
import { getEsClientFromContext } from './utils';
import { AssetNotFoundError } from '../lib/errors';
import { toArray } from '../lib/utils';
const assetType = schema.oneOf([
schema.literal('k8s.pod'),
@ -23,11 +27,29 @@ const assetType = schema.oneOf([
const getAssetsQueryOptions = schema.object({
from: schema.maybe(schema.string()),
to: schema.maybe(schema.string()),
type: schema.maybe(schema.oneOf([schema.arrayOf(assetType), assetType])),
type: schema.maybe(schema.oneOf([schema.arrayOf(assetTypeRT), assetTypeRT])),
ean: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])),
size: schema.maybe(schema.number()),
});
const getAssetsDiffQueryOptions = schema.object({
aFrom: schema.string(),
aTo: schema.string(),
bFrom: schema.string(),
bTo: schema.string(),
type: schema.maybe(schema.oneOf([schema.arrayOf(assetType), assetType])),
});
const getRelatedAssetsQueryOptions = schema.object({
from: schema.string(), // ISO timestamp or ES datemath
to: schema.maybe(schema.string()), // ISO timestamp or ES datemath
ean: schema.string(),
relation: relationRT,
type: schema.maybe(schema.oneOf([assetTypeRT, schema.arrayOf(assetTypeRT)])),
maxDistance: schema.maybe(schema.number()),
size: schema.maybe(schema.number()),
});
export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupRouteOptions<T>) {
// GET /assets
router.get<unknown, typeof getAssetsQueryOptions.type, unknown>(
@ -58,14 +80,51 @@ export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupR
}
);
// GET assets/related
router.get<unknown, typeof getRelatedAssetsQueryOptions.type, unknown>(
{
path: `${ASSET_MANAGER_API_BASE}/assets/related`,
validate: {
query: getRelatedAssetsQueryOptions,
},
},
async (context, req, res) => {
// Add references into sample data and write integration tests
const { from, to, ean, relation } = req.query || {};
const esClient = await getEsClientFromContext(context);
// What if maxDistance is below 1?
const maxDistance = req.query.maxDistance ? Math.min(req.query.maxDistance, 5) : 1; // Validate maxDistance not larger than 5
const size = req.query.size ? Math.min(req.query.size, 100) : 10; // Do we need pagination and sorting? Yes.
const type = toArray<AssetType>(req.query.type);
// Validate from and to to be ISO string only. Or use io-ts to coerce.
try {
return res.ok({
body: {
results: await getAllRelatedAssets(esClient, {
ean,
from,
to,
type,
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
const getAssetsDiffQueryOptions = schema.object({
aFrom: schema.string(),
aTo: schema.string(),
bFrom: schema.string(),
bTo: schema.string(),
type: schema.maybe(schema.oneOf([schema.arrayOf(assetType), assetType])),
});
router.get<unknown, typeof getAssetsDiffQueryOptions.type, unknown>(
{
path: `${ASSET_MANAGER_API_BASE}/assets/diff`,
@ -74,7 +133,8 @@ export function assetsRoutes<T extends RequestHandlerContext>({ router }: SetupR
},
},
async (context, req, res) => {
const { aFrom, aTo, bFrom, bTo, type } = req.query;
const { aFrom, aTo, bFrom, bTo } = req.query;
const type = toArray<AssetType>(req.query.type);
if (new Date(aFrom) > new Date(aTo)) {
return res.badRequest({

View file

@ -15,5 +15,6 @@
"@kbn/core",
"@kbn/config-schema",
"@kbn/core-http-server",
"@kbn/core-elasticsearch-client-server-mocks",
]
}

View file

@ -5,13 +5,16 @@
* 2.0.
*/
import { AssetWithoutTimestamp } from '@kbn/assetManager-plugin/common/types_api';
import { pick, sortBy } 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';
const ASSETS_ENDPOINT = '/api/asset-manager/assets';
const DIFF_ENDPOINT = ASSETS_ENDPOINT + '/diff';
const DIFF_ENDPOINT = `${ASSETS_ENDPOINT}/diff`;
const RELATED_ASSETS_ENDPOINT = `${ASSETS_ENDPOINT}/related`;
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -307,9 +310,252 @@ export default function ({ getService }: FtrProviderContext) {
delete asset['@timestamp'];
});
expect(getResponse.body.onlyInA).to.eql(onlyInA);
expect(getResponse.body.onlyInB).to.eql(onlyInB);
expect(getResponse.body.inBoth).to.eql(inBoth);
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));
});
});
describe('GET /assets/related', () => {
describe('basic validation of all relations', () => {
const relations = [
{
name: 'ancestors',
ean: 'k8s.node:node-101',
expectedRelatedEans: ['k8s.cluster:cluster-001'],
},
{
name: 'descendants',
ean: 'k8s.cluster:cluster-001',
expectedRelatedEans: ['k8s.node:node-101', 'k8s.node:node-102', 'k8s.node:node-103'],
},
{
name: 'references',
ean: 'k8s.pod:pod-200xrg1',
expectedRelatedEans: ['k8s.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({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: 'non-existing-ean',
maxDistance: 5,
})
.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({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 5,
})
.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({
relation: 'descendants',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleCluster!['asset.ean'],
maxDistance: 5,
})
.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({
relation: 'references',
size: sampleAssetDocs.length,
from: 'now-1d',
ean: sampleNode!['asset.ean'],
maxDistance: 5,
})
.expect(200);
const {
body: { results },
} = getResponse;
expect(
results.references.map((asset: Asset) => pick(asset, ['asset.ean', 'distance']))
).to.eql([
{ 'asset.ean': 'k8s.node:node-203', distance: 1 },
{ 'asset.ean': 'k8s.pod:pod-203ugg9', distance: 2 },
]);
});
});
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.type filters', () => {
it('should filter by the provided asset type', 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,
type: ['k8s.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,
type: ['k8s.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.type'] === 'k8s.pod'));
});
});
});
});