From 7671f7510f4706aca21b0ae4356e69f91605eb43 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 8 Aug 2026 16:05:57 -0400 Subject: [PATCH] add support for connecting via a Boundary tunnel https://developer.hashicorp.com/boundary/docs/what-is-boundary * Add CLI option --boundary-id. * Add [boundary_beta] section to ~/.myclirc. When this is fully configured, the single CLI option is all that is needed to connect. Boundary issues temporary credentials, which are plugged in. * Unless less_chatty is set, print a banner before connection showing the expiry time of the temporary connection. * When --verbose --verbose is given, print the temporary credentials before entering the REPL. Usecase: making additional connections over the same tunnel. * Allow boundary_id to be given in the query parameters of a DSN, and when connecting over a Boundary tunnel, return it in "/dsn show" by default. * Respect password_sources precedence, where there was already a stub value, unused. * Do _not_ include boundary in keyring sources. The credential is temporary; storing it in the system keyring would create clutter. There was not much abstraction which could be easily shared with SSH tunnels, so the code is separate, but adjacent. Suggested followups * Also bake in kubectl tunnels with "kubectl port-forward". * Also bake in gcloud ssh tunnels, or give an example for how to configure one with the existing SSH support. * Guide boundary authentication if it has expired, or at least give a more specific error message. * Resolve occasional SSL errors when making a boundary connection. --- AGENTS.md | 1 + changelog.md | 8 + mycli/boundary_tunnel.py | 157 +++++++++ mycli/cli_runner.py | 3 + mycli/client.py | 7 + mycli/client_connection.py | 59 ++++ mycli/constants.py | 1 + mycli/main.py | 5 + mycli/myclirc | 15 +- mycli/packages/special/utils.py | 3 + test/myclirc | 15 +- test/pytests/test_boundary_tunnel.py | 443 +++++++++++++++++++++++++ test/pytests/test_cli_runner.py | 47 +++ test/pytests/test_client.py | 39 ++- test/pytests/test_client_connection.py | 334 +++++++++++++++++++ test/pytests/test_dsn_aliases.py | 3 +- test/pytests/test_main.py | 69 +++- test/pytests/test_special_utils.py | 15 + 18 files changed, 1207 insertions(+), 17 deletions(-) create mode 100644 mycli/boundary_tunnel.py create mode 100644 test/pytests/test_boundary_tunnel.py diff --git a/AGENTS.md b/AGENTS.md index c458d64eb..da11c6ef7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ A command line client for MySQL with auto-completion and syntax highlighting. ├── mycli/ # application source ├── mycli/__init__.py # provides version number ├── mycli/app_state.py # `AppStateMixin` application state mixin and related functions +├── mycli/boundary_tunnel.py # connection over Boundary tunnel ├── mycli/cli_runner.py # connects and dispatches main modes based on CLI arguments ├── mycli/clibuffer.py # prompt_toolkit buffer utilities ├── mycli/client_commands.py # special commands which must be registered separately diff --git a/changelog.md b/changelog.md index 81436a287..08385bb97 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +Upcoming (TBD) +============== + +Features +--------- +* Add beta support for HashiCorp Boundary tunnels. + + 2.12.0 (2026/08/08) ============== diff --git a/mycli/boundary_tunnel.py b/mycli/boundary_tunnel.py new file mode 100644 index 000000000..73ce701a7 --- /dev/null +++ b/mycli/boundary_tunnel.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import datetime +import json +import os +import shlex +import socket +import subprocess +import threading +import time + + +class BoundaryTunnelError(RuntimeError): + pass + + +def _find_free_local_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(('127.0.0.1', 0)) + return int(sock.getsockname()[1]) + + +class BoundaryTunnel: + def __init__( + self, + *, + target_id: str, + boundary_executable: str = 'boundary', + address: str | None = None, + auth_method_id: str | None = None, + boundary_options: str | None = None, + local_port: int | None = None, + ready_timeout: float = 30.0, + ) -> None: + self.target_id = target_id + self.boundary_executable = boundary_executable + self.address = address + self.auth_method_id = auth_method_id + self.boundary_options = boundary_options + self.local_host = '127.0.0.1' + self.local_port = local_port or _find_free_local_port() + self.ready_timeout = ready_timeout + self.process: subprocess.Popen | None = None + self.stdout = '' + self._startup_error: OSError | ValueError | None = None + self._started = threading.Event() + self._ready = threading.Event() + self._failed = threading.Event() + self._thread: threading.Thread | None = None + self.username: str | None = None + self.password: str | None = None + self.expiry: str | None = None + + def command(self) -> list[str]: + options = shlex.split(self.boundary_options or '') + command = [ + self.boundary_executable, + 'connect', + *options, + f'-target-id={self.target_id}', + f'-listen-addr={self.local_host}', + f'-listen-port={self.local_port}', + '-format=json', + ] + if self.address: + command.append(f'-addr={self.address}') + return command + + def start(self, *, show_expiration_warning: bool = True) -> None: + self._thread = threading.Thread(target=self._run, name='mycli-boundary-tunnel', daemon=True) + self._thread.start() + deadline = time.monotonic() + self.ready_timeout + while time.monotonic() < deadline: + if self._failed.is_set(): + self.close() + if self._startup_error is not None: + raise BoundaryTunnelError(f'Unable to start Boundary tunnel process: {self._startup_error}') from self._startup_error + raise BoundaryTunnelError('Boundary tunnel process exited before it was ready.') + if self._started.is_set(): + self._read_stdout() + break + time.sleep(0.05) + else: + self.close() + raise BoundaryTunnelError('Timed out waiting for Boundary tunnel process to start.') + + connection_details = json.loads(self.stdout) + self.username = connection_details['credentials'][0]['secret']['decoded']['username'] + self.password = connection_details['credentials'][0]['secret']['decoded']['password'] + expiry_raw = connection_details['expiration'] + expiry_utc = datetime.datetime.strptime(expiry_raw, '%Y-%m-%dT%H:%M:%S.%f%z') + expiry_local = datetime.datetime.fromtimestamp(expiry_utc.timestamp()) + self.expiry = datetime.datetime.strftime(expiry_local, '%H:%M:%S %a %d %b %Y') + + while time.monotonic() < deadline: + if self._failed.is_set(): + self.close() + if self._startup_error is not None: + raise BoundaryTunnelError(f'Unable to start Boundary tunnel process: {self._startup_error}') from self._startup_error + raise BoundaryTunnelError('Boundary tunnel process exited before it was ready.') + if self._is_listening(): + self._ready.set() + return + time.sleep(0.05) + self.close() + raise BoundaryTunnelError('Timed out waiting for Boundary tunnel to become ready.') + + def _read_stdout(self) -> None: + process = self.process + if process is None or process.stdout is None: + return + self.stdout = process.stdout.readline().decode('utf-8') + + def _environment(self) -> dict[str, str] | None: + if not self.auth_method_id: + return None + environment = os.environ.copy() + environment['BOUNDARY_AUTH_METHOD_ID'] = self.auth_method_id + return environment + + 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._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=5) + + def _run(self) -> None: + try: + self.process = subprocess.Popen( + self.command(), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + start_new_session=True, + env=self._environment(), + ) + self._started.set() + except (OSError, ValueError) as exc: + self._startup_error = exc + self._failed.set() + return + return_code = self.process.wait() + if return_code != 0 and not self._ready.is_set(): + self._failed.set() + + def _is_listening(self) -> bool: + try: + with socket.create_connection((self.local_host, self.local_port), timeout=0.05): + return True + except OSError: + return False diff --git a/mycli/cli_runner.py b/mycli/cli_runner.py index 203ecc790..82be1efa4 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('boundary_id'): + cli_args.boundary_id = cli_args.boundary_id or params[0] if params := dsn_params.get('vault_address'): cli_args.vault_address = cli_args.vault_address or params[0] if params := dsn_params.get('vault_mount'): @@ -521,6 +523,7 @@ def load_vault_password() -> str | None: vault_password_field=cli_args.vault_password_field, vault_username_field=cli_args.vault_username_field, vault_username_from_vault=vault_username_from_vault, + boundary_target_id=cli_args.boundary_id, ) if combined_init_cmd: diff --git a/mycli/client.py b/mycli/client.py index 51d305d39..1d34f2c55 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -20,6 +20,7 @@ normalize_image_protocol, normalize_ssl_mode, ) +from mycli.boundary_tunnel import BoundaryTunnel from mycli.client_commands import ClientCommandsMixin, get_config_property_names from mycli.client_connection import ClientConnectionMixin from mycli.client_query import ClientQueryMixin @@ -79,6 +80,7 @@ def __init__( ) -> None: self.sqlexecute = sqlexecute self.ssh_tunnel: SshTunnel | None = None + self.boundary_tunnel: BoundaryTunnel | None = None self.logfile = logfile self.login_path = login_path self.toolbar_error_message: str | None = None @@ -253,6 +255,11 @@ def close(self) -> None: self.ssh_tunnel.close() except Exception: pass + if self.boundary_tunnel: + try: + self.boundary_tunnel.close() + except Exception: + pass def run_cli(self) -> None: repl_package.main_repl(self) diff --git a/mycli/client_connection.py b/mycli/client_connection.py index 66a3a9e16..16d3c4e05 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -12,6 +12,7 @@ from pymysql.constants.CR import CR_SERVER_LOST from pymysql.constants.ER import ACCESS_DENIED_ERROR, HANDSHAKE_ERROR +from mycli.boundary_tunnel import BoundaryTunnel, BoundaryTunnelError from mycli.compat import WIN from mycli.config import str_to_bool from mycli.constants import ( @@ -40,8 +41,10 @@ class ClientConnectionMixin: config_without_package_defaults: Any keepalive_ticks: int | None sandbox_mode: bool + verbosity: int sqlexecute: Any logger: Any + boundary_tunnel: BoundaryTunnel | None def read_mylogin_cnf(self, cnf: Any) -> dict[str, Any]: ... def echo(self, *args: Any, **kwargs: Any) -> None: ... @@ -70,6 +73,7 @@ def connect( vault_password_field: str | None = None, vault_username_field: str | None = None, vault_username_from_vault: bool = False, + boundary_target_id: str | None = None, ) -> None: mylogin_cnf: dict[str, Any] = self.read_mylogin_cnf(self.mylogin_cnf) # Fall back to .mylogin.cnf values only if user did not specify a value. @@ -81,6 +85,7 @@ def connect( self.keepalive_ticks = keepalive_ticks self.ssh_tunnel = None self.selected_password = None + self.boundary_tunnel = None int_port = port and int(port) if not int_port: @@ -125,9 +130,48 @@ def connect( except Exception: pass sys.exit(1) + elif boundary_target_id: + use_keyring = False + boundary_executable = self.config.get('boundary_beta', {}).get('boundary_executable', 'boundary') or 'boundary' + boundary_address = self.config.get('boundary_beta', {}).get('address') or None + boundary_auth_method_id = self.config.get('boundary_beta', {}).get('auth_method_id') or None + boundary_options = self.config.get('boundary_beta', {}).get('boundary_options') or None + try: + self.boundary_tunnel = BoundaryTunnel( + target_id=boundary_target_id, + boundary_executable=boundary_executable, + address=boundary_address, + auth_method_id=boundary_auth_method_id, + boundary_options=boundary_options, + ) + self.boundary_tunnel.start(show_expiration_warning=self.verbosity >= 0) + if self.verbosity >= 0: + click.secho( + f'The Boundary db connection will expire at: {self.boundary_tunnel.expiry}', + fg='white', + bg='red', + bold=True, + err=True, + ) + if self.verbosity >= 2: + click.secho(f'Temporary username: {self.boundary_tunnel.username}', err=True) + click.secho(f'Temporary password: {self.boundary_tunnel.password}', err=True) + click.secho(f'Temporary host: {self.boundary_tunnel.local_host}', err=True) + click.secho(f'Temporary port: {self.boundary_tunnel.local_port}', err=True) + socket = None + except (OSError, BoundaryTunnelError) as exc: + click.secho(f'Error: Unable to start Boundary tunnel: {exc}', err=True, fg='red') + try: + if self.boundary_tunnel: + self.boundary_tunnel.close() + except Exception: + pass + sys.exit(1) if password_candidates is None: password_candidates = PasswordCandidates() + if self.boundary_tunnel: + password_candidates.add_value('boundary', self.boundary_tunnel.password) if not character_set: if 'main' in self.config_without_package_defaults and 'default_character_set' in self.config_without_package_defaults['main']: @@ -219,6 +263,16 @@ def connect( vault_password_field=vault_password_field, vault_username_field=vault_username_field, ) + elif self.boundary_tunnel: + display_dsn = format_connection_dsn( + user=None, + host=host, + port=None, + socket=None, + database=database, + character_set=character_set, + boundary_id=boundary_target_id, + ) elif vault_secret: display_dsn = format_connection_dsn( user=display_dsn_user, @@ -254,6 +308,11 @@ def connect( connection_info['host'] = self.ssh_tunnel.local_host connection_info['port'] = self.ssh_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 + connection_info['port'] = self.boundary_tunnel.local_port + connection_info['socket'] = None else: connection_info['host'] = host connection_info['port'] = int_port diff --git a/mycli/constants.py b/mycli/constants.py index b9116093a..d4cc6a252 100644 --- a/mycli/constants.py +++ b/mycli/constants.py @@ -5,6 +5,7 @@ DEFAULT_CHARSET = 'utf8mb4' KNOWN_DSN_QUERY_PARAMS = { + 'boundary_id', 'character_set', 'keepalive_ticks', 'prompt', diff --git a/mycli/main.py b/mycli/main.py index a35c95bf3..25449b6db 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -321,6 +321,11 @@ class CliArgs: type=str, help='Extra CLI arguments for SSH with --ssh-jump, placed after options from myclirc.', ) + boundary_id: str | None = clickdc.option( + type=str, + help='BETA: open a HashiCorp Boundary tunnel to TARGET_ID and connect through it.', + hidden=True, + ) checkup: bool = clickdc.option( is_flag=True, help='Run a checkup on your configuration.', diff --git a/mycli/myclirc b/mycli/myclirc index 0e73f04fc..e1545849a 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -233,7 +233,7 @@ pager = 'less' # * file # --password-file= at the CLI # * environment # $MYSQL_PWD environment variable # * dsn # mysql://user:password@ field in a DSN -# * boundary # value provided by Boundary client (not yet implemented) +# * boundary # value provided by Boundary client # * vault # value retrieved from "vault kv get" # * login_path # --login-path= at the CLI # * keyring # value retrieved from system keyring @@ -422,6 +422,19 @@ default_password_field = password # Field/property containing the username, if --vault-username-field is not provided. default_username_field = username +[boundary_beta] +# Path to the HashiCorp Boundary executable used by --boundary-id. +boundary_executable = boundary + +# Address of the Boundary controller. If empty, use Boundary's default resolution. +address = + +# Boundary auth method ID. If empty, use BOUNDARY_AUTH_METHOD_ID from the environment. +auth_method_id = + +# Additional options passed to the "boundary connect" command. +boundary_options = + # Custom colors for the completion menu, toolbar, etc, with actual support # depending on the terminal, and the property being set. # Colors: #ffffff, bg:#ffffff, border:#ffffff. diff --git a/mycli/packages/special/utils.py b/mycli/packages/special/utils.py index 13d9c0f3c..b2c00560e 100644 --- a/mycli/packages/special/utils.py +++ b/mycli/packages/special/utils.py @@ -155,6 +155,7 @@ def format_connection_dsn( database: str | None, socket: str | None, character_set: str | None, + boundary_id: str | None = None, ssh_jump: str | None = None, vault_address: str | None = None, vault_mount: str | None = None, @@ -174,6 +175,8 @@ def format_connection_dsn( port_part = '' if character_set and character_set != 'utf8mb4': query_part['character_set'] = character_set + if boundary_id: + query_part['boundary_id'] = boundary_id if ssh_jump: query_part['ssh_jump'] = ssh_jump if vault_address: diff --git a/test/myclirc b/test/myclirc index 6cbd7e7cc..cbaf13a80 100644 --- a/test/myclirc +++ b/test/myclirc @@ -233,7 +233,7 @@ pager = python test/features/wrappager.py ---boundary--- # * file # --password-file= at the CLI # * environment # $MYSQL_PWD environment variable # * dsn # mysql://user:password@ field in a DSN -# * boundary # value provided by Boundary client (not yet implemented) +# * boundary # value provided by Boundary client # * vault # value retrieved from "vault kv get" # * login_path # --login-path= at the CLI # * keyring # value retrieved from system keyring @@ -422,6 +422,19 @@ default_password_field = password # Field/property containing the username, if --vault-username-field is not provided. default_username_field = username +[boundary_beta] +# Path to the HashiCorp Boundary executable used by --boundary-id. +boundary_executable = boundary + +# Address of the Boundary controller. If empty, use Boundary's default resolution. +address = + +# Boundary auth method ID. If empty, use BOUNDARY_AUTH_METHOD_ID from the environment. +auth_method_id = + +# Additional options passed to the "boundary connect" command. +boundary_options = + # Custom colors for the completion menu, toolbar, etc, with actual support # depending on the terminal, and the property being set. # Colors: #ffffff, bg:#ffffff, border:#ffffff. diff --git a/test/pytests/test_boundary_tunnel.py b/test/pytests/test_boundary_tunnel.py new file mode 100644 index 000000000..4ca0661a0 --- /dev/null +++ b/test/pytests/test_boundary_tunnel.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import os +import socket +import subprocess +from typing import Any, cast + +import pytest + +from mycli import boundary_tunnel +from mycli.boundary_tunnel import BoundaryTunnel, BoundaryTunnelError + +CONNECTION_DETAILS = ( + '{"address":"127.0.0.1","credentials":[{"secret":{"decoded":{"username":"1234","password":"5678"}}}],' + '"expiration":"2030-01-02T03:04:05.000000+0000"}\n' +) + + +@pytest.mark.parametrize('address', [None, '']) +def test_boundary_tunnel_command_uses_target_and_local_listener(address: str | None) -> None: + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_executable='/opt/bin/boundary', + address=address, + local_port=4406, + ) + + assert tunnel.command() == [ + '/opt/bin/boundary', + 'connect', + '-target-id=ttcp_123', + '-listen-addr=127.0.0.1', + '-listen-port=4406', + '-format=json', + ] + + +def test_boundary_tunnel_command_uses_configured_address() -> None: + tunnel = BoundaryTunnel( + target_id='ttcp_123', + address='https://boundary.example.com', + local_port=4406, + ) + + assert tunnel.command()[-1] == '-addr=https://boundary.example.com' + + +@pytest.mark.parametrize('boundary_options', [None, '']) +def test_boundary_tunnel_command_ignores_empty_options(boundary_options: str | None) -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', boundary_options=boundary_options, local_port=4406) + + assert tunnel.command()[2] == '-target-id=ttcp_123' + + +def test_boundary_tunnel_command_splits_options_before_generated_flags() -> None: + tunnel = BoundaryTunnel( + target_id='ttcp_123', + boundary_options='-name "my database" -target-id=ignored', + local_port=4406, + ) + + assert tunnel.command()[2:6] == [ + '-name', + 'my database', + '-target-id=ignored', + '-target-id=ttcp_123', + ] + + +@pytest.mark.parametrize('auth_method_id', [None, '']) +def test_boundary_tunnel_environment_uses_parent_default(auth_method_id: str | None) -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', auth_method_id=auth_method_id) + + assert tunnel._environment() is None + + +def test_boundary_tunnel_environment_sets_configured_auth_method(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('BOUNDARY_AUTH_METHOD_ID', 'ampw_parent') + tunnel = BoundaryTunnel(target_id='ttcp_123', auth_method_id='ampw_config') + + environment = tunnel._environment() + + assert environment is not None + assert environment['BOUNDARY_AUTH_METHOD_ID'] == 'ampw_config' + assert os.environ['BOUNDARY_AUTH_METHOD_ID'] == 'ampw_parent' + + +def test_boundary_tunnel_allocates_local_port(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(boundary_tunnel, '_find_free_local_port', lambda: 4406) + + tunnel = BoundaryTunnel(target_id='ttcp_123') + + assert tunnel.local_port == 4406 + + +def test_find_free_local_port_returns_available_port() -> None: + port = boundary_tunnel._find_free_local_port() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(('127.0.0.1', port)) + + +def test_boundary_tunnel_start_reads_stdout_in_main_thread(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + class FakeProcess: + returncode = 0 + + def poll(self) -> None: + return None + + def terminate(self) -> None: + pass + + def wait(self, timeout: float | None = None) -> int: + return 0 + + def fake_run() -> None: + tunnel.process = cast(Any, FakeProcess()) + tunnel._started.set() + + def fake_read_stdout() -> None: + calls.append('read_stdout') + tunnel.stdout = CONNECTION_DETAILS + + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + monkeypatch.setattr(tunnel, '_run', fake_run) + monkeypatch.setattr(tunnel, '_read_stdout', fake_read_stdout) + checks = iter([False, True]) + monkeypatch.setattr(tunnel, '_is_listening', lambda: next(checks)) + + tunnel.start() + + assert calls == ['read_stdout'] + assert tunnel.stdout == CONNECTION_DETAILS + assert tunnel.username == '1234' + assert tunnel.password == '5678' + assert tunnel.expiry is not None + + +def test_boundary_tunnel_start_waits_for_process_to_start(monkeypatch: pytest.MonkeyPatch) -> None: + sleeps: list[float] = [] + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + + def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + tunnel._started.set() + + def fake_read_stdout() -> None: + tunnel.stdout = CONNECTION_DETAILS + + monkeypatch.setattr(tunnel, '_run', lambda: None) + monkeypatch.setattr(tunnel, '_read_stdout', fake_read_stdout) + monkeypatch.setattr(tunnel, '_is_listening', lambda: True) + monkeypatch.setattr(boundary_tunnel.time, 'sleep', fake_sleep) + + tunnel.start() + + assert sleeps == [0.05] + + +def test_boundary_tunnel_start_reports_process_exit_before_ready(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeStdout: + def readline(self) -> bytes: + return b'{"error":"failed"}\n' + + class FakeProcess: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + self.returncode = 1 + self.stdout = FakeStdout() + + def poll(self) -> int: + return 1 + + def wait(self, timeout: float | None = None) -> int: + return 1 + + monkeypatch.setattr(boundary_tunnel.subprocess, 'Popen', FakeProcess) + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + monkeypatch.setattr(tunnel, '_is_listening', lambda: False) + + with pytest.raises(BoundaryTunnelError, match='exited before it was ready'): + tunnel.start() + + +def test_boundary_tunnel_start_reports_process_exit_after_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + + def fake_run() -> None: + tunnel._started.set() + + def fake_read_stdout() -> None: + tunnel.stdout = CONNECTION_DETAILS + tunnel._failed.set() + + monkeypatch.setattr(tunnel, '_run', fake_run) + monkeypatch.setattr(tunnel, '_read_stdout', fake_read_stdout) + + with pytest.raises(BoundaryTunnelError, match='exited before it was ready'): + tunnel.start() + + +def test_boundary_tunnel_start_reports_startup_error_after_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + + def fake_run() -> None: + tunnel._started.set() + + def fake_read_stdout() -> None: + tunnel.stdout = CONNECTION_DETAILS + tunnel._startup_error = OSError('boundary failed') + tunnel._failed.set() + + monkeypatch.setattr(tunnel, '_run', fake_run) + monkeypatch.setattr(tunnel, '_read_stdout', fake_read_stdout) + + with pytest.raises(BoundaryTunnelError, match='Unable to start Boundary tunnel process: boundary failed') as excinfo: + tunnel.start() + + assert isinstance(excinfo.value.__cause__, OSError) + + +def test_boundary_tunnel_start_reports_process_start_error(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_popen(*_args: Any, **_kwargs: Any) -> None: + raise FileNotFoundError('missing boundary') + + monkeypatch.setattr(boundary_tunnel.subprocess, 'Popen', fail_popen) + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + monkeypatch.setattr(tunnel, '_is_listening', lambda: False) + + with pytest.raises(BoundaryTunnelError, match='Unable to start Boundary tunnel process: missing boundary') as excinfo: + tunnel.start() + + assert isinstance(excinfo.value.__cause__, FileNotFoundError) + + +def test_boundary_tunnel_start_reports_invalid_options() -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', boundary_options='"unterminated', local_port=4406) + + with pytest.raises(BoundaryTunnelError, match='Unable to start Boundary tunnel process: No closing quotation') as excinfo: + tunnel.start() + + assert isinstance(excinfo.value.__cause__, ValueError) + + +def test_boundary_tunnel_start_reports_process_start_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406, ready_timeout=0) + monkeypatch.setattr(tunnel, '_run', lambda: None) + + with pytest.raises(BoundaryTunnelError, match='Timed out waiting for Boundary tunnel process to start'): + tunnel.start() + + +def test_boundary_tunnel_start_reports_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + tunnel.stdout = CONNECTION_DETAILS + + def fake_run() -> None: + tunnel._started.set() + + monkeypatch.setattr(tunnel, '_run', fake_run) + monkeypatch.setattr(tunnel, '_is_listening', lambda: False) + monotonic_values = iter([0.0, 0.0, 31.0]) + monkeypatch.setattr(boundary_tunnel.time, 'monotonic', lambda: next(monotonic_values)) + + with pytest.raises(BoundaryTunnelError, match='Timed out waiting for Boundary tunnel'): + tunnel.start() + + +def test_boundary_tunnel_read_stdout_ignores_missing_process_or_stdout() -> None: + class FakeProcess: + stdout = None + + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + + tunnel._read_stdout() + assert tunnel.stdout == '' + + tunnel.process = cast(Any, FakeProcess()) + tunnel._read_stdout() + assert tunnel.stdout == '' + + +def test_boundary_tunnel_read_stdout_decodes_process_output() -> None: + class FakeStdout: + def readline(self) -> bytes: + return CONNECTION_DETAILS.encode('utf-8') + + class FakeProcess: + stdout = FakeStdout() + + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + tunnel.process = cast(Any, FakeProcess()) + + tunnel._read_stdout() + + assert tunnel.stdout == CONNECTION_DETAILS + + +@pytest.mark.parametrize( + ('return_code', 'ready', 'expected_failed'), + [(0, False, False), (1, True, False), (1, False, True)], +) +def test_boundary_tunnel_run_tracks_process_status( + monkeypatch: pytest.MonkeyPatch, + return_code: int, + ready: bool, + expected_failed: bool, +) -> None: + popen_calls: list[tuple[list[str], dict[str, Any]]] = [] + + class FakeProcess: + def wait(self) -> int: + return return_code + + def fake_popen(command: list[str], **kwargs: Any) -> FakeProcess: + popen_calls.append((command, kwargs)) + return FakeProcess() + + monkeypatch.setattr(boundary_tunnel.subprocess, 'Popen', fake_popen) + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + if ready: + tunnel._ready.set() + + tunnel._run() + + assert tunnel._started.is_set() + assert tunnel._failed.is_set() is expected_failed + assert popen_calls == [ + ( + tunnel.command(), + { + 'stdin': subprocess.DEVNULL, + 'stdout': subprocess.PIPE, + 'stderr': subprocess.DEVNULL, + 'start_new_session': True, + 'env': None, + }, + ) + ] + + +def test_boundary_tunnel_close_terminates_running_process() -> None: + calls: list[str] = [] + + class FakeProcess: + 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 + + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + tunnel.process = cast(Any, FakeProcess()) + + tunnel.close() + + assert calls == ['terminate', 'wait:5'] + + +def test_boundary_tunnel_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('boundary', timeout) + return 0 + + def kill(self) -> None: + calls.append('kill') + + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + tunnel.process = cast(Any, FakeProcess()) + + tunnel.close() + + assert calls == ['terminate', 'wait:5', 'kill', 'wait:None'] + + +def test_boundary_tunnel_close_joins_running_thread() -> None: + calls: list[str] = [] + + class FakeThread: + def is_alive(self) -> bool: + return True + + def join(self, timeout: float | None = None) -> None: + calls.append(f'join:{timeout}') + + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + tunnel._thread = cast(Any, FakeThread()) + + tunnel.close() + + assert calls == ['join:5'] + + +def test_boundary_tunnel_is_listening_returns_true(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[tuple[str, int], float]] = [] + + class FakeConnection: + def __enter__(self) -> 'FakeConnection': + return self + + def __exit__(self, *_args: Any) -> None: + pass + + def fake_create_connection(address: tuple[str, int], timeout: float) -> FakeConnection: + calls.append((address, timeout)) + return FakeConnection() + + monkeypatch.setattr(boundary_tunnel.socket, 'create_connection', fake_create_connection) + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + + assert tunnel._is_listening() is True + assert calls == [(('127.0.0.1', 4406), 0.05)] + + +def test_boundary_tunnel_is_listening_returns_false(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_create_connection(*_args: Any, **_kwargs: Any) -> None: + raise socket.timeout + + monkeypatch.setattr(boundary_tunnel.socket, 'create_connection', fail_create_connection) + tunnel = BoundaryTunnel(target_id='ttcp_123', local_port=4406) + + assert tunnel._is_listening() is False diff --git a/test/pytests/test_cli_runner.py b/test/pytests/test_cli_runner.py index d13926721..a85702dc4 100644 --- a/test/pytests/test_cli_runner.py +++ b/test/pytests/test_cli_runner.py @@ -810,6 +810,30 @@ 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_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' + client = DummyMyCli() + secho_calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setattr(cli_runner.click, 'secho', lambda text, **kwargs: secho_calls.append((text, kwargs))) + + run_with_client(monkeypatch, cli_args, client) + + assert client.connect_calls[-1]['boundary_target_id'] == 'ttcp_dsn' + assert secho_calls == [] + + +def test_run_from_cli_args_prefers_cli_boundary_id_over_dsn_parameter(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.dsn = 'mysql://user@host/db?boundary_id=ttcp_dsn' + cli_args.boundary_id = 'ttcp_cli' + client = DummyMyCli() + + run_with_client(monkeypatch, cli_args, client) + + assert client.connect_calls[-1]['boundary_target_id'] == 'ttcp_cli' + + 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+' @@ -1465,3 +1489,26 @@ def fake_main_batch_from_stdin(mycli: DummyMyCli, args: main.CliArgs) -> int: assert excinfo.value.code == 14 assert batch_calls == [(client, cli_args)] assert client.close_called is True + + +def test_run_from_cli_args_passes_boundary_target_id(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.boundary_id = 'ttcp_123' + client = DummyMyCli() + + run_with_client(monkeypatch, cli_args, client) + + assert client.connect_calls[-1]['boundary_target_id'] == 'ttcp_123' + + +def test_run_from_cli_args_closes_client_when_mode_exits(monkeypatch: pytest.MonkeyPatch) -> None: + cli_args = make_cli_args() + cli_args.execute = 'select 1' + client = DummyMyCli() + monkeypatch.setattr(cli_runner, 'main_execute_from_cli', lambda _mycli, _cli_args: 7) + + with pytest.raises(SystemExit) as excinfo: + run_with_client(monkeypatch, cli_args, client) + + assert excinfo.value.code == 7 + assert client.close_called is True diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index 0fa108a90..6687ab557 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -370,20 +370,44 @@ def test_init_reports_unreadable_mylogin_cnf(monkeypatch: pytest.MonkeyPatch, tm assert 'Error: Unable to read login path file.' in capsys.readouterr().out +def test_init_reads_mylogin_cnf(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patch_constructor_side_effects(monkeypatch) + mylogin_handle = object() + read_calls: list[object] = [] + monkeypatch.setattr(client_module, 'get_mylogin_cnf_path', lambda: '/tmp/mylogin.cnf') + monkeypatch.setattr(client_module, 'open_mylogin_cnf', lambda path: mylogin_handle) + + def fake_read_config_file(handle: object, list_values: bool = True) -> dict[str, str]: + read_calls.append(handle) + assert list_values is False + return {'client': 'config'} + + monkeypatch.setattr(client_module, 'read_config_file', fake_read_config_file) + myclirc = write_myclirc(tmp_path, '') + + cli = MyCli(myclirc=myclirc) + + assert read_calls == [mylogin_handle] + assert cli.mylogin_cnf == {'client': 'config'} + + def test_close_stops_schema_prefetcher_and_closes_sqlexecute() -> None: cli = MyCli.__new__(MyCli) stopped: list[bool] = [] closed: list[bool] = [] - tunnel_closed: list[bool] = [] + ssh_tunnel_closed: list[bool] = [] + boundary_tunnel_closed: list[bool] = [] cli.schema_prefetcher = SimpleNamespace(stop=lambda: stopped.append(True)) cli.sqlexecute = SimpleNamespace(close=lambda: closed.append(True)) # type: ignore[assignment] - cast(Any, cli).ssh_tunnel = SimpleNamespace(close=lambda: tunnel_closed.append(True)) + cast(Any, cli).ssh_tunnel = SimpleNamespace(close=lambda: ssh_tunnel_closed.append(True)) + cli.boundary_tunnel = SimpleNamespace(close=lambda: boundary_tunnel_closed.append(True)) # type: ignore[assignment] MyCli.close(cli) assert stopped == [True] assert closed == [True] - assert tunnel_closed == [True] + assert ssh_tunnel_closed == [True] + assert boundary_tunnel_closed == [True] def test_close_swallows_cleanup_errors() -> None: @@ -395,7 +419,16 @@ 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) + cli.boundary_tunnel = SimpleNamespace(close=lambda: (_ for _ in ()).throw(RuntimeError('close failed'))) # type: ignore[assignment] + MyCli.close(cli) + +def test_close_swallows_boundary_tunnel_close_error() -> None: + cli = MyCli.__new__(MyCli) + cli.sqlexecute = None + tunnel_closed: list[bool] = [] + cast(Any, cli).ssh_tunnel = SimpleNamespace(close=lambda: tunnel_closed.append(True)) + 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 c5773062d..bcc3464e6 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -35,6 +35,7 @@ def __init__( cnf: dict[str, Any] | None = None, config: dict[str, Any] | None = None, config_without_package_defaults: dict[str, Any] | None = None, + verbosity: int = 0, ) -> None: self.cnf = cnf or default_cnf() self.mylogin_cnf = object() @@ -48,6 +49,7 @@ def __init__( self.config_without_package_defaults = config_without_package_defaults or {} self.keepalive_ticks: int | None = None self.sandbox_mode = False + self.verbosity = verbosity self.sqlexecute: Any = None self.logger = DummyLogger() self.echo_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] @@ -646,7 +648,339 @@ def close(self) -> None: with pytest.raises(SystemExit) as excinfo: client.connect(host='db.internal', ssh_jump='bastion') + assert excinfo.value.code == 1 + + +@pytest.mark.parametrize( + ('verbosity', 'expected_warning', 'expected_credentials'), + [ + (-1, False, []), + ( + 0, + True, + [ + ( + 'The Boundary db connection will expire at: 03:04:05 Wed 02 Jan 2030', + {'fg': 'white', 'bg': 'red', 'bold': True, 'err': True}, + ), + ], + ), + ( + 1, + True, + [ + ( + 'The Boundary db connection will expire at: 03:04:05 Wed 02 Jan 2030', + {'fg': 'white', 'bg': 'red', 'bold': True, 'err': True}, + ), + ], + ), + ( + 2, + True, + [ + ( + 'The Boundary db connection will expire at: 03:04:05 Wed 02 Jan 2030', + {'fg': 'white', 'bg': 'red', 'bold': True, 'err': True}, + ), + ('Temporary username: 1234', {'err': True}), + ('Temporary password: 5678', {'err': True}), + ('Temporary host: 127.0.0.1', {'err': True}), + ('Temporary port: 4406', {'err': True}), + ], + ), + ], +) +def test_connect_uses_boundary_tunnel( + monkeypatch: pytest.MonkeyPatch, + verbosity: int, + expected_warning: bool, + expected_credentials: list[tuple[str, dict[str, bool]]], +) -> None: + tunnel_calls: list[dict[str, Any]] = [] + warning_calls: list[bool] = [] + secho_calls: list[tuple[str, dict[str, Any]]] = [] + + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + username = '1234' + password = '5678' + expiry = '03:04:05 Wed 02 Jan 2030' + + def __init__( + self, + *, + target_id: str, + boundary_executable: str, + address: str | None, + auth_method_id: str | None, + boundary_options: str | None, + ) -> None: + tunnel_calls.append({ + 'target_id': target_id, + 'boundary_executable': boundary_executable, + 'address': address, + 'auth_method_id': auth_method_id, + 'boundary_options': boundary_options, + }) + + def start(self, *, show_expiration_warning: bool) -> None: + warning_calls.append(show_expiration_warning) + + def close(self) -> None: + pass + + monkeypatch.setattr(client_connection, 'BoundaryTunnel', FakeTunnel) + monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs))) + client = DummyClient( + config={ + 'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, + 'boundary_beta': { + 'boundary_executable': '/opt/bin/boundary', + 'address': 'https://boundary.example.com', + 'auth_method_id': 'ampw_123', + 'boundary_options': '-token env://BOUNDARY_TOKEN', + }, + 'connection': {}, + }, + verbosity=verbosity, + ) + + client.connect( + user='alice', + host='db.internal', + port=3307, + socket='/tmp/mysql.sock', + boundary_target_id='ttcp_123', + ) + + assert tunnel_calls == [ + { + 'target_id': 'ttcp_123', + 'boundary_executable': '/opt/bin/boundary', + 'address': 'https://boundary.example.com', + 'auth_method_id': 'ampw_123', + 'boundary_options': '-token env://BOUNDARY_TOKEN', + } + ] + assert warning_calls == [expected_warning] + assert secho_calls == expected_credentials + 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'] == '5678' + assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://db.internal?boundary_id=ttcp_123' + assert client.selected_password is not None + assert client.selected_password.source == 'boundary' + + +@pytest.mark.parametrize( + ('password_sources', 'expected_source', 'expected_password'), + [ + (['dsn', 'boundary'], 'dsn', 'dsn-secret'), + (['boundary', 'dsn'], 'boundary', '5678'), + (['dsn'], 'dsn', 'dsn-secret'), + ], +) +def test_connect_boundary_tunnel_respects_password_source_precedence( + monkeypatch: pytest.MonkeyPatch, + password_sources: list[str], + expected_source: str, + expected_password: str, +) -> None: + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + username = '1234' + password = '5678' + expiry = '03:04:05 Wed 02 Jan 2030' + + def __init__( + self, + *, + target_id: str, + boundary_executable: str, + address: str | None, + auth_method_id: str | None, + boundary_options: str | None, + ) -> None: + pass + + def start(self, *, show_expiration_warning: bool) -> None: + pass + + def close(self) -> None: + pass + + candidates = PasswordCandidates() + candidates.add_value('dsn', 'dsn-secret') + monkeypatch.setattr(client_connection, 'BoundaryTunnel', FakeTunnel) + client = DummyClient( + config={'main': {'password_sources': password_sources}, 'connection': {}}, + verbosity=-1, + ) + + client.connect( + host='db.internal', + password_candidates=candidates, + boundary_target_id='ttcp_123', + ) + + assert FakeSQLExecute.calls[-1]['user'] == '1234' + assert FakeSQLExecute.calls[-1]['password'] == expected_password + assert client.selected_password is not None + assert client.selected_password.source == expected_source + + +def test_connect_boundary_tunnel_uses_default_config(monkeypatch: pytest.MonkeyPatch) -> None: + tunnel_calls: list[tuple[str, str | None, str | None, str | None]] = [] + warning_calls: list[bool] = [] + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + username = '1234' + password = '5678' + expiry = '03:04:05 Wed 02 Jan 2030' + + def __init__( + self, + *, + target_id: str, + boundary_executable: str, + address: str | None, + auth_method_id: str | None, + boundary_options: str | None, + ) -> None: + tunnel_calls.append((boundary_executable, address, auth_method_id, boundary_options)) + + def start(self, *, show_expiration_warning: bool) -> None: + warning_calls.append(show_expiration_warning) + + def close(self) -> None: + pass + + monkeypatch.setattr(client_connection, 'BoundaryTunnel', FakeTunnel) + client = DummyClient() + + client.connect(host='db.internal', boundary_target_id='ttcp_123') + + assert tunnel_calls == [('boundary', None, None, None)] + assert warning_calls == [True] + + +def test_connect_boundary_tunnel_disables_keyring(monkeypatch: pytest.MonkeyPatch) -> None: + get_password_calls: list[tuple[str, str]] = [] + + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + username = '1234' + password = '5678' + + def __init__( + self, + *, + target_id: str, + boundary_executable: str, + address: str | None, + auth_method_id: str | None, + boundary_options: str | None, + ) -> None: + pass + + def start(self, *, show_expiration_warning: bool) -> None: + pass + + def close(self) -> None: + pass + + def fake_get_password(domain: str, identifier: str) -> None: + get_password_calls.append((domain, identifier)) + return None + + monkeypatch.setattr(client_connection, 'BoundaryTunnel', FakeTunnel) + monkeypatch.setattr(client_connection.keyring, 'get_password', fake_get_password) + client = DummyClient(verbosity=-1) + + client.connect(user='alice', host='db.internal', port=3307, use_keyring=True, boundary_target_id='ttcp_123') + + assert get_password_calls == [] + assert FakeSQLExecute.calls[-1]['password'] == '5678' + assert client.selected_password is not None + assert client.selected_password.source == 'boundary' + + +def test_connect_reports_boundary_tunnel_start_error_and_closes_tunnel(monkeypatch: pytest.MonkeyPatch) -> None: + close_calls: list[bool] = [] + secho_calls: list[tuple[str, dict[str, Any]]] = [] + + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + username = '1234' + password = '5678' + + def __init__( + self, + *, + target_id: str, + boundary_executable: str, + address: str | None, + auth_method_id: str | None, + boundary_options: str | None, + ) -> None: + pass + + def start(self, *, show_expiration_warning: bool) -> None: + raise client_connection.BoundaryTunnelError('no tunnel') + + def close(self) -> None: + close_calls.append(True) + + monkeypatch.setattr(client_connection, 'BoundaryTunnel', 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', boundary_target_id='ttcp_123') + + assert excinfo.value.code == 1 + assert close_calls == [True] + assert secho_calls == [('Error: Unable to start Boundary tunnel: no tunnel', {'err': True, 'fg': 'red'})] + assert FakeSQLExecute.calls == [] + + +def test_connect_swallows_boundary_tunnel_cleanup_error(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeTunnel: + local_host = '127.0.0.1' + local_port = 4406 + username = '1234' + password = '5678' + + def __init__( + self, + *, + target_id: str, + boundary_executable: str, + address: str | None, + auth_method_id: str | None, + boundary_options: str | None, + ) -> None: + pass + + def start(self, *, show_expiration_warning: bool) -> None: + raise OSError('no process') + + def close(self) -> None: + raise RuntimeError('close failed') + + monkeypatch.setattr(client_connection, 'BoundaryTunnel', FakeTunnel) + client = DummyClient() + + with pytest.raises(SystemExit) as excinfo: + client.connect(host='db.internal', boundary_target_id='ttcp_123') assert excinfo.value.code == 1 diff --git a/test/pytests/test_dsn_aliases.py b/test/pytests/test_dsn_aliases.py index 97c91a282..690ab2409 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?socket=%2Fruntime.sock&ssh_jump=bastion' + 'mysql://user@host/db?boundary_id=ttcp_123&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' ) @@ -548,6 +548,7 @@ def test_dsn_more_adds_non_default_runtime_parameters_in_sorted_order() -> None: more_params = parse_qsl(parsed.query) assert {key for key, _value in more_params} == KNOWN_DSN_QUERY_PARAMS assert more_params == [ + ('boundary_id', 'ttcp_123'), ('character_set', 'utf8'), ('keepalive_ticks', '45'), ('prompt', 'runtime> '), diff --git a/test/pytests/test_main.py b/test/pytests/test_main.py index f3ad65c1b..f6ad0ec37 100644 --- a/test/pytests/test_main.py +++ b/test/pytests/test_main.py @@ -957,6 +957,53 @@ def test_completions_rejects_missing_or_unknown_shell(args: list[str]) -> None: assert 'Error:' in result.output +def test_boundary_option_reaches_connect(monkeypatch): + class Formatter: + format_name = None + + class Logger: + def debug(self, *args, **args_dict): + pass + + def warning(self, *args, **args_dict): + pass + + class MockMyCli: + config = { + 'main': {}, + 'alias_dsn': {}, + 'connection': {'default_keepalive_ticks': 0}, + } + + def __init__(self, **_args): + self.logger = Logger() + self.destructive_warning = False + self.main_formatter = Formatter() + self.redirect_formatter = Formatter() + self.ssl_mode = 'auto' + self.default_keepalive_ticks = 0 + self.destructive_keywords = [] + + def connect(self, **args): + MockMyCli.connect_args = args + + def run_query(self, query, checkpoint=None, new_line=True): + return [] + + def close(self, **args): + pass + + import mycli.main + + monkeypatch.setattr(mycli.main, 'MyCli', MockMyCli) + runner = CliRunner() + + result = runner.invoke(mycli.main.click_entrypoint, args=['--boundary-id', 'ttcp_123', '--execute', 'select 1']) + + assert result.exit_code == 0, result.output + ' ' + str(result.exception) + assert MockMyCli.connect_args['boundary_target_id'] == 'ttcp_123' + + def test_dsn(monkeypatch): # Setup classes to mock mycli.main.MyCli class Formatter: @@ -992,7 +1039,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1254,7 +1301,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1309,7 +1356,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1370,7 +1417,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1441,7 +1488,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1525,7 +1572,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1600,7 +1647,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1671,7 +1718,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1728,7 +1775,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1799,7 +1846,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main @@ -1868,7 +1915,7 @@ def connect(self, **args): def run_query(self, query, new_line=True): pass - def close(self): + def close(self, **args): pass import mycli.main diff --git a/test/pytests/test_special_utils.py b/test/pytests/test_special_utils.py index b15e28d7b..5c13be1b4 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_boundary_id() -> None: + assert ( + format_connection_dsn( + user=None, + host='db.example.com', + port=3307, + database='prod', + socket=None, + character_set='utf8mb4', + boundary_id='ttcp target/1', + ) + == 'mysql://db.example.com:3307/prod?boundary_id=ttcp+target%2F1' + ) + + def test_format_connection_dsn_includes_vault_parameters() -> None: assert format_connection_dsn( user=None,