diff --git a/.changeset/hungry-lions-sit.md b/.changeset/hungry-lions-sit.md new file mode 100644 index 0000000000..76a46aacde --- /dev/null +++ b/.changeset/hungry-lions-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Establishing compatibility of scaffolder action gitlab:projectAccessToken:create with GitLab version ^16 by introducing the expired at parameter. diff --git a/packages/backend/package.json b/packages/backend/package.json index 2386d799a9..b8b2961870 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/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..4ee9375064 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts @@ -14,11 +14,14 @@ * 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 } from '@gitbeaker/core'; +import { Gitlab } from '@gitbeaker/rest'; +import { DateTime } from 'luxon'; import { z } from 'zod'; +import { getToken } from '../util'; import { examples } from './createGitlabProjectAccessTokenAction.examples'; /** @@ -27,6 +30,9 @@ import { examples } from './createGitlabProjectAccessTokenAction.examples'; * @param options - Templating configuration. * @public */ + +const gitbeakerAccessTokenScopesType: z.ZodType = z.any(); + export const createGitlabProjectAccessTokenAction = (options: { integrations: ScmIntegrationRegistry; }) => { @@ -35,46 +41,86 @@ 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.number({ + description: 'Project ID/Name(slug) of the Gitlab Project', }), - ), + 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: gitbeakerAccessTokenScopesType, + 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(), + token: z + .string({ + description: 'The token to use for authorization to GitLab', + }) + .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 mappedAccessLevel = Number(accessLevel.valueOf()) as AccessLevel; + + const response = await api.ProjectAccessTokens.create( + projectId, + name, + scopes, { - 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: mappedAccessLevel, }, ); + if (!response.token) { + throw new Error('Could not create project access token'); + } - const result = await response.json(); - - ctx.output('access_token', result.token); + ctx.output('access_token', response.token); }, }); }; + +declare type AccessLevel = 0 | 5 | 10 | 20 | 30 | 40 | 50; diff --git a/yarn.lock b/yarn.lock index 8c0ddcabc2..703e7debab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8385,6 +8385,7 @@ __metadata: "@gitbeaker/node": ^35.8.0 "@gitbeaker/rest": ^39.25.0 jest-date-mock: ^1.0.8 + luxon: ^3.4.4 yaml: ^2.0.0 zod: ^3.22.4 languageName: unknown @@ -26885,6 +26886,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:^" @@ -33297,7 +33299,7 @@ __metadata: languageName: node linkType: hard -"luxon@npm:^3.0.0, luxon@npm:^3.3.0, luxon@npm:^3.4.3": +"luxon@npm:^3.0.0, luxon@npm:^3.3.0, luxon@npm:^3.4.3, luxon@npm:^3.4.4": version: 3.4.4 resolution: "luxon@npm:3.4.4" checksum: 36c1f99c4796ee4bfddf7dc94fa87815add43ebc44c8934c924946260a58512f0fd2743a629302885df7f35ccbd2d13f178c15df046d0e3b6eb71db178f1c60c