implement dashboard link formatter for GKE

Signed-off-by: Morgan Martinet <morgan.martinet@montreal.ca>
This commit is contained in:
Morgan Martinet
2021-12-27 00:43:43 -05:00
parent a85d9fe831
commit 7ac0bd2c66
21 changed files with 475 additions and 13 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes-common': patch
---
implement dashboard link formatter for GKE
+58 -2
View File
@@ -113,9 +113,13 @@ kubectl -n <NAMESPACE> get secret $(kubectl -n <NAMESPACE> get sa <SERVICE_ACCOU
Specifies the link to the Kubernetes dashboard managing this cluster.
Note that you should specify the app used for the dashboard using the
**dashboardApp property**, in order to properly format links to kubernetes
`dashboardApp` property, in order to properly format links to kubernetes
resources, otherwise it will assume that you're running the standard one.
Note also that this attribute is optional for some kinds of dashboards, such as
GKE, which requires additional informations specified in the
`dashboardParameters` option.
##### `clusters.\*.dashboardApp` (optional)
Specifies the app that provides the Kubernetes dashboard.
@@ -124,11 +128,14 @@ This will be used for formatting links to kubernetes objects inside the
dashboard.
The supported dashboards are: `standard`, `rancher`, `openshift`, `gke`, `aks`,
`eks` However, not all of them are implemented yet, so please contribute!
`eks`. However, not all of them are implemented yet, so please contribute!
Note that it will default to the regular dashboard provided by the Kubernetes
project (`standard`), that can run in any Kubernetes cluster.
Note that for the `gke` app, you must provide additional information in the
`dashboardParameters` option.
Note that you can add your own formatter by registering it to the
`clusterLinksFormatters` dictionary, in the app project.
@@ -143,6 +150,43 @@ See also
https://github.com/backstage/backstage/tree/master/plugins/kubernetes/src/utils/clusterLinks/formatters
for real examples.
##### `clusters.\*.dashboardParameters` (optional)
Specifies additional information for the selected `dashboardApp` formatter.
Note that, eventhough `dashboardParameters` is optional for some formatters, it
is mandatory for others, such as GKE.
###### required parameters for GKE
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| projectId | the ID of the GCP project containing your Kubernetes clusters |
| region | the region of GCP containing your Kubernetes clusters |
| clusterName | the name of your kubernetes cluster, within your `projectId` GCP project |
Note that the GKE cluster locator can automatically provide the values for the
`dashboardApp` and `dashboardParameters` options if you set the
`exposeDashboard` property to `true`.
Example:
```yaml
kubernetes:
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- type: 'config'
clusters:
- url: http://127.0.0.1:9999
name: my-cluster
dashboardApp: gke
dashboardParameters:
projectId: my-project
region: us-east1
clusterName: my-cluster
```
##### `clusters.\*.caData` (optional)
PEM-encoded certificate authority certificates.
@@ -184,6 +228,10 @@ For example:
Will configure the Kubernetes plugin to connect to all GKE clusters in the
project `gke-clusters` in the region `europe-west1`.
Note that the GKE cluster locator can automatically provide the values for the
`dashboardApp` and `dashboardParameters` options if you enable the
`exposeDashboard` option.
##### `projectId`
The Google Cloud project to look for Kubernetes clusters in.
@@ -203,6 +251,14 @@ presented by the API server. Defaults to `false`.
This determines whether the Kubernetes client looks up resource metrics
CPU/Memory for pods returned by the API server. Defaults to `false`.
##### `exposeDashboard`
This determines wether the `dashboardApp` and `dashboardParameters` should be
automatically configured in order to expose the GKE dashboard from the
Kubernetes plugin.
Defaults to `false`.
### `customResources` (optional)
Configures which [custom resources][3] to look for when returning an entity's
+1
View File
@@ -31,6 +31,7 @@ export interface ClusterDetails {
// (undocumented)
caData?: string | undefined;
dashboardApp?: string;
dashboardParameters?: any;
dashboardUrl?: string;
name: string;
// (undocumented)
@@ -47,6 +47,10 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
if (dashboardApp) {
clusterDetails.dashboardApp = dashboardApp;
}
const dashboardParameters = c.getOptionalString('dashboardParameters');
if (dashboardParameters) {
clusterDetails.dashboardParameters = dashboardParameters;
}
switch (authProvider) {
case 'google': {
@@ -226,5 +226,51 @@ describe('GkeClusterLocator', () => {
parent: 'projects/some-project/locations/some-region',
});
});
it('expose GKE dashboard', async () => {
mockedListClusters.mockReturnValueOnce([
{
clusters: [
{
name: 'some-cluster',
endpoint: '1.2.3.4',
},
],
},
]);
const config: Config = new ConfigReader({
type: 'gke',
projectId: 'some-project',
region: 'some-region',
skipMetricsLookup: true,
exposeDashboard: true,
});
const sut = GkeClusterLocator.fromConfigWithClient(config, {
listClusters: mockedListClusters,
} as any);
const result = await sut.getClusters();
expect(result).toStrictEqual([
{
authProvider: 'google',
name: 'some-cluster',
url: 'https://1.2.3.4',
skipTLSVerify: false,
skipMetricsLookup: true,
dashboardApp: 'gke',
dashboardParameters: {
clusterName: 'some-cluster',
projectId: 'some-project',
region: 'some-region',
},
},
]);
expect(mockedListClusters).toBeCalledTimes(1);
expect(mockedListClusters).toHaveBeenCalledWith({
parent: 'projects/some-project/locations/some-region',
});
});
});
});
@@ -24,6 +24,7 @@ type GkeClusterLocatorOptions = {
region?: string;
skipTLSVerify?: boolean;
skipMetricsLookup?: boolean;
exposeDashboard?: boolean;
};
export class GkeClusterLocator implements KubernetesClustersSupplier {
@@ -42,6 +43,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
skipTLSVerify: config.getOptionalBoolean('skipTLSVerify') ?? false,
skipMetricsLookup:
config.getOptionalBoolean('skipMetricsLookup') ?? false,
exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false,
};
return new GkeClusterLocator(options, client);
}
@@ -55,8 +57,13 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
// TODO pass caData into the object
async getClusters(): Promise<GKEClusterDetails[]> {
const { projectId, region, skipTLSVerify, skipMetricsLookup } =
this.options;
const {
projectId,
region,
skipTLSVerify,
skipMetricsLookup,
exposeDashboard,
} = this.options;
const request = {
parent: `projects/${projectId}/locations/${region}`,
};
@@ -70,6 +77,16 @@ export class GkeClusterLocator implements KubernetesClustersSupplier {
authProvider: 'google',
skipTLSVerify,
skipMetricsLookup,
...(exposeDashboard
? {
dashboardApp: 'gke',
dashboardParameters: {
projectId,
region,
clusterName: r.name,
},
}
: {}),
}));
} catch (e) {
throw new ForwardedError(
@@ -255,6 +255,10 @@ export class KubernetesFanOutHandler {
if (clusterDetailsItem.dashboardApp) {
objects.cluster.dashboardApp = clusterDetailsItem.dashboardApp;
}
if (clusterDetailsItem.dashboardParameters) {
objects.cluster.dashboardParameters =
clusterDetailsItem.dashboardParameters;
}
return objects;
});
}),
@@ -111,6 +111,7 @@ export interface ClusterDetails {
* using the dashboardApp property, in order to properly format
* links to kubernetes resources, otherwise it will assume that you're running the standard one.
* @see dashboardApp
* @see dashboardParameters
*/
dashboardUrl?: string;
/**
@@ -129,6 +130,12 @@ export interface ClusterDetails {
* ```
*/
dashboardApp?: string;
/**
* Specifies specific parameters used by some dashboard URL formatters.
* This is used by the GKE formatter which requires the project, region and cluster name.
* @see dashboardApp
*/
dashboardParameters?: any;
}
export interface GKEClusterDetails extends ClusterDetails {}
+1
View File
@@ -62,6 +62,7 @@ export interface ClientPodStatus {
// @public (undocumented)
export interface ClusterAttributes {
dashboardApp?: string;
dashboardParameters?: any;
dashboardUrl?: string;
name: string;
}
+6
View File
@@ -45,6 +45,7 @@ export interface ClusterAttributes {
* Note that you should specify the app used for the dashboard
* using the dashboardApp property, in order to properly format
* links to kubernetes resources, otherwise it will assume that you're running the standard one.
* Also, for cloud clusters such as GKE, you should provide addititonal parameters using dashboardParameters.
* @see dashboardApp
*/
dashboardUrl?: string;
@@ -64,6 +65,11 @@ export interface ClusterAttributes {
* ```
*/
dashboardApp?: string;
/**
* Specifies specific parameters used by some dashboard URL formatters.
* This is used by the GKE formatter which requires the project, region and cluster name.
*/
dashboardParameters?: any;
}
export interface ClusterObjects {
@@ -155,6 +155,7 @@ const KubernetesDrawerContent = <T extends KubernetesDrawerable>({
const { clusterLink, errorMessage } = tryFormatClusterLink({
dashboardUrl: cluster.dashboardUrl,
dashboardApp: cluster.dashboardApp,
dashboardParameters: cluster.dashboardParameters,
object,
kind,
});
+2 -1
View File
@@ -43,7 +43,8 @@ export interface GroupedResponses extends DeploymentResources {
}
export interface ClusterLinksFormatterOptions {
dashboardUrl: URL;
dashboardUrl?: URL;
dashboardParameters?: any;
object: any;
kind: string;
}
@@ -19,15 +19,16 @@ import { defaultFormatterName, clusterLinksFormatters } from './formatters';
export type FormatClusterLinkOptions = {
dashboardUrl?: string;
dashboardApp?: string;
dashboardParameters?: any;
object: any;
kind: string;
};
export function formatClusterLink(options: FormatClusterLinkOptions) {
if (!options.dashboardUrl) {
if (!options.dashboardUrl && !options.dashboardParameters) {
return undefined;
}
if (!options.object) {
if (options.dashboardUrl && !options.object) {
return options.dashboardUrl;
}
const app = options.dashboardApp || defaultFormatterName;
@@ -36,7 +37,10 @@ export function formatClusterLink(options: FormatClusterLinkOptions) {
throw new Error(`Could not find Kubernetes dashboard app named '${app}'`);
}
const url = formatter({
dashboardUrl: new URL(options.dashboardUrl),
dashboardUrl: options.dashboardUrl
? new URL(options.dashboardUrl)
: undefined,
dashboardParameters: options.dashboardParameters,
object: options.object,
kind: options.kind,
});
@@ -16,10 +16,9 @@
import { gkeFormatter } from './gke';
describe('clusterLinks - GKE formatter', () => {
it('should return an url on the workloads when there is a namespace only', () => {
it('should provide a dashboardParameters in the options', () => {
expect(() =>
gkeFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
object: {
metadata: {
name: 'foobar',
@@ -28,6 +27,196 @@ describe('clusterLinks - GKE formatter', () => {
},
kind: 'Deployment',
}),
).toThrowError('GKE formatter is not yet implemented. Please, contribute!');
).toThrowError('GKE dashboard requires a dashboardParameters option');
});
it('should provide a projectId in the dashboardParameters options', () => {
expect(() =>
gkeFormatter({
dashboardParameters: {
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
}),
).toThrowError(
'GKE dashboard requires a "projectId" in the dashboardParameters option',
);
});
it('should provide a region in the dashboardParameters options', () => {
expect(() =>
gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
}),
).toThrowError(
'GKE dashboard requires a "region" in the dashboardParameters option',
);
});
it('should provide a clusterName in the dashboardParameters options', () => {
expect(() =>
gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
}),
).toThrowError(
'GKE dashboard requires a "clusterName" in the dashboardParameters option',
);
});
it('should return an url on the cluster when there is a namespace only', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
namespace: 'bar',
},
},
kind: 'foo',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/clusters/details/us-east1-c/cluster-1/details?project=foobar-333614',
);
});
it('should return an url on the cluster when the kind is not recognizeed', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'UnknownKind',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/clusters/details/us-east1-c/cluster-1/details?project=foobar-333614',
);
});
it('should return an url on the deployment', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/deployment/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614',
);
});
it('should return an url on the service', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Service',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/service/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614',
);
});
it('should return an url on the pod', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Pod',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/pod/us-east1-c/cluster-1/bar/foobar/details?project=foobar-333614',
);
});
it('should return an url on the ingress', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Ingress',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/ingress/us-east1-c/cluster-1/bar/foobar/details?project=foobar-333614',
);
});
it('should return an url on the deployment for a hpa', () => {
const url = gkeFormatter({
dashboardParameters: {
projectId: 'foobar-333614',
region: 'us-east1-c',
clusterName: 'cluster-1',
},
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'HorizontalPodAutoscaler',
});
expect(url.href).toBe(
'https://console.cloud.google.com/kubernetes/deployment/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614',
);
});
});
@@ -15,6 +15,60 @@
*/
import { ClusterLinksFormatterOptions } from '../../../types/types';
export function gkeFormatter(_options: ClusterLinksFormatterOptions): URL {
throw new Error('GKE formatter is not yet implemented. Please, contribute!');
const kindMappings: Record<string, string> = {
deployment: 'deployment',
pod: 'pod',
ingress: 'ingress',
service: 'service',
horizontalpodautoscaler: 'deployment',
};
export function gkeFormatter(options: ClusterLinksFormatterOptions): URL {
if (!options.dashboardParameters) {
throw new Error('GKE dashboard requires a dashboardParameters option');
}
const args = options.dashboardParameters;
if (!args.projectId) {
throw new Error(
'GKE dashboard requires a "projectId" in the dashboardParameters option',
);
}
if (!args.region) {
throw new Error(
'GKE dashboard requires a "region" in the dashboardParameters option',
);
}
if (!args.clusterName) {
throw new Error(
'GKE dashboard requires a "clusterName" in the dashboardParameters option',
);
}
const basePath = new URL('https://console.cloud.google.com/');
const region = encodeURIComponent(args.region);
const clusterName = encodeURIComponent(args.clusterName);
const name = encodeURIComponent(options.object.metadata?.name ?? '');
const namespace = encodeURIComponent(
options.object.metadata?.namespace ?? '',
);
const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')];
if (!basePath.pathname.endsWith('/')) {
// a dashboard url with a path should end with a slash otherwise
// the new combined URL will replace the last segment with the appended path!
// https://foobar.com/abc/def + k8s/cluster/projects/test --> https://foobar.com/abc/k8s/cluster/projects/test
// https://foobar.com/abc/def/ + k8s/cluster/projects/test --> https://foobar.com/abc/def/k8s/cluster/projects/test
basePath.pathname += '/';
}
let path = '';
if (namespace && name && validKind) {
const kindsWithDetails = ['ingress', 'pod'];
const landingPage = kindsWithDetails.includes(validKind)
? 'details'
: 'overview';
path = `kubernetes/${validKind}/${region}/${clusterName}/${namespace}/${name}/${landingPage}`;
} else {
path = `kubernetes/clusters/details/${region}/${clusterName}/details`;
}
const result = new URL(path, basePath);
result.searchParams.set('project', args.projectId);
return result;
}
@@ -16,6 +16,19 @@
import { openshiftFormatter } from './openshift';
describe('clusterLinks - OpenShift formatter', () => {
it('should provide a dashboardUrl in the options', () => {
expect(() =>
openshiftFormatter({
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
}),
).toThrowError('OpenShift dashboard requires a dashboardUrl option');
});
it('should return an url on the workloads when there is a namespace only', () => {
const url = openshiftFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
@@ -24,6 +24,9 @@ const kindMappings: Record<string, string> = {
};
export function openshiftFormatter(options: ClusterLinksFormatterOptions): URL {
if (!options.dashboardUrl) {
throw new Error('OpenShift dashboard requires a dashboardUrl option');
}
const basePath = new URL(options.dashboardUrl.href);
const name = encodeURIComponent(options.object.metadata?.name ?? '');
const namespace = encodeURIComponent(
@@ -16,6 +16,19 @@
import { rancherFormatter } from './rancher';
describe('clusterLinks - rancher formatter', () => {
it('should provide a dashboardUrl in the options', () => {
expect(() =>
rancherFormatter({
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
}),
).toThrowError('Rancher dashboard requires a dashboardUrl option');
});
it('should return a url on the workloads when there is a namespace only', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
@@ -23,6 +23,9 @@ const kindMappings: Record<string, string> = {
};
export function rancherFormatter(options: ClusterLinksFormatterOptions): URL {
if (!options.dashboardUrl) {
throw new Error('Rancher dashboard requires a dashboardUrl option');
}
const basePath = new URL(options.dashboardUrl.href);
const name = encodeURIComponent(options.object.metadata?.name ?? '');
const namespace = encodeURIComponent(
@@ -23,6 +23,19 @@ function formatUrl(url: URL) {
}
describe('clusterLinks - standard formatter', () => {
it('should provide a dashboardUrl in the options', () => {
expect(() =>
standardFormatter({
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
}),
).toThrowError('standard dashboard requires a dashboardUrl option');
});
it('should return an url on the workloads when there is a namespace only', () => {
const url = standardFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
@@ -67,6 +80,21 @@ describe('clusterLinks - standard formatter', () => {
'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar',
);
});
it('should return an url on the pod', () => {
const url = standardFormatter({
dashboardUrl: new URL('https://k8s.foo.com/'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Pod',
});
expect(formatUrl(url)).toBe(
'https://k8s.foo.com/#/pod/bar/foobar?namespace=bar',
);
});
it('should return an url on the deployment with a prefix 1', () => {
const url = standardFormatter({
dashboardUrl: new URL('https://k8s.foo.com/some/prefix'),
@@ -17,12 +17,16 @@ import { ClusterLinksFormatterOptions } from '../../../types/types';
const kindMappings: Record<string, string> = {
deployment: 'deployment',
pod: 'pod',
ingress: 'ingress',
service: 'service',
horizontalpodautoscaler: 'deployment',
};
export function standardFormatter(options: ClusterLinksFormatterOptions) {
if (!options.dashboardUrl) {
throw new Error('standard dashboard requires a dashboardUrl option');
}
const result = new URL(options.dashboardUrl.href);
const name = encodeURIComponent(options.object.metadata?.name ?? '');
const namespace = encodeURIComponent(