Refactor preparers

This commit is contained in:
Johan Haals
2021-01-19 15:31:07 +01:00
parent 9d0733de1f
commit 544139ed16
14 changed files with 297 additions and 630 deletions
@@ -26,7 +26,6 @@ import {
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { getVoidLogger, Git } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
describe('AzurePreparer', () => {
const mockGitClient = {
@@ -80,20 +79,13 @@ describe('AzurePreparer', () => {
};
});
const preparer = AzurePreparer.fromConfig({
host: 'dev.azure.com',
token: 'fake-azure-token',
});
// TODO(blam): Here's a test that will fail when the deprecation is complete
it('calls the clone command with deprecated token', async () => {
const preparer = new AzurePreparer(
new ConfigReader({
scaffolder: {
azure: {
api: {
token: 'fake-azure-token',
},
},
},
}),
);
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
@@ -104,28 +96,16 @@ describe('AzurePreparer', () => {
});
it('calls the clone command with token from integrations config', async () => {
const preparer = new AzurePreparer(
new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'fake-azure-token-integration' },
],
},
}),
);
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
password: 'fake-azure-token-integration',
password: 'fake-azure-token',
username: 'notempty',
});
});
it('calls the clone command with the correct arguments for a repository', async () => {
const preparer = new AzurePreparer(new ConfigReader({}));
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mockGitClient.clone).toHaveBeenCalledWith({
@@ -136,7 +116,6 @@ describe('AzurePreparer', () => {
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
const preparer = new AzurePreparer(new ConfigReader({}));
delete mockEntity.spec.path;
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
@@ -149,7 +128,6 @@ describe('AzurePreparer', () => {
});
it('return the temp directory with the path to the folder if it is specified', async () => {
const preparer = new AzurePreparer(new ConfigReader({}));
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
@@ -162,7 +140,6 @@ describe('AzurePreparer', () => {
});
it('return the working directory with the path to the folder if it is specified', async () => {
const preparer = new AzurePreparer(new ConfigReader({}));
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
@@ -18,54 +18,26 @@ import fs from 'fs-extra';
import path from 'path';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from '../helpers';
import { InputError, Git } from '@backstage/backend-common';
import { Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import parseGitUrl from 'git-url-parse';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import {
readAzureIntegrationConfigs,
AzureIntegrationConfig,
} from '@backstage/integration';
import { AzureIntegrationConfig } from '@backstage/integration';
export class AzurePreparer implements PreparerBase {
private readonly integrations: AzureIntegrationConfig[];
private readonly scaffolderToken: string | undefined;
static fromConfig(config: Config, { logger }: { logger: Logger }) {
if (config.getOptionalString('scaffolder.azure.api.token')) {
logger.warn(
"DEPRECATION: Using the token format under 'scaffolder.azure.api.token' will not be respected in future releases. Please consider using integrations config instead",
'Please migrate to using integrations config and specifying tokens under hostnames',
);
}
return new AzurePreparer(config);
static fromConfig(config: AzureIntegrationConfig) {
return new AzurePreparer(config.token);
}
constructor(config: Config) {
this.integrations = readAzureIntegrationConfigs(
config.getOptionalConfigArray('integrations.azure') ?? [],
);
this.scaffolderToken = config.getOptionalString(
'scaffolder.azure.api.token',
);
}
constructor(private readonly token?: string) {}
async prepare(
template: TemplateEntityV1alpha1,
opts: PreparerOptions,
): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
const { location } = parseLocationAnnotation(template);
const workingDirectory = opts.workingDirectory ?? os.tmpdir();
const logger = opts.logger;
if (!['azure', 'url'].includes(protocol)) {
throw new InputError(
`Wrong location protocol: ${protocol}, should be 'url'`,
);
}
const templateId = template.metadata.name;
const parsedGitLocation = parseGitUrl(location);
@@ -79,13 +51,11 @@ export class AzurePreparer implements PreparerBase {
template.spec.path ?? '.',
);
const token = this.getToken(parsedGitLocation.resource);
// Username can be 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
const git = token
const git = this.token
? Git.fromAuth({
password: token,
password: this.token,
username: 'notempty',
logger,
})
@@ -98,11 +68,4 @@ export class AzurePreparer implements PreparerBase {
return path.resolve(tempDir, templateDirectory);
}
private getToken(host: string): string | undefined {
return (
this.scaffolderToken ||
this.integrations.find(c => c.host === host)?.token
);
}
}
@@ -26,7 +26,6 @@ import {
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { getVoidLogger, Git } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
describe('BitbucketPreparer', () => {
let mockEntity: TemplateEntityV1alpha1;
@@ -79,8 +78,13 @@ describe('BitbucketPreparer', () => {
};
});
const preparer = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
});
it('calls the clone command with the correct arguments for a repository', async () => {
const preparer = new BitbucketPreparer(new ConfigReader({}));
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://bitbucket.org/backstage-project/backstage-repo',
@@ -89,20 +93,11 @@ describe('BitbucketPreparer', () => {
});
it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => {
const preparer = new BitbucketPreparer(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
},
],
},
}),
);
const preparer = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
username: 'fake-user',
appPassword: 'fake-password',
});
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
@@ -113,7 +108,6 @@ describe('BitbucketPreparer', () => {
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
const preparer = new BitbucketPreparer(new ConfigReader({}));
delete mockEntity.spec.path;
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mockGitClient.clone).toHaveBeenCalledWith({
@@ -123,7 +117,6 @@ describe('BitbucketPreparer', () => {
});
it('return the temp directory with the path to the folder if it is specified', async () => {
const preparer = new BitbucketPreparer(new ConfigReader({}));
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
logger: getVoidLogger(),
@@ -134,79 +127,22 @@ describe('BitbucketPreparer', () => {
);
});
it('calls the clone command with deprecated auth method', async () => {
const preparer = new BitbucketPreparer(
new ConfigReader({
scaffolder: {
bitbucket: {
api: {
username: 'fakeusername',
token: 'faketoken',
},
},
},
}),
);
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'fakeusername',
password: 'faketoken',
it('calls the clone command with with token for auth method', async () => {
const preparer = BitbucketPreparer.fromConfig({
host: 'bitbucket.org',
token: 'fake-token',
});
});
it('calls the clone command with integrations config for auth method', async () => {
const preparer = new BitbucketPreparer(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
username: 'asd3',
token: 'faketoken',
},
],
},
}),
);
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'asd3',
password: 'faketoken',
});
});
it('calls the clone command with integrations config with appPassword for auth method', async () => {
const preparer = new BitbucketPreparer(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
username: 'asd3',
appPassword: 'myapppassword',
},
],
},
}),
);
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'asd3',
password: 'myapppassword',
username: 'x-token-auth',
password: 'fake-token',
});
});
it('return the working directory with the path to the folder if it is specified', async () => {
const preparer = new BitbucketPreparer(new ConfigReader({}));
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
workingDirectory: '/workDir',
@@ -20,44 +20,23 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from '../helpers';
import { InputError, Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import {
readBitbucketIntegrationConfigs,
BitbucketIntegrationConfig,
} from '@backstage/integration';
import { BitbucketIntegrationConfig } from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
export class BitbucketPreparer implements PreparerBase {
private readonly privateToken: string;
private readonly username: string;
private readonly integrations: BitbucketIntegrationConfig[];
static fromConfig(config: Config, { logger }: { logger: Logger }) {
const user = config.getOptionalString('scaffolder.bitbucket.api.username');
const token = config.getOptionalString('scaffolder.bitbucket.api.token');
const password = config.getOptionalString(
'scaffolder.bitbucket.api.appPassword',
static fromConfig(config: BitbucketIntegrationConfig) {
return new BitbucketPreparer(
config.username,
config.token,
config.appPassword,
);
if (user || token || password) {
logger.warn(
"DEPRECATION: Setting credentials under 'scaffolder.bitbucket.api' will not be respected in future releases. Please consider using integrations config instead",
'Please migrate to using integrations config and specifying tokens under hostnames',
);
}
return new BitbucketPreparer(config);
}
constructor(config: Config) {
this.integrations = readBitbucketIntegrationConfigs(
config.getOptionalConfigArray('integrations.bitbucket') ?? [],
);
this.username =
config.getOptionalString('scaffolder.bitbucket.api.username') ?? '';
this.privateToken =
config.getOptionalString('scaffolder.bitbucket.api.token') ?? '';
}
constructor(
private readonly username?: string,
private readonly token?: string,
private readonly appPassword?: string,
) {}
async prepare(
template: TemplateEntityV1alpha1,
@@ -88,8 +67,7 @@ export class BitbucketPreparer implements PreparerBase {
const checkoutLocation = path.resolve(tempDir, templateDirectory);
const auth = this.getAuth(repo.resource);
const auth = this.getAuth();
const git = auth
? Git.fromAuth({
...auth,
@@ -105,36 +83,18 @@ export class BitbucketPreparer implements PreparerBase {
return checkoutLocation;
}
private getAuth(
host: string,
): { username: string; password: string } | undefined {
if (this.username && this.privateToken) {
return { username: this.username, password: this.privateToken };
private getAuth(): { username: string; password: string } | undefined {
if (this.username && this.appPassword) {
return { username: this.username, password: this.appPassword };
}
const bitbucketIntegrationConfig = this.integrations.find(
c => c.host === host,
);
if (!bitbucketIntegrationConfig) {
return undefined;
if (this.token) {
return {
username: 'x-token-auth',
password: this.token! || this.appPassword!,
};
}
if (
!bitbucketIntegrationConfig.username ||
!(
bitbucketIntegrationConfig.token ||
bitbucketIntegrationConfig.appPassword
)
) {
return undefined;
}
return {
username: bitbucketIntegrationConfig.username,
password:
bitbucketIntegrationConfig.token! ||
bitbucketIntegrationConfig.appPassword!,
};
return undefined;
}
}
@@ -26,7 +26,6 @@ import {
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { getVoidLogger, Git } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
describe('GitHubPreparer', () => {
let mockEntity: TemplateEntityV1alpha1;
@@ -78,17 +77,13 @@ describe('GitHubPreparer', () => {
},
};
});
it('calls the clone command with the correct arguments for a repository', async () => {
const preparer = new GithubPreparer(
new ConfigReader({
scaffolder: {
github: {
token: 'fake-token',
},
},
}),
);
const preparer = GithubPreparer.fromConfig({
host: 'github.com',
token: 'fake-token',
});
it('calls the clone command with the correct arguments for a repository', async () => {
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mockGitClient.clone).toHaveBeenCalledWith({
@@ -96,8 +91,8 @@ describe('GitHubPreparer', () => {
dir: expect.any(String),
});
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
const preparer = new GithubPreparer(new ConfigReader({}));
delete mockEntity.spec.path;
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
@@ -109,7 +104,10 @@ describe('GitHubPreparer', () => {
});
it('return the temp directory with the path to the folder if it is specified', async () => {
const preparer = new GithubPreparer(new ConfigReader({}));
const preparer = GithubPreparer.fromConfig({
host: 'github.com',
token: 'fake-token',
});
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
logger: getVoidLogger(),
@@ -120,7 +118,11 @@ describe('GitHubPreparer', () => {
});
it('return the working directory with the path to the folder if it is specified', async () => {
const preparer = new GithubPreparer(new ConfigReader({}));
const preparer = GithubPreparer.fromConfig({
host: 'github.com',
token: 'fake-token',
});
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
workingDirectory: '/workDir',
@@ -132,17 +134,7 @@ describe('GitHubPreparer', () => {
);
});
it('calls the clone command with deprecated token', async () => {
const preparer = new GithubPreparer(
new ConfigReader({
scaffolder: {
github: {
token: 'fake-token',
},
},
}),
);
it('calls the clone command with token', async () => {
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
@@ -151,22 +143,4 @@ describe('GitHubPreparer', () => {
password: 'x-oauth-basic',
});
});
it('calls the clone command with token from integrations config', async () => {
const preparer = new GithubPreparer(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'fake-me' }],
},
}),
);
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'fake-me',
password: 'x-oauth-basic',
});
});
});
@@ -20,35 +20,15 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from '../helpers';
import { InputError, Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import { Logger } from 'winston';
import parseGitUrl from 'git-url-parse';
import { Config } from '@backstage/config';
import {
GitHubIntegrationConfig,
readGitHubIntegrationConfigs,
} from '@backstage/integration';
import { GitHubIntegrationConfig } from '@backstage/integration';
export class GithubPreparer implements PreparerBase {
private readonly integrations: GitHubIntegrationConfig[];
private readonly scaffolderToken: string | undefined;
static fromConfig(config: Config, { logger }: { logger: Logger }) {
if (config.getOptionalString('scaffolder.github.token')) {
logger.warn(
"DEPRECATION: Using the token format under 'scaffolder.github.token' will not be respected in future releases. Please consider using integrations config instead",
'Please migrate to using integrations config and specifying tokens under hostnames',
);
}
return new GithubPreparer(config);
static fromConfig(config: GitHubIntegrationConfig) {
return new GithubPreparer(config.token);
}
constructor(config: Config) {
this.integrations = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
);
this.scaffolderToken = config.getOptionalString('scaffolder.github.token');
}
constructor(private readonly token?: string) {}
async prepare(
template: TemplateEntityV1alpha1,
@@ -78,11 +58,9 @@ export class GithubPreparer implements PreparerBase {
const checkoutLocation = path.resolve(tempDir, templateDirectory);
const token = this.getToken(parsedGitLocation.resource);
const git = token
const git = this.token
? Git.fromAuth({
username: token,
username: this.token,
password: 'x-oauth-basic',
logger,
})
@@ -95,10 +73,4 @@ export class GithubPreparer implements PreparerBase {
return checkoutLocation;
}
private getToken(host: string): string | undefined {
return (
this.scaffolderToken ||
this.integrations.find(c => c.host === host)?.token
);
}
}
@@ -78,9 +78,11 @@ describe('GitLabPreparer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const preparer = GitlabPreparer.fromConfig({
host: 'gitlab.com',
token: 'fake-token',
});
it(`calls the clone command with the correct arguments for a repository`, async () => {
const preparer = new GitlabPreparer(new ConfigReader({}));
mockEntity = mockTemplate();
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
@@ -92,37 +94,6 @@ describe('GitLabPreparer', () => {
});
it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository`, async () => {
const preparer = new GitlabPreparer(
new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'fake-token',
},
],
},
}),
);
mockEntity = mockTemplate();
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'oauth2',
password: 'fake-token',
});
});
it(`calls the clone command with the correct arguments if an access token is provided in scaffolder for a repository`, async () => {
const preparer = new GitlabPreparer(
new ConfigReader({
scaffolder: {
gitlab: { api: { token: 'fake-token' } },
},
}),
);
mockEntity = mockTemplate();
await preparer.prepare(mockEntity, { logger });
@@ -135,7 +106,6 @@ describe('GitLabPreparer', () => {
});
it(`calls the clone command with the correct arguments for a repository when no path is provided`, async () => {
const preparer = new GitlabPreparer(new ConfigReader({}));
mockEntity = mockTemplate();
delete mockEntity.spec.path;
@@ -148,7 +118,6 @@ describe('GitLabPreparer', () => {
});
it(`return the temp directory with the path to the folder if it is specified`, async () => {
const preparer = new GitlabPreparer(new ConfigReader({}));
mockEntity = mockTemplate();
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
@@ -160,7 +129,6 @@ describe('GitLabPreparer', () => {
});
it('return the working directory with the path to the folder if it is specified', async () => {
const preparer = new GitlabPreparer(new ConfigReader({}));
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity, {
workingDirectory: '/workDir',
@@ -15,43 +15,20 @@
*/
import { InputError, Git } from '@backstage/backend-common';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
GitLabIntegrationConfig,
readGitLabIntegrationConfigs,
} from '@backstage/integration';
import { GitLabIntegrationConfig } from '@backstage/integration';
import fs from 'fs-extra';
import parseGitUrl from 'git-url-parse';
import os from 'os';
import path from 'path';
import { parseLocationAnnotation } from '../helpers';
import { PreparerBase, PreparerOptions } from './types';
import { Logger } from 'winston';
export class GitlabPreparer implements PreparerBase {
private readonly integrations: GitLabIntegrationConfig[];
private readonly scaffolderToken: string | undefined;
static fromConfig(config: Config, { logger }: { logger: Logger }) {
if (config.getOptionalString('scaffolder.gitlab.api.token')) {
logger.warn(
"DEPRECATION: Using the token format under 'scaffolder.gitlab.token' will not be respected in future releases. Please consider using integrations config instead",
'Please migrate to using integrations config and specifying tokens under hostnames',
);
}
return new GitlabPreparer(config);
static fromConfig(config: GitLabIntegrationConfig) {
return new GitlabPreparer(config.token);
}
constructor(config: Config) {
this.integrations = readGitLabIntegrationConfigs(
config.getOptionalConfigArray('integrations.gitlab') ?? [],
);
this.scaffolderToken = config.getOptionalString(
'scaffolder.gitlab.api.token',
);
}
constructor(private readonly token?: string) {}
async prepare(
template: TemplateEntityV1alpha1,
@@ -79,10 +56,9 @@ export class GitlabPreparer implements PreparerBase {
template.spec.path ?? '.',
);
const token = this.getToken(parsedGitLocation.resource);
const git = token
const git = this.token
? Git.fromAuth({
password: token,
password: this.token,
username: 'oauth2',
logger,
})
@@ -95,11 +71,4 @@ export class GitlabPreparer implements PreparerBase {
return path.resolve(tempDir, templateDirectory);
}
private getToken(host: string): string | undefined {
return (
this.scaffolderToken ||
this.integrations.find(c => c.host === host)?.token
);
}
}
@@ -16,107 +16,143 @@
import { Preparers } from '.';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { FilePreparer } from './file';
import { GithubPreparer } from './github';
import { ConfigReader } from '@backstage/config';
describe('Preparers', () => {
const mockTemplate: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml',
},
name: 'react-ssr-template',
title: 'React SSR Template',
description:
'Next.js application skeleton for creating isomorphic web applications.',
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
generation: 1,
},
spec: {
templater: 'cookiecutter',
path: '.',
type: 'website',
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('should throw an error when the preparer for the source location is not registered', () => {
const preparers = new Preparers();
// const mockTemplate: TemplateEntityV1alpha1 = {
// apiVersion: 'backstage.io/v1alpha1',
// kind: 'Template',
// metadata: {
// annotations: {
// 'backstage.io/managed-by-location':
// 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml',
// },
// name: 'react-ssr-template',
// title: 'React SSR Template',
// description:
// 'Next.js application skeleton for creating isomorphic web applications.',
// uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
// etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
// generation: 1,
// },
// spec: {
// templater: 'cookiecutter',
// path: '.',
// type: 'website',
// 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('should throw an error when the preparer for the source location is not registered', () => {
// const preparers = new Preparers();
expect(() => preparers.get(mockTemplate)).toThrow(
expect.objectContaining({
message: 'No preparer registered for type: "file"',
// expect(() => preparers.get(mockTemplate)).toThrow(
// expect.objectContaining({
// message: 'No preparer registered for type: "file"',
// }),
// );
// });
// it('should return the correct preparer when the source matches', () => {
// const preparers = new Preparers();
// const preparer = new FilePreparer();
// preparers.register('file', preparer);
// expect(preparers.get(mockTemplate)).toBe(preparer);
// });
// it('should throw an error if the metadata tag does not exist in the entity', () => {
// const brokenTemplate: TemplateEntityV1alpha1 = {
// apiVersion: 'backstage.io/v1alpha1',
// kind: 'Template',
// metadata: {
// annotations: {},
// name: 'react-ssr-template',
// title: 'React SSR Template',
// description:
// 'Next.js application skeleton for creating isomorphic web applications.',
// uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
// etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
// generation: 1,
// },
// spec: {
// type: 'website',
// templater: 'cookiecutter',
// path: '.',
// 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',
// },
// },
// },
// },
// };
// const preparers = new Preparers();
// expect(() => preparers.get(brokenTemplate)).toThrow(
// expect.objectContaining({
// message: expect.stringContaining('No location annotation provided'),
// }),
// );
// });
it('should return the correct preparer based on the hostname', async () => {
const preparer = new GithubPreparer(
new ConfigReader({
host: 'github.com',
apiBaseUrl: 'lols',
token: 'something else yo',
}),
);
});
it('should return the correct preparer when the source matches', () => {
const preparers = new Preparers();
const preparer = new FilePreparer();
preparers.register('file', preparer);
expect(preparers.get(mockTemplate)).toBe(preparer);
});
it('should throw an error if the metadata tag does not exist in the entity', () => {
const brokenTemplate: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {},
name: 'react-ssr-template',
title: 'React SSR Template',
description:
'Next.js application skeleton for creating isomorphic web applications.',
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
generation: 1,
},
spec: {
type: 'website',
templater: 'cookiecutter',
path: '.',
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',
},
},
},
},
};
const preparers = new Preparers();
preparers.register('github.com', preparer);
expect(() => preparers.get(brokenTemplate)).toThrow(
expect.objectContaining({
message: expect.stringContaining('No location annotation provided'),
expect(
preparers.get('https://github.com/please/find/me/something/from/github'),
).toBe(preparer);
});
it('should throw an error if there is nothing that will match the url provided', async () => {
const preparer = new GithubPreparer(
new ConfigReader({
host: 'github.com',
apiBaseUrl: 'lols',
token: 'something else yo',
}),
);
const preparers = new Preparers();
preparers.register('github.com', preparer);
expect(() => preparers.get('https://404.com')).toThrow(
`Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`,
);
});
});
@@ -15,79 +15,74 @@
*/
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import { PreparerBase, PreparerBuilder } from './types';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
DeprecatedLocationTypeDetector,
makeDeprecatedLocationTypeDetector,
parseLocationAnnotation,
} from '../helpers';
import { RemoteProtocol } from '../types';
import { FilePreparer } from './file';
import { GitlabPreparer } from './gitlab';
import { AzurePreparer } from './azure';
import { GithubPreparer } from './github';
import { BitbucketPreparer } from './bitbucket';
import {
readAzureIntegrationConfigs,
readBitbucketIntegrationConfigs,
readGitHubIntegrationConfigs,
readGitLabIntegrationConfigs,
ScmIntegrations,
} from '@backstage/integration';
export class Preparers implements PreparerBuilder {
private preparerMap = new Map<RemoteProtocol, PreparerBase>();
private preparerMap = new Map<string, PreparerBase>();
constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {}
register(protocol: RemoteProtocol, preparer: PreparerBase) {
this.preparerMap.set(protocol, preparer);
register(host: string, preparer: PreparerBase) {
this.preparerMap.set(host, preparer);
}
get(template: TemplateEntityV1alpha1): PreparerBase {
const { protocol, location } = parseLocationAnnotation(template);
const preparer = this.preparerMap.get(protocol);
get(url: string): PreparerBase {
const preparer = this.preparerMap.get(new URL(url).host);
if (!preparer) {
if ((protocol as string) === 'url') {
const type = this.typeDetector?.(location);
const detected = type && this.preparerMap.get(type as RemoteProtocol);
if (detected) {
return detected;
}
if (type) {
throw new Error(
`No preparer configuration available for type '${type}' with url "${location}". ` +
"Make sure you've added appropriate configuration in the 'scaffolder' configuration section",
);
} else {
throw new Error(
`Failed to detect preparer type. Unable to determine integration type for location "${location}". ` +
"Please add appropriate configuration to the 'integrations' configuration section",
);
}
}
throw new Error(`No preparer registered for type: "${protocol}"`);
throw new Error(
`Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`,
);
}
return preparer;
}
static async fromConfig(
config: Config,
{ logger }: { logger: Logger },
// { logger }: { logger: Logger },
): Promise<PreparerBuilder> {
const typeDetector = makeDeprecatedLocationTypeDetector(config);
// TODO(blam/jhaals)
// iterate through the integration configs for each provider, and create the preparer
// register the preparer to hostname.
// if no preparer is returned for the hostname fromConfig, use the backup token credentials from scaffolder block and warn
const preparers = new Preparers();
const scm = ScmIntegrations.fromConfig(config);
for (const integration of scm.azure.list()) {
preparers.register(
integration.config.host,
AzurePreparer.fromConfig(integration.config),
);
}
const preparers = new Preparers(typeDetector);
for (const integration of scm.github.list()) {
preparers.register(
integration.config.host,
GithubPreparer.fromConfig(integration.config),
);
}
const filePreparer = new FilePreparer();
const gitlabPreparer = GitlabPreparer.fromConfig(config, { logger });
const azurePreparer = AzurePreparer.fromConfig(config, { logger });
const githubPreparer = GithubPreparer.fromConfig(config, { logger });
const bitbucketPreparer = BitbucketPreparer.fromConfig(config, { logger });
for (const integration of scm.gitlab.list()) {
preparers.register(
integration.config.host,
GitlabPreparer.fromConfig(integration),
);
}
preparers.register('file', filePreparer);
preparers.register('gitlab', gitlabPreparer);
preparers.register('azure', azurePreparer);
preparers.register('github', githubPreparer);
preparers.register('bitbucket', bitbucketPreparer);
for (const integration of scm.bitbucket.list()) {
preparers.register(
integration.config.host,
BitbucketPreparer.fromConfig(integration.config),
);
}
return preparers;
}
@@ -35,6 +35,6 @@ export interface PreparerBase {
}
export type PreparerBuilder = {
register(protocol: RemoteProtocol, preparer: PreparerBase): void;
get(template: TemplateEntityV1alpha1): PreparerBase;
register(host: string, preparer: PreparerBase): void;
get(url: string): PreparerBase;
};
@@ -24,7 +24,6 @@ import { AzurePublisher } from './azure';
import { WebApi } from 'azure-devops-node-api';
import * as helpers from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
describe('Azure Publisher', () => {
const logger = getVoidLogger();
@@ -40,23 +39,16 @@ describe('Azure Publisher', () => {
((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi);
const publisher = new AzurePublisher(
new ConfigReader({
scaffolder: {
azure: {
api: {
baseUrl: 'https://dev.azure.com/myorg',
token: 'fake-azure-token',
},
},
},
}),
);
const publisher = await AzurePublisher.fromConfig({
host: 'dev.azure.com',
token: 'fake-azure-token',
});
mockGitClient.createRepository.mockResolvedValue({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
} as { remoteUrl: string });
const result = await publisher.publish({
const result = await publisher!.publish({
values: {
storePath: 'project/repo',
owner: 'bob',
@@ -83,59 +75,5 @@ describe('Azure Publisher', () => {
logger,
});
});
it('should use azure-devops-node-api with integrations config', async () => {
const mockGitClient = {
createRepository: jest.fn(),
};
const mockGitApi = {
getGitApi: jest.fn().mockReturnValue(mockGitClient),
};
((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi);
const publisher = new AzurePublisher(
new ConfigReader({
integrations: {
azure: [
{
host: 'dev.azure.com',
token: 'fake-azure-token',
},
],
},
}),
);
mockGitClient.createRepository.mockResolvedValue({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
} as { remoteUrl: string });
const result = await publisher.publish({
values: {
storePath: 'https://dev.azure.com/organization/project/_git/repo',
owner: 'bob',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
catalogInfoUrl:
'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml',
});
expect(mockGitClient.createRepository).toHaveBeenCalledWith(
{
name: 'repo',
},
'project',
);
expect(helpers.initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
auth: { username: 'notempty', password: 'fake-azure-token' },
logger,
});
});
});
});
@@ -17,53 +17,35 @@
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { IGitApi } from 'azure-devops-node-api/GitApi';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { Config } from '@backstage/config';
import { initRepoAndPush } from './helpers';
import {
AzureIntegrationConfig,
readAzureIntegrationConfigs,
} from '@backstage/integration';
import { AzureIntegrationConfig } from '@backstage/integration';
import gitUrlParse from 'git-url-parse';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
export class AzurePublisher implements PublisherBase {
private readonly integrations: AzureIntegrationConfig[];
private readonly apiBaseUrl?: string;
private readonly token?: string;
static fromConfig(config: Config) {
return new AzurePublisher(config);
static async fromConfig(config: AzureIntegrationConfig) {
if (!config.token) {
return undefined;
}
const authHandler = getPersonalAccessTokenHandler(config.token);
const webApi = new WebApi(config.host, authHandler);
const azureClient = await webApi.getGitApi();
return new AzurePublisher(config.token, azureClient);
}
constructor(config: Config) {
this.integrations = readAzureIntegrationConfigs(
config.getOptionalConfigArray('integrations.azure') ?? [],
);
constructor(
private readonly token: string,
private readonly azureClient: IGitApi,
) {}
this.token = config.getOptionalString('scaffolder.azure.api.token');
this.apiBaseUrl = config.getOptionalString('scaffolder.azure.api.baseUrl');
}
async publish({
values,
directory,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const { resource: host, owner, name } = gitUrlParse(values.storePath);
const { owner, name } = gitUrlParse(values.storePath);
const token = this.getToken(host);
if (!token) {
throw new Error('No token provided to create the remote repository');
}
const baseUrl = this.getBaseUrl(host);
if (!baseUrl) {
throw new Error('No baseUrl provided to create the remote repository');
}
const authHandler = getPersonalAccessTokenHandler(token);
const webApi = new WebApi(baseUrl, authHandler);
const azureClient = await webApi.getGitApi();
const remoteUrl = await this.createRemote(azureClient, {
const remoteUrl = await this.createRemote({
project: owner,
name,
});
@@ -75,7 +57,7 @@ export class AzurePublisher implements PublisherBase {
remoteUrl,
auth: {
username: 'notempty',
password: token,
password: this.token,
},
logger,
});
@@ -83,24 +65,14 @@ export class AzurePublisher implements PublisherBase {
return { remoteUrl, catalogInfoUrl };
}
private async createRemote(
client: IGitApi,
opts: { name: string; project: string },
) {
private async createRemote(opts: { name: string; project: string }) {
const { name, project } = opts;
const createOptions: GitRepositoryCreateOptions = { name };
const repo = await client.createRepository(createOptions, project);
const repo = await this.azureClient.createRepository(
createOptions,
project,
);
return repo.remoteUrl || '';
}
private getToken(host: string): string | undefined {
return this.token || this.integrations.find(c => c.host === host)?.token;
}
private getBaseUrl(host: string): string | undefined {
return (
this.apiBaseUrl || this.integrations.find(c => c.host === host)?.host
);
}
}
@@ -27,6 +27,7 @@ import {
StageContext,
TemplaterBuilder,
PublisherBuilder,
parseLocationAnnotation,
} from '../scaffolder';
import { CatalogEntityClient } from '../lib/catalog';
import { validate, ValidatorResult } from 'jsonschema';
@@ -129,7 +130,13 @@ export async function createRouter(
{
name: 'Prepare the skeleton',
handler: async ctx => {
const preparer = preparers.get(ctx.entity);
const { protocol, location: pullPath } = parseLocationAnnotation(
ctx.entity,
);
const preparer =
protcol === file ? new FilePreparer() : preparers.get(pullPath);
const skeletonDir = await preparer.prepare(ctx.entity, {
logger: ctx.logger,
workingDirectory,