Merge branch 'backstage:master' into awanlin/azure-devops-frontend-plugin

This commit is contained in:
Andre Wanlin
2021-10-11 15:12:13 -05:00
committed by GitHub
85 changed files with 2433 additions and 753 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-auth-backend': patch
---
AWS-ALB: update provider to the latest changes described [here](https://backstage.io/docs/auth/identity-resolver).
This removes the `ExperimentalIdentityResolver` type in favor of `SignInResolver` and `AuthHandler`.
The AWS ALB provider can now be configured in the same way as the Google provider in the example.
+19
View File
@@ -0,0 +1,19 @@
---
'@backstage/create-app': patch
---
The scaffolder plugin has just released the beta 3 version of software templates, which replaces the handlebars templating syntax. As part of this change, the template entity schema is no longer included in the core catalog-model as with previous versions. The decoupling of the template entities version will allow us to more easily make updates in the future.
In order to use the new beta 3 templates, the following changes are **required** for any existing installation, inside `packages/backend/src/plugins/catalog.ts`:
```diff
+import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend';
...
const builder = await CatalogBuilder.create(env);
+ builder.addProcessor(new ScaffolderEntitiesProcessor());
const { processingEngine, router } = await builder.build();
```
If you're interested in learning more about creating custom kinds, please check out the [extending the model](https://backstage.io/docs/features/software-catalog/extending-the-model) documentation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
update the null check to use the optional chaining operator in case of non-null assertion operator is not working in function extractInitials(values: string)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Resolve a warning in `<Button>` related to not using `React.forwardRef`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': patch
---
Remove unused dependency on material-icons/font
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-components': minor
'@backstage/plugin-catalog-graph': minor
---
Add documentation and more type safety around DependencyGraph
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-backend': minor
---
This continues the deprecation of classes used by the legacy catalog engine. New deprecations can be viewed in this [PR](https://github.com/backstage/backstage/pull/7500) or in the API reference documentation.
The `batchAddOrUpdateEntities` method of the `EntitiesCatalog` interface has been marked as optional and is being deprecated. It is still implemented and required to be implemented by the legacy catalog classes, but was never implemented in the new catalog.
This change is only relevant if you are consuming the `EntitiesCatalog` interface directly, in which case you will get a type error that you need to resolve. It can otherwise be ignored.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Add semicolon in template to make prettier happy
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-azure-devops-backend': patch
---
Updates function for mapping RepoBuilds to handle undefined properties
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
SearchBar component to accept optional placeholder prop
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes': patch
---
Refactor kubernetes fetcher to reduce boilerplate code
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-react': patch
---
added sorting in entity `Name` column by `metadata.title` if present
+1
View File
@@ -54,3 +54,4 @@
| [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe |
| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. |
| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. |
| [Gelato](https://gelato.com/) | [Dmitry Makarenko](https://github.com/dmitry-makarenko-gelato) | Developer portal: documentation, service templates, org structure, service catalog, plugins for integration with internal and third-party systems🚀. |
+52 -24
View File
@@ -100,17 +100,16 @@ const app = createApp({
### Backend
When using ALB auth it is not possible to leverage the built-in auth config discovery mechanism implemented in the app created by default; bespoke logic needs to be implemented.
When using ALB auth you can configure it as described [here](https://backstage.io/docs/auth/identity-resolver).
- replace the content of `packages/backend/plugin/auth.ts` with the below
- replace the content of `packages/backend/plugin/auth.ts` with the below and tweak it according to your needs.
```ts
import {
createRouter,
AuthResponse,
AuthProviderFactoryOptions,
defaultAuthProviderFactories,
createAwsAlbProvider,
} from '@backstage/plugin-auth-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
@@ -118,30 +117,59 @@ export default async function createPlugin({
database,
config,
discovery,
}: PluginEnvironment) {
const identityResolver = (payload: any): Promise<AuthResponse<any>> => {
return Promise.resolve({
providerInfo: {},
profile: {
email: payload.email,
displayName: payload.name,
picture: payload.picture,
},
backstageIdentity: {
id: payload.email,
},
});
};
const providerFactories = {
awsalb: (options: AuthProviderFactoryOptions) =>
defaultAuthProviderFactories.awsalb({ ...options, identityResolver }),
};
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
config,
database,
discovery,
providerFactories,
providerFactories: {
awsalb: createAwsAlbProvider({
authHandler: async ({ fullProfile }) => {
let email: string | undefined = undefined;
if (fullProfile.emails && fullProfile.emails.length > 0) {
const [firstEmail] = fullProfile.emails;
email = firstEmail.value;
}
let picture: string | undefined = undefined;
if (fullProfile.photos && fullProfile.photos.length > 0) {
const [firstPhoto] = fullProfile.photos;
picture = firstPhoto.value;
}
const displayName: string | undefined =
fullProfile.displayName ?? fullProfile.username ?? fullProfile.id;
return {
profile: {
email,
picture,
displayName,
},
};
},
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
const [id] = email?.split('@') ?? '';
// Fetch from an external system that returns entity claims like:
// ['user:default/breanna.davison', ...]
const ent = [`user:default/${id}`];
// Resolve group membership from the Backstage catalog
const fullEnt =
await ctx.catalogIdentityClient.resolveCatalogMembership({
entityRefs: [id].concat(ent),
logger: ctx.logger,
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: fullEnt },
});
return { id, token };
},
},
}),
},
});
}
```
@@ -0,0 +1,43 @@
---
id: adrs-adr012
title: ADR000: Use Luxon.toLocaleString and date/time presets
description: Architecture Decision Record (ADR) for using Luxon's toLocaleString method and date/time presets for displaying dates and times
---
## Context
User's locales will have their own style of reading dates. It's counter
intuitive to not have dates formatted in their familiar formats, it can cause
users to have to think harder about what the date is and could even lead to
interpreting dates incorrectly (e.g. 05/03/2021, this could be March 5th or May
3rd, depending on where the user is). At the moment, plugins are defining dates
and times using custom formats and the `toFormat` method, which leads to
inconsistent and unfamiliar formats.
## Decision
To keep the UI consistent and familiar to users, irrespective of their location,
we have decided that we use `toLocaleString` and Luxon's
[extensive list](https://github.com/moment/luxon/blob/master/docs/formatting.md#presets)
of Date and Time presets.
Here is an example:
```typescript
const date = new luxon.DateTime();
/* Avoid this: */
date.toFormat('yyyy LLL dd'); // 2014 Aug 06
date.toFormat('yyyy LLL dd hh:mm'); // 2014 Aug 06 12:01
/* Do this instead: */
date.toLocaleString(luxon.DateTime.DATE_MED); // US: Oct 14, 1983 | FR: 14 oct. 1983
date.toLocaleString(luxon.DateTime.DATETIME_MED); // US: Oct 14, 1983, 9:30 | FR: 14 oct. 1983 9:30
```
## Consequences
- We will need to audit the current places Date/Times are being displayed in the
UI and update them to follow this ADR.
- We will need to keep in mind for reviewing PRs going forward to follow this
ADR, or find/create a linting rule to automate this in the review process.
+1 -1
View File
@@ -76,7 +76,7 @@ CMD ["node", "packages/backend", "--config", "app-config.yaml"]
For more details on how the `backend:bundle` command and the `skeleton.tar.gz`
file works, see the
[`backend:bundle` command docs](../cli/commands.md#backendbundle).
[`backend:bundle` command docs](../local-dev/cli-commands.md#backendbundle).
The `Dockerfile` is located at `packages/backend/Dockerfile`, but needs to be
executed with the root of the repo as the build context, in order to get access
+11 -7
View File
@@ -102,20 +102,24 @@ more to come...
See [Backstage Search Architecture](architecture.md) to get an overview of how
the search engines are used.
| Search Engine | Support Status |
| ------------- | -------------- |
| Basic (lunr) | ✅ |
| ElasticSearch | Not yet ❌ |
| Search Engines | Support Status |
| -------------------------------------------------- | -------------- |
| [Lunr](./search-engines.md#lunr) | ✅ |
| [ElasticSearch](./search-engines.md#elasticsearch) | |
| [Postgres](./search-engines.md#postgres) | ✅ |
[Reach out to us](#feedback) if you want to chat about support for more search
engines.
[Reach out to us](#get-involved) if you want to chat about support for more
search engines.
## Plugins Integrated with Search
| Plugin | Support Status |
| -------- | -------------- |
| Catalog | ✅ |
| TechDocs | Not yet ❌ |
| TechDocs | |
[Reach out to us](#get-involved) if you want to chat about support for more
plugins integrated to search.
## Tech Stack
+46
View File
@@ -0,0 +1,46 @@
---
id: how-to-guides
title: Search "HOW TO" guides
sidebar_label: "HOW TO" guides
description: Search "HOW TO" guides
---
## How to implement your own Search API
The Search plugin provides implementation of one primary API by default: the
[SearchApi](https://github.com/backstage/backstage/blob/db2666b980853c281b8fe77905d7639c5d255f13/plugins/search/src/apis.ts#L35),
which is responsible for talking to the search-backend to query search results.
There may be occasions where you need to implement this API yourself, to
customize it to your own needs - for example if you have your own search backend
that you want to talk to. The purpose of this guide is to walk you through how
to do that in two steps.
1. Implement the `SearchApi`
[interface](https://github.com/backstage/backstage/blob/db2666b980853c281b8fe77905d7639c5d255f13/plugins/search/src/apis.ts#L31)
according to your needs.
```typescript
export class SearchClient implements SearchApi {
// your implementation
}
```
2. Override the API ref `searchApiRef` with your new implemented API in the
`App.tsx` using `ApiFactories`.
[Read more about App APIs](https://backstage.io/docs/api/utility-apis#app-apis).
```typescript
const app = createApp({
apis: [
// SearchApi
createApiFactory({
api: searchApiRef,
deps: { discovery: discoveryApiRef },
factory({ discovery }) {
return new SearchClient({ discoveryApi: discovery });
},
}),
],
});
```
@@ -429,3 +429,101 @@ from one environment to the other, do rollbacks, see their relative performance
metrics, and similar. This coherency and collection of tooling in one place is
where something like Backstage can offer the most value and effectiveness of
use. Splitting your entities apart into small islands makes this harder.
## Implementing custom model extensions
This section walks you through the steps involved extending the catalog model
with a new Entity type.
### Creating a custom entity definition
The first step of introducing a custom entity is to define what shape and schema
it has. We do this both using a TypeScript type, along with a JSONSchema schema.
Most of the time you will want to have at least the TypeScript type of your
extension available in both frontend and backend code, which means you likely
want to have an isomorphic package that houses these types. Within the Backstage
main repo the package naming pattern of `<plugin>-common` is used for isomorphic
packages, and you may choose to adopt this pattern as well.
There's at this point no existing templates for generating isomorphic plugins
using the `@backstage/cli`. Perhaps the simplest wat to get started right now is
to copy the contents of one of the existing packages in the main repository,
such as `plugins/scaffolder-common`, and rename the folder and file contents to
the desired name. This example uses _foobar_ as the plugin name so the plugin
will be named _foobar-common_.
Once you have a common package in place you can start adding your own entity
definitions. For the exact details on how to do that we defer to getting
inspired by the existing
[scaffolder-common](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-common/src/index.ts)
package. But in short you will need to declare a TypeScript type and a
JSONSchema for the new entity kind.
### Building a custom processor for the entity
The next step is to create a custom processor for your new entity kind. This
will be used within the catalog to make sure that it's able to ingest and
validate entities of our new kind. Just like with the definition package, you
can find inspiration in for example the existing
[ScaffolderEntitiesProcessor](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.ts).
We also provide a high-level example of what a catalog process for a custom
entity might look like:
```ts
import { entityKindSchemaValidator } from '@backstage/catalog-model';
export class FoobarEntitiesProcessor implements CatalogProcessor {
// You often end up wanting to support multiple versions of your kind as you
// iterate on the definition, so we keep each version inside this array.
private readonly validators = [
// This is where we use the JSONSchema that we export from our isomorphic package
entityKindSchemaValidator(foobarEntityV1alpha1Schema),
];
// validateEntityKind is responsible for signaling to the catalog processing engine
// that this entity is valid and should therefore be submitted for further processing.
async validateEntityKind(entity: Entity): Promise<boolean> {
for (const validator of this.validators) {
if (validator(entity)) {
return true;
}
}
return false;
}
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
if (
entity.apiVersion === 'example.com/v1alpha1' &&
entity.kind === 'Foobar'
) {
const foobarEntity = entity as FoobarEntityV1alpha1;
// Here we can modify the entity or emit results related to the entity
// Typically you will want to emit any relations associated with the entity here
emit(results.relation({ ... }))
}
return entity;
}
}
```
Once the processor is created it can be wired up to the catalog via the
`CatalogBuilder` in `packages/backend/src/plugins/catalog.ts`:
```diff
+ import { FoobarEntitiesProcessor implements CatalogProcessor {
} from '@internal/plugin-foobar-backend';
// ...
const builder = await CatalogBuilder.create(env);
+ builder.addProcessor(new FoobarEntitiesProcessor());
const { processingEngine, router } = await builder.build();
```
-53
View File
@@ -132,56 +132,3 @@ Now you're free to hack away on your own Backstage installation!
As you get more experienced with the app, in future you can run just the
frontend with `yarn start` in one window, and the backend with
`yarn start-backend` in a different window.
## Linking in local Backstage packages
It can often be useful to try out changes to the packages in the main Backstage
repo within your own app. For example if you want to make modifications to
`@backstage/core-plugin-api` and try them out in your app.
To link in external packages, add them to your `package.json` and `lerna.json`
workspace paths. These can be either relative or absolute paths with or without
globs. For example:
```json
"packages": [
"packages/*",
"plugins/*",
"../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api
],
```
Then reinstall packages to make yarn set up symlinks:
```bash
yarn install
```
With this in place you can now modify the `@backstage/core-plugin-api` package
within the main repo, and have those changes be reflected and tested in your
app. Simply run your app using `yarn dev` (or `yarn start` for just frontend) as
normal.
Note that for backend packages you need to make sure that linked packages are
not dependencies of any non-linked package. If you for example want to work on
`@backstage/backend-common`, you need to also link in other backend plugins and
packages that depend on `@backstage/backend-common`, or temporarily disable
those plugins in your backend. This is because the transformation of backend
module tree stops whenever a non-local package is encountered, and from that
point node will `require` packages directly for that entire module subtree.
Type checking can also have issues when linking in external packages, since the
linked in packages will use the types in the external project and dependency
version mismatches between the two projects may cause errors. To fix any of
those errors you need to sync versions of the dependencies in the two projects.
A simple way to do this can be to copy over `yarn.lock` from the external
project and run `yarn install`, although this is quite intrusive and can cause
other issues in existing projects, so use this method with care. It can often be
best to simply ignore the type errors, as app serving will work just fine
anyway.
Another issue with type checking is that the incremental type cache doesn't
invalidate correctly for the linked in packages, causing type checking to not
reflect changes made to types. You can work around this by either setting
`compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the
types cache folder `dist-types` before running `yarn tsc`.
@@ -1,6 +1,6 @@
---
id: commands
title: Commands
id: cli-commands
title: CLI Commands
description: Descriptions of all commands available in the CLI.
---
@@ -1,6 +1,6 @@
---
id: index
title: Overview
id: cli-overview
title: CLI Overview
description: Overview of the Backstage CLI
---
@@ -20,7 +20,7 @@ Under the hood the CLI uses [Webpack](https://webpack.js.org/) for bundling,
linting. It also includes custom tooling for working within Backstage apps, for
example for keeping the app up to date and verifying static configuration.
For a full list of CLI commands, see the [commands](./commands.md) page.
For a full list of CLI commands, see the [commands](./cli-commands.md) page.
## Introduction
+56
View File
@@ -0,0 +1,56 @@
---
id: linking-local-packages
title: Linking in Local Packages
description: How to link in other local packages into your Backstage monorepo
---
It can often be useful to try out changes to the packages in the main Backstage
repo within your own app. For example if you want to make modifications to
`@backstage/core-plugin-api` and try them out in your app.
To link in external packages, add them to your `package.json` and `lerna.json`
workspace paths. These can be either relative or absolute paths with or without
globs. For example:
```json
"packages": [
"packages/*",
"plugins/*",
"../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api
],
```
Then reinstall packages to make yarn set up symlinks:
```bash
yarn install
```
With this in place you can now modify the `@backstage/core-plugin-api` package
within the main repo, and have those changes be reflected and tested in your
app. Simply run your app using `yarn dev` (or `yarn start` for just frontend) as
normal.
Note that for backend packages you need to make sure that linked packages are
not dependencies of any non-linked package. If you for example want to work on
`@backstage/backend-common`, you need to also link in other backend plugins and
packages that depend on `@backstage/backend-common`, or temporarily disable
those plugins in your backend. This is because the transformation of backend
module tree stops whenever a non-local package is encountered, and from that
point node will `require` packages directly for that entire module subtree.
Type checking can also have issues when linking in external packages, since the
linked in packages will use the types in the external project and dependency
version mismatches between the two projects may cause errors. To fix any of
those errors you need to sync versions of the dependencies in the two projects.
A simple way to do this can be to copy over `yarn.lock` from the external
project and run `yarn install`, although this is quite intrusive and can cause
other issues in existing projects, so use this method with care. It can often be
best to simply ignore the type errors, as app serving will work just fine
anyway.
Another issue with type checking is that the incremental type cache doesn't
invalidate correctly for the linked in packages, causing type checking to not
reflect changes made to types. You can work around this by either setting
`compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the
types cache folder `dist-types` before running `yarn tsc`.
+2 -1
View File
@@ -10,7 +10,8 @@ A Backstage Plugin adds functionality to Backstage.
To create a new plugin, make sure you've run `yarn install` and installed
dependencies, then run the following on your command line (a shortcut to
invoking the [`backstage-cli create-plugin`](../cli/commands.md#create-plugin))
invoking the
[`backstage-cli create-plugin`](../local-dev/cli-commands.md#create-plugin))
from the root of your project.
```bash
+1 -1
View File
@@ -36,7 +36,7 @@ that we provide. This gives us a way to automate some of the work required to
create a GitHub app.
You can read more about the
[`backstage-cli create-github-app` method](../cli/commands.md#create-github-app).
[`backstage-cli create-github-app` method](../local-dev/cli-commands.md#create-github-app).
Once you've gone through the CLI command, it should produce a YAML file in the
root of the project which you can then use as an `include` in your
+11 -2
View File
@@ -29,7 +29,14 @@
"getting-started/contributors",
"getting-started/project-structure"
],
"CLI": ["cli/index", "cli/commands"],
"Local Development": [
{
"type": "subcategory",
"label": "CLI",
"ids": ["local-dev/cli-overview", "local-dev/cli-commands"]
},
"local-dev/linking-local-packages"
],
"Core Features": [
{
"type": "subcategory",
@@ -83,7 +90,8 @@
"features/search/getting-started",
"features/search/concepts",
"features/search/architecture",
"features/search/search-engines"
"features/search/search-engines",
"features/search/how-to-guides"
]
},
{
@@ -210,6 +218,7 @@
"label": "Included providers",
"ids": [
"auth/auth0/provider",
"auth/bitbucket/provider",
"auth/microsoft/provider",
"auth/github/provider",
"auth/gitlab/provider",
+4
View File
@@ -1217,3 +1217,7 @@ code {
.medium-zoom-image {
z-index: 10000;
}
h3.collapsible span.arrow {
margin-right: 4px;
}
+6 -3
View File
@@ -27,9 +27,11 @@ nav:
- Key Concepts: 'getting-started/concepts.md'
- Contributors: 'getting-started/contributors.md'
- Project Structure: 'getting-started/project-structure.md'
- CLI:
- Overview: 'cli/index.md'
- Commands: 'cli/commands.md'
- Local Development:
- CLI:
- Overview: 'local-dev/cli-overview.md'
- Commands: 'local-dev/cli-commands.md'
- Linking in Local Packages: 'local-dev/linking-local-packages.md'
- Core Features:
- Software Catalog:
- Overview: 'features/software-catalog/index.md'
@@ -66,6 +68,7 @@ nav:
- Concepts: 'features/search/concepts.md'
- Search Architecture: 'features/search/architecture.md'
- Search Engines: 'features/search/search-engines.md'
- HOW TO guides: 'features/search/how-to-guides.md'
- TechDocs:
- Overview: 'features/techdocs/README.md'
- Getting Started: 'features/techdocs/getting-started.md'
+2
View File
@@ -15,6 +15,7 @@
*/
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -22,6 +23,7 @@ export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addProcessor(new ScaffolderEntitiesProcessor());
const { processingEngine, router } = await builder.build();
await processingEngine.start();
return router;
@@ -1,2 +1,2 @@
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill'
import 'cross-fetch/polyfill';
+52 -76
View File
@@ -17,7 +17,6 @@ import { ComponentProps } from 'react';
import { Context } from 'react';
import { default as CSS_2 } from 'csstype';
import { CSSProperties } from 'react';
import { default as dagre_2 } from 'dagre';
import { ElementType } from 'react';
import { ErrorInfo } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
@@ -53,15 +52,11 @@ export function AlertDisplay(_props: {}): JSX.Element | null;
// Warning: (ae-missing-release-tag) "Alignment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
enum Alignment {
// (undocumented)
DOWN_LEFT = 'DL',
// (undocumented)
DOWN_RIGHT = 'DR',
// (undocumented)
UP_LEFT = 'UL',
// (undocumented)
UP_RIGHT = 'UR',
}
@@ -264,11 +259,10 @@ export type CustomProviderClassKey = 'form' | 'button';
// @public (undocumented)
export function DashboardIcon(props: IconComponentProps): JSX.Element;
// Warning: (ae-forgotten-export) The symbol "CustomType" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "DependencyEdge" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
type DependencyEdge<T = CustomType> = T & {
// @public
type DependencyEdge<T = {}> = T & {
from: string;
to: string;
label?: string;
@@ -276,8 +270,10 @@ type DependencyEdge<T = CustomType> = T & {
// Warning: (ae-missing-release-tag) "DependencyGraph" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function DependencyGraph(props: DependencyGraphProps): JSX.Element;
// @public
export function DependencyGraph<NodeData, EdgeData>(
props: DependencyGraphProps<NodeData, EdgeData>,
): JSX.Element;
// Warning: (ae-missing-release-tag) "DependencyGraphDefaultLabelClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -301,40 +297,42 @@ export type DependencyGraphNodeClassKey = 'node';
// Warning: (ae-missing-release-tag) "DependencyGraphProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type DependencyGraphProps = React_2.SVGProps<SVGSVGElement> & {
edges: DependencyEdge[];
nodes: DependencyNode[];
direction?: Direction;
// @public
export interface DependencyGraphProps<NodeData, EdgeData>
extends React_2.SVGProps<SVGSVGElement> {
acyclicer?: 'greedy';
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
align?: Alignment;
nodeMargin?: number;
defs?: SVGDefsElement | SVGDefsElement[];
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
direction?: Direction;
edgeMargin?: number;
rankMargin?: number;
edgeRanks?: number;
edges: DependencyEdge<EdgeData>[];
edgeWeight?: number;
labelOffset?: number;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
labelPosition?: LabelPosition;
nodeMargin?: number;
nodes: DependencyNode<NodeData>[];
paddingX?: number;
paddingY?: number;
acyclicer?: 'greedy';
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
ranker?: Ranker;
labelPosition?: LabelPosition;
labelOffset?: number;
edgeRanks?: number;
edgeWeight?: number;
renderNode?: RenderNodeFunction;
renderLabel?: RenderLabelFunction;
defs?: SVGDefsElement | SVGDefsElement[];
rankMargin?: number;
renderLabel?: RenderLabelFunction<EdgeData>;
renderNode?: RenderNodeFunction<NodeData>;
zoom?: 'enabled' | 'disabled' | 'enable-on-click';
};
}
declare namespace DependencyGraphTypes {
export {
DependencyEdge,
GraphEdge,
RenderLabelProps,
RenderLabelFunction,
DependencyNode,
GraphNode,
RenderNodeProps,
RenderNodeFunction,
EdgeProperties,
Direction,
Alignment,
Ranker,
@@ -345,22 +343,18 @@ export { DependencyGraphTypes };
// Warning: (ae-missing-release-tag) "DependencyNode" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
type DependencyNode<T = CustomType> = T & {
// @public
type DependencyNode<T = {}> = T & {
id: string;
};
// Warning: (ae-missing-release-tag) "Direction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
enum Direction {
// (undocumented)
BOTTOM_TOP = 'BT',
// (undocumented)
LEFT_RIGHT = 'LR',
// (undocumented)
RIGHT_LEFT = 'RL',
// (undocumented)
TOP_BOTTOM = 'TB',
}
@@ -387,20 +381,6 @@ export type DismissbleBannerClassKey =
// @public (undocumented)
export function DocsIcon(props: IconComponentProps): JSX.Element;
// Warning: (ae-missing-release-tag) "EdgeProperties" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
type EdgeProperties = {
label?: string;
width?: number;
height?: number;
labeloffset?: number;
labelpos?: LabelPosition;
minlen?: number;
weight?: number;
[customKey: string]: any;
};
// Warning: (ae-missing-release-tag) "EmailIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -520,18 +500,6 @@ export type GaugeClassKey = 'root' | 'overlay' | 'circle' | 'colorUnknown';
// @public (undocumented)
export function GitHubIcon(props: IconComponentProps): JSX.Element;
// Warning: (ae-missing-release-tag) "GraphEdge" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
type GraphEdge<T = CustomType> = DependencyEdge<T> &
dagre_2.GraphEdge &
EdgeProperties;
// Warning: (ae-missing-release-tag) "GraphNode" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
type GraphNode<T = CustomType> = dagre_2.Node<DependencyNode<T>>;
// Warning: (ae-missing-release-tag) "GroupIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -738,7 +706,7 @@ export type ItemCardHeaderProps = Partial<WithStyles<typeof styles_2>> & {
// Warning: (ae-missing-release-tag) "LabelPosition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
enum LabelPosition {
// (undocumented)
CENTER = 'c',
@@ -888,37 +856,43 @@ export function Progress(
// Warning: (ae-missing-release-tag) "Ranker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
enum Ranker {
// (undocumented)
LONGEST_PATH = 'longest-path',
// (undocumented)
NETWORK_SIMPLEX = 'network-simplex',
// (undocumented)
TIGHT_TREE = 'tight-tree',
}
// Warning: (ae-missing-release-tag) "RenderLabelFunction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
type RenderLabelFunction = (props: RenderLabelProps<any>) => React.ReactNode;
// @public
type RenderLabelFunction<T = {}> = (
props: RenderLabelProps<T>,
) => React_2.ReactNode;
// Warning: (ae-missing-release-tag) "RenderLabelProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
//
// @public (undocumented)
type RenderLabelProps<T = CustomType> = {
// @public
type RenderLabelProps<T = unknown> = {
edge: DependencyEdge<T>;
};
// Warning: (ae-missing-release-tag) "RenderNodeFunction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
//
// @public (undocumented)
type RenderNodeFunction = (props: RenderNodeProps<any>) => React.ReactNode;
// @public
type RenderNodeFunction<T = {}> = (
props: RenderNodeProps<T>,
) => React_2.ReactNode;
// Warning: (ae-missing-release-tag) "RenderNodeProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
//
// @public (undocumented)
type RenderNodeProps<T = CustomType> = {
// @public
type RenderNodeProps<T = unknown> = {
node: DependencyNode<T>;
};
@@ -2498,6 +2472,8 @@ export type WarningPanelClassKey =
// Warnings were encountered during analysis:
//
// src/components/DependencyGraph/types.d.ts:14:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/core-components" does not have an export "DependencyNode"
// src/components/DependencyGraph/types.d.ts:18:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/core-components" does not have an export "DependencyNode"
// src/components/TabbedLayout/RoutedTabs.d.ts:9:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts
// src/components/Table/Table.d.ts:19:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts
// src/layout/ErrorBoundary/ErrorBoundary.d.ts:7:5 - (ae-forgotten-export) The symbol "SlackChannel" needs to be exported by the entry point index.d.ts
+1 -1
View File
@@ -37,7 +37,6 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@types/dagre": "^0.7.44",
"@types/react": "*",
"@types/react-sparklines": "^1.7.0",
"@types/react-text-truncate": "^0.14.0",
@@ -79,6 +78,7 @@
"@types/d3-selection": "^2.0.0",
"@types/d3-shape": "^3.0.1",
"@types/d3-zoom": "^2.0.0",
"@types/dagre": "^0.7.44",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
@@ -28,5 +28,5 @@ export function stringToColor(str: string) {
}
export function extractInitials(value: string) {
return value.match(/\b\w/g)!.join('').substring(0, 2);
return value.match(/\b\w/g)?.join('').substring(0, 2);
}
@@ -43,7 +43,9 @@ declare function ButtonType(props: ButtonProps): JSX.Element;
/**
* This wrapper is here to reset the color of the Link and make typescript happy.
*/
const LinkWrapper = (props: LinkProps) => <Link {...props} color="initial" />;
const LinkWrapper = React.forwardRef<any, LinkProps>((props, ref) => (
<Link ref={ref} {...props} color="initial" />
));
const ActualButton = React.forwardRef<any, ButtonProps>((props, ref) => (
<MaterialButton ref={ref} component={LinkWrapper} {...props} />
@@ -29,39 +29,148 @@ import {
Ranker,
RenderNodeFunction,
RenderLabelFunction,
GraphEdge,
GraphNode,
LabelPosition,
} from './types';
import { Node } from './Node';
import { Edge } from './Edge';
import { Edge, GraphEdge } from './Edge';
import { ARROW_MARKER_ID } from './constants';
export type DependencyGraphProps = React.SVGProps<SVGSVGElement> & {
edges: DependencyEdge[];
nodes: DependencyNode[];
/**
* Properties of {@link DependencyGraph}
*
* @remarks
* <NodeData> and <EdgeData> are useful when rendering custom or edge labels
*/
export interface DependencyGraphProps<NodeData, EdgeData>
extends React.SVGProps<SVGSVGElement> {
/**
* Edges of graph
*/
edges: DependencyEdge<EdgeData>[];
/**
* Nodes of Graph
*/
nodes: DependencyNode<NodeData>[];
/**
* Graph {@link DependencyGraphTypes.Direction | direction}
*
* @remarks
*
* Default: `DependencyGraphTypes.Direction.TOP_BOTTOM`
*/
direction?: Direction;
/**
* Node {@link DependencyGraphTypes.Alignment | alignment}
*/
align?: Alignment;
/**
* Margin between nodes on each rank
*
* @remarks
*
* Default: 50
*/
nodeMargin?: number;
/**
* Margin between edges
*
* @remarks
*
* Default: 10
*/
edgeMargin?: number;
/**
* Margin between each rank
*
* @remarks
*
* Default: 50
*/
rankMargin?: number;
/**
* Margin on left and right of whole graph
*
* @remarks
*
* Default: 0
*/
paddingX?: number;
/**
* Margin on top and bottom of whole graph
*
* @remarks
*
* Default: 0
*/
paddingY?: number;
/**
* Heuristic used to find set of edges that will make graph acyclic
*/
acyclicer?: 'greedy';
/**
* {@link DependencyGraphTypes.Ranker | Algorithm} used to rank nodes
*
* @remarks
*
* Default: `DependencyGraphTypes.Ranker.NETWORK_SIMPLEX`
*/
ranker?: Ranker;
/**
* {@link DependencyGraphTypes.LabelPosition | Position} of label in relation to edge
*
* @remarks
*
* Default: `DependencyGraphTypes.LabelPosition.RIGHT`
*/
labelPosition?: LabelPosition;
/**
* How much to move label away from edge
*
* @remarks
*
* Applies only when {@link DependencyGraphProps.labelPosition} is `DependencyGraphTypes.LabelPosition.LEFT` or
* `DependencyGraphTypes.LabelPosition.RIGHT`
*/
labelOffset?: number;
/**
* Minimum number of ranks to keep between connected nodes
*/
edgeRanks?: number;
/**
* Weight applied to edges in graph
*/
edgeWeight?: number;
renderNode?: RenderNodeFunction;
renderLabel?: RenderLabelFunction;
/**
* Custom node rendering component
*/
renderNode?: RenderNodeFunction<NodeData>;
/**
* Custom label rendering component
*/
renderLabel?: RenderLabelFunction<EdgeData>;
/**
* {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs | Defs} shared by rendered SVG to be used by
* {@link DependencyGraphProps.renderNode} and/or {@link DependencyGraphProps.renderLabel}
*/
defs?: SVGDefsElement | SVGDefsElement[];
/**
* Controls zoom behavior of graph
*
* @remarks
*
* Default: `enabled`
*/
zoom?: 'enabled' | 'disabled' | 'enable-on-click';
};
}
const WORKSPACE_ID = 'workspace';
export function DependencyGraph(props: DependencyGraphProps) {
/**
* Graph component used to visualize relations between entities
*/
export function DependencyGraph<NodeData, EdgeData>(
props: DependencyGraphProps<NodeData, EdgeData>,
) {
const {
edges,
nodes,
@@ -88,7 +197,7 @@ export function DependencyGraph(props: DependencyGraphProps) {
const [containerWidth, setContainerWidth] = React.useState<number>(100);
const [containerHeight, setContainerHeight] = React.useState<number>(100);
const graph = React.useRef<dagre.graphlib.Graph<{}>>(
const graph = React.useRef<dagre.graphlib.Graph<DependencyNode<NodeData>>>(
new dagre.graphlib.Graph(),
);
const [graphWidth, setGraphWidth] = React.useState<number>(
@@ -256,13 +365,13 @@ export function DependencyGraph(props: DependencyGraphProps) {
updateGraph,
]);
function setNode(id: string, node: DependencyNode) {
function setNode(id: string, node: DependencyNode<NodeData>) {
graph.current.setNode(id, node);
updateGraph();
return graph.current;
}
function setEdge(id: dagre.Edge, edge: DependencyEdge) {
function setEdge(id: dagre.Edge, edge: DependencyEdge<EdgeData>) {
graph.current.setEdge(id, edge);
updateGraph();
return graph.current;
@@ -303,7 +412,7 @@ export function DependencyGraph(props: DependencyGraphProps) {
viewBox={`0 0 ${graphWidth} ${graphHeight}`}
>
{graphEdges.map(e => {
const edge = graph.current.edge(e) as GraphEdge;
const edge = graph.current.edge(e) as GraphEdge<EdgeData>;
if (!edge) return null;
return (
<Edge
@@ -316,7 +425,7 @@ export function DependencyGraph(props: DependencyGraphProps) {
);
})}
{graphNodes.map((id: string) => {
const node = graph.current.node(id) as GraphNode;
const node = graph.current.node(id);
if (!node) return null;
return (
<Node
@@ -23,6 +23,10 @@ const fromNode = 'node';
const toNode = 'other-node';
const edge = {
points: [
{ x: 10, y: 20 },
{ x: 20, y: 20 },
],
from: fromNode,
to: toNode,
};
@@ -38,10 +42,6 @@ const renderElement = jest.fn((props: RenderLabelProps) => (
));
const minProps = {
points: [
{ x: 10, y: 20 },
{ x: 20, y: 20 },
],
id,
setEdge,
renderElement,
@@ -20,13 +20,26 @@ import isFinite from 'lodash/isFinite';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { BackstageTheme } from '@backstage/theme';
import {
GraphEdge,
RenderLabelProps,
RenderLabelFunction,
DependencyEdge,
LabelPosition,
} from './types';
import { ARROW_MARKER_ID, EDGE_TEST_ID, LABEL_TEST_ID } from './constants';
import { DefaultLabel } from './DefaultLabel';
import dagre from 'dagre';
/* Based on: https://github.com/dagrejs/dagre/wiki#configuring-the-layout */
export type EdgeProperties = {
label?: string;
width?: number;
height?: number;
labeloffset?: number;
labelpos?: LabelPosition;
minlen?: number;
weight?: number;
};
export type GraphEdge<T> = DependencyEdge<T> & dagre.GraphEdge & EdgeProperties;
export type DependencyGraphEdgeClassKey = 'path' | 'label';
@@ -47,14 +60,19 @@ const useStyles = makeStyles(
type EdgePoint = dagre.GraphEdge['points'][0];
export type EdgeComponentProps<T = any> = {
export type EdgeComponentProps<T = unknown> = {
id: dagre.Edge;
edge: GraphEdge<T>;
render?: RenderLabelFunction;
setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>;
render?: RenderLabelFunction<T>;
setEdge: (
id: dagre.Edge,
edge: DependencyEdge<T>,
) => dagre.graphlib.Graph<{}>;
};
const renderDefault = (props: RenderLabelProps) => <DefaultLabel {...props} />;
const renderDefault = (props: RenderLabelProps<unknown>) => (
<DefaultLabel {...props} />
);
const createPath = d3Shape
.line<EdgePoint>()
@@ -62,13 +80,14 @@ const createPath = d3Shape
.y(d => d.y)
.curve(d3Shape.curveMonotoneX);
export function Edge({
export function Edge<EdgeData>({
render = renderDefault,
setEdge,
id,
edge,
}: EdgeComponentProps) {
const { x = 0, y = 0, width, height, points, ...labelProps } = edge;
}: EdgeComponentProps<EdgeData>) {
const { x = 0, y = 0, width, height, points } = edge;
const labelProps: DependencyEdge<EdgeData> = edge;
const classes = useStyles();
const labelRef = React.useRef<SVGGElement>(null);
@@ -20,21 +20,16 @@ import { render } from '@testing-library/react';
import { Node } from './Node';
import { RenderNodeProps } from './types';
const node = { id: 'abc' };
const node = { id: 'abc', x: 0, y: 0, width: 0, height: 0 };
const setNode = jest.fn(() => new dagre.graphlib.Graph());
const renderElement = jest.fn((props: RenderNodeProps) => (
<text>{props.node.id}</text>
));
const minProps = {
id: node.id,
node,
setNode,
render: renderElement,
x: 0,
y: 0,
width: 0,
height: 0,
};
describe('<Node />', () => {
@@ -50,7 +45,7 @@ describe('<Node />', () => {
it('renders the supplied element', () => {
const { getByText } = render(<Node {...minProps} />);
expect(getByText(minProps.id)).toBeInTheDocument();
expect(getByText(minProps.node.id)).toBeInTheDocument();
});
it('passes down node properties to the render method', () => {
@@ -62,13 +57,13 @@ describe('<Node />', () => {
it('calls setNode with node ID and actual size after rendering', () => {
const { getByText } = render(<Node {...minProps} />);
expect(getByText(minProps.id)).toBeInTheDocument();
expect(getByText(minProps.node.id)).toBeInTheDocument();
// Updates the node in the graph
expect(setNode).toHaveBeenCalledWith(node.id, {
...node,
height: 100,
width: 100,
...node,
});
// Does not pass down width/height to node
@@ -17,8 +17,9 @@
import React from 'react';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { DefaultNode } from './DefaultNode';
import { RenderNodeFunction, RenderNodeProps, GraphNode } from './types';
import { RenderNodeFunction, RenderNodeProps, DependencyNode } from './types';
import { NODE_TEST_ID } from './constants';
import dagre from 'dagre';
export type DependencyGraphNodeClassKey = 'node';
@@ -31,20 +32,23 @@ const useStyles = makeStyles(
{ name: 'BackstageDependencyGraphNode' },
);
export type NodeComponentProps<T = any> = {
export type GraphNode<T> = dagre.Node<DependencyNode<T>>;
export type NodeComponentProps<T> = {
node: GraphNode<T>;
render?: RenderNodeFunction;
render?: RenderNodeFunction<T>;
setNode: dagre.graphlib.Graph['setNode'];
};
const renderDefault = (props: RenderNodeProps) => <DefaultNode {...props} />;
export function Node({
export function Node<T>({
render = renderDefault,
setNode,
node,
}: NodeComponentProps) {
const { width, height, x = 0, y = 0, ...nodeProps } = node;
}: NodeComponentProps<T>) {
const { width, height, x = 0, y = 0 } = node;
const nodeProps: DependencyNode<T> = node;
const classes = useStyles();
const nodeRef = React.useRef<SVGGElement | null>(null);
@@ -14,73 +14,132 @@
* limitations under the License.
*/
import dagre from 'dagre';
/**
* Types used to customize and provide data to {@link DependencyGraph}
*
* @packageDocumentation
*/
type CustomType = { [customKey: string]: any };
import React from 'react';
/* Edges */
export type DependencyEdge<T = CustomType> = T & {
/**
* Edge of {@link DependencyGraph}
*/
export type DependencyEdge<T = {}> = T & {
/**
* ID of {@link DependencyNode} from where the Edge start
*/
from: string;
/**
* ID of {@link DependencyNode} to where the Edge goes to
*/
to: string;
/**
* Label assigned and rendered with the Edge
*/
label?: string;
};
export type GraphEdge<T = CustomType> = DependencyEdge<T> &
dagre.GraphEdge &
EdgeProperties;
/**
* Properties of {@link DependencyGraphTypes.RenderLabelFunction} for {@link DependencyGraphTypes.DependencyEdge}
*/
export type RenderLabelProps<T = unknown> = { edge: DependencyEdge<T> };
export type RenderLabelProps<T = CustomType> = { edge: DependencyEdge<T> };
export type RenderLabelFunction = (
props: RenderLabelProps<any>,
/**
* Custom React component for edge labels
*/
export type RenderLabelFunction<T = {}> = (
props: RenderLabelProps<T>,
) => React.ReactNode;
/* Nodes */
export type DependencyNode<T = CustomType> = T & {
/**
* Node of {@link DependencyGraph}
*/
export type DependencyNode<T = {}> = T & {
id: string;
};
export type GraphNode<T = CustomType> = dagre.Node<DependencyNode<T>>;
/**
* Properties of {@link DependencyGraphTypes.RenderNodeFunction} for {@link DependencyGraphTypes.DependencyNode}
*/
export type RenderNodeProps<T = unknown> = { node: DependencyNode<T> };
export type RenderNodeProps<T = CustomType> = { node: DependencyNode<T> };
export type RenderNodeFunction = (
props: RenderNodeProps<any>,
/**
* Custom React component for graph {@link DependencyGraphTypes.DependencyNode}
*/
export type RenderNodeFunction<T = {}> = (
props: RenderNodeProps<T>,
) => React.ReactNode;
/* Based on: https://github.com/dagrejs/dagre/wiki#configuring-the-layout */
export type EdgeProperties = {
label?: string;
width?: number;
height?: number;
labeloffset?: number;
labelpos?: LabelPosition;
minlen?: number;
weight?: number;
[customKey: string]: any;
};
/**
* Graph direction
*/
export enum Direction {
/**
* Top to Bottom
*/
TOP_BOTTOM = 'TB',
/**
* Bottom to Top
*/
BOTTOM_TOP = 'BT',
/**
* Left to Right
*/
LEFT_RIGHT = 'LR',
/**
* Right to Left
*/
RIGHT_LEFT = 'RL',
}
/**
* Node alignment
*/
export enum Alignment {
/**
* Up Left
*/
UP_LEFT = 'UL',
/**
* Up Right
*/
UP_RIGHT = 'UR',
/**
* Down Left
*/
DOWN_LEFT = 'DL',
/**
* Down Right
*/
DOWN_RIGHT = 'DR',
}
/**
* Algorithm used to rand nodes in graph
*/
export enum Ranker {
/**
* {@link https://en.wikipedia.org/wiki/Network_simplex_algorithm | Network Simplex} algorithm
*/
NETWORK_SIMPLEX = 'network-simplex',
/**
* Tight Tree algorithm
*/
TIGHT_TREE = 'tight-tree',
/**
* Longest path algorithm
*
* @remarks
*
* Simplest and fastest
*/
LONGEST_PATH = 'longest-path',
}
/**
* Position of label in relation to the edge
*/
export enum LabelPosition {
LEFT = 'l',
RIGHT = 'r',
@@ -1,4 +1,5 @@
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -6,6 +7,7 @@ export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addProcessor(new ScaffolderEntitiesProcessor());
const { processingEngine, router } = await builder.build();
await processingEngine.start();
return router;
-1
View File
@@ -37,7 +37,6 @@
"@backstage/plugin-catalog": "^0.7.0",
"@backstage/plugin-catalog-react": "^0.5.2",
"@backstage/theme": "^0.2.10",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
+24 -6
View File
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
@@ -32,7 +34,6 @@ export type AuthProviderFactoryOptions = {
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
@@ -74,6 +75,16 @@ export type AuthResponse<ProviderInfo> = {
backstageIdentity?: BackstageIdentity;
};
// Warning: (ae-missing-release-tag) "AwsAlbProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AwsAlbProviderOptions = {
authHandler?: AuthHandler<AwsAlbResult>;
signIn: {
resolver: SignInResolver<AwsAlbResult>;
};
};
// Warning: (ae-missing-release-tag) "BackstageIdentity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -135,6 +146,13 @@ export const bitbucketUserIdSignInResolver: SignInResolver<BitbucketOAuthResult>
// @public (undocumented)
export const bitbucketUsernameSignInResolver: SignInResolver<BitbucketOAuthResult>;
// Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createAwsAlbProvider: (
options?: AwsAlbProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createBitbucketProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -526,9 +544,9 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/bitbucket/provider.d.ts:61:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/bitbucket/provider.d.ts:69:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:85:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:99:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:121:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
```
@@ -17,8 +17,14 @@ import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import { JWT } from 'jose';
import { AwsAlbAuthProvider } from './provider';
import { AuthResponse } from '../types';
import {
ALB_ACCESSTOKEN_HEADER,
ALB_JWT_HEADER,
AwsAlbAuthProvider,
} from './provider';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { makeProfileInfo } from '../../lib/passport';
const jwtMock = JWT as jest.Mocked<any>;
@@ -29,9 +35,21 @@ yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw==
-----END PUBLIC KEY-----
`;
};
const mockJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94';
const mockAccessToken = 'ACCESS_TOKEN';
const mockClaims = {
sub: '1234567890',
name: 'User Name',
family_name: 'Name',
given_name: 'User',
picture: 'PICTURE_URL',
email: 'user.name@email.test',
exp: 1632833763,
iss: 'ISSUER_URL',
};
jest.mock('jose');
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
@@ -43,53 +61,48 @@ jest.mock('cross-fetch', () => ({
},
}));
const identityResolutionCallbackMock = async (): Promise<AuthResponse<any>> => {
return {
backstageIdentity: {
id: 'foo',
idToken: '',
},
profile: {
displayName: 'Foo Bar',
},
providerInfo: {},
};
};
const identityResolutionCallbackRejectedMock = async (): Promise<
AuthResponse<any>
> => {
throw new Error('failed');
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('AwsALBAuthProvider', () => {
const catalogApi = {
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
describe('AwsAlbAuthProvider', () => {
const tokenIssuer: TokenIssuer = {
listPublicKeys: jest.fn(),
async issueToken(params) {
return `token-for-${params.claims.sub}`;
},
};
const catalogIdentityClient: CatalogIdentityClient = {
findUser: jest.fn(),
} as unknown as CatalogIdentityClient;
const mockRequest = {
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
}),
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(() => {
header: jest.fn(name => {
if (name === ALB_JWT_HEADER) {
return mockJwt;
} else if (name === ALB_ACCESSTOKEN_HEADER) {
return mockAccessToken;
}
return undefined;
}),
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(name => {
if (name === ALB_ACCESSTOKEN_HEADER) {
return mockAccessToken;
}
return undefined;
}),
} as unknown as express.Request;
const mockRequestWithoutAccessToken = {
header: jest.fn(name => {
if (name === ALB_JWT_HEADER) {
return mockJwt;
}
return undefined;
}),
} as unknown as express.Request;
const mockResponse = {
end: jest.fn(),
header: () => jest.fn(),
@@ -97,38 +110,78 @@ describe('AwsALBAuthProvider', () => {
status: jest.fn(),
} as unknown as express.Response;
describe('should transform to type OAuthResponse', () => {
describe('should transform to type AwsAlbResponse', () => {
it('when JWT is valid and identity is resolved successfully', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockImplementationOnce(() => ({
sub: 'foo',
}));
jwtMock.verify.mockReturnValueOnce(mockClaims);
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledWith({
backstageIdentity: {
id: 'foo',
idToken: '',
id: 'user.name',
token: 'TOKEN',
},
profile: {
displayName: 'Foo Bar',
displayName: 'User Name',
email: 'user.name@email.test',
picture: 'PICTURE_URL',
},
providerInfo: {
accessToken: mockAccessToken,
expiresInSeconds: mockClaims.exp,
},
providerInfo: {},
});
});
});
describe('should fail when', () => {
it('Access token is missing', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
await provider.refresh(mockRequestWithoutAccessToken, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('JWT is missing', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
await provider.refresh(mockRequestWithoutJwt, mockResponse);
@@ -137,10 +190,18 @@ describe('AwsALBAuthProvider', () => {
});
it('JWT is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foo',
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockImplementationOnce(() => {
@@ -152,11 +213,19 @@ describe('AwsALBAuthProvider', () => {
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackMock,
issuer: 'foobar',
it('issuer is missing', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockReturnValueOnce({});
@@ -165,14 +234,68 @@ describe('AwsALBAuthProvider', () => {
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('identity resolution callback rejects', async () => {
const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, {
region: 'us-west-2',
identityResolutionCallback: identityResolutionCallbackRejectedMock,
issuer: 'foo',
it('issuer is invalid', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockReturnValueOnce({});
jwtMock.verify.mockReturnValueOnce({
iss: 'INVALID_ISSUE_URL',
});
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('SignInResolver rejects', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
signInResolver: async () => {
throw new Error();
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
await provider.refresh(mockRequest, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
});
it('AuthHandler rejects', async () => {
const provider = new AwsAlbAuthProvider({
region: 'eu-west-1',
issuer: 'ISSUER_URL',
logger: getVoidLogger(),
catalogIdentityClient,
tokenIssuer,
authHandler: async () => {
throw new Error();
},
signInResolver: async () => {
return { id: 'user.name', token: 'TOKEN' };
},
});
jwtMock.verify.mockReturnValueOnce(mockClaims);
await provider.refresh(mockRequest, mockResponse);
@@ -14,9 +14,11 @@
* limitations under the License.
*/
import {
AuthProviderFactoryOptions,
AuthHandler,
AuthProviderFactory,
AuthProviderRouteHandlers,
ExperimentalIdentityResolver,
AuthResponse,
SignInResolver,
} from '../types';
import express from 'express';
import fetch from 'cross-fetch';
@@ -25,66 +27,101 @@ import { KeyObject } from 'crypto';
import { Logger } from 'winston';
import NodeCache from 'node-cache';
import { JWT } from 'jose';
import { CatalogApi } from '@backstage/catalog-client';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { Profile as PassportProfile } from 'passport';
import { makeProfileInfo } from '../../lib/passport';
import { AuthenticationError } from '@backstage/errors';
const ALB_JWT_HEADER = 'x-amzn-oidc-data';
/**
* A callback function that receives a verified JWT and returns a UserEntity
* @param {payload} The verified JWT payload
*/
type AwsAlbAuthProviderOptions = {
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken';
type Options = {
region: string;
issuer?: string;
identityResolutionCallback: ExperimentalIdentityResolver;
logger: Logger;
authHandler: AuthHandler<AwsAlbResult>;
signInResolver: SignInResolver<AwsAlbResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
};
export const getJWTHeaders = (input: string) => {
export const getJWTHeaders = (input: string): AwsAlbHeaders => {
const encoded = input.split('.')[0];
return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
};
export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
private logger: Logger;
private readonly catalogClient: CatalogApi;
private options: AwsAlbAuthProviderOptions;
private readonly keyCache: NodeCache;
export type AwsAlbHeaders = {
alg: string;
kid: string;
signer: string;
iss: string;
client: string;
exp: number;
};
constructor(
logger: Logger,
catalogClient: CatalogApi,
options: AwsAlbAuthProviderOptions,
) {
this.logger = logger;
this.catalogClient = catalogClient;
this.options = options;
export type AwsAlbClaims = {
sub: string;
name: string;
family_name: string;
given_name: string;
picture: string;
email: string;
exp: number;
iss: string;
};
export type AwsAlbResult = {
fullProfile: PassportProfile;
expiresInSeconds?: number;
accessToken: string;
};
export type AwsAlbProviderInfo = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
};
export type AwsAlbResponse = AuthResponse<AwsAlbProviderInfo>;
export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
private readonly region: string;
private readonly issuer?: string;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
private readonly keyCache: NodeCache;
private readonly authHandler: AuthHandler<AwsAlbResult>;
private readonly signInResolver: SignInResolver<AwsAlbResult>;
constructor(options: Options) {
this.region = options.region;
this.issuer = options.issuer;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this.keyCache = new NodeCache({ stdTTL: 3600 });
}
frameHandler(): Promise<void> {
return Promise.resolve(undefined);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const jwt = req.header(ALB_JWT_HEADER);
if (jwt !== undefined) {
try {
const headers = getJWTHeaders(jwt);
const key = await this.getKey(headers.kid);
const payload = JWT.verify(jwt, key);
if (this.options.issuer && headers.iss !== this.options.issuer) {
throw new Error('issuer mismatch on JWT');
}
const resolvedEntity = await this.options.identityResolutionCallback(
payload,
this.catalogClient,
);
res.json(resolvedEntity);
} catch (e) {
this.logger.error('exception occurred during JWT processing', e);
res.status(401);
res.end();
}
} else {
try {
const result = await this.getResult(req);
const response = await this.handleResult(result);
res.json(response);
} catch (e) {
this.logger.error('Exception occurred during AWS ALB token refresh', e);
res.status(401);
res.end();
}
@@ -94,13 +131,85 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
return Promise.resolve(undefined);
}
private async getResult(req: express.Request): Promise<AwsAlbResult> {
const jwt = req.header(ALB_JWT_HEADER);
const accessToken = req.header(ALB_ACCESSTOKEN_HEADER);
if (jwt === undefined) {
throw new AuthenticationError(
`Missing ALB OIDC header: ${ALB_JWT_HEADER}`,
);
}
if (accessToken === undefined) {
throw new AuthenticationError(
`Missing ALB OIDC header: ${ALB_ACCESSTOKEN_HEADER}`,
);
}
try {
const headers = getJWTHeaders(jwt);
const key = await this.getKey(headers.kid);
const claims = JWT.verify(jwt, key) as AwsAlbClaims;
if (this.issuer && claims.iss !== this.issuer) {
throw new AuthenticationError('Issuer mismatch on JWT token');
}
const fullProfile: PassportProfile = {
provider: 'unknown',
id: claims.sub,
displayName: claims.name,
username: claims.email.split('@')[0].toLowerCase(),
name: {
familyName: claims.family_name,
givenName: claims.given_name,
},
emails: [{ value: claims.email.toLowerCase() }],
photos: [{ value: claims.picture }],
};
return {
fullProfile,
expiresInSeconds: claims.exp,
accessToken,
};
} catch (e) {
throw new Error(`Exception occurred during JWT processing: ${e}`);
}
}
private async handleResult(result: AwsAlbResult): Promise<AwsAlbResponse> {
const { profile } = await this.authHandler(result);
const backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
return {
providerInfo: {
accessToken: result.accessToken,
expiresInSeconds: result.expiresInSeconds,
},
backstageIdentity,
profile,
};
}
async getKey(keyId: string): Promise<KeyObject> {
const optionalCacheKey = this.keyCache.get<KeyObject>(keyId);
if (optionalCacheKey) {
return crypto.createPublicKey(optionalCacheKey);
}
const keyText: string = await fetch(
`https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`,
`https://public-keys.auth.elb.${this.region}.amazonaws.com/${keyId}`,
).then(response => response.text());
const keyValue = crypto.createPublicKey(keyText);
this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' }));
@@ -108,26 +217,58 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
}
}
export type AwsAlbProviderOptions = {};
export type AwsAlbProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<AwsAlbResult>;
export const createAwsAlbProvider = (_options?: AwsAlbProviderOptions) => {
return ({
logger,
catalogApi,
config,
identityResolver,
}: AuthProviderFactoryOptions) => {
const region = config.getString('region');
const issuer = config.getOptionalString('iss');
if (identityResolver !== undefined) {
return new AwsAlbAuthProvider(logger, catalogApi, {
region,
issuer,
identityResolutionCallback: identityResolver,
});
}
throw new Error(
'Identity resolver is required to use this authentication provider',
);
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<AwsAlbResult>;
};
};
export const createAwsAlbProvider = (
options?: AwsAlbProviderOptions,
): AuthProviderFactory => {
return ({ config, tokenIssuer, catalogApi, logger }) => {
const region = config.getString('region');
const issuer = config.getOptionalString('iss');
if (options?.signIn.resolver === undefined) {
throw new Error(
'SignInResolver is required to use this authentication provider',
);
}
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<AwsAlbResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const signInResolver = options?.signIn.resolver;
return new AwsAlbAuthProvider({
region,
issuer,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
logger,
});
};
};
@@ -21,6 +21,7 @@ export * from './microsoft';
export * from './oauth2';
export * from './okta';
export * from './bitbucket';
export * from './aws-alb';
export { factories as defaultAuthProviderFactories } from './factories';
@@ -119,19 +119,6 @@ export interface AuthProviderRouteHandlers {
logout?(req: express.Request, res: express.Response): Promise<void>;
}
/**
* EXPERIMENTAL - this will almost certainly break in a future release.
*
* Used to resolve an identity from auth information in some auth providers.
*/
export type ExperimentalIdentityResolver = (
/**
* An object containing information specific to the auth provider.
*/
payload: object,
catalogApi: CatalogApi,
) => Promise<AuthResponse<any>>;
export type AuthProviderFactoryOptions = {
providerId: string;
globalConfig: AuthProviderConfig;
@@ -140,7 +127,6 @@ export type AuthProviderFactoryOptions = {
tokenIssuer: TokenIssuer;
discovery: PluginEndpointDiscovery;
catalogApi: CatalogApi;
identityResolver?: ExperimentalIdentityResolver;
};
export type AuthProviderFactory = (
+1 -1
View File
@@ -47,7 +47,7 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
export type RepoBuild = {
id?: number;
title: string;
link: string;
link?: string;
status?: BuildStatus;
result?: BuildResult;
queueTime?: Date;
@@ -94,4 +94,100 @@ describe('AzureDevOpsApi', () => {
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('repoBuildFromBuild with undefined status', () => {
it('should return BuildStatus of None for status', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: undefined,
result: BuildResult.Succeeded,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
sourceBranch: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.None,
result: BuildResult.Succeeded,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
source: 'refs/heads/develop (f4f78b31)',
};
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('repoBuildFromBuild with undefined result', () => {
it('should return BuildResult of None for result', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.InProgress,
result: undefined,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
sourceBranch: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
source: 'refs/heads/develop (f4f78b31)',
};
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('repoBuildFromBuild with undefined link', () => {
it('should return empty string for link', () => {
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.InProgress,
result: undefined,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
sourceBranch: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: undefined,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: '',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
source: 'refs/heads/develop (f4f78b31)',
};
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
});
@@ -17,7 +17,11 @@
import { Logger } from 'winston';
import { WebApi } from 'azure-devops-node-api';
import { RepoBuild } from './types';
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import {
Build,
BuildResult,
BuildStatus,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
export class AzureDevOpsApi {
constructor(
@@ -97,9 +101,9 @@ export function repoBuildFromBuild(build: Build) {
title: [build.definition?.name, build.buildNumber]
.filter(Boolean)
.join(' - '),
link: build._links?.web.href,
status: build.status,
result: build.result,
link: build._links?.web.href ? build._links?.web.href : '',
status: build.status ? build.status : BuildStatus.None,
result: build.result ? build.result : BuildResult.None,
queueTime: build.queueTime,
source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`,
};
@@ -22,7 +22,7 @@ import {
export type RepoBuild = {
id?: number;
title: string;
link: string;
link?: string;
status?: BuildStatus;
result?: BuildResult;
queueTime?: Date;
+14 -4
View File
@@ -93,13 +93,23 @@ export const EntityCatalogGraphCard: ({
}) => JSX.Element;
// @public
export type EntityEdge = DependencyGraphTypes.DependencyEdge<{
export type EntityEdge = DependencyGraphTypes.DependencyEdge<EntityEdgeData>;
// Warning: (ae-missing-release-tag) "EntityEdgeData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type EntityEdgeData = {
relations: string[];
label: 'visible';
}>;
};
// @public
export type EntityNode = DependencyGraphTypes.DependencyNode<{
export type EntityNode = DependencyGraphTypes.DependencyNode<EntityNodeData>;
// Warning: (ae-missing-release-tag) "EntityNodeData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type EntityNodeData = {
name: string;
kind?: string;
title?: string;
@@ -107,7 +117,7 @@ export type EntityNode = DependencyGraphTypes.DependencyNode<{
focused?: boolean;
color?: 'primary' | 'secondary' | 'default';
onClick?: MouseEventHandler<unknown>;
}>;
};
// @public
export const EntityRelationsGraph: ({
@@ -31,12 +31,6 @@ describe('<CustomLabel />', () => {
relations: [RELATION_PARENT_OF],
from: 'from-id',
to: 'to-id',
id: 'id',
x: 111,
y: 222,
width: 100,
height: 25,
points: [],
}}
/>
</svg>,
@@ -54,12 +48,6 @@ describe('<CustomLabel />', () => {
relations: [RELATION_PARENT_OF, RELATION_CHILD_OF],
from: 'from-id',
to: 'to-id',
id: 'id',
x: 111,
y: 222,
width: 100,
height: 25,
points: [],
}}
/>
</svg>,
@@ -17,7 +17,7 @@ import { DependencyGraphTypes } from '@backstage/core-components';
import { BackstageTheme } from '@backstage/theme';
import makeStyles from '@material-ui/core/styles/makeStyles';
import React from 'react';
import { GraphEdge } from './types';
import { EntityEdgeData } from './types';
import classNames from 'classnames';
const useStyles = makeStyles((theme: BackstageTheme) => ({
@@ -31,7 +31,7 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({
export function CustomLabel({
edge: { relations },
}: DependencyGraphTypes.RenderLabelProps<GraphEdge>) {
}: DependencyGraphTypes.RenderLabelProps<EntityEdgeData>) {
const classes = useStyles();
return (
<text className={classes.text} textAnchor="middle">
@@ -36,10 +36,6 @@ describe('<CustomNode />', () => {
name: 'name',
namespace: 'namespace',
id: 'kind:namespace/name',
x: 111,
y: 222,
width: 100,
height: 25,
color: 'primary',
}}
/>
@@ -59,10 +55,6 @@ describe('<CustomNode />', () => {
name: 'name',
namespace: 'default',
id: 'kind:default/name',
x: 111,
y: 222,
width: 100,
height: 25,
}}
/>
</svg>,
@@ -83,10 +75,6 @@ describe('<CustomNode />', () => {
namespace: 'namespace',
onClick,
id: 'kind:namespace/name',
x: 111,
y: 222,
width: 100,
height: 25,
}}
/>
</svg>,
@@ -108,10 +96,6 @@ describe('<CustomNode />', () => {
namespace: 'namespace',
title: 'Custom Title',
id: 'kind:namespace/name',
x: 111,
y: 222,
width: 100,
height: 25,
}}
/>
</svg>,
@@ -20,7 +20,7 @@ import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { EntityKindIcon } from './EntityKindIcon';
import { GraphNode } from './types';
import { EntityNodeData } from './types';
const useStyles = makeStyles((theme: BackstageTheme) => ({
node: {
@@ -65,7 +65,7 @@ export function CustomNode({
title,
onClick,
},
}: DependencyGraphTypes.RenderNodeProps<GraphNode>) {
}: DependencyGraphTypes.RenderNodeProps<EntityNodeData>) {
const classes = useStyles();
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
@@ -17,4 +17,9 @@ export { EntityRelationsGraph } from './EntityRelationsGraph';
export { ALL_RELATION_PAIRS } from './relations';
export type { RelationPairs } from './relations';
export { Direction } from './types';
export type { EntityEdge, EntityNode } from './types';
export type {
EntityEdgeData,
EntityEdge,
EntityNodeData,
EntityNode,
} from './types';
@@ -17,11 +17,9 @@ import { DependencyGraphTypes } from '@backstage/core-components';
import { MouseEventHandler } from 'react';
/**
* Edge between two entities.
*
* @public
* Additional Data for entities
*/
export type EntityEdge = DependencyGraphTypes.DependencyEdge<{
export type EntityEdgeData = {
/**
* Up to two relations that are connecting an entity.
*/
@@ -31,14 +29,19 @@ export type EntityEdge = DependencyGraphTypes.DependencyEdge<{
*/
// Not used, but has to be non empty to draw a label at all!
label: 'visible';
}>;
};
/**
* Node representing an entity.
* Edge between two entities.
*
* @public
*/
export type EntityNode = DependencyGraphTypes.DependencyNode<{
export type EntityEdge = DependencyGraphTypes.DependencyEdge<EntityEdgeData>;
/**
* Additional data for Entity Node
*/
export type EntityNodeData = {
/**
* Name of the entity.
*/
@@ -68,11 +71,14 @@ export type EntityNode = DependencyGraphTypes.DependencyNode<{
* Optional click handler.
*/
onClick?: MouseEventHandler<unknown>;
}>;
};
export type GraphEdge = DependencyGraphTypes.GraphEdge<EntityEdge>;
export type GraphNode = DependencyGraphTypes.GraphNode<EntityNode>;
/**
* Node representing an entity.
*
* @public
*/
export type EntityNode = DependencyGraphTypes.DependencyNode<EntityNodeData>;
/**
* Render direction of the graph.
@@ -20,6 +20,7 @@ import {
RELATION_OWNED_BY,
RELATION_PART_OF,
} from '@backstage/catalog-model';
import { OverflowTooltip, TableColumn } from '@backstage/core-components';
import React from 'react';
import { getEntityRelations } from '../../utils';
import {
@@ -27,7 +28,6 @@ import {
EntityRefLinks,
formatEntityRefTitle,
} from '../EntityRefLink';
import { OverflowTooltip, TableColumn } from '@backstage/core-components';
export function createEntityRefColumn<T extends Entity>({
defaultKind,
@@ -35,9 +35,12 @@ export function createEntityRefColumn<T extends Entity>({
defaultKind?: string;
}): TableColumn<T> {
function formatContent(entity: T): string {
return formatEntityRefTitle(entity, {
defaultKind,
});
return (
entity.metadata?.title ||
formatEntityRefTitle(entity, {
defaultKind,
})
);
}
return {
@@ -14,10 +14,15 @@
* limitations under the License.
*/
import React from 'react';
import { EntityRefLink, EntityRefLinks } from '@backstage/plugin-catalog-react';
import {
formatEntityRefTitle,
EntityRefLink,
EntityRefLinks,
} from '@backstage/plugin-catalog-react';
import { Chip } from '@material-ui/core';
import { EntityRow } from './types';
import { OverflowTooltip, TableColumn } from '@backstage/core-components';
import { Entity } from '@backstage/catalog-model';
type NameColumnProps = {
defaultKind?: string;
@@ -26,10 +31,24 @@ type NameColumnProps = {
export function createNameColumn(
props?: NameColumnProps,
): TableColumn<EntityRow> {
function formatContent(entity: Entity): string {
return (
entity.metadata?.title ||
formatEntityRefTitle(entity, {
defaultKind: props?.defaultKind,
})
);
}
return {
title: 'Name',
field: 'resolved.name',
highlight: true,
customSort({ entity: entity1 }, { entity: entity2 }) {
// TODO: We could implement this more efficiently by comparing field by field.
// This has similar issues as above.
return formatContent(entity1).localeCompare(formatContent(entity2));
},
render: ({ entity }) => (
<EntityRefLink
entityRef={entity}
+18 -8
View File
@@ -46,19 +46,15 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "CustomResource" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface CustomResource {
export interface CustomResource extends ObjectToFetch {
// (undocumented)
apiVersion: string;
// (undocumented)
group: string;
// (undocumented)
plural: string;
objectType: 'customresources';
}
// Warning: (ae-missing-release-tag) "DEFAULT_OBJECTS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DEFAULT_OBJECTS: KubernetesObjectTypes[];
export const DEFAULT_OBJECTS: ObjectToFetch[];
// Warning: (ae-missing-release-tag) "FetchResponseWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -139,11 +135,25 @@ export interface ObjectFetchParams {
// (undocumented)
labelSelector: string;
// (undocumented)
objectTypesToFetch: Set<KubernetesObjectTypes>;
objectTypesToFetch: Set<ObjectToFetch>;
// (undocumented)
serviceId: string;
}
// Warning: (ae-missing-release-tag) "ObjectToFetch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface ObjectToFetch {
// (undocumented)
apiVersion: string;
// (undocumented)
group: string;
// (undocumented)
objectType: KubernetesObjectTypes;
// (undocumented)
plural: string;
}
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -19,8 +19,8 @@ import {
ClusterDetails,
CustomResource,
KubernetesFetcher,
KubernetesObjectTypes,
KubernetesServiceLocator,
ObjectToFetch,
} from '../types/types';
import {
ClusterObjects,
@@ -29,14 +29,49 @@ import {
import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types';
import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator';
export const DEFAULT_OBJECTS: KubernetesObjectTypes[] = [
'pods',
'services',
'configmaps',
'deployments',
'replicasets',
'horizontalpodautoscalers',
'ingresses',
export const DEFAULT_OBJECTS: ObjectToFetch[] = [
{
group: '',
apiVersion: 'v1',
plural: 'pods',
objectType: 'pods',
},
{
group: '',
apiVersion: 'v1',
plural: 'services',
objectType: 'services',
},
{
group: '',
apiVersion: 'v1',
plural: 'configmaps',
objectType: 'configmaps',
},
{
group: 'apps',
apiVersion: 'v1',
plural: 'deployments',
objectType: 'deployments',
},
{
group: 'apps',
apiVersion: 'v1',
plural: 'replicasets',
objectType: 'replicasets',
},
{
group: 'autoscaling',
apiVersion: 'v1',
plural: 'horizontalpodautoscalers',
objectType: 'horizontalpodautoscalers',
},
{
group: 'networking.k8s.io',
apiVersion: 'v1',
plural: 'ingresses',
objectType: 'ingresses',
},
];
export interface KubernetesFanOutHandlerOptions {
@@ -44,7 +79,7 @@ export interface KubernetesFanOutHandlerOptions {
fetcher: KubernetesFetcher;
serviceLocator: KubernetesServiceLocator;
customResources: CustomResource[];
objectTypesToFetch?: KubernetesObjectTypes[];
objectTypesToFetch?: ObjectToFetch[];
}
export class KubernetesFanOutHandler {
@@ -52,7 +87,7 @@ export class KubernetesFanOutHandler {
private readonly fetcher: KubernetesFetcher;
private readonly serviceLocator: KubernetesServiceLocator;
private readonly customResources: CustomResource[];
private readonly objectTypesToFetch: KubernetesObjectTypes[];
private readonly objectTypesToFetch: Set<ObjectToFetch>;
constructor({
logger,
@@ -65,7 +100,7 @@ export class KubernetesFanOutHandler {
this.fetcher = fetcher;
this.serviceLocator = serviceLocator;
this.customResources = customResources;
this.objectTypesToFetch = objectTypesToFetch;
this.objectTypesToFetch = new Set(objectTypesToFetch);
}
async getKubernetesObjectsByEntity(requestBody: KubernetesRequestBody) {
@@ -109,7 +144,7 @@ export class KubernetesFanOutHandler {
.fetchObjectsForService({
serviceId: entityName,
clusterDetails: clusterDetailsItem,
objectTypesToFetch: new Set(this.objectTypesToFetch),
objectTypesToFetch: this.objectTypesToFetch,
labelSelector,
customResources: this.customResources,
})
@@ -16,6 +16,22 @@
import { getVoidLogger } from '@backstage/backend-common';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { ObjectToFetch } from '../types/types';
const OBJECTS_TO_FETCH = new Set<ObjectToFetch>([
{
group: '',
apiVersion: 'v1',
plural: 'pods',
objectType: 'pods',
},
{
group: '',
apiVersion: 'v1',
plural: 'services',
objectType: 'services',
},
]);
describe('KubernetesFetcher', () => {
let clientMock: any;
@@ -25,15 +41,11 @@ describe('KubernetesFetcher', () => {
beforeEach(() => {
jest.resetAllMocks();
clientMock = {
listPodForAllNamespaces: jest.fn(),
listServiceForAllNamespaces: jest.fn(),
listClusterCustomObject: jest.fn(),
addInterceptor: jest.fn(),
};
kubernetesClientProvider = {
getCoreClientByClusterDetails: jest.fn(() => clientMock),
getAppsClientByClusterDetails: jest.fn(() => clientMock),
getAutoscalingClientByClusterDetails: jest.fn(() => clientMock),
getNetworkingBeta1Client: jest.fn(() => clientMock),
getCustomObjectsClient: jest.fn(() => clientMock),
};
@@ -44,7 +56,7 @@ describe('KubernetesFetcher', () => {
});
const testErrorResponse = async (errorResponse: any, expectedResult: any) => {
clientMock.listPodForAllNamespaces.mockResolvedValueOnce({
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
@@ -56,7 +68,7 @@ describe('KubernetesFetcher', () => {
},
});
clientMock.listServiceForAllNamespaces.mockRejectedValue(errorResponse);
clientMock.listClusterCustomObject.mockRejectedValue(errorResponse);
const result = await sut.fetchObjectsForService({
serviceId: 'some-service',
@@ -66,7 +78,7 @@ describe('KubernetesFetcher', () => {
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
objectTypesToFetch: new Set(['pods', 'services']),
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
});
@@ -87,19 +99,35 @@ describe('KubernetesFetcher', () => {
],
});
expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1);
expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1);
expect(clientMock.listClusterCustomObject.mock.calls.length).toBe(2);
expect(clientMock.listClusterCustomObject.mock.calls[0]).toEqual([
'',
'v1',
'pods',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(clientMock.listClusterCustomObject.mock.calls[1]).toEqual([
'',
'v1',
'services',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(
kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length,
).toBe(2);
expect(
kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length,
kubernetesClientProvider.getCustomObjectsClient.mock.calls.length,
).toBe(2);
};
it('should return pods, services', async () => {
clientMock.listPodForAllNamespaces.mockResolvedValueOnce({
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
@@ -111,7 +139,7 @@ describe('KubernetesFetcher', () => {
},
});
clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
@@ -131,7 +159,7 @@ describe('KubernetesFetcher', () => {
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
objectTypesToFetch: new Set(['pods', 'services']),
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
});
@@ -162,41 +190,160 @@ describe('KubernetesFetcher', () => {
],
});
expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1);
expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1);
expect(clientMock.listClusterCustomObject.mock.calls.length).toBe(2);
expect(clientMock.listClusterCustomObject.mock.calls[0]).toEqual([
'',
'v1',
'pods',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(clientMock.listClusterCustomObject.mock.calls[1]).toEqual([
'',
'v1',
'services',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(
kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length,
).toBe(2);
expect(
kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length,
kubernetesClientProvider.getCustomObjectsClient.mock.calls.length,
).toBe(2);
});
it('should throw error on unknown type', () => {
expect(() =>
sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
it('should return pods, services and customobjects', async () => {
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'pod-name',
},
},
],
},
});
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'service-name',
},
},
],
},
});
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
metadata: {
name: 'something-else',
},
},
],
},
});
const result = await sut.fetchObjectsForService({
serviceId: 'some-service',
clusterDetails: {
name: 'cluster1',
url: 'http://localhost:9999',
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [
{
objectType: 'customresources',
group: 'some-group',
apiVersion: 'v2',
plural: 'things',
},
objectTypesToFetch: new Set<any>(['foo']),
labelSelector: '',
customResources: [],
}),
).toThrow('unrecognised type=foo');
],
});
expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(0);
expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(0);
expect(result).toStrictEqual({
errors: [],
responses: [
{
type: 'pods',
resources: [
{
metadata: {
name: 'pod-name',
},
},
],
},
{
type: 'services',
resources: [
{
metadata: {
name: 'service-name',
},
},
],
},
{
type: 'customresources',
resources: [
{
metadata: {
name: 'something-else',
},
},
],
},
],
});
expect(clientMock.listClusterCustomObject.mock.calls.length).toBe(3);
expect(clientMock.listClusterCustomObject.mock.calls[0]).toEqual([
'',
'v1',
'pods',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(clientMock.listClusterCustomObject.mock.calls[1]).toEqual([
'',
'v1',
'services',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(clientMock.listClusterCustomObject.mock.calls[2]).toEqual([
'some-group',
'v2',
'things',
'',
'',
'',
'backstage.io/kubernetes-id=some-service',
]);
expect(
kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length,
).toBe(0);
expect(
kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length,
).toBe(0);
kubernetesClientProvider.getCustomObjectsClient.mock.calls.length,
).toBe(3);
});
// they're in testErrorResponse
// eslint-disable-next-line jest/expect-expect
@@ -283,7 +430,7 @@ describe('KubernetesFetcher', () => {
);
});
it('should always add a labelSelector query', async () => {
clientMock.listPodForAllNamespaces.mockResolvedValueOnce({
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
@@ -295,7 +442,7 @@ describe('KubernetesFetcher', () => {
},
});
clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({
clientMock.listClusterCustomObject.mockResolvedValueOnce({
body: {
items: [
{
@@ -315,12 +462,12 @@ describe('KubernetesFetcher', () => {
serviceAccountToken: 'token',
authProvider: 'serviceAccount',
},
objectTypesToFetch: new Set(['pods', 'services']),
objectTypesToFetch: OBJECTS_TO_FETCH,
labelSelector: '',
customResources: [],
});
const mockCall = clientMock.listPodForAllNamespaces.mock.calls[0];
const mockCall = clientMock.listClusterCustomObject.mock.calls[0];
const actualSelector = mockCall[mockCall.length - 1];
const expectedSelector = 'backstage.io/kubernetes-id=some-service';
expect(actualSelector).toBe(expectedSelector);
@@ -18,16 +18,8 @@ import {
AppsV1Api,
AutoscalingV1Api,
CoreV1Api,
ExtensionsV1beta1Ingress,
NetworkingV1beta1Api,
V1ConfigMap,
V1Deployment,
V1HorizontalPodAutoscaler,
V1Pod,
V1ReplicaSet,
} from '@kubernetes/client-node';
import { V1Service } from '@kubernetes/client-node/dist/gen/model/v1Service';
import http from 'http';
import lodash, { Dictionary } from 'lodash';
import { Logger } from 'winston';
import {
@@ -36,7 +28,7 @@ import {
KubernetesFetcher,
KubernetesObjectTypes,
ObjectFetchParams,
CustomResource,
ObjectToFetch,
} from '../types/types';
import {
FetchResponse,
@@ -88,17 +80,6 @@ const statusCodeToErrorType = (statusCode: number): KubernetesErrorTypes => {
}
};
const captureKubernetesErrorsRethrowOthers = (e: any): KubernetesFetchError => {
if (e.response && e.response.statusCode) {
return {
errorType: statusCodeToErrorType(e.response.statusCode),
statusCode: e.response.statusCode,
resourcePath: e.response.request.uri.pathname,
};
}
throw e;
};
export class KubernetesClientBasedFetcher implements KubernetesFetcher {
private readonly kubernetesClientProvider: KubernetesClientProvider;
private readonly logger: Logger;
@@ -114,202 +95,60 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher {
fetchObjectsForService(
params: ObjectFetchParams,
): Promise<FetchResponseWrapper> {
const fetchResults = Array.from(params.objectTypesToFetch).map(type => {
return this.fetchByObjectType(
params.clusterDetails,
type,
params.labelSelector ||
`backstage.io/kubernetes-id=${params.serviceId}`,
).catch(captureKubernetesErrorsRethrowOthers);
});
const fetchResults = Array.from(params.objectTypesToFetch)
.concat(params.customResources)
.map(toFetch => {
return this.fetchResource(
params.clusterDetails,
toFetch,
params.labelSelector ||
`backstage.io/kubernetes-id=${params.serviceId}`,
toFetch.objectType,
).catch(this.captureKubernetesErrorsRethrowOthers.bind(this));
});
const customObjectsFetchResults = params.customResources.map(cr => {
return this.fetchCustomResource(
params.clusterDetails,
cr,
params.labelSelector ||
`backstage.io/kubernetes-id=${params.serviceId}`,
).catch(captureKubernetesErrorsRethrowOthers);
});
return Promise.all(fetchResults.concat(customObjectsFetchResults)).then(
fetchResultsToResponseWrapper,
);
return Promise.all(fetchResults).then(fetchResultsToResponseWrapper);
}
// TODO could probably do with a tidy up
private fetchByObjectType(
clusterDetails: ClusterDetails,
type: KubernetesObjectTypes,
labelSelector: string,
): Promise<FetchResponse> {
switch (type) {
case 'pods':
return this.fetchPodsForService(clusterDetails, labelSelector).then(
r => ({
type: type,
resources: r,
}),
);
case 'configmaps':
return this.fetchConfigMapsForService(
clusterDetails,
labelSelector,
).then(r => ({ type: type, resources: r }));
case 'deployments':
return this.fetchDeploymentsForService(
clusterDetails,
labelSelector,
).then(r => ({ type: type, resources: r }));
case 'replicasets':
return this.fetchReplicaSetsForService(
clusterDetails,
labelSelector,
).then(r => ({ type: type, resources: r }));
case 'services':
return this.fetchServicesForService(clusterDetails, labelSelector).then(
r => ({ type: type, resources: r }),
);
case 'horizontalpodautoscalers':
return this.fetchHorizontalPodAutoscalersForService(
clusterDetails,
labelSelector,
).then(r => ({ type: type, resources: r }));
case 'ingresses':
return this.fetchIngressesForService(
clusterDetails,
labelSelector,
).then(r => ({ type: type, resources: r }));
default:
// unrecognised type
throw new Error(`unrecognised type=${type}`);
private captureKubernetesErrorsRethrowOthers(e: any): KubernetesFetchError {
if (e.response && e.response.statusCode) {
this.logger.info(
`statusCode=${e.response.statusCode} for resource ${e.response.request.uri.pathname}`,
);
return {
errorType: statusCodeToErrorType(e.response.statusCode),
statusCode: e.response.statusCode,
resourcePath: e.response.request.uri.pathname,
};
}
throw e;
}
private fetchCustomResource(
private fetchResource(
clusterDetails: ClusterDetails,
customResource: CustomResource,
resource: ObjectToFetch,
labelSelector: string,
objectType: KubernetesObjectTypes,
): Promise<FetchResponse> {
const customObjects =
this.kubernetesClientProvider.getCustomObjectsClient(clusterDetails);
customObjects.addInterceptor((requestOptions: any) => {
requestOptions.uri = requestOptions.uri.replace('/apis//v1/', '/api/v1/');
});
return customObjects
.listClusterCustomObject(
customResource.group,
customResource.apiVersion,
customResource.plural,
resource.group,
resource.apiVersion,
resource.plural,
'',
'',
'',
labelSelector,
)
.then(r => {
return { type: 'customresources', resources: (r.body as any).items };
return { type: objectType, resources: (r.body as any).items };
});
}
private singleClusterFetch<T>(
clusterDetails: ClusterDetails,
fn: (
client: Clients,
) => Promise<{ body: { items: Array<T> }; response: http.IncomingMessage }>,
): Promise<Array<T>> {
const core =
this.kubernetesClientProvider.getCoreClientByClusterDetails(
clusterDetails,
);
const apps =
this.kubernetesClientProvider.getAppsClientByClusterDetails(
clusterDetails,
);
const autoscaling =
this.kubernetesClientProvider.getAutoscalingClientByClusterDetails(
clusterDetails,
);
const networkingBeta1 =
this.kubernetesClientProvider.getNetworkingBeta1Client(clusterDetails);
this.logger.debug(`calling cluster=${clusterDetails.name}`);
return fn({ core, apps, autoscaling, networkingBeta1 }).then(({ body }) => {
return body.items;
});
}
private fetchServicesForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<V1Service>> {
return this.singleClusterFetch<V1Service>(clusterDetails, ({ core }) =>
core.listServiceForAllNamespaces(false, '', '', labelSelector),
);
}
private fetchPodsForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<V1Pod>> {
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
core.listPodForAllNamespaces(false, '', '', labelSelector),
);
}
private fetchConfigMapsForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<V1ConfigMap>> {
return this.singleClusterFetch<V1Pod>(clusterDetails, ({ core }) =>
core.listConfigMapForAllNamespaces(false, '', '', labelSelector),
);
}
private fetchDeploymentsForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<V1Deployment>> {
return this.singleClusterFetch<V1Deployment>(clusterDetails, ({ apps }) =>
apps.listDeploymentForAllNamespaces(false, '', '', labelSelector),
);
}
private fetchReplicaSetsForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<V1ReplicaSet>> {
return this.singleClusterFetch<V1ReplicaSet>(clusterDetails, ({ apps }) =>
apps.listReplicaSetForAllNamespaces(false, '', '', labelSelector),
);
}
private fetchHorizontalPodAutoscalersForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<V1HorizontalPodAutoscaler>> {
return this.singleClusterFetch<V1HorizontalPodAutoscaler>(
clusterDetails,
({ autoscaling }) =>
autoscaling.listHorizontalPodAutoscalerForAllNamespaces(
false,
'',
'',
labelSelector,
),
);
}
private fetchIngressesForService(
clusterDetails: ClusterDetails,
labelSelector: string,
): Promise<Array<ExtensionsV1beta1Ingress>> {
return this.singleClusterFetch<ExtensionsV1beta1Ingress>(
clusterDetails,
({ networkingBeta1 }) =>
networkingBeta1.listIngressForAllNamespaces(
false,
'',
'',
labelSelector,
),
);
}
}
@@ -30,7 +30,10 @@ import {
} from '../types/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { KubernetesClientProvider } from './KubernetesClientProvider';
import { KubernetesFanOutHandler } from './KubernetesFanOutHandler';
import {
KubernetesFanOutHandler,
DEFAULT_OBJECTS,
} from './KubernetesFanOutHandler';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
export interface RouterOptions {
@@ -109,6 +112,7 @@ export async function createRouter(
group: c.getString('group'),
apiVersion: c.getString('apiVersion'),
plural: c.getString('plural'),
objectType: 'customresources',
} as CustomResource),
);
@@ -134,10 +138,18 @@ export async function createRouter(
);
const serviceLocator = getServiceLocator(options.config, clusterDetails);
const objectTypesToFetch = options.config.getOptionalStringArray(
const objectTypesToFetchStrings = options.config.getOptionalStringArray(
'kubernetes.objectTypes',
) as KubernetesObjectTypes[];
let objectTypesToFetch;
if (objectTypesToFetchStrings) {
objectTypesToFetch = DEFAULT_OBJECTS.filter(obj =>
objectTypesToFetchStrings.includes(obj.objectType),
);
}
const kubernetesFanOutHandler = new KubernetesFanOutHandler({
logger,
fetcher,
+12 -7
View File
@@ -19,12 +19,6 @@ import type {
KubernetesFetchError,
} from '@backstage/plugin-kubernetes-common';
export interface CustomResource {
group: string;
apiVersion: string;
plural: string;
}
export interface ObjectFetchParams {
serviceId: string;
clusterDetails:
@@ -32,7 +26,7 @@ export interface ObjectFetchParams {
| GKEClusterDetails
| ServiceAccountClusterDetails
| ClusterDetails;
objectTypesToFetch: Set<KubernetesObjectTypes>;
objectTypesToFetch: Set<ObjectToFetch>;
labelSelector: string;
customResources: CustomResource[];
}
@@ -52,6 +46,17 @@ export interface FetchResponseWrapper {
// TODO fairly sure there's a easier way to do this
export interface ObjectToFetch {
objectType: KubernetesObjectTypes;
group: string;
apiVersion: string;
plural: string;
}
export interface CustomResource extends ObjectToFetch {
objectType: 'customresources';
}
export type KubernetesObjectTypes =
| 'pods'
| 'services'
+16
View File
@@ -6,13 +6,17 @@
/// <reference types="node" />
import { CatalogApi } from '@backstage/catalog-client';
import { CatalogProcessor } from '@backstage/plugin-catalog-backend';
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
import { ContainerRunner } from '@backstage/backend-common';
import { createFetchCookiecutterAction } from '@backstage/plugin-scaffolder-backend-module-cookiecutter';
import { createPullRequest } from 'octokit-plugin-create-pull-request';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { JsonObject } from '@backstage/config';
import { JsonValue } from '@backstage/config';
import { LocationSpec } from '@backstage/catalog-model';
import { Logger as Logger_2 } from 'winston';
import { Octokit } from '@octokit/rest';
import { PluginDatabaseManager } from '@backstage/backend-common';
@@ -244,6 +248,18 @@ export const runCommand: ({
logStream,
}: RunCommandOptions) => Promise<void>;
// @public (undocumented)
export class ScaffolderEntitiesProcessor implements CatalogProcessor {
// (undocumented)
postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity>;
// (undocumented)
validateEntityKind(entity: Entity): Promise<boolean>;
}
// Warning: (ae-missing-release-tag) "TemplateAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+2
View File
@@ -36,6 +36,8 @@
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.2",
"@backstage/integration": "^0.6.7",
"@backstage/plugin-catalog-backend": "^0.16.0",
"@backstage/plugin-scaffolder-common": "^0.1.0",
"@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.2",
"@gitbeaker/core": "^30.2.0",
"@gitbeaker/node": "^30.2.0",
+1
View File
@@ -23,3 +23,4 @@
export * from './scaffolder';
export * from './service/router';
export * from './lib/catalog';
export * from './processor';
@@ -0,0 +1,80 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ScaffolderEntitiesProcessor } from './ScaffolderEntitiesProcessor';
const mockLocation = { type: 'a', target: 'b' };
const mockEntity: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'n' },
spec: {
parameters: {},
steps: [],
type: 'service',
owner: 'o',
},
};
describe('ScaffolderEntitiesProcessor', () => {
describe('validateEntityKind', () => {
it('validates the entity kind', async () => {
const processor = new ScaffolderEntitiesProcessor();
await expect(processor.validateEntityKind(mockEntity)).resolves.toBe(
true,
);
await expect(
processor.validateEntityKind({
...mockEntity,
apiVersion: 'backstage.io/v1beta3',
}),
).resolves.toBe(false);
await expect(
processor.validateEntityKind({ ...mockEntity, kind: 'Component' }),
).resolves.toBe(false);
});
});
describe('postProcessEntity', () => {
it('generates relations for component entities', async () => {
const processor = new ScaffolderEntitiesProcessor();
const emit = jest.fn();
await processor.postProcessEntity(mockEntity, mockLocation, emit);
expect(emit).toBeCalledTimes(2);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'o' },
type: 'ownerOf',
target: { kind: 'Template', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Template', namespace: 'default', name: 'n' },
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'o' },
},
});
});
});
});
@@ -0,0 +1,98 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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,
getEntityName,
LocationSpec,
parseEntityRef,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
entityKindSchemaValidator,
} from '@backstage/catalog-model';
import {
CatalogProcessor,
CatalogProcessorEmit,
results,
} from '@backstage/plugin-catalog-backend';
import {
TemplateEntityV1beta3,
templateEntityV1beta3Schema,
} from '@backstage/plugin-scaffolder-common';
/** @public */
export class ScaffolderEntitiesProcessor implements CatalogProcessor {
private readonly validators = [
entityKindSchemaValidator(templateEntityV1beta3Schema),
];
async validateEntityKind(entity: Entity): Promise<boolean> {
for (const validator of this.validators) {
if (validator(entity)) {
return true;
}
}
return false;
}
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity> {
const selfRef = getEntityName(entity);
if (
entity.apiVersion === 'scaffolder.backstage.io/v1beta3' &&
entity.kind === 'Template'
) {
const template = entity as TemplateEntityV1beta3;
const target = template.spec.owner;
if (target) {
const targetRef = parseEntityRef(target, {
defaultKind: 'Group',
defaultNamespace: selfRef.namespace,
});
emit(
results.relation({
source: selfRef,
type: RELATION_OWNED_BY,
target: {
kind: targetRef.kind,
namespace: targetRef.namespace,
name: targetRef.name,
},
}),
);
emit(
results.relation({
source: {
kind: targetRef.kind,
namespace: targetRef.namespace,
name: targetRef.name,
},
type: RELATION_OWNER_OF,
target: selfRef,
}),
);
}
}
return entity;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ScaffolderEntitiesProcessor } from './ScaffolderEntitiesProcessor';
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+3
View File
@@ -0,0 +1,3 @@
# @backstage/plugin-scaffolder-common
Common types and functionalities for the scaffolder, to be shared between scaffolder and scaffolder-backend.
+36
View File
@@ -0,0 +1,36 @@
## API Report File for "@backstage/plugin-scaffolder-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Entity } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/config';
import { JSONSchema } from '@backstage/catalog-model';
// @public (undocumented)
export interface TemplateEntityV1beta3 extends Entity {
// (undocumented)
apiVersion: 'scaffolder.backstage.io/v1beta3';
// (undocumented)
kind: 'Template';
// (undocumented)
spec: {
type: string;
parameters?: JsonObject | JsonObject[];
steps: Array<{
id?: string;
name?: string;
action: string;
input?: JsonObject;
if?: string | boolean;
}>;
output?: {
[name: string]: string;
};
owner?: string;
};
}
// @public (undocumented)
export const templateEntityV1beta3Schema: JSONSchema;
```
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@backstage/plugin-scaffolder-common",
"description": "Common functionalities for the scaffolder, to be shared between scaffolder and scaffolder-backend plugin",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/scaffolder-common"
},
"keywords": [
"scaffolder"
],
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"bugs": {
"url": "https://github.com/backstage/backstage/issues"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.4",
"@backstage/config": "^0.1.10"
},
"devDependencies": {
"@backstage/cli": "^0.7.15"
}
}
@@ -0,0 +1,186 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "TemplateV1beta3",
"description": "A Template describes a scaffolding task for use with the Scaffolder. It describes the required parameters as well as a series of steps that will be taken to execute the scaffolding task.",
"examples": [
{
"apiVersion": "scaffolder.backstage.io/v1beta3",
"kind": "Template",
"metadata": {
"name": "react-ssr-template",
"title": "React SSR Template",
"description": "Next.js application skeleton for creating isomorphic web applications.",
"tags": ["recommended", "react"]
},
"spec": {
"owner": "artist-relations-team",
"parameters": {
"required": ["name", "description", "repoUrl"],
"properties": {
"name": {
"title": "Name",
"type": "string",
"description": "Unique name of the component"
},
"description": {
"title": "Description",
"type": "string",
"description": "Description of the component"
},
"repoUrl": {
"title": "Pick a repository",
"type": "string",
"ui:field": "RepoUrlPicker"
}
}
},
"steps": [
{
"id": "fetch",
"name": "Fetch",
"action": "fetch:plain",
"parameters": {
"url": "./template"
}
},
{
"id": "publish",
"name": "Publish to GitHub",
"action": "publish:github",
"parameters": {
"repoUrl": "${{ parameters.repoUrl }}"
},
"if": "${{ parameters.repoUrl }}"
}
],
"output": {
"catalogInfoUrl": "${{ steps.publish.output.catalogInfoUrl }}"
}
}
}
],
"allOf": [
{
"$ref": "Entity"
},
{
"type": "object",
"required": ["spec"],
"properties": {
"apiVersion": {
"enum": ["scaffolder.backstage.io/v1beta3"]
},
"kind": {
"enum": ["Template"]
},
"spec": {
"type": "object",
"required": ["type", "steps"],
"properties": {
"type": {
"type": "string",
"description": "The type of component created by the template. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.",
"examples": ["service", "website", "library"],
"minLength": 1
},
"owner": {
"type": "string",
"description": "The user (or group) owner of the template",
"minLength": 1
},
"parameters": {
"oneOf": [
{
"type": "object",
"description": "The JSONSchema describing the inputs for the template."
},
{
"type": "array",
"description": "A list of separate forms to collect parameters.",
"items": {
"type": "object",
"description": "The JSONSchema describing the inputs for the template."
}
}
]
},
"steps": {
"type": "array",
"description": "A list of steps to execute.",
"items": {
"type": "object",
"description": "A description of the step to execute.",
"required": ["action"],
"properties": {
"id": {
"type": "string",
"description": "The ID of the step, which can be used to refer to its outputs."
},
"name": {
"type": "string",
"description": "The name of the step, which will be displayed in the UI during the scaffolding process."
},
"action": {
"type": "string",
"description": "The name of the action to execute."
},
"input": {
"type": "object",
"description": "A templated object describing the inputs to the action."
},
"if": {
"type": ["string", "boolean"],
"description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`."
}
}
}
},
"output": {
"type": "object",
"description": "A templated object describing the outputs of the scaffolding task.",
"properties": {
"links": {
"type": "array",
"description": "A list of external hyperlinks, typically pointing to resources created or updated by the template",
"items": {
"type": "object",
"required": [],
"properties": {
"url": {
"type": "string",
"description": "A url in a standard uri format.",
"examples": ["https://github.com/my-org/my-new-repo"],
"minLength": 1
},
"entityRef": {
"type": "string",
"description": "An entity reference to an entity in the catalog.",
"examples": ["Component:default/my-app"],
"minLength": 1
},
"title": {
"type": "string",
"description": "A user friendly display name for the link.",
"examples": ["View new repo"],
"minLength": 1
},
"icon": {
"type": "string",
"description": "A key representing a visual icon to be displayed in the UI.",
"examples": ["dashboard"],
"minLength": 1
}
}
}
}
},
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
]
}
@@ -0,0 +1,157 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { entityKindSchemaValidator } from '@backstage/catalog-model';
import type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3';
import schema from './Template.v1beta3.schema.json';
const validator = entityKindSchemaValidator(schema);
describe('templateEntityV1beta3Validator', () => {
let entity: TemplateEntityV1beta3;
beforeEach(() => {
entity = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 'test',
},
spec: {
type: 'website',
owner: 'team-b',
parameters: {
required: ['owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
},
},
steps: [
{
id: 'fetch',
name: 'Fetch',
action: 'fetch:plan',
input: {
url: './template',
},
if: '${{ parameters.owner }}',
},
],
output: {
fetchUrl: '${{ steps.fetch.output.targetUrl }}',
},
},
};
});
it('happy path: accepts valid data', async () => {
expect(validator(entity)).toBe(entity);
});
it('ignores unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
expect(validator(entity)).toBe(false);
});
it('ignores unknown kind', async () => {
(entity as any).kind = 'Wizard';
expect(validator(entity)).toBe(false);
});
it('rejects missing type', async () => {
delete (entity as any).spec.type;
expect(() => validator(entity)).toThrow(/type/);
});
it('accepts any other type', async () => {
(entity as any).spec.type = 'hallo';
expect(validator(entity)).toBe(entity);
});
it('accepts missing parameters', async () => {
delete (entity as any).spec.parameters;
expect(validator(entity)).toBe(entity);
});
it('accepts missing outputs', async () => {
delete (entity as any).spec.outputs;
expect(validator(entity)).toBe(entity);
});
it('rejects empty type', async () => {
(entity as any).spec.type = '';
expect(() => validator(entity)).toThrow(/type/);
});
it('rejects missing steps', async () => {
delete (entity as any).spec.steps;
expect(() => validator(entity)).toThrow(/steps/);
});
it('accepts step with missing id', async () => {
delete (entity as any).spec.steps[0].id;
expect(validator(entity)).toBe(entity);
});
it('accepts step with missing name', async () => {
delete (entity as any).spec.steps[0].name;
expect(validator(entity)).toBe(entity);
});
it('rejects step with missing action', async () => {
delete (entity as any).spec.steps[0].action;
expect(() => validator(entity)).toThrow(/action/);
});
it('accepts missing owner', async () => {
delete (entity as any).spec.owner;
expect(validator(entity)).toBe(entity);
});
it('rejects empty owner', async () => {
(entity as any).spec.owner = '';
expect(() => validator(entity)).toThrow(/owner/);
});
it('rejects wrong type owner', async () => {
(entity as any).spec.owner = 5;
expect(() => validator(entity)).toThrow(/owner/);
});
it('accepts missing if', async () => {
delete (entity as any).spec.steps[0].if;
expect(validator(entity)).toBe(entity);
});
it('accepts boolean in if', async () => {
(entity as any).spec.steps[0].if = true;
expect(validator(entity)).toBe(entity);
});
it('accepts empty if', async () => {
(entity as any).spec.steps[0].if = '';
expect(validator(entity)).toBe(entity);
});
it('rejects wrong type if', async () => {
(entity as any).spec.steps[0].if = 5;
expect(() => validator(entity)).toThrow(/if/);
});
});
@@ -0,0 +1,37 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { JsonObject } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
/** @public */
export interface TemplateEntityV1beta3 extends Entity {
apiVersion: 'scaffolder.backstage.io/v1beta3';
kind: 'Template';
spec: {
type: string;
parameters?: JsonObject | JsonObject[];
steps: Array<{
id?: string;
name?: string;
action: string;
input?: JsonObject;
if?: string | boolean;
}>;
output?: { [name: string]: string };
owner?: string;
};
}
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
/**
* Common functionalities for the scaffolder, to be shared between scaffolder and scaffolder-backend plugin
*
* @packageDocumentation
*/
import { JSONSchema } from '@backstage/catalog-model';
import v1beta3Schema from './Template.v1beta3.schema.json';
export type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3';
/** @public */
export const templateEntityV1beta3Schema: JSONSchema = v1beta3Schema as Omit<
JSONSchema,
'examples'
>;
+7 -1
View File
@@ -79,7 +79,11 @@ export const searchApiRef: ApiRef<SearchApi>;
// Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const SearchBar: ({ className, debounceTime }: Props) => JSX.Element;
export const SearchBar: ({
className,
debounceTime,
placeholder,
}: Props) => JSX.Element;
// Warning: (ae-missing-release-tag) "SearchBarNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -87,9 +91,11 @@ export const SearchBar: ({ className, debounceTime }: Props) => JSX.Element;
export const SearchBarNext: ({
className,
debounceTime,
placeholder,
}: {
className?: string | undefined;
debounceTime?: number | undefined;
placeholder?: string | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -45,3 +45,20 @@ export const Default = () => {
</MemoryRouter>
);
};
export const CustomPlaceholder = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={12}>
<Paper style={{ padding: '8px 0' }}>
<SearchBar placeholder="This is a custom placeholder" />
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
);
};
@@ -65,6 +65,26 @@ describe('SearchBar', () => {
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search in Mock title'),
).toBeInTheDocument();
});
});
it('Renders with custom placeholder', async () => {
render(
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={{ ...initialState }}>
<SearchBar placeholder="This is a custom placeholder" />
</SearchContextProvider>
,
</ApiProvider>,
);
await waitFor(() => {
expect(
screen.getByPlaceholderText('This is a custom placeholder'),
).toBeInTheDocument();
});
});
@@ -89,9 +89,14 @@ export const SearchBarBase = ({
type Props = {
className?: string;
debounceTime?: number;
placeholder?: string;
};
export const SearchBar = ({ className, debounceTime = 0 }: Props) => {
export const SearchBar = ({
className,
debounceTime = 0,
placeholder,
}: Props) => {
const { term, setTerm } = useSearch();
const [value, setValue] = useState<string>(term);
@@ -113,6 +118,7 @@ export const SearchBar = ({ className, debounceTime = 0 }: Props) => {
value={value}
onChange={handleQuery}
onClear={handleClear}
placeholder={placeholder}
/>
);
};
+3 -8
View File
@@ -4405,11 +4405,6 @@
semver "^7.3.4"
tar "^6.1.0"
"@material-icons/font@^1.0.2":
version "1.0.3"
resolved "https://registry.npmjs.org/@material-icons/font/-/font-1.0.3.tgz#f722e5a69a03f20ef47d015cb69420bebeeaabe5"
integrity sha512-aIRd0Z9b/HJ/O24KOaP7dNsXypMnXhpWrpLVYvQB/JesVqXYSbioYND200sR+C14a0LSCp+qWnWCnSXmN1hWGw==
"@material-table/core@^3.1.0":
version "3.1.0"
resolved "https://registry.npmjs.org/@material-table/core/-/core-3.1.0.tgz#4fc3bd1553359e628413437a4102d8469852c253"
@@ -7401,9 +7396,9 @@
"@types/node" "*"
"@types/regression@^2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.0.tgz#0677ea78d7bdb37039c02ebbccf062042f756ae3"
integrity sha512-Ch2FD53M1HpFLL6zSTc/sfuyqQcIPy+/PV3xFT6QYtk9EOiMI29XOYmLNxBb1Y0lfMOR/NNa86J1gRc/1jGLyw==
version "2.0.2"
resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.2.tgz#a1ad747fbcc6726643a8eb2c42bb804bbf34ce02"
integrity sha512-i7KOGl6xdkfpq5+p2ooC+/XFIRUMkYymZ29SD8p+Ko9lesKGUsh6860ey3YM7Y+ZG7kEDGcjzyLO3sOhozqEeA==
"@types/request@^2.47.1":
version "2.48.5"