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
+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(