Skip to content

Commit 1f926e4

Browse files
authored
Merge pull request #481 from mjcheetham/autov2
Speed up host provider auto-detection and make more robust
2 parents 7cff5f5 + 537c0ba commit 1f926e4

File tree

9 files changed

+337
-19
lines changed

9 files changed

+337
-19
lines changed

docs/autodetect.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Host provider auto-detection
2+
3+
Git Credential Manager (GCM) supports authentication with multiple different Git
4+
host providers including: GitHub, Bitbucket, and Azure Repos. As well as the
5+
hosted/cloud offerings, GCM can also work with the self-hosted or "on-premises"
6+
versions of these services: GitHub Enterprise Server, Bitbucket DC Server, and
7+
Azure DevOps Server (TFS).
8+
9+
By default, GCM will attempt to automatically detect which particular provider
10+
is behind the Git remote URL you're interacting with. For the cloud versions of
11+
the supported providers this is done by matching the hostname of the remote URL
12+
to the well-known hostnames of the services. For example "github.com" or
13+
"dev.azure.com".
14+
15+
## Self-hosted/on-prem detection
16+
17+
In order to detect which host provider to use for a self-hosted instance, each
18+
provider can provide some heuristic matching of the hostname. For example any
19+
hostname that begins "github.*" will be matched to the GitHub host provider.
20+
21+
If a heuristic matches incorrectly, you can always [explicitly configure](#explicit-configuration)
22+
GCM to use a particular provider.
23+
24+
## Remote URL probing
25+
26+
In addition to heuristic matching, GCM will make a network call to the remote
27+
URL and inspect HTTP response headers to try and detect a self-hosted instance.
28+
29+
This network call is only performed if neither an exact nor fuzzy match by
30+
hostname can be made. Only one HTTP `HEAD` call is made per credential request
31+
received by Git. To avoid this network call, please [explicitly configure](#explicit-configuration)
32+
the host provider for your self-hosted instance.
33+
34+
### Timeout
35+
36+
You can control how long GCM will wait for a response to the remote network call
37+
by setting the [`GCM_AUTODETECT_TIMEOUT`](environment.md#GCM_AUTODETECT_TIMEOUT)
38+
environment variable, or the [`credential.autoDetectTimeout`](configuration.md#credentialautodetecttimeout)
39+
Git configuration setting to the maximum number of milliseconds to wait.
40+
41+
The default value is 2000 milliseconds (2 seconds). You can prevent the network
42+
call altogether by setting a zero or negative value, for example -1.
43+
44+
## Explicit configuration
45+
46+
If the auto-detection mechanism fails to select the correct host provider, or
47+
if the remote probing network call is causing performance issues, you can
48+
configure GCM to always use a particular host provider, for a given remote URL.
49+
50+
You can either use the the [`GCM_PROVIDER`](environment.md#GCM_PROVIDER)
51+
environment variable, or the [`credential.provider`](configuration.md#credentialprovider)
52+
Git configuration setting for this purpose.
53+
54+
For example to tell GCM to always use the GitHub host provider for the
55+
"ghe.example.com" hostname, you can run the following command:
56+
57+
```shell
58+
git config --global credential.ghe.example.com.provider github
59+
```

docs/configuration.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Define the host provider to use when authenticating.
6060

6161
ID|Provider
6262
-|-
63-
`auto` _(default)_|_\[automatic\]_
63+
`auto` _(default)_|_\[automatic\]_ ([learn more](autodetect.md))
6464
`azure-repos`|Azure Repos
6565
`github`|GitHub
6666
`bitbucket`|Bitbucket
@@ -106,6 +106,27 @@ git config --global credential.ghe.contoso.com.authority github
106106

107107
---
108108

109+
### credential.autoDetectTimeout
110+
111+
Set the maximum length of time, in milliseconds, that GCM should wait for a
112+
network response during host provider auto-detection probing.
113+
114+
See [here](autodetect.md) for more information.
115+
116+
**Note:** Use a negative or zero value to disable probing altogether.
117+
118+
Defaults to 2000 milliseconds (2 seconds).
119+
120+
#### Example
121+
122+
```shell
123+
git config --global credential.autoDetectTimeout -1
124+
```
125+
126+
**Also see: [GCM_AUTODETECT_TIMEOUT](environment.md#GCM_AUTODETECT_TIMEOUT)**
127+
128+
---
129+
109130
### credential.allowWindowsAuth
110131

111132
Allow detection of Windows Integrated Authentication (WIA) support for generic host providers. Setting this value to `false` will prevent the use of WIA and force a basic authentication prompt when using the Generic host provider.

docs/environment.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ Define the host provider to use when authenticating.
166166

167167
ID|Provider
168168
-|-
169-
`auto` _(default)_|_\[automatic\]_
169+
`auto` _(default)_|_\[automatic\]_ ([learn more](autodetect.md))
170170
`azure-repos`|Azure Repos
171171
`github`|GitHub
172172
`generic`|Generic (any other provider not listed above)
@@ -226,6 +226,35 @@ export GCM_AUTHORITY=github
226226

227227
---
228228

229+
### GCM_AUTODETECT_TIMEOUT
230+
231+
Set the maximum length of time, in milliseconds, that GCM should wait for a
232+
network response during host provider auto-detection probing.
233+
234+
See [here](autodetect.md) for more information.
235+
236+
**Note:** Use a negative or zero value to disable probing altogether.
237+
238+
Defaults to 2000 milliseconds (2 seconds).
239+
240+
#### Example
241+
242+
##### Windows
243+
244+
```batch
245+
SET GCM_AUTODETECT_TIMEOUT=-1
246+
```
247+
248+
##### macOS/Linux
249+
250+
```bash
251+
export GCM_AUTODETECT_TIMEOUT=-1
252+
```
253+
254+
**Also see: [credential.autoDetectTimeout](configuration.md#credentialautodetecttimeout)**
255+
256+
---
257+
229258
### GCM_ALLOW_WINDOWSAUTH
230259

231260
Allow detection of Windows Integrated Authentication (WIA) support for generic host providers. Setting this value to `false` will prevent the use of WIA and force a basic authentication prompt when using the Generic host provider.

src/shared/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Net.Http;
35
using System.Threading.Tasks;
46
using Microsoft.Git.CredentialManager.Tests.Objects;
57
using Moq;
@@ -257,5 +259,154 @@ public async Task HostProviderRegistry_GetProvider_AutoLegacyAuthoritySpecified_
257259

258260
Assert.Same(provider2Mock.Object, result);
259261
}
262+
263+
[Fact]
264+
public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_ReturnsSupportedProvider()
265+
{
266+
var context = new TestCommandContext();
267+
var registry = new HostProviderRegistry(context);
268+
var remoteUri = new Uri("https://provider2.onprem.example.com");
269+
var input = new InputArguments(
270+
new Dictionary<string, string>
271+
{
272+
["protocol"] = remoteUri.Scheme,
273+
["host"] = remoteUri.Host
274+
}
275+
);
276+
277+
var provider1Mock = new Mock<IHostProvider>();
278+
provider1Mock.Setup(x => x.IsSupported(It.IsAny<InputArguments>())).Returns(false);
279+
provider1Mock.Setup(x => x.IsSupported(It.IsAny<HttpResponseMessage>())).Returns(false);
280+
281+
var provider2Mock = new Mock<IHostProvider>();
282+
provider2Mock.Setup(x => x.IsSupported(It.IsAny<InputArguments>())).Returns(false);
283+
provider2Mock.Setup(x => x.IsSupported(It.IsAny<HttpResponseMessage>())).Returns(true);
284+
285+
var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized)
286+
{
287+
Headers = { { "X-Provider2", "true" } }
288+
};
289+
290+
var httpHandler = new TestHttpMessageHandler();
291+
292+
httpHandler.Setup(HttpMethod.Head, remoteUri, responseMessage);
293+
context.HttpClientFactory.MessageHandler = httpHandler;
294+
295+
registry.Register(provider1Mock.Object, HostProviderPriority.Normal);
296+
registry.Register(provider2Mock.Object, HostProviderPriority.Normal);
297+
298+
IHostProvider result = await registry.GetProviderAsync(input);
299+
300+
httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 1);
301+
Assert.Same(provider2Mock.Object, result);
302+
}
303+
304+
[Fact]
305+
public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutZero_NoNetworkCall()
306+
{
307+
var context = new TestCommandContext();
308+
var registry = new HostProviderRegistry(context);
309+
var remoteUri = new Uri("https://onprem.example.com");
310+
var input = new InputArguments(
311+
new Dictionary<string, string>
312+
{
313+
["protocol"] = remoteUri.Scheme,
314+
["host"] = remoteUri.Host
315+
}
316+
);
317+
318+
var providerMock = new Mock<IHostProvider>();
319+
providerMock.Setup(x => x.IsSupported(It.IsAny<InputArguments>())).Returns(false);
320+
providerMock.Setup(x => x.IsSupported(It.IsAny<HttpResponseMessage>())).Returns(true);
321+
322+
var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized);
323+
var httpHandler = new TestHttpMessageHandler();
324+
325+
httpHandler.Setup(HttpMethod.Head, remoteUri, responseMessage);
326+
context.HttpClientFactory.MessageHandler = httpHandler;
327+
328+
registry.Register(providerMock.Object, HostProviderPriority.Normal);
329+
330+
context.Settings.AutoDetectProviderTimeout = 0;
331+
332+
await Assert.ThrowsAnyAsync<Exception>(() => registry.GetProviderAsync(input));
333+
334+
httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 0);
335+
}
336+
337+
[Fact]
338+
public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_TimeoutNegative_NoNetworkCall()
339+
{
340+
var context = new TestCommandContext();
341+
var registry = new HostProviderRegistry(context);
342+
var remoteUri = new Uri("https://onprem.example.com");
343+
var input = new InputArguments(
344+
new Dictionary<string, string>
345+
{
346+
["protocol"] = remoteUri.Scheme,
347+
["host"] = remoteUri.Host
348+
}
349+
);
350+
351+
var providerMock = new Mock<IHostProvider>();
352+
providerMock.Setup(x => x.IsSupported(It.IsAny<InputArguments>())).Returns(false);
353+
providerMock.Setup(x => x.IsSupported(It.IsAny<HttpResponseMessage>())).Returns(true);
354+
355+
var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized);
356+
var httpHandler = new TestHttpMessageHandler();
357+
358+
httpHandler.Setup(HttpMethod.Head, remoteUri, responseMessage);
359+
context.HttpClientFactory.MessageHandler = httpHandler;
360+
361+
registry.Register(providerMock.Object, HostProviderPriority.Normal);
362+
363+
context.Settings.AutoDetectProviderTimeout = -1;
364+
365+
await Assert.ThrowsAnyAsync<Exception>(() => registry.GetProviderAsync(input));
366+
367+
httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 0);
368+
}
369+
370+
[Fact]
371+
public async Task HostProviderRegistry_GetProvider_Auto_NetworkProbe_NoNetwork_ReturnsLastProvider()
372+
{
373+
var context = new TestCommandContext();
374+
var registry = new HostProviderRegistry(context);
375+
var remoteUri = new Uri("https://provider2.onprem.example.com");
376+
var input = new InputArguments(
377+
new Dictionary<string, string>
378+
{
379+
["protocol"] = remoteUri.Scheme,
380+
["host"] = remoteUri.Host
381+
}
382+
);
383+
384+
var highProviderMock = new Mock<IHostProvider>();
385+
highProviderMock.Setup(x => x.IsSupported(It.IsAny<InputArguments>())).Returns(false);
386+
highProviderMock.Setup(x => x.IsSupported(It.IsAny<HttpResponseMessage>())).Returns(false);
387+
registry.Register(highProviderMock.Object, HostProviderPriority.Normal);
388+
389+
var lowProviderMock = new Mock<IHostProvider>();
390+
lowProviderMock.Setup(x => x.IsSupported(It.IsAny<InputArguments>())).Returns(true);
391+
registry.Register(lowProviderMock.Object, HostProviderPriority.Low);
392+
393+
var responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized)
394+
{
395+
Headers = { { "X-Provider2", "true" } }
396+
};
397+
398+
var httpHandler = new TestHttpMessageHandler
399+
{
400+
SimulateNoNetwork = true,
401+
};
402+
403+
httpHandler.Setup(HttpMethod.Head, remoteUri, responseMessage);
404+
context.HttpClientFactory.MessageHandler = httpHandler;
405+
406+
IHostProvider result = await registry.GetProviderAsync(input);
407+
408+
httpHandler.AssertRequest(HttpMethod.Head, remoteUri, 1);
409+
Assert.Same(lowProviderMock.Object, result);
410+
}
260411
}
261412
}

