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: