diff --git a/src/makim/core.py b/src/makim/core.py index b7560f2..af1490f 100644 --- a/src/makim/core.py +++ b/src/makim/core.py @@ -20,7 +20,7 @@ from copy import deepcopy from itertools import product from pathlib import Path -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, TypedDict, Union, cast import dotenv import paramiko @@ -61,6 +61,19 @@ ) +class HostConfig(TypedDict): + """ + Type definition for SSH host configuration containing. + + Includes username, host, port, and optional password. + """ + + username: str + host: str + port: int + password: Optional[str] + + def strip_recursively(data: Any) -> Any: """Strip strings in list and dictionaries.""" if isinstance(data, str): @@ -176,14 +189,23 @@ def _call_shell_app(self, cmd: str) -> None: def _call_shell_remote(self, cmd: str, host_config: Any) -> None: try: + # Render the host configuration values + env, variables = self._load_scoped_data('task') + rendered_config = self._render_host_config(host_config, env) + ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + # Use typed dict values to ensure correct types for paramiko ssh.connect( - username=host_config['user'], - password=host_config.get('password'), - hostname=host_config['host'], - port=host_config.get('port', 22), + username=rendered_config[ + 'username' + ], # Already str from _render_host_config + password=rendered_config.get( + 'password' + ), # Already Optional[str] + hostname=rendered_config['host'], # Already str + port=rendered_config['port'], # Already int ) stdin, stdout, stderr = ssh.exec_command( @@ -216,6 +238,47 @@ def _call_shell_remote(self, cmd: str, host_config: Any) -> None: MakimError.SSH_EXECUTION_ERROR, ) + def _render_host_config( + self, host_config: dict[str, Any], env: dict[str, str] + ) -> HostConfig: + """Render the host configuration values using Jinja2 templates.""" + rendered: Dict[str, Any] = {} + + # Handle each field with appropriate type conversion + for key in ('username', 'host', 'password', 'port'): + value = host_config.get(key, '') + + if value is None and key == 'password': + rendered[key] = None + continue + + # Convert to string and render if it's a template + str_value = str(value) if value != '' else '' + if isinstance(value, str): + str_value = TEMPLATE.from_string(str_value).render(env=env) + + # Handle each field according to its required type + if key == 'port': + rendered[key] = int(str_value) if str_value.isdigit() else 22 + elif key == 'password': + rendered[key] = str_value if str_value else None + else: # username and host + if not str_value: + raise ValueError(f'{key} is required and cannot be empty') + rendered[key] = str_value + + # Ensure all required fields are present + if 'port' not in rendered: + rendered['port'] = 22 + + # Cast to HostConfig to ensure type safety + return HostConfig( + username=rendered['username'], + host=rendered['host'], + port=rendered['port'], + password=rendered.get('password'), + ) + def _check_makim_file(self, file_path: str = '') -> bool: return Path(file_path or self.file).exists() diff --git a/tests/smoke/.env-ssh b/tests/smoke/.env-ssh new file mode 100644 index 0000000..de98f18 --- /dev/null +++ b/tests/smoke/.env-ssh @@ -0,0 +1,4 @@ +SSH_HOST=localhost +SSH_PORT=2222 +SSH_USER=testuser +SSH_PASSWORD=testpassword diff --git a/tests/smoke/.makim-ssh.yaml b/tests/smoke/.makim-ssh.yaml index 6b148a7..5546b5e 100644 --- a/tests/smoke/.makim-ssh.yaml +++ b/tests/smoke/.makim-ssh.yaml @@ -1,9 +1,10 @@ +env-file: .env-ssh hosts: test_container: - host: localhost - port: 2222 - user: testuser - password: testpassword + host: ${{ env.SSH_HOST }} + port: ${{ env.SSH_PORT }} + user: ${{ env.SSH_USER }} + password: ${{ env.SSH_PASSWORD }} backend: bash groups: docker: