diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml
index 95bd6b24d3..c789937810 100644
--- a/gateway-server/pom.xml
+++ b/gateway-server/pom.xml
@@ -628,5 +628,18 @@
test
+
+ io.fabric8
+ kubernetes-client
+
+
+ io.fabric8
+ kubernetes-client-api
+
+
+ io.fabric8
+ kubernetes-model-core
+
+
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java
index 784c416c17..ea77ad9bf5 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/util/KnoxCLI.java
@@ -50,6 +50,10 @@
import javax.net.ssl.SSLException;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
@@ -117,6 +121,7 @@ public class KnoxCLI extends Configured implements Tool {
" [" + CertCreateCommand.USAGE + "]\n" +
" [" + CertExportCommand.USAGE + "]\n" +
" [" + AliasCreateCommand.USAGE + "]\n" +
+ " [" + K8sAliasCreateCommand.USAGE + "]\n" +
" [" + BatchAliasCreateCommand.USAGE + "]\n" +
" [" + AliasDeleteCommand.USAGE + "]\n" +
" [" + AliasListCommand.USAGE + "]\n" +
@@ -150,6 +155,7 @@ public class KnoxCLI extends Configured implements Tool {
private Command command;
private String value;
private String cluster;
+ private String namespace;
private String path;
private String generate = "false";
private String hostname;
@@ -214,6 +220,10 @@ public int run(String[] args) throws Exception {
return exitCode;
}
+ protected KubernetesClient buildKubernetesClient() {
+ return new KubernetesClientBuilder().build();
+ }
+
public static synchronized GatewayServices getGatewayServices() {
return services;
}
@@ -272,6 +282,16 @@ private int init(String[] args) throws IOException {
printKnoxShellUsage();
return -1;
}
+ } else if (args[i].equals("create-k8s-alias")) {
+ List secretNames = new ArrayList<>();
+ while (i + 1 < args.length && !args[i + 1].startsWith("--")) {
+ secretNames.add(args[++i]);
+ }
+ if (secretNames.isEmpty() || (secretNames.size() == 1 && "--help".equals(secretNames.get(0)))) {
+ printKnoxShellUsage();
+ return -1;
+ }
+ command = new K8sAliasCreateCommand(secretNames);
} else if (args[i].equals("create-aliases")) {
command = new BatchAliasCreateCommand();
if (args.length < 3 || "--help".equals(alias)) {
@@ -357,6 +377,12 @@ private int init(String[] args) throws IOException {
if(command instanceof CreateListAliasesCommand) {
((CreateListAliasesCommand) command).toMap(this.cluster);
}
+ } else if (args[i].equals("--namespace") || args[i].equals("--ns")) {
+ if (i + 1 >= args.length || args[i + 1].startsWith("-")) {
+ printKnoxShellUsage();
+ return -1;
+ }
+ this.namespace = args[++i];
} else if (args[i].equals("service-test")) {
if( i + 1 >= args.length) {
printKnoxShellUsage();
@@ -623,6 +649,9 @@ private void printKnoxShellUsage() {
out.println( AliasCreateCommand.USAGE + "\n\n" + AliasCreateCommand.DESC );
out.println();
out.println( div );
+ out.println( K8sAliasCreateCommand.USAGE + "\n\n" + K8sAliasCreateCommand.DESC );
+ out.println();
+ out.println( div );
out.println( AliasDeleteCommand.USAGE + "\n\n" + AliasDeleteCommand.DESC );
out.println();
out.println( div );
@@ -1029,6 +1058,84 @@ public String getUsage() {
}
}
+ public class K8sAliasCreateCommand extends Command {
+
+ public static final String USAGE = "create-k8s-alias secret-name [secret-name ...] [--namespace namespace]";
+ public static final String DESC = "The create-k8s-alias command reads one or more Kubernetes\n"
+ + "Secrets and creates a Knox alias for each. The namespace\n"
+ + "defaults to 'knox' and can be overridden with --namespace.\n"
+ + "Every Secret must contain 'alias.name' (the alias name)\n"
+ + "and 'alias.value' (the secret value); 'topology' is optional\n"
+ + "and defaults to the gateway-level credential store ('__gateway').\n"
+ + "All Secrets are fetched and validated before any alias is\n"
+ + "written, so a failure in the batch leaves the credential\n"
+ + "store untouched. Uses in-cluster Kubernetes config.";
+
+ private static final String DEFAULT_NAMESPACE = "knox";
+ private static final String ENTRY_NAME = "alias.name";
+ private static final String ENTRY_TOPOLOGY = "topology";
+ private static final String ENTRY_KEY = "alias.value";
+ private static final String DEFAULT_TOPOLOGY = "__gateway";
+
+ private final List secretNames;
+
+ public K8sAliasCreateCommand(List secretNames) {
+ this.secretNames = secretNames;
+ }
+
+ @Override
+ public void execute() throws Exception {
+ AliasService as = getAliasService();
+ String ns = (namespace == null || namespace.isEmpty()) ? DEFAULT_NAMESPACE : namespace;
+ List parsed = new ArrayList<>(secretNames.size());
+ try (KubernetesClient client = buildKubernetesClient()) {
+ for (String secretName : secretNames) {
+ Secret secret = client.secrets().inNamespace(ns).withName(secretName).get();
+ if (secret == null) {
+ throw new IllegalStateException(
+ "Secret '" + secretName + "' not found in namespace '" + ns + "'.");
+ }
+ String aliasName = requireEntry(secret, secretName, ENTRY_NAME);
+ String aliasValue = requireEntry(secret, secretName, ENTRY_KEY);
+ String topology = optionalEntry(secret, ENTRY_TOPOLOGY);
+ if (topology == null || topology.isEmpty()) {
+ topology = DEFAULT_TOPOLOGY;
+ }
+ parsed.add(new ParsedAlias(secretName, topology, aliasName, aliasValue));
+ }
+ }
+ for (ParsedAlias p : parsed) {
+ as.addAliasForCluster(p.topology(), p.aliasName(), p.aliasValue());
+ out.println(p.aliasName() + " has been successfully created in topology " + p.topology()
+ + " (from secret " + p.secretName() + ").");
+ }
+ }
+
+ private record ParsedAlias(String secretName, String topology, String aliasName, String aliasValue) {}
+
+ private String requireEntry(Secret secret, String secretName, String entryKey) {
+ String entry = optionalEntry(secret, entryKey);
+ if (entry == null || entry.isEmpty()) {
+ throw new IllegalStateException(
+ "Secret '" + secretName + "' is missing required entry '" + entryKey + "'.");
+ }
+ return entry;
+ }
+
+ private String optionalEntry(Secret secret, String entryKey) {
+ if (secret.getData() != null && secret.getData().containsKey(entryKey)) {
+ return new String(java.util.Base64.getDecoder().decode(secret.getData().get(entryKey)),
+ StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ @Override
+ public String getUsage() {
+ return USAGE + ":\n\n" + DESC;
+ }
+ }
+
public class AliasDeleteCommand extends Command {
public static final String USAGE = "delete-alias aliasname [--cluster clustername]";
public static final String DESC = "The delete-alias command removes the\n" +
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/util/KnoxCLITest.java b/gateway-server/src/test/java/org/apache/knox/gateway/util/KnoxCLITest.java
index ff28231619..338e0c285a 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/util/KnoxCLITest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/util/KnoxCLITest.java
@@ -38,8 +38,19 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
import java.util.UUID;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.api.model.SecretList;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.dsl.MixedOperation;
+import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
+import io.fabric8.kubernetes.client.dsl.Resource;
+
import org.apache.commons.io.FileUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.knox.gateway.GatewayServer;
@@ -53,6 +64,7 @@
import org.apache.knox.gateway.services.security.MasterService;
import org.apache.knox.gateway.services.security.token.impl.TokenMAC;
import org.apache.knox.test.TestUtils;
+import org.easymock.EasyMock;
import org.junit.Before;
import org.junit.Test;
@@ -1516,4 +1528,247 @@ private void setGatewayServicesToNull() throws Exception {
" {\"name\":\"RESOURCEMANAGER\"}\n" +
" ]\n" +
"}";
+
+ private static class FakeK8sKnoxCLI extends KnoxCLI {
+
+ private final KubernetesClient client;
+
+ FakeK8sKnoxCLI(KubernetesClient client) {
+ this.client = client;
+ }
+
+ @Override
+ protected KubernetesClient buildKubernetesClient() {
+ return client;
+ }
+ }
+
+ private static Secret secretWithEntries(Map entries) {
+ Map data = new HashMap<>();
+ for (Map.Entry e : entries.entrySet()) {
+ data.put(e.getKey(),
+ Base64.getEncoder().encodeToString(e.getValue().getBytes(StandardCharsets.UTF_8)));
+ }
+ return new SecretBuilder().withData(data).build();
+ }
+
+ private static MockChain expectSecretLookup(KubernetesClient client, String ns, String name, Secret secret) {
+ MixedOperation> mixed = EasyMock.createMock(MixedOperation.class);
+ NonNamespaceOperation> namespaced = EasyMock.createMock(NonNamespaceOperation.class);
+ Resource resource = EasyMock.createMock(Resource.class);
+ EasyMock.expect(client.secrets()).andReturn(mixed);
+ EasyMock.expect(mixed.inNamespace(ns)).andReturn(namespaced);
+ EasyMock.expect(namespaced.withName(name)).andReturn(resource);
+ EasyMock.expect(resource.get()).andReturn(secret);
+ return new MockChain(mixed, namespaced, resource);
+ }
+
+ private record MockChain(Object... mocks) {
+ }
+
+ @Test
+ public void testCreateK8sAliasSuccess() throws Exception {
+ outContent.reset();
+
+ Map entries = new HashMap<>();
+ entries.put("alias.name", "k8s-success-alias");
+ entries.put("alias.value", "p4ssw0rd");
+ entries.put("topology", "k8s-success-topo");
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chain = expectSecretLookup(client, "knox", "my-secret", secretWithEntries(entries));
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "my-secret", "--master", "master"});
+ assertEquals(errContent.toString(StandardCharsets.UTF_8), 0, rc);
+ String out = outContent.toString(StandardCharsets.UTF_8);
+ assertTrue(out, out.contains("k8s-success-alias has been successfully created in topology k8s-success-topo"));
+ assertTrue(out, out.contains("from secret my-secret"));
+
+ outContent.reset();
+ rc = cli.run(new String[]{"list-alias", "--cluster", "k8s-success-topo", "--master", "master"});
+ assertEquals(0, rc);
+ assertTrue(outContent.toString(StandardCharsets.UTF_8),
+ outContent.toString(StandardCharsets.UTF_8).contains("k8s-success-alias"));
+
+ EasyMock.verify(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+ }
+
+ @Test
+ public void testCreateK8sAliasDefaultsTopologyToGateway() throws Exception {
+ outContent.reset();
+
+ Map entries = new HashMap<>();
+ entries.put("alias.name", "k8s-default-topo-alias");
+ entries.put("alias.value", "v");
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chain = expectSecretLookup(client, "knox", "my-secret", secretWithEntries(entries));
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "my-secret", "--master", "master"});
+ assertEquals(errContent.toString(StandardCharsets.UTF_8), 0, rc);
+ String out = outContent.toString(StandardCharsets.UTF_8);
+ assertTrue(out, out.contains("k8s-default-topo-alias has been successfully created in topology __gateway"));
+
+ EasyMock.verify(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+ }
+
+ @Test
+ public void testCreateK8sAliasUsesCustomNamespace() throws Exception {
+ outContent.reset();
+
+ Map entries = new HashMap<>();
+ entries.put("alias.name", "k8s-custom-ns-alias");
+ entries.put("alias.value", "v");
+ entries.put("topology", "k8s-custom-ns-topo");
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chain = expectSecretLookup(client, "other-ns", "my-secret", secretWithEntries(entries));
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "my-secret", "--namespace", "other-ns", "--master", "master"});
+ assertEquals(errContent.toString(StandardCharsets.UTF_8), 0, rc);
+
+ EasyMock.verify(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+ }
+
+ @Test
+ public void testCreateK8sAliasBatch() throws Exception {
+ outContent.reset();
+
+ Map entriesA = new HashMap<>();
+ entriesA.put("alias.name", "k8s-batch-alias-a");
+ entriesA.put("alias.value", "va");
+ entriesA.put("topology", "k8s-batch-topo-a");
+
+ Map entriesB = new HashMap<>();
+ entriesB.put("alias.name", "k8s-batch-alias-b");
+ entriesB.put("alias.value", "vb");
+ entriesB.put("topology", "k8s-batch-topo-b");
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chainA = expectSecretLookup(client, "knox", "secret-a", secretWithEntries(entriesA));
+ MockChain chainB = expectSecretLookup(client, "knox", "secret-b", secretWithEntries(entriesB));
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client,
+ chainA.mocks[0], chainA.mocks[1], chainA.mocks[2],
+ chainB.mocks[0], chainB.mocks[1], chainB.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "secret-a", "secret-b", "--master", "master"});
+ assertEquals(errContent.toString(StandardCharsets.UTF_8), 0, rc);
+ String out = outContent.toString(StandardCharsets.UTF_8);
+ assertTrue(out, out.contains("k8s-batch-alias-a has been successfully created in topology k8s-batch-topo-a"));
+ assertTrue(out, out.contains("k8s-batch-alias-b has been successfully created in topology k8s-batch-topo-b"));
+
+ EasyMock.verify(client,
+ chainA.mocks[0], chainA.mocks[1], chainA.mocks[2],
+ chainB.mocks[0], chainB.mocks[1], chainB.mocks[2]);
+ }
+
+ @Test
+ public void testCreateK8sAliasFailsWhenSecretMissing() throws Exception {
+ outContent.reset();
+ errContent.reset();
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chain = expectSecretLookup(client, "knox", "missing-secret", null);
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "missing-secret", "--master", "master"});
+ assertEquals(-3, rc);
+ String err = errContent.toString(StandardCharsets.UTF_8);
+ assertTrue(err, err.contains("Secret 'missing-secret' not found"));
+ assertTrue(err, err.contains("namespace 'knox'"));
+
+ EasyMock.verify(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+ }
+
+ @Test
+ public void testCreateK8sAliasFailsWhenRequiredEntryMissing() throws Exception {
+ outContent.reset();
+ errContent.reset();
+
+ Map entries = new HashMap<>();
+ entries.put("alias.value", "v");
+ entries.put("topology", "k8s-missing-name-topo");
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chain = expectSecretLookup(client, "knox", "incomplete-secret", secretWithEntries(entries));
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "incomplete-secret", "--master", "master"});
+ assertEquals(-3, rc);
+ String err = errContent.toString(StandardCharsets.UTF_8);
+ assertTrue(err, err.contains("Secret 'incomplete-secret' is missing required entry 'alias.name'"));
+
+ EasyMock.verify(client, chain.mocks[0], chain.mocks[1], chain.mocks[2]);
+ }
+
+ @Test
+ public void testCreateK8sAliasBatchIsAtomicOnFailure() throws Exception {
+ outContent.reset();
+ errContent.reset();
+
+ Map entriesA = new HashMap<>();
+ entriesA.put("alias.name", "k8s-atomic-alias-a");
+ entriesA.put("alias.value", "va");
+ entriesA.put("topology", "k8s-atomic-topo-a");
+
+ KubernetesClient client = EasyMock.createMock(KubernetesClient.class);
+ MockChain chainA = expectSecretLookup(client, "knox", "secret-a", secretWithEntries(entriesA));
+ MockChain chainB = expectSecretLookup(client, "knox", "secret-b", null);
+ client.close();
+ EasyMock.expectLastCall();
+ EasyMock.replay(client,
+ chainA.mocks[0], chainA.mocks[1], chainA.mocks[2],
+ chainB.mocks[0], chainB.mocks[1], chainB.mocks[2]);
+
+ KnoxCLI cli = new FakeK8sKnoxCLI(client);
+ cli.setConf(new GatewayConfigImpl());
+
+ int rc = cli.run(new String[]{"create-k8s-alias", "secret-a", "secret-b", "--master", "master"});
+ assertEquals(-3, rc);
+ String err = errContent.toString(StandardCharsets.UTF_8);
+ assertTrue(err, err.contains("Secret 'secret-b' not found"));
+
+ outContent.reset();
+ rc = cli.run(new String[]{"list-alias", "--cluster", "k8s-atomic-topo-a", "--master", "master"});
+ assertEquals(0, rc);
+ assertFalse(outContent.toString(StandardCharsets.UTF_8),
+ outContent.toString(StandardCharsets.UTF_8).contains("k8s-atomic-alias-a"));
+
+ EasyMock.verify(client,
+ chainA.mocks[0], chainA.mocks[1], chainA.mocks[2],
+ chainB.mocks[0], chainB.mocks[1], chainB.mocks[2]);
+ }
}