This is an automated email from the ASF dual-hosted git repository.

smolnar82 pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git

commit 1e0292ffb8d8ea4a634c157580ed9ec6668da837
Author: hanicz <[email protected]>
AuthorDate: Fri Jun 26 12:22:38 2026 +0200

    KNOX-3360: create-k8s-alias command to save aliases from k8s secrets (#1279)
    
    * KNOX-3360: create-k8s-alias command to save aliases from k8s secrets
    
    * KNOX-3360: Check all secrets for validity before alias creation
    
    (cherry picked from commit f6c9adf7912e8baa027a5b8c101cb9ab729c1149)
---
 gateway-server/pom.xml                             |  13 ++
 .../java/org/apache/knox/gateway/util/KnoxCLI.java | 107 +++++++++
 .../org/apache/knox/gateway/util/KnoxCLITest.java  | 255 +++++++++++++++++++++
 3 files changed, 375 insertions(+)

diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml
index 95bd6b24d..c78993781 100644
--- a/gateway-server/pom.xml
+++ b/gateway-server/pom.xml
@@ -628,5 +628,18 @@
             <scope>test</scope>
         </dependency>
 
+        <dependency>
+            <groupId>io.fabric8</groupId>
+            <artifactId>kubernetes-client</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.fabric8</groupId>
+            <artifactId>kubernetes-client-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.fabric8</groupId>
+            <artifactId>kubernetes-model-core</artifactId>
+        </dependency>
+
     </dependencies>
 </project>
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 784c416c1..ea77ad9bf 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 java.util.stream.Stream;
 
 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 class KnoxCLI extends Configured implements Tool {
     return exitCode;
   }
 
+  protected KubernetesClient buildKubernetesClient() {
+    return new KubernetesClientBuilder().build();
+  }
+
   public static synchronized GatewayServices getGatewayServices() {
     return services;
   }
@@ -272,6 +282,16 @@ public class KnoxCLI extends Configured implements Tool {
           printKnoxShellUsage();
           return -1;
         }
+      } else if (args[i].equals("create-k8s-alias")) {
+        List<String> 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 @@ public class KnoxCLI extends Configured implements Tool {
         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 @@ public class KnoxCLI extends Configured implements Tool {
       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 class KnoxCLI extends Configured implements Tool {
    }
  }
 
+ 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<String> secretNames;
+
+   public K8sAliasCreateCommand(List<String> secretNames) {
+     this.secretNames = secretNames;
+   }
+
+   @Override
+   public void execute() throws Exception {
+     AliasService as = getAliasService();
+     String ns = (namespace == null || namespace.isEmpty()) ? 
DEFAULT_NAMESPACE : namespace;
+     List<ParsedAlias> 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 ff2823161..338e0c285 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.net.URL;
 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.AliasService;
 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 @@ public class KnoxCLITest {
                                                           "    
{\"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<String, String> entries) {
+    Map<String, String> data = new HashMap<>();
+    for (Map.Entry<String, String> 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<Secret, SecretList, Resource<Secret>> mixed = 
EasyMock.createMock(MixedOperation.class);
+    NonNamespaceOperation<Secret, SecretList, Resource<Secret>> namespaced = 
EasyMock.createMock(NonNamespaceOperation.class);
+    Resource<Secret> 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<String, String> 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<String, String> 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<String, String> 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<String, String> entriesA = new HashMap<>();
+    entriesA.put("alias.name", "k8s-batch-alias-a");
+    entriesA.put("alias.value", "va");
+    entriesA.put("topology", "k8s-batch-topo-a");
+
+    Map<String, String> 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<String, String> 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<String, String> 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]);
+  }
 }

Reply via email to