From 63b5094915bfa85a9de849950fe0b7c749218ad6 Mon Sep 17 00:00:00 2001 From: kroonprins <kroonprins@users.noreply.github.com> Date: Tue, 24 Mar 2020 23:08:00 +0100 Subject: [PATCH] feat(azure): support Azure DevOps Server authentication methods (#5602) * feat(azure): support Azure DevOps Server authentication methods * feat(azure): support Azure DevOps Server authentication methods Co-authored-by: Jamie Magee <JamieMagee@users.noreply.github.com> --- .../azure-got-wrapper.spec.ts.snap | 141 +++++++++++++++++- .../__snapshots__/azure-helper.spec.ts.snap | 18 +++ lib/platform/azure/azure-got-wrapper.spec.ts | 48 +++++- lib/platform/azure/azure-got-wrapper.ts | 16 +- lib/platform/azure/azure-helper.spec.ts | 20 +++ lib/platform/azure/azure-helper.ts | 21 +++ lib/platform/azure/index.spec.ts | 20 ++- lib/platform/azure/index.ts | 11 +- lib/platform/git/storage.spec.ts | 9 ++ lib/platform/git/storage.ts | 9 +- lib/workers/global/index.ts | 2 +- 11 files changed, 293 insertions(+), 22 deletions(-) diff --git a/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap b/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap index 5a6987453c..035875bc53 100644 --- a/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap +++ b/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`platform/azure/azure-got-wrapper gitApi should set token and endpoint 1`] = ` +exports[`platform/azure/azure-got-wrapper gitApi should set personal access token and endpoint 1`] = ` WebApi { "authHandler": PersonalAccessTokenCredentialHandler { - "token": "token", + "token": "1234567890123456789012345678901234567890123456789012", }, "isNoProxyHost": [Function], "options": Object { @@ -23,7 +23,7 @@ WebApi { "_socketTimeout": undefined, "handlers": Array [ PersonalAccessTokenCredentialHandler { - "token": "token", + "token": "1234567890123456789012345678901234567890123456789012", }, ], "requestOptions": Object { @@ -31,12 +31,12 @@ WebApi { }, }, }, - "serverUrl": "https://dev.azure.com/renovate12345", + "serverUrl": "https://dev.azure.com/renovate1", "vsoClient": VsoClient { "_initializationPromise": Promise {}, "_locationsByAreaPromises": Object {}, - "basePath": "/renovate12345", - "baseUrl": "https://dev.azure.com/renovate12345", + "basePath": "/renovate1", + "baseUrl": "https://dev.azure.com/renovate1", "restClient": RestClient { "client": HttpClient { "_allowRedirects": true, @@ -51,6 +51,69 @@ WebApi { "_socketTimeout": undefined, "handlers": Array [ PersonalAccessTokenCredentialHandler { + "token": "1234567890123456789012345678901234567890123456789012", + }, + ], + "requestOptions": Object { + "ignoreSslError": false, + }, + }, + }, + }, +} +`; + +exports[`platform/azure/azure-got-wrapper gitApi should set bearer token and endpoint 1`] = ` +WebApi { + "authHandler": BearerCredentialHandler { + "token": "token", + }, + "isNoProxyHost": [Function], + "options": Object { + "ignoreSslError": false, + }, + "rest": RestClient { + "client": HttpClient { + "_allowRedirects": true, + "_allowRetries": false, + "_certConfig": undefined, + "_disposed": false, + "_httpProxy": undefined, + "_ignoreSslError": false, + "_keepAlive": false, + "_maxRedirects": 50, + "_maxRetries": 1, + "_socketTimeout": undefined, + "handlers": Array [ + BearerCredentialHandler { + "token": "token", + }, + ], + "requestOptions": Object { + "ignoreSslError": false, + }, + }, + }, + "serverUrl": "https://dev.azure.com/renovate2", + "vsoClient": VsoClient { + "_initializationPromise": Promise {}, + "_locationsByAreaPromises": Object {}, + "basePath": "/renovate2", + "baseUrl": "https://dev.azure.com/renovate2", + "restClient": RestClient { + "client": HttpClient { + "_allowRedirects": true, + "_allowRetries": false, + "_certConfig": undefined, + "_disposed": false, + "_httpProxy": undefined, + "_ignoreSslError": false, + "_keepAlive": false, + "_maxRedirects": 50, + "_maxRetries": 1, + "_socketTimeout": undefined, + "handlers": Array [ + BearerCredentialHandler { "token": "token", }, ], @@ -62,3 +125,69 @@ WebApi { }, } `; + +exports[`platform/azure/azure-got-wrapper gitApi should set password and endpoint 1`] = ` +WebApi { + "authHandler": BasicCredentialHandler { + "password": "pass", + "username": "user", + }, + "isNoProxyHost": [Function], + "options": Object { + "ignoreSslError": false, + }, + "rest": RestClient { + "client": HttpClient { + "_allowRedirects": true, + "_allowRetries": false, + "_certConfig": undefined, + "_disposed": false, + "_httpProxy": undefined, + "_ignoreSslError": false, + "_keepAlive": false, + "_maxRedirects": 50, + "_maxRetries": 1, + "_socketTimeout": undefined, + "handlers": Array [ + BasicCredentialHandler { + "password": "pass", + "username": "user", + }, + ], + "requestOptions": Object { + "ignoreSslError": false, + }, + }, + }, + "serverUrl": "https://dev.azure.com/renovate3", + "vsoClient": VsoClient { + "_initializationPromise": Promise {}, + "_locationsByAreaPromises": Object {}, + "basePath": "/renovate3", + "baseUrl": "https://dev.azure.com/renovate3", + "restClient": RestClient { + "client": HttpClient { + "_allowRedirects": true, + "_allowRetries": false, + "_certConfig": undefined, + "_disposed": false, + "_httpProxy": undefined, + "_ignoreSslError": false, + "_keepAlive": false, + "_maxRedirects": 50, + "_maxRetries": 1, + "_socketTimeout": undefined, + "handlers": Array [ + BasicCredentialHandler { + "password": "pass", + "username": "user", + }, + ], + "requestOptions": Object { + "ignoreSslError": false, + }, + }, + }, + }, +} +`; diff --git a/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap b/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap index bac3928c71..26d0c17e4f 100644 --- a/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap +++ b/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap @@ -1,5 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`platform/azure/helpers getStorageExtraCloneOpts should configure basic auth 1`] = ` +Object { + "--config": "http.extraheader=AUTHORIZATION: basic dXNlcjpwYXNz", +} +`; + +exports[`platform/azure/helpers getStorageExtraCloneOpts should configure personal access token 1`] = ` +Object { + "--config": "http.extraheader=AUTHORIZATION: basic OjEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", +} +`; + +exports[`platform/azure/helpers getStorageExtraCloneOpts should configure bearer token 1`] = ` +Object { + "--config": "http.extraheader=AUTHORIZATION: bearer token", +} +`; + exports[`platform/azure/helpers getAzureBranchObj should be the branch object formated 1`] = ` Object { "name": "refs/heads/branchName", diff --git a/lib/platform/azure/azure-got-wrapper.spec.ts b/lib/platform/azure/azure-got-wrapper.spec.ts index a6b4d0f8ad..23cf907ed0 100644 --- a/lib/platform/azure/azure-got-wrapper.spec.ts +++ b/lib/platform/azure/azure-got-wrapper.spec.ts @@ -12,18 +12,52 @@ describe('platform/azure/azure-got-wrapper', () => { }); describe('gitApi', () => { - it('should throw an error if no token is provided', () => { - expect(azure.gitApi).toThrow('No token found for azure'); - expect(azure.coreApi).toThrow('No token found for azure'); - expect(azure.policyApi).toThrow('No token found for azure'); + it('should throw an error if no config found', () => { + expect(azure.gitApi).toThrow('No config found for azure'); + expect(azure.coreApi).toThrow('No config found for azure'); + expect(azure.policyApi).toThrow('No config found for azure'); }); - it('should set token and endpoint', () => { + it('should set personal access token and endpoint', () => { + hostRules.add({ + hostType: PLATFORM_TYPE_AZURE, + token: '1234567890123456789012345678901234567890123456789012', + baseUrl: 'https://dev.azure.com/renovate1', + }); + azure.setEndpoint('https://dev.azure.com/renovate1'); + + const res = azure.azureObj(); + + delete res.rest.client.userAgent; + delete res.vsoClient.restClient.client.userAgent; + + // We will track if the lib azure-devops-node-api change + expect(res).toMatchSnapshot(); + }); + it('should set bearer token and endpoint', () => { hostRules.add({ hostType: PLATFORM_TYPE_AZURE, token: 'token', - baseUrl: 'https://dev.azure.com/renovate12345', + baseUrl: 'https://dev.azure.com/renovate2', + }); + azure.setEndpoint('https://dev.azure.com/renovate2'); + + const res = azure.azureObj(); + + delete res.rest.client.userAgent; + delete res.vsoClient.restClient.client.userAgent; + + // We will track if the lib azure-devops-node-api change + expect(res).toMatchSnapshot(); + }); + + it('should set password and endpoint', () => { + hostRules.add({ + hostType: PLATFORM_TYPE_AZURE, + username: 'user', + password: 'pass', + baseUrl: 'https://dev.azure.com/renovate3', }); - azure.setEndpoint('https://dev.azure.com/renovate12345'); + azure.setEndpoint('https://dev.azure.com/renovate3'); const res = azure.azureObj(); diff --git a/lib/platform/azure/azure-got-wrapper.ts b/lib/platform/azure/azure-got-wrapper.ts index 1a2b50040d..fec7a3ec65 100644 --- a/lib/platform/azure/azure-got-wrapper.ts +++ b/lib/platform/azure/azure-got-wrapper.ts @@ -2,18 +2,28 @@ import * as azure from 'azure-devops-node-api'; import { IGitApi } from 'azure-devops-node-api/GitApi'; import { ICoreApi } from 'azure-devops-node-api/CoreApi'; import { IPolicyApi } from 'azure-devops-node-api/PolicyApi'; +import { getHandlerFromToken, getBasicHandler } from 'azure-devops-node-api'; +import { IRequestHandler } from 'azure-devops-node-api/interfaces/common/VsoBaseInterfaces'; import * as hostRules from '../../util/host-rules'; import { PLATFORM_TYPE_AZURE } from '../../constants/platforms'; +import { HostRule } from '../../types'; const hostType = PLATFORM_TYPE_AZURE; let endpoint: string; +function getAuthenticationHandler(config: HostRule): IRequestHandler { + if (!config.token && config.username && config.password) { + return getBasicHandler(config.username, config.password); + } + return getHandlerFromToken(config.token); +} + export function azureObj(): azure.WebApi { const config = hostRules.find({ hostType, url: endpoint }); - if (!(config && config.token)) { - throw new Error(`No token found for azure`); + if (!config.token && !(config.username && config.password)) { + throw new Error(`No config found for azure`); } - const authHandler = azure.getPersonalAccessTokenHandler(config.token); + const authHandler = getAuthenticationHandler(config); return new azure.WebApi(endpoint, authHandler); } diff --git a/lib/platform/azure/azure-helper.spec.ts b/lib/platform/azure/azure-helper.spec.ts index fbdc3c6b79..e812179c3e 100644 --- a/lib/platform/azure/azure-helper.spec.ts +++ b/lib/platform/azure/azure-helper.spec.ts @@ -13,6 +13,26 @@ describe('platform/azure/helpers', () => { azureApi = require('./azure-got-wrapper'); }); + describe('getStorageExtraCloneOpts', () => { + it('should configure basic auth', () => { + const res = azureHelper.getStorageExtraCloneOpts({ + username: 'user', + password: 'pass', + }); + expect(res).toMatchSnapshot(); + }); + it('should configure personal access token', () => { + const res = azureHelper.getStorageExtraCloneOpts({ + token: '1234567890123456789012345678901234567890123456789012', + }); + expect(res).toMatchSnapshot(); + }); + it('should configure bearer token', () => { + const res = azureHelper.getStorageExtraCloneOpts({ token: 'token' }); + expect(res).toMatchSnapshot(); + }); + }); + describe('getNewBranchName', () => { it('should add refs/heads', () => { const res = azureHelper.getNewBranchName('testBB'); diff --git a/lib/platform/azure/azure-helper.ts b/lib/platform/azure/azure-helper.ts index 1732baed5d..ca42e32127 100644 --- a/lib/platform/azure/azure-helper.ts +++ b/lib/platform/azure/azure-helper.ts @@ -5,6 +5,7 @@ import { GitPullRequest, } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { Options } from 'simple-git/promise'; import * as azureApi from './azure-got-wrapper'; import { logger } from '../../logger'; import { Pr } from '../common'; @@ -13,9 +14,29 @@ import { PR_STATE_MERGED, PR_STATE_OPEN, } from '../../constants/pull-requests'; +import { HostRule } from '../../types'; const mergePolicyGuid = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab'; // Magic GUID for merge strategy policy configurations +function toBase64(from: string): string { + return Buffer.from(from).toString('base64'); +} + +export function getStorageExtraCloneOpts(config: HostRule): Options { + let header: string; + const headerName = 'AUTHORIZATION'; + if (!config.token && config.username && config.password) { + header = `${headerName}: basic ${toBase64( + `${config.username}:${config.password}` + )}`; + } else if (config.token.length !== 52) { + header = `${headerName}: bearer ${config.token}`; + } else { + header = `${headerName}: basic ${toBase64(`:${config.token}`)}`; + } + return { '--config': `http.extraheader=${header}` }; +} + export function getNewBranchName(branchName?: string): string { if (branchName && !branchName.startsWith('refs/heads/')) { return `refs/heads/${branchName}`; diff --git a/lib/platform/azure/index.spec.ts b/lib/platform/azure/index.spec.ts index 6c652fdb52..821e103aef 100644 --- a/lib/platform/azure/index.spec.ts +++ b/lib/platform/azure/index.spec.ts @@ -82,7 +82,7 @@ describe('platform/azure', () => { expect.assertions(1); expect(() => azure.initPlatform({})).toThrow(); }); - it('should throw if no token', () => { + it('should throw if no token nor a username and password', () => { expect.assertions(1); expect(() => azure.initPlatform({ @@ -90,6 +90,24 @@ describe('platform/azure', () => { }) ).toThrow(); }); + it('should throw if a username but no password', () => { + expect.assertions(1); + expect(() => + azure.initPlatform({ + endpoint: 'https://dev.azure.com/renovate12345', + username: 'user', + }) + ).toThrow(); + }); + it('should throw if a password but no username', () => { + expect.assertions(1); + expect(() => + azure.initPlatform({ + endpoint: 'https://dev.azure.com/renovate12345', + password: 'pass', + }) + ).toThrow(); + }); it('should init', async () => { expect( await azure.initPlatform({ diff --git a/lib/platform/azure/index.ts b/lib/platform/azure/index.ts index dfc322b133..dff1cea09e 100644 --- a/lib/platform/azure/index.ts +++ b/lib/platform/azure/index.ts @@ -58,12 +58,16 @@ const defaults: any = { export function initPlatform({ endpoint, token, + username, + password, }: RenovateConfig): Promise<PlatformConfig> { if (!endpoint) { throw new Error('Init: You must configure an Azure DevOps endpoint'); } - if (!token) { - throw new Error('Init: You must configure an Azure DevOps token'); + if (!token && !(username && password)) { + throw new Error( + 'Init: You must configure an Azure DevOps token, or a username and password' + ); } // TODO: Add a connection check that endpoint/token combination are valid const res = { @@ -149,12 +153,13 @@ export async function initRepo({ url: defaults.endpoint, }); const url = - defaults.endpoint.replace('https://', `https://token:${opts.token}@`) + + defaults.endpoint + `${encodeURIComponent(projectName)}/_git/${encodeURIComponent(repoName)}`; await config.storage.initRepo({ ...config, localDir, url, + extraCloneOpts: azureHelper.getStorageExtraCloneOpts(opts), }); const repoConfig: RepoConfig = { baseBranch: config.baseBranch, diff --git a/lib/platform/git/storage.spec.ts b/lib/platform/git/storage.spec.ts index be30d0b260..bac0b0c7c8 100644 --- a/lib/platform/git/storage.spec.ts +++ b/lib/platform/git/storage.spec.ts @@ -54,6 +54,9 @@ describe('platform/git/storage', () => { await git.initRepo({ localDir: tmpDir.path, url: origin.path, + extraCloneOpts: { + '--config': 'extra.clone.config=test-extra-config-value', + }, }); }); @@ -400,5 +403,11 @@ describe('platform/git/storage', () => { expect(await fs.exists(tmpDir.path + '/.gitmodules')).toBeTruthy(); await repo.reset(['--hard', 'HEAD^']); }); + + it('should use extra clone configuration', async () => { + const repo = Git(tmpDir.path).silent(true); + const res = (await repo.raw(['config', 'extra.clone.config'])).trim(); + expect(res).toBe('test-extra-config-value'); + }); }); }); diff --git a/lib/platform/git/storage.ts b/lib/platform/git/storage.ts index 5da5d66f70..eebd206a56 100644 --- a/lib/platform/git/storage.ts +++ b/lib/platform/git/storage.ts @@ -39,6 +39,7 @@ interface StorageConfig { baseBranch?: string; url: string; gitPrivateKey?: string; + extraCloneOpts?: Git.Options; } interface LocalConfig extends StorageConfig { @@ -183,7 +184,13 @@ export class Storage { const cloneStart = process.hrtime(); try { // clone only the default branch - await this._git.clone(config.url, '.', ['--depth=2']); + let opts = ['--depth=2']; + if (config.extraCloneOpts) { + opts = opts.concat( + Object.entries(config.extraCloneOpts).map(e => `${e[0]}=${e[1]}`) + ); + } + await this._git.clone(config.url, '.', opts); } catch (err) /* istanbul ignore next */ { logger.debug({ err }, 'git clone error'); throw new Error(PLATFORM_FAILURE); diff --git a/lib/workers/global/index.ts b/lib/workers/global/index.ts index cefca1a89e..17cb888219 100644 --- a/lib/workers/global/index.ts +++ b/lib/workers/global/index.ts @@ -85,7 +85,7 @@ export async function start(): Promise<0 | 1> { await repositoryWorker.renovateRepository(repoConfig); } setMeta({}); - logger.debug(`Renovate existing successfully`); + logger.debug(`Renovate exiting successfully`); } catch (err) /* istanbul ignore next */ { if (err.message.startsWith('Init: ')) { logger.fatal(err.message.substring(6)); -- GitLab