diff --git a/.changeset/hungry-lions-sit.md b/.changeset/hungry-lions-sit.md new file mode 100644 index 0000000000..b283cc976f --- /dev/null +++ b/.changeset/hungry-lions-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Use the Gitbeaker library for `gitlab:projectAccessToken:create` action, enabling the `expiresAt` option diff --git a/packages/backend/package.json b/packages/backend/package.json index 17a2f3b307..ac5abc09ba 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -63,6 +63,7 @@ "@backstage/plugin-rollbar-backend": "workspace:^", "@backstage/plugin-scaffolder-backend": "workspace:^", "@backstage/plugin-scaffolder-backend-module-confluence-to-markdown": "workspace:^", + "@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^", "@backstage/plugin-scaffolder-backend-module-rails": "workspace:^", "@backstage/plugin-search-backend": "workspace:^", "@backstage/plugin-search-backend-module-catalog": "workspace:^", diff --git a/plugins/scaffolder-backend-module-gitlab/api-report.md b/plugins/scaffolder-backend-module-gitlab/api-report.md index 94aabe9b72..f7ec2edac8 100644 --- a/plugins/scaffolder-backend-module-gitlab/api-report.md +++ b/plugins/scaffolder-backend-module-gitlab/api-report.md @@ -62,6 +62,7 @@ export const createGitlabProjectAccessTokenAction: (options: { name?: string | undefined; accessLevel?: number | undefined; scopes?: string[] | undefined; + expiresAt?: string | undefined; }, { access_token: string; diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index 1a8cc7729b..c380e54ffa 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -49,6 +49,7 @@ "@gitbeaker/core": "^35.8.0", "@gitbeaker/node": "^35.8.0", "@gitbeaker/rest": "^39.25.0", + "luxon": "^3.0.0", "yaml": "^2.0.0", "zod": "^3.22.4" }, diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts index 6a809fc386..ab4fe0e637 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts @@ -13,23 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { PassThrough } from 'stream'; import yaml from 'yaml'; import { createGitlabProjectAccessTokenAction } from './createGitlabProjectAccessTokenAction'; // Adjust the import based on your project structure -import { ScmIntegrations } from '@backstage/integration'; -import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { examples } from './createGitlabProjectAccessTokenAction.examples'; +import { DateTime } from 'luxon'; + jest.mock('node-fetch'); const mockGitlabClient = { - ProjectDeployTokens: { + ProjectAccessTokens: { create: jest.fn(), }, }; -jest.mock('@gitbeaker/node', () => ({ +jest.mock('@gitbeaker/rest', () => ({ Gitlab: class { constructor() { return mockGitlabClient; @@ -73,231 +75,127 @@ describe('gitlab:projectAccessToken:create examples', () => { }); it('Create a GitLab project access token with minimal options.', async () => { - const fetchMock = jest.spyOn(global, 'fetch'); - const mockResponse = { - token: 'mock-access-token', - }; - - fetchMock.mockResolvedValue({ - json: async () => mockResponse, - } as Response); - - jest.mock('../util', () => ({ - getToken: jest.fn().mockReturnValue({ - token: 'mock-api-token', - integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, - }), - })); + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'TOKEN', + username: 'User', + }); const input = yaml.parse(examples[0].example).steps[0].input; - await action.handler({ ...mockContext, input, }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://gitlab.com/api/v4/projects/456/access_tokens', + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '456', + 'tokenname', + ['read_repository'], { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': 'tokenlols', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: undefined, - scopes: undefined, - access_level: undefined, - }), + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, }, ); - expect(mockContext.output).toHaveBeenCalledWith( - 'access_token', - 'mock-access-token', - ); + + expect(mockContext.output).toHaveBeenCalledWith('access_token', 'TOKEN'); }); it('Create a GitLab project access token with custom scopes.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'TOKEN', + username: 'User', + }); + const input = yaml.parse(examples[1].example).steps[0].input; - - const mockResponse = { - token: 'mock-access-token', - }; - - const fetchMock = jest.spyOn(global, 'fetch'); - fetchMock.mockResolvedValue({ - json: async () => mockResponse, - } as Response); - - jest.mock('../util', () => ({ - getToken: jest.fn().mockReturnValue({ - token: 'mock-api-token', - integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, - }), - })); - await action.handler({ ...mockContext, input, }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://gitlab.com/api/v4/projects/789/access_tokens', + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '789', + 'tokenname', + ['read_registry', 'write_repository'], { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': 'tokenlols', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: undefined, - scopes: ['read_registry', 'write_repository'], - access_level: undefined, - }), + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, }, ); - expect(mockContext.output).toHaveBeenCalledWith( - 'access_token', - 'mock-access-token', - ); + expect(mockContext.output).toHaveBeenCalledWith('access_token', 'TOKEN'); }); it('Create a GitLab project access token with a specified name.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'TOKEN', + username: 'User', + }); + const input = yaml.parse(examples[2].example).steps[0].input; - - const mockResponse = { - token: 'mock-access-token', - }; - - const fetchMock = jest.spyOn(global, 'fetch'); - fetchMock.mockResolvedValue({ - json: async () => mockResponse, - } as Response); - - jest.mock('../util', () => ({ - getToken: jest.fn().mockReturnValue({ - token: 'mock-api-token', - integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, - }), - })); - await action.handler({ ...mockContext, input, }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://gitlab.com/api/v4/projects/101112/access_tokens', + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '101112', + 'my-custom-token', + ['read_repository'], { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': 'tokenlols', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: 'my-custom-token', - scopes: undefined, - access_level: undefined, - }), + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, }, ); - expect(mockContext.output).toHaveBeenCalledWith( - 'access_token', - 'mock-access-token', - ); + expect(mockContext.output).toHaveBeenCalledWith('access_token', 'TOKEN'); }); it('Create a GitLab project access token with a numeric project ID.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'TOKEN', + username: 'User', + }); + const input = yaml.parse(examples[3].example).steps[0].input; - - const mockResponse = { - token: 'mock-access-token', - }; - - const fetchMock = jest.spyOn(global, 'fetch'); - fetchMock.mockResolvedValue({ - json: async () => mockResponse, - } as Response); - - jest.mock('../util', () => ({ - getToken: jest.fn().mockReturnValue({ - token: 'mock-api-token', - integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, - }), - })); - await action.handler({ ...mockContext, input, }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://gitlab.com/api/v4/projects/42/access_tokens', + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + 42, + 'tokenname', + ['read_repository'], { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': 'tokenlols', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: undefined, - scopes: undefined, - access_level: undefined, - }), + accessLevel: 40, + expiresAt: DateTime.now().plus({ days: 365 }).toISODate()!, }, ); - expect(mockContext.output).toHaveBeenCalledWith( - 'access_token', - 'mock-access-token', - ); + expect(mockContext.output).toHaveBeenCalledWith('access_token', 'TOKEN'); }); - it('Create a GitLab project access token using specific GitLab integrations.', async () => { + it('Create a GitLab project access token with a specified expired Date.', async () => { + mockGitlabClient.ProjectAccessTokens.create.mockResolvedValue({ + token: 'TOKEN', + username: 'User', + }); + const input = yaml.parse(examples[4].example).steps[0].input; - - const mockResponse = { - token: 'mock-access-token', - }; - - const fetchMock = jest.spyOn(global, 'fetch'); - fetchMock.mockResolvedValue({ - json: async () => mockResponse, - } as Response); - - jest.mock('../util', () => ({ - getToken: jest.fn().mockReturnValue({ - token: 'mock-api-token', - integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, - }), - })); - await action.handler({ ...mockContext, input, }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://gitlab.com/api/v4/projects/123/access_tokens', + expect(mockGitlabClient.ProjectAccessTokens.create).toHaveBeenCalledWith( + '123', + 'tokenname', + ['read_repository'], { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': 'tokenlols', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: undefined, - scopes: undefined, - access_level: undefined, - }), + accessLevel: 40, + expiresAt: '2024-06-25', }, ); - expect(mockContext.output).toHaveBeenCalledWith( - 'access_token', - 'mock-access-token', - ); + expect(mockContext.output).toHaveBeenCalledWith('access_token', 'TOKEN'); }); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts index 5fe157badf..8d4f335a46 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts @@ -86,7 +86,7 @@ export const examples: TemplateExample[] = [ }, { description: - 'Create a GitLab project access token using specific GitLab integrations.', + 'Create a GitLab project access token with a specified expired Date.', example: yaml.stringify({ steps: [ { @@ -96,6 +96,7 @@ export const examples: TemplateExample[] = [ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', projectId: '123', + expiresAt: '2024-06-25', }, }, ], diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts index a050b3c1b4..2193dbb02f 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts @@ -14,11 +14,13 @@ * limitations under the License. */ -import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import commonGitlabConfig from '../commonGitlabConfig'; -import { getToken } from '../util'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { AccessTokenScopes, Gitlab } from '@gitbeaker/rest'; +import { DateTime } from 'luxon'; import { z } from 'zod'; +import { getToken } from '../util'; import { examples } from './createGitlabProjectAccessTokenAction.examples'; /** @@ -27,6 +29,7 @@ import { examples } from './createGitlabProjectAccessTokenAction.examples'; * @param options - Templating configuration. * @public */ + export const createGitlabProjectAccessTokenAction = (options: { integrations: ScmIntegrationRegistry; }) => { @@ -35,46 +38,88 @@ export const createGitlabProjectAccessTokenAction = (options: { id: 'gitlab:projectAccessToken:create', examples, schema: { - input: commonGitlabConfig.merge( - z.object({ - projectId: z.union([z.number(), z.string()], { - description: 'Project ID', - }), - name: z.string({ description: 'Deploy Token Name' }).optional(), - accessLevel: z - .number({ description: 'Access Level of the Token' }) - .optional(), - scopes: z.array(z.string(), { description: 'Scopes' }).optional(), + input: z.object({ + projectId: z.union([z.number(), z.string()], { + description: 'Project ID/Name(slug) of the Gitlab Project', }), - ), + token: z + .string({ + description: 'The token to use for authorization to GitLab', + }) + .optional(), + name: z.string({ description: 'Name of Access Key' }).optional(), + repoUrl: z.string({ description: 'URL to gitlab instance' }), + accessLevel: z + .number({ + description: + 'Access Level of the Token, 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), and 50 (Owner)', + }) + .optional(), + scopes: z + .string({ + description: 'Scopes for a project access token', + }) + .array() + .optional(), + expiresAt: z + .string({ + description: + 'Expiration date of the access token in ISO format (YYYY-MM-DD). If Empty, it will set to the maximum of 365 days.', + }) + .optional(), + }), output: z.object({ access_token: z.string({ description: 'Access Token' }), }), }, async handler(ctx) { ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`); - const { projectId, name, accessLevel, scopes } = ctx.input; + const { + projectId, + name = 'tokenname', + accessLevel = 40, + scopes = ['read_repository'], + expiresAt, + } = ctx.input; + const { token, integrationConfig } = getToken(ctx.input, integrations); - const response = await fetch( - `${integrationConfig.config.baseUrl}/api/v4/projects/${projectId}/access_tokens`, + if (!integrationConfig.config.token && token) { + throw new InputError( + `No token available for host ${integrationConfig.config.baseUrl}`, + ); + } + + let api; + + if (!ctx.input.token) { + api = new Gitlab({ + host: integrationConfig.config.baseUrl, + token: token, + }); + } else { + api = new Gitlab({ + host: integrationConfig.config.baseUrl, + oauthToken: token, + }); + } + + const response = await api.ProjectAccessTokens.create( + projectId, + name, + scopes as AccessTokenScopes[], { - method: 'POST', // *GET, POST, PUT, DELETE, etc. - headers: { - 'PRIVATE-TOKEN': token, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: name, - scopes: scopes, - access_level: accessLevel, - }), + expiresAt: + expiresAt || DateTime.now().plus({ days: 365 }).toISODate()!, + accessLevel, }, ); - const result = await response.json(); + if (!response.token) { + throw new Error('Could not create project access token'); + } - ctx.output('access_token', result.token); + ctx.output('access_token', response.token); }, }); }; diff --git a/plugins/scaffolder-backend-module-gitlab/src/util.ts b/plugins/scaffolder-backend-module-gitlab/src/util.ts index a4d819d490..d897604956 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/util.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/util.ts @@ -19,9 +19,9 @@ import { GitLabIntegration, ScmIntegrationRegistry, } from '@backstage/integration'; +import { Gitlab, GroupSchema } from '@gitbeaker/rest'; import { z } from 'zod'; import commonGitlabConfig from './commonGitlabConfig'; -import { Gitlab, GroupSchema } from '@gitbeaker/rest'; import * as util from './util'; export const parseRepoHost = (repoUrl: string): string => { @@ -50,11 +50,6 @@ export const getToken = ( } 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`); - } return { token: token, integrationConfig: integrationConfig }; }; diff --git a/yarn.lock b/yarn.lock index 363f1751b5..6efdaa1d5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8533,6 +8533,7 @@ __metadata: "@gitbeaker/node": ^35.8.0 "@gitbeaker/rest": ^39.25.0 jest-date-mock: ^1.0.8 + luxon: ^3.0.0 yaml: ^2.0.0 zod: ^3.22.4 languageName: unknown @@ -27741,6 +27742,7 @@ __metadata: "@backstage/plugin-rollbar-backend": "workspace:^" "@backstage/plugin-scaffolder-backend": "workspace:^" "@backstage/plugin-scaffolder-backend-module-confluence-to-markdown": "workspace:^" + "@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^" "@backstage/plugin-scaffolder-backend-module-rails": "workspace:^" "@backstage/plugin-search-backend": "workspace:^" "@backstage/plugin-search-backend-module-catalog": "workspace:^"