Skip to content

Commit 59390b8

Browse files
authored
Merge branch 'antalya-26.3' into expand-replicated-partition-exports-columns
2 parents 15a0608 + 87b83dc commit 59390b8

96 files changed

Lines changed: 2871 additions & 1311 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ci/docker/integration/runner/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ org.apache.hudi:hudi-spark3.5-bundle_2.12:1.0.1,\
8080
org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.4.3,\
8181
org.apache.hadoop:hadoop-aws:3.3.4,\
8282
com.amazonaws:aws-java-sdk-bundle:1.12.262,\
83+
org.apache.hadoop:hadoop-azure:3.3.4,\
84+
com.microsoft.azure:azure-storage:8.6.6,\
8385
org.apache.spark:spark-avro_2.12:3.5.1"\
8486
&& /spark-3.5.5-bin-hadoop3/bin/spark-shell --packages "$packages" \
8587
&& find /root/.ivy2/ -name '*.jar' -exec ln -sf {} /spark-3.5.5-bin-hadoop3/jars/ \;

docs/en/interfaces/cli.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,8 @@ All command-line options can be specified directly on the command line or as def
836836
| `-d [ --database ] <database>` | Select the database to default to for this connection. | The current database from the server settings (`default` by default) |
837837
| `-h [ --host ] <host>` | The hostname of the ClickHouse server to connect to. Can either be a hostname or an IPv4 or IPv6 address. Multiple hosts can be passed via multiple arguments. | `localhost` |
838838
| `--jwt <value>` | Use JSON Web Token (JWT) for authentication. <br/><br/>Server JWT authorization is only available in ClickHouse Cloud. | - |
839+
| `--jwt-command <command>` | Shell command whose stdout is used as the JWT. Invoked on the first connect, before reconnects when the cached JWT is near expiry, and after the server rejects the cached token. See [`--jwt-command` details](#jwt-command-details) below. | - |
840+
| `--jwt-command-timeout <seconds>` | Timeout for `--jwt-command`. Also settable as `<jwt-command-timeout>` in the config file; CLI wins. | `30` |
839841
| `--login[=<mode>]` | Authenticate via OAuth2. Bare `--login` (no `=<mode>`) triggers ClickHouse Cloud automatic login — the provider is inferred from the server. To authenticate against a custom OpenID Connect provider, supply a `mode` and `--oauth-credentials`: `--login=browser` runs the Authorization Code + PKCE flow (opens a browser), `--login=device` runs the Device Authorization flow (prints a URL and short code — no browser needed). | - |
840842
| `--oauth-credentials <path>` | Path to an OAuth2 credentials JSON file (Google Cloud Console format). Required when using `--login=browser` or `--login=device` with a custom OpenID Connect provider. See [OAuth credentials file format](#oauth-credentials-file) below. Refresh tokens are cached in `~/.clickhouse-client/oauth_cache.json` (mode `0600`). | `~/.clickhouse-client/oauth_client.json` |
841843
| `--no-warnings` | Disable showing warnings from `system.warnings` when the client connects to the server. | - |
@@ -880,6 +882,18 @@ The default path is `~/.clickhouse-client/oauth_client.json`. Override it with `
880882

881883
After a successful login the obtained refresh token is cached in `~/.clickhouse-client/oauth_cache.json` (file mode `0600`). Subsequent runs reuse the cached token silently and only open the browser or print a device code when the refresh token has expired.
882884

885+
### `--jwt-command` details {#jwt-command-details}
886+
887+
The command is executed via `/bin/sh -c`. Stdout is taken as the JWT (one trailing newline stripped); any human-facing output (prompts, URLs, device codes) must go to stderr — it is forwarded unbuffered to the client's stderr. Stdin is closed.
888+
889+
The command runs on the first connect to obtain the initial token. On subsequent (re)connects the client reuses the cached token; it re-invokes the command only when (a) the cached token parses as a JWT whose `exp` claim is within 30 seconds, or (b) the server rejects the cached token with an authentication failure, in which case the client refetches the token and retries the handshake once. Opaque tokens (anything that does not parse as a JWT) and JWTs without a usable `exp` claim are reused until the server rejects them — caching/refresh in those cases is the script's responsibility.
890+
891+
```bash
892+
clickhouse-client --jwt-command "curl -sS https://idp.example/token | jq -r .access_token"
893+
```
894+
895+
Cannot be combined with `--jwt`, `--login`, or a non-default `--user`. Non-zero exit, empty output, or exceeding `--jwt-command-timeout` (default `30`s, overridable via `<jwt-command-timeout>` in `~/.clickhouse-client/config.xml`) fails authentication. On timeout the entire helper subprocess tree is terminated.
896+
883897
### Query options {#command-line-options-query}
884898

885899
| Option | Description |

programs/client/Client.cpp

Lines changed: 125 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include <Interpreters/Context.h>
2727

2828
#include <Client/JWTProvider.h>
29+
#include <Client/CommandJWTProvider.h>
2930
#include <Client/ClientBaseHelpers.h>
3031
#include <Client/OAuthLogin.h>
3132

@@ -374,10 +375,30 @@ try
374375
}
375376

376377
#if USE_JWT_CPP && USE_SSL
377-
if (config().getBool("cloud_oauth_pending", false) && !config().has("jwt"))
378+
/// Empty-value check; `config().has(k)` returns true for empty XML elements too.
379+
const bool has_jwt_command_value = !config().getString("jwt-command", "").empty();
380+
const bool has_jwt_value = !config().getString("jwt", "").empty();
381+
382+
if (has_jwt_command_value && has_jwt_value)
383+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "jwt-command and jwt cannot both be specified");
384+
385+
if (has_jwt_command_value)
386+
{
387+
int timeout = config().getInt("jwt-command-timeout", DEFAULT_JWT_COMMAND_TIMEOUT_SECONDS);
388+
if (timeout <= 0)
389+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "jwt-command-timeout must be positive, got {}", timeout);
390+
391+
jwt_provider = std::make_shared<CommandJWTProvider>(config().getString("jwt-command"), timeout);
392+
config().setString("jwt", "");
393+
}
394+
395+
if (config().getBool("cloud_oauth_pending", false) && !has_jwt_value && !has_jwt_command_value)
378396
{
379397
login();
380398
}
399+
#else
400+
if (!config().getString("jwt-command", "").empty() || !config().getString("jwt", "").empty())
401+
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is disabled, because ClickHouse is built without JWT or SSL support");
381402
#endif
382403

383404
bool asked_password = false;
@@ -393,10 +414,16 @@ try
393414
{
394415
auto code = e.code();
395416

417+
/// Don't prompt for a password on a JWT auth failure.
418+
const bool jwt_auth_in_use =
419+
!config().getString("jwt", "").empty()
420+
|| !config().getString("jwt-command", "").empty();
421+
396422
bool should_ask_password = !asked_password && is_interactive &&
397423
(code == ErrorCodes::AUTHENTICATION_FAILED || code == ErrorCodes::REQUIRED_PASSWORD) &&
398424
!config().has("password") && !config().getBool("ask-password", false) &&
399-
!config().has("ssh-key-file");
425+
!config().has("ssh-key-file") &&
426+
!jwt_auth_in_use;
400427

401428
if (should_ask_password)
402429
{
@@ -742,6 +769,10 @@ void Client::printHelpMessage(const OptionsDescription & options_description)
742769

743770
void Client::addExtraOptions(OptionsDescription & options_description)
744771
{
772+
static const std::string jwt_command_timeout_help =
773+
"Timeout in seconds for --jwt-command. Default: " + std::to_string(DEFAULT_JWT_COMMAND_TIMEOUT_SECONDS)
774+
+ ". Also configurable as <jwt-command-timeout> in the client config file.";
775+
745776
/// Main commandline options related to client functionality and all parameters from Settings.
746777
options_description.main_description->add_options()
747778
("config,c", po::value<std::string>(), "config-file path (another shorthand)")
@@ -756,6 +787,11 @@ void Client::addExtraOptions(OptionsDescription & options_description)
756787
("ssh-key-passphrase", po::value<std::string>(), "Passphrase for the SSH private key specified by --ssh-key-file.")
757788
("quota_key", po::value<std::string>(), "A string to differentiate quotas when the user have keyed quotas configured on server")
758789
("jwt", po::value<std::string>(), "Use JWT for authentication")
790+
("jwt-command", po::value<std::string>(),
791+
"Shell command whose stdout is used as the JWT. Invoked on the first connect, "
792+
"before reconnects when the cached JWT is near expiry, and after the server "
793+
"rejects the cached token with an authentication failure.")
794+
("jwt-command-timeout", po::value<int>(), jwt_command_timeout_help.c_str())
759795
("one-time-password", po::value<std::string>(), "Time-based one-time password (TOTP) for two-factor authentication")
760796
("login", po::value<std::string>()->implicit_value(""),
761797
"Authenticate via OAuth2. Optional mode: 'browser' (auth-code + PKCE, opens browser) "
@@ -931,75 +967,108 @@ void Client::processOptions(
931967
config().setString("jwt", options["jwt"].as<std::string>());
932968
config().setString("user", "");
933969
}
970+
if (options.contains("jwt-command-timeout"))
971+
config().setInt("jwt-command-timeout", options["jwt-command-timeout"].as<int>());
972+
973+
if (options.contains("jwt-command"))
974+
{
975+
#if USE_JWT_CPP && USE_SSL
976+
if (options.contains("jwt"))
977+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "--jwt-command and --jwt cannot both be specified");
978+
if (options.contains("login"))
979+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "--jwt-command and --login cannot both be specified");
980+
if (!options["user"].defaulted())
981+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "User and JWT flags can't be specified together");
982+
983+
/// Defer execution to Client::main, after processConfig has loaded the XML config.
984+
/// Reading config().getInt("jwt-command-timeout", ...) here would miss the XML value.
985+
config().setString("jwt-command", options["jwt-command"].as<std::string>());
986+
config().setString("user", "");
987+
#else
988+
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "JWT is disabled, because ClickHouse is built without JWT or SSL support");
989+
#endif
990+
}
934991
if (options.count("oauth-credentials") && !options.count("login"))
935992
throw Exception(
936993
ErrorCodes::BAD_ARGUMENTS,
937994
"--oauth-credentials requires --login=browser or --login=device");
938995

939996
if (options.count("login"))
940997
{
941-
/// Reject mixed JWT + --login from any source. The --login branch below
942-
/// ends up calling config().setString("jwt", jwt_provider->getJWT()),
943-
/// which would silently overwrite a JWT supplied via --jwt or via the
944-
/// XML config file. config().has("jwt") covers both: CLI --jwt was
945-
/// already copied into config() above, and a <jwt> element in
946-
/// ~/.clickhouse-client/config.xml is loaded into config() at startup.
947-
if (config().has("jwt"))
948-
throw Exception(
949-
ErrorCodes::BAD_ARGUMENTS,
950-
"--login cannot be combined with a JWT (provided via --jwt or in the config file)");
951-
952-
const std::string login_mode = options["login"].as<std::string>();
953-
if (!login_mode.empty() && login_mode != "browser" && login_mode != "device")
954-
throw Exception(
955-
ErrorCodes::BAD_ARGUMENTS,
956-
"--login value must be 'browser' or 'device', got '{}'",
957-
login_mode);
998+
bool defer_to_existing_jwt = false;
958999

9591000
#if USE_JWT_CPP && USE_SSL
960-
if (!options["user"].defaulted())
961-
throw Exception(ErrorCodes::BAD_ARGUMENTS, "--user and --login cannot both be specified");
962-
963-
// Bare --login (empty mode, including auto-added for *.clickhouse.cloud) → cloud path.
964-
// Explicit --login=browser or --login=device (or --oauth-credentials) → credentials-file
965-
// OIDC path. This prevents the credentials file from hijacking the cloud auto-login.
966-
const bool use_credentials_file
967-
= !login_mode.empty()
968-
|| options.count("oauth-credentials");
1001+
/// --login would overwrite config["jwt"]; reject if a JWT is already configured.
1002+
/// Auto-added --login (cloud endpoint, no CLI auth) defers silently to it instead.
1003+
const bool jwt_already_configured
1004+
= !config().getString("jwt", "").empty()
1005+
|| !config().getString("jwt-command", "").empty();
9691006

970-
if (use_credentials_file)
1007+
if (jwt_already_configured)
9711008
{
972-
const char * home_path_cstr = getenv("HOME"); // NOLINT(concurrency-mt-unsafe)
973-
const std::string default_creds_path = home_path_cstr
974-
? std::string(home_path_cstr) + "/.clickhouse-client/oauth_client.json"
975-
: "";
976-
977-
const std::string creds_path = options.count("oauth-credentials")
978-
? options["oauth-credentials"].as<std::string>()
979-
: default_creds_path;
980-
981-
auto creds = loadOAuthCredentials(creds_path);
982-
const auto mode = (login_mode == "device") ? OAuthFlowMode::Device : OAuthFlowMode::AuthCode;
983-
984-
// createOAuthJWTProvider runs the initial flow (trying the cached
985-
// refresh token first) and returns a provider that Connection can
986-
// call to refresh the id_token transparently during long sessions.
987-
jwt_provider = createOAuthJWTProvider(creds, mode);
988-
config().setString("jwt", jwt_provider->getJWT());
989-
config().setString("user", "");
1009+
if (!login_was_auto_added)
1010+
throw Exception(
1011+
ErrorCodes::BAD_ARGUMENTS,
1012+
"--login cannot be combined with a JWT (provided via --jwt, --jwt-command, or in the config file)");
1013+
login_was_auto_added = false;
1014+
defer_to_existing_jwt = true;
9901015
}
991-
else
1016+
#endif
1017+
1018+
if (!defer_to_existing_jwt)
9921019
{
993-
// Cloud-specific login path — bare --login, including auto-added for
994-
// *.clickhouse.cloud endpoints. Use a separate config key so that
995-
// argsToConfig() overwriting config["login"] with the raw string value
996-
// cannot cause getBool("login") to throw in main().
997-
config().setBool("cloud_oauth_pending", true);
998-
config().setString("user", "");
999-
}
1020+
const std::string login_mode = options["login"].as<std::string>();
1021+
if (!login_mode.empty() && login_mode != "browser" && login_mode != "device")
1022+
throw Exception(
1023+
ErrorCodes::BAD_ARGUMENTS,
1024+
"--login value must be 'browser' or 'device', got '{}'",
1025+
login_mode);
1026+
1027+
#if USE_JWT_CPP && USE_SSL
1028+
if (!options["user"].defaulted())
1029+
throw Exception(ErrorCodes::BAD_ARGUMENTS, "--user and --login cannot both be specified");
1030+
1031+
// Bare --login (empty mode, including auto-added for *.clickhouse.cloud) → cloud path.
1032+
// Explicit --login=browser or --login=device (or --oauth-credentials) → credentials-file
1033+
// OIDC path. This prevents the credentials file from hijacking the cloud auto-login.
1034+
const bool use_credentials_file
1035+
= !login_mode.empty()
1036+
|| options.count("oauth-credentials");
1037+
1038+
if (use_credentials_file)
1039+
{
1040+
const char * home_path_cstr = getenv("HOME"); // NOLINT(concurrency-mt-unsafe)
1041+
const std::string default_creds_path = home_path_cstr
1042+
? std::string(home_path_cstr) + "/.clickhouse-client/oauth_client.json"
1043+
: "";
1044+
1045+
const std::string creds_path = options.count("oauth-credentials")
1046+
? options["oauth-credentials"].as<std::string>()
1047+
: default_creds_path;
1048+
1049+
auto creds = loadOAuthCredentials(creds_path);
1050+
const auto mode = (login_mode == "device") ? OAuthFlowMode::Device : OAuthFlowMode::AuthCode;
1051+
1052+
// createOAuthJWTProvider runs the initial flow (trying the cached
1053+
// refresh token first) and returns a provider that Connection can
1054+
// call to refresh the id_token transparently during long sessions.
1055+
jwt_provider = createOAuthJWTProvider(creds, mode);
1056+
config().setString("jwt", jwt_provider->getJWT());
1057+
config().setString("user", "");
1058+
}
1059+
else
1060+
{
1061+
// Cloud-specific login path — bare --login, including auto-added for
1062+
// *.clickhouse.cloud endpoints. Use a separate config key so that
1063+
// argsToConfig() overwriting config["login"] with the raw string value
1064+
// cannot cause getBool("login") to throw in main().
1065+
config().setBool("cloud_oauth_pending", true);
1066+
config().setString("user", "");
1067+
}
10001068
#else
1001-
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "OAuth login requires a build with JWT and SSL support");
1069+
throw Exception(ErrorCodes::SUPPORT_IS_DISABLED, "OAuth login requires a build with JWT and SSL support");
10021070
#endif
1071+
}
10031072
}
10041073
#if USE_JWT_CPP && USE_SSL
10051074
if (options.contains("oauth-url"))

0 commit comments

Comments
 (0)