From 6367e800abeefc3ab484a884b1a0bcafc60d73ad Mon Sep 17 00:00:00 2001 From: Noppanut Ploywong Date: Tue, 22 Sep 2026 17:19:53 +0800 Subject: [PATCH] Make the Nexus Dashboard HTTP request timeout configurable The ND client's httpx timeout was hard-coded to 60 seconds in _build_config, so a cluster that answered slowly failed every command with httpx.ReadTimeout and the operator had no way to raise the ceiling. Expose it the same three ways as poll_interval: --request-timeout, ND_REQUEST_TIMEOUT_SECONDS, and request_timeout_seconds under the nexus_dashboard YAML section, defaulting to 60 seconds. It is typed as an int like the other timing settings, so it shares their env coercion. Every ND command gets the flag, including doctor and snapshots, since both reach the fabric inventory before doing anything else. A non-positive value is rejected as InputError (exit 4) rather than handed to httpx, which reads 0 as "fail immediately". --- CHANGELOG.md | 6 ++ docs/configuration.md | 1 + docs/nexus-dashboard.md | 23 +++--- .../config/config.example.yaml | 29 ++++---- examples/nexus_dashboard/config/env.example | 1 + nac_analytics/products/nexus_dashboard/cli.py | 23 +++--- .../nexus_dashboard/commands/_helpers.py | 16 +++- .../nexus_dashboard/commands/analyze.py | 8 +- .../nexus_dashboard/commands/compliance.py | 9 ++- .../nexus_dashboard/commands/delta.py | 8 +- .../nexus_dashboard/commands/doctor.py | 9 ++- .../nexus_dashboard/commands/prechange.py | 8 +- .../nexus_dashboard/commands/snapshots.py | 8 +- .../products/nexus_dashboard/config.py | 8 +- .../products/nexus_dashboard/settings.py | 4 +- .../templates/config.example.yaml | 29 ++++---- .../nexus_dashboard/templates/env.example | 1 + tests/unit/test_cli_commands.py | 73 +++++++++++++++++++ tests/unit/test_config.py | 19 ++++- tests/unit/test_settings.py | 20 +++++ 20 files changed, 244 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec3440..870761d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Added + +- Configurable HTTP request timeout for Nexus Dashboard: `--request-timeout`, `ND_REQUEST_TIMEOUT_SECONDS`, or `request_timeout_seconds` in YAML (default 60 seconds). Previously the 60-second timeout was hard-coded, so a cluster that answered slowly failed every command with `httpx.ReadTimeout`. + ## [0.2.0] - 2026-09-11 ### Added diff --git a/docs/configuration.md b/docs/configuration.md index fe1ce2f..d2791f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,7 @@ The authoritative key list and inline comments live in [config.example.yaml](../ | `ca_bundle` | `ND_CA_BUNDLE` | Path to CA bundle | | `job_timeout_minutes` | `ND_JOB_TIMEOUT_MINUTES` | Analysis job timeout | | `poll_interval` | `ND_POLL_INTERVAL` | Job poll interval (seconds) | +| `request_timeout_seconds` | `ND_REQUEST_TIMEOUT_SECONDS` | Single HTTP response timeout (seconds), default 60 | | `delta_detail` | `ND_DELTA_DETAIL` | Default `--detail` for prechange/delta | Minimal example: diff --git a/docs/nexus-dashboard.md b/docs/nexus-dashboard.md index ca20939..008acaa 100644 --- a/docs/nexus-dashboard.md +++ b/docs/nexus-dashboard.md @@ -16,17 +16,18 @@ Usage: nac-analytics nexus-dashboard [OPTIONS] COMMAND [ARGS]... Change analysis for Cisco Nexus Dashboard 4.2.1+ (GA REST APIs, ACI). Configuration: - ND_HOST Nexus Dashboard hostname or IP - ND_USER Login username - ND_PASSWORD Login password - ND_DOMAIN Login domain - ND_FABRIC Default ACI fabric name - ND_VERIFY_SSL Verify TLS certificate (ND_VERIFY_TLS accepted) - ND_CA_BUNDLE Path to CA bundle - ND_JOB_TIMEOUT_MINUTES Minutes to wait for analysis jobs - ND_POLL_INTERVAL Seconds between job status polls - ND_DELTA_DETAIL Default --detail for prechange and delta - ND_CONFIG Path to YAML config file + ND_HOST Nexus Dashboard hostname or IP + ND_USER Login username + ND_PASSWORD Login password + ND_DOMAIN Login domain + ND_FABRIC Default ACI fabric name + ND_VERIFY_SSL Verify TLS certificate (ND_VERIFY_TLS accepted) + ND_CA_BUNDLE Path to CA bundle + ND_JOB_TIMEOUT_MINUTES Minutes to wait for analysis jobs + ND_POLL_INTERVAL Seconds between job status polls + ND_REQUEST_TIMEOUT_SECONDS Seconds to wait for one HTTP response + ND_DELTA_DETAIL Default --detail for prechange and delta + ND_CONFIG Path to YAML config file In YAML, nest these under a `nexus_dashboard:` section. Settings load from CLI flags, then environment variables, nac-analytics.yaml, or .env. diff --git a/examples/nexus_dashboard/config/config.example.yaml b/examples/nexus_dashboard/config/config.example.yaml index d652a79..02baab6 100644 --- a/examples/nexus_dashboard/config/config.example.yaml +++ b/examples/nexus_dashboard/config/config.example.yaml @@ -13,21 +13,23 @@ # --------------------------------------------------------------------------- # Supported variables — nexus_dashboard section # --------------------------------------------------------------------------- -# Key Env var Description -# host ND_HOST Hostname or IP (HTTPS assumed when no scheme given). -# domain ND_DOMAIN Login domain (DefaultAuth, local, or your LDAP domain). -# verify_ssl ND_VERIFY_SSL Verify TLS cert (ND_VERIFY_TLS also accepted). -# ca_bundle ND_CA_BUNDLE Path to a CA bundle for private/self-signed certs. -# fabric ND_FABRIC Default fabric when a command needs one and --fabric is omitted. -# fabrics — Fabrics `compliance --all` checks (defaults to [fabric]). -# job_timeout_minutes ND_JOB_TIMEOUT_MINUTES Minutes to wait for a pre-change analysis job. -# poll_interval ND_POLL_INTERVAL Seconds between job status polls. -# delta_detail ND_DELTA_DETAIL Default detail: none, resources, anomalies, policy-diff, full -# (prechange defaults to `full`; delta to `resources`). +# Key Env var Description +# host ND_HOST Hostname or IP (HTTPS assumed when no scheme given). +# domain ND_DOMAIN Login domain (DefaultAuth, local, or your LDAP domain). +# verify_ssl ND_VERIFY_SSL Verify TLS cert (ND_VERIFY_TLS also accepted). +# ca_bundle ND_CA_BUNDLE Path to a CA bundle for private/self-signed certs. +# fabric ND_FABRIC Default fabric when a command needs one and --fabric is omitted. +# fabrics — Fabrics `compliance --all` checks (defaults to [fabric]). +# job_timeout_minutes ND_JOB_TIMEOUT_MINUTES Minutes to wait for a pre-change analysis job. +# poll_interval ND_POLL_INTERVAL Seconds between job status polls. +# request_timeout_seconds ND_REQUEST_TIMEOUT_SECONDS Seconds to wait for one HTTP response (default 60). Raise it +# when the cluster is slow to answer. +# delta_detail ND_DELTA_DETAIL Default detail: none, resources, anomalies, policy-diff, full +# (prechange defaults to `full`; delta to `resources`). # # Credentials (secrets — set via environment, not here): -# username ND_USER Login username. -# password ND_PASSWORD Login password. +# username ND_USER Login username. +# password ND_PASSWORD Login password. # --------------------------------------------------------------------------- nexus_dashboard: @@ -41,4 +43,5 @@ nexus_dashboard: - FABRIC-B job_timeout_minutes: 30 poll_interval: 15 + request_timeout_seconds: 60 delta_detail: resources diff --git a/examples/nexus_dashboard/config/env.example b/examples/nexus_dashboard/config/env.example index 07ca00a..93b1448 100644 --- a/examples/nexus_dashboard/config/env.example +++ b/examples/nexus_dashboard/config/env.example @@ -19,5 +19,6 @@ ND_PASSWORD=change-me # ND_CA_BUNDLE=/path/to/nd-cluster-ca.pem # ND_JOB_TIMEOUT_MINUTES=60 # ND_POLL_INTERVAL=15 +# ND_REQUEST_TIMEOUT_SECONDS=60 # ND_DELTA_DETAIL=resources # ND_CONFIG=/path/to/nac-analytics.yaml diff --git a/nac_analytics/products/nexus_dashboard/cli.py b/nac_analytics/products/nexus_dashboard/cli.py index 0a81e71..52fc417 100644 --- a/nac_analytics/products/nexus_dashboard/cli.py +++ b/nac_analytics/products/nexus_dashboard/cli.py @@ -14,17 +14,18 @@ Change analysis for Cisco Nexus Dashboard 4.2.1+ (GA REST APIs, ACI). Configuration: - ND_HOST Nexus Dashboard hostname or IP - ND_USER Login username - ND_PASSWORD Login password - ND_DOMAIN Login domain - ND_FABRIC Default ACI fabric name - ND_VERIFY_SSL Verify TLS certificate (ND_VERIFY_TLS accepted) - ND_CA_BUNDLE Path to CA bundle - ND_JOB_TIMEOUT_MINUTES Minutes to wait for analysis jobs - ND_POLL_INTERVAL Seconds between job status polls - ND_DELTA_DETAIL Default --detail for prechange and delta - ND_CONFIG Path to YAML config file + ND_HOST Nexus Dashboard hostname or IP + ND_USER Login username + ND_PASSWORD Login password + ND_DOMAIN Login domain + ND_FABRIC Default ACI fabric name + ND_VERIFY_SSL Verify TLS certificate (ND_VERIFY_TLS accepted) + ND_CA_BUNDLE Path to CA bundle + ND_JOB_TIMEOUT_MINUTES Minutes to wait for analysis jobs + ND_POLL_INTERVAL Seconds between job status polls + ND_REQUEST_TIMEOUT_SECONDS Seconds to wait for one HTTP response + ND_DELTA_DETAIL Default --detail for prechange and delta + ND_CONFIG Path to YAML config file In YAML, nest these under a `nexus_dashboard:` section. Settings load from CLI flags, then environment variables, nac-analytics.yaml, or .env.""" diff --git a/nac_analytics/products/nexus_dashboard/commands/_helpers.py b/nac_analytics/products/nexus_dashboard/commands/_helpers.py index fe13cc6..17a169c 100644 --- a/nac_analytics/products/nexus_dashboard/commands/_helpers.py +++ b/nac_analytics/products/nexus_dashboard/commands/_helpers.py @@ -36,7 +36,10 @@ prechange_job_details, snapshot_details, ) -from nac_analytics.products.nexus_dashboard.config import Config +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_REQUEST_TIMEOUT_SECONDS, + Config, +) from nac_analytics.products.nexus_dashboard.delta import ( DELTA_DETAIL_LEVELS, PRECHANGE_DEFAULT_DETAIL, @@ -103,6 +106,14 @@ help="Seconds between job status polls.", ), ] +RequestTimeoutOpt = Annotated[ + int, + typer.Option( + "--request-timeout", + envvar="ND_REQUEST_TIMEOUT_SECONDS", + help="Seconds to wait for a single HTTP response from Nexus Dashboard.", + ), +] OutputOpt = Annotated[ str, typer.Option( @@ -250,6 +261,7 @@ def _build_config( ca_bundle: str | None, timeout: int, poll_interval: int, + request_timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, ) -> Config: if not fabric: raise InputError( @@ -263,7 +275,7 @@ def _build_config( fabric=fabric, verify_ssl=verify_ssl, ca_bundle=ca_bundle, - request_timeout_seconds=60.0, + request_timeout_seconds=request_timeout, poll_interval_seconds=poll_interval, job_timeout_minutes=timeout, ) diff --git a/nac_analytics/products/nexus_dashboard/commands/analyze.py b/nac_analytics/products/nexus_dashboard/commands/analyze.py index b1fb6e1..b7ad0e7 100644 --- a/nac_analytics/products/nexus_dashboard/commands/analyze.py +++ b/nac_analytics/products/nexus_dashboard/commands/analyze.py @@ -8,7 +8,10 @@ from nac_analytics.core.exceptions import ApiError, InputError from nac_analytics.core.progress import note -from nac_analytics.products.nexus_dashboard.config import DEFAULT_DOMAIN +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_DOMAIN, + DEFAULT_REQUEST_TIMEOUT_SECONDS, +) from . import _helpers from ._helpers import ( @@ -18,6 +21,7 @@ HostOpt, PasswordOpt, PollOpt, + RequestTimeoutOpt, TimeoutOpt, UserOpt, VerboseOpt, @@ -57,6 +61,7 @@ def analyze( ] = "text", timeout: TimeoutOpt = 30, poll_interval: PollOpt = 15, + request_timeout: RequestTimeoutOpt = DEFAULT_REQUEST_TIMEOUT_SECONDS, verify_ssl: VerifyOpt = True, ca_bundle: CaBundleOpt = None, verbose: VerboseOpt = False, @@ -78,6 +83,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=timeout, poll_interval=poll_interval, + request_timeout=request_timeout, ) note(_connect_message(config)) with _helpers.NDClient(config) as client: diff --git a/nac_analytics/products/nexus_dashboard/commands/compliance.py b/nac_analytics/products/nexus_dashboard/commands/compliance.py index e0a5fa3..99c4516 100644 --- a/nac_analytics/products/nexus_dashboard/commands/compliance.py +++ b/nac_analytics/products/nexus_dashboard/commands/compliance.py @@ -11,7 +11,10 @@ from nac_analytics.core.progress import note from nac_analytics.core.report import MultiFabricResult, Result, render_multi from nac_analytics.products.nexus_dashboard.compliance import run_compliance_check -from nac_analytics.products.nexus_dashboard.config import DEFAULT_DOMAIN +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_DOMAIN, + DEFAULT_REQUEST_TIMEOUT_SECONDS, +) from nac_analytics.products.nexus_dashboard.settings import configured_fabrics from . import _helpers @@ -23,6 +26,7 @@ OutputOpt, PasswordOpt, PollOpt, + RequestTimeoutOpt, SinceOpt, TimeoutOpt, UntilOpt, @@ -79,6 +83,7 @@ def compliance( ca_bundle: CaBundleOpt = None, timeout: TimeoutOpt = 30, poll_interval: PollOpt = 15, + request_timeout: RequestTimeoutOpt = DEFAULT_REQUEST_TIMEOUT_SECONDS, verbose: VerboseOpt = False, ) -> None: """Report compliance rule status for a fabric (or every fabric with --all). @@ -109,6 +114,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=timeout, poll_interval=poll_interval, + request_timeout=request_timeout, ) results: list[Result] = [] failed: list[str] = [] @@ -150,6 +156,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=timeout, poll_interval=poll_interval, + request_timeout=request_timeout, ) note(_connect_message(config)) with _helpers.NDClient(config) as client: diff --git a/nac_analytics/products/nexus_dashboard/commands/delta.py b/nac_analytics/products/nexus_dashboard/commands/delta.py index 4ef46dc..c5dc3ad 100644 --- a/nac_analytics/products/nexus_dashboard/commands/delta.py +++ b/nac_analytics/products/nexus_dashboard/commands/delta.py @@ -10,7 +10,10 @@ from nac_analytics.core.report import GATE_DEFAULT_OUTPUT, parse_fail_on from nac_analytics.products.nexus_dashboard.client import resolve_snapshot_ids from nac_analytics.products.nexus_dashboard.compliance import snapshot_details -from nac_analytics.products.nexus_dashboard.config import DEFAULT_DOMAIN +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_DOMAIN, + DEFAULT_REQUEST_TIMEOUT_SECONDS, +) from nac_analytics.products.nexus_dashboard.delta import ( DEFAULT_DELTA_DETAIL, normalize_delta_detail, @@ -32,6 +35,7 @@ PasswordOpt, PollOpt, ReportFileOpt, + RequestTimeoutOpt, SinceOpt, TimeoutOpt, UntilOpt, @@ -98,6 +102,7 @@ def delta( ca_bundle: CaBundleOpt = None, timeout: TimeoutOpt = 30, poll_interval: PollOpt = 15, + request_timeout: RequestTimeoutOpt = DEFAULT_REQUEST_TIMEOUT_SECONDS, verbose: VerboseOpt = False, ) -> None: """Compare two snapshots of a fabric and report what changed. @@ -126,6 +131,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=timeout, poll_interval=poll_interval, + request_timeout=request_timeout, ) job_name = name or _auto_name("delta") note(_connect_message(config)) diff --git a/nac_analytics/products/nexus_dashboard/commands/doctor.py b/nac_analytics/products/nexus_dashboard/commands/doctor.py index 7b0ab9c..68bdc0f 100644 --- a/nac_analytics/products/nexus_dashboard/commands/doctor.py +++ b/nac_analytics/products/nexus_dashboard/commands/doctor.py @@ -6,7 +6,11 @@ from nac_analytics.core.progress import note from nac_analytics.core.report import Result from nac_analytics.products.nexus_dashboard.client import fabric_name, is_aci_fabric -from nac_analytics.products.nexus_dashboard.config import DEFAULT_DOMAIN, normalise_host +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_DOMAIN, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + normalise_host, +) from . import _helpers from ._helpers import ( @@ -16,6 +20,7 @@ HostOpt, OutputOpt, PasswordOpt, + RequestTimeoutOpt, UserOpt, VerboseOpt, VerifyOpt, @@ -35,6 +40,7 @@ def doctor( output: OutputOpt = "text", verify_ssl: VerifyOpt = True, ca_bundle: CaBundleOpt = None, + request_timeout: RequestTimeoutOpt = DEFAULT_REQUEST_TIMEOUT_SECONDS, verbose: VerboseOpt = False, ) -> None: """Check connectivity, credentials, and fabric visibility. @@ -54,6 +60,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=30, poll_interval=15, + request_timeout=request_timeout, ) details: dict[str, object] = { "base_url": config.base_url, diff --git a/nac_analytics/products/nexus_dashboard/commands/prechange.py b/nac_analytics/products/nexus_dashboard/commands/prechange.py index 2439e71..d7a27e1 100644 --- a/nac_analytics/products/nexus_dashboard/commands/prechange.py +++ b/nac_analytics/products/nexus_dashboard/commands/prechange.py @@ -10,7 +10,10 @@ from nac_analytics.core.exceptions import ApiError, InputError from nac_analytics.core.progress import note from nac_analytics.core.report import GATE_DEFAULT_OUTPUT, parse_fail_on -from nac_analytics.products.nexus_dashboard.config import DEFAULT_DOMAIN +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_DOMAIN, + DEFAULT_REQUEST_TIMEOUT_SECONDS, +) from nac_analytics.products.nexus_dashboard.delta import ( PRECHANGE_DEFAULT_DETAIL, normalize_delta_detail, @@ -32,6 +35,7 @@ PasswordOpt, PollOpt, ReportFileOpt, + RequestTimeoutOpt, SinceOpt, TimeoutOpt, UntilOpt, @@ -106,6 +110,7 @@ def prechange( ca_bundle: CaBundleOpt = None, timeout: TimeoutOpt = 30, poll_interval: PollOpt = 15, + request_timeout: RequestTimeoutOpt = DEFAULT_REQUEST_TIMEOUT_SECONDS, verbose: VerboseOpt = False, ) -> None: """Analyse a candidate configuration against a fabric's current state. @@ -142,6 +147,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=timeout, poll_interval=poll_interval, + request_timeout=request_timeout, ) detail_level = normalize_delta_detail(detail) upload_content: bytes | None = None diff --git a/nac_analytics/products/nexus_dashboard/commands/snapshots.py b/nac_analytics/products/nexus_dashboard/commands/snapshots.py index 17d45e7..a521862 100644 --- a/nac_analytics/products/nexus_dashboard/commands/snapshots.py +++ b/nac_analytics/products/nexus_dashboard/commands/snapshots.py @@ -8,7 +8,10 @@ from nac_analytics.core.exceptions import InputError from nac_analytics.core.progress import note -from nac_analytics.products.nexus_dashboard.config import DEFAULT_DOMAIN +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_DOMAIN, + DEFAULT_REQUEST_TIMEOUT_SECONDS, +) from . import _helpers from ._helpers import ( @@ -17,6 +20,7 @@ FabricOpt, HostOpt, PasswordOpt, + RequestTimeoutOpt, SinceOpt, UntilOpt, UserOpt, @@ -54,6 +58,7 @@ def snapshots( ] = "text", verify_ssl: VerifyOpt = True, ca_bundle: CaBundleOpt = None, + request_timeout: RequestTimeoutOpt = DEFAULT_REQUEST_TIMEOUT_SECONDS, verbose: VerboseOpt = False, ) -> None: """Resolve a fabric snapshot and print its ID (for CI baseline pinning).""" @@ -73,6 +78,7 @@ def body() -> None: ca_bundle=ca_bundle, timeout=30, poll_interval=15, + request_timeout=request_timeout, ) note(_connect_message(config)) with _helpers.NDClient(config) as client: diff --git a/nac_analytics/products/nexus_dashboard/config.py b/nac_analytics/products/nexus_dashboard/config.py index 5df1e31..32b6ebd 100644 --- a/nac_analytics/products/nexus_dashboard/config.py +++ b/nac_analytics/products/nexus_dashboard/config.py @@ -10,6 +10,10 @@ # there is always a value here. DEFAULT_DOMAIN = "DefaultAuth" +# Seconds to wait for a single HTTP response. Deliberately generous: some +# endpoints stay slow under load, and raising it is cheaper than a failed run. +DEFAULT_REQUEST_TIMEOUT_SECONDS = 60 + def normalise_host(host: str) -> str: """Strip any URL scheme and trailing slashes from a host. @@ -42,7 +46,7 @@ class Config: fabric: str = "" verify_ssl: bool = True ca_bundle: str | None = None - request_timeout_seconds: float = 60.0 + request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT_SECONDS poll_interval_seconds: int = 15 job_timeout_minutes: int = 30 scheme: str = field(init=False, default="https") @@ -66,6 +70,8 @@ def __post_init__(self) -> None: raise InputError("--poll-interval must be at least 1 second.") if self.job_timeout_minutes < 1: raise InputError("--timeout must be at least 1 minute.") + if self.request_timeout_seconds <= 0: + raise InputError("--request-timeout must be greater than 0 seconds.") @property def base_url(self) -> str: diff --git a/nac_analytics/products/nexus_dashboard/settings.py b/nac_analytics/products/nexus_dashboard/settings.py index d0e6367..87a3f61 100644 --- a/nac_analytics/products/nexus_dashboard/settings.py +++ b/nac_analytics/products/nexus_dashboard/settings.py @@ -37,6 +37,7 @@ "ca_bundle", "job_timeout_minutes", "poll_interval", + "request_timeout_seconds", "delta_detail", } ) @@ -53,6 +54,7 @@ "ca_bundle": "ND_CA_BUNDLE", "job_timeout_minutes": "ND_JOB_TIMEOUT_MINUTES", "poll_interval": "ND_POLL_INTERVAL", + "request_timeout_seconds": "ND_REQUEST_TIMEOUT_SECONDS", "delta_detail": "ND_DELTA_DETAIL", } @@ -97,7 +99,7 @@ def _coerce_env_value(key: str, value: Any) -> str: return "true" if value else "false" if value is None: return "" - if key in {"job_timeout_minutes", "poll_interval"}: + if key in {"job_timeout_minutes", "poll_interval", "request_timeout_seconds"}: return str(int(value)) return str(value) diff --git a/nac_analytics/products/nexus_dashboard/templates/config.example.yaml b/nac_analytics/products/nexus_dashboard/templates/config.example.yaml index d652a79..02baab6 100644 --- a/nac_analytics/products/nexus_dashboard/templates/config.example.yaml +++ b/nac_analytics/products/nexus_dashboard/templates/config.example.yaml @@ -13,21 +13,23 @@ # --------------------------------------------------------------------------- # Supported variables — nexus_dashboard section # --------------------------------------------------------------------------- -# Key Env var Description -# host ND_HOST Hostname or IP (HTTPS assumed when no scheme given). -# domain ND_DOMAIN Login domain (DefaultAuth, local, or your LDAP domain). -# verify_ssl ND_VERIFY_SSL Verify TLS cert (ND_VERIFY_TLS also accepted). -# ca_bundle ND_CA_BUNDLE Path to a CA bundle for private/self-signed certs. -# fabric ND_FABRIC Default fabric when a command needs one and --fabric is omitted. -# fabrics — Fabrics `compliance --all` checks (defaults to [fabric]). -# job_timeout_minutes ND_JOB_TIMEOUT_MINUTES Minutes to wait for a pre-change analysis job. -# poll_interval ND_POLL_INTERVAL Seconds between job status polls. -# delta_detail ND_DELTA_DETAIL Default detail: none, resources, anomalies, policy-diff, full -# (prechange defaults to `full`; delta to `resources`). +# Key Env var Description +# host ND_HOST Hostname or IP (HTTPS assumed when no scheme given). +# domain ND_DOMAIN Login domain (DefaultAuth, local, or your LDAP domain). +# verify_ssl ND_VERIFY_SSL Verify TLS cert (ND_VERIFY_TLS also accepted). +# ca_bundle ND_CA_BUNDLE Path to a CA bundle for private/self-signed certs. +# fabric ND_FABRIC Default fabric when a command needs one and --fabric is omitted. +# fabrics — Fabrics `compliance --all` checks (defaults to [fabric]). +# job_timeout_minutes ND_JOB_TIMEOUT_MINUTES Minutes to wait for a pre-change analysis job. +# poll_interval ND_POLL_INTERVAL Seconds between job status polls. +# request_timeout_seconds ND_REQUEST_TIMEOUT_SECONDS Seconds to wait for one HTTP response (default 60). Raise it +# when the cluster is slow to answer. +# delta_detail ND_DELTA_DETAIL Default detail: none, resources, anomalies, policy-diff, full +# (prechange defaults to `full`; delta to `resources`). # # Credentials (secrets — set via environment, not here): -# username ND_USER Login username. -# password ND_PASSWORD Login password. +# username ND_USER Login username. +# password ND_PASSWORD Login password. # --------------------------------------------------------------------------- nexus_dashboard: @@ -41,4 +43,5 @@ nexus_dashboard: - FABRIC-B job_timeout_minutes: 30 poll_interval: 15 + request_timeout_seconds: 60 delta_detail: resources diff --git a/nac_analytics/products/nexus_dashboard/templates/env.example b/nac_analytics/products/nexus_dashboard/templates/env.example index 07ca00a..93b1448 100644 --- a/nac_analytics/products/nexus_dashboard/templates/env.example +++ b/nac_analytics/products/nexus_dashboard/templates/env.example @@ -19,5 +19,6 @@ ND_PASSWORD=change-me # ND_CA_BUNDLE=/path/to/nd-cluster-ca.pem # ND_JOB_TIMEOUT_MINUTES=60 # ND_POLL_INTERVAL=15 +# ND_REQUEST_TIMEOUT_SECONDS=60 # ND_DELTA_DETAIL=resources # ND_CONFIG=/path/to/nac-analytics.yaml diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 45fdc1e..be3721d 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -18,6 +18,8 @@ from nac_analytics.cli import app from nac_analytics.core.exceptions import AnomalyThresholdError, InputError from nac_analytics.products.nexus_dashboard import settings as nd_settings +from nac_analytics.products.nexus_dashboard.client import NDClient +from nac_analytics.products.nexus_dashboard.config import Config from nac_analytics.products.nexus_dashboard.settings import ( apply_settings, load_settings, @@ -234,6 +236,77 @@ def test_doctor_reports_connectivity( assert "authenticated_as" in result.output +def _record_configs(monkeypatch: pytest.MonkeyPatch, lab: Lab) -> list[Config]: + """Patch the CLI client factory, keeping every Config it is handed.""" + seen: list[Config] = [] + + def factory(config: Config, **_: object) -> NDClient: + seen.append(config) + return NDClient(config, http=httpx.Client(transport=httpx.MockTransport(lab))) + + monkeypatch.setattr( + "nac_analytics.products.nexus_dashboard.commands._helpers.NDClient", factory + ) + return seen + + +@pytest.mark.parametrize( + ("argv", "extra_env", "expected"), + [ + (["nd", "doctor"], {}, 60), + (["nd", "doctor", "--request-timeout", "90"], {}, 90), + (["nd", "doctor"], {"ND_REQUEST_TIMEOUT_SECONDS": "120"}, 120), + # A flag beats the environment, as it does for every other setting. + ( + ["nd", "doctor", "--request-timeout", "90"], + {"ND_REQUEST_TIMEOUT_SECONDS": "120"}, + 90, + ), + ], +) +def test_the_request_timeout_reaches_the_config( + argv: list[str], + extra_env: dict[str, str], + expected: int, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + seen = _record_configs(monkeypatch, build_lab()) + + result = runner.invoke(app, argv, env={**ENV, **extra_env}) + + assert result.exit_code == 0, result.output + assert [config.request_timeout_seconds for config in seen] == [expected] + + +def test_the_configured_timeout_is_applied_to_the_http_client() -> None: + """The setting is only useful if httpx receives it; constructing does no I/O.""" + config = Config( + host="nd.test", + username="admin", + password="secret", + fabric="FABRIC-A", + verify_ssl=False, + request_timeout_seconds=90, + ) + + with NDClient(config) as client: + assert client.client.timeout.read == 90 + assert client.client.timeout.connect == 90 + + +def test_a_non_positive_request_timeout_exits_4( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["nd", "doctor", "--request-timeout", "0"], env=ENV) + + assert result.exit_code == InputError.exit_code + assert "--request-timeout" in result.output + + def test_snapshots_prints_id_only( use_lab, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index dc6c2c6..b8ba50e 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -5,7 +5,11 @@ import pytest from nac_analytics.core.exceptions import InputError -from nac_analytics.products.nexus_dashboard.config import Config, normalise_host +from nac_analytics.products.nexus_dashboard.config import ( + DEFAULT_REQUEST_TIMEOUT_SECONDS, + Config, + normalise_host, +) @pytest.mark.parametrize( @@ -69,3 +73,16 @@ def test_nonsensical_polling_settings_are_rejected() -> None: Config(host="nd", username="u", password="p", poll_interval_seconds=0) with pytest.raises(InputError): Config(host="nd", username="u", password="p", job_timeout_minutes=0) + + +@pytest.mark.parametrize("seconds", [0, -1]) +def test_a_non_positive_request_timeout_is_rejected(seconds: int) -> None: + """httpx treats 0 as 'fail immediately', which is never what was meant.""" + with pytest.raises(InputError, match="--request-timeout"): + Config(host="nd", username="u", password="p", request_timeout_seconds=seconds) + + +def test_the_request_timeout_defaults_to_sixty_seconds() -> None: + config = Config(host="nd", username="u", password="p") + + assert config.request_timeout_seconds == DEFAULT_REQUEST_TIMEOUT_SECONDS == 60 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 709c948..a81e1aa 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -44,6 +44,26 @@ def test_a_real_environment_variable_beats_yaml(restore_environ: None) -> None: assert os.environ["ND_HOST"] == "real.example.com" +def test_a_yaml_request_timeout_reaches_the_environment( + restore_environ: None, +) -> None: + """Typer parses the env var as an int, so a float YAML value must not survive.""" + os.environ.pop("ND_REQUEST_TIMEOUT_SECONDS", None) + + apply_settings({"request_timeout_seconds": 90.0}, path=Path("nac-analytics.yaml")) + + assert os.environ["ND_REQUEST_TIMEOUT_SECONDS"] == "90" + + +def test_request_timeout_is_a_known_key( + restore_environ: None, + caplog: pytest.LogCaptureFixture, +) -> None: + apply_settings({"request_timeout_seconds": 90}, path=Path("x.yaml")) + + assert not any("request_timeout_seconds" in r.message for r in caplog.records) + + def test_fabrics_default_from_fabric_when_the_list_is_omitted( restore_environ: None, ) -> None: