Skip to content

Commit

Permalink
Prompt for Zabbix URL if missing from config (#237)
Browse files Browse the repository at this point in the history
* Prompt for URL, refactor client setup

* Fix invalid config test
  • Loading branch information
pederhan authored Oct 30, 2024
1 parent 3a7381d commit 9b94397
Show file tree
Hide file tree
Showing 7 changed files with 54 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
8 changes: 2 additions & 6 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
51 changes: 38 additions & 13 deletions zabbix_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion zabbix_cli/config/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand Down
4 changes: 1 addition & 3 deletions zabbix_cli/config/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}")
Expand Down
11 changes: 10 additions & 1 deletion zabbix_cli/pyzabbix/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down
5 changes: 1 addition & 4 deletions zabbix_cli/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit 9b94397

Please sign in to comment.