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
@@ -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);