Add Azure prepare stage to scaffolder

This commit is contained in:
Mattias Frinnström
2020-09-18 06:34:10 +00:00
parent e7686833fd
commit c109cbe4df
7 changed files with 247 additions and 5 deletions
+5
View File
@@ -132,6 +132,11 @@ scaffolder:
token:
$secret:
env: GITLAB_ACCESS_TOKEN
azure:
api:
token:
$secret:
env: AZURE_PRIVATE_TOKEN
auth:
providers:
@@ -217,6 +217,11 @@ The Github access token is retrieved from environment variables via the config.
The config file needs to specify what environment variable the token is
retrieved from. Your config should have the following objects.
You can configure who can see the new repositories that the scaffolder creates
by specifying `visibility` option. Valid options are `public`, `private` and
`internal`. `internal` options is for GitHub Enterprise clients, which means
public within the organization.
#### Gitlab
For Gitlab, we currently support the configuration of the GitLab publisher and
@@ -238,10 +243,19 @@ scaffolder:
env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN
```
You can configure who can see the new repositories that the scaffolder creates
by specifying `visibility` option. Valid options are `public`, `private` and
`internal`. `internal` option is for GitHub Enterprise clients, which means
public within the organization.
#### Azure DevOps
For Azure DevOps we currently support the preparer stage with the configuration
of a private access token (PAT) when needed:
```yaml
scaffolder:
azure:
api:
token:
$secret:
env: AZURE_PRIVATE_TOKEN
```
### Running the Backend
@@ -20,6 +20,7 @@ import {
FilePreparer,
GithubPreparer,
GitlabPreparer,
AzurePreparer,
Preparers,
Publishers,
GithubPublisher,
@@ -46,12 +47,14 @@ export default async function createPlugin({
const filePreparer = new FilePreparer();
const githubPreparer = new GithubPreparer();
const gitlabPreparer = new GitlabPreparer(config);
const azurePreparer = new AzurePreparer(config);
const preparers = new Preparers();
preparers.register('file', filePreparer);
preparers.register('github', githubPreparer);
preparers.register('gitlab', gitlabPreparer);
preparers.register('gitlab/api', gitlabPreparer);
preparers.register('azure/api', azurePreparer);
const publishers = new Publishers();
@@ -0,0 +1,138 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const mocks = {
Clone: { clone: jest.fn() },
CheckoutOptions: jest.fn(() => {}),
};
jest.doMock('nodegit', () => mocks);
import { AzurePreparer } from './azure';
import {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
describe('AzurePreparer', () => {
let mockEntity: TemplateEntityV1alpha1;
beforeEach(() => {
jest.clearAllMocks();
mockEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]:
'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
});
it('calls the clone command with the correct arguments for a repository', async () => {
const preparer = new AzurePreparer(ConfigReader.fromConfigs([]));
await preparer.prepare(mockEntity);
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
expect.any(String),
{},
);
});
it('calls the clone command with the correct arguments if an access token is provided for a repository', async () => {
const preparer = new AzurePreparer(
ConfigReader.fromConfigs([
{
context: '',
data: {
scaffolder: {
azure: {
api: {
token: 'fake-token',
},
},
},
},
},
]),
);
await preparer.prepare(mockEntity);
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
expect.any(String),
{
fetchOpts: {
callbacks: {
credentials: expect.anything(),
},
},
},
);
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
const preparer = new AzurePreparer(ConfigReader.fromConfigs([]));
delete mockEntity.spec.path;
await preparer.prepare(mockEntity);
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
expect.any(String),
{},
);
});
it('return the temp directory with the path to the folder if it is specified', async () => {
const preparer = new AzurePreparer(ConfigReader.fromConfigs([]));
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity);
expect(response.split('\\').join('/')).toMatch(
/\/template\/test\/1\/2\/3$/,
);
});
});
@@ -0,0 +1,76 @@
/*
* Copyright 2020 Spotify AB
*
* 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 fs from 'fs-extra';
import path from 'path';
import os from 'os';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from '../helpers';
import { InputError } from '@backstage/backend-common';
import { PreparerBase } from './types';
import GitUriParser from 'git-url-parse';
import { Clone, Cred } from 'nodegit';
import { Config } from '@backstage/config';
export class AzurePreparer implements PreparerBase {
private readonly privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('scaffolder.azure.api.token') ?? '';
}
async prepare(template: TemplateEntityV1alpha1): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
if (protocol !== 'azure/api') {
throw new InputError(
`Wrong location protocol: ${protocol}, should be 'azure/api'`,
);
}
const templateId = template.metadata.name;
const url = new URL(location); // Need to extract filepath from search parameter
const parsedGitLocation = GitUriParser(location);
const repositoryCheckoutUrl = parsedGitLocation.toString('https');
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), templateId),
);
const templateDirectory = path.join(
`${path
.dirname(url.searchParams.get('path') || '')
.replace(/^\/+/g, '')}`, // Strip leading slash
template.spec.path ?? '.',
);
const options = this.privateToken
? {
fetchOpts: {
callbacks: {
credentials: () =>
// Username can anything but the empty string according to: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat
Cred.userpassPlaintextNew('notempty', this.privateToken),
},
},
}
: {};
await Clone.clone(repositoryCheckoutUrl, tempDir, options);
return path.resolve(tempDir, templateDirectory);
}
}
@@ -18,3 +18,4 @@ export * from './types';
export * from './file';
export * from './github';
export * from './gitlab';
export * from './azure';
@@ -13,4 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type RemoteProtocol = 'file' | 'github' | 'gitlab' | 'gitlab/api';
export type RemoteProtocol =
| 'file'
| 'github'
| 'gitlab'
| 'gitlab/api'
| 'azure/api';