feat(admin cli client v2): show help based on Keycloak server version (#47525)

* Closes: https://github.com/keycloak/keycloak/issues/47171
* Adds information about autocomplete to the v2 help

**More about OpenAPI document fetching:**

When Admin CLI is used with a different Keycloak server version, we need to update help and autocomplete according to OpenAPI document, which describes what endpoints and schemas can be used. This can only be done when the Keycloak server has enabled (currently experimental) OpenAPI feature and users specify OpenAPI path. The management path and port can be changed by user, therefore we ask them to specify OpenAPI URL entirely.

This feature is only provided for the current session (when user is logged in into some server). We cannot easily provide help and autocomplete based on inline arguments like `--server` because it is bit chicken-egg problem (injected options are available after you provided command to PicoCLI, so we would need to do a lot manually).

Here is how it works:

```bash
mvavrik@fedora:~/sources/keycloak$ kcadm.sh --v2 config credentials --server http://localhost:8080 --realm master --user admin --password admin
Logging into http://localhost:8080 as user admin of realm master
OpenAPI descriptor cached for http://localhost:8080 (version 999.0.0-SNAPSHOT)

```

or for non-default management port:

```bash
mvavrik@fedora:~/sources/keycloak$ kcadm.sh --v2 config credentials --server http://localhost:8080 --realm master --user admin --password admin
Logging into http://localhost:8080 as user admin of realm master
mvavrik@fedora:~/sources/keycloak$ kcadm.sh --v2 config openapi http://localhost:9004/openapi
OpenAPI descriptor cached for http://localhost:8080 (version 999.0.0-SNAPSHOT)
```

or alternatively:

```bash
mvavrik@fedora:~/sources/keycloak$ kcadm.sh --v2 config credentials --server http://localhost:8080 --realm master --user admin --password admin --openapi-url http://localhost:9004/openapi
Logging into http://localhost:8080 as user admin of realm master
OpenAPI descriptor cached for http://localhost:8080 (version 999.0.0-SNAPSHOT)
```

After that, command structure reflects the OpenAPI document, including help and autocomplete. If you switch server using `config credentials`, command changes as well. If the server you are communicating does not provide OpenAPI endpoint, we silently fallback to the default OpenAPI document (bundled with the command). However,
the `kcadm.sh --v2 config openapi` fails if the OpenAPI endpoint wasn't available.

Signed-off-by: Michal Vavřík <michal.vavrik@aol.com>
This commit is contained in:
Michal Vavřík
2026-04-10 17:52:54 +02:00
committed by GitHub
parent 9130641970
commit d4cd08824d
17 changed files with 1403 additions and 74 deletions
@@ -73,7 +73,7 @@ public class KcAdmMain {
KcAdmV2Completer.complete(stripArgs(v2Args, COMPLETE_FLAG),
new PrintWriter(System.out, true));
} else {
Globals.main(v2Args, new KcAdmV2Cmd(), CMD, DEFAULT_CONFIG_FILE_STRING);
Globals.main(v2Args, new KcAdmV2Cmd(v2Args), CMD, DEFAULT_CONFIG_FILE_STRING);
}
}
@@ -22,6 +22,7 @@ import java.io.StringWriter;
import picocli.CommandLine.Command;
import static org.keycloak.client.admin.cli.KcAdmMain.CMD;
import static org.keycloak.client.admin.cli.KcAdmMain.V2_FLAG;
/**
@@ -36,6 +37,16 @@ public class ConfigCmd extends AbstractAuthOptionsCmd {
public static final String NAME = "config";
private final boolean v2;
public ConfigCmd() {
this.v2 = false;
}
public ConfigCmd(boolean v2) {
this.v2 = v2;
}
@Override
protected void process() {
@@ -48,19 +59,27 @@ public class ConfigCmd extends AbstractAuthOptionsCmd {
@Override
protected String help() {
return usage();
return usage(v2);
}
public static String usage() {
return usage(false);
}
private static String usage(boolean v2) {
String cmd = v2 ? CMD + " " + V2_FLAG : CMD;
String subcommands = v2 ? "'credentials', 'truststore', 'openapi'" : "'credentials', 'truststore'";
String helpHint = v2 ? cmd + " config SUB_COMMAND --help" : CMD + " help config SUB_COMMAND";
StringWriter sb = new StringWriter();
PrintWriter out = new PrintWriter(sb);
out.println("Usage: " + CMD + " config SUB_COMMAND [ARGUMENTS]");
out.println("Usage: " + cmd + " config SUB_COMMAND [ARGUMENTS]");
out.println();
out.println("Where SUB_COMMAND is one of: 'credentials', 'truststore'");
out.println("Where SUB_COMMAND is one of: " + subcommands);
out.println();
out.println();
out.println("Use '" + CMD + " help config SUB_COMMAND' for more info.");
out.println("Use '" + CMD + " help' for general information and a list of commands.");
out.println("Use '" + helpHint + "' for more info.");
out.println("Use '" + cmd + " help' for general information and a list of commands.");
return sb.toString();
}
}
@@ -1,11 +1,17 @@
package org.keycloak.client.admin.cli.v2;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.nio.file.Path;
import org.keycloak.client.admin.cli.KcAdmMain;
import org.keycloak.client.admin.cli.commands.ConfigCmd;
import org.keycloak.client.cli.common.BaseGlobalOptionsCmd;
import org.keycloak.client.cli.config.ConfigData;
import org.keycloak.util.JsonSerialization;
import picocli.CommandLine;
import picocli.CommandLine.Command;
@@ -17,15 +23,41 @@ import static org.keycloak.client.admin.cli.KcAdmMain.V2_FLAG;
import static org.keycloak.client.cli.util.OsUtil.PROMPT;
@Command(name = "kcadm",
description = "%nCOMMAND [ARGUMENTS]"
description = "%nCOMMAND [ARGUMENTS]",
footer = {"%nEnable tab completion:%n source <(kcadm.sh --v2 completion)"}
)
public class KcAdmV2Cmd extends BaseGlobalOptionsCmd {
private static final String BUNDLED_DESCRIPTOR = "/kcadm-v2-commands.json";
private static final String CONFIG_FILE_NAME = Path.of(KcAdmMain.DEFAULT_CONFIG_FILE_PATH).getFileName().toString();
private static final String CONFIG_OPTION = "--config";
private static final Path DEFAULT_CACHE_DIR =
Path.of(KcAdmMain.DEFAULT_CONFIG_FILE_PATH).getParent().resolve("command-descriptors").resolve("v2");
private final Path cacheDir;
private final String configFilePath;
@Spec
CommandSpec spec;
public KcAdmV2Cmd() {
this(DEFAULT_CACHE_DIR);
}
public KcAdmV2Cmd(Path cacheDir) {
this(cacheDir, null);
}
public KcAdmV2Cmd(String[] args) {
this(DEFAULT_CACHE_DIR, args);
}
public KcAdmV2Cmd(Path cacheDir, String[] args) {
this.cacheDir = cacheDir;
this.configFilePath = findConfigPath(args);
}
@Override
protected boolean nothingToDo() {
return true;
@@ -57,20 +89,72 @@ public class KcAdmV2Cmd extends BaseGlobalOptionsCmd {
@Override
protected void configureCommandLine(CommandLine cli) {
cli.getCommandSpec().name(CMD + " " + V2_FLAG);
CommandLine configCmd = new CommandLine(new ConfigCmd());
CommandLine configCmd = new CommandLine(new ConfigCmd(true));
configCmd.getCommandSpec().usageMessage().description("Configuration management");
configCmd.getCommandSpec().removeSubcommand("credentials");
configCmd.addSubcommand("credentials", new CommandLine(new KcAdmV2ConfigCredentialsCmd(cacheDir)));
configCmd.addSubcommand("openapi", new CommandLine(new KcAdmV2ConfigOpenApiCmd(cacheDir)));
cli.addSubcommand(configCmd);
KcAdmV2CommandDescriptor descriptor = loadDescriptor();
KcAdmV2CommandBuilder.addCommands(cli, descriptor);
}
private KcAdmV2CommandDescriptor loadDescriptor() {
// TODO: fetch and cache server-specific descriptor (follow-up PR)
KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir);
String serverUrl = readServerUrlFromConfig();
if (serverUrl != null) {
KcAdmV2CommandDescriptor cached = cache.loadForServer(serverUrl);
if (cached != null) {
return cached;
}
}
return loadBundledDescriptor();
}
private KcAdmV2CommandDescriptor loadBundledDescriptor() {
try (InputStream is = getClass().getResourceAsStream(BUNDLED_DESCRIPTOR)) {
private String readServerUrlFromConfig() {
if (configFilePath != null) {
String fromConfig = readServerUrlFrom(configFilePath);
if (fromConfig != null) {
return fromConfig;
}
}
String fromCacheDir = readServerUrlFrom(cacheDir.resolve(CONFIG_FILE_NAME).toString());
if (fromCacheDir != null) {
return fromCacheDir;
}
return readServerUrlFrom(KcAdmMain.DEFAULT_CONFIG_FILE_PATH);
}
static String readServerUrlFrom(String configFilePath) {
try {
File configFile = new File(configFilePath);
if (!configFile.isFile()) {
return null;
}
try (FileInputStream is = new FileInputStream(configFile)) {
ConfigData config = JsonSerialization.readValue(is, ConfigData.class);
return config.getServerUrl();
}
} catch (Exception e) {
return null;
}
}
private static String findConfigPath(String[] args) {
if (args != null) {
for (int i = 0; i < args.length - 1; i++) {
if (CONFIG_OPTION.equals(args[i])) {
return args[i + 1];
}
}
}
return null;
}
public static KcAdmV2CommandDescriptor loadBundledDescriptor() {
try (InputStream is = KcAdmV2Cmd.class.getResourceAsStream(BUNDLED_DESCRIPTOR)) {
if (is == null) {
throw new RuntimeException("Bundled command descriptor not found: " + BUNDLED_DESCRIPTOR);
}
@@ -1,6 +1,7 @@
package org.keycloak.client.admin.cli.v2;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -16,10 +17,20 @@ public class KcAdmV2Completer {
private static final String LONG_OPTION_PREFIX = "--";
public static void complete(String[] args, PrintWriter out) {
KcAdmV2Cmd rootCmd = new KcAdmV2Cmd();
completeWith(buildCli(new KcAdmV2Cmd(args)), args, out);
}
public static void complete(String[] args, PrintWriter out, Path cacheDir) {
completeWith(buildCli(new KcAdmV2Cmd(cacheDir, args)), args, out);
}
private static CommandLine buildCli(KcAdmV2Cmd rootCmd) {
CommandLine cli = new CommandLine(rootCmd);
rootCmd.configureCommandLine(cli);
return cli;
}
private static void completeWith(CommandLine cli, String[] args, PrintWriter out) {
String partial = args.length > 0 ? args[args.length - 1] : "";
if (LONG_OPTION_PREFIX.equals(partial)) {
@@ -0,0 +1,98 @@
package org.keycloak.client.admin.cli.v2;
import java.io.PrintWriter;
import java.net.URI;
import java.net.URL;
import java.nio.file.Path;
import org.keycloak.client.admin.cli.commands.ConfigCredentialsCmd;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;
import static org.keycloak.client.admin.cli.KcAdmMain.CMD;
import static org.keycloak.client.admin.cli.KcAdmMain.V2_FLAG;
@Command(name = "credentials", description = "--server SERVER_URL --realm REALM [ARGUMENTS]")
class KcAdmV2ConfigCredentialsCmd extends ConfigCredentialsCmd {
private static final int DEFAULT_MANAGEMENT_PORT = 9000;
private static final String OPENAPI_PATH = "/openapi";
@Option(names = "--openapi-url", description = "URL of the OpenAPI endpoint for auto-fetching the server descriptor " +
"(default: <server-protocol>://<server-host>:" + DEFAULT_MANAGEMENT_PORT + OPENAPI_PATH + ")")
String openApiUrl;
@Spec
CommandSpec spec;
private final Path cacheDir;
KcAdmV2ConfigCredentialsCmd(Path cacheDir) {
this.cacheDir = cacheDir;
}
@Override
protected String getCommand() {
return CMD + " " + V2_FLAG;
}
@Override
protected void printExtraOptions(PrintWriter out) {
out.println(" --openapi-url URL URL of the OpenAPI endpoint for auto-fetching the server descriptor");
out.println(" (default: <server-protocol>://<server-host>:" + DEFAULT_MANAGEMENT_PORT + OPENAPI_PATH + ")");
}
@Override
public void process() {
super.process();
tryAutoFetchOpenApi();
}
private void tryAutoFetchOpenApi() {
if (server == null) {
// --status without --server — no login happened, nothing to fetch
return;
}
String url = openApiUrl;
if (url == null) {
try {
url = deriveDefaultOpenApiUrl();
} catch (Exception e) {
warnAutoFetchFailed("could not determine OpenAPI URL from server " + server
+ (e.getMessage() != null ? ": " + e.getMessage() : ""));
return;
}
}
try {
KcAdmV2CommandDescriptor descriptor = KcAdmV2ConfigOpenApiCmd.fetchDescriptorFromUrl(url);
KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir);
cache.save(server, descriptor);
printToErr("OpenAPI descriptor fetched from " + url + " and cached for " + server
+ " (version " + descriptor.getVersion() + ")");
} catch (Exception e) {
warnAutoFetchFailed(url + (e.getMessage() != null ? ": " + e.getMessage() : ""));
}
}
private void warnAutoFetchFailed(String detail) {
printToErr("Failed to fetch OpenAPI descriptor from " + detail + ". "
+ "CLI commands may not match your server version. "
+ "You can use 'config openapi' to manually load the descriptor.");
}
private void printToErr(String message) {
spec.commandLine().getErr().println(message);
}
private String deriveDefaultOpenApiUrl() throws Exception {
URL serverParsed = new URL(server);
URI managementUri = new URI(serverParsed.getProtocol(), null,
serverParsed.getHost(), DEFAULT_MANAGEMENT_PORT, OPENAPI_PATH, null, null);
return managementUri.toString();
}
}
@@ -0,0 +1,106 @@
package org.keycloak.client.admin.cli.v2;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import org.keycloak.client.admin.cli.KcAdmMain;
import org.keycloak.client.cli.common.BaseAuthOptionsCmd;
import org.keycloak.client.cli.util.Headers;
import org.keycloak.client.cli.util.HeadersBody;
import org.keycloak.client.cli.util.HeadersBodyStatus;
import org.keycloak.client.cli.util.HttpUtil;
import org.eclipse.microprofile.openapi.models.OpenAPI;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;
@Command(name = "openapi", description = "Fetch the server's OpenAPI spec to get commands, options, and help " +
"that match your server version — instead of the built-in defaults.%n%n" +
"The source can be a URL (e.g., https://localhost:9000/openapi) or a local file path.%n%n" +
"The OpenAPI endpoint is served on the management interface, which may use a different host and port " +
"than the main server. Requires 'config credentials' to be run first.")
class KcAdmV2ConfigOpenApiCmd implements Runnable {
@Parameters(index = "0", paramLabel = "<source>",
description = "URL of the OpenAPI endpoint (e.g., http://localhost:9000/openapi) or path to a local OpenAPI JSON file")
String openApiSource;
@Option(names = "--config", description = "Path to the config file (${sys:" + BaseAuthOptionsCmd.DEFAULT_CONFIG_PATH_STRING_KEY + "} by default)")
String config;
@Option(names = { "-h", "--help" }, usageHelp = true, hidden = true)
boolean help;
@Spec
CommandSpec spec;
private final Path cacheDir;
KcAdmV2ConfigOpenApiCmd(Path cacheDir) {
this.cacheDir = cacheDir;
}
@Override
public void run() {
String configPath = config != null ? config : KcAdmMain.DEFAULT_CONFIG_FILE_PATH;
String serverUrl = KcAdmV2Cmd.readServerUrlFrom(configPath);
if (serverUrl == null) {
throw new RuntimeException("No server configured. Run 'config credentials' first.");
}
try {
KcAdmV2CommandDescriptor descriptor = loadDescriptor(openApiSource);
KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir);
cache.save(serverUrl, descriptor);
spec.commandLine().getErr().println(
"OpenAPI descriptor cached for " + serverUrl + " (version " + descriptor.getVersion() + ")");
} catch (Exception e) {
String cause = e.getMessage() != null ? ": " + e.getMessage() : "";
throw new RuntimeException("Failed to load OpenAPI from " + openApiSource + cause, e);
}
}
static KcAdmV2CommandDescriptor loadDescriptor(String source) throws IOException {
KcAdmV2CommandDescriptor descriptor;
if (isUrl(source)) {
descriptor = fetchDescriptorFromUrl(source);
} else {
File file = new File(source);
if (!file.isFile()) {
throw new RuntimeException("Not a valid URL (must start with http:// or https://) and no file found at: " + source);
}
descriptor = loadDescriptorFromFile(file);
}
if (descriptor.getResources() == null || descriptor.getResources().isEmpty()) {
throw new RuntimeException("OpenAPI spec contains no resources — the file may not be a valid OpenAPI spec");
}
return descriptor;
}
private static boolean isUrl(String source) {
return source.startsWith("http://") || source.startsWith("https://");
}
static KcAdmV2CommandDescriptor fetchDescriptorFromUrl(String url) throws IOException {
HeadersBodyStatus response = HttpUtil.doRequest("get", url, new HeadersBody(new Headers()));
response.checkSuccess();
InputStream body = response.getBody();
OpenAPI openApi = KcAdmV2DescriptorBuilder.parseOpenApi(() -> body);
return KcAdmV2DescriptorBuilder.convert(openApi);
}
private static KcAdmV2CommandDescriptor loadDescriptorFromFile(File file) throws IOException {
try (InputStream is = new FileInputStream(file)) {
OpenAPI openApi = KcAdmV2DescriptorBuilder.parseOpenApi(() -> is);
return KcAdmV2DescriptorBuilder.convert(openApi);
}
}
}
@@ -11,9 +11,8 @@ import java.util.Map;
import java.util.Optional;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.OptionDescriptor;
import org.keycloak.client.cli.util.OutputUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.smallrye.openapi.api.SmallRyeOpenAPI;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigValue;
@@ -37,8 +36,6 @@ public class KcAdmV2DescriptorBuilder {
static final String ID_PATH_PARAM = "{id}";
private static final ObjectMapper MAPPER = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
private static final Map<PathItem.HttpMethod, String> HTTP_METHOD_TO_COMMAND = Map.of(
PathItem.HttpMethod.GET, "get",
@@ -120,11 +117,11 @@ public class KcAdmV2DescriptorBuilder {
public static void writeDescriptor(KcAdmV2CommandDescriptor descriptor, Path outputFile) throws IOException {
Files.createDirectories(outputFile.getParent());
MAPPER.writeValue(outputFile.toFile(), descriptor);
OutputUtil.MAPPER.writeValue(outputFile.toFile(), descriptor);
}
public static KcAdmV2CommandDescriptor readDescriptor(InputStream is) throws IOException {
return MAPPER.readValue(is, KcAdmV2CommandDescriptor.class);
return OutputUtil.MAPPER.readValue(is, KcAdmV2CommandDescriptor.class);
}
private static String extractResourceName(PathItem pathItem) {
@@ -0,0 +1,136 @@
package org.keycloak.client.admin.cli.v2;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import org.keycloak.client.cli.util.OutputUtil;
public final class KcAdmV2DescriptorCache {
public static final String REGISTRY_FILENAME = "registry.json";
public static final String DESCRIPTOR_PREFIX = "descriptor-";
private final Path cacheDir;
public KcAdmV2DescriptorCache(Path cacheDir) {
this.cacheDir = cacheDir;
}
public KcAdmV2CommandDescriptor loadForServer(String serverUrl) {
if (!Files.isDirectory(cacheDir)) {
return null;
}
Registry registry = readRegistry();
if (registry == null) {
return null;
}
ServerEntry entry = registry.servers.get(serverUrl);
if (entry == null || entry.version == null) {
return null;
}
Path descriptorFile = descriptorPath(entry.version);
if (!Files.isRegularFile(descriptorFile)) {
return null;
}
try {
return OutputUtil.MAPPER.readValue(descriptorFile.toFile(), KcAdmV2CommandDescriptor.class);
} catch (IOException e) {
return null;
}
}
public void save(String serverUrl, KcAdmV2CommandDescriptor descriptor) {
String newVersion = descriptor.getVersion();
if (newVersion == null || newVersion.isBlank()) {
throw new IllegalArgumentException("Descriptor version must not be null or blank");
}
try {
Files.createDirectories(cacheDir);
} catch (IOException e) {
throw new RuntimeException("Failed to create cache directory: " + cacheDir, e);
}
Registry registry = readRegistryOrEmpty();
String oldVersion = versionForServer(registry, serverUrl);
try {
OutputUtil.MAPPER.writeValue(descriptorPath(newVersion).toFile(), descriptor);
} catch (IOException e) {
throw new RuntimeException("Failed to write descriptor: " + descriptorPath(newVersion), e);
}
registry.servers.put(serverUrl, new ServerEntry(newVersion));
writeRegistry(registry);
if (oldVersion != null && !oldVersion.equals(newVersion)) {
deleteOrphanedDescriptor(registry, oldVersion);
}
}
private void deleteOrphanedDescriptor(Registry registry, String version) {
boolean stillReferenced = registry.servers.values().stream().anyMatch(e -> version.equals(e.version));
if (!stillReferenced) {
try {
Files.deleteIfExists(descriptorPath(version));
} catch (IOException ignored) {
}
}
}
private Path descriptorPath(String version) {
String sanitized = version.replaceAll("[^a-zA-Z0-9_-]", "_");
return cacheDir.resolve(DESCRIPTOR_PREFIX + sanitized + ".json");
}
private String versionForServer(Registry registry, String serverUrl) {
ServerEntry entry = registry.servers.get(serverUrl);
return entry != null ? entry.version : null;
}
private Registry readRegistry() {
Path registryFile = cacheDir.resolve(REGISTRY_FILENAME);
if (!Files.isRegularFile(registryFile)) {
return null;
}
try {
Registry registry = OutputUtil.MAPPER.readValue(registryFile.toFile(), Registry.class);
if (registry == null || registry.servers == null) {
return null;
}
return registry;
} catch (IOException e) {
return null;
}
}
private Registry readRegistryOrEmpty() {
Registry registry = readRegistry();
return registry != null ? registry : new Registry();
}
private void writeRegistry(Registry registry) {
try {
OutputUtil.MAPPER.writeValue(cacheDir.resolve(REGISTRY_FILENAME).toFile(), registry);
} catch (IOException e) {
throw new RuntimeException("Failed to write registry: " + cacheDir.resolve(REGISTRY_FILENAME), e);
}
}
static class Registry {
public Map<String, ServerEntry> servers = new LinkedHashMap<>();
}
static class ServerEntry {
public String version;
ServerEntry() {}
ServerEntry(String version) {
this.version = version;
}
}
}
@@ -226,6 +226,7 @@ public class BaseConfigCredentialsCmd extends BaseAuthOptionsCmd {
out.println(" otherwise defaults to keystore password)");
out.println(" --alias ALIAS Alias of the key inside a keystore (defaults to the value of ClientId)");
out.println(" --status Checks the validity of the existing connection (Note: It does not update the config)");
printExtraOptions(out);
out.println();
out.println();
out.println("Examples:");
@@ -261,4 +262,7 @@ public class BaseConfigCredentialsCmd extends BaseAuthOptionsCmd {
out.println("Use '" + getCommand() + " help' for general information and a list of commands");
return sb.toString();
}
protected void printExtraOptions(PrintWriter out) {
}
}
@@ -0,0 +1,216 @@
package org.keycloak.client.admin.cli.commands.v2;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import org.keycloak.client.admin.cli.KcAdmMain;
import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.CommandDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.OptionDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.ResourceDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2Completer;
import org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache;
import org.keycloak.client.cli.common.Globals;
import org.keycloak.client.cli.config.ConfigData;
import org.keycloak.client.cli.config.FileConfigHandler;
import org.keycloak.client.cli.util.ConfigUtil;
import org.keycloak.util.JsonSerialization;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import picocli.CommandLine;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class KcAdmV2CachedDescriptorHelpTest {
private static final String TEST_SERVER = "http://test-server:8080";
private static final String CONFIG_FILE_NAME = Path.of(KcAdmMain.DEFAULT_CONFIG_FILE_PATH).getFileName().toString();
private static Path tempDir;
@BeforeClass
public static void setUpClass() throws IOException {
tempDir = Files.createTempDirectory("kcadm-help-test");
setUpCachedDescriptor();
ConfigUtil.setHandler(null);
FileConfigHandler.setConfigFile(null);
}
@AfterClass
public static void tearDownClass() throws IOException {
if (tempDir != null && Files.exists(tempDir)) {
deleteRecursively(tempDir);
}
}
@After
public void tearDown() {
ConfigUtil.setHandler(null);
FileConfigHandler.setConfigFile(null);
}
@Test
public void helpShowsCachedResource() {
String help = createCli().getUsageMessage();
assertTrue("'widget' not found in: " + help, help.contains("widget"));
assertFalse("bundled 'client' should not appear in: " + help, help.contains("client"));
}
@Test
public void helpShowsCachedSubcommands() {
CommandLine widgetCli = createCli().getSubcommands().get("widget");
assertNotNull("'widget' should be a subcommand", widgetCli);
String help = widgetCli.getUsageMessage();
assertTrue("'list' not found in: " + help, help.contains("list"));
assertTrue("'create' not found in: " + help, help.contains("create"));
}
@Test
public void helpShowsCachedOptions() {
CommandLine createCli = createCli().getSubcommands().get("widget").getSubcommands().get("create");
String help = createCli.getUsageMessage();
assertTrue("'--widget-name' not found in: " + help, help.contains("--widget-name"));
assertTrue("'Name of the widget' not found in: " + help, help.contains("Name of the widget"));
}
@Test
public void autocompleteShowsCachedResourceAndNotBundled() {
List<String> candidates = complete("");
assertTrue("'widget' not found in: " + candidates, candidates.contains("widget"));
assertFalse("bundled 'client' should not appear in: " + candidates, candidates.contains("client"));
}
@Test
public void autocompleteShowsCachedSubcommands() {
List<String> candidates = complete("widget", "");
assertTrue("'list' not found in: " + candidates, candidates.contains("list"));
assertTrue("'create' not found in: " + candidates, candidates.contains("create"));
}
@Test
public void autocompleteShowsCachedOptions() {
List<String> candidates = complete("widget", "create", "--");
assertTrue("'--widget-name' not found in: " + candidates, candidates.contains("--widget-name"));
}
@Test
public void fallsBackToBundledWhenCacheHasNoEntryForServer() throws IOException {
Path emptyCache = Files.createTempDirectory("kcadm-empty-cache");
try {
CommandLine cli = createCliWithCacheDir(emptyCache, "http://unknown-server:8080");
String help = cli.getUsageMessage();
assertTrue("should fall back to bundled 'client': " + help, help.contains("client"));
assertFalse("cached 'widget' should not appear: " + help, help.contains("widget"));
} finally {
deleteRecursively(emptyCache);
}
}
@Test
public void fallsBackToBundledWhenConfigHasNoServerUrl() throws IOException {
Path emptyCache = Files.createTempDirectory("kcadm-empty-cache");
try {
CommandLine cli = createCliWithCacheDir(emptyCache, null);
String help = cli.getUsageMessage();
assertTrue("should fall back to bundled 'client': " + help, help.contains("client"));
} finally {
deleteRecursively(emptyCache);
}
}
private static void setUpCachedDescriptor() throws IOException {
KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(tempDir);
cache.save(TEST_SERVER, widgetDescriptor());
Path configFile = tempDir.resolve(CONFIG_FILE_NAME);
ConfigData config = new ConfigData();
config.setServerUrl(TEST_SERVER);
Files.writeString(configFile, JsonSerialization.writeValueAsPrettyString(config));
}
private CommandLine createCli() {
return createCliWithCacheDir(tempDir, TEST_SERVER);
}
private static CommandLine createCliWithCacheDir(Path cacheDir, String serverUrl) {
try {
Path configFile = cacheDir.resolve(CONFIG_FILE_NAME);
ConfigData config = new ConfigData();
if (serverUrl != null) {
config.setServerUrl(serverUrl);
}
Files.writeString(configFile, JsonSerialization.writeValueAsPrettyString(config));
FileConfigHandler.setConfigFile(configFile.toString());
ConfigUtil.setHandler(new FileConfigHandler());
} catch (IOException e) {
throw new RuntimeException("Failed to set up config", e);
}
return Globals.createCommandLine(new KcAdmV2Cmd(cacheDir), KcAdmMain.CMD,
new PrintWriter(System.err, true));
}
private List<String> complete(String... args) {
StringWriter sw = new StringWriter();
KcAdmV2Completer.complete(args, new PrintWriter(sw), tempDir);
String output = sw.toString().trim();
if (output.isEmpty()) {
return List.of();
}
return List.of(output.split(System.lineSeparator()));
}
private static void deleteRecursively(Path dir) throws IOException {
try (var paths = Files.walk(dir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(p -> {
try { Files.delete(p); } catch (IOException ignored) {}
});
}
}
private static KcAdmV2CommandDescriptor widgetDescriptor() {
OptionDescriptor opt = new OptionDescriptor();
opt.setName("widget-name");
opt.setFieldName("widgetName");
opt.setType(OptionDescriptor.TYPE_STRING);
opt.setDescription("Name of the widget");
CommandDescriptor listCmd = new CommandDescriptor();
listCmd.setName("list");
listCmd.setResourceName("widget");
listCmd.setHttpMethod("GET");
listCmd.setPath("/admin/api/{realmName}/widgets/{version}");
listCmd.setDescription("List widgets");
listCmd.setOptions(List.of());
CommandDescriptor createCmd = new CommandDescriptor();
createCmd.setName("create");
createCmd.setResourceName("widget");
createCmd.setHttpMethod("POST");
createCmd.setPath("/admin/api/{realmName}/widgets/{version}");
createCmd.setDescription("Create a widget");
createCmd.setOptions(List.of(opt));
ResourceDescriptor resource = new ResourceDescriptor();
resource.setName("widget");
resource.setCommands(List.of(listCmd, createCmd));
KcAdmV2CommandDescriptor descriptor = new KcAdmV2CommandDescriptor();
descriptor.setVersion("99.0.0");
descriptor.setResources(List.of(resource));
return descriptor;
}
}
@@ -140,6 +140,28 @@ public class KcAdmV2CompleterTest {
assertFalse("Should not suggest '--sign-documents'", candidates.contains("--sign-documents"));
}
@Test
public void testConfigShowsSubcommands() {
List<String> candidates = complete("config", "");
assertTrue("Should suggest 'credentials'", candidates.contains("credentials"));
assertTrue("Should suggest 'openapi'", candidates.contains("openapi"));
}
@Test
public void testConfigOpenApiOptionsInAutocomplete() {
List<String> candidates = complete("config", "openapi", "--");
assertTrue("Should suggest '--config': " + candidates, candidates.contains("--config"));
assertTrue("Should suggest '--help': " + candidates, candidates.contains("--help"));
}
@Test
public void testConfigCredentialsShowsOpenApiUrlOption() {
List<String> candidates = complete("config", "credentials", "--");
assertTrue("Should suggest '--openapi-url': " + candidates, candidates.contains("--openapi-url"));
assertTrue("Should suggest '--server': " + candidates, candidates.contains("--server"));
assertTrue("Should suggest '--realm': " + candidates, candidates.contains("--realm"));
}
@Test
public void testUnknownSubcommandStaysAtCurrentLevel() {
List<String> candidates = complete("client", "nonexistent", "");
@@ -0,0 +1,189 @@
package org.keycloak.client.admin.cli.commands.v2;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.CommandDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.ResourceDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache;
import org.keycloak.client.cli.config.FileConfigHandler;
import org.keycloak.client.cli.util.ConfigUtil;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.DESCRIPTOR_PREFIX;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class KcAdmV2DescriptorCacheTest {
private Path tempDir;
private KcAdmV2DescriptorCache cache;
@Before
public void setUp() throws IOException {
ConfigUtil.setHandler(null);
FileConfigHandler.setConfigFile(null);
tempDir = Files.createTempDirectory("kcadm-cache-test");
cache = new KcAdmV2DescriptorCache(tempDir);
}
@After
public void tearDown() throws IOException {
if (tempDir != null && Files.exists(tempDir)) {
deleteRecursively(tempDir);
}
ConfigUtil.setHandler(null);
FileConfigHandler.setConfigFile(null);
}
@Test
public void loadReturnsNullForUnknownServer() {
assertNull(cache.loadForServer("http://unknown:8080"));
}
@Test
public void saveAndLoadRoundTrip() {
KcAdmV2CommandDescriptor descriptor = descriptorWithVersion("26.0.0");
cache.save("http://localhost:8080", descriptor);
KcAdmV2CommandDescriptor loaded = cache.loadForServer("http://localhost:8080");
assertNotNull(loaded);
assertEquals("26.0.0", loaded.getVersion());
assertEquals(1, loaded.getResources().size());
assertEquals("client", loaded.getResources().get(0).getName());
}
@Test
public void twoServersWithSameVersionShareOneDescriptorFile() {
KcAdmV2CommandDescriptor descriptor = descriptorWithVersion("26.0.0");
cache.save("http://server-a:8080", descriptor);
cache.save("http://server-b:8080", descriptor);
KcAdmV2CommandDescriptor loadedA = cache.loadForServer("http://server-a:8080");
KcAdmV2CommandDescriptor loadedB = cache.loadForServer("http://server-b:8080");
assertNotNull(loadedA);
assertNotNull(loadedB);
assertEquals("26.0.0", loadedA.getVersion());
assertEquals("26.0.0", loadedB.getVersion());
long descriptorFileCount = countDescriptorFiles();
assertEquals("two servers of same version should share one descriptor file",
1, descriptorFileCount);
}
@Test
public void differentVersionsCreateSeparateDescriptorFiles() {
cache.save("http://server-a:8080", descriptorWithVersion("26.0.0"));
cache.save("http://server-b:8080", descriptorWithVersion("27.0.0-SNAPSHOT"));
assertEquals(2, countDescriptorFiles());
assertEquals("26.0.0", cache.loadForServer("http://server-a:8080").getVersion());
assertEquals("27.0.0-SNAPSHOT", cache.loadForServer("http://server-b:8080").getVersion());
}
@Test
public void saveUpdatesVersionWhenServerUpgraded() {
cache.save("http://localhost:8080", descriptorWithVersion("26.0.0"));
assertEquals("26.0.0", cache.loadForServer("http://localhost:8080").getVersion());
assertEquals(1, countDescriptorFiles());
cache.save("http://localhost:8080", descriptorWithVersion("27.0.0"));
assertEquals("27.0.0", cache.loadForServer("http://localhost:8080").getVersion());
assertEquals("old descriptor file should be removed", 1, countDescriptorFiles());
}
@Test
public void saveKeepsOldDescriptorIfReferencedByOtherServer() {
cache.save("http://server-a:8080", descriptorWithVersion("26.0.0"));
cache.save("http://server-b:8080", descriptorWithVersion("26.0.0"));
assertEquals(1, countDescriptorFiles());
cache.save("http://server-a:8080", descriptorWithVersion("27.0.0"));
assertEquals("old descriptor kept because server-b still uses it",
2, countDescriptorFiles());
}
@Test
public void versionIsSanitizedInFilename() {
String maliciousVersion = "../../etc/passwd";
Path sanitizedFile = tempDir.resolve(DESCRIPTOR_PREFIX + "______etc_passwd.json");
assertFalse("sanitized file should not exist before save", Files.exists(sanitizedFile));
cache.save("http://localhost:8080", descriptorWithVersion(maliciousVersion));
assertTrue("descriptor should be saved with sanitized filename", Files.exists(sanitizedFile));
assertNotNull(cache.loadForServer("http://localhost:8080"));
}
@Test
public void loadReturnsNullWhenCacheDirDoesNotExist() throws IOException {
deleteRecursively(tempDir);
assertNull(cache.loadForServer("http://localhost:8080"));
}
@Test
public void saveCreatesDirectoryIfNeeded() {
Path nested = tempDir.resolve("sub").resolve("dir");
KcAdmV2DescriptorCache nestedCache = new KcAdmV2DescriptorCache(nested);
nestedCache.save("http://localhost:8080", descriptorWithVersion("26.0.0"));
assertTrue(Files.isDirectory(nested));
assertNotNull(nestedCache.loadForServer("http://localhost:8080"));
}
private KcAdmV2CommandDescriptor descriptorWithVersion(String version) {
CommandDescriptor cmd = new CommandDescriptor();
cmd.setName("list");
cmd.setResourceName("client");
cmd.setHttpMethod("GET");
cmd.setPath("/admin/api/{realmName}/clients/{version}");
cmd.setDescription("List clients");
cmd.setOptions(List.of());
ResourceDescriptor resource = new ResourceDescriptor();
resource.setName("client");
resource.setCommands(List.of(cmd));
KcAdmV2CommandDescriptor descriptor = new KcAdmV2CommandDescriptor();
descriptor.setVersion(version);
descriptor.setResources(List.of(resource));
return descriptor;
}
private long countDescriptorFiles() {
try (var files = Files.list(tempDir)) {
return files
.filter(p -> p.getFileName().toString().startsWith(DESCRIPTOR_PREFIX))
.count();
} catch (IOException e) {
throw new RuntimeException("Failed to list descriptor files", e);
}
}
private static void deleteRecursively(Path dir) throws IOException {
try (var paths = Files.walk(dir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(p -> {
try { Files.delete(p); } catch (IOException ignored) {}
});
}
}
}
@@ -8,6 +8,7 @@ import java.util.List;
import org.keycloak.client.admin.cli.KcAdmMain;
import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd;
import org.keycloak.client.cli.common.BaseConfigCredentialsCmd;
import org.keycloak.client.cli.common.Globals;
import org.junit.Test;
@@ -28,6 +29,12 @@ public class KcAdmV2HelpTest {
assertTrue("Help should list 'config' command", help.contains("config"));
}
@Test
public void testHelpShowsCompletionInstructions() {
String help = createCli().getUsageMessage();
assertTrue("Help should mention tab completion setup", help.contains("completion"));
}
@Test
public void testHelpShowsConsistentDescriptions() {
String help = createCli().getUsageMessage();
@@ -383,6 +390,31 @@ public class KcAdmV2HelpTest {
}
}
@Test
public void testConfigOpenApiHelpShowsUrl() {
CommandLine cli = createCli();
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
cli.setOut(new PrintWriter(out));
cli.setErr(new PrintWriter(err));
int exitCode = cli.execute("config", "openapi", "--help");
assertEquals("--help on config openapi should exit with 0", 0, exitCode);
String output = out.toString();
assertTrue("should show <source> parameter: " + output, output.contains("<source>"));
assertTrue("should show --config option: " + output, output.contains("--config"));
assertTrue("should mention 'config credentials': " + output, output.contains("config credentials"));
}
@Test
public void testConfigCredentialsHelpShowsOpenApiUrl() {
CommandLine cli = createCli();
String help = ((BaseConfigCredentialsCmd) cli.getSubcommands().get("config")
.getSubcommands().get("credentials").getCommand()).help();
assertTrue("should show --openapi-url option: " + help, help.contains("--openapi-url"));
assertTrue("should show --v2 in command: " + help, help.contains("--v2"));
}
private String getVariantHelp(String command, String variant) {
CommandLine cli = createCli();
return cli.getSubcommands().get("client").getSubcommands().get(command)
@@ -0,0 +1,61 @@
package org.keycloak.tests.admin.cli.v2;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Path;
import org.keycloak.client.admin.cli.KcAdmMain;
import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd;
import org.keycloak.client.cli.common.Globals;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.server.KeycloakUrls;
import picocli.CommandLine;
abstract class AbstractKcAdmV2CLITest {
@InjectKeycloakUrls
KeycloakUrls keycloakUrls;
protected CommandResult kcAdmV2Cmd(Path cacheDir, String configFile, String... args) {
String[] fullArgs = new String[args.length + 2];
System.arraycopy(args, 0, fullArgs, 0, args.length);
fullArgs[args.length] = "--config";
fullArgs[args.length + 1] = configFile;
KcAdmV2Cmd cmd = cacheDir != null ? new KcAdmV2Cmd(cacheDir, fullArgs) : new KcAdmV2Cmd(fullArgs);
return execute(cmd, fullArgs);
}
protected CommandResult kcAdmV2CmdNoConfig(String... args) {
String[] fullArgs = new String[args.length + 1];
System.arraycopy(args, 0, fullArgs, 0, args.length);
fullArgs[args.length] = "--no-config";
return execute(new KcAdmV2Cmd(fullArgs), fullArgs);
}
protected CommandResult kcAdmV2CmdRaw(String... args) {
return execute(new KcAdmV2Cmd(args), args);
}
private CommandResult execute(KcAdmV2Cmd cmd, String[] args) {
CommandLine cli = Globals.createCommandLine(cmd, KcAdmMain.CMD, new PrintWriter(System.err, true));
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
cli.setOut(new PrintWriter(out));
cli.setErr(new PrintWriter(err));
int exitCode = cli.execute(args);
return new CommandResult(exitCode, out.toString(), err.toString());
}
protected String managementBaseUrl() {
String metricUrl = keycloakUrls.getMetric();
return metricUrl.substring(0, metricUrl.lastIndexOf("/metrics"));
}
record CommandResult(int exitCode, String out, String err) {
}
}
@@ -1,18 +1,13 @@
package org.keycloak.tests.admin.cli.v2;
import java.io.File;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import org.keycloak.client.admin.cli.KcAdmMain;
import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd;
import org.keycloak.client.cli.common.Globals;
import org.keycloak.client.cli.config.FileConfigHandler;
import org.keycloak.client.cli.util.HttpUtil;
import org.keycloak.common.Profile;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.annotations.TestSetup;
@@ -20,13 +15,12 @@ import org.keycloak.testframework.config.Config;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.testframework.server.KeycloakUrls;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import picocli.CommandLine;
import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.REGISTRY_FILENAME;
import static org.keycloak.client.cli.config.FileConfigHandler.setConfigFile;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -34,16 +28,14 @@ import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertFalse;
@KeycloakIntegrationTest(config = KcAdmV2ClientCLITest.V2ApiServerConfig.class)
public class KcAdmV2ClientCLITest {
public class KcAdmV2ClientCLITest extends AbstractKcAdmV2CLITest {
@InjectRealm
ManagedRealm realm;
@InjectKeycloakUrls
KeycloakUrls keycloakUrls;
@TempDir
static File tempDir;
@@ -583,6 +575,33 @@ public class KcAdmV2ClientCLITest {
assertThat(getResult.err(), containsString("Could not find client"));
}
@Test
void testLoginAutoFetchFailsGracefully() {
// This server does NOT have OPENAPI enabled, so auto-fetch on login should fail gracefully
Path cacheDir = tempDir.toPath().resolve("auto-fetch-fail");
String autoFetchConfigFile = new File(tempDir, "auto-fetch-fail.config").getAbsolutePath();
HttpUtil.clearHttpClient();
CommandResult result = kcAdmV2Cmd(cacheDir, autoFetchConfigFile,
"config", "credentials",
"--server", keycloakUrls.getBase(),
"--realm", "master",
"--client", Config.getAdminClientId(),
"--secret", Config.getAdminClientSecret());
assertThat("login should succeed even when auto-fetch fails: " + result.err(), result.exitCode(), is(0));
assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"no registry should be created when auto-fetch fails");
// Warning should explain: what failed, why it matters, and how to fix it
assertThat("should mention fetch failure: " + result.err(),
result.err(), containsString("Failed to fetch OpenAPI"));
assertThat("should explain why it matters: " + result.err(),
result.err(), containsString("CLI commands may not match your server version"));
assertThat("should suggest manual fallback: " + result.err(),
result.err(), containsString("config openapi"));
}
private String createClientWithAllParams(String clientId) {
CommandResult result = kcAdmV2Cmd("client", "create", "oidc",
"--client-id", clientId,
@@ -613,48 +632,7 @@ public class KcAdmV2ClientCLITest {
}
private CommandResult kcAdmV2Cmd(String... args) {
CommandLine cli = Globals.createCommandLine(new KcAdmV2Cmd(), KcAdmMain.CMD, new PrintWriter(System.err, true));
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
cli.setOut(new PrintWriter(out));
cli.setErr(new PrintWriter(err));
String[] fullArgs = new String[args.length + 2];
System.arraycopy(args, 0, fullArgs, 0, args.length);
fullArgs[args.length] = "--config";
fullArgs[args.length + 1] = configFilePath;
int exitCode = cli.execute(fullArgs);
return new CommandResult(exitCode, out.toString(), err.toString());
}
private CommandResult kcAdmV2CmdRaw(String... args) {
CommandLine cli = Globals.createCommandLine(new KcAdmV2Cmd(), KcAdmMain.CMD, new PrintWriter(System.err, true));
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
cli.setOut(new PrintWriter(out));
cli.setErr(new PrintWriter(err));
int exitCode = cli.execute(args);
return new CommandResult(exitCode, out.toString(), err.toString());
}
private CommandResult kcAdmV2CmdNoConfig(String... args) {
CommandLine cli = Globals.createCommandLine(new KcAdmV2Cmd(), KcAdmMain.CMD, new PrintWriter(System.err, true));
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
cli.setOut(new PrintWriter(out));
cli.setErr(new PrintWriter(err));
String[] fullArgs = new String[args.length + 1];
System.arraycopy(args, 0, fullArgs, 0, args.length);
fullArgs[args.length] = "--no-config";
int exitCode = cli.execute(fullArgs);
return new CommandResult(exitCode, out.toString(), err.toString());
return kcAdmV2Cmd(null, configFilePath, args);
}
private String getTokenFromConfig() {
@@ -676,9 +654,6 @@ public class KcAdmV2ClientCLITest {
}
}
record CommandResult(int exitCode, String out, String err) {
}
public static class V2ApiServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
@@ -0,0 +1,254 @@
package org.keycloak.tests.admin.cli.v2;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.keycloak.client.admin.cli.v2.KcAdmV2Cmd;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.CommandDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.OptionDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2CommandDescriptor.ResourceDescriptor;
import org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache;
import org.keycloak.client.cli.util.Headers;
import org.keycloak.client.cli.util.HeadersBody;
import org.keycloak.client.cli.util.HeadersBodyStatus;
import org.keycloak.client.cli.util.HttpUtil;
import org.keycloak.common.Profile;
import org.keycloak.config.OpenApiOptions;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.config.Config;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.REGISTRY_FILENAME;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = KcAdmV2OpenApiFetchCLITest.OpenApiServerConfig.class)
public class KcAdmV2OpenApiFetchCLITest extends AbstractKcAdmV2CLITest {
@TempDir
File tempDir;
@Test
void testWrongOpenApiUrlFailsHard() {
Path cacheDir = cacheDir("wrong-url");
String configFile = configFile("wrong-url.config");
login(cacheDir, configFile);
String wrongUrl = keycloakUrls.getBase() + "/wrong/openapi";
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "openapi", wrongUrl);
assertThat("should fail when OpenAPI URL is wrong: " + result.err(),
result.exitCode(), is(not(0)));
assertThat("error should mention the URL: " + result.err(),
result.err(), containsString(wrongUrl));
}
@Test
void testOpenApiFetched() throws Exception {
Path cacheDir = cacheDir("fetched");
String configFile = configFile("fetched.config");
CommandResult loginResult = login(cacheDir, configFile);
assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should exist after login (auto-fetched)");
// we must assert that user is informed where we took the OpenAPI document from
// as we assume that on the same base URL but with a different port,
// there runs our Keycloak server management interface, but theoretically, it could be some other service
assertThat("auto-fetch should report the URL it fetched from: " + loginResult.err(),
loginResult.err(), containsString("fetched from "));
// Delete the registry to simulate auto-fetch failure, then verify explicit fetch recovers
Files.delete(cacheDir.resolve(REGISTRY_FILENAME));
assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should be gone after delete");
CommandResult fetchResult = fetchOpenApi(cacheDir, configFile);
assertThat("config openapi should succeed: " + fetchResult.err(),
fetchResult.exitCode(), is(0));
assertThat("should confirm descriptor was cached: " + fetchResult.err(),
fetchResult.err(), containsString("OpenAPI descriptor cached for"));
assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should be recreated by explicit config openapi");
CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c");
assertThat("client list should succeed: " + listResult.err(),
listResult.exitCode(), is(0));
assertThat("should return JSON array: " + listResult.out(),
listResult.out().trim(), startsWith("["));
}
@Test
void testServerSpecificOpenApiUsed() {
Path cacheDir = cacheDir("server-specific");
String configFile = configFile("server-specific.config");
login(cacheDir, configFile);
KcAdmV2CommandDescriptor descriptor = KcAdmV2Cmd.loadBundledDescriptor();
OptionDescriptor opt = new OptionDescriptor();
opt.setName("whatever");
opt.setFieldName("whatever");
opt.setType(OptionDescriptor.TYPE_STRING);
CommandDescriptor fineTestCmd = new CommandDescriptor();
fineTestCmd.setName("fine-test-operation");
fineTestCmd.setResourceName("client");
fineTestCmd.setHttpMethod("POST");
fineTestCmd.setPath("/no-such-endpoint-at-all");
fineTestCmd.setDescription("A test operation that does not exist on the server");
fineTestCmd.setOptions(List.of(opt));
ResourceDescriptor clientResource = descriptor.getResources().stream()
.filter(r -> "client".equals(r.getName()))
.findFirst()
.orElseThrow();
List<CommandDescriptor> commands = new ArrayList<>(clientResource.getCommands());
commands.add(fineTestCmd);
clientResource.setCommands(commands);
KcAdmV2DescriptorCache cache = new KcAdmV2DescriptorCache(cacheDir);
cache.save(keycloakUrls.getBase(), descriptor);
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"client", "fine-test-operation", "--whatever", "test");
assertThat("server should reject the fake operation: " + result.err(),
result.exitCode(), is(not(0)));
assertThat("server should reject the unknown operation: " + result.err(),
result.err(), containsString("Unable to find matching target resource method"));
}
@Test
void testOpenApiFetchedFromFile() throws Exception {
Path cacheDir = cacheDir("from-file");
String configFile = configFile("from-file.config");
login(cacheDir, configFile);
// Fetch raw OpenAPI JSON from the server
String openApiUrl = managementBaseUrl() + "/openapi";
HeadersBodyStatus response = HttpUtil.doRequest("get", openApiUrl, new HeadersBody(new Headers()));
response.checkSuccess();
// Save to a local file
Path openApiFile = tempDir.toPath().resolve("openapi-spec.json");
Files.write(openApiFile, response.getBody().readAllBytes());
// Delete the registry so we can verify file loading recreates it
Files.delete(cacheDir.resolve(REGISTRY_FILENAME));
assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should be gone after delete");
// Load from file via config openapi
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "openapi", openApiFile.toString());
assertThat("config openapi from file should succeed: " + result.err(),
result.exitCode(), is(0));
assertThat("should confirm descriptor was cached: " + result.err(),
result.err(), containsString("OpenAPI descriptor cached for"));
assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should be recreated by config openapi from file");
// Verify the cached descriptor is usable
CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c");
assertThat("client list should succeed with file-loaded descriptor: " + listResult.err(),
listResult.exitCode(), is(0));
assertThat("should return JSON array: " + listResult.out(),
listResult.out().trim(), startsWith("["));
}
@Test
void testOpenApiInvalidFileContent() throws Exception {
Path cacheDir = cacheDir("invalid-content");
String configFile = configFile("invalid-content.config");
login(cacheDir, configFile);
Path invalidFile = tempDir.toPath().resolve("not-openapi.yaml");
Files.writeString(invalidFile, """
openapi: 3.0.0
info:
title: not real
""");
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "openapi", invalidFile.toString());
assertThat("should fail for invalid OpenAPI content: " + result.err(),
result.exitCode(), is(not(0)));
assertThat("error should mention the source: " + result.err(),
result.err(), containsString(invalidFile.toString()));
assertThat("error should explain why: " + result.err(),
result.err(), containsString("no resources"));
}
@Test
void testOpenApiInvalidSource() {
Path cacheDir = cacheDir("invalid-source");
String configFile = configFile("invalid-source.config");
login(cacheDir, configFile);
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "openapi", "/nonexistent/openapi.json");
assertThat("should fail for invalid source: " + result.err(),
result.exitCode(), is(not(0)));
assertThat("error should mention it's not a URL and file not found: " + result.err(),
result.err(), containsString("no file found at"));
}
private CommandResult login(Path cacheDir, String configFile) {
HttpUtil.clearHttpClient();
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "credentials",
"--server", keycloakUrls.getBase(),
"--realm", "master",
"--client", Config.getAdminClientId(),
"--secret", Config.getAdminClientSecret());
assertThat("login should succeed: " + result.err(), result.exitCode(), is(0));
return result;
}
private CommandResult fetchOpenApi(Path cacheDir, String configFile) {
String openApiUrl = managementBaseUrl() + "/openapi";
return kcAdmV2Cmd(cacheDir, configFile, "config", "openapi", openApiUrl);
}
private Path cacheDir(String name) {
return tempDir.toPath().resolve(name);
}
private String configFile(String name) {
return new File(tempDir, name).getAbsolutePath();
}
public static class OpenApiServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.CLIENT_ADMIN_API_V2, Profile.Feature.OPENAPI)
.option(OpenApiOptions.OPENAPI_ENABLED.getKey(), "true");
}
}
}
@@ -0,0 +1,125 @@
package org.keycloak.tests.admin.cli.v2;
import java.io.File;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import org.keycloak.client.cli.util.HttpUtil;
import org.keycloak.common.Profile;
import org.keycloak.config.ManagementOptions;
import org.keycloak.config.OpenApiOptions;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.config.Config;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.keycloak.client.admin.cli.v2.KcAdmV2DescriptorCache.REGISTRY_FILENAME;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = KcAdmV2OpenApiUrlCLITest.NonDefaultManagementPortConfig.class)
public class KcAdmV2OpenApiUrlCLITest extends AbstractKcAdmV2CLITest {
private static final String NON_DEFAULT_MANAGEMENT_PORT = "9004";
@TempDir
File tempDir;
@Test
void testLoginWithOpenApiUrl() {
Path cacheDir = tempDir.toPath().resolve("openapi-url");
String configFile = new File(tempDir, "openapi-url.config").getAbsolutePath();
HttpUtil.clearHttpClient();
// Login with --openapi-url pointing to the actual (non-default) management URL
String openApiUrl = managementOpenApiUrl();
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "credentials",
"--server", keycloakUrls.getBase(),
"--realm", "master",
"--client", Config.getAdminClientId(),
"--secret", Config.getAdminClientSecret(),
"--openapi-url", openApiUrl);
assertThat("login should succeed: " + result.err(), result.exitCode(), is(0));
assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should exist — auto-fetch should use the provided --openapi-url");
assertThat("should report the URL it fetched from: " + result.err(),
result.err(), containsString("fetched from " + openApiUrl));
// Verify the auto-fetched descriptor is usable
CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c");
assertThat("client list should succeed: " + listResult.err(),
listResult.exitCode(), is(0));
assertThat("should return JSON array: " + listResult.out(),
listResult.out().trim(), startsWith("["));
}
@Test
void testLoginWithoutOpenApiUrlFailsOnNonDefaultPort() {
Path cacheDir = tempDir.toPath().resolve("no-openapi-url");
String configFile = new File(tempDir, "no-openapi-url.config").getAbsolutePath();
HttpUtil.clearHttpClient();
// Login WITHOUT --openapi-url — auto-fetch tries default port 9000, but server is on NON_DEFAULT_MANAGEMENT_PORT
CommandResult result = kcAdmV2Cmd(cacheDir, configFile,
"config", "credentials",
"--server", keycloakUrls.getBase(),
"--realm", "master",
"--client", Config.getAdminClientId(),
"--secret", Config.getAdminClientSecret());
assertThat("login should succeed even when auto-fetch fails: " + result.err(), result.exitCode(), is(0));
assertFalse(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"no registry should be created — auto-fetch should fail on wrong port");
assertThat("should warn about auto-fetch failure: " + result.err(),
result.err(), containsString("Failed to fetch OpenAPI"));
// Explicit config openapi with the correct URL should recover
String openApiUrl = managementOpenApiUrl();
CommandResult fetchResult = kcAdmV2Cmd(cacheDir, configFile,
"config", "openapi", openApiUrl);
assertThat("explicit config openapi should succeed: " + fetchResult.err(),
fetchResult.exitCode(), is(0));
assertTrue(Files.exists(cacheDir.resolve(REGISTRY_FILENAME)),
"registry should exist after explicit config openapi");
// Verify the descriptor is usable
CommandResult listResult = kcAdmV2Cmd(cacheDir, configFile, "client", "list", "-c");
assertThat("client list should succeed: " + listResult.err(),
listResult.exitCode(), is(0));
assertThat("should return JSON array: " + listResult.out(),
listResult.out().trim(), startsWith("["));
}
private String managementOpenApiUrl() {
// FIXME: drop this when https://github.com/keycloak/keycloak/issues/47673 is fixed
try {
URL managementUrl = new URL(managementBaseUrl());
return managementUrl.getProtocol() + "://" + managementUrl.getHost()
+ ":" + NON_DEFAULT_MANAGEMENT_PORT + "/openapi";
} catch (Exception e) {
throw new AssertionError("Could not parse management base URL", e);
}
}
public static class NonDefaultManagementPortConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.CLIENT_ADMIN_API_V2, Profile.Feature.OPENAPI)
.option(OpenApiOptions.OPENAPI_ENABLED.getKey(), "true")
.option(ManagementOptions.HTTP_MANAGEMENT_PORT.getKey(), NON_DEFAULT_MANAGEMENT_PORT);
}
}
}