Merge branch 'master' of github.com:backstage/backstage into contrib/dockerfile

* 'master' of github.com:backstage/backstage: (26 commits)
  Remove heading from the adr template
  Updated version changes to be minor
  Updated changeset with breaking changes documentation
  chore(deps-dev): bump @types/mini-css-extract-plugin from 0.9.1 to 1.2.2
  chore(deps): bump @octokit/request from 5.4.12 to 5.4.13
  Change category from Feedback to Data Display
  Fix typo
  Tweak wording
  Update descriptions
  Point to main docs site
  Reorg Kubernetes section
  config-loader: Fix tests for windows
  Add pictures to users and groups
  remove last usages of APP_ENV
  Add `EntityRefLinks` that shows one or multiple entity ref links.
  catalog: support supplying a custom catalog descriptor file parser
  move types/aws4 to devDeps
  feature: add aws iam auth translator for kubernetes
  Ask the SonarQube server for all support metrics prior to querying them for a project
  Fixed bug in ConsumerGroupOffsets test
  ...
This commit is contained in:
blam
2021-01-26 15:57:43 +01:00
66 changed files with 1371 additions and 457 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-kubernetes-backend': patch
---
Add AWS auth provider for Kubernetes
+31
View File
@@ -0,0 +1,31 @@
---
'@backstage/plugin-kafka': minor
'@backstage/plugin-kafka-backend': minor
---
Added support for multiple Kafka clusters and multiple consumers per component.
Note that this introduces several breaking changes.
1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way:
```diff
kafka:
clientId: backstage
- brokers:
- - localhost:9092
+ clusters:
+ - name: prod
+ brokers:
+ - localhost:9092
```
2. Configuration of services has changed as well to support multiple clusters:
```diff
annotations:
- kafka.apache.org/consumer-groups: consumer
+ kafka.apache.org/consumer-groups: prod/consumer
```
3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path:
`/consumers/${clusterId}/${consumerGroup}/offsets`
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog': patch
---
Add `EntityRefLinks` that shows one or multiple entity ref links.
Change the about card and catalog table to use `EntityRefLinks` due to the
nature of relations to support multiple relations per type.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Support supplying a custom catalog descriptor file parser
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-sonarqube': patch
---
Ask the SonarQube server for all support metrics prior to querying them for a project.
+7 -5
View File
@@ -99,6 +99,13 @@ kubernetes:
- 'config'
clusters: []
kafka:
clientId: backstage
clusters:
- name: cluster
brokers:
- localhost:9092
integrations:
github:
- host: github.com
@@ -372,8 +379,3 @@ homepage:
timezone: 'Asia/Tokyo'
pagerduty:
eventsBaseUrl: 'https://events.pagerduty.com/v2'
kafka:
clientId: backstage
brokers:
- localhost:9092
@@ -4,8 +4,6 @@ title: ADR000: [TITLE]
description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION]
---
# ADR000: [title]
<!-- These documents have names that are short noun phrases. For example, "ADR001: Deployment on Ruby on Rails 3.0.10" or "ADR009: LDAP for Multitenant Integration" -->
## Context
@@ -4,8 +4,6 @@ title: ADR010: Use the Luxon Date Library
description: Architecture Decision Record (ADR) for Luxon Date Library
---
# ADR010: Use the Luxon Date Library
## Context
Date formatting (e.g. `a day ago`) and calculations are common within Backstage.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+130
View File
@@ -0,0 +1,130 @@
---
id: configuration
title: Configuring Kubernetes integration
sidebar_label: Configuration
# prettier-ignore
description: Configuring the Kubernetes integration for Backstage expose your entity's objects
---
Configuring the Backstage Kubernetes integration involves two steps:
1. Enabling the backend to collect objects from your Kubernetes cluster(s).
2. Surfacing your Kubernetes objects in catalog entities
## Configuring Kubernetes Clusters
The following is a full example entry in `app-config.yaml`:
```yaml
kubernetes:
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
authProvider: 'serviceAccount'
serviceAccountToken:
$env: K8S_MINIKUBE_TOKEN
- 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 value is:
- `multiTenant` - This configuration assumes that all components run on all the
provided clusters.
### `clusterLocatorMethods`
This is an array 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.
### Role Based Access Control
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's `catalog-info.yaml`:
```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 themselves can have the following label:
```yaml
'backstage.io/kubernetes-id': <BACKSTAGE_ENTITY_NAME>
```
### 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
[labels and selectors Kubernetes documentation](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'
```
+17 -114
View File
@@ -5,123 +5,26 @@ sidebar_label: Overview
description: Monitoring Kubernetes based services with the service catalog
---
Kubernetes in Backstage is a way to monitor your service's current status when
it is deployed on Kubernetes.
Kubernetes in Backstage is a tool that's designed around the needs of service
owners, not cluster admins. Now developers can easily check the health of their
services no matter how or where those services are deployed — whether it's on a
local host for testing or in production on dozens of clusters around the world.
## Configuration
It will elevate the visibility of errors where identified, and provide drill
down about the deployments, pods, and other objects for a service.
Example:
![Kubernetes plugin screenshot](../../assets/features/kubernetes/backstage-k8s-2-deployments.png)
```yaml
kubernetes:
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
authProvider: 'serviceAccount'
serviceAccountToken:
$env: K8S_MINIKUBE_TOKEN
- url: http://127.0.0.2:9999
name: gke-cluster-1
authProvider: 'google'
```
The feature is made up of two plugins:
[`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes)
and
[`@backstage/plugin-kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend).
### serviceLocatorMethod
The frontend plugin exposes information to the end user in a digestible way,
while the backend wraps the mechanics to connect to Kubernetes clusters to
collect the relevant information.
This configures how to determine which clusters a component is running in.
## Let's use it!
Currently, the only valid value is:
- `multiTenant` - This configuration assumes that all components run on all the
provided clusters.
### clusterLocatorMethods
This is an array 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.
## Role Based Access Control
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's `catalog-info.yaml`:
```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 themselves can have the following label:
```yaml
'backstage.io/kubernetes-id': <BACKSTAGE_ENTITY_NAME>
```
### 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
[labels and selectors Kubernetes documentation](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'
```
To get started, first you must [install the Kubernetes plugins](installation.md)
and then [configure them](configuration.md).
+119
View File
@@ -0,0 +1,119 @@
---
id: installation
title: Installation
description: Installing Kubernetes plugin into Backstage
---
The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when
viewing entities in the software catalog.
If you haven't setup Backstage already, start
[here](../../getting-started/index.md).
## Adding the Kubernetes frontend plugin
The first step is to add the frontend Kubernetes plugin to your Backstage
application. Navigate to your new Backstage application directory. And then to
your `packages/app` directory, and install the `@backstage/plugin-kubernetes`
package.
```bash
cd my-backstage-app/
cd packages/app
yarn add @backstage/plugin-kubernetes
```
Once the package has been installed, you need to import the plugin in your app.
Add the following to `packages/app/src/plugins.ts`:
`plugins.ts`:
```typescript
export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
```
Now, add the "Kubernetes" tab to the catalog entity page. In
`packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get
to the tab, and add the tab itself.
`EntityPage.tsx`:
```tsx
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
// ...
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
// ...
<EntityPageLayout.Content
path="/kubernetes/*"
title="Kubernetes"
element={<KubernetesRouter entity={entity} />}
/>
// ...
</EntityPageLayout>
);
```
That's it! But now, we need the Kubernetes Backend plugin for the frontend to
work.
## Adding Kubernetes Backend plugin
Navigate to `packages/backend` of your Backstage app, and install the
`@backstage/plugin-kubernetes-backend` package.
```bash
cd my-backstage-app/
cd packages/backend
yarn add @backstage/plugin-kubernetes-backend
```
Create a file called `kubernetes.ts` inside `packages/backend/src/plugins/` and
add the following
`kubernetes.ts`:
```typescript
import { createRouter } from '@backstage/plugin-kubernetes-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
```
And import the plugin to `packages/backend/src/index.ts`. There are three lines
of code you'll need to add, and they should be added near similar code in your
existing Backstage backend.
`index.ts`:
```typescript
import kubernetes from './plugins/kubernetes';
// ...
const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes'));
// ...
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
```
That's it! The Kubernetes frontend and backend have now been added to your
Backstage app.
## Running Backstage locally
Start the frontend and the backend app by
[running backstage locally](../../getting-started/running-backstage-locally.md).
## Configuration
After installing the plugins in the code, you'll need to then
[configure them](configuration.md).
+5 -1
View File
@@ -39,7 +39,11 @@
{
"type": "subcategory",
"label": "Kubernetes",
"ids": ["features/kubernetes/overview"]
"ids": [
"features/kubernetes/overview",
"features/kubernetes/installation",
"features/kubernetes/configuration"
]
},
{
"type": "subcategory",
-1
View File
@@ -37,7 +37,6 @@ export async function loadBackendConfig(options: Options): Promise<Config> {
/* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
const configs = await loadConfig({
env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development',
configRoot: paths.targetRoot,
configPaths: configOpts.map(opt => resolvePath(opt)),
});
@@ -8,6 +8,6 @@ spec:
profile:
displayName: Backstage
email: backstage@example.com
picture: https://example.com/groups/backstage.jpeg
picture: https://avatars.dicebear.com/api/identicon/backstage@example.com.svg?background=%23fff&margin=25
parent: infrastructure
children: [team-a, team-b]
@@ -8,7 +8,7 @@ spec:
profile:
displayName: ACME Corp
email: info@example.com
picture: https://example.com/logo.jpeg
picture: https://avatars.dicebear.com/api/identicon/info@example.com.svg?background=%23fff&margin=25
children: [infrastructure]
---
apiVersion: backstage.io/v1alpha1
@@ -8,7 +8,7 @@ spec:
profile:
# Intentional no displayName for testing
email: team-a@example.com
picture: https://example.com/groups/team-a.jpeg
picture: https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25
parent: backstage
children: []
---
@@ -20,7 +20,7 @@ spec:
profile:
# Intentional no displayName for testing
email: breanna-davison@example.com
picture: https://example.com/staff/breanna.jpeg
picture: https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg?background=%23fff
memberOf: [team-a]
---
apiVersion: backstage.io/v1alpha1
@@ -31,7 +31,7 @@ spec:
profile:
displayName: Janelle Dawe
email: janelle-dawe@example.com
picture: https://example.com/staff/janelle.jpeg
picture: https://avatars.dicebear.com/api/avataaars/janelle-dawe@example.com.svg?background=%23fff
memberOf: [team-a]
---
apiVersion: backstage.io/v1alpha1
@@ -42,7 +42,7 @@ spec:
profile:
displayName: Nigel Manning
email: nigel-manning@example.com
picture: https://example.com/staff/nigel.jpeg
picture: https://avatars.dicebear.com/api/avataaars/nigel-manning@example.com.svg?background=%23fff
memberOf: [team-a]
---
# This user is added as an example, to make it more easy for the "Guest"
@@ -56,5 +56,5 @@ spec:
profile:
displayName: Guest User
email: guest@example.com
picture: https://example.com/staff/the-ceos-dog.jpeg
picture: https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff
memberOf: [team-a]
@@ -8,7 +8,7 @@ spec:
profile:
displayName: Team B
email: team-b@example.com
picture: https://example.com/groups/team-b.jpeg
picture: https://avatars.dicebear.com/api/identicon/team-b@example.com.svg?background=%23fff&margin=25
parent: backstage
children: []
---
@@ -20,7 +20,7 @@ spec:
profile:
displayName: Amelia Park
email: amelia-park@example.com
picture: https://example.com/staff/amelia.jpeg
picture: https://avatars.dicebear.com/api/avataaars/amelia-park@example.com.svg?background=%23fff
memberOf: [team-b]
---
apiVersion: backstage.io/v1alpha1
@@ -31,7 +31,7 @@ spec:
profile:
displayName: Colette Brock
email: colette-brock@example.com
picture: https://example.com/staff/colette.jpeg
picture: https://avatars.dicebear.com/api/avataaars/colette-brock@example.com.svg?background=%23fff
memberOf: [team-b]
---
apiVersion: backstage.io/v1alpha1
@@ -42,7 +42,7 @@ spec:
profile:
displayName: Jenny Doe
email: jenny-doe@example.com
picture: https://example.com/staff/jenny.jpeg
picture: https://avatars.dicebear.com/api/avataaars/jenny-doe@example.com.svg?background=%23fff
memberOf: [team-b]
---
apiVersion: backstage.io/v1alpha1
@@ -53,7 +53,7 @@ spec:
profile:
displayName: Jonathon Page
email: jonathon-page@example.com
picture: https://example.com/staff/jonathon.jpeg
picture: https://avatars.dicebear.com/api/avataaars/jonathon-page@example.com.svg?background=%23fff
memberOf: [team-b]
---
apiVersion: backstage.io/v1alpha1
@@ -64,5 +64,5 @@ spec:
profile:
displayName: Justine Barrow
email: justine-barrow@example.com
picture: https://example.com/staff/justine.jpeg
picture: https://avatars.dicebear.com/api/avataaars/justine-barrow@example.com.svg?background=%23fff
memberOf: [team-b]
@@ -8,7 +8,7 @@ spec:
profile:
displayName: Team C
email: team-c@example.com
picture: https://example.com/groups/team-c.jpeg
picture: https://avatars.dicebear.com/api/identicon/team-c@example.com.svg?background=%23fff&margin=25
parent: boxoffice
children: []
---
@@ -20,7 +20,7 @@ spec:
profile:
displayName: Calum Leavy
email: calum-leavy@example.com
picture: https://example.com/staff/calum.jpeg
picture: https://avatars.dicebear.com/api/avataaars/calum-leavy@example.com.svg?background=%23fff
memberOf: [team-c]
---
apiVersion: backstage.io/v1alpha1
@@ -31,7 +31,7 @@ spec:
profile:
displayName: Frank Tiernan
email: frank-tiernan@example.com
picture: https://example.com/staff/frank.jpeg
picture: https://avatars.dicebear.com/api/avataaars/frank-tiernan@example.com.svg?background=%23fff
memberOf: [team-c]
---
apiVersion: backstage.io/v1alpha1
@@ -42,7 +42,7 @@ spec:
profile:
displayName: Peadar MacMahon
email: peadar-macmahon@example.com
picture: https://example.com/staff/peadar.jpeg
picture: https://avatars.dicebear.com/api/avataaars/peadar-macmahon@example.com.svg?background=%23fff
memberOf: [team-c]
---
apiVersion: backstage.io/v1alpha1
@@ -53,7 +53,7 @@ spec:
profile:
displayName: Sarah Gilroy
email: sarah-gilroy@example.com
picture: https://example.com/staff/sarah.jpeg
picture: https://avatars.dicebear.com/api/avataaars/sarah-gilroy@example.com.svg?background=%23fff
memberOf: [team-c]
---
apiVersion: backstage.io/v1alpha1
@@ -64,5 +64,5 @@ spec:
profile:
displayName: Tara MacGovern
email: tara-macgovern@example.com
picture: https://example.com/staff/tara.jpeg
picture: https://avatars.dicebear.com/api/avataaars/tara-macgovern@example.com.svg?background=%23fff
memberOf: [team-c]
@@ -8,7 +8,7 @@ spec:
profile:
displayName: Team D
email: team-d@example.com
picture: https://example.com/groups/team-d.jpeg
picture: https://avatars.dicebear.com/api/identicon/team-d@example.com.svg?background=%23fff&margin=25
parent: boxoffice
children: []
---
@@ -20,7 +20,7 @@ spec:
profile:
displayName: Eva MacDowell
email: eva-macdowell@example.com
picture: https://example.com/staff/eva.jpeg
picture: https://avatars.dicebear.com/api/avataaars/eva-macdowell@example.com.svg?background=%23fff
memberOf: [team-d]
---
apiVersion: backstage.io/v1alpha1
@@ -31,5 +31,5 @@ spec:
profile:
displayName: Lucy Sheehan
email: lucy-sheehan@example.com
picture: https://example.com/staff/lucy.jpeg
picture: https://avatars.dicebear.com/api/avataaars/lucy-sheehan@example.com.svg?background=%23fff
memberOf: [team-d]
+1 -1
View File
@@ -125,7 +125,7 @@
"@types/html-webpack-plugin": "^3.2.2",
"@types/http-proxy": "^1.17.4",
"@types/inquirer": "^7.3.1",
"@types/mini-css-extract-plugin": "^0.9.1",
"@types/mini-css-extract-plugin": "^1.2.2",
"@types/mock-fs": "^4.13.0",
"@types/node": "^13.7.2",
"@types/react-dev-utils": "^9.0.4",
@@ -14,8 +14,12 @@
* limitations under the License.
*/
import * as os from 'os';
import { resolve as resolvePath } from 'path';
import { createIncludeTransform } from './include';
const root = os.platform() === 'win32' ? 'C:\\' : '/';
const env = jest.fn(async (name: string) => {
return ({
SECRET: 'my-secret',
@@ -24,11 +28,11 @@ const env = jest.fn(async (name: string) => {
const readFile = jest.fn(async (path: string) => {
const content = ({
'/my-secret': 'secret',
'/my-data.json': '{"a":{"b":{"c":42}}}',
'/my-data.yaml': 'some:\n yaml:\n key: 7',
'/my-data.yml': 'different: { key: hello }',
'/invalid.yaml': 'foo: [}',
[resolvePath(root, 'my-secret')]: 'secret',
[resolvePath(root, 'my-data.json')]: '{"a":{"b":{"c":42}}}',
[resolvePath(root, 'my-data.yaml')]: 'some:\n yaml:\n key: 7',
[resolvePath(root, 'my-data.yml')]: 'different: { key: hello }',
[resolvePath(root, 'invalid.yaml')]: 'foo: [}',
} as { [key: string]: string })[path];
if (!content) {
@@ -41,56 +45,58 @@ const includeTransform = createIncludeTransform(env, readFile);
describe('includeTransform', () => {
it('should not transform unknown values', async () => {
await expect(includeTransform('foo', '/')).resolves.toEqual({
await expect(includeTransform('foo', root)).resolves.toEqual({
applied: false,
});
await expect(includeTransform([1], '/')).resolves.toEqual({
await expect(includeTransform([1], root)).resolves.toEqual({
applied: false,
});
await expect(includeTransform(1, '/')).resolves.toEqual({ applied: false });
await expect(includeTransform({ x: 'y' }, '/')).resolves.toEqual({
await expect(includeTransform(1, root)).resolves.toEqual({
applied: false,
});
await expect(includeTransform(null, '/')).resolves.toEqual({
await expect(includeTransform({ x: 'y' }, root)).resolves.toEqual({
applied: false,
});
await expect(includeTransform(null, root)).resolves.toEqual({
applied: false,
});
});
it('should include text files', async () => {
await expect(
includeTransform({ $file: 'my-secret' }, '/'),
includeTransform({ $file: 'my-secret' }, root),
).resolves.toEqual({ applied: true, value: 'secret' });
await expect(includeTransform({ $file: 'no-secret' }, '/')).rejects.toThrow(
'File not found!',
);
await expect(
includeTransform({ $file: 'no-secret' }, root),
).rejects.toThrow('File not found!');
});
it('should include env vars', async () => {
await expect(includeTransform({ $env: 'SECRET' }, '/')).resolves.toEqual({
await expect(includeTransform({ $env: 'SECRET' }, root)).resolves.toEqual({
applied: true,
value: 'my-secret',
});
await expect(includeTransform({ $env: 'NO_SECRET' }, '/')).resolves.toEqual(
{
applied: true,
value: undefined,
},
);
await expect(
includeTransform({ $env: 'NO_SECRET' }, root),
).resolves.toEqual({
applied: true,
value: undefined,
});
});
it('should include config files', async () => {
// New format with path in fragment
await expect(
includeTransform({ $include: 'my-data.json#a.b.c' }, '/'),
includeTransform({ $include: 'my-data.json#a.b.c' }, root),
).resolves.toEqual({ applied: true, value: 42 });
await expect(
includeTransform({ $include: 'my-data.json#a.b' }, '/'),
includeTransform({ $include: 'my-data.json#a.b' }, root),
).resolves.toEqual({ applied: true, value: { c: 42 } });
await expect(
includeTransform({ $include: 'my-data.yaml#some.yaml.key' }, '/'),
includeTransform({ $include: 'my-data.yaml#some.yaml.key' }, root),
).resolves.toEqual({ applied: true, value: 7 });
await expect(
includeTransform({ $include: 'my-data.yaml' }, '/'),
includeTransform({ $include: 'my-data.yaml' }, root),
).resolves.toEqual({
applied: true,
value: {
@@ -98,7 +104,7 @@ describe('includeTransform', () => {
},
});
await expect(
includeTransform({ $include: 'my-data.yaml#' }, '/'),
includeTransform({ $include: 'my-data.yaml#' }, root),
).resolves.toEqual({
applied: true,
value: {
@@ -106,26 +112,26 @@ describe('includeTransform', () => {
},
});
await expect(
includeTransform({ $include: 'my-data.yml#different.key' }, '/'),
includeTransform({ $include: 'my-data.yml#different.key' }, root),
).resolves.toEqual({ applied: true, value: 'hello' });
});
it('should reject invalid includes', async () => {
await expect(
includeTransform({ $include: 'no-parser.js' }, '/'),
includeTransform({ $include: 'no-parser.js' }, root),
).rejects.toThrow(
'no configuration parser available for included file no-parser.js',
);
await expect(
includeTransform({ $include: 'no-data.yml#different.key' }, '/'),
includeTransform({ $include: 'no-data.yml#different.key' }, root),
).rejects.toThrow('File not found!');
await expect(
includeTransform({ $include: 'my-data.yml#missing.key' }, '/'),
includeTransform({ $include: 'my-data.yml#missing.key' }, root),
).rejects.toThrow(
"value at 'missing' in included file my-data.yml is not an object",
);
await expect(
includeTransform({ $include: 'invalid.yaml' }, '/'),
includeTransform({ $include: 'invalid.yaml' }, root),
).rejects.toThrow(
'failed to parse included file invalid.yaml, YAMLSyntaxError: Flow sequence contains an unexpected }',
);
@@ -20,7 +20,7 @@ import { Gauge } from './Gauge';
const containerStyle = { width: 300 };
export default {
title: 'Feedback/Gauge',
title: 'Data Display/Gauge',
component: Gauge,
};
@@ -20,7 +20,7 @@ import { LinearGauge } from './LinearGauge';
const containerStyle = { width: 300 };
export default {
title: 'Feedback/LinearGauge',
title: 'Data Display/LinearGauge',
component: LinearGauge,
};
@@ -27,7 +27,7 @@ import { Table } from '../Table';
import { InfoCard } from '../../layout/InfoCard';
export default {
title: 'Feedback/Status',
title: 'Data Display/Status',
component: StatusOK,
};
@@ -32,6 +32,7 @@ import {
CatalogProcessorEntityResult,
CatalogProcessorErrorResult,
CatalogProcessorLocationResult,
CatalogProcessorParser,
CatalogProcessorResult,
} from './processors/types';
import { LocationReader, ReadLocationResult } from './types';
@@ -41,6 +42,7 @@ const MAX_DEPTH = 10;
type Options = {
reader: UrlReader;
parser: CatalogProcessorParser;
logger: Logger;
config: Config;
processors: CatalogProcessor[];
@@ -137,7 +139,6 @@ export class LocationReaders implements LocationReader {
if (emitResult.type === 'relation') {
throw new Error('readLocation may not emit entity relations');
}
emit(emitResult);
};
@@ -149,6 +150,7 @@ export class LocationReaders implements LocationReader {
item.location,
item.optional,
validatedEmit,
this.options.parser,
)
) {
return;
@@ -25,6 +25,7 @@ import {
CatalogProcessorErrorResult,
CatalogProcessorResult,
} from './types';
import { defaultEntityDataParser } from './util/parse';
describe('UrlReaderProcessor', () => {
const mockApiOrigin = 'http://localhost';
@@ -52,7 +53,7 @@ describe('UrlReaderProcessor', () => {
);
const generated = (await new Promise<CatalogProcessorResult>(emit =>
processor.readLocation(spec, false, emit),
processor.readLocation(spec, false, emit, defaultEntityDataParser),
)) as CatalogProcessorEntityResult;
expect(generated.type).toBe('entity');
@@ -81,7 +82,7 @@ describe('UrlReaderProcessor', () => {
);
const generated = (await new Promise<CatalogProcessorResult>(emit =>
processor.readLocation(spec, false, emit),
processor.readLocation(spec, false, emit, defaultEntityDataParser),
)) as CatalogProcessorErrorResult;
expect(generated.type).toBe('error');
@@ -18,8 +18,11 @@ import { UrlReader } from '@backstage/backend-common';
import { LocationSpec } from '@backstage/catalog-model';
import { Logger } from 'winston';
import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import { parseEntityYaml } from './util/parse';
import {
CatalogProcessor,
CatalogProcessorEmit,
CatalogProcessorParser,
} from './types';
// TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this
// can be removed in a bit
@@ -43,6 +46,7 @@ export class UrlReaderProcessor implements CatalogProcessor {
location: LocationSpec,
optional: boolean,
emit: CatalogProcessorEmit,
parser: CatalogProcessorParser,
): Promise<boolean> {
if (deprecatedTypes.includes(location.type)) {
// TODO(Rugvip): Remove this warning a month or two into 2021, and remove support for the deprecated types.
@@ -57,7 +61,7 @@ export class UrlReaderProcessor implements CatalogProcessor {
try {
const data = await this.options.reader.read(location.target);
for (const parseResult of parseEntityYaml(data, location)) {
for await (const parseResult of parser({ data, location })) {
emit(parseResult);
}
} catch (error) {
@@ -27,12 +27,15 @@ export type CatalogProcessor = {
* @param location The location to read
* @param optional Whether a missing target should trigger an error
* @param emit A sink for items resulting from the read
* @param parser A parser, that is able to take the raw catalog descriptor
* data and turn it into the actual result pieces.
* @returns True if handled by this processor, false otherwise
*/
readLocation?(
location: LocationSpec,
optional: boolean,
emit: CatalogProcessorEmit,
parser: CatalogProcessorParser,
): Promise<boolean>;
/**
@@ -100,6 +103,16 @@ export type CatalogProcessor = {
): Promise<void>;
};
/**
* A parser, that is able to take the raw catalog descriptor data and turn it
* into the actual result pieces. The default implementation performs a YAML
* document parsing.
*/
export type CatalogProcessorParser = (options: {
data: Buffer;
location: LocationSpec;
}) => AsyncIterable<CatalogProcessorResult>;
export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void;
export type CatalogProcessorLocationResult = {
@@ -18,7 +18,7 @@ import { Entity, LocationSpec } from '@backstage/catalog-model';
import lodash from 'lodash';
import yaml from 'yaml';
import * as result from '../results';
import { CatalogProcessorResult } from '../types';
import { CatalogProcessorParser, CatalogProcessorResult } from '../types';
export function* parseEntityYaml(
data: Buffer,
@@ -50,3 +50,12 @@ export function* parseEntityYaml(
}
}
}
export const defaultEntityDataParser: CatalogProcessorParser = async function* defaultEntityDataParser({
data,
location,
}) {
for (const e of parseEntityYaml(data, location)) {
yield e;
}
};
@@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config';
import Knex from 'knex';
import yaml from 'yaml';
import { DatabaseManager } from '../database';
import { CatalogProcessorParser } from '../ingestion';
import * as result from '../ingestion/processors/results';
import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder';
@@ -209,4 +210,26 @@ describe('CatalogBuilder', () => {
}),
]);
});
it('setEntityDataParser works', async () => {
const mockParser: CatalogProcessorParser = jest
.fn()
.mockImplementation(() => {});
const builder = new CatalogBuilder(env)
.setEntityDataParser(mockParser)
.replaceProcessors([
{
async readLocation(_location, _optional, _emit, parser) {
expect(parser).toBe(mockParser);
return true;
},
},
]);
const { higherOrderOperation } = await builder.build();
await higherOrderOperation.addLocation({ type: 'x', target: 'y' });
expect.assertions(1);
});
});
@@ -39,6 +39,7 @@ import {
AnnotateLocationEntityProcessor,
BuiltinKindsEntityProcessor,
CatalogProcessor,
CatalogProcessorParser,
CodeOwnersProcessor,
FileReaderProcessor,
GithubOrgReaderProcessor,
@@ -60,6 +61,7 @@ import {
textPlaceholderResolver,
yamlPlaceholderResolver,
} from '../ingestion/processors/PlaceholderProcessor';
import { defaultEntityDataParser } from '../ingestion/processors/util/parse';
import { LocationAnalyzer } from '../ingestion/types';
export type CatalogEnvironment = {
@@ -96,6 +98,7 @@ export class CatalogBuilder {
private fieldFormatValidators: Partial<Validators>;
private processors: CatalogProcessor[];
private processorsReplace: boolean;
private parser: CatalogProcessorParser | undefined;
constructor(env: CatalogEnvironment) {
this.env = env;
@@ -105,6 +108,7 @@ export class CatalogBuilder {
this.fieldFormatValidators = {};
this.processors = [];
this.processorsReplace = false;
this.parser = undefined;
}
/**
@@ -197,6 +201,20 @@ export class CatalogBuilder {
return this;
}
/**
* Sets up the catalog to use a custom parser for entity data.
*
* This is the function that gets called immediately after some raw entity
* specification data has been read from a remote source, and needs to be
* parsed and emitted as structured data.
*
* @param parser The custom parser
*/
setEntityDataParser(parser: CatalogProcessorParser): CatalogBuilder {
this.parser = parser;
return this;
}
/**
* Wires up and returns all of the component parts of the catalog
*/
@@ -211,9 +229,11 @@ export class CatalogBuilder {
const policy = this.buildEntityPolicy();
const processors = this.buildProcessors();
const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config);
const parser = this.parser || defaultEntityDataParser;
const locationReader = new LocationReaders({
...this.env,
parser,
processors,
rulesEnforcer,
policy,
@@ -21,7 +21,7 @@ import {
} from '@backstage/catalog-model';
import { Chip, Grid, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
import { EntityRefLink } from '../EntityRefLink';
import { EntityRefLinks } from '../EntityRefLink';
import { getEntityRelations } from '../getEntityRelations';
import { AboutField } from './AboutField';
@@ -41,17 +41,17 @@ export const AboutContent = ({ entity }: Props) => {
const isDomain = entity.kind.toLowerCase() === 'domain';
const isResource = entity.kind.toLowerCase() === 'resource';
const isComponent = entity.kind.toLowerCase() === 'component';
const [partOfSystemRelation] = getEntityRelations(entity, RELATION_PART_OF, {
const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'system',
});
const [partOfComponentRelation] = getEntityRelations(
const partOfComponentRelations = getEntityRelations(
entity,
RELATION_PART_OF,
{
kind: 'component',
},
);
const [partOfDomainRelation] = getEntityRelations(entity, RELATION_PART_OF, {
const partOfDomainRelations = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'domain',
});
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
@@ -64,12 +64,7 @@ export const AboutContent = ({ entity }: Props) => {
</Typography>
</AboutField>
<AboutField label="Owner" gridSizes={{ xs: 12, sm: 6, lg: 4 }}>
{ownedByRelations.map((t, i) => (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink entityRef={t} defaultKind="group" />
</React.Fragment>
))}
<EntityRefLinks entityRefs={ownedByRelations} />
</AboutField>
{isSystem && (
<AboutField
@@ -77,12 +72,10 @@ export const AboutContent = ({ entity }: Props) => {
value="No Domain"
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{partOfDomainRelation && (
<EntityRefLink
entityRef={partOfDomainRelation}
defaultKind="domain"
/>
)}
<EntityRefLinks
entityRefs={partOfDomainRelations}
defaultKind="domain"
/>
</AboutField>
)}
{!isSystem && !isDomain && (
@@ -91,22 +84,20 @@ export const AboutContent = ({ entity }: Props) => {
value="No System"
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{partOfSystemRelation && (
<EntityRefLink
entityRef={partOfSystemRelation}
defaultKind="system"
/>
)}
<EntityRefLinks
entityRefs={partOfSystemRelations}
defaultKind="system"
/>
</AboutField>
)}
{isComponent && partOfComponentRelation && (
{isComponent && partOfComponentRelations.length > 0 && (
<AboutField
label="Parent Component"
value="No Parent Component"
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
<EntityRefLink
entityRef={partOfComponentRelation}
<EntityRefLinks
entityRefs={partOfComponentRelations}
defaultKind="component"
/>
</AboutField>
@@ -20,17 +20,19 @@ import {
RELATION_PART_OF,
} from '@backstage/catalog-model';
import { Table, TableColumn, TableProps } from '@backstage/core';
import { Chip, Link } from '@material-ui/core';
import { Chip } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import OpenInNew from '@material-ui/icons/OpenInNew';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntities';
import { entityRoute, entityRouteParams } from '../../routes';
import { createEditLink } from '../createEditLink';
import { EntityRefLink, formatEntityRefTitle } from '../EntityRefLink';
import {
EntityRefLink,
EntityRefLinks,
formatEntityRefTitle,
} from '../EntityRefLink';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
@@ -40,7 +42,7 @@ import { getEntityRelations } from '../getEntityRelations';
type EntityRow = Entity & {
row: {
partOfSystemRelationTitle?: string;
partOfSystemRelation?: EntityName;
partOfSystemRelations: EntityName[];
ownedByRelationsTitle?: string;
ownedByRelations: EntityName[];
};
@@ -52,43 +54,27 @@ const columns: TableColumn<EntityRow>[] = [
field: 'metadata.name',
highlight: true,
render: entity => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
...entityRouteParams(entity),
selectedTabId: 'overview',
})}
>
{entity.metadata.name}
</Link>
<EntityRefLink entityRef={entity}>{entity.metadata.name}</EntityRefLink>
),
},
{
title: 'System',
field: 'row.partOfSystemRelationTitle',
render: entity => (
<>
{entity.row.partOfSystemRelation && (
<EntityRefLink
entityRef={entity.row.partOfSystemRelation}
defaultKind="system"
/>
)}
</>
<EntityRefLinks
entityRefs={entity.row.partOfSystemRelations}
defaultKind="system"
/>
),
},
{
title: 'Owner',
field: 'row.ownedByRelationsTitle',
render: entity => (
<>
{entity.row.ownedByRelations.map((t, i) => (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink entityRef={t} defaultKind="group" />
</React.Fragment>
))}
</>
<EntityRefLinks
entityRefs={entity.row.ownedByRelations}
defaultKind="group"
/>
),
},
{
@@ -182,7 +168,7 @@ export const CatalogTable = ({
];
const rows = entities.map(e => {
const [partOfSystemRelation] = getEntityRelations(e, RELATION_PART_OF, {
const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, {
kind: 'system',
});
const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY);
@@ -194,12 +180,13 @@ export const CatalogTable = ({
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
.join(', '),
ownedByRelations,
partOfSystemRelationTitle: partOfSystemRelation
? formatEntityRefTitle(partOfSystemRelation, {
defaultKind: 'system',
})
: undefined,
partOfSystemRelation,
partOfSystemRelationTitle:
partOfSystemRelations.length > 0
? formatEntityRefTitle(partOfSystemRelations[0], {
defaultKind: 'system',
})
: undefined,
partOfSystemRelations,
},
};
});
@@ -0,0 +1,66 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router';
import { EntityRefLinks } from './EntityRefLinks';
describe('<EntityRefLinks />', () => {
it('renders a single link', () => {
const entityNames = [
{
kind: 'Component',
namespace: 'default',
name: 'software',
},
];
const { getByText } = render(<EntityRefLinks entityRefs={entityNames} />, {
wrapper: MemoryRouter,
});
expect(getByText('component:software')).toHaveAttribute(
'href',
'/catalog/default/component/software',
);
});
it('renders multiple links', () => {
const entityNames = [
{
kind: 'Component',
namespace: 'default',
name: 'software',
},
{
kind: 'API',
namespace: 'default',
name: 'interface',
},
];
const { getByText } = render(<EntityRefLinks entityRefs={entityNames} />, {
wrapper: MemoryRouter,
});
expect(getByText(',')).toBeInTheDocument();
expect(getByText('component:software')).toHaveAttribute(
'href',
'/catalog/default/component/software',
);
expect(getByText('api:interface')).toHaveAttribute(
'href',
'/catalog/default/api/interface',
);
});
});
@@ -0,0 +1,40 @@
/*
* 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 { Entity, EntityName } from '@backstage/catalog-model';
import React from 'react';
import { EntityRefLink } from './EntityRefLink';
type EntityRefLinksProps = {
entityRefs: (Entity | EntityName)[];
defaultKind?: string;
};
// TODO: Move into a shared helper package
export const EntityRefLinks = ({
entityRefs,
defaultKind,
}: EntityRefLinksProps) => {
return (
<>
{entityRefs.map((r, i) => (
<React.Fragment key={i}>
{i > 0 && ', '}
<EntityRefLink entityRef={r} defaultKind={defaultKind} />
</React.Fragment>
))}
</>
);
};
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { EntityRefLink } from './EntityRefLink';
export { EntityRefLinks } from './EntityRefLinks';
export { formatEntityRefTitle } from './format';
+4 -2
View File
@@ -23,6 +23,8 @@ Example:
```yaml
kafka:
clientId: backstage
brokers:
- localhost:9092
clusters:
name: prod
brokers:
- localhost:9092
```
+17 -15
View File
@@ -19,20 +19,22 @@ export interface Config {
* Client ID used to Backstage uses to identify when connecting to the Kafka cluster.
*/
clientId: string;
/**
* List of brokers in the Kafka cluster to connect to.
*/
brokers: string[];
/**
* Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.
* See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options
*/
ssl?: {
ca: string[];
/** @visibility secret */
key: string;
cert: string;
};
clusters: {
name: string;
/**
* List of brokers in the Kafka cluster to connect to.
*/
brokers: string[];
/**
* Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.
* See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options
*/
ssl?: {
ca: string[];
/** @visibility secret */
key: string;
cert: string;
};
}[];
};
}
@@ -0,0 +1,45 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { getClusterDetails } from './ClusterReader';
describe('getClusterDetails', () => {
it('empty clusters return empty cluster details', async () => {
const config = new ConfigReader({ clusters: [] });
const result = getClusterDetails(config.getConfigArray('clusters'));
expect(result).toStrictEqual([]);
});
it('two clusters return two cluster details', async () => {
const config = new ConfigReader({
clusters: [
{ name: 'cluster1', brokers: ['a', 'b'] },
{ name: 'cluster2', brokers: ['d'] },
],
});
const result = getClusterDetails(config.getConfigArray('clusters'));
expect(result).toStrictEqual([
{ name: 'cluster1', brokers: ['a', 'b'] },
{ name: 'cluster2', brokers: ['d'] },
]);
});
});
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { ConnectionOptions } from 'tls';
import { ClusterDetails } from '../types/types';
export function getClusterDetails(config: Config[]): ClusterDetails[] {
return config.map(clusterConfig => {
const clusterDetails = {
name: clusterConfig.getString('name'),
brokers: clusterConfig.getStringArray('brokers'),
};
const sslConfig = clusterConfig.getOptional('kafka.ssl');
if (sslConfig) {
return {
...clusterDetails,
ssl: sslConfig as ConnectionOptions,
};
}
return clusterDetails;
});
}
@@ -16,22 +16,34 @@
import request from 'supertest';
import express from 'express';
import { makeRouter } from './router';
import { makeRouter, ClusterApi } from './router';
import { getVoidLogger } from '@backstage/backend-common';
import { KafkaApi } from './KafkaApi';
import { when } from 'jest-when';
describe('router', () => {
let app: express.Express;
let kafkaApi: jest.Mocked<KafkaApi>;
let apis: ClusterApi[];
let devKafkaApi: jest.Mocked<KafkaApi>;
let prodKafkaApi: jest.Mocked<KafkaApi>;
beforeAll(async () => {
kafkaApi = {
devKafkaApi = {
fetchTopicOffsets: jest.fn(),
fetchGroupOffsets: jest.fn(),
};
const router = makeRouter(getVoidLogger(), kafkaApi);
prodKafkaApi = {
fetchTopicOffsets: jest.fn(),
fetchGroupOffsets: jest.fn(),
};
apis = [
{ name: 'dev', api: devKafkaApi },
{ name: 'prod', api: prodKafkaApi },
];
const router = makeRouter(getVoidLogger(), apis);
app = express().use(router);
});
@@ -39,7 +51,7 @@ describe('router', () => {
jest.resetAllMocks();
});
describe('get /consumer/:consumerId/offsets', () => {
describe('get /consumers/clusterId/:consumerId/offsets', () => {
it('returns topic and group offsets', async () => {
const topic1Offsets = [
{ id: 1, offset: '500' },
@@ -60,15 +72,17 @@ describe('router', () => {
partitions: [{ id: 1, offset: '456' }],
},
];
when(kafkaApi.fetchTopicOffsets)
when(prodKafkaApi.fetchTopicOffsets)
.calledWith('topic1')
.mockResolvedValue(topic1Offsets);
when(kafkaApi.fetchTopicOffsets)
when(prodKafkaApi.fetchTopicOffsets)
.calledWith('topic2')
.mockResolvedValue(topic2Offsets);
kafkaApi.fetchGroupOffsets.mockResolvedValue(groupOffsets);
when(prodKafkaApi.fetchGroupOffsets)
.calledWith('hey')
.mockResolvedValue(groupOffsets);
const response = await request(app).get('/consumer/hey/offsets');
const response = await request(app).get('/consumers/prod/hey/offsets');
expect(response.status).toEqual(200);
expect(response.body.consumerId).toEqual('hey');
@@ -98,11 +112,70 @@ describe('router', () => {
});
it('handles internal error correctly', async () => {
kafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no'));
prodKafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no'));
const response = await request(app).get('/consumer/hey/offsets');
const response = await request(app).get('/consumers/prod/hey/offsets');
expect(response.status).toEqual(500);
});
it('uses correct kafka cluster', async () => {
const topic1ProdOffsets = [{ id: 1, offset: '500' }];
const topic1DevOffsets = [{ id: 1, offset: '1234' }];
const groupProdOffsets = [
{
topic: 'topic1',
partitions: [{ id: 1, offset: '100' }],
},
];
const groupDevOffsets = [
{
topic: 'topic1',
partitions: [{ id: 1, offset: '567' }],
},
];
when(prodKafkaApi.fetchTopicOffsets)
.calledWith('topic1')
.mockResolvedValue(topic1ProdOffsets);
when(prodKafkaApi.fetchGroupOffsets)
.calledWith('hey')
.mockResolvedValue(groupProdOffsets);
when(devKafkaApi.fetchTopicOffsets)
.calledWith('topic1')
.mockResolvedValue(topic1DevOffsets);
when(devKafkaApi.fetchGroupOffsets)
.calledWith('hey')
.mockResolvedValue(groupDevOffsets);
const prodResponse = await request(app).get(
'/consumers/prod/hey/offsets',
);
const devResponse = await request(app).get('/consumers/dev/hey/offsets');
expect(prodResponse.status).toEqual(200);
expect(prodResponse.body.consumerId).toEqual('hey');
expect(new Set(prodResponse.body.offsets)).toStrictEqual(
new Set([
{
topic: 'topic1',
partitionId: 1,
groupOffset: '100',
topicOffset: '500',
},
]),
);
expect(devResponse.status).toEqual(200);
expect(devResponse.body.consumerId).toEqual('hey');
expect(new Set(devResponse.body.offsets)).toStrictEqual(
new Set([
{
topic: 'topic1',
partitionId: 1,
groupOffset: '567',
topicOffset: '1234',
},
]),
);
});
});
});
+26 -11
View File
@@ -20,31 +20,43 @@ import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { KafkaApi, KafkaJsApiImpl } from './KafkaApi';
import _ from 'lodash';
import { ConnectionOptions } from 'tls';
import { getClusterDetails } from '../config/ClusterReader';
export interface RouterOptions {
logger: Logger;
config: Config;
}
export interface ClusterApi {
name: string;
api: KafkaApi;
}
export const makeRouter = (
logger: Logger,
kafkaApi: KafkaApi,
kafkaApis: ClusterApi[],
): express.Router => {
const router = Router();
router.use(express.json());
router.get('/consumer/:consumerId/offsets', async (req, res) => {
const kafkaApiByClusterName = _.keyBy(kafkaApis, item => item.name);
router.get('/consumers/:clusterId/:consumerId/offsets', async (req, res) => {
const clusterId = req.params.clusterId;
const consumerId = req.params.consumerId;
logger.debug(`Fetch consumer group ${consumerId} offsets`);
logger.info(
`Fetch consumer group ${consumerId} offsets from cluster ${clusterId}`,
);
const groupOffsets = await kafkaApi.fetchGroupOffsets(consumerId);
const kafkaApi = kafkaApiByClusterName[clusterId];
const groupOffsets = await kafkaApi.api.fetchGroupOffsets(consumerId);
const groupWithTopicOffsets = await Promise.all(
groupOffsets.map(async ({ topic, partitions }) => {
const topicOffsets = _.keyBy(
await kafkaApi.fetchTopicOffsets(topic),
await kafkaApi.api.fetchTopicOffsets(topic),
partition => partition.id,
);
@@ -70,12 +82,15 @@ export async function createRouter(
logger.info('Initializing Kafka backend');
const clientId = options.config.getString('kafka.clientId');
const brokers = options.config.getStringArray('kafka.brokers');
const sslConfig = options.config.getOptional('kafka.ssl');
const ssl = sslConfig ? (sslConfig as ConnectionOptions) : undefined;
const clusters = getClusterDetails(
options.config.getConfigArray('kafka.clusters'),
);
const kafkaApi = new KafkaJsApiImpl({ clientId, brokers, logger, ssl });
const kafkaApis = clusters.map(cluster => ({
name: cluster.name,
api: new KafkaJsApiImpl({ clientId, logger, ...cluster }),
}));
return makeRouter(logger, kafkaApi);
return makeRouter(logger, kafkaApis);
}
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { ConnectionOptions } from 'tls';
export interface ClusterDetails {
name: string;
brokers: string[];
ssl?: ConnectionOptions;
}
+5 -3
View File
@@ -65,8 +65,10 @@ import { Router as KafkaRouter } from '@backstage/plugin-kafka';
```yaml
kafka:
clientId: backstage
brokers:
- localhost:9092
clusters:
- name: cluster-name
brokers:
- localhost:9092
```
6. Add `kafka.apache.org/consumer-groups` annotation to your services:
@@ -77,7 +79,7 @@ kind: Component
metadata:
# ...
annotations:
kafka.apache.org/consumer-groups: consumer-group-name
kafka.apache.org/consumer-groups: cluster-name/consumer-group-name
spec:
type: service
```
+4 -1
View File
@@ -43,8 +43,11 @@ export class KafkaBackendClient implements KafkaApi {
}
async getConsumerGroupOffsets(
clusterId: string,
consumerGroup: string,
): Promise<ConsumerGroupOffsetsResponse> {
return await this.internalGet(`/consumer/${consumerGroup}/offsets`);
return await this.internalGet(
`/consumers/${clusterId}/${consumerGroup}/offsets`,
);
}
}
+1
View File
@@ -34,6 +34,7 @@ export type ConsumerGroupOffsetsResponse = {
export interface KafkaApi {
getConsumerGroupOffsets(
clusterId: string,
consumerGroup: string,
): Promise<ConsumerGroupOffsetsResponse>;
}
@@ -27,6 +27,7 @@ describe('ConsumerGroupOffsets', () => {
const rendered = await renderInTestApp(
<ConsumerGroupOffsets
consumerGroup={consumerGroupOffsets.consumerId}
clusterId="prod"
topics={consumerGroupOffsets.offsets}
loading={false}
retry={() => {}}
@@ -15,7 +15,7 @@
*/
import { Table, TableColumn } from '@backstage/core';
import { Box, Typography } from '@material-ui/core';
import { Box, Grid, Typography } from '@material-ui/core';
import RetryIcon from '@material-ui/icons/Replay';
import React from 'react';
import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity';
@@ -62,6 +62,7 @@ const generatedColumns: TableColumn[] = [
type Props = {
loading: boolean;
retry: () => void;
clusterId: string;
consumerGroup: string;
topics?: TopicPartitionInfo[];
};
@@ -69,6 +70,7 @@ type Props = {
export const ConsumerGroupOffsets = ({
loading,
topics,
clusterId,
consumerGroup,
retry,
}: Props) => {
@@ -87,7 +89,7 @@ export const ConsumerGroupOffsets = ({
title={
<Box display="flex" alignItems="center">
<Typography variant="h6">
Consumed Topics for {consumerGroup}
Consumed Topics for {consumerGroup} ({clusterId})
</Typography>
</Box>
}
@@ -98,6 +100,15 @@ export const ConsumerGroupOffsets = ({
export const KafkaTopicsForConsumer = () => {
const [tableProps, { retry }] = useConsumerGroupsOffsetsForEntity();
return <ConsumerGroupOffsets {...tableProps} retry={retry} />;
return (
<Grid>
{tableProps.consumerGroupsTopics?.map(consumerGroup => (
<ConsumerGroupOffsets
{...consumerGroup}
loading={tableProps.loading}
retry={retry}
/>
))}
</Grid>
);
};
@@ -20,21 +20,7 @@ import { EntityContext } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
describe('useConsumerGroupOffsets', () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups': 'consumer',
},
},
spec: {
owner: 'guest',
type: 'Website',
lifecycle: 'development',
},
};
let entity: Entity;
const wrapper = ({ children }: PropsWithChildren<{}>) => {
return (
@@ -46,8 +32,107 @@ describe('useConsumerGroupOffsets', () => {
const subject = () => renderHook(useConsumerGroupsForEntity, { wrapper });
it('returns correct consumer group for annotation', async () => {
it('returns correct cluster and consumer group for annotation', async () => {
entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups': 'prod/consumer',
},
},
spec: {
owner: 'guest',
type: 'Website',
lifecycle: 'development',
},
};
const { result } = subject();
expect(result.current).toBe('consumer');
expect(result.current).toStrictEqual([
{
clusterId: 'prod',
consumerGroup: 'consumer',
},
]);
});
it('returns correct cluster and consumer group for multiple consumers', async () => {
entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups':
'prod/consumer,dev/another-consumer',
},
},
spec: {
owner: 'guest',
type: 'Website',
lifecycle: 'development',
},
};
const { result } = subject();
expect(result.current).toStrictEqual([
{ clusterId: 'prod', consumerGroup: 'consumer' },
{
clusterId: 'dev',
consumerGroup: 'another-consumer',
},
]);
});
it('returns correct cluster and consumer group for annotation with extra spaces', async () => {
entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups':
' prod/consumer , dev/another-consumer ',
},
},
spec: {
owner: 'guest',
type: 'Website',
lifecycle: 'development',
},
};
const { result } = subject();
expect(result.current).toStrictEqual([
{ clusterId: 'prod', consumerGroup: 'consumer' },
{
clusterId: 'dev',
consumerGroup: 'another-consumer',
},
]);
});
it('fails on missing cluster', async () => {
entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups': 'dev/another,consumer',
},
},
spec: {
owner: 'guest',
type: 'Website',
lifecycle: 'development',
},
};
const { result } = subject();
expect(() => result.current).toThrowError();
expect(result.error).toStrictEqual(
new Error(
`Failed to parse kafka consumer group annotation: got "dev/another,consumer"`,
),
);
});
});
@@ -15,10 +15,29 @@
*/
import { useEntity } from '@backstage/plugin-catalog';
import { useMemo } from 'react';
import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants';
export const useConsumerGroupsForEntity = () => {
const { entity } = useEntity();
const annotation =
entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION] ?? '';
return entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION] ?? '';
const consumerList = useMemo(() => {
return annotation.split(',').map(consumer => {
const [clusterId, consumerGroup] = consumer.split('/');
if (!clusterId || !consumerGroup) {
throw new Error(
`Failed to parse kafka consumer group annotation: got "${annotation}"`,
);
}
return {
clusterId: clusterId.trim(),
consumerGroup: consumerGroup.trim(),
};
});
}, [annotation]);
return consumerList;
};
@@ -45,7 +45,7 @@ describe('useConsumerGroupOffsets', () => {
metadata: {
name: 'test',
annotations: {
'kafka.apache.org/consumer-groups': consumerGroupOffsets.consumerId,
'kafka.apache.org/consumer-groups': `prod/${consumerGroupOffsets.consumerId}`,
},
},
spec: {
@@ -74,16 +74,24 @@ describe('useConsumerGroupOffsets', () => {
renderHook(useConsumerGroupsOffsetsForEntity, { wrapper });
it('returns correct consumer group for annotation', async () => {
mockKafkaApi.getConsumerGroupOffsets.mockResolvedValue(
consumerGroupOffsets,
);
when(mockKafkaApi.getConsumerGroupOffsets)
.calledWith(consumerGroupOffsets.consumerId)
.calledWith('prod', consumerGroupOffsets.consumerId)
.mockResolvedValue(consumerGroupOffsets);
const { result, waitForNextUpdate } = subject();
await waitForNextUpdate();
const [tableProps] = result.current;
expect(tableProps.consumerGroup).toBe(consumerGroupOffsets.consumerId);
expect(tableProps.topics).toBe(consumerGroupOffsets.offsets);
expect(tableProps.consumerGroupsTopics).toStrictEqual([
{
clusterId: 'prod',
consumerGroup: consumerGroupOffsets.consumerId,
topics: consumerGroupOffsets.offsets,
},
]);
});
it('posts an error to the error api', async () => {
@@ -20,25 +20,35 @@ import { kafkaApiRef } from '../../api/types';
import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity';
export const useConsumerGroupsOffsetsForEntity = () => {
const consumerGroup = useConsumerGroupsForEntity();
const consumers = useConsumerGroupsForEntity();
const api = useApi(kafkaApiRef);
const errorApi = useApi(errorApiRef);
const { loading, value: topics, retry } = useAsyncRetry(async () => {
const {
loading,
value: consumerGroupsTopics,
retry,
} = useAsyncRetry(async () => {
try {
const response = await api.getConsumerGroupOffsets(consumerGroup);
return response.offsets;
return await Promise.all(
consumers.map(async ({ clusterId, consumerGroup }) => {
const response = await api.getConsumerGroupOffsets(
clusterId,
consumerGroup,
);
return { clusterId, consumerGroup, topics: response.offsets };
}),
);
} catch (e) {
errorApi.post(e);
throw e;
}
}, [api, errorApi, consumerGroup]);
}, [consumers, api, errorApi]);
return [
{
loading,
consumerGroup,
topics,
consumerGroupsTopics,
},
{
retry,
+9 -73
View File
@@ -1,83 +1,19 @@
# Kubernetes Backend
WORK IN PROGRESS
This is the backend part of the Kubernetes plugin for Backstage. It is called by and responds to requests from the frontend [`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) plugin.
This is the backend part of the Kubernetes plugin.
It directly interfaces with the Kubernetes API control plane to obtain information about objects that will then be presented at the front end.
It responds to Kubernetes requests from the frontend.
## Introduction
## Configuration
See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin.
### serviceLocatorMethod
## Setup & Configuration
This configures how to determine which clusters a component is running in.
This plugin must be explicitly added to a Backstage app, along with it's peer frontend plugin.
Currently, the only valid serviceLocatorMethod is:
The plugin requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane.
#### multiTenant
In addition, configuration of an entity's `catalog-info.yaml` helps identify which specific Kubernetes object(s) should be presented on a specific entity catalog page.
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 serviceLocatorMethod is:
#### config
This clusterLocatorMethod will read cluster information in from config
Example:
```yaml
kubernetes:
serviceLocatorMethod: 'multiTenant'
clusterLocatorMethods:
- 'config'
clusters:
- url: http://127.0.0.1:9999
name: minikube
serviceAccountToken: <TOKEN FROM STEP 4>
authProvider: 'serviceAccount'
- url: http://127.0.0.2:9999
name: gke-cluster-1
authProvider: 'google'
```
##### clusters
Used by the `config` `clusterLocatorMethods` to construct Kubernetes clients.
###### 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.
###### 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.
###### authProvider
This determines how the Kubernetes client authenticate 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. |
###### serviceAccount (optional)
The service account token to be used when using the `authProvider`, `serviceAccount`.
## RBAC
The current RBAC permissions required are read-only cluster wide, for the following objects:
- pods
- services
- configmaps
- deployments
- replicasets
- horizontalpodautoscalers
- ingresses
For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview).
+3
View File
@@ -31,11 +31,13 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@aws-sdk/credential-provider-node": "^3.3.0",
"@backstage/backend-common": "^0.5.0",
"@backstage/catalog-model": "^0.7.0",
"@backstage/config": "^0.1.2",
"@kubernetes/client-node": "^0.13.2",
"@types/express": "^4.17.6",
"aws4": "^1.11.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -50,6 +52,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.4.7",
"@types/aws4": "^1.5.1",
"supertest": "^4.0.2"
},
"files": [
+2 -2
View File
@@ -20,8 +20,8 @@ export interface Config {
clusters: {
url: string;
name: string;
serviceAccountToken: string;
authProvider: 'serviceAccount';
serviceAccountToken: string | undefined;
authProvider: 'aws' | 'google' | 'serviceAccount';
}[];
};
}
@@ -0,0 +1,63 @@
/*
* 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 mockCredentialProvider = jest.fn();
jest.mock('@aws-sdk/credential-provider-node', () => {
return {
defaultProvider: () => mockCredentialProvider,
};
});
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
describe('AwsIamKubernetesAuthTranslator tests', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('returns a signed url for aws credentials', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
mockCredentialProvider.mockImplementation(async () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
return {
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretKeyId: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
};
});
const clusterDetails = await authTranslator.decorateClusterDetailsWithAuth({
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
expect(clusterDetails.serviceAccountToken).toBeDefined();
});
it('throws when unable to get aws credentials', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
mockCredentialProvider.mockImplementation(async () => {
throw new Error('not implemented');
});
const promise = authTranslator.decorateClusterDetailsWithAuth({
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
await expect(promise).rejects.toThrow('not implemented');
});
});
@@ -0,0 +1,73 @@
/*
* 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 { defaultProvider } from '@aws-sdk/credential-provider-node';
import { sign } from 'aws4';
import { KubernetesAuthTranslator } from './types';
import { ClusterDetails } from '..';
const base64 = (str: string) =>
Buffer.from(str.toString(), 'binary').toString('base64');
const prepend = (prep: string) => (str: string) => prep + str;
const replace = (search: string | RegExp, substitution: string) => (
str: string,
) => str.replace(search, substitution);
const pipe = (fns: ReadonlyArray<any>) => (thing: string): string =>
fns.reduce((val, fn) => fn(val), thing);
const removePadding = replace(/=+$/, '');
const makeUrlSafe = pipe([replace('+', '-'), replace('/', '_')]);
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator {
async getBearerToken(clusterName: string): Promise<string> {
const credentialProvider = defaultProvider();
const credentials = await credentialProvider();
const request = {
host: `sts.amazonaws.com`,
path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`,
headers: {
'x-k8s-aws-id': clusterName,
},
signQuery: true,
};
const signedRequest = sign(request, {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
});
return pipe([
(signed: any) => `https://${signed.host}${signed.path}`,
base64,
removePadding,
makeUrlSafe,
prepend('k8s-aws-v1.'),
])(signedRequest);
}
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
): Promise<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
{},
clusterDetails,
);
clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken(
clusterDetails.name,
);
return clusterDetailsWithAuthToken;
}
}
@@ -18,6 +18,7 @@ import { KubernetesAuthTranslator } from './types';
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator';
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
describe('getKubernetesAuthTranslatorInstance', () => {
const sut = KubernetesAuthTranslatorGenerator;
@@ -29,6 +30,13 @@ describe('getKubernetesAuthTranslatorInstance', () => {
expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true);
});
it('can return an auth translator for aws auth', () => {
const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
'aws',
);
expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true);
});
it('can return an auth translator for serviceAccount auth', () => {
const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance(
'serviceAccount',
@@ -17,6 +17,7 @@
import { KubernetesAuthTranslator } from './types';
import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator';
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
export class KubernetesAuthTranslatorGenerator {
static getKubernetesAuthTranslatorInstance(
@@ -26,6 +27,9 @@ export class KubernetesAuthTranslatorGenerator {
case 'google': {
return new GoogleKubernetesAuthTranslator();
}
case 'aws': {
return new AwsIamKubernetesAuthTranslator();
}
case 'serviceAccount': {
return new ServiceAccountKubernetesAuthTranslator();
}
@@ -148,4 +148,4 @@ export interface KubernetesFetchError {
export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http
export type ClusterLocatorMethod = 'config';
export type AuthProviderType = 'google' | 'serviceAccount';
export type AuthProviderType = 'google' | 'serviceAccount' | 'aws';
+22 -39
View File
@@ -1,9 +1,29 @@
# kubernetes
# Kubernetes
Welcome to the kubernetes plugin!
Welcome to the Backstage Kubernetes frontend plugin!
This plugin exposes information about your entity-specific Kubernetes objects with a desire to provide value to the service owner, rather than just a Kubernetes cluster administrator.
It will elevate the visibility of errors where identified, and provide drill down about the deployments, pods, and other objects for a service.
It directly interfaces with the [Kubernetes Backend Plugin (`@backstage-plugin-kubernetes-backend`)](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend).
_This plugin was created through the Backstage CLI_
## Introduction
See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin.
## Setup & Configuration
This plugin must be explicitly added to a Backstage app, along with it's peer backend plugin.
It requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane.
In addition, configuration of an entity's `catalog-info.yaml` helps identify which specific Kubernetes object(s) should be presented on a specific entity catalog page.
For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview).
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/kubernetes](http://localhost:3000/kubernetes).
@@ -11,40 +31,3 @@ Your plugin has been added to the example app in this repository, meaning you'll
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
## Surfacing your Kubernetes components as part of an entity
There are two ways to surface your kubernetes components as part of an entity.
The label selector takes precedence over the annotation/service id.
### Common `backstage.io/kubernetes-id` label
#### Adding the entity annotation
In order for Backstage to detect that an entity has Kubernetes components,
the following annotation should be added to the entity.
```yaml
annotations:
'backstage.io/kubernetes-id': dice-roller
```
#### Labeling Kubernetes components
In order for Kubernetes components to show up in the service catalog
as a part of an entity, Kubernetes components must be labeled with the following label:
```yaml
'backstage.io/kubernetes-id': <ENTITY_NAME>
```
### label selector query annotation
#### Adding a label selector query annotation
You can write your own custom label selector query that backstage will use to lookup the objects (similar to `kubectl --selector="your query here"`)
review the documentation [here](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) for more info
```yaml
'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end'
```
+2 -2
View File
@@ -35,11 +35,11 @@ export interface Config {
/**
* @visibility secret
*/
serviceAccountToken: string;
serviceAccountToken: string | undefined;
/**
* @visibility frontend
*/
authProvider: 'serviceAccount';
authProvider: 'aws' | 'google' | 'serviceAccount';
}[];
};
}
@@ -29,7 +29,31 @@ describe('SonarQubeClient', () => {
const mockBaseUrl = 'http://backstage:9191/api/proxy';
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
const setupHandlers = () => {
const setupHandlers = (
metricKeys = [
'alert_status',
'bugs',
'reliability_rating',
'vulnerabilities',
'security_rating',
'security_hotspots_reviewed',
'security_review_rating',
'code_smells',
'sqale_rating',
'coverage',
'duplicated_lines_density',
],
) => {
server.use(
rest.get(`${mockBaseUrl}/sonarqube/metrics/search`, (_, res, ctx) => {
return res(
ctx.json({
metrics: metricKeys.map(k => ({ key: k })),
}),
);
}),
);
server.use(
rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe('component=our-service');
@@ -46,8 +70,9 @@ describe('SonarQubeClient', () => {
server.use(
rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Csecurity_hotspots_reviewed%2Csecurity_review_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density',
`projectKeys=our-service&metricKeys=${metricKeys.join('%2C')}`,
);
return res(
ctx.json({
measures: [
@@ -111,7 +136,7 @@ describe('SonarQubeClient', () => {
value: '1.0',
component: 'our-service',
},
],
].filter(m => metricKeys.includes(m.metric)),
} as MeasuresWrapper),
);
}),
@@ -187,4 +212,32 @@ describe('SonarQubeClient', () => {
'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list',
);
});
it('should only request selected metrics', async () => {
setupHandlers(['alert_status', 'bugs']);
const client = new SonarQubeClient({
discoveryApi,
baseUrl: 'http://a.instance.local',
});
const summary = await client.getFindingSummary('our-service');
expect(summary).toEqual(
expect.objectContaining({
lastAnalysis: '2020-01-01T00:00:00Z',
metrics: {
alert_status: 'OK',
bugs: '2',
},
projectUrl: 'http://a.instance.local/dashboard?id=our-service',
}) as FindingSummary,
);
expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual(
'http://a.instance.local/project/issues?id=our-service&types=CODE_SMELL&resolved=false',
);
expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual(
'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list',
);
});
});
+16 -3
View File
@@ -43,6 +43,13 @@ export class SonarQubeClient implements SonarQubeApi {
return undefined;
}
private async getSupportedMetrics(): Promise<string[]> {
const result = await this.callApi<{ metrics: Array<{ key: string }> }>(
'metrics/search',
);
return result?.metrics?.map(m => m.key) ?? [];
}
async getFindingSummary(
componentKey?: string,
): Promise<FindingSummary | undefined> {
@@ -71,10 +78,16 @@ export class SonarQubeClient implements SonarQubeApi {
duplicated_lines_density: undefined,
};
// select the metrics that are supported by the SonarQube instance
const supportedMetrics = await this.getSupportedMetrics();
const metricKeys = Object.keys(metrics).filter(m =>
supportedMetrics.includes(m),
);
const measures = await this.callApi<MeasuresWrapper>(
`measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys(
metrics,
).join(',')}`,
`measures/search?projectKeys=${componentKey}&metricKeys=${metricKeys.join(
',',
)}`,
);
if (!measures) {
return undefined;
+77 -7
View File
@@ -278,6 +278,15 @@
"@aws-sdk/property-provider" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-env@3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.3.0.tgz#7930e504a7a79ab98a9fd34befc5c84b8c4df679"
integrity sha512-kyqZMlGdH/05IhuXLBUXtj5+hhRfYiHFcJLc3ts/uiwCixswVHPAYHgyWm9ajFkmWtpz6ih+0LoYryhPbYu01A==
dependencies:
"@aws-sdk/property-provider" "3.3.0"
"@aws-sdk/types" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-imds@3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz#33d48753dc00bddce79d2aa8076a7cb5bf8562df"
@@ -286,6 +295,15 @@
"@aws-sdk/property-provider" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-imds@3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.3.0.tgz#ff0cf5489853c16d23fc99d7bae425587e836c40"
integrity sha512-Cx0YMnO/ScGQVDns006bLbqOxNURGN2Xm21bCY0l0ZUJCdJ2va1/9q1rljDyw2KvdzZNQVRQII3uUgj/Oq/K+g==
dependencies:
"@aws-sdk/property-provider" "3.3.0"
"@aws-sdk/types" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-ini@3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.1.0.tgz#1a6cf9ab9fa1450d4472b9e371099b0c0283349b"
@@ -295,6 +313,16 @@
"@aws-sdk/shared-ini-file-loader" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-ini@3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.3.0.tgz#55fe8f391b72d30e650ba8bc680e82bbeacbbfe5"
integrity sha512-zawNFJoasXiaV5n0H3/KNOi7mAZ7mHpG1+nBEkoWhZ31lIUM9+heGPcxKCbf/pMQjiOebUqL1OpWe4uSWxIVMw==
dependencies:
"@aws-sdk/property-provider" "3.3.0"
"@aws-sdk/shared-ini-file-loader" "3.1.0"
"@aws-sdk/types" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-node@3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.1.0.tgz#89bc8803752a3e580c6f2410306c7edad6be7fa2"
@@ -307,6 +335,19 @@
"@aws-sdk/property-provider" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-node@^3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.3.0.tgz#5c97323fa7b23590070d06aa7b1be8d93b2bf4be"
integrity sha512-PPBNzPq8fHk9dEQTTE4iJi6ZWtmo057Lc+I8Rlzmvz6NthK9iKiU819tfaxVBb6ZR7bLP0BuDiCi4G1lD+rQnQ==
dependencies:
"@aws-sdk/credential-provider-env" "3.3.0"
"@aws-sdk/credential-provider-imds" "3.3.0"
"@aws-sdk/credential-provider-ini" "3.3.0"
"@aws-sdk/credential-provider-process" "3.3.0"
"@aws-sdk/property-provider" "3.3.0"
"@aws-sdk/types" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-process@3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.1.0.tgz#ff817b29a9760c463b77be3ce49375eaeb753ef3"
@@ -317,6 +358,17 @@
"@aws-sdk/shared-ini-file-loader" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/credential-provider-process@3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.3.0.tgz#9de0984bd6dd0f5e40cff3672d7dd19e8cd43074"
integrity sha512-7oOF1j6ydUq43P3SsasiIpbxMKCmT0C+XwggHTGiVxNtX+QZiH1vdMf8otA7puLEey0iY5wTAIEcZhC6HenojA==
dependencies:
"@aws-sdk/credential-provider-ini" "3.3.0"
"@aws-sdk/property-provider" "3.3.0"
"@aws-sdk/shared-ini-file-loader" "3.1.0"
"@aws-sdk/types" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/eventstream-marshaller@3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@aws-sdk/eventstream-marshaller/-/eventstream-marshaller-3.1.0.tgz#65a217e37abcaa162276ccb1d4487d42431d1534"
@@ -649,6 +701,14 @@
dependencies:
tslib "^1.8.0"
"@aws-sdk/property-provider@3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.3.0.tgz#49979cb1a3e5562d51807c7403c5fd48cb9f2cdc"
integrity sha512-JTyjtXVNhFczL9IfgwXD55F6DqXL50PhfZxFW92t5dDj5VtWpOL74BbuxHQxHBgnQv1FKLr6N9cr7gfXWexDug==
dependencies:
"@aws-sdk/types" "3.1.0"
tslib "^1.8.0"
"@aws-sdk/protocol-http@3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.1.0.tgz#7da0ebcf02a40a8300f3bd52f9206f25fdf1ca7f"
@@ -4774,9 +4834,9 @@
once "^1.4.0"
"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.11", "@octokit/request@^5.4.12":
version "5.4.12"
resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.12.tgz#b04826fa934670c56b135a81447be2c1723a2ffc"
integrity sha512-MvWYdxengUWTGFpfpefBBpVmmEYfkwMoxonIB3sUGp5rhdgwjXL1ejo6JbgzG/QD9B/NYt/9cJX1pxXeSIUCkg==
version "5.4.13"
resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.13.tgz#eec5987b3e96f984fc5f41967e001170c6d23a18"
integrity sha512-WcNRH5XPPtg7i1g9Da5U9dvZ6YbTffw9BN2rVezYiE7couoSyaRsw0e+Tl8uk1fArHE7Dn14U7YqUDy59WaqEw==
dependencies:
"@octokit/endpoint" "^6.0.1"
"@octokit/request-error" "^2.0.0"
@@ -6183,6 +6243,11 @@
resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0"
integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==
"@types/aws4@^1.5.1":
version "1.5.1"
resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.1.tgz#361fadab198a030ab398269183ae3fa86e958ed9"
integrity sha1-Nh+tqxmKAwqzmCaRg64/qG6Vjtk=
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.9"
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
@@ -6828,10 +6893,10 @@
resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
"@types/mini-css-extract-plugin@^0.9.1":
version "0.9.1"
resolved "https://registry.npmjs.org/@types/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.1.tgz#d4bdde5197326fca039d418f4bdda03dc74dc451"
integrity sha512-+mN04Oszdz9tGjUP/c1ReVwJXxSniLd7lF++sv+8dkABxVNthg6uccei+4ssKxRHGoMmPxdn7uBdJWONSJGTGQ==
"@types/mini-css-extract-plugin@^1.2.2":
version "1.2.2"
resolved "https://registry.npmjs.org/@types/mini-css-extract-plugin/-/mini-css-extract-plugin-1.2.2.tgz#e6031da8d60777b3da3f5b4daf285437d7b6580b"
integrity sha512-EoHBJ4rcrd5j7weAFE4yU1gxedx53EFCWKso03G7DW0h2YvtwjKYz/NnuFHudcQDI1HpTLqoQFTwEgfJxygYCw==
dependencies:
"@types/webpack" "*"
@@ -8602,6 +8667,11 @@ aws-sign2@~0.7.0:
resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
aws4@^1.11.0:
version "1.11.0"
resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
aws4@^1.8.0:
version "1.9.1"
resolved "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"