Skip to content

Commit 53e5ece

Browse files
aulearnorth
authored andcommitted
Improved RegistryAuthLocator and added tests for Windows (#868)
#756 tested on Windows. Fixed RegistryAuthLocatorTest on Windows and also allowed better fallbacks from running credential provider (to allow lookup alternative AuthConfigs), when: 1) there is no hostName, then there is no point to ask credentials 2) when credential helper response with "credentials not found in native keychain" to try other resources Main reason for failing for me on Windows machine was #710 changes. When i used Netty or OkHttp together with npipe, then it worked fine. Yesterday evening i found out the reason and today morning i found also fix in master for that :-) - #865, breaking docker response by line breaks.
1 parent faf5bc7 commit 53e5ece

File tree

4 files changed

+197
-36
lines changed

4 files changed

+197
-36
lines changed

core/src/main/java/org/testcontainers/utility/RegistryAuthLocator.java

Lines changed: 126 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@
55
import com.github.dockerjava.api.model.AuthConfig;
66
import com.google.common.annotations.VisibleForTesting;
77
import org.apache.commons.lang.StringUtils;
8-
import org.apache.commons.lang.SystemUtils;
98
import org.slf4j.Logger;
9+
import org.zeroturnaround.exec.InvalidResultException;
1010
import org.zeroturnaround.exec.ProcessExecutor;
1111

1212
import java.io.ByteArrayInputStream;
1313
import java.io.File;
14+
import java.io.IOException;
1415
import java.util.Base64;
16+
import java.util.HashMap;
1517
import java.util.Iterator;
1618
import java.util.Map;
1719
import java.util.concurrent.TimeUnit;
20+
import java.util.concurrent.TimeoutException;
1821

1922
import static org.apache.commons.lang.StringUtils.isBlank;
2023
import static org.slf4j.LoggerFactory.getLogger;
@@ -28,14 +31,27 @@ public class RegistryAuthLocator {
2831
private static final Logger log = getLogger(RegistryAuthLocator.class);
2932
private static final String DEFAULT_REGISTRY_NAME = "index.docker.io";
3033
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
34+
3135
private static RegistryAuthLocator instance;
36+
3237
private final String commandPathPrefix;
38+
private final String commandExtension;
3339
private final File configFile;
3440

41+
/**
42+
* key - credential helper's name
43+
* value - helper's response for "credentials not found" use case
44+
*/
45+
private final Map<String, String> CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE;
46+
3547
@VisibleForTesting
36-
RegistryAuthLocator(File configFile, String commandPathPrefix) {
48+
RegistryAuthLocator(File configFile, String commandPathPrefix, String commandExtension,
49+
Map<String, String> notFoundMessageHolderReference) {
3750
this.configFile = configFile;
3851
this.commandPathPrefix = commandPathPrefix;
52+
this.commandExtension = commandExtension;
53+
54+
this.CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE = notFoundMessageHolderReference;
3955
}
4056

4157
/**
@@ -45,6 +61,9 @@ protected RegistryAuthLocator() {
4561
System.getProperty("user.home") + "/.docker");
4662
this.configFile = new File(dockerConfigLocation + "/config.json");
4763
this.commandPathPrefix = "";
64+
this.commandExtension = "";
65+
66+
this.CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE = new HashMap<>();
4867
}
4968

5069
public synchronized static RegistryAuthLocator instance() {
@@ -79,12 +98,6 @@ static void setInstance(RegistryAuthLocator overrideInstance) {
7998
*/
8099
public AuthConfig lookupAuthConfig(DockerImageName dockerImageName, AuthConfig defaultAuthConfig) {
81100

82-
if (SystemUtils.IS_OS_WINDOWS) {
83-
log.debug("RegistryAuthLocator is not supported on Windows. Please help test or improve it and update " +
84-
"https://github.com/testcontainers/testcontainers-java/issues/756");
85-
return defaultAuthConfig;
86-
}
87-
88101
log.debug("Looking up auth config for image: {}", dockerImageName);
89102

90103
log.debug("RegistryAuthLocator has configFile: {} ({}) and commandPathPrefix: {}",
@@ -119,7 +132,7 @@ public AuthConfig lookupAuthConfig(DockerImageName dockerImageName, AuthConfig d
119132
log.debug("no matching Auth Configs - falling back to defaultAuthConfig [{}]", toSafeString(defaultAuthConfig));
120133
// otherwise, defaultAuthConfig should already contain any credentials available
121134
} catch (Exception e) {
122-
log.debug("Failure when attempting to lookup auth config (dockerImageName: {}, configFile: {}. Falling back to docker-java default behaviour. Exception message: {}",
135+
log.warn("Failure when attempting to lookup auth config (dockerImageName: {}, configFile: {}. Falling back to docker-java default behaviour. Exception message: {}",
123136
dockerImageName,
124137
configFile,
125138
e.getMessage());
@@ -189,38 +202,126 @@ private Map.Entry<String, JsonNode> findAuthNode(final JsonNode config, final St
189202
return null;
190203
}
191204

192-
private AuthConfig runCredentialProvider(String hostName, String credHelper) throws Exception {
193-
final String credentialHelperName = commandPathPrefix + "docker-credential-" + credHelper;
194-
String data;
205+
private AuthConfig runCredentialProvider(String hostName, String helperOrStoreName) throws Exception {
206+
207+
if (isBlank(hostName)) {
208+
log.debug("There is no point to locate AuthConfig for blank hostName. Return NULL to allow fallback");
209+
return null;
210+
}
211+
212+
final String credentialProgramName = getCredentialProgramName(helperOrStoreName);
213+
final String data;
195214

196-
log.debug("Executing docker credential helper: {} to locate auth config for: {}",
197-
credentialHelperName, hostName);
215+
log.debug("Executing docker credential provider: {} to locate auth config for: {}",
216+
credentialProgramName, hostName);
198217

199218
try {
200-
data = new ProcessExecutor()
201-
.command(credentialHelperName, "get")
202-
.redirectInput(new ByteArrayInputStream(hostName.getBytes()))
203-
.readOutput(true)
204-
.exitValueNormal()
205-
.timeout(30, TimeUnit.SECONDS)
206-
.execute()
207-
.outputUTF8()
208-
.trim();
219+
data = runCredentialProgram(hostName, credentialProgramName);
220+
} catch (InvalidResultException e) {
221+
222+
final String responseErrorMsg = extractCredentialProviderErrorMessage(e);
223+
224+
if (!isBlank(responseErrorMsg)) {
225+
String credentialsNotFoundMsg = getGenericCredentialsNotFoundMsg(credentialProgramName);
226+
if (credentialsNotFoundMsg != null && credentialsNotFoundMsg.equals(responseErrorMsg)) {
227+
log.info("Credentials not found for host ({}) when using credential helper/store ({})",
228+
hostName,
229+
credentialProgramName);
230+
231+
return null;
232+
}
233+
234+
log.debug("Failure running docker credential helper/store ({}) with output '{}'",
235+
credentialProgramName, responseErrorMsg);
236+
237+
} else {
238+
log.debug("Failure running docker credential helper/store ({})", credentialProgramName);
239+
}
240+
241+
throw e;
209242
} catch (Exception e) {
210-
log.debug("Failure running docker credential helper ({})", credentialHelperName);
243+
log.debug("Failure running docker credential helper/store ({})", credentialProgramName);
211244
throw e;
212245
}
213246

214247
final JsonNode helperResponse = OBJECT_MAPPER.readTree(data);
215-
log.debug("Credential helper provided auth config for: {}", hostName);
248+
log.debug("Credential helper/store provided auth config for: {}", hostName);
216249

217250
return new AuthConfig()
218251
.withRegistryAddress(helperResponse.at("/ServerURL").asText())
219252
.withUsername(helperResponse.at("/Username").asText())
220253
.withPassword(helperResponse.at("/Secret").asText());
221254
}
222255

256+
private String getCredentialProgramName(String credHelper) {
257+
return commandPathPrefix + "docker-credential-" + credHelper + commandExtension;
258+
}
259+
223260
private String effectiveRegistryName(DockerImageName dockerImageName) {
224261
return StringUtils.defaultIfEmpty(dockerImageName.getRegistry(), DEFAULT_REGISTRY_NAME);
225262
}
263+
264+
private String getGenericCredentialsNotFoundMsg(String credentialHelperName) {
265+
if (!CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.containsKey(credentialHelperName)) {
266+
String credentialsNotFoundMsg = discoverCredentialsHelperNotFoundMessage(credentialHelperName);
267+
if (!isBlank(credentialsNotFoundMsg)) {
268+
CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.put(credentialHelperName, credentialsNotFoundMsg);
269+
}
270+
}
271+
272+
return CREDENTIALS_HELPERS_NOT_FOUND_MESSAGE_CACHE.get(credentialHelperName);
273+
}
274+
275+
private String discoverCredentialsHelperNotFoundMessage(String credentialHelperName) {
276+
// will do fake call to given credential helper to find out with which message
277+
// it response when there are no credentials for given hostName
278+
279+
// hostName should be valid, but most probably not existing
280+
// IF its not enough, then should probably run 'list' command first to be sure...
281+
final String notExistentFakeHostName = "https://not.a.real.registry/url";
282+
283+
String credentialsNotFoundMsg = null;
284+
try {
285+
runCredentialProgram(notExistentFakeHostName, credentialHelperName);
286+
287+
// should not reach here
288+
log.warn("Failure running docker credential helper ({}) with fake call, expected 'credentials not found' response",
289+
credentialHelperName);
290+
} catch(Exception e) {
291+
if (e instanceof InvalidResultException) {
292+
credentialsNotFoundMsg = extractCredentialProviderErrorMessage((InvalidResultException)e);
293+
}
294+
295+
if (isBlank(credentialsNotFoundMsg)) {
296+
log.warn("Failure running docker credential helper ({}) with fake call, expected 'credentials not found' response. Exception message: {}",
297+
credentialHelperName,
298+
e.getMessage());
299+
} else {
300+
log.debug("Got credentials not found error message from docker credential helper - {}", credentialsNotFoundMsg);
301+
}
302+
}
303+
304+
return credentialsNotFoundMsg;
305+
}
306+
307+
private String extractCredentialProviderErrorMessage(InvalidResultException invalidResultEx) {
308+
if (invalidResultEx.getResult() != null && invalidResultEx.getResult().hasOutput()) {
309+
return invalidResultEx.getResult().outputString().trim();
310+
}
311+
return null;
312+
}
313+
314+
private String runCredentialProgram(String hostName, String credentialHelperName)
315+
throws InvalidResultException, InterruptedException, TimeoutException, IOException {
316+
317+
return new ProcessExecutor()
318+
.command(credentialHelperName, "get")
319+
.redirectInput(new ByteArrayInputStream(hostName.getBytes()))
320+
.readOutput(true)
321+
.exitValueNormal()
322+
.timeout(30, TimeUnit.SECONDS)
323+
.execute()
324+
.outputUTF8()
325+
.trim();
326+
}
226327
}

core/src/test/java/org/testcontainers/utility/RegistryAuthLocatorTest.java

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,17 @@
44
import com.google.common.io.Resources;
55
import org.apache.commons.lang.SystemUtils;
66
import org.jetbrains.annotations.NotNull;
7-
import org.junit.Assume;
8-
import org.junit.BeforeClass;
97
import org.junit.Test;
108

119
import java.io.File;
1210
import java.net.URISyntaxException;
11+
import java.util.HashMap;
12+
import java.util.Map;
1313

1414
import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals;
1515
import static org.rnorth.visibleassertions.VisibleAssertions.assertNull;
1616

1717
public class RegistryAuthLocatorTest {
18-
19-
@BeforeClass
20-
public static void nonWindowsTest() throws Exception {
21-
Assume.assumeFalse(SystemUtils.IS_OS_WINDOWS);
22-
}
23-
2418
@Test
2519
public void lookupAuthConfigWithoutCredentials() throws URISyntaxException {
2620
final RegistryAuthLocator authLocator = createTestAuthLocator("config-empty.json");
@@ -87,10 +81,46 @@ public void lookupNonEmptyAuthWithHelper() throws URISyntaxException {
8781
assertEquals("Correct password is obtained from a credential helper", "secret", authConfig.getPassword());
8882
}
8983

84+
@Test
85+
public void lookupAuthConfigWithCredentialsNotFound() throws URISyntaxException {
86+
Map<String, String> notFoundMessagesReference = new HashMap<>();
87+
final RegistryAuthLocator authLocator = createTestAuthLocator("config-with-store.json", notFoundMessagesReference);
88+
89+
DockerImageName dockerImageName = new DockerImageName("registry2.example.com/org/repo");
90+
final AuthConfig authConfig = authLocator.lookupAuthConfig(dockerImageName, new AuthConfig());
91+
92+
assertNull("No username should have been obtained from a credential store", authConfig.getUsername());
93+
assertNull("No secret should have been obtained from a credential store", authConfig.getPassword());
94+
assertEquals("Should have one 'credentials not found' message discovered", 1, notFoundMessagesReference.size());
95+
96+
String discoveredMessage = notFoundMessagesReference.values().iterator().next();
97+
98+
assertEquals(
99+
"Not correct message discovered",
100+
"Fake credentials not found on credentials store 'https://not.a.real.registry/url'",
101+
discoveredMessage);
102+
}
103+
90104
@NotNull
91105
private RegistryAuthLocator createTestAuthLocator(String configName) throws URISyntaxException {
92-
final File configFile = new File(Resources.getResource("auth-config/" + configName).toURI());
93-
return new RegistryAuthLocator(configFile, configFile.getParentFile().getAbsolutePath() + "/");
106+
return createTestAuthLocator(configName, new HashMap<>());
94107
}
95108

109+
@NotNull
110+
private RegistryAuthLocator createTestAuthLocator(String configName, Map<String, String> notFoundMessagesReference) throws URISyntaxException {
111+
final File configFile = new File(Resources.getResource("auth-config/" + configName).toURI());
112+
113+
String commandPathPrefix = configFile.getParentFile().getAbsolutePath() + "/";
114+
String commandExtension = "";
115+
116+
if (SystemUtils.IS_OS_WINDOWS) {
117+
commandPathPrefix += "win/";
118+
119+
// need to provide executable extension otherwise won't run it
120+
// with real docker wincredential exe there is no problem
121+
commandExtension = ".bat";
122+
}
123+
124+
return new RegistryAuthLocator(configFile, commandPathPrefix, commandExtension, notFoundMessagesReference);
125+
}
96126
}

core/src/test/resources/auth-config/docker-credential-fake

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@ if [[ $1 != "get" ]]; then
44
exit 1
55
fi
66

7-
read > /dev/null
7+
read inputLine
8+
9+
if [[ $inputLine == "registry2.example.com" ]]; then
10+
echo Fake credentials not found on credentials store \'$inputLine\' 1>&2
11+
exit 1
12+
fi
13+
if [[ $inputLine == "https://not.a.real.registry/url" ]]; then
14+
echo Fake credentials not found on credentials store \'$inputLine\' 1>&2
15+
exit 1
16+
fi
817

918
echo '{' \
1019
' "ServerURL": "url",' \
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@echo off
2+
if not "%1" == "get" (
3+
exit 1
4+
)
5+
6+
set /p inputLine=""
7+
8+
if "%inputLine%" == "registry2.example.com" (
9+
echo Fake credentials not found on credentials store '%inputLine%' 1>&2
10+
exit 1
11+
)
12+
if "%inputLine%" == "https://not.a.real.registry/url" (
13+
echo Fake credentials not found on credentials store '%inputLine%' 1>&2
14+
exit 1
15+
)
16+
17+
echo {
18+
echo "ServerURL": "url",
19+
echo "Username": "username",
20+
echo "Secret": "secret"
21+
echo }

0 commit comments

Comments
 (0)