Merge remote-tracking branch 'upstream/master' into responsive-component-wrapping
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-pagerduty': patch
|
||||
---
|
||||
|
||||
Update README
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
add default branch property for publish GitLab, Bitbucket and Azure actions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Added filesystem remove/rename built-in actions
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-ldap': minor
|
||||
---
|
||||
|
||||
Add extension points to the `LdapOrgReaderProcessor` to make it possible to do more advanced modifications
|
||||
of the ingested users and groups.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Show scroll bar of the sidebar wrapper only on hover
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-ilert': patch
|
||||
---
|
||||
|
||||
chore: bump `@date-io/luxon` from 1.3.13 to 2.10.11
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
Add edit button to Group Profile Card
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Pass through the `idToken` in `Authorization` Header for `listActions` request
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
removing mandatory of protection for the default branch, that could be handled by the GitHub automation in async manner, thus throwing floating errors
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Add custom styles to scroll bar of the sidebar wrapper to fix flaky behaviour
|
||||
@@ -137,6 +137,7 @@ maintainership
|
||||
makefile
|
||||
md
|
||||
memcache
|
||||
memoized
|
||||
microservice
|
||||
microservices
|
||||
microsite
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Catalog', () => {
|
||||
|
||||
cy.visit('/catalog');
|
||||
|
||||
cy.contains('Owned (8)').should('be.visible');
|
||||
cy.contains('Owned (10)').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,10 @@ describe('Integrations', () => {
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit('/catalog');
|
||||
cy.contains('All').click();
|
||||
cy.get('[data-testid="user-picker-all"]').click();
|
||||
cy.get('table').should('contain', 'github-repo');
|
||||
cy.get('table').should('contain', 'github-repo-nested');
|
||||
});
|
||||
@@ -52,8 +54,10 @@ describe('Integrations', () => {
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit('/catalog');
|
||||
cy.contains('All').click();
|
||||
cy.get('[data-testid="user-picker-all"]').click();
|
||||
cy.get('table').should('contain', 'gitlab-repo');
|
||||
cy.get('table').should('contain', 'gitlab-repo-nested');
|
||||
});
|
||||
@@ -67,8 +71,10 @@ describe('Integrations', () => {
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit('/catalog');
|
||||
cy.contains('All').click();
|
||||
cy.get('[data-testid="user-picker-all"]').click();
|
||||
cy.get('table').should('contain', 'bitbucket-repo');
|
||||
cy.get('table').should('contain', 'bitbucket-repo-nested');
|
||||
});
|
||||
|
||||
@@ -122,8 +122,8 @@ The DN under which users are stored, e.g.
|
||||
#### users.options
|
||||
|
||||
The search options to use when sending the query to the server, when reading all
|
||||
users. All of the options are shown below, with their default values, but they
|
||||
are all optional.
|
||||
users. All the options are shown below, with their default values, but they are
|
||||
all optional.
|
||||
|
||||
```yaml
|
||||
options:
|
||||
@@ -158,8 +158,8 @@ set:
|
||||
|
||||
Mappings from well known entity fields, to LDAP attribute names. This is where
|
||||
you are able to define how to interpret the attributes of each LDAP result item,
|
||||
and to move them into the corresponding entity fields. All of the options are
|
||||
shown below, with their default values, but they are all optional.
|
||||
and to move them into the corresponding entity fields. All the options are shown
|
||||
below, with their default values, but they are all optional.
|
||||
|
||||
If you leave out an optional mapping, it will still be copied using that default
|
||||
value. For example, even if you do not put in the field `displayName` in your
|
||||
@@ -204,8 +204,8 @@ The DN under which groups are stored, e.g.
|
||||
#### groups.options
|
||||
|
||||
The search options to use when sending the query to the server, when reading all
|
||||
groups. All of the options are shown below, with their default values, but they
|
||||
are all optional.
|
||||
groups. All the options are shown below, with their default values, but they are
|
||||
all optional.
|
||||
|
||||
```yaml
|
||||
options:
|
||||
@@ -282,3 +282,35 @@ map:
|
||||
# the spec.children field of the entity.
|
||||
members: member
|
||||
```
|
||||
|
||||
## Customize the Processor
|
||||
|
||||
In case you want to customize the ingested entities, the
|
||||
`LdapOrgReaderProcessor` allows to pass transformers for users and groups.
|
||||
|
||||
1. Create a transformer:
|
||||
|
||||
```ts
|
||||
export async function myGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
// Transformations may change namespace, change entity naming pattern, fill
|
||||
// profile with more or other details...
|
||||
|
||||
// Create the group entity on your own, or wrap the default transformer
|
||||
return await defaultGroupTransformer(vendor, config, group);
|
||||
}
|
||||
```
|
||||
|
||||
2. Configure the processor with the transformer:
|
||||
|
||||
```ts
|
||||
builder.addProcessor(
|
||||
LdapOrgReaderProcessor.fromConfig(config, {
|
||||
logger,
|
||||
groupTransformer: myGroupTransformer,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
id: url-reader
|
||||
title: URL Reader
|
||||
sidebar_label: URL Reader
|
||||
# prettier-ignore
|
||||
description: URL Reader is a backend core API responsible for reading files from external locations.
|
||||
---
|
||||
|
||||
## Concept
|
||||
|
||||
Some of the core plugins of Backstage have to read files from an external
|
||||
location. [Software Catalog](../features/software-catalog/index.md) has to read
|
||||
the [`catalog-info.yaml`](../features/software-catalog/descriptor-format.md)
|
||||
entity descriptor files to register and track an entity.
|
||||
[Software Templates](../features/software-templates/index.md) have to download
|
||||
the template skeleton files before creating a new component.
|
||||
[TechDocs](../features/techdocs/README.md) has to download the markdown source
|
||||
files before generating a documentation site.
|
||||
|
||||
Since, the requirement for reading files is so essential for Backstage plugins,
|
||||
the
|
||||
[`@backstage/backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common)
|
||||
package provides a dedicated API for reading from such URL based remote
|
||||
locations like GitHub, GitLab, Bitbucket, Google Cloud Storage, etc. This is
|
||||
commonly referred to as "URL Reader". It takes care of making authenticated
|
||||
requests to the remote host so that private files can be read securely. If users
|
||||
have [GitHub App based authentication](github-apps.md) set up, URL Reader even
|
||||
refreshes the token, to avoid reaching the GitHub API rate limit.
|
||||
|
||||
As a result, plugin authors do not have to worry about any of these problems
|
||||
when trying to read files.
|
||||
|
||||
## Interface
|
||||
|
||||
When the Backstage backend starts, a new instance of URL Reader is created. You
|
||||
can see this in the index file of your Backstage backend
|
||||
i.e.`packages/backend/src/index.ts`.
|
||||
[Example](https://github.com/backstage/backstage/blob/ebbe91dbe79038a61d35cf6ed2d96e0e0d5a15f3/packages/backend/src/index.ts#L57)
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/index.ts
|
||||
|
||||
import { URLReaders } from '@backstage/backend-common';
|
||||
|
||||
function makeCreateEnv(config: Config) {
|
||||
// ....
|
||||
const reader = UrlReaders.default({ logger, config });
|
||||
//
|
||||
}
|
||||
```
|
||||
|
||||
This instance contains all
|
||||
[the default URL Reader providers](https://github.com/backstage/backstage/blob/master/packages/backend-common/src/reading/UrlReaders.ts)
|
||||
in the backend-common package including GitHub, GitLab, Bitbucket, Azure, Google
|
||||
GCS. As the need arises, more URL Readers are being written to support different
|
||||
providers.
|
||||
|
||||
The generic interface of a URL Reader instance looks like this.
|
||||
|
||||
```ts
|
||||
export type UrlReader = {
|
||||
/* Used to read a single file and return its content. */
|
||||
read(url: string): Promise<Buffer>;
|
||||
/**
|
||||
* A replacement for the read method that supports options and complex responses.
|
||||
*
|
||||
* Use this whenever it is available, as the read method will be deprecated and
|
||||
* eventually removed in the future.
|
||||
*/
|
||||
readUrl?(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
|
||||
/* Used to read a file tree and download as a directory. */
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
/* Used to search a file in a tree. */
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
```
|
||||
|
||||
## Using a URL Reader inside a plugin
|
||||
|
||||
The `reader` instance is available in the backend plugin environment and passed
|
||||
on to all the backend plugins. You can see an
|
||||
[example](https://github.com/backstage/backstage/blob/b0be185369ebaad22255b7cdf18535d1d4ffd0e7/packages/backend/src/plugins/techdocs.ts#L31).
|
||||
When any of the methods on this instance is called with a URL, URL Reader
|
||||
extracts the host for that URL (e.g. `github.com`, `ghe.mycompany.com`, etc.).
|
||||
Using the
|
||||
[`@backstage/integration`](https://github.com/backstage/backstage/tree/master/packages/integration)
|
||||
package, it looks inside the
|
||||
[`integrations:`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/app-config.yaml#L134-L158)
|
||||
config of the `app-config.yaml` to find out how to work with the host based on
|
||||
the configs provided like authentication token, API base URL, etc.
|
||||
|
||||
Make sure your plugin-specific backend file at
|
||||
`packages/backend/src/plugins/<PLUGIN>.ts` is forwarding the `reader` instance
|
||||
passed on as the `PluginEnvironment` to the actual plugin's `createRouter`
|
||||
function. See how this is done in
|
||||
[Catalog](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend/src/plugins/catalog.ts#L25-L27)
|
||||
and
|
||||
[TechDocs](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend/src/plugins/techdocs.ts#L31-L36)
|
||||
backend plugins.
|
||||
|
||||
Once the reader instance is available inside the plugin, one of its methods can
|
||||
directly be used with a URL. Some example usages -
|
||||
|
||||
- [`read`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts#L24-L33) -
|
||||
Catalog using the `read` method to read the CODEOWNERS file in a repository.
|
||||
- [`readTree`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/techdocs-common/src/helpers.ts#L198-L220) -
|
||||
TechDocs using the `readTree` method to download markdown files in order to
|
||||
generate the documentation site.
|
||||
- [`readTree`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/techdocs-common/src/stages/prepare/url.ts#L33-L54) -
|
||||
TechDocs using `NotModifiedError` to maintain cache and speed up and limit the
|
||||
number of requests.
|
||||
- [`search`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts#L88-L108) -
|
||||
Catalog using the `search` method to find files for a location URL containing
|
||||
a glob pattern.
|
||||
|
||||
## Writing a new URL Reader
|
||||
|
||||
If the available URL Readers are not sufficient for your use case and you want
|
||||
to add a new URL Reader for any other provider, you are most welcome to
|
||||
contribute one!
|
||||
|
||||
Feel free to use the
|
||||
[GitHub URL Reader](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/GithubUrlReader.ts)
|
||||
as a source of inspiration.
|
||||
|
||||
### 1. Add an integration
|
||||
|
||||
The provider for your new URL Reader can also be called an "integration" in
|
||||
Backstage. The `integrations:` section of your Backstage `app-config.yaml`
|
||||
config file is supposed to be the place where a Backstage integrator defines the
|
||||
host URL for the integration, authentication details and other integration
|
||||
related configurations.
|
||||
|
||||
The `@backstage/integration` package is where most of the integration specific
|
||||
code lives, so that it is shareable across Backstage. Functions like "read the
|
||||
integrations config and process it", "construct headers for authenticated
|
||||
requests to the host" or "convert a plain file URL into its API URL for
|
||||
downloading the file" would live in this package.
|
||||
|
||||
### 2. Create the URL Reader
|
||||
|
||||
Create a new class which implements the
|
||||
[`UrlReader` type](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/types.ts#L21-L28)
|
||||
inside `@backstage/backend-common`. Create and export a static `factory` method
|
||||
which reads the integration config and returns a map of host URLs the new reader
|
||||
should be used for. See the
|
||||
[GitHub URL Reader](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/GithubUrlReader.ts#L50-L63)
|
||||
for example.
|
||||
|
||||
### 3. Implement the methods
|
||||
|
||||
We want to make sure all URL Readers behave in the same way. Hence if possible,
|
||||
all the methods of the `UrlReader` interface should be implemented. However it
|
||||
is okay to start by implementing just one of them and create issues for the
|
||||
remaining.
|
||||
|
||||
#### read
|
||||
|
||||
NOTE: Use `readUrl` instead of `read`.
|
||||
|
||||
`read` method expects a user-friendly URL, something which can be copied from
|
||||
the browser naturally when a person is browsing the provider in their browser.
|
||||
|
||||
- ✅ Valid URL :
|
||||
`https://github.com/backstage/backstage/blob/master/ADOPTERS.md`
|
||||
- ❌ Not a valid URL :
|
||||
`https://raw.githubusercontent.com/backstage/backstage/master/ADOPTERS.md`
|
||||
- ❌ Not a valid URL : `https://github.com/backstage/backstage/ADOPTERS.md`
|
||||
|
||||
Upon receiving the URL, `read` converts the user-friendly URL into an API URL
|
||||
which can be used to request the provider's API.
|
||||
|
||||
`read` then makes an authenticated request to the provider API and returns the
|
||||
file's content.
|
||||
|
||||
#### readUrl
|
||||
|
||||
`readUrl` is a new interface that allows complex response objects and is
|
||||
intended to replace the `read` method. This new method is currently optional to
|
||||
implement which allows for a soft migration to `readUrl` instead of `read` in
|
||||
the future.
|
||||
|
||||
#### readTree
|
||||
|
||||
`readTree` method also expects user-friendly URLs similar to `read` but the URL
|
||||
should point to a tree (could be the root of a repository or even a
|
||||
sub-directory).
|
||||
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/docs`
|
||||
|
||||
Using the provider's API documentation, find out an API endpoint which can be
|
||||
used to download either a zip or a tarball. You can download the entire tree
|
||||
(e.g. a repository) and filter out in case the user is expecting only a
|
||||
sub-tree. But some APIs are smart enough to accept a path and return only a
|
||||
sub-tree in the downloaded archive.
|
||||
|
||||
#### search
|
||||
|
||||
`search` method expects a glob pattern of a URL and returns a list of files
|
||||
matching the query.
|
||||
|
||||
- ✅ Valid URL :
|
||||
`https://github.com/backstage/backstage/blob/master/**/catalog-info.yaml`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/**/*.md`
|
||||
- ✅ Valid URL :
|
||||
`https://github.com/backstage/backstage/blob/master/*/package.json`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/READM`
|
||||
|
||||
The core logic of `readTree` can be used here to extract all the files inside
|
||||
the tree and return the files matching the pattern in the `url`.
|
||||
|
||||
### 4. Add to available URL Readers
|
||||
|
||||
There are two ways to make your new URL Reader available for use.
|
||||
|
||||
You can choose to make it open source, by updating the
|
||||
[`default` factory](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/UrlReaders.ts#L62-L81)
|
||||
method of URL Readers.
|
||||
|
||||
But for something internal which you don't want to make open source, you can
|
||||
update your `packages/backend/src/index.ts` file and update how the `reader`
|
||||
instance is created.
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/index.ts
|
||||
const reader = UrlReaders.default({
|
||||
logger: root,
|
||||
config,
|
||||
// This is where your internal URL Readers would go.
|
||||
factories: [myCustomReader.factory],
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Caching
|
||||
|
||||
All of the methods above support an ETag based caching. If the method is called
|
||||
without an `etag`, the response contains an ETag of the resource (should ideally
|
||||
forward the ETag returned by the provider). If the method is called with an
|
||||
`etag`, it first compares the ETag and returns a `NotModifiedError` in case the
|
||||
resource has not been modified. This approach is very similar to the actual
|
||||
[ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) and
|
||||
[If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
|
||||
HTTP headers.
|
||||
|
||||
### 6. Debugging
|
||||
|
||||
When debugging one of the URL Readers, you can straightforward use the
|
||||
[`reader` instance created](https://github.com/backstage/backstage/blob/ebbe91dbe79038a61d35cf6ed2d96e0e0d5a15f3/packages/backend/src/index.ts#L57)
|
||||
when the backend starts and call one of the methods with your debugging URL.
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/index.ts
|
||||
|
||||
async function main() {
|
||||
// ...
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const testReader = createEnv('test-url-reader').reader;
|
||||
const response = await testReader.readUrl(
|
||||
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
);
|
||||
console.log((await response.buffer()).toString());
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This will be run every time you restart the backend. Note that after any change
|
||||
in the URL Reader code, you need to kill the backend and restart, since the
|
||||
`reader` instance is memoized and does not update on hot module reloading. Also,
|
||||
there are a lot of unit tests written for the URL Readers, which you can make
|
||||
use of.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Harbor
|
||||
author: BESTSELLER
|
||||
authorUrl: bestsellerit.com
|
||||
category: Discovery
|
||||
description: This plugin will show you information about docker images within harbor.
|
||||
documentation: https://github.com/BESTSELLER/backstage-plugin-harbor/blob/master/README.md
|
||||
iconUrl: https://bestsellerit.com/img/terraform-harbor/goharbor.jpeg
|
||||
npmPackageName: '@bestsellerit/backstage-plugin-harbor'
|
||||
tags:
|
||||
- goharbor
|
||||
- harbor
|
||||
- docker
|
||||
@@ -166,7 +166,8 @@
|
||||
"plugins/proxying",
|
||||
"plugins/backend-plugin",
|
||||
"plugins/call-existing-api",
|
||||
"plugins/github-apps"
|
||||
"plugins/github-apps",
|
||||
"plugins/url-reader"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -112,6 +112,7 @@ nav:
|
||||
- Backend plugin: 'plugins/backend-plugin.md'
|
||||
- Call existing API: 'plugins/call-existing-api.md'
|
||||
- GitHub Apps for Backend Authentication: 'plugins/github-apps.md'
|
||||
- URL Reader: 'plugins/url-reader.md'
|
||||
- Testing:
|
||||
- Testing with Jest: 'plugins/testing.md'
|
||||
- Publishing:
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Config } from '@backstage/config';
|
||||
* A generic interface for fetching plain data from URLs.
|
||||
*/
|
||||
export type UrlReader = {
|
||||
/* Used to read a single file and return its content. */
|
||||
read(url: string): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
@@ -32,8 +33,9 @@ export type UrlReader = {
|
||||
*/
|
||||
readUrl?(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
|
||||
/* Used to read a file tree and download as a directory. */
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
|
||||
/* Used to search a file in a tree using a glob pattern. */
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
},
|
||||
header: {
|
||||
display: 'inline-block',
|
||||
padding: theme.spacing(2, 2, 2, 2.5),
|
||||
},
|
||||
headerTitle: {
|
||||
@@ -121,6 +120,7 @@ type Props = {
|
||||
children?: ReactNode;
|
||||
headerStyle?: object;
|
||||
headerProps?: CardHeaderProps;
|
||||
action?: ReactNode;
|
||||
actionsClassName?: string;
|
||||
actions?: ReactNode;
|
||||
cardClassName?: string;
|
||||
@@ -141,6 +141,7 @@ export const InfoCard = ({
|
||||
children,
|
||||
headerStyle,
|
||||
headerProps,
|
||||
action,
|
||||
actionsClassName,
|
||||
actions,
|
||||
cardClassName,
|
||||
@@ -190,6 +191,7 @@ export const InfoCard = ({
|
||||
}}
|
||||
title={title}
|
||||
subheader={subheader}
|
||||
action={action}
|
||||
style={{ ...headerStyle }}
|
||||
titleTypographyProps={titleTypographyProps}
|
||||
{...headerProps}
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
makeStyles,
|
||||
styled,
|
||||
TextField,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
@@ -291,10 +293,31 @@ export const SidebarDivider = styled('hr')({
|
||||
margin: '12px 0px',
|
||||
});
|
||||
|
||||
export const SidebarScrollWrapper = styled('div')({
|
||||
flex: '0 1 auto',
|
||||
const styledScrollbar = (theme: Theme): CreateCSSProperties => ({
|
||||
overflowY: 'auto',
|
||||
// Display at least one item in the container
|
||||
// Question: Can this be a config/theme variable - if so, which? :/
|
||||
minHeight: '48px',
|
||||
'&::-webkit-scrollbar': {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
width: '5px',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.text.hint,
|
||||
borderRadius: '5px',
|
||||
},
|
||||
});
|
||||
|
||||
export const SidebarScrollWrapper = styled('div')(({ theme }) => {
|
||||
const scrollbarStyles = styledScrollbar(theme);
|
||||
return {
|
||||
flex: '0 1 auto',
|
||||
overflowX: 'hidden',
|
||||
// 5px space to the right of the scrollbar
|
||||
width: 'calc(100% - 5px)',
|
||||
// Display at least one item in the container
|
||||
// Question: Can this be a config/theme variable - if so, which? :/
|
||||
minHeight: '48px',
|
||||
overflowY: 'hidden',
|
||||
'@media (hover: none)': scrollbarStyles,
|
||||
'&:hover': scrollbarStyles,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,6 +16,15 @@ import { SearchEntry } from 'ldapjs';
|
||||
import { SearchOptions } from 'ldapjs';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
// @public (undocumented)
|
||||
export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise<GroupEntity | undefined>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise<UserEntity | undefined>;
|
||||
|
||||
// @public
|
||||
export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise<GroupEntity | undefined>;
|
||||
|
||||
// @public
|
||||
export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn";
|
||||
|
||||
@@ -40,14 +49,18 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config, options: {
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}): LdapOrgReaderProcessor;
|
||||
// (undocumented)
|
||||
readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
// @public
|
||||
export type LdapProviderConfig = {
|
||||
@@ -57,15 +70,25 @@ export type LdapProviderConfig = {
|
||||
groups: GroupConfig;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void;
|
||||
|
||||
// @public
|
||||
export function readLdapConfig(config: Config): LdapProviderConfig[];
|
||||
|
||||
// @public
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
}): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise<UserEntity | undefined>;
|
||||
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
export { LdapClient } from './client';
|
||||
export { mapStringAttr } from './util';
|
||||
export { readLdapConfig } from './config';
|
||||
export type { LdapProviderConfig } from './config';
|
||||
export {
|
||||
@@ -22,4 +23,9 @@ export {
|
||||
LDAP_RDN_ANNOTATION,
|
||||
LDAP_UUID_ANNOTATION,
|
||||
} from './constants';
|
||||
export { readLdapOrg } from './read';
|
||||
export {
|
||||
defaultGroupTransformer,
|
||||
defaultUserTransformer,
|
||||
readLdapOrg,
|
||||
} from './read';
|
||||
export type { GroupTransformer, UserTransformer } from './types';
|
||||
|
||||
@@ -26,74 +26,97 @@ import {
|
||||
LDAP_UUID_ANNOTATION,
|
||||
} from './constants';
|
||||
import { LdapVendor } from './vendors';
|
||||
import { Logger } from 'winston';
|
||||
import { GroupTransformer, UserTransformer } from './types';
|
||||
import { mapStringAttr } from './util';
|
||||
|
||||
export async function defaultUserTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: UserConfig,
|
||||
entry: SearchEntry,
|
||||
): Promise<UserEntity | undefined> {
|
||||
const { set, map } = config;
|
||||
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
profile: {},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads users out of an LDAP provider.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param config The user data configuration
|
||||
* @param opts
|
||||
*/
|
||||
export async function readLdapUsers(
|
||||
client: LdapClient,
|
||||
config: UserConfig,
|
||||
opts?: { transformer?: UserTransformer },
|
||||
): Promise<{
|
||||
users: UserEntity[]; // With all relations empty
|
||||
userMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
|
||||
}> {
|
||||
const { dn, options, set, map } = config;
|
||||
const { dn, options, map } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
const entities: UserEntity[] = [];
|
||||
const userMemberOf: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
profile: {},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
const transformer = opts?.transformer ?? defaultUserTransformer;
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
for (const user of entries) {
|
||||
const entity = await transformer(vendor, config, user);
|
||||
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(userMemberOf, myDn, vs);
|
||||
});
|
||||
|
||||
@@ -103,82 +126,103 @@ export async function readLdapUsers(
|
||||
return { users: entities, userMemberOf };
|
||||
}
|
||||
|
||||
export async function defaultGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
entry: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
const { set, map } = config;
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
profile: {},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.type, v => {
|
||||
entity.spec.type = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads groups out of an LDAP provider.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param config The group data configuration
|
||||
* @param opts
|
||||
*/
|
||||
export async function readLdapGroups(
|
||||
client: LdapClient,
|
||||
config: GroupConfig,
|
||||
opts?: {
|
||||
transformer?: GroupTransformer;
|
||||
},
|
||||
): Promise<{
|
||||
groups: GroupEntity[]; // With all relations empty
|
||||
groupMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
|
||||
groupMember: Map<string, Set<string>>; // DN -> DN or UUID of groups & users
|
||||
}> {
|
||||
const { dn, options, set, map } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
const groups: GroupEntity[] = [];
|
||||
const groupMemberOf: Map<string, Set<string>> = new Map();
|
||||
const groupMember: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
profile: {},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
const { dn, map, options } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
const transformer = opts?.transformer ?? defaultGroupTransformer;
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
for (const group of entries) {
|
||||
const entity = await transformer(vendor, config, group);
|
||||
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.type, v => {
|
||||
entity.spec.type = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(groupMemberOf, myDn, vs);
|
||||
});
|
||||
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
|
||||
mapReferencesAttr(group, vendor, map.members, (myDn, vs) => {
|
||||
ensureItems(groupMember, myDn, vs);
|
||||
});
|
||||
|
||||
@@ -199,22 +243,30 @@ export async function readLdapGroups(
|
||||
* with all relations etc filled in.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param logger A logger instance
|
||||
* @param userConfig The user data configuration
|
||||
* @param groupConfig The group data configuration
|
||||
* @param options
|
||||
*/
|
||||
export async function readLdapOrg(
|
||||
client: LdapClient,
|
||||
userConfig: UserConfig,
|
||||
groupConfig: GroupConfig,
|
||||
options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
},
|
||||
): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
}> {
|
||||
const { users, userMemberOf } = await readLdapUsers(client, userConfig);
|
||||
const { users, userMemberOf } = await readLdapUsers(client, userConfig, {
|
||||
transformer: options?.userTransformer,
|
||||
});
|
||||
const { groups, groupMemberOf, groupMember } = await readLdapGroups(
|
||||
client,
|
||||
groupConfig,
|
||||
{ transformer: options?.groupTransformer },
|
||||
);
|
||||
|
||||
resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember);
|
||||
@@ -228,21 +280,6 @@ export async function readLdapOrg(
|
||||
// Helpers
|
||||
//
|
||||
|
||||
// Maps a single-valued attribute to a consumer
|
||||
function mapStringAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
setter: (value: string) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
const values = vendor.decodeStringAttribute(entry, attributeName);
|
||||
if (values && values.length === 1) {
|
||||
setter(values[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a multi-valued attribute of references to other objects, to a consumer
|
||||
function mapReferencesAttr(
|
||||
entry: SearchEntry,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
|
||||
import { SearchEntry } from 'ldapjs';
|
||||
import { LdapVendor } from './vendors';
|
||||
import { GroupConfig, UserConfig } from './config';
|
||||
|
||||
/**
|
||||
* Customize the ingested User entity
|
||||
*
|
||||
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
|
||||
* @param config The User specific config used by the default transformer.
|
||||
* @param user The found LDAP entry in its source format. This is the entry that you want to transform
|
||||
* @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog
|
||||
*/
|
||||
export type UserTransformer = (
|
||||
vendor: LdapVendor,
|
||||
config: UserConfig,
|
||||
user: SearchEntry,
|
||||
) => Promise<UserEntity | undefined>;
|
||||
|
||||
/**
|
||||
* Customize the ingested Group entity
|
||||
*
|
||||
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
|
||||
* @param config The Group specific config used by the default transformer.
|
||||
* @param group The found LDAP entry in its source format. This is the entry that you want to transform
|
||||
* @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog
|
||||
*/
|
||||
export type GroupTransformer = (
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
) => Promise<GroupEntity | undefined>;
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Error as LDAPError } from 'ldapjs';
|
||||
import { Error as LDAPError, SearchEntry } from 'ldapjs';
|
||||
import { LdapVendor } from './vendors';
|
||||
|
||||
/**
|
||||
* Builds a string form of an LDAP Error structure.
|
||||
@@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs';
|
||||
export function errorString(error: LDAPError) {
|
||||
return `${error.code} ${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single-valued attribute to a consumer
|
||||
*
|
||||
* @param entry The LDAP source entry
|
||||
* @param vendor The LDAP vendor
|
||||
* @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored.
|
||||
* @param setter The function to be called with the decoded attribute from the source entry
|
||||
*/
|
||||
export function mapStringAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
setter: (value: string) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
const values = vendor.decodeStringAttribute(entry, attributeName);
|
||||
if (values && values.length === 1) {
|
||||
setter(values[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
GroupTransformer,
|
||||
LdapClient,
|
||||
LdapProviderConfig,
|
||||
readLdapConfig,
|
||||
readLdapOrg,
|
||||
UserTransformer,
|
||||
} from '../ldap';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
@@ -35,8 +37,17 @@ import {
|
||||
export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
private readonly providers: LdapProviderConfig[];
|
||||
private readonly logger: Logger;
|
||||
private readonly groupTransformer?: GroupTransformer;
|
||||
private readonly userTransformer?: UserTransformer;
|
||||
|
||||
static fromConfig(config: Config, options: { logger: Logger }) {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
},
|
||||
) {
|
||||
const c = config.getOptionalConfig('catalog.processors.ldapOrg');
|
||||
return new LdapOrgReaderProcessor({
|
||||
...options,
|
||||
@@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}) {
|
||||
this.providers = options.providers;
|
||||
this.logger = options.logger;
|
||||
this.groupTransformer = options.groupTransformer;
|
||||
this.userTransformer = options.userTransformer;
|
||||
}
|
||||
|
||||
async readLocation(
|
||||
@@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
client,
|
||||
provider.users,
|
||||
provider.groups,
|
||||
{
|
||||
groupTransformer: this.groupTransformer,
|
||||
userTransformer: this.userTransformer,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
|
||||
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/plugin-catalog-react": "^0.2.4",
|
||||
"@backstage/theme": "^0.2.8",
|
||||
"@date-io/luxon": "1.x",
|
||||
"@date-io/luxon": "2.x",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
|
||||
@@ -23,23 +23,30 @@ import {
|
||||
EntityRefLinks,
|
||||
getEntityRelations,
|
||||
useEntity,
|
||||
getEntityMetadataEditUrl,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import AccountTreeIcon from '@material-ui/icons/AccountTree';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import React from 'react';
|
||||
import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components';
|
||||
import {
|
||||
Avatar,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
Link,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const CardTitle = ({ title }: { title: string }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
@@ -72,14 +79,31 @@ export const GroupProfileCard = ({
|
||||
kind: 'group',
|
||||
});
|
||||
|
||||
const entityMetadataEditUrl = getEntityMetadataEditUrl(group);
|
||||
|
||||
const displayName = profile?.displayName ?? name;
|
||||
const emailHref = profile?.email ? `mailto:${profile.email}` : undefined;
|
||||
const emailHref = profile?.email ? `mailto:${profile.email}` : '#';
|
||||
const infoCardAction = entityMetadataEditUrl ? (
|
||||
<IconButton
|
||||
aria-label="Edit"
|
||||
title="Edit Metadata"
|
||||
component={Link}
|
||||
to={entityMetadataEditUrl}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton aria-label="Edit" disabled title="Edit Metadata">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={<CardTitle title={displayName} />}
|
||||
subheader={description}
|
||||
variant={variant}
|
||||
action={infoCardAction}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={2} xl={1}>
|
||||
@@ -95,7 +119,7 @@ export const GroupProfileCard = ({
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<Link href={emailHref}>{profile.email}</Link>
|
||||
<Link to={emailHref}>{profile.email}</Link>
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,21 @@ There is also an easy way to trigger an alarm directly to the person who is curr
|
||||
|
||||
This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document.
|
||||
|
||||
This plugin provides:
|
||||
## Features
|
||||
|
||||
- A list of incidents
|
||||
- A way to trigger an alarm to the person on-call
|
||||
- Information details about the person on-call
|
||||
### View any open incidents
|
||||
|
||||

|
||||
|
||||
### Email link, and view contact information for staff on call
|
||||
|
||||

|
||||
|
||||
### Trigger an incident for a service
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Setup instructions
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -143,6 +143,12 @@ export function createFetchPlainAction(options: {
|
||||
integrations: ScmIntegrations;
|
||||
}): TemplateAction<any>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const createFilesystemDeleteAction: () => TemplateAction<any>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const createFilesystemRenameAction: () => TemplateAction<any>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createLegacyActions(options: Options): TemplateAction<any>[];
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
} from './catalog';
|
||||
import { createDebugLogAction } from './debug';
|
||||
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
|
||||
import {
|
||||
createFilesystemDeleteAction,
|
||||
createFilesystemRenameAction,
|
||||
} from './filesystem';
|
||||
import {
|
||||
createPublishAzureAction,
|
||||
createPublishBitbucketAction,
|
||||
@@ -68,5 +72,7 @@ export const createBuiltinActions = (options: {
|
||||
createDebugLogAction(),
|
||||
createCatalogRegisterAction({ catalogClient, integrations }),
|
||||
createCatalogWriteAction(),
|
||||
createFilesystemDeleteAction(),
|
||||
createFilesystemRenameAction(),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { createFilesystemDeleteAction } from './delete';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { PassThrough } from 'stream';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
|
||||
const workspacePath = resolvePath(root, 'my-workspace');
|
||||
|
||||
describe('fs:delete', () => {
|
||||
const action = createFilesystemDeleteAction();
|
||||
|
||||
const mockContext = {
|
||||
input: {
|
||||
files: ['unit-test-a.js', 'unit-test-b.js'],
|
||||
},
|
||||
workspacePath,
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
mockFs({
|
||||
[workspacePath]: {
|
||||
'unit-test-a.js': 'hello',
|
||||
'unit-test-b.js': 'world',
|
||||
'a-folder': {
|
||||
'unit-test-in-a-folder.js2': 'content',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should throw an error when files is not an array', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: undefined },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: {} },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: '' },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: null },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
});
|
||||
|
||||
it('should throw when file name is not relative to the workspace', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: ['/foo/../../../index.js'] },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Relative path is not allowed to refer to a directory outside its parent/,
|
||||
);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: ['../../../index.js'] },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Relative path is not allowed to refer to a directory outside its parent/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call fs.rm with the correct values', async () => {
|
||||
const files = ['unit-test-a.js', 'unit-test-b.js'];
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
export const createFilesystemDeleteAction = () => {
|
||||
return createTemplateAction<{ files: string[] }>({
|
||||
id: 'fs:delete',
|
||||
description: 'Deletes files and directories from the workspace',
|
||||
schema: {
|
||||
input: {
|
||||
required: ['files'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
files: {
|
||||
title: 'Files',
|
||||
description: 'A list of files and directories that will be deleted',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
if (!Array.isArray(ctx.input?.files)) {
|
||||
throw new InputError('files must be an Array');
|
||||
}
|
||||
|
||||
for (const file of ctx.input.files) {
|
||||
const filepath = resolveSafeChildPath(ctx.workspacePath, file);
|
||||
|
||||
try {
|
||||
await fs.remove(filepath);
|
||||
ctx.logger.info(`File ${filepath} deleted successfully`);
|
||||
} catch (err) {
|
||||
ctx.logger.error(`Failed to delete file ${filepath}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createFilesystemDeleteAction } from './delete';
|
||||
export { createFilesystemRenameAction } from './rename';
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { createFilesystemRenameAction } from './rename';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { PassThrough } from 'stream';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
|
||||
const workspacePath = resolvePath(root, 'my-workspace');
|
||||
|
||||
describe('fs:rename', () => {
|
||||
const action = createFilesystemRenameAction();
|
||||
|
||||
const mockInputFiles = [
|
||||
{
|
||||
from: 'unit-test-a.js',
|
||||
to: 'new-a.js',
|
||||
},
|
||||
{
|
||||
from: 'unit-test-b.js',
|
||||
to: 'new-b.js',
|
||||
},
|
||||
{
|
||||
from: 'a-folder',
|
||||
to: 'brand-new-folder',
|
||||
},
|
||||
];
|
||||
const mockContext = {
|
||||
input: {
|
||||
files: mockInputFiles,
|
||||
},
|
||||
workspacePath,
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
mockFs({
|
||||
[workspacePath]: {
|
||||
'unit-test-a.js': 'hello',
|
||||
'unit-test-b.js': 'world',
|
||||
'unit-test-c.js': 'i will be overwritten :-(',
|
||||
'a-folder': {
|
||||
'file.md': 'content',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should throw an error when files is not an array', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: undefined },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: {} },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: '' },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: null },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
});
|
||||
|
||||
it('should throw an error when files have missing from/to', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: ['old.md'] },
|
||||
}),
|
||||
).rejects.toThrow(/each file must have a from and to property/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ from: 'old.md' }] },
|
||||
}),
|
||||
).rejects.toThrow(/each file must have a from and to property/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ to: 'new.md' }] },
|
||||
}),
|
||||
).rejects.toThrow(/each file must have a from and to property/);
|
||||
});
|
||||
|
||||
it('should throw when file name is not relative to the workspace', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ from: 'index.js', to: '/core/../../../index.js' }] },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Relative path is not allowed to refer to a directory outside its parent/,
|
||||
);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ from: '/core/../../../index.js', to: 'index.js' }] },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Relative path is not allowed to refer to a directory outside its parent/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw is trying to override by mistake', async () => {
|
||||
const destFile = 'unit-test-c.js';
|
||||
const filePath = resolvePath(workspacePath, destFile);
|
||||
const beforeContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
files: [
|
||||
{
|
||||
from: 'unit-test-a.js',
|
||||
to: 'unit-test-c.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/dest already exists/);
|
||||
|
||||
const afterContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
expect(beforeContent).toEqual(afterContent);
|
||||
});
|
||||
|
||||
it('should call fs.move with the correct values', async () => {
|
||||
mockInputFiles.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file.from);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
mockInputFiles.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file.from);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should override when requested', async () => {
|
||||
const sourceFile = 'unit-test-a.js';
|
||||
const destFile = 'unit-test-c.js';
|
||||
const sourceFilePath = resolvePath(workspacePath, sourceFile);
|
||||
const destFilePath = resolvePath(workspacePath, destFile);
|
||||
|
||||
const sourceBeforeContent = fs.readFileSync(sourceFilePath, 'utf-8');
|
||||
const destBeforeContent = fs.readFileSync(destFilePath, 'utf-8');
|
||||
|
||||
expect(sourceBeforeContent).not.toEqual(destBeforeContent);
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
files: [
|
||||
{
|
||||
from: sourceFile,
|
||||
to: destFile,
|
||||
overwrite: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const destAfterContent = fs.readFileSync(destFilePath, 'utf-8');
|
||||
|
||||
expect(sourceBeforeContent).toEqual(destAfterContent);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
interface FilesToRename extends JsonObject {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const createFilesystemRenameAction = () => {
|
||||
return createTemplateAction<{ files: FilesToRename }>({
|
||||
id: 'fs:rename',
|
||||
description: 'Renames files and directories within the workspace',
|
||||
schema: {
|
||||
input: {
|
||||
required: ['files'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
files: {
|
||||
title: 'Files',
|
||||
description:
|
||||
'A list of file and directory names that will be renamed',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['from', 'to'],
|
||||
properties: {
|
||||
from: {
|
||||
type: 'string',
|
||||
title: 'The source location of the file to be renamed',
|
||||
},
|
||||
to: {
|
||||
type: 'string',
|
||||
title: 'The destination of the new file',
|
||||
},
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
title:
|
||||
'Overwrite existing file or directory, default is false',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
if (!Array.isArray(ctx.input?.files)) {
|
||||
throw new InputError('files must be an Array');
|
||||
}
|
||||
|
||||
for (const file of ctx.input.files) {
|
||||
if (!file.from || !file.to) {
|
||||
throw new InputError('each file must have a from and to property');
|
||||
}
|
||||
|
||||
const sourceFilepath = resolveSafeChildPath(
|
||||
ctx.workspacePath,
|
||||
file.from,
|
||||
);
|
||||
const destFilepath = resolveSafeChildPath(ctx.workspacePath, file.to);
|
||||
|
||||
try {
|
||||
await fs.move(sourceFilepath, destFilepath, {
|
||||
overwrite: file.overwrite ?? false,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`File ${sourceFilepath} renamed to ${destFilepath} successfully`,
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.logger.error(
|
||||
`Failed to rename file ${sourceFilepath} to ${destFilepath}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -18,4 +18,5 @@ export * from './catalog';
|
||||
export { createBuiltinActions } from './createBuiltinActions';
|
||||
export * from './debug';
|
||||
export * from './fetch';
|
||||
export * from './filesystem';
|
||||
export * from './publish';
|
||||
|
||||
@@ -162,6 +162,29 @@ describe('publish:azure', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
defaultBranch: 'master',
|
||||
auth: { username: 'notempty', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call initRepoAndPush with the correct default branch', async () => {
|
||||
mockGitClient.createRepository.mockImplementation(() => ({
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
}));
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
defaultBranch: 'main',
|
||||
},
|
||||
});
|
||||
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
|
||||
defaultBranch: 'master',
|
||||
auth: { username: 'notempty', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createPublishAzureAction(options: {
|
||||
return createTemplateAction<{
|
||||
repoUrl: string;
|
||||
description?: string;
|
||||
defaultBranch?: string;
|
||||
sourcePath?: string;
|
||||
}>({
|
||||
id: 'publish:azure',
|
||||
@@ -48,6 +49,11 @@ export function createPublishAzureAction(options: {
|
||||
title: 'Repository Description',
|
||||
type: 'string',
|
||||
},
|
||||
defaultBranch: {
|
||||
title: 'Default Branch',
|
||||
type: 'string',
|
||||
description: `Sets the default branch on the repository. The default value is 'master'`,
|
||||
},
|
||||
sourcePath: {
|
||||
title:
|
||||
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
|
||||
@@ -70,9 +76,9 @@ export function createPublishAzureAction(options: {
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { owner, repo, host, organization } = parseRepoUrl(
|
||||
ctx.input.repoUrl,
|
||||
);
|
||||
const { repoUrl, defaultBranch = 'master' } = ctx.input;
|
||||
|
||||
const { owner, repo, host, organization } = parseRepoUrl(repoUrl);
|
||||
|
||||
if (!organization) {
|
||||
throw new InputError(
|
||||
@@ -120,6 +126,7 @@ export function createPublishAzureAction(options: {
|
||||
await initRepoAndPush({
|
||||
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
|
||||
remoteUrl,
|
||||
defaultBranch,
|
||||
auth: {
|
||||
username: 'notempty',
|
||||
password: integrationConfig.config.token,
|
||||
|
||||
@@ -286,6 +286,49 @@ describe('publish:bitbucket', () => {
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
remoteUrl: 'https://bitbucket.org/owner/cloneurl',
|
||||
defaultBranch: 'master',
|
||||
auth: { username: 'x-token-auth', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call initAndPush with the correct default branch', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
'https://api.bitbucket.org/2.0/repositories/owner/repo',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json({
|
||||
links: {
|
||||
html: {
|
||||
href: 'https://bitbucket.org/owner/repo',
|
||||
},
|
||||
clone: [
|
||||
{
|
||||
name: 'https',
|
||||
href: 'https://bitbucket.org/owner/cloneurl',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
defaultBranch: 'main',
|
||||
},
|
||||
});
|
||||
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
remoteUrl: 'https://bitbucket.org/owner/cloneurl',
|
||||
defaultBranch: 'main',
|
||||
auth: { username: 'x-token-auth', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
});
|
||||
|
||||
@@ -190,6 +190,7 @@ export function createPublishBitbucketAction(options: {
|
||||
return createTemplateAction<{
|
||||
repoUrl: string;
|
||||
description: string;
|
||||
defaultBranch?: string;
|
||||
repoVisibility: 'private' | 'public';
|
||||
sourcePath?: string;
|
||||
enableLFS: boolean;
|
||||
@@ -215,6 +216,11 @@ export function createPublishBitbucketAction(options: {
|
||||
type: 'string',
|
||||
enum: ['private', 'public'],
|
||||
},
|
||||
defaultBranch: {
|
||||
title: 'Default Branch',
|
||||
type: 'string',
|
||||
description: `Sets the default branch on the repository. The default value is 'master'`,
|
||||
},
|
||||
sourcePath: {
|
||||
title:
|
||||
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
|
||||
@@ -245,6 +251,7 @@ export function createPublishBitbucketAction(options: {
|
||||
const {
|
||||
repoUrl,
|
||||
description,
|
||||
defaultBranch = 'master',
|
||||
repoVisibility = 'private',
|
||||
enableLFS = false,
|
||||
} = ctx.input;
|
||||
@@ -288,6 +295,7 @@ export function createPublishBitbucketAction(options: {
|
||||
? integrationConfig.config.appPassword
|
||||
: integrationConfig.config.token ?? '',
|
||||
},
|
||||
defaultBranch,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -268,8 +268,8 @@ export function createPublishGithubAction(options: {
|
||||
defaultBranch,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to add branch protection to '${newRepo.name}', ${e}`,
|
||||
ctx.logger.warn(
|
||||
`Skipping: default branch protection on '${newRepo.name}', ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -139,6 +139,30 @@ describe('publish:gitlab', () => {
|
||||
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
defaultBranch: 'master',
|
||||
remoteUrl: 'http://mockurl.git',
|
||||
auth: { username: 'oauth2', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call initRepoAndPush with the correct default branch', async () => {
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'http://mockurl.git',
|
||||
});
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
defaultBranch: 'main',
|
||||
},
|
||||
});
|
||||
|
||||
expect(initRepoAndPush).toHaveBeenCalledWith({
|
||||
dir: mockContext.workspacePath,
|
||||
defaultBranch: 'main',
|
||||
remoteUrl: 'http://mockurl.git',
|
||||
auth: { username: 'oauth2', password: 'tokenlols' },
|
||||
logger: mockContext.logger,
|
||||
@@ -28,6 +28,7 @@ export function createPublishGitlabAction(options: {
|
||||
|
||||
return createTemplateAction<{
|
||||
repoUrl: string;
|
||||
defaultBranch?: string;
|
||||
repoVisibility: 'private' | 'internal' | 'public';
|
||||
sourcePath?: string;
|
||||
}>({
|
||||
@@ -48,6 +49,11 @@ export function createPublishGitlabAction(options: {
|
||||
type: 'string',
|
||||
enum: ['private', 'public', 'internal'],
|
||||
},
|
||||
defaultBranch: {
|
||||
title: 'Default Branch',
|
||||
type: 'string',
|
||||
description: `Sets the default branch on the repository. The default value is 'master'`,
|
||||
},
|
||||
sourcePath: {
|
||||
title:
|
||||
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
|
||||
@@ -70,7 +76,11 @@ export function createPublishGitlabAction(options: {
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { repoUrl, repoVisibility = 'private' } = ctx.input;
|
||||
const {
|
||||
repoUrl,
|
||||
repoVisibility = 'private',
|
||||
defaultBranch = 'master',
|
||||
} = ctx.input;
|
||||
|
||||
const { owner, repo, host } = parseRepoUrl(repoUrl);
|
||||
|
||||
@@ -114,6 +124,7 @@ export function createPublishGitlabAction(options: {
|
||||
await initRepoAndPush({
|
||||
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
|
||||
remoteUrl: http_url_to_repo as string,
|
||||
defaultBranch,
|
||||
auth: {
|
||||
username: 'oauth2',
|
||||
password: integrationConfig.config.token,
|
||||
|
||||
@@ -245,7 +245,10 @@ export class ScaffolderClient implements ScaffolderApi {
|
||||
*/
|
||||
async listActions(): Promise<ListActionsResponse> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
|
||||
const response = await fetch(`${baseUrl}/v2/actions`);
|
||||
const token = await this.identityApi.getIdToken();
|
||||
const response = await fetch(`${baseUrl}/v2/actions`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw ResponseError.fromResponse(response);
|
||||
|
||||
@@ -1689,6 +1689,11 @@
|
||||
resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.7.tgz#0fe1fa0ef02c827919e23c2802a4b25589ac522d"
|
||||
integrity sha512-EG/1qDiQvd12RoNJ6H+sZcHVswC/3uMx/ySvfaJ24vB30rLjkgHggEXbgMbfgki7wMuiQ/zXI8QlmF1k3kWRGQ==
|
||||
|
||||
"@date-io/core@^2.10.11":
|
||||
version "2.10.11"
|
||||
resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.11.tgz#b1a3d57730f3eaaab54d5658be4a71727297e357"
|
||||
integrity sha512-keXQnwH0LM8wyvu+j5Z2KGK56D+eItjy7DnwuWl/oV+DM2UEYl0z5WhdPMpfswSyt/kjuPOzcVF/7u/skMLaoA==
|
||||
|
||||
"@date-io/date-fns@^1.1.0":
|
||||
version "1.3.13"
|
||||
resolved "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.13.tgz#7798844041640ab393f7e21a7769a65d672f4735"
|
||||
@@ -1696,12 +1701,12 @@
|
||||
dependencies:
|
||||
"@date-io/core" "^1.3.13"
|
||||
|
||||
"@date-io/luxon@1.x":
|
||||
version "1.3.13"
|
||||
resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a"
|
||||
integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw==
|
||||
"@date-io/luxon@2.x":
|
||||
version "2.10.11"
|
||||
resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.10.11.tgz#d0981b9fdf5e5f17f8ce59265a3ac6c335565fac"
|
||||
integrity sha512-SS6SIkp0Y9GFwpQycCTUAyW3OZTW05CWI1DJu10hUzcg8SmjJfhjs7hQY3TOeW+JT6VtXGTVGwbWPUBJsNkhZg==
|
||||
dependencies:
|
||||
"@date-io/core" "^1.3.13"
|
||||
"@date-io/core" "^2.10.11"
|
||||
|
||||
"@emotion/cache@^10.0.27":
|
||||
version "10.0.29"
|
||||
@@ -5437,9 +5442,9 @@
|
||||
integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==
|
||||
|
||||
"@types/archiver@^5.1.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.1.0.tgz#869f4ce4028e49cf9a0243cf914415f4cc3d1f3d"
|
||||
integrity sha512-baFOhanb/hxmcOd1Uey2TfFg43kTSmM6py1Eo7Rjbv/ivcl7PXLhY0QgXGf50Hx/eskGCFqPfhs/7IZLb15C5g==
|
||||
version "5.3.0"
|
||||
resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f"
|
||||
integrity sha512-qJ79qsmq7O/k9FYwsF6O1xVA1PeLV+9Bh3TYkVCu3VzMR6vN9JQkgEOh/rrQ0R+F4Ta+R3thHGewxQtFglwVfg==
|
||||
dependencies:
|
||||
"@types/glob" "*"
|
||||
|
||||
@@ -5454,9 +5459,9 @@
|
||||
integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==
|
||||
|
||||
"@types/aws4@^1.5.1":
|
||||
version "1.5.1"
|
||||
resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.1.tgz#361fadab198a030ab398269183ae3fa86e958ed9"
|
||||
integrity sha1-Nh+tqxmKAwqzmCaRg64/qG6Vjtk=
|
||||
version "1.5.2"
|
||||
resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.2.tgz#34e35b4405a619b9205be3e7678963bc7c8a47db"
|
||||
integrity sha512-r8+XOv0BKw3Br0oU6w9cfu21PaQq/5ZeXvMOivNQYJLc9WcPCpEFDaQu72QNEQjYv5Otu48VhjjnbtwrSmk/rg==
|
||||
|
||||
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
|
||||
version "7.1.9"
|
||||
@@ -6866,9 +6871,9 @@
|
||||
integrity sha512-/emrKCfQMQmFCqRqqBJ0JueHBT06jBRM3e8OgnvDUcvuExONujIk2hFA5dNsN9Nt41ljGVDdChvCydATZ+KOZw==
|
||||
|
||||
"@types/yup@^0.29.8":
|
||||
version "0.29.11"
|
||||
resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e"
|
||||
integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g==
|
||||
version "0.29.12"
|
||||
resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.12.tgz#59c9577bea11d2b3d78717ea7591caacad6dfa1b"
|
||||
integrity sha512-fA7bXyBzWEAgOwX2SD/5/iaZY/4In0EvJEzFmBWzaGNF4vxr8d5iOFUMFBpL4cMEmlSx2wW9ginJNnoZjE/vOg==
|
||||
|
||||
"@types/zen-observable@^0.8.0", "@types/zen-observable@^0.8.2":
|
||||
version "0.8.2"
|
||||
|
||||
Reference in New Issue
Block a user