Skip to content

feat(core): controller resolution refactor - single-source-of-truth - #896

Open
oboehmer wants to merge 31 commits into
mainfrom
feat/856-controller-resolution-refactor
Open

feat(core): controller resolution refactor - single-source-of-truth#896
oboehmer wants to merge 31 commits into
mainfrom
feat/856-controller-resolution-refactor

Conversation

@oboehmer

@oboehmer oboehmer commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Description

Problem

Controller credentials were validated and resolved across multiple layers — CLI validators, orchestrators, PyATS subprocesses — with each layer independently reading os.environ, re-detecting the controller type, and making its own assumptions about credential shapes. This created:

  • Multiple sources of truth: 3+ detection call sites per execution, each potentially disagreeing
  • Drift risk: Adding a new auth method (e.g., SD-WAN JWT tokens in PR Add sdwan (20.18+) jwt token based auth support for pyats nac-test testcases #847) required touching every layer
  • Scattered env var literals: SDWAN_URL, ACI_USERNAME, etc. hardcoded across both nac-test and nac-test-pyats-common
  • No typed contract: Subprocesses re-derived controller identity from scratch instead of receiving it from the parent

Approach

This PR establishes nac_test/core/controller.py as the single source of truth for all controller metadata and provides a typed resolution flow that eliminates redundant detection.

Architecture (both repos)

CombinedOrchestrator
  └─ resolve_controller()              ← ONE detection call site
       └─ ControllerContext(type, auth_method)
            │
            ├─ preflight_auth_check(ctx)
            │
            └─ PyATSOrchestrator(controller_context=ctx)
                 └─ NAC_TEST_CONTROLLER_CONTEXT (env var, set at subprocess launch)
                      │
                      └─ NACTestBase.setup()
                           ├─ get_controller_context()    ← deserializes identity
                           ├─ get_connection_params()     ← resolves credentials by kind
                           ├─ get_controller_url()        ← reads URL from env
                           └─ should_verify_ssl()         ← reads SSL flag from env

nac-test (this PR) owns the SSOT registry and resolution primitives.
nac-test-pyats-common (#46) consumes them — auth adapters no longer read env vars directly for controller config.

Key design decisions

  1. ControllerContext is identity, not state — It carries controller type + auth method selection. Credentials remain in env vars (already there, set by the caller). No duplication into another transport mechanism.

  2. CredentialSet.fields — A Mapping[CredentialKind, str] that is the single source for "which env var backs which semantic kind". env_vars and kinds are derived properties that cannot drift. The order of controller env variables in the definition bears no meaning anymore (previously the controller URL had to be the first value in the list).

  3. get_connection_params(controller_type, auth_method) — Generic resolver that returns {"url": "...", "username": "...", ...} keyed by semantic kind. Adapters never need to know env var names.

  4. should_verify_ssl(controller_type) — Centralized SSL flag resolution from {PREFIX}_INSECURE env vars. Returns True when SSL should be verified.

  5. URL normalization is an adapter concernget_controller_url() returns the raw value (whitespace-stripped only). Each architecture-specific adapter owns rstrip("/") as needed. Integration tests enforce the trailing-slash contract.

Benefits

Before After
3+ detection calls per execution 1 call in CombinedOrchestrator
Env var names scattered across 2 repos All in CONTROLLER_REGISTRY
Each adapter reads its own {PREFIX}_INSECURE should_verify_ssl("ACI") — one call
Auth method inferred per-adapter Resolved once, passed as auth_method
Subprocess re-detects (21 env var lookups) Deserializes typed context
Adding a new controller → touch 5+ files Add one entry to CONTROLLER_REGISTRY

Closes

Related Issue(s)

Type of Change

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactoring / Technical debt (internal improvements with no user-facing changes)

Both packages published in lockstep.

Test Framework Affected

  • PyATS
  • Robot Framework
  • Both
  • N/A (not test-framework specific)

Network as Code (NaC) Architecture Affected

  • All architectures

Key Changes

Core SSOT primitives (nac_test/core/)

  • ControllerContext dataclass in core/types.py — identity-only, JSON serialization
  • resolve_controller() → typed ResolutionError hierarchy (replaces ValueError)
  • get_controller_context() — subprocess accessor (deserializes from env var)
  • get_connection_params(controller_type, auth_method)dict[CredentialKind, str]
  • should_verify_ssl(controller_type, default=False)bool
  • CredentialSet.fields: Mapping[CredentialKind, str] replaces the old env_vars tuple

Orchestration wiring

  • CombinedOrchestrator calls resolve_controller() once, passes context downstream
  • PyATSOrchestrator accepts controller_context parameter; serializes at subprocess launch (not __init__)
  • NACTestBase.setup() consumes get_controller_context() + get_connection_params() (fails fast)

Cleanup

  • controller_auth.py: Consolidated URL lookup to use get_controller_url() from core
  • EnvironmentValidator dissolved (redundant layer)
  • is_architecture_active() and TerminalColors.format_env_var_error() removed (dead code)
  • detect_controller_type() / get_matched_credential_set() deprecated with DeprecationWarning
  • detect_controller_type removed from utils.__init__ re-exports
  • Bridge shim at utils/controller.py retained for external consumers until Phase 3

Deprecation path

Function Replacement Removal
detect_controller_type() resolve_controller() Phase 3 (#897)
get_matched_credential_set() get_controller_context().auth_method Phase 3 (#897)
utils.controller shim core.controller direct imports Phase 3 (#897)

Testing Done

  • 2066 tests pass (0 failures, 377 skipped)
  • New tests: from_json() error paths, primary context path via NAC_TEST_CONTROLLER_CONTEXT, URL trailing-slash normalization contract (integration)
  • Global autouse clean_controller_env fixture (registry-derived, xdist-safe)
  • Pre-commit clean (ruff, mypy, bandit)

Opportunistic Fix (unrelated to #856)

Circular import: utils/cleanup.pydiscovery/test_type_resolver → full pyats_core.discovery chain. Moved VALID_TEST_TYPES to pyats_core/constants.py.

Checklist

  • Code follows project style guidelines (pre-commit run -a passes)
  • Self-review of code completed
  • Code is commented where necessary (especially complex logic)
  • Documentation updated (dev-docs/PRD_AND_ARCHITECTURE.md)
  • No new warnings introduced

Single-resolution design: resolve_controller() in core/controller.py is the
sole detection point. PyATSOrchestrator receives ControllerContext as a typed
parameter and owns serialization to NAC_TEST_CONTROLLER_CONTEXT before
subprocess launch. EnvironmentValidator dissolved.

Ref: #856
@oboehmer
oboehmer marked this pull request as draft August 17, 2026 11:06
@oboehmer
oboehmer force-pushed the feat/856-controller-resolution-refactor branch from 281549c to 5f20bc8 Compare August 17, 2026 11:31
Single source of truth for controller detection with typed context.

Changes:
- Add ControllerContext dataclass to core/types.py with JSON serialization
- Move utils/controller.py → core/controller.py with new API:
  - resolve_controller() returns ControllerContext, raises typed exceptions
  - get_controller_context() for subprocess access via env var
  - format_resolution_error() for user-friendly error messages
- Move cli/validators/controller_auth.py → core/controller_auth.py
  - preflight_auth_check() now accepts ControllerContext
- Dissolve EnvironmentValidator class (validate_controller_env removed)
- Update CombinedOrchestrator to use resolve_controller() + store context
- Update PyATSOrchestrator to accept controller_context parameter
  - Serializes to NAC_TEST_CONTROLLER_CONTEXT env var for subprocess
- Wire base_test.py to use get_controller_context() (no more re-detection)
- Make detect_controller_type() delegate to resolve_controller() (deprecated)
- Add bridge-release shim at utils/controller.py (2 exports only)
- Add ControllerContext fixtures (aci_context, sdwan_context)
- Move tests/utils/test_controller.py → tests/unit/core/
- Parametrize TestResolveController tests
- Add tests for env var population in PyATSOrchestrator

Closes #856
@oboehmer
oboehmer force-pushed the feat/856-controller-resolution-refactor branch from 5f20bc8 to 1b0b905 Compare August 17, 2026 11:32
- Remove NAC_TEST_STRICT_CONTEXT (transitional, not needed)
- Downgrade fallback warning to INFO log level
…import

cleanup.py imported from discovery/test_type_resolver, which triggered
the full pyats_core.discovery chain including common/types. This caused
a circular import when utils/__init__.py was loaded early.

Moving VALID_TEST_TYPES to pyats_core/constants.py breaks the cycle since
that module only imports from core/constants and _env - no discovery deps.

Note: Opportunistic fix discovered during PR #896 review, not directly
related to the controller resolution refactor.
- Move clean_controller_env fixture to tests/conftest.py (global autouse)
- Derive CONTROLLER_ENV_PREFIXES from CONTROLLER_REGISTRY (single source of truth)
- Clear NAC_TEST_CONTROLLER_CONTEXT and _matched_credential_sets cache
- Remove duplicate fixtures from tests/unit/ and tests/pyats_core/
- Remove redundant clean_environment fixture from test_controller.py

Fixes test isolation issues when running with pytest-xdist (-n auto).
- Fix format_resolution_error() signature (single arg, not two)
- Clarify dry-run semantics: note PyATSOrchestrator fallback behavior
- Fix base_test.py status: should be updated in Phase 1, not 'has been'
- Remove NAC_TEST_STRICT_CONTEXT references (feature removed)
…raction

- Add deprecation docstrings to detect_controller_type() and get_matched_credential_set()
- Clarify get_controller_context() docstring for subprocess usage
- Extract is_env_var_set() to _env.py as public utility
- Add parametrized unit tests for is_env_var_set()
- Reorder controller.py: public API → deprecated → private helpers
- Remove sdwan_session_context fixture, unify to sdwan_context with session auth
- Move test_controller_auth.py from tests/unit/cli/validators/ to tests/unit/core/
- Update docstring to remove CLI reference
- test_controller.py: Remove 4 happy-path tests covered by parametrized contract tests
- test_controller_auth.py: Move TestClassifyAuthError and TestExtractHttpStatusCode
  to new test_error_classification.py (correct module ownership); remove duplicate URL test
- test_orchestrator_config_error.py: Remove explicit-context test (duplicate of
  test_orchestrator_controller_param.py::test_orchestrator_uses_provided_controller_context)
Keep locally for reference but not needed in PR.
- Wrap get_controller_context() in setup() with try/except and log via
  self.logger.error before re-raising (ValueError, KeyError,
  JSONDecodeError) — aligns with every other failure path in base_test.py

- Fix clean_controller_env fixture: use key.startswith(prefix) instead
  of prefix in key to avoid false matches on unrelated env vars
  (e.g. GCC_FLAGS matching CC_, NOISE_LEVEL matching ISE_)

- Add parametrized tests for setup() error logging covering all three
  exception types (tests/pyats_core/common/test_base_test_controller_detection.py)
Patching logging.getLogger globally interfered with unittest.mock's
own internal use of logging, causing the get_controller_context patch
to silently fail — the real function ran and raised ValueError instead
of the injected KeyError/JSONDecodeError.

Scope the patch to nac_test.pyats_core.common.base_test.logging.getLogger
so it only intercepts the logger setup inside setup(), not the mock
machinery itself.
Patching logging.getLogger (even scoped to the base_test module) causes
unittest.mock's own patch machinery to malfunction on Python 3.10 — the
get_controller_context patch silently fails, the real function runs, and
ValueError is raised instead of the injected KeyError/JSONDecodeError.

Replace the logger mock + logging.getLogger patch with pytest's built-in
caplog fixture. caplog captures log records at the handler level without
touching getLogger, so the get_controller_context patch works correctly.
Patching 'nac_test.pyats_core.common.base_test.get_controller_context'
consistently failed on Python 3.10 — the real function executed regardless
of the patch, causing ValueError instead of the injected KeyError/JSONDecodeError.

Root cause is still under investigation but appears to be a Python 3.10
interaction between pytest-xdist worker isolation and unittest.mock's
attribute lookup for the patch target.

Replace the patching approach with real env var injection through
NAC_TEST_CONTROLLER_CONTEXT, which exercises the actual get_controller_context
code paths:
- absent env var + no controller creds → ValueError (fallback path)
- JSON missing controller_type field → KeyError (from_json path)
- malformed JSON → JSONDecodeError (from_json path)

This is more robust: no mock machinery involved, tests the real execution
path, and works identically across all Python versions.
@oboehmer oboehmer changed the title feat(core): controller resolution refactor - Phase 1 feat(core): controller resolution refactor - single-source-of-truth Aug 23, 2026
oboehmer and others added 3 commits August 23, 2026 12:15
Add CredentialSet.fields (a Mapping[CredentialKind, str]) as the single
source of truth for which env var backs which semantic credential kind
(url/username/password/token) per controller type and auth method;
env_vars and kinds are now derived properties so they can never drift
out of sync with each other.

Add get_connection_params(controller_type, auth_method) to resolve a
dict of {kind: value} from the environment, and get_insecure_flag
(controller_type, default) to resolve the SSL-verification-disable
flag via a new ControllerConfig.insecure_env_var field. Both raise
KeyError for an unregistered controller_type; get_connection_params
raises ValueError when no credential set matches the given auth_method
or when required env vars are missing, and picks the most-fully-
configured candidate among sets that share an auth_method (e.g.
IOSXE's IOSXE_URL vs IOSXE_HOST variants) so the error points at the
variant the caller actually started configuring.

NACTestBase.setup() now resolves self.auth_method and
self.connection_params via these new functions in addition to the
existing self.username/self.password, giving pyats_core-based test
suites (e.g. nac-test-pyats-common) a generic, non-username/password
credential shape to consume without re-reading os.environ themselves.
The abstract NACTestBase.get_connection_params() stub (previously
NotImplementedError, never implemented by any subclass) is removed in
favor of this instance attribute.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…drop dead code

display_auth_failure_banner() no longer takes an env_var_prefix param;
it now derives its "export VAR=<kind>" remediation lines directly from
CONTROLLER_REGISTRY via a new _credential_remediation_lines() helper,
so token-only credential sets (e.g. SDWAN's API token) are represented
correctly instead of assuming every controller has USERNAME/PASSWORD
vars. combined_orchestrator.py no longer needs to look up an env var
prefix to pass in.

validate_aci_defaults() reads CONTROLLER_REGISTRY["ACI"].url_env_var
instead of a hardcoded ACI_URL_ENV_VAR constant, so an env var rename
only touches nac_test/core/controller.py.

Remove now-dead code with no remaining callers:
- is_architecture_active() (nac_test/cli/validators/common.py, whole
  file) - superseded by controller-type resolution.
- TerminalColors.format_env_var_error() (~94 lines) - the old
  auto-detection error banner this formatted has been replaced by
  get_connection_params()'s ValueError messages and the auth-failure
  banner above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… and API cleanup

- Add from_json() validation (wraps JSONDecodeError → ValueError)
- Clarify ControllerContext as identity-only (docstring + design intent)
- Consolidate URL lookup: controller_auth.py uses core's get_controller_url()
- Make base_test.py fail fast on get_connection_params() failure
- Add DeprecationWarning to detect_controller_type() / get_matched_credential_set()
- Remove detect_controller_type re-export from utils/__init__
- Move NAC_TEST_CONTROLLER_CONTEXT env write from __init__ to subprocess launch
- Rename get_insecure_flag() → should_verify_ssl() (polarity flip, clearer API)
- Keep URL normalization (rstrip) as adapter concern, not core
- Add integration tests for URL trailing-slash normalization contract
- Add unit tests for from_json error paths and primary context path
- Update PRD_AND_ARCHITECTURE.md with new resolution design

Co-Authored-By: Claude <noreply@anthropic.com>
@oboehmer
oboehmer force-pushed the feat/856-controller-resolution-refactor branch from 1b75a18 to 0fade2e Compare August 24, 2026 12:12
@oboehmer
oboehmer marked this pull request as ready for review August 24, 2026 13:30
@oboehmer
oboehmer requested a review from aitestino August 25, 2026 12:55
@oboehmer oboehmer added code-quality Code quality improvements and standards enforcement tech-debt General technical debt requiring refactoring refactor Code refactoring without changing functionality labels Aug 25, 2026

@aitestino aitestino left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @oboehmer, thank you for the PR — the SSOT direction is right, and the internal consolidation has genuinely landed. CONTROLLER_REGISTRY is the single source, CredentialSet.fields deriving env_vars/kinds closes the drift risk between those two, MappingProxyType on the frozen dataclass is the correct immutability pattern, and the ControllerContext-as-identity design (credentials stay in original env vars, only the identity crosses the subprocess boundary) is sound. The deprecation strategy on detect_controller_type()/get_matched_credential_set() is clean — call-time warnings.warn(stacklevel=2), and I verified the warning fires correctly through the utils.controller shim.

I did a thorough review. Most findings are finishing-pass or cross-repo migration items I'd rather track as issues. A handful are worth addressing before merge:

Things that need adjustment before merge:

  1. HTML report gives wrong remediation for SDWAN token auth — this is the one real user-facing bug I found. Your banners.py::_credential_remediation_lines() in this PR correctly derives credential vars from CONTROLLER_REGISTRY, but the sibling HTML template at pyats_core/reporting/templates/auth_failure/report.html.j2:395-408, 449-454 still hardcodes _USERNAME/_PASSWORD for only ACI/SDWAN/CC. So an SDWAN token-auth user hitting a preflight failure sees export SDWAN_API_TOKEN=… in the terminal banner but export SDWAN_USERNAME= / SDWAN_PASSWORD=… in the HTML report — contradictory instructions for the same failure. MERAKI/FMC/ISE/IOSXE users get no guidance from that block at all. The fix is to expose _credential_remediation_lines() publicly and drive the template loops from CONTROLLER_REGISTRY the same way banners.py does.

  2. ResolutionError doesn't inherit from NacTestError — CLAUDE.md convention. One-line change at core/controller.py:253; the three subclasses get it transitively. nac_test/exceptions.py has zero internal imports so there's no circular-import risk. Downstream except NacTestError handlers currently miss these.

  3. NAC_TEST_CONTROLLER_CONTEXT env-var name repeated 4 times with no constantcore/controller.py:353, pyats_core/orchestrator.py:281, 369, 384. The precedent already exists: ENV_TEST_DIR: str = "NAC_TEST_TEST_DIR" at pyats_core/constants.py:124, same shape of problem (orchestrator writes, subprocess-side reads by env var name). Add ENV_CONTROLLER_CONTEXT to core/constants.py — it has to live in core since core/controller.py is the reader and core mustn't import from pyats_core.

  4. AuthMethod isn't a real enumauth_method: str in CredentialSet:56, ControllerContext:48, and get_connection_params:534, compared by == at line 573. The only two values that ever flow are "session" and "token". Since nac-test-pyats-common doesn't consume these APIs yet (grep confirms zero external callers of get_connection_params/get_controller_context), this is the cheapest possible moment to lock the enum in. A StrEnum would catch a typo like auth_method="Token" in a future CredentialSet at mypy instead of silently returning an empty candidates list three calls downstream.

  5. Orphan clean_controller_env fixtures shadow the new global onetests/integration/test_cli_aci_validation.py:29-40, 209-223 still have class-scoped autouse fixtures with the same name as the new global one you added. Pytest fixture resolution means these local copies override for those classes — and they use prefix in key (substring), miss the IOSXE_ prefix, don't clear NAC_TEST_CONTROLLER_CONTEXT, and don't clear _matched_credential_sets. Same consolidation pattern you fixed twice in this PR, just missed in these two spots.

Follow-up issues to track (none block the merge):

I have opened:

  • #910 — Wire up should_verify_ssl() across nac-test-pyats-common auth adapters (currently the function exists but has zero production callers on our side; the four hardcoded os.environ.get("*_INSECURE", "True")... sites in aci/sdwan/catc/api_test_base bypass it). Also captures the caveat that the eventual fix isn't a drop-in not get_bool_env(...)should_verify_ssl calls .strip() while get_bool_env doesn't. Softening the docstring so it doesn't read as "already replaced" would be good in the meantime.
  • #911 — Delete nac_test/utils/environment.py (grepped across nac-test AND nac-test-pyats-common: check_required_vars and format_missing_vars_error have zero callers anywhere).
  • #912 — Move AuthCache from pyats_core/common/ to core/ (core/controller_auth.py now imports it, which inverts the layering; AuthCache's own docstring says it's controller-agnostic).
  • #913 — End-to-end ControllerContext subprocess round-trip test — the serialization side (orchestrator.py:281) and the deserialization side (base_test.py::setup()) are each tested in isolation but never chained. Extending the existing test_orchestrator_env_var.py capture to feed its own output into a real NACTestBase.setup() proves the round-trip without needing an actual OS subprocess.
  • #914 — Add nac_test.utils.controller shim to tests/unit/test_compat_shims.py::SHIMS — the DeprecationWarning does fire, but there's no test that either re-exported symbol is still resolvable if core/controller.py renames one in Phase 3.
  • #915 — Sanitize controller_url on display: banners.py:295, 332, 335 pass it verbatim to _wrap_url_lines, so ACI_URL=https://admin:pw@apic.local leaks credentials to terminal/CI logs/screenshots. Also AuthCheckResult.controller_url no longer trims trailing slashes (the old _get_controller_url did) — small cosmetic regression, worth locking down.
  • #916 — Migrate tests/unit/core/test_controller_auth.py to mocker fixture (from unittest.mock import MagicMock, patch at line 9, 15 usage sites; brand-new file so no legacy excuse).
  • #917 — Rewrite TestControllerEdgeCases (13 tests, lines 413-618) to exercise resolve_controller() — currently only calls the deprecated detect_controller_type(), so between now and Phase 3 those edge cases are unverified against the production code path.
  • #918 — Add public reset_controller_cache() on core/controller — the new clean_controller_env fixture at tests/conftest.py:73 reaches into controller._matched_credential_sets.clear(). A public reset function keeps future refactors of that private dict from silently breaking test isolation.
  • #919 — Adopt aci_context/sdwan_context/cc_context fixtures at the seven remaining inline ControllerContext(...) construction sites (also collapses four two-line field-by-field assertions into single dataclass equality checks).

PyATSTestDirs + pyats_test_dirs fixture is also duplicated verbatim between tests/pyats_core/conftest.py:18-65 and tests/unit/conftest.py:20-25, 126-139, but you already documented that in-file as tracked for #541 — happy to defer to that.

Design decisions I checked and agree with:

  • dataclass over Pydantic for ControllerContext — right call for a 2-field IPC envelope; adding pydantic just for that would be over-engineering. One caveat worth doing regardless of container type: from_json currently accepts any string for controller_type/auth_method, so a corrupted env var or version-skew subprocess silently constructs an invalid ControllerContext that only fails much later at a bare KeyError in the registry. A get_args(ControllerTypeKey) check with a clear ValueError at the deserialization boundary is a couple of lines and prevents that. Happy to include this in the "before merge" list if you'd rather — I put it here because you might already be planning it under a different heading.
  • Env-var handoff over stdin/file/IPC — right choice; pyats run job owns stdin, and config files add lifecycle overhead. Consistent with the existing DEVICE_INFO/MERGED_DATA_MODEL_TEST_VARIABLES_FILEPATH patterns.
  • Bridge shim — thin, time-boxed, Phase-3 removal documented. Solid.

What do you think?

P.S. — This comment was drafted using voice-to-text via Claude Code. If the tone comes across as overly direct or terse, please know that's just how it tends to phrase things. No offense or criticism is intended — this is purely an objective technical review of the PR. Thanks for understanding! 🙂

ResolutionError and its subclasses (NoCredentialsFound,
MultipleControllersFound, IncompleteCredentials) now inherit from
NacTestError instead of bare Exception.

This ensures downstream 'except NacTestError' handlers catch
controller resolution failures consistently.

Ref: PR #896 review point 2
Replace 11 raw 'NAC_TEST_CONTROLLER_CONTEXT' string literals with a
single named constant, following the ENV_TEST_DIR precedent in
pyats_core/constants.py.

Constant lives in core/constants.py (not pyats_core/) because
core/controller.py is the reader and core must not import from
pyats_core.

Ref: PR #896 review point 3
Introduce AuthMethod(str, Enum) with SESSION and TOKEN values as the
single source of truth for authentication method identifiers.

Key changes:
- ControllerContext.auth_method typed as AuthMethod (was bare str)
- CredentialSet.auth_method typed as AuthMethod with SESSION default
- get_connection_params() and _infer_auth_method() use AuthMethod
- from_json() validates auth_method via enum constructor — catches
  typos like 'Token' at deserialization with clear ValueError
- Uses str,Enum pattern (not StrEnum) for Python 3.10 mypy compat

No breaking change for consumers: AuthMethod values ARE strings
(via str inheritance), so existing == comparisons continue to work.
JSON wire format unchanged.

Ref: PR #896 review point 4
Remove 4 redundant/buggy fixtures:
- TestCliAciValidationIntegration.clean_controller_env (substring match,
  missing IOSXE_, no NAC_TEST_CONTROLLER_CONTEXT clear, no cache clear)
- TestCliAciSubprocessIntegration.clean_controller_env (same issues +
  raw os.environ instead of monkeypatch)
- TestOrchestratorUnsupportedPythonExit._clean_env (redundant delegate)
- TestCombinedOrchestratorController._clean_env (redundant delegate)

The global autouse clean_controller_env in tests/conftest.py already
runs for every test function, uses key.startswith(prefix) (correct),
includes IOSXE_, clears ENV_CONTROLLER_CONTEXT, and clears the
_matched_credential_sets cache.

Ref: PR #896 review point 5
Add get_args(ControllerTypeKey) check at from_json() so a corrupted
NAC_TEST_CONTROLLER_CONTEXT env var (version skew, partial write, CI
artifact from older branch) fails fast with a clear ValueError listing
valid values, instead of silently constructing an invalid context that
explodes later with an unhelpful KeyError in the registry.

Combined with the AuthMethod enum validation (previous commit), both
fields are now fully validated at the deserialization boundary.

Ref: PR #896 review hardening suggestion
check_required_vars() and format_missing_vars_error() have zero callers
anywhere in nac-test or nac-test-pyats-common. utils/__init__.py does
not re-export them. Safe to delete.

Closes #911
Restores correct layering: core/controller_auth.py now imports from
core/auth_cache instead of reaching into pyats_core/common/.

Changes:
- Move auth_cache.py to nac_test/core/auth_cache.py
- Move AUTH_CACHE_DIR constant to core/constants.py
- Re-export shim at old location for nac-test-pyats-common main branch
  compat (removal tracked in Phase 3 #897)
- Update test mock paths to new module location

Closes #912
Chain orchestrator serialization (to_json → env dict) with subprocess
deserialization (get_controller_context → from_json) and verify the
resulting ControllerContext is identical to the original.

Parametrized across ACI/session, SDWAN/token, CC/session to cover
both auth methods and multiple controller types.

Closes #913
…es (#919)

Replace inline ControllerContext(...) with existing conftest fixtures
(aci_context, sdwan_context, cc_context) in 4 test files. Reduces
boilerplate and ensures fixture consistency.

Left unchanged: test_orchestrator_controller_param.py (uses TOKEN auth,
no matching fixture) and test_controller_auth.py (intentional
UNKNOWN_CONTROLLER case).

Closes #919
Replace unittest.mock (MagicMock, patch context managers) with
pytest-mock's mocker fixture. 8 test methods updated, all 14 tests
pass. No logic changes — only mocking mechanism.

Closes #916
Replace hardcoded ACI/SDWAN/CC credential remediation in the auth
failure HTML report with CONTROLLER_REGISTRY-driven content:

- Detection section: removed redundant terminal-block that duplicated
  the error detail (which already lists all controllers with correct
  alternatives via _format_no_credentials_error). Remediation now
  references the error detail above.
- Bad-credentials section: uses get_credential_vars() to derive
  credential variable names from the registry, so SDWAN token auth
  shows SDWAN_API_TOKEN (not USERNAME/PASSWORD).
- Grep verification pattern: dynamically derived from registry keys.
- Add get_credential_vars() to core/controller.py as the SSOT
  primitive for credential field iteration. Both banners.py and
  combined_generator.py now delegate to it.
- Remove _build_all_controller_env_examples() (had IOSXE alternative
  bug and was a DRY violation).

Ref: PR #896 review point 1

@oboehmer oboehmer left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review! Addressed all 5 before-merge points plus several follow-ups in this push. Summary below.

Before-merge points

1. HTML report gives wrong remediation for SDWAN token auth
Fixed. The auth_failure template now derives credential remediation from CONTROLLER_REGISTRY via a new get_credential_vars() primitive in core/controller.py. The detection section no longer duplicates the error detail (which already shows all controllers with correct alternatives from _format_no_credentials_error()). The bad-credentials section correctly shows SDWAN_API_TOKEN for token auth. The grep verification pattern is also dynamically derived from registry keys. → 3d70583

2. ResolutionError does not inherit from NacTestError
Fixed. One-line change + import. Subclasses get it transitively. → 870afca

3. NAC_TEST_CONTROLLER_CONTEXT repeated with no constant
Fixed. ENV_CONTROLLER_CONTEXT added to core/constants.py, all 11 programmatic uses replaced. Docstrings left as human-readable text. → cd319b8

4. AuthMethod is not a real enum
Fixed. Introduced AuthMethod(str, Enum) with SESSION/TOKEN values. Uses str, Enum (not StrEnum) for Python 3.10 mypy compat. from_json() now validates via enum constructor — catches typos like "Token" at deserialization. No breaking change for consumers: AuthMethod.SESSION == "session" is True. JSON wire format unchanged. → 89cc192

5. Orphan clean_controller_env fixtures
Fixed. Removed 4 fixtures: 2 buggy orphans in test_cli_aci_validation.py (substring match, missing IOSXE_, no cache clear) + 2 redundant _clean_env wrappers in unit tests (global autouse already covers them). → ce80549

Follow-up issues addressed in this PR

Issue Status Commit
#911 — Delete orphaned utils/environment.py ✅ Closed 2aea719
#912 — Move AuthCache to core/ ✅ Closed (re-export shim at old path for main-branch compat) 04935f6
#913 — Round-trip subprocess test ✅ Closed (3 parametrized cases: ACI/session, SDWAN/token, CC/session) acf80b1
#916 — Migrate test_controller_auth.py to mocker ✅ Closed 7c4473c
#919 — Adopt context fixtures ✅ Closed (4 files, 8 inline sites replaced) 9f2a992

Follow-up issues deferred to Phase 3 (#897)

Comment added to #897 with the following items:

  • #910should_verify_ssl() wiring (callers are in nac-test-pyats-common, not this repo)
  • #914 — Shim regression test (shim goes away in Phase 3 anyway)
  • #915 — URL credential sanitization (pre-existing condition, not introduced by this PR)
  • #917 — Rewrite TestControllerEdgeCases to use resolve_controller() (Phase 3 scope)
  • #918 — Public reset_controller_cache() API
  • Auth cache re-export shim removal (pyats_core/common/auth_cache.py)

Hardening

Added from_json() validation at the deserialization boundary for both fields:

  • controller_type: validated against get_args(ControllerTypeKey) — catches unknown types with clear ValueError
  • auth_method: validated via AuthMethod enum constructor — catches typos immediately

11b02ca + 89cc192

Design decisions

Agreed with all three assessments. On the from_json validation caveat — implemented as suggested (see hardening above).

Test results

1218 passed, 0 failures (1 pre-existing flaky broker integration test excluded).

Replace string literals with AuthMethod.SESSION/TOKEN in 11
get_connection_params() call sites to satisfy mypy after the
AuthMethod enum introduction.

Ref: PR #896 point 4 follow-up
@oboehmer
oboehmer requested a review from aitestino August 26, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code-quality Code quality improvements and standards enforcement prio: high refactor Code refactoring without changing functionality tech-debt General technical debt requiring refactoring

Projects

None yet

2 participants