Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a

## Unreleased

### Fixed

- **A `--schema`/`NSC_SCHEMA` override no longer leaks into later runs** ([#196]).
The override was saved as the profile's freshly fetched schema. So for the
next 24 hours (default `schema_refresh: daily`), and whenever NetBox was
unreachable, runs *without* the override silently used it. Overrides are now
never written to the profile's cache.

## v1.8.5 — 2026-10-23

Patch release. `--schema` and `NSC_SCHEMA` report load failures clearly and behave the same way.
Expand Down Expand Up @@ -167,6 +175,7 @@ notices, and a fix for `nsc login` reporting the wrong user.
[#186]: https://github.com/thomaschristory/netbox-super-cli/issues/186
[#189]: https://github.com/thomaschristory/netbox-super-cli/issues/189
[#192]: https://github.com/thomaschristory/netbox-super-cli/issues/192
[#196]: https://github.com/thomaschristory/netbox-super-cli/issues/196
[#171]: https://github.com/thomaschristory/netbox-super-cli/issues/171
[#179]: https://github.com/thomaschristory/netbox-super-cli/issues/179
[#173]: https://github.com/thomaschristory/netbox-super-cli/issues/173
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/schema-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ and an optional `--refresh-schema` override:

1. `--schema <path-or-url>` (or `NSC_SCHEMA`; the flag wins) — one-shot
override. Always wins; never consults the TTL fast-path, the cache
fallback or the bundled snapshot. A missing file, unreachable URL or
fallback or the bundled snapshot. It never writes or refreshes the
profile's cache, so a later run without it can't pick it up; a cache
entry with the same hash (identical schema) is reused read-only. A missing file, unreachable URL or
invalid document exits 3 with `cannot load schema from <source>: …`.
2. **TTL fast-path.** If the policy is `daily`, `weekly`, or `manual` and
the newest sidecar-validated cache entry for the active profile is
Expand Down
19 changes: 17 additions & 2 deletions nsc/schema/source.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Schema-source resolution chain.

Order, given a `schema_refresh` policy and an optional `force_refresh`:
1. --schema flag (path or URL) — always wins, never consults TTL.
1. --schema flag / NSC_SCHEMA (path or URL) — always wins, never consults
TTL, and never writes the profile's cache (#196).
2. TTL fast-path: if `schema_refresh` allows it and a cache entry for
this profile is fresher than the policy's TTL, return it directly
without any HTTP roundtrip. Skipped when `force_refresh=True`.
Expand Down Expand Up @@ -75,7 +76,7 @@ def resolve_command_model(
)
except SchemaLoadError as exc:
raise SchemaSourceError(f"cannot load schema from {exc}") from exc
return _build_and_cache(loaded, paths, profile)
return _build_override(loaded, paths, profile)

if not force_refresh:
ttl = _ttl_for_policy(schema_refresh)
Expand Down Expand Up @@ -129,6 +130,20 @@ def _fetch_schema(url: str, profile: ResolvedProfile) -> LoadedSchema:
return LoadedSchema(source=url, body=body, hash=canonical_sha256(body), document=document)


def _build_override(loaded: LoadedSchema, paths: Paths, profile: ResolvedProfile) -> CommandModel:
"""Issue #196: an override never writes or refreshes the profile's cache.
Persisting it (with a fresh `fetched_at`) let later runs without the
override serve it via the TTL fast-path or the offline fallback. A
same-hash entry is the identical schema, so reading it is safe."""
# ValueError: CacheStore rejects a profile name outside `_PROFILE_RE`;
# the override still works, just without the cache shortcut.
with contextlib.suppress(ValueError):
cached = CacheStore(root=paths.cache_dir).load(profile.name, loaded.hash)
if cached is not None:
return cached
return build_command_model(loaded)


def _build_and_cache(loaded: LoadedSchema, paths: Paths, profile: ResolvedProfile) -> CommandModel:
store = CacheStore(root=paths.cache_dir)
cached = store.load(profile.name, loaded.hash)
Expand Down
71 changes: 71 additions & 0 deletions tests/schema/test_source_ttl.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path
from typing import Any

import httpx2
import pytest
import respx

Expand Down Expand Up @@ -444,3 +445,73 @@ def test_aged_cache_with_unchanged_hash_refreshes_sidecar(
assert route.call_count == 2, (
"the second resolve refreshed the sidecar, so the third must use the fast path"
)


def _override_schema_doc() -> dict[str, Any]:
doc = _minimal_schema_doc()
doc["info"] = {"title": "override", "version": "9.9.9"}
return doc


def _write_override(tmp_path: Path) -> str:
path = tmp_path / "override.json"
path.write_text(json.dumps(_override_schema_doc()), encoding="utf-8")
return str(path)


def test_schema_override_does_not_satisfy_later_ttl_fast_path(
tmp_path: Path, httpx2_mock: respx.Router
) -> None:
"""#196: an override must not become the profile's fresh cache entry, or the
next run without it serves the override schema for the whole TTL window."""
route = httpx2_mock.get("https://nb.example/api/schema/?format=json").respond(
200, json=_minimal_schema_doc()
)
paths = _paths(tmp_path)
override = resolve_command_model(
paths=paths,
profile=_profile(),
schema_override=_write_override(tmp_path),
schema_refresh=SchemaRefresh.DAILY,
)
live = resolve_command_model(
paths=paths, profile=_profile(), schema_override=None, schema_refresh=SchemaRefresh.DAILY
)
assert route.call_count == 1
assert live.schema_hash != override.schema_hash


def test_schema_override_is_not_served_by_offline_fallback(
tmp_path: Path, httpx2_mock: respx.Router
) -> None:
httpx2_mock.get("https://nb.example/api/schema/?format=json").mock(
side_effect=httpx2.ConnectError("offline")
)
paths = _paths(tmp_path)
override = resolve_command_model(
paths=paths, profile=_profile(), schema_override=_write_override(tmp_path)
)
fallback = resolve_command_model(paths=paths, profile=_profile(), schema_override=None)
assert fallback.schema_hash != override.schema_hash


@pytest.mark.httpx2(assert_all_called=False)
def test_schema_override_reuses_matching_cache_entry_without_refreshing_it(
tmp_path: Path, httpx2_mock: respx.Router
) -> None:
httpx2_mock.get("https://nb.example/api/schema/?format=json").respond(
200, json=_minimal_schema_doc()
)
paths = _paths(tmp_path)
live = resolve_command_model(paths=paths, profile=_profile(), schema_override=None)
profile_dir = paths.cache_dir / "prod"
stale = time.time() - 10 * 86_400
_age_sidecars(profile_dir, stale)
same = tmp_path / "same.json"
same.write_text(json.dumps(_minimal_schema_doc()), encoding="utf-8")

model = resolve_command_model(paths=paths, profile=_profile(), schema_override=str(same))

assert model.schema_hash == live.schema_hash
meta = json.loads((profile_dir / f"{live.schema_hash}.meta.json").read_text())
assert meta["fetched_at"] == stale
Loading