Merge pull request #24700 from backstage/github-multi-org-fix-namespace
catalog-backend-module-github-org: fix default namespace for groups
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2024 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 { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
EntityProvider,
|
||||
EntityProviderConnection,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
export class GithubOrgEntityCleanerProvider implements EntityProvider {
|
||||
logger: LoggerService;
|
||||
constructor(private readonly options: { id: string; logger: LoggerService }) {
|
||||
this.logger = options.logger.child({ target: this.getProviderName() });
|
||||
}
|
||||
|
||||
getProviderName() {
|
||||
return `GithubOrgEntityProvider:${this.options.id}`;
|
||||
}
|
||||
async connect(connection: EntityProviderConnection) {
|
||||
// Clean up any existing entities
|
||||
connection
|
||||
.applyMutation({
|
||||
type: 'full',
|
||||
entities: [],
|
||||
})
|
||||
.catch(error => {
|
||||
this.logger.error('Failed to clean up entities', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@backstage/plugin-catalog-backend-module-github';
|
||||
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
|
||||
import { eventsServiceRef } from '@backstage/plugin-events-node';
|
||||
import { GithubOrgEntityCleanerProvider } from './GithubOrgEntityCleanerProvider';
|
||||
|
||||
/**
|
||||
* Interface for {@link githubOrgEntityProviderTransformsExtensionPoint}.
|
||||
@@ -99,8 +100,14 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({
|
||||
logger: coreServices.logger,
|
||||
scheduler: coreServices.scheduler,
|
||||
},
|
||||
|
||||
async init({ catalog, config, events, logger, scheduler }) {
|
||||
for (const definition of readDefinitionsFromConfig(config)) {
|
||||
const definitions = readDefinitionsFromConfig(config);
|
||||
|
||||
for (const definition of definitions) {
|
||||
catalog.addEntityProvider(
|
||||
new GithubOrgEntityCleanerProvider({ id: definition.id, logger }),
|
||||
);
|
||||
catalog.addEntityProvider(
|
||||
GithubMultiOrgEntityProvider.fromConfig(config, {
|
||||
id: definition.id,
|
||||
@@ -113,6 +120,8 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({
|
||||
logger,
|
||||
userTransformer,
|
||||
teamTransformer,
|
||||
alwaysUseDefaultNamespace:
|
||||
definitions.length === 1 && definition.orgs?.length === 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
|
||||
orgs?: string[];
|
||||
userTransformer?: UserTransformer;
|
||||
teamTransformer?: TeamTransformer;
|
||||
alwaysUseDefaultNamespace?: boolean;
|
||||
});
|
||||
// (undocumented)
|
||||
connect(connection: EntityProviderConnection): Promise<void>;
|
||||
@@ -165,6 +166,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
|
||||
|
||||
// @public
|
||||
export interface GithubMultiOrgEntityProviderOptions {
|
||||
alwaysUseDefaultNamespace?: boolean;
|
||||
events?: EventsService;
|
||||
githubCredentialsProvider?: GithubCredentialsProvider;
|
||||
githubUrl: string;
|
||||
|
||||
+303
-15
@@ -68,26 +68,24 @@ describe('GithubMultiOrgEntityProvider', () => {
|
||||
headers: { token: 'blah' },
|
||||
type: 'app',
|
||||
});
|
||||
|
||||
const githubCredentialsProvider: GithubCredentialsProvider = {
|
||||
getCredentials: mockGetCredentials,
|
||||
};
|
||||
|
||||
entityProvider = new GithubMultiOrgEntityProvider({
|
||||
id: 'my-id',
|
||||
gitHubConfig,
|
||||
githubCredentialsProvider,
|
||||
githubUrl: 'https://github.com',
|
||||
logger,
|
||||
orgs: ['orgA', 'orgB'], // only include for tests that require it
|
||||
});
|
||||
|
||||
entityProvider.connect(entityProviderConnection);
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('should read specified orgs', async () => {
|
||||
entityProvider = new GithubMultiOrgEntityProvider({
|
||||
id: 'my-id',
|
||||
gitHubConfig,
|
||||
githubCredentialsProvider: {
|
||||
getCredentials: mockGetCredentials,
|
||||
},
|
||||
githubUrl: 'https://github.com',
|
||||
logger,
|
||||
orgs: ['orgA', 'orgB'],
|
||||
});
|
||||
|
||||
await entityProvider.connect(entityProviderConnection);
|
||||
|
||||
mockClient
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
@@ -348,6 +346,19 @@ describe('GithubMultiOrgEntityProvider', () => {
|
||||
});
|
||||
|
||||
it('should read every accessible org', async () => {
|
||||
entityProvider = new GithubMultiOrgEntityProvider({
|
||||
id: 'my-id',
|
||||
gitHubConfig,
|
||||
githubCredentialsProvider: {
|
||||
getCredentials: mockGetCredentials,
|
||||
},
|
||||
githubUrl: 'https://github.com',
|
||||
logger,
|
||||
orgs: ['orgA', 'orgB'],
|
||||
});
|
||||
|
||||
await entityProvider.connect(entityProviderConnection);
|
||||
|
||||
getAllInstallationsMock.mockResolvedValue([
|
||||
{
|
||||
target_type: 'Organization',
|
||||
@@ -636,7 +647,284 @@ describe('GithubMultiOrgEntityProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the default namespace if options.alwaysUseDefaultNamespace is provided', async () => {
|
||||
mockClient
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
membersWithRole: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [
|
||||
{
|
||||
login: 'a',
|
||||
name: 'b',
|
||||
bio: 'c',
|
||||
email: 'd',
|
||||
avatarUrl: 'e',
|
||||
},
|
||||
{
|
||||
login: 'x',
|
||||
name: 'y',
|
||||
bio: 'z',
|
||||
email: 'w',
|
||||
avatarUrl: 'v',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
teams: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [
|
||||
{
|
||||
slug: 'team',
|
||||
combinedSlug: 'orgC/team',
|
||||
name: 'Team',
|
||||
description: 'The one and only team',
|
||||
avatarUrl: 'http://example.com/team.jpeg',
|
||||
parentTeam: {
|
||||
slug: 'parent',
|
||||
combinedSlug: '',
|
||||
members: { pageInfo: { hasNextPage: false }, nodes: [] },
|
||||
},
|
||||
members: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ login: 'a' }, { login: 'x' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
membersWithRole: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [
|
||||
{
|
||||
login: 'a',
|
||||
name: 'b',
|
||||
bio: 'c',
|
||||
email: 'd',
|
||||
avatarUrl: 'e',
|
||||
},
|
||||
{
|
||||
login: 'x',
|
||||
name: 'y',
|
||||
bio: 'z',
|
||||
email: 'w',
|
||||
avatarUrl: 'v',
|
||||
},
|
||||
{
|
||||
login: 'q',
|
||||
name: 'r',
|
||||
bio: 's',
|
||||
email: 't',
|
||||
avatarUrl: 'u',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
organization: {
|
||||
teams: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [
|
||||
{
|
||||
slug: 'team',
|
||||
combinedSlug: 'orgD/team',
|
||||
name: 'Team',
|
||||
description: 'The one and only team',
|
||||
avatarUrl: 'http://example.com/team.jpeg',
|
||||
parentTeam: {
|
||||
slug: 'parent',
|
||||
combinedSlug: '',
|
||||
members: { pageInfo: { hasNextPage: false }, nodes: [] },
|
||||
},
|
||||
members: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [{ login: 'a' }, { login: 'q' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(graphql.defaults as jest.Mock).mockReturnValue(mockClient);
|
||||
|
||||
entityProvider = new GithubMultiOrgEntityProvider({
|
||||
id: 'my-id',
|
||||
gitHubConfig,
|
||||
githubCredentialsProvider: {
|
||||
getCredentials: mockGetCredentials,
|
||||
},
|
||||
githubUrl: 'https://github.com',
|
||||
logger,
|
||||
orgs: ['orgA', 'orgB'],
|
||||
alwaysUseDefaultNamespace: true,
|
||||
});
|
||||
|
||||
await entityProvider.connect(entityProviderConnection);
|
||||
await entityProvider.read();
|
||||
|
||||
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
|
||||
entities: [
|
||||
{
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'url:https://github.com/a',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'url:https://github.com/a',
|
||||
'github.com/user-login': 'a',
|
||||
},
|
||||
description: 'c',
|
||||
name: 'a',
|
||||
},
|
||||
spec: {
|
||||
memberOf: ['team'],
|
||||
profile: {
|
||||
displayName: 'b',
|
||||
email: 'd',
|
||||
picture: 'e',
|
||||
},
|
||||
},
|
||||
},
|
||||
locationKey: 'github-multi-org-provider:my-id',
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'url:https://github.com/x',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'url:https://github.com/x',
|
||||
'github.com/user-login': 'x',
|
||||
},
|
||||
description: 'z',
|
||||
name: 'x',
|
||||
},
|
||||
spec: {
|
||||
memberOf: ['team'],
|
||||
profile: {
|
||||
displayName: 'y',
|
||||
email: 'w',
|
||||
picture: 'v',
|
||||
},
|
||||
},
|
||||
},
|
||||
locationKey: 'github-multi-org-provider:my-id',
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'url:https://github.com/q',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'url:https://github.com/q',
|
||||
'github.com/user-login': 'q',
|
||||
},
|
||||
description: 's',
|
||||
name: 'q',
|
||||
},
|
||||
spec: {
|
||||
memberOf: ['team'],
|
||||
profile: {
|
||||
displayName: 'r',
|
||||
email: 't',
|
||||
picture: 'u',
|
||||
},
|
||||
},
|
||||
},
|
||||
locationKey: 'github-multi-org-provider:my-id',
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'url:https://github.com/orgs/orgC/teams/team',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'url:https://github.com/orgs/orgC/teams/team',
|
||||
'github.com/team-slug': 'orgC/team',
|
||||
},
|
||||
name: 'team',
|
||||
description: 'The one and only team',
|
||||
},
|
||||
spec: {
|
||||
children: [],
|
||||
parent: 'parent',
|
||||
profile: {
|
||||
displayName: 'Team',
|
||||
picture: 'http://example.com/team.jpeg',
|
||||
},
|
||||
type: 'team',
|
||||
members: ['default/a', 'default/x'],
|
||||
},
|
||||
},
|
||||
locationKey: 'github-multi-org-provider:my-id',
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'url:https://github.com/orgs/orgD/teams/team',
|
||||
'backstage.io/managed-by-origin-location':
|
||||
'url:https://github.com/orgs/orgD/teams/team',
|
||||
'github.com/team-slug': 'orgD/team',
|
||||
},
|
||||
name: 'team',
|
||||
description: 'The one and only team',
|
||||
},
|
||||
spec: {
|
||||
children: [],
|
||||
parent: 'parent',
|
||||
profile: {
|
||||
displayName: 'Team',
|
||||
picture: 'http://example.com/team.jpeg',
|
||||
},
|
||||
type: 'team',
|
||||
members: ['default/a', 'default/q'],
|
||||
},
|
||||
},
|
||||
locationKey: 'github-multi-org-provider:my-id',
|
||||
},
|
||||
],
|
||||
type: 'full',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call applyMutation if an error is thrown', async () => {
|
||||
entityProvider = new GithubMultiOrgEntityProvider({
|
||||
id: 'my-id',
|
||||
gitHubConfig,
|
||||
githubCredentialsProvider: {
|
||||
getCredentials: mockGetCredentials,
|
||||
},
|
||||
githubUrl: 'https://github.com',
|
||||
logger,
|
||||
orgs: ['orgA', 'orgB'],
|
||||
});
|
||||
|
||||
await entityProvider.connect(entityProviderConnection);
|
||||
|
||||
mockClient.mockImplementationOnce(() => {
|
||||
throw new Error('Network Error');
|
||||
});
|
||||
|
||||
+15
-1
@@ -143,6 +143,15 @@ export interface GithubMultiOrgEntityProviderOptions {
|
||||
*/
|
||||
githubCredentialsProvider?: GithubCredentialsProvider;
|
||||
|
||||
/**
|
||||
* Use the default namespace for groups. By default, groups will be namespaced according to their GitHub org.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If set to true, groups with the same name across different orgs will be considered the same group.
|
||||
*/
|
||||
alwaysUseDefaultNamespace?: boolean;
|
||||
|
||||
/**
|
||||
* Optionally include a user transformer for transforming from GitHub users to User Entities
|
||||
*/
|
||||
@@ -198,6 +207,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
|
||||
userTransformer: options.userTransformer,
|
||||
teamTransformer: options.teamTransformer,
|
||||
events: options.events,
|
||||
alwaysUseDefaultNamespace: options.alwaysUseDefaultNamespace,
|
||||
});
|
||||
|
||||
provider.schedule(options.schedule);
|
||||
@@ -216,6 +226,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
|
||||
orgs?: string[];
|
||||
userTransformer?: UserTransformer;
|
||||
teamTransformer?: TeamTransformer;
|
||||
alwaysUseDefaultNamespace?: boolean;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -846,7 +857,10 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
|
||||
const result = await defaultOrganizationTeamTransformer(team, ctx);
|
||||
|
||||
if (result && result.spec) {
|
||||
result.metadata.namespace = ctx.org.toLocaleLowerCase('en-US');
|
||||
if (!this.options.alwaysUseDefaultNamespace) {
|
||||
result.metadata.namespace = ctx.org.toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
// Group `spec.members` inherits the namespace of it's group so need to explicitly specify refs here
|
||||
result.spec.members = team.members.map(
|
||||
user => `${DEFAULT_NAMESPACE}/${user.login}`,
|
||||
|
||||
Reference in New Issue
Block a user