feature(azure devops): support multiple organisations
Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
@@ -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,
|
||||
)}}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user