Merge remote-tracking branch 'upstream/master' into responsive-component-wrapping

This commit is contained in:
Philipp Hugenroth
2021-07-06 17:11:06 +02:00
51 changed files with 1425 additions and 170 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-pagerduty': patch
---
Update README
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
add default branch property for publish GitLab, Bitbucket and Azure actions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Added filesystem remove/rename built-in actions
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Show scroll bar of the sidebar wrapper only on hover
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-ilert': patch
---
chore: bump `@date-io/luxon` from 1.3.13 to 2.10.11
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-components': patch
'@backstage/plugin-org': patch
---
Add edit button to Group Profile Card
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Pass through the `idToken` in `Authorization` Header for `listActions` request
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Add custom styles to scroll bar of the sidebar wrapper to fix flaky behaviour
+1
View File
@@ -137,6 +137,7 @@ maintainership
makefile
md
memcache
memoized
microservice
microservices
microsite
+1 -1
View File
@@ -23,7 +23,7 @@ describe('Catalog', () => {
cy.visit('/catalog');
cy.contains('Owned (8)').should('be.visible');
cy.contains('Owned (10)').should('be.visible');
});
});
});
+9 -3
View File
@@ -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');
});
+38 -6
View File
@@ -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,
}),
);
```
+274
View File
@@ -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.
+13
View File
@@ -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
+2 -1
View File
@@ -166,7 +166,8 @@
"plugins/proxying",
"plugins/backend-plugin",
"plugins/call-existing-api",
"plugins/github-apps"
"plugins/github-apps",
"plugins/url-reader"
]
},
{
+1
View File
@@ -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:
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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>
)}
+14 -4
View File
@@ -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
![PagerDuty plugin showing no incidents and the on-call rotation](doc/pd1.png)
### Email link, and view contact information for staff on call
![PagerDuty plugin showing on-call rotation contact information](doc/pd2.png)
### Trigger an incident for a service
![PagerDuty plugin popup modal for creating an incident](doc/pd3.png)
![PagerDuty plugin showing an active incident](doc/pd4.png)
## 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

+6
View File
@@ -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}`,
);
}
@@ -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,
+4 -1
View File
@@ -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);
+19 -14
View File
@@ -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"