Merge branch 'master' into feature/migrate-scaffolder-octokit

This commit is contained in:
Crevil
2022-01-27 08:36:56 +01:00
34 changed files with 1370 additions and 1086 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Remove the `ignoreChildEvent` utility from the sidebar component to avoid conflicts with popovers
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
chore(deps): bump `remark-gfm` from 2.0.0 to 3.0.1
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
chore(deps): bump `react-markdown` from 7.1.2 to 8.0.0
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
This change is for adding the option of inputs on the `github:actions:dispatch` Backstage Action. This will allow users to pass data from Backstage to the GitHub Action.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updated the dependency warning that is baked into `app:serve` to only warn about packages that are not allowed to have duplicates.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Updates styling of Header component by removing flex wrap and add max width of characters for subtitle
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Switched the `WebpackDevServer` configuration to use client-side detection of the WebSocket protocol.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Running the `auth-backend` on multiple domains, perhaps different domains depending on the `auth.environment`, was previously not possible as the `domain` name of the cookie was taken from `backend.baseUrl`. This prevented any cookies to be set in the start of the auth flow as the domain of the cookie would not match the domain of the callbackUrl configured in the OAuth app. This change checks if a provider supports custom `callbackUrl`'s to be configured in the application configuration and uses the domain from that, allowing the `domain`'s to match and the cookie to be set.
+4
View File
@@ -208,6 +208,7 @@ pageview
parallelization
Patrik
Peloton
performant
plantuml
Platformize
Podman
@@ -222,6 +223,7 @@ productional
Protobuf
proxying
Proxying
pubsub
pygments
pymdownx
rankdir
@@ -314,6 +316,7 @@ unmanaged
unregister
unregistration
untracked
upsert
upvote
url
URLs
@@ -334,3 +337,4 @@ Zalando
Zhou
zoomable
zsh
Alef
+5
View File
@@ -4,6 +4,11 @@ on:
pull_request:
paths-ignore:
- 'microsite/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
+4
View File
@@ -8,6 +8,10 @@ on:
- 'docs/**'
- 'microsite/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ${{ matrix.os }}
@@ -8,6 +8,10 @@ on:
- 'docs/**'
- 'microsite/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
+4
View File
@@ -10,6 +10,10 @@ on:
- 'packages/e2e-test/**'
- 'packages/create-app/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ${{ matrix.os }}
@@ -9,24 +9,286 @@ Backstage natively supports importing catalog data through the use of
[entity descriptor YAML files](descriptor-format.md). However, companies that
already have an existing system for keeping track of software and its owners can
leverage those systems by integrating them with Backstage. This article shows
the most common way of doing that integration: by adding a custom catalog
_processor_.
the two common ways of doing that integration: by adding a custom catalog
_entity provider_, or by adding a _processor_.
## Background
The catalog has a frontend plugin part, that communicates via a service API to
the backend plugin part. The backend has a processing loop that repeatedly
ingests data from the sources you specify, to store them in its database.
the backend plugin part. The backend continuously ingests data from the sources
you specify, to store them in its database. The details of how this works is
detailed in [The Life of an Entity](life-of-an-entity.md). Reading that article
first is recommended.
As a Backstage adopter, you would be able to customize or extend the catalog in
several ways - by replacing the entire backend API, or by replacing entire
implementation classes at certain points in the backend, or by leveraging the
ingestion process to fetch data from your own authoritative source. Each method
has benefits and drawbacks, but this article will focus on the last one of the
above. It is the one that is the most straight forward and future proof, and
leverages a lot of benefits that come with the builtin catalog.
There are two main options for how to ingest data into the catalog: making a
[custom entity provider](#custom-entity-providers), or making a
[custom processor](#custom-processors). They both have strengths and drawbacks,
but the former would usually be preferred. Both options are presented in a
dedicated subsection below.
## Processors and the Ingestion Loop
## Custom Entity Providers
Entity providers sit at the very edge of the catalog. They are the original
sources of entities that form roots of the processing tree. The dynamic location
store API, and the static locations you can specify in your app-config, are two
examples of builtin providers in the catalog.
Some defining traits of entity providers:
- You instantiate them individually using code in your backend, and pass them to
the catalog builder. Often there's one provider instance per remote system.
- You may be responsible for actively running them. For example, some providers
need to be triggered periodically by a method call to know when they are meant
to do their job; in that case you'll have to make that happen.
- The timing of their work is entirely detached from the processing loops. One
provider may run every 30 seconds, another one on every incoming webhook call
of a certain type, etc.
- They can perform detailed updates on the set of entities that they are
responsible for. They can make full updates of the entire set, or issue
individual additions and removals.
- Their output is a set of unprocessed entities. Those are then subject to the
processing loops before becoming final, stitched entities.
- When they remove an entity, the entire subtree of processor-generated entities
under that root is eagerly removed as well.
### Creating an Entity Provider
The recommended way of instantiating the catalog backend classes is to use the
`CatalogBuilder`, as illustrated in the
[example backend here](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/catalog.ts).
We will create a new
[`EntityProvider`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/providers/types.ts)
subclass that can be added to this catalog builder.
Let's make a simple provider that can refresh a set of entities based on a
remote store. The provider part of the interface is actually tiny - you only
have to supply a (unique) name, and accept a connection from the environment
through which you can issue writes. The rest is up to the individual provider
implementation.
It is up to you where you put the code for this new processor class. For quick
experimentation you could place it in your backend package, but we recommend
putting all extensions like this in a backend plugin package of their own in the
`plugins` folder of your Backstage repo.
The class will have this basic structure:
```ts
import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import {
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-backend';
/**
* Provides entities from fictional frobs service.
*/
export class FrobsProvider implements EntityProvider {
private readonly env: string;
private readonly reader: UrlReader;
private connection?: EntityProviderConnection;
/** [1] **/
constructor(env: string, reader: UrlReader) {
this.env = env;
this.reader = reader;
}
/** [2] **/
getProviderName(): string {
return `frobs-${this.env}`;
}
/** [3] **/
async connect(connection: EntityProviderConnection): Promise<void> {
this.connection = connection;
}
/** [4] **/
async run(): Promise<void> {
if (!this.connection) {
throw new Error('Not initialized');
}
const raw = await this.reader.read(
`https://frobs-${this.env}.example.com/data`,
);
const data = JSON.parse(raw.toString());
/** [5] **/
const entities: Entity[] = frobsToEntities(data);
/** [6] **/
await this.connection.applyMutation({
type: 'full',
entities: entities.map(entity => ({
entity,
locationKey: `frobs-provider:${this.env}`,
})),
});
}
}
```
This class demonstrates several important concepts, some of which are optional.
Check out the numbered markings - let's go through them one by one.
1. The class takes an `env` parameter. This is only illustrative for the sake of
the example. We'll use this field to exhibit the type of provider where end
users may want or need to make multiple instances of the same provider, and
what the implications would be in that case.
2. The catalog requires that all registered providers return a name that is
_unique_ among those providers, and which is _stable_ over time. The reason
for these requirements is, the emitted entities for each provider instance
all hang around in a closed bucket of their own. This bucket needs to be tied
to their provider over time, and across backend restarts. We'll see below how
the processor emits some entities and what that means for its own bucket.
3. Once the catalog engine starts up, it immediately issues the `connect` call
to all known providers. This forms the bond between the code and the
database. This is also an opportunity for the provider to do one-time updates
on the connection at startup if it wants to.
4. At this point the provider contract is already complete. But the class needs
to do some actual work too! In this particular example, we chose to make a
`run` method that has to be called each time that you want to issue a sync
with the `frobs` service. Let's repeat that - this is only an example
implementation; some providers may be written in entirely different ways,
such as for example subscribing to pubsub events and only reacting to those,
or any number of other solutions. The only point is - external stimuli happen
somehow, which somehow get translated to calls on the `connection` to persist
the outcome of that. This example issues a `fetch` to the right service and
issues a full refresh of its entity bucket based on that.
5. The method translates the foreign data model to the native `Entity` form, as
expected by the catalog.
6. Finally, we issue a "mutation" to the catalog. This persists the entities in
our own bucket, along with an optional `locationKey` that's used for conflict
checks. But this is a bigger topic - mutations warrant their own explanatory
section below.
### Provider Mutations
Let's circle back to the bucket analogy.
Each provider _instance_ - not each class but each instance registered with the
catalog - has access to its own bucket of entities, and the bucket is identified
by the stable name of the provider instance. Every time the provider issues
"mutations", it changes the contents of that bucket. Nothing else outside of the
bucket is accessible.
There are two different types of mutation.
The first is `'full'`, which means to figuratively throw away the contents of
the bucket and replacing it with all of the new contents specified. Under the
hood, this is actually implemented through a highly efficient delta mechanism
for performance reasons, since it is common that the difference from one run to
the other is actually very small. This strategy is convenient for providers that
have easy access to batch-fetches of the entire subject material from a remote
source, and doesn't have access to, or does not want to compute, deltas.
The other mutation type is `'delta'`, which lets the provider explicitly upsert
or delete entities in its bucket. This mutation is convenient e.g. for event
based providers, and can also be more performant since no deltas need to be
computed, and previous bucket contents outside of the targeted set do not have
to be taken into account.
In all cases, the mutation entities are treated as _unprocessed_ entities. When
they land in the database, the registered catalog processors go to work on them
to transform them into final, processed and stitched, entities ready for
consumption.
Every entity emitted by a processor can have a `locationKey`, as shown above.
This is a critical conflict resolution key, in the form of an opaque string that
should be unique for each location that an entity could be located at, and
undefined if the entity does not have a fixed location.
In practice it should be set to the serialized location reference if the entity
is stored in Git, for example
`https://github.com/backstage/backstage/blob/master/catalog-info.yaml`, or a
similar string that distinctly pins down its origins. In our example we set it
to a string that was distinct for the provider class, plus its instance
identifying properties which in this case was the `env`.
A conflict between two entity definitions happen when they have the same entity
reference, i.e. kind, namespace, and name. In the event of a conflict, such as
if two "competing" providers try to emit entities that have the same reference
triplet, the location key will be used according to the following rules to
resolve the conflict:
- If the entity is already present in the database but does not have a location
key set, the new entity wins and will override the existing one.
- If the entity is already present in the database the new entity will only win
if the location keys of the existing and new entity are the same.
- If the entity is not already present, insert the entity into the database
along with the provided location key.
This may seem complex, but is a vital mechanism for ensuring that users aren't
permitted to do "rogue" takeovers of already registered entities that belong to
others.
### Installing the Provider
You should now be able to add this class to your backend in
`packages/backend/src/plugins/catalog.ts`:
```diff
+import { Duration } from 'luxon';
+import { FrobsProvider } from '../path/to/class';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = CatalogBuilder.create(env);
+ const frobs = new FrobsProvider('production', env.reader);
+ builder.addEntityProvider(frobs);
const { processingEngine, router } = await builder.build();
await processingEngine.start();
+ await env.scheduler.scheduleTask({
+ id: 'run_frobs_refresh',
+ fn: async () => { await frobs.run(); },
+ frequency: Duration.fromObject({ minutes: 30 }),
+ timeout: Duration.fromObject({ minutes: 10 }),
+ });
```
Note that we used the builtin scheduler facility to regularly call the `run`
method of the provider, in this example. It is a suitable driver for this
particular type of recurring task. We placed the scheduling after the actual
construction and startup phase of the rest of the catalog, because at that point
the `connect` call has been made to the provider.
Start up the backend - it should now start reading from the previously
registered location and you'll see your entities start to appear in Backstage.
## Custom Processors
The other possible way of ingesting data into the catalog is through the use of
location reading catalog processors.
Processors sit in the middle of the processing loops of the catalog. They are
responsible for updating and finalizing unprocessed entities on their way to
becoming final, stitched entities. They can also, crucially, emit other entities
while doing so. Those then form branches of the entity tree.
Some defining traits of processors:
- You instantiate them using code in your backend, and pass them to the catalog
builder. There's usually only one instance of each type, which then gets
called many times over in parallel for all entities in the catalog.
- Their invocation is driven by the fixed processing loop. All processors are
unconditionally repeatedly called for all entities. You cannot control this
behavior, besides adjusting the frequency of the loop, which then applies
equally to all processors.
- They cannot control in detail the entities that they emit, the only effective
operation is upsert on their children. If they stop emitting a certain child,
that child becomes marked as an orphan; no deletions are possible.
- Their input is an unprocessed entity, and their output is modifications to
that same entity plus possibly some auxiliary data including unprocessed child
entities.
### Processors and the Ingestion Loop
The catalog holds a number of registered locations, that were added either by
site admins or by individual Backstage users. Their purpose is to reference some
@@ -54,7 +316,7 @@ added by the organization that adopts Backstage.
We will now show the process of creating a new processor and location type,
which enables the ingestion of catalog data from an existing external API.
## Deciding on the New Locations
### Deciding on the New Locations
The first step is to decide how we want to point at the system that holds our
data. Let's assume that it is internally named System-X and can be reached
@@ -87,7 +349,7 @@ If you start up the backend now, it will start to periodically say that it could
not find a processor that supports that location. So let's make a processor that
does so!
## Creating a Catalog Data Reader Processor
### Creating a Catalog Data Reader Processor
The recommended way of instantiating the catalog backend classes is to use the
`CatalogBuilder`, as illustrated in the
@@ -130,7 +392,7 @@ export class SystemXReaderProcessor implements CatalogProcessor {
try {
// Use the builtin reader facility to grab data from the
// API. If you prefer, you can just use plain fetch here
// (from the cross-fetch package), or any other method of
// (from the node-fetch package), or any other method of
// your choosing.
const data = await this.reader.read(location.target);
const json = JSON.parse(data.toString());
@@ -159,19 +421,19 @@ You should now be able to add this class to your backend in
`packages/backend/src/plugins/catalog.ts`:
```diff
+ import { SystemXReaderProcessor } from '../path/to/class';
+import { SystemXReaderProcessor } from '../path/to/class';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = new CatalogBuilder(env);
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = CatalogBuilder.create(env);
+ builder.addProcessor(new SystemXReaderProcessor(env.reader));
```
Start up the backend - it should now start reading from the previously
registered location and you'll see your entities start to appear in Backstage.
## Caching processing results
### Caching processing results
The catalog periodically refreshes entities in the catalog, and in doing so it
calls out to external systems to fetch changes. This can be taxing for upstream
@@ -150,11 +150,12 @@ return await createRouter({
Here is a list of Open Source custom actions that you can add to your Backstage
scaffolder backend:
| Name | Package | Owner |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| Yeoman | [plugin-scaffolder-backend-module-yeoman](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend-module-yeoman) | [Backstage](https://backstage.io) |
| Cookiecutter | [plugin-scaffolder-backend-module-cookiecutter](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend-module-cookiecutter) | [Backstage](https://backstage.io) |
| Rails | [plugin-scaffolder-backend-module-rails](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend-module-rails) | [Backstage](https://backstage.io) |
| HTTP requests | [scaffolder-backend-module-http-request](https://www.npmjs.com/package/@roadiehq/scaffolder-backend-module-http-request) | [Roadie](https://roadie.io) |
| Name | Package | Owner |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| Yeoman | [plugin-scaffolder-backend-module-yeoman](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend-module-yeoman) | [Backstage](https://backstage.io) |
| Cookiecutter | [plugin-scaffolder-backend-module-cookiecutter](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend-module-cookiecutter) | [Backstage](https://backstage.io) |
| Rails | [plugin-scaffolder-backend-module-rails](https://www.npmjs.com/package/@backstage/plugin-scaffolder-backend-module-rails) | [Backstage](https://backstage.io) |
| HTTP requests | [scaffolder-backend-module-http-request](https://www.npmjs.com/package/@roadiehq/scaffolder-backend-module-http-request) | [Roadie](https://roadie.io) |
| Scaffolder .NET Actions | [plugin-scaffolder-dotnet-backend](https://www.npmjs.com/package/@plusultra/plugin-scaffolder-dotnet-backend) | [Alef Carlos](https://github.com/alefcarlos) |
Have fun! 🚀
+60
View File
@@ -314,3 +314,63 @@ builder.addProcessor(
}),
);
```
## Using a Provider instead of a Processor
An alternative to using the Processor for ingesting LDAP entries is to use a
Provider. Doing this can give you a little bit more freedom to handle the LDAP
ingestion more independently from the rest of the catalog ingestion.
This can be useful if you have a lot of Users and Groups and hitting your LDAP
server is resource intensive but you still want your other catalog entries to be
updated frequently.
> Note: When configuring to use a Provider instead of a Processor you do not
> need to add a _location_ pointing to your LDAP server
```ts
// packages/backend/src/plugins/catalog.ts
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { PluginEnvironment } from '../types';
import { Router } from 'express';
import { LdapOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-ldap';
import { Duration } from 'luxon';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const ldapEntityProvider = LdapOrgEntityProvider.fromConfig(env.config, {
id: 'custom-ldap',
// target needs to match the catalog.processors.ldapOrg.providers.target specified in app-config
target: 'ldaps://ds.example.net',
logger: env.logger,
});
const builder = await CatalogBuilder.create(env);
builder.addEntityProvider(ldapEntityProvider);
// You can change the refresh interval for the other catalog entries independently, or just leave the line below out to use the default refresh interval
builder.setRefreshIntervalSeconds(100);
const { processingEngine, router } = await builder.build();
await processingEngine.start();
await env.scheduler.scheduleTask({
id: 'refresh_ldap',
// frequency sets how often you want to ingest users and groups from LDAP, in this case every 60 minutes
frequency: Duration.fromObject({ minutes: 60 }),
timeout: Duration.fromObject({ minutes: 15 }),
fn: async () => {
try {
await ldapEntityProvider.read();
} catch (error) {
env.logger.error(error);
}
},
});
return router;
}
```
@@ -0,0 +1,9 @@
---
title: Scaffolder .NET Actions
author: Alef Carlos
authorUrl: https://github.com/alefcarlos
category: Scaffolder
description: Here you can find all .NET related actions to improve your scaffolder.
documentation: https://github.com/alefcarlos/plusultra-dotnet-backstage-plugins/blob/main/plugins/scaffolder-dotnet-backend/README.md
iconUrl: img/scaffolder-backend-dotnet-icon.png
npmPackageName: '@plusultra/plugin-scaffolder-dotnet-backend'
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+6 -6
View File
@@ -22,18 +22,18 @@ import { serveBundle } from '../../lib/bundler';
import { loadCliConfig } from '../../lib/config';
import { paths } from '../../lib/paths';
import { Lockfile } from '../../lib/versioning';
import { includedFilter } from '../versions/lint';
import { forbiddenDuplicatesFilter, includedFilter } from '../versions/lint';
export default async (cmd: Command) => {
const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
const result = lockfile.analyze({
filter: includedFilter,
});
const problemPackages = [...result.newVersions, ...result.newRanges].map(
({ name }) => name,
);
const problemPackages = [...result.newVersions, ...result.newRanges]
.map(({ name }) => name)
.filter(name => forbiddenDuplicatesFilter(name));
if (problemPackages.length > 1) {
if (problemPackages.length > 0) {
console.log(
chalk.yellow(
`⚠️ Some of the following packages may be outdated or have duplicate installations:
@@ -44,7 +44,7 @@ export default async (cmd: Command) => {
);
console.log(
chalk.yellow(
`⚠️ This can be resolved using the following command:
`⚠️ The following command may fix the issue, but it could also be an issue within one of your dependencies:
yarn backstage-cli versions:check --fix
`,
+3
View File
@@ -67,6 +67,9 @@ export async function serveBundle(options: ServeOptions) {
proxy: pkg.proxy,
// When the dev server is behind a proxy, the host and public hostname differ
allowedHosts: [url.hostname],
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
},
} as any,
);
+2 -2
View File
@@ -54,7 +54,7 @@
"rc-progress": "3.2.4",
"react-helmet": "6.1.0",
"react-hook-form": "^7.12.2",
"react-markdown": "^7.0.1",
"react-markdown": "^8.0.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
@@ -63,7 +63,7 @@
"react-use": "^17.2.4",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"remark-gfm": "^2.0.0",
"remark-gfm": "^3.0.1",
"zen-observable": "^0.8.15",
"zod": "^3.11.6"
},
@@ -49,11 +49,13 @@ const useStyles = makeStyles<BackstageTheme>(
zIndex: 100,
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
backgroundImage: theme.page.backgroundImage,
backgroundPosition: 'center',
backgroundSize: 'cover',
[theme.breakpoints.down('sm')]: {
flexWrap: 'wrap',
},
},
leftItemsBox: {
maxWidth: '100%',
@@ -73,6 +75,7 @@ const useStyles = makeStyles<BackstageTheme>(
opacity: 0.8,
display: 'inline-block', // prevents margin collapse of adjacent siblings
marginTop: theme.spacing(1),
maxWidth: '75ch',
},
type: {
textTransform: 'uppercase',
@@ -182,13 +182,9 @@ const DesktopSidebar = (props: SidebarProps) => {
className={classes.root}
data-testid="sidebar-root"
onMouseEnter={disableExpandOnHover ? () => {} : handleOpen}
onFocus={
disableExpandOnHover ? () => {} : ignoreChildEvent(handleOpen)
}
onFocus={disableExpandOnHover ? () => {} : handleOpen}
onMouseLeave={disableExpandOnHover ? () => {} : handleClose}
onBlur={
disableExpandOnHover ? () => {} : ignoreChildEvent(handleClose)
}
onBlur={disableExpandOnHover ? () => {} : handleClose}
>
<div
className={classnames(classes.drawer, {
@@ -242,13 +238,3 @@ function A11ySkipSidebar() {
</Button>
);
}
function ignoreChildEvent(handlerFn: (e?: any) => void) {
// TODO type the event
return (event: any) => {
const currentTarget = event?.currentTarget as HTMLElement;
if (!currentTarget?.contains(event.relatedTarget as HTMLElement)) {
handlerFn(event);
}
};
}
+5 -1
View File
@@ -478,7 +478,11 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
handlers: OAuthHandlers,
options: Pick<
Options,
'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer'
| 'providerId'
| 'persistScopes'
| 'disableRefresh'
| 'tokenIssuer'
| 'callbackUrl'
>,
): OAuthAdapter;
// (undocumented)
@@ -347,4 +347,85 @@ describe('OAuthAdapter', () => {
},
});
});
it('sets the correct cookie configuration using the base url', async () => {
const config = {
baseUrl: 'http://domain.org/auth',
appUrl: 'http://domain.org',
isOriginAllowed: () => false,
};
const oauthProvider = OAuthAdapter.fromConfig(
config,
providerInstance,
oAuthProviderOptions,
);
const mockRequest = {
query: {
scope: 'user',
env: 'development',
},
} as unknown as express.Request;
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({
domain: 'domain.org',
path: '/auth/test-provider/handler',
secure: false,
}),
);
});
it('sets the correct cookie configuration using a callbackUrl', async () => {
const config = {
baseUrl: 'http://domain.org/auth',
appUrl: 'http://domain.org',
isOriginAllowed: () => false,
};
const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, {
...oAuthProviderOptions,
callbackUrl: 'https://authdomain.org/auth/test-provider/handler/frame',
});
const mockRequest = {
query: {
scope: 'user',
env: 'development',
},
} as unknown as express.Request;
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({
domain: 'authdomain.org',
path: '/auth/test-provider/handler',
secure: true,
}),
);
});
});
@@ -35,7 +35,7 @@ import {
NotAllowedError,
} from '@backstage/errors';
import { TokenIssuer } from '../../identity/types';
import { readState, verifyNonce } from './helpers';
import { getCookieConfig, readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import {
OAuthHandlers,
@@ -58,25 +58,32 @@ export type Options = {
appOrigin: string;
tokenIssuer: TokenIssuer;
isOriginAllowed: (origin: string) => boolean;
callbackUrl?: string;
};
export class OAuthAdapter implements AuthProviderRouteHandlers {
static fromConfig(
config: AuthProviderConfig,
handlers: OAuthHandlers,
options: Pick<
Options,
'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer'
| 'providerId'
| 'persistScopes'
| 'disableRefresh'
| 'tokenIssuer'
| 'callbackUrl'
>,
): OAuthAdapter {
const { origin: appOrigin } = new URL(config.appUrl);
const secure = config.baseUrl.startsWith('https://');
const url = new URL(config.baseUrl);
const cookiePath = `${url.pathname}/${options.providerId}`;
const authUrl = new URL(options.callbackUrl ?? config.baseUrl);
const { cookieDomain, cookiePath, secure } = getCookieConfig(
authUrl,
options.providerId,
);
return new OAuthAdapter(handlers, {
...options,
appOrigin,
cookieDomain: url.hostname,
cookieDomain,
cookiePath,
secure,
isOriginAllowed: config.isOriginAllowed,
@@ -15,7 +15,12 @@
*/
import express from 'express';
import { verifyNonce, encodeState, readState } from './helpers';
import {
verifyNonce,
encodeState,
readState,
getCookieConfig,
} from './helpers';
describe('OAuthProvider Utils', () => {
describe('encodeState', () => {
@@ -104,4 +109,33 @@ describe('OAuthProvider Utils', () => {
}).not.toThrow();
});
});
describe('getCookieConfig', () => {
it('should set the correct domain and path for a base url', () => {
const mockAuthUrl = new URL('http://domain.org/auth');
expect(getCookieConfig(mockAuthUrl, 'test-provider')).toMatchObject({
cookieDomain: 'domain.org',
cookiePath: '/auth/test-provider',
secure: false,
});
});
it('should set the correct domain and path for a url containing a frame handler', () => {
const mockAuthUrl = new URL(
'http://domain.org/auth/test-provider/handler/frame',
);
expect(getCookieConfig(mockAuthUrl, 'test-provider')).toMatchObject({
cookieDomain: 'domain.org',
cookiePath: '/auth/test-provider',
secure: false,
});
});
it('should set the secure flag if url is using https', () => {
const mockAuthUrl = new URL('https://domain.org/auth');
expect(getCookieConfig(mockAuthUrl, 'test-provider')).toMatchObject({
secure: true,
});
});
});
});
@@ -57,3 +57,21 @@ export const verifyNonce = (req: express.Request, providerId: string) => {
throw new Error('Invalid nonce');
}
};
export const getCookieConfig = (authUrl: URL, providerId: string) => {
const { hostname: cookieDomain, pathname, protocol } = authUrl;
const secure = protocol === 'https:';
// If the provider supports callbackUrls, the pathname will
// contain the complete path to the frame handler so we need
// to slice off the trailing part of the path.
const cookiePath = pathname.endsWith(`${providerId}/handler/frame`)
? pathname.slice(0, -'/handler/frame'.length)
: `${pathname}/${providerId}`;
return {
cookieDomain,
cookiePath,
secure,
};
};
@@ -314,6 +314,7 @@ export const createGithubProvider = (
persistScopes: true,
providerId,
tokenIssuer,
callbackUrl,
});
});
};
@@ -66,7 +66,7 @@ describe('github:actions:dispatch', () => {
});
});
it('should call the githubApis for creating WorkflowDispatch', async () => {
it('should call the githubApis for creating WorkflowDispatch without an input object', async () => {
mockGithubClient.rest.actions.createWorkflowDispatch.mockResolvedValue({
data: {
foo: 'bar',
@@ -90,4 +90,31 @@ describe('github:actions:dispatch', () => {
ref: branchOrTagName,
});
});
it('should call the githubApis for creating WorkflowDispatch with an input object', async () => {
mockGithubClient.rest.actions.createWorkflowDispatch.mockResolvedValue({
data: {
foo: 'bar',
},
});
const repoUrl = 'github.com?repo=repo&owner=owner';
const workflowId = 'dispatch_workflow';
const branchOrTagName = 'main';
const workflowInputs = '{ "foo": "bar" }';
const ctx = Object.assign({}, mockContext, {
input: { repoUrl, workflowId, branchOrTagName, workflowInputs },
});
await action.handler(ctx);
expect(
mockGithubClient.rest.actions.createWorkflowDispatch,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
workflow_id: workflowId,
ref: branchOrTagName,
inputs: workflowInputs,
});
});
});
@@ -36,6 +36,7 @@ export function createGithubActionsDispatchAction(options: {
repoUrl: string;
workflowId: string;
branchOrTagName: string;
workflowInputs?: { [key: string]: string };
}>({
id: 'github:actions:dispatch',
description:
@@ -61,11 +62,18 @@ export function createGithubActionsDispatchAction(options: {
'The git branch or tag name used to dispatch the workflow',
type: 'string',
},
workflowInputs: {
title: 'Workflow Inputs',
description:
'Inputs keys and values to send to GitHub Action configured on the workflow file. The maximum number of properties is 10. ',
type: 'object',
},
},
},
},
async handler(ctx) {
const { repoUrl, workflowId, branchOrTagName } = ctx.input;
const { repoUrl, workflowId, branchOrTagName, workflowInputs } =
ctx.input;
ctx.logger.info(
`Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`,
@@ -78,6 +86,7 @@ export function createGithubActionsDispatchAction(options: {
repo,
workflow_id: workflowId,
ref: branchOrTagName,
inputs: workflowInputs,
});
ctx.logger.info(`Workflow ${workflowId} dispatched successfully`);
@@ -44,7 +44,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.12.0",
"@types/node-cron": "^2.0.4"
"@types/node-cron": "^3.0.1"
},
"files": [
"dist"
+1 -1
View File
@@ -45,7 +45,7 @@ async function main() {
p =>
(p.packageJson.name.startsWith('@backstage/') ||
p.packageJson.name.startsWith('@techdocs/')) &&
p.packageJson.private === false,
p.packageJson.private !== true,
)
.map(p => {
return { name: p.packageJson.name, version: p.packageJson.version };
+739 -1021
View File
File diff suppressed because it is too large Load Diff