Merge pull request #3805 from backstage/blam/isomorphic-git

Remove the NodeGit Dependency in favour of isomorphic-git
This commit is contained in:
Ben Lambert
2020-12-29 14:46:35 +01:00
committed by GitHub
27 changed files with 1011 additions and 617 deletions
+3 -4
View File
@@ -42,6 +42,7 @@
"command-exists-promise": "^2.0.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"cross-fetch": "^3.0.6",
"dockerode": "^3.2.1",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -49,20 +50,18 @@
"git-url-parse": "^11.4.3",
"globby": "^11.0.0",
"helmet": "^4.0.0",
"isomorphic-git": "^1.8.0",
"jsonschema": "^1.2.6",
"morgan": "^1.10.0",
"nodegit": "0.27.0",
"uuid": "^8.2.0",
"winston": "^3.2.1",
"yaml": "^1.10.0",
"cross-fetch": "^3.0.6"
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/cli": "^0.4.3",
"@octokit/types": "^5.4.1",
"@types/fs-extra": "^9.0.1",
"@types/git-url-parse": "^9.0.0",
"@types/nodegit": "0.26.11",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"yaml": "^1.10.0"
@@ -14,11 +14,6 @@
* limitations under the License.
*/
const mocks = {
Clone: { clone: jest.fn() },
CheckoutOptions: jest.fn(() => {}),
};
jest.doMock('nodegit', () => mocks);
jest.doMock('fs-extra', () => ({
promises: {
mkdtemp: jest.fn(dir => `${dir}-static`),
@@ -30,10 +25,16 @@ import {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, Git } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
describe('AzurePreparer', () => {
const mockGitClient = {
clone: jest.fn(),
};
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
let mockEntity: TemplateEntityV1alpha1;
beforeEach(() => {
jest.clearAllMocks();
@@ -77,18 +78,7 @@ describe('AzurePreparer', () => {
};
});
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(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 () => {
it('initializes git client with the correct arguments if an access token is provided for a repository', async () => {
const preparer = new AzurePreparer(
new ConfigReader({
scaffolder: {
@@ -100,36 +90,44 @@ describe('AzurePreparer', () => {
},
}),
);
const logger = getVoidLogger();
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
username: 'notempty',
password: 'fake-token',
logger,
});
});
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(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
expect.any(String),
{
fetchOpts: {
callbacks: {
credentials: expect.anything(),
},
},
},
);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
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 AzurePreparer(new ConfigReader({}));
delete mockEntity.spec.path;
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
expect.any(String),
{},
);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url:
'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo',
dir: expect.any(String),
});
});
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, {
logger: getVoidLogger(),
});
@@ -142,6 +140,7 @@ 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, {
logger: getVoidLogger(),
workingDirectory: '/workDir',
@@ -18,10 +18,9 @@ import fs from 'fs-extra';
import path from 'path';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from '../helpers';
import { InputError } from '@backstage/backend-common';
import { InputError, Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import GitUriParser from 'git-url-parse';
import { Clone, Cred } from 'nodegit';
import { Config } from '@backstage/config';
export class AzurePreparer implements PreparerBase {
@@ -38,6 +37,7 @@ export class AzurePreparer implements PreparerBase {
): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
const { logger } = opts;
if (!['azure/api', 'url'].includes(protocol)) {
throw new InputError(
@@ -57,19 +57,20 @@ export class AzurePreparer implements PreparerBase {
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),
},
},
}
: {};
// 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 = this.privateToken
? Git.fromAuth({
password: this.privateToken,
username: 'notempty',
logger,
})
: Git.fromAuth({ logger });
await Clone.clone(repositoryCheckoutUrl, tempDir, options);
await git.clone({
url: repositoryCheckoutUrl,
dir: tempDir,
});
return path.resolve(tempDir, templateDirectory);
}
@@ -14,11 +14,6 @@
* limitations under the License.
*/
const mocks = {
Clone: { clone: jest.fn() },
CheckoutOptions: jest.fn(() => {}),
};
jest.doMock('nodegit', () => mocks);
jest.doMock('fs-extra', () => ({
promises: {
mkdtemp: jest.fn(dir => `${dir}-static`),
@@ -30,10 +25,16 @@ import {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, Git } from '@backstage/backend-common';
describe('GitHubPreparer', () => {
let mockEntity: TemplateEntityV1alpha1;
const mockGitClient = {
clone: jest.fn(),
};
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
beforeEach(() => {
jest.clearAllMocks();
mockEntity = {
@@ -77,28 +78,24 @@ describe('GitHubPreparer', () => {
});
it('calls the clone command with the correct arguments for a repository', async () => {
const preparer = new GithubPreparer();
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{
checkoutBranch: 'master',
},
);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://github.com/benjdlambert/backstage-graphql-template',
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();
delete mockEntity.spec.path;
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{
checkoutBranch: 'master',
},
);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://github.com/benjdlambert/backstage-graphql-template',
dir: expect.any(String),
});
});
it('return the temp directory with the path to the folder if it is specified', async () => {
@@ -128,19 +125,14 @@ describe('GitHubPreparer', () => {
it('calls the clone command with the token when provided', async () => {
const preparer = new GithubPreparer({ token: 'abc' });
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{
checkoutBranch: 'master',
fetchOpts: {
callbacks: {
credentials: expect.any(Function),
},
},
},
);
const logger = getVoidLogger();
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'abc',
password: 'x-oauth-basic',
});
});
});
@@ -18,10 +18,9 @@ import fs from 'fs-extra';
import path from 'path';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from '../helpers';
import { InputError } from '@backstage/backend-common';
import { InputError, Git } from '@backstage/backend-common';
import { PreparerBase, PreparerOptions } from './types';
import GitUriParser from 'git-url-parse';
import { Clone, CloneOptions, Cred } from 'nodegit';
export class GithubPreparer implements PreparerBase {
token?: string;
@@ -36,7 +35,7 @@ export class GithubPreparer implements PreparerBase {
): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
const { token } = this;
const { logger } = opts;
if (!['github', 'url'].includes(protocol)) {
throw new InputError(
@@ -56,25 +55,21 @@ export class GithubPreparer implements PreparerBase {
template.spec.path ?? '.',
);
let cloneOptions: CloneOptions = {
checkoutBranch: parsedGitLocation.ref,
};
const checkoutLocation = path.resolve(tempDir, templateDirectory);
if (token) {
cloneOptions = {
...cloneOptions,
fetchOpts: {
callbacks: {
credentials() {
return Cred.userpassPlaintextNew(token, 'x-oauth-basic');
},
},
},
};
}
const git = this.token
? Git.fromAuth({
username: this.token,
password: 'x-oauth-basic',
logger,
})
: Git.fromAuth({ logger });
await Clone.clone(repositoryCheckoutUrl, tempDir, cloneOptions);
await git.clone({
url: repositoryCheckoutUrl,
dir: tempDir,
});
return path.resolve(tempDir, templateDirectory);
return checkoutLocation;
}
}
@@ -13,11 +13,6 @@
* 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);
jest.doMock('fs-extra', () => ({
promises: {
mkdtemp: jest.fn(dir => `${dir}-static`),
@@ -30,7 +25,7 @@ import {
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, Git } from '@backstage/backend-common';
const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({
apiVersion: 'backstage.io/v1alpha1',
@@ -72,6 +67,12 @@ const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({
describe('GitLabPreparer', () => {
let mockEntity: TemplateEntityV1alpha1;
const mockGitClient = {
clone: jest.fn(),
};
jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any);
beforeEach(() => {
jest.clearAllMocks();
});
@@ -80,13 +81,13 @@ describe('GitLabPreparer', () => {
it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => {
const preparer = new GitlabPreparer(new ConfigReader({}));
mockEntity = mockEntityWithProtocol(protocol);
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://gitlab.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{},
);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template',
dir: expect.any(String),
});
});
it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository using the ${protocol} protocol`, async () => {
@@ -103,19 +104,15 @@ describe('GitLabPreparer', () => {
}),
);
mockEntity = mockEntityWithProtocol(protocol);
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://gitlab.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{
fetchOpts: {
callbacks: {
credentials: expect.anything(),
},
},
},
);
const logger = getVoidLogger();
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 using the ${protocol} protocol`, async () => {
@@ -127,32 +124,28 @@ describe('GitLabPreparer', () => {
}),
);
mockEntity = mockEntityWithProtocol(protocol);
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://gitlab.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{
fetchOpts: {
callbacks: {
credentials: expect.anything(),
},
},
},
);
const logger = getVoidLogger();
await preparer.prepare(mockEntity, { logger });
expect(Git.fromAuth).toHaveBeenCalledWith({
logger,
username: 'oauth2',
password: 'fake-token',
});
});
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(new ConfigReader({}));
mockEntity = mockEntityWithProtocol(protocol);
delete mockEntity.spec.path;
await preparer.prepare(mockEntity, { logger: getVoidLogger() });
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://gitlab.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{},
);
expect(mockGitClient.clone).toHaveBeenCalledWith({
url: 'https://gitlab.com/benjdlambert/backstage-graphql-template',
dir: expect.any(String),
});
});
it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => {
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { InputError, Git } from '@backstage/backend-common';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
@@ -22,7 +22,6 @@ import {
} from '@backstage/integration';
import fs from 'fs-extra';
import GitUriParser from 'git-url-parse';
import { Clone, Cred } from 'nodegit';
import os from 'os';
import path from 'path';
import { parseLocationAnnotation } from '../helpers';
@@ -46,6 +45,7 @@ export class GitlabPreparer implements PreparerBase {
opts: PreparerOptions,
): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
const { logger } = opts;
const workingDirectory = opts?.workingDirectory ?? os.tmpdir();
if (!['gitlab', 'gitlab/api', 'url'].includes(protocol)) {
@@ -67,17 +67,18 @@ export class GitlabPreparer implements PreparerBase {
);
const token = this.getToken(parsedGitLocation.resource);
const options = token
? {
fetchOpts: {
callbacks: {
credentials: () => Cred.userpassPlaintextNew('oauth2', token),
},
},
}
: {};
const git = token
? Git.fromAuth({
password: token,
username: 'oauth2',
logger,
})
: Git.fromAuth({ logger });
await Clone.clone(repositoryCheckoutUrl, tempDir, options);
await git.clone({
url: repositoryCheckoutUrl,
dir: tempDir,
});
return path.resolve(tempDir, templateDirectory);
}
@@ -13,17 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('nodegit');
jest.mock('azure-devops-node-api/GitApi');
jest.mock('azure-devops-node-api/interfaces/GitInterfaces');
jest.mock('./helpers', () => ({
pushToRemoteUserPass: jest.fn(),
}));
jest.mock('./helpers');
import { AzurePublisher } from './azure';
import { GitApi } from 'azure-devops-node-api/GitApi';
import { pushToRemoteUserPass } from './helpers';
import * as helpers from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
const { mockGitApi } = require('azure-devops-node-api/GitApi') as {
mockGitApi: {
@@ -33,6 +28,7 @@ const { mockGitApi } = require('azure-devops-node-api/GitApi') as {
describe('Azure Publisher', () => {
const publisher = new AzurePublisher(new GitApi('', []), 'fake-token');
const logger = getVoidLogger();
beforeEach(() => {
jest.clearAllMocks();
@@ -50,6 +46,7 @@ describe('Azure Publisher', () => {
owner: 'bob',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
@@ -63,12 +60,12 @@ describe('Azure Publisher', () => {
},
'project',
);
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'https://dev.azure.com/organization/project/_git/repo',
'notempty',
'fake-token',
);
expect(helpers.initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
auth: { username: 'notempty', password: 'fake-token' },
logger,
});
});
});
});
@@ -17,9 +17,9 @@
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { GitApi } from 'azure-devops-node-api/GitApi';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { pushToRemoteUserPass } from './helpers';
import { JsonValue } from '@backstage/config';
import { RequiredTemplateValues } from '../templater';
import { initRepoAndPush } from './helpers';
export class AzurePublisher implements PublisherBase {
private readonly client: GitApi;
@@ -33,11 +33,21 @@ export class AzurePublisher implements PublisherBase {
async publish({
values,
directory,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const remoteUrl = await this.createRemote(values);
await pushToRemoteUserPass(directory, remoteUrl, 'notempty', this.token);
const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`;
await initRepoAndPush({
dir: directory,
remoteUrl,
auth: {
username: 'notempty',
password: this.token,
},
logger,
});
return { remoteUrl, catalogInfoUrl };
}
@@ -15,10 +15,7 @@
*/
jest.mock('@octokit/rest');
jest.mock('nodegit');
jest.mock('./helpers', () => ({
pushToRemoteUserPass: jest.fn(),
}));
jest.mock('./helpers');
import { Octokit } from '@octokit/rest';
import {
@@ -27,7 +24,8 @@ import {
UsersGetByUsernameResponseData,
} from '@octokit/types';
import { GithubPublisher } from './github';
import { pushToRemoteUserPass } from './helpers';
import { initRepoAndPush } from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
const { mockGithubClient } = require('@octokit/rest') as {
mockGithubClient: {
@@ -38,6 +36,7 @@ const { mockGithubClient } = require('@octokit/rest') as {
};
describe('GitHub Publisher', () => {
const logger = getVoidLogger();
beforeEach(() => {
jest.clearAllMocks();
});
@@ -69,6 +68,7 @@ describe('GitHub Publisher', () => {
access: 'blam/team',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
@@ -91,12 +91,12 @@ describe('GitHub Publisher', () => {
repo: 'test',
permission: 'admin',
});
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'https://github.com/backstage/backstage.git',
'abc',
'x-oauth-basic',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'abc', password: 'x-oauth-basic' },
logger,
});
});
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
@@ -118,6 +118,7 @@ describe('GitHub Publisher', () => {
access: 'blam',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
@@ -132,12 +133,13 @@ describe('GitHub Publisher', () => {
private: false,
});
expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled();
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'https://github.com/backstage/backstage.git',
'abc',
'x-oauth-basic',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'abc', password: 'x-oauth-basic' },
logger,
});
});
});
@@ -161,6 +163,7 @@ describe('GitHub Publisher', () => {
description: 'description',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
@@ -181,12 +184,12 @@ describe('GitHub Publisher', () => {
username: 'bob',
permission: 'admin',
});
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'https://github.com/backstage/backstage.git',
'abc',
'x-oauth-basic',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'abc', password: 'x-oauth-basic' },
logger,
});
});
});
@@ -216,6 +219,7 @@ describe('GitHub Publisher', () => {
owner: 'bob',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
@@ -229,12 +233,12 @@ describe('GitHub Publisher', () => {
private: true,
visibility: 'internal',
});
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'https://github.com/backstage/backstage.git',
'abc',
'x-oauth-basic',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'abc', password: 'x-oauth-basic' },
logger,
});
});
});
@@ -263,6 +267,7 @@ describe('GitHub Publisher', () => {
owner: 'bob',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({
@@ -276,12 +281,12 @@ describe('GitHub Publisher', () => {
name: 'test',
private: true,
});
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'https://github.com/backstage/backstage.git',
'abc',
'x-oauth-basic',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'https://github.com/backstage/backstage.git',
auth: { username: 'abc', password: 'x-oauth-basic' },
logger,
});
});
});
});
@@ -16,7 +16,7 @@
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { Octokit } from '@octokit/rest';
import { pushToRemoteUserPass } from './helpers';
import { initRepoAndPush } from './helpers';
import { JsonValue } from '@backstage/config';
import { RequiredTemplateValues } from '../templater';
@@ -46,14 +46,20 @@ export class GithubPublisher implements PublisherBase {
async publish({
values,
directory,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const remoteUrl = await this.createRemote(values);
await pushToRemoteUserPass(
directory,
await initRepoAndPush({
dir: directory,
remoteUrl,
this.token,
'x-oauth-basic',
);
auth: {
username: this.token,
password: 'x-oauth-basic',
},
logger,
});
const catalogInfoUrl = remoteUrl.replace(
/\.git$/,
'/blob/master/catalog-info.yaml',
@@ -14,16 +14,14 @@
* limitations under the License.
*/
jest.mock('nodegit');
jest.mock('@gitbeaker/node');
jest.mock('./helpers', () => ({
pushToRemoteUserPass: jest.fn(),
}));
jest.mock('./helpers');
import { GitlabPublisher } from './gitlab';
import { Gitlab as GitlabAPI } from '@gitbeaker/core';
import { Gitlab } from '@gitbeaker/node';
import { pushToRemoteUserPass } from './helpers';
import { initRepoAndPush } from './helpers';
import { getVoidLogger } from '@backstage/backend-common';
const { mockGitlabClient } = require('@gitbeaker/node') as {
mockGitlabClient: {
@@ -34,6 +32,7 @@ const { mockGitlabClient } = require('@gitbeaker/node') as {
};
describe('GitLab Publisher', () => {
const logger = getVoidLogger();
const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token');
beforeEach(() => {
@@ -56,6 +55,7 @@ describe('GitLab Publisher', () => {
owner: 'bob',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({ remoteUrl: 'mockclone' });
@@ -63,12 +63,12 @@ describe('GitLab Publisher', () => {
namespace_id: 42,
name: 'test',
});
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'mockclone',
'oauth2',
'fake-token',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'mockclone',
auth: { username: 'oauth2', password: 'fake-token' },
logger,
});
});
it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => {
@@ -86,6 +86,7 @@ describe('GitLab Publisher', () => {
owner: 'bob',
},
directory: '/tmp/test',
logger,
});
expect(result).toEqual({ remoteUrl: 'mockclone' });
@@ -94,12 +95,12 @@ describe('GitLab Publisher', () => {
namespace_id: 21,
name: 'test',
});
expect(pushToRemoteUserPass).toHaveBeenCalledWith(
'/tmp/test',
'mockclone',
'oauth2',
'fake-token',
);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: '/tmp/test',
remoteUrl: 'mockclone',
auth: { username: 'oauth2', password: 'fake-token' },
logger,
});
});
});
});
@@ -16,8 +16,8 @@
import { PublisherBase, PublisherOptions, PublisherResult } from './types';
import { Gitlab } from '@gitbeaker/core';
import { pushToRemoteUserPass } from './helpers';
import { JsonValue } from '@backstage/config';
import { initRepoAndPush } from './helpers';
import { RequiredTemplateValues } from '../templater';
export class GitlabPublisher implements PublisherBase {
@@ -32,9 +32,19 @@ export class GitlabPublisher implements PublisherBase {
async publish({
values,
directory,
logger,
}: PublisherOptions): Promise<PublisherResult> {
const remoteUrl = await this.createRemote(values);
await pushToRemoteUserPass(directory, remoteUrl, 'oauth2', this.token);
await initRepoAndPush({
dir: directory,
remoteUrl,
auth: {
username: 'oauth2',
password: this.token,
},
logger,
});
return { remoteUrl };
}
@@ -1,112 +0,0 @@
/*
* 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');
import * as NodeGit from 'nodegit';
import { pushToRemoteCred } from './helpers';
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('pushToRemoteCred', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const directory = '/tmp/test/dir';
const remote = 'mockclone';
const credentialsProvider = () =>
NodeGit.Cred.userpassPlaintextNew('username', 'password');
it('should call init on the repo with the directory', async () => {
await pushToRemoteCred(directory, remote, credentialsProvider);
expect(Repository.init).toHaveBeenCalledWith(directory, 0);
});
it('should call refresh index on the index and write the new files', async () => {
await pushToRemoteCred(directory, remote, credentialsProvider);
expect(mockRepo.refreshIndex).toHaveBeenCalled();
});
it('should call add all files and write', async () => {
await pushToRemoteCred(directory, remote, credentialsProvider);
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 pushToRemoteCred(directory, remote, credentialsProvider);
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 pushToRemoteCred(directory, remote, credentialsProvider);
expect(Remote.create).toHaveBeenCalledWith(mockRepo, 'origin', 'mockclone');
});
it('shoud push to the remote repo', async () => {
await pushToRemoteCred(directory, remote, credentialsProvider);
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(
'username',
'password',
);
});
});
@@ -13,43 +13,57 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Repository, Remote, Signature, Cred } from 'nodegit';
export async function pushToRemoteCred(
directory: string,
remote: string,
credentialsProvider: () => Cred,
): 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,
[],
);
import globby from 'globby';
import { Logger } from 'winston';
import { Git } from '@backstage/backend-common';
const remoteRepo = await Remote.create(repo, 'origin', remote);
export async function initRepoAndPush({
dir,
remoteUrl,
auth,
logger,
}: {
dir: string;
remoteUrl: string;
auth: { username: string; password: string };
logger: Logger;
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
password: auth.password,
logger,
});
await remoteRepo.push(['refs/heads/master:refs/heads/master'], {
callbacks: {
credentials: credentialsProvider,
},
await git.init({
dir,
});
const paths = await globby(['./**', './**/.*'], {
cwd: dir,
gitignore: true,
dot: true,
});
for (const filepath of paths) {
await git.add({ dir, filepath });
}
await git.commit({
dir,
message: 'Initial commit',
author: { name: 'Scaffolder', email: 'scaffolder@backstage.io' },
committer: { name: 'Scaffolder', email: 'scaffolder@backstage.io' },
});
await git.addRemote({
dir,
url: remoteUrl,
remote: 'origin',
});
await git.push({
dir,
remote: 'origin',
});
}
export async function pushToRemoteUserPass(
directory: string,
remote: string,
username: string,
password: string,
): Promise<void> {
return pushToRemoteCred(directory, remote, () =>
Cred.userpassPlaintextNew(username, password),
);
}
@@ -17,6 +17,7 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { RequiredTemplateValues } from '../templater';
import { JsonValue } from '@backstage/config';
import { RemoteProtocol } from '../types';
import { Logger } from 'winston';
/**
* Publisher is in charge of taking a folder created by
@@ -34,6 +35,7 @@ export type PublisherBase = {
export type PublisherOptions = {
values: RequiredTemplateValues & Record<string, JsonValue>;
logger: Logger;
directory: string;
};
@@ -159,6 +159,7 @@ export async function createRouter(
const result = await publisher.publish({
values: ctx.values,
directory: ctx.resultDir,
logger: ctx.logger,
});
return result;
},