diff --git a/AGENTS.md b/AGENTS.md index c458d64e..da11c6ef 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 81436a28..08385bb9 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 00000000..73ce701a --- /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 203ecc79..82be1efa 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 51d305d3..1d34f2c5 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 66a3a9e1..16d3c4e0 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 b9116093..d4cc6a25 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 a35c95bf..25449b6d 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 0e73f04f..e1545849 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 13d9c0f3..b2c00560 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 6cbd7e7c..cbaf13a8 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 00000000..4ca0661a --- /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 d1392672..a85702dc 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 0fa108a9..6687ab55 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 c5773062..bcc3464e 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 97c91a28..690ab240 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 f3ad65c1..f6ad0ec3 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 b15e28d7..5c13be1b 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,