mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
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:
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+89
-5
@@ -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);
|
||||
}
|
||||
|
||||
+12
-1
@@ -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)) {
|
||||
|
||||
+98
@@ -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();
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -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) {
|
||||
|
||||
+136
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+216
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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", "");
|
||||
|
||||
+189
@@ -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) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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)
|
||||
|
||||
+61
@@ -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) {
|
||||
}
|
||||
}
|
||||
+31
-56
@@ -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) {
|
||||
|
||||
+254
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user