Merge pull request #9168 from backstage/blam/scaffodler-secrets-frontend
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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<ScmAuthApi>;
|
||||
export interface ScmAuthTokenOptions extends AuthRequestOptions {
|
||||
additionalScope?: {
|
||||
repoWrite?: boolean;
|
||||
customScopes?: {
|
||||
github?: string[];
|
||||
azure?: string[];
|
||||
bitbucket?: string[];
|
||||
gitlab?: string[];
|
||||
};
|
||||
};
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<ScmAuth>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OctokitIntegration>;
|
||||
getOctokit(
|
||||
repoUrl: string,
|
||||
options?: {
|
||||
token?: string;
|
||||
},
|
||||
): Promise<OctokitIntegration>;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
+11
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+17
-2
@@ -52,7 +52,10 @@ export class OctokitProvider {
|
||||
*
|
||||
* @param repoUrl - Repository URL
|
||||
*/
|
||||
async getOctokit(repoUrl: string): Promise<OctokitIntegration> {
|
||||
async getOctokit(
|
||||
repoUrl: string,
|
||||
options?: { token?: string },
|
||||
): Promise<OctokitIntegration> {
|
||||
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(
|
||||
|
||||
+17
-3
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
+19
-2
@@ -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<PullRequestCreator> => {
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
+10
-2
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -53,6 +53,7 @@ type TemplateContext = {
|
||||
steps: {
|
||||
[stepName: string]: { output: { [outputName: string]: JsonValue } };
|
||||
};
|
||||
secrets?: Record<string, string>;
|
||||
};
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<string>) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const useTemplateSecrets: () => {
|
||||
setSecret: (input: Record<string, string>) => void;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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) => {
|
||||
/>
|
||||
<Route
|
||||
path="/templates/:templateName"
|
||||
element={<TemplatePage customFieldExtensions={fieldExtensions} />}
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage customFieldExtensions={fieldExtensions} />
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
<Route path="/tasks/:taskId" element={<TaskPage />} />
|
||||
<Route path="/actions" element={<ActionsPage />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<ScaffolderApi> = {
|
||||
getIntegrationsList: async () => [
|
||||
{ host: 'github.com', type: 'github', title: 'github.com' },
|
||||
{ host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
|
||||
byHost: () => ({ type: 'github' }),
|
||||
};
|
||||
|
||||
const mockScmAuthApi: Partial<ScmAuthApi> = {
|
||||
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(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{ 'ui:field': 'RepoUrlPicker' }}
|
||||
fields={{ RepoUrlPicker: RepoUrlPicker }}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, {}],
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoUrlPicker',
|
||||
'ui:options': { allowedHosts: ['dev.azure.com'] },
|
||||
}}
|
||||
fields={{ RepoUrlPicker: RepoUrlPicker }}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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 <div data-testid="current-secrets">{JSON.stringify(value)}</div>;
|
||||
};
|
||||
const { getAllByRole, getByTestId } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scmIntegrationsApiRef, mockIntegrationsApi],
|
||||
[scmAuthApiRef, mockScmAuthApi],
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
]}
|
||||
>
|
||||
<SecretsContextProvider>
|
||||
<Form
|
||||
schema={{ type: 'string' }}
|
||||
uiSchema={{
|
||||
'ui:field': 'RepoUrlPicker',
|
||||
'ui:options': {
|
||||
requestUserCredentials: {
|
||||
secretsKey: 'testKey',
|
||||
additionalScopes: { github: ['workflow:write'] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
fields={{ RepoUrlPicker: RepoUrlPicker }}
|
||||
/>
|
||||
<SecretsComponent />
|
||||
</SecretsContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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' }];
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 }) => (
|
||||
<SecretsContextProvider>{children}</SecretsContextProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(result.current.context?.secrets.foo).toEqual(undefined);
|
||||
|
||||
act(() => result.current.hook.setSecret({ foo: 'bar' }));
|
||||
|
||||
expect(result.current.context?.secrets.foo).toEqual('bar');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
setSecrets: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actual context object.
|
||||
*/
|
||||
export const SecretsContext = createContext<SecretsContextContents | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* The Context Provider that holds the state for the secrets.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<SecretsContext.Provider value={{ secrets, setSecrets }}>
|
||||
{children}
|
||||
</SecretsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, string>) => {
|
||||
setSecrets(currentSecrets => ({ ...currentSecrets, ...input }));
|
||||
},
|
||||
[setSecrets],
|
||||
);
|
||||
|
||||
return { setSecret };
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user