Added support for {org}.visualstudio.com domains used by Azure DevOps
Signed-off-by: Andre Wanlin <awanlin@spotify.com> Address CodeQL comments Signed-off-by: Andre Wanlin <awanlin@spotify.com> Another correction Signed-off-by: Andre Wanlin <awanlin@spotify.com> Fixed casing Signed-off-by: Andre Wanlin <awanlin@spotify.com> Adjusted to be more secure based on feedback Signed-off-by: Andre Wanlin <awanlin@spotify.com> Tighten up endsWith Signed-off-by: Andre Wanlin <awanlin@spotify.com> Corrections to TSDoc comment Signed-off-by: Andre Wanlin <awanlin@spotify.com> Changes based on feedback Signed-off-by: Andre Wanlin <awanlin@spotify.com> Correct URL for discovery Signed-off-by: Andre Wanlin <awanlin@spotify.com> Updated docs Signed-off-by: Andre Wanlin <awanlin@spotify.com> Updated changeset Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/integration': patch
|
||||
'@backstage/plugin-catalog-backend-module-azure': patch
|
||||
---
|
||||
|
||||
Added support for `{org}.visualstudio.com` domains used by Azure DevOps
|
||||
@@ -9,11 +9,7 @@ description: Automatically discovering catalog entities from repositories in an
|
||||
This documentation is written for [the new backend system](../../backend-system/index.md) which is the default since Backstage [version 1.24](../../releases/v1.24.0.md). If you are still on the old backend system, you may want to read [its own article](https://github.com/backstage/backstage/blob/v1.37.0/docs/integrations/azure/discovery--old.md) instead, and [consider migrating](../../backend-system/building-backends/08-migrating.md)!
|
||||
:::
|
||||
|
||||
The Azure DevOps integration has a special entity provider for discovering
|
||||
catalog entities within an Azure DevOps. The provider will crawl your Azure
|
||||
DevOps organization and register entities matching the configured path. This can
|
||||
be useful as an alternative to static locations or manually adding things to the
|
||||
catalog.
|
||||
The Azure DevOps integration has a special entity provider for discovering catalog entities within an Azure DevOps. The provider will crawl your Azure DevOps organization and register entities matching the configured path. This can be useful as an alternative to static locations or manually adding things to the catalog.
|
||||
|
||||
This guide explains how to install and configure the Azure DevOps Entity Provider (recommended) or the Azure DevOps Processor.
|
||||
|
||||
@@ -21,9 +17,7 @@ This guide explains how to install and configure the Azure DevOps Entity Provide
|
||||
|
||||
### Code Search Feature
|
||||
|
||||
Azure discovery is driven by the Code Search feature in Azure DevOps, this may not be enabled by default. For Azure
|
||||
DevOps Services you can confirm this by looking at the installed extensions in your Organization Settings. For Azure
|
||||
DevOps Server you'll find this information in your Collection Settings.
|
||||
Azure discovery is driven by the Code Search feature in Azure DevOps, this may not be enabled by default. For Azure DevOps Services you can confirm this by looking at the installed extensions in your Organization Settings. For Azure DevOps Server you'll find this information in your Collection Settings.
|
||||
|
||||
If the Code Search extension is not listed then you can install it from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=ms.vss-code-search&targetId=f9352dac-ba6e-434e-9241-a848a510ce3f&utm_source=vstsproduct&utm_medium=SearchExtStatus).
|
||||
|
||||
@@ -68,7 +62,7 @@ catalog:
|
||||
|
||||
The parameters available are:
|
||||
|
||||
- **`host:`** _(optional)_ Leave empty for Cloud hosted, otherwise set to your self-hosted instance host.
|
||||
- **`host:`** _(optional)_ The default value is `dev.azure.com`, it is required for legacy `{org}.visualstudio.com` domains or for on-premise installations.
|
||||
- **`organization:`** Your Organization slug (or Collection for on-premise users). Required.
|
||||
- **`project:`** _(required)_ Your project slug. Wildcards are supported as shown on the examples above. Using '\*' will search all projects. For a project name containing spaces, use both single and double quotes as in `project: '"My Project Name"'`.
|
||||
- **`repository:`** _(optional)_ The repository name. Wildcards are supported as show on the examples above. If not set, all repositories will be searched.
|
||||
|
||||
@@ -200,6 +200,47 @@ However a system-assigned managed identity is the most secure option because:
|
||||
|
||||
:::
|
||||
|
||||
### Legacy `{org}.visualstudio.com` Domains
|
||||
|
||||
Backstage supports the legacy `{org}.visualstudio.com` domains along with all the previously mentioned authentication options, the caveat is that each Azure DevOps Organization will need to be defined in your configuration along with a single credential.
|
||||
|
||||
For example, this will work:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: my-org.visualstudio.com
|
||||
credentials:
|
||||
- clientId: ${AZURE_CLIENT_ID}
|
||||
clientSecret: ${AZURE_CLIENT_SECRET}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
As will this:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: my-other-org.visualstudio.com
|
||||
credentials:
|
||||
- personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
|
||||
```
|
||||
|
||||
But this will NOT work:
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
azure:
|
||||
- host: my-org.visualstudio.com
|
||||
credentials:
|
||||
- organizations:
|
||||
- my-org
|
||||
- my-other-org
|
||||
clientId: ${AZURE_CLIENT_ID}
|
||||
clientSecret: ${AZURE_CLIENT_SECRET}
|
||||
tenantId: ${AZURE_TENANT_ID}
|
||||
```
|
||||
|
||||
## Configuration schema
|
||||
|
||||
The configuration is a structure with these elements:
|
||||
|
||||
@@ -270,4 +270,53 @@ describe('AzureUrl', () => {
|
||||
),
|
||||
).toThrow('Azure URL must point to a git repository');
|
||||
});
|
||||
|
||||
it('should work with the old visualstudio long URL', () => {
|
||||
const url = AzureUrl.fromRepoUrl(
|
||||
'https://my-org.visualstudio.com/my-project/_git/my-repo',
|
||||
);
|
||||
|
||||
expect(url.getOwner()).toBe('my-org');
|
||||
expect(url.getProject()).toBe('my-project');
|
||||
expect(url.getRepo()).toBe('my-repo');
|
||||
expect(url.getRef()).toBeUndefined();
|
||||
expect(url.getPath()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should work with the old visualstudio long URL form with a path', () => {
|
||||
const url = AzureUrl.fromRepoUrl(
|
||||
'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ftest.yaml',
|
||||
);
|
||||
|
||||
expect(url.getOwner()).toBe('my-org');
|
||||
expect(url.getProject()).toBe('my-project');
|
||||
expect(url.getRepo()).toBe('my-repo');
|
||||
expect(url.getRef()).toBeUndefined();
|
||||
expect(url.getPath()).toBe('/test.yaml');
|
||||
|
||||
expect(url.toRepoUrl()).toBe(
|
||||
'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ftest.yaml',
|
||||
);
|
||||
expect(url.toFileUrl()).toBe(
|
||||
'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/items?api-version=6.0&path=%2Ftest.yaml',
|
||||
);
|
||||
expect(url.toArchiveUrl()).toBe(
|
||||
'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/items?recursionLevel=full&download=true&api-version=6.0&scopePath=%2Ftest.yaml',
|
||||
);
|
||||
expect(url.toCommitsUrl()).toBe(
|
||||
'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/commits?api-version=6.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with the old visualstudio long URL form with a path and ref', () => {
|
||||
const url = AzureUrl.fromRepoUrl(
|
||||
'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ffolder&version=GBtest-branch',
|
||||
);
|
||||
|
||||
expect(url.getOwner()).toBe('my-org');
|
||||
expect(url.getProject()).toBe('my-project');
|
||||
expect(url.getRepo()).toBe('my-repo');
|
||||
expect(url.getRef()).toBe('test-branch');
|
||||
expect(url.getPath()).toBe('/folder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isVisualStudioDomain } from './core';
|
||||
|
||||
export class AzureUrl {
|
||||
/**
|
||||
* Parses an azure URL as copied from the browser address bar.
|
||||
@@ -28,7 +30,12 @@ export class AzureUrl {
|
||||
let repo;
|
||||
|
||||
const parts = url.pathname.split('/').map(part => decodeURIComponent(part));
|
||||
if (parts[2] === '_git') {
|
||||
|
||||
if (isVisualStudioDomain(url.origin) && parts[2] === '_git') {
|
||||
owner = url.host.split('.')[0];
|
||||
project = parts[1];
|
||||
repo = parts[3];
|
||||
} else if (parts[2] === '_git') {
|
||||
owner = parts[1];
|
||||
project = repo = parts[3];
|
||||
} else if (parts[3] === '_git') {
|
||||
@@ -102,7 +109,9 @@ export class AzureUrl {
|
||||
*/
|
||||
toRepoUrl(): string {
|
||||
let url;
|
||||
if (this.#project === this.#repo) {
|
||||
if (isVisualStudioDomain(this.#origin)) {
|
||||
url = this.#baseUrl(this.#project, '_git', this.#repo);
|
||||
} else if (this.#project === this.#repo) {
|
||||
url = this.#baseUrl(this.#owner, '_git', this.#repo);
|
||||
} else {
|
||||
url = this.#baseUrl(this.#owner, this.#project, '_git', this.#repo);
|
||||
@@ -130,15 +139,28 @@ export class AzureUrl {
|
||||
);
|
||||
}
|
||||
|
||||
const url = this.#baseUrl(
|
||||
this.#owner,
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'items',
|
||||
);
|
||||
let url;
|
||||
if (isVisualStudioDomain(this.#origin)) {
|
||||
url = this.#baseUrl(
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'items',
|
||||
);
|
||||
} else {
|
||||
url = this.#baseUrl(
|
||||
this.#owner,
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'items',
|
||||
);
|
||||
}
|
||||
|
||||
url.searchParams.set('api-version', '6.0');
|
||||
url.searchParams.set('path', this.#path);
|
||||
|
||||
@@ -155,15 +177,28 @@ export class AzureUrl {
|
||||
* Throws an error if the URL does not point to a repo.
|
||||
*/
|
||||
toArchiveUrl(): string {
|
||||
const url = this.#baseUrl(
|
||||
this.#owner,
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'items',
|
||||
);
|
||||
let url;
|
||||
if (isVisualStudioDomain(this.#origin)) {
|
||||
url = this.#baseUrl(
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'items',
|
||||
);
|
||||
} else {
|
||||
url = this.#baseUrl(
|
||||
this.#owner,
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'items',
|
||||
);
|
||||
}
|
||||
|
||||
url.searchParams.set('recursionLevel', 'full');
|
||||
url.searchParams.set('download', 'true');
|
||||
url.searchParams.set('api-version', '6.0');
|
||||
@@ -184,15 +219,28 @@ export class AzureUrl {
|
||||
* Throws an error if the URL does not point to a commit.
|
||||
*/
|
||||
toCommitsUrl(): string {
|
||||
const url = this.#baseUrl(
|
||||
this.#owner,
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'commits',
|
||||
);
|
||||
let url;
|
||||
if (isVisualStudioDomain(this.#origin)) {
|
||||
url = this.#baseUrl(
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'commits',
|
||||
);
|
||||
} else {
|
||||
url = this.#baseUrl(
|
||||
this.#owner,
|
||||
this.#project,
|
||||
'_apis',
|
||||
'git',
|
||||
'repositories',
|
||||
this.#repo,
|
||||
'commits',
|
||||
);
|
||||
}
|
||||
|
||||
url.searchParams.set('api-version', '6.0');
|
||||
|
||||
if (this.#ref) {
|
||||
|
||||
@@ -445,5 +445,25 @@ describe('DefaultAzureDevOpsCredentialProvider', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Azure DevOps (visualstudio.com)', () => {
|
||||
it('Should return a token when a credential with the same organization is specified', async () => {
|
||||
const provider = buildProvider([
|
||||
{
|
||||
host: 'org1.visualstudio.com',
|
||||
credentials: [
|
||||
{
|
||||
personalAccessToken: 'pat',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const credentials = provider.getCredentials({
|
||||
url: 'https://org1.visualstudio.com/project1',
|
||||
});
|
||||
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
|
||||
import { ScmIntegrationRegistry } from '../registry';
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
import { isVisualStudioDomain } from './core';
|
||||
|
||||
/**
|
||||
* Default implementation of AzureDevOpsCredentialsProvider.
|
||||
@@ -110,6 +111,17 @@ export class DefaultAzureDevOpsCredentialsProvider
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private forVisualStudioOrganization(
|
||||
url: URL,
|
||||
): AzureDevOpsCredentialsProvider | undefined {
|
||||
const parts = url.host.split('.');
|
||||
if (isVisualStudioDomain(url.origin) && parts.length > 0) {
|
||||
return this.providers.get(url.host);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private forHost(url: URL): AzureDevOpsCredentialsProvider | undefined {
|
||||
return this.providers.get(url.host);
|
||||
}
|
||||
@@ -121,6 +133,7 @@ export class DefaultAzureDevOpsCredentialsProvider
|
||||
const provider =
|
||||
this.forAzureDevOpsOrganization(url) ??
|
||||
this.forAzureDevOpsServerOrganization(url) ??
|
||||
this.forVisualStudioOrganization(url) ??
|
||||
this.forHost(url);
|
||||
|
||||
if (provider === undefined) {
|
||||
|
||||
@@ -53,3 +53,14 @@ export function getAzureDownloadUrl(url: string): string {
|
||||
export function getAzureCommitsUrl(url: string): string {
|
||||
return AzureUrl.fromRepoUrl(url).toCommitsUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a URL, return true if it contains `.visualstudio.com` and false if it does not
|
||||
* URLs can be in these two formats: `dev.azure.com/{org}` or the legacy `{org}.visualstudio.com`
|
||||
*
|
||||
* @param origin - A URL origin string pointing to an Azure DevOps instance
|
||||
* @public
|
||||
*/
|
||||
export function isVisualStudioDomain(origin: string): boolean {
|
||||
return origin.endsWith('.visualstudio.com');
|
||||
}
|
||||
|
||||
@@ -75,12 +75,16 @@ export async function codeSearch(
|
||||
: `https://${azureConfig.host}`;
|
||||
const searchUrl = `${searchBaseUrl}/${org}/_apis/search/codesearchresults?api-version=6.0-preview.1`;
|
||||
|
||||
const url = azureConfig.host.endsWith('.visualstudio.com')
|
||||
? `https://${azureConfig.host}`
|
||||
: `https://${azureConfig.host}/${org}`;
|
||||
|
||||
let items: CodeSearchResultItem[] = [];
|
||||
let hasMorePages = true;
|
||||
|
||||
do {
|
||||
const credentials = await credentialsProvider.getCredentials({
|
||||
url: `https://${azureConfig.host}/${org}`,
|
||||
url,
|
||||
});
|
||||
|
||||
const searchRequestBody: CodeSearchRequest = {
|
||||
|
||||
Reference in New Issue
Block a user