Add support for reading files from Bitbucket Server.

This commit is contained in:
Mathias Åhsberg
2020-10-22 11:17:25 +02:00
parent 628f782948
commit 6579769dff
5 changed files with 482 additions and 222 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': minor
---
Add the ability to import components from Bitbucket Server to the service catalog
+1 -1
View File
@@ -41,7 +41,7 @@
"express": "^4.17.1",
"express-prom-bundle": "^6.1.0",
"express-promise-router": "^3.0.3",
"git-url-parse": "^11.3.0",
"git-url-parse": "^11.4.0",
"helmet": "^4.0.0",
"knex": "^0.21.1",
"lodash": "^4.17.15",
@@ -14,139 +14,253 @@
* limitations under the License.
*/
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '../logging';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { msw } from '@backstage/test-utils';
const logger = getVoidLogger();
import {
BitbucketUrlReader,
getApiRequestOptions,
getApiUrl,
getRawRequestOptions,
getRawUrl,
ProviderConfig,
readConfig,
} from './BitbucketUrlReader';
describe('BitbucketUrlReader', () => {
const worker = setupServer();
describe('getApiRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withToken).headers as any).Authorization,
).toEqual('Bearer A');
expect(
(getApiRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});
msw.setupDefaultHandlers(worker);
it('insert basic auth when needed', () => {
const withUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
const withoutUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getApiRequestOptions(withUsernameAndPassword).headers as any)
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getApiRequestOptions(withoutUsernameAndPassword).headers as any)
.Authorization,
).toBeUndefined();
});
});
beforeEach(() => {
worker.use(
rest.get('*', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
url: req.url.toString(),
headers: req.headers.getAllHeaders(),
}),
describe('getRawRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getRawRequestOptions(withToken).headers as any).Authorization,
).toEqual('Bearer A');
expect(
(getRawRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});
it('insert basic auth when needed', () => {
const withUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
const withoutUsernameAndPassword: ProviderConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getRawRequestOptions(withUsernameAndPassword).headers as any)
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getRawRequestOptions(withoutUsernameAndPassword).headers as any)
.Authorization,
).toBeUndefined();
});
});
describe('getApiUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for Bitbucket Cloud', () => {
const config: ProviderConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getApiUrl(
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config,
),
),
);
});
const createConfig = (username?: string, appPassword?: string) =>
new ConfigReader(
{
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
username: username,
appPassword: appPassword,
},
],
},
},
'test-config',
);
it.each([
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig(),
response: expect.objectContaining({
url:
).toEqual(
new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
}),
},
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig('some-user', 'my-secret'),
response: expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==',
}),
}),
},
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig(),
response: expect.objectContaining({
headers: expect.not.objectContaining({
authorization: expect.anything(),
}),
}),
},
{
url:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config: createConfig(undefined, 'only-password-provided'),
response: expect.objectContaining({
headers: expect.not.objectContaining({
authorization: expect.anything(),
}),
}),
},
])('should handle happy path %#', async ({ url, config, response }) => {
const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
const data = await reader.read(url);
const res = await JSON.parse(data.toString('utf-8'));
expect(res).toEqual(response);
),
);
});
it('happy path for Bitbucket Server', () => {
const config: ProviderConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
};
expect(
getApiUrl(
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
),
);
});
});
it.each([
{
url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
config: createConfig(),
error:
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path',
},
{
url: 'com/a/b/blob/master/path/to/c.yaml',
config: createConfig(),
error:
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
},
{
url: '',
config: createConfig('', ''),
error:
"Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
},
{
url: '',
config: createConfig('only-user-provided', ''),
error:
"Invalid type in config for key 'integrations.bitbucket[0].appPassword' in 'test-config', got empty-string, wanted string",
},
{
url: '',
config: createConfig('', 'only-password-provided'),
error:
"Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
},
{
url: '',
config: createConfig('only-user-provided', undefined),
error:
"Missing required config value at 'integrations.bitbucket[0].appPassword'",
},
])('should handle error path %#', async ({ url, config, error }) => {
await expect(async () => {
const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
await reader.read(url);
}).rejects.toThrow(error);
describe('getRawUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
});
it('happy path for Bitbucket Cloud', () => {
const config: ProviderConfig = {
host: 'bitbucket.org',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getRawUrl(
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config,
),
).toEqual(
new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
),
);
});
it('happy path for Bitbucket Server', () => {
const config: ProviderConfig = {
host: 'bitbucket.mycompany.net',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getRawUrl(
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
config,
),
).toEqual(
new URL(
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
),
);
});
});
describe('readConfig', () => {
function config(
providers: {
host: string;
apiBaseUrl?: string;
token?: string;
username?: string;
password?: string;
}[],
) {
return ConfigReader.fromConfigs([
{
context: '',
data: {
integrations: { bitbucket: providers },
},
},
]);
}
it('adds a default Bitbucket Cloud entry when missing', () => {
const output = readConfig(config([]));
expect(output).toEqual([
{
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
},
]);
});
it('injects the correct Bitbucket Cloud API base URL when missing', () => {
const output = readConfig(config([{ host: 'bitbucket.org' }]));
expect(output).toEqual([
{
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
rawBaseUrl: 'https://api.bitbucket.org/2.0',
},
]);
});
it('rejects custom targets with no base URLs', () => {
expect(() =>
readConfig(config([{ host: 'bitbucket.mycompany.net' }])),
).toThrow(
"Bitbucket integration for 'bitbucket.mycompany.net' must configure an explicit apiBaseUrl and rawBaseUrl",
);
});
it('rejects funky configs', () => {
expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/);
expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/);
expect(() =>
readConfig(config([{ host: 'bitbucket.org', apiBaseUrl: 7 } as any])),
).toThrow(/apiBaseUrl/);
expect(() =>
readConfig(config([{ host: 'bitbucket.org', token: 7 } as any])),
).toThrow(/token/);
});
});
describe('implementation', () => {
it('rejects unknown targets', async () => {
const processor = new BitbucketUrlReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
});
await expect(
processor.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
);
});
});
});
@@ -14,71 +14,262 @@
* limitations under the License.
*/
import fetch from 'cross-fetch';
import { Config } from '@backstage/config';
import { ReaderFactory, UrlReader } from './types';
import parseGitUri from 'git-url-parse';
import fetch from 'cross-fetch';
import { NotFoundError } from '../errors';
import { ReaderFactory, UrlReader } from './types';
type Options = {
// TODO: added here for future support, but we only allow bitbucket.org for now
const DEFAULT_BASE_URL = 'https://api.bitbucket.org/2.0';
/**
* The configuration parameters for a single Bitbucket API provider.
*/
export type ProviderConfig = {
/**
* The host of the target that this matches on, e.g. "bitbucket.com"
*/
host: string;
auth?: {
username: string;
appPassword: string;
};
/**
* The base URL of the API of this provider, e.g. "https://api.bitbucket.org/2.0",
* with no trailing slash.
*
* May be omitted specifically for Bitbucket Cloud; then it will be deduced.
*
* The API will always be preferred if both its base URL and a token are
* present.
*/
apiBaseUrl?: string;
/**
* The base URL of the raw fetch endpoint of this provider, e.g.
* "https://api.bitbucket.org/2.0", with no trailing slash.
*
* May be omitted specifically for Bitbucket Cloud; then it will be deduced.
*
* The API will always be preferred if both its base URL and a token are
* present.
*/
rawBaseUrl?: string;
/**
* The authorization token to use for requests to a Bitbucket Server provider.
*
* See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html
*
* If no token is specified, anonymous access is used.
*/
token?: string;
/**
* The username to use for requests to Bitbucket Cloud (bitbucket.org).
*/
username?: string;
/**
* Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords.
*
* See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
*/
appPassword?: string;
};
function readConfig(config: Config): Options[] {
const optionsArr = Array<Options>();
export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
const headers: HeadersInit = {};
if (provider.token) {
headers.Authorization = `Bearer ${provider.token}`;
} else if (provider.username && provider.appPassword) {
headers.Authorization = `Basic ${Buffer.from(
`${provider.username}:${provider.appPassword}`,
'utf8',
).toString('base64')}`;
}
return {
headers,
};
}
export function getRawRequestOptions(provider: ProviderConfig): RequestInit {
const headers: HeadersInit = {};
if (provider.token) {
headers.Authorization = `Bearer ${provider.token}`;
} else if (provider.username && provider.appPassword) {
headers.Authorization = `Basic ${Buffer.from(
`${provider.username}:${provider.appPassword}`,
'utf8',
).toString('base64')}`;
}
return {
headers,
};
}
// Converts for example
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
export function getApiUrl(target: string, provider: ProviderConfig): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
if (
!owner ||
!name ||
(filepathtype !== 'browse' &&
filepathtype !== 'raw' &&
filepathtype !== 'src')
) {
throw new Error('Invalid Bitbucket URL or file path');
}
const pathWithoutSlash = filepath.replace(/^\//, '');
if (provider.host === 'bitbucket.org') {
if (!ref) {
throw new Error('Invalid Bitbucket URL or file path');
}
return new URL(
`${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`,
);
}
return new URL(
`${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`,
);
} catch (e) {
throw new Error(`Incorrect URL: ${target}, ${e}`);
}
}
// Converts for example
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
export function getRawUrl(target: string, provider: ProviderConfig): URL {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
if (
!owner ||
!name ||
(filepathtype !== 'browse' &&
filepathtype !== 'raw' &&
filepathtype !== 'src')
) {
throw new Error('Invalid Bitbucket URL or file path');
}
const pathWithoutSlash = filepath.replace(/^\//, '');
if (provider.host === 'bitbucket.org') {
if (!ref) {
throw new Error('Invalid Bitbucket URL or file path');
}
return new URL(
`${provider.rawBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`,
);
}
return new URL(
`${provider.rawBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`,
);
} catch (e) {
throw new Error(`Incorrect URL: ${target}, ${e}`);
}
}
export function readConfig(config: Config): ProviderConfig[] {
const providers: ProviderConfig[] = [];
const providerConfigs =
config.getOptionalConfigArray('integrations.bitbucket') ?? [];
// First read all the explicit providers
for (const providerConfig of providerConfigs) {
const host = providerConfig.getOptionalString('host') ?? 'bitbucket.org';
let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl');
let rawBaseUrl = providerConfig.getOptionalString('rawBaseUrl');
const token = providerConfig.getOptionalString('token');
const username = providerConfig.getOptionalString('username');
const password = providerConfig.getOptionalString('appPassword');
let auth;
if (providerConfig.has('username')) {
const username = providerConfig.getString('username');
const appPassword = providerConfig.getString('appPassword');
auth = { username, appPassword };
if (apiBaseUrl) {
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
} else if (host === 'bitbucket.org') {
apiBaseUrl = DEFAULT_BASE_URL;
}
optionsArr.push({ host, auth });
if (rawBaseUrl) {
rawBaseUrl = rawBaseUrl.replace(/\/+$/, '');
} else if (host === 'bitbucket.org') {
rawBaseUrl = DEFAULT_BASE_URL;
}
if (!apiBaseUrl && !rawBaseUrl) {
throw new Error(
`Bitbucket integration for '${host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
);
}
if (!token && username && !password) {
throw new Error(
`Bitbucket integration for '${host}' has configured a username but is missing a required password.`,
);
}
providers.push({
host,
apiBaseUrl,
rawBaseUrl,
token,
username,
appPassword: password,
});
}
// As a convenience we always make sure there's at least an unauthenticated
// reader for public bitbucket repos.
if (!optionsArr.some(p => p.host === 'bitbucket.org')) {
optionsArr.push({ host: 'bitbucket.org' });
// If no explicit bitbucket.org provider was added, put one in the list as
// a convenience
if (!providers.some(p => p.host === 'bitbucket.org')) {
providers.push({
host: 'bitbucket.org',
apiBaseUrl: DEFAULT_BASE_URL,
rawBaseUrl: DEFAULT_BASE_URL,
});
}
return optionsArr;
return providers;
}
/**
* A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as
* the one exposed by Bitbucket Cloud itself.
*/
export class BitbucketUrlReader implements UrlReader {
private config: ProviderConfig;
static factory: ReaderFactory = ({ config }) => {
return readConfig(config).map(options => {
const reader = new BitbucketUrlReader(options);
const predicate = (url: URL) => url.host === options.host;
return readConfig(config).map(provider => {
const reader = new BitbucketUrlReader(provider);
const predicate = (url: URL) => url.host === provider.host;
return { reader, predicate };
});
};
constructor(private readonly options: Options) {
if (options.host !== 'bitbucket.org') {
throw Error(
`Bitbucket integration currently only supports 'bitbucket.org', tried to use host '${options.host}'`,
);
}
constructor(config: ProviderConfig) {
this.config = config;
}
async read(url: string): Promise<Buffer> {
const builtUrl = this.buildRawUrl(url);
const useApi =
this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl);
const bitbucketUrl = useApi
? getApiUrl(url, this.config)
: getRawUrl(url, this.config);
const options = useApi
? getApiRequestOptions(this.config)
: getRawRequestOptions(this.config);
let response: Response;
try {
response = await fetch(builtUrl.toString(), this.getRequestOptions());
response = await fetch(bitbucketUrl.toString(), options);
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -87,76 +278,19 @@ export class BitbucketUrlReader implements UrlReader {
return Buffer.from(await response.text());
}
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
// Converts
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
private buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
userOrOrg,
repoName,
srcKeyword,
ref,
...restOfPath
] = url.pathname.split('/');
if (
url.hostname !== 'bitbucket.org' ||
empty !== '' ||
userOrOrg === '' ||
repoName === '' ||
srcKeyword !== 'src'
) {
throw new Error('Wrong Bitbucket URL or Invalid file path');
}
// transform to api
url.pathname = [
empty,
'2.0',
'repositories',
userOrOrg,
repoName,
'src',
ref,
...restOfPath,
].join('/');
url.hostname = 'api.bitbucket.org';
url.protocol = 'https';
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
private getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
if (this.options.auth) {
headers.Authorization = `Basic ${Buffer.from(
`${this.options.auth.username}:${this.options.auth.appPassword}`,
'utf8',
).toString('base64')}`;
}
return {
headers,
};
}
toString() {
const { host, auth } = this.options;
return `bitbucket{host=${host},authed=${Boolean(auth)}}`;
const { host, token, username, appPassword } = this.config;
let authed = Boolean(token);
if (!authed) {
authed = Boolean(username && appPassword);
}
return `bitbucket{host=${host},authed=${authed}}`;
}
}
+7
View File
@@ -12249,6 +12249,13 @@ git-url-parse@^11.3.0:
dependencies:
git-up "^4.0.0"
git-url-parse@^11.4.0:
version "11.4.0"
resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.0.tgz#f2bb1f2b00f05552540e95a62e31399a639a6aa6"
integrity sha512-KlIa5jvMYLjXMQXkqpFzobsyD/V2K5DRHl5OAf+6oDFPlPLxrGDVQlIdI63c4/Kt6kai4kALENSALlzTGST3GQ==
dependencies:
git-up "^4.0.0"
gitconfiglocal@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b"