diff --git a/CHANGELOG b/CHANGELOG index e94a193b..744ffd7d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Custom auth (token) file paths in config now take precedence over the default path if both exist. +- Application now prompts for Zabbix API URL if missing from config. ## [3.2.0] diff --git a/tests/test_config.py b/tests/test_config.py index 151acad7..8ad9981c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,6 @@ import pytest from inline_snapshot import snapshot -from pydantic import ValidationError from zabbix_cli.config.model import Config from zabbix_cli.config.model import PluginConfig from zabbix_cli.config.model import PluginsConfig @@ -18,11 +17,8 @@ def test_config_default() -> None: - """Assert that the config by default only requires a URL.""" - with pytest.raises(ValidationError) as excinfo: - Config() - assert "1 validation error" in str(excinfo.value) - assert "url" in str(excinfo.value) + """Assert that the config can be instantiated with no arguments.""" + assert Config() def test_sample_config() -> None: diff --git a/zabbix_cli/auth.py b/zabbix_cli/auth.py index d1979137..b6a1b328 100644 --- a/zabbix_cli/auth.py +++ b/zabbix_cli/auth.py @@ -14,6 +14,7 @@ import logging import os import sys +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING from typing import Final @@ -22,6 +23,8 @@ from typing import Optional from typing import Tuple +from rich.console import ScreenContext + from zabbix_cli._v2_compat import AUTH_FILE as AUTH_FILE_LEGACY from zabbix_cli._v2_compat import AUTH_TOKEN_FILE as AUTH_TOKEN_FILE_LEGACY from zabbix_cli.config.constants import AUTH_FILE @@ -36,10 +39,10 @@ from zabbix_cli.output.console import exit_err from zabbix_cli.output.console import warning from zabbix_cli.output.prompts import str_prompt +from zabbix_cli.pyzabbix.client import ZabbixAPI if TYPE_CHECKING: from zabbix_cli.config.model import Config - from zabbix_cli.pyzabbix.client import ZabbixAPI logger = logging.getLogger(__name__) @@ -70,16 +73,20 @@ class Authenticator: """Encapsulates logic for authenticating with the Zabbix API using various methods, as well as storing and loading auth tokens.""" - client: ZabbixAPI config: Config - def __init__(self, client: ZabbixAPI, config: Config) -> None: - self.client = client + def __init__(self, config: Config) -> None: self.config = config - def login(self) -> str: + @cached_property + def screen(self) -> ScreenContext: + return err_console.screen() + + def login(self) -> Tuple[ZabbixAPI, str]: """Log in to the Zabbix API using the configured credentials. + Returns the Zabbix API client object and the session token. + If multiple methods are available, they are tried in the following order: 1. API token in config file @@ -90,6 +97,10 @@ def login(self) -> str: 6. Username and password in environment variables 7. Username and password from prompt """ + # Ensure we have a Zabbix API URL + self.config.api.url = self.get_zabbix_url() + client = ZabbixAPI.from_config(self.config) + for func in [ self._get_auth_token_config, self._get_auth_token_env, @@ -107,10 +118,10 @@ def login(self) -> str: logger.debug( "Attempting to log in with credentials from %s", func.__name__ ) - token = self.do_login(credentials) + token = self.do_login(client, credentials) logger.info("Logged in with %s", func.__name__) self.update_config(credentials) - return token + return client, token except ZabbixAPIException as e: logger.warning("Failed to log in with %s: %s", func.__name__, e) continue @@ -124,9 +135,9 @@ def login(self) -> str: f"No authentication method succeeded for {self.config.api.url}. Check the logs for more information." ) - def do_login(self, credentials: Credentials) -> str: + def do_login(self, client: ZabbixAPI, credentials: Credentials) -> str: """Log in to the Zabbix API using the provided credentials.""" - return self.client.login( + return client.login( user=credentials.username, password=credentials.password, auth_token=credentials.auth_token, @@ -143,6 +154,13 @@ def update_config(self, credentials: Credentials) -> None: if credentials.auth_token: self.config.api.auth_token = SecretStr(credentials.auth_token) + def get_zabbix_url(self) -> str: + """Get the URL of the Zabbix server from the config, or prompt for it.""" + if not self.config.api.url: + with self.screen: + return str_prompt("Zabbix URL (without /api_jsonrpc.php)") + return self.config.api.url + def _get_username_password_env(self) -> Credentials: """Get username and password from environment variables.""" return Credentials( @@ -175,7 +193,7 @@ def _get_username_password_prompt( self, ) -> Credentials: """Get username and password from a prompt in a separate screen.""" - with err_console.screen(): + with self.screen: username, password = prompt_username_password( username=self.config.api.username ) @@ -240,16 +258,23 @@ def _do_load_auth_file(self, file: Path) -> Optional[str]: return file.read_text().strip() -def login(client: ZabbixAPI, config: Config) -> None: - auth = Authenticator(client, config) - token = auth.login() +def login(config: Config) -> ZabbixAPI: + """Log in to the Zabbix API using the configured credentials. + + Returns the Zabbix API client object. + """ + auth = Authenticator(config) + client, token = auth.login() + # NOTE: should we bake token file and logging config into Authenticator? if config.app.use_auth_token_file: write_auth_token_file(config.api.username, token, config.app.auth_token_file) add_user(config.api.username) logger.info("Logged in as %s", config.api.username) + return client def logout(client: ZabbixAPI, config: Config) -> None: + """Log out of the current Zabbix API session.""" try: client.logout() if config.app.use_auth_token_file: diff --git a/zabbix_cli/config/model.py b/zabbix_cli/config/model.py index 7aeb3aa0..88d73716 100644 --- a/zabbix_cli/config/model.py +++ b/zabbix_cli/config/model.py @@ -86,7 +86,7 @@ def _conf_bool_validator_compat(cls, v: Any, info: ValidationInfo) -> Any: class APIConfig(BaseModel): url: str = Field( - default=..., + default="", # Changed in V3: zabbix_api_url -> url validation_alias=AliasChoices("url", "zabbix_api_url"), ) diff --git a/zabbix_cli/config/utils.py b/zabbix_cli/config/utils.py index 7e206524..a9d6aa55 100644 --- a/zabbix_cli/config/utils.py +++ b/zabbix_cli/config/utils.py @@ -96,7 +96,6 @@ def init_config( from zabbix_cli.dirs import init_directories from zabbix_cli.output.console import info from zabbix_cli.output.prompts import str_prompt - from zabbix_cli.pyzabbix.client import ZabbixAPI # Create required directories init_directories() @@ -120,8 +119,7 @@ def init_config( config.api.username = username if login: - client = ZabbixAPI.from_config(config) - auth.login(client, config) + auth.login(config) config.dump_to_file(config_file) info(f"Configuration file created: {config_file}") diff --git a/zabbix_cli/pyzabbix/client.py b/zabbix_cli/pyzabbix/client.py index 65194ab9..1233d35a 100644 --- a/zabbix_cli/pyzabbix/client.py +++ b/zabbix_cli/pyzabbix/client.py @@ -246,7 +246,7 @@ def __init__( self.id = 0 server, _, _ = server.partition(RPC_ENDPOINT) - self.url = f"{server}/api_jsonrpc.php" + self.url = self._get_url(server) logger.info("JSON-RPC Server Endpoint: %s", self.url) # Cache @@ -255,6 +255,15 @@ def __init__( # Attributes for properties self._version: Optional[Version] = None + def _get_url(self, server: str) -> str: + """Format a URL for the Zabbix API.""" + return f"{server}/api_jsonrpc.php" + + def set_url(self, server: str) -> str: + """Set a new URL for the client.""" + self.url = self._get_url(server) + return self.url + @classmethod def from_config(cls, config: Config) -> ZabbixAPI: """Create a ZabbixAPI instance from a Config object and logs in.""" diff --git a/zabbix_cli/state.py b/zabbix_cli/state.py index d3a730a6..03d406c1 100644 --- a/zabbix_cli/state.py +++ b/zabbix_cli/state.py @@ -220,11 +220,8 @@ def login(self) -> None: so that each model is aware of which version its data is from.""" from zabbix_cli import auth from zabbix_cli.models import TableRenderable - from zabbix_cli.pyzabbix.client import ZabbixAPI - self.client = ZabbixAPI.from_config(self.config) - - auth.login(self.client, self.config) + self.client = auth.login(self.config) TableRenderable.zabbix_version = self.client.version TableRenderable.legacy_json_format = self.config.app.legacy_json_format