Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a571a81
docs(#856): architecture plan for controller resolution refactor
oboehmer Jul 17, 2026
1b0b905
feat(core): controller resolution refactor - Phase 1
oboehmer Aug 17, 2026
4846fe3
refactor: simplify get_controller_context() fallback
oboehmer Aug 17, 2026
1ee7116
fix: move VALID_TEST_TYPES to pyats_core/constants to break circular …
oboehmer Aug 17, 2026
23845f7
test: consolidate controller env cleanup into global autouse fixture
oboehmer Aug 17, 2026
9b631cc
docs: update controller-resolution-refactor.md for Phase 1 accuracy
oboehmer Aug 17, 2026
a765352
refactor: Phase 2.5 cleanup - deprecation docs and is_env_var_set ext…
oboehmer Aug 17, 2026
49a29be
remove redundant clear env fixture
oboehmer Aug 18, 2026
0ed93fb
test: consolidate SDWAN fixtures and move auth tests to core
oboehmer Aug 18, 2026
b53986e
test: consolidate controller detection tests
oboehmer Aug 18, 2026
12bf870
chore: remove architecture planning doc from repo
oboehmer Aug 18, 2026
421aca2
fix: address agent review comments
oboehmer Aug 19, 2026
68bc411
fix: scope logging.getLogger patch to base_test module
oboehmer Aug 19, 2026
9ac0a2c
fix: use caplog instead of mocking logging.getLogger
oboehmer Aug 19, 2026
2c05054
run pipeline
oboehmer Aug 20, 2026
0925e62
fix: replace get_controller_context mock with real env var injection
oboehmer Aug 20, 2026
2f0f659
feat(core): add generic connection-param and insecure-flag resolvers
oboehmer Aug 23, 2026
67235df
refactor: derive credential remediation UI from CONTROLLER_REGISTRY, …
oboehmer Aug 23, 2026
0fade2e
refactor: harden SSOT contract — validation, deprecation, strictness,…
oboehmer Aug 24, 2026
870afca
fix: ResolutionError inherits from NacTestError
oboehmer Aug 26, 2026
cd319b8
refactor: extract ENV_CONTROLLER_CONTEXT constant to core/constants.py
oboehmer Aug 26, 2026
89cc192
feat(core): add AuthMethod enum for typed auth_method values
oboehmer Aug 26, 2026
ce80549
fix: remove orphan clean_controller_env fixtures that shadow global
oboehmer Aug 26, 2026
11b02ca
fix: validate controller_type in from_json() deserialization boundary
oboehmer Aug 26, 2026
2aea719
chore: delete orphaned nac_test/utils/environment.py
oboehmer Aug 26, 2026
04935f6
refactor: move AuthCache from pyats_core/common/ to core/ (#912)
oboehmer Aug 26, 2026
acf80b1
test: add ControllerContext subprocess round-trip test (#913)
oboehmer Aug 26, 2026
9f2a992
refactor: adopt ControllerContext fixtures at inline construction sit…
oboehmer Aug 26, 2026
7c4473c
refactor: migrate test_controller_auth.py to pytest-mock (#916)
oboehmer Aug 26, 2026
3d70583
fix: derive HTML report remediation from CONTROLLER_REGISTRY
oboehmer Aug 26, 2026
995af42
fix: use AuthMethod enum in get_connection_params() test calls
oboehmer Aug 26, 2026
2543223
test: add empty/whitespace env var coverage for get_connection_params
oboehmer Aug 26, 2026
8ad115d
refactor: add EXPECTED_CONTROLLER_TYPE and SUPPORTED_AUTH_METHODS cla…
oboehmer Aug 26, 2026
9676848
Merge remote-tracking branch 'origin/main' into feat/856-controller-r…
oboehmer Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 76 additions & 24 deletions dev-docs/PRD_AND_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5727,21 +5727,32 @@ apic:

### Controller Type Auto-Detection

**Location:** `nac_test/utils/controller.py`
**Location:** `nac_test/core/controller.py`

nac-test automatically detects the network architecture based on which credential environment variables are set, eliminating the need for users to explicitly set `CONTROLLER_TYPE`.

Controller resolution follows a **single source of truth (SSOT)** pattern.
All controller metadata — env var names, credential sets, auth methods,
display names, defaults prefixes, and insecure-flag env vars — lives in
`CONTROLLER_REGISTRY` in `nac_test/core/controller.py`.

#### CONTROLLER_REGISTRY and CredentialSets

```python
from dataclasses import dataclass
from collections.abc import Mapping

@dataclass(frozen=True)
class CredentialSet:
"""A single credential combination that can authenticate to a controller."""
env_vars: list[str] # Env vars required for this method
label: str # Human-readable label (e.g., "API Token (20.18+)")
auth_method: str = "session" # Auth mechanism hint for downstream adapters
fields: Mapping[CredentialKind, str] # kind → env var name (SSOT)
label: str # Human-readable label
auth_method: str = "session" # Auth mechanism for downstream adapters

@property
def env_vars(self) -> tuple[str, ...]:
"""Derived from fields — cannot drift."""
return tuple(self.fields.values())

@dataclass(frozen=True)
class ControllerConfig:
Expand Down Expand Up @@ -5792,44 +5803,85 @@ CONTROLLER_REGISTRY: dict[str, ControllerConfig] = {
}
```

#### Detection Algorithm
#### Resolution Algorithm

```python
def detect_controller_type() -> str:
"""Auto-detect controller type from environment variables.
@dataclass(frozen=True)
class ControllerContext:
"""Resolved controller selection identity (type + auth method).
Not connection state — credentials resolved separately via get_connection_params().
"""
controller_type: ControllerTypeKey
auth_method: str

def resolve_controller() -> ControllerContext:
"""Single source of truth for controller detection.

Iterates through CONTROLLER_REGISTRY. For each controller, iterates its
credential_sets in order. The first CredentialSet whose env_vars are ALL
present and non-empty marks the controller as detected. The winning
CredentialSet is stored for later retrieval via get_matched_credential_set().
present and non-empty marks the controller as detected.

Returns:
Controller type key (e.g., "ACI", "SDWAN")
ControllerContext with controller_type and auth_method.

Raises:
ValueError: If no credentials, multiple controllers, or incomplete credentials
NoCredentialsFound: No controller env vars detected.
MultipleControllersFound: More than one controller configured.
IncompleteCredentials: Some env vars present but no complete set.

Example:
# If SDWAN_URL and SDWAN_API_TOKEN are set:
detect_controller_type() → "SDWAN"
get_matched_credential_set("SDWAN").auth_method → "token"
ctx = resolve_controller()
ctx.controller_type → "SDWAN"
ctx.auth_method → "token"
"""
complete_sets, partial_controllers, matched_creds = _find_credential_sets()
# ... validation logic (multiple, none, incomplete) ...
controller_type = complete_sets[0]
_matched_credential_sets[controller_type] = matched_creds[controller_type]
return controller_type


def get_matched_credential_set(controller_type: str) -> CredentialSet | None:
"""Public API for nac-test-pyats-common to retrieve the winning credential set.
def get_controller_context() -> ControllerContext:
"""Subprocess accessor — reads NAC_TEST_CONTROLLER_CONTEXT env var.
Falls back to resolve_controller() if env var is absent (transitional).
"""

Returns the CredentialSet that was matched during detect_controller_type().
The auth_method attribute tells the auth adapter which mechanism to use.
def get_connection_params(controller_type, auth_method) -> dict[CredentialKind, str]:
"""Resolve credential values by kind from environment.
Example: {"url": "https://...", "token": "abc.def.ghi"}
"""
return _matched_credential_sets.get(controller_type)

def should_verify_ssl(controller_type, default=False) -> bool:
"""Determine whether SSL certificate verification should be enabled."""
```

#### Resolution Flow

```
CombinedOrchestrator
└─ resolve_controller() # Single detection call site
└─ Returns ControllerContext(controller_type, auth_method)
├─ preflight_auth_check(ctx) # Pre-flight validation
└─ PyATSOrchestrator(controller_context=ctx)
└─ Serializes to NAC_TEST_CONTROLLER_CONTEXT at subprocess launch
└─ NACTestBase.setup()
└─ get_controller_context() # Deserializes from env
└─ get_connection_params() # Reads credentials from env
└─ get_controller_url() # Reads URL from env
└─ should_verify_ssl() # Reads SSL flag from env
```

#### Cross-Package Contract (nac-test-pyats-common)

Auth adapters consume these functions from `nac_test.core.controller`:

| Function | Purpose |
|----------|---------|
| `get_controller_context()` | Controller identity in subprocess |
| `get_connection_params()` | Credential values by kind |
| `should_verify_ssl()` | SSL verification toggle |

Adapters validate `controller_type` and `auth_method` at setup time
via guards (`_SUPPORTED_AUTH_METHODS`).

#### Detection Flow Diagram

```mermaid
Expand Down
27 changes: 25 additions & 2 deletions nac_test/_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

"""Low-level environment variable parsing utilities.

For higher-level environment validation (required vars, controller credentials),
see nac_test.utils.environment.EnvironmentValidator.
For higher-level environment validation, see nac_test.utils.environment.
For controller resolution and credentials, see nac_test.core.controller.
"""

# Why _env.py lives here instead of utils/env.py:
Expand Down Expand Up @@ -98,3 +98,26 @@ def get_positive_numeric_env(
)

return default


def is_env_var_set(var: str) -> bool:
"""Check if an environment variable exists and has a non-whitespace value.

Args:
var: Environment variable name.

Returns:
True if the variable exists and contains non-whitespace content.

Example:
>>> os.environ["MY_VAR"] = "value"
>>> is_env_var_set("MY_VAR")
True
>>> os.environ["EMPTY"] = " "
>>> is_env_var_set("EMPTY")
False
>>> is_env_var_set("NOT_SET")
False
"""
value = os.environ.get(var)
return bool(value and value.strip())
27 changes: 22 additions & 5 deletions nac_test/cli/ui/banners.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@

import typer

from nac_test.utils.controller import get_display_name
from nac_test.core.controller import (
get_credential_vars,
get_display_name,
)
from nac_test.utils.terminal import TerminalColors
from nac_test.utils.url import extract_host

Expand Down Expand Up @@ -239,11 +242,27 @@ def display_aci_defaults_banner() -> None:
_render_banner(title, content_lines)


def _credential_remediation_lines(controller_type: str) -> list[str]:
"""Build 'export VAR=<kind>' lines for every credential var a controller accepts.

Derives var names and kinds from ``CONTROLLER_REGISTRY`` via the
:func:`~nac_test.core.controller.get_credential_vars` primitive, so
token-only credential sets (e.g. SDWAN's API token) are represented correctly.
"""
try:
cred_vars = get_credential_vars(controller_type)
except KeyError:
return [
f" export {controller_type}_USERNAME=<username>",
f" export {controller_type}_PASSWORD=<password>",
]
return [f" export {var}=<{kind}>" for kind, var in cred_vars]


def display_auth_failure_banner(
controller_type: str,
controller_url: str,
detail: str,
env_var_prefix: str,
) -> None:
"""Display a prominent banner when controller authentication fails.

Expand All @@ -255,7 +274,6 @@ def display_auth_failure_banner(
controller_type: The controller type string (e.g., "ACI", "SDWAN", "CC").
controller_url: The URL that was attempted.
detail: Human-readable error detail (e.g., "HTTP 401: Unauthorized").
env_var_prefix: The environment variable prefix (e.g., "ACI", "SDWAN", "CC").

Note:
Uses the same box style and color handling as display_aci_defaults_banner.
Expand All @@ -274,8 +292,7 @@ def display_auth_failure_banner(
),
"",
"Verify your credentials:",
f" export {env_var_prefix}_USERNAME=<username>",
f" export {env_var_prefix}_PASSWORD=<password>",
*_credential_remediation_lines(controller_type),
"",
]
_render_banner(title, content_lines)
Expand Down
6 changes: 2 additions & 4 deletions nac_test/cli/validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@

from nac_test.cli.validators.aci_defaults import validate_aci_defaults
from nac_test.cli.validators.args import validate_extra_args
from nac_test.cli.validators.common import is_architecture_active
from nac_test.cli.validators.controller_auth import (
from nac_test.core.controller import CONTROLLER_REGISTRY, ControllerConfig
from nac_test.core.controller_auth import (
AuthCheckResult,
preflight_auth_check,
)
from nac_test.core.error_classification import AuthOutcome
from nac_test.utils.controller import CONTROLLER_REGISTRY, ControllerConfig
from nac_test.utils.url import extract_host

__all__ = [
Expand All @@ -28,7 +27,6 @@
"CONTROLLER_REGISTRY",
"ControllerConfig",
"extract_host",
"is_architecture_active",
"validate_extra_args",
"preflight_auth_check",
"validate_aci_defaults",
Expand Down
6 changes: 2 additions & 4 deletions nac_test/cli/validators/aci_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@
import os
from pathlib import Path

from nac_test.core.controller import CONTROLLER_REGISTRY
from nac_test.utils.yaml import YAMLError, safe_load

logger = logging.getLogger(__name__)

# Environment variable that indicates ACI environment
ACI_URL_ENV_VAR = "ACI_URL"


def validate_aci_defaults(data_paths: list[Path]) -> bool:
"""Validate that ACI defaults file is provided when in ACI environment.
Expand All @@ -43,7 +41,7 @@ def validate_aci_defaults(data_paths: list[Path]) -> bool:
True if validation passes (not ACI environment, or defaults found).
False if ACI environment detected AND no defaults structure found.
"""
aci_url = os.environ.get(ACI_URL_ENV_VAR)
aci_url = os.environ.get(CONTROLLER_REGISTRY["ACI"].url_env_var)
if not aci_url:
# Not an ACI environment, no validation needed
return True
Expand Down
41 changes: 0 additions & 41 deletions nac_test/cli/validators/common.py

This file was deleted.

Loading
Loading