add new scaffolder action to ensure a gitlab group exists

Signed-off-by: Andreas Berger <andreas@berger-ecommerce.com>
This commit is contained in:
Andreas Berger
2023-03-21 09:52:44 +01:00
parent 280c485572
commit 439e2986be
13 changed files with 401 additions and 218 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': minor
---
Add a new scaffolder action for gitlab to ensure a group exists
@@ -45,6 +45,9 @@ const actions = [
createGitlabProjectDeployTokenAction({
integrations: integrations,
}),
createGitlabGroupEnsureExistsAction({
integrations: integrations,
}),
];
// Create Scaffolder Router
@@ -104,13 +107,22 @@ spec:
url: https://github.com/TEMPLATE
values:
name: ${{ parameters.name }}
- id: createGitlabGroup
name: Ensure Gitlab group exists
action: gitlab:group:ensureExists
input:
repoUrl: ${{ parameters.repoUrl }}
path:
- path
- to
- group
- id: publish
name: Publish
action: publish:gitlab
input:
description: This is ${{ parameters.name }}
repoUrl: ${{ parameters.repoUrl }}
repoUrl: ${{ parameters.repoUrl }}?owner=${{ steps.createGitlabGroup.output.groupId }}
sourcePath: pimcore
defaultBranch: main
@@ -7,6 +7,18 @@ import { JsonObject } from '@backstage/types';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
// @public
export const createGitlabGroupEnsureExistsAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<
{
repoUrl: string;
token?: string | undefined;
} & {
path: string[];
}
>;
// @public
export const createGitlabProjectAccessTokenAction: (options: {
integrations: ScmIntegrationRegistry;
@@ -43,16 +55,18 @@ export const createGitlabProjectVariableAction: (options: {
}) => TemplateAction<
{
repoUrl: string;
projectId: string | number;
token?: string | undefined;
} & {
key: string;
value: string;
projectId: string | number;
variableType: string;
variableProtected: boolean;
masked: boolean;
raw: boolean;
environmentScope: string;
token?: string | undefined;
},
variableProtected?: boolean | undefined;
masked?: boolean | undefined;
raw?: boolean | undefined;
environmentScope?: string | undefined;
}
,
JsonObject
>;
```
@@ -35,7 +35,8 @@
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@gitbeaker/node": "^35.8.0"
"@gitbeaker/node": "^35.8.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-common": "workspace:^",
@@ -0,0 +1,140 @@
/*
* 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 { PassThrough } from 'stream';
import { createGitlabGroupEnsureExistsAction } from './createGitlabGroupEnsureExistsAction';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/core-app-api';
import { ScmIntegrations } from '@backstage/integration';
const mockGitlabClient = {
Groups: {
search: jest.fn(),
create: jest.fn(),
},
};
jest.mock('@gitbeaker/node', () => ({
Gitlab: class {
constructor() {
return mockGitlabClient;
}
},
}));
describe('gitlab:group:ensureExists', () => {
const mockContext = {
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
afterEach(() => {
jest.resetAllMocks();
});
it('should create a new group if it does not exists', async () => {
mockGitlabClient.Groups.search.mockResolvedValue([
{
id: 1,
full_path: 'repos/bar',
},
{
id: 2,
full_path: 'repos/foo',
},
]);
mockGitlabClient.Groups.create.mockResolvedValue({
id: 3,
full_path: 'repos/foo/bar',
});
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createGitlabGroupEnsureExistsAction({ integrations });
await action.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com',
path: ['foo', 'bar'],
},
});
expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith('bar', 'bar', {
parent_id: 2,
});
expect(mockContext.output).toHaveBeenCalledWith('groupId', 3);
});
it('should return existing group if it does exists', async () => {
mockGitlabClient.Groups.search.mockResolvedValue([
{
id: 1,
full_path: 'repos/bar',
},
{
id: 2,
full_path: 'repos/foo',
},
{
id: 42,
full_path: 'repos/foo/bar',
},
]);
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createGitlabGroupEnsureExistsAction({ integrations });
await action.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com',
path: ['foo', 'bar'],
},
});
expect(mockGitlabClient.Groups.create).not.toHaveBeenCalled();
expect(mockContext.output).toHaveBeenCalledWith('groupId', 42);
});
});
@@ -0,0 +1,93 @@
/*
* 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 '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Gitlab } from '@gitbeaker/node';
import { GroupSchema } from '@gitbeaker/core/dist/types/resources/Groups';
import commonGitlabConfig from '../commonGitlabConfig';
import { getToken } from '../util';
import { z } from 'zod';
const input = commonGitlabConfig.and(
z.object({
path: z
.array(z.string(), {
description: 'A path of group names that is ensured to exist',
})
.min(1),
}),
);
const output = z.object({
groupId: z.string({ description: 'The id of the innermost sub-group' }),
});
/**
* Creates an `gitlab:group:ensureExists` Scaffolder action.
*
* @public
*/
export const createGitlabGroupEnsureExistsAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<z.infer<typeof input>>({
id: 'gitlab:group:ensureExists',
description: 'Ensures a Gitlab group exists',
schema: { input, output },
async handler(ctx) {
const { path } = ctx.input;
const { token, integrationConfig } = getToken(ctx.input, integrations);
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
token: token,
});
let currentPath: string = 'repos';
let parent: GroupSchema | null = null;
for (const pathElement of path) {
const fullPath = `${currentPath}/${pathElement}`;
const result = (await api.Groups.search(
fullPath,
)) as any as Array<GroupSchema>;
const subGroup = result.find(
searchPathElem => searchPathElem.full_path === fullPath,
);
if (!subGroup) {
ctx.logger.info(`creating missing group ${fullPath}`);
parent = await api.Groups.create(
pathElement,
pathElement,
parent
? {
parent_id: parent.id,
}
: {},
);
} else {
parent = subGroup;
}
currentPath = fullPath;
}
if (parent !== null) {
ctx.output('groupId', parent?.id);
}
},
});
};
@@ -16,10 +16,27 @@
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import commonGitlabConfig from '../commonGitlabConfig';
import { getToken } from '../util';
import { z } from 'zod';
const input = commonGitlabConfig.and(
z.object({
projectId: z.union([z.number(), z.string()], { description: 'Project ID' }),
name: z.string({ description: 'Deploy Token Name' }).optional(),
accessLevel: z
.string({ description: 'Access Level of the Token' })
.optional(),
scopes: z.array(z.string(), { description: 'Scopes' }).optional(),
}),
);
const output = z.object({
access_token: z.string({ description: 'Access Token' }),
});
/**
* Creates a `gitlab:create-project-access-token` Scaffolder action.
* Creates a `gitlab:projectAccessToken:create` Scaffolder action.
*
* @param options - Templating configuration.
* @public
@@ -28,65 +45,13 @@ export const createGitlabProjectAccessTokenAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
projectId: string | number;
name: string;
accessLevel: number;
scopes: string[];
token?: string;
}>({
return createTemplateAction<z.infer<typeof input>>({
id: 'gitlab:projectAccessToken:create',
schema: {
input: {
required: ['projectId', 'repoUrl'],
type: 'object',
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
projectId: {
title: 'Project ID',
type: ['string', 'number'],
},
name: {
title: 'Deploy Token Name',
type: 'string',
},
accessLevel: {
title: 'Access Level of the Token',
type: 'number',
},
scopes: {
title: 'Scopes',
type: 'array',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
},
},
output: {
type: 'object',
properties: {
access_token: {
title: 'Access Token',
type: 'string',
},
},
},
},
schema: { input, output },
async handler(ctx) {
ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`);
const { repoUrl, projectId, name, accessLevel, scopes } = ctx.input;
const { token, integrationConfig } = getToken(
repoUrl,
ctx.input.token,
integrations,
);
const { projectId, name, accessLevel, scopes } = ctx.input;
const { token, integrationConfig } = getToken(ctx.input, integrations);
const response = await fetch(
`${integrationConfig.config.baseUrl}/api/v4/projects/${projectId}/access_tokens`,
@@ -18,11 +18,27 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { Gitlab } from '@gitbeaker/node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { DeployTokenScope } from '@gitbeaker/core/dist/types/templates/ResourceDeployTokens';
import commonGitlabConfig from '../commonGitlabConfig';
import { getToken } from '../util';
import { InputError } from '@backstage/errors';
import { z } from 'zod';
const input = commonGitlabConfig.and(
z.object({
projectId: z.union([z.number(), z.string()], { description: 'Project ID' }),
name: z.string({ description: 'Deploy Token Name' }),
username: z.string({ description: 'Deploy Token Username' }).optional(),
scopes: z.array(z.string(), { description: 'Scopes' }).optional(),
}),
);
const output = z.object({
deploy_token: z.string({ description: 'Deploy Token' }),
user: z.string({ description: 'User' }),
});
/**
* Creates a `gitlab:create-project-deploy-token` Scaffolder action.
* Creates a `gitlab:projectDeployToken:create` Scaffolder action.
*
* @param options - Templating configuration.
* @public
@@ -31,69 +47,13 @@ export const createGitlabProjectDeployTokenAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
projectId: string | number;
name: string;
username: string;
scopes: string[];
token?: string;
}>({
return createTemplateAction<z.infer<typeof input>>({
id: 'gitlab:projectDeployToken:create',
schema: {
input: {
required: ['projectId', 'repoUrl'],
type: 'object',
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
projectId: {
title: 'Project ID',
type: ['string', 'number'],
},
name: {
title: 'Deploy Token Name',
type: 'string',
},
username: {
title: 'Deploy Token Username',
type: 'string',
},
scopes: {
title: 'Scopes',
type: 'array',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
},
},
output: {
type: 'object',
properties: {
deploy_token: {
title: 'Deploy Token',
type: 'string',
},
user: {
title: 'User',
type: 'string',
},
},
},
},
schema: { input, output },
async handler(ctx) {
ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`);
const { repoUrl, projectId, name, username, scopes } = ctx.input;
const { token, integrationConfig } = getToken(
repoUrl,
ctx.input.token,
integrations,
);
const { projectId, name, username, scopes } = ctx.input;
const { token, integrationConfig } = getToken(ctx.input, integrations);
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
@@ -16,11 +16,43 @@
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { getToken } from '../util';
import { Gitlab } from '@gitbeaker/node';
import { getToken } from '../util';
import commonGitlabConfig from '../commonGitlabConfig';
import { z } from 'zod';
const input = commonGitlabConfig.and(
z.object({
projectId: z.union([z.number(), z.string()], { description: 'Project ID' }),
key: z
.string({
description:
'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed',
})
.regex(/^[A-Za-z0-9_]{1,255}$/),
value: z.string({ description: 'The value of a variable' }),
variableType: z.string({ description: 'Variable Type (env_var or file)' }),
variableProtected: z
.boolean({ description: 'Whether the variable is protected' })
.default(false)
.optional(),
masked: z
.boolean({ description: 'Whether the variable is masked' })
.default(false)
.optional(),
raw: z
.boolean({ description: 'Whether the variable is expandable' })
.default(false)
.optional(),
environmentScope: z
.string({ description: 'The environment_scope of the variable' })
.default('*')
.optional(),
}),
);
/**
* Creates a `gitlab:create-project-variable` Scaffolder action.
* Creates a `gitlab:projectVariable:create` Scaffolder action.
*
* @param options - Templating configuration.
* @public
@@ -29,96 +61,21 @@ export const createGitlabProjectVariableAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
projectId: string | number;
key: string;
value: string;
variableType: string;
variableProtected: boolean;
masked: boolean;
raw: boolean;
environmentScope: string;
token?: string;
}>({
return createTemplateAction<z.infer<typeof input>>({
id: 'gitlab:projectVariable:create',
schema: {
input: {
required: [
'repoUrl',
'projectId',
'key',
'value',
'variableType',
'variableProtected',
'masked',
'raw',
'environmentScope',
],
type: 'object',
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
projectId: {
title: 'Project ID',
type: ['string', 'number'],
},
key: {
title:
'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed',
type: 'string',
},
value: {
title: 'The value of a variable',
type: 'string',
},
variableType: {
title: 'Variable Type (env_var or file)',
type: 'string',
},
variableProtected: {
title: 'Whether the variable is protected. Default: false',
type: 'boolean',
},
masked: {
title: 'Whether the variable is masked. Default: false',
type: 'boolean',
},
raw: {
title: 'Whether the variable is expandable. Default: false',
type: 'boolean',
},
environmentScope: {
title: 'The environment_scope of the variable. Default: *',
type: 'string',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
},
},
},
schema: { input },
async handler(ctx) {
const {
repoUrl,
projectId,
key,
value,
variableType,
variableProtected,
masked,
raw,
environmentScope,
variableProtected = false,
masked = false,
raw = false,
environmentScope = '*',
} = ctx.input;
const { token, integrationConfig } = getToken(
repoUrl,
ctx.input.token,
integrations,
);
const { token, integrationConfig } = getToken(ctx.input, integrations);
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
@@ -0,0 +1,26 @@
/*
* 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 { z } from 'zod';
const commonGitlabConfig = z.object({
repoUrl: z.string({ description: 'Repository Location' }),
token: z
.string({ description: 'The token to use for authorization to GitLab' })
.optional(),
});
export default commonGitlabConfig;
@@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A module for the scaffolder backend that lets you create gitlab project access tokens or deploy tokens
* A module for the scaffolder backend that lets you interact with gitlab
*
* @packageDocumentation
*/
export * from './actions/createGitlabGroupEnsureExistsAction';
export * from './actions/createGitlabProjectDeployTokenAction';
export * from './actions/createGitlabProjectAccessTokenAction';
export * from './actions/createGitlabProjectVariableAction';
@@ -19,6 +19,8 @@ import {
GitLabIntegration,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { z } from 'zod';
import commonGitlabConfig from './commonGitlabConfig';
export const parseRepoHost = (repoUrl: string): string => {
let parsed;
@@ -33,11 +35,10 @@ export const parseRepoHost = (repoUrl: string): string => {
};
export const getToken = (
repoUrl: string,
inputToken: string | null | undefined,
config: z.infer<typeof commonGitlabConfig>,
integrations: ScmIntegrationRegistry,
): { token: string; integrationConfig: GitLabIntegration } => {
const host = parseRepoHost(repoUrl);
const host = parseRepoHost(config.repoUrl);
const integrationConfig = integrations.gitlab.byHost(host);
if (!integrationConfig) {
@@ -46,8 +47,8 @@ export const getToken = (
);
}
const token = inputToken || integrationConfig.config.token!;
const tokenType = inputToken ? 'oauthToken' : 'token';
const token = config.token || integrationConfig.config.token!;
const tokenType = config.token ? 'oauthToken' : 'token';
if (tokenType === 'oauthToken') {
throw new InputError(`OAuth Token is currently not supported`);
+8
View File
@@ -7855,6 +7855,7 @@ __metadata:
"@backstage/integration": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@gitbeaker/node": ^35.8.0
zod: ^3.21.4
languageName: unknown
linkType: soft
@@ -40593,6 +40594,13 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.21.4":
version: 3.21.4
resolution: "zod@npm:3.21.4"
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
languageName: node
linkType: hard
"zod@npm:~3.18.0":
version: 3.18.0
resolution: "zod@npm:3.18.0"