From 0768cba7ef1e9fe4bec98b6a40955ee9315a9d10 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Mon, 7 Sep 2026 10:03:54 -0400 Subject: [PATCH] bake in Kubernetes port-forwarding The --kubectl-resource option names a Kubernetes resource, usually in the form type/name, to which a tunnel is created using the kubectl CLI tool. Mycli then connects over that tunnel. The --port option will be needed if the remote service is on a nonstandard port, for example mycli --kubectl-resource=pod/mysql --user=root --port=13306 It is unfortunate that our three tunneling options have quite different spellings --ssh-jump --boundary-id --kubectl-resource However, there is meaning behind them for each case. In this case, the terminology in Kubernetes for the value of the argument is exactly a "resource" (and Kubernetes has very precise terminology). And we wouldn't call it a "jump" because the tunnel terminates at the resource. DSNs also support `kubectl_resource` as a query parameter. Future work: it is not entirely clear whether use of the keyring should be suppressed for all kubectl connections. It is kept off here, because of obvious issues with a naive implementation. For instance, the Kubernetes context is ignored here. While the context _can_ be set via --kubectl-options, it is not tracked by our logic, and there is no way to make it part of the keyring identifier. Yet changing the context _would_ change which resource we connect to, and presumably the needed credentials would change. Limitation: kubectl can port-forward to a _named_ port on the resource we are connecting to, but the mycli --port option only accepts integers. So, there is an argument for adding a second option --kubectl-port or incorporating the (possibly non-integer) port into the resource string. Or for extending --port to accept strings when --kubectl-resource is given. Each would add complexity, and nothing stops us from choosing one of those paths later if there is such a need. Since named ports correspond to integers, the current functionality ought to be sufficient. --- AGENTS.md | 1 + README.md | 1 + changelog.md | 1 + mycli/TIPS | 2 + mycli/cli_runner.py | 12 +- mycli/client.py | 7 + mycli/client_connection.py | 46 ++++ mycli/constants.py | 1 + mycli/kubectl_tunnel.py | 128 +++++++++++ mycli/main.py | 8 + mycli/myclirc | 8 + mycli/packages/special/utils.py | 3 + mycli/resources/completions/bash/mycli | 2 +- mycli/resources/completions/fish/mycli.fish | 2 +- mycli/resources/completions/zsh/_mycli | 2 +- test/myclirc | 8 + test/pytests/test_cli_runner.py | 86 +++++++- test/pytests/test_client.py | 5 +- test/pytests/test_client_connection.py | 144 ++++++++++++ test/pytests/test_dsn_aliases.py | 3 +- test/pytests/test_kubectl_tunnel.py | 231 ++++++++++++++++++++ test/pytests/test_main.py | 24 ++ test/pytests/test_special_utils.py | 15 ++ 23 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 mycli/kubectl_tunnel.py create mode 100644 test/pytests/test_kubectl_tunnel.py diff --git a/AGENTS.md b/AGENTS.md index 4142a643f..b83d397fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ A command line client for MySQL with auto-completion and syntax highlighting. ├── mycli/config.py # configuration file readers and utilities ├── mycli/constants.py # shared constants ├── mycli/key_bindings.py # prompt_toolkit key binding utilities +├── mycli/kubectl_tunnel.py # connection over kubectl tunnel ├── mycli/lexer.py # extends `MySqlLexer` from Pygments ├── mycli/main.py # processes CLI arguments ├── mycli/main_modes/ # main execution paths diff --git a/README.md b/README.md index c3d957ca6..c761b1ba6 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ mycli --help * [Polars](https://pola.rs) dataframe [transforms and plots](https://github.com/dbcli/mycli/blob/main/doc/transforms.md) with `.|`, and Parquet saves with `.>`. * Support for [querying LLMs](https://www.mycli.net/llm) with context derived from your schema using `/llm`. * Support for storing passwords in the system keyring. +* Integrations with: [SSH](https://www.openssh.org/), [Kubernetes](https://kubernetes.io/), [Vault](https://github.com/hashicorp/vault), and [Boundary](https://github.com/hashicorp/boundary). Mycli creates a config file `~/.myclirc` on the first run; you can use the options in that file to configure the above features, and more. diff --git a/changelog.md b/changelog.md index ef6050339..7d79dc27d 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Upcoming (TBD) Features -------- * Add completion on leading-abbreviated pathname elements. +* Add port-forwarding into a Kubernetes cluster with `--kubectl-resource`. 2.21.1 (2026/09/07) diff --git a/mycli/TIPS b/mycli/TIPS index c22ed094b..39d5c1ab7 100644 --- a/mycli/TIPS +++ b/mycli/TIPS @@ -42,6 +42,8 @@ the --login-path option lets you work with login-path files! connect via an SSH tunnel with --ssh-jump! +connect to a Kubernetes pod with --kubectl-resource! + ### ### commands ### diff --git a/mycli/cli_runner.py b/mycli/cli_runner.py index fb1e16182..18dd01886 100644 --- a/mycli/cli_runner.py +++ b/mycli/cli_runner.py @@ -359,6 +359,8 @@ def run_from_cli_args(cli_args: 'CliArgs', client_factory: ClientFactory) -> Non mycli.prompt_format = cli_args.prompt or params[0] or mycli.prompt_format if params := dsn_params.get('ssh_jump'): cli_args.ssh_jump = cli_args.ssh_jump or params[0] + if params := dsn_params.get('kubectl_resource'): + cli_args.kubectl_resource = cli_args.kubectl_resource or params[0] if params := dsn_params.get('boundary_id'): cli_args.boundary_id = cli_args.boundary_id or params[0] if params := dsn_params.get('vault_address'): @@ -372,8 +374,12 @@ def run_from_cli_args(cli_args: 'CliArgs', client_factory: ClientFactory) -> Non if params := dsn_params.get('vault_username_field'): cli_args.vault_username_field = cli_args.vault_username_field or params[0] - if cli_args.ssh_jump and cli_args.boundary_id: - click.secho('Error: --ssh-jump and --boundary-id are incompatible.', err=True, fg='red') + tunnel_options = [cli_args.ssh_jump, cli_args.kubectl_resource, cli_args.boundary_id] + if sum(option is not None and bool(option) for option in tunnel_options) > 1: + click.secho('Error: --ssh-jump, --kubectl-resource, and --boundary-id are mutually exclusive.', err=True, fg='red') + sys.exit(1) + if cli_args.kubectl_options and not cli_args.kubectl_resource: + click.secho('Error: --kubectl-options requires --kubectl-resource.', err=True, fg='red') sys.exit(1) keepalive_ticks = cli_args.keepalive_ticks if cli_args.keepalive_ticks is not None else mycli.default_keepalive_ticks @@ -521,6 +527,8 @@ def load_vault_password() -> str | None: keepalive_ticks=keepalive_ticks, ssh_jump=cli_args.ssh_jump, ssh_cli_options=cli_args.ssh_options, + kubectl_resource=cli_args.kubectl_resource, + kubectl_cli_options=cli_args.kubectl_options, vault_address=cli_args.vault_address, vault_mount=cli_args.vault_mount, vault_secret=cli_args.vault_secret, diff --git a/mycli/client.py b/mycli/client.py index bd7829812..2784bfe14 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -34,6 +34,7 @@ write_default_config, ) from mycli.constants import DEFAULT_PROMPT +from mycli.kubectl_tunnel import KubectlTunnel from mycli.main_modes import repl as repl_package from mycli.output import OutputMixin from mycli.packages import special @@ -80,6 +81,7 @@ def __init__( ) -> None: self.sqlexecute = sqlexecute self.ssh_tunnel: SshTunnel | None = None + self.kubectl_tunnel: KubectlTunnel | None = None self.boundary_tunnel: BoundaryTunnel | None = None self.logfile = logfile self.login_path = login_path @@ -260,6 +262,11 @@ def close(self) -> None: self.ssh_tunnel.close() except Exception: pass + if self.kubectl_tunnel is not None: + try: + self.kubectl_tunnel.close() + except Exception: + pass if self.boundary_tunnel: try: self.boundary_tunnel.close() diff --git a/mycli/client_connection.py b/mycli/client_connection.py index 6a64a1d05..db8844d41 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -22,6 +22,7 @@ EMPTY_PASSWORD_FLAG_SENTINEL, ER_MUST_CHANGE_PASSWORD_LOGIN, ) +from mycli.kubectl_tunnel import KubectlTunnel, KubectlTunnelError from mycli.packages.filepaths import guess_socket_location from mycli.packages.special.utils import format_connection_dsn from mycli.password_sources import PasswordCandidates @@ -45,6 +46,7 @@ class ClientConnectionMixin: sqlexecute: Any logger: Any boundary_tunnel: BoundaryTunnel | None + kubectl_tunnel: KubectlTunnel | None def read_mylogin_cnf(self, cnf: Any) -> dict[str, Any]: ... def echo(self, *args: Any, **kwargs: Any) -> None: ... @@ -67,6 +69,8 @@ def connect( keepalive_ticks: int | None = None, ssh_jump: str | None = None, ssh_cli_options: str | None = None, + kubectl_resource: str | None = None, + kubectl_cli_options: str | None = None, vault_address: str | None = None, vault_mount: str | None = None, vault_secret: str | None = None, @@ -84,6 +88,7 @@ def connect( user_connection_config = self.config_without_package_defaults.get('connection', {}) self.keepalive_ticks = keepalive_ticks self.ssh_tunnel = None + self.kubectl_tunnel = None self.selected_password = None self.boundary_tunnel = None @@ -130,6 +135,28 @@ def connect( except Exception: pass sys.exit(1) + elif kubectl_resource: + use_keyring = False + kubectl_executable = self.config.get('kubectl', {}).get('kubectl_executable', 'kubectl') or 'kubectl' + kubectl_config_options = self.config.get('kubectl', {}).get('kubectl_options') or None + try: + self.kubectl_tunnel = KubectlTunnel( + resource=kubectl_resource, + remote_port=int(int_port), + kubectl_executable=kubectl_executable, + kubectl_config_options=kubectl_config_options, + kubectl_cli_options=kubectl_cli_options, + ) + self.kubectl_tunnel.start() + socket = None + except (OSError, ValueError, KubectlTunnelError) as exc: + click.secho(f'Error: Unable to start kubectl tunnel: {exc}', err=True, fg='red') + try: + if self.kubectl_tunnel: + self.kubectl_tunnel.close() + except Exception: + pass + sys.exit(1) elif boundary_target_id: use_keyring = False boundary_executable = self.config.get('boundary', {}).get('boundary_executable', 'boundary') or 'boundary' @@ -267,6 +294,21 @@ def connect( vault_password_field=vault_password_field, vault_username_field=vault_username_field, ) + elif self.kubectl_tunnel: + display_dsn = format_connection_dsn( + user=display_dsn_user, + host=host, + port=int_port, + socket=None, + database=database, + character_set=character_set, + kubectl_resource=kubectl_resource, + vault_address=vault_address, + vault_mount=vault_mount, + vault_secret=vault_secret, + vault_password_field=vault_password_field, + vault_username_field=vault_username_field, + ) elif self.boundary_tunnel: display_dsn = format_connection_dsn( user=None, @@ -312,6 +354,10 @@ def connect( connection_info['host'] = self.ssh_tunnel.local_host connection_info['port'] = self.ssh_tunnel.local_port connection_info['socket'] = None + elif self.kubectl_tunnel: + connection_info['host'] = self.kubectl_tunnel.local_host + connection_info['port'] = self.kubectl_tunnel.local_port + connection_info['socket'] = None elif self.boundary_tunnel: connection_info['user'] = self.boundary_tunnel.username connection_info['host'] = self.boundary_tunnel.local_host diff --git a/mycli/constants.py b/mycli/constants.py index d4cc6a252..9ab216b34 100644 --- a/mycli/constants.py +++ b/mycli/constants.py @@ -8,6 +8,7 @@ 'boundary_id', 'character_set', 'keepalive_ticks', + 'kubectl_resource', 'prompt', 'socket', 'ssh_jump', diff --git a/mycli/kubectl_tunnel.py b/mycli/kubectl_tunnel.py new file mode 100644 index 000000000..f2f3c5a6a --- /dev/null +++ b/mycli/kubectl_tunnel.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import re +import shlex +import subprocess +import tempfile +import time +from typing import IO + +from mycli.compat import WIN + +DEFAULT_KUBECTL_EXECUTABLE = 'kubectl' +LOCAL_HOST = '127.0.0.1' + + +class KubectlTunnelError(RuntimeError): + pass + + +def _split_options(options: str | None) -> list[str]: + try: + arguments = shlex.split(options or '', posix=not WIN) + except ValueError as exc: + raise KubectlTunnelError(f'Unable to parse kubectl options: {exc}') from exc + if WIN: + arguments = [ + argument[1:-1] if len(argument) >= 2 and argument[0] == argument[-1] and argument[0] in ('"', "'") else argument + for argument in arguments + ] + return arguments + + +class KubectlTunnel: + def __init__( + self, + *, + resource: str, + remote_port: int, + kubectl_executable: str = DEFAULT_KUBECTL_EXECUTABLE, + kubectl_config_options: str | None = None, + kubectl_cli_options: str | None = None, + local_port: int | None = None, + ready_timeout: float = 30.0, + ) -> None: + if not resource.strip(): + raise KubectlTunnelError('Kubernetes resource must not be empty.') + if not 1 <= remote_port <= 65535: + raise KubectlTunnelError('Kubernetes remote port must be an integer between 1 and 65535.') + self.resource = resource + self.remote_port = remote_port + self.kubectl_executable = kubectl_executable + self.kubectl_config_options = kubectl_config_options + self.kubectl_cli_options = kubectl_cli_options + self.local_host = LOCAL_HOST + self.local_port = local_port + self.ready_timeout = ready_timeout + self.process: subprocess.Popen | None = None + self._output_file: IO[bytes] | None = None + + def command(self) -> list[str]: + options = _split_options(self.kubectl_config_options) + options.extend(_split_options(self.kubectl_cli_options)) + return [ + self.kubectl_executable, + 'port-forward', + *options, + f'--address={self.local_host}', + self.resource, + f'{self.local_port or ""}:{self.remote_port}', + ] + + def start(self) -> None: + self._output_file = tempfile.TemporaryFile(mode='w+b') + try: + self.process = subprocess.Popen( + self.command(), + stdin=subprocess.DEVNULL, + stdout=self._output_file, + stderr=self._output_file, + start_new_session=True, + ) + except KubectlTunnelError: + self.close() + raise + except (OSError, ValueError) as exc: + self.close() + raise KubectlTunnelError(f'Unable to start kubectl port-forward process: {exc}') from exc + + deadline = time.monotonic() + self.ready_timeout + while time.monotonic() < deadline: + return_code = self.process.poll() + if return_code is not None: + output = self._captured_output() + self.close() + detail = f': {output}' if output else '' + raise KubectlTunnelError(f'kubectl port-forward exited with status {return_code}{detail}') + if local_port := self._forwarded_local_port(): + self.local_port = local_port + return + time.sleep(0.05) + + output = self._captured_output() + self.close() + detail = f': {output}' if output else '' + raise KubectlTunnelError(f'Timed out waiting for kubectl port-forward to become ready{detail}') + + def close(self) -> None: + process = self.process + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + if self._output_file is not None: + self._output_file.close() + self._output_file = None + + def _captured_output(self) -> str: + if self._output_file is None: + return '' + self._output_file.seek(0) + return self._output_file.read().decode('utf-8', errors='replace').strip() + + def _forwarded_local_port(self) -> int | None: + match = re.search(rf'Forwarding from {re.escape(self.local_host)}:(\d+) ->', self._captured_output()) + return int(match.group(1)) if match else None diff --git a/mycli/main.py b/mycli/main.py index 504880a38..c6b3a3f5f 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -321,6 +321,14 @@ class CliArgs: type=str, help='Extra CLI arguments for SSH with --ssh-jump, placed after options from myclirc.', ) + kubectl_resource: str | None = clickdc.option( + type=str, + help='Open a kubectl port-forward tunnel to RESOURCE and connect through it.', + ) + kubectl_options: str | None = clickdc.option( + type=str, + help='Extra CLI arguments for kubectl with --kubectl-resource.', + ) boundary_id: str | None = clickdc.option( type=str, help='Open a HashiCorp Boundary tunnel to TARGET_ID and connect through it.', diff --git a/mycli/myclirc b/mycli/myclirc index 1d7122363..ee546318c 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -414,6 +414,14 @@ ssh_options = -a -o ServerAliveInterval=60 -o ExitOnForwardFailure=yes -o IPQoS= # auto means: port if on Windows, socket otherwise. tunnel_method = auto +[kubectl] +# Path to the kubectl executable used by --kubectl-resource. +kubectl_executable = kubectl + +# Options to pass to "kubectl port-forward" before the resource name. +# This can be used, for example, to set the context. +kubectl_options = + [vault] # Path to the vault executable used for Vault integration. vault_executable = vault diff --git a/mycli/packages/special/utils.py b/mycli/packages/special/utils.py index b2c00560e..f729c097f 100644 --- a/mycli/packages/special/utils.py +++ b/mycli/packages/special/utils.py @@ -156,6 +156,7 @@ def format_connection_dsn( socket: str | None, character_set: str | None, boundary_id: str | None = None, + kubectl_resource: str | None = None, ssh_jump: str | None = None, vault_address: str | None = None, vault_mount: str | None = None, @@ -177,6 +178,8 @@ def format_connection_dsn( query_part['character_set'] = character_set if boundary_id: query_part['boundary_id'] = boundary_id + if kubectl_resource: + query_part['kubectl_resource'] = kubectl_resource if ssh_jump: query_part['ssh_jump'] = ssh_jump if vault_address: diff --git a/mycli/resources/completions/bash/mycli b/mycli/resources/completions/bash/mycli index 5d8727c69..36f23c0f2 100644 --- a/mycli/resources/completions/bash/mycli +++ b/mycli/resources/completions/bash/mycli @@ -37,7 +37,7 @@ _mycli_is_positional_db_arg() { ;; --*=*) ;; - --host|--hostname|--port|--user|--username|--socket|--pass|--password|--password-file|--vault-address|--vault-mount|--vault-secret|--vault-password-field|--vault-username-field|--ssl-mode|--ssl-ca|--ssl-capath|--ssl-cert|--ssl-key|--ssl-cipher|--tls-version|--database|--dsn|--completions|--prompt|--toolbar|--logfile|--checkpoint|--myclirc|--local-infile|--login-path|--execute|--init-command|--charset|--character-set|--batch|--format|--throttle|--use-keyring|--keepalive-ticks|--ssh-jump|--ssh-options|--boundary-id) + --host|--hostname|--port|--user|--username|--socket|--pass|--password|--password-file|--vault-address|--vault-mount|--vault-secret|--vault-password-field|--vault-username-field|--ssl-mode|--ssl-ca|--ssl-capath|--ssl-cert|--ssl-key|--ssl-cipher|--tls-version|--database|--dsn|--completions|--prompt|--toolbar|--logfile|--checkpoint|--myclirc|--local-infile|--login-path|--execute|--init-command|--charset|--character-set|--batch|--format|--throttle|--use-keyring|--keepalive-ticks|--ssh-jump|--ssh-options|--kubectl-resource|--kubectl-options|--boundary-id) expects_value=1 ;; -?*) diff --git a/mycli/resources/completions/fish/mycli.fish b/mycli/resources/completions/fish/mycli.fish index f6b3e089c..f7a18547d 100644 --- a/mycli/resources/completions/fish/mycli.fish +++ b/mycli/resources/completions/fish/mycli.fish @@ -28,7 +28,7 @@ function _mycli_is_positional_db_arg case -- set options_ended 1 case '--*=*' - case --host --hostname --port --user --username --socket --pass --password --password-file --vault-address --vault-mount --vault-secret --vault-password-field --vault-username-field --ssl-mode --ssl-ca --ssl-capath --ssl-cert --ssl-key --ssl-cipher --tls-version --database --dsn --completions --prompt --toolbar --logfile --checkpoint --myclirc --local-infile --login-path --execute --init-command --charset --character-set --batch --format --throttle --use-keyring --keepalive-ticks --ssh-jump --ssh-options --boundary-id + case --host --hostname --port --user --username --socket --pass --password --password-file --vault-address --vault-mount --vault-secret --vault-password-field --vault-username-field --ssl-mode --ssl-ca --ssl-capath --ssl-cert --ssl-key --ssl-cipher --tls-version --database --dsn --completions --prompt --toolbar --logfile --checkpoint --myclirc --local-infile --login-path --execute --init-command --charset --character-set --batch --format --throttle --use-keyring --keepalive-ticks --ssh-jump --ssh-options --kubectl-resource --kubectl-options --boundary-id set expects_value 1 case '-*' set -l option_length (string length -- "$word") diff --git a/mycli/resources/completions/zsh/_mycli b/mycli/resources/completions/zsh/_mycli index 97c8be1f6..f7a20830c 100644 --- a/mycli/resources/completions/zsh/_mycli +++ b/mycli/resources/completions/zsh/_mycli @@ -35,7 +35,7 @@ _mycli_is_positional_db_arg() { ;; --*=*) ;; - --host|--hostname|--port|--user|--username|--socket|--pass|--password|--password-file|--vault-address|--vault-mount|--vault-secret|--vault-password-field|--vault-username-field|--ssl-mode|--ssl-ca|--ssl-capath|--ssl-cert|--ssl-key|--ssl-cipher|--tls-version|--database|--dsn|--completions|--prompt|--toolbar|--logfile|--checkpoint|--myclirc|--local-infile|--login-path|--execute|--init-command|--charset|--character-set|--batch|--format|--throttle|--use-keyring|--keepalive-ticks|--ssh-jump|--ssh-options|--boundary-id) + --host|--hostname|--port|--user|--username|--socket|--pass|--password|--password-file|--vault-address|--vault-mount|--vault-secret|--vault-password-field|--vault-username-field|--ssl-mode|--ssl-ca|--ssl-capath|--ssl-cert|--ssl-key|--ssl-cipher|--tls-version|--database|--dsn|--completions|--prompt|--toolbar|--logfile|--checkpoint|--myclirc|--local-infile|--login-path|--execute|--init-command|--charset|--character-set|--batch|--format|--throttle|--use-keyring|--keepalive-ticks|--ssh-jump|--ssh-options|--kubectl-resource|--kubectl-options|--boundary-id) expects_value=1 ;; -?*) diff --git a/test/myclirc b/test/myclirc index 5a69857f0..d3f272bb3 100644 --- a/test/myclirc +++ b/test/myclirc @@ -414,6 +414,14 @@ ssh_options = -a -o ServerAliveInterval=60 -o ExitOnForwardFailure=yes -o IPQoS= # auto means: port if on Windows, socket otherwise. tunnel_method = auto +[kubectl] +# Path to the kubectl executable used by --kubectl-resource. +kubectl_executable = kubectl + +# Options to pass to "kubectl port-forward" before the resource name. +# This can be used, for example, to set the context. +kubectl_options = + [vault] # Path to the vault executable used for Vault integration. vault_executable = vault diff --git a/test/pytests/test_cli_runner.py b/test/pytests/test_cli_runner.py index 4bcf06b64..0a3b314f0 100644 --- a/test/pytests/test_cli_runner.py +++ b/test/pytests/test_cli_runner.py @@ -810,6 +810,28 @@ def test_run_from_cli_args_maps_dsn_ssh_jump_parameter(monkeypatch: pytest.Monke assert client.connect_calls[-1]['ssh_jump'] == 'bastion' +def test_run_from_cli_args_maps_dsn_kubectl_resource_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.dsn = 'mysql://user@host:3307/db?kubectl_resource=service%2Fmysql' + client = DummyMyCli() + + run_with_client(monkeypatch, cli_args, client) + + assert client.connect_calls[-1]['kubectl_resource'] == 'service/mysql' + assert client.connect_calls[-1]['port'] == 3307 + + +def test_run_from_cli_args_prefers_cli_kubectl_resource_over_dsn_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.dsn = 'mysql://user@host/db?kubectl_resource=service%2Fdsn' + cli_args.kubectl_resource = 'pod/cli' + client = DummyMyCli() + + run_with_client(monkeypatch, cli_args, client) + + assert client.connect_calls[-1]['kubectl_resource'] == 'pod/cli' + + def test_run_from_cli_args_maps_known_dsn_boundary_id_parameter(monkeypatch: pytest.MonkeyPatch) -> None: cli_args = make_cli_args() cli_args.dsn = 'mysql://user@host/db?boundary_id=ttcp_dsn' @@ -864,12 +886,62 @@ def test_run_from_cli_args_rejects_ssh_and_boundary_tunnels( assert client.connect_calls == [] assert secho_calls == [ ( - 'Error: --ssh-jump and --boundary-id are incompatible.', + 'Error: --ssh-jump, --kubectl-resource, and --boundary-id are mutually exclusive.', + {'err': True, 'fg': 'red'}, + ) + ] + + +@pytest.mark.parametrize( + ('kubectl_resource', 'ssh_jump', 'boundary_id'), + [ + ('service/mysql', 'bastion', None), + ('service/mysql', None, 'ttcp_123'), + ('service/mysql', 'bastion', 'ttcp_123'), + ], +) +def test_run_from_cli_args_rejects_kubectl_with_other_tunnels( + monkeypatch: pytest.MonkeyPatch, + kubectl_resource: str, + ssh_jump: str | None, + boundary_id: str | None, +) -> None: + cli_args = make_cli_args() + cli_args.kubectl_resource = kubectl_resource + cli_args.ssh_jump = ssh_jump + cli_args.boundary_id = boundary_id + client = DummyMyCli() + secho_calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr(cli_runner.click, 'secho', lambda text, **kwargs: secho_calls.append((text, kwargs))) + + with pytest.raises(SystemExit) as excinfo: + run_with_client(monkeypatch, cli_args, client) + + assert excinfo.value.code == 1 + assert client.connect_calls == [] + assert secho_calls == [ + ( + 'Error: --ssh-jump, --kubectl-resource, and --boundary-id are mutually exclusive.', {'err': True, 'fg': 'red'}, ) ] +def test_run_from_cli_args_rejects_kubectl_options_without_jump(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.kubectl_options = '--namespace database' + client = DummyMyCli() + secho_calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr(cli_runner.click, 'secho', lambda text, **kwargs: secho_calls.append((text, kwargs))) + + with pytest.raises(SystemExit) as excinfo: + run_with_client(monkeypatch, cli_args, client) + + assert excinfo.value.code == 1 + assert client.connect_calls == [] + assert secho_calls == [('Error: --kubectl-options requires --kubectl-resource.', {'err': True, 'fg': 'red'})] + + def test_run_from_cli_args_maps_percent_encoded_dsn_prompt(monkeypatch: pytest.MonkeyPatch) -> None: cli_args = make_cli_args() cli_args.dsn = 'mysql://user@host/db?prompt=%5Cu%40%5Ch%3A%5Cd%3E+' @@ -1257,6 +1329,18 @@ def test_run_from_cli_args_passes_ssh_options_to_connect(monkeypatch: pytest.Mon assert client.connect_calls[-1]['ssh_cli_options'] == '-o Compression=yes' +def test_run_from_cli_args_passes_kubectl_options_to_connect(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.kubectl_resource = 'service/mysql' + cli_args.kubectl_options = '--namespace database' + client = DummyMyCli() + + run_with_client(monkeypatch, cli_args, client) + + assert client.connect_calls[-1]['kubectl_resource'] == 'service/mysql' + assert client.connect_calls[-1]['kubectl_cli_options'] == '--namespace database' + + @pytest.mark.parametrize( ('ssh_connection', 'expected_use_keyring'), ( diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index 410e63e83..0ca8b49ce 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -398,11 +398,12 @@ def test_close_stops_refreshers_before_closing_connection_and_tunnels() -> None: cli.schema_prefetcher = SimpleNamespace(stop=lambda: calls.append('prefetch')) cli.sqlexecute = SimpleNamespace(close=lambda: calls.append('connection')) # type: ignore[assignment] cast(Any, cli).ssh_tunnel = SimpleNamespace(close=lambda: calls.append('ssh')) + cast(Any, cli).kubectl_tunnel = SimpleNamespace(close=lambda: calls.append('kubectl')) cli.boundary_tunnel = SimpleNamespace(close=lambda: calls.append('boundary')) # type: ignore[assignment] MyCli.close(cli) - assert calls == ['completion', 'prefetch', 'connection', 'ssh', 'boundary'] + assert calls == ['completion', 'prefetch', 'connection', 'ssh', 'kubectl', 'boundary'] def test_close_swallows_cleanup_errors() -> None: @@ -415,6 +416,7 @@ def fail() -> None: cli.schema_prefetcher = SimpleNamespace(stop=fail) cli.sqlexecute = SimpleNamespace(close=fail) # type: ignore[assignment] cast(Any, cli).ssh_tunnel = SimpleNamespace(close=fail) + cast(Any, cli).kubectl_tunnel = SimpleNamespace(close=fail) cli.boundary_tunnel = SimpleNamespace(close=lambda: (_ for _ in ()).throw(RuntimeError('close failed'))) # type: ignore[assignment] MyCli.close(cli) @@ -426,6 +428,7 @@ def test_close_swallows_boundary_tunnel_close_error() -> None: cli.sqlexecute = None tunnel_closed: list[bool] = [] cast(Any, cli).ssh_tunnel = SimpleNamespace(close=lambda: tunnel_closed.append(True)) + cast(Any, cli).kubectl_tunnel = None cli.boundary_tunnel = SimpleNamespace(close=lambda: (_ for _ in ()).throw(RuntimeError('close failed'))) # type: ignore[assignment] MyCli.close(cli) diff --git a/test/pytests/test_client_connection.py b/test/pytests/test_client_connection.py index 9cf12a067..df1aeb4c0 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -461,6 +461,150 @@ def close(self) -> None: assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://alice@db.internal:3307?ssh_jump=bastion' +def test_connect_uses_kubectl_tunnel_with_resolved_database_port(monkeypatch: pytest.MonkeyPatch) -> None: + tunnel_calls: list[dict[str, Any]] = [] + keyring_calls: list[tuple[Any, ...]] = [] + + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + + def __init__(self, **kwargs: Any) -> None: + tunnel_calls.append(kwargs) + + def start(self) -> None: + pass + + def close(self) -> None: + pass + + monkeypatch.setattr(client_connection, 'KubectlTunnel', FakeTunnel) + monkeypatch.setattr( + client_connection.keyring, + 'get_password', + lambda domain, identifier: keyring_calls.append(('get', domain, identifier)), + ) + monkeypatch.setattr( + client_connection.keyring, + 'set_password', + lambda domain, identifier, password: keyring_calls.append(('set', domain, identifier, password)), + ) + password_candidates = PasswordCandidates() + password_candidates.add_value('literal', 'secret') + client = DummyClient( + config={ + 'main': { + 'password_sources': ['literal'], + 'keyring_sources': ['literal'], + }, + 'connection': {}, + 'kubectl': { + 'kubectl_executable': '/opt/bin/kubectl', + 'kubectl_options': '--context prod', + }, + } + ) + + client.connect( + user='alice', + host='db.internal', + port=3307, + socket='/tmp/mysql.sock', + use_keyring=True, + password_candidates=password_candidates, + kubectl_resource='service/mysql', + kubectl_cli_options='--namespace database', + ) + + assert tunnel_calls == [ + { + 'resource': 'service/mysql', + 'remote_port': 3307, + 'kubectl_executable': '/opt/bin/kubectl', + 'kubectl_config_options': '--context prod', + 'kubectl_cli_options': '--namespace database', + } + ] + assert FakeSQLExecute.calls[-1]['host'] == '127.0.0.1' + assert FakeSQLExecute.calls[-1]['port'] == 4406 + assert FakeSQLExecute.calls[-1]['socket'] is None + assert FakeSQLExecute.calls[-1]['password'] == 'secret' + assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://alice@db.internal:3307?kubectl_resource=service%2Fmysql' + assert keyring_calls == [] + + +def test_connect_kubectl_tunnel_uses_default_database_port(monkeypatch: pytest.MonkeyPatch) -> None: + remote_ports: list[int] = [] + + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + + def __init__(self, *, remote_port: int, **_kwargs: Any) -> None: + remote_ports.append(remote_port) + + def start(self) -> None: + pass + + def close(self) -> None: + pass + + monkeypatch.setattr(client_connection, 'KubectlTunnel', FakeTunnel) + client = DummyClient() + + client.connect(user='alice', kubectl_resource='pod/mysql-0') + + assert remote_ports == [3306] + assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://alice@localhost:3306?kubectl_resource=pod%2Fmysql-0' + + +def test_connect_reports_kubectl_tunnel_start_error_and_closes_tunnel(monkeypatch: pytest.MonkeyPatch) -> None: + close_calls: list[bool] = [] + secho_calls: list[tuple[str, dict[str, Any]]] = [] + + class FakeTunnel: + def __init__(self, **_kwargs: Any) -> None: + pass + + def start(self) -> None: + raise client_connection.KubectlTunnelError('no resource') + + def close(self) -> None: + close_calls.append(True) + + monkeypatch.setattr(client_connection, 'KubectlTunnel', FakeTunnel) + monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs))) + client = DummyClient() + + with pytest.raises(SystemExit) as excinfo: + client.connect(host='db.internal', kubectl_resource='service/mysql') + + assert excinfo.value.code == 1 + assert close_calls == [True] + assert secho_calls == [('Error: Unable to start kubectl tunnel: no resource', {'err': True, 'fg': 'red'})] + assert FakeSQLExecute.calls == [] + + +def test_connect_swallows_kubectl_tunnel_cleanup_error(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeTunnel: + def __init__(self, **_kwargs: Any) -> None: + pass + + def start(self) -> None: + raise OSError('no process') + + def close(self) -> None: + raise RuntimeError('close failed') + + monkeypatch.setattr(client_connection, 'KubectlTunnel', FakeTunnel) + client = DummyClient() + + with pytest.raises(SystemExit) as excinfo: + client.connect(host='db.internal', kubectl_resource='service/mysql') + + assert excinfo.value.code == 1 + + def test_connect_with_vault_password_keeps_explicit_user_in_display_dsn() -> None: client = DummyClient() diff --git a/test/pytests/test_dsn_aliases.py b/test/pytests/test_dsn_aliases.py index 690ab2409..5d44eb725 100644 --- a/test/pytests/test_dsn_aliases.py +++ b/test/pytests/test_dsn_aliases.py @@ -536,7 +536,7 @@ def test_dsn_more_adds_non_default_runtime_parameters_in_sorted_order() -> None: ) aliases = DsnAliases(config, mycli) # type: ignore[arg-type] dsn = ( - 'mysql://user@host/db?boundary_id=ttcp_123&socket=%2Fruntime.sock&ssh_jump=bastion' + 'mysql://user@host/db?boundary_id=ttcp_123&kubectl_resource=service%2Fmysql&socket=%2Fruntime.sock&ssh_jump=bastion' '&vault_address=https%3A%2F%2Fruntime-vault&vault_mount=runtime-kv' '&vault_secret=database%2Fprod&vault_password_field=secret&vault_username_field=login' ) @@ -551,6 +551,7 @@ def test_dsn_more_adds_non_default_runtime_parameters_in_sorted_order() -> None: ('boundary_id', 'ttcp_123'), ('character_set', 'utf8'), ('keepalive_ticks', '45'), + ('kubectl_resource', 'service/mysql'), ('prompt', 'runtime> '), ('socket', '/runtime.sock'), ('ssh_jump', 'bastion'), diff --git a/test/pytests/test_kubectl_tunnel.py b/test/pytests/test_kubectl_tunnel.py new file mode 100644 index 000000000..2cd636b22 --- /dev/null +++ b/test/pytests/test_kubectl_tunnel.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import subprocess +import tempfile +from typing import Any, cast + +import pytest + +from mycli import kubectl_tunnel +from mycli.kubectl_tunnel import KubectlTunnel, KubectlTunnelError + + +def test_split_options_handles_windows_quotes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(kubectl_tunnel, 'WIN', True) + + assert kubectl_tunnel._split_options('--context "prod cluster"') == ['--context', 'prod cluster'] + + +def test_split_options_reports_invalid_quoting() -> None: + with pytest.raises(KubectlTunnelError, match='Unable to parse kubectl options') as excinfo: + kubectl_tunnel._split_options('--context "unterminated') + + assert isinstance(excinfo.value.__cause__, ValueError) + + +@pytest.mark.parametrize( + ('resource', 'remote_port', 'message'), + [ + ('', 3306, 'resource must not be empty'), + (' ', 3306, 'resource must not be empty'), + ('service/mysql', 0, 'remote port must be an integer between'), + ('service/mysql', 65536, 'remote port must be an integer between'), + ], +) +def test_kubectl_tunnel_rejects_invalid_target(resource: str, remote_port: int, message: str) -> None: + with pytest.raises(KubectlTunnelError, match=message): + KubectlTunnel(resource=resource, remote_port=remote_port) + + +def test_command_combines_config_and_cli_options() -> None: + tunnel = KubectlTunnel( + resource='service/mysql', + remote_port=3307, + kubectl_executable='/opt/bin/kubectl', + kubectl_config_options='--context prod --namespace database', + kubectl_cli_options='--pod-running-timeout 20s', + ) + + assert tunnel.command() == [ + '/opt/bin/kubectl', + 'port-forward', + '--context', + 'prod', + '--namespace', + 'database', + '--pod-running-timeout', + '20s', + '--address=127.0.0.1', + 'service/mysql', + ':3307', + ] + + +def test_start_waits_for_local_port(monkeypatch: pytest.MonkeyPatch) -> None: + popen_calls: list[tuple[list[str], dict[str, Any]]] = [] + sleep_calls: list[float] = [] + + class FakeProcess: + def __init__(self, command: list[str], stdout: Any, **kwargs: Any) -> None: + popen_calls.append((command, kwargs)) + + def poll(self) -> None: + return None + + def terminate(self) -> None: + pass + + def wait(self, timeout: float | None = None) -> int: + return 0 + + monkeypatch.setattr(kubectl_tunnel.subprocess, 'Popen', FakeProcess) + tunnel = KubectlTunnel(resource='service/mysql', remote_port=3306) + + def finish_startup(seconds: float) -> None: + sleep_calls.append(seconds) + assert tunnel._output_file is not None + tunnel._output_file.write(b'Forwarding from 127.0.0.1:4406 -> 3306\n') + + monkeypatch.setattr(kubectl_tunnel.time, 'sleep', finish_startup) + + tunnel.start() + tunnel.close() + + assert popen_calls[0][0] == [ + 'kubectl', + 'port-forward', + '--address=127.0.0.1', + 'service/mysql', + ':3306', + ] + assert popen_calls[0][1]['stdin'] is subprocess.DEVNULL + assert popen_calls[0][1]['stderr'] is not None + assert popen_calls[0][1]['start_new_session'] is True + assert sleep_calls == [0.05] + assert tunnel.local_port == 4406 + + +def test_start_reports_process_start_error(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_popen(*_args: Any, **_kwargs: Any) -> None: + raise FileNotFoundError('missing kubectl') + + monkeypatch.setattr(kubectl_tunnel.subprocess, 'Popen', fail_popen) + tunnel = KubectlTunnel(resource='service/mysql', remote_port=3306, local_port=4406) + + with pytest.raises(KubectlTunnelError, match='Unable to start kubectl port-forward process: missing kubectl') as excinfo: + tunnel.start() + + assert isinstance(excinfo.value.__cause__, FileNotFoundError) + assert tunnel._output_file is None + + +def test_start_closes_output_after_invalid_options() -> None: + tunnel = KubectlTunnel( + resource='service/mysql', + remote_port=3306, + local_port=4406, + kubectl_cli_options='"unterminated', + ) + + with pytest.raises(KubectlTunnelError, match='Unable to parse kubectl options'): + tunnel.start() + + assert tunnel._output_file is None + + +def test_start_reports_process_output_on_early_exit(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeProcess: + def __init__(self, *_args: Any, stdout: Any, **_kwargs: Any) -> None: + stdout.write(b'pods "mysql" not found\n') + + def poll(self) -> int: + return 1 + + monkeypatch.setattr(kubectl_tunnel.subprocess, 'Popen', FakeProcess) + tunnel = KubectlTunnel(resource='pod/mysql', remote_port=3306, local_port=4406) + + with pytest.raises(KubectlTunnelError, match='exited with status 1: pods "mysql" not found'): + tunnel.start() + + assert tunnel._output_file is None + + +def test_start_reports_timeout_with_process_output(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + class FakeProcess: + def __init__(self, *_args: Any, stdout: Any, **_kwargs: Any) -> None: + stdout.write(b'waiting for pod\n') + + def poll(self) -> None: + return None + + def terminate(self) -> None: + calls.append('terminate') + + def wait(self, timeout: float | None = None) -> int: + calls.append(f'wait:{timeout}') + return 0 + + monkeypatch.setattr(kubectl_tunnel.subprocess, 'Popen', FakeProcess) + tunnel = KubectlTunnel(resource='deployment/mysql', remote_port=3306, local_port=4406, ready_timeout=0) + + with pytest.raises(KubectlTunnelError, match='Timed out.*: waiting for pod'): + tunnel.start() + + assert calls == ['terminate', 'wait:5'] + assert tunnel._output_file is None + + +def test_close_kills_process_after_terminate_timeout() -> None: + calls: list[str] = [] + + class FakeProcess: + def __init__(self) -> None: + self.wait_calls = 0 + + def poll(self) -> None: + return None + + def terminate(self) -> None: + calls.append('terminate') + + def wait(self, timeout: float | None = None) -> int: + calls.append(f'wait:{timeout}') + self.wait_calls += 1 + if self.wait_calls == 1: + assert timeout is not None + raise subprocess.TimeoutExpired('kubectl', timeout) + return 0 + + def kill(self) -> None: + calls.append('kill') + + tunnel = KubectlTunnel(resource='service/mysql', remote_port=3306, local_port=4406) + tunnel.process = cast(Any, FakeProcess()) + + tunnel.close() + + assert calls == ['terminate', 'wait:5', 'kill', 'wait:None'] + + +def test_captured_output_is_empty_before_start() -> None: + tunnel = KubectlTunnel(resource='service/mysql', remote_port=3306, local_port=4406) + + assert tunnel._captured_output() == '' + + +@pytest.mark.parametrize( + ('output', 'expected'), + [ + ('Forwarding from 127.0.0.1:4406 -> 3306', 4406), + ('waiting for pod', None), + ], +) +def test_forwarded_local_port(output: str, expected: int | None) -> None: + tunnel = KubectlTunnel(resource='service/mysql', remote_port=3306, local_port=4406) + with tempfile.TemporaryFile(mode='w+b') as output_file: + output_file.write(output.encode()) + tunnel._output_file = output_file + + assert tunnel._forwarded_local_port() == expected diff --git a/test/pytests/test_main.py b/test/pytests/test_main.py index 25c3fc0b1..dd8dcefde 100644 --- a/test/pytests/test_main.py +++ b/test/pytests/test_main.py @@ -786,6 +786,30 @@ def test_help_strings_end_with_periods(): assert param.help.endswith(".") +def test_help_lists_kubectl_tunnel_options() -> None: + result = CliRunner().invoke(click_entrypoint, args=['--help']) + + assert result.exit_code == 0 + assert '--kubectl-resource TEXT' in result.output + assert '--kubectl-options TEXT' in result.output + + +def test_click_entrypoint_parses_kubectl_tunnel_options(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[CliArgs, Any]] = [] + monkeypatch.setattr(main, 'run_from_cli_args', lambda cli_args, client_factory: calls.append((cli_args, client_factory))) + + result = CliRunner().invoke( + click_entrypoint, + args=['--kubectl-resource', 'service/mysql', '--kubectl-options', '--context prod', '--port', '3307'], + ) + + assert result.exit_code == 0 + assert calls[0][0].kubectl_resource == 'service/mysql' + assert calls[0][0].kubectl_options == '--context prod' + assert calls[0][0].port == 3307 + assert calls[0][1] is MyCli + + def test_command_descriptions_end_with_periods(): """Make sure that mycli commands' descriptions end with a period.""" MyCli() diff --git a/test/pytests/test_special_utils.py b/test/pytests/test_special_utils.py index 5c13be1b4..1bd5de539 100644 --- a/test/pytests/test_special_utils.py +++ b/test/pytests/test_special_utils.py @@ -327,6 +327,21 @@ def test_format_connection_dsn_includes_ssh_jump() -> None: ) +def test_format_connection_dsn_includes_encoded_kubectl_resource() -> None: + assert ( + format_connection_dsn( + user='alice', + host='db.example.com', + port=3307, + database='prod', + socket=None, + character_set='utf8mb4', + kubectl_resource='service/my sql', + ) + == 'mysql://alice@db.example.com:3307/prod?kubectl_resource=service%2Fmy+sql' + ) + + def test_format_connection_dsn_includes_encoded_boundary_id() -> None: assert ( format_connection_dsn(