diff --git a/.changeset/lemon-jars-teach.md b/.changeset/lemon-jars-teach.md new file mode 100644 index 0000000000..d8392d3a92 --- /dev/null +++ b/.changeset/lemon-jars-teach.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Added the ability to collect users `oauth` token from the `RepoUrlPicker` for use in the template manifest diff --git a/.changeset/twenty-queens-scream.md b/.changeset/twenty-queens-scream.md new file mode 100644 index 0000000000..92626ed12c --- /dev/null +++ b/.changeset/twenty-queens-scream.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added support for templating secrets into actions input, and also added an extra `token` input argument to all publishers to provide a token that would override the `integrations.config`. +You can find more information over at [Writing Templates](https://backstage.io/docs/features/software-templates/writing-templates#using-the-users-oauth-token) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index a1dffe1580..415f8cd85e 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -287,6 +287,79 @@ The `RepoUrlPicker` is a custom field that we provide part of the `plugin-scaffolder`. You can provide your own custom fields by [writing your own Custom Field Extensions](./writing-custom-field-extensions.md) +##### Using the Users `oauth` token + +There's a little bit of extra magic that you get out of the box when using the +`RepoUrlPicker` as a field input. You can provide some additional options under +`ui:options` to allow the `RepoUrlPicker` to grab an `oauth` token for the user +for the required `repository`. + +This is great for when you are wanting to create a new repository, or wanting to +perform operations on top of an existing repository. + +A sample template that takes advantage of this is like so: + +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: v1beta3-demo + title: Test Action template + description: scaffolder v1beta3 template demo +spec: + owner: backstage/techdocs-core + type: service + + parameters: + ... + + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + # Here's the option you can pass to the RepoUrlPicker + requestUserCredentials: + secretsKey: USER_OAUTH_TOKEN + additionalScopes: + github: + - workflow:write + allowedHosts: + - github.com + ... + + steps: + ... + + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: This is ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + # here's where the secret can be used + token: ${{ secrets.USER_OAUTH_TOKEN }} + + ... +``` + +You will see from above that there is an additional `requestUserCredentials` +object that is passed to the `RepoUrlPicker`. This object defines what the +returned `secret` should be stored as when accessing using +`${{ secrets.secretName }}`, in this case it is `USER_OAUTH_TOKEN`. And then you +will see that there is an additional `input` field into the `publish:github` +action called `token`, in which you can use the `secret` like so: +`token: ${{ secrets.USER_OAUTH_TOKEN }}`. + +There's also the ability to pass additional scopes when requesting the `oauth` +token from the user, which you can do on a per-provider basis, in case your +template can be published to multiple providers. + #### The Owner Picker When the scaffolder needs to add new components to the catalog, it needs to have diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md index 2db29631a3..5b0c9d6174 100644 --- a/packages/integration-react/api-report.md +++ b/packages/integration-react/api-report.md @@ -29,6 +29,7 @@ export class ScmAuth implements ScmAuthApi { ProfileInfoApi & BackstageIdentityApi & SessionApi; + bitbucket: OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi; } >; static forAuthApi( @@ -82,6 +83,12 @@ export const scmAuthApiRef: ApiRef; export interface ScmAuthTokenOptions extends AuthRequestOptions { additionalScope?: { repoWrite?: boolean; + customScopes?: { + github?: string[]; + azure?: string[]; + bitbucket?: string[]; + gitlab?: string[]; + }; }; url: string; } diff --git a/packages/integration-react/src/api/ScmAuth.test.ts b/packages/integration-react/src/api/ScmAuth.test.ts index 2d0ea9b97d..4ebfef749e 100644 --- a/packages/integration-react/src/api/ScmAuth.test.ts +++ b/packages/integration-react/src/api/ScmAuth.test.ts @@ -141,6 +141,58 @@ describe('ScmAuth', () => { }); }); + it('should support additional provided scopes from the caller', async () => { + const mockAuthApi = { + getAccessToken: async (scopes: string[]) => { + return scopes.join(' '); + }, + }; + + const githubAuth = ScmAuth.forGithub(mockAuthApi); + await expect( + githubAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { + customScopes: { github: ['org:read', 'workflow:write'] }, + }, + }), + ).resolves.toMatchObject({ + token: 'repo read:org read:user org:read workflow:write', + }); + + const gitlabAuth = ScmAuth.forGitlab(mockAuthApi); + await expect( + gitlabAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { customScopes: { gitlab: ['write_repository'] } }, + }), + ).resolves.toMatchObject({ + token: 'read_user read_api read_repository write_repository', + }); + + const azureAuth = ScmAuth.forAzure(mockAuthApi); + await expect( + azureAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { customScopes: { azure: ['vso.org'] } }, + }), + ).resolves.toMatchObject({ + token: 'vso.build vso.code vso.graph vso.project vso.profile vso.org', + }); + + const bitbucketAuth = ScmAuth.forBitbucket(mockAuthApi); + await expect( + bitbucketAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { + customScopes: { bitbucket: ['snippet:write', 'issue:write'] }, + }, + }), + ).resolves.toMatchObject({ + token: 'account team pullrequest snippet issue snippet:write issue:write', + }); + }); + it('should handle host option', () => { const mockAuthApi = { getAccessToken: jest.fn(), diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index be3216962d..071374e6cb 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -15,6 +15,7 @@ */ import { + bitbucketAuthApiRef, createApiFactory, githubAuthApiRef, gitlabAuthApiRef, @@ -35,6 +36,9 @@ type ScopeMapping = { repoWrite: string[]; }; +// An enum of all supported providers +type ProviderName = 'generic' | 'github' | 'azure' | 'bitbucket' | 'gitlab'; + class ScmAuthMux implements ScmAuthApi { #providers: Array; @@ -93,12 +97,14 @@ export class ScmAuth implements ScmAuthApi { github: githubAuthApiRef, gitlab: gitlabAuthApiRef, azure: microsoftAuthApiRef, + bitbucket: bitbucketAuthApiRef, }, - factory: ({ github, gitlab, azure }) => + factory: ({ github, gitlab, azure, bitbucket }) => ScmAuth.merge( ScmAuth.forGithub(github), ScmAuth.forGitlab(gitlab), ScmAuth.forAzure(azure), + ScmAuth.forBitbucket(bitbucket), ), }); } @@ -116,7 +122,7 @@ export class ScmAuth implements ScmAuthApi { }; }, ): ScmAuth { - return new ScmAuth(authApi, options.host, options.scopeMapping); + return new ScmAuth('generic', authApi, options.host, options.scopeMapping); } /** @@ -139,7 +145,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'github.com'; - return new ScmAuth(githubAuthApi, host, { + return new ScmAuth('github', githubAuthApi, host, { default: ['repo', 'read:org', 'read:user'], repoWrite: ['gist'], }); @@ -165,7 +171,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'gitlab.com'; - return new ScmAuth(gitlabAuthApi, host, { + return new ScmAuth('gitlab', gitlabAuthApi, host, { default: ['read_user', 'read_api', 'read_repository'], repoWrite: ['write_repository', 'api'], }); @@ -191,7 +197,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'dev.azure.com'; - return new ScmAuth(microsoftAuthApi, host, { + return new ScmAuth('azure', microsoftAuthApi, host, { default: [ 'vso.build', 'vso.code', @@ -223,7 +229,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'bitbucket.org'; - return new ScmAuth(bitbucketAuthApi, host, { + return new ScmAuth('bitbucket', bitbucketAuthApi, host, { default: ['account', 'team', 'pullrequest', 'snippet', 'issue'], repoWrite: ['pullrequest:write', 'snippet:write', 'issue:write'], }); @@ -240,11 +246,18 @@ export class ScmAuth implements ScmAuthApi { #api: OAuthApi; #host: string; #scopeMapping: ScopeMapping; + #providerName: ProviderName; - private constructor(api: OAuthApi, host: string, scopeMapping: ScopeMapping) { + private constructor( + providerName: ProviderName, + api: OAuthApi, + host: string, + scopeMapping: ScopeMapping, + ) { this.#api = api; this.#host = host; this.#scopeMapping = scopeMapping; + this.#providerName = providerName; } /** @@ -254,6 +267,16 @@ export class ScmAuth implements ScmAuthApi { return url.host === this.#host; } + private getAdditionalScopesForProvider( + additionalScopes: ScmAuthTokenOptions['additionalScope'], + ): string[] { + if (!additionalScopes?.customScopes || this.#providerName === 'generic') { + return []; + } + + return additionalScopes.customScopes?.[this.#providerName] ?? []; + } + /** * Fetches credentials for the given resource. */ @@ -267,7 +290,17 @@ export class ScmAuth implements ScmAuthApi { scopes.push(...this.#scopeMapping.repoWrite); } - const token = await this.#api.getAccessToken(scopes, restOptions); + const additionalScopes = + this.getAdditionalScopesForProvider(additionalScope); + + if (additionalScopes.length) { + scopes.push(...additionalScopes); + } + + const uniqueScopes = [...new Set(scopes)]; + + const token = await this.#api.getAccessToken(uniqueScopes, restOptions); + return { token, headers: { diff --git a/packages/integration-react/src/api/ScmAuthApi.ts b/packages/integration-react/src/api/ScmAuthApi.ts index 44b007c4a2..be65842bc1 100644 --- a/packages/integration-react/src/api/ScmAuthApi.ts +++ b/packages/integration-react/src/api/ScmAuthApi.ts @@ -44,6 +44,16 @@ export interface ScmAuthTokenOptions extends AuthRequestOptions { * the ability to create things like issues and pull requests. */ repoWrite?: boolean; + /** + * Allow an arbitrary list of scopes provided from the user + * to request from the provider. + */ + customScopes?: { + github?: string[]; + azure?: string[]; + bitbucket?: string[]; + gitlab?: string[]; + }; }; } diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index cfe8f5d2b0..5e7b5a428b 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -299,7 +299,12 @@ export class OctokitProvider { githubCredentialsProvider?: GithubCredentialsProvider, ); // Warning: (ae-forgotten-export) The symbol "OctokitIntegration" needs to be exported by the entry point index.d.ts - getOctokit(repoUrl: string): Promise; + getOctokit( + repoUrl: string, + options?: { + token?: string; + }, + ): Promise; } // @public diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts index 0d4353c857..e2449917ab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts @@ -74,4 +74,15 @@ describe('getOctokit', () => { expect(owner).toBe('owner'); expect(repo).toBe('bob'); }); + + it('should return an octokit client with the passed in token if it is provided', async () => { + const { client, token, owner, repo } = await octokitProvider.getOctokit( + 'github.com?repo=bob&owner=owner', + { token: 'tokenlols2' }, + ); + expect(client).toBeDefined(); + expect(token).toBe('tokenlols2'); + expect(owner).toBe('owner'); + expect(repo).toBe('bob'); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts index a3eeec4373..3ac66a0023 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts @@ -52,7 +52,10 @@ export class OctokitProvider { * * @param repoUrl - Repository URL */ - async getOctokit(repoUrl: string): Promise { + async getOctokit( + repoUrl: string, + options?: { token?: string }, + ): Promise { const { owner, repo, host } = parseRepoUrl(repoUrl, this.integrations); if (!owner) { @@ -65,7 +68,19 @@ export class OctokitProvider { throw new InputError(`No integration for host ${host}`); } - // TODO(blam): Consider changing this API to have owner, repo interface instead of URL as the it's + // Short circuit the internal Github Token provider the token provided + // by the action or the caller. + if (options?.token) { + const client = new Octokit({ + auth: options.token, + baseUrl: integrationConfig.apiBaseUrl, + previews: ['nebula-preview'], + }); + + return { client, token: options.token, owner, repo }; + } + + // TODO(blam): Consider changing this API to have owner, repoo interface instead of URL as the it's // needless to create URL and then parse again the other side. const { token } = await this.githubCredentialsProvider.getCredentials({ url: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index 1f7f0f60b9..0c8b4d4aab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts @@ -37,6 +37,7 @@ export function createGithubActionsDispatchAction(options: { workflowId: string; branchOrTagName: string; workflowInputs?: { [key: string]: string }; + token?: string; }>({ id: 'github:actions:dispatch', description: @@ -68,18 +69,31 @@ export function createGithubActionsDispatchAction(options: { 'Inputs keys and values to send to GitHub Action configured on the workflow file. The maximum number of properties is 10. ', type: 'object', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITHUB_TOKEN to use for authorization to GitHub', + }, }, }, }, async handler(ctx) { - const { repoUrl, workflowId, branchOrTagName, workflowInputs } = - ctx.input; + const { + repoUrl, + workflowId, + branchOrTagName, + workflowInputs, + token: providedToken, + } = ctx.input; ctx.logger.info( `Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`, ); - const { client, owner, repo } = await octokitProvider.getOctokit(repoUrl); + const { client, owner, repo } = await octokitProvider.getOctokit( + repoUrl, + { token: providedToken }, + ); await client.rest.actions.createWorkflowDispatch({ owner, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts index 39e8c3aa70..660c7793f2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts @@ -47,6 +47,7 @@ export function createGithubWebhookAction(options: { active?: boolean; contentType?: ContentType; insecureSsl?: boolean; + token?: string; }>({ id: 'github:webhook', description: 'Creates webhook for a repository on GitHub.', @@ -107,6 +108,11 @@ export function createGithubWebhookAction(options: { type: 'boolean', description: `Determines whether the SSL certificate of the host for url will be verified when delivering payloads. Default 'false'`, }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The GITHUB_TOKEN to use for authorization to GitHub', + }, }, }, }, @@ -119,11 +125,15 @@ export function createGithubWebhookAction(options: { active = true, contentType = 'form', insecureSsl = false, + token: providedToken, } = ctx.input; ctx.logger.info(`Creating webhook ${webhookUrl} for repo ${repoUrl}`); - const { client, owner, repo } = await octokitProvider.getOctokit(repoUrl); + const { client, owner, repo } = await octokitProvider.getOctokit( + repoUrl, + { token: providedToken }, + ); try { const insecure_ssl = insecureSsl ? '1' : '0'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts index cee99e1646..9247109121 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.test.ts @@ -119,6 +119,32 @@ describe('publish:azure', () => { ).rejects.toThrow(/Unable to create the repository/); }); + it('should not throw if there is a token provided through ctx.input', async () => { + mockGitClient.createRepository.mockImplementation(() => ({ + remoteUrl: 'http://google.com', + })); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'myazurehostnotoken.com?repo=bob&owner=owner&organization=org', + token: 'lols', + }, + }); + + expect(WebApi).toHaveBeenCalledWith( + 'https://myazurehostnotoken.com/org', + expect.any(Function), + ); + + expect(mockGitClient.createRepository).toHaveBeenCalledWith( + { + name: 'bob', + }, + 'owner', + ); + }); + it('should throw if there is no remoteUrl returned', async () => { mockGitClient.createRepository.mockImplementation(() => ({ remoteUrl: null, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 7e394a8d8b..4f67a0e45f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -34,6 +34,7 @@ export function createPublishAzureAction(options: { description?: string; defaultBranch?: string; sourcePath?: string; + token?: string; }>({ id: 'publish:azure', description: @@ -57,10 +58,16 @@ export function createPublishAzureAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to Azure', + }, }, }, output: { @@ -98,12 +105,13 @@ export function createPublishAzureAction(options: { `No matching integration configuration for host ${host}, please check your integrations config`, ); } - if (!integrationConfig.config.token) { + + if (!integrationConfig.config.token && !ctx.input.token) { throw new InputError(`No token provided for Azure Integration ${host}`); } - const authHandler = getPersonalAccessTokenHandler( - integrationConfig.config.token, - ); + + const token = ctx.input.token ?? integrationConfig.config.token!; + const authHandler = getPersonalAccessTokenHandler(token); const webApi = new WebApi(`https://${host}/${organization}`, authHandler); const client = await webApi.getGitApi(); @@ -139,7 +147,7 @@ export function createPublishAzureAction(options: { defaultBranch, auth: { username: 'notempty', - password: integrationConfig.config.token, + password: token, }, logger: ctx.logger, commitMessage: config.getOptionalString( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts index 2678abd9dd..3d14710f3e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.test.ts @@ -194,6 +194,45 @@ describe('publish:bitbucket', () => { }); }); + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(2); + server.use( + rest.post( + 'https://notoken.bitbucket.com/rest/api/1.0/projects/project/repos', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer lols'); + expect(req.body).toEqual({ public: false, name: 'repo' }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: 'https://bitbucket.mycompany.com/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: 'https://bitbucket.mycompany.com/scm/project/repo', + }, + ], + }, + }), + ); + }, + ), + ); + await action.handler({ + ...mockContext, + input: { + repoUrl: 'notoken.bitbucket.com?project=project&repo=repo', + token: 'lols', + }, + }); + }); + describe('LFS for hosted bitbucket', () => { const repoCreationResponse = { links: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index fbfe87b076..8ba8592642 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -205,6 +205,7 @@ export function createPublishBitbucketAction(options: { repoVisibility: 'private' | 'public'; sourcePath?: string; enableLFS: boolean; + token?: string; }>({ id: 'publish:bitbucket', description: @@ -233,15 +234,22 @@ export function createPublishBitbucketAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, enableLFS: { - title: + title: 'Enable LFS?', + description: 'Enable LFS for the repository. Only available for hosted Bitbucket.', type: 'boolean', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to BitBucket', + }, }, }, output: { @@ -296,7 +304,12 @@ export function createPublishBitbucketAction(options: { ); } - const authorization = getAuthorizationHeader(integrationConfig.config); + const authorization = getAuthorizationHeader( + ctx.input.token + ? { host: integrationConfig.config.host, token: ctx.input.token } + : integrationConfig.config, + ); + const apiBaseUrl = integrationConfig.config.apiBaseUrl; const createMethod = @@ -320,17 +333,28 @@ export function createPublishBitbucketAction(options: { email: config.getOptionalString('scaffolder.defaultAuthor.email'), }; - await initRepoAndPush({ - dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), - remoteUrl, - auth: { + let auth; + + if (ctx.input.token) { + auth = { + username: 'x-token-auth', + password: ctx.input.token, + }; + } else { + auth = { username: integrationConfig.config.username ? integrationConfig.config.username : 'x-token-auth', password: integrationConfig.config.appPassword ? integrationConfig.config.appPassword : integrationConfig.config.token ?? '', - }, + }; + } + + await initRepoAndPush({ + dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), + remoteUrl, + auth, defaultBranch, logger: ctx.logger, commitMessage: config.getOptionalString( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 081f9616ac..139fad04d4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -52,6 +52,7 @@ export function createPublishGithubAction(options: { requireCodeOwnerReviews?: boolean; repoVisibility: 'private' | 'internal' | 'public'; collaborators: Collaborator[]; + token?: string; topics?: string[]; }>({ id: 'publish:github', @@ -77,7 +78,8 @@ export function createPublishGithubAction(options: { type: 'string', }, requireCodeOwnerReviews: { - title: + title: 'Require CODEOWNER Reviews?', + description: 'Require an approved review in PR including files with a designated Code Owner', type: 'boolean', }, @@ -92,7 +94,8 @@ export function createPublishGithubAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, @@ -116,6 +119,11 @@ export function createPublishGithubAction(options: { }, }, }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to GitHub', + }, topics: { title: 'Topics', type: 'array', @@ -149,10 +157,12 @@ export function createPublishGithubAction(options: { defaultBranch = 'master', collaborators, topics, + token: providedToken, } = ctx.input; const { client, token, owner, repo } = await octokitProvider.getOctokit( repoUrl, + { token: providedToken }, ); const user = await client.rest.users.getByUsername({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 841d4c7a82..80333c4966 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -55,6 +55,7 @@ export type GithubPullRequestActionInput = { repoUrl: string; targetPath?: string; sourcePath?: string; + token?: string; }; export type ClientFactoryInput = { @@ -63,6 +64,7 @@ export type ClientFactoryInput = { host: string; owner: string; repo: string; + token?: string; }; export const defaultClientFactory = async ({ @@ -71,13 +73,22 @@ export const defaultClientFactory = async ({ owner, repo, host = 'github.com', + token: providedToken, }: ClientFactoryInput): Promise => { const integrationConfig = integrations.github.byHost(host)?.config; + const OctokitPR = Octokit.plugin(createPullRequest); if (!integrationConfig) { throw new InputError(`No integration for host ${host}`); } + if (providedToken) { + return new OctokitPR({ + auth: providedToken, + baseUrl: integrationConfig.apiBaseUrl, + }); + } + const credentialsProvider = githubCredentialsProvider || SingleInstanceGithubCredentialsProvider.create(integrationConfig); @@ -94,8 +105,6 @@ export const defaultClientFactory = async ({ ); } - const OctokitPR = Octokit.plugin(createPullRequest); - return new OctokitPR({ auth: token, baseUrl: integrationConfig.apiBaseUrl, @@ -151,6 +160,11 @@ export const createPublishGithubPullRequestAction = ({ title: 'Repository Subdirectory', description: 'Subdirectory of repository to apply changes to', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to GitHub', + }, }, }, output: { @@ -173,6 +187,7 @@ export const createPublishGithubPullRequestAction = ({ description, targetPath, sourcePath, + token: providedToken, } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); @@ -189,7 +204,9 @@ export const createPublishGithubPullRequestAction = ({ host, owner, repo, + token: providedToken, }); + const fileRoot = sourcePath ? resolveSafeChildPath(ctx.workspacePath, sourcePath) : ctx.workspacePath; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts index 2cd27da359..a32fea8830 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.test.ts @@ -96,6 +96,28 @@ describe('publish:gitlab', () => { ).rejects.toThrow(/No token available for host/); }); + it('should work when there is a token provided through ctx.input', async () => { + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'http://mockurl.git', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'hosted.gitlab.com?repo=bob&owner=owner', + token: 'token', + }, + }); + + expect(mockGitlabClient.Namespaces.show).toHaveBeenCalledWith('owner'); + expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ + namespace_id: 1234, + name: 'bob', + visibility: 'private', + }); + }); + it('should call the correct Gitlab APIs when the owner is an organization', async () => { mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 }); mockGitlabClient.Projects.create.mockResolvedValue({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 5941f52746..a04766f941 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -33,6 +33,7 @@ export function createPublishGitlabAction(options: { defaultBranch?: string; repoVisibility: 'private' | 'internal' | 'public'; sourcePath?: string; + token?: string; }>({ id: 'publish:gitlab', description: @@ -57,10 +58,16 @@ export function createPublishGitlabAction(options: { description: `Sets the default branch on the repository. The default value is 'master'`, }, sourcePath: { - title: + title: 'Source Path', + description: 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.', type: 'string', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to GitLab', + }, }, }, output: { @@ -100,13 +107,15 @@ export function createPublishGitlabAction(options: { ); } - if (!integrationConfig.config.token) { + if (!integrationConfig.config.token && !ctx.input.token) { throw new InputError(`No token available for host ${host}`); } + const token = ctx.input.token || integrationConfig.config.token!; + const client = new Gitlab({ host: integrationConfig.config.baseUrl, - token: integrationConfig.config.token, + token, }); let { id: targetNamespace } = (await client.Namespaces.show(owner)) as { @@ -140,7 +149,7 @@ export function createPublishGitlabAction(options: { defaultBranch, auth: { username: 'oauth2', - password: integrationConfig.config.token, + password: token, }, logger: ctx.logger, commitMessage: config.getOptionalString( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts index 2ac369249b..281b72789f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -31,6 +31,7 @@ export type GitlabMergeRequestActionInput = { description: string; branchName: string; targetPath: string; + token?: string; }; export const createPublishGitlabMergeRequestAction = (options: { @@ -75,6 +76,11 @@ export const createPublishGitlabMergeRequestAction = (options: { title: 'Repository Subdirectory', description: 'Subdirectory of repository to apply changes to', }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to GitLab', + }, }, }, output: { @@ -107,13 +113,15 @@ export const createPublishGitlabMergeRequestAction = (options: { ); } - if (!integrationConfig.config.token) { + if (!integrationConfig.config.token && !ctx.input.token) { throw new InputError(`No token available for host ${host}`); } + const token = ctx.input.token ?? integrationConfig.config.token!; + const api = new Gitlab({ host: integrationConfig.config.baseUrl, - token: integrationConfig.config.token, + token, }); const fileRoot = ctx.workspacePath; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 161d5b865b..2fc6b99406 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -497,6 +497,60 @@ describe('DefaultWorkflowRunner', () => { expect.objectContaining({ secrets: { foo: 'bar' } }), ); }); + + it('should be able to template secrets into the input of an action', async () => { + const task = createMockTaskWithSpec( + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-mock-action', + input: { + b: '${{ secrets.foo }}', + }, + }, + ], + output: {}, + parameters: {}, + }, + { foo: 'bar' }, + ); + + await runner.execute(task); + + expect(fakeActionHandler).toHaveBeenCalledWith( + expect.objectContaining({ input: { b: 'bar' } }), + ); + }); + + it('does not allow templating of secrets as an output', async () => { + const task = createMockTaskWithSpec( + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-mock-action', + input: { + b: '${{ secrets.foo }}', + }, + }, + ], + output: { + b: '${{ secrets.foo }}', + }, + parameters: {}, + }, + { foo: 'bar' }, + ); + + const executedTask = await runner.execute(task); + + expect(executedTask.output.b).toBeUndefined(); + }); }); describe('filters', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 5089998834..889e94d000 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -53,6 +53,7 @@ type TemplateContext = { steps: { [stepName: string]: { output: { [outputName: string]: JsonValue } }; }; + secrets?: Record; }; const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta3 => { @@ -231,8 +232,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const action = this.options.actionRegistry.get(step.action); const { taskLogger, streamLogger } = createStepLogger({ task, step }); + // Secrets are only passed when templating the input to actions for security reasons const input = - (step.input && this.render(step.input, context, renderTemplate)) ?? + (step.input && + this.render( + step.input, + { ...context, secrets: task.secrets ?? {} }, + renderTemplate, + )) ?? {}; if (action.schema?.input) { diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index b77fb5f939..80b5faf201 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -164,6 +164,16 @@ export interface RepoUrlPickerUiOptions { allowedHosts?: string[]; // (undocumented) allowedOwners?: string[]; + // (undocumented) + requestUserCredentials?: { + secretsKey: string; + additionalScopes?: { + github?: string[]; + gitlab?: string[]; + bitbucket?: string[]; + azure?: string[]; + }; + }; } // @public @@ -317,4 +327,9 @@ export const TextValuePicker: ({ idSchema, placeholder, }: FieldProps) => JSX.Element; + +// @public +export const useTemplateSecrets: () => { + setSecret: (input: Record) => void; +}; ``` diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index e667c676f2..ab79f9dec3 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -21,6 +21,7 @@ import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; +import { SecretsContextProvider } from './secrets/SecretsContext'; import { FieldExtensionOptions, @@ -77,7 +78,11 @@ export const Router = ({ TemplateCardComponent, groups }: RouterProps) => { /> } + element={ + + + + } /> } /> } /> diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 8ce7ecf6b3..852ae3d81c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -17,12 +17,13 @@ import { JsonObject, JsonValue } from '@backstage/types'; import { LinearProgress } from '@material-ui/core'; import { FormValidation, IChangeEvent } from '@rjsf/core'; import qs from 'qs'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { generatePath, Navigate, useNavigate } from 'react-router'; import { useParams } from 'react-router-dom'; import useAsync from 'react-use/lib/useAsync'; import { scaffolderApiRef } from '../../api'; import { CustomFieldValidator, FieldExtensionOptions } from '../../extensions'; +import { SecretsContext } from '../secrets/SecretsContext'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; @@ -115,6 +116,7 @@ export const TemplatePage = ({ customFieldExtensions?: FieldExtensionOptions[]; }) => { const apiHolder = useApiHolder(); + const secretsContext = useContext(SecretsContext); const errorApi = useApi(errorApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); @@ -135,7 +137,11 @@ export const TemplatePage = ({ ); const handleCreate = async () => { - const id = await scaffolderApi.scaffold(templateName, formState); + const id = await scaffolderApi.scaffold( + templateName, + formState, + secretsContext?.secrets, + ); const formParams = qs.stringify( { formData: formState }, diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx new file mode 100644 index 0000000000..829495cdda --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -0,0 +1,177 @@ +/* + * Copyright 2022 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 React, { useContext } from 'react'; +import { RepoUrlPicker } from './RepoUrlPicker'; +import Form from '@rjsf/core'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { + scmIntegrationsApiRef, + ScmIntegrationsApi, + scmAuthApiRef, + ScmAuthApi, +} from '@backstage/integration-react'; +import { scaffolderApiRef, ScaffolderApi } from '../../../api'; +import { + SecretsContextProvider, + SecretsContext, +} from '../../secrets/SecretsContext'; +import { act, fireEvent } from '@testing-library/react'; + +describe('RepoUrlPicker', () => { + const mockScaffolderApi: Partial = { + getIntegrationsList: async () => [ + { host: 'github.com', type: 'github', title: 'github.com' }, + { host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' }, + ], + }; + + const mockIntegrationsApi: Partial = { + byHost: () => ({ type: 'github' }), + }; + + const mockScmAuthApi: Partial = { + getCredentials: jest.fn().mockResolvedValue({ token: 'abc123' }), + }; + + describe('happy path rendering', () => { + it('should render the repo url picker with minimal props', async () => { + const onSubmit = jest.fn(); + const { getAllByRole, getByRole } = await renderInTestApp( + + +
+ + , + ); + + const [ownerInput, repoInput] = getAllByRole('textbox'); + const submitButton = getByRole('button'); + + fireEvent.change(ownerInput, { target: { value: 'backstage' } }); + fireEvent.change(repoInput, { target: { value: 'repo123' } }); + + fireEvent.click(submitButton); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + formData: 'github.com?owner=backstage&repo=repo123', + }), + expect.anything(), + ); + }); + + it('should render properly with allowedHosts', async () => { + const { getByRole } = await renderInTestApp( + + + + + , + ); + + expect( + getByRole('option', { name: 'dev.azure.com' }), + ).toBeInTheDocument(); + }); + }); + + describe('requestUserCredentials', () => { + it('should call the scmAuthApi with the correct params', async () => { + const SecretsComponent = () => { + const value = useContext(SecretsContext); + return
{JSON.stringify(value)}
; + }; + const { getAllByRole, getByTestId } = await renderInTestApp( + + + + + + , + ); + + const [ownerInput, repoInput] = getAllByRole('textbox'); + + await act(async () => { + fireEvent.change(ownerInput, { target: { value: 'backstage' } }); + fireEvent.change(repoInput, { target: { value: 'repo123' } }); + + // need to wait for the debounce to finish + await new Promise(resolve => setTimeout(resolve, 600)); + }); + + expect(mockScmAuthApi.getCredentials).toHaveBeenCalledWith({ + url: 'https://github.com/backstage/repo123', + additionalScope: { + repoWrite: true, + customScopes: { + github: ['workflow:write'], + }, + }, + }); + + const currentSecrets = JSON.parse( + getByTestId('current-secrets').textContent!, + ); + + expect(currentSecrets).toEqual({ + secrets: { testKey: 'abc123' }, + }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 998fa41588..1ccaed4e86 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ import { useApi } from '@backstage/core-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { + scmIntegrationsApiRef, + scmAuthApiRef, +} from '@backstage/integration-react'; import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { GithubRepoPicker } from './GithubRepoPicker'; import { GitlabRepoPicker } from './GitlabRepoPicker'; @@ -24,10 +27,21 @@ import { FieldExtensionComponentProps } from '../../../extensions'; import { RepoUrlPickerHost } from './RepoUrlPickerHost'; import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; import { RepoUrlPickerState } from './types'; +import useDebounce from 'react-use/lib/useDebounce'; +import { useTemplateSecrets } from '../../secrets'; export interface RepoUrlPickerUiOptions { allowedHosts?: string[]; allowedOwners?: string[]; + requestUserCredentials?: { + secretsKey: string; + additionalScopes?: { + github?: string[]; + gitlab?: string[]; + bitbucket?: string[]; + azure?: string[]; + }; + }; } export const RepoUrlPicker = ( @@ -38,7 +52,8 @@ export const RepoUrlPicker = ( parseRepoPickerUrl(formData), ); const integrationApi = useApi(scmIntegrationsApiRef); - + const scmAuthApi = useApi(scmAuthApiRef); + const { setSecret } = useTemplateSecrets(); const allowedHosts = useMemo( () => uiSchema?.['ui:options']?.allowedHosts ?? [], [uiSchema], @@ -66,6 +81,42 @@ export const RepoUrlPicker = ( [setState], ); + useDebounce( + async () => { + const { requestUserCredentials } = uiSchema?.['ui:options'] ?? {}; + + if ( + !requestUserCredentials || + !(state.host && state.owner && state.repoName) + ) { + return; + } + + const [host, owner, repoName] = [ + state.host, + state.owner, + state.repoName, + ].map(encodeURIComponent); + + // user has requested that we use the users credentials + // so lets grab them using the scmAuthApi and pass through + // any additional scopes from the ui:options + const { token } = await scmAuthApi.getCredentials({ + url: `https://${host}/${owner}/${repoName}`, + additionalScope: { + repoWrite: true, + customScopes: requestUserCredentials.additionalScopes, + }, + }); + + // set the secret using the key provided in the the ui:options for use + // in the templating the manifest with ${{ secrets[secretsKey] }} + setSecret({ [requestUserCredentials.secretsKey]: token }); + }, + 500, + [state, uiSchema], + ); + const hostType = (state.host && integrationApi.byHost(state.host)?.type) ?? null; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx index 261429c757..ec09494a1b 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx @@ -37,16 +37,23 @@ export const RepoUrlPickerHost = (props: { }); useEffect(() => { - if (hosts && !host) { - // This is only hear to set the default as the first one in the hosts array - // if the host is not set yet and there is a list of hosts. - onChange(hosts[0]); + // If there is no host chosen currently + if (!host) { + // Set the first of the allowedHosts option if that available + if (hosts?.length) { + onChange(hosts[0]); + // if there's no hosts provided, fallback to using the first integration + } else if (integrations?.length) { + onChange(integrations[0].host); + } } - }, [hosts, host, onChange]); + }, [hosts, host, onChange, integrations]); + // If there are no allowedHosts provided, then show all integrations. Otherwise, only show integrations + // that are provided in the dropdown for the user to choose from. const hostsOptions: SelectItem[] = integrations ? integrations - .filter(i => hosts?.includes(i.host)) + .filter(i => (hosts?.length ? hosts?.includes(i.host) : true)) .map(i => ({ label: i.title, value: i.host })) : [{ label: 'Loading...', value: 'loading' }]; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts index d1f2302d3e..2f17f0aef7 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/utils.ts @@ -44,22 +44,22 @@ export function serializeRepoPickerUrl(data: RepoUrlPickerState) { export function parseRepoPickerUrl( url: string | undefined, ): RepoUrlPickerState { - let host = undefined; - let owner = undefined; - let repoName = undefined; - let organization = undefined; - let workspace = undefined; - let project = undefined; + let host = ''; + let owner = ''; + let repoName = ''; + let organization = ''; + let workspace = ''; + let project = ''; try { if (url) { const parsed = new URL(`https://${url}`); host = parsed.host; - owner = parsed.searchParams.get('owner') || undefined; - repoName = parsed.searchParams.get('repo') || undefined; - organization = parsed.searchParams.get('organization') || undefined; - workspace = parsed.searchParams.get('workspace') || undefined; - project = parsed.searchParams.get('project') || undefined; + owner = parsed.searchParams.get('owner') || ''; + repoName = parsed.searchParams.get('repo') || ''; + organization = parsed.searchParams.get('organization') || ''; + workspace = parsed.searchParams.get('workspace') || ''; + project = parsed.searchParams.get('project') || ''; } } catch { /* ok */ diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx new file mode 100644 index 0000000000..37d35c9015 --- /dev/null +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2022 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 React, { useContext } from 'react'; +import { + useTemplateSecrets, + SecretsContextProvider, + SecretsContext, +} from './SecretsContext'; +import { renderHook, act } from '@testing-library/react-hooks'; + +describe('SecretsContext', () => { + it('should allow the setting of secrets in the context', async () => { + const { result } = renderHook( + () => ({ + hook: useTemplateSecrets(), + context: useContext(SecretsContext), + }), + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + expect(result.current.context?.secrets.foo).toEqual(undefined); + + act(() => result.current.hook.setSecret({ foo: 'bar' })); + + expect(result.current.context?.secrets.foo).toEqual('bar'); + }); +}); diff --git a/plugins/scaffolder/src/components/secrets/SecretsContext.tsx b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx new file mode 100644 index 0000000000..6570f9d901 --- /dev/null +++ b/plugins/scaffolder/src/components/secrets/SecretsContext.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2022 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 React, { + useState, + useCallback, + useContext, + createContext, + PropsWithChildren, +} from 'react'; + +type SecretsContextContents = { + secrets: Record; + setSecrets: React.Dispatch>>; +}; + +/** + * The actual context object. + */ +export const SecretsContext = createContext( + undefined, +); + +/** + * The Context Provider that holds the state for the secrets. + * + * @public + */ +export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => { + const [secrets, setSecrets] = useState>({}); + + return ( + + {children} + + ); +}; + +/** + * Hook to access the secrets context. + * @public + */ +export const useTemplateSecrets = () => { + const value = useContext(SecretsContext); + if (!value) { + throw new Error( + 'useTemplateSecrets must be used within a SecretsContextProvider', + ); + } + + const { setSecrets } = value; + + const setSecret = useCallback( + (input: Record) => { + setSecrets(currentSecrets => ({ ...currentSecrets, ...input })); + }, + [setSecrets], + ); + + return { setSecret }; +}; diff --git a/plugins/scaffolder/src/components/secrets/index.ts b/plugins/scaffolder/src/components/secrets/index.ts new file mode 100644 index 0000000000..44adb2a00a --- /dev/null +++ b/plugins/scaffolder/src/components/secrets/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 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 { useTemplateSecrets } from './SecretsContext'; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 3cf65d7a07..133234e20b 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -56,3 +56,4 @@ export { FavouriteTemplate } from './components/FavouriteTemplate'; export { TemplateList } from './components/TemplateList'; export type { TemplateListProps } from './components/TemplateList'; export { TemplateTypePicker } from './components/TemplateTypePicker'; +export * from './components/secrets';