feature(azure devops): support multiple organisations

Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
Sander Aernouts
2023-06-12 16:37:22 +02:00
parent b3d14f8112
commit 5f1a92b9f1
28 changed files with 2387 additions and 363 deletions
+2
View File
@@ -9,6 +9,7 @@
import { AppConfig } from '@backstage/config';
import { AwsCredentialsManager } from '@backstage/integration-aws-node';
import { AwsS3Integration } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BitbucketCloudIntegration } from '@backstage/integration';
@@ -94,6 +95,7 @@ export class AzureUrlReader implements UrlReader {
integration: AzureIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: AzureDevOpsCredentialsProvider;
},
);
// (undocumented)
@@ -17,7 +17,11 @@
import { ConfigReader } from '@backstage/config';
import {
AzureIntegration,
DefaultAzureDevOpsCredentialsProvider,
readAzureIntegrationConfig,
ScmIntegrations,
AzureDevOpsCredentialLike,
AzureIntegrationConfig,
} from '@backstage/integration';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -31,12 +35,40 @@ import { getVoidLogger } from '../logging';
import { AzureUrlReader } from './AzureUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
type AzureIntegrationConfigLike = Partial<
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
> & {
credentials?: Partial<AzureDevOpsCredentialLike>[];
};
const logger = getVoidLogger();
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const urlReaderFactory = (azureIntegration: AzureIntegrationConfigLike) => {
const credentialsProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [azureIntegration],
},
}),
),
);
return new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(new ConfigReader(azureIntegration)),
),
{
treeResponseFactory,
credentialsProvider,
},
);
};
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('AzureUrlReader', () => {
@@ -68,13 +100,29 @@ describe('AzureUrlReader', () => {
);
});
const createConfig = (token?: string) =>
new ConfigReader(
const createConfig = (token?: string) => {
let credentials: AzureDevOpsCredentialLike[] | undefined = undefined;
if (token !== undefined) {
credentials = [
{
personalAccessToken: token,
},
];
}
return new ConfigReader(
{
integrations: { azure: [{ host: 'dev.azure.com', token }] },
integrations: {
azure: [
{
host: 'dev.azure.com',
credentials: credentials,
},
],
},
},
'test-config',
);
};
it.each([
{
@@ -104,8 +152,8 @@ describe('AzureUrlReader', () => {
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
config: createConfig(undefined),
response: expect.objectContaining({
headers: expect.not.objectContaining({
authorization: expect.anything(),
headers: expect.objectContaining({
authorization: expect.stringMatching(/^Bearer /),
}),
}),
},
@@ -137,7 +185,7 @@ describe('AzureUrlReader', () => {
url: '',
config: createConfig(''),
error:
"Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
"Invalid type in config for key 'integrations.azure[0].credentials[0].personalAccessToken' in 'test-config', got empty-string, wanted string",
},
])('should handle error path %#', async ({ url, config, error }) => {
await expect(async () => {
@@ -156,16 +204,14 @@ describe('AzureUrlReader', () => {
path.resolve(__dirname, '__fixtures__/mock-main.zip'),
);
const processor = new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(
new ConfigReader({
host: 'dev.azure.com',
}),
),
),
{ treeResponseFactory },
);
const processor = urlReaderFactory({
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'my-pat',
},
],
});
beforeEach(() => {
worker.use(
@@ -268,16 +314,14 @@ describe('AzureUrlReader', () => {
path.resolve(__dirname, '__fixtures__/mock-main.zip'),
);
const processor = new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(
new ConfigReader({
host: 'dev.azure.com',
}),
),
),
{ treeResponseFactory },
);
const processor = urlReaderFactory({
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'my-pat',
},
],
});
beforeEach(() => {
worker.use(
@@ -15,12 +15,13 @@
*/
import {
AzureIntegration,
getAzureCommitsUrl,
getAzureDownloadUrl,
getAzureFileFetchUrl,
getAzureRequestOptions,
AzureDevOpsCredentialsProvider,
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrations,
AzureIntegration,
} from '@backstage/integration';
import fetch, { Response } from 'node-fetch';
import { Minimatch } from 'minimatch';
@@ -47,8 +48,13 @@ import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
export class AzureUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
const credentialProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations);
return integrations.azure.list().map(integration => {
const reader = new AzureUrlReader(integration, { treeResponseFactory });
const reader = new AzureUrlReader(integration, {
treeResponseFactory,
credentialsProvider: credentialProvider,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
@@ -56,7 +62,10 @@ export class AzureUrlReader implements UrlReader {
constructor(
private readonly integration: AzureIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
private readonly deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: AzureDevOpsCredentialsProvider;
},
) {}
async read(url: string): Promise<Buffer> {
@@ -72,11 +81,13 @@ export class AzureUrlReader implements UrlReader {
const { signal } = options ?? {};
const builtUrl = getAzureFileFetchUrl(url);
let response: Response;
try {
const credentials = await this.deps.credentialsProvider.getCredentials({
url: builtUrl,
});
response = await fetch(builtUrl, {
...(await getAzureRequestOptions(this.integration.config)),
headers: credentials?.headers,
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
@@ -111,10 +122,13 @@ export class AzureUrlReader implements UrlReader {
// Get latest commit SHA
const commitsAzureResponse = await fetch(
getAzureCommitsUrl(url),
await getAzureRequestOptions(this.integration.config),
);
const credentials = await this.deps.credentialsProvider.getCredentials({
url: url,
});
const commitsAzureResponse = await fetch(getAzureCommitsUrl(url), {
headers: credentials?.headers,
});
if (!commitsAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`;
if (commitsAzureResponse.status === 404) {
@@ -129,9 +143,10 @@ export class AzureUrlReader implements UrlReader {
}
const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), {
...(await getAzureRequestOptions(this.integration.config, {
headers: {
...credentials?.headers,
Accept: 'application/zip',
})),
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
@@ -198,7 +213,9 @@ export class AzureUrlReader implements UrlReader {
}
toString() {
const { host, token } = this.integration.config;
return `azure{host=${host},authed=${Boolean(token)}}`;
const { host, credentials } = this.integration.config;
return `azure{host=${host},authed=${Boolean(
credentials !== undefined && credentials.length > 0,
)}}`;
}
}