K8s add cluster lookup (#2571)

* Kubernetes plugins boilerplate

* pr feedback; check for k8s annotation

* temp

* fix config

* add example local setup

* dice rolelr

* rever yarn lock update

* forgotten prettier

* prettier again

* master lock

* update lock with k8s deps

* prettier

* router tests

* internal error

* fix package.json

* PR feedback

* more pr feedback

* rename component file

* kubernetes fetcher tests

* fix test

* option constructor
This commit is contained in:
Matthew Clarke
2020-09-24 16:30:48 +01:00
committed by GitHub
parent b41ffc8c0f
commit b16e164815
26 changed files with 1671 additions and 56 deletions
+4
View File
@@ -55,6 +55,10 @@ newrelic:
lighthouse:
baseUrl: http://localhost:3003
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
clusters: []
catalog:
rules:
- allow: [Component, API, Group, Template, Location]
+5 -2
View File
@@ -17,6 +17,9 @@
import { createRouter } from '@backstage/plugin-kubernetes-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
@@ -0,0 +1,42 @@
# Dice roller
An app to roll dice (it doesn't actually do that).
# Viewing in local Minikube running Backstage locally
## Prerequisites
- kubectl installed
- Minikube installed
- jq installed
- Backstage locally built and ready to run
## Steps
1. Start minikube
2. Get the Kubernetes master base url `kubectl cluster-info`
3. Apply manifests `kubectl apply -f dice-roller-manifests.yaml`
4. Get service account token (see below)
5. Start Backstage UI and backend
6. Register existing component in Backstage
- https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml
Update `app-config.yaml` as follows.
```yaml
---
kubernetes:
clusterLocatorMethod: 'configMultiTenant'
clusters:
- url: <KUBERNETES MASTER BASE URL FROM STEP 2>
name: minikube
serviceAccountToken: <TOKEN FROM STEP 4>
```
### Getting the service account token
```
kubectl get secret DICE_ROLLER_TOKEN_NAME -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy
```
Paste into `app-config.yaml` `kubernetes.clusters[].serviceAccountToken`
@@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: dice-roller
description: It rolls dice
tags:
- go
annotations:
'backstage.io/kubernetes-id': dice-roller
spec:
type: service
lifecycle: production
owner: guest
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: dice-roller
labels:
'backstage.io/kubernetes-id': dice-roller
spec:
selector:
matchLabels:
app: dice-roller
replicas: 2
template:
metadata:
labels:
app: dice-roller
'backstage.io/kubernetes-id': dice-roller
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dice-roller
namespace: default
labels:
'backstage.io/kubernetes-id': dice-roller
data:
foo: bar
---
apiVersion: v1
kind: Secret
metadata:
name: dice-roller
labels:
'backstage.io/kubernetes-id': dice-roller
type: Opaque
data:
username: YWRtaW4=
---
apiVersion: v1
kind: Service
metadata:
name: dice-roller
labels:
'backstage.io/kubernetes-id': dice-roller
spec:
selector:
app: dice-roller
ports:
- protocol: TCP
port: 80
targetPort: 9376
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dice-roller
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sa-admin
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
name: dice-roller
namespace: default
+6 -2
View File
@@ -21,7 +21,9 @@
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.23",
"@backstage/config": "^0.1.1-alpha.23",
"@types/express": "^4.17.6",
"@kubernetes/client-node": "^0.12.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -29,12 +31,14 @@
"fs-extra": "^9.0.0",
"helmet": "^4.0.0",
"morgan": "^1.10.0",
"stream-buffers": "^3.0.2",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.23",
"jest-fetch-mock": "^3.0.3"
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2",
"@backstage/cli": "^0.1.1-alpha.23"
},
"files": [
"dist"
@@ -0,0 +1,105 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@backstage/backend-common';
import { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator';
import { ConfigReader, Config } from '@backstage/config';
describe('MultiTenantConfigClusterLocator', () => {
it('empty clusters returns empty cluster details', async () => {
const config: Config = new ConfigReader(
{
clusters: [],
},
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
expect(result).toStrictEqual([]);
});
it('one clusters returns one cluster details', async () => {
const config: Config = new ConfigReader(
{
clusters: [
{
name: 'cluster1',
url: 'http://localhost:8080',
},
],
},
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
},
]);
});
it('two clusters returns two cluster details', async () => {
const config: Config = new ConfigReader(
{
clusters: [
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
},
],
},
'ctx',
);
const sut = MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('clusters'),
);
const result = await sut.getClusterByServiceId('ignored');
expect(result).toStrictEqual([
{
name: 'cluster1',
serviceAccountToken: undefined,
url: 'http://localhost:8080',
},
{
name: 'cluster2',
serviceAccountToken: undefined,
url: 'http://localhost:8081',
},
]);
});
});
@@ -0,0 +1,47 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { ClusterDetails, KubernetesClusterLocator } from '..';
// This cluster locator assumes that every service is located on every cluster
// Therefore it will always return all clusters in an app configuration file
export class MultiTenantConfigClusterLocator
implements KubernetesClusterLocator {
private readonly clusterDetails: ClusterDetails[];
constructor(clusterDetails: ClusterDetails[]) {
this.clusterDetails = clusterDetails;
}
static fromConfig(config: Config[]): MultiTenantConfigClusterLocator {
return new MultiTenantConfigClusterLocator(
config.map(c => {
return {
name: c.getString('name'),
url: c.getString('url'),
serviceAccountToken: c.getOptionalString('serviceAccountToken'),
};
}),
);
}
// As this implementation always returns all clusters serviceId is ignored here
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getClusterByServiceId(_serviceId: string): Promise<ClusterDetails[]> {
return this.clusterDetails;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type ClusterLocatorMethod = 'configMultiTenant' | 'http';
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './service/router';
export * from './types/types';
@@ -0,0 +1,136 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@backstage/backend-common';
import { KubernetesClientProvider } from './KubernetesClientProvider';
describe('KubernetesClientProvider', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('can get core client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth = (result as any).authentications.default;
expect(auth.users[0].token).toBe('TOKEN');
expect(auth.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
it('can get cached core client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result1 = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
const result2 = sut.getCoreClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result1.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth1 = (result1 as any).authentications.default;
expect(auth1.users[0].token).toBe('TOKEN');
expect(auth1.clusters[0].name).toBe('cluster-name');
expect(result2.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth2 = (result2 as any).authentications.default;
expect(auth2.users[0].token).toBe('TOKEN');
expect(auth2.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
it('can get apps client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth = (result as any).authentications.default;
expect(auth.users[0].token).toBe('TOKEN');
expect(auth.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
it('can get cached apps client by cluster details', async () => {
const sut = new KubernetesClientProvider();
const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({}));
sut.getKubeConfig = mockGetKubeConfig;
const result1 = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
const result2 = sut.getAppsClientByClusterDetails({
name: 'cluster-name',
url: 'http://localhost:9999',
serviceAccountToken: 'TOKEN',
});
expect(result1.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth1 = (result1 as any).authentications.default;
expect(auth1.users[0].token).toBe('TOKEN');
expect(auth1.clusters[0].name).toBe('cluster-name');
expect(result2.basePath).toBe('http://localhost:9999');
// These fields aren't on the type but are there
const auth2 = (result2 as any).authentications.default;
expect(auth2.users[0].token).toBe('TOKEN');
expect(auth2.clusters[0].name).toBe('cluster-name');
expect(mockGetKubeConfig.mock.calls.length).toBe(1);
});
});
@@ -0,0 +1,96 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ClusterDetails } from '..';
import { AppsV1Api, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
export class KubernetesClientProvider {
private readonly coreClientMap: {
[key: string]: CoreV1Api;
};
private readonly appsClientMap: {
[key: string]: AppsV1Api;
};
constructor() {
this.coreClientMap = {};
this.appsClientMap = {};
}
// visible for testing
getKubeConfig(clusterDetails: ClusterDetails) {
const cluster = {
name: clusterDetails.name,
server: clusterDetails.url,
// TODO configure this
skipTLSVerify: true,
};
// TODO configure
const user = {
name: 'service-account',
token: clusterDetails.serviceAccountToken,
};
const context = {
name: `${clusterDetails.name}`,
user: user.name,
cluster: cluster.name,
};
const kc = new KubeConfig();
kc.loadFromOptions({
clusters: [cluster],
users: [user],
contexts: [context],
currentContext: context.name,
});
return kc;
}
getCoreClientByClusterDetails(clusterDetails: ClusterDetails) {
const clientMapKey = clusterDetails.name;
if (this.coreClientMap.hasOwnProperty(clientMapKey)) {
return this.coreClientMap[clientMapKey];
}
const kc = this.getKubeConfig(clusterDetails);
const client = kc.makeApiClient(CoreV1Api);
this.coreClientMap[clientMapKey] = client;
return client;
}
getAppsClientByClusterDetails(clusterDetails: ClusterDetails) {
const clientMapKey = clusterDetails.name;
if (this.appsClientMap.hasOwnProperty(clientMapKey)) {
return this.appsClientMap[clientMapKey];
}
const kc = this.getKubeConfig(clusterDetails);
const client = kc.makeApiClient(AppsV1Api);
this.appsClientMap[clientMapKey] = client;
return client;
}
}
@@ -0,0 +1,108 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
describe('KubernetesClientProvider', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should return pods, services', async () => {
const clientMock: any = {
listPodForAllNamespaces: jest.fn(),
listServiceForAllNamespaces: jest.fn(),
};
const kubernetesClientProvider: any = {
getCoreClientByClusterDetails: jest.fn(() => clientMock),
getAppsClientByClusterDetails: jest.fn(() => clientMock),
};
const sut = new KubernetesClientBasedFetcher({
kubernetesClientProvider,
logger: getVoidLogger(),
});
clientMock.listPodForAllNamespaces.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'pod-name',
},
},
],
},
});
clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'service-name',
},
},
],
},
});
const result = await sut.fetchObjectsByServiceId(
'some-service',
{
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: undefined,
},
new Set(['pods', 'services']),
);
expect(result).toStrictEqual([
{
type: 'pods',
resources: [
{
metadata: {
name: 'pod-name',
},
},
],
},
{
type: 'services',
resources: [
{
metadata: {
name: 'service-name',
},
},
],
},
]);
expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1);
expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1);
expect(
kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length,
).toBe(2);
expect(
kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length,
).toBe(2);
});
});
@@ -0,0 +1,212 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AppsV1Api,
CoreV1Api,
V1ConfigMap,
V1Deployment,
V1Pod,
V1ReplicaSet,
V1Secret,
} from '@kubernetes/client-node';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import { V1Service } from '@kubernetes/client-node/dist/gen/model/v1Service';
import { Logger } from 'winston';
import {
KubernetesFetcher,
ClusterDetails,
KubernetesObjectTypes,
FetchResponse,
} from '..';
export interface Clients {
core: CoreV1Api;
apps: AppsV1Api;
}
export interface KubernetesClientBasedFetcherOptions {
kubernetesClientProvider: KubernetesClientProvider;
logger: Logger;
}
export class KubernetesClientBasedFetcher implements KubernetesFetcher {
private readonly kubernetesClientProvider: KubernetesClientProvider;
private readonly logger: Logger;
constructor({
kubernetesClientProvider,
logger,
}: KubernetesClientBasedFetcherOptions) {
this.kubernetesClientProvider = kubernetesClientProvider;
this.logger = logger;
}
fetchObjectsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
objectTypesToFetch: Set<KubernetesObjectTypes>,
): Promise<FetchResponse[]> {
return Promise.all(
Array.from(objectTypesToFetch).map(type => {
return this.fetchByObjectType(serviceId, clusterDetails, type);
}),
);
}
private fetchByObjectType(
serviceId: string,
clusterDetails: ClusterDetails,
type: KubernetesObjectTypes,
): Promise<FetchResponse> {
switch (type) {
case 'pods':
return this.fetchPodsByServiceId(serviceId, clusterDetails).then(r => ({
type: type,
resources: r,
}));
case 'configmaps':
return this.fetchConfigMapsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'deployments':
return this.fetchDeploymentsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'replicasets':
return this.fetchReplicaSetsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'secrets':
return this.fetchSecretsByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
case 'services':
return this.fetchServicesByServiceId(
serviceId,
clusterDetails,
).then(r => ({ type: type, resources: r }));
default:
// unrecognised type
throw new Error(`unrecognised type=${type}`);
}
}
private singleClusterFetch<T>(
clusterDetails: ClusterDetails,
fn: (client: Clients) => Promise<{ body: { items: Array<T> } }>,
): Promise<Array<T>> {
const core = this.kubernetesClientProvider.getCoreClientByClusterDetails(
clusterDetails,
);
const apps = this.kubernetesClientProvider.getAppsClientByClusterDetails(
clusterDetails,
);
this.logger.debug(`calling cluster=${clusterDetails.name}`);
return fn({ core, apps }).then(result => {
return result.body.items;
});
}
private fetchServicesByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Service>> {
return this.singleClusterFetch<V1Service>(clusterDetails, ({ core }) =>
core.listServiceForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchPodsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Pod>> {
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
core.listPodForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchConfigMapsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1ConfigMap>> {
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
core.listConfigMapForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchSecretsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Secret>> {
return this.singleClusterFetch<V1Secret>(clusterDetails, ({ core }) =>
core.listSecretForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchDeploymentsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1Deployment>> {
return this.singleClusterFetch<V1Deployment>(clusterDetails, ({ apps }) =>
apps.listDeploymentForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
private fetchReplicaSetsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
): Promise<Array<V1ReplicaSet>> {
return this.singleClusterFetch<V1ReplicaSet>(clusterDetails, ({ apps }) =>
apps.listReplicaSetForAllNamespaces(
false,
'',
'',
`backstage.io/kubernetes-id=${serviceId}`,
),
);
}
}
@@ -0,0 +1,242 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { handleGetKubernetesObjectsByServiceId } from './getKubernetesObjectsByServiceIdHandler';
import { getVoidLogger } from '@backstage/backend-common';
import { ClusterDetails } from '..';
const TEST_SERVICE_ID = 'my-service';
const fetchObjectsByServiceId = jest.fn();
const getClusterByServiceId = jest.fn();
const mockFetch = (mock: jest.Mock) => {
mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) =>
Promise.resolve([
{
type: 'pods',
resources: [
{
metadata: {
name: `my-pods-${serviceId}-${clusterDetails.name}`,
},
},
],
},
{
type: 'configmaps',
resources: [
{
metadata: {
name: `my-configmaps-${serviceId}-${clusterDetails.name}`,
},
},
],
},
{
type: 'services',
resources: [
{
metadata: {
name: `my-services-${serviceId}-${clusterDetails.name}`,
},
},
],
},
]),
);
};
describe('handleGetKubernetesObjectsByServiceId', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('retrieve objects for one cluster', async () => {
getClusterByServiceId.mockImplementation(() =>
Promise.resolve([
{
name: 'test-cluster',
},
]),
);
mockFetch(fetchObjectsByServiceId);
const result = await handleGetKubernetesObjectsByServiceId(
TEST_SERVICE_ID,
{
fetchObjectsByServiceId,
},
{
getClusterByServiceId,
},
getVoidLogger(),
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsByServiceId.mock.calls.length).toBe(1);
expect(result).toStrictEqual({
items: [
{
cluster: {
name: 'test-cluster',
},
resources: [
{
resources: [
{
metadata: {
name: 'my-pods-my-service-test-cluster',
},
},
],
type: 'pods',
},
{
resources: [
{
metadata: {
name: 'my-configmaps-my-service-test-cluster',
},
},
],
type: 'configmaps',
},
{
resources: [
{
metadata: {
name: 'my-services-my-service-test-cluster',
},
},
],
type: 'services',
},
],
},
],
});
});
it('retrieve objects for two clusters', async () => {
getClusterByServiceId.mockImplementation(() =>
Promise.resolve([
{
name: 'test-cluster',
},
{
name: 'other-cluster',
},
]),
);
mockFetch(fetchObjectsByServiceId);
const result = await handleGetKubernetesObjectsByServiceId(
TEST_SERVICE_ID,
{
fetchObjectsByServiceId,
},
{
getClusterByServiceId,
},
getVoidLogger(),
);
expect(getClusterByServiceId.mock.calls.length).toBe(1);
expect(fetchObjectsByServiceId.mock.calls.length).toBe(2);
expect(result).toStrictEqual({
items: [
{
cluster: {
name: 'test-cluster',
},
resources: [
{
resources: [
{
metadata: {
name: 'my-pods-my-service-test-cluster',
},
},
],
type: 'pods',
},
{
resources: [
{
metadata: {
name: 'my-configmaps-my-service-test-cluster',
},
},
],
type: 'configmaps',
},
{
resources: [
{
metadata: {
name: 'my-services-my-service-test-cluster',
},
},
],
type: 'services',
},
],
},
{
cluster: {
name: 'other-cluster',
},
resources: [
{
resources: [
{
metadata: {
name: 'my-pods-my-service-other-cluster',
},
},
],
type: 'pods',
},
{
resources: [
{
metadata: {
name: 'my-configmaps-my-service-other-cluster',
},
},
],
type: 'configmaps',
},
{
resources: [
{
metadata: {
name: 'my-services-my-service-other-cluster',
},
},
],
type: 'services',
},
],
},
],
});
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import {
KubernetesClusterLocator,
KubernetesFetcher,
KubernetesObjectTypes,
ObjectsByServiceIdResponse,
} from '..';
export type GetKubernetesObjectsByServiceIdHandler = (
serviceId: string,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
logger: Logger,
objectsToFetch?: Set<KubernetesObjectTypes>,
) => Promise<ObjectsByServiceIdResponse>;
const DEFAULT_OBJECTS = new Set<KubernetesObjectTypes>([
'pods',
'services',
'configmaps',
'secrets',
'deployments',
'replicasets',
]);
export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async (
serviceId,
fetcher,
clusterLocator,
logger,
objectsToFetch = DEFAULT_OBJECTS,
) => {
const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId);
logger.info(
`serviceId=${serviceId} clusterDetails=${clusterDetails.map(c => c.name)}`,
);
return Promise.all(
clusterDetails.map(cd => {
return fetcher
.fetchObjectsByServiceId(serviceId, cd, objectsToFetch)
.then(result => {
return {
cluster: {
name: cd.name,
},
resources: result,
};
});
}),
).then(r => ({ items: r }));
};
@@ -0,0 +1,87 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { makeRouter } from './router';
import {
KubernetesClusterLocator,
KubernetesFetcher,
ObjectsByServiceIdResponse,
} from '..';
describe('router', () => {
let app: express.Express;
let kubernetesFetcher: jest.Mocked<KubernetesFetcher>;
let kubernetesClusterLocator: jest.Mocked<KubernetesClusterLocator>;
let handleGetByServiceId: jest.Mock<Promise<ObjectsByServiceIdResponse>>;
beforeAll(async () => {
kubernetesFetcher = {
fetchObjectsByServiceId: jest.fn(),
};
kubernetesClusterLocator = {
getClusterByServiceId: jest.fn(),
};
handleGetByServiceId = jest.fn();
const router = makeRouter(
getVoidLogger(),
kubernetesFetcher,
kubernetesClusterLocator,
handleGetByServiceId as any,
);
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /services/:serviceId', () => {
it('happy path: lists kubernetes objects', async () => {
const result = {
clusterOne: {
pods: [
{
metadata: {
name: 'pod1',
},
},
],
},
} as any;
handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result));
const response = await request(app).get('/services/test-service');
expect(response.status).toEqual(200);
expect(response.body).toEqual(result);
});
it('internal error: lists kubernetes objects', async () => {
handleGetByServiceId.mockRejectedValue(Error('some internal error'));
const response = await request(app).get('/services/test-service');
expect(response.status).toEqual(500);
expect(response.body).toEqual({ error: 'some internal error' });
});
});
});
@@ -17,19 +17,65 @@
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { ClusterLocatorMethod } from '../cluster-locator/types';
import { MultiTenantConfigClusterLocator } from '../cluster-locator/MultiTenantConfigClusterLocator';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import {
GetKubernetesObjectsByServiceIdHandler,
handleGetKubernetesObjectsByServiceId,
} from './getKubernetesObjectsByServiceIdHandler';
import { KubernetesClusterLocator, KubernetesFetcher } from '..';
export interface RouterOptions {
logger: Logger;
config: Config;
}
const makeRouter = (logger: Logger): express.Router => {
const getClusterLocator = (config: Config): KubernetesClusterLocator => {
const clusterLocatorMethod = config.getString(
'kubernetes.clusterLocatorMethod',
) as ClusterLocatorMethod;
switch (clusterLocatorMethod) {
case 'configMultiTenant':
return MultiTenantConfigClusterLocator.fromConfig(
config.getConfigArray('kubernetes.clusters'),
);
case 'http':
throw new Error('not implemented');
default:
throw new Error(
`Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`,
);
}
};
export const makeRouter = (
logger: Logger,
fetcher: KubernetesFetcher,
clusterLocator: KubernetesClusterLocator,
handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler,
): express.Router => {
const router = Router();
router.use(express.json());
// TODO error handling
router.get('/services/:serviceId', async (req, res) => {
const serviceId = req.params.serviceId;
logger.info(`HERE ${serviceId}`);
res.send({ serviceId });
try {
const response = await handleGetByServiceId(
serviceId,
fetcher,
clusterLocator,
logger,
);
res.send(response);
} catch (e) {
res.status(500).send({ error: e.message });
}
});
return router;
@@ -41,5 +87,18 @@ export async function createRouter(
const logger = options.logger;
logger.info('Initializing Kubernetes backend');
return makeRouter(logger);
const clusterLocator = getClusterLocator(options.config);
const fetcher = new KubernetesClientBasedFetcher({
kubernetesClientProvider: new KubernetesClientProvider(),
logger,
});
return makeRouter(
logger,
fetcher,
clusterLocator,
handleGetKubernetesObjectsByServiceId,
);
}
@@ -25,6 +25,7 @@ import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
export interface ApplicationOptions {
enableCors: boolean;
@@ -35,6 +36,7 @@ export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger } = options;
const config = ConfigReader.fromConfigs([]);
const app = express();
app.use(helmet());
@@ -44,7 +46,7 @@ export async function createStandaloneApplication(
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger }));
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
@@ -0,0 +1,103 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
V1ConfigMap,
V1Deployment,
V1Pod,
V1ReplicaSet,
V1Secret,
V1Service,
} from '@kubernetes/client-node';
export interface ClusterDetails {
name: string;
url: string;
// TODO this will eventually be configured by the auth translation work
serviceAccountToken: string | undefined;
}
export interface ClusterObjects {
cluster: { name: string };
resources: FetchResponse[];
}
export interface ObjectsByServiceIdResponse {
items: ClusterObjects[];
}
export type FetchResponse =
| PodFetchResponse
| ServiceFetchResponse
| ConfigMapFetchResponse
| SecretFetchResponse
| DeploymentFetchResponse
| ReplicaSetsFetchResponse;
// TODO fairly sure there's a easier way to do this
export type KubernetesObjectTypes =
| 'pods'
| 'services'
| 'configmaps'
| 'secrets'
| 'deployments'
| 'replicasets';
export interface PodFetchResponse {
type: 'pods';
resources: Array<V1Pod>;
}
export interface ServiceFetchResponse {
type: 'services';
resources: Array<V1Service>;
}
export interface ConfigMapFetchResponse {
type: 'configmaps';
resources: Array<V1ConfigMap>;
}
export interface SecretFetchResponse {
type: 'secrets';
resources: Array<V1Secret>;
}
export interface DeploymentFetchResponse {
type: 'deployments';
resources: Array<V1Deployment>;
}
export interface ReplicaSetsFetchResponse {
type: 'replicasets';
resources: Array<V1ReplicaSet>;
}
// Fetches information from a kubernetes cluster using the cluster details object
// to target a specific cluster
export interface KubernetesFetcher {
fetchObjectsByServiceId(
serviceId: string,
clusterDetails: ClusterDetails,
objectTypesToFetch: Set<KubernetesObjectTypes>,
): Promise<FetchResponse[]>;
}
// Used to locate which cluster(s) a service is running on
export interface KubernetesClusterLocator {
getClusterByServiceId(serviceId: string): Promise<ClusterDetails[]>;
}
+1
View File
@@ -22,6 +22,7 @@
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.23",
"@backstage/core": "^0.1.1-alpha.23",
"@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.23",
"@backstage/theme": "^0.1.1-alpha.23",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
+1 -1
View File
@@ -22,7 +22,7 @@ import { rootCatalogKubernetesRouteRef } from './plugin';
import { KubernetesContent } from './components/KubernetesContent';
import { WarningPanel } from '@backstage/core';
const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes';
const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id';
export const Router = ({ entity }: { entity: Entity }) => {
const kubernetesAnnotationValue =
@@ -16,6 +16,7 @@
import { DiscoveryApi } from '@backstage/core';
import { KubernetesApi } from './types';
import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
export class KubernetesBackendClient implements KubernetesApi {
private readonly discoveryApi: DiscoveryApi;
@@ -37,7 +38,9 @@ export class KubernetesBackendClient implements KubernetesApi {
return await response.json();
}
async getObjectsByServiceId(serviceId: String): Promise<{}> {
async getObjectsByServiceId(
serviceId: String,
): Promise<ObjectsByServiceIdResponse> {
return await this.getRequired(`/services/${serviceId}`);
}
}
+2 -1
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '@backstage/core';
import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
export const kubernetesApiRef = createApiRef<KubernetesApi>({
id: 'plugin.kubernetes.service',
@@ -23,5 +24,5 @@ export const kubernetesApiRef = createApiRef<KubernetesApi>({
});
export interface KubernetesApi {
getObjectsByServiceId(serviceId: String): Promise<{}>;
getObjectsByServiceId(serviceId: String): Promise<ObjectsByServiceIdResponse>;
}
@@ -15,25 +15,19 @@
*/
import React, { FC, useEffect, useState } from 'react';
import { Typography, Grid } from '@material-ui/core';
import {
InfoCard,
Page,
pageTheme,
Content,
ContentHeader,
useApi,
} from '@backstage/core';
import { Grid } from '@material-ui/core';
import { InfoCard, Page, pageTheme, Content, useApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { kubernetesApiRef } from '../../api/types';
import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend';
// TODO this is a temporary component used to construct the Kubernetes plugin boilerplate
export const KubernetesContent: FC<{ entity: Entity }> = ({ entity }) => {
const kubernetesApi = useApi(kubernetesApiRef);
const [kubernetesObjects, setKubernetesObjects] = useState<{} | undefined>(
undefined,
);
const [kubernetesObjects, setKubernetesObjects] = useState<
ObjectsByServiceIdResponse | undefined
>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
@@ -50,21 +44,28 @@ export const KubernetesContent: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Page theme={pageTheme.tool}>
<Content>
<ContentHeader title="This is where you would see your kubernetes objects" />
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="This is where you would see your kubernetes objects">
<Typography variant="body1">
{kubernetesObjects === undefined && <div>loading....</div>}
{error !== undefined && <div>{error}</div>}
{kubernetesObjects !== undefined && (
<div>
backend response: {JSON.stringify(kubernetesObjects)}
</div>
)}
</Typography>
</InfoCard>
</Grid>
{kubernetesObjects === undefined && <div>loading....</div>}
{error !== undefined && <div>{error}</div>}
{kubernetesObjects !== undefined && (
<div>
{kubernetesObjects.items.map((item, i) => (
<Grid item key={i}>
<InfoCard key={item.cluster.name} title={item.cluster.name}>
{item.resources.map((fr, j) => (
<div key={j}>
<br />
{fr.type}:{' '}
{(fr.resources as any)
.map((v: any) => v.metadata.name)
.join(' ')}
</div>
))}
</InfoCard>
</Grid>
))}
</div>
)}
</Grid>
</Content>
</Page>
+198 -18
View File
@@ -2441,6 +2441,34 @@
resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@kubernetes/client-node@^0.12.1":
version "0.12.2"
resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.12.2.tgz#8728684bd57d1cbcbe14fe742e79be7021403eea"
integrity sha512-J0UwyFl1Iv/IZ6WMP7LaizBEoKPnqwtc8tIO2q/X+EuDT7eGpPPAMHXSEOC/EI9JGIf0FaJEcDHhB/Dio/mKhw==
dependencies:
"@types/js-yaml" "^3.12.1"
"@types/node" "^10.12.0"
"@types/request" "^2.47.1"
"@types/stream-buffers" "^3.0.3"
"@types/tar" "^4.0.3"
"@types/underscore" "^1.8.9"
"@types/ws" "^6.0.1"
byline "^5.0.0"
execa "1.0.0"
isomorphic-ws "^4.0.1"
js-yaml "^3.13.1"
jsonpath-plus "^0.19.0"
openid-client "^4.1.1"
request "^2.88.0"
rfc4648 "^1.3.0"
shelljs "^0.8.2"
stream-buffers "^3.0.2"
tar "^6.0.2"
tmp-promise "^3.0.2"
tslib "^1.9.3"
underscore "^1.9.1"
ws "^7.3.1"
"@kyleshockey/object-assign-deep@^0.4.2":
version "0.4.2"
resolved "https://registry.npmjs.org/@kyleshockey/object-assign-deep/-/object-assign-deep-0.4.2.tgz#84900f0eefc372798f4751b5262830b8208922ec"
@@ -4638,6 +4666,11 @@
"@types/node" "*"
"@types/responselike" "*"
"@types/caseless@*":
version "0.12.2"
resolved "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"
integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==
"@types/classnames@^2.2.9":
version "2.2.10"
resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
@@ -4995,7 +5028,7 @@
resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
"@types/js-yaml@^3.12.5":
"@types/js-yaml@^3.12.1", "@types/js-yaml@^3.12.5":
version "3.12.5"
resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb"
integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww==
@@ -5139,6 +5172,11 @@
resolved "https://registry.npmjs.org/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9"
integrity sha512-dzjES1Egb4c1a89C7lKwQh8pwjYmlOAG9dW1pBgxEk57tMrLnssOfEthz8kdkNaBd7lIqQx7APm5+mZ619IiCQ==
"@types/node@^10.12.0":
version "10.17.35"
resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56"
integrity sha512-gXx7jAWpMddu0f7a+L+txMplp3FnHl53OhQIF9puXKq3hDGY/GjH+MF04oWnV/adPSCrbtHumDCFwzq2VhltWA==
"@types/node@^12.0.0":
version "12.12.53"
resolved "https://registry.npmjs.org/@types/node/-/node-12.12.53.tgz#be0d375933c3d15ef2380dafb3b0350ea7021129"
@@ -5387,6 +5425,16 @@
resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
"@types/request@^2.47.1":
version "2.48.5"
resolved "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0"
integrity sha512-/LO7xRVnL3DxJ1WkPGDQrp4VTV1reX9RkC85mJ+Qzykj2Bdw+mG15aAfDahc76HtknjzE16SX/Yddn6MxVbmGQ==
dependencies:
"@types/caseless" "*"
"@types/node" "*"
"@types/tough-cookie" "*"
form-data "^2.5.0"
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@@ -5472,6 +5520,13 @@
dependencies:
"@types/node" "*"
"@types/stream-buffers@^3.0.3":
version "3.0.3"
resolved "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.3.tgz#34e565bf64e3e4bdeee23fd4aa58d4636014a02b"
integrity sha512-NeFeX7YfFZDYsCfbuaOmFQ0OjSmHreKBpp7MQ4alWQBHeh2USLsj7qyMyn9t82kjqIX516CR/5SRHnARduRtbQ==
dependencies:
"@types/node" "*"
"@types/styled-jsx@^2.2.8":
version "2.2.8"
resolved "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.8.tgz#b50d13d8a3c34036282d65194554cf186bab7234"
@@ -5550,6 +5605,11 @@
dependencies:
"@types/node" "*"
"@types/tough-cookie@*":
version "4.0.0"
resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d"
integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==
"@types/uglify-js@*":
version "3.0.4"
resolved "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
@@ -5557,6 +5617,11 @@
dependencies:
source-map "^0.6.1"
"@types/underscore@^1.8.9":
version "1.10.23"
resolved "https://registry.npmjs.org/@types/underscore/-/underscore-1.10.23.tgz#cc672e8864000d288e1e39c609fd9cab84391ff3"
integrity sha512-vX1NPekXhrLquFWskH2thcvFAha187F/lM6xYOoEMZWwJ/6alSk0/ttmGP/YRqcqtCv0TMbZjYAdZyHAEcuU4g==
"@types/uuid@^8.0.0":
version "8.0.0"
resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0"
@@ -5618,6 +5683,13 @@
resolved "https://registry.npmjs.org/@types/whatwg-streams/-/whatwg-streams-0.0.7.tgz#28bfe73dc850562296367249c4b32a50db81e9d3"
integrity sha512-6sDiSEP6DWcY2ZolsJ2s39ZmsoGQ7KVwBDI3sESQsEm9P2dHTcqnDIHRZFRNtLCzWp7hCFGqYbw5GyfpQnJ01A==
"@types/ws@^6.0.1":
version "6.0.4"
resolved "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1"
integrity sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==
dependencies:
"@types/node" "*"
"@types/ws@^7.0.0":
version "7.2.6"
resolved "https://registry.npmjs.org/@types/ws/-/ws-7.2.6.tgz#516cbfb818310f87b43940460e065eb912a4178d"
@@ -7156,7 +7228,7 @@ base64-js@^1.0.2, base64-js@^1.2.0:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
base64url@3.x.x:
base64url@3.x.x, base64url@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
@@ -10630,6 +10702,19 @@ exec-sh@^0.3.2:
resolved "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5"
integrity sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==
execa@1.0.0, execa@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
dependencies:
cross-spawn "^6.0.0"
get-stream "^4.0.0"
is-stream "^1.1.0"
npm-run-path "^2.0.0"
p-finally "^1.0.0"
signal-exit "^3.0.0"
strip-eof "^1.0.0"
execa@3.4.0:
version "3.4.0"
resolved "https://registry.npmjs.org/execa/-/execa-3.4.0.tgz#c08ed4550ef65d858fac269ffc8572446f37eb89"
@@ -10659,19 +10744,6 @@ execa@^0.7.0:
signal-exit "^3.0.0"
strip-eof "^1.0.0"
execa@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
dependencies:
cross-spawn "^6.0.0"
get-stream "^4.0.0"
is-stream "^1.1.0"
npm-run-path "^2.0.0"
p-finally "^1.0.0"
signal-exit "^3.0.0"
strip-eof "^1.0.0"
execa@^4.0.0, execa@^4.0.1:
version "4.0.2"
resolved "https://registry.npmjs.org/execa/-/execa-4.0.2.tgz#ad87fb7b2d9d564f70d2b62d511bee41d5cbb240"
@@ -11337,7 +11409,7 @@ fork-ts-checker-webpack-plugin@^4.0.5, fork-ts-checker-webpack-plugin@^4.1.4:
tapable "^1.0.0"
worker-rpc "^0.1.0"
form-data@^2.3.1, form-data@^2.3.2:
form-data@^2.3.1, form-data@^2.3.2, form-data@^2.5.0:
version "2.5.1"
resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
@@ -11982,6 +12054,23 @@ got@^11.5.2:
p-cancelable "^2.0.0"
responselike "^2.0.0"
got@^11.6.2:
version "11.7.0"
resolved "https://registry.npmjs.org/got/-/got-11.7.0.tgz#a386360305571a74548872e674932b4ef70d3b24"
integrity sha512-7en2XwH2MEqOsrK0xaKhbWibBoZqy+f1RSUoIeF1BLcnf+pyQdDsljWMfmOh+QKJwuvDIiKx38GtPh5wFdGGjg==
dependencies:
"@sindresorhus/is" "^3.1.1"
"@szmarczak/http-timer" "^4.0.5"
"@types/cacheable-request" "^6.0.1"
"@types/responselike" "^1.0.0"
cacheable-lookup "^5.0.3"
cacheable-request "^7.0.1"
decompress-response "^6.0.0"
http2-wrapper "^1.0.0-beta.5.2"
lowercase-keys "^2.0.0"
p-cancelable "^2.0.0"
responselike "^2.0.0"
got@^9.6.0:
version "9.6.0"
resolved "https://registry.npmjs.org/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
@@ -13604,6 +13693,11 @@ isomorphic-form-data@~2.0.0:
dependencies:
form-data "^2.3.2"
isomorphic-ws@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc"
integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -14110,6 +14204,13 @@ jose@^1.27.1:
dependencies:
"@panva/asn1.js" "^1.0.0"
jose@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/jose/-/jose-2.0.2.tgz#fb22385b80c658cc7a0cae05b7086c04c6be49f4"
integrity sha512-yD93lsiMA1go/qxSY/vXWBodmIZJIxeB7QhFi8z1yQ3KUwKENqI9UA8VCHlQ5h3x1zWuWZjoY87ByQzkQbIrQg==
dependencies:
"@panva/asn1.js" "^1.0.0"
js-cookie@^2.2.1:
version "2.2.1"
resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
@@ -14402,6 +14503,11 @@ jsonparse@^1.2.0:
resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
jsonpath-plus@^0.19.0:
version "0.19.0"
resolved "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-0.19.0.tgz#b901e57607055933dc9a8bef0cc25160ee9dd64c"
integrity sha512-GSVwsrzW9LsA5lzsqe4CkuZ9wp+kxBb2GwNniaWzI2YFn5Ig42rSW8ZxVpWXaAfakXNrx5pgY5AbQq7kzX29kg==
jsonpointer@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
@@ -15297,7 +15403,7 @@ make-dir@^3.0.0, make-dir@^3.0.2:
dependencies:
semver "^6.0.0"
make-error@1.x, make-error@^1.1.1:
make-error@1.x, make-error@^1.1.1, make-error@^1.3.6:
version "1.3.6"
resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
@@ -16500,6 +16606,11 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-hash@^2.0.1:
version "2.0.3"
resolved "https://registry.npmjs.org/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea"
integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg==
object-inspect@^1.7.0:
version "1.7.0"
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
@@ -16615,6 +16726,11 @@ octokit-pagination-methods@^1.1.0:
resolved "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
oidc-token-hash@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888"
integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg==
omggif@1.0.7:
version "1.0.7"
resolved "https://registry.npmjs.org/omggif/-/omggif-1.0.7.tgz#59d2eecb0263de84635b3feb887c0c9973f1e49d"
@@ -16683,6 +16799,20 @@ opencollective-postinstall@^2.0.2:
resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89"
integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw==
openid-client@^4.1.1:
version "4.1.1"
resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.1.1.tgz#3e8a25584c4292e9b9b03e60358f5549fb85197a"
integrity sha512-/qch3I3v8UtO0A7wVgyXJJjGX/knR8bv06DQpLuKQqLG5u4AHcgusGuVKPKAcneLZvHKbKovF2+3e2ngXyuudA==
dependencies:
base64url "^3.0.1"
got "^11.6.2"
jose "^2.0.2"
lru-cache "^6.0.0"
make-error "^1.3.6"
object-hash "^2.0.1"
oidc-token-hash "^5.0.0"
p-any "^3.0.0"
opn@^5.5.0:
version "5.5.0"
resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc"
@@ -16788,6 +16918,14 @@ overlayscrollbars@^1.10.2:
resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-1.13.0.tgz#1edb436328133b94877b558f77966d5497ca36a7"
integrity sha512-p8oHrMeRAKxXDMPI/EBNITj/zTVHKNnAnM59Im+xnoZUlV07FyTg46wom2286jJlXGGfcPFG/ba5NUiCwWNd4w==
p-any@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/p-any/-/p-any-3.0.0.tgz#79847aeed70b5d3a10ea625296c0c3d2e90a87b9"
integrity sha512-5rqbqfsRWNb0sukt0awwgJMlaep+8jV45S15SKKB34z4UuzjcofIfnriCBhWjZP2jbVtjt9yRl7buB6RlKsu9w==
dependencies:
p-cancelable "^2.0.0"
p-some "^5.0.0"
p-cancelable@^1.0.0:
version "1.1.0"
resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
@@ -16934,6 +17072,14 @@ p-retry@^3.0.1:
dependencies:
retry "^0.12.0"
p-some@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/p-some/-/p-some-5.0.0.tgz#8b730c74b4fe5169d7264a240ad010b6ebc686a4"
integrity sha512-Js5XZxo6vHjB9NOYAzWDYAIyyiPvva0DWESAIWIK7uhSpGsyg5FwUPxipU/SOQx5x9EqhOh545d1jo6cVkitig==
dependencies:
aggregate-error "^3.0.0"
p-cancelable "^2.0.0"
p-timeout@^3.1.0:
version "3.2.0"
resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
@@ -19764,6 +19910,11 @@ reusify@^1.0.4:
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rfc4648@^1.3.0:
version "1.4.0"
resolved "https://registry.npmjs.org/rfc4648/-/rfc4648-1.4.0.tgz#c75b2856ad2e2d588b6ddb985d556f1f7f2a2abd"
integrity sha512-3qIzGhHlMHA6PoT6+cdPKZ+ZqtxkIvg8DZGKA5z6PQ33/uuhoJ+Ws/D/J9rXW6gXodgH8QYlz2UCl+sdUDmNIg==
rgb-regex@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1"
@@ -20303,7 +20454,7 @@ shell-quote@1.7.2:
resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==
shelljs@^0.8.3:
shelljs@^0.8.2, shelljs@^0.8.3:
version "0.8.4"
resolved "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
@@ -20867,6 +21018,11 @@ stream-browserify@^2.0.1:
inherits "~2.0.1"
readable-stream "^2.0.2"
stream-buffers@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz#5249005a8d5c2d00b3a32e6e0a6ea209dc4f3521"
integrity sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==
stream-combiner@~0.0.4:
version "0.0.4"
resolved "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14"
@@ -21731,6 +21887,13 @@ tinycolor2@^1.4.1:
resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8"
integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=
tmp-promise@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.2.tgz#6e933782abff8b00c3119d63589ca1fb9caaa62a"
integrity sha512-OyCLAKU1HzBjL6Ev3gxUeraJNlbNingmi8IrHHEsYH8LTmEuhvYfqvhn2F/je+mjf4N58UmZ96OMEy1JanSCpA==
dependencies:
tmp "^0.2.0"
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -21738,6 +21901,13 @@ tmp@^0.0.33:
dependencies:
os-tmpdir "~1.0.2"
tmp@^0.2.0:
version "0.2.1"
resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==
dependencies:
rimraf "^3.0.0"
tmp@~0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877"
@@ -22190,6 +22360,11 @@ undefsafe@^2.0.2:
dependencies:
debug "^2.2.0"
underscore@^1.9.1:
version "1.11.0"
resolved "https://registry.npmjs.org/underscore/-/underscore-1.11.0.tgz#dd7c23a195db34267186044649870ff1bab5929e"
integrity sha512-xY96SsN3NA461qIRKZ/+qox37YXPtSBswMGfiNptr+wrt6ds4HaMw23TP612fEyGekRE6LNRiLYr/aqbHXNedw==
unfetch@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz#6ec2dd0de887e58a4dee83a050ded80ffc4137db"
@@ -23243,6 +23418,11 @@ ws@^7.2.3:
resolved "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd"
integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==
ws@^7.3.1:
version "7.3.1"
resolved "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8"
integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==
x-is-string@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"