Kubernetes: initial CRD framework (#4892)

* Kubernetes: initial CRD framework

Signed-off-by: mclarke <mclarke@spotify.com>

* use luxon instead of date-fns

Signed-off-by: mclarke <mclarke@spotify.com>

* fix test

Signed-off-by: mclarke <mclarke@spotify.com>

* update ui schema

Signed-off-by: mclarke <mclarke@spotify.com>

* update app config

Signed-off-by: mclarke <mclarke@spotify.com>

* rollout tests

Signed-off-by: mclarke <mclarke@spotify.com>

* add default custom resource component

Signed-off-by: mclarke <mclarke@spotify.com>

* make custom resources optional

Signed-off-by: mclarke <mclarke@spotify.com>

* add docs

Signed-off-by: mclarke <mclarke@spotify.com>

* prettier

Signed-off-by: mclarke <mclarke@spotify.com>

* replace core-api usage with core

Signed-off-by: mclarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2021-03-11 10:32:41 +00:00
committed by GitHub
parent 6bd417b7b8
commit e2c1b3fb6a
53 changed files with 5853 additions and 198 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes': minor
'@backstage/plugin-kubernetes-backend': minor
---
Add initial CRD support framework
+30
View File
@@ -123,6 +123,34 @@ The Google Cloud project to look for Kubernetes clusters in.
The Google Cloud region to look for Kubernetes clusters in. Defaults to all
regions.
### `customResources` (optional)
Configures which [custom resources][3] to look for when returning an entity's
Kubernetes resources.
Defaults to empty array. router.ts Example:
```yaml
---
kubernetes:
customResources:
- group: 'argoproj.io'
apiVersion: 'v1alpha1'
plural: 'rollouts'
```
#### `customResources.\*.group`
The custom resource's group.
#### `customResources.\*.apiVersion`
The custom resource's apiVersion.
#### `customResources.\*.plural`
The plural representing the custom resource.
### Role Based Access Control
The current RBAC permissions required are read-only cluster wide, for the
@@ -176,3 +204,5 @@ for more info.
[1]: https://cloud.google.com/kubernetes-engine
[2]: https://cloud.google.com/docs/authentication/production#linux-or-macos
[3]:
https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/
+2 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ClusterLocatorMethod } from './src/types';
import { ClusterLocatorMethod, CustomResource } from './src/types';
export interface Config {
kubernetes?: {
@@ -22,5 +22,6 @@ export interface Config {
type: 'multiTenant';
};
clusterLocatorMethods: ClusterLocatorMethod[];
customResources?: CustomResource[];
};
}
@@ -20,6 +20,7 @@ import {
CoreV1Api,
KubeConfig,
NetworkingV1beta1Api,
CustomObjectsApi,
} from '@kubernetes/client-node';
import { ClusterDetails } from '../types/types';
@@ -78,4 +79,10 @@ export class KubernetesClientProvider {
return kc.makeApiClient(NetworkingV1beta1Api);
}
getCustomObjectsClient(clusterDetails: ClusterDetails) {
const kc = this.getKubeConfig(clusterDetails);
return kc.makeApiClient(CustomObjectsApi);
}
}
@@ -87,6 +87,7 @@ describe('handleGetKubernetesObjectsForService', () => {
{
getClustersByServiceId,
},
[],
);
const result = await sut.getKubernetesObjectsByEntity({
@@ -178,6 +179,7 @@ describe('handleGetKubernetesObjectsForService', () => {
{
getClustersByServiceId,
},
[],
);
const result = await sut.getKubernetesObjectsByEntity({
@@ -17,6 +17,7 @@
import { Logger } from 'winston';
import {
ClusterDetails,
CustomResource,
KubernetesFetcher,
KubernetesObjectTypes,
KubernetesRequestBody,
@@ -39,15 +40,18 @@ export class KubernetesFanOutHandler {
private readonly logger: Logger;
private readonly fetcher: KubernetesFetcher;
private readonly serviceLocator: KubernetesServiceLocator;
private readonly customResources: CustomResource[];
constructor(
logger: Logger,
fetcher: KubernetesFetcher,
serviceLocator: KubernetesServiceLocator,
customResources: CustomResource[],
) {
this.logger = logger;
this.fetcher = fetcher;
this.serviceLocator = serviceLocator;
this.customResources = customResources;
}
async getKubernetesObjectsByEntity(
@@ -93,6 +97,7 @@ export class KubernetesFanOutHandler {
clusterDetails: clusterDetailsItem,
objectTypesToFetch,
labelSelector,
customResources: this.customResources,
})
.then(result => {
return {
@@ -15,10 +15,9 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { ObjectFetchParams } from '../types/types';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
describe('KubernetesClientProvider', () => {
describe('KubernetesFetcher', () => {
let clientMock: any;
let kubernetesClientProvider: any;
let sut: KubernetesClientBasedFetcher;
@@ -35,6 +34,7 @@ describe('KubernetesClientProvider', () => {
getAppsClientByClusterDetails: jest.fn(() => clientMock),
getAutoscalingClientByClusterDetails: jest.fn(() => clientMock),
getNetworkingBeta1Client: jest.fn(() => clientMock),
getCustomObjectsClient: jest.fn(() => clientMock),
};
sut = new KubernetesClientBasedFetcher({
@@ -58,7 +58,7 @@ describe('KubernetesClientProvider', () => {
clientMock.listServiceForAllNamespaces.mockRejectedValue(errorResponse);
const result = await sut.fetchObjectsForService(<ObjectFetchParams>{
const result = await sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
@@ -68,6 +68,7 @@ describe('KubernetesClientProvider', () => {
},
objectTypesToFetch: new Set(['pods', 'services']),
labelSelector: '',
customResources: [],
});
expect(result).toStrictEqual({
@@ -122,7 +123,7 @@ describe('KubernetesClientProvider', () => {
},
});
const result = await sut.fetchObjectsForService(<ObjectFetchParams>{
const result = await sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
@@ -132,6 +133,7 @@ describe('KubernetesClientProvider', () => {
},
objectTypesToFetch: new Set(['pods', 'services']),
labelSelector: '',
customResources: [],
});
expect(result).toStrictEqual({
@@ -172,7 +174,7 @@ describe('KubernetesClientProvider', () => {
});
it('should throw error on unknown type', () => {
expect(() =>
sut.fetchObjectsForService(<ObjectFetchParams>{
sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
@@ -182,6 +184,7 @@ describe('KubernetesClientProvider', () => {
},
objectTypesToFetch: new Set<any>(['foo']),
labelSelector: '',
customResources: [],
}),
).toThrow('unrecognised type=foo');
@@ -304,7 +307,7 @@ describe('KubernetesClientProvider', () => {
},
});
await sut.fetchObjectsForService(<ObjectFetchParams>{
await sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
@@ -314,6 +317,7 @@ describe('KubernetesClientProvider', () => {
},
objectTypesToFetch: new Set(['pods', 'services']),
labelSelector: '',
customResources: [],
});
const mockCall = clientMock.listPodForAllNamespaces.mock.calls[0];
@@ -39,6 +39,7 @@ import {
KubernetesFetchError,
KubernetesObjectTypes,
ObjectFetchParams,
CustomResource,
} from '../types/types';
import { KubernetesClientProvider } from './KubernetesClientProvider';
@@ -120,7 +121,18 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
).catch(captureKubernetesErrorsRethrowOthers);
});
return Promise.all(fetchResults).then(fetchResultsToResponseWrapper);
const customObjectsFetchResults = params.customResources.map(cr => {
return this.fetchCustomResource(
params.clusterDetails,
cr,
params.labelSelector ||
`backstage.io/kubernetes-id=${params.serviceId}`,
).catch(captureKubernetesErrorsRethrowOthers);
});
return Promise.all(fetchResults.concat(customObjectsFetchResults)).then(
fetchResultsToResponseWrapper,
);
}
// TODO could probably do with a tidy up
@@ -173,6 +185,30 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
}
}
private fetchCustomResource(
clusterDetails: ClusterDetails,
customResource: CustomResource,
labelSelector: string,
): Promise<FetchResponse> {
const customObjects = this.kubernetesClientProvider.getCustomObjectsClient(
clusterDetails,
);
return customObjects
.listClusterCustomObject(
customResource.group,
customResource.apiVersion,
customResource.plural,
'',
'',
'',
labelSelector,
)
.then(r => {
return { type: 'customresources', resources: (r.body as any).items };
});
}
private singleClusterFetch<T>(
clusterDetails: ClusterDetails,
fn: (
@@ -26,6 +26,7 @@ import {
KubernetesRequestBody,
KubernetesServiceLocator,
ServiceLocatorMethod,
CustomResource,
} from '../types/types';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
@@ -99,6 +100,21 @@ export async function createRouter(
logger.info('Initializing Kubernetes backend');
const customResources: CustomResource[] = (
options.config.getOptionalConfigArray('kubernetes.customResources') ?? []
).map(
c =>
({
group: c.getString('group'),
apiVersion: c.getString('apiVersion'),
plural: c.getString('plural'),
} as CustomResource),
);
logger.info(
`action=LoadingCustomResources numOfCustomResources=${customResources.length}`,
);
const fetcher = new KubernetesClientBasedFetcher({
kubernetesClientProvider: new KubernetesClientProvider(),
logger,
@@ -122,6 +138,7 @@ export async function createRouter(
logger,
fetcher,
serviceLocator,
customResources,
);
return makeRouter(logger, kubernetesFanOutHandler, clusterDetails);
+16 -2
View File
@@ -61,7 +61,8 @@ export type FetchResponse =
| DeploymentFetchResponse
| ReplicaSetsFetchResponse
| HorizontalPodAutoscalersFetchResponse
| IngressesFetchResponse;
| IngressesFetchResponse
| CustomResourceFetchResponse;
// TODO fairly sure there's a easier way to do this
@@ -72,7 +73,8 @@ export type KubernetesObjectTypes =
| 'deployments'
| 'replicasets'
| 'horizontalpodautoscalers'
| 'ingresses';
| 'ingresses'
| 'customresources';
export interface PodFetchResponse {
type: 'pods';
@@ -109,11 +111,17 @@ export interface IngressesFetchResponse {
resources: Array<ExtensionsV1beta1Ingress>;
}
export interface CustomResourceFetchResponse {
type: 'customresources';
resources: Array<any>;
}
export interface ObjectFetchParams {
serviceId: string;
clusterDetails: ClusterDetails;
objectTypesToFetch: Set<KubernetesObjectTypes>;
labelSelector: string;
customResources: CustomResource[];
}
// Fetches information from a kubernetes cluster using the cluster details object
@@ -192,3 +200,9 @@ export type ClusterLocatorMethod =
export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http
export type AuthProviderType = 'google' | 'serviceAccount' | 'aws';
export interface CustomResource {
group: string;
apiVersion: string;
plural: string;
}
+3 -1
View File
@@ -32,9 +32,9 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.7.3",
"@backstage/plugin-catalog-react": "^0.1.1",
"@backstage/config": "^0.1.3",
"@backstage/core": "^0.7.0",
"@backstage/plugin-catalog-react": "^0.1.1",
"@backstage/plugin-kubernetes-backend": "^0.2.8",
"@backstage/theme": "^0.2.3",
"@kubernetes/client-node": "^0.13.2",
@@ -42,6 +42,8 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"js-yaml": "^4.0.0",
"lodash": "^4.17.21",
"luxon": "^1.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
+8 -1
View File
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { ClusterLocatorMethod } from '@backstage/plugin-kubernetes-backend';
import {
ClusterLocatorMethod,
CustomResource,
} from '@backstage/plugin-kubernetes-backend';
export interface Config {
kubernetes?: {
@@ -31,5 +34,9 @@ export interface Config {
* @visibility frontend
*/
clusterLocatorMethods: ClusterLocatorMethod[];
/**
* @visibility frontend
*/
customResources?: CustomResource[];
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
/*
* Copyright 2021 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 React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../../hooks/test-utils';
import * as rollout from './__fixtures__/rollout.json';
import * as pausedRollout from './__fixtures__/paused-rollout.json';
import * as abortedRollout from './__fixtures__/aborted-rollout.json';
import * as groupedResources from './__fixtures__/grouped-resources.json';
import { RolloutAccordions } from './Rollout';
import { DateTime, Duration } from 'luxon';
describe('Rollout', () => {
it('should render RolloutAccordion', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
const { getByText, queryByText } = render(
wrapper(wrapInTestApp(<RolloutAccordions rollouts={[rollout] as any} />)),
);
expect(getByText('dice-roller')).toBeInTheDocument();
expect(getByText('Rollout')).toBeInTheDocument();
expect(getByText('2 pods')).toBeInTheDocument();
expect(getByText('No pods with errors')).toBeInTheDocument();
expect(queryByText('Paused')).toBeNull();
});
it('should render RolloutAccordion with error', async () => {
const wrapper = kubernetesProviders(
groupedResources,
new Set(['dice-roller-6c8646bfd-2m5hv']),
);
const { getByText, queryByText } = render(
wrapper(wrapInTestApp(<RolloutAccordions rollouts={[rollout] as any} />)),
);
expect(getByText('dice-roller')).toBeInTheDocument();
expect(getByText('Rollout')).toBeInTheDocument();
expect(getByText('2 pods')).toBeInTheDocument();
expect(getByText('1 pod with errors')).toBeInTheDocument();
expect(queryByText('Paused')).toBeNull();
});
it('should render Paused Rollout with pause text', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
(pausedRollout.status.pauseConditions[0]
.startTime as any) = DateTime.local()
// millis * secs * mins = 45 mins
.minus(Duration.fromMillis(1000 * 60 * 45));
const { getByText } = render(
wrapper(
wrapInTestApp(<RolloutAccordions rollouts={[pausedRollout] as any} />),
),
);
expect(getByText('dice-roller')).toBeInTheDocument();
expect(getByText('Rollout')).toBeInTheDocument();
expect(getByText('2 pods')).toBeInTheDocument();
expect(getByText('No pods with errors')).toBeInTheDocument();
expect(getByText('Paused (45 minutes ago)')).toBeInTheDocument();
});
it('should render aborted Rollout with aborted text', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
const { getByText, getAllByText, queryByText } = render(
wrapper(
wrapInTestApp(
<RolloutAccordions
defaultExpanded
rollouts={[abortedRollout] as any}
/>,
),
),
);
expect(getByText('dice-roller')).toBeInTheDocument();
expect(getByText('Rollout')).toBeInTheDocument();
expect(getByText('2 pods')).toBeInTheDocument();
expect(getByText('No pods with errors')).toBeInTheDocument();
expect(queryByText('Paused')).toBeNull();
expect(getByText('Rollout status')).toBeInTheDocument();
expect(getAllByText('Aborted')).toHaveLength(2);
expect(
getByText('some metric related failure message'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,278 @@
/*
* 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 React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Divider,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Pod, V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { StatusError, StatusOK } from '@backstage/core';
import { PodsTable } from '../../Pods';
import { HorizontalPodAutoscalerDrawer } from '../../HorizontalPodAutoscalers';
import { RolloutDrawer } from './RolloutDrawer';
import PauseIcon from '@material-ui/icons/Pause';
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';
import { DateTime } from 'luxon';
import { StepsProgress } from './StepsProgress';
import {
PodNamesWithErrorsContext,
GroupedResponsesContext,
} from '../../../hooks';
import {
getMatchingHpa,
getOwnedPodsThroughReplicaSets,
} from '../../../utils/owner';
type RolloutAccordionsProps = {
rollouts: any[];
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type RolloutAccordionProps = {
rollout: any;
ownedPods: V1Pod[];
defaultExpanded?: boolean;
matchingHpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
type RolloutSummaryProps = {
rollout: any;
numberOfCurrentPods: number;
numberOfPodsWithErrors: number;
hpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
const AbortedTitle = (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<ErrorOutlineIcon />
<Typography variant="subtitle1">Aborted</Typography>
</div>
);
const findAbortedMessage = (rollout: any): string | undefined =>
rollout.status?.conditions?.find(
(c: any) =>
c.type === 'Progressing' &&
c.status === 'False' &&
c.reason === 'RolloutAborted',
)?.message;
const RolloutSummary = ({
rollout,
numberOfCurrentPods,
numberOfPodsWithErrors,
hpa,
}: RolloutSummaryProps) => {
const pauseTime: string | undefined = rollout.status?.pauseConditions?.find(
(p: any) => p.reason === 'CanaryPauseStep',
)?.startTime;
const abortedMessage = findAbortedMessage(rollout);
return (
<Grid container direction="row" justify="flex-start" alignItems="center">
<Grid xs={3} item>
<RolloutDrawer rollout={rollout} />
</Grid>
<Grid item xs={1}>
<Divider style={{ height: '5em' }} orientation="vertical" />
</Grid>
{hpa && (
<Grid item xs={3}>
<HorizontalPodAutoscalerDrawer hpa={hpa}>
<Grid
item
container
direction="column"
justify="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="subtitle2">
min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '}
{hpa.spec?.maxReplicas ?? '?'}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
current CPU usage:{' '}
{hpa.status?.currentCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
target CPU usage:{' '}
{hpa.spec?.targetCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
</Grid>
</HorizontalPodAutoscalerDrawer>
</Grid>
)}
<Grid
item
container
xs={3}
direction="column"
justify="flex-start"
alignItems="flex-start"
>
<Grid item>
<StatusOK>{numberOfCurrentPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>
{numberOfPodsWithErrors} pod
{numberOfPodsWithErrors > 1 ? 's' : ''} with errors
</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
{pauseTime && (
<Grid item xs={3}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<PauseIcon />
<Typography variant="subtitle1">
Paused ({DateTime.fromISO(pauseTime).toRelative({ locale: 'en' })}
)
</Typography>
</div>
</Grid>
)}
{abortedMessage && (
<Grid item xs={3}>
{AbortedTitle}
</Grid>
)}
</Grid>
);
};
const RolloutAccordion = ({
rollout,
ownedPods,
matchingHpa,
defaultExpanded,
}: RolloutAccordionProps) => {
const podNamesWithErrors = useContext(PodNamesWithErrorsContext);
const podsWithErrors = ownedPods.filter(p =>
podNamesWithErrors.has(p.metadata?.name ?? ''),
);
const currentStepIndex = rollout.status?.currentStepIndex ?? 0;
const abortedMessage = findAbortedMessage(rollout);
return (
<Accordion
defaultExpanded={defaultExpanded}
TransitionProps={{ unmountOnExit: true }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<RolloutSummary
rollout={rollout}
numberOfCurrentPods={ownedPods.length}
numberOfPodsWithErrors={podsWithErrors.length}
hpa={matchingHpa}
/>
</AccordionSummary>
<AccordionDetails>
<div style={{ width: '100%' }}>
<div>
<Typography variant="h6">Rollout status</Typography>
</div>
<div style={{ margin: '1rem' }}>
{abortedMessage && (
<>
{AbortedTitle}
<Typography variant="subtitle2">{abortedMessage}</Typography>
</>
)}
<StepsProgress
aborted={abortedMessage !== undefined}
steps={rollout.spec?.strategy?.canary?.steps ?? []}
currentStepIndex={currentStepIndex}
/>
</div>
<div>
<PodsTable pods={ownedPods} />
</div>
</div>
</AccordionDetails>
</Accordion>
);
};
export const RolloutAccordions = ({
rollouts,
defaultExpanded = false,
}: RolloutAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justify="flex-start"
alignItems="flex-start"
>
{rollouts.map((rollout, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<RolloutAccordion
defaultExpanded={defaultExpanded}
matchingHpa={getMatchingHpa(
rollout.metadata?.name,
'rollout',
groupedResponses.horizontalPodAutoscalers,
)}
ownedPods={getOwnedPodsThroughReplicaSets(
rollout,
groupedResponses.replicaSets,
groupedResponses.pods,
)}
rollout={rollout}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,55 @@
/*
* 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 React from 'react';
import { KubernetesDrawer } from '../../KubernetesDrawer/KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const RolloutDrawer = ({
rollout,
expanded,
}: {
rollout: any;
expanded?: boolean;
}) => {
return (
<KubernetesDrawer
object={rollout}
expanded={expanded}
kind="Rollout"
renderObject={() => ({})}
>
<Grid
container
direction="column"
justify="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="h5">
{rollout.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="body1">
Rollout
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
);
};
@@ -0,0 +1,139 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import pauseSteps from './__fixtures__/pause-steps';
import setWeightSteps from './__fixtures__/setweight-steps';
import analysisSteps from './__fixtures__/analysis-steps';
import { wrapInTestApp } from '@backstage/test-utils';
import { StepsProgress } from './StepsProgress';
describe('StepsProgress', () => {
it('should render Pause step text', async () => {
const { getByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={pauseSteps}
/>,
),
);
expect(getByText('pause for 1h')).toBeInTheDocument();
expect(getByText('infinite pause')).toBeInTheDocument();
});
it('should render SetWeight step text', async () => {
const { getByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={setWeightSteps}
/>,
),
);
expect(getByText('setWeight 10%')).toBeInTheDocument();
expect(getByText('setWeight 95%')).toBeInTheDocument();
});
it('should render Analysis step text', async () => {
const { getAllByText, getByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={analysisSteps}
/>,
),
);
expect(getAllByText('analysis templates:')).toHaveLength(2);
expect(getByText('always-pass')).toBeInTheDocument();
expect(getByText('always-fail')).toBeInTheDocument();
expect(getByText('req-rate (cluster scoped)')).toBeInTheDocument();
});
it('should render 3 different steps', async () => {
const { getByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
),
);
expect(getByText('setWeight 10%')).toBeInTheDocument();
expect(getByText('pause for 1h')).toBeInTheDocument();
expect(getByText('analysis templates:')).toBeInTheDocument();
expect(getByText('always-pass')).toBeInTheDocument();
expect(getByText('Canary promoted')).toBeInTheDocument();
});
it('current step is highlighted, previous steps are ticked', async () => {
const { getByText, queryByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={1}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
),
);
// It is ticked, so it's not visible
expect(queryByText('1')).toBeNull();
// The current step
expect(getByText('2')).toBeInTheDocument();
// The future step
expect(getByText('3')).toBeInTheDocument();
// The canary promoted step should always be added at the end
expect(getByText('4')).toBeInTheDocument();
});
it('aborted canary has all steps grey', async () => {
const { getByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={2}
aborted
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
),
);
expect(getByText('1')).toBeInTheDocument();
expect(getByText('2')).toBeInTheDocument();
expect(getByText('3')).toBeInTheDocument();
expect(getByText('4')).toBeInTheDocument();
});
it('promoted canary has all steps ticked', async () => {
const { queryByText } = render(
wrapInTestApp(
<StepsProgress
currentStepIndex={3}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
),
);
expect(queryByText('1')).toBeNull();
expect(queryByText('2')).toBeNull();
expect(queryByText('3')).toBeNull();
expect(queryByText('4')).toBeNull();
});
});
@@ -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 React from 'react';
import { Step, StepLabel, Stepper } from '@material-ui/core';
import {
ArgoRolloutCanaryStep,
SetWeightStep,
PauseStep,
AnalysisStep,
} from './types';
interface StepsProgressProps {
currentStepIndex: number;
aborted: boolean;
steps: ArgoRolloutCanaryStep[];
children?: React.ReactNode;
}
const isSetWeightStep = (step: ArgoRolloutCanaryStep): step is SetWeightStep =>
step.hasOwnProperty('setWeight');
const isPauseStep = (step: ArgoRolloutCanaryStep): step is PauseStep =>
step.hasOwnProperty('pause');
const isAnalysisStep = (step: ArgoRolloutCanaryStep): step is AnalysisStep =>
step.hasOwnProperty('analysis');
const createLabelForStep = (step: ArgoRolloutCanaryStep): React.ReactNode => {
if (isSetWeightStep(step)) {
return `setWeight ${step.setWeight}%`;
} else if (isPauseStep(step)) {
return step.pause.duration === undefined
? 'infinite pause'
: `pause for ${step.pause.duration}`;
} else if (isAnalysisStep(step)) {
return (
<div>
<p>analysis templates:</p>
{step.analysis.templates.map((t, i) => (
<p key={i}>{`${t.templateName}${
t.clusterScope ? ' (cluster scoped)' : ''
}`}</p>
))}
</div>
);
}
return 'unknown step';
};
export const StepsProgress = ({
currentStepIndex,
aborted,
steps,
}: StepsProgressProps) => {
// If the activeStep is greater/equal to the number of steps
// Then the canary is being promoted
// Increase the step index to mark the 'canary promoted' step as done also
const activeStepIndex =
currentStepIndex >= steps.length ? currentStepIndex + 1 : currentStepIndex;
/*
* When the Rollout is aborted set the active step to -1
* otherwise it appears to always be on the first step
*/
return (
<Stepper activeStep={aborted ? -1 : activeStepIndex} alternativeLabel>
{steps
.map((step, i) => (
<Step key={i}>
<StepLabel data-testid={`step-${i}`}>
{createLabelForStep(step)}
</StepLabel>
</Step>
))
.concat(
<Step key="-1">
<StepLabel data-testid="step--1">Canary promoted</StepLabel>
</Step>,
)}
</Stepper>
);
};
@@ -0,0 +1,129 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "some metric related failure message",
"reason": "RolloutAborted",
"status": "False",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 0,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2021 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 { AnalysisStep } from '../types';
export const steps: AnalysisStep[] = [
{
analysis: {
templates: [
{
templateName: 'always-pass',
},
{
templateName: 'always-fail',
},
],
},
},
{
analysis: {
templates: [
{
templateName: 'req-rate',
clusterScope: true,
},
],
},
},
];
export default steps;
@@ -0,0 +1,576 @@
{
"pods": [
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:generateName": {},
"f:labels": {
".": {},
"f:app": {},
"f:backstage.io/kubernetes-id": {},
"f:pod-template-hash": {}
},
"f:ownerReferences": {
".": {},
"k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": {
".": {},
"f:apiVersion": {},
"f:blockOwnerDeletion": {},
"f:controller": {},
"f:kind": {},
"f:name": {},
"f:uid": {}
}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:name": {},
"f:ports": {
".": {},
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {},
"f:terminationMessagePath": {},
"f:terminationMessagePolicy": {}
}
},
"f:dnsPolicy": {},
"f:enableServiceLinks": {},
"f:restartPolicy": {},
"f:schedulerName": {},
"f:securityContext": {},
"f:terminationGracePeriodSeconds": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-25T09:58:50.000Z"
},
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"ContainersReady\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Initialized\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Ready\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
}
},
"f:containerStatuses": {},
"f:hostIP": {},
"f:phase": {},
"f:podIP": {},
"f:podIPs": {
".": {},
"k:{\"ip\":\"172.17.0.11\"}": {
".": {},
"f:ip": {}
}
},
"f:startTime": {}
}
},
"manager": "kubelet",
"operation": "Update",
"time": "2020-09-25T09:58:55.000Z"
}
],
"name": "dice-roller-6c8646bfd-2m5hv",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593216",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv",
"uid": "aadb71c0-36fa-43e3-b38a-162f134d4359"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.11",
"podIPs": [
{
"ip": "172.17.0.11"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
},
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:generateName": {},
"f:labels": {
".": {},
"f:app": {},
"f:backstage.io/kubernetes-id": {},
"f:pod-template-hash": {}
},
"f:ownerReferences": {
".": {},
"k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": {
".": {},
"f:apiVersion": {},
"f:blockOwnerDeletion": {},
"f:controller": {},
"f:kind": {},
"f:name": {},
"f:uid": {}
}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:name": {},
"f:ports": {
".": {},
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {},
"f:terminationMessagePath": {},
"f:terminationMessagePolicy": {}
}
},
"f:dnsPolicy": {},
"f:enableServiceLinks": {},
"f:restartPolicy": {},
"f:schedulerName": {},
"f:securityContext": {},
"f:terminationGracePeriodSeconds": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-25T09:58:50.000Z"
},
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"ContainersReady\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Initialized\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Ready\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
}
},
"f:containerStatuses": {},
"f:hostIP": {},
"f:phase": {},
"f:podIP": {},
"f:podIPs": {
".": {},
"k:{\"ip\":\"172.17.0.7\"}": {
".": {},
"f:ip": {}
}
},
"f:startTime": {}
}
},
"manager": "kubelet",
"operation": "Update",
"time": "2020-09-25T09:58:55.000Z"
}
],
"name": "dice-roller-6c8646bfd-4v6s8",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593221",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-4v6s8",
"uid": "32e56490-6f20-4757-991f-a0c7fb407358"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://d91cc76c41249b8d3dfcf538d180b471b2a7966aae0d17a818307c8a9b4af897",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.7",
"podIPs": [
{
"ip": "172.17.0.7"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
}
],
"horizontalPodAutoscalers": [],
"replicaSets": [
{
"metadata": {
"annotations": {
"deployment.kubernetes.io/desired-replicas": "10",
"deployment.kubernetes.io/max-replicas": "13",
"deployment.kubernetes.io/revision": "2"
},
"creationTimestamp": "2020-09-24T11:39:26.000Z",
"generation": 3,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [],
"name": "dice-roller-6c8646bfd",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "Rollout",
"name": "dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
}
],
"resourceVersion": "593228",
"selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
},
"spec": {
"replicas": 10,
"selector": {
"matchLabels": {
"app": "dice-roller",
"pod-template-hash": "6c8646bfd"
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"dnsPolicy": "ClusterFirst",
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"terminationGracePeriodSeconds": 30
}
}
},
"status": {
"availableReplicas": 10,
"fullyLabeledReplicas": 10,
"observedGeneration": 3,
"readyReplicas": 10,
"replicas": 10
}
}
]
}
@@ -0,0 +1,29 @@
/*
* Copyright 2021 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 { PauseStep } from '../types';
export const steps: PauseStep[] = [
{
pause: {
duration: '1h',
},
},
{
pause: {},
},
];
export default steps;
@@ -0,0 +1,135 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"pauseConditions": [
{
"reason": "CanaryPauseStep",
"startTime": "SET DYNAMICALLY IN TEST"
}
],
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "ReplicaSet \"dice-roller-546c476497\" has successfully progressed.",
"reason": "NewReplicaSetAvailable",
"status": "True",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 2,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -0,0 +1,129 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "ReplicaSet \"dice-roller-546c476497\" has successfully progressed.",
"reason": "NewReplicaSetAvailable",
"status": "True",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 6,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -0,0 +1,27 @@
/*
* Copyright 2021 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 { SetWeightStep } from '../types';
export const steps: SetWeightStep[] = [
{
setWeight: 10,
},
{
setWeight: 95,
},
];
export default steps;
@@ -0,0 +1,16 @@
/*
* 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 * from './Rollout';
@@ -0,0 +1,36 @@
/*
* 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 interface SetWeightStep {
setWeight: number;
}
export interface PauseStep {
pause: {
duration?: string;
};
}
export interface AnalysisStep {
analysis: {
templates: {
templateName: string;
clusterScope?: boolean;
}[];
};
}
export type ArgoRolloutCanaryStep = SetWeightStep | PauseStep | AnalysisStep;
@@ -0,0 +1,55 @@
/*
* 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 React, { useContext } from 'react';
import lodash, { Dictionary } from 'lodash';
import { RolloutAccordions } from './ArgoRollouts';
import { DefaultCustomResourceAccordions } from './DefaultCustomResource';
import { GroupedResponsesContext } from '../../hooks';
interface CustomResourcesProps {
children?: React.ReactNode;
}
const kindToResource = (customResources: any[]): Dictionary<any[]> => {
return lodash.groupBy(customResources, value => {
return value.kind;
});
};
export const CustomResources = ({}: CustomResourcesProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
const kindToResourceMap = kindToResource(groupedResponses.customResources);
return (
<>
{Object.entries(kindToResourceMap).map(([kind, resources], i) => {
switch (kind) {
case 'Rollout':
return <RolloutAccordions key={i} rollouts={resources} />;
default:
return (
<DefaultCustomResourceAccordions
key={i}
customResources={resources}
customResourceName={kind}
/>
);
}
})}
</>
);
};
@@ -0,0 +1,41 @@
/*
* Copyright 2021 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 React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
import * as ar from './__fixtures__/analysis-run.json';
import { DefaultCustomResourceAccordions } from './DefaultCustomResource';
describe('DefaultCustomResource', () => {
it('should render DefaultCustomResource Accordion', async () => {
const wrapper = kubernetesProviders({}, new Set([]));
const { getByText } = render(
wrapper(
wrapInTestApp(
<DefaultCustomResourceAccordions
customResources={[ar] as any}
customResourceName="AnalysisRun"
/>,
),
),
);
expect(getByText('dice-roller-546c476497-4-1')).toBeInTheDocument();
expect(getByText('AnalysisRun')).toBeInTheDocument();
});
});
@@ -0,0 +1,118 @@
/*
* Copyright 2021 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 React from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Divider,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { DefaultCustomResourceDrawer } from './DefaultCustomResourceDrawer';
import { StructuredMetadataTable } from '@backstage/core';
type DefaultCustomResourceAccordionsProps = {
customResources: any[];
customResourceName: string;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type DefaultCustomResourceAccordionProps = {
customResource: any;
customResourceName: string;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type DefaultCustomResourceSummaryProps = {
customResource: any;
customResourceName: string;
children?: React.ReactNode;
};
const DefaultCustomResourceSummary = ({
customResource,
customResourceName,
}: DefaultCustomResourceSummaryProps) => {
return (
<Grid container direction="row" justify="flex-start" alignItems="center">
<Grid xs={3} item>
<DefaultCustomResourceDrawer
customResource={customResource}
customResourceName={customResourceName}
/>
</Grid>
<Grid item xs={1}>
<Divider style={{ height: '5em' }} orientation="vertical" />
</Grid>
</Grid>
);
};
const DefaultCustomResourceAccordion = ({
customResource,
customResourceName,
defaultExpanded,
}: DefaultCustomResourceAccordionProps) => {
return (
<Accordion
defaultExpanded={defaultExpanded}
TransitionProps={{ unmountOnExit: true }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<DefaultCustomResourceSummary
customResource={customResource}
customResourceName={customResourceName}
/>
</AccordionSummary>
<AccordionDetails>
{customResource.hasOwnProperty('status') && (
<StructuredMetadataTable metadata={customResource.status} />
)}
</AccordionDetails>
</Accordion>
);
};
export const DefaultCustomResourceAccordions = ({
customResources,
customResourceName,
defaultExpanded = false,
}: DefaultCustomResourceAccordionsProps) => {
return (
<Grid
container
direction="column"
justify="flex-start"
alignItems="flex-start"
>
{customResources.map((cr, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<DefaultCustomResourceAccordion
defaultExpanded={defaultExpanded}
customResource={cr}
customResourceName={customResourceName}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,61 @@
/*
* Copyright 2021 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 React from 'react';
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
export const DefaultCustomResourceDrawer = ({
customResource,
customResourceName,
expanded,
}: {
customResource: any;
customResourceName: string;
expanded?: boolean;
}) => {
const capitalizedName = capitalize(customResourceName);
return (
<KubernetesDrawer
object={customResource}
expanded={expanded}
kind={capitalizedName}
renderObject={cr => cr}
>
<Grid
container
direction="column"
justify="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="h5">
{customResource.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="body1">
{capitalizedName}
</Typography>
</Grid>
</Grid>
</KubernetesDrawer>
);
};
@@ -0,0 +1,129 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "AnalysisRun",
"metadata": {
"annotations": {
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-09T15:05:49Z",
"generation": 11,
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"rollout-type": "Step",
"rollouts-pod-template-hash": "546c476497",
"step-index": "1"
},
"name": "dice-roller-546c476497-4-1",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "argoproj.io/v1alpha1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "Rollout",
"name": "dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eef60"
}
],
"resourceVersion": "3342462005",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/analysisruns/dice-roller-546c476497-4-1",
"uid": "b4e720ea-0488-42e2-8bd9-924c78843ee2"
},
"spec": {
"metrics": [
{
"count": 5,
"interval": "5s",
"name": "always-pass",
"provider": {
"job": {
"metadata": {
"creationTimestamp": null
},
"spec": {
"backoffLimit": 1,
"template": {
"metadata": {
"creationTimestamp": null
},
"spec": {
"containers": [
{
"image": "some-image",
"name": "always-pass",
"resources": {
"limits": {
"cpu": "800m",
"memory": "1G"
},
"requests": {
"cpu": "200m",
"memory": "1G"
}
}
}
],
"restartPolicy": "Never"
}
}
}
}
}
}
]
},
"status": {
"metricResults": [
{
"count": 5,
"measurements": [
{
"finishedAt": "2021-03-09T15:06:13Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.1"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:05:49Z"
},
{
"finishedAt": "2021-03-09T15:06:41Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.2"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:06:18Z"
},
{
"finishedAt": "2021-03-09T15:07:08Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.3"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:06:46Z"
},
{
"finishedAt": "2021-03-09T15:07:35Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.4"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:07:13Z"
},
{
"finishedAt": "2021-03-09T15:08:02Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.5"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:07:40Z"
}
],
"name": "always-pass",
"phase": "Successful",
"successful": 5
}
],
"phase": "Successful",
"startedAt": "2021-03-09T15:05:49Z"
}
}
@@ -0,0 +1,16 @@
/*
* 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 { CustomResources } from './CustomResources';
@@ -16,7 +16,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import * as deployments from './__fixtures__/2-deployments.json';
import * as deployments from '../../__fixtures__/2-deployments.json';
import { wrapInTestApp } from '@backstage/test-utils';
import { DeploymentDrawer } from './DeploymentDrawer';
@@ -17,20 +17,19 @@
import React from 'react';
import { render } from '@testing-library/react';
import { DeploymentsAccordions } from './DeploymentsAccordions';
import * as twoDeployFixture from './__fixtures__/2-deployments.json';
import * as twoDeployFixture from '../../__fixtures__/2-deployments.json';
import { wrapInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('DeploymentsAccordions', () => {
it('should render 2 deployments', async () => {
const wrapper = kubernetesProviders(
twoDeployFixture,
new Set(['dice-roller-canary-7d64cd756c-vtbdx']),
);
const { getByText } = render(
wrapInTestApp(
<DeploymentsAccordions
deploymentResources={twoDeployFixture as any}
clusterPodNamesWithErrors={
new Set(['dice-roller-canary-7d64cd756c-vtbdx'])
}
/>,
),
wrapper(wrapInTestApp(<DeploymentsAccordions />)),
);
expect(getByText('dice-roller')).toBeInTheDocument();
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import { DeploymentResources } from '../../types/types';
import React from 'react';
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
@@ -25,21 +24,25 @@ import {
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference';
import {
V1Deployment,
V1Pod,
V1ReplicaSet,
V1HorizontalPodAutoscaler,
} from '@kubernetes/client-node';
import { StatusError, StatusOK } from '@backstage/core';
import { PodsTable } from '../Pods';
import { DeploymentDrawer } from './DeploymentDrawer';
import { HorizontalPodAutoscalerDrawer } from '../HorizontalPodAutoscalers';
import {
getOwnedPodsThroughReplicaSets,
getMatchingHpa,
} from '../../utils/owner';
import {
GroupedResponsesContext,
PodNamesWithErrorsContext,
} from '../../hooks';
type DeploymentsAccordionsProps = {
deploymentResources: DeploymentResources;
clusterPodNamesWithErrors: Set<string>;
children?: React.ReactNode;
};
@@ -47,7 +50,6 @@ type DeploymentAccordionProps = {
deployment: V1Deployment;
ownedPods: V1Pod[];
matchingHpa?: V1HorizontalPodAutoscaler;
clusterPodNamesWithErrors: Set<string>;
children?: React.ReactNode;
};
@@ -136,10 +138,11 @@ const DeploymentAccordion = ({
deployment,
ownedPods,
matchingHpa,
clusterPodNamesWithErrors,
}: DeploymentAccordionProps) => {
const podNamesWithErrors = useContext(PodNamesWithErrorsContext);
const podsWithErrors = ownedPods.filter(p =>
clusterPodNamesWithErrors.has(p.metadata?.name ?? ''),
podNamesWithErrors.has(p.metadata?.name ?? ''),
);
return (
@@ -159,16 +162,8 @@ const DeploymentAccordion = ({
);
};
export const DeploymentsAccordions = ({
deploymentResources,
clusterPodNamesWithErrors,
}: DeploymentsAccordionsProps) => {
const isOwnedBy = (
ownerReferences: V1OwnerReference[],
obj: V1Pod | V1ReplicaSet | V1Deployment,
): boolean => {
return ownerReferences?.some(or => or.name === obj.metadata?.name);
};
export const DeploymentsAccordions = ({}: DeploymentsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
@@ -177,43 +172,23 @@ export const DeploymentsAccordions = ({
justify="flex-start"
alignItems="flex-start"
>
{deploymentResources.deployments.map((deployment, i) => (
{groupedResponses.deployments.map((deployment, i) => (
<Grid container item key={i} xs>
{deploymentResources.replicaSets
// Filter out replica sets with no replicas
.filter(rs => rs.status && rs.status.replicas > 0)
// Find the replica sets this deployment owns
.filter(rs =>
isOwnedBy(rs.metadata?.ownerReferences ?? [], deployment),
)
.map((rs, j) => {
// Find the pods this replica set owns and render them in the table
const ownedPods = deploymentResources.pods.filter(pod =>
isOwnedBy(pod.metadata?.ownerReferences ?? [], rs),
);
const matchingHpa = deploymentResources.horizontalPodAutoscalers.find(
(hpa: V1HorizontalPodAutoscaler) => {
return (
(hpa.spec?.scaleTargetRef?.kind ?? '').toLowerCase() ===
'deployment' &&
(hpa.spec?.scaleTargetRef?.name ?? '') ===
(deployment.metadata?.name ?? 'unknown-deployment')
);
},
);
return (
<Grid item key={j} xs>
<DeploymentAccordion
deployment={deployment}
ownedPods={ownedPods}
matchingHpa={matchingHpa}
clusterPodNamesWithErrors={clusterPodNamesWithErrors}
/>
</Grid>
);
})}
<Grid item xs>
<DeploymentAccordion
matchingHpa={getMatchingHpa(
deployment.metadata?.name,
'deployment',
groupedResponses.horizontalPodAutoscalers,
)}
ownedPods={getOwnedPodsThroughReplicaSets(
deployment,
groupedResponses.replicaSets,
groupedResponses.pods,
)}
deployment={deployment}
/>
</Grid>
</Grid>
))}
</Grid>
@@ -19,13 +19,14 @@ import { render } from '@testing-library/react';
import * as oneIngressFixture from './__fixtures__/2-ingresses.json';
import { wrapInTestApp } from '@backstage/test-utils';
import { IngressesAccordions } from './IngressesAccordions';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('IngressesAccordions', () => {
it('should render 1 ingress', async () => {
const wrapper = kubernetesProviders(oneIngressFixture, new Set());
const { getByText } = render(
wrapInTestApp(
<IngressesAccordions deploymentResources={oneIngressFixture as any} />,
),
wrapper(wrapInTestApp(<IngressesAccordions />)),
);
expect(getByText('awesome-service')).toBeInTheDocument();
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import { GroupedResponses } from '../../types/types';
import React from 'react';
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
@@ -27,10 +26,9 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node';
import { StructuredMetadataTable } from '@backstage/core';
import { IngressDrawer } from './IngressDrawer';
import { GroupedResponsesContext } from '../../hooks';
type IngressesAccordionsProps = {
deploymentResources: GroupedResponses;
};
type IngressesAccordionsProps = {};
type IngressAccordionProps = {
ingress: ExtensionsV1beta1Ingress;
@@ -80,9 +78,8 @@ const IngressAccordion = ({ ingress }: IngressAccordionProps) => {
</Accordion>
);
};
export const IngressesAccordions = ({
deploymentResources,
}: IngressesAccordionsProps) => {
export const IngressesAccordions = ({}: IngressesAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
@@ -90,7 +87,7 @@ export const IngressesAccordions = ({
justify="flex-start"
alignItems="flex-start"
>
{deploymentResources.ingresses.map((ingress, i) => (
{groupedResponses.ingresses.map((ingress, i) => (
<Grid item key={i} xs>
<IngressAccordion ingress={ingress} />
</Grid>
@@ -0,0 +1,172 @@
/*
* Copyright 2021 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 React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { KubernetesContent } from './KubernetesContent';
import { useKubernetesObjects } from '../../hooks';
jest.mock('../../hooks');
import * as oneDeployment from '../../__fixtures__/1-deployments.json';
import * as twoDeployments from '../../__fixtures__/2-deployments.json';
describe('KubernetesContent', () => {
it('render empty response', async () => {
(useKubernetesObjects as any).mockReturnValue({
kubernetesObjects: {
items: [],
},
error: undefined,
});
const { getByText } = render(
wrapInTestApp(
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>,
),
);
expect(getByText('Error Reporting')).toBeInTheDocument();
expect(
getByText('Nice! There are no errors to report!'),
).toBeInTheDocument();
expect(getByText('Your Clusters')).toBeInTheDocument();
// TODO add a prompt for the user to configure their clusters
});
it('render 1 cluster happy path', async () => {
(useKubernetesObjects as any).mockReturnValue({
kubernetesObjects: {
items: [
{
cluster: { name: 'cluster-1' },
resources: [
{
type: 'deployments',
resources: oneDeployment.deployments,
},
{
type: 'replicasets',
resources: oneDeployment.replicaSets,
},
{
type: 'pods',
resources: oneDeployment.pods,
},
],
errors: [],
},
],
},
error: undefined,
});
const { getByText } = render(
wrapInTestApp(
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>,
),
);
expect(
getByText('Nice! There are no errors to report!'),
).toBeInTheDocument();
expect(getByText('cluster-1')).toBeInTheDocument();
expect(getByText('Cluster')).toBeInTheDocument();
expect(getByText('10 pods')).toBeInTheDocument();
expect(getByText('No pods with errors')).toBeInTheDocument();
});
it('render 2 clusters happy path, one with errors', async () => {
(useKubernetesObjects as any).mockReturnValue({
kubernetesObjects: {
items: [
{
cluster: { name: 'cluster-1' },
resources: [
{
type: 'deployments',
resources: [twoDeployments.deployments[1]],
},
{
type: 'replicasets',
resources: twoDeployments.replicaSets,
},
{
type: 'pods',
resources: twoDeployments.pods,
},
],
errors: [],
},
{
cluster: { name: 'cluster-a' },
resources: [
{
type: 'deployments',
resources: oneDeployment.deployments,
},
{
type: 'replicasets',
resources: oneDeployment.replicaSets,
},
{
type: 'pods',
resources: oneDeployment.pods,
},
],
errors: [],
},
],
},
error: undefined,
});
const { getByText, getAllByText, queryByText } = render(
wrapInTestApp(
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>,
),
);
expect(queryByText('Nice! There are no errors to report!')).toBeNull();
expect(getAllByText('Cluster')).toHaveLength(2);
expect(getByText('cluster-a')).toBeInTheDocument();
expect(getByText('10 pods')).toBeInTheDocument();
expect(getByText('No pods with errors')).toBeInTheDocument();
expect(getAllByText('cluster-1')).toHaveLength(6);
expect(getByText('12 pods')).toBeInTheDocument();
expect(getByText('2 pods with errors')).toBeInTheDocument();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
Accordion,
AccordionDetails,
@@ -29,16 +29,9 @@ import {
Progress,
StatusError,
StatusOK,
useApi,
} from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { kubernetesApiRef } from '../../api/types';
import {
ClusterObjects,
KubernetesRequestBody,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-backend';
import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types';
import { ClusterObjects } from '@backstage/plugin-kubernetes-backend';
import { ErrorPanel } from './ErrorPanel';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { DeploymentsAccordions } from '../DeploymentsAccordions';
@@ -47,6 +40,12 @@ import { groupResponses } from '../../utils/response';
import { DetectedError, detectErrors } from '../../error-detection';
import { IngressesAccordions } from '../IngressesAccordions';
import { ServicesAccordions } from '../ServicesAccordions';
import { CustomResources } from '../CustomResources';
import {
GroupedResponsesContext,
PodNamesWithErrorsContext,
useKubernetesObjects,
} from '../../hooks';
type ClusterSummaryProps = {
clusterName: string;
@@ -68,8 +67,9 @@ const ClusterSummary = ({
alignItems="flex-start"
>
<Grid
xs={2}
xs={4}
item
container
direction="column"
justify="flex-start"
alignItems="flex-start"
@@ -110,96 +110,49 @@ const ClusterSummary = ({
type ClusterProps = {
clusterObjects: ClusterObjects;
detectedErrors?: DetectedError[];
podsWithErrors: Set<string>;
children?: React.ReactNode;
};
const Cluster = ({ clusterObjects, detectedErrors }: ClusterProps) => {
const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
const groupedResponses = groupResponses(clusterObjects.resources);
const podsWithErrors = new Set<string>(
detectedErrors
?.filter(de => de.kind === 'Pod')
.map(de => de.names)
.flat() ?? [],
);
return (
<>
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<ClusterSummary
clusterName={clusterObjects.cluster.name}
totalNumberOfPods={groupedResponses.pods.length}
numberOfPodsWithErrors={podsWithErrors.size}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container direction="column">
<Grid item>
<DeploymentsAccordions
deploymentResources={groupedResponses}
clusterPodNamesWithErrors={podsWithErrors}
/>
<GroupedResponsesContext.Provider value={groupedResponses}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<ClusterSummary
clusterName={clusterObjects.cluster.name}
totalNumberOfPods={groupedResponses.pods.length}
numberOfPodsWithErrors={podsWithErrors.size}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container direction="column">
<Grid item>
<CustomResources />
</Grid>
<Grid item>
<DeploymentsAccordions />
</Grid>
<Grid item>
<IngressesAccordions />
</Grid>
<Grid item>
<ServicesAccordions />
</Grid>
</Grid>
<Grid item>
<IngressesAccordions deploymentResources={groupedResponses} />
</Grid>
<Grid item>
<ServicesAccordions deploymentResources={groupedResponses} />
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</GroupedResponsesContext.Provider>
);
};
type KubernetesContentProps = { entity: Entity; children?: React.ReactNode };
export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
const kubernetesApi = useApi(kubernetesApiRef);
const [kubernetesObjects, setKubernetesObjects] = useState<
ObjectsByEntityResponse | undefined
>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef);
useEffect(() => {
(async () => {
const clusters = await kubernetesApi.getClusters();
const authProviders: string[] = [
...new Set(clusters.map(c => c.authProvider)),
];
// For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
let requestBody: KubernetesRequestBody = {
entity,
};
for (const authProviderStr of authProviders) {
// Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously
requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
authProviderStr,
requestBody,
);
}
// TODO: Add validation on contents/format of requestBody
kubernetesApi
.getObjectsByEntity(requestBody)
.then(result => {
setKubernetesObjects(result);
})
.catch(e => {
setError(e.message);
});
})();
/* eslint-disable react-hooks/exhaustive-deps */
}, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]);
/* eslint-enable react-hooks/exhaustive-deps */
const { kubernetesObjects, error } = useKubernetesObjects(entity);
const clustersWithErrors =
kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? [];
@@ -250,14 +203,24 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
<Typography variant="h3">Your Clusters</Typography>
</Grid>
<Grid item container>
{kubernetesObjects?.items.map((item, i) => (
<Grid item key={i} xs={12}>
<Cluster
clusterObjects={item}
detectedErrors={detectedErrors.get(item.cluster.name)}
/>
</Grid>
))}
{kubernetesObjects?.items.map((item, i) => {
const podsWithErrors = new Set<string>(
detectedErrors
.get(item.cluster.name)
?.filter(de => de.kind === 'Pod')
.map(de => de.names)
.flat() ?? [],
);
return (
<Grid item key={i} xs={12}>
<Cluster
clusterObjects={item}
podsWithErrors={podsWithErrors}
/>
</Grid>
);
})}
</Grid>
</Grid>
)}
@@ -84,6 +84,15 @@ interface KubernetesDrawerContentProps<T extends KubernetesDrawerable> {
kind: string;
}
function replaceNullsWithUndefined(someObj: any) {
const replacer = (_: any, value: any) =>
String(value) === 'null' || String(value) === 'undefined'
? undefined
: value;
return JSON.parse(JSON.stringify(someObj, replacer));
}
const KubernetesDrawerContent = <T extends KubernetesDrawerable>({
toggleDrawer,
object,
@@ -139,7 +148,11 @@ const KubernetesDrawerContent = <T extends KubernetesDrawerable>({
</div>
<div className={classes.content}>
{isYaml && <CodeSnippet language="yaml" text={jsYaml.dump(object)} />}
{!isYaml && <StructuredMetadataTable metadata={renderObject(object)} />}
{!isYaml && (
<StructuredMetadataTable
metadata={renderObject(replaceNullsWithUndefined(object))}
/>
)}
</div>
</>
);
@@ -19,13 +19,14 @@ import { render } from '@testing-library/react';
import * as twoDeployFixture from './__fixtures__/2-services.json';
import { wrapInTestApp } from '@backstage/test-utils';
import { ServicesAccordions } from './ServicesAccordions';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('ServicesAccordions', () => {
it('should render 2 services', async () => {
const wrapper = kubernetesProviders(twoDeployFixture, new Set());
const { getByText } = render(
wrapInTestApp(
<ServicesAccordions deploymentResources={twoDeployFixture as any} />,
),
wrapper(wrapInTestApp(<ServicesAccordions />)),
);
expect(getByText('awesome-service-grpc')).toBeInTheDocument();
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import { GroupedResponses } from '../../types/types';
import React from 'react';
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
@@ -28,6 +27,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Service } from '@kubernetes/client-node';
import { StructuredMetadataTable } from '@backstage/core';
import { ServiceDrawer } from './ServiceDrawer';
import { GroupedResponsesContext } from '../../hooks';
type ServiceSummaryProps = {
service: V1Service;
@@ -82,9 +82,7 @@ const ServiceCard = ({ service }: ServiceCardProps) => {
);
};
type ServicesAccordionsProps = {
deploymentResources: GroupedResponses;
};
type ServicesAccordionsProps = {};
type ServiceAccordionProps = {
service: V1Service;
@@ -103,9 +101,8 @@ const ServiceAccordion = ({ service }: ServiceAccordionProps) => {
);
};
export const ServicesAccordions = ({
deploymentResources,
}: ServicesAccordionsProps) => {
export const ServicesAccordions = ({}: ServicesAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
@@ -113,7 +110,7 @@ export const ServicesAccordions = ({
justify="flex-start"
alignItems="flex-start"
>
{deploymentResources.services.map((service, i) => (
{groupedResponses.services.map((service, i) => (
<Grid item key={i} xs>
<ServiceAccordion service={service} />
</Grid>
@@ -0,0 +1,28 @@
/*
* Copyright 2021 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 React from 'react';
import { GroupedResponses } from '../types/types';
export const GroupedResponsesContext = React.createContext<GroupedResponses>({
pods: [],
replicaSets: [],
deployments: [],
services: [],
configMaps: [],
horizontalPodAutoscalers: [],
ingresses: [],
customResources: [],
});
@@ -0,0 +1,20 @@
/*
* Copyright 2021 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 React from 'react';
export const PodNamesWithErrorsContext = React.createContext<Set<string>>(
new Set<string>(),
);
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 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 * from './useKubernetesObjects';
export * from './PodNamesWithErrors';
export * from './GroupedResponses';
@@ -0,0 +1,34 @@
/*
* Copyright 2021 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 React from 'react';
import { GroupedResponsesContext } from './GroupedResponses';
import { PodNamesWithErrorsContext } from './PodNamesWithErrors';
export const kubernetesProviders = (
groupedResponses: any,
podsWithErrors: any,
) => {
return (node: React.ReactNode) => (
<>
<GroupedResponsesContext.Provider value={groupedResponses}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
{node}
</PodNamesWithErrorsContext.Provider>
</GroupedResponsesContext.Provider>
</>
);
};
@@ -0,0 +1,77 @@
/*
* Copyright 2021 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 { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { kubernetesApiRef } from '../api/types';
import { kubernetesAuthProvidersApiRef } from '../kubernetes-auth-provider/types';
import { useEffect, useState } from 'react';
import {
KubernetesRequestBody,
ObjectsByEntityResponse,
} from '@backstage/plugin-kubernetes-backend';
export interface KubernetesObjects {
kubernetesObjects: ObjectsByEntityResponse | undefined;
error: string | undefined;
}
export const useKubernetesObjects = (entity: Entity): KubernetesObjects => {
const kubernetesApi = useApi(kubernetesApiRef);
const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef);
const [kubernetesObjects, setKubernetesObjects] = useState<
ObjectsByEntityResponse | undefined
>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
(async () => {
const clusters = await kubernetesApi.getClusters();
const authProviders: string[] = [
...new Set(clusters.map(c => c.authProvider)),
];
// For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
let requestBody: KubernetesRequestBody = {
entity,
};
for (const authProviderStr of authProviders) {
// Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously
requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
authProviderStr,
requestBody,
);
}
// TODO: Add validation on contents/format of requestBody
kubernetesApi
.getObjectsByEntity(requestBody)
.then(result => {
setKubernetesObjects(result);
})
.catch(e => {
setError(e.message);
});
})();
/* eslint-disable react-hooks/exhaustive-deps */
}, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]);
/* eslint-enable react-hooks/exhaustive-deps */
return {
kubernetesObjects,
error,
};
};
+1
View File
@@ -35,4 +35,5 @@ export interface GroupedResponses extends DeploymentResources {
services: V1Service[];
configMaps: V1ConfigMap[];
ingresses: ExtensionsV1beta1Ingress[];
customResources: any[];
}
@@ -0,0 +1,31 @@
/*
* Copyright 2021 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 { getOwnedResources } from './owner';
import * as fixture from '../__fixtures__/2-deployments.json';
describe('owner', () => {
describe('getOwnedResources', () => {
it('should find replicaset ownership from deployment', () => {
const result = getOwnedResources(
fixture.deployments[0] as any,
fixture.replicaSets as any,
);
expect(result).toHaveLength(1);
expect(result[0]?.metadata?.name).toBe('dice-roller-6c8646bfd');
});
});
});
+70
View File
@@ -0,0 +1,70 @@
/*
* Copyright 2021 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 { V1ObjectMeta } from '@kubernetes/client-node/dist/gen/model/v1ObjectMeta';
import {
V1HorizontalPodAutoscaler,
V1Pod,
V1ReplicaSet,
} from '@kubernetes/client-node';
interface CanOwn {
metadata?: V1ObjectMeta;
}
interface CanBeOwned {
metadata?: V1ObjectMeta;
}
export function getOwnedResources<R extends CanBeOwned>(
potentialOwner: CanOwn,
possiblyOwned: R[],
): R[] {
return possiblyOwned.filter(
p =>
p.metadata?.ownerReferences?.some(
o => o.uid === potentialOwner.metadata?.uid,
) ?? false,
);
}
export const getOwnedPodsThroughReplicaSets = (
potentialOwner: CanOwn,
replicaSets: V1ReplicaSet[],
pods: V1Pod[],
) => {
return getOwnedResources(
potentialOwner,
replicaSets.filter(rs => rs.status && rs.status.replicas > 0),
).reduce((accum, rs) => {
return accum.concat(getOwnedResources(rs, pods));
}, [] as V1Pod[]);
};
export const getMatchingHpa = (
ownerName: string | undefined,
ownerKind: string,
hpas: V1HorizontalPodAutoscaler[],
): V1HorizontalPodAutoscaler | undefined => {
return hpas.find(hpa => {
return (
(hpa.spec?.scaleTargetRef?.kind ?? '').toLowerCase() ===
ownerKind.toLowerCase() &&
(hpa.spec?.scaleTargetRef?.name ?? '') ===
(ownerName ?? 'unknown-deployment')
);
});
};
+4
View File
@@ -45,6 +45,9 @@ export const groupResponses = (
case 'ingresses':
prev.ingresses.push(...next.resources);
break;
case 'customresources':
prev.customResources.push(...next.resources);
break;
default:
}
return prev;
@@ -57,6 +60,7 @@ export const groupResponses = (
configMaps: [],
horizontalPodAutoscalers: [],
ingresses: [],
customResources: [],
} as GroupedResponses,
);
};