From 265a7ab30836646db21bf5733d9fea73eac7b655 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 22 Dec 2020 23:14:31 -0800 Subject: [PATCH 01/29] fix(core): SidebarItem without `to` prop renders accessible button --- .changeset/rich-games-yawn.md | 5 + .../core/src/layout/Sidebar/Items.test.tsx | 58 ++++++ packages/core/src/layout/Sidebar/Items.tsx | 168 ++++++++++-------- 3 files changed, 153 insertions(+), 78 deletions(-) create mode 100644 .changeset/rich-games-yawn.md create mode 100644 packages/core/src/layout/Sidebar/Items.test.tsx diff --git a/.changeset/rich-games-yawn.md b/.changeset/rich-games-yawn.md new file mode 100644 index 0000000000..1106a8b3de --- /dev/null +++ b/.changeset/rich-games-yawn.md @@ -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. diff --git a/packages/core/src/layout/Sidebar/Items.test.tsx b/packages/core/src/layout/Sidebar/Items.test.tsx new file mode 100644 index 0000000000..099a3477d3 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Items.test.tsx @@ -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( + + + {}} + text="Create..." + /> + , + ); + 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(); + }); + }); +}); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 929828cee4..f09ac1a6a0 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -53,6 +53,15 @@ const useStyles = makeStyles(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,103 @@ const useStyles = makeStyles(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( - ( - { 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 = ( - - - - ); +type SidebarItemLinkProps = SidebarItemBaseProps & { + text?: string; + 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 ( -
- {itemIcon} -
- ); - } +function isButtonItem( + props: SidebarItemProps, +): props is SidebarItemButtonProps { + return (props as SidebarItemLinkProps).to === undefined; +} - return ( - - {itemIcon} - - ); - } +export const SidebarItem = forwardRef((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 = ( - <> -
- {itemIcon} -
- {text && ( - - {text} - - )} -
{children}
- - ); + const itemIcon = ( + + + + ); - if (to === undefined) { - return ( -
- {content} -
- ); - } + const closedContent = itemIcon; + const openContent = ( + <> +
+ {itemIcon} +
+ {text && ( + + {text} + + )} +
{children}
+ + ); + + 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 ( - + ); - }, -); + } + + return ( + + {content} + + ); +}); type SidebarSearchFieldProps = { onSearch: (input: string) => void; From 006c78eb6055e2d02060736dfb4c4e2fa7178367 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 29 Dec 2020 11:47:11 -0800 Subject: [PATCH 02/29] fix(core): remove redundant SidebarItem `text` type --- packages/core/src/layout/Sidebar/Items.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index f09ac1a6a0..e954dcb4f8 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -135,7 +135,6 @@ type SidebarItemButtonProps = SidebarItemBaseProps & { }; type SidebarItemLinkProps = SidebarItemBaseProps & { - text?: string; to: string; onClick?: (ev: React.MouseEvent) => void; }; From 9cf71f8bfead4ed76e44f8a688db95f9f363f6ff Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 5 Jan 2021 14:32:21 +0100 Subject: [PATCH 03/29] cli: added experimental create-github-app command Co-authored-by: Ben Lambert Co-authored-by: Patrik Oldsberg --- .changeset/spicy-feet-sparkle.md | 5 + packages/cli/package.json | 3 + .../GithubCreateAppServer.ts | 152 ++++++++++++++++++ .../src/commands/create-github-app/index.ts | 31 ++++ packages/cli/src/commands/index.ts | 7 + 5 files changed, 198 insertions(+) create mode 100644 .changeset/spicy-feet-sparkle.md create mode 100644 packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts create mode 100644 packages/cli/src/commands/create-github-app/index.ts diff --git a/.changeset/spicy-feet-sparkle.md b/.changeset/spicy-feet-sparkle.md new file mode 100644 index 0000000000..dcc8a7c5fb --- /dev/null +++ b/.changeset/spicy-feet-sparkle.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added experimental `create-github-app` command. diff --git a/packages/cli/package.json b/packages/cli/package.json index 6da871e74c..95aff3b724 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts new file mode 100644 index 0000000000..95a1816cd7 --- /dev/null +++ b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts @@ -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-', + url: 'https://backstage.io', + description: 'Github App for Backstage', + public: false, +}; + +const FORM_PAGE = ` + + +
+ + +
+ + + +`; + +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 { + 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 { + const app = express(); + + app.get('/', this.formHandler); + + const callPromise = new Promise((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//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((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}`); + }); + }); + } +} diff --git a/packages/cli/src/commands/create-github-app/index.ts b/packages/cli/src/commands/create-github-app/index.ts new file mode 100644 index 0000000000..e10b720d4b --- /dev/null +++ b/packages/cli/src/commands/create-github-app/index.ts @@ -0,0 +1,31 @@ +/* + * 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'; + +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. +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index e090c454f9..510fe5d918 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -202,6 +202,13 @@ export function registerCommands(program: CommanderStatic) { .command('build-workspace ...') .description('Builds a temporary dist workspace from the provided packages') .action(lazy(() => import('./buildWorkspace').then(m => m.default))); + + program + .command('create-github-app ', { 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 From 2170b3436f7c7c5bbc4a9a66df18eb076c775f82 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 6 Jan 2021 00:08:07 +0100 Subject: [PATCH 04/29] Update packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- .../cli/src/commands/create-github-app/GithubCreateAppServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts index 95a1816cd7..406e563ebc 100644 --- a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts +++ b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts @@ -27,7 +27,7 @@ const MANIFEST_DATA = { }, name: 'Backstage-', url: 'https://backstage.io', - description: 'Github App for Backstage', + description: 'GitHub App for Backstage', public: false, }; From 5663fa567c0d15bfe95c415c4e7bbf66c470e286 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Wed, 6 Jan 2021 15:09:19 +0100 Subject: [PATCH 05/29] TechDocs: Remove failing TechDocs project board workflow The workflow can not run on PRs created from a fork because secrets are not shared in GitHub actions with forks. To avoid having a failed workflow in some PRs, this condition is being added on the job. --- .github/workflows/techdocs-project-board.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index 679abe6536..c982b219ba 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -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 From 630be24a4d112ff498a8afcbd38b4ec93b45bec6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 7 Jan 2021 11:01:47 +0100 Subject: [PATCH 06/29] Added comment about lacking GHE support --- packages/cli/src/commands/create-github-app/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/commands/create-github-app/index.ts b/packages/cli/src/commands/create-github-app/index.ts index e10b720d4b..b8d232674e 100644 --- a/packages/cli/src/commands/create-github-app/index.ts +++ b/packages/cli/src/commands/create-github-app/index.ts @@ -20,6 +20,9 @@ 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 }); From e79c98cf291a989ecb9dfea3e8aa17da544533ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 Jan 2021 18:46:42 +0000 Subject: [PATCH 07/29] Version Packages --- .changeset/young-pumas-clap.md | 5 ----- packages/create-app/CHANGELOG.md | 6 ++++++ packages/create-app/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/young-pumas-clap.md diff --git a/.changeset/young-pumas-clap.md b/.changeset/young-pumas-clap.md deleted file mode 100644 index 2c7b1986b9..0000000000 --- a/.changeset/young-pumas-clap.md +++ /dev/null @@ -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 diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 235660acc2..557f2e4901 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/create-app +## 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 diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 70ce66de11..ed9010098a 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.2", + "version": "0.3.3", "private": false, "publishConfig": { "access": "public" From 3a03a4100a14174999e401adf50d044702f66d42 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 19:54:44 +0100 Subject: [PATCH 08/29] chore: actually add the adr template which appears to have gone missing in the repo --- docs/architecture-decisions/0000-template.md | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/architecture-decisions/0000-template.md diff --git a/docs/architecture-decisions/0000-template.md b/docs/architecture-decisions/0000-template.md new file mode 100644 index 0000000000..d1ad78ffc8 --- /dev/null +++ b/docs/architecture-decisions/0000-template.md @@ -0,0 +1,22 @@ +# ADR 0000: [title] + + + +## Status + + + +## Context + + + +## Decision + + + +## Consequences + + + + From 643dcec7c0c827603efc64beedb5d89d837fc03b Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 22:47:23 +0100 Subject: [PATCH 09/29] chore: force another noop deploy of create-app --- .changeset/thick-bugs-talk.md | 5 +++++ .github/styles/vocab.txt | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/thick-bugs-talk.md diff --git a/.changeset/thick-bugs-talk.md b/.changeset/thick-bugs-talk.md new file mode 100644 index 0000000000..6911c5fad6 --- /dev/null +++ b/.changeset/thick-bugs-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +noop release for create-app to force re-deploy diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index f9d5024af4..2281fcd168 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -138,6 +138,7 @@ Niklas nodegit nohoist nonces +noop npm nvarchar nvm From 0f2f8e2939c2ae2ca715fe3b95da57e8e557ecda Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 22:54:01 +0100 Subject: [PATCH 10/29] chore: update workflow to check the username is the github bot that creates the commit message --- .github/workflows/master.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index d49f82c235..6f45968002 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -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 From 3254b55617918703fbd811bda1841944538ab2e3 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 23:00:05 +0100 Subject: [PATCH 11/29] chore: fixing code review comments to align with better numbers --- docs/architecture-decisions/0000-template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture-decisions/0000-template.md b/docs/architecture-decisions/0000-template.md index d1ad78ffc8..d45faefb80 100644 --- a/docs/architecture-decisions/0000-template.md +++ b/docs/architecture-decisions/0000-template.md @@ -1,6 +1,6 @@ -# ADR 0000: [title] +# ADR0000: [title] - + ## Status From 38c2f1be56f9d974a6b0451b50bdf206ee462203 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 23:13:49 +0100 Subject: [PATCH 12/29] chore: add sample preamble --- docs/architecture-decisions/0000-template.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/architecture-decisions/0000-template.md b/docs/architecture-decisions/0000-template.md index d45faefb80..ebbcef6aaa 100644 --- a/docs/architecture-decisions/0000-template.md +++ b/docs/architecture-decisions/0000-template.md @@ -1,6 +1,16 @@ -# ADR0000: [title] +--- +id: adrs-adr000 +title: ADR000: [TITLE] +description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION] +--- - +| Created | Status | +| ---------- | ------ | +| YYYY-MM-DD | Open | | + +# ADR000: [title] + + ## Status From 47814df6f48a89c2118821bfc44d144218f6c988 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 23:14:40 +0100 Subject: [PATCH 13/29] chore: remove superfluous comment --- docs/architecture-decisions/0000-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture-decisions/0000-template.md b/docs/architecture-decisions/0000-template.md index ebbcef6aaa..783db8e4bc 100644 --- a/docs/architecture-decisions/0000-template.md +++ b/docs/architecture-decisions/0000-template.md @@ -6,7 +6,7 @@ description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION] | Created | Status | | ---------- | ------ | -| YYYY-MM-DD | Open | | +| YYYY-MM-DD | Open | # ADR000: [title] From 6cd4a99261add9032e2815f9f2d3ee47fd14f325 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 9 Jan 2021 23:18:14 +0100 Subject: [PATCH 14/29] chore: update documentation around ADR and the template skeleton --- .../{0000-template.md => adr000-template.md} | 0 docs/architecture-decisions/index.md | 5 +++-- 2 files changed, 3 insertions(+), 2 deletions(-) rename docs/architecture-decisions/{0000-template.md => adr000-template.md} (100%) diff --git a/docs/architecture-decisions/0000-template.md b/docs/architecture-decisions/adr000-template.md similarity index 100% rename from docs/architecture-decisions/0000-template.md rename to docs/architecture-decisions/adr000-template.md diff --git a/docs/architecture-decisions/index.md b/docs/architecture-decisions/index.md index 3211f37550..f852170b1e 100644 --- a/docs/architecture-decisions/index.md +++ b/docs/architecture-decisions/index.md @@ -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 From 49338846703ef95c65f5fa0b065381f5e0ca7295 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 Jan 2021 22:18:23 +0000 Subject: [PATCH 15/29] Version Packages --- .changeset/thick-bugs-talk.md | 5 ----- packages/create-app/CHANGELOG.md | 6 ++++++ packages/create-app/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/thick-bugs-talk.md diff --git a/.changeset/thick-bugs-talk.md b/.changeset/thick-bugs-talk.md deleted file mode 100644 index 6911c5fad6..0000000000 --- a/.changeset/thick-bugs-talk.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -noop release for create-app to force re-deploy diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 557f2e4901..5c9b44fd07 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/create-app +## 0.3.4 + +### Patch Changes + +- 643dcec7c: noop release for create-app to force re-deploy + ## 0.3.3 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index ed9010098a..9dec615bb7 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.3", + "version": "0.3.4", "private": false, "publishConfig": { "access": "public" From de4c3af8674950a0f3f996caf54cafdf45d4b786 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 10 Jan 2021 10:09:42 +0100 Subject: [PATCH 16/29] fix(techdocs): Handle images and other binaries correctly in aws/gcs publishers Closes https://github.com/backstage/backstage/issues/3990 --- packages/techdocs-common/src/stages/publish/awsS3.ts | 2 +- packages/techdocs-common/src/stages/publish/googleStorage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 15ff33408a..a17bd25011 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -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, diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index b33e2bfbe0..12a92aeb53 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -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, From f1e74777a391b12aabb0d383f2031ffdff017e6a Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 10 Jan 2021 10:17:06 +0100 Subject: [PATCH 17/29] changesets: TechDocs AWS/GCS image bug fix --- .changeset/grumpy-trains-juggle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/grumpy-trains-juggle.md diff --git a/.changeset/grumpy-trains-juggle.md b/.changeset/grumpy-trains-juggle.md new file mode 100644 index 0000000000..91ba37b72a --- /dev/null +++ b/.changeset/grumpy-trains-juggle.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +Fix bug where binary files (`png`, etc.) could not load when using AWS or GCS publisher. From ddc7e094b5adef2b242a29264206b90fd62e878c Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Sun, 10 Jan 2021 08:13:58 -0500 Subject: [PATCH 18/29] docs: update okta auth readme --- .github/styles/vocab.txt | 1 + plugins/auth-backend/README.md | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 2281fcd168..29df243e23 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -145,6 +145,7 @@ nvm oauth OAuth oidc +okta Okta Oldsberg onboarding diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index ee6e84cded..49d8d52fd3 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -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 ``` From 3bfcc82af3d3f0e8d537aa7caaf3386b9f9025d4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 10 Jan 2021 16:49:21 +0100 Subject: [PATCH 19/29] core: simplify table icon type declarations --- packages/core/src/components/Table/Table.tsx | 67 ++++++-------------- 1 file changed, 19 insertions(+), 48 deletions(-) diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 852f4a1547..dbb2964845 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -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) => ( - - )), - Check: forwardRef((props, ref: React.Ref) => ( - - )), - Clear: forwardRef((props, ref: React.Ref) => ( - - )), - Delete: forwardRef((props, ref: React.Ref) => ( - - )), - DetailPanel: forwardRef((props, ref: React.Ref) => ( +const tableIcons: Icons = { + Add: forwardRef((props, ref) => ), + Check: forwardRef((props, ref) => ), + Clear: forwardRef((props, ref) => ), + Delete: forwardRef((props, ref) => ), + DetailPanel: forwardRef((props, ref) => ( )), - Edit: forwardRef((props, ref: React.Ref) => ( - - )), - Export: forwardRef((props, ref: React.Ref) => ( - - )), - Filter: forwardRef((props, ref: React.Ref) => ( - - )), - FirstPage: forwardRef((props, ref: React.Ref) => ( - - )), - LastPage: forwardRef((props, ref: React.Ref) => ( - - )), - NextPage: forwardRef((props, ref: React.Ref) => ( - - )), - PreviousPage: forwardRef((props, ref: React.Ref) => ( + Edit: forwardRef((props, ref) => ), + Export: forwardRef((props, ref) => ), + Filter: forwardRef((props, ref) => ), + FirstPage: forwardRef((props, ref) => ), + LastPage: forwardRef((props, ref) => ), + NextPage: forwardRef((props, ref) => ), + PreviousPage: forwardRef((props, ref) => ( )), - ResetSearch: forwardRef((props, ref: React.Ref) => ( - - )), - Search: forwardRef((props, ref: React.Ref) => ( - - )), - SortArrow: forwardRef((props, ref: React.Ref) => ( - - )), - ThirdStateCheck: forwardRef((props, ref: React.Ref) => ( - - )), - ViewColumn: forwardRef((props, ref: React.Ref) => ( - - )), + ResetSearch: forwardRef((props, ref) => ), + Search: forwardRef((props, ref) => ), + SortArrow: forwardRef((props, ref) => ), + ThirdStateCheck: forwardRef((props, ref) => ), + ViewColumn: forwardRef((props, ref) => ), }; // TODO: Material table might already have such a function internally that we can use? From 1cac511fd56128def602d27065be43d8409e930d Mon Sep 17 00:00:00 2001 From: Kevin Lee Date: Sun, 10 Jan 2021 08:56:35 -0800 Subject: [PATCH 20/29] Strip trailing slash from urls when creating lighthouse audit --- plugins/lighthouse/src/components/CreateAudit/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index 18f2c30a6e..8f514eb54a 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -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: { From cf7df3b1ffd855822ff5a06a506984ebb28b48a0 Mon Sep 17 00:00:00 2001 From: Kevin Lee Date: Sun, 10 Jan 2021 09:07:33 -0800 Subject: [PATCH 21/29] Add changeset for lighthouse plugin bugfix --- .changeset/brave-boats-greet.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-boats-greet.md diff --git a/.changeset/brave-boats-greet.md b/.changeset/brave-boats-greet.md new file mode 100644 index 0000000000..8b7fbae63b --- /dev/null +++ b/.changeset/brave-boats-greet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-lighthouse': patch +--- + +Strip trailing slash from urls when creating a new audit. This change prevents duplicate audits from being displayed in the audit list. From fd2c2fc1caf0f8fb5bd58affa8b76782165e1600 Mon Sep 17 00:00:00 2001 From: Kevin Lee Date: Sun, 10 Jan 2021 14:53:06 -0800 Subject: [PATCH 22/29] Fix typo in changeset --- .changeset/brave-boats-greet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/brave-boats-greet.md b/.changeset/brave-boats-greet.md index 8b7fbae63b..e9d3debc35 100644 --- a/.changeset/brave-boats-greet.md +++ b/.changeset/brave-boats-greet.md @@ -2,4 +2,4 @@ '@backstage/plugin-lighthouse': patch --- -Strip trailing slash from urls when creating a new audit. This change prevents duplicate audits from being displayed in the audit list. +Strip trailing slash from url when creating a new audit. This change prevents duplicate audits from being displayed in the audit list. From 21296e785a09ff781d76f69814e5ec8f6a62fdef Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 11 Jan 2021 09:53:13 +0100 Subject: [PATCH 23/29] Handle missing values in Jenkins API requests --- plugins/jenkins/src/api/JenkinsApi.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 8dbde9160d..3c04a79d54 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -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 && jobDetails.jobs) { // skipping folders inside folders for now } else { for (const buildDetails of jobDetails.builds) { From feabc7f0cd912ba404ba7e001b253b9d939823b0 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 11 Jan 2021 09:54:30 +0100 Subject: [PATCH 24/29] Add changeset --- .changeset/friendly-rats-wonder.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/friendly-rats-wonder.md diff --git a/.changeset/friendly-rats-wonder.md b/.changeset/friendly-rats-wonder.md new file mode 100644 index 0000000000..5a5b30f98a --- /dev/null +++ b/.changeset/friendly-rats-wonder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins': patch +--- + +Handle missing ObjectMetadataAction in Jenkins API From 2ee01130319ed024e64e8d34e434c9b80b252b85 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 11 Jan 2021 10:37:14 +0100 Subject: [PATCH 25/29] Review comments --- plugins/jenkins/src/api/JenkinsApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 3c04a79d54..ed70607c27 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -145,7 +145,7 @@ export class JenkinsApi { for (const jobDetails of folder.jobs) { const jobScmInfo = this.extractScmDetailsFromJob(jobDetails); - if (jobDetails && jobDetails.jobs) { + if (jobDetails?.jobs) { // skipping folders inside folders for now } else { for (const buildDetails of jobDetails.builds) { From 466354aaa7bf93c747527322d5ea974a22ebce9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 4 Jan 2021 19:41:56 +0100 Subject: [PATCH 26/29] integration: build out the integrations class hierarchy --- .changeset/flat-walls-burn.md | 5 ++ .../integration/src/ScmIntegrations.test.ts | 76 +++++++++++++++++++ packages/integration/src/ScmIntegrations.ts | 59 +++++++++++--- .../src/azure/AzureIntegration.test.ts | 5 +- .../integration/src/azure/AzureIntegration.ts | 21 +++-- .../bitbucket/BitbucketIntegration.test.ts | 5 +- .../src/bitbucket/BitbucketIntegration.ts | 23 ++++-- .../src/github/GitHubIntegration.test.ts | 13 +++- .../src/github/GitHubIntegration.ts | 21 +++-- .../src/gitlab/GitLabIntegration.test.ts | 5 +- .../src/gitlab/GitLabIntegration.ts | 21 +++-- packages/integration/src/helpers.ts | 20 +++++ packages/integration/src/types.ts | 49 ++++++++---- 13 files changed, 255 insertions(+), 68 deletions(-) create mode 100644 .changeset/flat-walls-burn.md create mode 100644 packages/integration/src/ScmIntegrations.test.ts diff --git a/.changeset/flat-walls-burn.md b/.changeset/flat-walls-burn.md new file mode 100644 index 0000000000..370b651a4b --- /dev/null +++ b/.changeset/flat-walls-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': minor +--- + +Build out the `ScmIntegrations` class, as well as the individual `*Integration` classes diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts new file mode 100644 index 0000000000..b43e69eba4 --- /dev/null +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -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); + }); +}); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 515a32502d..102273a03b 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -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; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +}; + 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 { + return this.byType.azure; + } + + get bitbucket(): ScmIntegrationsGroup { + return this.byType.bitbucket; + } + + get github(): ScmIntegrationsGroup { + return this.byType.github; + } + + get gitlab(): ScmIntegrationsGroup { + 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); } } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 4a0c32badb..90c098b637 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -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', () => { diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 84dc3800d2..446e6a9480 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -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 = ({ 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; } } diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 87dbac2263..3f130a393c 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -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', () => { diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index b271e2f408..f3e69b946a 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -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 = ({ + 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; } } diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts index 0c326d81cd..9056517f32 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -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'); }); }); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index 92c5951873..c103597d74 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -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 = ({ 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; } } diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 260afd23d8..8814e33302 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -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', () => { diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 4d035cb24e..d939917366 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -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 = ({ 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; } } diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index 02393f99e1..cc1c59a238 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -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( + integrations: T[], + getHost: (integration: T) => string, +): ScmIntegrationsGroup { + 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); + }, + }; +} diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index ae9c360980..d8fb7a14e2 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -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 { /** - * 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 { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +} + +export type ScmIntegrationsFactory = (options: { config: Config; -}) => ScmIntegrationPredicateTuple[]; +}) => ScmIntegrationsGroup; From be5ac7fde869debc53af7fcf9284bb31442d01fb Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Wed, 6 Jan 2021 10:55:49 +0100 Subject: [PATCH 27/29] Remove the plugin-catalog-backend dependency from the catalog-import --- .changeset/rare-paws-listen.md | 5 +++++ plugins/catalog-import/package.json | 1 - plugins/catalog-import/src/api/CatalogImportClient.ts | 5 ++--- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 .changeset/rare-paws-listen.md diff --git a/.changeset/rare-paws-listen.md b/.changeset/rare-paws-listen.md new file mode 100644 index 0000000000..1946cd7f2f --- /dev/null +++ b/.changeset/rare-paws-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Remove dependency to `@backstage/plugin-catalog-backend`. diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 495552983f..d308bef7b8 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -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", diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 0e28042db3..a578f37a96 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -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({ From 711ba55a245589cd6000fb43c684f10d030cbf72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 Jan 2021 07:51:01 +0100 Subject: [PATCH 28/29] scaffolder-backend: export all preparers and publishers properly --- .changeset/wild-dolls-rest.md | 5 +++++ .../src/scaffolder/stages/prepare/index.ts | 14 ++++++++------ .../src/scaffolder/stages/publish/index.ts | 17 ++++++++++++----- 3 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 .changeset/wild-dolls-rest.md diff --git a/.changeset/wild-dolls-rest.md b/.changeset/wild-dolls-rest.md new file mode 100644 index 0000000000..33298dbb52 --- /dev/null +++ b/.changeset/wild-dolls-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Export all preparers and publishers properly diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts index f7bc535189..80db93d00f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts index b37baa3246..e55aa0919b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts @@ -13,8 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './publishers'; -export * from './github'; -export * from './gitlab'; -export * from './azure'; -export * from './types'; +export { AzurePublisher } from './azure'; +export { BitbucketPublisher } from './bitbucket'; +export { GithubPublisher } from './github'; +export type { RepoVisibilityOptions } from './github'; +export { GitlabPublisher } from './gitlab'; +export { Publishers } from './publishers'; +export type { + PublisherBase, + PublisherBuilder, + PublisherOptions, + PublisherResult, +} from './types'; From 5a9a7e7c2675291dbd1a87ab6e10045bb6ace668 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 11 Jan 2021 16:50:30 +0000 Subject: [PATCH 29/29] Kubernetes plugin: UI revamp (#3918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial revamp, no error reporting * initial error detection * more error detection * add microsite page * minor fixes * add HPA error detection * refactor error reporting; add tests * fix import * add changeseet * empty error state * prettier yaml file * Update plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx Co-authored-by: Patrik Oldsberg * Update plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx Co-authored-by: Patrik Oldsberg * PR feedback * revert odd change * make patch change * fix test * Apply suggestions from code review Co-authored-by: Fredrik Adelöw * prettier * pr feedback Co-authored-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw --- .changeset/flat-cycles-lay.md | 6 + .../kubernetes-in-backstage.md | 132 ++++++++ microsite/sidebars.json | 1 + .../dice-roller/dice-roller-manifests.yaml | 131 ++++++++ plugins/kubernetes/assets/emptystate.svg | 1 + plugins/kubernetes/package.json | 1 + .../components/ConfigMaps/ConfigMaps.test.tsx | 41 --- .../src/components/ConfigMaps/ConfigMaps.tsx | 51 ---- .../ConfigMaps/__fixtures__/configmap.json | 46 --- .../DeploymentTables/DeploymentTables.tsx | 220 -------------- .../DeploymentDrawer.test.tsx | 50 ++++ .../DeploymentDrawer.tsx | 72 +++++ .../DeploymentsAccordions.test.tsx} | 26 +- .../DeploymentsAccordions.tsx | 221 ++++++++++++++ .../__fixtures__/2-deployments.json | 84 ++++++ .../index.ts | 2 +- .../ErrorReporting/ErrorReporting.tsx | 135 +++++++++ .../{Ingresses => ErrorReporting}/index.ts | 2 +- ...=> HorizontalPodAutoscalerDrawer.test.tsx} | 42 ++- .../HorizontalPodAutoscalerDrawer.tsx | 51 ++++ .../HorizontalPodAutoscalers.tsx | 64 ---- .../horizontalpodautoscalers.json | 5 +- .../HorizontalPodAutoscalers/index.ts | 2 +- .../components/Ingresses/Ingresses.test.tsx | 47 --- .../src/components/Ingresses/Ingresses.tsx | 51 ---- .../Ingresses/__fixtures__/ingress.json | 87 ------ .../KubernetesContent/ErrorPanel.tsx | 2 +- .../KubernetesContent/KubernetesContent.tsx | 253 ++++++++-------- .../KubernetesDrawer/KubernetesDrawer.tsx | 205 +++++++++++++ .../src/components/Pods/PodDrawer.test.tsx | 91 ++++++ .../src/components/Pods/PodDrawer.tsx | 69 +++++ .../src/components/Pods/PodsTable.test.tsx | 67 +++++ .../src/components/Pods/PodsTable.tsx | 74 +++++ .../Pods/__fixtures__/crashing-pod.json | 233 +++++++++++++++ .../src/components/Pods/__fixtures__/pod.json | 139 +++++++++ .../components/{Services => Pods}/index.ts | 3 +- .../src/components/Services/Services.test.tsx | 54 ---- .../src/components/Services/Services.tsx | 62 ---- .../Services/__fixtures__/services.json | 164 ---------- .../__fixtures__/deploy-bad.json | 119 ++++++++ .../__fixtures__/deploy-healthy.json | 92 ++++++ .../__fixtures__/hpa-healthy.json | 32 ++ .../__fixtures__/hpa-maxed-out.json | 32 ++ .../__fixtures__/pod-crashing.json | 233 +++++++++++++++ .../__fixtures__/pod-missing-cm.json | 168 +++++++++++ .../src/error-detection/__fixtures__/pod.json | 139 +++++++++ .../kubernetes/src/error-detection/common.ts | 70 +++++ .../src/error-detection/deployments.ts | 49 +++ .../error-detection/error-detection.test.ts | 282 ++++++++++++++++++ .../src/error-detection/error-detection.ts | 58 ++++ .../kubernetes/src/error-detection/hpas.ts | 50 ++++ .../ConfigMaps => error-detection}/index.ts | 4 +- .../kubernetes/src/error-detection/pods.ts | 95 ++++++ .../kubernetes/src/error-detection/types.ts | 48 +++ plugins/kubernetes/src/types/types.ts | 19 +- plugins/kubernetes/src/utils/pod.tsx | 104 +++++++ plugins/kubernetes/src/utils/response.ts | 62 ++++ 57 files changed, 3589 insertions(+), 1054 deletions(-) create mode 100644 .changeset/flat-cycles-lay.md create mode 100644 docs/features/software-catalog/kubernetes-in-backstage.md create mode 100644 plugins/kubernetes/assets/emptystate.svg delete mode 100644 plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx delete mode 100644 plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx delete mode 100644 plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json delete mode 100644 plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx create mode 100644 plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx create mode 100644 plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx rename plugins/kubernetes/src/components/{DeploymentTables/DeploymentTables.test.tsx => DeploymentsAccordions/DeploymentsAccordions.test.tsx} (57%) create mode 100644 plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx rename plugins/kubernetes/src/components/{DeploymentTables => DeploymentsAccordions}/__fixtures__/2-deployments.json (97%) rename plugins/kubernetes/src/components/{DeploymentTables => DeploymentsAccordions}/index.ts (90%) create mode 100644 plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx rename plugins/kubernetes/src/components/{Ingresses => ErrorReporting}/index.ts (92%) rename plugins/kubernetes/src/components/HorizontalPodAutoscalers/{HorizontalPodAutoscalers.test.tsx => HorizontalPodAutoscalerDrawer.test.tsx} (59%) create mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx delete mode 100644 plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx delete mode 100644 plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx delete mode 100644 plugins/kubernetes/src/components/Ingresses/Ingresses.tsx delete mode 100644 plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json create mode 100644 plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodDrawer.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodsTable.test.tsx create mode 100644 plugins/kubernetes/src/components/Pods/PodsTable.tsx create mode 100644 plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json create mode 100644 plugins/kubernetes/src/components/Pods/__fixtures__/pod.json rename plugins/kubernetes/src/components/{Services => Pods}/index.ts (87%) delete mode 100644 plugins/kubernetes/src/components/Services/Services.test.tsx delete mode 100644 plugins/kubernetes/src/components/Services/Services.tsx delete mode 100644 plugins/kubernetes/src/components/Services/__fixtures__/services.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json create mode 100644 plugins/kubernetes/src/error-detection/__fixtures__/pod.json create mode 100644 plugins/kubernetes/src/error-detection/common.ts create mode 100644 plugins/kubernetes/src/error-detection/deployments.ts create mode 100644 plugins/kubernetes/src/error-detection/error-detection.test.ts create mode 100644 plugins/kubernetes/src/error-detection/error-detection.ts create mode 100644 plugins/kubernetes/src/error-detection/hpas.ts rename plugins/kubernetes/src/{components/ConfigMaps => error-detection}/index.ts (82%) create mode 100644 plugins/kubernetes/src/error-detection/pods.ts create mode 100644 plugins/kubernetes/src/error-detection/types.ts create mode 100644 plugins/kubernetes/src/utils/pod.tsx create mode 100644 plugins/kubernetes/src/utils/response.ts diff --git a/.changeset/flat-cycles-lay.md b/.changeset/flat-cycles-lay.md new file mode 100644 index 0000000000..2f84b38ffb --- /dev/null +++ b/.changeset/flat-cycles-lay.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +--- + +Revamped Kubernetes UI and added error reporting/detection diff --git a/docs/features/software-catalog/kubernetes-in-backstage.md b/docs/features/software-catalog/kubernetes-in-backstage.md new file mode 100644 index 0000000000..e9fe00718a --- /dev/null +++ b/docs/features/software-catalog/kubernetes-in-backstage.md @@ -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': +``` + +### 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' +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index b31f0ccdf3..d9da0ae449 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -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" ] }, diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index 50956bd7d3..68ef489170 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -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 diff --git a/plugins/kubernetes/assets/emptystate.svg b/plugins/kubernetes/assets/emptystate.svg new file mode 100644 index 0000000000..fa7f19123e --- /dev/null +++ b/plugins/kubernetes/assets/emptystate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 56a61d12a2..f00c846163 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -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", diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx deleted file mode 100644 index 35b8f2143a..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx +++ /dev/null @@ -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( - , - ), - ); - - // 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 - }); -}); diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx deleted file mode 100644 index 5a013eb2c6..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx +++ /dev/null @@ -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 ( - - {configMaps.map((cm, i) => { - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json b/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json deleted file mode 100644 index 77ea844611..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json +++ /dev/null @@ -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" - } - } -] diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx deleted file mode 100644 index a009c20d7e..0000000000 --- a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx +++ /dev/null @@ -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 ; - } - - const status = condition.status; - - if (status === 'True') { - return ; - } else if (status === 'False') { - return ; - } - return ; -}; - -const columns: TableColumn[] = [ - { - 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 ; - }); - }, - }, - { - 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) => ( - - Container: {next.name}} - subvalue={reason} - /> -
- - ); - - if (waiting) { - accum.push(renderCell(waiting.reason)); - } - - if (terminated) { - accum.push(renderCell(terminated.reason)); - } - - return accum; - }, [] as React.ReactNode[]); - - if (errors.length === 0) { - return OK; - } - - 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 ( - - {deploymentTriple.deployments.map((deployment, i) => ( - - {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 ( - - - - ); - })} - - ))} - - ); -}; diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx new file mode 100644 index 0000000000..5bf2c16de2 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx @@ -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( + , + ), + ); + + 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); + }); +}); diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx new file mode 100644 index 0000000000..6bacad9b16 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx @@ -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 ( + { + 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, + }; + }} + > + + + + {deployment.metadata?.name ?? 'unknown object'} + + + + + Deployment + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx similarity index 57% rename from plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx rename to plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx index 3dacac7055..a522af6d4f 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx @@ -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( - , + , ), ); - // 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(); }); }); diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx new file mode 100644 index 0000000000..c2bda7a895 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx @@ -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; + 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 ( + + {deploymentResources.deployments.map((deployment, i) => ( + + {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 ( + + + + ); + })} + + ))} + + ); +}; + +type DeploymentAccordionProps = { + deployment: V1Deployment; + ownedPods: V1Pod[]; + matchingHpa?: V1HorizontalPodAutoscaler; + clusterPodNamesWithErrors: Set; + children?: React.ReactNode; +}; + +const DeploymentAccordion = ({ + deployment, + ownedPods, + matchingHpa, + clusterPodNamesWithErrors, +}: DeploymentAccordionProps) => { + const podsWithErrors = ownedPods.filter(p => + clusterPodNamesWithErrors.has(p.metadata?.name ?? ''), + ); + + return ( + + }> + + + + + + + ); +}; + +type DeploymentSummaryProps = { + deployment: V1Deployment; + numberOfCurrentPods: number; + numberOfPodsWithErrors: number; + hpa?: V1HorizontalPodAutoscaler; + children?: React.ReactNode; +}; + +const DeploymentSummary = ({ + deployment, + numberOfCurrentPods, + numberOfPodsWithErrors, + hpa, +}: DeploymentSummaryProps) => { + return ( + + + + + + + + {hpa && ( + + + + + + min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '} + {hpa.spec?.maxReplicas ?? '?'} + + + + + current CPU usage:{' '} + {hpa.status?.currentCPUUtilizationPercentage ?? '?'}% + + + + + target CPU usage:{' '} + {hpa.spec?.targetCPUUtilizationPercentage ?? '?'}% + + + + + + )} + + + {numberOfCurrentPods} pods + + + {numberOfPodsWithErrors > 0 ? ( + + {numberOfPodsWithErrors} pod + {numberOfPodsWithErrors > 1 ? 's' : ''} with errors + + ) : ( + No pods with errors + )} + + + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json b/plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json similarity index 97% rename from plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json rename to plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json index e51d0d0b5f..f5efdbf1cb 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json @@ -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 + } + } ] } diff --git a/plugins/kubernetes/src/components/DeploymentTables/index.ts b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts similarity index 90% rename from plugins/kubernetes/src/components/DeploymentTables/index.ts rename to plugins/kubernetes/src/components/DeploymentsAccordions/index.ts index dac38c5c81..923fec624b 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/index.ts +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts @@ -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'; diff --git a/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx b/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx new file mode 100644 index 0000000000..c986c0158d --- /dev/null +++ b/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx @@ -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[] = [ + { + 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 && ( + 1 ? 's' : '' + }`} + size="small" + /> + )} + + ); + }, + }, + { + title: 'messages', + width: '40%', + render: (detectedError: DetectedError) => ( + <> + {detectedError.message.map((m, i) => ( +
{m}
+ ))} + + ), + }, +]; + +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 ? ( + + + + ) : ( +
+ )} + + ); +}; + +export const ErrorEmptyState = () => { + return ( + + + + Nice! There are no errors to report! + + + + EmptyState + + + ); +}; diff --git a/plugins/kubernetes/src/components/Ingresses/index.ts b/plugins/kubernetes/src/components/ErrorReporting/index.ts similarity index 92% rename from plugins/kubernetes/src/components/Ingresses/index.ts rename to plugins/kubernetes/src/components/ErrorReporting/index.ts index ecc67480db..87ce11ffd8 100644 --- a/plugins/kubernetes/src/components/Ingresses/index.ts +++ b/plugins/kubernetes/src/components/ErrorReporting/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Ingresses } from './Ingresses'; +export { ErrorReporting } from './ErrorReporting'; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx similarity index 59% rename from plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx rename to plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx index eb5d407c76..fc64de0f0a 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx @@ -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( - , + +

CHILD

+
, ), ); - // 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(); }); }); diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx new file mode 100644 index 0000000000..00ab2cad84 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx @@ -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 ( + { + 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} + + ); +}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx deleted file mode 100644 index b4540840d2..0000000000 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx +++ /dev/null @@ -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 ( - - {hpas.map((hpa, i) => { - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json index 6bc6028246..6afdda48ed 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json @@ -74,8 +74,9 @@ "targetCPUUtilizationPercentage": 50 }, "status": { - "currentReplicas": 10, - "desiredReplicas": 0 + "currentReplicas": 13, + "desiredReplicas": 14, + "currentCPUUtilizationPercentage": 30 } } ] diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts index 3eebbe9813..b5ca533079 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts @@ -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'; diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx deleted file mode 100644 index f2507a3f39..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx +++ /dev/null @@ -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( - , - ), - ); - - // 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(); - }); -}); diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx deleted file mode 100644 index 37932601a1..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx +++ /dev/null @@ -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 ( - - {ingresses.map((ingress, i) => { - return ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json b/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json deleted file mode 100644 index fd0bc5ec43..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json +++ /dev/null @@ -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" - } - ] - } - } - } -] diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx index 1b8336dd72..85060fc11b 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx @@ -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[], diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 65c1102d47..293e7a4833 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -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(); + return ( @@ -172,11 +127,29 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { /> )} - {kubernetesObjects?.items.map((item, i) => ( - - - - ))} + {kubernetesObjects && ( + <> + + + + + + + + Your Clusters + + + {kubernetesObjects?.items.map((item, i) => ( + + + + ))} + + + )} @@ -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('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[] = [ - - - , - - - , - ]; - - if (configMaps.length > 0) { - tabs.push( - - - , - ); - } - if (hpas.length > 0) { - tabs.push( - - - , - ); - } - if (ingresses.length > 0) { - tabs.push( - - - , - ); - } + const podsWithErrors = new Set( + detectedErrors + ?.filter(de => de.kind === 'Pod') + .map(de => de.names) + .flat() ?? [], + ); return ( <> - - {tabs} - + + }> + + + + + + ); }; + +type ClusterSummaryProps = { + clusterName: string; + totalNumberOfPods: number; + numberOfPodsWithErrors: number; + children?: React.ReactNode; +}; + +const ClusterSummary = ({ + clusterName, + totalNumberOfPods, + numberOfPodsWithErrors, +}: ClusterSummaryProps) => { + return ( + + + + {clusterName} + + Cluster + + + + + + + + + {totalNumberOfPods} pods + + + {numberOfPodsWithErrors > 0 ? ( + {numberOfPodsWithErrors} pods with errors + ) : ( + No pods with errors + )} + + + + ); +}; diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx new file mode 100644 index 0000000000..8deb49bf07 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -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 { + object: T; + renderObject: (obj: T) => object; + buttonVariant?: 'h5' | 'subtitle2'; + kind: string; + expanded?: boolean; + children?: React.ReactNode; +} + +export const KubernetesDrawer = ({ + object, + renderObject, + kind, + buttonVariant = 'subtitle2', + expanded = false, + children, +}: KubernetesDrawerProps) => { + const [isOpen, setIsOpen] = useState(expanded); + const classes = useDrawerStyles(); + + const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => { + e.stopPropagation(); + setIsOpen(newValue); + }; + + return ( + <> + toggleDrawer(e, true)} + onFocus={event => event.stopPropagation()} + > + {children === undefined ? ( + + {object.metadata?.name ?? 'unknown object'} + + ) : ( + children + )} + + toggleDrawer(e, false)} + onClick={event => event.stopPropagation()} + > + + + + ); +}; + +interface KubernetesDrawerContentProps { + toggleDrawer: (e: ChangeEvent<{}>, isOpen: boolean) => void; + object: T; + renderObject: (obj: T) => object; + kind: string; +} + +const KubernetesDrawerContent = ({ + toggleDrawer, + object, + renderObject, + kind, +}: KubernetesDrawerContentProps) => { + const [isYaml, setIsYaml] = useState(false); + + const classes = useDrawerContentStyles(); + + return ( + <> +
+ + + + {object.metadata?.name ?? 'unknown name'} + + + + + {kind} + + + + toggleDrawer(e, false)} + color="inherit" + > + + +
+
+ { + setIsYaml(event.target.checked); + }} + name="YAML" + /> + } + label="YAML" + /> +
+
+ {isYaml && } + {!isYaml && } +
+ + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx new file mode 100644 index 0000000000..f166dc5f47 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx @@ -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(), + ); + + 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(), + ); + + 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(); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer.tsx new file mode 100644 index 0000000000..ed9e208fb1 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodDrawer.tsx @@ -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 ( + { + 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, + }; + }} + /> + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx new file mode 100644 index 0000000000..96f3035372 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx @@ -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(), + ); + + // 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(), + ); + + // 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); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx new file mode 100644 index 0000000000..fb2b26cd39 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodsTable.tsx @@ -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[] = [ + { + title: 'name', + highlight: true, + render: (pod: V1Pod) => , + }, + { + 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 ( +
+
+ + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json b/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json new file mode 100644 index 0000000000..79acd2900d --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json @@ -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" + } +} diff --git a/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json b/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json new file mode 100644 index 0000000000..5092efc089 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json @@ -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" + } +} diff --git a/plugins/kubernetes/src/components/Services/index.ts b/plugins/kubernetes/src/components/Pods/index.ts similarity index 87% rename from plugins/kubernetes/src/components/Services/index.ts rename to plugins/kubernetes/src/components/Pods/index.ts index d52ebf5f14..319950b1da 100644 --- a/plugins/kubernetes/src/components/Services/index.ts +++ b/plugins/kubernetes/src/components/Pods/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Services } from './Services'; +export { PodDrawer } from './PodDrawer'; +export { PodsTable } from './PodsTable'; diff --git a/plugins/kubernetes/src/components/Services/Services.test.tsx b/plugins/kubernetes/src/components/Services/Services.test.tsx deleted file mode 100644 index 5e74499bc5..0000000000 --- a/plugins/kubernetes/src/components/Services/Services.test.tsx +++ /dev/null @@ -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(), - ); - - // 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(); - }); -}); diff --git a/plugins/kubernetes/src/components/Services/Services.tsx b/plugins/kubernetes/src/components/Services/Services.tsx deleted file mode 100644 index d0a4f71eae..0000000000 --- a/plugins/kubernetes/src/components/Services/Services.tsx +++ /dev/null @@ -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 ( - - {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 ( - - -
- -
-
-
- ); - })} -
- ); -}; diff --git a/plugins/kubernetes/src/components/Services/__fixtures__/services.json b/plugins/kubernetes/src/components/Services/__fixtures__/services.json deleted file mode 100644 index 7f9b49a9f6..0000000000 --- a/plugins/kubernetes/src/components/Services/__fixtures__/services.json +++ /dev/null @@ -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": {} - } - } -] diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json new file mode 100644 index 0000000000..f07a6f7d69 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json @@ -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 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json new file mode 100644 index 0000000000..87a7e07b0d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json @@ -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 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json new file mode 100644 index 0000000000..23edee5a07 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json @@ -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 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json new file mode 100644 index 0000000000..4466e7b4b1 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json @@ -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 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json new file mode 100644 index 0000000000..79acd2900d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json @@ -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" + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json new file mode 100644 index 0000000000..3fdd4deaab --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json @@ -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" + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod.json new file mode 100644 index 0000000000..5092efc089 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod.json @@ -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" + } +} diff --git a/plugins/kubernetes/src/error-detection/common.ts b/plugins/kubernetes/src/error-detection/common.ts new file mode 100644 index 0000000000..626eaf39d4 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/common.ts @@ -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 = ( + objects: T[], + kind: ErrorDetectableKind, + clusterName: string, + errorMappers: ErrorMapper[], +): DetectedError[] => { + // Build up a map of errors + // key: the joined message produced by an error + // value: the error + const errors = new Map(); + + 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()); +}; diff --git a/plugins/kubernetes/src/error-detection/deployments.ts b/plugins/kubernetes/src/error-detection/deployments.ts new file mode 100644 index 0000000000..f70d4196cb --- /dev/null +++ b/plugins/kubernetes/src/error-detection/deployments.ts @@ -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[] = [ + { + // 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, + ); diff --git a/plugins/kubernetes/src/error-detection/error-detection.test.ts b/plugins/kubernetes/src/error-detection/error-detection.test.ts new file mode 100644 index 0000000000..8a4575c8be --- /dev/null +++ b/plugins/kubernetes/src/error-detection/error-detection.test.ts @@ -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, + }); + }); +}); diff --git a/plugins/kubernetes/src/error-detection/error-detection.ts b/plugins/kubernetes/src/error-detection/error-detection.ts new file mode 100644 index 0000000000..a44747969d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/error-detection.ts @@ -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(); + + 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; +}; diff --git a/plugins/kubernetes/src/error-detection/hpas.ts b/plugins/kubernetes/src/error-detection/hpas.ts new file mode 100644 index 0000000000..53c429b2f2 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/hpas.ts @@ -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[] = [ + { + // 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, + ); diff --git a/plugins/kubernetes/src/components/ConfigMaps/index.ts b/plugins/kubernetes/src/error-detection/index.ts similarity index 82% rename from plugins/kubernetes/src/components/ConfigMaps/index.ts rename to plugins/kubernetes/src/error-detection/index.ts index b1c7d37a5a..69a4d11851 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/index.ts +++ b/plugins/kubernetes/src/error-detection/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ConfigMaps } from './ConfigMaps'; + +export type { DetectedError, DetectedErrorsByCluster } from './types'; +export { detectErrors } from './error-detection'; diff --git a/plugins/kubernetes/src/error-detection/pods.ts b/plugins/kubernetes/src/error-detection/pods.ts new file mode 100644 index 0000000000..8b990d0d52 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/pods.ts @@ -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[] = [ + { + 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); diff --git a/plugins/kubernetes/src/error-detection/types.ts b/plugins/kubernetes/src/error-detection/types.ts new file mode 100644 index 0000000000..817911ce40 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/types.ts @@ -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; + +export interface DetectedError { + severity: ErrorSeverity; + cluster: string; + kind: ErrorDetectableKind; + names: string[]; + message: string[]; +} + +export interface ErrorMapper { + severity: ErrorSeverity; + errorExplanation: string; + errorExists: (object: T) => boolean; + messageAccessor: (object: T) => string[]; +} diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 09bff511a7..ab12730f72 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -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[]; } diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx new file mode 100644 index 0000000000..4e317cd606 --- /dev/null +++ b/plugins/kubernetes/src/utils/pod.tsx @@ -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 ; + }); + + return
{images}
; +}; + +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) => ( + + Container: {next.name}} + subvalue={reason} + /> +
+
+ ); + + if (waiting) { + accum.push(renderCell(waiting.reason)); + } + + if (terminated) { + accum.push(renderCell(terminated.reason)); + } + + return accum; + }, [] as React.ReactNode[]); + + if (errors.length === 0) { + return OK; + } + + return errors; +}; + +export const renderCondition = ( + condition: V1PodCondition | V1DeploymentCondition, +): [string, ReactNode] => { + const status = condition.status; + + if (status === 'True') { + return [condition.type, True]; + } else if (status === 'False') { + return [ + condition.type, + False} + subvalue={condition.message ?? ''} + />, + ]; + } + return [condition.type, ]; +}; diff --git a/plugins/kubernetes/src/utils/response.ts b/plugins/kubernetes/src/utils/response.ts new file mode 100644 index 0000000000..54b319a6ef --- /dev/null +++ b/plugins/kubernetes/src/utils/response.ts @@ -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, + ); +};