Skip to content

Commit 0abbd01

Browse files
authored
Merge pull request #512 from jglick/ExecCredential
#238: support client.authentication.k8s.io/v1beta1 ExecCredential
2 parents 5cac38e + f843269 commit 0abbd01

File tree

5 files changed

+232
-4
lines changed

5 files changed

+232
-4
lines changed

pom.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@
114114
<groupId>org.apache.maven.plugins</groupId>
115115
<artifactId>maven-surefire-plugin</artifactId>
116116
<version>2.22.0</version>
117+
<configuration>
118+
<trimStackTrace>false</trimStackTrace> <!-- SUREFIRE-1226 workaround -->
119+
</configuration>
117120
</plugin>
118121
<plugin>
119122
<groupId>com.coveo</groupId>

util/src/main/java/io/kubernetes/client/util/ClientBuilder.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,21 +78,23 @@ public static ClientBuilder standard() throws IOException {
7878
public static ClientBuilder standard(boolean persistConfig) throws IOException {
7979
final File kubeConfig = findConfigFromEnv();
8080
if (kubeConfig != null) {
81-
try (FileReader kubeConfigReader = new FileReader(kubeConfig)) {
81+
try (FileReader kubeConfigReader = new FileReader(kubeConfig)) { // TODO UTF-8
8282
KubeConfig kc = KubeConfig.loadKubeConfig(kubeConfigReader);
8383
if (persistConfig) {
8484
kc.setPersistConfig(new FilePersister(kubeConfig));
8585
}
86+
kc.setFile(kubeConfig);
8687
return kubeconfig(kc);
8788
}
8889
}
8990
final File config = findConfigInHomeDir();
9091
if (config != null) {
91-
try (FileReader configReader = new FileReader(config)) {
92+
try (FileReader configReader = new FileReader(config)) { // TODO UTF-8
9293
KubeConfig kc = KubeConfig.loadKubeConfig(configReader);
9394
if (persistConfig) {
9495
kc.setPersistConfig(new FilePersister(config));
9596
}
97+
kc.setFile(kubeConfig);
9698
return kubeconfig(kc);
9799
}
98100
}

util/src/main/java/io/kubernetes/client/util/Config.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import io.kubernetes.client.ApiClient;
1616
import io.kubernetes.client.util.credentials.AccessTokenAuthentication;
1717
import io.kubernetes.client.util.credentials.UsernamePasswordAuthentication;
18+
import java.io.File;
1819
import java.io.FileReader;
1920
import java.io.IOException;
2021
import java.io.InputStream;
@@ -73,11 +74,13 @@ public static ApiClient fromToken(String url, String token, boolean validateSSL)
7374
}
7475

7576
public static ApiClient fromConfig(String fileName) throws IOException {
76-
return fromConfig(new FileReader(fileName));
77+
KubeConfig config = KubeConfig.loadKubeConfig(new FileReader(fileName)); // TODO UTF-8
78+
config.setFile(new File(fileName));
79+
return fromConfig(config);
7780
}
7881

7982
public static ApiClient fromConfig(InputStream stream) throws IOException {
80-
return fromConfig(new InputStreamReader(stream));
83+
return fromConfig(new InputStreamReader(stream)); // TODO UTF-8
8184
}
8285

8386
public static ApiClient fromConfig(Reader input) throws IOException {

util/src/main/java/io/kubernetes/client/util/KubeConfig.java

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,26 @@
1212
*/
1313
package io.kubernetes.client.util;
1414

15+
import com.google.gson.JsonElement;
16+
import com.google.gson.JsonObject;
17+
import com.google.gson.JsonParseException;
18+
import com.google.gson.JsonParser;
1519
import io.kubernetes.client.util.authenticators.Authenticator;
1620
import io.kubernetes.client.util.authenticators.AzureActiveDirectoryAuthenticator;
1721
import io.kubernetes.client.util.authenticators.GCPAuthenticator;
22+
import java.io.File;
1823
import java.io.IOException;
24+
import java.io.InputStream;
25+
import java.io.InputStreamReader;
1926
import java.io.Reader;
2027
import java.nio.charset.StandardCharsets;
2128
import java.nio.file.FileSystems;
2229
import java.nio.file.Files;
30+
import java.nio.file.Path;
2331
import java.nio.file.Paths;
2432
import java.util.ArrayList;
2533
import java.util.HashMap;
34+
import java.util.List;
2635
import java.util.Map;
2736
import org.apache.commons.codec.binary.Base64;
2837
import org.slf4j.Logger;
@@ -54,6 +63,7 @@ public class KubeConfig {
5463
String currentNamespace;
5564
Object preferences;
5665
ConfigPersister persister;
66+
private File file;
5767

5868
public static void registerAuthenticator(Authenticator auth) {
5969
synchronized (authenticators) {
@@ -185,6 +195,7 @@ public String getPassword() {
185195
return getData(currentUser, "password");
186196
}
187197

198+
@SuppressWarnings("unchecked")
188199
public String getAccessToken() {
189200
if (currentUser == null) {
190201
return null;
@@ -214,6 +225,11 @@ public String getAccessToken() {
214225
}
215226
}
216227
}
228+
String tokenViaExecCredential =
229+
tokenViaExecCredential((Map<String, Object>) currentUser.get("exec"));
230+
if (tokenViaExecCredential != null) {
231+
return tokenViaExecCredential;
232+
}
217233
if (currentUser.containsKey("token")) {
218234
return (String) currentUser.get("token");
219235
}
@@ -229,6 +245,102 @@ public String getAccessToken() {
229245
return null;
230246
}
231247

248+
/**
249+
* Attempt to create an access token by running a configured external program.
250+
*
251+
* @see <a
252+
* href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins">
253+
* Authenticating » client-go credential plugins</a>
254+
*/
255+
@SuppressWarnings("unchecked")
256+
private String tokenViaExecCredential(Map<String, Object> execMap) {
257+
if (execMap == null) {
258+
return null;
259+
}
260+
String apiVersion = (String) execMap.get("apiVersion");
261+
if (!"client.authentication.k8s.io/v1beta1".equals(apiVersion)
262+
&& !"client.authentication.k8s.io/v1alpha1".equals(apiVersion)) {
263+
log.error("Unrecognized user.exec.apiVersion: {}", apiVersion);
264+
return null;
265+
}
266+
String command = (String) execMap.get("command");
267+
JsonElement root = runExec(command, (List) execMap.get("args"), (List) execMap.get("env"));
268+
if (root == null) {
269+
return null;
270+
}
271+
if (!"ExecCredential".equals(root.getAsJsonObject().get("kind").getAsString())) {
272+
log.error("Unrecognized kind in response");
273+
return null;
274+
}
275+
if (!apiVersion.equals(root.getAsJsonObject().get("apiVersion").getAsString())) {
276+
log.error("Mismatched apiVersion in response");
277+
return null;
278+
}
279+
JsonObject status = root.getAsJsonObject().get("status").getAsJsonObject();
280+
JsonElement token = status.get("token");
281+
if (token == null) {
282+
// TODO handle clientCertificateData/clientKeyData
283+
// (KubeconfigAuthentication is not yet set up for that to be dynamic)
284+
log.warn("No token produced by {}", command);
285+
return null;
286+
}
287+
log.debug("Obtained a token from {}", command);
288+
return token.getAsString();
289+
// TODO cache tokens between calls, up to .status.expirationTimestamp
290+
// TODO a 401 is supposed to force a refresh,
291+
// but KubeconfigAuthentication hardcodes AccessTokenAuthentication which does not support that
292+
// and anyway ClientBuilder only calls Authenticator.provide once per ApiClient;
293+
// we would need to do it on every request
294+
}
295+
296+
private JsonElement runExec(String command, List<String> args, List<Map<String, String>> env) {
297+
List<String> argv = new ArrayList<>();
298+
if (command.contains("/") || command.contains("\\")) {
299+
// Spec is unclear on what should be treated as a “relative command path”.
300+
// This clause should cover anything not resolved from $PATH / %Path%.
301+
Path resolvedCommand = file.toPath().getParent().resolve(command).normalize();
302+
if (!Files.exists(resolvedCommand)) {
303+
log.error("No such file: {}", resolvedCommand);
304+
return null;
305+
}
306+
// Not checking isRegularFile or isExecutable here; leave that to ProcessBuilder.start.
307+
log.debug("Resolved {} to {}", command, resolvedCommand);
308+
argv.add(resolvedCommand.toString());
309+
} else {
310+
argv.add(command);
311+
}
312+
if (args != null) {
313+
argv.addAll(args);
314+
}
315+
ProcessBuilder pb = new ProcessBuilder(argv);
316+
if (env != null) {
317+
for (Map<String, String> entry : env) {
318+
pb.environment().put(entry.get("name"), entry.get("value"));
319+
}
320+
}
321+
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
322+
try {
323+
Process proc = pb.start();
324+
JsonElement root;
325+
try (InputStream is = proc.getInputStream();
326+
Reader r = new InputStreamReader(is, StandardCharsets.UTF_8)) {
327+
root = new JsonParser().parse(r);
328+
} catch (JsonParseException x) {
329+
log.error("Failed to parse output of " + command, x);
330+
return null;
331+
}
332+
int r = proc.waitFor();
333+
if (r != 0) {
334+
log.error("{} failed with exit code {}", command, r);
335+
return null;
336+
}
337+
return root;
338+
} catch (IOException | InterruptedException x) {
339+
log.error("Failed to run " + command, x);
340+
return null;
341+
}
342+
}
343+
232344
public boolean verifySSL() {
233345
if (currentCluster == null) {
234346
return false;
@@ -248,6 +360,15 @@ public void setPersistConfig(ConfigPersister persister) {
248360
this.persister = persister;
249361
}
250362

363+
/**
364+
* Indicates a file from which this configuration was loaded.
365+
*
366+
* @param file a file path, available for use when resolving relative file paths
367+
*/
368+
public void setFile(File file) {
369+
this.file = file;
370+
}
371+
251372
public void setPreferences(Object preferences) {
252373
this.preferences = preferences;
253374
}

util/src/test/java/io/kubernetes/client/util/KubeConfigTest.java

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,103 @@ public void testRefreshToken() {
273273
String token = config.getAccessToken();
274274
assertEquals(token, fake.token);
275275
}
276+
277+
private static final String KUBECONFIG_EXEC =
278+
"apiVersion: v1\n"
279+
+ "current-context: c\n"
280+
+ "contexts:\n"
281+
+ "- name: c\n"
282+
+ " context:\n"
283+
+ " user: u\n"
284+
+ "users:\n"
285+
+ "- name: u\n"
286+
+ " user:\n"
287+
+ " exec:\n"
288+
+ " apiVersion: client.authentication.k8s.io/v1beta1\n"
289+
+ " command: echo\n"
290+
+ " args:\n"
291+
+ " - >-\n"
292+
+ " {\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"token\": \"abc123\"}}\n";
293+
294+
@Test
295+
public void testExecCredentials() throws Exception {
296+
KubeConfig kc = KubeConfig.loadKubeConfig(new StringReader(KUBECONFIG_EXEC));
297+
kc.setFile(folder.newFile()); // just making sure it is ignored
298+
assertEquals("abc123", kc.getAccessToken());
299+
}
300+
301+
@Test
302+
public void testExecCredentialsAlpha1() throws Exception {
303+
KubeConfig kc =
304+
KubeConfig.loadKubeConfig(new StringReader(KUBECONFIG_EXEC.replace("v1beta1", "v1alpha1")));
305+
assertEquals("abc123", kc.getAccessToken());
306+
}
307+
308+
private static final String KUBECONFIG_EXEC_ENV =
309+
"apiVersion: v1\n"
310+
+ "current-context: c\n"
311+
+ "contexts:\n"
312+
+ "- name: c\n"
313+
+ " context:\n"
314+
+ " user: u\n"
315+
+ "users:\n"
316+
+ "- name: u\n"
317+
+ " user:\n"
318+
+ " exec:\n"
319+
+ " apiVersion: client.authentication.k8s.io/v1beta1\n"
320+
+ " command: sh\n"
321+
+ " env:\n"
322+
+ " - name: TOK\n"
323+
+ " value: abc\n"
324+
+ " args:\n"
325+
+ " - -c\n"
326+
+ " - >-\n"
327+
+ " echo '{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"token\": \"'$TOK'123\"}}'\n";
328+
329+
@Test
330+
public void testExecCredentialsEnv() throws Exception {
331+
KubeConfig kc = KubeConfig.loadKubeConfig(new StringReader(KUBECONFIG_EXEC_ENV));
332+
assertEquals("abc123", kc.getAccessToken());
333+
}
334+
335+
private static final String KUBECONFIG_EXEC_BASEDIR =
336+
"apiVersion: v1\n"
337+
+ "current-context: c\n"
338+
+ "contexts:\n"
339+
+ "- name: c\n"
340+
+ " context:\n"
341+
+ " user: u\n"
342+
+ "users:\n"
343+
+ "- name: u\n"
344+
+ " user:\n"
345+
+ " exec:\n"
346+
+ " apiVersion: client.authentication.k8s.io/v1beta1\n"
347+
+ " command: ./bin/authenticate\n";
348+
349+
private static final String AUTH_SCRIPT =
350+
"#!/bin/sh\n"
351+
+ "echo '{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"token\": \"abc123\"}}'\n";
352+
353+
@Test
354+
public void testExecCredentialsBasedir() throws Exception {
355+
File basedir = folder.newFolder();
356+
File config = new File(basedir, ".kubeconfig");
357+
try (FileWriter writer = new FileWriter(config)) {
358+
writer.write(KUBECONFIG_EXEC_BASEDIR);
359+
writer.flush();
360+
}
361+
File bindir = new File(basedir, "bin");
362+
bindir.mkdir();
363+
File script = new File(bindir, "authenticate");
364+
try (FileWriter writer = new FileWriter(script)) {
365+
writer.write(AUTH_SCRIPT);
366+
writer.flush();
367+
}
368+
script.setExecutable(true);
369+
try (FileReader reader = new FileReader(config)) {
370+
KubeConfig kc = KubeConfig.loadKubeConfig(reader);
371+
kc.setFile(config);
372+
assertEquals("abc123", kc.getAccessToken());
373+
}
374+
}
276375
}

0 commit comments

Comments
 (0)