Merge branch 'backstage:master' into master

This commit is contained in:
Ambrish R
2025-05-27 19:58:13 +05:30
committed by GitHub
119 changed files with 3908 additions and 677 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
render details for composite property schemas
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-catalog-unprocessed-entities': patch
'@backstage/plugin-app-visualizer': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-notifications': patch
'@backstage/plugin-user-settings': patch
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-api-docs': patch
'@backstage/plugin-devtools': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-signals': patch
'@backstage/plugin-search': patch
'@backstage/plugin-home': patch
'@backstage/plugin-app': patch
'@backstage/plugin-org': patch
---
Added the `info.packageJson` option to the plugin instance for the new frontend system.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/repo-tools': patch
---
Add missing highlight language for the `package-docs` command.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch
'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch
'@backstage/plugin-scaffolder-node': patch
---
Update pull request creation filter to include .gitignore files in the created pull request
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new optional `info` option to `createFrontendPlugin` that lets you provide a loaders for different sources of metadata information about the plugin.
There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin. This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: '...',
info: {
packageJson: () => import('../package.json'),
},
});
```
The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`.
Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: '...',
info: {
manifest: () => import('../catalog-info.yaml'),
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new `useAppNode` hook, which can be used to get a reference to the `AppNode` from by the closest `ExtensionBoundary`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Support merge request labels in publish:gitlab:merge-request
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-techdocs': minor
'@backstage/plugin-techdocs-react': minor
'@backstage/plugin-catalog': minor
'@backstage/plugin-techdocs-common': patch
---
Introduced `backstage.io/techdocs-entity-path` annotation which allows deep linking into another entities TechDocs in conjunction with `backstage.io/techdocs-entity`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': patch
---
Forwarded the new `pluginInfoResolver` option for `createApp`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overridden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': minor
---
Added mock implementations for `ActionsService` and `ActionsRegistryService`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Update `refresh_state_references.id` to be a big int
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': minor
---
Added `coreServices.actionsRegistry` and `coreServices.actions` to allow registration of distributed actions from plugins, and the ability to invoke these actions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Added some default implementations for the `ActionsService` and `ActionsRegistryService` that allow registration of actions for a particular plugin.
+5 -211
View File
@@ -64,83 +64,11 @@ backend:
# See README.md in the proxy-backend plugin for information on the configuration format
proxy:
endpoints:
'/circleci/api':
target: https://circleci.com/api/v1.1
headers:
Circle-Token: ${CIRCLECI_AUTH_TOKEN}
'/jenkins/api':
target: http://localhost:8080
headers:
Authorization: ${JENKINS_BASIC_AUTH_HEADER}
'/travisci/api':
target: https://api.travis-ci.com
changeOrigin: true
headers:
Authorization: ${TRAVISCI_AUTH_TOKEN}
travis-api-version: '3'
'/newrelic/apm/api':
target: https://api.newrelic.com/v2
headers:
X-Api-Key: ${NEW_RELIC_REST_API_KEY}
allowedHeaders:
- link
'/newrelic/api':
target: https://api.newrelic.com
headers:
X-Api-Key: ${NEW_RELIC_USER_KEY}
'/pagerduty':
target: https://api.pagerduty.com
headers:
Authorization: Token token=${PAGERDUTY_TOKEN}
'/buildkite/api':
target: https://api.buildkite.com/v2/
headers:
Authorization: ${BUILDKITE_TOKEN}
'/sentry/api':
target: https://sentry.io/api/
allowedMethods: ['GET']
headers:
Authorization: ${SENTRY_TOKEN}
'/ilert':
target: https://api.ilert.com
allowedMethods: ['GET', 'POST', 'PUT']
allowedHeaders: ['Authorization']
headers:
Authorization: ${ILERT_AUTH_HEADER}
'/airflow':
target: https://your.airflow.instance.com/api/v1
headers:
Authorization: ${AIRFLOW_BASIC_AUTH_HEADER}
'/gocd':
target: https://your.gocd.instance.com/go/api
allowedMethods: ['GET']
allowedHeaders: ['Authorization']
headers:
Authorization: Basic ${GOCD_AUTH_CREDENTIALS}
'/dynatrace':
target: https://your.dynatrace.instance.com/api/v2
headers:
Authorization: 'Api-Token ${DYNATRACE_ACCESS_TOKEN}'
'/stackstorm':
target: https://your.stackstorm.instance.com/api
headers:
St2-Api-Key: ${ST2_API_KEY}
'/puppetdb':
target: https://your.puppetdb.instance.com
organization:
name: My Company
@@ -157,46 +85,6 @@ techdocs:
publisher:
type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage' or 'openStackSwift'. Read documentation for using alternatives.
dynatrace:
baseUrl: https://your.dynatrace.instance.com
nomad:
addr: 0.0.0.0
# Score-cards sample configuration.
scorecards:
jsonDataUrl: https://raw.githubusercontent.com/Oriflame/backstage-plugins/main/plugins/score-card/sample-data/
wikiLinkTemplate: https://link-to-wiki/{id}
sentry:
organization: my-company
rollbar:
organization: my-company
# NOTE: The rollbar-backend & accountToken key may be deprecated in the future (replaced by a proxy config)
accountToken: my-rollbar-account-token
lighthouse:
baseUrl: http://localhost:3003
kubernetes:
serviceLocatorMethod:
type: 'multiTenant'
clusterLocatorMethods:
- type: 'config'
clusters: []
kafka:
clientId: backstage
clusters:
- name: cluster
dashboardUrl: https://akhq.io/
brokers:
- localhost:9092
allure:
baseUrl: http://localhost:5050/allure-docker-service
integrations:
github:
- host: github.com
@@ -269,26 +157,6 @@ catalog:
plugins:
- catalog
- search
processors:
ldapOrg:
### Example for how to add your enterprise LDAP server
# providers:
# - target: ldaps://ds.example.net
# bind:
# dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
# secret: ${LDAP_SECRET}
# users:
# dn: ou=people,ou=example,dc=example,dc=net
# options:
# filter: (uid=*)
# map:
# description: l
# groups:
# dn: ou=access,ou=groups,ou=example,dc=example,dc=net
# options:
# filter: (&(objectClass=some-group-class)(!(groupType=email)))
locations:
# Add a location here to ingest it, for example from a URL:
#
@@ -325,13 +193,14 @@ catalog:
target: ../../plugins/scaffolder-backend/sample-templates/all-templates.yaml
rules:
- allow: [Template]
scaffolder:
# Use to customize default commit author info used when new components are created
# defaultAuthor:
# name: Scaffolder
# email: scaffolder@backstage.io
defaultAuthor:
name: Scaffolder
email: scaffolder@backstage.io
# Use to customize the default commit message when new components are created
# defaultCommitMessage: 'Initial commit'
defaultCommitMessage: 'Initial commit'
auth:
### Add auth.keyStore.provider to more granularly control how to store JWK data when running
@@ -422,80 +291,5 @@ auth:
myproxy: {}
guest: {}
costInsights:
engineerCost: 200000
engineerThreshold: 0.5
products:
computeEngine:
name: Compute Engine
icon: compute
cloudDataflow:
name: Cloud Dataflow
icon: data
cloudStorage:
name: Cloud Storage
icon: storage
bigQuery:
name: BigQuery
icon: search
events:
name: Events
icon: data
metrics:
DAU:
name: Daily Active Users
default: true
MSC:
name: Monthly Subscribers
currencies:
engineers:
label: 'Engineers 🛠'
unit: 'engineer'
usd:
label: 'US Dollars 💵'
kind: 'USD'
unit: 'dollar'
prefix: '$'
rate: 1
carbonOffsetTons:
label: 'Carbon Offset Tons ♻️⚖️s'
kind: 'CARBON_OFFSET_TONS'
unit: 'carbon offset ton'
rate: 3.5
beers:
label: 'Beers 🍺'
kind: 'BEERS'
unit: 'beer'
rate: 4.5
pintsIceCream:
label: 'Pints of Ice Cream 🍦'
kind: 'PINTS_OF_ICE_CREAM'
unit: 'ice cream pint'
rate: 5.5
pagerDuty:
eventsBaseUrl: 'https://events.pagerduty.com/v2'
jenkins:
instances:
- name: default
baseUrl: https://jenkins.example.com
username: backstage-bot
apiKey: 123456789abcdef0123456789abcedf012
azureDevOps:
host: dev.azure.com
token: my-token
organization: my-company
apacheAirflow:
baseUrl: https://your.airflow.instance.com
gocd:
baseUrl: https://your.gocd.instance.com
stackstorm:
webUrl: https://your.stackstorm.webui.instance.com
permission:
enabled: true
+57 -36
View File
@@ -1,56 +1,77 @@
# Releases
## Version 0.4.0
### Main updates
- ✨ Add new `Tab` component - [#29996](https://github.com/backstage/backstage/pull/29996)
- ✨ Add `truncate` prop to `Text` and `Heading` - [#29988](https://github.com/backstage/backstage/pull/29988)
- ✨ Add combobox option to `Menu` - [#29986](https://github.com/backstage/backstage/pull/29986)
- ✨ Add icon prop on `TextField` - [#29820](https://github.com/backstage/backstage/pull/29820)
- Improve icon props on `Button` and `IconButton` - [#29667](https://github.com/backstage/backstage/pull/29667)
- Improve the way we treat custom render on `Text` and `Heading` - [#29989](https://github.com/backstage/backstage/pull/29989)
- Improve `Menu` styles - [#29986](https://github.com/backstage/backstage/pull/29986)
- Improve `TextField` styles - [#29974](https://github.com/backstage/backstage/pull/29974)
- Improve clear button on `TextField` - [#29878](https://github.com/backstage/backstage/pull/29878)
### Notable fixes
- Fix spacing props on all layout components - [#30013](https://github.com/backstage/backstage/pull/30013)
- Fix - Pin Base UI version - [#29782](https://github.com/backstage/backstage/pull/29782)
- Fix - Clicking `Select` label moves focus to trigger - [#29755](https://github.com/backstage/backstage/pull/29755)
- Fix `DataTable.Pagination` count issue - [#29688](https://github.com/backstage/backstage/pull/29688)
## Version 0.3.0
### Main updates
- Add `DataTable` component - ([#29484](https://github.com/backstage/backstage/pull/29484), [#29603](https://github.com/backstage/backstage/pull/29603))
- Add `Select` component - ([#29440](https://github.com/backstage/backstage/pull/29440))
- Add `Avatar` component - ([#29594](https://github.com/backstage/backstage/pull/29594))
- Add `Collapsible` component - ([#29617](https://github.com/backstage/backstage/pull/29617))
- Add `TextField` component instead of `Field` + `Input` - ([#29364](https://github.com/backstage/backstage/pull/29364))
- Add `TableCellProfile` - ([#29600](https://github.com/backstage/backstage/pull/29600))
- Add breakpoint hooks - `up()` and `down()` - ([#29564](https://github.com/backstage/backstage/pull/29564))
- Add gray scale css tokens - ([#29543](https://github.com/backstage/backstage/pull/29543))
- Update CSS styling API using `[data-___]` instead of class names for props - ([#29560](https://github.com/backstage/backstage/pull/29560))
- Update `Checkbox` dark mode - ([#29544](https://github.com/backstage/backstage/pull/29544))
- Update `Container` styles - ([#29475](https://github.com/backstage/backstage/pull/29475))
- Update `Menu` styles - ([#29351](https://github.com/backstage/backstage/pull/29351))
- Fix `Select` styles on small sizes + with long option names - ([#29545](https://github.com/backstage/backstage/pull/29545))
- Fix render prop on `Link` - ([#29247](https://github.com/backstage/backstage/pull/29247))
- Remove `Field` from `TextField` + `Select` - ([#29482](https://github.com/backstage/backstage/pull/29482))
- Update `textDecoration` to `none` on `Text` / `Heading` - ([#29357](https://github.com/backstage/backstage/pull/29357))
- Add `DataTable` component - [#29484](https://github.com/backstage/backstage/pull/29484), [#29603](https://github.com/backstage/backstage/pull/29603)
- Add `Select` component - [#29440](https://github.com/backstage/backstage/pull/29440)
- Add `Avatar` component - [#29594](https://github.com/backstage/backstage/pull/29594)
- Add `Collapsible` component - [#29617](https://github.com/backstage/backstage/pull/29617)
- Add `TextField` component instead of `Field` + `Input` - [#29364](https://github.com/backstage/backstage/pull/29364)
- Add `TableCellProfile` - [#29600](https://github.com/backstage/backstage/pull/29600)
- Add breakpoint hooks - `up()` and `down()` - [#29564](https://github.com/backstage/backstage/pull/29564)
- Add gray scale css tokens - [#29543](https://github.com/backstage/backstage/pull/29543)
- Update CSS styling API using `[data-___]` instead of class names for props - [#29560](https://github.com/backstage/backstage/pull/29560)
- Update `Checkbox` dark mode - [#29544](https://github.com/backstage/backstage/pull/29544)
- Update `Container` styles - [#29475](https://github.com/backstage/backstage/pull/29475)
- Update `Menu` styles - [#29351](https://github.com/backstage/backstage/pull/29351)
- Fix `Select` styles on small sizes + with long option names - [#29545](https://github.com/backstage/backstage/pull/29545)
- Fix render prop on `Link` - [#29247](https://github.com/backstage/backstage/pull/29247)
- Remove `Field` from `TextField` + `Select` - [#29482](https://github.com/backstage/backstage/pull/29482)
- Update `textDecoration` to `none` on `Text` / `Heading` - [#29357](https://github.com/backstage/backstage/pull/29357)
### Notable fixes
- Docs - Use stories from Storybook for all examples in Nextjs - ([#29306](https://github.com/backstage/backstage/pull/29306))
- Docs - Add release page (this one 🤗) - ([#29461](https://github.com/backstage/backstage/pull/29461))
- Docs - Add docs for Menu, Link - ([#29576](https://github.com/backstage/backstage/pull/29576))
- Fix CSS watch mode - ([#29352](https://github.com/backstage/backstage/pull/29352))
- Docs - Use stories from Storybook for all examples in Nextjs - [#29306](https://github.com/backstage/backstage/pull/29306)
- Docs - Add release page (this one 🤗) - [#29461](https://github.com/backstage/backstage/pull/29461)
- Docs - Add docs for Menu, Link - [#29576](https://github.com/backstage/backstage/pull/29576)
- Fix CSS watch mode - [#29352](https://github.com/backstage/backstage/pull/29352)
## Version 0.2.0
### Main updates
- New `Tooltip` component ([#29241](https://github.com/backstage/backstage/pull/29241))
- New `Menu` component. ([#29151](https://github.com/backstage/backstage/pull/29151))
- New `IconButton` component. ([#29239](https://github.com/backstage/backstage/pull/29239))
- New `ScrollArea` component. ([#29240](https://github.com/backstage/backstage/pull/29240))
- Improve `Button` & `Checkbox` styles. ([#29127](https://github.com/backstage/backstage/pull/29127)) ([#28789](https://github.com/backstage/backstage/pull/28789))
- Improve `Text` styles. ([#29200](https://github.com/backstage/backstage/pull/29200))
- Renamed `CanonProvider` to `IconProvider`. ([#29002](https://github.com/backstage/backstage/pull/29002))
- Added about 40+ new icons. ([#29264](https://github.com/backstage/backstage/pull/29264))
- Simplified styling into a unique styles.css file ([#29199](https://github.com/backstage/backstage/pull/29199))
- Added Canon styles to Backstage ([#29137](https://github.com/backstage/backstage/pull/29137))
- Update global CSS tokens ([#28804](https://github.com/backstage/backstage/pull/28804))
- Merge Stack + Inline into Flex ([#28634](https://github.com/backstage/backstage/pull/28634))
- New `Tooltip` component - [#29241](https://github.com/backstage/backstage/pull/29241)
- New `Menu` component - [#29151](https://github.com/backstage/backstage/pull/29151)
- New `IconButton` component - [#29239](https://github.com/backstage/backstage/pull/29239)
- New `ScrollArea` component - [#29240](https://github.com/backstage/backstage/pull/29240)
- Improve `Button` & `Checkbox` styles - [#29127](https://github.com/backstage/backstage/pull/29127), [#28789](https://github.com/backstage/backstage/pull/28789)
- Improve `Text` styles - [#29200](https://github.com/backstage/backstage/pull/29200)
- Renamed `CanonProvider` to `IconProvider` - [#29002](https://github.com/backstage/backstage/pull/29002)
- Added about 40+ new icons - [#29264](https://github.com/backstage/backstage/pull/29264)
- Simplified styling into a unique styles.css file - [#29199](https://github.com/backstage/backstage/pull/29199)
- Added Canon styles to Backstage - [#29137](https://github.com/backstage/backstage/pull/29137)
- Update global CSS tokens - [#28804](https://github.com/backstage/backstage/pull/28804)
- Merge Stack + Inline into Flex - [#28634](https://github.com/backstage/backstage/pull/28634)
### Notable fixes:
- Improve `Button` types. ([#29205](https://github.com/backstage/backstage/pull/29205))
- Move font weight and family back to each components ([#28972](https://github.com/backstage/backstage/pull/28972))
- Fix custom values in spacing props ([#28770](https://github.com/backstage/backstage/pull/28770))
- Multiple updates on the Canon Docs site ([#28760](https://github.com/backstage/backstage/pull/28760)) ([#28591](https://github.com/backstage/backstage/pull/28591))
- Improve `Button` types - [#29205](https://github.com/backstage/backstage/pull/29205)
- Move font weight and family back to each components - [#28972](https://github.com/backstage/backstage/pull/28972)
- Fix custom values in spacing props - [#28770](https://github.com/backstage/backstage/pull/28770)
- Multiple updates on the Canon Docs site - [#28760](https://github.com/backstage/backstage/pull/28760), [#28591](https://github.com/backstage/backstage/pull/28591)
## Version 0.1.0
@@ -80,11 +80,11 @@ const Content = () => {
const Line = ({ content, title }: { content: ReactNode; title: string }) => {
return (
<Grid gap={{ xs: '2', md: '4' }}>
<Grid.Root gap={{ xs: '2', md: '4' }}>
<Grid.Item colSpan="2">
<Text>{title}</Text>
</Grid.Item>
<Grid.Item colSpan="10">{content}</Grid.Item>
</Grid>
</Grid.Root>
);
};
@@ -115,6 +115,20 @@ the TechDocs in the TechDocs page or needing multiple builds of the same docs.
This is for situations where you have complex systems where they share a single repo, and likely a single TechDoc location.
### backstage.io/techdocs-entity-path
```yaml
# Example:
metadata:
annotations:
backstage.io/techdocs-entity: component:default/example
backstage.io/techdocs-entity-path: /path/to/this/component
```
The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs.
In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of
another entity, not just linking to the root of another entities TechDocs.
### backstage.io/view-url, backstage.io/edit-url
```yaml
+29
View File
@@ -942,6 +942,35 @@ metadata:
backstage.io/techdocs-entity: system:default/example
```
### Deep linking into TechDocs
The `backstage.io/techdocs-entity-path` annotation can be use to deep link into a specific page within the components TechDocs.
This can be used in conjunction with `backstage.io/techdocs-entity` or standalone.
```yaml
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: example
namespace: default
title: Example
description: This is the parent entity
annotations:
backstage.io/techdocs-ref: dir:.
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: example-platfrom
title: Example Application Platform
namespace: default
description: This is the child entity
annotations:
backstage.io/techdocs-entity: system:default/example
backstage.io/techdocs-entity-path: /path/to/component/docs
```
## How to resolve broken links from moved or renamed pages in your documentation site
TechDocs supports using the [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects/tree/master) plugin to create a redirect map for any TechDocs site. This allows broken links from renamed or moved pages in your site to be redirected to their specified replacement.
@@ -77,3 +77,98 @@ app:
```
Note that you do not need to manually exclude packages that you also import explicitly in code, since plugin instances are deduplicated by the app. You will never end up with duplicate plugin installations except if they are in fact two different plugin instances with different IDs.
## Plugin Info Resolution
When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info).
By default the app will pick a few common fields from `package.json` files, and assume that the opaque manifests are `catalog-info.yaml` files that some information can be gathered from too. This information will then be available via the `info()` method on plugin instances, returning a structure of the `FrontendPluginInfo` type.
### Extending Plugin Info
The default plugin info is intended as a base to build upon. As part of setting up an app you can both customize the way that the plugin info is resolved, as well as extend the `FrontendPluginInfo` type to include more information.
In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` interface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows:
```ts
declare module '@backstage/frontend-plugin-api' {
interface FrontendPluginInfo {
/**
* The slack channel to use for support requests for this plugin.
*/
slackChannel?: string;
}
}
```
### Customizing Plugin Info Resolution
With the new `slackChannel` field in place, we now need to provide a custom resolver that knows how to extract this information from the plugin information sources. This is done by passing a custom `pluginInfoResolver` to `createApp`, which in our example is declared like this:
```ts title="pluginInfoResolver.ts"
import { createPluginInfoResolver } from '@backstage/frontend-plugin-api';
// It is recommended to keep the above module augmentation in this file too
export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => {
// In our particular example app we assume that all plugin manifests are catalog-info.yaml files
const manifest = (await ctx.manifest?.()) as Entity | undefined;
// Call the default resolver to populate common fields
const { info } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: manifest,
});
// In this example the catalog model has been extended with a metadata.slackChannel field
const slackChannel = manifest?.metadata?.slackChannel?.toString();
if (slackChannel) {
info.slackChannel = slackChannel;
info.links = [
...(info.links ?? []),
{
title: 'Slack Channel',
url: `https://our-workspace.enterprise.slack.com/archives/${slackChannel}`,
},
];
}
return { info };
};
```
And included in the app as follows:
```ts title="App.tsx"
import { pluginInfoResolver } from './pluginInfoResolver';
const app = createApp({
pluginInfoResolver,
// ... other options
});
```
### Overriding Plugin Info
Another way to customize the plugin info is to use the `app.pluginOverrides` static configuration key. These overrides are applied after the plugin info has been resolved as a final step before making it available to users. These overrides are particularly useful to override information in third-party plugins. For example, if your organization has an individual team that is responsible for the maintenance of the Software Catalog, you might configure the following override:
```yaml
app:
pluginOverrides:
- match:
pluginId: catalog
info:
ownerEntityRefs: [catalog-owners]
```
You can match on both the `pluginId` and/or `packageName` of the plugin, although the `packageName` will only be supported if the plugin provides an loader for the `package.json` file. Using `/<pattern>/` you are also able to use a regex pattern for this matching. For example, if you wanted to override the owner for all plugins from the `@acme` namespace, you could do the following:
```yaml
app:
pluginOverrides:
- match:
packageName: /@acme/.*/
info:
ownerEntityRefs: [acme-owners]
```
@@ -53,6 +53,35 @@ These are the routes that the plugin exposes to the app. The `routes` option dec
This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`.
### `info` option
This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources.
The `info.packageJson` loader **MUST** be used by all plugins that are implemented within their own package, and it should load the `package.json` file for the plugin package. Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: 'my-plugin',
info: {
packageJson: () => import('../package.json'),
},
extensions: [...],
});
```
The `info.manifest` loader is used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `metadata.links` and `spec.owner`.
Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: '...',
info: {
manifest: () => import('../catalog-info.yaml'),
},
});
```
## Installing a Plugin in an App
A plugin instance is considered a frontend feature and can be installed directly in any Backstage frontend app. See the [app documentation](./10-app.md) for more information about the different ways in which you can install new features in an app.
@@ -0,0 +1,10 @@
---
title: Wheel of Names
author: intive
authorUrl: https://www.intive.com/
category: Utility
description: This plugin provides a customizable spinning wheel that can be used for random selection, decision making, or just for fun in your Backstage instance.
documentation: https://github.com/backstage/community-plugins/blob/main/workspaces/wheel-of-names/plugins/wheel-of-names/README.md
iconUrl: /img/wheel-of-names.svg
npmPackageName: '@backstage-community/plugin-wheel-of-names'
addedDate: '2025-05-21'
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.5 KiB

+14
View File
@@ -8,6 +8,20 @@ app:
catalog.createComponent: catalog-import.importPage
org.catalogIndex: catalog.catalogIndex
pluginOverrides:
- match:
pluginId: pages
info:
description: 'This description was overridden in packages/app-next/app-config.yaml'
- match:
pluginId: /^catalog(-.*)?$/
info:
ownerEntityRefs: [cubic-belugas]
- match:
packageName: '@backstage/plugin-scaffolder'
info:
ownerEntityRefs: [cubic-belugas]
extensions:
# - apis.plugin.graphiql.browse.gitlab: true
# - graphiql-endpoint:graphiql/gitlab: true
+2
View File
@@ -43,6 +43,7 @@ import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha';
import { convertLegacyPlugin } from '@backstage/core-compat-api';
import { convertLegacyPageExtension } from '@backstage/core-compat-api';
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
import { pluginInfoResolver } from './pluginInfoResolver';
/*
@@ -132,6 +133,7 @@ const app = createApp({
customHomePageModule,
...collectedLegacyPlugins,
],
pluginInfoResolver,
/* Handled through config instead */
// bindRoutes({ bind }) {
// bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX });
@@ -21,7 +21,10 @@ import {
createExternalRouteRef,
useRouteRef,
PageBlueprint,
FrontendPluginInfo,
useAppNode,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
const indexRouteRef = createRouteRef();
@@ -36,6 +39,22 @@ export const pageXRouteRef = createRouteRef();
// path: '/page2',
// });
function PluginInfo() {
const node = useAppNode();
const [info, setInfo] = useState<FrontendPluginInfo | undefined>(undefined);
useEffect(() => {
node?.spec.source?.info().then(setInfo);
}, [node]);
return (
<div>
<h3>Plugin Info</h3>
<pre>{JSON.stringify(info, null, 2)}</pre>
</div>
);
}
const IndexPage = PageBlueprint.make({
name: 'index',
params: {
@@ -64,6 +83,7 @@ const IndexPage = PageBlueprint.make({
<div>
<Link to="/settings">Settings</Link>
</div>
<PluginInfo />
</div>
);
};
@@ -139,6 +159,10 @@ export const pagesPlugin = createFrontendPlugin({
// // OR
// // 'page1'
// },
info: {
packageJson: () => import('../../package.json'),
manifest: () => import('../../catalog-info.yaml'),
},
routes: {
page1: page1RouteRef,
pageX: pageXRouteRef,
@@ -0,0 +1,52 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
// This file shows an example of what it looks like to extend the plugin info
// resolution with custom logic and fields. In this case we're reading the
// `spec.type` field from the plugin manifest (catalog-info.yaml).
//
// Using module augmentation we extend the `FrontendPluginInfo` interface to
// include our custom fields. This makes these fields available throughout our project.
declare module '@backstage/frontend-plugin-api' {
export interface FrontendPluginInfo {
/**
* **DO NOT USE**
*
* This field is added in the example app to showcase module augmentation
* for extending the plugin info in internal apps. It only exists as an
* example in this project.
*/
exampleFieldDoNotUse?: string;
}
}
export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => {
const manifest = (await ctx.manifest?.()) as Entity | undefined;
const { info: defaultInfo } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: manifest,
});
return {
info: {
...defaultInfo,
exampleFieldDoNotUse: manifest?.spec?.type?.toString(),
},
};
};
+10
View File
@@ -121,6 +121,16 @@ export interface Config {
};
};
/**
* Options used by the default actions service.
*/
actions?: {
/**
* List of plugin sources to load actions from.
*/
pluginSources?: string[];
};
/**
* Options used by the default auth, httpAuth and userInfo services.
*/
+10 -1
View File
@@ -20,6 +20,8 @@
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts",
"./actions": "./src/entrypoints/actions/index.ts",
"./actionsRegistry": "./src/entrypoints/actionsRegistry/index.ts",
"./auditor": "./src/entrypoints/auditor/index.ts",
"./auth": "./src/entrypoints/auth/index.ts",
"./cache": "./src/entrypoints/cache/index.ts",
@@ -45,6 +47,12 @@
"types": "src/index.ts",
"typesVersions": {
"*": {
"actions": [
"src/entrypoints/actions/index.ts"
],
"actionsRegistry": [
"src/entrypoints/actionsRegistry/index.ts"
],
"auditor": [
"src/entrypoints/auditor/index.ts"
],
@@ -188,7 +196,8 @@
"winston-transport": "^4.5.0",
"yauzl": "^3.0.0",
"yn": "^4.0.0",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zod-to-json-schema": "^3.20.4"
},
"devDependencies": {
"@aws-sdk/util-stream-node": "^3.350.0",
@@ -0,0 +1,17 @@
## API Report File for "@backstage/backend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ActionsService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
export const actionsServiceFactory: ServiceFactory<
ActionsService,
'plugin',
'singleton'
>;
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,17 @@
## API Report File for "@backstage/backend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ActionsRegistryService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
export const actionsRegistryServiceFactory: ServiceFactory<
ActionsRegistryService,
'plugin',
'singleton'
>;
// (No @packageDocumentation comment for this package)
```
@@ -35,8 +35,12 @@ import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler';
import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader';
import { userInfoServiceFactory } from '@backstage/backend-defaults/userInfo';
import { eventsServiceFactory } from '@backstage/plugin-events-node';
import { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
import { actionsServiceFactory } from './entrypoints/actions';
export const defaultServiceFactories = [
actionsRegistryServiceFactory,
actionsServiceFactory,
auditorServiceFactory,
authServiceFactory,
cacheServiceFactory,
@@ -0,0 +1,139 @@
/*
* Copyright 2025 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 {
ActionsService,
ActionsServiceAction,
AuthService,
BackstageCredentials,
DiscoveryService,
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import { ResponseError } from '@backstage/errors';
import { JsonObject } from '@backstage/types';
export class DefaultActionsService implements ActionsService {
private constructor(
private readonly discovery: DiscoveryService,
private readonly config: RootConfigService,
private readonly logger: LoggerService,
private readonly auth: AuthService,
) {}
static create({
discovery,
config,
logger,
auth,
}: {
discovery: DiscoveryService;
config: RootConfigService;
logger: LoggerService;
auth: AuthService;
}) {
return new DefaultActionsService(discovery, config, logger, auth);
}
async list({ credentials }: { credentials: BackstageCredentials }) {
const pluginSources =
this.config.getOptionalStringArray('backend.actions.pluginSources') ?? [];
const remoteActionsList = await Promise.all(
pluginSources.map(async source => {
try {
const response = await this.makeRequest({
path: `/.backstage/actions/v1/actions`,
pluginId: source,
credentials,
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const { actions } = (await response.json()) as {
actions: ActionsServiceAction;
};
return actions;
} catch (error) {
this.logger.warn(`Failed to fetch actions from ${source}`, error);
return [];
}
}),
);
return { actions: remoteActionsList.flat() };
}
async invoke(opts: {
id: string;
input?: JsonObject;
credentials: BackstageCredentials;
}) {
const pluginId = this.pluginIdFromActionId(opts.id);
const response = await this.makeRequest({
path: `/.backstage/actions/v1/actions/${encodeURIComponent(
opts.id,
)}/invoke`,
pluginId,
credentials: opts.credentials,
options: {
method: 'POST',
body: JSON.stringify(opts.input),
headers: {
'Content-Type': 'application/json',
},
},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const { output } = await response.json();
return { output };
}
private async makeRequest(opts: {
path: string;
pluginId: string;
options?: RequestInit;
credentials: BackstageCredentials;
}) {
const { path, pluginId, credentials, options } = opts;
const baseUrl = await this.discovery.getBaseUrl(pluginId);
const { token } = await this.auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: opts.pluginId,
});
return fetch(`${baseUrl}${path}`, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${token}`,
},
});
}
private pluginIdFromActionId(id: string): string {
const colonIndex = id.indexOf(':');
if (colonIndex === -1) {
throw new Error(`Invalid action id: ${id}`);
}
return id.substring(0, colonIndex);
}
}
@@ -0,0 +1,334 @@
/*
* Copyright 2025 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 {
mockCredentials,
mockServices,
registerMswTestHooks,
ServiceFactoryTester,
startTestBackend,
} from '@backstage/backend-test-utils';
import { actionsRegistryServiceFactory } from '../actionsRegistry';
import { httpRouterServiceFactory } from '../httpRouter';
import { actionsServiceFactory } from './actionsServiceFactory';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import {
ActionsServiceAction,
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { json } from 'express';
import Router from 'express-promise-router';
import request from 'supertest';
const server = setupServer();
describe('actionsServiceFactory', () => {
describe('client', () => {
registerMswTestHooks(server);
const mockActionsListEndpoint = jest.fn();
const mockNotFoundActionsListEndpoint = jest.fn();
const defaultServices = [
mockServices.rootConfig.factory({
data: {
backend: {
actions: {
pluginSources: ['my-plugin', 'not-found-plugin'],
},
},
},
}),
actionsServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
mockServices.discovery.factory(),
actionsRegistryServiceFactory,
];
const mockActionsDefinition: ActionsServiceAction = {
description: 'my mock description',
id: 'my-plugin:test',
name: 'testy',
title: 'Test',
schema: {
input: {},
output: {},
},
};
beforeEach(() => {
server.use(
rest.get(
'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions',
mockActionsListEndpoint.mockImplementation((_req, res, ctx) =>
res(
ctx.json({
actions: [mockActionsDefinition],
}),
),
),
),
rest.get(
'http://localhost:0/api/not-found-plugin/.backstage/actions/v1/actions',
mockNotFoundActionsListEndpoint.mockImplementation((_req, res, ctx) =>
res(ctx.status(404)),
),
),
);
});
describe('list', () => {
it('should list all plugins in config to find actions and handle failures gracefully', async () => {
const subject = await ServiceFactoryTester.from(actionsServiceFactory, {
dependencies: defaultServices,
}).getSubject();
const { actions } = await subject.list({
credentials: mockCredentials.service('user:default/mock'),
});
expect(actions).toEqual([mockActionsDefinition]);
expect(mockActionsListEndpoint).toHaveBeenCalledTimes(1);
expect(mockNotFoundActionsListEndpoint).toHaveBeenCalledTimes(1);
});
});
describe('invoke', () => {
it('should invoke the action and return the output', async () => {
server.use(
rest.post(
'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
(_req, res, ctx) => res(ctx.json({ output: { ok: true } })),
),
);
const subject = await ServiceFactoryTester.from(actionsServiceFactory, {
dependencies: defaultServices,
}).getSubject();
const { output } = await subject.invoke({
id: 'my-plugin:test',
credentials: mockCredentials.service('user:default/mock'),
});
expect(output).toEqual({ ok: true });
});
it('should throw a 404 if the action does not exist', async () => {
server.use(
rest.post(
'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
(_req, res, ctx) => res(ctx.status(404)),
),
);
const subject = await ServiceFactoryTester.from(actionsServiceFactory, {
dependencies: defaultServices,
}).getSubject();
await expect(
subject.invoke({
id: 'my-plugin:test',
credentials: mockCredentials.service('user:default/mock'),
}),
).rejects.toThrow('404');
});
it('should throw a 400 if the action returns an invalid input', async () => {
server.use(
rest.post(
'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
(_req, res, ctx) => res(ctx.status(400)),
),
);
const subject = await ServiceFactoryTester.from(actionsServiceFactory, {
dependencies: defaultServices,
}).getSubject();
await expect(
subject.invoke({
id: 'my-plugin:test',
credentials: mockCredentials.service('user:default/mock'),
}),
).rejects.toThrow('400');
});
});
describe('integration', () => {
beforeAll(() => {
// disable the msw server for this test.
server.close();
});
const features = [
actionsServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
actionsRegistryServiceFactory,
mockServices.rootConfig.factory({
data: {
backend: {
actions: { pluginSources: ['plugin-with-action'] },
},
},
}),
createBackendPlugin({
pluginId: 'plugin-with-action',
register({ registerInit }) {
registerInit({
deps: { actionsRegistry: coreServices.actionsRegistry },
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'with-validation',
title: 'Test',
description: 'Test',
schema: {
input: z =>
z.object({
name: z.string(),
}),
output: z =>
z.object({
ok: z.boolean(),
string: z.string(),
}),
},
action: async ({ input }) => {
return {
output: {
ok: true,
string: `hello ${input.name}`,
},
};
},
});
},
});
},
}),
createBackendPlugin({
pluginId: 'test-harness',
register({ registerInit }) {
registerInit({
deps: {
actionsService: coreServices.actions,
router: coreServices.httpRouter,
httpAuth: coreServices.httpAuth,
},
async init({ actionsService, router, httpAuth }) {
const innerRouter = Router();
innerRouter.use(json());
router.use(innerRouter);
innerRouter.post('/invoke', async (req, res) => {
const { id, input } = req.body;
const response = await actionsService.invoke({
id,
input,
credentials: await httpAuth.credentials(req),
});
res.json(response);
});
innerRouter.get('/actions', async (req, res) => {
const response = await actionsService.list({
credentials: await httpAuth.credentials(req),
});
res.json(response);
});
},
});
},
}),
];
it('should list the actions and return the output', async () => {
const { server: testHarnessServer } = await startTestBackend({
features,
});
const { body, status } = await request(testHarnessServer).get(
'/api/test-harness/actions',
);
expect(status).toBe(200);
expect(body).toEqual({
actions: [
{
description: 'Test',
id: 'plugin-with-action:with-validation',
name: 'with-validation',
schema: {
input: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: {
name: {
type: 'string',
},
},
required: ['name'],
type: 'object',
},
output: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: {
ok: {
type: 'boolean',
},
string: {
type: 'string',
},
},
required: ['ok', 'string'],
type: 'object',
},
},
title: 'Test',
},
],
});
});
it('should invoke the action and return the output', async () => {
const { server: testHarnessServer } = await startTestBackend({
features,
});
const { body, status } = await request(testHarnessServer)
.post('/api/test-harness/invoke')
.send({
id: 'plugin-with-action:with-validation',
input: {
name: 'world',
},
});
expect(body).toEqual({ output: { ok: true, string: 'hello world' } });
expect(status).toBe(200);
});
});
});
});
@@ -0,0 +1,38 @@
/*
* Copyright 2025 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 { createServiceFactory } from '@backstage/backend-plugin-api';
import { coreServices } from '@backstage/backend-plugin-api';
import { DefaultActionsService } from './DefaultActionsService';
/**
* @public
*/
export const actionsServiceFactory = createServiceFactory({
service: coreServices.actions,
deps: {
discovery: coreServices.discovery,
config: coreServices.rootConfig,
logger: coreServices.logger,
auth: coreServices.auth,
},
factory: ({ discovery, config, logger, auth }) =>
DefaultActionsService.create({
discovery,
config,
logger,
auth,
}),
});
@@ -0,0 +1,16 @@
/*
* Copyright 2025 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 { actionsServiceFactory } from './actionsServiceFactory';
@@ -0,0 +1,156 @@
/*
* Copyright 2025 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 {
ActionsRegistryActionOptions,
ActionsRegistryService,
AuthService,
HttpAuthService,
LoggerService,
PluginMetadataService,
} from '@backstage/backend-plugin-api';
import PromiseRouter from 'express-promise-router';
import { Router, json } from 'express';
import { z, ZodType } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import {
ForwardedError,
InputError,
NotAllowedError,
NotFoundError,
} from '@backstage/errors';
export class DefaultActionsRegistryService implements ActionsRegistryService {
private actions: Map<string, ActionsRegistryActionOptions<any, any>> =
new Map();
private constructor(
private readonly logger: LoggerService,
private readonly httpAuth: HttpAuthService,
private readonly auth: AuthService,
private readonly metadata: PluginMetadataService,
) {}
static create({
httpAuth,
logger,
auth,
metadata,
}: {
httpAuth: HttpAuthService;
logger: LoggerService;
auth: AuthService;
metadata: PluginMetadataService;
}): DefaultActionsRegistryService {
return new DefaultActionsRegistryService(logger, httpAuth, auth, metadata);
}
createRouter(): Router {
const router = PromiseRouter();
router.use(json());
router.get('/.backstage/actions/v1/actions', (_, res) => {
return res.json({
actions: Array.from(this.actions.entries()).map(([id, action]) => ({
id,
...action,
schema: {
input: action.schema?.input
? zodToJsonSchema(action.schema.input(z))
: zodToJsonSchema(z.any()),
output: action.schema?.output
? zodToJsonSchema(action.schema.output(z))
: zodToJsonSchema(z.any()),
},
})),
});
});
router.post(
'/.backstage/actions/v1/actions/:actionId/invoke',
async (req, res) => {
const credentials = await this.httpAuth.credentials(req);
if (this.auth.isPrincipal(credentials, 'user')) {
if (!credentials.principal.actor) {
throw new NotAllowedError(
`Actions must be invoked by a service, not a user`,
);
}
} else if (this.auth.isPrincipal(credentials, 'none')) {
throw new NotAllowedError(
`Actions must be invoked by a service, not an anonymous request`,
);
}
const action = this.actions.get(req.params.actionId);
if (!action) {
throw new NotFoundError(`Action "${req.params.actionId}" not found`);
}
const input = action.schema?.input
? action.schema.input(z).safeParse(req.body)
: ({ success: true, data: undefined } as const);
if (!input.success) {
throw new InputError(
`Invalid input to action "${req.params.actionId}"`,
input.error,
);
}
try {
const result = await action.action({
input: input.data,
credentials,
logger: this.logger,
});
const output = action.schema?.output
? action.schema.output(z).safeParse(result?.output)
: ({ success: true, data: result?.output } as const);
if (!output.success) {
throw new InputError(
`Invalid output from action "${req.params.actionId}"`,
output.error,
);
}
res.json({ output: output.data });
} catch (error) {
throw new ForwardedError(
`Failed execution of action "${req.params.actionId}"`,
error,
);
}
},
);
return router;
}
register<TInputSchema extends ZodType, TOutputSchema extends ZodType>(
options: ActionsRegistryActionOptions<TInputSchema, TOutputSchema>,
): void {
const id = `${this.metadata.getId()}:${options.name}`;
if (this.actions.has(id)) {
throw new Error(`Action with id "${id}" is already registered`);
}
this.actions.set(id, options);
}
}
@@ -0,0 +1,434 @@
/*
* Copyright 2025 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 {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import {
mockCredentials,
mockServices,
startTestBackend,
} from '@backstage/backend-test-utils';
import { httpRouterServiceFactory } from '../httpRouter';
import request from 'supertest';
import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory';
import { InputError } from '@backstage/errors';
describe('actionsRegistryServiceFactory', () => {
const defaultServices = [
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.service('user:default/mock'),
}),
];
describe('typescript tests', () => {
it('should properly infer the input types', () => {
createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: z =>
z.object({
test: z.string(),
}),
output: z =>
z.object({
ok: z.boolean(),
}),
},
action: async ({ input: { test } }) => {
// @ts-expect-error - test is not a boolean
const _t: boolean = test;
return { output: { ok: true } };
},
});
},
});
},
});
expect(true).toBe(true);
});
it('should properly infer the output types', () => {
createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: z =>
z.object({
test: z.string(),
}),
output: z =>
z.object({
ok: z.boolean(),
}),
},
// @ts-expect-error - ok is not a boolean
action: async () => {
return { output: { ok: 'bo' } };
},
});
},
});
},
});
expect(true).toBe(true);
});
});
describe('/.backstage/actions/v1/actions', () => {
it('should allow registering of actions', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: z =>
z.object({
name: z.string(),
}),
output: z =>
z.object({
ok: z.boolean(),
}),
},
action: async () => ({ output: { ok: true } }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server).get(
'/api/my-plugin/.backstage/actions/v1/actions',
);
expect(status).toBe(200);
expect(body).toMatchObject({
actions: [
{
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
output: {
type: 'object',
properties: {
ok: {
type: 'boolean',
},
},
},
},
},
],
});
});
it('should allow registering of actions with no schema', async () => {
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: z => z.undefined(),
output: z => z.string(),
},
action: async () => ({ output: 'ok' }),
});
},
});
},
});
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server).get(
'/api/my-plugin/.backstage/actions/v1/actions',
);
expect(status).toBe(200);
expect(body).toMatchObject({
actions: [
{
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: {},
output: {},
},
},
],
});
});
});
describe('/.backstage/actions/v1/actions/:actionId/invoke', () => {
const mockAction = jest.fn();
beforeEach(() => {
mockAction.mockResolvedValue({ output: { ok: true } });
});
const pluginSubject = createBackendPlugin({
pluginId: 'my-plugin',
register(reg) {
reg.registerInit({
deps: {
actionsRegistry: coreServices.actionsRegistry,
},
async init({ actionsRegistry }) {
actionsRegistry.register({
name: 'test',
title: 'Test',
description: 'Test',
schema: {
input: z =>
z.object({
name: z.string(),
}),
output: z =>
z.object({
ok: z.boolean(),
}),
},
action: mockAction,
});
},
});
},
});
it('should throw an error if the action is not found', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server).post(
'/api/my-plugin/.backstage/actions/v1/actions/test/invoke',
);
expect(status).toBe(404);
expect(body).toMatchObject({
error: {
message: 'Action "test" not found',
},
});
});
it('should throw an error if the action input does not match the schema', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 123,
});
expect(status).toBe(400);
expect(body).toMatchObject({
error: {
message: expect.stringMatching(
'Invalid input to action "my-plugin:test"',
),
},
});
});
it('should call the action with the input', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 'test',
});
expect(mockAction).toHaveBeenCalledWith({
input: {
name: 'test',
},
credentials: {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'service',
subject: 'user:default/mock',
},
},
logger: expect.anything(),
});
});
it('should throw an error if the action is invoked by a user', async () => {
const testServices = [
actionsRegistryServiceFactory,
httpRouterServiceFactory,
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.user(),
}),
];
const { server } = await startTestBackend({
features: [pluginSubject, ...testServices],
});
const { body, status } = await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 'test',
});
expect(status).toBe(403);
expect(body).toMatchObject({
error: {
message: 'Actions must be invoked by a service, not a user',
},
});
});
it('should validate the output of the action if provided', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
mockAction.mockResolvedValue({ ok: 'blob' });
const { body, status } = await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 'test',
});
expect(status).toBe(400);
expect(body).toMatchObject({
error: {
message: expect.stringMatching(
'Invalid output from action "my-plugin:test"',
),
},
});
});
it('should return the output of the action', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
const { body, status } = await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 'test',
});
expect(status).toBe(200);
expect(body).toMatchObject({ output: { ok: true } });
});
it('should return the error from the action if it throws', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
mockAction.mockRejectedValue(new InputError('test'));
const { body, status } = await request(server)
.post(
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
)
.send({
name: 'test',
});
expect(status).toBe(400);
expect(body).toMatchObject({
error: {
message: expect.stringContaining(
'Failed execution of action "my-plugin:test"',
),
},
});
});
});
});
@@ -0,0 +1,47 @@
/*
* Copyright 2025 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 {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultActionsRegistryService } from './DefaultActionsRegistryService';
/**
* @public
*/
export const actionsRegistryServiceFactory = createServiceFactory({
service: coreServices.actionsRegistry,
deps: {
metadata: coreServices.pluginMetadata,
httpRouter: coreServices.httpRouter,
httpAuth: coreServices.httpAuth,
logger: coreServices.logger,
auth: coreServices.auth,
},
factory: ({ metadata, httpRouter, httpAuth, logger, auth }) => {
const actionsRegistryService = DefaultActionsRegistryService.create({
httpAuth,
logger,
auth,
metadata,
});
httpRouter.use(actionsRegistryService.createRouter());
return actionsRegistryService;
},
});
@@ -0,0 +1,16 @@
/*
* Copyright 2025 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 { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory';
+4 -1
View File
@@ -61,9 +61,12 @@
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/types": "workspace:^",
"@types/express": "^4.17.6",
"@types/json-schema": "^7.0.6",
"@types/luxon": "^3.0.0",
"json-schema": "^0.4.0",
"knex": "^3.0.0",
"luxon": "^3.0.0"
"luxon": "^3.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
+73
View File
@@ -12,6 +12,7 @@ import type { Handler } from 'express';
import { HumanDuration } from '@backstage/types';
import { isChildPath } from '@backstage/cli-common';
import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/types';
import { Knex } from 'knex';
import { Permission } from '@backstage/plugin-permission-common';
@@ -25,6 +26,72 @@ import { QueryPermissionResponse } from '@backstage/plugin-permission-common';
import { Readable } from 'stream';
import type { Request as Request_2 } from 'express';
import type { Response as Response_2 } from 'express';
import { z } from 'zod';
import { ZodType } from 'zod';
// @public (undocumented)
export type ActionsRegistryActionContext<TInputSchema extends ZodType> = {
input: z.infer<TInputSchema>;
logger: LoggerService;
credentials: BackstageCredentials;
};
// @public (undocumented)
export type ActionsRegistryActionOptions<
TInputSchema extends ZodType,
TOutputSchema extends ZodType,
> = {
name: string;
title: string;
description: string;
schema: {
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
action: (context: ActionsRegistryActionContext<TInputSchema>) => Promise<
z.infer<TOutputSchema> extends void
? void
: {
output: z.infer<TOutputSchema>;
}
>;
};
// @public (undocumented)
export interface ActionsRegistryService {
// (undocumented)
register<TInputSchema extends ZodType, TOutputSchema extends ZodType>(
options: ActionsRegistryActionOptions<TInputSchema, TOutputSchema>,
): void;
}
// @public (undocumented)
export interface ActionsService {
// (undocumented)
invoke(opts: {
id: string;
input?: JsonObject;
credentials: BackstageCredentials;
}): Promise<{
output: JsonValue;
}>;
// (undocumented)
list: (opts: { credentials: BackstageCredentials }) => Promise<{
actions: ActionsServiceAction[];
}>;
}
// @public (undocumented)
export type ActionsServiceAction = {
id: string;
name: string;
title: string;
description: string;
schema: {
input: JSONSchema7;
output: JSONSchema7;
};
};
// @public
export interface AuditorService {
@@ -205,6 +272,12 @@ export type CacheServiceSetOptions = {
// @public
export namespace coreServices {
const auth: ServiceRef<AuthService, 'plugin', 'singleton'>;
const actions: ServiceRef<ActionsService, 'plugin', 'singleton'>;
const actionsRegistry: ServiceRef<
ActionsRegistryService,
'plugin',
'singleton'
>;
const userInfo: ServiceRef<UserInfoService, 'plugin', 'singleton'>;
const cache: ServiceRef<CacheService, 'plugin', 'singleton'>;
const rootConfig: ServiceRef<RootConfigService, 'root', 'singleton'>;
@@ -0,0 +1,59 @@
/*
* Copyright 2025 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 { z, ZodType } from 'zod';
import { LoggerService } from './LoggerService';
import { BackstageCredentials } from './AuthService';
/**
* @public
*/
export type ActionsRegistryActionContext<TInputSchema extends ZodType> = {
input: z.infer<TInputSchema>;
logger: LoggerService;
credentials: BackstageCredentials;
};
/**
* @public
*/
export type ActionsRegistryActionOptions<
TInputSchema extends ZodType,
TOutputSchema extends ZodType,
> = {
name: string;
title: string;
description: string;
schema: {
input: (zod: typeof z) => TInputSchema;
output: (zod: typeof z) => TOutputSchema;
};
action: (
context: ActionsRegistryActionContext<TInputSchema>,
) => Promise<
z.infer<TOutputSchema> extends void
? void
: { output: z.infer<TOutputSchema> }
>;
};
/**
* @public
*/
export interface ActionsRegistryService {
register<TInputSchema extends ZodType, TOutputSchema extends ZodType>(
options: ActionsRegistryActionOptions<TInputSchema, TOutputSchema>,
): void;
}
@@ -0,0 +1,46 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject, JsonValue } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { BackstageCredentials } from './AuthService';
/**
* @public
*/
export type ActionsServiceAction = {
id: string;
name: string;
title: string;
description: string;
schema: {
input: JSONSchema7;
output: JSONSchema7;
};
};
/**
* @public
*/
export interface ActionsService {
list: (opts: {
credentials: BackstageCredentials;
}) => Promise<{ actions: ActionsServiceAction[] }>;
invoke(opts: {
id: string;
input?: JsonObject;
credentials: BackstageCredentials;
}): Promise<{ output: JsonValue }>;
}
@@ -35,6 +35,36 @@ export namespace coreServices {
id: 'core.auth',
});
/**
* Service for calling distributed actions
*
* See {@link ActionsService}
* and {@link https://backstage.io/docs/backend-system/core-services/actions | the service docs}
* for more information.
*
* @public
*/
export const actions = createServiceRef<
import('./ActionsService').ActionsService
>({
id: 'core.actions',
});
/**
* Service for registering and managing distributed actions.
*
* See {@link ActionsRegistryService}
* and {@link https://backstage.io/docs/backend-system/core-services/actions-registry | the service docs}
* for more information.
*
* @public
*/
export const actionsRegistry = createServiceRef<
import('./ActionsRegistryService').ActionsRegistryService
>({
id: 'core.actionsRegistry',
});
/**
* Authenticated user information retrieval.
*
@@ -14,6 +14,13 @@
* limitations under the License.
*/
export type {
ActionsRegistryService,
ActionsRegistryActionOptions,
ActionsRegistryActionContext,
} from './ActionsRegistryService';
export type { ActionsService, ActionsServiceAction } from './ActionsService';
export type {
AuditorService,
AuditorServiceCreateEventOptions,
+24
View File
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ActionsRegistryService } from '@backstage/backend-plugin-api';
import { ActionsService } from '@backstage/backend-plugin-api';
import { AuditorService } from '@backstage/backend-plugin-api';
import { AuthService } from '@backstage/backend-plugin-api';
import { Backend } from '@backstage/backend-app-api';
@@ -161,6 +163,28 @@ export function mockErrorHandler(): ErrorRequestHandler<
// @public
export namespace mockServices {
// (undocumented)
export namespace actions {
const // (undocumented)
factory: () => ServiceFactory<ActionsService, 'plugin', 'singleton'>;
const // (undocumented)
mock: (
partialImpl?: Partial<ActionsService> | undefined,
) => ServiceMock<ActionsService>;
}
// (undocumented)
export namespace actionsRegistry {
const // (undocumented)
factory: () => ServiceFactory<
ActionsRegistryService,
'plugin',
'singleton'
>;
const // (undocumented)
mock: (
partialImpl?: Partial<ActionsRegistryService> | undefined,
) => ServiceMock<ActionsRegistryService>;
}
// (undocumented)
export namespace auditor {
const // (undocumented)
@@ -53,6 +53,8 @@ import { MockRootLoggerService } from './MockRootLoggerService';
import { MockUserInfoService } from './MockUserInfoService';
import { mockCredentials } from './mockCredentials';
import { MockEventsService } from './MockEventsService';
import { actionsServiceFactory } from '@backstage/backend-defaults/actions';
import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/actionsRegistry';
/** @internal */
function createLoggerMock() {
@@ -518,6 +520,20 @@ export namespace mockServices {
search: jest.fn(),
}));
}
export namespace actions {
export const factory = () => actionsServiceFactory;
export const mock = simpleMock(coreServices.actions, () => ({
list: jest.fn(),
invoke: jest.fn(),
}));
}
export namespace actionsRegistry {
export const factory = () => actionsRegistryServiceFactory;
export const mock = simpleMock(coreServices.actionsRegistry, () => ({
register: jest.fn(),
}));
}
/**
* Creates a functional mock implementation of the
@@ -41,6 +41,8 @@ describe('convertLegacyPlugin', () => {
"featureFlags": [],
"getExtension": [Function],
"id": "test",
"info": [Function],
"infoOptions": undefined,
"routes": {},
"toString": [Function],
"version": "v1",
+66
View File
@@ -51,5 +51,71 @@ export interface Config {
};
}
>;
/**
* This section enables you to override certain properties of specific or
* groups of plugins.
*
* @remarks
* All matching entries will be applied to each plugin, with the later
* entries taking precedence.
*
* This configuration is intended to be used primarily to apply overrides
* for third-party plugins.
*
* @deepVisibility frontend
*/
pluginOverrides?: Array<{
/**
* The criteria for matching plugins to override.
*
* @remarks
* If no match criteria are provided, the override will be applied to
* all plugins.
*/
match?: {
/**
* A pattern that is matched against the plugin ID.
*
* @remarks
* By default the string is interpreted as a glob pattern, but if the
* string is surrounded by '/' it is interpreted as a regex.
*/
pluginId?: string;
/**
* A pattern that is matched against the package name.
*
* @remarks
* By default the string is interpreted as a glob pattern, but if the
* string is surrounded by '/' it is interpreted as a regex.
*
* Note that this will only work for plugins that provide a
* `package.json` info loader.
*/
packageName?: string;
};
/**
* Overrides individual top-level fields of the plugin info.
*/
info: {
/**
* Override the description of the plugin.
*/
description?: string;
/**
* Override the owner entity references of the plugin.
*
* @remarks
* The provided values are interpreted as entity references defaulting
* to Group entities in the default namespace.
*/
ownerEntityRefs?: string[];
/**
* Override the links of the plugin.
*/
links?: Array<{ title: string; url: string }>;
};
}>;
};
}
+18 -1
View File
@@ -9,6 +9,8 @@ import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
@@ -27,7 +29,7 @@ export type CreateAppRouteBinder = <
// @public
export function createSpecializedApp(options?: {
features?: FrontendFeature[];
features?: FrontendFeature_2[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
apis?: ApiHolder;
@@ -37,6 +39,7 @@ export function createSpecializedApp(options?: {
flags?: {
allowUnknownExtensionConfig?: boolean;
};
pluginInfoResolver?: FrontendPluginInfoResolver;
}): {
apis: ApiHolder;
tree: AppTree;
@@ -44,4 +47,18 @@ export function createSpecializedApp(options?: {
// @public @deprecated (undocumented)
export type FrontendFeature = FrontendFeature_2;
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
manifest(): Promise<JsonObject | undefined>;
defaultResolver(sources: {
packageJson: JsonObject | undefined;
manifest: JsonObject | undefined;
}): Promise<{
info: FrontendPluginInfo;
}>;
}) => Promise<{
info: FrontendPluginInfo;
}>;
```
@@ -0,0 +1,284 @@
/*
* Copyright 2025 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 { mockApis } from '@backstage/test-utils';
import { createPluginInfoAttacher } from './createPluginInfoAttacher';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import {
createFrontendPlugin,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
function getInfo(plugin: FrontendFeature) {
return OpaqueFrontendPlugin.toInternal(plugin).info();
}
describe('createPluginInfoAttacher', () => {
const mockConfig = mockApis.config({
data: {
app: {
pluginOverrides: [
{
match: {
pluginId: '/^.*-tester$/',
},
info: {
description: 'Overridden description',
},
},
{
match: {
pluginId: '/^not-.*-tester$/',
},
info: {
ownerEntityRefs: ['test-group'],
},
},
{
match: {
packageName: '@test/package',
},
info: {
description: 'Package name matched',
},
},
{
match: {
pluginId: 'info-tester',
},
info: {
links: [{ title: 'Custom Link', url: 'https://example.com' }],
},
},
],
},
},
});
describe('with default resolver', () => {
const attacher = createPluginInfoAttacher(mockConfig);
it('should return a new plugin instance', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
});
const newPlugin = attacher(plugin);
expect(newPlugin).not.toBe(plugin);
await expect(getInfo(newPlugin)).resolves.toEqual({});
});
it('should return non-plugin features unchanged', () => {
const nonPluginFeature = {
type: 'not-a-plugin',
} as unknown as FrontendFeature;
expect(attacher(nonPluginFeature)).toBe(nonPluginFeature);
});
it('should resolve plugin info from package.json and config overrides', async () => {
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'other-tester',
info: {
packageJson: async () => ({
name: '@test/package',
version: '1.0.0',
description: 'Original description',
homepage: 'https://homepage.com',
repository: {
url: 'https://github.com/test/project',
directory: 'packages/test',
},
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@test/package',
version: '1.0.0',
description: 'Package name matched',
links: [
{
title: 'Homepage',
url: 'https://homepage.com',
},
{
title: 'Repository',
url: 'https://github.com/test/project/tree/-/packages/test',
},
],
});
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'info-tester',
info: {
packageJson: async () => ({
name: '@other/package',
description: 'Original description',
homepage: 'https://homepage.com',
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@other/package',
description: 'Overridden description',
links: [
{
title: 'Custom Link',
url: 'https://example.com',
},
],
});
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'not-info-tester',
info: {
packageJson: async () => ({
name: '@other/package',
description: 'Original description',
repository: {
url: 'http://example.com',
directory: 'packages/test',
},
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@other/package',
description: 'Overridden description',
ownerEntityRefs: ['group:default/test-group'],
links: [
{
title: 'Repository',
url: 'http://example.com/',
},
],
});
});
});
describe('with custom resolver', () => {
const plugin = createFrontendPlugin({
pluginId: 'custom-resolver',
info: {
packageJson: async () => ({
name: '@test/resolver',
version: '1.0.0',
}),
manifest: async () => ({
metadata: {
links: [{ title: 'Metadata link', url: 'https://example.com' }],
},
}),
},
});
it('should use the default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, async ctx =>
ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
packageName: '@test/resolver',
version: '1.0.0',
links: [
{
title: 'Metadata link',
url: 'https://example.com',
},
],
});
});
it('should override info sources passed to default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
ctx.defaultResolver({
packageJson: {
name: '@test/resolver-other',
version: '2.0.0',
},
manifest: {
metadata: {
links: [{ title: 'Other link', url: 'https://example.com' }],
},
spec: {
owner: 'test-group',
},
},
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
packageName: '@test/resolver-other',
version: '2.0.0',
links: [
{
title: 'Other link',
url: 'https://example.com',
},
],
ownerEntityRefs: ['group:default/test-group'],
});
});
it('should use a completely custom resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, async () => ({
info: { version: '0.1.0' },
}));
await expect(getInfo(attacher(plugin))).resolves.toEqual({
version: '0.1.0',
});
});
it('should handle unexpected input from the default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
ctx.defaultResolver({
packageJson: {
name: null,
version: {},
},
manifest: {
metadata: {
links: 'not an array',
},
spec: [],
},
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
version: '[object Object]',
});
});
});
});
@@ -0,0 +1,247 @@
/*
* Copyright 2025 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 { ConfigApi } from '@backstage/core-plugin-api';
import {
FrontendFeature,
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import { JsonObject, JsonValue } from '@backstage/types';
import once from 'lodash/once';
// Avoid full dependency on catalog-model
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
parseEntityRef,
stringifyEntityRef,
} from '../../../catalog-model/src/entity/ref';
/**
* A function that resolves plugin info from a plugin manifest and package.json.
*
* @public
*/
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
manifest(): Promise<JsonObject | undefined>;
defaultResolver(sources: {
packageJson: JsonObject | undefined;
manifest: JsonObject | undefined;
}): Promise<{ info: FrontendPluginInfo }>;
}) => Promise<{ info: FrontendPluginInfo }>;
export function createPluginInfoAttacher(
config: ConfigApi,
infoResolver: FrontendPluginInfoResolver = async ctx =>
ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
}),
): (feature: FrontendFeature) => FrontendFeature {
const applyInfoOverrides = createPluginInfoOverrider(config);
return (feature: FrontendFeature) => {
if (!OpaqueFrontendPlugin.isType(feature)) {
return feature;
}
const plugin = OpaqueFrontendPlugin.toInternal(feature);
return {
...plugin,
info: once(async () => {
const manifestLoader = plugin.infoOptions?.manifest;
const packageJsonLoader = plugin.infoOptions?.packageJson;
const { info: resolvedInfo } = await infoResolver({
manifest: async () => manifestLoader?.(),
packageJson: async () => packageJsonLoader?.(),
defaultResolver: async sources => ({
info: {
...resolvePackageInfo(sources.packageJson),
...resolveManifestInfo(sources.manifest),
},
}),
});
const infoWithOverrides = applyInfoOverrides(plugin.id, resolvedInfo);
return normalizePluginInfo(infoWithOverrides);
}),
};
};
}
function normalizePluginInfo(info: FrontendPluginInfo) {
return {
...info,
ownerEntityRefs: info.ownerEntityRefs?.map(ref =>
stringifyEntityRef(
parseEntityRef(ref, {
defaultKind: 'Group',
}),
),
),
};
}
function createPluginInfoOverrider(config: ConfigApi) {
const overrideConfigs =
config.getOptionalConfigArray('app.pluginOverrides') ?? [];
const overrideMatchers = overrideConfigs.map(overrideConfig => {
const pluginIdMatcher = makeStringMatcher(
overrideConfig.getOptionalString('match.pluginId'),
);
const packageNameMatcher = makeStringMatcher(
overrideConfig.getOptionalString('match.packageName'),
);
const description = overrideConfig.getOptionalString('info.description');
const ownerEntityRefs = overrideConfig.getOptionalStringArray(
'info.ownerEntityRefs',
);
const links = overrideConfig
.getOptionalConfigArray('info.links')
?.map(linkConfig => ({
title: linkConfig.getString('title'),
url: linkConfig.getString('url'),
}));
return {
test(pluginId: string, packageName?: string) {
return packageNameMatcher(packageName) && pluginIdMatcher(pluginId);
},
info: {
description,
ownerEntityRefs,
links,
},
};
});
return (pluginId: string, info: FrontendPluginInfo) => {
const { packageName } = info;
for (const matcher of overrideMatchers) {
if (matcher.test(pluginId, packageName)) {
if (matcher.info.description) {
info.description = matcher.info.description;
}
if (matcher.info.ownerEntityRefs) {
info.ownerEntityRefs = matcher.info.ownerEntityRefs;
}
if (matcher.info.links) {
info.links = matcher.info.links;
}
}
}
return info;
};
}
function resolveManifestInfo(manifest?: JsonValue) {
if (!isJsonObject(manifest) || !isJsonObject(manifest.metadata)) {
return undefined;
}
const info: FrontendPluginInfo = {};
if (isJsonObject(manifest.spec) && typeof manifest.spec.owner === 'string') {
info.ownerEntityRefs = [
stringifyEntityRef(
parseEntityRef(manifest.spec.owner, {
defaultKind: 'Group',
defaultNamespace: manifest.metadata.namespace?.toString(),
}),
),
];
}
if (Array.isArray(manifest.metadata.links)) {
info.links = manifest.metadata.links.filter(isJsonObject).map(link => ({
title: String(link.title),
url: String(link.url),
}));
}
return info;
}
function resolvePackageInfo(packageJson?: JsonObject) {
if (!packageJson) {
return undefined;
}
const info: FrontendPluginInfo = {
packageName: packageJson?.name?.toString(),
version: packageJson?.version?.toString(),
description: packageJson?.description?.toString(),
};
const links: { title: string; url: string }[] = [];
if (typeof packageJson.homepage === 'string') {
links.push({
title: 'Homepage',
url: packageJson.homepage,
});
}
if (
isJsonObject(packageJson.repository) &&
typeof packageJson.repository?.url === 'string'
) {
try {
const url = new URL(packageJson.repository?.url);
if (url.protocol === 'http:' || url.protocol === 'https:') {
// TODO(Rugvip): Support more variants
if (
url.hostname === 'github.com' &&
typeof packageJson.repository.directory === 'string'
) {
const path = `${url.pathname}/tree/-/${packageJson.repository.directory}`;
url.pathname = path.replaceAll('//', '/');
}
links.push({
title: 'Repository',
url: url.toString(),
});
}
} catch {
/* ignored */
}
}
if (links.length > 0) {
info.links = links;
}
return info;
}
function makeStringMatcher(pattern: string | undefined) {
if (!pattern) {
return () => true;
}
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
const regex = new RegExp(pattern.slice(1, -1));
return (str?: string) => (str ? regex.test(str) : false);
}
return (str?: string) => str === pattern;
}
function isJsonObject(value?: JsonValue): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -671,4 +671,123 @@ describe('createSpecializedApp', () => {
expect(render(root).container.textContent).toBe('1-2-test-1-2');
});
describe('plugin info', () => {
const testExtension = createExtension({
attachTo: { id: 'root', input: 'app' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>Test</div>)],
});
it('should throw unless accessed via an app', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
extensions: [testExtension],
});
const errorMsg =
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app";
await expect(plugin.info()).rejects.toThrow(errorMsg);
const app = createSpecializedApp({ features: [plugin] });
await expect(plugin.info()).rejects.toThrow(errorMsg);
const installedPlugin = app.tree.nodes.get('test')?.spec.source;
expect(installedPlugin).toBeDefined();
const info = await installedPlugin?.info();
expect(info).toEqual({});
});
it('should forward plugin info', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const app = createSpecializedApp({ features: [plugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toMatchObject({
packageName: '@backstage/frontend-app-api',
});
});
it('should allow overriding plugin info per plugin', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const overriddenPlugin = plugin.withOverrides({
extensions: [],
info: {
packageJson: () => Promise.resolve({ name: 'test-override' }),
},
});
const app = createSpecializedApp({ features: [overriddenPlugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toMatchObject({
packageName: 'test-override',
});
});
it('should merge with plugin info from manifest', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
manifest: async () => ({
metadata: {
links: [{ title: 'Example', url: 'https://example.com' }],
},
spec: {
owner: 'cubic-belugas',
},
}),
},
extensions: [testExtension],
});
const app = createSpecializedApp({ features: [plugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toEqual({
packageName: '@backstage/frontend-app-api',
version: expect.any(String),
links: [{ title: 'Example', url: 'https://example.com' }],
ownerEntityRefs: ['group:default/cubic-belugas'],
});
});
it('should allow overriding of the plugin info resolver', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const app = createSpecializedApp({
features: [plugin],
async pluginInfoResolver(ctx) {
const { info } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
});
return { info: { packageName: `decorated:${info.packageName}` } };
},
});
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toEqual({
packageName: 'decorated:@backstage/frontend-app-api',
});
});
});
});
@@ -31,6 +31,7 @@ import {
routeResolutionApiRef,
AppNode,
ExtensionFactoryMiddleware,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -74,8 +75,12 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
import { FrontendFeature, RouteInfo } from './types';
import { RouteInfo } from './types';
import { matchRoutes } from 'react-router-dom';
import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
@@ -209,9 +214,12 @@ export function createSpecializedApp(options?: {
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
flags?: { allowUnknownExtensionConfig?: boolean };
pluginInfoResolver?: FrontendPluginInfoResolver;
}): { apis: ApiHolder; tree: AppTree } {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []);
const features = deduplicateFeatures(options?.features ?? []).map(
createPluginInfoAttacher(config, options?.pluginInfoResolver),
);
const tree = resolveAppTree(
'root',
@@ -15,4 +15,5 @@
*/
export { createSpecializedApp } from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export * from './types';
+3
View File
@@ -9,6 +9,7 @@ import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
@@ -44,6 +45,8 @@ export interface CreateAppOptions {
| CreateAppFeatureLoader
)[];
loadingComponent?: ReactNode;
// (undocumented)
pluginInfoResolver?: FrontendPluginInfoResolver;
}
// @public
@@ -23,12 +23,15 @@ import {
createFrontendPlugin,
ThemeBlueprint,
createFrontendModule,
useAppNode,
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { screen, waitFor } from '@testing-library/react';
import { CreateAppFeatureLoader, createApp } from './createApp';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import { default as appPluginOriginal } from '@backstage/plugin-app';
import { useState, useEffect } from 'react';
describe('createApp', () => {
const appPlugin = appPluginOriginal.withOverrides({
@@ -119,6 +122,52 @@ describe('createApp', () => {
);
});
it('should allow overriding the plugin info resolver', async () => {
function TestComponent() {
const appNode = useAppNode();
const [info, setInfo] = useState<FrontendPluginInfo | undefined>(
undefined,
);
useEffect(() => {
appNode?.spec.source?.info().then(setInfo);
}, [appNode]);
return <div>Package name: {info?.packageName}</div>;
}
const app = createApp({
configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
pluginId: 'test',
extensions: [
PageBlueprint.make({
params: {
defaultPath: '/',
loader: async () => <TestComponent />,
},
}),
],
}),
],
pluginInfoResolver: async () => {
return {
info: {
packageName: '@test/test',
},
};
},
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Package name: @test/test'),
).resolves.toBeInTheDocument();
});
it('should support feature loaders', async () => {
const loader: CreateAppFeatureLoader = {
getLoaderName() {
@@ -30,6 +30,7 @@ import { ConfigReader } from '@backstage/config';
import {
CreateAppRouteBinder,
createSpecializedApp,
FrontendPluginInfoResolver,
} from '@backstage/frontend-app-api';
import appPlugin from '@backstage/plugin-app';
import { discoverAvailableFeatures } from './discovery';
@@ -78,6 +79,7 @@ export interface CreateAppOptions {
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
pluginInfoResolver?: FrontendPluginInfoResolver;
}
/**
@@ -112,6 +114,7 @@ export function createApp(options?: CreateAppOptions): {
features: [appPlugin, ...loadedFeatures],
bindRoutes: options?.bindRoutes,
extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
pluginInfoResolver: options?.pluginInfoResolver,
});
const rootEl = app.tree.root.instance!.getData(
@@ -19,6 +19,7 @@ import {
FeatureFlagConfig,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { OpaqueType } from '@internal/opaque';
export const OpaqueFrontendPlugin = OpaqueType.create<{
@@ -27,6 +28,10 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly infoOptions?: {
packageJson?: () => Promise<JsonObject>;
manifest?: () => Promise<JsonObject>;
};
};
}>({
type: '@backstage/FrontendPlugin',
@@ -1324,14 +1324,38 @@ export interface FrontendPlugin<
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
// (undocumented)
readonly id: string;
info(): Promise<FrontendPluginInfo>;
// (undocumented)
readonly routes: TRoutes;
// (undocumented)
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
info?: FrontendPluginInfoOptions;
}): FrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
// @public
export interface FrontendPluginInfo {
description?: string;
links?: Array<{
title: string;
url: string;
}>;
ownerEntityRefs?: string[];
packageName?: string;
version?: string;
}
// @public
export type FrontendPluginInfoOptions = {
packageJson?: () => Promise<
{
name: string;
} & JsonObject
>;
manifest?: () => Promise<JsonObject>;
};
export { githubAuthApiRef };
export { gitlabAuthApiRef };
@@ -1516,6 +1540,8 @@ export interface PluginOptions<
// (undocumented)
featureFlags?: FeatureFlagConfig[];
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
@@ -1806,6 +1832,9 @@ export { useApi };
export { useApiHolder };
// @public
export function useAppNode(): AppNode | undefined;
// @public
export function useComponentRef<T extends {}>(
ref: ComponentRef<T>,
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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 {
createVersionedContext,
createVersionedValueMap,
} from '@backstage/version-bridge';
import { AppNode } from '../apis';
import { renderHook } from '@testing-library/react';
import { AppNodeProvider, useAppNode } from './AppNodeProvider';
import { withLogCollector } from '@backstage/test-utils';
describe('AppNodeProvider', () => {
it('should provide app node context to children', () => {
const node = { id: 'test' } as unknown as AppNode;
const { result } = renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<AppNodeProvider node={node}>{children}</AppNodeProvider>
),
});
expect(result.current).toBe(node);
});
it('should return undefined when used outside provider', () => {
const { result } = renderHook(() => useAppNode());
expect(result.current).toBeUndefined();
});
it('should return the closest app node', () => {
const node1 = { id: 'test1' } as unknown as AppNode;
const node2 = { id: 'test2' } as unknown as AppNode;
const { result } = renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<AppNodeProvider node={node1}>
<AppNodeProvider node={node2}>{children}</AppNodeProvider>
</AppNodeProvider>
),
});
expect(result.current).toBe(node2);
});
it('should throw error for invalid context version', () => {
const node = { id: 'test' } as unknown as AppNode;
const Context = createVersionedContext('app-node-context');
const value = createVersionedValueMap({ 2: { node } });
const { error } = withLogCollector(() => {
expect(() =>
renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<Context.Provider value={value}>{children}</Context.Provider>
),
}),
).toThrow('AppNodeContext v1 not available');
});
expect(error).toEqual([
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.stringContaining(
'The above error occurred in the <TestComponent> component:',
),
]);
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2025 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 {
createVersionedContext,
createVersionedValueMap,
useVersionedContext,
} from '@backstage/version-bridge';
import { AppNode } from '../apis';
import { ReactNode } from 'react';
const CONTEXT_KEY = 'app-node-context';
type AppNodeContextV1 = {
node?: AppNode;
};
type AppNodeContextMap = {
1: AppNodeContextV1;
};
const AppNodeContext = createVersionedContext<AppNodeContextMap>(CONTEXT_KEY);
/** @internal */
export function AppNodeProvider({
node,
children,
}: {
node: AppNode;
children: ReactNode;
}) {
const versionedValue = createVersionedValueMap({ 1: { node } });
return <AppNodeContext.Provider value={versionedValue} children={children} />;
}
/**
* React hook providing access to the current {@link AppNode}.
*
* @public
* @remarks
*
* This hook will return the {@link AppNode} for the closest extension. This
* relies on the extension using the {@link (ExtensionBoundary:function)} component in its
* implementation, which is included by default for all common blueprints.
*
* If the current component is not inside an {@link (ExtensionBoundary:function)}, it will
* return `undefined`.
*/
export function useAppNode(): AppNode | undefined {
const versionedContext = useVersionedContext<AppNodeContextMap>(CONTEXT_KEY);
if (!versionedContext) {
return undefined;
}
const context = versionedContext.atVersion(1);
if (!context) {
throw new Error('AppNodeContext v1 not available');
}
return context.node;
}
@@ -28,6 +28,7 @@ import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/ana
import { AppNode, useComponentRef } from '../apis';
import { coreComponentRefs } from './coreComponentRefs';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
type RouteTrackerProps = PropsWithChildren<{
disableTracking?: boolean;
@@ -80,15 +81,17 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
};
return (
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
<AppNodeProvider node={node}>
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
</AppNodeProvider>
);
}
@@ -20,3 +20,4 @@ export {
} from './ExtensionBoundary';
export { coreComponentRefs } from './coreComponentRefs';
export { createComponentRef, type ComponentRef } from './createComponentRef';
export { useAppNode } from './AppNodeProvider';
@@ -281,6 +281,60 @@ describe('createFrontendPlugin', () => {
).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3");
});
describe('info', () => {
it('should support reading info from package.json', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: { packageJson: () => Promise.resolve({ name: '@test/test' }) },
});
await expect((plugin as any).infoOptions?.packageJson()).resolves.toEqual(
{ name: '@test/test' },
);
});
it('should support reading info from actual package.json', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: { packageJson: () => import('../../package.json') },
});
await expect(
(plugin as any).infoOptions?.packageJson(),
).resolves.toMatchObject({ name: '@backstage/frontend-plugin-api' });
});
it('should support reading info from opaque manifest', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: { manifest: () => Promise.resolve({ owner: 'me' }) },
});
await expect((plugin as any).infoOptions?.manifest()).resolves.toEqual({
owner: 'me',
});
});
it('should throw when trying to load info without installing in an app', async () => {
await expect(
createFrontendPlugin({
pluginId: 'test',
}).info(),
).rejects.toThrow(
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app",
);
await expect(
createFrontendPlugin({
pluginId: 'test',
info: { packageJson: () => Promise.resolve({ name: '@test/test' }) },
}).info(),
).rejects.toThrow(
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app",
);
});
});
describe('overrides', () => {
it('should return a plugin instance with the correct namespace', () => {
const plugin = createFrontendPlugin({
@@ -25,6 +25,67 @@ import {
} from './resolveExtensionDefinition';
import { AnyExternalRoutes, AnyRoutes, FeatureFlagConfig } from './types';
import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap';
import { JsonObject } from '@backstage/types';
/**
* Information about the plugin.
*
* @public
* @remarks
*
* This interface is intended to be extended via [module
* augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation)
* in order to add fields that are specific to each project.
*
* For example, one might add a `slackChannel` field that is read from the
* opaque manifest file.
*
* See the options for `createApp` for more information about how to
* customize the parsing of manifest files.
*/
export interface FrontendPluginInfo {
/**
* The name of the package that implements the plugin.
*/
packageName?: string;
/**
* The version of the plugin, typically the version of the package.json file.
*/
version?: string;
/**
* As short description of the plugin, typically the description field in
* package.json.
*/
description?: string;
/**
* The owner entity references of the plugin.
*/
ownerEntityRefs?: string[];
/**
* Links related to the plugin.
*/
links?: Array<{ title: string; url: string }>;
}
/**
* Options for providing information for a plugin.
*
* @public
*/
export type FrontendPluginInfoOptions = {
/**
* A loader function for the package.json file for the plugin.
*/
packageJson?: () => Promise<{ name: string } & JsonObject>;
/**
* A loader function for an opaque manifest file for the plugin.
*/
manifest?: () => Promise<JsonObject>;
};
/** @public */
export interface FrontendPlugin<
@@ -38,9 +99,19 @@ export interface FrontendPlugin<
readonly id: string;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
/**
* Loads the plugin info.
*/
info(): Promise<FrontendPluginInfo>;
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
/**
* Overrides the original info loaders of the plugin one by one.
*/
info?: FrontendPluginInfoOptions;
}): FrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
@@ -56,6 +127,7 @@ export interface PluginOptions<
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
info?: FrontendPluginInfoOptions;
}
/** @public */
@@ -150,6 +222,14 @@ export function createFrontendPlugin<
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
featureFlags: options.featureFlags ?? [],
extensions: extensions,
infoOptions: options.info,
// This method is overridden when the plugin instance is installed in an app
async info() {
throw new Error(
`Attempted to load plugin info for plugin '${pluginId}', but the plugin instance is not installed in an app`,
);
},
getExtension(id) {
const ext = extensionDefinitionsById.get(id);
if (!ext) {
@@ -178,6 +258,10 @@ export function createFrontendPlugin<
...options,
pluginId,
extensions: [...nonOverriddenExtensions, ...overrides.extensions],
info: {
...options.info,
...overrides.info,
},
});
},
});
@@ -40,6 +40,8 @@ export {
createFrontendPlugin,
type FrontendPlugin,
type PluginOptions,
type FrontendPluginInfo,
type FrontendPluginInfoOptions,
} from './createFrontendPlugin';
export {
createFrontendModule,
@@ -56,6 +56,7 @@ const HIGHLIGHT_LANGUAGES = [
'diff',
'js',
'json',
'docker',
];
function getExports(packageJson: any) {
+1
View File
@@ -228,6 +228,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({
export default createFrontendPlugin({
pluginId: 'api-docs',
info: { packageJson: () => import('../package.json') },
routes: {
root: convertLegacyRouteRef(rootRoute),
},
+1
View File
@@ -46,5 +46,6 @@ export const appVisualizerNavItem = NavItemBlueprint.make({
/** @public */
export const visualizerPlugin = createFrontendPlugin({
pluginId: 'app-visualizer',
info: { packageJson: () => import('../package.json') },
extensions: [appVisualizerPage, appVisualizerNavItem],
});
+1
View File
@@ -42,6 +42,7 @@ import { apis } from './defaultApis';
/** @public */
export const appPlugin = createFrontendPlugin({
pluginId: 'app',
info: { packageJson: () => import('../package.json') },
extensions: [
...apis,
App,
@@ -0,0 +1,53 @@
/*
* Copyright 2024 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.
*/
/**
* @param {import('knex').Knex} knex
* @returns {Promise<void>}
*/
exports.up = async function up(knex) {
if (knex.client.config.client.includes('pg')) {
await knex.schema.raw(
`ALTER TABLE refresh_state_references ALTER COLUMN id TYPE bigint;`,
);
await knex.schema.raw(
`ALTER SEQUENCE refresh_state_references_id_seq AS bigint MAXVALUE 9223372036854775807;`,
);
} else if (knex.client.config.client.includes('mysql')) {
await knex.schema.raw(
`ALTER TABLE refresh_state_references MODIFY id bigint AUTO_INCREMENT;`,
);
}
};
/**
* @param {import('knex').Knex} knex
* @returns {Promise<void>}
*/
exports.down = async function down(knex) {
if (knex.client.config.client.includes('pg')) {
await knex.schema.raw(
`ALTER SEQUENCE refresh_state_references_id_seq AS integer MAXVALUE 2147483647;`,
);
await knex.schema.raw(
`ALTER TABLE refresh_state_references ALTER COLUMN id TYPE integer;`,
);
} else if (knex.client.config.client.includes('mysql')) {
await knex.schema.raw(
`ALTER TABLE refresh_state_references MODIFY id integer AUTO_INCREMENT;`,
);
}
};
+7 -7
View File
@@ -8,7 +8,7 @@
## Sequences
- `location_update_log_id_seq` (bigint)
- `refresh_state_references_id_seq` (integer)
- `refresh_state_references_id_seq` (bigint)
## Table `final_entities`
@@ -93,12 +93,12 @@
## Table `refresh_state_references`
| Column | Type | Nullable | Max Length | Default |
| ------------------- | --------- | -------- | ---------- | ------------------------------------------------------ |
| `id` | `integer` | false | - | `nextval('refresh_state_references_id_seq'::regclass)` |
| `source_entity_ref` | `text` | true | - | - |
| `source_key` | `text` | true | - | - |
| `target_entity_ref` | `text` | false | - | - |
| Column | Type | Nullable | Max Length | Default |
| ------------------- | -------- | -------- | ---------- | ------------------------------------------------------ |
| `id` | `bigint` | false | - | `nextval('refresh_state_references_id_seq'::regclass)` |
| `source_entity_ref` | `text` | true | - | - |
| `source_key` | `text` | true | - | - |
| `target_entity_ref` | `text` | false | - | - |
### Indices
@@ -610,16 +610,18 @@ describe('DefaultProviderDatabase', () => {
);
let references = await knex<DbRefreshStateReferencesRow>(
'refresh_state_references',
).select();
)
.select()
.orderBy('id');
expect(references).toEqual([
{
id: 1,
id: expect.anything(),
source_key: 'lols',
source_entity_ref: null,
target_entity_ref: 'component:default/a',
},
{
id: 2,
id: expect.anything(),
source_key: 'lols',
source_entity_ref: null,
target_entity_ref: 'component:default/b',
@@ -670,16 +672,18 @@ describe('DefaultProviderDatabase', () => {
);
references = await knex<DbRefreshStateReferencesRow>(
'refresh_state_references',
).select();
)
.select()
.orderBy('id');
expect(references).toEqual([
{
id: 2,
id: expect.anything(),
source_key: 'lols',
source_entity_ref: null,
target_entity_ref: 'component:default/b',
},
{
id: 3,
id: expect.anything(),
source_key: 'lols',
source_entity_ref: null,
target_entity_ref: 'component:default/a',
@@ -521,6 +521,7 @@ describe('migrations', () => {
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'20250401200503_update_refresh_state_columns.js, %p',
async databaseId => {
@@ -604,4 +605,89 @@ describe('migrations', () => {
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'20250514000000_refresh_state_references_big_increments.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
const read = async () => {
return await knex('refresh_state_references')
.orderBy('id')
.then(rs => rs.map(r => ({ ...r, id: String(r.id) })));
};
// Run migrations up to just before the target migration
await migrateUntilBefore(
knex,
'20250514000000_refresh_state_references_big_increments.js',
);
await knex('refresh_state').insert({
entity_id: 'a',
entity_ref: 'k:ns/a',
unprocessed_entity: '{}',
cache: '{}',
errors: '[]',
next_update_at: knex.fn.now(),
last_discovery_at: knex.fn.now(),
});
await knex('refresh_state_references').insert({
source_key: 'before',
target_entity_ref: 'k:ns/a',
});
await migrateUpOnce(knex);
// can still insert with auto generated id in sequence
await knex('refresh_state_references').insert({
source_key: 'after1',
target_entity_ref: 'k:ns/a',
});
await expect(read()).resolves.toEqual([
{
id: '1',
source_key: 'before',
source_entity_ref: null,
target_entity_ref: 'k:ns/a',
},
{
id: '2',
source_key: 'after1',
source_entity_ref: null,
target_entity_ref: 'k:ns/a',
},
]);
await migrateDownOnce(knex);
// can still insert with auto generated id in sequence
await knex('refresh_state_references').insert({
source_key: 'after2',
target_entity_ref: 'k:ns/a',
});
await expect(read()).resolves.toEqual([
{
id: '1',
source_key: 'before',
source_entity_ref: null,
target_entity_ref: 'k:ns/a',
},
{
id: '2',
source_key: 'after1',
source_entity_ref: null,
target_entity_ref: 'k:ns/a',
},
{
id: '3',
source_key: 'after2',
source_entity_ref: null,
target_entity_ref: 'k:ns/a',
},
]);
await knex.destroy();
},
);
});
+1
View File
@@ -87,6 +87,7 @@ const CatalogGraphPage = PageBlueprint.makeWithOverrides({
export default createFrontendPlugin({
pluginId: 'catalog-graph',
info: { packageJson: () => import('../package.json') },
routes: {
catalogGraph: convertLegacyRouteRef(catalogGraphRouteRef),
},
+1
View File
@@ -87,6 +87,7 @@ const catalogImportApi = ApiBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog-import',
info: { packageJson: () => import('../package.json') },
extensions: [catalogImportApi, catalogImportPage],
routes: {
importPage: convertLegacyRouteRef(rootRouteRef),
@@ -74,6 +74,7 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog-unprocessed-entities',
info: { packageJson: () => import('../../package.json') },
routes: {
root: convertLegacyRouteRef(rootRouteRef),
},
+2
View File
@@ -72,6 +72,8 @@
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-search-react": "workspace:^",
"@backstage/plugin-techdocs-common": "workspace:^",
"@backstage/plugin-techdocs-react": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.2",
+1
View File
@@ -39,6 +39,7 @@ import contextMenuItems from './contextMenuItems';
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog',
info: { packageJson: () => import('../../package.json') },
routes: convertLegacyRouteRefs({
catalogIndex: rootRouteRef,
catalogEntity: entityRouteRef,
@@ -589,6 +589,64 @@ describe('<AboutCard />', () => {
);
});
it('renders techdocs link to specific path', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/techdocs-entity': 'system:default/example',
'backstage.io/techdocs-entity-path': '/path/to/component',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
await renderInTestApp(
<TestApiProvider
apis={[
[
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
],
[catalogApiRef, catalogApi],
[permissionApiRef, {}],
]}
>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</TestApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name': viewTechDocRouteRef,
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(screen.getByText('View TechDocs').closest('a')).toHaveAttribute(
'href',
'/docs/default/system/example/path/to/component',
);
});
it('renders techdocs link', async () => {
const entity = {
apiVersion: 'v1',
@@ -16,10 +16,8 @@
import {
ANNOTATION_EDIT_URL,
ANNOTATION_LOCATION,
CompoundEntityRef,
DEFAULT_NAMESPACE,
stringifyEntityRef,
parseEntityRef,
} from '@backstage/catalog-model';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
@@ -66,10 +64,11 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
import { usePermission } from '@backstage/plugin-permission-react';
import { catalogTranslationRef } from '../../alpha/translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
import {
TECHDOCS_ANNOTATION,
TECHDOCS_EXTERNAL_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
const useStyles = makeStyles({
gridItemCard: {
@@ -135,19 +134,6 @@ export function AboutCard(props: AboutCardProps) {
const entityMetadataEditUrl =
entity.metadata.annotations?.[ANNOTATION_EDIT_URL];
let techdocsRef: CompoundEntityRef | undefined;
if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
try {
techdocsRef = parseEntityRef(
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
);
// not a fan of this but we don't care if the parseEntityRef fails
} catch {
techdocsRef = undefined;
}
}
const viewInSource: IconLinkVerticalProps = {
label: t('aboutCard.viewSource'),
disabled: !entitySourceLocation,
@@ -162,19 +148,7 @@ export function AboutCard(props: AboutCardProps) {
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
) || !viewTechdocLink,
icon: <DocsIcon />,
href:
viewTechdocLink &&
(techdocsRef
? viewTechdocLink({
namespace: techdocsRef.namespace || DEFAULT_NAMESPACE,
kind: techdocsRef.kind,
name: techdocsRef.name,
})
: viewTechdocLink({
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
kind: entity.kind,
name: entity.metadata.name,
})),
href: buildTechDocsURL(entity, viewTechdocLink),
};
const subHeaderLinks = [viewInSource, viewInTechDocs];
+1
View File
@@ -71,6 +71,7 @@ export const devToolsNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'devtools',
info: { packageJson: () => import('../../package.json') },
routes: {
root: convertLegacyRouteRef(rootRouteRef),
},
+1
View File
@@ -68,6 +68,7 @@ const homePage = PageBlueprint.makeWithOverrides({
*/
export default createFrontendPlugin({
pluginId: 'home',
info: { packageJson: () => import('../package.json') },
extensions: [homePage],
routes: {
root: rootRouteRef,
+1
View File
@@ -28,6 +28,7 @@ import {
export default createFrontendPlugin({
pluginId: 'kubernetes',
info: { packageJson: () => import('../../package.json') },
extensions: [
kubernetesPage,
entityKubernetesContent,
+1
View File
@@ -54,6 +54,7 @@ const api = ApiBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'notifications',
info: { packageJson: () => import('../package.json') },
routes: convertLegacyRouteRefs({
root: rootRouteRef,
}),
+1
View File
@@ -87,6 +87,7 @@ const EntityUserProfileCard = EntityCardBlueprint.makeWithOverrides({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'org',
info: { packageJson: () => import('../package.json') },
extensions: [
EntityGroupProfileCard,
EntityMembersListCard,
@@ -23,6 +23,7 @@ import {
addFiles,
cloneRepo,
parseRepoUrl,
isNotGitDirectoryOrContents,
} from '@backstage/plugin-scaffolder-node';
import { Config } from '@backstage/config';
import fs from 'fs-extra';
@@ -413,9 +414,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: {
// copy files
fs.cpSync(sourceDir, tempDir, {
recursive: true,
filter: path => {
return !(path.indexOf('.git') > -1);
},
filter: isNotGitDirectoryOrContents,
});
await addFiles({
@@ -26,6 +26,7 @@ import {
addFiles,
cloneRepo,
parseRepoUrl,
isNotGitDirectoryOrContents,
} from '@backstage/plugin-scaffolder-node';
import { Config } from '@backstage/config';
import fs from 'fs-extra';
@@ -453,9 +454,7 @@ export function createPublishBitbucketServerPullRequestAction(options: {
// copy files
fs.cpSync(sourceDir, tempDir, {
recursive: true,
filter: path => {
return !(path.indexOf('.git') > -1);
},
filter: isNotGitDirectoryOrContents,
});
await addFiles({
@@ -196,19 +196,25 @@ export const createPublishGitlabMergeRequestAction: (options: {
title: string;
description: string;
branchName: string;
targetBranchName?: string;
sourcePath?: string;
targetPath?: string;
token?: string;
commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto';
projectid?: string;
removeSourceBranch?: boolean;
assignee?: string;
reviewers?: string[];
assignReviewersFromApprovalRules?: boolean;
targetBranchName?: string | undefined;
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
projectid?: string | undefined;
removeSourceBranch?: boolean | undefined;
assignee?: string | undefined;
reviewers?: string[] | undefined;
assignReviewersFromApprovalRules?: boolean | undefined;
labels?: string | string[] | undefined;
},
JsonObject,
'v1'
{
targetBranchName: string;
projectid: string;
projectPath: string;
mergeRequestUrl: string;
},
'v2'
>;
// @public
@@ -125,7 +125,7 @@ jest.mock('@gitbeaker/rest', () => ({
}));
describe('createGitLabMergeRequest', () => {
let instance: TemplateAction<any>;
let instance: TemplateAction<any, any, 'v2'>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
@@ -238,7 +238,7 @@ jest.mock('@gitbeaker/rest', () => ({
}));
describe('createGitLabMergeRequest', () => {
let instance: TemplateAction<any>;
let instance: TemplateAction<any, any, 'v2'>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
@@ -1446,4 +1446,66 @@ describe('createGitLabMergeRequest', () => {
);
});
});
describe('with labels', () => {
it('handles single label', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
title: 'Create my new MR',
branchName: 'new-mr',
description: 'MR description',
commitAction: 'skip',
labels: 'single-label',
};
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
labels: 'single-label',
},
);
});
it('handles array of labels', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
title: 'Create my new MR',
branchName: 'new-mr',
description: 'MR description',
commitAction: 'skip',
labels: ['foo', 'bar', 'baz'],
};
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
labels: ['foo', 'bar', 'baz'],
},
);
});
});
});
@@ -134,8 +134,10 @@ async function getReviewersFromApprovalRules(
}
}
const commitActions = ['create', 'delete', 'update', 'skip', 'auto'] as const;
/**
* Create a new action that creates a gitlab merge request.
* Create a new action that creates a GitLab merge request.
*
* @public
*/
@@ -144,138 +146,96 @@ export const createPublishGitlabMergeRequestAction = (options: {
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
title: string;
description: string;
branchName: string;
targetBranchName?: string;
sourcePath?: string;
targetPath?: string;
token?: string;
commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto';
/** @deprecated projectID passed as query parameters in the repoUrl */
projectid?: string;
removeSourceBranch?: boolean;
assignee?: string;
reviewers?: string[];
assignReviewersFromApprovalRules?: boolean;
}>({
return createTemplateAction({
id: 'publish:gitlab:merge-request',
examples,
schema: {
input: {
required: ['repoUrl', 'branchName'],
type: 'object',
properties: {
repoUrl: {
type: 'string',
title: 'Repository Location',
description: `\
Accepts the format 'gitlab.com?repo=project_name&owner=group_name' where \
'project_name' is the repository name and 'group_name' is a group or username`,
},
/** @deprecated projectID is passed as query parameters in the repoUrl */
projectid: {
type: 'string',
title: 'projectid',
description: 'Project ID/Name(slug) of the Gitlab Project',
},
title: {
type: 'string',
title: 'Merge Request Name',
description: 'The name for the merge request',
},
description: {
type: 'string',
title: 'Merge Request Description',
description: 'The description of the merge request',
},
branchName: {
type: 'string',
title: 'Source Branch Name',
description: 'The source branch name of the merge request',
},
targetBranchName: {
type: 'string',
title: 'Target Branch Name',
description: 'The target branch name of the merge request',
},
sourcePath: {
type: 'string',
title: 'Working Subdirectory',
description: `\
repoUrl: z =>
z.string().describe(`\
Accepts the format \`gitlab.com?repo=project_name&owner=group_name\` where \
\`project_name\` is the repository name and \`group_name\` is a group or username`),
title: z => z.string().describe('The name for the merge request'),
description: z =>
z.string().describe('The description of the merge request'),
branchName: z =>
z.string().describe('The source branch name of the merge request'),
targetBranchName: z =>
z
.string()
.optional()
.describe(
'The target branch name of the merge request (defaults to _default branch of repository_)',
),
sourcePath: z =>
z.string().optional().describe(`\
Subdirectory of working directory to copy changes from. \
For reasons of backward compatibility, any specified 'targetPath' input will \
For reasons of backward compatibility, any specified \`targetPath\` input will \
be applied in place of an absent/falsy value for this input. \
Circumvent this behavior using '.'`,
},
targetPath: {
type: 'string',
title: 'Repository Subdirectory',
description: 'Subdirectory of repository to apply changes to',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
commitAction: {
title: 'Commit action',
type: 'string',
enum: ['create', 'update', 'delete', 'auto'],
description: `\
The action to be used for git commit. Defaults to the custom 'auto' action provided by backstage,
which uses additional API calls in order to detect whether to 'create', 'update' or 'skip' each source file.`,
},
removeSourceBranch: {
title: 'Delete source branch',
type: 'boolean',
description:
'Option to delete source branch once the MR has been merged. Default: false',
},
assignee: {
title: 'Merge Request Assignee',
type: 'string',
description: 'User this merge request will be assigned to',
},
reviewers: {
title: 'Merge Request Reviewers',
type: 'array',
items: {
type: 'string',
},
description: 'Users that will be assigned as reviewers',
},
assignReviewersFromApprovalRules: {
title: 'Assign reviewers from approval rules',
type: 'boolean',
description:
'Automatically assign reviewers from the approval rules of the MR. Includes Codeowners',
},
},
Circumvent this behavior using \`.\``),
targetPath: z =>
z
.string()
.optional()
.describe('Subdirectory of repository to apply changes to'),
token: z =>
z
.string()
.optional()
.describe('The token to use for authorization to GitLab'),
commitAction: z =>
z.enum(commitActions).optional().describe(`\
The action to be used for \`git\` commit. Defaults to the custom \`auto\` action provided by Backstage,
which uses additional API calls in order to detect whether to \`create\`, \`update\` or \`skip\` each source file.`),
/** @deprecated projectID passed as query parameters in the repoUrl */
projectid: z =>
z
.string()
.optional()
.describe(
`\
Project ID/Name(slug) of the GitLab Project
_deprecated_: \`projectid\` passed as query parameters in the \`repoUrl\``,
),
removeSourceBranch: z =>
z
.boolean()
.optional()
.describe(
'Option to delete source branch once the MR has been merged. Default: `false`',
),
assignee: z =>
z
.string()
.optional()
.describe('User this merge request will be assigned to'),
reviewers: z =>
z
.string()
.array()
.optional()
.describe('Users that will be assigned as reviewers'),
assignReviewersFromApprovalRules: z =>
z
.boolean()
.optional()
.describe(
'Automatically assign reviewers from the approval rules of the MR. Includes `CODEOWNERS`',
),
labels: z =>
z
.string()
.or(z.string().array())
.optional()
.describe('Labels with which to tag the created merge request'),
},
output: {
type: 'object',
properties: {
targetBranchName: {
title: 'Target branch name of the merge request',
type: 'string',
},
projectid: {
title: 'Gitlab Project id/Name(slug)',
type: 'string',
},
projectPath: {
title: 'Gitlab Project path',
type: 'string',
},
mergeRequestUrl: {
title: 'MergeRequest(MR) URL',
type: 'string',
description: 'Link to the merge request in GitLab',
},
},
targetBranchName: z =>
z.string().describe('Target branch name of the merge request'),
projectid: z => z.string().describe('GitLab Project id/Name(slug)'),
projectPath: z => z.string().describe('GitLab Project path'),
mergeRequestUrl: z =>
z.string().describe('Link to the merge request in GitLab'),
},
},
async handler(ctx) {
@@ -291,6 +251,7 @@ which uses additional API calls in order to detect whether to 'create', 'update'
sourcePath,
title,
token,
labels,
} = ctx.input;
const { owner, repo, project } = parseRepoUrl(repoUrl, integrations);
@@ -499,6 +460,7 @@ which uses additional API calls in order to detect whether to 'create', 'update'
: false,
assigneeId,
reviewerIds,
labels,
},
);
return {
@@ -552,7 +514,7 @@ which uses additional API calls in order to detect whether to 'create', 'update'
ctx.output('projectid', repoID);
ctx.output('targetBranchName', targetBranch);
ctx.output('projectPath', repoID);
ctx.output('mergeRequestUrl', mrWebUrl);
ctx.output('mergeRequestUrl', mrWebUrl as string);
},
});
};
+18 -12
View File
@@ -365,19 +365,25 @@ export const createPublishGitlabMergeRequestAction: (options: {
title: string;
description: string;
branchName: string;
targetBranchName?: string;
sourcePath?: string;
targetPath?: string;
token?: string;
commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto';
projectid?: string;
removeSourceBranch?: boolean;
assignee?: string;
reviewers?: string[];
assignReviewersFromApprovalRules?: boolean;
targetBranchName?: string | undefined;
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
projectid?: string | undefined;
removeSourceBranch?: boolean | undefined;
assignee?: string | undefined;
reviewers?: string[] | undefined;
assignReviewersFromApprovalRules?: boolean | undefined;
labels?: string | string[] | undefined;
},
JsonObject,
'v1'
{
targetBranchName: string;
projectid: string;
projectPath: string;
mergeRequestUrl: string;
},
'v2'
>;
// @public @deprecated
@@ -38,17 +38,16 @@ import {
mockCredentials,
mockServices,
} from '@backstage/backend-test-utils';
import { LoggerService } from '@backstage/backend-plugin-api';
import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger';
describe('NunjucksWorkflowRunner', () => {
let logger: LoggerService;
let actionRegistry = new TemplateActionRegistry();
let actionRegistry: TemplateActionRegistry;
let runner: NunjucksWorkflowRunner;
let fakeActionHandler: jest.Mock;
let fakeTaskLog: jest.Mock;
let stripAnsi: typeof import('strip-ansi').default;
const logger = mockServices.logger.mock();
const mockDir = createMockDirectory();
const mockedPermissionApi: jest.Mocked<PermissionEvaluator> = {
@@ -71,11 +70,21 @@ describe('NunjucksWorkflowRunner', () => {
});
const createMockTaskWithSpec = (
spec: TaskSpec,
{
apiVersion = 'scaffolder.backstage.io/v1beta3',
output = {},
parameters = {},
...spec
}: Partial<TaskSpec>,
secrets?: TaskSecrets,
isDryRun?: boolean,
): TaskContext => ({
spec,
spec: {
apiVersion,
output,
parameters,
...spec,
} as TaskSpec,
secrets,
isDryRun,
complete: async () => {},
@@ -98,8 +107,6 @@ describe('NunjucksWorkflowRunner', () => {
// This one is ESM-only
stripAnsi = await import('strip-ansi').then(m => m.default);
jest.resetAllMocks();
logger = mockServices.logger.mock();
actionRegistry = new TemplateActionRegistry();
fakeActionHandler = jest.fn();
fakeTaskLog = jest.fn();
@@ -234,11 +241,14 @@ describe('NunjucksWorkflowRunner', () => {
});
});
afterEach(() => {
mockDir.clear();
jest.resetAllMocks();
});
it('should throw an error if the action does not exist', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [{ id: 'test', name: 'name', action: 'does-not-exist' }],
});
@@ -250,9 +260,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('validation', () => {
it('should throw an error if the action has a schema and the input does not match', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [{ id: 'test', name: 'name', action: 'jest-validated-action' }],
});
@@ -263,9 +270,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should throw an error if the action has a zod schema and the input does not match', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{ id: 'test', name: 'name', action: 'jest-zod-validated-action' },
],
@@ -278,9 +282,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should throw an error if the action has legacy zod schema and the input does not match', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -297,9 +298,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should run the action when the zod validation passes', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -317,9 +315,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should run the action when the zod validation passes with legacy zod', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -337,9 +332,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should run the action when the validation passes', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -373,9 +365,6 @@ describe('NunjucksWorkflowRunner', () => {
};
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -404,9 +393,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should pass token through', async () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -433,7 +419,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('conditionals', () => {
it('should execute steps conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{ id: 'test', name: 'test', action: 'output-action' },
{
@@ -446,7 +431,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
@@ -456,7 +440,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should skips steps conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{ id: 'test', name: 'test', action: 'output-action' },
{
@@ -469,7 +452,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
@@ -479,7 +461,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should skips steps using the negating equals operator', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{ id: 'test', name: 'test', action: 'output-action' },
{
@@ -492,7 +473,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
@@ -502,7 +482,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('should apply boolean step conditions', () => {
it('executes when true', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'conditional',
@@ -514,7 +493,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
@@ -522,7 +500,6 @@ describe('NunjucksWorkflowRunner', () => {
});
it('skips when false', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'conditional',
@@ -534,7 +511,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
result: '${{ steps.conditional.output.mock }}',
},
parameters: {},
});
const { output } = await runner.execute(task);
@@ -546,7 +522,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('templating', () => {
it('should template the input to an action', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -557,7 +532,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
input: 'BACKSTAGE',
},
@@ -573,7 +547,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should not try and parse something that is not parsable', async () => {
jest.spyOn(logger, 'error');
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -584,7 +557,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
input: 'BACKSTAGE',
},
@@ -597,7 +569,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should keep the original types for the input and not parse things that are not meant to be parsed', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -609,7 +580,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
number: 0,
string: '1',
@@ -625,7 +595,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should template complex values into the action', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -636,7 +605,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
complex: { bar: 'BACKSTAGE' },
},
@@ -651,7 +619,6 @@ describe('NunjucksWorkflowRunner', () => {
it('supports really complex structures', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -662,7 +629,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
complex: {
bar: 'BACKSTAGE',
@@ -680,7 +646,6 @@ describe('NunjucksWorkflowRunner', () => {
it('supports numbers as first class too', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -691,7 +656,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
complex: {
bar: 'BACKSTAGE',
@@ -710,8 +674,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should deal with checkpoints', async () => {
const task = {
...createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
steps: [
{
id: 'test',
@@ -762,7 +724,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should template the output from simple actions', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -774,7 +735,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
foo: '${{steps.test.output.mock | upper}}',
},
parameters: {},
});
const { output } = await runner.execute(task);
@@ -784,7 +744,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should include task ID in the templated context', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -797,8 +756,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {},
});
await runner.execute(task);
@@ -836,9 +793,6 @@ describe('NunjucksWorkflowRunner', () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -882,9 +836,6 @@ describe('NunjucksWorkflowRunner', () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -909,7 +860,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should run a step repeatedly - flat values', async () => {
const colors = ['blue', 'green', 'red'];
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -919,7 +869,6 @@ describe('NunjucksWorkflowRunner', () => {
input: { color: '${{each.value}}' },
},
],
output: {},
parameters: {
colors,
},
@@ -938,7 +887,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should run a step repeatedly - object list', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -951,7 +899,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {
settings: [{ color: 'blue' }],
},
@@ -974,7 +921,6 @@ describe('NunjucksWorkflowRunner', () => {
transparent: 'yes',
};
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -984,7 +930,6 @@ describe('NunjucksWorkflowRunner', () => {
input: { key: '${{each.key}}', value: '${{each.value}}' },
},
],
output: {},
parameters: {
settings,
},
@@ -1006,7 +951,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should run a step repeatedly with validation of single-expression value', async () => {
const numbers = [5, 7, 9];
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1016,7 +960,6 @@ describe('NunjucksWorkflowRunner', () => {
input: { foo: '${{each.value}}' },
},
],
output: {},
parameters: {
numbers,
},
@@ -1037,7 +980,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should validate each action iteration', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1047,7 +989,6 @@ describe('NunjucksWorkflowRunner', () => {
input: { foo: '${{each.value.foo}}' },
},
],
output: {},
parameters: {
data: [
{
@@ -1065,7 +1006,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should validate each parameter renders to a valid value', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1075,8 +1015,6 @@ describe('NunjucksWorkflowRunner', () => {
input: { foo: '${{each.value}}' },
},
],
output: {},
parameters: {},
});
await expect(runner.execute(task)).rejects.toThrow(
'Invalid value on action jest-validated-action.each parameter, "${{parameters.data}}" cannot be resolved to a value',
@@ -1089,7 +1027,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should pass through the secrets to the context', async () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1098,8 +1035,6 @@ describe('NunjucksWorkflowRunner', () => {
input: {},
},
],
output: {},
parameters: {},
},
{ foo: 'bar' },
);
@@ -1114,7 +1049,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should be able to template secrets into the input of an action', async () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1125,8 +1059,6 @@ describe('NunjucksWorkflowRunner', () => {
},
},
],
output: {},
parameters: {},
},
{ foo: 'bar' },
);
@@ -1141,7 +1073,6 @@ describe('NunjucksWorkflowRunner', () => {
it('does not allow templating of secrets as an output', async () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1155,7 +1086,6 @@ describe('NunjucksWorkflowRunner', () => {
output: {
b: '${{ secrets.foo }}',
},
parameters: {},
},
{ foo: 'bar' },
);
@@ -1169,7 +1099,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('user', () => {
it('allows access to the user entity at the templating level', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1199,7 +1128,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('filters', () => {
it('provides the parseRepoUrl filter', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1228,7 +1156,6 @@ describe('NunjucksWorkflowRunner', () => {
describe('parseEntityRef', () => {
it('parses entity ref', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1256,7 +1183,6 @@ describe('NunjucksWorkflowRunner', () => {
it('provides default kind for parsing entity ref', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1284,7 +1210,6 @@ describe('NunjucksWorkflowRunner', () => {
it('provides default namespace for parsing entity ref', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1312,7 +1237,6 @@ describe('NunjucksWorkflowRunner', () => {
it('provides default kind and namespace for parsing entity ref', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1342,7 +1266,6 @@ describe('NunjucksWorkflowRunner', () => {
'ignores invalid context "%s" for parsing entity refF',
async kind => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1371,7 +1294,6 @@ describe('NunjucksWorkflowRunner', () => {
it('fails when unable to parse entity ref', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1398,7 +1320,6 @@ describe('NunjucksWorkflowRunner', () => {
it('provides the pick filter', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1422,7 +1343,6 @@ describe('NunjucksWorkflowRunner', () => {
it('should allow deep nesting of picked objects', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
@@ -1455,9 +1375,6 @@ describe('NunjucksWorkflowRunner', () => {
it('sets isDryRun flag correctly', async () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -1481,13 +1398,10 @@ describe('NunjucksWorkflowRunner', () => {
it('should have metadata in action context during dry run', async () => {
const task = createMockTaskWithSpec(
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
templateInfo: {
entityRef: 'dryRun-Entity',
entity: { metadata: { name: 'test-template' } },
},
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -1519,9 +1433,6 @@ describe('NunjucksWorkflowRunner', () => {
]);
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test',
@@ -1560,9 +1471,6 @@ describe('NunjucksWorkflowRunner', () => {
]);
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
parameters: {},
output: {},
steps: [
{
id: 'test1',
+3
View File
@@ -316,6 +316,9 @@ export function initRepoAndPush(input: {
commitHash: string;
}>;
// @public
export function isNotGitDirectoryOrContents(path: string): boolean;
// @public (undocumented)
export const parseRepoUrl: (
repoUrl: string,
+5 -1
View File
@@ -33,4 +33,8 @@ export {
createBranch,
cloneRepo,
} from './gitHelpers';
export { parseRepoUrl, getRepoSourceDirectory } from './util';
export {
parseRepoUrl,
getRepoSourceDirectory,
isNotGitDirectoryOrContents,
} from './util';
@@ -244,4 +244,30 @@ describe('scaffolder action utils', () => {
});
});
});
describe('isNotGitDirectoryOrContents', () => {
it('should filter .git directory and its contents but keep other files', () => {
// Import the function to test
const { isNotGitDirectoryOrContents } = require('./util');
// Should filter out .git directory
expect(isNotGitDirectoryOrContents('.git')).toBe(false);
// Should filter out .git directory in subdirectories
expect(isNotGitDirectoryOrContents('subdir/.git')).toBe(false);
// Should filter out files inside .git directory
expect(isNotGitDirectoryOrContents('.git/config')).toBe(false);
expect(isNotGitDirectoryOrContents('subdir/.git/config')).toBe(false);
// Should keep .gitignore and other non-.git-directory files
expect(isNotGitDirectoryOrContents('.gitignore')).toBe(true);
expect(isNotGitDirectoryOrContents('src/components/GitHubIcon.js')).toBe(
true,
);
expect(isNotGitDirectoryOrContents('.github/workflows/ci.yml')).toBe(
true,
);
});
});
});
@@ -186,3 +186,12 @@ export const parseSchemas = (
outputSchema: action.schema.output,
};
};
/**
* Filter function to exclude the .git directory and its contents
* while keeping other files like .gitignore
* @public
*/
export function isNotGitDirectoryOrContents(path: string): boolean {
return !(path.endsWith('.git') || path.includes('.git/'));
}

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