Add GitLab integration for scaffolder
This adds a GitLab integration for the scaffolder backend. We're introduceing a preparer and a publisher for GitLab so that we can read templates from GitLab and publish them to a configured GitLab instance. The two instances don't need to be the same. For instance, templates could be public on gitlab.com, but the created repos will live in a hosted GitLab somewhere else. The publisher gets its own config object in `app-config.yaml` where the target instance and token can be specified. The service catalogue defines both `gitlab` and `gitlab/api` as processors. They are both handled by the same preparer. Closes #2372
This commit is contained in:
@@ -23,6 +23,8 @@
|
||||
"@backstage/backend-common": "^0.1.1-alpha.21",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.21",
|
||||
"@backstage/config": "^0.1.1-alpha.21",
|
||||
"@gitbeaker/core": "^23.5.0",
|
||||
"@gitbeaker/node": "^23.5.0",
|
||||
"@octokit/rest": "^18.0.0",
|
||||
"@types/dockerode": "^2.5.32",
|
||||
"@types/express": "^4.17.6",
|
||||
@@ -33,7 +35,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
"git-url-parse": "^11.1.2",
|
||||
"git-url-parse": "^11.2.0",
|
||||
"globby": "^11.0.0",
|
||||
"helmet": "^4.0.0",
|
||||
"jsonschema": "^1.2.6",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const mockGitlabClient = {
|
||||
Namespaces: {
|
||||
show: jest.fn(),
|
||||
},
|
||||
Projects: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
Users: {
|
||||
current: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export class Gitlab {
|
||||
constructor() {
|
||||
return mockGitlabClient;
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@
|
||||
export * from './prepare';
|
||||
export * from './publish';
|
||||
export * from './templater';
|
||||
export * from './helpers';
|
||||
|
||||
@@ -17,7 +17,7 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from './helpers';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { PreparerBase } from './types';
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from './helpers';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { PreparerBase } from './types';
|
||||
import GitUriParser from 'git-url-parse';
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 { GitlabPreparer } from './gitlab';
|
||||
import {
|
||||
TemplateEntityV1alpha1,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[LOCATION_ANNOTATION]: `${protocol}:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('GitLabPreparer', () => {
|
||||
let mockEntity: TemplateEntityV1alpha1;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
['gitlab', 'gitlab/api'].forEach(protocol => {
|
||||
it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(ConfigReader.fromConfigs([]));
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
await preparer.prepare(mockEntity);
|
||||
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
expect.any(String),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it(`calls the clone command with the correct arguments if an access token is provided for a repository using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(
|
||||
ConfigReader.fromConfigs([
|
||||
{
|
||||
context: '',
|
||||
data: {
|
||||
catalog: {
|
||||
processors: {
|
||||
gitlabApi: {
|
||||
privateToken: 'fake-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
await preparer.prepare(mockEntity);
|
||||
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
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 using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(ConfigReader.fromConfigs([]));
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
delete mockEntity.spec.path;
|
||||
await preparer.prepare(mockEntity);
|
||||
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://gitlab.com/benjdlambert/backstage-graphql-template',
|
||||
expect.any(String),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => {
|
||||
const preparer = new GitlabPreparer(ConfigReader.fromConfigs([]));
|
||||
mockEntity = mockEntityWithProtocol(protocol);
|
||||
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,73 @@
|
||||
/*
|
||||
* 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 GitlabPreparer implements PreparerBase {
|
||||
private readonly privateToken: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.privateToken =
|
||||
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
|
||||
'';
|
||||
}
|
||||
|
||||
async prepare(template: TemplateEntityV1alpha1): Promise<string> {
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
|
||||
if (['gitlab', 'gitlab/api'].indexOf(protocol) < 0) {
|
||||
throw new InputError(
|
||||
`Wrong location protocol: ${protocol}, should be 'gitlab' or 'gitlab/api'`,
|
||||
);
|
||||
}
|
||||
const templateId = template.metadata.name;
|
||||
|
||||
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(parsedGitLocation.filepath)}`,
|
||||
template.spec.path ?? '.',
|
||||
);
|
||||
|
||||
const options = this.privateToken
|
||||
? {
|
||||
fetchOpts: {
|
||||
callbacks: {
|
||||
credentials: () =>
|
||||
Cred.userpassPlaintextNew('oauth2', this.privateToken),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
await Clone.clone(repositoryCheckoutUrl, tempDir, options);
|
||||
|
||||
return path.resolve(tempDir, templateDirectory);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
export * from './preparers';
|
||||
export * from './types';
|
||||
export * from './helpers';
|
||||
export * from './file';
|
||||
export * from './github';
|
||||
export * from './gitlab';
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PreparerBase, RemoteProtocol, PreparerBuilder } from './types';
|
||||
import { PreparerBase, PreparerBuilder } from './types';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from './helpers';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { RemoteProtocol } from '../types';
|
||||
|
||||
export class Preparers implements PreparerBuilder {
|
||||
private preparerMap = new Map<RemoteProtocol, PreparerBase>();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import { RemoteProtocol } from '../types';
|
||||
|
||||
export type PreparerBase = {
|
||||
/**
|
||||
@@ -32,5 +33,3 @@ export type PreparerBuilder = {
|
||||
register(protocol: RemoteProtocol, preparer: PreparerBase): void;
|
||||
get(template: TemplateEntityV1alpha1): PreparerBase;
|
||||
};
|
||||
|
||||
export type RemoteProtocol = 'file' | 'github';
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('nodegit');
|
||||
jest.mock('@gitbeaker/node');
|
||||
|
||||
import { GitlabPublisher } from './gitlab';
|
||||
import { Gitlab as GitlabAPI } from '@gitbeaker/core';
|
||||
import { Gitlab } from '@gitbeaker/node';
|
||||
import * as NodeGit from 'nodegit';
|
||||
|
||||
const { mockGitlabClient } = require('@gitbeaker/node') as {
|
||||
mockGitlabClient: {
|
||||
Namespaces: jest.Mocked<GitlabAPI['Namespaces']>;
|
||||
Projects: jest.Mocked<GitlabAPI['Projects']>;
|
||||
Users: jest.Mocked<GitlabAPI['Users']>;
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
Repository,
|
||||
mockRepo,
|
||||
mockIndex,
|
||||
Signature,
|
||||
Remote,
|
||||
mockRemote,
|
||||
Cred,
|
||||
} = require('nodegit') as {
|
||||
Repository: jest.Mocked<{ init: any }>;
|
||||
Signature: jest.Mocked<{ now: any }>;
|
||||
Cred: jest.Mocked<{ userpassPlaintextNew: any }>;
|
||||
Remote: jest.Mocked<{ create: any }>;
|
||||
|
||||
mockIndex: jest.Mocked<NodeGit.Index>;
|
||||
mockRepo: jest.Mocked<NodeGit.Repository>;
|
||||
mockRemote: jest.Mocked<NodeGit.Remote>;
|
||||
};
|
||||
|
||||
describe('GitLab Publisher', () => {
|
||||
const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInGitLab', () => {
|
||||
it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => {
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({
|
||||
id: 42,
|
||||
} as { id: number });
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
|
||||
namespace_id: 42,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => {
|
||||
mockGitlabClient.Namespaces.show.mockResolvedValue({});
|
||||
mockGitlabClient.Users.current.mockResolvedValue({
|
||||
id: 21,
|
||||
} as { id: number });
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'mockclone',
|
||||
} as { http_url_to_repo: string });
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockGitlabClient.Users.current).toHaveBeenCalled();
|
||||
|
||||
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
|
||||
namespace_id: 21,
|
||||
name: 'test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish: createGitDirectory', () => {
|
||||
const values = {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
owner: 'lols',
|
||||
};
|
||||
|
||||
const mockDir = '/tmp/test/dir';
|
||||
|
||||
mockGitlabClient.Projects.create.mockResolvedValue({
|
||||
http_url_to_repo: 'mockclone',
|
||||
} as { http_url_to_repo: string });
|
||||
|
||||
it('should call init on the repo with the directory', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Repository.init).toHaveBeenCalledWith(mockDir, 0);
|
||||
});
|
||||
|
||||
it('should call refresh index on the index and write the new files', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockRepo.refreshIndex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call add all files and write', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockIndex.addAll).toHaveBeenCalled();
|
||||
expect(mockIndex.write).toHaveBeenCalled();
|
||||
expect(mockIndex.writeTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a commit with on head with the right name and commiter', async () => {
|
||||
const mockSignature = { mockSignature: 'bloblly' };
|
||||
Signature.now.mockReturnValue(mockSignature);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Signature.now).toHaveBeenCalledTimes(2);
|
||||
expect(Signature.now).toHaveBeenCalledWith(
|
||||
'Scaffolder',
|
||||
'scaffolder@backstage.io',
|
||||
);
|
||||
|
||||
expect(mockRepo.createCommit).toHaveBeenCalledWith(
|
||||
'HEAD',
|
||||
mockSignature,
|
||||
mockSignature,
|
||||
'initial commit',
|
||||
'mockoid',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a remote with the repo and remote', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Remote.create).toHaveBeenCalledWith(
|
||||
mockRepo,
|
||||
'origin',
|
||||
'mockclone',
|
||||
);
|
||||
});
|
||||
|
||||
it('shoud push to the remote repo', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
const [remotes, { callbacks }] = mockRemote.push.mock
|
||||
.calls[0] as NodeGit.PushOptions[];
|
||||
|
||||
expect(remotes).toEqual(['refs/heads/master:refs/heads/master']);
|
||||
|
||||
callbacks?.credentials?.();
|
||||
|
||||
expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith(
|
||||
'oauth2',
|
||||
'fake-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 { PublisherBase } from './types';
|
||||
import { Gitlab } from '@gitbeaker/core';
|
||||
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { Repository, Remote, Signature, Cred } from 'nodegit';
|
||||
|
||||
export class GitlabPublisher implements PublisherBase {
|
||||
private readonly client: Gitlab;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(client: Gitlab, token: string) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async publish({
|
||||
values,
|
||||
directory,
|
||||
}: {
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
directory: string;
|
||||
}): Promise<{ remoteUrl: string }> {
|
||||
const remoteUrl = await this.createRemote(values);
|
||||
await this.pushToRemote(directory, remoteUrl);
|
||||
|
||||
return { remoteUrl };
|
||||
}
|
||||
|
||||
private async createRemote(
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
) {
|
||||
const [owner, name] = values.storePath.split('/');
|
||||
|
||||
let targetNamespace = ((await this.client.Namespaces.show(owner)) as {
|
||||
id: number;
|
||||
}).id;
|
||||
if (!targetNamespace) {
|
||||
targetNamespace = ((await this.client.Users.current()) as { id: number })
|
||||
.id;
|
||||
}
|
||||
|
||||
const project = (await this.client.Projects.create({
|
||||
namespace_id: targetNamespace,
|
||||
name: name,
|
||||
})) as { http_url_to_repo: string };
|
||||
|
||||
return project?.http_url_to_repo;
|
||||
}
|
||||
|
||||
private async pushToRemote(directory: string, remote: string): Promise<void> {
|
||||
const repo = await Repository.init(directory, 0);
|
||||
const index = await repo.refreshIndex();
|
||||
await index.addAll();
|
||||
await index.write();
|
||||
const oid = await index.writeTree();
|
||||
await repo.createCommit(
|
||||
'HEAD',
|
||||
Signature.now('Scaffolder', 'scaffolder@backstage.io'),
|
||||
Signature.now('Scaffolder', 'scaffolder@backstage.io'),
|
||||
'initial commit',
|
||||
oid,
|
||||
[],
|
||||
);
|
||||
|
||||
const remoteRepo = await Remote.create(repo, 'origin', remote);
|
||||
|
||||
await remoteRepo.push(['refs/heads/master:refs/heads/master'], {
|
||||
callbacks: {
|
||||
credentials: () => Cred.userpassPlaintextNew('oauth2', this.token),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './publishers';
|
||||
export * from './github';
|
||||
export * from './gitlab';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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 { Publishers } from './publishers';
|
||||
import {
|
||||
LOCATION_ANNOTATION,
|
||||
TemplateEntityV1alpha1,
|
||||
} from '@backstage/catalog-model';
|
||||
import { GithubPublisher } from './github';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
jest.mock('@octokit/rest');
|
||||
|
||||
describe('Publishers', () => {
|
||||
const mockTemplate: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
[LOCATION_ANNOTATION]:
|
||||
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.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('should throw an error when the publisher for the source location is not registered', () => {
|
||||
const publishers = new Publishers();
|
||||
|
||||
expect(() => publishers.get(mockTemplate)).toThrow(
|
||||
expect.objectContaining({
|
||||
message: 'No publisher registered for type: "github"',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct preparer when the source matches', () => {
|
||||
const publishers = new Publishers();
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'fake',
|
||||
repoVisibility: 'public',
|
||||
});
|
||||
publishers.register('github', publisher);
|
||||
|
||||
expect(publishers.get(mockTemplate)).toBe(publisher);
|
||||
});
|
||||
|
||||
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 publishers = new Publishers();
|
||||
|
||||
expect(() => publishers.get(brokenTemplate)).toThrow(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('No location annotation provided'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { parseLocationAnnotation } from '../helpers';
|
||||
import { PublisherBase, PublisherBuilder } from './types';
|
||||
import { RemoteProtocol } from '../types';
|
||||
|
||||
export class Publishers implements PublisherBuilder {
|
||||
private publisherMap = new Map<RemoteProtocol, PublisherBase>();
|
||||
|
||||
register(protocol: RemoteProtocol, publisher: PublisherBase) {
|
||||
this.publisherMap.set(protocol, publisher);
|
||||
}
|
||||
|
||||
get(template: TemplateEntityV1alpha1): PublisherBase {
|
||||
const { protocol } = parseLocationAnnotation(template);
|
||||
const publisher = this.publisherMap.get(protocol);
|
||||
|
||||
if (!publisher) {
|
||||
throw new Error(`No publisher registered for type: "${protocol}"`);
|
||||
}
|
||||
|
||||
return publisher;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RemoteProtocol } from '../types';
|
||||
|
||||
/**
|
||||
* Publisher is in charge of taking a folder created by
|
||||
@@ -34,3 +35,8 @@ export type PublisherBase = {
|
||||
directory: string;
|
||||
}): Promise<{ remoteUrl: string }>;
|
||||
};
|
||||
|
||||
export type PublisherBuilder = {
|
||||
register(protocol: RemoteProtocol, publisher: PublisherBase): void;
|
||||
get(template: TemplateEntityV1alpha1): PublisherBase;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export type RemoteProtocol = 'file' | 'github' | 'gitlab' | 'gitlab/api';
|
||||
@@ -18,21 +18,20 @@ import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createRouter } from './router';
|
||||
import { Templaters, Preparers, PublisherBase } from '../scaffolder';
|
||||
import { Templaters, Preparers, Publishers } from '../scaffolder';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
jest.mock('dockerode');
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
const publisher: jest.Mocked<PublisherBase> = { publish: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
preparers: new Preparers(),
|
||||
templaters: new Templaters(),
|
||||
publisher: publisher,
|
||||
publishers: new Publishers(),
|
||||
dockerClient: new Docker(),
|
||||
});
|
||||
app = express().use(router);
|
||||
|
||||
@@ -26,14 +26,14 @@ import {
|
||||
RequiredTemplateValues,
|
||||
StageContext,
|
||||
TemplaterBuilder,
|
||||
PublisherBase,
|
||||
PublisherBuilder,
|
||||
} from '../scaffolder';
|
||||
import { validate, ValidatorResult } from 'jsonschema';
|
||||
|
||||
export interface RouterOptions {
|
||||
preparers: PreparerBuilder;
|
||||
templaters: TemplaterBuilder;
|
||||
publisher: PublisherBase;
|
||||
publishers: PublisherBuilder;
|
||||
|
||||
logger: Logger;
|
||||
dockerClient: Docker;
|
||||
@@ -48,7 +48,7 @@ export async function createRouter(
|
||||
const {
|
||||
preparers,
|
||||
templaters,
|
||||
publisher,
|
||||
publishers,
|
||||
logger: parentLogger,
|
||||
dockerClient,
|
||||
} = options;
|
||||
@@ -125,6 +125,7 @@ export async function createRouter(
|
||||
{
|
||||
name: 'Publish template',
|
||||
handler: async (ctx: StageContext<{ resultDir: string }>) => {
|
||||
const publisher = publishers.get(ctx.entity);
|
||||
ctx.logger.info('Will now store the template');
|
||||
const { remoteUrl } = await publisher.publish({
|
||||
entity: ctx.entity,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.1",
|
||||
"git-url-parse": "^11.1.3",
|
||||
"git-url-parse": "^11.2.0",
|
||||
"knex": "^0.21.1",
|
||||
"node-fetch": "^2.6.0",
|
||||
"nodegit": "^0.27.0",
|
||||
|
||||
Reference in New Issue
Block a user