This commit is contained in:
Isaiah Thiessen
2022-06-10 09:34:18 -07:00
210 changed files with 7207 additions and 1656 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': minor
'@backstage/plugin-techdocs': minor
---
Add an optional icon to the Catalog and TechDocs search results
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Removed peer dependencies, as they are no longer needed.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-search': patch
'@backstage/plugin-search-react': patch
---
Components `<DefaultResultListItem>`, `<SearchBar>` (including `<SearchBarBase>`), `<SearchFilter>` (including `.Checkbox`, `.Select`, and `.Autocomplete` static prop components), `<SearchResult>`, and `<SearchResultPager>` are now exported from `@backstage/plugin-search-react`. They are now deprecated in `@backstage/plugin-search` and will be removed in a future release.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
**DEPRECATION**: The `projectid` input parameters to the `publish:gitlab:merge-request`, it's no longer required as it can be decoded from the `repoUrl` input parameter.
**DEPRECATION**: The `projectid` output of the action in favour of `projectPath`
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-permission-node': patch
---
Added a new endpoint for aggregating permission metadata from a plugin backend: `/.well-known/backstage/permissions/metadata`
By default, the metadata endpoint will return information about the permission rules supported by the plugin. Plugin authors can also provide an optional `permissions` parameter to `createPermissionIntegrationRouter`. If provided, these `Permission` objects will be included in the metadata returned by this endpoint. The `permissions` parameter will eventually be required in a future breaking change.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Improved the `create-github-app` permissions selection prompt by converting it into a multi-select with clearer descriptions. The `members` permission is now also included in the list which is required for ingesting user data into the catalog.
+64
View File
@@ -0,0 +1,64 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
Add a new provider `GitlabDiscoveryEntityProvider` as replacement for `GitlabDiscoveryProcessor`
In order to migrate from the `GitlabDiscoveryProcessor` you need to apply
the following changes:
**Before:**
```yaml
# app-config.yaml
catalog:
locations:
- type: gitlab-discovery
target: https://company.gitlab.com/prefix/*/catalog-info.yaml
```
```ts
/* packages/backend/src/plugins/catalog.ts */
import { GitlabDiscoveryProcessor } from '@backstage/plugin-catalog-backend-module-gitlab';
const builder = await CatalogBuilder.create(env);
/** ... other processors ... */
builder.addProcessor(
GitLabDiscoveryProcessor.fromConfig(env.config, { logger: env.logger }),
);
```
**After:**
```yaml
# app-config.yaml
catalog:
providers:
gitlab:
yourProviderId: # identifies your dataset / provider independent of config changes
host: gitlab-host # Identifies one of the hosts set up in the integrations
branch: main # Optional. Uses `master` as default
group: example-group # Group and subgroup (if needed) to look for repositories
entityFilename: catalog-info.yaml # Optional. Defaults to `catalog-info.yaml`
```
```ts
/* packages/backend/src/plugins/catalog.ts */
import { GitlabDiscoveryEntityProvider } from '@backstage/plugin-catalog-backend-module-gitlab';
const builder = await CatalogBuilder.create(env);
/** ... other processors and/or providers ... */
builder.addEntityProvider(
...GitlabDiscoveryEntityProvider.fromConfig(env.config, {
logger: env.logger,
schedule: env.scheduler.createScheduledTaskRunner({
frequency: { minutes: 30 },
timeout: { minutes: 3 },
}),
}),
);
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
The `last_activity_after` timestamp is now being omitted when querying the GitLab API for the first time.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Use entity title as label in `TechDocsReaderPageHeader` if available
+1 -1
View File
@@ -1,5 +1,5 @@
{
"mode": "pre",
"mode": "exit",
"tag": "next",
"initialVersions": {
"example-app": "0.2.71",
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/create-app': patch
---
Components `<DefaultResultListItem>`, `<SearchBar>`, `<SearchFilter>`, and `<SearchResult>` are now deprecated in `@backstage/plugin-search` and should be imported from `@backstage/plugin-search-react` instead.
To upgrade your App, update the following in `packages/app/src/components/search/SearchPage.tsx`:
```diff
import {
DefaultResultListItem
SearchBar
SearchFilter
SearchResult
- } from `@backstage/plugin-search`;
+ } from `@backstage/plugin-search-react`;
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-common': patch
'@backstage/plugin-techdocs-node': patch
---
Updated dependency `@google-cloud/storage` to `^6.0.0`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Updated dependency `react-text-truncate` to `^0.19.0`.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-tech-radar': patch
---
Updated dependency `d3-force` to `^3.0.0`.
Updated dependency `@types/d3-force` to `^3.0.0`.
+50
View File
@@ -0,0 +1,50 @@
---
'@backstage/create-app': patch
---
It's now possible to pass result item components a `rank`, which is captured by the analytics API when a user clicks on a search result. To apply this change, update your `/packages/app/src/components/search/SearchPage.tsx` in the following way:
```diff
// ...
<SearchResult>
{({ results }) => (
<List>
- {results.map(({ type, document, highlight }) => {
+ {results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
+ rank={rank}
/>
);
case 'techdocs':
return (
<TechDocsSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
+ rank={rank}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
+ rank={rank}
/>
);
}
})}
</List>
)}
</SearchResult>
// ...
```
If you have implemented a custom Search Modal or other custom search experience, you will want to make similar changes in those components.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-common': patch
---
Added an optional `rank` attribute to the `Result` type. This represents the result rank (starting at 1) for a given result in a result set for a given search.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-search-backend': patch
'@backstage/plugin-search-backend-module-elasticsearch': patch
'@backstage/plugin-search-backend-module-pg': patch
'@backstage/plugin-search-backend-node': patch
---
The provided search engine now adds a pagination-aware `rank` value to all results.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-search': patch
'@backstage/plugin-techdocs': patch
---
In order to simplify analytics on top of the search experience in Backstage, the provided `<*ResultListItem />` component now captures a `discover` analytics event instead of a `click` event. This event includes the result rank as its `value` and, like a click, the URL/path clicked to as its `to` attribute.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': patch
---
Now possible to set a custom index template on the elasticsearch search engine.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-vault-backend': minor
---
First implementation for the backend vault plugin. For more information refer to its `README.md`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-vault': minor
---
First implementation of the frontend vault plugin. For more information refer to its `README.md`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix the missing filter in the toolbar when passing a custom component in the core-components Table
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': minor
---
The pre-alpha `<SearchPageNext>`, `<SearchBarNext>`, `etc...` components have been removed. In the unlikely event you were still using/referencing them, please update to using their non-`*Next` equivalents from either `@backstage/plugin-search-react` or `@backstage/plugin-search`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Handle URLs as the first argument to `fetchApi`, when using the `plugin:` protocol
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-addons-test-utils': patch
---
Update default mock
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder': minor
'@backstage/plugin-scaffolder-backend': minor
---
Added the ability to support running of templates that are not in the `default` namespace
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-api-docs': patch
---
Updated `swagger-ui-react` to 4.11.1 in order to address a [XSS
vulnerability](https://github.com/advisories/GHSA-hqq7-2q2v-82xq) in `@braintree/sanitize-url`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Updated the auth backend setup in the template to include a guest sign-in resolver in order to make it quicker to get up and running with a basic sign-in setup. There is no need to update existing apps to match this change, but in case you want to use the guest sign-in resolver you can find it at https://backstage.io/docs/auth/identity-resolver#guest-sign-in-resolver
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-tech-insights-backend': patch
'@backstage/plugin-tech-insights-node': patch
---
Introduce additional JsonValue types to be storable as facts. This enables the possibility to store more complex objects for fact checking purposes. The rules engine supports walking keyed object values directly to create rules and checks
Modify facts database table to have a more restricted timestamp precision for cases where the postgres server isn't configured to contain such value. This fixes the issue where in some cases `maxItems` lifecycle condition didn't work as expected.
+2
View File
@@ -277,6 +277,7 @@ scrollbar
seb
semlas
semver
serializable
Serverless
shoutout
siloed
@@ -303,6 +304,7 @@ superfences
Superfences
superset
supertype
storable
talkdesk
Talkdesk
tasklist
+2
View File
@@ -157,3 +157,5 @@ _If you're using Backstage in your organization, please try to add your company
| [Lendingkart](https://www.lendingkart.com/) | [Dinesh Rajpoot](mailto:dinesh.rajpoot@lendingkart.com) | Service catalog, Software templates to enforce best practices and tech insights to track mandates & migrations.
| [Meltwater](https://underthehood.meltwater.com) | [@spier](https://github.com/spier), [@remen](https://github.com/remen) | Improving developer experience by centralizing documentation and internal APIs. Goal: Foster InnerSource collaboration and speed up onboarding time in our 500+ people Product & Engineering org.
| [Doctolib](https://doctolib.engineering/) | [@djiit](https://github.com/djiit) | Rails modularization effort awareness, tech organization discoverability. Improving the daily workflows and collaboration processes of our engineers.
| [Twilio](https://www.twilio.com) | [Kyle Smith](https://github.com/knksmith57) | Developer portal, universal software catalog, and centralized taxonomy platform.
| [OVHcloud](https://www.ovhcloud.com/fr/) | [Jean-Philippe Blary](https://github.com/blaryjp), [Arnaud Bauer](mailto:arnaud.bauer@ovhcloud.com), [Flavien Chantelot](https://github.com/Dorn-) | We're providing Backstage to our collaborators to ease their daily jobs, and let them extends it using plugins.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+46 -2
View File
@@ -12,6 +12,34 @@ If you want to use an auth provider to sign in users, you need to explicitly con
it have sign-in enabled and also tell it how the external identities should
be mapped to user identities within Backstage.
## Guest Sign-In Resolver
Backstage projects created with `npx @backstage/create-app` come configured with a
sign-in resolver for GitHub guest access. This resolver makes all users share
a single "guest" identity and is only intended as a minimum requirement to quickly
get up and running. You can replace `github` for any of the other providers if you need.
This resolver should not be used in production, as it uses a single shared identity,
and has no restrictions on who is able to sign-in. Be sure to read through the rest
of this page to understand the Backstage identity system once you need to install
a resolver for your production environment.
The guest resolver can be useful for testing purposes too, and it looks like this:
```ts
signIn: {
resolver(_, ctx) {
const userRef = 'user:default/guest'
return ctx.issueToken({
claims: {
sub: userRef,
ent: [userRef],
},
}),
},
},
```
## Backstage User Identity
A user identity within Backstage is built up from two pieces of information, a
@@ -59,6 +87,11 @@ the given auth provider, as well as a context object that contains various helpe
for looking up users and issuing tokens. There are also a number of built-in sign-in
resolvers that can be used, which are covered a bit further down.
Note that while it possible to configure multiple auth providers to be used for sign-in,
you should take care when doing so. It is best to make sure that the different auth
providers either do not have any user overlap, or that any users that are able to log
in with multiple providers always end up with the same Backstage identity.
### Custom Resolver Example
Let's look at an example of a custom sign-in resolver for the Google auth provider.
@@ -210,6 +243,11 @@ is that it can be tricky to determine the ownership references, although it can
be achieved for example through a lookup to an external service. You typically
want to at least use the user itself as a lone ownership reference.
Because we no longer use the catalog as an allow-list of users, it is often important
that you limit what users are allowed to sign in. This could be a simple email domain
check like in the example below, or you might for example look up the GitHub organizations
that the user belongs to using the user access token in the provided result object.
```ts
import { DEFAULT_NAMESPACE, stringifyEntityRef, } from '@backstage/catalog-model';
@@ -220,8 +258,14 @@ async ({ profile }, ctx) => {
'Login failed, user profile does not contain an email',
);
}
// We again use the local part of the email as the user name.
const [localPart] = profile.email.split('@');
// Split the email into the local part and the domain.
const [localPart, domain] = profile.email.split('@');
// Next we verify the email domain. It is recommended to include this
// kind of check if you don't look up the user in an external service.
if (domain !== 'acme.org') {
throw new Error('Login failed, user email domain check failed');
}
// By using `stringifyEntityRef` we ensure that the reference is formatted correctly
const userEntityRef = stringifyEntityRef({
-62
View File
@@ -1,62 +0,0 @@
---
id: helm
title: Deploying with Helm
description: How to deploy Backstage with Helm and Kubernetes
sidebar_label: Helm
---
An example Backstage app can be deployed in Kubernetes using the
[Backstage Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage).
First, choose a DNS name where Backstage will be hosted, and create a YAML file
for your custom configuration.
```yaml
appConfig:
app:
baseUrl: https://backstage.mydomain.com
title: Backstage
backend:
baseUrl: https://backstage.mydomain.com
cors:
origin: https://backstage.mydomain.com
lighthouse:
baseUrl: https://backstage.mydomain.com/lighthouse-api
techdocs:
storageUrl: https://backstage.mydomain.com/api/techdocs/static/docs
requestUrl: https://backstage.mydomain.com/api/techdocs
```
Then use it to run:
```bash
git clone https://github.com/backstage/backstage.git
cd backstage/contrib/chart/backstage
helm dependency update
helm install -f backstage-mydomain.yaml backstage .
```
This command will deploy the following pieces:
- Backstage frontend
- Backstage backend with scaffolder and auth plugins
- (optional) a PostgreSQL instance
- lighthouse plugin
- ingress
After a few minutes Backstage should be up and running in your cluster under the
DNS specified earlier.
Make sure to create the appropriate DNS entry in your infrastructure. To find
the public IP address run:
```bash
$ kubectl get ingress
NAME HOSTS ADDRESS PORTS AGE
backstage-ingress * 123.1.2.3 80 17m
```
> **NOTE**: this is not a production ready deployment.
For more information on how to customize the deployment check the
[README](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage/README.md).
+1 -3
View File
@@ -29,9 +29,7 @@ This method is covered in [Building a Docker image](docker.md) and
There is also an example of deploying on [Heroku](heroku.md), which only
requires the first two steps.
An example of deploying Backstage with a [Helm chart](helm.md), a common pattern
in AWS, is also available. There is also a contrib guide to deploying Backstage
with
There is also a contrib guide to deploying Backstage with
[AWS Fargate and Aurora PostgreSQL](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/aws-fargate-deployment.md).
Please consider contributing other deployment guides if you get Backstage set up
+4 -4
View File
@@ -18,7 +18,7 @@ If you haven't setup Backstage already, start
```bash
# From your Backstage root directory
yarn add --cwd packages/app @backstage/plugin-search
yarn add --cwd packages/app @backstage/plugin-search @backstage/plugin-search-react
```
Create a new `packages/app/src/components/search/SearchPage.tsx` file in your
@@ -33,7 +33,7 @@ import {
SearchResult,
DefaultResultListItem,
SearchFilter,
} from '@backstage/plugin-search';
} from '@backstage/plugin-search-react';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
export const searchPage = (
@@ -213,7 +213,7 @@ apiRouter.use('/search', await search(searchEnv));
### Frontend
The Search Plugin exposes several default filter types as static properties,
The Search Plugin web library (`@backstage/plugin-search-react`) exposes several default filter types as static properties,
including `<SearchFilter.Select />` and `<SearchFilter.Checkbox />`. These allow
you to provide values relevant to your Backstage instance that, when selected,
get passed to the backend.
@@ -237,7 +237,7 @@ If you have advanced filter needs, you can specify your own filter component
like this (although new core filter contributions are welcome):
```tsx
import { useSearch, SearchFilter } from '@backstage/plugin-search';
import { useSearch, SearchFilter } from '@backstage/plugin-search-react';
const MyCustomFilter = () => {
// Note: filters contain filter data from other filter components. Be sure
+25
View File
@@ -102,6 +102,31 @@ import { Client } from '@elastic/elastic-search';
const client = searchEngine.newClient(options => new Client(options));
```
#### Set custom index template
The elasticsearch engine gives you the ability to set a custom index template if needed.
> Index templates define settings, mappings, and aliases that can be applied automatically to new indices.
```typescript
// app/backend/src/plugins/search.ts
const searchEngine = await ElasticSearchSearchEngine.initialize({
logger: env.logger,
config: env.config,
});
searchEngine.setIndexTemplate({
name: '<name-of-your-custom-template>',
body: {
index_patterns: ['<your-index-pattern>'],
template: {
mappings: {},
settings: {},
},
},
});
```
## Example configurations
### AWS
@@ -33,8 +33,8 @@ tracked in source control, or use some existing open source or commercial
software.
A component can implement APIs for other components to consume. In turn it might
depend on APIs implemented by other components, or resources that are attached
to it at runtime.
consume APIs implemented by other components, or directly depend on components or
resources that are attached to it at runtime.
### API
@@ -353,6 +353,19 @@ to `scm-only`, the plugin will only take into account files stored in source
control (e.g. ignoring generated code). If set to `enabled`, all files covered
by a coverage report will be taken into account.
### vault.io/secrets-path
```yaml
# Example:
metadata:
annotations:
vault.io/secrets-path: test/backstage
```
The value of this annotation contains the path to the secrets of the entity in
Vault. If not present when the Vault plugin is in use, a message will be shown
instead, letting the user know what is missing in the `catalog-info.yaml`.
## Deprecated Annotations
The following annotations are deprecated, and only listed here to aid in
+1 -1
View File
@@ -95,7 +95,7 @@ See [TechDocs Architecture](architecture.md) to get an overview of where the bel
[techdocs/frontend-library]: https://github.com/backstage/backstage/blob/master/plugins/techdocs-react
[techdocs/backend]: https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend
[techdocs/container]: https://github.com/backstage/techdocs-container
[techdocs/cli]: https://github.com/backstage/techdocs-cli
[techdocs/cli]: https://github.com/backstage/backstage/blob/master/packages/techdocs-cli
## Get involved
+13 -15
View File
@@ -164,27 +164,25 @@ Open `packages/app/src/App.tsx` and below the last `import` line, add:
```typescript
import { githubAuthApiRef } from '@backstage/core-plugin-api';
import { SignInProviderConfig, SignInPage } from '@backstage/core-components';
const githubProvider: SignInProviderConfig = {
id: 'github-auth-provider',
title: 'GitHub',
message: 'Sign in using GitHub',
apiRef: githubAuthApiRef,
};
```
Search for `const app = createApp({` in this file, and below `apis,` add:
```typescript
components: {
SignInPage: props => (
<SignInPage
{...props}
auto
provider={githubProvider}
/>
),
},
SignInPage: props => (
<SignInPage
{...props}
auto
provider={{
id: 'github-auth-provider',
title: 'GitHub',
message: 'Sign in using GitHub',
apiRef: githubAuthApiRef,
}}
/>
),
},
```
> Since [v1.1.0](https://github.com/backstage/backstage/releases/tag/v1.1.0-next.3), you must provide an [explicit sign-in resolver](../auth/identity-resolver.md).
+50 -6
View File
@@ -6,14 +6,55 @@ sidebar_label: Discovery
description: Automatically discovering catalog entities from repositories in GitLab
---
The GitLab integration has a special discovery processor for discovering catalog
entities from GitLab. The processor will crawl the GitLab instance and register
entities matching the configured path. This can be useful as an alternative to
The GitLab integration has a special entity provider for discovering catalog
entities from GitLab. The entity provider will crawl the GitLab instance and register
entities matching the configured paths. This can be useful as an alternative to
static locations or manually adding things to the catalog.
To use the discovery processor, you'll need a GitLab integration
[set up](locations.md) with a `token`. Then you can add a location target to the
catalog configuration:
To use the discovery provider, you'll need a GitLab integration
[set up](locations.md) with a `token`. Then you can add a provider config per group
to the catalog configuration:
```yaml
catalog:
providers:
gitlab:
yourProviderId:
host: gitlab-host # Identifies one of the hosts set up in the integrations
branch: main # Optional. Uses `master` as default
group: example-group # Group and subgroup (if needed) to look for repositories
entityFilename: catalog-info.yaml # Optional. Defaults to `catalog-info.yaml`
```
As this provider is not one of the default providers, you will first need to install
the gitlab catalog plugin:
```bash
# From the Backstage root directory
yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-gitlab
```
Once you've done that, you'll also need to add the segment below to `packages/backend/src/plugins/catalog.ts`:
```ts
/* packages/backend/src/plugins/catalog.ts */
import { GitlabDiscoveryEntityProvider } from '@backstage/plugin-catalog-backend-module-gitlab';
const builder = await CatalogBuilder.create(env);
/** ... other processors and/or providers ... */
builder.addEntityProvider(
...GitlabDiscoveryEntityProvider.fromConfig(env.config, {
logger: env.logger,
schedule: env.scheduler.createScheduledTaskRunner({
frequency: { minutes: 30 },
timeout: { minutes: 3 },
}),
}),
);
```
## Alternative processor
```yaml
catalog:
@@ -22,6 +63,9 @@ catalog:
target: https://gitlab.com/group/subgroup/blob/main/catalog-info.yaml
```
As alternative to the entity provider `GitlabDiscoveryEntityProvider`
you can still use the `GitLabDiscoveryProcessor`.
Note the `gitlab-discovery` type, as this is not a regular `url` processor.
The target is composed of three parts:
+6 -5
View File
@@ -52,11 +52,12 @@ learn how to contribute the integration yourself!
The following table summarizes events that, depending on the plugins you have
installed, may be captured.
| Action | Provided By | Subject |
| ---------- | -------------- | --------------------------------------------------- |
| `navigate` | Backstage Core | The URL of the page that was navigated to |
| `click` | Backstage Core | The text of the link that was clicked on |
| `search` | Backstage Core | The search term entered in any search bar component |
| Action | Subject | Other Notes |
| ---------- | --------------------------------------------------- | ----------------------------------------------------------------- |
| `navigate` | The URL of the page that was navigated to | |
| `click` | The text of the link that was clicked on | The `to` attribute represents the URL clicked to |
| `search` | The search term entered in any search bar component | The `searchTypes` attribute holds `types` constraining the search |
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided |
If there is an event you'd like to see captured, please [open an
issue][add-event] describing the event you want to see and the questions it
+11
View File
@@ -0,0 +1,11 @@
---
title: Vault
author: Spread Group
authorUrl: https://github.com/ivangonzalezacuna
category: Vault
description: Visualize a list secrets stored in your vault instance.
documentation: https://github.com/backstage/backstage/tree/master/plugins/vault
iconUrl: img/vault.png
npmPackageName: '@backstage/plugin-vault'
tags:
- vault
-1
View File
@@ -288,7 +288,6 @@
"deployment/index",
"deployment/docker",
"deployment/k8s",
"deployment/helm",
"deployment/heroku"
],
"Designing for Backstage": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

-1
View File
@@ -167,7 +167,6 @@ nav:
- Deploying Backstage: 'deployment/index.md'
- Docker: 'deployment/docker.md'
- Kubernetes: 'deployment/k8s.md'
- Helm: 'deployment/helm.md'
- Heroku: 'deployment/heroku.md'
- Designing for Backstage:
- Design: 'dls/design.md'
+1 -1
View File
@@ -76,7 +76,7 @@
"semver": "^7.3.2",
"shx": "^0.3.2",
"ts-node": "^10.4.0",
"typescript": "~4.6.4",
"typescript": "~4.7.0",
"yarn-lock-check": "^1.0.5"
},
"prettier": "@spotify/prettier-config",
@@ -26,23 +26,27 @@ import {
useTheme,
} from '@material-ui/core';
import LaunchIcon from '@material-ui/icons/Launch';
import { Link, useContent } from '@backstage/core-components';
import {
CatalogIcon,
DocsIcon,
Link,
useContent,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { CatalogSearchResultListItem } from '@backstage/plugin-catalog';
import {
catalogApiRef,
CATALOG_FILTER_EXISTS,
} from '@backstage/plugin-catalog-react';
import { searchPlugin, SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchBar,
SearchFilter,
searchPlugin,
SearchBar,
SearchResult,
SearchResultPager,
SearchType,
} from '@backstage/plugin-search';
import { useSearch } from '@backstage/plugin-search-react';
useSearch,
} from '@backstage/plugin-search-react';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
const useStyles = makeStyles(theme => ({
@@ -180,24 +184,28 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight }) => {
{results.map(({ type, document, highlight, rank }) => {
let resultItem;
switch (type) {
case 'software-catalog':
resultItem = (
<CatalogSearchResultListItem
icon={<CatalogIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
case 'techdocs':
resultItem = (
<TechDocsSearchResultListItem
icon={<DocsIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
@@ -207,6 +215,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
@@ -29,15 +29,15 @@ import {
catalogApiRef,
CATALOG_FILTER_EXISTS,
} from '@backstage/plugin-catalog-react';
import { SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchBar,
SearchFilter,
SearchResult,
SearchResultPager,
SearchType,
} from '@backstage/plugin-search';
import { useSearch } from '@backstage/plugin-search-react';
useSearch,
} from '@backstage/plugin-search-react';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core';
import React from 'react';
@@ -132,22 +132,26 @@ const SearchPage = () => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight }) => {
{results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
icon={<CatalogIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'techdocs':
return (
<TechDocsSearchResultListItem
icon={<DocsIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
@@ -156,6 +160,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@
"@backstage/errors": "^1.0.0",
"@backstage/integration": "^1.2.1-next.2",
"@backstage/types": "^1.0.0",
"@google-cloud/storage": "^5.8.0",
"@google-cloud/storage": "^6.0.0",
"@manypkg/get-packages": "^1.1.3",
"@octokit/rest": "^18.5.3",
"@types/cors": "^2.8.6",
@@ -19,18 +19,6 @@ import openBrowser from 'react-dev-utils/openBrowser';
import { request } from '@octokit/request';
import express, { Express, Request, Response } from 'express';
const MANIFEST_DATA = {
default_events: ['create', 'delete', 'push', 'repository'],
default_permissions: {
contents: 'read',
metadata: 'read',
},
name: 'Backstage-<changeme>',
url: 'https://backstage.io',
description: 'GitHub App for Backstage',
public: false,
};
const FORM_PAGE = `
<html>
<body>
@@ -62,17 +50,17 @@ export class GithubCreateAppServer {
static async run(options: {
org: string;
readWrite: boolean;
permissions: string[];
}): Promise<GithubAppConfig> {
const encodedOrg = encodeURIComponent(options.org);
const actionUrl = `https://github.com/organizations/${encodedOrg}/settings/apps/new`;
const server = new GithubCreateAppServer(actionUrl, options.readWrite);
const server = new GithubCreateAppServer(actionUrl, options.permissions);
return server.start();
}
private constructor(
private readonly actionUrl: string,
private readonly readWrite: boolean,
private readonly permissions: string[],
) {
const webhookId = crypto
.randomBytes(15)
@@ -121,21 +109,29 @@ export class GithubCreateAppServer {
if (!baseUrl) {
throw new Error('baseUrl is not set');
}
const manifest = {
...MANIFEST_DATA,
default_events: ['create', 'delete', 'push', 'repository'],
default_permissions: {
metadata: 'read',
...(this.permissions.includes('members') && { members: 'read' }),
...(this.permissions.includes('read') && { contents: 'read' }),
...(this.permissions.includes('write') && {
contents: 'write',
actions: 'write',
}),
},
name: 'Backstage-<changeme>',
url: 'https://backstage.io',
description: 'GitHub App for Backstage',
public: false,
redirect_url: `${baseUrl}/callback`,
hook_attributes: {
url: this.webhookUrl,
active: false,
},
...(this.readWrite && {
default_permissions: {
contents: 'write',
actions: 'write',
metadata: 'read',
},
}),
};
const manifestJson = JSON.stringify(manifest).replace(/\"/g, '&quot;');
let body = FORM_PAGE;
@@ -26,19 +26,38 @@ import openBrowser from 'react-dev-utils/openBrowser';
// due to lacking support for creating apps from manifests.
// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest
export default async (org: string) => {
const answers: Answers = await inquirer.prompt([
{
type: 'list',
name: 'appType',
message: chalk.blue('What will the app be used for [required]'),
choices: ['Read and Write (needed by Software Templates)', 'Read only'],
},
]);
const readWrite = answers.appType !== 'Read only';
const answers: Answers = await inquirer.prompt({
name: 'appType',
type: 'checkbox',
message:
'Select permissions [required] (these can be changed later but then require approvals in all installations)',
choices: [
{
name: 'Read access to content (required by Software Catalog to ingest data from repositories)',
value: 'read',
checked: true,
},
{
name: 'Read access to members (required by Software Catalog to ingest GitHub teams)',
value: 'members',
checked: true,
},
{
name: 'Read and Write to content and actions (required by Software Templates to create new repositories)',
value: 'write',
},
],
});
if (answers.appType.length === 0) {
console.log(chalk.red('You must select at least one permission'));
process.exit(1);
}
await verifyGithubOrg(org);
const { slug, name, ...config } = await GithubCreateAppServer.run({
org,
readWrite,
permissions: answers.appType,
});
const fileName = `github-app-${slug}-credentials.yaml`;
@@ -55,7 +55,14 @@ export class PluginProtocolResolverFetchMiddleware implements FetchMiddleware {
}
const target = `${join(base, pathname)}${search}${hash}`;
return next(target, typeof input === 'string' ? init : input);
return next(
target,
typeof input === 'string' || isUrl(input) ? init : input,
);
};
}
}
function isUrl(a: unknown): a is URL {
return typeof a === 'object' && a?.constructor === URL;
}
+1 -1
View File
@@ -64,7 +64,7 @@
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^15.4.5",
"react-text-truncate": "^0.18.0",
"react-text-truncate": "^0.19.0",
"react-use": "^17.3.2",
"@react-hookz/web": "^14.0.0",
"react-virtualized-auto-sizer": "^1.0.6",
@@ -160,4 +160,55 @@ describe('<Table />', () => {
);
expect(rendered.getByText('EMPTY')).toBeInTheDocument();
});
describe('with custom components', () => {
const CustomRow = ({ data }: any) => {
return (
<tr>
<td>customised cell {data.col1}</td>
<td>customised cell {data.col2}</td>
</tr>
);
};
it('should not override the toolbar implementation', async () => {
const rendered = await renderInTestApp(
<Table
subtitle="subtitle"
emptyContent={<div>EMPTY</div>}
columns={minProps.columns}
data={minProps.data}
filters={[
{
column: column1.title,
type: 'select',
},
]}
components={{
Row: CustomRow,
}}
/>,
);
expect(rendered.getByText('Filters (0)')).toBeInTheDocument();
});
it('should render the provided custom row component correctly', async () => {
const rendered = await renderInTestApp(
<Table
subtitle="subtitle"
emptyContent={<div>EMPTY</div>}
columns={minProps.columns}
data={minProps.data}
components={{
Row: CustomRow,
}}
/>,
);
expect(
rendered.getByText('customised cell first value, first row'),
).toBeInTheDocument();
});
});
});
@@ -303,6 +303,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
initialState,
emptyContent,
onStateChange,
components,
...restProps
} = props;
const tableClasses = useTableStyles();
@@ -493,6 +494,7 @@ export function Table<T extends object = {}>(props: TableProps<T>) {
Header: StyledMTableHeader,
Toolbar,
Body,
...components,
}}
options={{ ...defaultOptions, ...options }}
columns={MTColumns}
-48
View File
@@ -50,54 +50,6 @@
"mock-fs": "^5.1.1",
"ts-node": "^10.0.0"
},
"peerDependencies": {
"@backstage/app-defaults": "",
"@backstage/backend-common": "",
"@backstage/backend-tasks": "",
"@backstage/catalog-client": "",
"@backstage/catalog-model": "",
"@backstage/cli": "",
"@backstage/config": "",
"@backstage/core-app-api": "",
"@backstage/core-components": "",
"@backstage/core-plugin-api": "",
"@backstage/errors": "",
"@backstage/integration-react": "",
"@backstage/plugin-api-docs": "",
"@backstage/plugin-app-backend": "",
"@backstage/plugin-auth-backend": "",
"@backstage/plugin-catalog": "",
"@backstage/plugin-catalog-backend": "",
"@backstage/plugin-catalog-common": "",
"@backstage/plugin-catalog-graph": "",
"@backstage/plugin-catalog-import": "",
"@backstage/plugin-catalog-react": "",
"@backstage/plugin-circleci": "",
"@backstage/plugin-explore": "",
"@backstage/plugin-github-actions": "",
"@backstage/plugin-lighthouse": "",
"@backstage/plugin-org": "",
"@backstage/plugin-permission-common": "",
"@backstage/plugin-permission-node": "",
"@backstage/plugin-permission-react": "",
"@backstage/plugin-proxy-backend": "",
"@backstage/plugin-rollbar-backend": "",
"@backstage/plugin-scaffolder": "",
"@backstage/plugin-scaffolder-backend": "",
"@backstage/plugin-search": "",
"@backstage/plugin-search-react": "",
"@backstage/plugin-search-backend": "",
"@backstage/plugin-search-backend-module-pg": "",
"@backstage/plugin-search-backend-node": "",
"@backstage/plugin-tech-radar": "",
"@backstage/plugin-techdocs": "",
"@backstage/plugin-techdocs-react": "",
"@backstage/plugin-techdocs-module-addons-contrib": "",
"@backstage/plugin-techdocs-backend": "",
"@backstage/plugin-user-settings": "",
"@backstage/test-utils": "",
"@backstage/theme": ""
},
"nodemonConfig": {
"watch": "./src",
"exec": "bin/backstage-create-app",
+2 -3
View File
@@ -20,9 +20,8 @@
This is a list of all packages used by the template. If dependencies are added or removed,
this list should be updated as well.
The list, and the accompanying peerDependencies entries, are here to ensure correct versioning
and bumping of this package. Without this list the version would not be bumped unless we
manually trigger a release.
There is a release step that ensures that this package is always bumped with every
release, meaning these version will always be up to date.
This does not create an actual dependency on these packages and does not bring in any code.
Relative imports are used rather than package imports to make sure the packages aren't externalized.
@@ -8,14 +8,14 @@ import {
} from '@backstage/plugin-catalog-react';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchBar,
SearchFilter,
SearchResult,
SearchType,
DefaultResultListItem,
} from '@backstage/plugin-search';
import { useSearch } from '@backstage/plugin-search-react';
useSearch,
} from '@backstage/plugin-search-react';
import {
CatalogIcon,
Content,
@@ -112,7 +112,7 @@ const SearchPage = () => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight }) => {
{results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
@@ -120,6 +120,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'techdocs':
@@ -128,6 +129,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
@@ -136,6 +138,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
@@ -18,18 +18,36 @@ export default async function createPlugin(
providerFactories: {
...defaultAuthProviderFactories,
// This overrides the default GitHub auth provider with a custom one.
// Since the options are empty it will behave just like the default
// provider, but if you uncomment the `signIn` section you will enable
// sign-in via GitHub. This particular configuration uses a resolver
// that matches the username to the user entity name. See the auth
// documentation for more details on how to enable and customize sign-in:
// This replaces the default GitHub auth provider with a customized one.
// The `signIn` option enables sign-in for this provider, using the
// identity resolution logic that's provided in the `resolver` callback.
//
// This particular resolver makes all users share a single "guest" identity.
// It should only be used for testing and trying out Backstage.
//
// If you want to use a production ready resolver you can switch to the
// the one that is commented out below, it looks up a user entity in the
// catalog using the GitHub username of the authenticated user.
// That resolver requires you to have user entities populated in the catalog,
// for example using https://backstage.io/docs/integrations/github/org
//
// There are other resolvers to choose from, and you can also create
// your own, see the auth documentation for more details:
//
// https://backstage.io/docs/auth/identity-resolver
github: providers.github.create({
// signIn: {
// resolver: providers.github.resolvers.usernameMatchingUserEntityName(),
// },
signIn: {
resolver(_, ctx) {
const userRef = 'user:default/guest'; // Must be a full entity reference
return ctx.issueToken({
claims: {
sub: userRef, // The user's own identity
ent: [userRef], // A list of identities that the user claims ownership through
},
});
},
// resolver: providers.github.resolvers.usernameMatchingUserEntityName(),
},
}),
},
});
+1 -1
View File
@@ -50,7 +50,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"swagger-ui-react": "^4.1.3"
"swagger-ui-react": "^4.11.1"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -6,8 +6,29 @@
import { CatalogProcessor } from '@backstage/plugin-catalog-backend';
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
import { EntityProvider } from '@backstage/plugin-catalog-backend';
import { EntityProviderConnection } from '@backstage/plugin-catalog-backend';
import { LocationSpec } from '@backstage/plugin-catalog-backend';
import { Logger } from 'winston';
import { TaskRunner } from '@backstage/backend-tasks';
// @public
export class GitlabDiscoveryEntityProvider implements EntityProvider {
// (undocumented)
connect(connection: EntityProviderConnection): Promise<void>;
// (undocumented)
static fromConfig(
config: Config,
options: {
logger: Logger;
schedule: TaskRunner;
},
): GitlabDiscoveryEntityProvider[];
// (undocumented)
getProviderName(): string;
// (undocumented)
refresh(logger: Logger): Promise<void>;
}
// @public
export class GitLabDiscoveryProcessor implements CatalogProcessor {
+55
View File
@@ -0,0 +1,55 @@
/*
* 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.
*/
export interface Config {
catalog?: {
/**
* List of provider-specific options and attributes
*/
providers?: {
/**
* GitlabDiscoveryEntityProvider configuration
*/
gitlab?: Record<
string,
{
/**
* (Required) Gitlab's host name.
* @visibility backend
*/
host: string;
/**
* (Required) Gitlab's group[/subgroup] where the discovery is done.
* @visibility backend
*/
group: string;
/**
* (Optional) Default branch to read the catalog-info.yaml file.
* If not set, 'master' will be used.
* @visibility backend
*/
branch?: string;
/**
* (Optional) The name used for the catalog file.
* If not set, 'catalog-info.yaml' will be used.
* @visibility backend
*/
entityFilename?: string;
}
>;
};
};
}
@@ -35,6 +35,7 @@
"dependencies": {
"@backstage/backend-common": "^0.14.0-next.2",
"@backstage/catalog-model": "^1.0.3-next.0",
"@backstage/backend-tasks": "^0.3.2-next.1",
"@backstage/config": "^1.0.1",
"@backstage/errors": "^1.0.0",
"@backstage/integration": "^1.2.1-next.2",
@@ -43,12 +44,14 @@
"lodash": "^4.17.21",
"msw": "^0.42.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1"
"winston": "^3.2.1",
"uuid": "^8.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "^0.1.25-next.2",
"@backstage/cli": "^0.17.2-next.2",
"@types/lodash": "^4.14.151"
"@backstage/backend-test-utils": "^0.1.25-next.1",
"@backstage/cli": "^0.17.2-next.1",
"@types/lodash": "^4.14.151",
"@types/uuid": "^8.0.0"
},
"files": [
"dist"
@@ -17,7 +17,7 @@
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { LocationSpec } from '@backstage/plugin-catalog-backend';
import { rest } from 'msw';
import { rest, RestRequest } from 'msw';
import { setupServer } from 'msw/node';
import { GitLabDiscoveryProcessor, parseUrl } from './GitLabDiscoveryProcessor';
import { GitLabProject } from './lib';
@@ -48,6 +48,7 @@ const GROUP_LOCATION_CUSTOM_BRANCH: LocationSpec = {
type: 'gitlab-discovery',
target: `${SERVER_URL}/group/subgroup/blob/test/catalog-info.yaml`,
};
const SERVER_TIME = '2001-01-01T12:34:56.000Z';
function setupFakeServer(
url: string,
@@ -58,9 +59,15 @@ function setupFakeServer(
data: GitLabProject[];
nextPage?: number;
},
assertion?: (r: RestRequest) => any,
) {
server.use(
rest.get(url, (req, res, ctx) => {
// Send the request to the assertion to give the test an opportunity to inspect the parameters.
if (assertion !== undefined) {
assertion(req);
}
if (req.headers.get('private-token') !== 'test-token') {
return res(ctx.status(401), ctx.json({}));
}
@@ -145,7 +152,7 @@ describe('GitlabDiscoveryProcessor', () => {
beforeAll(() => {
server.listen();
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2001-01-01T12:34:56Z'));
jest.setSystemTime(new Date(SERVER_TIME));
});
afterEach(() => server.resetHandlers());
afterAll(() => {
@@ -394,34 +401,43 @@ describe('GitlabDiscoveryProcessor', () => {
});
it('uses the previous scan timestamp to filter', async () => {
const payload = {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2000-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/1',
},
{
id: 2,
archived: false,
default_branch: 'main',
last_activity_at: '2002-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/2',
},
],
};
const processor = getProcessor();
setupFakeServer(PROJECTS_URL, request => {
switch (request.page) {
case 1:
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2000-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/1',
path_with_namespace: '1',
},
{
id: 2,
archived: false,
default_branch: 'main',
last_activity_at: '2002-01-01T00:00:00Z',
web_url: 'https://gitlab.fake/2',
path_with_namespace: '2',
},
],
};
default:
throw new Error('Invalid request');
}
});
setupFakeServer(
PROJECTS_URL,
request => {
switch (request.page) {
case 1:
return payload;
default:
throw new Error('Invalid request');
}
},
request => {
// We assert that the last activity timestamp is not being sent to the GitLab API as we expect the cache to be empty.
expect(
request.url.searchParams.get('last_activity_after'),
).toBeNull();
},
);
const result: any[] = [];
@@ -433,6 +449,18 @@ describe('GitlabDiscoveryProcessor', () => {
// Second scan should have used the mocked Date to set the last scanned time to 2001
// This should result in only the second repo being scanned, since that has a timestamp of 2002
setupFakeServer(
PROJECTS_URL,
_ => {
return payload;
},
request => {
// We assert that the last activity timestamp is being sent to the GitLab API since we expect it to be in the cache.
expect(request.url.searchParams.get('last_activity_after')).toMatch(
SERVER_TIME,
);
},
);
const result2: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result2.push(e);
@@ -84,6 +84,7 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor {
return false;
}
const startTime = new Date();
const { group, host, branch, catalogPath } = parseUrl(location.target);
const integration = this.integrations.gitlab.byUrl(`https://${host}`);
@@ -97,14 +98,18 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor {
config: integration.config,
logger: this.logger,
});
const startTimestamp = Date.now();
this.logger.debug(`Reading GitLab projects from ${location.target}`);
const projects = paginated(options => client.listProjects(options), {
const lastActivity = (await this.cache.get(this.getCacheKey())) as string;
const opts = {
group,
last_activity_after: await this.updateLastActivity(),
page: 1,
});
// We check for the existence of lastActivity and only set it if it's present to ensure
// that the options doesn't include the key so that the API doesn't receive an empty query parameter.
...(lastActivity && { last_activity_after: lastActivity }),
};
const projects = paginated(options => client.listProjects(options), opts);
const res: Result = {
scanned: 0,
@@ -156,7 +161,10 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor {
);
}
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
// Save an ISO formatted string in the cache as that's what GitLab expects in the API request.
await this.cache.set(this.getCacheKey(), startTime.toISOString());
const duration = ((Date.now() - startTime.getTime()) / 1000).toFixed(1);
this.logger.debug(
`Read ${res.scanned} GitLab repositories in ${duration} seconds`,
);
@@ -164,11 +172,8 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor {
return true;
}
private async updateLastActivity(): Promise<string | undefined> {
const cacheKey = `processors/${this.getProcessorName()}/last-activity`;
const lastActivity = await this.cache.get(cacheKey);
await this.cache.set(cacheKey, new Date().toISOString());
return lastActivity as string | undefined;
private getCacheKey(): string {
return `processors/${this.getProcessorName()}/last-activity`;
}
}
@@ -21,3 +21,4 @@
*/
export { GitLabDiscoveryProcessor } from './GitLabDiscoveryProcessor';
export { GitlabDiscoveryEntityProvider } from './providers';
@@ -103,6 +103,21 @@ function setupFakeInstanceProjectsEndpoint(
);
}
function setupFakeHasFileEndpoint(srv: SetupServerApi, apiBaseUrl: string) {
srv.use(
rest.head(
`${apiBaseUrl}/projects/group%2Frepo/repository/files/catalog-info.yaml`,
(req, res, ctx) => {
const branch = req.url.searchParams.get('ref');
if (branch === 'master') {
return res(ctx.status(200));
}
return res(ctx.status(404, 'Not Found'));
},
),
);
}
describe('GitLabClient', () => {
describe('isSelfManaged', () => {
it('returns true if self managed instance', () => {
@@ -266,3 +281,33 @@ describe('paginated', () => {
expect(allItems).toHaveLength(4);
});
});
describe('hasFile', () => {
let client: GitLabClient;
beforeEach(() => {
setupFakeHasFileEndpoint(server, MOCK_CONFIG.apiBaseUrl);
client = new GitLabClient({
config: MOCK_CONFIG,
logger: getVoidLogger(),
});
});
it('should not find catalog file', async () => {
const hasFile = await client.hasFile(
'group/repo',
'master',
'catalog-info.yaml',
);
expect(hasFile).toBe(true);
});
it('should find catalog file', async () => {
const hasFile = await client.hasFile(
'group/repo',
'unknown',
'catalog-info.yaml',
);
expect(hasFile).toBe(false);
});
});
@@ -63,6 +63,13 @@ export class GitLabClient {
return this.pagedRequest(`/projects`, options);
}
/**
* General existence check.
*
* @param projectPath - The path to the project
* @param branch - The branch used to search
* @param filePath - The path to the file
*/
async hasFile(
projectPath: string,
branch: string,
@@ -15,4 +15,9 @@
*/
export { GitLabClient, paginated } from './client';
export type { GitLabProject } from './types';
export type {
GitLabProject,
GitlabProviderConfig,
GitlabGroupDescription,
} from './types';
export { readGitlabConfigs } from '../providers/config';
@@ -14,10 +14,25 @@
* limitations under the License.
*/
export type GitlabGroupDescription = {
id: number;
web_url: string;
projects: GitLabProject[];
};
export type GitLabProject = {
id: number;
default_branch?: string;
archived: boolean;
last_activity_at: string;
web_url: string;
path_with_namespace?: string;
};
export type GitlabProviderConfig = {
host: string;
group: string;
id: string;
branch: string;
catalogFile: string;
};
@@ -0,0 +1,230 @@
/*
* Copyright 2022 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 { getVoidLogger } from '@backstage/backend-common';
import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks';
import { ConfigReader } from '@backstage/config';
import { EntityProviderConnection } from '@backstage/plugin-catalog-backend';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { GitlabDiscoveryEntityProvider } from './GitlabDiscoveryEntityProvider';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
class PersistingTaskRunner implements TaskRunner {
private tasks: TaskInvocationDefinition[] = [];
getTasks() {
return this.tasks;
}
run(task: TaskInvocationDefinition): Promise<void> {
this.tasks.push(task);
return Promise.resolve(undefined);
}
}
const logger = getVoidLogger();
const server = setupServer();
describe('GitlabDiscoveryEntityProvider', () => {
setupRequestMockHandlers(server);
afterEach(() => jest.resetAllMocks());
it('no provider config', () => {
const schedule = new PersistingTaskRunner();
const config = new ConfigReader({});
const providers = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
});
expect(providers).toHaveLength(0);
});
it('single simple discovery config', () => {
const schedule = new PersistingTaskRunner();
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'test-gitlab',
apiBaseUrl: 'https://api.gitlab.example/api/v4',
token: '1234',
},
],
},
catalog: {
providers: {
gitlab: {
'test-id': {
host: 'test-gitlab',
group: 'test-group',
},
},
},
},
});
const providers = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
});
expect(providers).toHaveLength(1);
expect(providers[0].getProviderName()).toEqual(
'GitlabDiscoveryEntityProvider:test-id',
);
});
it('multiple discovery configs', () => {
const schedule = new PersistingTaskRunner();
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'test-gitlab',
apiBaseUrl: 'https://api.gitlab.example/api/v4',
token: '1234',
},
],
},
catalog: {
providers: {
gitlab: {
'test-id': {
host: 'test-gitlab',
group: 'test-group',
},
'second-test': {
host: 'test-gitlab',
group: 'second-group',
},
},
},
},
});
const providers = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
});
expect(providers).toHaveLength(2);
expect(providers[0].getProviderName()).toEqual(
'GitlabDiscoveryEntityProvider:test-id',
);
expect(providers[1].getProviderName()).toEqual(
'GitlabDiscoveryEntityProvider:second-test',
);
});
it('apply full update on scheduled execution', async () => {
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'test-gitlab',
apiBaseUrl: 'https://api.gitlab.example/api/v4',
token: '1234',
},
],
},
catalog: {
providers: {
gitlab: {
'test-id': {
host: 'test-gitlab',
group: 'test-group',
},
},
},
},
});
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
})[0];
expect(provider.getProviderName()).toEqual(
'GitlabDiscoveryEntityProvider:test-id',
);
server.use(
rest.get(
`https://api.gitlab.example/api/v4/groups/test-group/projects`,
(_req, res, ctx) => {
const response = [
{
id: 123,
default_branch: 'master',
archived: false,
last_activity_at: new Date().toString(),
web_url: 'https://api.gitlab.example/test-group/test-repo',
path_with_namespace: 'test-group/test-repo',
},
];
return res(ctx.json(response));
},
),
rest.head(
'https://api.gitlab.example/api/v4/projects/test-group%2Ftest-repo/repository/files/catalog-info.yaml',
(req, res, ctx) => {
if (req.url.searchParams.get('ref') === 'master') {
return res(ctx.status(200));
}
return res(ctx.status(404, 'Not Found'));
},
),
);
await provider.connect(entityProviderConnection);
const taskDef = schedule.getTasks()[0];
expect(taskDef.id).toEqual('GitlabDiscoveryEntityProvider:test-id:refresh');
await (taskDef.fn as () => Promise<void>)();
const url = `https://api.gitlab.example/test-group/test-repo/-/blob/master/catalog-info.yaml`;
const expectedEntities = [
{
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
annotations: {
'backstage.io/managed-by-location': `url:${url}`,
'backstage.io/managed-by-origin-location': `url:${url}`,
},
name: 'generated-cd37bf72a2fe92603f4255d9f49c6c1ead746a48',
},
spec: {
presence: 'optional',
target: `${url}`,
type: 'url',
},
},
locationKey: 'GitlabDiscoveryEntityProvider:test-id',
},
];
expect(entityProviderConnection.applyMutation).toBeCalledTimes(1);
expect(entityProviderConnection.applyMutation).toBeCalledWith({
type: 'full',
entities: expectedEntities,
});
});
});
@@ -0,0 +1,190 @@
/*
* 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.
*/
import { TaskRunner } from '@backstage/backend-tasks';
import { Config } from '@backstage/config';
import { GitLabIntegration, ScmIntegrations } from '@backstage/integration';
import {
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-backend';
import { LocationSpec } from '@backstage/plugin-catalog-backend';
import { Logger } from 'winston';
import {
GitLabClient,
GitLabProject,
GitlabProviderConfig,
paginated,
readGitlabConfigs,
} from '../lib';
import * as uuid from 'uuid';
import { locationSpecToLocationEntity } from '@backstage/plugin-catalog-backend';
type Result = {
scanned: number;
matches: GitLabProject[];
};
/**
* Discovers entity definition files in the groups of a Gitlab instance.
* @public
*/
export class GitlabDiscoveryEntityProvider implements EntityProvider {
private readonly config: GitlabProviderConfig;
private readonly integration: GitLabIntegration;
private readonly logger: Logger;
private readonly scheduleFn: () => Promise<void>;
private connection?: EntityProviderConnection;
static fromConfig(
config: Config,
options: { logger: Logger; schedule: TaskRunner },
): GitlabDiscoveryEntityProvider[] {
const providerConfigs = readGitlabConfigs(config);
const integrations = ScmIntegrations.fromConfig(config).gitlab;
const providers: GitlabDiscoveryEntityProvider[] = [];
providerConfigs.forEach(providerConfig => {
const integration = integrations.byHost(providerConfig.host);
if (!integration) {
throw new Error(
`No gitlab integration found that matches host ${providerConfig.host}`,
);
}
providers.push(
new GitlabDiscoveryEntityProvider({
...options,
config: providerConfig,
integration,
}),
);
});
return providers;
}
private constructor(options: {
config: GitlabProviderConfig;
integration: GitLabIntegration;
logger: Logger;
schedule: TaskRunner;
}) {
this.config = options.config;
this.integration = options.integration;
this.logger = options.logger.child({
target: this.getProviderName(),
});
this.scheduleFn = this.createScheduleFn(options.schedule);
}
getProviderName(): string {
return `GitlabDiscoveryEntityProvider:${this.config.id}`;
}
async connect(connection: EntityProviderConnection): Promise<void> {
this.connection = connection;
await this.scheduleFn();
}
private createScheduleFn(schedule: TaskRunner): () => Promise<void> {
return async () => {
const taskId = `${this.getProviderName()}:refresh`;
return schedule.run({
id: taskId,
fn: async () => {
const logger = this.logger.child({
class: GitlabDiscoveryEntityProvider.prototype.constructor.name,
taskId,
taskInstanceId: uuid.v4(),
});
try {
await this.refresh(logger);
} catch (error) {
logger.error(error);
}
},
});
};
}
async refresh(logger: Logger): Promise<void> {
if (!this.connection) {
throw new Error(
`Gitlab discovery connection not initialized for ${this.getProviderName()}`,
);
}
const client = new GitLabClient({
config: this.integration.config,
logger: logger,
});
const projects = paginated<GitLabProject>(
options => client.listProjects(options),
{
group: this.config.group,
page: 1,
per_page: 50,
},
);
const res: Result = {
scanned: 0,
matches: [],
};
for await (const project of projects) {
res.scanned++;
if (project.archived) {
continue;
}
if (this.config.branch === '*' && project.default_branch === undefined) {
continue;
}
const project_branch = project.default_branch ?? this.config.branch;
const projectHasFile: boolean = await client.hasFile(
project.path_with_namespace ?? '',
project_branch,
this.config.catalogFile,
);
if (projectHasFile) {
res.matches.push(project);
}
}
const locations = res.matches.map(p => this.createLocationSpec(p));
await this.connection.applyMutation({
type: 'full',
entities: locations.map(location => ({
locationKey: this.getProviderName(),
entity: locationSpecToLocationEntity({ location }),
})),
});
}
private createLocationSpec(project: GitLabProject): LocationSpec {
const project_branch = project.default_branch ?? this.config.branch;
return {
type: 'url',
target: `${project.web_url}/-/blob/${project_branch}/${this.config.catalogFile}`,
presence: 'optional',
};
}
}
@@ -0,0 +1,106 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { readGitlabConfigs } from './config';
describe('config', () => {
it('empty gitlab config', () => {
const config = new ConfigReader({
catalog: {
providers: {},
},
});
const result = readGitlabConfigs(config);
expect(result).toHaveLength(0);
});
it('valid config with default optional params', () => {
const config = new ConfigReader({
catalog: {
providers: {
gitlab: {
test: {
group: 'group',
host: 'host',
},
},
},
},
});
const result = readGitlabConfigs(config);
expect(result).toHaveLength(1);
result.forEach(r =>
expect(r).toStrictEqual({
id: 'test',
group: 'group',
branch: 'master',
host: 'host',
catalogFile: 'catalog-info.yaml',
}),
);
});
it('valid config with custom optional params', () => {
const config = new ConfigReader({
catalog: {
providers: {
gitlab: {
test: {
group: 'group',
host: 'host',
branch: 'not-master',
entityFilename: 'custom-file.yaml',
},
},
},
},
});
const result = readGitlabConfigs(config);
expect(result).toHaveLength(1);
result.forEach(r =>
expect(r).toStrictEqual({
id: 'test',
group: 'group',
branch: 'not-master',
host: 'host',
catalogFile: 'custom-file.yaml',
}),
);
});
it('missing params', () => {
const config = new ConfigReader({
catalog: {
providers: {
gitlab: {
test: {
branch: 'not-master',
entityFilename: 'custom-file.yaml',
},
},
},
},
});
expect(() => readGitlabConfigs(config)).toThrow(
"Missing required config value at 'catalog.providers.gitlab.test.group'",
);
});
});
@@ -0,0 +1,64 @@
/*
* Copyright 2022 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 { Config } from '@backstage/config';
import { GitlabProviderConfig } from '../lib/types';
/**
* Extracts the gitlab config from a config object
*
* @public
*
* @param config - The config object to extract from
*/
function readGitlabConfig(id: string, config: Config): GitlabProviderConfig {
const group = config.getString('group');
const host = config.getString('host');
const branch = config.getOptionalString('branch') ?? 'master';
const catalogFile =
config.getOptionalString('entityFilename') ?? 'catalog-info.yaml';
return {
id,
group,
branch,
host,
catalogFile,
};
}
/**
* Extracts the gitlab config from a config object array
*
* @public
*
* @param config - The config object to extract from
*/
export function readGitlabConfigs(config: Config): GitlabProviderConfig[] {
const configs: GitlabProviderConfig[] = [];
const providerConfigs = config.getOptionalConfig('catalog.providers.gitlab');
if (!providerConfigs) {
return configs;
}
for (const id of providerConfigs.keys()) {
configs.push(readGitlabConfig(id, providerConfigs.getConfig(id)));
}
return configs;
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { GitlabDiscoveryEntityProvider } from './GitlabDiscoveryEntityProvider';
+4
View File
@@ -112,6 +112,10 @@ export interface CatalogSearchResultListItemProps {
// (undocumented)
highlight?: ResultHighlight;
// (undocumented)
icon?: ReactNode;
// (undocumented)
rank?: number;
// (undocumented)
result: IndexableDocument;
}
@@ -14,16 +14,18 @@
* limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import {
Box,
Chip,
Divider,
ListItem,
ListItemIcon,
ListItemText,
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import {
IndexableDocument,
ResultHighlight,
@@ -47,8 +49,10 @@ const useStyles = makeStyles({
* @public
*/
export interface CatalogSearchResultListItemProps {
icon?: ReactNode;
result: IndexableDocument;
highlight?: ResultHighlight;
rank?: number;
}
/** @public */
@@ -58,41 +62,54 @@ export function CatalogSearchResultListItem(
const result = props.result as any;
const classes = useStyles();
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: props.rank,
});
};
return (
<Link to={result.location}>
<ListItem alignItems="flex-start" className={classes.flexContainer}>
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={
props.highlight?.fields.title ? (
<HighlightedSearchResultText
text={props.highlight.fields.title}
preTag={props.highlight.preTag}
postTag={props.highlight.postTag}
/>
) : (
result.title
)
}
secondary={
props.highlight?.fields.text ? (
<HighlightedSearchResultText
text={props.highlight.fields.text}
preTag={props.highlight.preTag}
postTag={props.highlight.postTag}
/>
) : (
result.text
)
}
/>
<Box>
{result.kind && <Chip label={`Kind: ${result.kind}`} size="small" />}
{result.lifecycle && (
<Chip label={`Lifecycle: ${result.lifecycle}`} size="small" />
)}
</Box>
<Link noTrack to={result.location} onClick={handleClick}>
<ListItem alignItems="flex-start">
{props.icon && <ListItemIcon>{props.icon}</ListItemIcon>}
<div className={classes.flexContainer}>
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={
props.highlight?.fields.title ? (
<HighlightedSearchResultText
text={props.highlight.fields.title}
preTag={props.highlight.preTag}
postTag={props.highlight.postTag}
/>
) : (
result.title
)
}
secondary={
props.highlight?.fields.text ? (
<HighlightedSearchResultText
text={props.highlight.fields.text}
preTag={props.highlight.preTag}
postTag={props.highlight.postTag}
/>
) : (
result.text
)
}
/>
<Box>
{result.kind && (
<Chip label={`Kind: ${result.kind}`} size="small" />
)}
{result.lifecycle && (
<Chip label={`Lifecycle: ${result.lifecycle}`} size="small" />
)}
</Box>
</div>
</ListItem>
<Divider component="li" />
</Link>
+1
View File
@@ -112,6 +112,7 @@ export const createPermissionIntegrationRouter: <
TResource,
>(options: {
resourceType: TResourceType;
permissions?: Permission[] | undefined;
rules: PermissionRule<TResource, any, NoInfer<TResourceType>, unknown[]>[];
getResources: (resourceRefs: string[]) => Promise<(TResource | undefined)[]>;
}) => express.Router;
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
AuthorizeResult,
createPermission,
Permission,
} from '@backstage/plugin-permission-common';
import express, { Express, Router } from 'express';
import request, { Response } from 'supertest';
import { createPermissionIntegrationRouter } from './createPermissionIntegrationRouter';
@@ -26,22 +30,25 @@ const mockGetResources: jest.MockedFunction<
resourceRefs.map(resourceRef => ({ id: resourceRef })),
);
const testPermission: Permission = createPermission({
name: 'test.permission',
attributes: {},
});
const testRule1 = createPermissionRule({
name: 'test-rule-1',
description: 'Test rule 1',
resourceType: 'test-resource',
apply: jest.fn(
(_resource: any, _firstParam: string, _secondParam: number) => true,
),
toQuery: jest.fn(),
apply: (_resource: any, _firstParam: string, _secondParam: number) => true,
toQuery: (_firstParam: string, _secondParam: number) => ({}),
});
const testRule2 = createPermissionRule({
name: 'test-rule-2',
description: 'Test rule 2',
resourceType: 'test-resource',
apply: jest.fn((_resource: any, _firstParam: object) => false),
toQuery: jest.fn(),
apply: (_resource: any, _firstParam: object) => false,
toQuery: (_firstParam: object) => ({}),
});
describe('createPermissionIntegrationRouter', () => {
@@ -51,6 +58,7 @@ describe('createPermissionIntegrationRouter', () => {
beforeAll(() => {
router = createPermissionIntegrationRouter({
resourceType: 'test-resource',
permissions: [testPermission],
getResources: mockGetResources,
rules: [testRule1, testRule2],
});
@@ -501,4 +509,31 @@ describe('createPermissionIntegrationRouter', () => {
expect(response.error && response.error.text).toMatch(/invalid/i);
});
});
describe('GET /.well-known/backstage/permissions/metadata', () => {
it('returns a list of permissions and rules used by a given backend', async () => {
const response = await request(app).get(
'/.well-known/backstage/permissions/metadata',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
permissions: [testPermission],
rules: [
{
name: testRule1.name,
description: testRule1.description,
resourceType: testRule1.resourceType,
parameters: { count: 2 },
},
{
name: testRule2.name,
description: testRule2.description,
resourceType: testRule2.resourceType,
parameters: { count: 1 },
},
],
});
});
});
});
@@ -23,6 +23,7 @@ import {
AuthorizeResult,
DefinitivePolicyDecision,
IdentifiedPermissionMessage,
Permission,
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
@@ -174,6 +175,7 @@ export const createPermissionIntegrationRouter = <
TResource,
>(options: {
resourceType: TResourceType;
permissions?: Array<Permission>;
// Do not infer value of TResourceType from supplied rules.
// instead only consider the resourceType parameter, and
// consider any rules whose resource type does not match
@@ -183,8 +185,22 @@ export const createPermissionIntegrationRouter = <
resourceRefs: string[],
) => Promise<Array<TResource | undefined>>;
}): express.Router => {
const { resourceType, rules, getResources } = options;
const { resourceType, permissions, rules, getResources } = options;
const router = Router();
router.use(express.json());
router.get('/.well-known/backstage/permissions/metadata', (_, res) => {
const serializableRules = rules.map(rule => ({
name: rule.name,
description: rule.description,
resourceType: rule.resourceType,
parameters: {
count: rule.toQuery.length,
},
}));
return res.json({ permissions, rules: serializableRules });
});
const getRule = createGetRule(rules);
@@ -202,8 +218,6 @@ export const createPermissionIntegrationRouter = <
}
};
router.use(express.json());
router.post(
'/.well-known/backstage/permissions/apply-conditions',
async (req, res: Response<ApplyConditionsResponse | string>) => {
@@ -227,7 +241,7 @@ export const createPermissionIntegrationRouter = <
return acc;
}, {} as Record<string, TResource | undefined>);
return res.status(200).json({
return res.json({
items: body.items.map(request => ({
id: request.id,
result: applyConditions(
+1 -1
View File
@@ -338,13 +338,13 @@ export function createPublishGitlabAction(options: {
export const createPublishGitlabMergeRequestAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<{
projectid: string;
repoUrl: string;
title: string;
description: string;
branchName: string;
targetPath: string;
token?: string | undefined;
projectid?: string | undefined;
}>;
// @public
@@ -34,18 +34,19 @@ export const createPublishGitlabMergeRequestAction = (options: {
const { integrations } = options;
return createTemplateAction<{
projectid: string;
repoUrl: string;
title: string;
description: string;
branchName: string;
targetPath: string;
token?: string;
/** @deprecated Use projectPath instead */
projectid?: string;
}>({
id: 'publish:gitlab:merge-request',
schema: {
input: {
required: ['projectid', 'repoUrl', 'targetPath', 'branchName'],
required: ['repoUrl', 'targetPath', 'branchName'],
type: 'object',
properties: {
repoUrl: {
@@ -53,6 +54,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
title: 'Repository Location',
description: `Accepts the format 'gitlab.com/group_name/project_name' where 'project_name' is the repository name and 'group_name' is a group or username`,
},
/** @deprecated Use projectPath instead */
projectid: {
type: 'string',
title: 'projectid',
@@ -92,6 +94,10 @@ export const createPublishGitlabMergeRequestAction = (options: {
title: 'Gitlab Project id/Name(slug)',
type: 'string',
},
projectPath: {
title: 'Gitlab Project path',
type: 'string',
},
mergeRequestURL: {
title: 'MergeRequest(MR) URL',
type: 'string',
@@ -102,7 +108,15 @@ export const createPublishGitlabMergeRequestAction = (options: {
},
async handler(ctx) {
const repoUrl = ctx.input.repoUrl;
const { host } = parseRepoUrl(repoUrl, integrations);
const { host, owner, repo } = parseRepoUrl(repoUrl, integrations);
const projectPath = `${owner}/${repo}`;
if (ctx.input.projectid) {
const deprecationWarning = `Property "projectid" is deprecated and no longer to needed to create a MR`;
ctx.logger.warn(deprecationWarning);
console.warn(deprecationWarning);
}
const integrationConfig = integrations.gitlab.byHost(host);
const destinationBranch = ctx.input.branchName;
@@ -140,14 +154,13 @@ export const createPublishGitlabMergeRequestAction = (options: {
content: file.content.toString('base64'),
execute_filemode: file.executable,
}));
const projects = await api.Projects.show(ctx.input.projectid);
const projects = await api.Projects.show(projectPath);
const { default_branch: defaultBranch } = projects;
try {
await api.Branches.create(
ctx.input.projectid,
projectPath,
destinationBranch,
String(defaultBranch),
);
@@ -157,7 +170,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
try {
await api.Commits.create(
ctx.input.projectid,
projectPath,
destinationBranch,
ctx.input.title,
actions,
@@ -170,7 +183,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
try {
const mergeRequestUrl = await api.MergeRequests.create(
ctx.input.projectid,
projectPath,
destinationBranch,
String(defaultBranch),
ctx.input.title,
@@ -178,7 +191,9 @@ export const createPublishGitlabMergeRequestAction = (options: {
).then((mergeRequest: { web_url: string }) => {
return mergeRequest.web_url;
});
ctx.output('projectid', ctx.input.projectid);
/** @deprecated */
ctx.output('projectid', projectPath);
ctx.output('projectPath', projectPath);
ctx.output('mergeRequestUrl', mergeRequestUrl);
} catch (e) {
throw new InputError(`Merge request creation failed${e}`);
@@ -21,7 +21,6 @@ import {
parseLocationRef,
ANNOTATION_SOURCE_LOCATION,
CompoundEntityRef,
DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
@@ -95,11 +94,6 @@ export async function findTemplate(options: {
}): Promise<TemplateEntityV1beta3> {
const { entityRef, token, catalogApi } = options;
if (entityRef.namespace.toLocaleLowerCase('en-US') !== DEFAULT_NAMESPACE) {
throw new InputError(
`Invalid namespace, only '${DEFAULT_NAMESPACE}' namespace is supported`,
);
}
if (entityRef.kind.toLocaleLowerCase('en-US') !== 'template') {
throw new InputError(`Invalid kind, only 'Template' kind is supported`);
}
+23 -3
View File
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, { ComponentType } from 'react';
import { Routes, Route, useOutlet, Navigate } from 'react-router';
import React, { ComponentType, useEffect } from 'react';
import { Routes, Route, useOutlet, Navigate, useParams } from 'react-router';
import { Entity } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ScaffolderPage } from './ScaffolderPage';
@@ -31,10 +31,11 @@ import {
FIELD_EXTENSION_KEY,
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
} from '../extensions';
import { useElementFilter } from '@backstage/core-plugin-api';
import { useElementFilter, useRouteRef } from '@backstage/core-plugin-api';
import {
actionsRouteRef,
editRouteRef,
legacySelectedTemplateRouteRef,
scaffolderListTaskRouteRef,
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
@@ -100,6 +101,22 @@ export const Router = (props: RouterProps) => {
),
),
];
/**
* This component can be deleted once the older routes have been deprecated.
*/
const RedirectingComponent = () => {
const { templateName } = useParams();
const newLink = useRouteRef(selectedTemplateRouteRef);
useEffect(
() =>
// eslint-disable-next-line no-console
console.warn(
'The route /template/:templateName is deprecated, please use the new /template/:namespace/:templateName route instead',
),
[],
);
return <Navigate to={newLink({ namespace: 'default', templateName })} />;
};
return (
<Routes>
@@ -112,6 +129,9 @@ export const Router = (props: RouterProps) => {
/>
}
/>
<Route path={legacySelectedTemplateRouteRef.path}>
<RedirectingComponent />
</Route>
<Route
path={selectedTemplateRouteRef.path}
element={
@@ -299,12 +299,12 @@ export const TaskPage = ({ loadingText }: TaskPageProps) => {
const formData = taskStream.task!.spec.parameters;
const { name } = parseEntityRef(
const { name, namespace } = parseEntityRef(
taskStream.task!.spec.templateInfo?.entityRef,
);
navigate(
`${templateRoute({ templateName: name })}?${qs.stringify({
`${templateRoute({ templateName: name, namespace })}?${qs.stringify({
formData: JSON.stringify(formData),
})}`,
);
@@ -13,7 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
Entity,
parseEntityRef,
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
ScmIntegrationIcon,
@@ -162,7 +167,8 @@ export const TemplateCard = ({ template, deprecated }: TemplateCardProps) => {
: 'other';
const theme = backstageTheme.getPageTheme({ themeId });
const classes = useStyles({ backgroundImage: theme.backgroundImage });
const href = templateRoute({ templateName: templateProps.name });
const { name, namespace } = parseEntityRef(stringifyEntityRef(template));
const href = templateRoute({ templateName: name, namespace });
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const sourceLocation = getEntitySourceLocation(template, scmIntegrationsApi);
@@ -54,11 +54,16 @@ export const TemplatePage = ({
const secretsContext = useContext(SecretsContext);
const errorApi = useApi(errorApiRef);
const scaffolderApi = useApi(scaffolderApiRef);
const { templateName } = useParams();
const { templateName, namespace } = useParams();
const templateRef = stringifyEntityRef({
name: templateName,
kind: 'template',
namespace,
});
const navigate = useNavigate();
const scaffolderTaskRoute = useRouteRef(scaffolderTaskRouteRef);
const rootRoute = useRouteRef(rootRouteRef);
const { schema, loading, error } = useTemplateParameterSchema(templateName);
const { schema, loading, error } = useTemplateParameterSchema(templateRef);
const [formState, setFormState] = useState<Record<string, any>>(() => {
const query = qs.parse(window.location.search, {
ignoreQueryPrefix: true,
@@ -78,11 +83,7 @@ export const TemplatePage = ({
const handleCreate = async () => {
const { taskId } = await scaffolderApi.scaffold({
templateRef: stringifyEntityRef({
name: templateName,
kind: 'template',
namespace: 'default',
}),
templateRef,
values: formState,
secrets: secretsContext?.secrets,
});
@@ -47,7 +47,9 @@ describe('Router', () => {
describe('/templates/:templateName', () => {
it('should render the TemplateWizard page', async () => {
await renderInTestApp(<Router />, { routeEntries: ['/templates/foo'] });
await renderInTestApp(<Router />, {
routeEntries: ['/templates/default/foo'],
});
expect(TemplateWizardPage).toHaveBeenCalled();
});
@@ -67,7 +69,7 @@ describe('Router', () => {
<CustomFieldExtension />
</ScaffolderFieldExtensions>
</Router>,
{ routeEntries: ['/templates/foo'] },
{ routeEntries: ['/templates/default/foo'] },
);
const mock = TemplateWizardPage as jest.Mock;
@@ -233,7 +233,7 @@ describe('TemplateCard', () => {
expect(getByRole('button', { name: 'Choose' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Choose' })).toHaveAttribute(
'href',
'/templates/bob',
'/templates/default/bob',
);
});
});
@@ -26,7 +26,11 @@ import {
} from '@material-ui/core';
import { CardHeader } from './CardHeader';
import { MarkdownContent, UserIcon, Button } from '@backstage/core-components';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
parseEntityRef,
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
EntityRefLinks,
getEntityRelations,
@@ -91,7 +95,8 @@ export const TemplateCard = (props: TemplateCardProps) => {
const styles = useStyles();
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const href = templateRoute({ templateName: template.metadata.name });
const { name, namespace } = parseEntityRef(stringifyEntityRef(template));
const href = templateRoute({ templateName: name, namespace: namespace });
return (
<Card>
+10 -1
View File
@@ -28,10 +28,19 @@ export const rootRouteRef = createRouteRef({
id: 'scaffolder',
});
/**
* @deprecated This is the old template route, can be deleted before next major release
*/
export const legacySelectedTemplateRouteRef = createSubRouteRef({
id: 'scaffolder/legacy/selected-template',
parent: rootRouteRef,
path: '/templates/:templateName',
});
export const selectedTemplateRouteRef = createSubRouteRef({
id: 'scaffolder/selected-template',
parent: rootRouteRef,
path: '/templates/:templateName',
path: '/templates/:namespace/:templateName',
});
export const scaffolderTaskRouteRef = createSubRouteRef({
@@ -137,6 +137,19 @@ export interface ElasticSearchConnectionConstructor {
};
}
// @public
export type ElasticSearchCustomIndexTemplate = {
name: string;
body: ElasticSearchCustomIndexTemplateBody;
};
// @public
export type ElasticSearchCustomIndexTemplateBody = {
index_patterns: string[];
composed_of?: string[];
template?: Record<string, any>;
};
// @public (undocumented)
export type ElasticSearchHighlightConfig = {
fragmentDelimiter: string;
@@ -215,6 +228,8 @@ export class ElasticSearchSearchEngine implements SearchEngine {
// (undocumented)
query(query: SearchQuery): Promise<IndexableResultSet>;
// (undocumented)
setIndexTemplate(template: ElasticSearchCustomIndexTemplate): Promise<void>;
// (undocumented)
setTranslator(translator: ElasticSearchQueryTranslator): void;
// (undocumented)
protected translator(
@@ -50,6 +50,23 @@ jest.mock('./ElasticSearchSearchEngineIndexer', () => ({
.mockImplementation(() => indexerMock),
}));
const customIndexTemplate = {
name: 'custom-index-template',
body: {
index_patterns: ['*'],
template: {
settings: {
number_of_shards: 1,
},
mappings: {
_source: {
enabled: false,
},
},
},
},
};
describe('ElasticSearchSearchEngine', () => {
let testSearchEngine: ElasticSearchSearchEngine;
let inspectableSearchEngine: ElasticSearchSearchEngineForTranslatorTests;
@@ -72,6 +89,20 @@ describe('ElasticSearchSearchEngine', () => {
client = testSearchEngine['elasticSearchClient'];
});
describe('custom index template', () => {
it('should set custom index template', async () => {
const indexTemplateSpy = jest.fn().mockReturnValue(customIndexTemplate);
mock.add(
{ method: 'PUT', path: '/_index_template/custom-index-template' },
indexTemplateSpy,
);
await inspectableSearchEngine.setIndexTemplate(customIndexTemplate);
expect(indexTemplateSpy).toHaveBeenCalled();
expect(indexTemplateSpy).toHaveBeenCalledTimes(1);
});
});
describe('queryTranslator', () => {
beforeAll(() => {
mock.clearAll();
@@ -492,6 +523,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
})),
),
});
@@ -536,6 +568,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
})),
),
nextPageCursor: 'MQ==',
@@ -583,6 +616,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
}))
.slice(25),
),
@@ -636,6 +670,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
highlight: {
preTag: '<tag>',
postTag: '</tag>',
@@ -760,6 +795,10 @@ describe('ElasticSearchSearchEngine', () => {
});
describe('indexer', () => {
beforeEach(async () => {
await testSearchEngine.setIndexTemplate(customIndexTemplate);
});
it('should get indexer', async () => {
const indexer = await testSearchEngine.getIndexer('test-index');
@@ -36,6 +36,37 @@ import { ElasticSearchSearchEngineIndexer } from './ElasticSearchSearchEngineInd
export type { ElasticSearchClientOptions };
/**
* Elasticsearch specific index template
* @public
*/
export type ElasticSearchCustomIndexTemplate = {
name: string;
body: ElasticSearchCustomIndexTemplateBody;
};
/**
* Elasticsearch specific index template body
* @public
*/
export type ElasticSearchCustomIndexTemplateBody = {
/**
* Array of wildcard (*) expressions used to match the names of data streams and indices during creation.
*/
index_patterns: string[];
/**
* An ordered list of component template names.
* Component templates are merged in the order specified,
* meaning that the last component template specified has the highest precedence.
*/
composed_of?: string[];
/**
* See available properties of template
* https://www.elastic.co/guide/en/elasticsearch/reference/7.15/indices-put-template.html#put-index-template-api-request-body
*/
template?: Record<string, any>;
};
/**
* Search query that the elasticsearch engine understands.
* @public
@@ -236,8 +267,18 @@ export class ElasticSearchSearchEngine implements SearchEngine {
this.translator = translator;
}
async setIndexTemplate(template: ElasticSearchCustomIndexTemplate) {
try {
await this.elasticSearchClient.indices.putIndexTemplate(template);
this.logger.info('Custom index template set');
} catch (error) {
this.logger.error(`Unable to set custom index template: ${error}`);
}
}
async getIndexer(type: string) {
const alias = this.constructSearchAlias(type);
const indexer = new ElasticSearchSearchEngineIndexer({
type,
indexPrefix: this.indexPrefix,
@@ -293,27 +334,30 @@ export class ElasticSearchSearchEngine implements SearchEngine {
: undefined;
return {
results: result.body.hits.hits.map((d: ElasticSearchResult) => {
const resultItem: IndexableResult = {
type: this.getTypeFromIndex(d._index),
document: d._source,
};
if (d.highlight) {
resultItem.highlight = {
preTag: this.highlightOptions.preTag as string,
postTag: this.highlightOptions.postTag as string,
fields: Object.fromEntries(
Object.entries(d.highlight).map(([field, fragments]) => [
field,
fragments.join(this.highlightOptions.fragmentDelimiter),
]),
),
results: result.body.hits.hits.map(
(d: ElasticSearchResult, index: number) => {
const resultItem: IndexableResult = {
type: this.getTypeFromIndex(d._index),
document: d._source,
rank: pageSize * page + index + 1,
};
}
return resultItem;
}),
if (d.highlight) {
resultItem.highlight = {
preTag: this.highlightOptions.preTag as string,
postTag: this.highlightOptions.postTag as string,
fields: Object.fromEntries(
Object.entries(d.highlight).map(([field, fragments]) => [
field,
fragments.join(this.highlightOptions.fragmentDelimiter),
]),
),
};
}
return resultItem;
},
),
nextPageCursor,
previousPageCursor,
};
@@ -30,6 +30,8 @@ export type {
ElasticSearchQueryTranslator,
ElasticSearchQueryTranslatorOptions,
ElasticSearchOptions,
ElasticSearchCustomIndexTemplate,
ElasticSearchCustomIndexTemplateBody,
} from './ElasticSearchSearchEngine';
export type {
ElasticSearchSearchEngineIndexer,
@@ -36,4 +36,6 @@ export type {
ElasticSearchAuth,
ElasticSearchSearchEngineIndexer,
ElasticSearchSearchEngineIndexerOptions,
ElasticSearchCustomIndexTemplate,
ElasticSearchCustomIndexTemplateBody,
} from './engines';

Some files were not shown because too many files have changed in this diff Show More