Merge branch 'master' of github.com:backstage/backstage into blam/move-scaffolder-to-integrations
* 'master' of github.com:backstage/backstage: (29 commits) Kubernetes plugin: UI revamp (#3918) scaffolder-backend: export all preparers and publishers properly Remove the plugin-catalog-backend dependency from the catalog-import integration: build out the integrations class hierarchy Review comments Add changeset Handle missing values in Jenkins API requests Fix typo in changeset Add changeset for lighthouse plugin bugfix Strip trailing slash from urls when creating lighthouse audit core: simplify table icon type declarations docs: update okta auth readme changesets: TechDocs AWS/GCS image bug fix fix(techdocs): Handle images and other binaries correctly in aws/gcs publishers Version Packages chore: update documentation around ADR and the template skeleton chore: remove superfluous comment chore: add sample preamble chore: fixing code review comments to align with better numbers chore: update workflow to check the username is the github bot that creates the commit message ...
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-lighthouse': patch
|
||||
---
|
||||
|
||||
Strip trailing slash from url when creating a new audit. This change prevents duplicate audits from being displayed in the audit list.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
---
|
||||
|
||||
Revamped Kubernetes UI and added error reporting/detection
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
---
|
||||
|
||||
Build out the `ScmIntegrations` class, as well as the individual `*Integration` classes
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-jenkins': patch
|
||||
---
|
||||
|
||||
Handle missing ObjectMetadataAction in Jenkins API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/techdocs-common': patch
|
||||
---
|
||||
|
||||
Fix bug where binary files (`png`, etc.) could not load when using AWS or GCS publisher.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
Remove dependency to `@backstage/plugin-catalog-backend`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Fix issue where `SidebarItem` with `onClick` and without `to` renders an inaccessible div. It now renders a button.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Added experimental `create-github-app` command.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Export all preparers and publishers properly
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Bumping the version for `create-app` so that we can use the latest versions of internal packages and rebuild the version which is passed to the package.json
|
||||
@@ -138,12 +138,14 @@ Niklas
|
||||
nodegit
|
||||
nohoist
|
||||
nonces
|
||||
noop
|
||||
npm
|
||||
nvarchar
|
||||
nvm
|
||||
oauth
|
||||
OAuth
|
||||
oidc
|
||||
okta
|
||||
Okta
|
||||
Oldsberg
|
||||
onboarding
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
# We can't re-use the output from the above step, but we'll have a guaranteed node_modules cache and
|
||||
# only run the build steps that are necessary for publishing
|
||||
release:
|
||||
if: contains(github.event.commits.*.author.username, 'backstage-service') && contains(github.event.head_commit.message, 'from backstage/changeset-release/master')
|
||||
if: contains(github.event.commits.*.author.username, 'github-actions[bot]') && contains(github.event.head_commit.message, 'from backstage/changeset-release/master')
|
||||
needs: build
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,6 +16,7 @@ jobs:
|
||||
assign_issue_or_pr_to_project:
|
||||
runs-on: ubuntu-latest
|
||||
name: Triage
|
||||
if: ${{ env.MY_GITHUB_TOKEN }} != null
|
||||
steps:
|
||||
- name: Assign new issue to Incoming based on its title.
|
||||
uses: srggrs/assign-one-project-github-action@1.2.0
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: adrs-adr000
|
||||
title: ADR000: [TITLE]
|
||||
description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION]
|
||||
---
|
||||
|
||||
| Created | Status |
|
||||
| ---------- | ------ |
|
||||
| YYYY-MM-DD | Open |
|
||||
|
||||
# ADR000: [title]
|
||||
|
||||
<!-- These documents have names that are short noun phrases. For example, "ADR001: Deployment on Ruby on Rails 3.0.10" or "ADR009: LDAP for Multitenant Integration" -->
|
||||
|
||||
## Status
|
||||
|
||||
<!-- A decision may be "proposed" if the project stakeholders haven't agreed with it yet, or "accepted" once it is agreed. If a later ADR changes or reverses a decision, it may be marked as "deprecated" or "superseded" with a reference to its replacement. -->
|
||||
|
||||
## Context
|
||||
|
||||
<!--
|
||||
This section describes the forces at play, including technological, political, social, and project local. These forces are probably in tension, and should be called out as such. The language in this section is value-neutral. It is simply describing facts. -->
|
||||
|
||||
## Decision
|
||||
|
||||
<!-- This section describes our response to these forces. It is stated in full sentences, with active voice. "We will ..." -->
|
||||
|
||||
## Consequences
|
||||
|
||||
<!-- This section describes the resulting context, after applying the decision. All consequences should be listed here, not just the "positive" ones. A particular decision may have positive, negative, and neutral consequences, but all of them affect the team and project in the future. -->
|
||||
|
||||
<!-- This template is taken from a blog post by Michael Nygard http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions -->
|
||||
@@ -18,8 +18,9 @@ Records should be stored under the `architecture-decisions` directory.
|
||||
|
||||
### Creating an ADR
|
||||
|
||||
- Copy `0000-template.md` to `docs/architecture-decisions/0000-my-decision.md`
|
||||
(my-decision should be descriptive. Do not assign an ADR number.)
|
||||
- Copy `docs/architecture-decisions/adr000-template.md` to
|
||||
`docs/architecture-decisions/adr000-my-decision.md` (my-decision should be
|
||||
descriptive. Do not assign an ADR number.)
|
||||
- Fill in the ADR following the guidelines in the template
|
||||
- Submit a pull request
|
||||
- Address and integrate feedback from the community
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
id: kubernetes-in-backstage
|
||||
title: Kubernetes in Backstage
|
||||
description: Monitoring Kubernetes based services with the service catalog
|
||||
---
|
||||
|
||||
# Kubernetes in Backstage
|
||||
|
||||
Kubernetes in Backstage is a way to monitor your service's current status when
|
||||
it is deployed on Kubernetes.
|
||||
|
||||
## Configuration
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
kubernetes:
|
||||
serviceLocatorMethod: 'multiTenant'
|
||||
clusterLocatorMethods:
|
||||
- 'config'
|
||||
clusters:
|
||||
- url: http://127.0.0.1:9999
|
||||
name: minikube
|
||||
serviceAccountToken: TOKEN
|
||||
authProvider: 'serviceAccount'
|
||||
- url: http://127.0.0.2:9999
|
||||
name: gke-cluster-1
|
||||
authProvider: 'google'
|
||||
```
|
||||
|
||||
### serviceLocatorMethod
|
||||
|
||||
This configures how to determine which clusters a component is running in.
|
||||
|
||||
Currently, the only valid serviceLocatorMethod is:
|
||||
|
||||
### multiTenant
|
||||
|
||||
This configuration assumes that all components run on all the provided clusters.
|
||||
|
||||
### clusterLocatorMethods
|
||||
|
||||
This is used to determine where to retrieve cluster configuration from.
|
||||
|
||||
Currently, the only valid cluster locator method is:
|
||||
|
||||
`"config"`
|
||||
|
||||
This cluster locator method will read cluster information from your app-config
|
||||
(see below).
|
||||
|
||||
### clusters
|
||||
|
||||
Used by the `config` cluster locator method to construct Kubernetes clients.
|
||||
|
||||
### clusters.\*.url
|
||||
|
||||
The base URL to the Kubernetes control plane. Can be found by using the
|
||||
`Kubernetes master` result from running the `kubectl cluster-info` command.
|
||||
|
||||
### clusters.\*.name
|
||||
|
||||
A name to represent this cluster, this must be unique within the `clusters`
|
||||
array. Users will see this value in the Service Catalog Kubernetes plugin.
|
||||
|
||||
### clusters.\*.authProvider
|
||||
|
||||
This determines how the Kubernetes client authenticates with the Kubernetes
|
||||
cluster. Valid values are:
|
||||
|
||||
| Value | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. |
|
||||
| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. |
|
||||
|
||||
### clusters.\*.serviceAccount (optional)
|
||||
|
||||
The service account token to be used when using the `serviceAccount` auth
|
||||
provider.
|
||||
|
||||
## RBAC
|
||||
|
||||
The current RBAC permissions required are read-only cluster wide, for the
|
||||
following objects:
|
||||
|
||||
- pods
|
||||
- services
|
||||
- configmaps
|
||||
- deployments
|
||||
- replicasets
|
||||
- horizontalpodautoscalers
|
||||
- ingresses
|
||||
|
||||
## Surfacing your Kubernetes components as part of an entity
|
||||
|
||||
There are two ways to surface your kubernetes components as part of an entity.
|
||||
The label selector takes precedence over the annotation/service id.
|
||||
|
||||
### Common `backstage.io/kubernetes-id` label
|
||||
|
||||
#### Adding the entity annotation
|
||||
|
||||
In order for Backstage to detect that an entity has Kubernetes components, the
|
||||
following annotation should be added to the entity.
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
```
|
||||
|
||||
#### Labeling Kubernetes components
|
||||
|
||||
In order for Kubernetes components to show up in the service catalog as a part
|
||||
of an entity, Kubernetes components must be labeled with the following label:
|
||||
|
||||
```yaml
|
||||
'backstage.io/kubernetes-id': <ENTITY_NAME>
|
||||
```
|
||||
|
||||
### label selector query annotation
|
||||
|
||||
#### Adding a label selector query annotation
|
||||
|
||||
You can write your own custom label selector query that backstage will use to
|
||||
lookup the objects (similar to `kubectl --selector="your query here"`). Review
|
||||
the documentation
|
||||
[here](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/)
|
||||
for more info.
|
||||
|
||||
```yaml
|
||||
'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end'
|
||||
```
|
||||
@@ -50,6 +50,7 @@
|
||||
"features/software-catalog/well-known-relations",
|
||||
"features/software-catalog/extending-the-model",
|
||||
"features/software-catalog/external-integrations",
|
||||
"features/software-catalog/kubernetes-in-backstage",
|
||||
"features/software-catalog/software-catalog-api"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@hot-loader/react-dom": "^16.13.0",
|
||||
"@lerna/package-graph": "^3.18.5",
|
||||
"@lerna/project": "^3.18.0",
|
||||
"@octokit/request": "^5.2.0",
|
||||
"@rollup/plugin-commonjs": "^16.0.0",
|
||||
"@rollup/plugin-json": "^4.0.2",
|
||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||
@@ -69,6 +70,7 @@
|
||||
"eslint-plugin-monorepo": "^0.3.2",
|
||||
"eslint-plugin-react": "^7.12.4",
|
||||
"eslint-plugin-react-hooks": "^4.0.0",
|
||||
"express": "^4.17.1",
|
||||
"fork-ts-checker-webpack-plugin": "^4.0.5",
|
||||
"fs-extra": "^9.0.0",
|
||||
"handlebars": "^4.7.3",
|
||||
@@ -118,6 +120,7 @@
|
||||
"@backstage/test-utils": "^0.1.6",
|
||||
"@backstage/theme": "^0.2.2",
|
||||
"@types/diff": "^4.0.2",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/html-webpack-plugin": "^3.2.2",
|
||||
"@types/http-proxy": "^1.17.4",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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 crypto from 'crypto';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { request } from '@octokit/request';
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
|
||||
const MANIFEST_DATA = {
|
||||
default_events: ['create', 'delete', 'push', 'repository'],
|
||||
default_permissions: {
|
||||
contents: 'read',
|
||||
metadata: 'read',
|
||||
},
|
||||
name: 'Backstage-<changeme>',
|
||||
url: 'https://backstage.io',
|
||||
description: 'GitHub App for Backstage',
|
||||
public: false,
|
||||
};
|
||||
|
||||
const FORM_PAGE = `
|
||||
<html>
|
||||
<body>
|
||||
<form id="form" action="ACTION_URL" method="post">
|
||||
<input type="hidden" name="manifest" value="MANIFEST_JSON">
|
||||
<input type="submit" value="Continue">
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById("form").submit()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
type GithubAppConfig = {
|
||||
appId: number;
|
||||
apiUrl: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
webhookUrl?: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
webhookSecret: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
export class GithubCreateAppServer {
|
||||
private baseUrl?: string;
|
||||
private webhookUrl?: string;
|
||||
|
||||
static async run({ org }: { org: string }): Promise<GithubAppConfig> {
|
||||
const encodedOrg = encodeURIComponent(org);
|
||||
const actionUrl = `https://github.com/organizations/${encodedOrg}/settings/apps/new`;
|
||||
const server = new GithubCreateAppServer(actionUrl);
|
||||
return server.start();
|
||||
}
|
||||
|
||||
constructor(private readonly actionUrl: string) {
|
||||
const webhookId = crypto
|
||||
.randomBytes(15)
|
||||
.toString('base64')
|
||||
.replace(/[\+\/]/g, '');
|
||||
|
||||
this.webhookUrl = `https://smee.io/${webhookId}`;
|
||||
}
|
||||
|
||||
private async start(): Promise<GithubAppConfig> {
|
||||
const app = express();
|
||||
|
||||
app.get('/', this.formHandler);
|
||||
|
||||
const callPromise = new Promise<GithubAppConfig>((resolve, reject) => {
|
||||
app.get('/callback', (req, res) => {
|
||||
request(
|
||||
`POST /app-manifests/${encodeURIComponent(
|
||||
req.query.code as string,
|
||||
)}/conversions`,
|
||||
).then(({ data, url }) => {
|
||||
// url = https://api.github.com/app-manifests/<code>/conversions
|
||||
const apiUrl = url.replace(/(?:\/[^\/]+){3}$/, '');
|
||||
resolve({
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
appId: data.id,
|
||||
apiUrl,
|
||||
webhookUrl: this.webhookUrl,
|
||||
clientId: data.client_id,
|
||||
clientSecret: data.client_secret,
|
||||
webhookSecret: data.webhook_secret,
|
||||
privateKey: data.pem,
|
||||
});
|
||||
res.redirect(302, `${data.html_url}/installations/new`);
|
||||
}, reject);
|
||||
});
|
||||
});
|
||||
|
||||
this.baseUrl = await this.listen(app);
|
||||
|
||||
openBrowser(this.baseUrl);
|
||||
|
||||
return callPromise;
|
||||
}
|
||||
|
||||
private formHandler = (_req: Request, res: Response) => {
|
||||
const baseUrl = this.baseUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error('baseUrl is not set');
|
||||
}
|
||||
const manifest = {
|
||||
...MANIFEST_DATA,
|
||||
redirect_url: `${baseUrl}/callback`,
|
||||
hook_attributes: {
|
||||
url: this.webhookUrl,
|
||||
},
|
||||
};
|
||||
const manifestJson = JSON.stringify(manifest).replace(/\"/g, '"');
|
||||
|
||||
let body = FORM_PAGE;
|
||||
body = body.replace('MANIFEST_JSON', manifestJson);
|
||||
body = body.replace('ACTION_URL', this.actionUrl);
|
||||
|
||||
res.setHeader('content-type', 'text/html');
|
||||
res.send(body);
|
||||
};
|
||||
|
||||
private async listen(app: Express) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const listener = app.listen(0, () => {
|
||||
const info = listener.address();
|
||||
if (typeof info !== 'object' || info === null) {
|
||||
reject(new Error(`Unexpected listener info '${info}'`));
|
||||
return;
|
||||
}
|
||||
const { port } = info;
|
||||
resolve(`http://localhost:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { GithubCreateAppServer } from './GithubCreateAppServer';
|
||||
|
||||
// This is an experimental command that at this point does not support GitHub Enterprise
|
||||
// due to lacking support for creating apps from manifests.
|
||||
// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest
|
||||
export default async (org: string) => {
|
||||
const { slug, name, ...config } = await GithubCreateAppServer.run({ org });
|
||||
|
||||
const fileName = `github-app-${slug}.yaml`;
|
||||
const content = `# Name: ${name}\n${stringifyYaml(config)}`;
|
||||
await fs.writeFile(paths.resolveTargetRoot(fileName), content);
|
||||
console.log(`GitHub App configuration written to ${chalk.cyan(fileName)}`);
|
||||
// TODO: log instructions on how to use the newly created app configuration.
|
||||
};
|
||||
@@ -205,6 +205,13 @@ export function registerCommands(program: CommanderStatic) {
|
||||
.command('build-workspace <workspace-dir> ...<packages>')
|
||||
.description('Builds a temporary dist workspace from the provided packages')
|
||||
.action(lazy(() => import('./buildWorkspace').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create-github-app <github-org>', { hidden: true })
|
||||
.description(
|
||||
'Create new GitHub App in your organization. This command is experimental and may change in the future.',
|
||||
)
|
||||
.action(lazy(() => import('./create-github-app').then(m => m.default)));
|
||||
}
|
||||
|
||||
// Wraps an action function so that it always exits and handles errors
|
||||
|
||||
@@ -40,6 +40,7 @@ import ViewColumn from '@material-ui/icons/ViewColumn';
|
||||
import { isEqual, transform } from 'lodash';
|
||||
import MTable, {
|
||||
Column,
|
||||
Icons,
|
||||
MaterialTableProps,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
@@ -56,58 +57,28 @@ import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
|
||||
import { SelectProps } from '../Select/Select';
|
||||
import { Filter, Filters, SelectedFilters, Without } from './Filters';
|
||||
|
||||
const tableIcons = {
|
||||
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<AddBox {...props} ref={ref} />
|
||||
)),
|
||||
Check: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<Check {...props} ref={ref} />
|
||||
)),
|
||||
Clear: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<Clear {...props} ref={ref} />
|
||||
)),
|
||||
Delete: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<DeleteOutline {...props} ref={ref} />
|
||||
)),
|
||||
DetailPanel: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
const tableIcons: Icons = {
|
||||
Add: forwardRef((props, ref) => <AddBox {...props} ref={ref} />),
|
||||
Check: forwardRef((props, ref) => <Check {...props} ref={ref} />),
|
||||
Clear: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
|
||||
Delete: forwardRef((props, ref) => <DeleteOutline {...props} ref={ref} />),
|
||||
DetailPanel: forwardRef((props, ref) => (
|
||||
<ChevronRight {...props} ref={ref} />
|
||||
)),
|
||||
Edit: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<Edit {...props} ref={ref} />
|
||||
)),
|
||||
Export: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<SaveAlt {...props} ref={ref} />
|
||||
)),
|
||||
Filter: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<FilterList {...props} ref={ref} />
|
||||
)),
|
||||
FirstPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<FirstPage {...props} ref={ref} />
|
||||
)),
|
||||
LastPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<LastPage {...props} ref={ref} />
|
||||
)),
|
||||
NextPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<ChevronRight {...props} ref={ref} />
|
||||
)),
|
||||
PreviousPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
Edit: forwardRef((props, ref) => <Edit {...props} ref={ref} />),
|
||||
Export: forwardRef((props, ref) => <SaveAlt {...props} ref={ref} />),
|
||||
Filter: forwardRef((props, ref) => <FilterList {...props} ref={ref} />),
|
||||
FirstPage: forwardRef((props, ref) => <FirstPage {...props} ref={ref} />),
|
||||
LastPage: forwardRef((props, ref) => <LastPage {...props} ref={ref} />),
|
||||
NextPage: forwardRef((props, ref) => <ChevronRight {...props} ref={ref} />),
|
||||
PreviousPage: forwardRef((props, ref) => (
|
||||
<ChevronLeft {...props} ref={ref} />
|
||||
)),
|
||||
ResetSearch: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<Clear {...props} ref={ref} />
|
||||
)),
|
||||
Search: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<Search {...props} ref={ref} />
|
||||
)),
|
||||
SortArrow: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<ArrowUpward {...props} ref={ref} />
|
||||
)),
|
||||
ThirdStateCheck: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<Remove {...props} ref={ref} />
|
||||
)),
|
||||
ViewColumn: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
<ViewColumn {...props} ref={ref} />
|
||||
)),
|
||||
ResetSearch: forwardRef((props, ref) => <Clear {...props} ref={ref} />),
|
||||
Search: forwardRef((props, ref) => <Search {...props} ref={ref} />),
|
||||
SortArrow: forwardRef((props, ref) => <ArrowUpward {...props} ref={ref} />),
|
||||
ThirdStateCheck: forwardRef((props, ref) => <Remove {...props} ref={ref} />),
|
||||
ViewColumn: forwardRef((props, ref) => <ViewColumn {...props} ref={ref} />),
|
||||
};
|
||||
|
||||
// TODO: Material table might already have such a function internally that we can use?
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import { Sidebar } from './Bar';
|
||||
import { SidebarItem } from './Items';
|
||||
|
||||
async function renderSidebar() {
|
||||
await renderInTestApp(
|
||||
<Sidebar>
|
||||
<SidebarItem text="Home" icon={HomeIcon} to="./" />
|
||||
<SidebarItem
|
||||
icon={CreateComponentIcon}
|
||||
onClick={() => {}}
|
||||
text="Create..."
|
||||
/>
|
||||
</Sidebar>,
|
||||
);
|
||||
userEvent.hover(screen.getByTestId('sidebar-root'));
|
||||
}
|
||||
|
||||
describe('Items', () => {
|
||||
beforeEach(async () => {
|
||||
await renderSidebar();
|
||||
});
|
||||
|
||||
describe('SidebarItem', () => {
|
||||
it('should render a link when `to` prop provided', async () => {
|
||||
expect(
|
||||
await screen.findByRole('link', { name: /home/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a button when `to` prop is not provided', async () => {
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /create/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,15 @@ const useStyles = makeStyles<BackstageTheme>(theme => {
|
||||
height: 48,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
buttonItem: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
width: 'auto',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
textAlign: 'inherit',
|
||||
font: 'inherit',
|
||||
},
|
||||
closed: {
|
||||
width: drawerWidthClosed,
|
||||
justifyContent: 'center',
|
||||
@@ -114,100 +123,102 @@ const useStyles = makeStyles<BackstageTheme>(theme => {
|
||||
};
|
||||
});
|
||||
|
||||
type SidebarItemProps = {
|
||||
type SidebarItemBaseProps = {
|
||||
icon: IconComponent;
|
||||
text?: string;
|
||||
// If 'to' is set the item will act as a nav link with highlight, otherwise it's just a button
|
||||
to?: string;
|
||||
hasNotifications?: boolean;
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const SidebarItem = forwardRef<any, SidebarItemProps>(
|
||||
(
|
||||
{ icon: Icon, text, to, hasNotifications = false, onClick, children },
|
||||
ref,
|
||||
) => {
|
||||
const classes = useStyles();
|
||||
// XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component
|
||||
// depend on the current location, and at least have it being optionally forced to selected.
|
||||
// Still waiting on a Q answered to fine tune the implementation
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
type SidebarItemButtonProps = SidebarItemBaseProps & {
|
||||
onClick: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
const itemIcon = (
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="dot"
|
||||
overlap="circle"
|
||||
invisible={!hasNotifications}
|
||||
>
|
||||
<Icon fontSize="small" className={classes.icon} />
|
||||
</Badge>
|
||||
);
|
||||
type SidebarItemLinkProps = SidebarItemBaseProps & {
|
||||
to: string;
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
const childProps = {
|
||||
onClick,
|
||||
className: clsx(classes.root, isOpen ? classes.open : classes.closed),
|
||||
};
|
||||
type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps;
|
||||
|
||||
if (!isOpen) {
|
||||
if (to === undefined) {
|
||||
return (
|
||||
<div {...childProps} ref={ref}>
|
||||
{itemIcon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function isButtonItem(
|
||||
props: SidebarItemProps,
|
||||
): props is SidebarItemButtonProps {
|
||||
return (props as SidebarItemLinkProps).to === undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
{...childProps}
|
||||
activeClassName={classes.selected}
|
||||
to={to}
|
||||
end
|
||||
ref={ref}
|
||||
>
|
||||
{itemIcon}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
const {
|
||||
icon: Icon,
|
||||
text,
|
||||
hasNotifications = false,
|
||||
onClick,
|
||||
children,
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
// XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component
|
||||
// depend on the current location, and at least have it being optionally forced to selected.
|
||||
// Still waiting on a Q answered to fine tune the implementation
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
{itemIcon}
|
||||
</div>
|
||||
{text && (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
<div className={classes.secondaryAction}>{children}</div>
|
||||
</>
|
||||
);
|
||||
const itemIcon = (
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="dot"
|
||||
overlap="circle"
|
||||
invisible={!hasNotifications}
|
||||
>
|
||||
<Icon fontSize="small" className={classes.icon} />
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (to === undefined) {
|
||||
return (
|
||||
<div {...childProps} ref={ref}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const closedContent = itemIcon;
|
||||
|
||||
const openContent = (
|
||||
<>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
{itemIcon}
|
||||
</div>
|
||||
{text && (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
<div className={classes.secondaryAction}>{children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const content = isOpen ? openContent : closedContent;
|
||||
|
||||
const childProps = {
|
||||
onClick,
|
||||
className: clsx(
|
||||
classes.root,
|
||||
isOpen ? classes.open : classes.closed,
|
||||
isButtonItem(props) && classes.buttonItem,
|
||||
),
|
||||
};
|
||||
|
||||
if (isButtonItem(props)) {
|
||||
return (
|
||||
<NavLink
|
||||
{...childProps}
|
||||
activeClassName={classes.selected}
|
||||
to={to}
|
||||
end
|
||||
ref={ref}
|
||||
>
|
||||
<button {...childProps} ref={ref}>
|
||||
{content}
|
||||
</NavLink>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
{...childProps}
|
||||
activeClassName={classes.selected}
|
||||
to={props.to}
|
||||
end
|
||||
ref={ref}
|
||||
>
|
||||
{content}
|
||||
</NavLink>
|
||||
);
|
||||
});
|
||||
|
||||
type SidebarSearchFieldProps = {
|
||||
onSearch: (input: string) => void;
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @backstage/create-app
|
||||
|
||||
## 0.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 643dcec7c: noop release for create-app to force re-deploy
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bd9c6719f: Bumping the version for `create-app` so that we can use the latest versions of internal packages and rebuild the version which is passed to the package.json
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/create-app",
|
||||
"description": "Create app package for Backstage",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.4",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 { AzureIntegrationConfig } from './azure';
|
||||
import { AzureIntegration } from './azure/AzureIntegration';
|
||||
import { BitbucketIntegrationConfig } from './bitbucket';
|
||||
import { BitbucketIntegration } from './bitbucket/BitbucketIntegration';
|
||||
import { GitHubIntegrationConfig } from './github';
|
||||
import { GitHubIntegration } from './github/GitHubIntegration';
|
||||
import { GitLabIntegrationConfig } from './gitlab';
|
||||
import { GitLabIntegration } from './gitlab/GitLabIntegration';
|
||||
import { basicIntegrations } from './helpers';
|
||||
import { ScmIntegrations } from './ScmIntegrations';
|
||||
|
||||
describe('ScmIntegrations', () => {
|
||||
const azure = new AzureIntegration({
|
||||
host: 'azure.local',
|
||||
} as AzureIntegrationConfig);
|
||||
|
||||
const bitbucket = new BitbucketIntegration({
|
||||
host: 'bitbucket.local',
|
||||
} as BitbucketIntegrationConfig);
|
||||
|
||||
const github = new GitHubIntegration({
|
||||
host: 'github.local',
|
||||
} as GitHubIntegrationConfig);
|
||||
|
||||
const gitlab = new GitLabIntegration({
|
||||
host: 'gitlab.local',
|
||||
} as GitLabIntegrationConfig);
|
||||
|
||||
const i = new ScmIntegrations({
|
||||
azure: basicIntegrations([azure], i => i.config.host),
|
||||
bitbucket: basicIntegrations([bitbucket], i => i.config.host),
|
||||
github: basicIntegrations([github], i => i.config.host),
|
||||
gitlab: basicIntegrations([gitlab], i => i.config.host),
|
||||
});
|
||||
|
||||
it('can get the specifics', () => {
|
||||
expect(i.azure.byUrl('https://azure.local')).toBe(azure);
|
||||
expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket);
|
||||
expect(i.github.byUrl('https://github.local')).toBe(github);
|
||||
expect(i.gitlab.byUrl('https://gitlab.local')).toBe(gitlab);
|
||||
});
|
||||
|
||||
it('can list', () => {
|
||||
expect(i.list()).toEqual(
|
||||
expect.arrayContaining([azure, bitbucket, github, gitlab]),
|
||||
);
|
||||
});
|
||||
|
||||
it('can select by url and host', () => {
|
||||
expect(i.byUrl('https://azure.local')).toBe(azure);
|
||||
expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket);
|
||||
expect(i.byUrl('https://github.local')).toBe(github);
|
||||
expect(i.byUrl('https://gitlab.local')).toBe(gitlab);
|
||||
|
||||
expect(i.byHost('azure.local')).toBe(azure);
|
||||
expect(i.byHost('bitbucket.local')).toBe(bitbucket);
|
||||
expect(i.byHost('github.local')).toBe(github);
|
||||
expect(i.byHost('gitlab.local')).toBe(gitlab);
|
||||
});
|
||||
});
|
||||
@@ -21,27 +21,64 @@ import { GitHubIntegration } from './github/GitHubIntegration';
|
||||
import { GitLabIntegration } from './gitlab/GitLabIntegration';
|
||||
import {
|
||||
ScmIntegration,
|
||||
ScmIntegrationPredicateTuple,
|
||||
ScmIntegrationRegistry,
|
||||
ScmIntegrationsGroup,
|
||||
} from './types';
|
||||
|
||||
type IntegrationsByType = {
|
||||
azure: ScmIntegrationsGroup<AzureIntegration>;
|
||||
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
|
||||
github: ScmIntegrationsGroup<GitHubIntegration>;
|
||||
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
|
||||
};
|
||||
|
||||
export class ScmIntegrations implements ScmIntegrationRegistry {
|
||||
private readonly byType: IntegrationsByType;
|
||||
|
||||
static fromConfig(config: Config): ScmIntegrations {
|
||||
return new ScmIntegrations([
|
||||
...AzureIntegration.factory({ config }),
|
||||
...BitbucketIntegration.factory({ config }),
|
||||
...GitHubIntegration.factory({ config }),
|
||||
...GitLabIntegration.factory({ config }),
|
||||
]);
|
||||
return new ScmIntegrations({
|
||||
azure: AzureIntegration.factory({ config }),
|
||||
bitbucket: BitbucketIntegration.factory({ config }),
|
||||
github: GitHubIntegration.factory({ config }),
|
||||
gitlab: GitLabIntegration.factory({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
constructor(private readonly integrations: ScmIntegrationPredicateTuple[]) {}
|
||||
constructor(integrationsByType: IntegrationsByType) {
|
||||
this.byType = integrationsByType;
|
||||
}
|
||||
|
||||
get azure(): ScmIntegrationsGroup<AzureIntegration> {
|
||||
return this.byType.azure;
|
||||
}
|
||||
|
||||
get bitbucket(): ScmIntegrationsGroup<BitbucketIntegration> {
|
||||
return this.byType.bitbucket;
|
||||
}
|
||||
|
||||
get github(): ScmIntegrationsGroup<GitHubIntegration> {
|
||||
return this.byType.github;
|
||||
}
|
||||
|
||||
get gitlab(): ScmIntegrationsGroup<GitLabIntegration> {
|
||||
return this.byType.gitlab;
|
||||
}
|
||||
|
||||
list(): ScmIntegration[] {
|
||||
return this.integrations.map(i => i.integration);
|
||||
return Object.values(this.byType).flatMap(
|
||||
i => i.list() as ScmIntegration[],
|
||||
);
|
||||
}
|
||||
|
||||
byUrl(url: string): ScmIntegration | undefined {
|
||||
return this.integrations.find(i => i.predicate(new URL(url)))?.integration;
|
||||
byUrl(url: string | URL): ScmIntegration | undefined {
|
||||
return Object.values(this.byType)
|
||||
.map(i => i.byUrl(url))
|
||||
.find(Boolean);
|
||||
}
|
||||
|
||||
byHost(host: string): ScmIntegration | undefined {
|
||||
return Object.values(this.byType)
|
||||
.map(i => i.byHost(host))
|
||||
.find(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ describe('AzureIntegration', () => {
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(integrations.length).toBe(2); // including default
|
||||
expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true);
|
||||
expect(integrations.list().length).toBe(2); // including default
|
||||
expect(integrations.list()[0].config.host).toBe('h.com');
|
||||
expect(integrations.list()[1].config.host).toBe('dev.azure.com');
|
||||
});
|
||||
|
||||
it('returns the basics', () => {
|
||||
|
||||
@@ -14,27 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegration, ScmIntegrationFactory } from '../types';
|
||||
import { basicIntegrations } from '../helpers';
|
||||
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
|
||||
import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config';
|
||||
|
||||
export class AzureIntegration implements ScmIntegration {
|
||||
static factory: ScmIntegrationFactory = ({ config }) => {
|
||||
static factory: ScmIntegrationsFactory<AzureIntegration> = ({ config }) => {
|
||||
const configs = readAzureIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.azure') ?? [],
|
||||
);
|
||||
return configs.map(integration => ({
|
||||
predicate: (url: URL) => url.host === integration.host,
|
||||
integration: new AzureIntegration(integration),
|
||||
}));
|
||||
return basicIntegrations(
|
||||
configs.map(c => new AzureIntegration(c)),
|
||||
i => i.config.host,
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly config: AzureIntegrationConfig) {}
|
||||
constructor(private readonly integrationConfig: AzureIntegrationConfig) {}
|
||||
|
||||
get type(): string {
|
||||
return 'azure';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.config.host;
|
||||
return this.integrationConfig.host;
|
||||
}
|
||||
|
||||
get config(): AzureIntegrationConfig {
|
||||
return this.integrationConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ describe('BitbucketIntegration', () => {
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(integrations.length).toBe(2); // including default
|
||||
expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true);
|
||||
expect(integrations.list().length).toBe(2); // including default
|
||||
expect(integrations.list()[0].config.host).toBe('h.com');
|
||||
expect(integrations.list()[1].config.host).toBe('bitbucket.org');
|
||||
});
|
||||
|
||||
it('returns the basics', () => {
|
||||
|
||||
@@ -14,30 +14,37 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegration, ScmIntegrationFactory } from '../types';
|
||||
import { basicIntegrations } from '../helpers';
|
||||
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
|
||||
import {
|
||||
BitbucketIntegrationConfig,
|
||||
readBitbucketIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
export class BitbucketIntegration implements ScmIntegration {
|
||||
static factory: ScmIntegrationFactory = ({ config }) => {
|
||||
static factory: ScmIntegrationsFactory<BitbucketIntegration> = ({
|
||||
config,
|
||||
}) => {
|
||||
const configs = readBitbucketIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.bitbucket') ?? [],
|
||||
);
|
||||
return configs.map(integration => ({
|
||||
predicate: (url: URL) => url.host === integration.host,
|
||||
integration: new BitbucketIntegration(integration),
|
||||
}));
|
||||
return basicIntegrations(
|
||||
configs.map(c => new BitbucketIntegration(c)),
|
||||
i => i.config.host,
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly config: BitbucketIntegrationConfig) {}
|
||||
constructor(private readonly integrationConfig: BitbucketIntegrationConfig) {}
|
||||
|
||||
get type(): string {
|
||||
return 'bitbucket';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.config.host;
|
||||
return this.integrationConfig.host;
|
||||
}
|
||||
|
||||
get config(): BitbucketIntegrationConfig {
|
||||
return this.integrationConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +33,20 @@ describe('GitHubIntegration', () => {
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(integrations.length).toBe(2); // including default
|
||||
expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true);
|
||||
expect(integrations.list().length).toBe(2); // including default
|
||||
expect(integrations.list()[0].config.host).toBe('h.com');
|
||||
expect(integrations.list()[1].config.host).toBe('github.com');
|
||||
});
|
||||
|
||||
it('returns the basics', () => {
|
||||
const integration = new GitHubIntegration({ host: 'h.com' } as any);
|
||||
const integration = new GitHubIntegration({
|
||||
host: 'h.com',
|
||||
apiBaseUrl: 'a',
|
||||
rawBaseUrl: 'r',
|
||||
token: 't',
|
||||
});
|
||||
expect(integration.type).toBe('github');
|
||||
expect(integration.title).toBe('h.com');
|
||||
expect(integration.config.host).toBe('h.com');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,30 +14,35 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegration, ScmIntegrationFactory } from '../types';
|
||||
import { basicIntegrations } from '../helpers';
|
||||
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
export class GitHubIntegration implements ScmIntegration {
|
||||
static factory: ScmIntegrationFactory = ({ config }) => {
|
||||
static factory: ScmIntegrationsFactory<GitHubIntegration> = ({ config }) => {
|
||||
const configs = readGitHubIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.github') ?? [],
|
||||
);
|
||||
return configs.map(integration => ({
|
||||
predicate: (url: URL) => url.host === integration.host,
|
||||
integration: new GitHubIntegration(integration),
|
||||
}));
|
||||
return basicIntegrations(
|
||||
configs.map(c => new GitHubIntegration(c)),
|
||||
i => i.config.host,
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly config: GitHubIntegrationConfig) {}
|
||||
constructor(private readonly integrationConfig: GitHubIntegrationConfig) {}
|
||||
|
||||
get type(): string {
|
||||
return 'github';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.config.host;
|
||||
return this.integrationConfig.host;
|
||||
}
|
||||
|
||||
get config(): GitHubIntegrationConfig {
|
||||
return this.integrationConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ describe('GitLabIntegration', () => {
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(integrations.length).toBe(2); // including default
|
||||
expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true);
|
||||
expect(integrations.list().length).toBe(2); // including default
|
||||
expect(integrations.list()[0].config.host).toBe('h.com');
|
||||
expect(integrations.list()[1].config.host).toBe('gitlab.com');
|
||||
});
|
||||
|
||||
it('returns the basics', () => {
|
||||
|
||||
@@ -14,30 +14,35 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegration, ScmIntegrationFactory } from '../types';
|
||||
import { basicIntegrations } from '../helpers';
|
||||
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
|
||||
import {
|
||||
GitLabIntegrationConfig,
|
||||
readGitLabIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
export class GitLabIntegration implements ScmIntegration {
|
||||
static factory: ScmIntegrationFactory = ({ config }) => {
|
||||
static factory: ScmIntegrationsFactory<GitLabIntegration> = ({ config }) => {
|
||||
const configs = readGitLabIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.gitlab') ?? [],
|
||||
);
|
||||
return configs.map(integration => ({
|
||||
predicate: (url: URL) => url.host === integration.host,
|
||||
integration: new GitLabIntegration(integration),
|
||||
}));
|
||||
return basicIntegrations(
|
||||
configs.map(c => new GitLabIntegration(c)),
|
||||
i => i.config.host,
|
||||
);
|
||||
};
|
||||
|
||||
constructor(private readonly config: GitLabIntegrationConfig) {}
|
||||
constructor(private readonly integrationConfig: GitLabIntegrationConfig) {}
|
||||
|
||||
get type(): string {
|
||||
return 'gitlab';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.config.host;
|
||||
return this.integrationConfig.host;
|
||||
}
|
||||
|
||||
get config(): GitLabIntegrationConfig {
|
||||
return this.integrationConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegration, ScmIntegrationsGroup } from './types';
|
||||
|
||||
/** Checks whether the given url is a valid host */
|
||||
export function isValidHost(url: string): boolean {
|
||||
const check = new URL('http://example.com');
|
||||
check.host = url;
|
||||
return check.host === url;
|
||||
}
|
||||
|
||||
export function basicIntegrations<T extends ScmIntegration>(
|
||||
integrations: T[],
|
||||
getHost: (integration: T) => string,
|
||||
): ScmIntegrationsGroup<T> {
|
||||
return {
|
||||
list(): T[] {
|
||||
return integrations;
|
||||
},
|
||||
byUrl(url: string | URL): T | undefined {
|
||||
const parsed = typeof url === 'string' ? new URL(url) : url;
|
||||
return integrations.find(i => getHost(i) === parsed.hostname);
|
||||
},
|
||||
byHost(host: string): T | undefined {
|
||||
return integrations.find(i => getHost(i) === host);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { AzureIntegration } from './azure/AzureIntegration';
|
||||
import { BitbucketIntegration } from './bitbucket/BitbucketIntegration';
|
||||
import { GitHubIntegration } from './github/GitHubIntegration';
|
||||
import { GitLabIntegration } from './gitlab/GitLabIntegration';
|
||||
|
||||
/**
|
||||
* Encapsulates a single SCM integration.
|
||||
*/
|
||||
export type ScmIntegration = {
|
||||
export interface ScmIntegration {
|
||||
/**
|
||||
* The type of integration, e.g. "github".
|
||||
*/
|
||||
@@ -30,30 +34,43 @@ export type ScmIntegration = {
|
||||
* differentiate between different integrations.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds all registered SCM integrations.
|
||||
* Encapsulates several integrations, that are all of the same type.
|
||||
*/
|
||||
export type ScmIntegrationRegistry = {
|
||||
export interface ScmIntegrationsGroup<T extends ScmIntegration> {
|
||||
/**
|
||||
* Lists all registered integrations.
|
||||
* Lists all registered integrations of this type.
|
||||
*/
|
||||
list(): ScmIntegration[];
|
||||
list(): T[];
|
||||
|
||||
/**
|
||||
* Fetches an integration by URL.
|
||||
* Fetches an integration of this type by URL.
|
||||
*
|
||||
* @param url A URL that matches a registered integration
|
||||
* @param url A URL that matches a registered integration of this type
|
||||
*/
|
||||
byUrl(url: string): ScmIntegration | undefined;
|
||||
};
|
||||
byUrl(url: string | URL): T | undefined;
|
||||
|
||||
export type ScmIntegrationPredicateTuple = {
|
||||
predicate: (url: URL) => boolean;
|
||||
integration: ScmIntegration;
|
||||
};
|
||||
/**
|
||||
* Fetches an integration of this type by host name.
|
||||
*
|
||||
* @param url A host name that matches a registered integration of this type
|
||||
*/
|
||||
byHost(host: string): T | undefined;
|
||||
}
|
||||
|
||||
export type ScmIntegrationFactory = (options: {
|
||||
/**
|
||||
* Holds all registered SCM integrations, of all types.
|
||||
*/
|
||||
export interface ScmIntegrationRegistry
|
||||
extends ScmIntegrationsGroup<ScmIntegration> {
|
||||
azure: ScmIntegrationsGroup<AzureIntegration>;
|
||||
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
|
||||
github: ScmIntegrationsGroup<GitHubIntegration>;
|
||||
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
|
||||
}
|
||||
|
||||
export type ScmIntegrationsFactory<T extends ScmIntegration> = (options: {
|
||||
config: Config;
|
||||
}) => ScmIntegrationPredicateTuple[];
|
||||
}) => ScmIntegrationsGroup<T>;
|
||||
|
||||
@@ -186,7 +186,7 @@ export class AwsS3Publish implements PublisherBase {
|
||||
fileStreamChunks.push(chunk);
|
||||
})
|
||||
.on('end', () => {
|
||||
const fileContent = Buffer.concat(fileStreamChunks).toString();
|
||||
const fileContent = Buffer.concat(fileStreamChunks);
|
||||
// Inject response headers
|
||||
for (const [headerKey, headerValue] of Object.entries(
|
||||
responseHeaders,
|
||||
|
||||
@@ -184,7 +184,7 @@ export class GoogleGCSPublish implements PublisherBase {
|
||||
fileStreamChunks.push(chunk);
|
||||
})
|
||||
.on('end', () => {
|
||||
const fileContent = Buffer.concat(fileStreamChunks).toString();
|
||||
const fileContent = Buffer.concat(fileStreamChunks);
|
||||
// Inject response headers
|
||||
for (const [headerKey, headerValue] of Object.entries(
|
||||
responseHeaders,
|
||||
|
||||
@@ -89,8 +89,16 @@ export AUTH_GITLAB_CLIENT_SECRET=x
|
||||
|
||||
### Okta
|
||||
|
||||
Add a new Okta application using the following URI conventions:
|
||||
|
||||
Login redirect URI's: http://localhost:7000/api/auth/okta/handler/frame
|
||||
Logout redirect URI's: http://localhost:7000/api/auth/okta/logout
|
||||
Initiate login URI's: http://localhost:7000/api/auth/okta/start
|
||||
|
||||
Then configure the following environment variables to be used in the `app-config.yaml` file:
|
||||
|
||||
```bash
|
||||
export AUTH_OKTA_AUDIENCE=x
|
||||
export AUTH_OKTA_AUDIENCE=https://example.okta.com
|
||||
export AUTH_OKTA_CLIENT_ID=x
|
||||
export AUTH_OKTA_CLIENT_SECRET=x
|
||||
```
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"@backstage/catalog-model": "^0.6.0",
|
||||
"@backstage/core": "^0.4.3",
|
||||
"@backstage/plugin-catalog": "^0.2.10",
|
||||
"@backstage/plugin-catalog-backend": "^0.5.2",
|
||||
"@backstage/integration": "^0.1.5",
|
||||
"@backstage/theme": "^0.2.2",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { DiscoveryApi, OAuthApi, ConfigApi } from '@backstage/core';
|
||||
import { CatalogImportApi } from './CatalogImportApi';
|
||||
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend';
|
||||
import { PartialEntity } from '../util/types';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
|
||||
@@ -61,8 +60,8 @@ export class CatalogImportClient implements CatalogImportApi {
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AnalyzeLocationResponse;
|
||||
return payload.generateEntities.map(x => x.entity);
|
||||
const payload = await response.json();
|
||||
return payload.generateEntities.map((x: any) => x.entity);
|
||||
}
|
||||
|
||||
async createRepositoryLocation({
|
||||
|
||||
@@ -63,7 +63,7 @@ export class JenkinsApi {
|
||||
return lastBuild;
|
||||
}
|
||||
|
||||
extractScmDetailsFromJob(jobDetails: any): any {
|
||||
extractScmDetailsFromJob(jobDetails: any): any | undefined {
|
||||
const scmInfo = jobDetails.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
@@ -79,6 +79,10 @@ export class JenkinsApi {
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (!scmInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const author = jobDetails.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
@@ -141,7 +145,7 @@ export class JenkinsApi {
|
||||
|
||||
for (const jobDetails of folder.jobs) {
|
||||
const jobScmInfo = this.extractScmDetailsFromJob(jobDetails);
|
||||
if (jobDetails.jobs) {
|
||||
if (jobDetails?.jobs) {
|
||||
// skipping folders inside folders for now
|
||||
} else {
|
||||
for (const buildDetails of jobDetails.builds) {
|
||||
|
||||
@@ -18,6 +18,17 @@ spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
args:
|
||||
- bash
|
||||
- -c
|
||||
- yes > /dev/null & yes > /dev/null & yes > /dev/null
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '50m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '50m'
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
@@ -44,14 +55,134 @@ spec:
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '50m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '500m'
|
||||
- name: side-car
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 81
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '50m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '500m'
|
||||
- name: other-side-car
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 82
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '50m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '500m'
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: dice-roller-bad-config
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: dice-roller-bad-config
|
||||
replicas: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dice-roller-bad-config
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:6000000
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '50m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '500m'
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: dice-roller-big
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: dice-roller-big
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dice-roller-big
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '100000m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '100000m'
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: dice-roller-bad-cm
|
||||
labels:
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: dice-roller-bad-cm
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dice-roller-bad-cm
|
||||
'backstage.io/kubernetes-id': dice-roller
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
env:
|
||||
- name: SOME_ENV_VAR
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: some-cm
|
||||
key: some-key
|
||||
resources:
|
||||
requests:
|
||||
memory: '64Mi'
|
||||
cpu: '50m'
|
||||
limits:
|
||||
memory: '128Mi'
|
||||
cpu: '500m'
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
---
|
||||
apiVersion: autoscaling/v1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="282" height="173" fill="none" viewBox="0 0 282 173"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M16.4571 45.1637C11.0514 46.1711 7.48574 51.3699 8.49306 56.7756C9.50039 62.1814 14.6992 65.747 20.105 64.7397L27.5528 63.3518C25.4791 65.5835 24.4525 68.7347 25.0535 71.9596C26.0608 77.3653 31.2596 80.931 36.6654 79.9236L89.691 70.0427C89.7016 70.1067 89.7129 70.1708 89.7249 70.2349C90.3258 73.4598 92.4185 76.0298 95.1569 77.3647L91.9031 77.971C86.4974 78.9784 82.9318 84.1772 83.9391 89.583C84.9464 94.9887 90.1452 98.5543 95.551 97.547L250.098 68.7482C255.504 67.7409 259.069 62.5421 258.062 57.1363C257.461 53.9114 255.368 51.3414 252.63 50.0065L257.835 49.0366C263.241 48.0292 266.807 42.8304 265.799 37.4247C264.792 32.0189 259.593 28.4533 254.187 29.4606L161.492 46.7338C161.481 46.6697 161.47 46.6056 161.458 46.5415C160.857 43.3166 158.764 40.7466 156.026 39.4117L165.025 37.7347C170.431 36.7274 173.997 31.5286 172.989 26.1228C171.982 20.7171 166.783 17.1514 161.378 18.1588L16.4571 45.1637ZM24.3031 122.54C23.2958 117.134 26.8614 111.936 32.2672 110.928L190.856 81.3762C196.262 80.3688 201.461 83.9345 202.468 89.3402C203.476 94.746 199.91 99.9448 194.504 100.952L189.963 101.798C190.493 102.057 190.999 102.362 191.474 102.708L246.43 92.4677C251.835 91.4604 257.034 95.026 258.041 100.432C258.642 103.657 257.616 106.808 255.542 109.04L256.649 108.833C262.055 107.826 267.253 111.392 268.261 116.797C269.268 122.203 265.702 127.402 260.297 128.409L95.5591 159.107C90.1534 160.114 84.9545 156.549 83.9472 151.143C82.9399 145.737 86.5055 140.538 91.9113 139.531L103.94 137.29C103.41 137.031 102.904 136.726 102.429 136.38L29.1002 150.044C23.6944 151.051 18.4956 147.486 17.4882 142.08C16.4809 136.674 20.0465 131.475 25.4523 130.468L29.7352 129.67C26.9967 128.335 24.904 125.765 24.3031 122.54Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><path fill="#EEE" d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V46.8101C47.4005 48.9598 49.1452 50.7025 51.2975 50.7025H232.896C235.048 50.7025 236.793 48.9598 236.793 46.8101V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z"/><mask id="mask0" width="190" height="114" x="47" y="31" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#404040" d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V141.007C47.4005 143.157 49.1452 144.9 51.2975 144.9H232.896C235.048 144.9 236.793 143.157 236.793 141.007V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z"/></mask><g mask="url(#mask0)"><path fill="#EEE" d="M239.91 42.1391H47.4005V150.349H239.91V42.1391Z"/></g></g><circle cx="188" cy="55" r="6" fill="#69DDC7"/><circle cx="91" cy="92" r="6" fill="#69DDC7"/><path fill="#69DDC7" d="M121 114L95.5 88L86.5 96L121 130L192.5 59L183.5 51L121 114Z"/><defs><filter id="filter0_d" width="229.392" height="153.66" x="29.401" y="15.24" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="10"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -40,6 +40,7 @@
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"js-yaml": "^4.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* 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 { ConfigMaps } from './ConfigMaps';
|
||||
import * as configmapFixture from './__fixtures__/configmap.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
|
||||
describe('ConfigMaps', () => {
|
||||
it('should render configmap', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ConfigMaps configMaps={(configmapFixture as any).default} />,
|
||||
),
|
||||
);
|
||||
|
||||
// title
|
||||
expect(getByText('dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('Config Map')).toBeInTheDocument();
|
||||
|
||||
// values
|
||||
expect(getByText('Immutable')).toBeInTheDocument();
|
||||
expect(getByText('false')).toBeInTheDocument();
|
||||
expect(getByText('Data')).toBeInTheDocument();
|
||||
expect(getByText('Foo: bar')).toBeInTheDocument(); // TODO wish this wasn't upper case
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* 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 { Grid } from '@material-ui/core';
|
||||
import { V1ConfigMap } from '@kubernetes/client-node';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
|
||||
type ConfigMapsProps = {
|
||||
configMaps: V1ConfigMap[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConfigMaps = ({ configMaps }: ConfigMapsProps) => {
|
||||
return (
|
||||
<Grid container>
|
||||
{configMaps.map((cm, i) => {
|
||||
return (
|
||||
<Grid item key={i}>
|
||||
<InfoCard
|
||||
title={cm.metadata?.name ?? 'un-named service'}
|
||||
subheader="Config Map"
|
||||
>
|
||||
<div>
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
immutable: `${cm.immutable === true}`,
|
||||
data: cm.data,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
[
|
||||
{
|
||||
"data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"bar\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-24T11:39:26.000Z",
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"managedFields": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:data": {
|
||||
".": {},
|
||||
"f:foo": {}
|
||||
},
|
||||
"f:metadata": {
|
||||
"f:annotations": {
|
||||
".": {},
|
||||
"f:kubectl.kubernetes.io/last-applied-configuration": {}
|
||||
},
|
||||
"f:labels": {
|
||||
".": {},
|
||||
"f:backstage.io/kubernetes-id": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"manager": "kubectl",
|
||||
"operation": "Update",
|
||||
"time": "2020-09-24T11:39:26.000Z"
|
||||
}
|
||||
],
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "503867",
|
||||
"selfLink": "/api/v1/namespaces/default/configmaps/dice-roller",
|
||||
"uid": "e9efe5ee-53b9-4422-aef2-877a03c73d5f"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
* 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, { Fragment } from 'react';
|
||||
import { Chip, Grid } from '@material-ui/core';
|
||||
import {
|
||||
StatusAborted,
|
||||
StatusError,
|
||||
StatusOK,
|
||||
SubvalueCell,
|
||||
Table,
|
||||
TableColumn,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
V1ComponentCondition,
|
||||
V1Deployment,
|
||||
V1Pod,
|
||||
V1ReplicaSet,
|
||||
} from '@kubernetes/client-node';
|
||||
import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference';
|
||||
import { DeploymentTriple } from '../../types/types';
|
||||
|
||||
const renderCondition = (condition: V1ComponentCondition | undefined) => {
|
||||
if (!condition) {
|
||||
return <StatusAborted />;
|
||||
}
|
||||
|
||||
const status = condition.status;
|
||||
|
||||
if (status === 'True') {
|
||||
return <StatusOK />;
|
||||
} else if (status === 'False') {
|
||||
return <StatusError />;
|
||||
}
|
||||
return <StatusAborted />;
|
||||
};
|
||||
|
||||
const columns: TableColumn<V1Pod>[] = [
|
||||
{
|
||||
title: 'name',
|
||||
highlight: true,
|
||||
width: '20%',
|
||||
render: (pod: V1Pod) => pod.metadata?.name ?? 'un-named pod',
|
||||
},
|
||||
{
|
||||
title: 'images',
|
||||
width: '20%',
|
||||
render: (pod: V1Pod) => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
return containerStatuses.map((cs, i) => {
|
||||
return <Chip key={i} label={`${cs.name}=${cs.image}`} size="small" />;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'phase',
|
||||
render: (pod: V1Pod) => pod.status?.phase ?? 'unknown',
|
||||
},
|
||||
{
|
||||
title: 'containers ready',
|
||||
align: 'center',
|
||||
render: (pod: V1Pod) => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const containersReady = containerStatuses.filter(cs => cs.ready).length;
|
||||
|
||||
return `${containersReady}/${containerStatuses.length}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'total restarts',
|
||||
render: (pod: V1Pod) => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
return containerStatuses?.reduce((a, b) => a + b.restartCount, 0);
|
||||
},
|
||||
type: 'numeric',
|
||||
},
|
||||
{
|
||||
title: 'status',
|
||||
width: '20%',
|
||||
render: (pod: V1Pod) => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const errors = containerStatuses.reduce((accum, next) => {
|
||||
if (next.state === undefined) {
|
||||
return accum;
|
||||
}
|
||||
|
||||
const waiting = next.state.waiting;
|
||||
const terminated = next.state.terminated;
|
||||
|
||||
const renderCell = (reason: string | undefined) => (
|
||||
<Fragment key={`${pod.metadata?.name}-${next.name}`}>
|
||||
<SubvalueCell
|
||||
value={<StatusError>Container: {next.name}</StatusError>}
|
||||
subvalue={reason}
|
||||
/>
|
||||
<br />
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (waiting) {
|
||||
accum.push(renderCell(waiting.reason));
|
||||
}
|
||||
|
||||
if (terminated) {
|
||||
accum.push(renderCell(terminated.reason));
|
||||
}
|
||||
|
||||
return accum;
|
||||
}, [] as React.ReactNode[]);
|
||||
|
||||
if (errors.length === 0) {
|
||||
return <StatusOK>OK</StatusOK>;
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Pod Initialized',
|
||||
align: 'center',
|
||||
render: (pod: V1Pod) => {
|
||||
const conditions = pod.status?.conditions ?? [];
|
||||
return renderCondition(conditions.find(c => c.type === 'Initialized'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Pod Ready',
|
||||
align: 'center',
|
||||
render: (pod: V1Pod) => {
|
||||
const conditions = pod.status?.conditions ?? [];
|
||||
return renderCondition(conditions.find(c => c.type === 'Ready'));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Containers Ready',
|
||||
align: 'center',
|
||||
render: (pod: V1Pod) => {
|
||||
const conditions = pod.status?.conditions ?? [];
|
||||
return renderCondition(
|
||||
conditions.find(c => c.type === 'ContainersReady'),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Pod Scheduled',
|
||||
align: 'center',
|
||||
render: (pod: V1Pod) => {
|
||||
const conditions = pod.status?.conditions ?? [];
|
||||
return renderCondition(conditions.find(c => c.type === 'PodScheduled'));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type DeploymentTablesProps = {
|
||||
deploymentTriple: DeploymentTriple;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DeploymentTables = ({
|
||||
deploymentTriple,
|
||||
}: DeploymentTablesProps) => {
|
||||
const isOwnedBy = (
|
||||
ownerReferences: V1OwnerReference[],
|
||||
obj: V1Pod | V1ReplicaSet | V1Deployment,
|
||||
): boolean => {
|
||||
return ownerReferences?.some(or => or.name === obj.metadata?.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
{deploymentTriple.deployments.map((deployment, i) => (
|
||||
<Grid container item key={i} xs>
|
||||
{deploymentTriple.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 = deploymentTriple.pods.filter(pod =>
|
||||
isOwnedBy(pod.metadata?.ownerReferences ?? [], rs),
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid item key={j} xs>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense', search: false }}
|
||||
data={ownedPods}
|
||||
columns={columns}
|
||||
title={deployment.metadata?.name ?? ''}
|
||||
subtitle="Deployment"
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 * as deployments from './__fixtures__/2-deployments.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { DeploymentDrawer } from './DeploymentDrawer';
|
||||
|
||||
describe('DeploymentDrawer', () => {
|
||||
it('should render deployment drawer', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(
|
||||
<DeploymentDrawer
|
||||
deployment={(deployments as any).deployments[0]}
|
||||
expanded
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getAllByText('dice-roller')).toHaveLength(2);
|
||||
expect(getAllByText('Deployment')).toHaveLength(2);
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Strategy')).toBeInTheDocument();
|
||||
expect(getByText('Rolling Update:')).toBeInTheDocument();
|
||||
expect(getByText('Max Surge: 25%')).toBeInTheDocument();
|
||||
expect(getByText('Max Unavailable: 25%')).toBeInTheDocument();
|
||||
expect(getByText('Type: RollingUpdate')).toBeInTheDocument();
|
||||
expect(getByText('Min Ready Seconds')).toBeInTheDocument();
|
||||
expect(getByText('???')).toBeInTheDocument();
|
||||
expect(getByText('Progress Deadline Seconds')).toBeInTheDocument();
|
||||
expect(getByText('600')).toBeInTheDocument();
|
||||
expect(getByText('Progressing')).toBeInTheDocument();
|
||||
expect(getByText('Available')).toBeInTheDocument();
|
||||
expect(getAllByText('True')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 { V1Deployment } from '@kubernetes/client-node';
|
||||
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
|
||||
import { renderCondition } from '../../utils/pod';
|
||||
import { Typography, Grid } from '@material-ui/core';
|
||||
|
||||
export const DeploymentDrawer = ({
|
||||
deployment,
|
||||
expanded,
|
||||
}: {
|
||||
deployment: V1Deployment;
|
||||
expanded?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<KubernetesDrawer
|
||||
object={deployment}
|
||||
expanded={expanded}
|
||||
kind="Deployment"
|
||||
renderObject={(deployment: V1Deployment) => {
|
||||
const conditions = (deployment.status?.conditions ?? [])
|
||||
.map(renderCondition)
|
||||
.reduce((accum, next) => {
|
||||
accum[next[0]] = next[1];
|
||||
return accum;
|
||||
}, {} as { [key: string]: React.ReactNode });
|
||||
|
||||
return {
|
||||
strategy: deployment.spec?.strategy ?? '???',
|
||||
minReadySeconds: deployment.spec?.minReadySeconds ?? '???',
|
||||
progressDeadlineSeconds:
|
||||
deployment.spec?.progressDeadlineSeconds ?? '???',
|
||||
...conditions,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
spacing={0}
|
||||
>
|
||||
<Grid item>
|
||||
<Typography variant="h5">
|
||||
{deployment.metadata?.name ?? 'unknown object'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
Deployment
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</KubernetesDrawer>
|
||||
);
|
||||
};
|
||||
+16
-10
@@ -16,26 +16,32 @@
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { DeploymentTables } from './DeploymentTables';
|
||||
import { DeploymentsAccordions } from './DeploymentsAccordions';
|
||||
import * as twoDeployFixture from './__fixtures__/2-deployments.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
|
||||
describe('DeploymentTables', () => {
|
||||
describe('DeploymentsAccordions', () => {
|
||||
it('should render 2 deployments', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<DeploymentTables deploymentTriple={twoDeployFixture as any} />,
|
||||
<DeploymentsAccordions
|
||||
deploymentResources={twoDeployFixture as any}
|
||||
clusterPodNamesWithErrors={
|
||||
new Set(['dice-roller-canary-7d64cd756c-vtbdx'])
|
||||
}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
// title
|
||||
expect(getByText('dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('dice-roller-canary')).toBeInTheDocument();
|
||||
expect(getByText('10 pods')).toBeInTheDocument();
|
||||
expect(getByText('No pods with errors')).toBeInTheDocument();
|
||||
expect(getByText('min replicas 10 / max replicas 15')).toBeInTheDocument();
|
||||
expect(getByText('current CPU usage: 30%')).toBeInTheDocument();
|
||||
expect(getByText('target CPU usage: 50%')).toBeInTheDocument();
|
||||
|
||||
// pod names
|
||||
expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText('dice-roller-canary-7d64cd756c-55rfq'),
|
||||
).toBeInTheDocument();
|
||||
expect(getByText('dice-roller-canary')).toBeInTheDocument();
|
||||
expect(getByText('2 pods')).toBeInTheDocument();
|
||||
expect(getByText('1 pod with errors')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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 { DeploymentResources } from '../../types/types';
|
||||
import React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Divider,
|
||||
Grid,
|
||||
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';
|
||||
|
||||
type DeploymentsAccordionsProps = {
|
||||
deploymentResources: DeploymentResources;
|
||||
clusterPodNamesWithErrors: Set<string>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DeploymentsAccordions = ({
|
||||
deploymentResources,
|
||||
clusterPodNamesWithErrors,
|
||||
}: DeploymentsAccordionsProps) => {
|
||||
const isOwnedBy = (
|
||||
ownerReferences: V1OwnerReference[],
|
||||
obj: V1Pod | V1ReplicaSet | V1Deployment,
|
||||
): boolean => {
|
||||
return ownerReferences?.some(or => or.name === obj.metadata?.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
{deploymentResources.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>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type DeploymentAccordionProps = {
|
||||
deployment: V1Deployment;
|
||||
ownedPods: V1Pod[];
|
||||
matchingHpa?: V1HorizontalPodAutoscaler;
|
||||
clusterPodNamesWithErrors: Set<string>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DeploymentAccordion = ({
|
||||
deployment,
|
||||
ownedPods,
|
||||
matchingHpa,
|
||||
clusterPodNamesWithErrors,
|
||||
}: DeploymentAccordionProps) => {
|
||||
const podsWithErrors = ownedPods.filter(p =>
|
||||
clusterPodNamesWithErrors.has(p.metadata?.name ?? ''),
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion TransitionProps={{ unmountOnExit: true }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<DeploymentSummary
|
||||
deployment={deployment}
|
||||
numberOfCurrentPods={ownedPods.length}
|
||||
numberOfPodsWithErrors={podsWithErrors.length}
|
||||
hpa={matchingHpa}
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<PodsTable pods={ownedPods} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
type DeploymentSummaryProps = {
|
||||
deployment: V1Deployment;
|
||||
numberOfCurrentPods: number;
|
||||
numberOfPodsWithErrors: number;
|
||||
hpa?: V1HorizontalPodAutoscaler;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DeploymentSummary = ({
|
||||
deployment,
|
||||
numberOfCurrentPods,
|
||||
numberOfPodsWithErrors,
|
||||
hpa,
|
||||
}: DeploymentSummaryProps) => {
|
||||
return (
|
||||
<Grid container direction="row" justify="flex-start" alignItems="center">
|
||||
<Grid xs={3} item>
|
||||
<DeploymentDrawer deployment={deployment} />
|
||||
</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>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
+84
@@ -4431,5 +4431,89 @@
|
||||
"updatedReplicas": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"horizontalPodAutoscalers": [
|
||||
{
|
||||
"apiVersion": "autoscaling/v1",
|
||||
"kind": "HorizontalPodAutoscaler",
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2021-01-05T10:26:04Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2021-01-05T10:26:04Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: no metrics returned from resource metrics API\"}]",
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n"
|
||||
},
|
||||
"creationTimestamp": "2021-01-05T10:25:48Z",
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"managedFields": [
|
||||
{
|
||||
"apiVersion": "autoscaling/v1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:metadata": {
|
||||
"f:annotations": {
|
||||
".": {},
|
||||
"f:kubectl.kubernetes.io/last-applied-configuration": {}
|
||||
},
|
||||
"f:labels": {
|
||||
".": {},
|
||||
"f:backstage.io/kubernetes-id": {}
|
||||
}
|
||||
},
|
||||
"f:spec": {
|
||||
"f:maxReplicas": {},
|
||||
"f:minReplicas": {},
|
||||
"f:scaleTargetRef": {
|
||||
"f:apiVersion": {},
|
||||
"f:kind": {},
|
||||
"f:name": {}
|
||||
},
|
||||
"f:targetCPUUtilizationPercentage": {}
|
||||
}
|
||||
},
|
||||
"manager": "kubectl-client-side-apply",
|
||||
"operation": "Update",
|
||||
"time": "2021-01-05T10:25:48Z"
|
||||
},
|
||||
{
|
||||
"apiVersion": "autoscaling/v1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:metadata": {
|
||||
"f:annotations": {
|
||||
"f:autoscaling.alpha.kubernetes.io/conditions": {}
|
||||
}
|
||||
},
|
||||
"f:status": {
|
||||
"f:currentReplicas": {}
|
||||
}
|
||||
},
|
||||
"manager": "kube-controller-manager",
|
||||
"operation": "Update",
|
||||
"time": "2021-01-05T10:26:04Z"
|
||||
}
|
||||
],
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "598",
|
||||
"selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller",
|
||||
"uid": "dd7c5329-567c-43c2-b159-756808d90a8e"
|
||||
},
|
||||
"spec": {
|
||||
"maxReplicas": 15,
|
||||
"minReplicas": 10,
|
||||
"scaleTargetRef": {
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"name": "dice-roller"
|
||||
},
|
||||
"targetCPUUtilizationPercentage": 50
|
||||
},
|
||||
"status": {
|
||||
"currentReplicas": 10,
|
||||
"desiredReplicas": 0,
|
||||
"currentCPUUtilizationPercentage": 30
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { DeploymentTables } from './DeploymentTables';
|
||||
export { DeploymentsAccordions } from './DeploymentsAccordions';
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 * as React from 'react';
|
||||
import { Table, TableColumn, InfoCard } from '@backstage/core';
|
||||
import { DetectedError, DetectedErrorsByCluster } from '../../error-detection';
|
||||
import { Chip, Typography, Grid } from '@material-ui/core';
|
||||
import EmptyStateImage from '../../../assets/emptystate.svg';
|
||||
|
||||
type ErrorReportingProps = {
|
||||
detectedErrors: DetectedErrorsByCluster;
|
||||
};
|
||||
|
||||
const columns: TableColumn<DetectedError>[] = [
|
||||
{
|
||||
title: 'cluster',
|
||||
width: '15%',
|
||||
render: (detectedError: DetectedError) => detectedError.cluster,
|
||||
},
|
||||
{
|
||||
title: 'kind',
|
||||
width: '15%',
|
||||
render: (detectedError: DetectedError) => detectedError.kind,
|
||||
},
|
||||
{
|
||||
title: 'name',
|
||||
width: '30%',
|
||||
render: (detectedError: DetectedError) => {
|
||||
const errorCount = detectedError.names.length;
|
||||
|
||||
if (errorCount === 0) {
|
||||
// This shouldn't happen
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayName = detectedError.names[0];
|
||||
|
||||
const otherErrorCount = errorCount - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayName}{' '}
|
||||
{otherErrorCount > 0 && (
|
||||
<Chip
|
||||
label={`+ ${otherErrorCount} other${
|
||||
otherErrorCount > 1 ? 's' : ''
|
||||
}`}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'messages',
|
||||
width: '40%',
|
||||
render: (detectedError: DetectedError) => (
|
||||
<>
|
||||
{detectedError.message.map((m, i) => (
|
||||
<div key={i}>{m}</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const sortBySeverity = (a: DetectedError, b: DetectedError) => {
|
||||
if (a.severity < b.severity) {
|
||||
return 1;
|
||||
} else if (b.severity < a.severity) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const ErrorReporting = ({ detectedErrors }: ErrorReportingProps) => {
|
||||
const errors = Array.from(detectedErrors.values())
|
||||
.flat()
|
||||
.sort(sortBySeverity);
|
||||
|
||||
return (
|
||||
<>
|
||||
{errors.length === 0 ? (
|
||||
<InfoCard title="Error Reporting">
|
||||
<ErrorEmptyState />
|
||||
</InfoCard>
|
||||
) : (
|
||||
<Table
|
||||
title="Error Reporting"
|
||||
data={errors}
|
||||
columns={columns}
|
||||
options={{ paging: true, search: false }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorEmptyState = () => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
justify="center"
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">
|
||||
Nice! There are no errors to report!
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { ConfigMaps } from './ConfigMaps';
|
||||
export { ErrorReporting } from './ErrorReporting';
|
||||
+20
-22
@@ -16,37 +16,35 @@
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as hpaFixture from './__fixtures__/horizontalpodautoscalers.json';
|
||||
import * as hpas from './__fixtures__/horizontalpodautoscalers.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers';
|
||||
import { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer';
|
||||
|
||||
describe('HorizontalPodAutoscalers', () => {
|
||||
it('should render horizontalpodautoscaler', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
describe('HorizontalPodAutoscalersDrawer', () => {
|
||||
it('should render hpa drawer', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<HorizontalPodAutoscalers hpas={(hpaFixture as any).default} />,
|
||||
<HorizontalPodAutoscalerDrawer hpa={hpas[0] as any} expanded>
|
||||
<h1>CHILD</h1>
|
||||
</HorizontalPodAutoscalerDrawer>,
|
||||
),
|
||||
);
|
||||
|
||||
// title
|
||||
expect(getByText('dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('Horizontal Pod Autoscaler')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Scaling Target')).toBeInTheDocument();
|
||||
expect(getByText('Api Version: apps/v1')).toBeInTheDocument();
|
||||
expect(getByText('Kind: Deployment')).toBeInTheDocument();
|
||||
expect(getByText('Name: dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('Min Replicas')).toBeInTheDocument();
|
||||
expect(getByText('Max Replicas')).toBeInTheDocument();
|
||||
expect(getByText('15')).toBeInTheDocument();
|
||||
expect(getByText('Current Replicas')).toBeInTheDocument();
|
||||
expect(getByText('Desired Replicas')).toBeInTheDocument();
|
||||
expect(getByText('0')).toBeInTheDocument();
|
||||
expect(getByText('CHILD')).toBeInTheDocument();
|
||||
expect(getByText('HorizontalPodAutoscaler')).toBeInTheDocument();
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Target CPU Utilization Percentage')).toBeInTheDocument();
|
||||
expect(getByText('50')).toBeInTheDocument();
|
||||
expect(getByText('Current CPU Utilization Percentage')).toBeInTheDocument();
|
||||
expect(getByText('Last Scale Time')).toBeInTheDocument();
|
||||
expect(getAllByText('unknown')).toHaveLength(2);
|
||||
expect(getAllByText('10')).toHaveLength(2);
|
||||
expect(getByText('30')).toBeInTheDocument();
|
||||
expect(getByText('Min Replicas')).toBeInTheDocument();
|
||||
expect(getByText('10')).toBeInTheDocument();
|
||||
expect(getByText('Max Replicas')).toBeInTheDocument();
|
||||
expect(getByText('15')).toBeInTheDocument();
|
||||
expect(getByText('Current Replicas')).toBeInTheDocument();
|
||||
expect(getByText('13')).toBeInTheDocument();
|
||||
expect(getByText('Desired Replicas')).toBeInTheDocument();
|
||||
expect(getByText('14')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
|
||||
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
|
||||
|
||||
export const HorizontalPodAutoscalerDrawer = ({
|
||||
hpa,
|
||||
expanded,
|
||||
children,
|
||||
}: {
|
||||
hpa: V1HorizontalPodAutoscaler;
|
||||
expanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<KubernetesDrawer
|
||||
kind="HorizontalPodAutoscaler"
|
||||
object={hpa}
|
||||
expanded={expanded}
|
||||
renderObject={(hpa: V1HorizontalPodAutoscaler) => {
|
||||
return {
|
||||
targetCPUUtilizationPercentage:
|
||||
hpa.spec?.targetCPUUtilizationPercentage,
|
||||
currentCPUUtilizationPercentage:
|
||||
hpa.status?.currentCPUUtilizationPercentage,
|
||||
minReplicas: hpa.spec?.minReplicas,
|
||||
maxReplicas: hpa.spec?.maxReplicas,
|
||||
currentReplicas: hpa.status?.currentReplicas,
|
||||
desiredReplicas: hpa.status?.desiredReplicas,
|
||||
};
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</KubernetesDrawer>
|
||||
);
|
||||
};
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* 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 { Grid } from '@material-ui/core';
|
||||
import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
import { orUnknown } from '../../utils';
|
||||
|
||||
type HorizontalPodAutoscalersProps = {
|
||||
hpas: V1HorizontalPodAutoscaler[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const HorizontalPodAutoscalers = ({
|
||||
hpas,
|
||||
}: HorizontalPodAutoscalersProps) => {
|
||||
return (
|
||||
<Grid container>
|
||||
{hpas.map((hpa, i) => {
|
||||
return (
|
||||
<Grid item key={i}>
|
||||
<InfoCard
|
||||
title={hpa.metadata?.name ?? 'un-named service'}
|
||||
subheader="Horizontal Pod Autoscaler"
|
||||
>
|
||||
<div>
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
scalingTarget: orUnknown(hpa.spec?.scaleTargetRef),
|
||||
minReplicas: orUnknown(hpa.spec?.minReplicas),
|
||||
maxReplicas: orUnknown(hpa.spec?.maxReplicas),
|
||||
currentReplicas: orUnknown(hpa.status?.currentReplicas),
|
||||
desiredReplicas: orUnknown(hpa.status?.desiredReplicas),
|
||||
targetCPUUtilizationPercentage: orUnknown(
|
||||
hpa.spec?.targetCPUUtilizationPercentage,
|
||||
),
|
||||
currentCPUUtilizationPercentage: orUnknown(
|
||||
hpa.status?.currentCPUUtilizationPercentage,
|
||||
),
|
||||
lastScaleTime: orUnknown(hpa.status?.lastScaleTime),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
+3
-2
@@ -74,8 +74,9 @@
|
||||
"targetCPUUtilizationPercentage": 50
|
||||
},
|
||||
"status": {
|
||||
"currentReplicas": 10,
|
||||
"desiredReplicas": 0
|
||||
"currentReplicas": 13,
|
||||
"desiredReplicas": 14,
|
||||
"currentCPUUtilizationPercentage": 30
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers';
|
||||
export { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer';
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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 * as ingressesFixture from './__fixtures__/ingress.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { Ingresses } from './Ingresses';
|
||||
|
||||
describe('Ingresses', () => {
|
||||
it('should render ingress', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<Ingresses ingresses={(ingressesFixture as any).default} />,
|
||||
),
|
||||
);
|
||||
|
||||
// title
|
||||
expect(getByText('dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('Ingress')).toBeInTheDocument();
|
||||
|
||||
// values
|
||||
expect(getByText('Backend')).toBeInTheDocument();
|
||||
expect(getByText('Ip: 192.168.64.2')).toBeInTheDocument();
|
||||
expect(getByText('Rules')).toBeInTheDocument();
|
||||
expect(getByText('Host: nginx')).toBeInTheDocument();
|
||||
expect(getByText('Http:')).toBeInTheDocument();
|
||||
expect(getByText('Paths:')).toBeInTheDocument();
|
||||
expect(getByText('Service Name: dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('Service Port: 80')).toBeInTheDocument();
|
||||
expect(getByText('Path: /')).toBeInTheDocument();
|
||||
expect(getByText('Path Type: ImplementationSpecific')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* 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 { Grid } from '@material-ui/core';
|
||||
import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
|
||||
type IngressesProps = {
|
||||
ingresses: ExtensionsV1beta1Ingress[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const Ingresses = ({ ingresses }: IngressesProps) => {
|
||||
return (
|
||||
<Grid container>
|
||||
{ingresses.map((ingress, i) => {
|
||||
return (
|
||||
<Grid item key={i}>
|
||||
<InfoCard
|
||||
title={ingress.metadata?.name ?? 'un-named ingress'}
|
||||
subheader="Ingress"
|
||||
>
|
||||
<div>
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
backend: ingress.status?.loadBalancer?.ingress,
|
||||
rules: ingress.spec?.rules,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
[
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"networking.k8s.io/v1beta1\",\"kind\":\"Ingress\",\"metadata\":{\"annotations\":{\"nginx.ingress.kubernetes.io/rewrite-target\":\"/$1\"},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"rules\":[{\"host\":\"nginx\",\"http\":{\"paths\":[{\"backend\":{\"serviceName\":\"dice-roller\",\"servicePort\":80},\"path\":\"/\"}]}}]}}\n",
|
||||
"nginx.ingress.kubernetes.io/rewrite-target": "/$1"
|
||||
},
|
||||
"creationTimestamp": "2020-09-28T13:28:00.000Z",
|
||||
"generation": 1,
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"managedFields": [
|
||||
{
|
||||
"apiVersion": "networking.k8s.io/v1beta1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:metadata": {
|
||||
"f:annotations": {
|
||||
".": {},
|
||||
"f:kubectl.kubernetes.io/last-applied-configuration": {},
|
||||
"f:nginx.ingress.kubernetes.io/rewrite-target": {}
|
||||
},
|
||||
"f:labels": {
|
||||
".": {},
|
||||
"f:backstage.io/kubernetes-id": {}
|
||||
}
|
||||
},
|
||||
"f:spec": {
|
||||
"f:rules": {}
|
||||
}
|
||||
},
|
||||
"manager": "kubectl",
|
||||
"operation": "Update",
|
||||
"time": "2020-09-28T13:28:21.000Z"
|
||||
},
|
||||
{
|
||||
"apiVersion": "networking.k8s.io/v1beta1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:status": {
|
||||
"f:loadBalancer": {
|
||||
"f:ingress": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"manager": "nginx-ingress-controller",
|
||||
"operation": "Update",
|
||||
"time": "2020-09-28T13:28:40.000Z"
|
||||
}
|
||||
],
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "699017",
|
||||
"selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/dice-roller",
|
||||
"uid": "e96994c0-49b9-4c1c-8ce0-72c5336fe960"
|
||||
},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"host": "nginx",
|
||||
"http": {
|
||||
"paths": [
|
||||
{
|
||||
"backend": {
|
||||
"serviceName": "dice-roller",
|
||||
"servicePort": 80
|
||||
},
|
||||
"path": "/",
|
||||
"pathType": "ImplementationSpecific"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {
|
||||
"ingress": [
|
||||
{
|
||||
"ip": "192.168.64.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -17,7 +17,7 @@
|
||||
import React from 'react';
|
||||
import { WarningPanel } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { ClusterObjects } from '../../../../kubernetes-backend/src';
|
||||
import { ClusterObjects } from '@backstage/plugin-kubernetes-backend';
|
||||
|
||||
const clustersWithErrorsToErrorMessage = (
|
||||
clustersWithErrors: ClusterObjects[],
|
||||
|
||||
@@ -14,89 +14,39 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { Grid, TabProps } from '@material-ui/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Divider,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
CardTab,
|
||||
configApiRef,
|
||||
Content,
|
||||
Page,
|
||||
Progress,
|
||||
TabbedCard,
|
||||
StatusError,
|
||||
StatusOK,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { kubernetesApiRef } from '../../api/types';
|
||||
import {
|
||||
KubernetesRequestBody,
|
||||
ClusterObjects,
|
||||
FetchResponse,
|
||||
KubernetesRequestBody,
|
||||
ObjectsByEntityResponse,
|
||||
} from '@backstage/plugin-kubernetes-backend';
|
||||
import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types';
|
||||
import { DeploymentTables } from '../DeploymentTables';
|
||||
import { DeploymentTriple } from '../../types/types';
|
||||
import {
|
||||
ExtensionsV1beta1Ingress,
|
||||
V1ConfigMap,
|
||||
V1HorizontalPodAutoscaler,
|
||||
V1Service,
|
||||
} from '@kubernetes/client-node';
|
||||
import { Services } from '../Services';
|
||||
import { ConfigMaps } from '../ConfigMaps';
|
||||
import { Ingresses } from '../Ingresses';
|
||||
import { HorizontalPodAutoscalers } from '../HorizontalPodAutoscalers';
|
||||
import { ErrorPanel } from './ErrorPanel';
|
||||
|
||||
interface GroupedResponses extends DeploymentTriple {
|
||||
services: V1Service[];
|
||||
configMaps: V1ConfigMap[];
|
||||
horizontalPodAutoscalers: V1HorizontalPodAutoscaler[];
|
||||
ingresses: ExtensionsV1beta1Ingress[];
|
||||
}
|
||||
|
||||
// TODO this could probably be a lodash groupBy
|
||||
const groupResponses = (fetchResponse: FetchResponse[]) => {
|
||||
return fetchResponse.reduce(
|
||||
(prev, next) => {
|
||||
switch (next.type) {
|
||||
case 'deployments':
|
||||
prev.deployments.push(...next.resources);
|
||||
break;
|
||||
case 'pods':
|
||||
prev.pods.push(...next.resources);
|
||||
break;
|
||||
case 'replicasets':
|
||||
prev.replicaSets.push(...next.resources);
|
||||
break;
|
||||
case 'services':
|
||||
prev.services.push(...next.resources);
|
||||
break;
|
||||
case 'configmaps':
|
||||
prev.configMaps.push(...next.resources);
|
||||
break;
|
||||
case 'horizontalpodautoscalers':
|
||||
prev.horizontalPodAutoscalers.push(...next.resources);
|
||||
break;
|
||||
case 'ingresses':
|
||||
prev.ingresses.push(...next.resources);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{
|
||||
pods: [],
|
||||
replicaSets: [],
|
||||
deployments: [],
|
||||
services: [],
|
||||
configMaps: [],
|
||||
horizontalPodAutoscalers: [],
|
||||
ingresses: [],
|
||||
} as GroupedResponses,
|
||||
);
|
||||
};
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { DeploymentsAccordions } from '../DeploymentsAccordions';
|
||||
import { ErrorReporting } from '../ErrorReporting';
|
||||
import { groupResponses } from '../../utils/response';
|
||||
import { DetectedError, detectErrors } from '../../error-detection';
|
||||
|
||||
type KubernetesContentProps = { entity: Entity; children?: React.ReactNode };
|
||||
|
||||
@@ -148,6 +98,11 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
|
||||
const clustersWithErrors =
|
||||
kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? [];
|
||||
|
||||
const detectedErrors =
|
||||
kubernetesObjects !== undefined
|
||||
? detectErrors(kubernetesObjects)
|
||||
: new Map<string, DetectedError[]>();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Content>
|
||||
@@ -172,11 +127,29 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{kubernetesObjects?.items.map((item, i) => (
|
||||
<Grid item key={i}>
|
||||
<Cluster clusterObjects={item} />
|
||||
</Grid>
|
||||
))}
|
||||
{kubernetesObjects && (
|
||||
<>
|
||||
<Grid item>
|
||||
<ErrorReporting detectedErrors={detectedErrors} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<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>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
@@ -185,67 +158,97 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
|
||||
|
||||
type ClusterProps = {
|
||||
clusterObjects: ClusterObjects;
|
||||
detectedErrors?: DetectedError[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Cluster = ({ clusterObjects }: ClusterProps) => {
|
||||
const [selectedTab, setSelectedTab] = useState<string | number>('one');
|
||||
|
||||
const handleChange = (_ev: any, newSelectedTab: string | number) =>
|
||||
setSelectedTab(newSelectedTab);
|
||||
|
||||
const Cluster = ({ clusterObjects, detectedErrors }: ClusterProps) => {
|
||||
const groupedResponses = groupResponses(clusterObjects.resources);
|
||||
|
||||
const configMaps = groupedResponses.configMaps;
|
||||
const hpas = groupedResponses.horizontalPodAutoscalers;
|
||||
const ingresses = groupedResponses.ingresses;
|
||||
|
||||
const tabs: ReactElement<TabProps>[] = [
|
||||
<CardTab key={1} value="one" label="Deployments">
|
||||
<DeploymentTables
|
||||
deploymentTriple={{
|
||||
deployments: groupedResponses.deployments,
|
||||
replicaSets: groupedResponses.replicaSets,
|
||||
pods: groupedResponses.pods,
|
||||
}}
|
||||
/>
|
||||
</CardTab>,
|
||||
<CardTab key={2} value="two" label="Services">
|
||||
<Services services={groupedResponses.services} />
|
||||
</CardTab>,
|
||||
];
|
||||
|
||||
if (configMaps.length > 0) {
|
||||
tabs.push(
|
||||
<CardTab key={3} value="three" label="Config Maps">
|
||||
<ConfigMaps configMaps={configMaps} />
|
||||
</CardTab>,
|
||||
);
|
||||
}
|
||||
if (hpas.length > 0) {
|
||||
tabs.push(
|
||||
<CardTab key={4} value="four" label="Horizontal Pod Autoscalers">
|
||||
<HorizontalPodAutoscalers hpas={hpas} />
|
||||
</CardTab>,
|
||||
);
|
||||
}
|
||||
if (ingresses.length > 0) {
|
||||
tabs.push(
|
||||
<CardTab key={5} value="five" label="Ingresses">
|
||||
<Ingresses ingresses={ingresses} />
|
||||
</CardTab>,
|
||||
);
|
||||
}
|
||||
const podsWithErrors = new Set<string>(
|
||||
detectedErrors
|
||||
?.filter(de => de.kind === 'Pod')
|
||||
.map(de => de.names)
|
||||
.flat() ?? [],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedCard
|
||||
value={selectedTab}
|
||||
onChange={handleChange}
|
||||
title={clusterObjects.cluster.name}
|
||||
>
|
||||
{tabs}
|
||||
</TabbedCard>
|
||||
<Accordion TransitionProps={{ unmountOnExit: true }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<ClusterSummary
|
||||
clusterName={clusterObjects.cluster.name}
|
||||
totalNumberOfPods={groupedResponses.pods.length}
|
||||
numberOfPodsWithErrors={podsWithErrors.size}
|
||||
/>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<DeploymentsAccordions
|
||||
deploymentResources={groupedResponses}
|
||||
clusterPodNamesWithErrors={podsWithErrors}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ClusterSummaryProps = {
|
||||
clusterName: string;
|
||||
totalNumberOfPods: number;
|
||||
numberOfPodsWithErrors: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ClusterSummary = ({
|
||||
clusterName,
|
||||
totalNumberOfPods,
|
||||
numberOfPodsWithErrors,
|
||||
}: ClusterSummaryProps) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<Grid
|
||||
xs={2}
|
||||
item
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
spacing={0}
|
||||
>
|
||||
<Grid item xs>
|
||||
<Typography variant="h3">{clusterName}</Typography>
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
Cluster
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={1}>
|
||||
<Divider style={{ height: '4em' }} orientation="vertical" />
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs={3}
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<Grid item>
|
||||
<StatusOK>{totalNumberOfPods} pods</StatusOK>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{numberOfPodsWithErrors > 0 ? (
|
||||
<StatusError>{numberOfPodsWithErrors} pods with errors</StatusError>
|
||||
) : (
|
||||
<StatusOK>No pods with errors</StatusOK>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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, { ChangeEvent, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
makeStyles,
|
||||
IconButton,
|
||||
createStyles,
|
||||
Theme,
|
||||
Drawer,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
} from '@material-ui/core';
|
||||
import Close from '@material-ui/icons/Close';
|
||||
import { V1ObjectMeta } from '@kubernetes/client-node';
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
import { CodeSnippet, StructuredMetadataTable } from '@backstage/core';
|
||||
import jsYaml from 'js-yaml';
|
||||
|
||||
const useDrawerStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
paper: {
|
||||
width: '50%',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(2.5),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const useDrawerContentStyles = makeStyles((_: Theme) =>
|
||||
createStyles({
|
||||
header: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
options: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
icon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
content: {
|
||||
height: '80%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const PodDrawerButton = withStyles({
|
||||
root: {
|
||||
padding: '6px 5px',
|
||||
},
|
||||
label: {
|
||||
textTransform: 'none',
|
||||
},
|
||||
})(Button);
|
||||
|
||||
interface KubernetesDrawerable {
|
||||
metadata?: V1ObjectMeta;
|
||||
}
|
||||
|
||||
interface KubernetesDrawerProps<T extends KubernetesDrawerable> {
|
||||
object: T;
|
||||
renderObject: (obj: T) => object;
|
||||
buttonVariant?: 'h5' | 'subtitle2';
|
||||
kind: string;
|
||||
expanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const KubernetesDrawer = <T extends KubernetesDrawerable>({
|
||||
object,
|
||||
renderObject,
|
||||
kind,
|
||||
buttonVariant = 'subtitle2',
|
||||
expanded = false,
|
||||
children,
|
||||
}: KubernetesDrawerProps<T>) => {
|
||||
const [isOpen, setIsOpen] = useState(expanded);
|
||||
const classes = useDrawerStyles();
|
||||
|
||||
const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PodDrawerButton
|
||||
onClick={e => toggleDrawer(e, true)}
|
||||
onFocus={event => event.stopPropagation()}
|
||||
>
|
||||
{children === undefined ? (
|
||||
<Typography variant={buttonVariant}>
|
||||
{object.metadata?.name ?? 'unknown object'}
|
||||
</Typography>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</PodDrawerButton>
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.paper,
|
||||
}}
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={(e: any) => toggleDrawer(e, false)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<KubernetesDrawerContent
|
||||
kind={kind}
|
||||
toggleDrawer={toggleDrawer}
|
||||
object={object}
|
||||
renderObject={renderObject}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface KubernetesDrawerContentProps<T extends KubernetesDrawerable> {
|
||||
toggleDrawer: (e: ChangeEvent<{}>, isOpen: boolean) => void;
|
||||
object: T;
|
||||
renderObject: (obj: T) => object;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
const KubernetesDrawerContent = <T extends KubernetesDrawerable>({
|
||||
toggleDrawer,
|
||||
object,
|
||||
renderObject,
|
||||
kind,
|
||||
}: KubernetesDrawerContentProps<T>) => {
|
||||
const [isYaml, setIsYaml] = useState<boolean>(false);
|
||||
|
||||
const classes = useDrawerContentStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.header}>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<Grid item>
|
||||
<Typography variant="h5">
|
||||
{object.metadata?.name ?? 'unknown name'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography color="textSecondary" variant="body1">
|
||||
{kind}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<IconButton
|
||||
key="dismiss"
|
||||
title="Close the drawer"
|
||||
onClick={e => toggleDrawer(e, false)}
|
||||
color="inherit"
|
||||
>
|
||||
<Close className={classes.icon} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={classes.options}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isYaml}
|
||||
onChange={event => {
|
||||
setIsYaml(event.target.checked);
|
||||
}}
|
||||
name="YAML"
|
||||
/>
|
||||
}
|
||||
label="YAML"
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.content}>
|
||||
{isYaml && <CodeSnippet language="yaml" text={jsYaml.dump(object)} />}
|
||||
{!isYaml && <StructuredMetadataTable metadata={renderObject(object)} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 * as pod from './__fixtures__/pod.json';
|
||||
import * as crashingPod from './__fixtures__/crashing-pod.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { PodDrawer } from './PodDrawer';
|
||||
|
||||
describe('PodDrawer', () => {
|
||||
it('should render pod', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(<PodDrawer pod={pod as any} expanded />),
|
||||
);
|
||||
|
||||
expect(getAllByText('dice-roller-6c8646bfd-2m5hv')).toHaveLength(2);
|
||||
expect(getByText('Pod')).toBeInTheDocument();
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Images')).toBeInTheDocument();
|
||||
expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument();
|
||||
expect(getByText('Phase')).toBeInTheDocument();
|
||||
expect(getByText('Running')).toBeInTheDocument();
|
||||
expect(getAllByText('Containers Ready')).toHaveLength(2);
|
||||
expect(getByText('1/1')).toBeInTheDocument();
|
||||
expect(getByText('Total Restarts')).toBeInTheDocument();
|
||||
expect(getByText('0')).toBeInTheDocument();
|
||||
expect(getByText('Container Statuses')).toBeInTheDocument();
|
||||
expect(getByText('OK')).toBeInTheDocument();
|
||||
expect(getByText('Initialized')).toBeInTheDocument();
|
||||
expect(getByText('Ready')).toBeInTheDocument();
|
||||
expect(getByText('Pod Scheduled')).toBeInTheDocument();
|
||||
expect(getAllByText('True')).toHaveLength(4);
|
||||
expect(getByText('Exposed Ports')).toBeInTheDocument();
|
||||
expect(getByText('Nginx:')).toBeInTheDocument();
|
||||
expect(getByText('Container Port: 80')).toBeInTheDocument();
|
||||
expect(getByText('Protocol: TCP')).toBeInTheDocument();
|
||||
});
|
||||
it('should render crashing pod', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(<PodDrawer pod={crashingPod as any} expanded />),
|
||||
);
|
||||
|
||||
expect(getAllByText('dice-roller-canary-7d64cd756c-55rfq')).toHaveLength(2);
|
||||
expect(getByText('Pod')).toBeInTheDocument();
|
||||
expect(getByText('YAML')).toBeInTheDocument();
|
||||
expect(getByText('Images')).toBeInTheDocument();
|
||||
expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument();
|
||||
expect(getByText('other-side-car=nginx:1.14.2')).toBeInTheDocument();
|
||||
expect(getByText('side-car=nginx:1.14.2')).toBeInTheDocument();
|
||||
expect(getByText('Phase')).toBeInTheDocument();
|
||||
expect(getByText('Running')).toBeInTheDocument();
|
||||
expect(getAllByText('Containers Ready')).toHaveLength(2);
|
||||
expect(getByText('1/3')).toBeInTheDocument();
|
||||
expect(getByText('Total Restarts')).toBeInTheDocument();
|
||||
expect(getByText('76')).toBeInTheDocument();
|
||||
expect(getByText('Container Statuses')).toBeInTheDocument();
|
||||
expect(getByText('Container: side-car')).toBeInTheDocument();
|
||||
expect(getByText('Container: other-side-car')).toBeInTheDocument();
|
||||
expect(getAllByText('CrashLoopBackOff')).toHaveLength(2);
|
||||
expect(getByText('Initialized')).toBeInTheDocument();
|
||||
expect(getByText('Ready')).toBeInTheDocument();
|
||||
expect(getByText('Pod Scheduled')).toBeInTheDocument();
|
||||
expect(getAllByText('True')).toHaveLength(2);
|
||||
expect(getAllByText('False')).toHaveLength(2);
|
||||
expect(
|
||||
getAllByText('containers with unready status: [side-car other-side-car]'),
|
||||
).toHaveLength(2);
|
||||
expect(getByText('Exposed Ports')).toBeInTheDocument();
|
||||
expect(getAllByText('Protocol: TCP')).toHaveLength(3);
|
||||
expect(getByText('Nginx:')).toBeInTheDocument();
|
||||
expect(getByText('Container Port: 80')).toBeInTheDocument();
|
||||
expect(getByText('Side Car:')).toBeInTheDocument();
|
||||
expect(getByText('Container Port: 81')).toBeInTheDocument();
|
||||
expect(getByText('Other Side Car:')).toBeInTheDocument();
|
||||
expect(getByText('Container Port: 82')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { V1Pod } from '@kubernetes/client-node';
|
||||
import {
|
||||
containersReady,
|
||||
containerStatuses,
|
||||
totalRestarts,
|
||||
imageChips,
|
||||
renderCondition,
|
||||
} from '../../utils/pod';
|
||||
import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer';
|
||||
|
||||
export const PodDrawer = ({
|
||||
pod,
|
||||
expanded,
|
||||
}: {
|
||||
pod: V1Pod;
|
||||
expanded?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<KubernetesDrawer
|
||||
object={pod}
|
||||
expanded={expanded}
|
||||
kind="Pod"
|
||||
renderObject={(pod: V1Pod) => {
|
||||
const phase = pod.status?.phase ?? 'unknown';
|
||||
|
||||
const ports =
|
||||
pod.spec?.containers?.map(c => {
|
||||
return {
|
||||
[c.name]: c.ports,
|
||||
};
|
||||
}) ?? 'N/A';
|
||||
|
||||
const conditions = (pod.status?.conditions ?? [])
|
||||
.map(renderCondition)
|
||||
.reduce((accum, next) => {
|
||||
accum[next[0]] = next[1];
|
||||
return accum;
|
||||
}, {} as { [key: string]: React.ReactNode });
|
||||
|
||||
return {
|
||||
images: imageChips(pod),
|
||||
phase: phase,
|
||||
'Containers Ready': containersReady(pod),
|
||||
'Total Restarts': totalRestarts(pod),
|
||||
'Container Statuses': containerStatuses(pod),
|
||||
...conditions,
|
||||
'Exposed ports': ports,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 * as pod from './__fixtures__/pod.json';
|
||||
import * as crashingPod from './__fixtures__/crashing-pod.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { PodsTable } from './PodsTable';
|
||||
|
||||
describe('PodsTable', () => {
|
||||
it('should render pod', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(<PodsTable pods={[pod as any]} />),
|
||||
);
|
||||
|
||||
// titles
|
||||
expect(getByText('name')).toBeInTheDocument();
|
||||
expect(getByText('phase')).toBeInTheDocument();
|
||||
expect(getByText('containers ready')).toBeInTheDocument();
|
||||
expect(getByText('total restarts')).toBeInTheDocument();
|
||||
expect(getByText('status')).toBeInTheDocument();
|
||||
|
||||
// values
|
||||
expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
|
||||
expect(getByText('Running')).toBeInTheDocument();
|
||||
expect(getByText('1/1')).toBeInTheDocument();
|
||||
expect(getByText('0')).toBeInTheDocument();
|
||||
expect(getByText('OK')).toBeInTheDocument();
|
||||
});
|
||||
it('should render crashing pod', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(<PodsTable pods={[crashingPod as any]} />),
|
||||
);
|
||||
|
||||
// titles
|
||||
expect(getByText('name')).toBeInTheDocument();
|
||||
expect(getByText('phase')).toBeInTheDocument();
|
||||
expect(getByText('containers ready')).toBeInTheDocument();
|
||||
expect(getByText('total restarts')).toBeInTheDocument();
|
||||
expect(getByText('status')).toBeInTheDocument();
|
||||
|
||||
// values
|
||||
expect(
|
||||
getByText('dice-roller-canary-7d64cd756c-55rfq'),
|
||||
).toBeInTheDocument();
|
||||
expect(getByText('Running')).toBeInTheDocument();
|
||||
expect(getByText('1/3')).toBeInTheDocument();
|
||||
expect(getByText('76')).toBeInTheDocument();
|
||||
expect(getByText('Container: side-car')).toBeInTheDocument();
|
||||
expect(getByText('Container: other-side-car')).toBeInTheDocument();
|
||||
expect(getAllByText('CrashLoopBackOff')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 { Table, TableColumn } from '@backstage/core';
|
||||
import { V1Pod } from '@kubernetes/client-node';
|
||||
import { PodDrawer } from './PodDrawer';
|
||||
import {
|
||||
containersReady,
|
||||
containerStatuses,
|
||||
totalRestarts,
|
||||
} from '../../utils/pod';
|
||||
|
||||
const columns: TableColumn<V1Pod>[] = [
|
||||
{
|
||||
title: 'name',
|
||||
highlight: true,
|
||||
render: (pod: V1Pod) => <PodDrawer pod={pod} />,
|
||||
},
|
||||
{
|
||||
title: 'phase',
|
||||
render: (pod: V1Pod) => pod.status?.phase ?? 'unknown',
|
||||
},
|
||||
{
|
||||
title: 'containers ready',
|
||||
align: 'center',
|
||||
render: containersReady,
|
||||
},
|
||||
{
|
||||
title: 'total restarts',
|
||||
align: 'center',
|
||||
render: totalRestarts,
|
||||
type: 'numeric',
|
||||
},
|
||||
{
|
||||
title: 'status',
|
||||
render: containerStatuses,
|
||||
},
|
||||
];
|
||||
|
||||
type DeploymentTablesProps = {
|
||||
pods: V1Pod[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const PodsTable = ({ pods }: DeploymentTablesProps) => {
|
||||
const tableStyle: React.CSSProperties = {
|
||||
minWidth: '0',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={tableStyle}>
|
||||
<Table
|
||||
options={{ paging: true, search: false }}
|
||||
data={pods}
|
||||
columns={columns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"metadata": {
|
||||
"creationTimestamp": "2020-09-25T10:34:01.000Z",
|
||||
"generateName": "dice-roller-canary-7d64cd756c-",
|
||||
"labels": {
|
||||
"app": "dice-roller-canary",
|
||||
"backstage.io/kubernetes-id": "dice-roller",
|
||||
"pod-template-hash": "7d64cd756c"
|
||||
},
|
||||
"name": "dice-roller-canary-7d64cd756c-55rfq",
|
||||
"namespace": "default",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"blockOwnerDeletion": true,
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "dice-roller-canary-7d64cd756c",
|
||||
"uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0"
|
||||
}
|
||||
],
|
||||
"resourceVersion": "620452",
|
||||
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq",
|
||||
"uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034"
|
||||
},
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "side-car",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 81,
|
||||
"protocol": "TCP"
|
||||
}
|
||||
],
|
||||
"resources": {},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File",
|
||||
"volumeMounts": [
|
||||
{
|
||||
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
|
||||
"name": "default-token-5gctn",
|
||||
"readOnly": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "other-side-car",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 82,
|
||||
"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-25T10:34:01.000Z",
|
||||
"status": "True",
|
||||
"type": "Initialized"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2020-09-25T14:18:53.000Z",
|
||||
"message": "containers with unready status: [side-car other-side-car]",
|
||||
"reason": "ContainersNotReady",
|
||||
"status": "False",
|
||||
"type": "Ready"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2020-09-25T14:18:53.000Z",
|
||||
"message": "containers with unready status: [side-car other-side-car]",
|
||||
"reason": "ContainersNotReady",
|
||||
"status": "False",
|
||||
"type": "ContainersReady"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2020-09-25T10:34:01.000Z",
|
||||
"status": "True",
|
||||
"type": "PodScheduled"
|
||||
}
|
||||
],
|
||||
"containerStatuses": [
|
||||
{
|
||||
"containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9",
|
||||
"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-25T10:34:01.000Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4",
|
||||
"image": "nginx:1.14.2",
|
||||
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
|
||||
"lastState": {
|
||||
"terminated": {
|
||||
"containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4",
|
||||
"exitCode": 1,
|
||||
"finishedAt": "2020-09-25T14:18:52.000Z",
|
||||
"reason": "Error",
|
||||
"startedAt": "2020-09-25T14:18:50.000Z"
|
||||
}
|
||||
},
|
||||
"name": "other-side-car",
|
||||
"ready": false,
|
||||
"restartCount": 38,
|
||||
"started": false,
|
||||
"state": {
|
||||
"waiting": {
|
||||
"message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)",
|
||||
"reason": "CrashLoopBackOff"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb",
|
||||
"image": "nginx:1.14.2",
|
||||
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
|
||||
"lastState": {
|
||||
"terminated": {
|
||||
"containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb",
|
||||
"exitCode": 1,
|
||||
"finishedAt": "2020-09-25T14:18:52.000Z",
|
||||
"reason": "Error",
|
||||
"startedAt": "2020-09-25T14:18:50.000Z"
|
||||
}
|
||||
},
|
||||
"name": "side-car",
|
||||
"ready": false,
|
||||
"restartCount": 38,
|
||||
"started": false,
|
||||
"state": {
|
||||
"waiting": {
|
||||
"message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)",
|
||||
"reason": "CrashLoopBackOff"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostIP": "192.168.64.2",
|
||||
"phase": "Running",
|
||||
"podIP": "172.17.0.16",
|
||||
"podIPs": [
|
||||
{
|
||||
"ip": "172.17.0.16"
|
||||
}
|
||||
],
|
||||
"qosClass": "BestEffort",
|
||||
"startTime": "2020-09-25T10:34:01.000Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -13,4 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { Ingresses } from './Ingresses';
|
||||
export { PodDrawer } from './PodDrawer';
|
||||
export { PodsTable } from './PodsTable';
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* 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 { Services } from './Services';
|
||||
import * as servicesFixture from './__fixtures__/services.json';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
|
||||
describe('Services', () => {
|
||||
it('should render 2 services', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
wrapInTestApp(<Services services={(servicesFixture as any).default} />),
|
||||
);
|
||||
|
||||
// common elements
|
||||
expect(getAllByText('Service')).toHaveLength(2);
|
||||
expect(getAllByText('Ports')).toHaveLength(2);
|
||||
expect(getAllByText('Type')).toHaveLength(2);
|
||||
expect(getAllByText('Protocol: TCP')).toHaveLength(3);
|
||||
|
||||
// service 1
|
||||
expect(getByText('dice-roller')).toBeInTheDocument();
|
||||
expect(getByText('ClusterIP')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Name: port1')).toBeInTheDocument();
|
||||
expect(getByText('Port: 80')).toBeInTheDocument();
|
||||
expect(getByText('Target Port: 9376')).toBeInTheDocument();
|
||||
expect(getByText('Name: port1')).toBeInTheDocument();
|
||||
expect(getByText('Port: 81')).toBeInTheDocument();
|
||||
expect(getByText('Target Port: 9377')).toBeInTheDocument();
|
||||
expect(getByText('10.102.223.105')).toBeInTheDocument();
|
||||
|
||||
// service 2
|
||||
expect(getByText('dice-roller-lb')).toBeInTheDocument();
|
||||
expect(getByText('LoadBalancer')).toBeInTheDocument();
|
||||
expect(getByText('Node Port: 32276')).toBeInTheDocument();
|
||||
expect(getByText('Port: 8765')).toBeInTheDocument();
|
||||
expect(getByText('Target Port: 9378')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* 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 { Grid } from '@material-ui/core';
|
||||
import { V1Service } from '@kubernetes/client-node';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
|
||||
type ServicesProps = {
|
||||
services: V1Service[];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const Services = ({ services }: ServicesProps) => {
|
||||
return (
|
||||
<Grid container>
|
||||
{services.map((s, i) => {
|
||||
const metadata: any = {};
|
||||
|
||||
if (s.status?.loadBalancer?.ingress?.length ?? -1 > 0) {
|
||||
metadata.loadbalancer = s.status?.loadBalancer;
|
||||
}
|
||||
|
||||
if (s.spec?.type === 'ClusterIP') {
|
||||
metadata.clusterIP = s.spec.clusterIP;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item key={i}>
|
||||
<InfoCard
|
||||
title={s.metadata?.name ?? 'un-named service'}
|
||||
subheader="Service"
|
||||
>
|
||||
<div>
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
type: s.spec?.type,
|
||||
ports: s.spec?.ports,
|
||||
...metadata,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
[
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"name\":\"port1\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":9376},{\"name\":\"port2\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":9377}],\"selector\":{\"app\":\"dice-roller\"}}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-23T12:00:55.000Z",
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"managedFields": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:metadata": {
|
||||
"f:annotations": {
|
||||
".": {},
|
||||
"f:kubectl.kubernetes.io/last-applied-configuration": {}
|
||||
},
|
||||
"f:labels": {
|
||||
".": {},
|
||||
"f:backstage.io/kubernetes-id": {}
|
||||
}
|
||||
},
|
||||
"f:spec": {
|
||||
"f:ports": {
|
||||
".": {},
|
||||
"k:{\"port\":80,\"protocol\":\"TCP\"}": {
|
||||
".": {},
|
||||
"f:name": {},
|
||||
"f:port": {},
|
||||
"f:protocol": {},
|
||||
"f:targetPort": {}
|
||||
},
|
||||
"k:{\"port\":81,\"protocol\":\"TCP\"}": {
|
||||
".": {},
|
||||
"f:name": {},
|
||||
"f:port": {},
|
||||
"f:protocol": {},
|
||||
"f:targetPort": {}
|
||||
}
|
||||
},
|
||||
"f:selector": {
|
||||
".": {},
|
||||
"f:app": {}
|
||||
},
|
||||
"f:sessionAffinity": {},
|
||||
"f:type": {}
|
||||
}
|
||||
},
|
||||
"manager": "kubectl",
|
||||
"operation": "Update",
|
||||
"time": "2020-09-28T08:50:11.000Z"
|
||||
}
|
||||
],
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "665838",
|
||||
"selfLink": "/api/v1/namespaces/default/services/dice-roller",
|
||||
"uid": "ae9aff92-a525-4bc9-82dc-a0537bf8034c"
|
||||
},
|
||||
"spec": {
|
||||
"clusterIP": "10.102.223.105",
|
||||
"ports": [
|
||||
{
|
||||
"name": "port1",
|
||||
"port": 80,
|
||||
"protocol": "TCP",
|
||||
"targetPort": 9376
|
||||
},
|
||||
{
|
||||
"name": "port2",
|
||||
"port": 81,
|
||||
"protocol": "TCP",
|
||||
"targetPort": 9377
|
||||
}
|
||||
],
|
||||
"selector": {
|
||||
"app": "dice-roller"
|
||||
},
|
||||
"sessionAffinity": "None",
|
||||
"type": "ClusterIP"
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-lb\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"port\":8765,\"targetPort\":9376}],\"selector\":{\"app\":\"dice-roller\"},\"type\":\"LoadBalancer\"}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-28T08:51:21.000Z",
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"managedFields": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"fieldsType": "FieldsV1",
|
||||
"fieldsV1": {
|
||||
"f:metadata": {
|
||||
"f:annotations": {
|
||||
".": {},
|
||||
"f:kubectl.kubernetes.io/last-applied-configuration": {}
|
||||
},
|
||||
"f:labels": {
|
||||
".": {},
|
||||
"f:backstage.io/kubernetes-id": {}
|
||||
}
|
||||
},
|
||||
"f:spec": {
|
||||
"f:externalTrafficPolicy": {},
|
||||
"f:ports": {
|
||||
".": {},
|
||||
"k:{\"port\":8765,\"protocol\":\"TCP\"}": {
|
||||
".": {},
|
||||
"f:port": {},
|
||||
"f:protocol": {},
|
||||
"f:targetPort": {}
|
||||
}
|
||||
},
|
||||
"f:selector": {
|
||||
".": {},
|
||||
"f:app": {}
|
||||
},
|
||||
"f:sessionAffinity": {},
|
||||
"f:type": {}
|
||||
}
|
||||
},
|
||||
"manager": "kubectl",
|
||||
"operation": "Update",
|
||||
"time": "2020-09-28T08:51:21.000Z"
|
||||
}
|
||||
],
|
||||
"name": "dice-roller-lb",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "665998",
|
||||
"selfLink": "/api/v1/namespaces/default/services/dice-roller-lb",
|
||||
"uid": "5554da3b-2041-4403-8cf4-cd2ccae760f8"
|
||||
},
|
||||
"spec": {
|
||||
"clusterIP": "10.99.205.233",
|
||||
"externalTrafficPolicy": "Cluster",
|
||||
"ports": [
|
||||
{
|
||||
"nodePort": 32276,
|
||||
"port": 8765,
|
||||
"protocol": "TCP",
|
||||
"targetPort": 9378
|
||||
}
|
||||
],
|
||||
"selector": {
|
||||
"app": "dice-roller"
|
||||
},
|
||||
"sessionAffinity": "None",
|
||||
"type": "LoadBalancer"
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"deployment.kubernetes.io/revision": "3",
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-canary\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller-canary\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller-canary\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]},{\"image\":\"nginx:1.14.2\",\"name\":\"side-car\",\"ports\":[{\"containerPort\":81}]},{\"image\":\"nginx:1.14.2\",\"name\":\"other-side-car\",\"ports\":[{\"containerPort\":82}]}]}}}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-25T09:02:53.000Z",
|
||||
"generation": 3,
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"name": "dice-roller-canary",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "620480",
|
||||
"selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller-canary",
|
||||
"uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b"
|
||||
},
|
||||
"spec": {
|
||||
"progressDeadlineSeconds": 600,
|
||||
"replicas": 2,
|
||||
"revisionHistoryLimit": 10,
|
||||
"selector": {
|
||||
"matchLabels": {
|
||||
"app": "dice-roller-canary"
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
"rollingUpdate": {
|
||||
"maxSurge": "25%",
|
||||
"maxUnavailable": "25%"
|
||||
},
|
||||
"type": "RollingUpdate"
|
||||
},
|
||||
"template": {
|
||||
"metadata": {
|
||||
"creationTimestamp": null,
|
||||
"labels": {
|
||||
"app": "dice-roller-canary",
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "nginx",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 80,
|
||||
"protocol": "TCP"
|
||||
}
|
||||
],
|
||||
"resources": {},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File"
|
||||
},
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "side-car",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 81,
|
||||
"protocol": "TCP"
|
||||
}
|
||||
],
|
||||
"resources": {},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File"
|
||||
},
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "other-side-car",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 82,
|
||||
"protocol": "TCP"
|
||||
}
|
||||
],
|
||||
"resources": {},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File"
|
||||
}
|
||||
],
|
||||
"dnsPolicy": "ClusterFirst",
|
||||
"restartPolicy": "Always",
|
||||
"schedulerName": "default-scheduler",
|
||||
"securityContext": {},
|
||||
"terminationGracePeriodSeconds": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"conditions": [
|
||||
{
|
||||
"lastTransitionTime": "2020-09-25T09:02:53.000Z",
|
||||
"lastUpdateTime": "2020-09-25T10:34:04.000Z",
|
||||
"message": "ReplicaSet \"dice-roller-canary-7d64cd756c\" has successfully progressed.",
|
||||
"reason": "NewReplicaSetAvailable",
|
||||
"status": "True",
|
||||
"type": "Progressing"
|
||||
},
|
||||
{
|
||||
"lastTransitionTime": "2020-09-25T13:48:06.000Z",
|
||||
"lastUpdateTime": "2020-09-25T13:48:06.000Z",
|
||||
"message": "Deployment does not have minimum availability.",
|
||||
"reason": "MinimumReplicasUnavailable",
|
||||
"status": "False",
|
||||
"type": "Available"
|
||||
}
|
||||
],
|
||||
"observedGeneration": 3,
|
||||
"replicas": 2,
|
||||
"unavailableReplicas": 2,
|
||||
"updatedReplicas": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"deployment.kubernetes.io/revision": "2",
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"replicas\":10,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-23T12:00:55.000Z",
|
||||
"generation": 3,
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "593230",
|
||||
"selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller",
|
||||
"uid": "7551e949-42d1-4061-83c5-9da107186e47"
|
||||
},
|
||||
"spec": {
|
||||
"progressDeadlineSeconds": 600,
|
||||
"replicas": 10,
|
||||
"revisionHistoryLimit": 10,
|
||||
"selector": {
|
||||
"matchLabels": {
|
||||
"app": "dice-roller"
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
"rollingUpdate": {
|
||||
"maxSurge": "25%",
|
||||
"maxUnavailable": "25%"
|
||||
},
|
||||
"type": "RollingUpdate"
|
||||
},
|
||||
"template": {
|
||||
"metadata": {
|
||||
"creationTimestamp": null,
|
||||
"labels": {
|
||||
"app": "dice-roller",
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"conditions": [
|
||||
{
|
||||
"lastTransitionTime": "2020-09-23T12:00:55.000Z",
|
||||
"lastUpdateTime": "2020-09-24T11:39:28.000Z",
|
||||
"message": "ReplicaSet \"dice-roller-6c8646bfd\" has successfully progressed.",
|
||||
"reason": "NewReplicaSetAvailable",
|
||||
"status": "True",
|
||||
"type": "Progressing"
|
||||
},
|
||||
{
|
||||
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
|
||||
"lastUpdateTime": "2020-09-25T09:58:55.000Z",
|
||||
"message": "Deployment has minimum availability.",
|
||||
"reason": "MinimumReplicasAvailable",
|
||||
"status": "True",
|
||||
"type": "Available"
|
||||
}
|
||||
],
|
||||
"observedGeneration": 3,
|
||||
"readyReplicas": 10,
|
||||
"replicas": 10,
|
||||
"updatedReplicas": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]",
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-28T13:28:00.000Z",
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "698957",
|
||||
"selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller",
|
||||
"uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446"
|
||||
},
|
||||
"spec": {
|
||||
"maxReplicas": 15,
|
||||
"minReplicas": 10,
|
||||
"scaleTargetRef": {
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"name": "dice-roller"
|
||||
},
|
||||
"targetCPUUtilizationPercentage": 50
|
||||
},
|
||||
"status": {
|
||||
"currentReplicas": 13,
|
||||
"desiredReplicas": 14,
|
||||
"currentCPUUtilizationPercentage": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]",
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n"
|
||||
},
|
||||
"creationTimestamp": "2020-09-28T13:28:00.000Z",
|
||||
"labels": {
|
||||
"backstage.io/kubernetes-id": "dice-roller"
|
||||
},
|
||||
"name": "dice-roller",
|
||||
"namespace": "default",
|
||||
"resourceVersion": "698957",
|
||||
"selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller",
|
||||
"uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446"
|
||||
},
|
||||
"spec": {
|
||||
"maxReplicas": 10,
|
||||
"minReplicas": 5,
|
||||
"scaleTargetRef": {
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"name": "dice-roller"
|
||||
},
|
||||
"targetCPUUtilizationPercentage": 70
|
||||
},
|
||||
"status": {
|
||||
"currentReplicas": 10,
|
||||
"desiredReplicas": 10,
|
||||
"currentCPUUtilizationPercentage": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"metadata": {
|
||||
"creationTimestamp": "2020-09-25T10:34:01.000Z",
|
||||
"generateName": "dice-roller-canary-7d64cd756c-",
|
||||
"labels": {
|
||||
"app": "dice-roller-canary",
|
||||
"backstage.io/kubernetes-id": "dice-roller",
|
||||
"pod-template-hash": "7d64cd756c"
|
||||
},
|
||||
"name": "dice-roller-canary-7d64cd756c-55rfq",
|
||||
"namespace": "default",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"blockOwnerDeletion": true,
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "dice-roller-canary-7d64cd756c",
|
||||
"uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0"
|
||||
}
|
||||
],
|
||||
"resourceVersion": "620452",
|
||||
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq",
|
||||
"uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034"
|
||||
},
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "side-car",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 81,
|
||||
"protocol": "TCP"
|
||||
}
|
||||
],
|
||||
"resources": {},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File",
|
||||
"volumeMounts": [
|
||||
{
|
||||
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
|
||||
"name": "default-token-5gctn",
|
||||
"readOnly": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "other-side-car",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 82,
|
||||
"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-25T10:34:01.000Z",
|
||||
"status": "True",
|
||||
"type": "Initialized"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2020-09-25T14:18:53.000Z",
|
||||
"message": "containers with unready status: [side-car other-side-car]",
|
||||
"reason": "ContainersNotReady",
|
||||
"status": "False",
|
||||
"type": "Ready"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2020-09-25T14:18:53.000Z",
|
||||
"message": "containers with unready status: [side-car other-side-car]",
|
||||
"reason": "ContainersNotReady",
|
||||
"status": "False",
|
||||
"type": "ContainersReady"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2020-09-25T10:34:01.000Z",
|
||||
"status": "True",
|
||||
"type": "PodScheduled"
|
||||
}
|
||||
],
|
||||
"containerStatuses": [
|
||||
{
|
||||
"containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9",
|
||||
"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-25T10:34:01.000Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4",
|
||||
"image": "nginx:1.14.2",
|
||||
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
|
||||
"lastState": {
|
||||
"terminated": {
|
||||
"containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4",
|
||||
"exitCode": 1,
|
||||
"finishedAt": "2020-09-25T14:18:52.000Z",
|
||||
"reason": "Error",
|
||||
"startedAt": "2020-09-25T14:18:50.000Z"
|
||||
}
|
||||
},
|
||||
"name": "other-side-car",
|
||||
"ready": false,
|
||||
"restartCount": 38,
|
||||
"started": false,
|
||||
"state": {
|
||||
"waiting": {
|
||||
"message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)",
|
||||
"reason": "CrashLoopBackOff"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb",
|
||||
"image": "nginx:1.14.2",
|
||||
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
|
||||
"lastState": {
|
||||
"terminated": {
|
||||
"containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb",
|
||||
"exitCode": 1,
|
||||
"finishedAt": "2020-09-25T14:18:52.000Z",
|
||||
"reason": "Error",
|
||||
"startedAt": "2020-09-25T14:18:50.000Z"
|
||||
}
|
||||
},
|
||||
"name": "side-car",
|
||||
"ready": false,
|
||||
"restartCount": 38,
|
||||
"started": false,
|
||||
"state": {
|
||||
"waiting": {
|
||||
"message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)",
|
||||
"reason": "CrashLoopBackOff"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostIP": "192.168.64.2",
|
||||
"phase": "Running",
|
||||
"podIP": "172.17.0.16",
|
||||
"podIPs": [
|
||||
{
|
||||
"ip": "172.17.0.16"
|
||||
}
|
||||
],
|
||||
"qosClass": "BestEffort",
|
||||
"startTime": "2020-09-25T10:34:01.000Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"metadata": {
|
||||
"creationTimestamp": "2021-01-06T14:33:54Z",
|
||||
"generateName": "dice-roller-bad-cm-855bf85464-",
|
||||
"labels": {
|
||||
"app": "dice-roller-bad-cm",
|
||||
"backstage.io/kubernetes-id": "dice-roller",
|
||||
"pod-template-hash": "855bf85464"
|
||||
},
|
||||
"name": "dice-roller-bad-cm-855bf85464-mg6xb",
|
||||
"namespace": "default",
|
||||
"ownerReferences": [
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"blockOwnerDeletion": true,
|
||||
"controller": true,
|
||||
"kind": "ReplicaSet",
|
||||
"name": "dice-roller-bad-cm-855bf85464",
|
||||
"uid": "9bc2a418-60eb-4dfc-9748-78cf49ea9863"
|
||||
}
|
||||
],
|
||||
"resourceVersion": "2457755284",
|
||||
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-bad-cm-855bf85464-mg6xb",
|
||||
"uid": "5f257d3c-a16d-4ef1-9dc9-d11e321a640a"
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"env": [
|
||||
{
|
||||
"name": "SOME_ENV_VAR",
|
||||
"valueFrom": {
|
||||
"configMapKeyRef": {
|
||||
"key": "some-key",
|
||||
"name": "some-cm"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"image": "nginx:1.14.2",
|
||||
"imagePullPolicy": "IfNotPresent",
|
||||
"name": "nginx",
|
||||
"ports": [
|
||||
{
|
||||
"containerPort": 80,
|
||||
"protocol": "TCP"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"limits": {
|
||||
"cpu": "500m",
|
||||
"memory": "128Mi"
|
||||
},
|
||||
"requests": {
|
||||
"cpu": "50m",
|
||||
"memory": "64Mi"
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"allowPrivilegeEscalation": false
|
||||
},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File",
|
||||
"volumeMounts": [
|
||||
{
|
||||
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
|
||||
"name": "default-token-dbkkb",
|
||||
"readOnly": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"dnsPolicy": "ClusterFirst",
|
||||
"enableServiceLinks": true,
|
||||
"nodeName": "node1",
|
||||
"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-dbkkb",
|
||||
"secret": {
|
||||
"defaultMode": 420,
|
||||
"secretName": "default-token-dbkkb"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"conditions": [
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2021-01-06T14:33:54Z",
|
||||
"status": "True",
|
||||
"type": "Initialized"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2021-01-06T14:33:54Z",
|
||||
"message": "containers with unready status: [nginx]",
|
||||
"reason": "ContainersNotReady",
|
||||
"status": "False",
|
||||
"type": "Ready"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2021-01-06T14:33:54Z",
|
||||
"message": "containers with unready status: [nginx]",
|
||||
"reason": "ContainersNotReady",
|
||||
"status": "False",
|
||||
"type": "ContainersReady"
|
||||
},
|
||||
{
|
||||
"lastProbeTime": null,
|
||||
"lastTransitionTime": "2021-01-06T14:33:54Z",
|
||||
"status": "True",
|
||||
"type": "PodScheduled"
|
||||
}
|
||||
],
|
||||
"containerStatuses": [
|
||||
{
|
||||
"image": "nginx:1.14.2",
|
||||
"imageID": "",
|
||||
"lastState": {},
|
||||
"name": "nginx",
|
||||
"ready": false,
|
||||
"restartCount": 0,
|
||||
"started": false,
|
||||
"state": {
|
||||
"waiting": {
|
||||
"message": "configmap \"some-cm\" not found",
|
||||
"reason": "CreateContainerConfigError"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostIP": "10.1.33.73",
|
||||
"phase": "Pending",
|
||||
"podIP": "10.1.115.15",
|
||||
"podIPs": [
|
||||
{
|
||||
"ip": "10.1.115.15"
|
||||
}
|
||||
],
|
||||
"qosClass": "Burstable",
|
||||
"startTime": "2021-01-06T14:33:54Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 {
|
||||
DetectedError,
|
||||
ErrorDetectable,
|
||||
ErrorDetectableKind,
|
||||
ErrorMapper,
|
||||
} from './types';
|
||||
|
||||
// Run through the each error mapper for each object
|
||||
// returning a deduplicated (mostly) result
|
||||
export const detectErrorsInObjects = <T extends ErrorDetectable>(
|
||||
objects: T[],
|
||||
kind: ErrorDetectableKind,
|
||||
clusterName: string,
|
||||
errorMappers: ErrorMapper<T>[],
|
||||
): DetectedError[] => {
|
||||
// Build up a map of errors
|
||||
// key: the joined message produced by an error
|
||||
// value: the error
|
||||
const errors = new Map<string, DetectedError>();
|
||||
|
||||
for (const object of objects) {
|
||||
for (const errorMapper of errorMappers) {
|
||||
if (errorMapper.errorExists(object)) {
|
||||
const message = errorMapper.messageAccessor(object);
|
||||
|
||||
// TODO This is not perfect as errors with uuid/hashes/date/times will not be caught by this
|
||||
const dedupKey = message.join('');
|
||||
|
||||
const value = errors.get(dedupKey);
|
||||
|
||||
const name = object.metadata?.name ?? 'unknown';
|
||||
|
||||
if (value !== undefined) {
|
||||
// This gets translated into the Chip "+5 others"
|
||||
// in the ErrorReporting component
|
||||
// but we need to keep the names so we can easily
|
||||
// find which objects owns the error later
|
||||
value.names.push(name);
|
||||
errors.set(dedupKey, value);
|
||||
} else {
|
||||
errors.set(dedupKey, {
|
||||
cluster: clusterName,
|
||||
kind: kind,
|
||||
names: [name],
|
||||
message: message,
|
||||
severity: errorMapper.severity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(errors.values());
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DetectedError, ErrorMapper } from './types';
|
||||
import { V1Deployment } from '@kubernetes/client-node';
|
||||
import { detectErrorsInObjects } from './common';
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const deploymentErrorMappers: ErrorMapper<V1Deployment>[] = [
|
||||
{
|
||||
// this is probably important
|
||||
severity: 6,
|
||||
errorExplanation: 'condition-message-present',
|
||||
errorExists: deployment => {
|
||||
return (deployment.status?.conditions ?? [])
|
||||
.filter(c => c.status === 'False')
|
||||
.some(c => c.message !== undefined);
|
||||
},
|
||||
messageAccessor: deployment => {
|
||||
return (deployment.status?.conditions ?? [])
|
||||
.filter(c => c.status === 'False')
|
||||
.filter(c => c.message !== undefined)
|
||||
.map(c => c.message ?? '');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const detectErrorsInDeployments = (
|
||||
deployments: V1Deployment[],
|
||||
clusterName: string,
|
||||
): DetectedError[] =>
|
||||
detectErrorsInObjects(
|
||||
deployments,
|
||||
'Deployment',
|
||||
clusterName,
|
||||
deploymentErrorMappers,
|
||||
);
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* 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 {
|
||||
V1Pod,
|
||||
V1Deployment,
|
||||
V1HorizontalPodAutoscaler,
|
||||
} from '@kubernetes/client-node';
|
||||
import { detectErrors } from './error-detection';
|
||||
import * as healthyPod from './__fixtures__/pod.json';
|
||||
import * as podMissingCm from './__fixtures__/pod-missing-cm.json';
|
||||
import * as crashingPod from './__fixtures__/pod-crashing.json';
|
||||
import * as healthyDeploy from './__fixtures__/deploy-healthy.json';
|
||||
import * as failingDeploy from './__fixtures__/deploy-bad.json';
|
||||
import * as healthyHpa from './__fixtures__/hpa-healthy.json';
|
||||
import * as maxedOutHpa from './__fixtures__/hpa-maxed-out.json';
|
||||
import {
|
||||
FetchResponse,
|
||||
ObjectsByEntityResponse,
|
||||
} from '@backstage/plugin-kubernetes-backend';
|
||||
|
||||
const CLUSTER_NAME = 'cluster-a';
|
||||
|
||||
const oneItem = (value: FetchResponse): ObjectsByEntityResponse => {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
cluster: { name: CLUSTER_NAME },
|
||||
errors: [],
|
||||
resources: [value],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const onePod = (pod: V1Pod): ObjectsByEntityResponse => {
|
||||
return oneItem({
|
||||
type: 'pods',
|
||||
resources: [pod],
|
||||
});
|
||||
};
|
||||
|
||||
const oneDeployment = (deployment: V1Deployment): ObjectsByEntityResponse => {
|
||||
return oneItem({
|
||||
type: 'deployments',
|
||||
resources: [deployment],
|
||||
});
|
||||
};
|
||||
|
||||
const oneHpa = (hpa: V1HorizontalPodAutoscaler): ObjectsByEntityResponse => {
|
||||
return oneItem({
|
||||
type: 'horizontalpodautoscalers',
|
||||
resources: [hpa],
|
||||
});
|
||||
};
|
||||
|
||||
describe('detectErrors', () => {
|
||||
it('should return errors from different clusters', () => {
|
||||
const result = detectErrors({
|
||||
items: [
|
||||
{
|
||||
cluster: { name: 'cluster-a' },
|
||||
errors: [],
|
||||
resources: [
|
||||
{
|
||||
type: 'pods',
|
||||
resources: [crashingPod as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: { name: 'cluster-b' },
|
||||
errors: [],
|
||||
resources: [
|
||||
{
|
||||
type: 'horizontalpodautoscalers',
|
||||
resources: [maxedOutHpa as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cluster: { name: 'cluster-c' },
|
||||
errors: [],
|
||||
resources: [
|
||||
{
|
||||
type: 'deployments',
|
||||
resources: [healthyDeploy as any],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
|
||||
const errorsFromClusterA = result.get('cluster-a');
|
||||
const errorsFromClusterB = result.get('cluster-b');
|
||||
const errorsFromClusterC = result.get('cluster-c');
|
||||
|
||||
expect(errorsFromClusterA).toBeDefined();
|
||||
expect(errorsFromClusterA).toHaveLength(4);
|
||||
|
||||
expect(errorsFromClusterB).toBeDefined();
|
||||
expect(errorsFromClusterB).toHaveLength(1);
|
||||
|
||||
expect(errorsFromClusterC).toBeDefined();
|
||||
expect(errorsFromClusterC).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect no errors in healthy pod', () => {
|
||||
const result = detectErrors(onePod(healthyPod as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
it('should detect errors in crashing pod', () => {
|
||||
const result = detectErrors(onePod(crashingPod as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(4);
|
||||
|
||||
const [err1, err2, err3, err4] = errors ?? [];
|
||||
|
||||
expect(err1).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Pod',
|
||||
message: [
|
||||
'container=other-side-car restarted 38 times',
|
||||
'container=side-car restarted 38 times',
|
||||
],
|
||||
names: ['dice-roller-canary-7d64cd756c-55rfq'],
|
||||
severity: 4,
|
||||
});
|
||||
|
||||
expect(err2).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Pod',
|
||||
message: [
|
||||
'containers with unready status: [side-car other-side-car]',
|
||||
'containers with unready status: [side-car other-side-car]',
|
||||
],
|
||||
names: ['dice-roller-canary-7d64cd756c-55rfq'],
|
||||
severity: 5,
|
||||
});
|
||||
|
||||
expect(err3).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Pod',
|
||||
message: [
|
||||
'back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)',
|
||||
'back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)',
|
||||
],
|
||||
names: ['dice-roller-canary-7d64cd756c-55rfq'],
|
||||
severity: 6,
|
||||
});
|
||||
|
||||
expect(err4).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Pod',
|
||||
message: [
|
||||
'container=other-side-car exited with error code (1)',
|
||||
'container=side-car exited with error code (1)',
|
||||
],
|
||||
names: ['dice-roller-canary-7d64cd756c-55rfq'],
|
||||
severity: 4,
|
||||
});
|
||||
});
|
||||
it('should detect errors in pod with missing Config Map', () => {
|
||||
const result = detectErrors(onePod(podMissingCm as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(2);
|
||||
|
||||
const [err1, err2] = errors ?? [];
|
||||
|
||||
expect(err1).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Pod',
|
||||
message: [
|
||||
'containers with unready status: [nginx]',
|
||||
'containers with unready status: [nginx]',
|
||||
],
|
||||
names: ['dice-roller-bad-cm-855bf85464-mg6xb'],
|
||||
severity: 5,
|
||||
});
|
||||
|
||||
expect(err2).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Pod',
|
||||
message: ['configmap "some-cm" not found'],
|
||||
names: ['dice-roller-bad-cm-855bf85464-mg6xb'],
|
||||
severity: 6,
|
||||
});
|
||||
});
|
||||
it('should detect no errors in healthy deployment', () => {
|
||||
const result = detectErrors(oneDeployment(healthyDeploy as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
it('should detect in deployment which cant progress', () => {
|
||||
const result = detectErrors(oneDeployment(failingDeploy as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
const [err1] = errors ?? [];
|
||||
|
||||
expect(err1).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'Deployment',
|
||||
message: ['Deployment does not have minimum availability.'],
|
||||
names: ['dice-roller-canary'],
|
||||
severity: 6,
|
||||
});
|
||||
});
|
||||
it('should detect no errors in healthy hpa', () => {
|
||||
const result = detectErrors(oneHpa(healthyHpa as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
it('should detect in maxed out hpa', () => {
|
||||
const result = detectErrors(oneHpa(maxedOutHpa as any));
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const errors = result.get(CLUSTER_NAME);
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
const [err1] = errors ?? [];
|
||||
|
||||
expect(err1).toStrictEqual({
|
||||
cluster: 'cluster-a',
|
||||
kind: 'HorizontalPodAutoscaler',
|
||||
message: [
|
||||
'Current number of replicas (10) is equal to the configured max number of replicas (10)',
|
||||
],
|
||||
names: ['dice-roller'],
|
||||
severity: 8,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { DetectedError, DetectedErrorsByCluster } from './types';
|
||||
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-backend';
|
||||
import { groupResponses } from '../utils/response';
|
||||
import { detectErrorsInPods } from './pods';
|
||||
import { detectErrorsInDeployments } from './deployments';
|
||||
import { detectErrorsInHpa } from './hpas';
|
||||
|
||||
// For each cluster try to find errors in each of the object types provided
|
||||
// returning a map of cluster names to errors in that cluster
|
||||
export const detectErrors = (
|
||||
objects: ObjectsByEntityResponse,
|
||||
): DetectedErrorsByCluster => {
|
||||
const errors: DetectedErrorsByCluster = new Map<string, DetectedError[]>();
|
||||
|
||||
for (const clusterResponse of objects.items) {
|
||||
let clusterErrors: DetectedError[] = [];
|
||||
|
||||
const groupedResponses = groupResponses(clusterResponse.resources);
|
||||
|
||||
clusterErrors = clusterErrors.concat(
|
||||
detectErrorsInPods(groupedResponses.pods, clusterResponse.cluster.name),
|
||||
);
|
||||
|
||||
clusterErrors = clusterErrors.concat(
|
||||
detectErrorsInDeployments(
|
||||
groupedResponses.deployments,
|
||||
clusterResponse.cluster.name,
|
||||
),
|
||||
);
|
||||
|
||||
clusterErrors = clusterErrors.concat(
|
||||
detectErrorsInHpa(
|
||||
groupedResponses.horizontalPodAutoscalers,
|
||||
clusterResponse.cluster.name,
|
||||
),
|
||||
);
|
||||
|
||||
errors.set(clusterResponse.cluster.name, clusterErrors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
|
||||
import { DetectedError, ErrorMapper } from './types';
|
||||
import { detectErrorsInObjects } from './common';
|
||||
|
||||
const hpaErrorMappers: ErrorMapper<V1HorizontalPodAutoscaler>[] = [
|
||||
{
|
||||
// this is probably important
|
||||
severity: 8,
|
||||
errorExplanation: 'hpa-max-current-replicas',
|
||||
errorExists: hpa => {
|
||||
return (hpa.spec?.maxReplicas ?? -1) === hpa.status?.currentReplicas;
|
||||
},
|
||||
messageAccessor: hpa => {
|
||||
return [
|
||||
`Current number of replicas (${
|
||||
hpa.status?.currentReplicas
|
||||
}) is equal to the configured max number of replicas (${
|
||||
hpa.spec?.maxReplicas ?? -1
|
||||
})`,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const detectErrorsInHpa = (
|
||||
hpas: V1HorizontalPodAutoscaler[],
|
||||
clusterName: string,
|
||||
): DetectedError[] =>
|
||||
detectErrorsInObjects(
|
||||
hpas,
|
||||
'HorizontalPodAutoscaler',
|
||||
clusterName,
|
||||
hpaErrorMappers,
|
||||
);
|
||||
+3
-1
@@ -13,4 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { Services } from './Services';
|
||||
|
||||
export type { DetectedError, DetectedErrorsByCluster } from './types';
|
||||
export { detectErrors } from './error-detection';
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 { V1Pod } from '@kubernetes/client-node';
|
||||
import { totalRestarts } from '../utils/pod';
|
||||
import { DetectedError, ErrorMapper } from './types';
|
||||
import { detectErrorsInObjects } from './common';
|
||||
|
||||
const podErrorMappers: ErrorMapper<V1Pod>[] = [
|
||||
{
|
||||
severity: 5,
|
||||
errorExplanation: 'status-message',
|
||||
errorExists: pod => {
|
||||
return pod.status?.message !== undefined;
|
||||
},
|
||||
messageAccessor: pod => {
|
||||
return [pod.status?.message ?? ''];
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: 4,
|
||||
errorExplanation: 'containers-restarting',
|
||||
errorExists: pod => {
|
||||
// TODO magic number
|
||||
return totalRestarts(pod) > 3;
|
||||
},
|
||||
messageAccessor: pod => {
|
||||
return (pod.status?.containerStatuses ?? [])
|
||||
.filter(cs => cs.restartCount > 0)
|
||||
.map(cs => `container=${cs.name} restarted ${cs.restartCount} times`);
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: 5,
|
||||
errorExplanation: 'condition-message-present',
|
||||
errorExists: pod => {
|
||||
return (pod.status?.conditions ?? []).some(c => c.message !== undefined);
|
||||
},
|
||||
messageAccessor: pod => {
|
||||
return (pod.status?.conditions ?? [])
|
||||
.filter(c => c.message !== undefined)
|
||||
.map(c => c.message ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: 6,
|
||||
errorExplanation: 'container-waiting',
|
||||
errorExists: pod => {
|
||||
return (pod.status?.containerStatuses ?? []).some(
|
||||
cs => cs.state?.waiting?.message !== undefined,
|
||||
);
|
||||
},
|
||||
messageAccessor: pod => {
|
||||
return (pod.status?.containerStatuses ?? [])
|
||||
.filter(cs => cs.state?.waiting?.message !== undefined)
|
||||
.map(cs => cs.state?.waiting?.message ?? '');
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: 4,
|
||||
errorExplanation: 'container-last-state-error',
|
||||
errorExists: pod => {
|
||||
return (pod.status?.containerStatuses ?? []).some(
|
||||
cs => (cs.lastState?.terminated?.reason ?? '') === 'Error',
|
||||
);
|
||||
},
|
||||
messageAccessor: pod => {
|
||||
return (pod.status?.containerStatuses ?? [])
|
||||
.filter(cs => (cs.lastState?.terminated?.reason ?? '') === 'Error')
|
||||
.map(
|
||||
cs =>
|
||||
`container=${cs.name} exited with error code (${cs.lastState?.terminated?.exitCode})`,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const detectErrorsInPods = (
|
||||
pods: V1Pod[],
|
||||
clusterName: string,
|
||||
): DetectedError[] =>
|
||||
detectErrorsInObjects(pods, 'Pod', clusterName, podErrorMappers);
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Higher is more sever, but it's relative
|
||||
import {
|
||||
V1Deployment,
|
||||
V1HorizontalPodAutoscaler,
|
||||
V1Pod,
|
||||
} from '@kubernetes/client-node';
|
||||
|
||||
export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
|
||||
export type ErrorDetectable = V1Pod | V1Deployment | V1HorizontalPodAutoscaler;
|
||||
|
||||
export type ErrorDetectableKind =
|
||||
| 'Pod'
|
||||
| 'Deployment'
|
||||
| 'HorizontalPodAutoscaler';
|
||||
|
||||
export type DetectedErrorsByCluster = Map<string, DetectedError[]>;
|
||||
|
||||
export interface DetectedError {
|
||||
severity: ErrorSeverity;
|
||||
cluster: string;
|
||||
kind: ErrorDetectableKind;
|
||||
names: string[];
|
||||
message: string[];
|
||||
}
|
||||
|
||||
export interface ErrorMapper<T extends ErrorDetectable> {
|
||||
severity: ErrorSeverity;
|
||||
errorExplanation: string;
|
||||
errorExists: (object: T) => boolean;
|
||||
messageAccessor: (object: T) => string[];
|
||||
}
|
||||
@@ -14,10 +14,25 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { V1Deployment, V1Pod, V1ReplicaSet } from '@kubernetes/client-node';
|
||||
import {
|
||||
V1Deployment,
|
||||
V1Pod,
|
||||
V1ReplicaSet,
|
||||
V1HorizontalPodAutoscaler,
|
||||
V1Service,
|
||||
V1ConfigMap,
|
||||
ExtensionsV1beta1Ingress,
|
||||
} from '@kubernetes/client-node';
|
||||
|
||||
export interface DeploymentTriple {
|
||||
export interface DeploymentResources {
|
||||
pods: V1Pod[];
|
||||
replicaSets: V1ReplicaSet[];
|
||||
deployments: V1Deployment[];
|
||||
horizontalPodAutoscalers: V1HorizontalPodAutoscaler[];
|
||||
}
|
||||
|
||||
export interface GroupedResponses extends DeploymentResources {
|
||||
services: V1Service[];
|
||||
configMaps: V1ConfigMap[];
|
||||
ingresses: ExtensionsV1beta1Ingress[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { V1Pod, V1PodCondition } from '@kubernetes/client-node';
|
||||
import React, { Fragment, ReactNode } from 'react';
|
||||
import {
|
||||
StatusAborted,
|
||||
StatusError,
|
||||
StatusOK,
|
||||
SubvalueCell,
|
||||
} from '@backstage/core';
|
||||
import { Chip } from '@material-ui/core';
|
||||
import { V1DeploymentCondition } from '@kubernetes/client-node/dist/gen/model/v1DeploymentCondition';
|
||||
|
||||
export const imageChips = (pod: V1Pod): ReactNode => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const images = containerStatuses.map((cs, i) => {
|
||||
return <Chip key={i} label={`${cs.name}=${cs.image}`} size="small" />;
|
||||
});
|
||||
|
||||
return <div>{images}</div>;
|
||||
};
|
||||
|
||||
export const containersReady = (pod: V1Pod): string => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const containersReady = containerStatuses.filter(cs => cs.ready).length;
|
||||
|
||||
return `${containersReady}/${containerStatuses.length}`;
|
||||
};
|
||||
|
||||
export const totalRestarts = (pod: V1Pod): number => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
return containerStatuses?.reduce((a, b) => a + b.restartCount, 0);
|
||||
};
|
||||
|
||||
export const containerStatuses = (pod: V1Pod): ReactNode => {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const errors = containerStatuses.reduce((accum, next) => {
|
||||
if (next.state === undefined) {
|
||||
return accum;
|
||||
}
|
||||
|
||||
const waiting = next.state.waiting;
|
||||
const terminated = next.state.terminated;
|
||||
|
||||
const renderCell = (reason: string | undefined) => (
|
||||
<Fragment key={`${pod.metadata?.name}-${next.name}`}>
|
||||
<SubvalueCell
|
||||
value={<StatusError>Container: {next.name}</StatusError>}
|
||||
subvalue={reason}
|
||||
/>
|
||||
<br />
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (waiting) {
|
||||
accum.push(renderCell(waiting.reason));
|
||||
}
|
||||
|
||||
if (terminated) {
|
||||
accum.push(renderCell(terminated.reason));
|
||||
}
|
||||
|
||||
return accum;
|
||||
}, [] as React.ReactNode[]);
|
||||
|
||||
if (errors.length === 0) {
|
||||
return <StatusOK>OK</StatusOK>;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const renderCondition = (
|
||||
condition: V1PodCondition | V1DeploymentCondition,
|
||||
): [string, ReactNode] => {
|
||||
const status = condition.status;
|
||||
|
||||
if (status === 'True') {
|
||||
return [condition.type, <StatusOK>True</StatusOK>];
|
||||
} else if (status === 'False') {
|
||||
return [
|
||||
condition.type,
|
||||
<SubvalueCell
|
||||
value={<StatusError>False</StatusError>}
|
||||
subvalue={condition.message ?? ''}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
return [condition.type, <StatusAborted />];
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 { FetchResponse } from '@backstage/plugin-kubernetes-backend';
|
||||
import { GroupedResponses } from '../types/types';
|
||||
|
||||
// TODO this could probably be a lodash groupBy
|
||||
export const groupResponses = (
|
||||
fetchResponse: FetchResponse[],
|
||||
): GroupedResponses => {
|
||||
return fetchResponse.reduce(
|
||||
(prev, next) => {
|
||||
switch (next.type) {
|
||||
case 'deployments':
|
||||
prev.deployments.push(...next.resources);
|
||||
break;
|
||||
case 'pods':
|
||||
prev.pods.push(...next.resources);
|
||||
break;
|
||||
case 'replicasets':
|
||||
prev.replicaSets.push(...next.resources);
|
||||
break;
|
||||
case 'services':
|
||||
prev.services.push(...next.resources);
|
||||
break;
|
||||
case 'configmaps':
|
||||
prev.configMaps.push(...next.resources);
|
||||
break;
|
||||
case 'horizontalpodautoscalers':
|
||||
prev.horizontalPodAutoscalers.push(...next.resources);
|
||||
break;
|
||||
case 'ingresses':
|
||||
prev.ingresses.push(...next.resources);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{
|
||||
pods: [],
|
||||
replicaSets: [],
|
||||
deployments: [],
|
||||
services: [],
|
||||
configMaps: [],
|
||||
horizontalPodAutoscalers: [],
|
||||
ingresses: [],
|
||||
} as GroupedResponses,
|
||||
);
|
||||
};
|
||||
@@ -79,7 +79,7 @@ export const CreateAuditContent = () => {
|
||||
// TODO use the id from the response to redirect to the audit page for that id when
|
||||
// FAILED and RUNNING audits are supported
|
||||
await lighthouseApi.triggerAudit({
|
||||
url,
|
||||
url: url.replace(/\/$/, ''),
|
||||
options: {
|
||||
lighthouseConfig: {
|
||||
settings: {
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './preparers';
|
||||
export * from './types';
|
||||
export * from './file';
|
||||
export * from './github';
|
||||
export * from './gitlab';
|
||||
export * from './azure';
|
||||
|
||||
export { AzurePreparer } from './azure';
|
||||
export { BitbucketPreparer } from './bitbucket';
|
||||
export { FilePreparer } from './file';
|
||||
export { GithubPreparer } from './github';
|
||||
export { GitlabPreparer } from './gitlab';
|
||||
export { Preparers } from './preparers';
|
||||
export type { PreparerBase, PreparerBuilder, PreparerOptions } from './types';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user