Skip to content

Commit 8bff181

Browse files
authored
chore: adopt kubernetes dql (#62)
* chore: adopt kubernetes DQL * chore: typo * chore: add tests and fix quotation of filter values * chore: add some comments * chore: address feedback * chore: don't use component namespace, only selector * chore: use correct namespace filter * chore: fix tests * chore: fix tests * chore: make kubernetes id annotation required, do not fallback to 'default' namespace * chore: fix kubernetes namespace, adjust readme, override kubernetes id if label is given * chore: address readme feedback * chore: address feedback * chore: address feedback & improve client-backend request * chore: type fixes and standalone server fix * chore: adjust empty and missing annotation screen * chore: adapt no-resources-found message for kubernetes
1 parent 4dd2edb commit 8bff181

27 files changed

+544
-203
lines changed

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,34 @@ deployments in your Dynatrace environment.
192192
/>
193193
```
194194

195-
_Convention:_ Kubernetes pods with a `backstage.io/component` label will be
196-
listed for the corresponding Backstage component if they are properly annotated
197-
in the deployment descriptor:
195+
_Convention:_ Kubernetes pods will be listed for the corresponding Backstage
196+
component if they are properly annotated in the deployment descriptor. See
197+
[annotations](https://backstage.io/docs/features/software-catalog/descriptor-format/#annotations-optional).
198+
199+
Example:
198200

199201
```yaml
200-
backstage.io/component: <backstage-namespace>.<backstage-component-name>
202+
backstage.io/kubernetes-id: kubernetesid
203+
backstage.io/kubernetes-namespace: namespace
204+
backstage.io/kubernetes-label-selector: stage=hardening,name=frontend
201205
```
202206

207+
- The annotation `backstage.io/kubernetes-id` will look for the Kubernetes label
208+
`backstage.io/kubernetes-id`.
209+
- The annotation `backstage.io/kubernetes-namespace` will look for the
210+
Kubernetes namespace.
211+
- The annotation `backstage.io/kubernetes-label-selector` will look for the
212+
labels defined in it. So
213+
`backstage.io/kubernetes-label-selector: stage=hardening,name=frontend` will
214+
look for a Kubernetes label `stage` with the value `hardening` and a label
215+
`name` with the value `frontend`.
216+
217+
If a `backstage.io/kubernetes-label-selector` is given,
218+
`backstage.io/kubernetes-id` is ignored.
219+
220+
If no namespace is given, it looks for all namespaces. There is no fallback to
221+
`default`.
222+
203223
The query for fetching the monitoring data for Kubernetes deployments is defined
204224
here:
205225
[`dynatrace.kubernetes-deployments`](plugins/dql-backend/src/service/queries.ts).

catalog-info.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ kind: Component
33
metadata:
44
name: demo-backstage
55
description: Backstage Demo instance.
6+
annotations:
7+
backstage.io/kubernetes-id: kubernetescustom
68
spec:
79
type: website
810
owner: user:default/mjakl

packages/backend/src/plugins/dynatrace-dql.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,5 @@ import { Router } from 'express';
2020
export default async function createPlugin(
2121
env: PluginEnvironment,
2222
): Promise<Router> {
23-
return await createRouter({
24-
logger: env.logger,
25-
config: env.config,
26-
});
23+
return await createRouter(env);
2724
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Dynatrace LLC
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { dynatraceQueries, DynatraceQueryKeys } from './queries';
17+
import { Entity } from '@backstage/catalog-model';
18+
19+
describe('queries', () => {
20+
const getEntity = (annotations?: Record<string, string>): Entity => ({
21+
apiVersion: '1.0.0',
22+
kind: 'component',
23+
metadata: { name: 'componentName', annotations },
24+
});
25+
const defaultApiConfig = {
26+
environmentName: 'environment',
27+
environmentUrl: 'url',
28+
};
29+
30+
describe(DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS, () => {
31+
it('should fail if neither the label selector nor the kubernetes id annotation is provided', () => {
32+
// act, assert
33+
expect(() =>
34+
dynatraceQueries[DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS](
35+
getEntity(),
36+
defaultApiConfig,
37+
),
38+
).toThrow();
39+
});
40+
41+
it('should return the query without the kubernetesId filter if a label selector is provided', () => {
42+
// act
43+
const query = dynatraceQueries[DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS](
44+
getEntity({
45+
'backstage.io/kubernetes-id': 'kubernetesId',
46+
'backstage.io/kubernetes-label-selector': 'label=value',
47+
}),
48+
defaultApiConfig,
49+
);
50+
51+
// assert
52+
expect(query).not.toContain(
53+
'| filter workload.labels[`backstage.io/kubernetes-id`] == "kubernetesId"',
54+
);
55+
expect(query).toContain('| filter workload.labels[`label`] == "value"');
56+
});
57+
58+
it('should return the query with the kubernetesId filter ', () => {
59+
// act
60+
const query = dynatraceQueries[DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS](
61+
getEntity({ 'backstage.io/kubernetes-id': 'kubernetesId' }),
62+
defaultApiConfig,
63+
);
64+
65+
// assert
66+
expect(query).toContain(
67+
'| filter workload.labels[`backstage.io/kubernetes-id`] == "kubernetesId"',
68+
);
69+
});
70+
71+
it('should return the query with the namespace filter', () => {
72+
// act
73+
const query = dynatraceQueries[DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS](
74+
getEntity({
75+
'backstage.io/kubernetes-id': 'kubernetesId',
76+
'backstage.io/kubernetes-namespace': 'namespace',
77+
}),
78+
defaultApiConfig,
79+
);
80+
81+
// assert
82+
expect(query).toContain('| filter Namespace == "namespace"');
83+
});
84+
85+
it('should return the query with the label selector filter', () => {
86+
// act
87+
const query = dynatraceQueries[DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS](
88+
getEntity({
89+
'backstage.io/kubernetes-label-selector': 'label=value',
90+
'backstage.io/kubernetes-namespace': 'namespace',
91+
}),
92+
defaultApiConfig,
93+
);
94+
95+
// assert
96+
expect(query).toContain('| filter workload.labels[`label`] == "value"');
97+
});
98+
});
99+
});

plugins/dql-backend/src/service/queries.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,53 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
import { generateKubernetesSelectorFilter } from '../utils/labelSelectorParser';
17+
import { Entity } from '@backstage/catalog-model';
1618

17-
export const dynatraceQueries: Record<string, string | undefined> = {
18-
'kubernetes-deployments': `
19+
export enum DynatraceQueryKeys {
20+
KUBERNETES_DEPLOYMENTS = 'kubernetes-deployments',
21+
}
22+
23+
interface ApiConfig {
24+
environmentName: string;
25+
environmentUrl: string;
26+
}
27+
28+
export const isValidDynatraceQueryKey = (
29+
key: string,
30+
): key is DynatraceQueryKeys => key in dynatraceQueries;
31+
32+
export const dynatraceQueries: Record<
33+
DynatraceQueryKeys,
34+
(entity: Entity, apiConfig: ApiConfig) => string
35+
> = {
36+
[DynatraceQueryKeys.KUBERNETES_DEPLOYMENTS]: (entity, apiConfig) => {
37+
const labelSelector =
38+
entity.metadata.annotations?.['backstage.io/kubernetes-label-selector'];
39+
const kubernetesId =
40+
entity.metadata.annotations?.['backstage.io/kubernetes-id'];
41+
const namespace =
42+
entity.metadata.annotations?.['backstage.io/kubernetes-namespace'];
43+
44+
const filterLabel = labelSelector
45+
? generateKubernetesSelectorFilter(labelSelector)
46+
: '';
47+
// if a label filter is given, the id is ignored
48+
const filterKubernetesId =
49+
filterLabel || !kubernetesId
50+
? ''
51+
: `| filter workload.labels[\`backstage.io/kubernetes-id\`] == "${kubernetesId}"`;
52+
const filterNamespace = namespace
53+
? `| filter Namespace == "${namespace}"`
54+
: '';
55+
56+
if (!filterKubernetesId && !filterLabel) {
57+
throw new Error(
58+
'One of the component annotations is required: "backstage.io/kubernetes-id" or "backstage.io/kubernetes-label-selector"',
59+
);
60+
}
61+
62+
return `
1963
fetch dt.entity.cloud_application, from: -10m
2064
| fields id,
2165
name = entity.name,
@@ -25,19 +69,22 @@ export const dynatraceQueries: Record<string, string | undefined> = {
2569
namespace.id = belongs_to[dt.entity.cloud_application_namespace]
2670
| sort upper(name) asc
2771
| lookup [fetch dt.entity.cloud_application_instance, from: -10m | fields matchedId = instance_of[dt.entity.cloud_application], podVersion = cloudApplicationLabels[\`app.kubernetes.io/version\`]], sourceField:id, lookupField:matchedId, fields:{podVersion}
28-
| fieldsAdd Workload = record({type="link", text=name, url=concat("\${environmentUrl}/ui/apps/dynatrace.kubernetes/resources/pod?entityId=", id)})
72+
| fieldsAdd Workload = record({type="link", text=name, url=concat("${apiConfig.environmentUrl}/ui/apps/dynatrace.kubernetes/resources/pod?entityId=", id)})
2973
| lookup [fetch dt.entity.kubernetes_cluster, from: -10m | fields id, Cluster = entity.name],sourceField:cluster.id, lookupField:id, fields:{Cluster}
3074
| lookup [fetch dt.entity.cloud_application_namespace, from: -10m | fields id, Namespace = entity.name], sourceField:namespace.id, lookupField:id, fields:{Namespace}
3175
| lookup [fetch events, from: -30m | filter event.kind == "DAVIS_PROBLEM" | fieldsAdd affected_entity_id = affected_entity_ids[0] | summarize collectDistinct(event.status), by:{display_id, affected_entity_id}, alias:problem_status | filter NOT in(problem_status, "CLOSED") | summarize Problems = count(), by:{affected_entity_id}], sourceField:id, lookupField:affected_entity_id, fields:{Problems}
3276
| fieldsAdd Problems=coalesce(Problems,0)
3377
| lookup [ fetch events, from: -30m | filter event.kind=="SECURITY_EVENT" | filter event.category=="VULNERABILITY_MANAGEMENT" | filter event.provider=="Dynatrace" | filter event.type=="VULNERABILITY_STATE_REPORT_EVENT" | filter in(vulnerability.stack,{"CODE_LIBRARY","SOFTWARE","CONTAINER_ORCHESTRATION"}) | filter event.level=="ENTITY" | summarize { workloadId=arrayFirst(takeFirst(related_entities.kubernetes_workloads.ids)), vulnerability.stack=takeFirst(vulnerability.stack)}, by: {vulnerability.id, affected_entity.id} | summarize { Vulnerabilities=count() }, by: {workloadId}], sourceField:id, lookupField:workloadId, fields:{Vulnerabilities}
3478
| fieldsAdd Vulnerabilities=coalesce(Vulnerabilities,0)
35-
| filter workload.labels[\`backstage.io/component\`] == "\${componentNamespace}.\${componentName}"
79+
${filterKubernetesId}
80+
${filterNamespace}
81+
${filterLabel}
3682
| fieldsAdd Logs = record({type="link", text="Show logs", url=concat(
37-
"\${environmentUrl}",
83+
"${apiConfig.environmentUrl}",
3884
"/ui/apps/dynatrace.notebooks/intent/view-query#%7B%22dt.query%22%3A%22fetch%20logs%20%7C%20filter%20matchesValue(dt.entity.cloud_application%2C%5C%22",
3985
id,
4086
"%5C%22)%20%7C%20sort%20timestamp%20desc%22%2C%22title%22%3A%22Logs%22%7D")})
4187
| fieldsRemove id, deploymentVersion, podVersion, name, workload.labels, cluster.id, namespace.id
42-
| fieldsAdd Environment = "\${environmentName}"`,
88+
| fieldsAdd Environment = "${apiConfig.environmentName}"`;
89+
},
4390
};

plugins/dql-backend/src/service/queryCompiler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616

1717
export function compileDqlQuery(
1818
queryToCompile: string,
19-
variables: Record<string, unknown>,
19+
variables: Record<string, string | undefined>,
2020
): string {
2121
return Array.from(Object.entries(variables)).reduce(
2222
(query: string, [variable, value]) =>
23-
query.replaceAll(`\${${variable}}`, String(value)),
23+
query.replaceAll(`\${${variable}}`, String(value ?? '')),
2424
queryToCompile,
2525
);
2626
}

plugins/dql-backend/src/service/queryExecutor.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,19 @@
1414
* limitations under the License.
1515
*/
1616
import { QueryExecutor } from './queryExecutor';
17+
import { Entity } from '@backstage/catalog-model';
1718

1819
describe('queryExecutor', () => {
1920
const executor = new QueryExecutor([], { 'my.id': 'myQuery' });
2021
const inputVariables = {
2122
componentNamespace: 'namespace',
2223
componentName: 'name',
2324
};
25+
const entity: Entity = {
26+
apiVersion: '1.0.0',
27+
kind: 'component',
28+
metadata: { name: 'componentName' },
29+
};
2430

2531
describe('Invalid IDs', () => {
2632
it('should throw an error if a custom query is undefined', async () => {
@@ -33,7 +39,7 @@ describe('queryExecutor', () => {
3339
it('should throw an error if a Dynatrace query is undefined', async () => {
3440
// assert
3541
await expect(() =>
36-
executor.executeDynatraceQuery('not.existing', inputVariables),
42+
executor.executeDynatraceQuery('not.existing', entity),
3743
).rejects.toThrow();
3844
});
3945
});
@@ -50,7 +56,7 @@ describe('queryExecutor', () => {
5056
// act
5157
const result = await executor.executeDynatraceQuery(
5258
'kubernetes-deployments',
53-
inputVariables,
59+
entity,
5460
);
5561
// assert
5662
expect(result).toEqual([]);

plugins/dql-backend/src/service/queryExecutor.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
* limitations under the License.
1515
*/
1616
import { DynatraceApi } from './dynatraceApi';
17-
import { dynatraceQueries } from './queries';
17+
import { dynatraceQueries, isValidDynatraceQueryKey } from './queries';
1818
import { compileDqlQuery } from './queryCompiler';
19+
import { Entity } from '@backstage/catalog-model';
1920
import { TabularData } from '@dynatrace/backstage-plugin-dql-common';
2021
import { z } from 'zod';
2122

@@ -44,30 +45,12 @@ export class QueryExecutor {
4445
queryId: string,
4546
variables: ComponentQueryVariables,
4647
): Promise<TabularData> {
47-
const query = this.queries[queryId];
48-
if (!query) {
48+
const dqlQuery = this.queries[queryId];
49+
if (!dqlQuery) {
4950
throw new Error(`No custom query to the given id "${queryId}" found`);
5051
}
51-
return this.executeQuery(query, variables);
52-
}
5352

54-
async executeDynatraceQuery(
55-
queryId: string,
56-
variables: ComponentQueryVariables,
57-
): Promise<TabularData> {
58-
const query = dynatraceQueries[queryId];
59-
if (!query) {
60-
throw new Error(`No Dynatrace query to the given id "${queryId}" found`);
61-
}
62-
return this.executeQuery(query, variables);
63-
}
64-
65-
private async executeQuery(
66-
dqlQuery: string,
67-
variables: ComponentQueryVariables,
68-
): Promise<TabularData> {
6953
componentQueryVariablesSchema.parse(variables);
70-
7154
const results$ = this.apis.map(api => {
7255
const compiledQuery = compileDqlQuery(dqlQuery, {
7356
...variables,
@@ -79,4 +62,18 @@ export class QueryExecutor {
7962
const results = await Promise.all(results$);
8063
return results.flatMap(result => result);
8164
}
65+
66+
async executeDynatraceQuery(
67+
queryId: string,
68+
entity: Entity,
69+
): Promise<TabularData> {
70+
if (!isValidDynatraceQueryKey(queryId)) {
71+
throw new Error(`No Dynatrace query to the given id "${queryId}" found`);
72+
}
73+
const results$ = this.apis.map(api =>
74+
api.executeDqlQuery(dynatraceQueries[queryId](entity, api)),
75+
);
76+
const results = await Promise.all(results$);
77+
return results.flatMap(result => result);
78+
}
8279
}

plugins/dql-backend/src/service/router.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import { createRouter } from './router';
1717
import { getVoidLogger } from '@backstage/backend-common';
1818
import { MockConfigApi } from '@backstage/test-utils';
19+
import { PluginEnvironment } from 'backend/src/types';
1920
import express from 'express';
2021

2122
describe('createRouter', () => {
@@ -24,6 +25,14 @@ describe('createRouter', () => {
2425
beforeAll(async () => {
2526
const router = await createRouter({
2627
logger: getVoidLogger(),
28+
discovery: {
29+
async getBaseUrl(): Promise<string> {
30+
return '';
31+
},
32+
async getExternalBaseUrl(): Promise<string> {
33+
return '';
34+
},
35+
},
2736
config: new MockConfigApi({
2837
dynatrace: {
2938
environments: [
@@ -38,7 +47,7 @@ describe('createRouter', () => {
3847
],
3948
},
4049
}),
41-
});
50+
} as unknown as PluginEnvironment);
4251
app = express().use(router);
4352
});
4453

0 commit comments

Comments
 (0)