From 43be12b78450e0e3565dad88e6dd55a0f50fb6f1 Mon Sep 17 00:00:00 2001 From: thanicz Date: Thu, 25 Jun 2026 08:21:44 +0200 Subject: [PATCH 1/2] KNOX-3360: create-k8s-alias command to save aliases from k8s secrets --- gateway-server/pom.xml | 13 ++ .../org/apache/knox/gateway/util/KnoxCLI.java | 100 ++++++++ .../apache/knox/gateway/util/KnoxCLITest.java | 217 ++++++++++++++++++ 3 files changed, 330 insertions(+) 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..29b8504827 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,77 @@ 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" + + "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; + 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; + } + + as.addAliasForCluster(topology, aliasName, aliasValue); + out.println(aliasName + " has been successfully created in topology " + topology + + " (from secret " + secretName + ")."); + } + } + } + + 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..65f003bb4a 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,209 @@ 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]); + } } From 5d2be2e88ba36765a960b2da56c6ff31d3c65d85 Mon Sep 17 00:00:00 2001 From: thanicz Date: Fri, 26 Jun 2026 08:58:02 +0200 Subject: [PATCH 2/2] KNOX-3360: Check all secrets for validity before alias creation --- .../org/apache/knox/gateway/util/KnoxCLI.java | 17 ++++++--- .../apache/knox/gateway/util/KnoxCLITest.java | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) 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 29b8504827..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 @@ -1067,7 +1067,9 @@ public class K8sAliasCreateCommand extends Command { + "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" - + "Uses in-cluster Kubernetes config."; + + "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"; @@ -1085,6 +1087,7 @@ public K8sAliasCreateCommand(List secretNames) { 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(); @@ -1098,14 +1101,18 @@ public void execute() throws Exception { if (topology == null || topology.isEmpty()) { topology = DEFAULT_TOPOLOGY; } - - as.addAliasForCluster(topology, aliasName, aliasValue); - out.println(aliasName + " has been successfully created in topology " + topology - + " (from secret " + secretName + ")."); + 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()) { 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 65f003bb4a..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 @@ -1733,4 +1733,42 @@ public void testCreateK8sAliasFailsWhenRequiredEntryMissing() throws Exception { 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]); + } }