diff --git a/README.md b/README.md index 0cf00477..0afc0caf 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,8 @@ clickhousectl local client --host remote-host --version 26.8.1.1760 # Use an in `--name` selects the connection and local client binary from managed server metadata, so named mode does not need a global default. It cannot be combined with direct `--host` or `--port` selectors, and named mode does not accept `--version`. +Without `--host` or `--port`, managed client lookup uses `.clickhouse/servers` from the canonical current directory only. It does not search parent directories. If lookup fails, return to the project root that owns the server, inspect that project's servers with `local server list`, or use direct mode. + In direct mode, `--host` and `--port` select the server connection while `--version` independently selects an already installed local client binary. Numeric selectors such as `26`, `26.8`, and `26.8.1.1760` select the newest installed match. This does not install a binary or change `~/.clickhouse/default`. Without `--version`, direct mode uses the valid default. If no default exists, zero installed versions is an error, one installed version is used without creating a default, and multiple installed versions require either `--version` or `local use`. A default that names a missing binary is an error; repair it with `local use`, or bypass it for one direct connection with `--version`. @@ -972,13 +974,19 @@ Local runtime failures also use structured output when `local --json` is set or } ``` -`error.code` and `error.message` are always present. `error.command` is an optional safe recovery command. Messages are built from allowlisted fields and never serialize raw I/O errors, paths, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema. +`error.code` and `error.message` are always present. `error.command` is an optional safe recovery command. Messages are built from allowlisted fields and never serialize raw I/O errors, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema. + +Managed `local client` failures deliberately use dedicated `managed_client_*` codes rather than the general server codes. Their error object includes `project_scope.path` (the canonical directory inspected), `server.selection` and `server.name`, an optional `server.binary_version`, and ordered `guidance` entries with allowlisted messages and optional commands. No raw lock, metadata, or I/O error is included in JSON. The nested shape distinguishes this exact-project lookup contract from failures in other local commands without changing those commands' stable envelopes. The schema and meanings of existing codes are stable. New optional fields or codes may be added compatibly; unclassified local failures use the bounded `local_error` fallback. | Code | Meaning | | ---- | ------- | | `server_not_found` | The selected local server does not exist | +| `managed_client_server_not_found` | Managed client lookup did not find the selected server in the current project | +| `managed_client_server_not_running` | The managed client server exists in the current project but is stopped | +| `managed_client_binary_not_found` | The client binary selected by managed server metadata is not installed | +| `managed_client_project_state_unavailable` | Managed client lookup could not read or lock current-project server state | | `server_selection_required` | A server name is required because omission is ambiguous or unsafe | | `server_not_running` | The selected local server exists but is stopped | | `server_running` | The operation requires a stopped server | diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs index b4cd9044..31eaf8c8 100644 --- a/crates/clickhousectl/src/error.rs +++ b/crates/clickhousectl/src/error.rs @@ -160,6 +160,106 @@ impl fmt::Display for StartupKind { } } +#[derive(Debug)] +pub enum ManagedClientErrorKind { + ServerNotFound, + ServerNotRunning, + BinaryNotFound, + ProjectStateUnavailable(Box), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagedClientSelection { + Default, + Named, +} + +impl ManagedClientSelection { + fn start_command(self) -> &'static str { + match self { + Self::Default => "clickhousectl local server start", + Self::Named => "clickhousectl local server start ", + } + } +} + +#[derive(Debug)] +pub struct ManagedClientError { + pub kind: ManagedClientErrorKind, + pub project_dir: PathBuf, + pub selection: ManagedClientSelection, + pub server_name: String, + pub binary_version: Option, +} + +impl fmt::Display for ManagedClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.kind { + ManagedClientErrorKind::ServerNotFound => { + writeln!( + f, + "Managed client mode: server '{}' was not found in current project '{}'; parent projects are not searched.", + self.server_name, + self.project_dir.display() + )?; + write!( + f, + "Run `clickhousectl local server list`; return to the project root if needed; start it with `{}`, or use direct mode with `clickhousectl local client --host --port `.", + self.selection.start_command() + ) + } + ManagedClientErrorKind::ServerNotRunning => { + writeln!( + f, + "Managed client mode: server '{}' is not running in current project '{}'.", + self.server_name, + self.project_dir.display() + )?; + write!( + f, + "Run `clickhousectl local server list`, then `{}`; or use direct mode with `clickhousectl local client --host --port `.", + self.selection.start_command() + ) + } + ManagedClientErrorKind::BinaryNotFound => { + let version = self.binary_version.as_deref().unwrap_or("unknown"); + writeln!( + f, + "Managed client mode: server '{}' in current project '{}' selected ClickHouse version '{}', but its client binary is missing.", + self.server_name, + self.project_dir.display(), + version + )?; + write!( + f, + "Run `clickhousectl local server list` and install the selected version with `clickhousectl local install `, or use direct mode with `clickhousectl local client --host --port `." + ) + } + ManagedClientErrorKind::ProjectStateUnavailable(source) => { + writeln!( + f, + "Managed client mode: server '{}' could not be resolved because state in current project '{}' is unavailable: {source}", + self.server_name, + self.project_dir.display() + )?; + write!( + f, + "Repair the project state error above, then run `clickhousectl local server list`; or use direct mode with `clickhousectl local client --host --port `." + ) + } + } + } +} + +impl std::error::Error for ManagedClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.kind { + ManagedClientErrorKind::ProjectStateUnavailable(source) => Some(source.as_ref()), + _ => None, + } + } +} + #[derive(Error, Debug)] #[allow(dead_code)] pub enum Error { @@ -310,6 +410,9 @@ pub enum Error { #[error("Server '{0}' not found")] ServerNotFound(String), + #[error("{0}")] + ManagedClient(ManagedClientError), + #[error( "No server name was provided and multiple non-default ClickHouse servers exist (available: {available}). Pass a name with `clickhousectl local server stop `, or stop every server with `clickhousectl local server stop-all`." )] diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs index 7f5cc2e7..f77d021a 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -245,7 +245,8 @@ CONTEXT FOR AGENTS: CONTEXT FOR AGENTS: Two connection modes: 1. Named server: `clickhousectl local client --name dev` — looks up port and version from a - locally managed server started via `clickhousectl local server start`. Defaults to \"default\". + locally managed server started via `clickhousectl local server start`. Lookup uses the exact + current project directory and does not search parents. Defaults to \"default\". 2. Explicit host/port: `clickhousectl local client --host myhost --port 9000` — connects to any ClickHouse server directly, bypassing local server lookup. Host-only uses port 9000; port-only connects to localhost. Direct selectors cannot be combined with --name. @@ -1082,6 +1083,8 @@ mod tests { for text in [ "Installed local client version for direct host/port mode", "Does not change the default", + "Lookup uses the exact", + "current project directory and does not search parents", ] { assert!(help.contains(text), "missing {text:?} in:\n{help}"); } diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs index d82b583c..be50ef3f 100644 --- a/crates/clickhousectl/src/local/mod.rs +++ b/crates/clickhousectl/src/local/mod.rs @@ -9,7 +9,9 @@ pub mod symlink; use cli::{ClientVersionArg, InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg}; -use crate::error::{Error, Result}; +use crate::error::{ + Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, Result, +}; use crate::{init, paths, version_manager}; use std::io::Write; use std::os::unix::process::CommandExt; @@ -271,32 +273,65 @@ fn run_client( ) -> Result<()> { // If --host or --port is set, connect directly (bypass local server lookup). // Otherwise, look up the named server for port and version. - let (resolved_host, tcp_port, version) = if host.is_some() || port.is_some() { + let (resolved_host, tcp_port, version, binary) = if host.is_some() || port.is_some() { let h = host.unwrap_or_else(|| "localhost".to_string()); let p = port.unwrap_or(9000); let v = resolve_direct_client_version(version_spec)?; - (h, p, v) + let binary = paths::binary_path(&v)?; + if !binary.exists() { + return Err(Error::VersionNotFound(v)); + } + (h, p, v, binary) } else { let server_name = name.as_deref().unwrap_or("default"); - let metadata_lock = server::lock_metadata()?; - server::recover_current_project_servers_locked(&metadata_lock)?; - let entry = server::server_entry_locked(server_name, &metadata_lock)? - .ok_or_else(|| Error::ServerNotFound(server_name.to_string()))?; + let selection = if name.is_some() { + ManagedClientSelection::Named + } else { + ManagedClientSelection::Default + }; + // Resolve symlinks so diagnostics identify the one physical directory + // whose project-local state was inspected. + let project_dir = std::env::current_dir()?.canonicalize()?; + let managed_error = |kind, binary_version| { + Error::ManagedClient(ManagedClientError { + kind, + project_dir: project_dir.clone(), + selection, + server_name: server_name.to_string(), + binary_version, + }) + }; + let project_state_error = |source| { + managed_error( + ManagedClientErrorKind::ProjectStateUnavailable(Box::new(source)), + None, + ) + }; + let metadata_lock = server::lock_metadata().map_err(&project_state_error)?; + server::recover_current_project_servers_locked(&metadata_lock) + .map_err(&project_state_error)?; + let entry = server::server_entry_locked(server_name, &metadata_lock) + .map_err(&project_state_error)? + .ok_or_else(|| managed_error(ManagedClientErrorKind::ServerNotFound, None))?; if !entry.running { - return Err(Error::ServerNotRunning(server_name.to_string())); + return Err(managed_error( + ManagedClientErrorKind::ServerNotRunning, + None, + )); } let info = entry .info - .ok_or_else(|| Error::ServerNotRunning(server_name.to_string()))?; - ("localhost".to_string(), info.tcp_port, info.version) + .ok_or_else(|| managed_error(ManagedClientErrorKind::ServerNotRunning, None))?; + let binary = paths::binary_path(&info.version)?; + if !binary.exists() { + return Err(managed_error( + ManagedClientErrorKind::BinaryNotFound, + Some(info.version), + )); + } + ("localhost".to_string(), info.tcp_port, info.version, binary) }; - let binary = paths::binary_path(&version)?; - - if !binary.exists() { - return Err(Error::VersionNotFound(version)); - } - ensure_repeated_query_supported(&version, query.len())?; let mut cmd = Command::new(&binary); diff --git a/crates/clickhousectl/src/local/output.rs b/crates/clickhousectl/src/local/output.rs index 8b37b5a2..7a078a38 100644 --- a/crates/clickhousectl/src/local/output.rs +++ b/crates/clickhousectl/src/local/output.rs @@ -3,7 +3,10 @@ //! Successful output types support both JSON serialization and human-readable //! display. Runtime failures use the redacted stable envelope below. -use crate::error::{Error, NetworkStage, PortKind}; +use crate::error::{ + Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, NetworkStage, + PortKind, +}; use serde::Serialize; use std::fmt; use std::io::Write; @@ -14,6 +17,10 @@ use tabled::{Table, Tabled, settings::Style}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] enum LocalErrorCode { + ManagedClientServerNotFound, + ManagedClientServerNotRunning, + ManagedClientBinaryNotFound, + ManagedClientProjectStateUnavailable, ServerNotFound, ServerSelectionRequired, ServerNotRunning, @@ -36,13 +43,61 @@ struct LocalErrorDetail { command: Option<&'static str>, } +#[derive(Debug, PartialEq, Eq, Serialize)] +struct LocalProjectScope { + path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum LocalServerSelection { + Default, + Named, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct LocalManagedServer { + selection: LocalServerSelection, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + binary_version: Option, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct LocalGuidance { + message: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option<&'static str>, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct ManagedClientErrorDetail { + code: LocalErrorCode, + message: &'static str, + project_scope: LocalProjectScope, + server: LocalManagedServer, + guidance: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(untagged)] +enum LocalErrorBody { + General(LocalErrorDetail), + ManagedClient(ManagedClientErrorDetail), +} + #[derive(Debug, PartialEq, Eq, Serialize)] struct LocalErrorOutput { - error: LocalErrorDetail, + error: LocalErrorBody, } impl LocalErrorOutput { fn from_error(error: &Error) -> Self { + if let Error::ManagedClient(error) = error { + return Self { + error: LocalErrorBody::ManagedClient(ManagedClientErrorDetail::from_error(error)), + }; + } let detail = match error { Error::ServerNotFound(name) => LocalErrorDetail { code: LocalErrorCode::ServerNotFound, @@ -181,7 +236,98 @@ impl LocalErrorOutput { command: None, }, }; - Self { error: detail } + Self { + error: LocalErrorBody::General(detail), + } + } +} + +impl ManagedClientErrorDetail { + fn from_error(error: &ManagedClientError) -> Self { + let (code, message) = match &error.kind { + ManagedClientErrorKind::ServerNotFound => ( + LocalErrorCode::ManagedClientServerNotFound, + "Managed client server was not found in the current project", + ), + ManagedClientErrorKind::ServerNotRunning => ( + LocalErrorCode::ManagedClientServerNotRunning, + "Managed client server is not running in the current project", + ), + ManagedClientErrorKind::BinaryNotFound => ( + LocalErrorCode::ManagedClientBinaryNotFound, + "Managed client binary selected by server metadata is not installed", + ), + ManagedClientErrorKind::ProjectStateUnavailable(_) => ( + LocalErrorCode::ManagedClientProjectStateUnavailable, + "Managed client project state is unavailable", + ), + }; + let selection = match error.selection { + ManagedClientSelection::Default => LocalServerSelection::Default, + ManagedClientSelection::Named => LocalServerSelection::Named, + }; + let mut guidance = vec![LocalGuidance { + message: "List managed servers in this exact project", + command: Some("clickhousectl local server list"), + }]; + match &error.kind { + ManagedClientErrorKind::ServerNotFound => { + guidance.push(LocalGuidance { + message: "Return to the project directory that owns the managed server", + command: None, + }); + guidance.push(start_guidance(error.selection)); + } + ManagedClientErrorKind::ServerNotRunning => { + guidance.push(start_guidance(error.selection)); + } + ManagedClientErrorKind::BinaryNotFound => { + guidance.push(LocalGuidance { + message: "Install the version selected by the managed server metadata", + command: Some("clickhousectl local install "), + }); + } + ManagedClientErrorKind::ProjectStateUnavailable(_) => { + guidance.insert( + 0, + LocalGuidance { + message: "Repair the reported project state error before retrying", + command: None, + }, + ); + } + } + guidance.push(LocalGuidance { + message: "Bypass managed project lookup and connect directly", + command: Some("clickhousectl local client --host --port "), + }); + + Self { + code, + message, + project_scope: LocalProjectScope { + path: error.project_dir.display().to_string(), + }, + server: LocalManagedServer { + selection, + name: error.server_name.clone(), + binary_version: error.binary_version.clone(), + }, + guidance, + } + } +} + +fn start_guidance(selection: ManagedClientSelection) -> LocalGuidance { + match selection { + ManagedClientSelection::Default => LocalGuidance { + message: "Start the default managed server in this project", + command: Some("clickhousectl local server start"), + }, + ManagedClientSelection::Named => LocalGuidance { + message: "Start the selected named managed server in this project", + command: Some("clickhousectl local server start "), + }, } } @@ -809,6 +955,53 @@ mod tests { #[test] fn local_error_codes_cover_the_stable_vocabulary() { let cases = [ + ( + Error::ManagedClient(ManagedClientError { + kind: ManagedClientErrorKind::ServerNotFound, + project_dir: "/project".into(), + selection: ManagedClientSelection::Default, + server_name: "default".into(), + binary_version: None, + }), + "managed_client_server_not_found", + ), + ( + Error::ManagedClient(ManagedClientError { + kind: ManagedClientErrorKind::ServerNotRunning, + project_dir: "/project".into(), + selection: ManagedClientSelection::Named, + server_name: "dev".into(), + binary_version: None, + }), + "managed_client_server_not_running", + ), + ( + Error::ManagedClient(ManagedClientError { + kind: ManagedClientErrorKind::BinaryNotFound, + project_dir: "/project".into(), + selection: ManagedClientSelection::Named, + server_name: "dev".into(), + binary_version: Some("25.12.9.61".into()), + }), + "managed_client_binary_not_found", + ), + ( + Error::ManagedClient(ManagedClientError { + kind: ManagedClientErrorKind::ProjectStateUnavailable(Box::new( + Error::ServerLock { + operation: "open the server metadata lock file", + path: "/project/.clickhouse/servers/.metadata.lock".into(), + remediation: "Check access, then retry.", + source: std::io::Error::other("lock failed"), + }, + )), + project_dir: "/project".into(), + selection: ManagedClientSelection::Default, + server_name: "default".into(), + binary_version: None, + }), + "managed_client_project_state_unavailable", + ), (Error::ServerNotFound("default".into()), "server_not_found"), ( Error::ServerStopSelectionRequired { available: 2 }, diff --git a/crates/clickhousectl/tests/local_client_project_scope_errors_test.rs b/crates/clickhousectl/tests/local_client_project_scope_errors_test.rs new file mode 100644 index 00000000..87840757 --- /dev/null +++ b/crates/clickhousectl/tests/local_client_project_scope_errors_test.rs @@ -0,0 +1,428 @@ +//! Managed local-client project-scope diagnostics. + +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +const VERSION: &str = "27.1.2.3"; + +fn clickhousectl_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_clickhousectl")) +} + +fn command(project: &Path, home: &Path) -> Command { + let mut command = Command::new(clickhousectl_binary()); + command + .env_clear() + .env("DO_NOT_TRACK", "1") + .env("HOME", home) + .current_dir(project); + command +} + +fn run(project: &Path, home: &Path, json: bool, client_args: &[&str]) -> Output { + let mut args = vec!["local"]; + if json { + args.push("--json"); + } + args.push("client"); + args.extend_from_slice(client_args); + command(project, home) + .args(args) + .output() + .expect("run clickhousectl") +} + +fn assert_failure(output: &Output, expected: &str) { + assert_eq!( + output.status.code(), + Some(1), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert_eq!(String::from_utf8_lossy(&output.stderr), expected); +} + +fn canonical(project: &Path) -> String { + project + .canonicalize() + .expect("canonical project path") + .display() + .to_string() +} + +fn write_server(project: &Path, name: &str, pid: u32, version: &str) { + let servers = project.join(".clickhouse/servers"); + std::fs::create_dir_all(&servers).expect("create server metadata directory"); + std::fs::write( + servers.join(format!("{name}.json")), + serde_json::to_vec(&serde_json::json!({ + "name": name, + "pid": pid, + "version": version, + "http_port": if pid == 0 { 0 } else { 8123 }, + "tcp_port": if pid == 0 { 0 } else { 9000 }, + "started_at": "test", + "cwd": canonical(project), + "engine": "clickhouse" + })) + .expect("serialize server metadata"), + ) + .expect("write server metadata"); +} + +fn install_fake_clickhouse(home: &Path, version: &str) { + let binary = home + .join(".clickhouse/versions") + .join(version) + .join("clickhouse"); + std::fs::create_dir_all(binary.parent().unwrap()).expect("create version directory"); + std::fs::write(binary, "#!/bin/sh\nexit 0\n").expect("write fake ClickHouse"); +} + +#[derive(Serialize)] +struct ExpectedEnvelope<'a> { + error: ExpectedError<'a>, +} + +#[derive(Serialize)] +struct ExpectedError<'a> { + code: &'a str, + message: &'a str, + project_scope: ExpectedScope<'a>, + server: ExpectedServer<'a>, + guidance: Vec, +} + +#[derive(Serialize)] +struct ExpectedScope<'a> { + path: &'a str, +} + +#[derive(Serialize)] +struct ExpectedServer<'a> { + selection: &'a str, + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + binary_version: Option<&'a str>, +} + +#[derive(Serialize)] +struct ExpectedGuidance { + message: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option<&'static str>, +} + +fn list_guidance() -> ExpectedGuidance { + ExpectedGuidance { + message: "List managed servers in this exact project", + command: Some("clickhousectl local server list"), + } +} + +fn direct_guidance() -> ExpectedGuidance { + ExpectedGuidance { + message: "Bypass managed project lookup and connect directly", + command: Some("clickhousectl local client --host --port "), + } +} + +fn start_guidance(selection: &str) -> ExpectedGuidance { + if selection == "default" { + ExpectedGuidance { + message: "Start the default managed server in this project", + command: Some("clickhousectl local server start"), + } + } else { + ExpectedGuidance { + message: "Start the selected named managed server in this project", + command: Some("clickhousectl local server start "), + } + } +} + +fn expected_json( + project: &str, + code: &str, + message: &str, + selection: &str, + name: &str, + binary_version: Option<&str>, +) -> String { + let mut guidance = vec![list_guidance()]; + match code { + "managed_client_server_not_found" => { + guidance.push(ExpectedGuidance { + message: "Return to the project directory that owns the managed server", + command: None, + }); + guidance.push(start_guidance(selection)); + } + "managed_client_server_not_running" => guidance.push(start_guidance(selection)), + "managed_client_binary_not_found" => guidance.push(ExpectedGuidance { + message: "Install the version selected by the managed server metadata", + command: Some("clickhousectl local install "), + }), + "managed_client_project_state_unavailable" => guidance.insert( + 0, + ExpectedGuidance { + message: "Repair the reported project state error before retrying", + command: None, + }, + ), + other => panic!("unexpected code: {other}"), + } + guidance.push(direct_guidance()); + + let expected = ExpectedEnvelope { + error: ExpectedError { + code, + message, + project_scope: ExpectedScope { path: project }, + server: ExpectedServer { + selection, + name, + binary_version, + }, + guidance, + }, + }; + format!("{}\n", serde_json::to_string_pretty(&expected).unwrap()) +} + +fn assert_human_and_json( + project: &Path, + home: &Path, + client_args: &[&str], + human: &str, + json: &str, +) { + assert_failure(&run(project, home, false, client_args), human); + assert_failure(&run(project, home, true, client_args), json); +} + +#[test] +fn empty_project_identifies_default_managed_mode_and_exact_scope() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + let project_path = canonical(project.path()); + let human = format!( + "Error: Managed client mode: server 'default' was not found in current project '{project_path}'; parent projects are not searched.\n\ + Run `clickhousectl local server list`; return to the project root if needed; start it with `clickhousectl local server start`, or use direct mode with `clickhousectl local client --host --port `.\n" + ); + let json = expected_json( + &project_path, + "managed_client_server_not_found", + "Managed client server was not found in the current project", + "default", + "default", + None, + ); + + assert_human_and_json(project.path(), home.path(), &[], &human, &json); +} + +#[test] +fn child_of_valid_parent_does_not_search_parent_and_agent_gets_exact_json() { + let parent = tempfile::tempdir().expect("create parent project"); + let home = tempfile::tempdir().expect("create home"); + install_fake_clickhouse(home.path(), VERSION); + write_server(parent.path(), "default", std::process::id(), VERSION); + let child = parent.path().join("child"); + std::fs::create_dir(&child).expect("create child directory"); + let child_path = canonical(&child); + let expected = expected_json( + &child_path, + "managed_client_server_not_found", + "Managed client server was not found in the current project", + "default", + "default", + None, + ); + + assert_failure(&run(&child, home.path(), true, &[]), &expected); + let agent = command(&child, home.path()) + .env("AGENT", "opencode") + .args(["local", "client"]) + .output() + .expect("run agent-mode client"); + assert_failure(&agent, &expected); + assert!( + parent + .path() + .join(".clickhouse/servers/default.json") + .exists() + ); +} + +#[test] +fn explicit_wrong_name_preserves_named_selection_without_unsafe_command_interpolation() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + write_server(project.path(), "default", std::process::id(), VERSION); + let project_path = canonical(project.path()); + let human = format!( + "Error: Managed client mode: server 'wrong name' was not found in current project '{project_path}'; parent projects are not searched.\n\ + Run `clickhousectl local server list`; return to the project root if needed; start it with `clickhousectl local server start `, or use direct mode with `clickhousectl local client --host --port `.\n" + ); + let json = expected_json( + &project_path, + "managed_client_server_not_found", + "Managed client server was not found in the current project", + "named", + "wrong name", + None, + ); + + assert_human_and_json( + project.path(), + home.path(), + &["--name", "wrong name"], + &human, + &json, + ); + assert!(!json.contains("server start wrong name")); +} + +#[test] +fn stopped_metadata_suggests_starting_the_selected_named_server() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + write_server(project.path(), "dev", 0, ""); + let project_path = canonical(project.path()); + let human = format!( + "Error: Managed client mode: server 'dev' is not running in current project '{project_path}'.\n\ + Run `clickhousectl local server list`, then `clickhousectl local server start `; or use direct mode with `clickhousectl local client --host --port `.\n" + ); + let json = expected_json( + &project_path, + "managed_client_server_not_running", + "Managed client server is not running in the current project", + "named", + "dev", + None, + ); + + assert_human_and_json( + project.path(), + home.path(), + &["--name", "dev"], + &human, + &json, + ); +} + +#[test] +fn selected_missing_binary_keeps_managed_scope_and_install_guidance() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + write_server(project.path(), "dev", std::process::id(), VERSION); + let project_path = canonical(project.path()); + let human = format!( + "Error: Managed client mode: server 'dev' in current project '{project_path}' selected ClickHouse version '{VERSION}', but its client binary is missing.\n\ + Run `clickhousectl local server list` and install the selected version with `clickhousectl local install `, or use direct mode with `clickhousectl local client --host --port `.\n" + ); + let json = expected_json( + &project_path, + "managed_client_binary_not_found", + "Managed client binary selected by server metadata is not installed", + "named", + "dev", + Some(VERSION), + ); + + assert_human_and_json( + project.path(), + home.path(), + &["--name", "dev"], + &human, + &json, + ); +} + +#[test] +fn invalid_metadata_lock_path_keeps_managed_scope_in_json_and_human_errors() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + let state_dir = project.path().join(".clickhouse"); + std::fs::create_dir(&state_dir).expect("create state directory"); + std::fs::write(state_dir.join("servers"), "not a directory") + .expect("create invalid servers path"); + let project_path = canonical(project.path()); + let json = expected_json( + &project_path, + "managed_client_project_state_unavailable", + "Managed client project state is unavailable", + "default", + "default", + None, + ); + + assert_failure(&run(project.path(), home.path(), true, &[]), &json); + + let human = run(project.path(), home.path(), false, &[]); + assert_eq!(human.status.code(), Some(1)); + assert!(human.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&human.stderr); + assert!( + stderr.starts_with("Error: Managed client mode: server 'default' could not be resolved") + ); + assert!(stderr.contains(&project_path)); + assert!(stderr.contains("create the server metadata lock directory")); + assert!(stderr.contains("`clickhousectl local server list`")); + assert!(stderr.contains("`clickhousectl local client --host --port `")); +} + +#[test] +fn corrupt_metadata_keeps_managed_scope_and_redacts_source_from_json() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + let servers = project.path().join(".clickhouse/servers"); + std::fs::create_dir_all(&servers).expect("create servers directory"); + std::fs::write(servers.join("default.json"), "{").expect("write corrupt metadata"); + let project_path = canonical(project.path()); + let json = expected_json( + &project_path, + "managed_client_project_state_unavailable", + "Managed client project state is unavailable", + "default", + "default", + None, + ); + + let json_output = run(project.path(), home.path(), true, &[]); + assert_failure(&json_output, &json); + assert!(!String::from_utf8_lossy(&json_output.stderr).contains("not valid JSON")); + + let human = run(project.path(), home.path(), false, &[]); + assert_eq!(human.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&human.stderr); + assert!(stderr.contains(&project_path)); + assert!(stderr.contains("not valid JSON")); +} + +#[cfg(unix)] +#[test] +fn symlinked_working_directory_reports_the_canonical_project_scope() { + let root = tempfile::tempdir().expect("create root"); + let project = root.path().join("real-project"); + let alias = root.path().join("project-alias"); + let home = tempfile::tempdir().expect("create home"); + std::fs::create_dir(&project).expect("create real project"); + std::os::unix::fs::symlink(&project, &alias).expect("create project symlink"); + let project_path = canonical(&project); + let expected = expected_json( + &project_path, + "managed_client_server_not_found", + "Managed client server was not found in the current project", + "default", + "default", + None, + ); + + let output = run(&alias, home.path(), true, &[]); + assert_failure(&output, &expected); + assert!(!String::from_utf8_lossy(&output.stderr).contains("project-alias")); +} diff --git a/crates/clickhousectl/tests/local_server_stopped_test.rs b/crates/clickhousectl/tests/local_server_stopped_test.rs index cc83a9a6..113e477c 100644 --- a/crates/clickhousectl/tests/local_server_stopped_test.rs +++ b/crates/clickhousectl/tests/local_server_stopped_test.rs @@ -259,7 +259,13 @@ fn stopped_server_connection_commands_fail_without_using_saved_ports() { &["local", "client", "--name", "default"], ); assert_eq!(client.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&client.stderr).contains("Server 'default' is not running")); + let client_stderr = String::from_utf8_lossy(&client.stderr); + assert!(client_stderr.contains("Managed client mode: server 'default' is not running")); + assert!(client_stderr.contains(&project.path().canonicalize().unwrap().display().to_string())); + assert!(client_stderr.contains("clickhousectl local server list")); + assert!(client_stderr.contains("clickhousectl local server start ")); + assert!(!client_stderr.contains("8123")); + assert!(!client_stderr.contains("9000")); let dotenv = run( project.path(), diff --git a/crates/clickhousectl/tests/telemetry_test.rs b/crates/clickhousectl/tests/telemetry_test.rs index da852a8a..ccd2912b 100644 --- a/crates/clickhousectl/tests/telemetry_test.rs +++ b/crates/clickhousectl/tests/telemetry_test.rs @@ -310,6 +310,61 @@ async fn failure_reported_and_positional_value_never_leaks() { ); } +#[tokio::test] +async fn managed_client_failure_details_never_reach_telemetry() { + let sandbox = Sandbox::new().await; + sandbox.write_state(false); + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project-private-token"); + let server_name = "server-private-token"; + let version = "99.99.1-version-private-token"; + let servers = project.join(".clickhouse/servers"); + std::fs::create_dir_all(&servers).unwrap(); + std::fs::write( + servers.join(format!("{server_name}.json")), + serde_json::to_vec(&serde_json::json!({ + "name": server_name, + "pid": std::process::id(), + "version": version, + "http_port": 8123, + "tcp_port": 9000, + "started_at": "test", + "cwd": project, + "engine": "clickhouse" + })) + .unwrap(), + ) + .unwrap(); + + let output = sandbox + .command(&["local", "client", "--name", server_name]) + .current_dir(&project) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + let raw_message = stderr_of(&output); + assert!(raw_message.contains(server_name)); + assert!(raw_message.contains(version)); + + let payloads = sandbox.wait_for_requests(1).await; + let event = &payloads[0]; + assert_eq!(event["command"], "local client"); + assert_eq!(event["flags"], serde_json::json!(["name"])); + assert_eq!(event["exit_code"], 1); + let raw_payload = serde_json::to_string(event).unwrap(); + for sensitive in [ + server_name, + version, + "project-private-token", + raw_message.as_str(), + ] { + assert!( + !raw_payload.contains(sensitive), + "managed client detail leaked into telemetry: {raw_payload}" + ); + } +} + #[cfg(unix)] #[tokio::test] async fn child_exit_code_reaches_the_telemetry_tail_unchanged() {