Merge remote-tracking branch 'upstream/master' into responsive-component-wrapping
This commit is contained in:
@@ -16,6 +16,15 @@ import { SearchEntry } from 'ldapjs';
|
||||
import { SearchOptions } from 'ldapjs';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
// @public (undocumented)
|
||||
export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise<GroupEntity | undefined>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise<UserEntity | undefined>;
|
||||
|
||||
// @public
|
||||
export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise<GroupEntity | undefined>;
|
||||
|
||||
// @public
|
||||
export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn";
|
||||
|
||||
@@ -40,14 +49,18 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config, options: {
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}): LdapOrgReaderProcessor;
|
||||
// (undocumented)
|
||||
readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
// @public
|
||||
export type LdapProviderConfig = {
|
||||
@@ -57,15 +70,25 @@ export type LdapProviderConfig = {
|
||||
groups: GroupConfig;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void;
|
||||
|
||||
// @public
|
||||
export function readLdapConfig(config: Config): LdapProviderConfig[];
|
||||
|
||||
// @public
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
}): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise<UserEntity | undefined>;
|
||||
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
export { LdapClient } from './client';
|
||||
export { mapStringAttr } from './util';
|
||||
export { readLdapConfig } from './config';
|
||||
export type { LdapProviderConfig } from './config';
|
||||
export {
|
||||
@@ -22,4 +23,9 @@ export {
|
||||
LDAP_RDN_ANNOTATION,
|
||||
LDAP_UUID_ANNOTATION,
|
||||
} from './constants';
|
||||
export { readLdapOrg } from './read';
|
||||
export {
|
||||
defaultGroupTransformer,
|
||||
defaultUserTransformer,
|
||||
readLdapOrg,
|
||||
} from './read';
|
||||
export type { GroupTransformer, UserTransformer } from './types';
|
||||
|
||||
@@ -26,74 +26,97 @@ import {
|
||||
LDAP_UUID_ANNOTATION,
|
||||
} from './constants';
|
||||
import { LdapVendor } from './vendors';
|
||||
import { Logger } from 'winston';
|
||||
import { GroupTransformer, UserTransformer } from './types';
|
||||
import { mapStringAttr } from './util';
|
||||
|
||||
export async function defaultUserTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: UserConfig,
|
||||
entry: SearchEntry,
|
||||
): Promise<UserEntity | undefined> {
|
||||
const { set, map } = config;
|
||||
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
profile: {},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads users out of an LDAP provider.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param config The user data configuration
|
||||
* @param opts
|
||||
*/
|
||||
export async function readLdapUsers(
|
||||
client: LdapClient,
|
||||
config: UserConfig,
|
||||
opts?: { transformer?: UserTransformer },
|
||||
): Promise<{
|
||||
users: UserEntity[]; // With all relations empty
|
||||
userMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
|
||||
}> {
|
||||
const { dn, options, set, map } = config;
|
||||
const { dn, options, map } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
const entities: UserEntity[] = [];
|
||||
const userMemberOf: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
profile: {},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
const transformer = opts?.transformer ?? defaultUserTransformer;
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
for (const user of entries) {
|
||||
const entity = await transformer(vendor, config, user);
|
||||
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(userMemberOf, myDn, vs);
|
||||
});
|
||||
|
||||
@@ -103,82 +126,103 @@ export async function readLdapUsers(
|
||||
return { users: entities, userMemberOf };
|
||||
}
|
||||
|
||||
export async function defaultGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
entry: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
const { set, map } = config;
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
profile: {},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.type, v => {
|
||||
entity.spec.type = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads groups out of an LDAP provider.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param config The group data configuration
|
||||
* @param opts
|
||||
*/
|
||||
export async function readLdapGroups(
|
||||
client: LdapClient,
|
||||
config: GroupConfig,
|
||||
opts?: {
|
||||
transformer?: GroupTransformer;
|
||||
},
|
||||
): Promise<{
|
||||
groups: GroupEntity[]; // With all relations empty
|
||||
groupMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
|
||||
groupMember: Map<string, Set<string>>; // DN -> DN or UUID of groups & users
|
||||
}> {
|
||||
const { dn, options, set, map } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
const groups: GroupEntity[] = [];
|
||||
const groupMemberOf: Map<string, Set<string>> = new Map();
|
||||
const groupMember: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
profile: {},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
const { dn, map, options } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
const transformer = opts?.transformer ?? defaultGroupTransformer;
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
for (const group of entries) {
|
||||
const entity = await transformer(vendor, config, group);
|
||||
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.type, v => {
|
||||
entity.spec.type = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(groupMemberOf, myDn, vs);
|
||||
});
|
||||
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
|
||||
mapReferencesAttr(group, vendor, map.members, (myDn, vs) => {
|
||||
ensureItems(groupMember, myDn, vs);
|
||||
});
|
||||
|
||||
@@ -199,22 +243,30 @@ export async function readLdapGroups(
|
||||
* with all relations etc filled in.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param logger A logger instance
|
||||
* @param userConfig The user data configuration
|
||||
* @param groupConfig The group data configuration
|
||||
* @param options
|
||||
*/
|
||||
export async function readLdapOrg(
|
||||
client: LdapClient,
|
||||
userConfig: UserConfig,
|
||||
groupConfig: GroupConfig,
|
||||
options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
},
|
||||
): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
}> {
|
||||
const { users, userMemberOf } = await readLdapUsers(client, userConfig);
|
||||
const { users, userMemberOf } = await readLdapUsers(client, userConfig, {
|
||||
transformer: options?.userTransformer,
|
||||
});
|
||||
const { groups, groupMemberOf, groupMember } = await readLdapGroups(
|
||||
client,
|
||||
groupConfig,
|
||||
{ transformer: options?.groupTransformer },
|
||||
);
|
||||
|
||||
resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember);
|
||||
@@ -228,21 +280,6 @@ export async function readLdapOrg(
|
||||
// Helpers
|
||||
//
|
||||
|
||||
// Maps a single-valued attribute to a consumer
|
||||
function mapStringAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
setter: (value: string) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
const values = vendor.decodeStringAttribute(entry, attributeName);
|
||||
if (values && values.length === 1) {
|
||||
setter(values[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a multi-valued attribute of references to other objects, to a consumer
|
||||
function mapReferencesAttr(
|
||||
entry: SearchEntry,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
|
||||
import { SearchEntry } from 'ldapjs';
|
||||
import { LdapVendor } from './vendors';
|
||||
import { GroupConfig, UserConfig } from './config';
|
||||
|
||||
/**
|
||||
* Customize the ingested User entity
|
||||
*
|
||||
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
|
||||
* @param config The User specific config used by the default transformer.
|
||||
* @param user The found LDAP entry in its source format. This is the entry that you want to transform
|
||||
* @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog
|
||||
*/
|
||||
export type UserTransformer = (
|
||||
vendor: LdapVendor,
|
||||
config: UserConfig,
|
||||
user: SearchEntry,
|
||||
) => Promise<UserEntity | undefined>;
|
||||
|
||||
/**
|
||||
* Customize the ingested Group entity
|
||||
*
|
||||
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
|
||||
* @param config The Group specific config used by the default transformer.
|
||||
* @param group The found LDAP entry in its source format. This is the entry that you want to transform
|
||||
* @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog
|
||||
*/
|
||||
export type GroupTransformer = (
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
) => Promise<GroupEntity | undefined>;
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Error as LDAPError } from 'ldapjs';
|
||||
import { Error as LDAPError, SearchEntry } from 'ldapjs';
|
||||
import { LdapVendor } from './vendors';
|
||||
|
||||
/**
|
||||
* Builds a string form of an LDAP Error structure.
|
||||
@@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs';
|
||||
export function errorString(error: LDAPError) {
|
||||
return `${error.code} ${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single-valued attribute to a consumer
|
||||
*
|
||||
* @param entry The LDAP source entry
|
||||
* @param vendor The LDAP vendor
|
||||
* @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored.
|
||||
* @param setter The function to be called with the decoded attribute from the source entry
|
||||
*/
|
||||
export function mapStringAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
setter: (value: string) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
const values = vendor.decodeStringAttribute(entry, attributeName);
|
||||
if (values && values.length === 1) {
|
||||
setter(values[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
GroupTransformer,
|
||||
LdapClient,
|
||||
LdapProviderConfig,
|
||||
readLdapConfig,
|
||||
readLdapOrg,
|
||||
UserTransformer,
|
||||
} from '../ldap';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
@@ -35,8 +37,17 @@ import {
|
||||
export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
private readonly providers: LdapProviderConfig[];
|
||||
private readonly logger: Logger;
|
||||
private readonly groupTransformer?: GroupTransformer;
|
||||
private readonly userTransformer?: UserTransformer;
|
||||
|
||||
static fromConfig(config: Config, options: { logger: Logger }) {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
},
|
||||
) {
|
||||
const c = config.getOptionalConfig('catalog.processors.ldapOrg');
|
||||
return new LdapOrgReaderProcessor({
|
||||
...options,
|
||||
@@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}) {
|
||||
this.providers = options.providers;
|
||||
this.logger = options.logger;
|
||||
this.groupTransformer = options.groupTransformer;
|
||||
this.userTransformer = options.userTransformer;
|
||||
}
|
||||
|
||||
async readLocation(
|
||||
@@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
client,
|
||||
provider.users,
|
||||
provider.groups,
|
||||
{
|
||||
groupTransformer: this.groupTransformer,
|
||||
userTransformer: this.userTransformer,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
|
||||
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/plugin-catalog-react": "^0.2.4",
|
||||
"@backstage/theme": "^0.2.8",
|
||||
"@date-io/luxon": "1.x",
|
||||
"@date-io/luxon": "2.x",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
|
||||
@@ -23,23 +23,30 @@ import {
|
||||
EntityRefLinks,
|
||||
getEntityRelations,
|
||||
useEntity,
|
||||
getEntityMetadataEditUrl,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import AccountTreeIcon from '@material-ui/icons/AccountTree';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import React from 'react';
|
||||
import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components';
|
||||
import {
|
||||
Avatar,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
Link,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const CardTitle = ({ title }: { title: string }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
@@ -72,14 +79,31 @@ export const GroupProfileCard = ({
|
||||
kind: 'group',
|
||||
});
|
||||
|
||||
const entityMetadataEditUrl = getEntityMetadataEditUrl(group);
|
||||
|
||||
const displayName = profile?.displayName ?? name;
|
||||
const emailHref = profile?.email ? `mailto:${profile.email}` : undefined;
|
||||
const emailHref = profile?.email ? `mailto:${profile.email}` : '#';
|
||||
const infoCardAction = entityMetadataEditUrl ? (
|
||||
<IconButton
|
||||
aria-label="Edit"
|
||||
title="Edit Metadata"
|
||||
component={Link}
|
||||
to={entityMetadataEditUrl}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton aria-label="Edit" disabled title="Edit Metadata">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={<CardTitle title={displayName} />}
|
||||
subheader={description}
|
||||
variant={variant}
|
||||
action={infoCardAction}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={2} xl={1}>
|
||||
@@ -95,7 +119,7 @@ export const GroupProfileCard = ({
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<Link href={emailHref}>{profile.email}</Link>
|
||||
<Link to={emailHref}>{profile.email}</Link>
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,21 @@ There is also an easy way to trigger an alarm directly to the person who is curr
|
||||
|
||||
This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document.
|
||||
|
||||
This plugin provides:
|
||||
## Features
|
||||
|
||||
- A list of incidents
|
||||
- A way to trigger an alarm to the person on-call
|
||||
- Information details about the person on-call
|
||||
### View any open incidents
|
||||
|
||||

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

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

|
||||
|
||||

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