src/shared/Microsoft.Git.CredentialManager/Constants.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public static class Constants
77
{
88
public const string PersonalAccessTokenUserName = "PersonalAccessToken";
99
public const string DefaultCredentialNamespace = "git";
10+
public const int DefaultAutoDetectProviderTimeoutMs = 2000; // 2 seconds
1011

1112
public const string ProviderIdAuto = "auto";
1213
public const string AuthorityIdAuto = "auto";
@@ -70,6 +71,7 @@ public static class EnvironmentVariables
7071
public const string GcmDpapiStorePath = "GCM_DPAPI_STORE_PATH";
7172
public const string GitExecutablePath = "GIT_EXEC_PATH";
7273
public const string GpgExecutablePath = "GCM_GPG_PATH";
74+
public const string GcmAutoDetectTimeout = "GCM_AUTODETECT_TIMEOUT";
7375
}
7476

7577
public static class Http
@@ -103,6 +105,7 @@ public static class Credential
103105
public const string PlaintextStorePath = "plaintextStorePath";
104106
public const string DpapiStorePath = "dpapiStorePath";
105107
public const string UserName = "username";
108+
public const string AutoDetectTimeout = "autoDetectTimeout";
106109
}
107110

108111
public static class Http
@@ -138,6 +141,7 @@ public static class HelpUrls
138141
public const string GcmTlsVerification = "https://aka.ms/gcmcore-tlsverify";
139142
public const string GcmCredentialStores = "https://aka.ms/gcmcore-credstores";
140143
public const string GcmWamComSecurity = "https://aka.ms/gcmcore-wamadmin";
144+
public const string GcmAutoDetect = "https://aka.ms/gcmcore-autodetect";
141145
}
142146

143147
private static Version _gcmVersion;

0 commit comments

Comments
 (0)