diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b243c5..07f92dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,45 @@ and this file MUST be updated together whenever `__version__` changes. --- +## [0.8.0-dev4] — Deployment-safety: NetBox writeback dry-run knob + +> Note: dev4 was originally planned for the first SensoryEvent publisher. +> That work shifts to dev5; this slot was reused for a deployment-safety +> follow-up to dev3 (PEP 440 doesn't allow `dev3.1` post-release suffixes +> on a dev release, so we step the dev counter instead). + +### Added +- `Settings.netbox_writeback_dry_run` config knob, sourced from either: + - env var `NETBOX_WRITEBACK_DRY_RUN=1` (highest precedence, intended for + one-off rollouts via Helm `worker.extraEnv`), or + - core secret key `netbox_writeback_dry_run: true`. + When true, the worker's periodic NetBox writeback loop still computes the + full diff and emits a structured report, but every PATCH/POST/DELETE is + short-circuited and tagged `dry_run=True`. The plumbing inside + `reconcile_to_netbox` has supported this since 0.7.0; only the worker + toggle was missing. + +### Changed +- `worker._netbox_writeback_loop` now reads `cfg.netbox_writeback_dry_run` + instead of hardcoding `dry_run=False`, and tags the + `worker.netbox_writeback_done` log line with the active mode. + +### Rationale +First production deploy of the 0.7.0 NetBox-as-system-of-record release on +`cpn-ful-netcortex1` (the cluster is jumping `0.6.0.dev66 → 0.8.0-dev3` +in one upgrade window). A one-cycle observe-only baseline lets us verify +the diff against the live NetBox before any writes happen. + +Operational pattern: +1. Deploy with `--set worker.extraEnv[0].name=NETBOX_WRITEBACK_DRY_RUN + --set worker.extraEnv[0].value="true"`. +2. After one writeback cycle (≤30 minutes), inspect the + `worker.netbox_writeback_done` log and the per-entity reports. +3. Helm-upgrade again with the env var removed (or set to `"false"`) to + enable real writes. + +--- + ## [0.8.0-dev3] — 2026-06-01 ### Added — Subject taxonomy + `DedupStore` + `ReflexContext` (foundation for multi-source sensing) diff --git a/netcortex/__init__.py b/netcortex/__init__.py index ed242a3..1ae6bec 100644 --- a/netcortex/__init__.py +++ b/netcortex/__init__.py @@ -22,4 +22,4 @@ ``CHANGELOG.md`` MUST be kept in sync whenever ``__version__`` changes. """ -__version__ = "0.8.0-dev3" +__version__ = "0.8.0-dev4" diff --git a/netcortex/config.py b/netcortex/config.py index e288903..8fbddf6 100644 --- a/netcortex/config.py +++ b/netcortex/config.py @@ -97,6 +97,13 @@ class Settings: netbox_url: str netbox_token: str netbox_verify_ssl: bool + # When true, the worker's NetBox writeback loop computes the full diff but + # short-circuits every PATCH/POST/DELETE. The resulting `report` still lists + # every intended change with `dry_run=True` on each entry, which is useful + # for verifying a new release against a live NetBox without modifying it. + # Override with NETBOX_WRITEBACK_DRY_RUN=1 or core-secret + # `netbox_writeback_dry_run=true`. + netbox_writeback_dry_run: bool # Neo4j graph database neo4j_uri: str @@ -189,6 +196,13 @@ def __init__(self, bootstrap: BootstrapSettings) -> None: self.netbox_verify_ssl = _verify_env.strip().lower() in { "1", "true", "yes", "on", } + _dry_env = os.environ.get("NETBOX_WRITEBACK_DRY_RUN") + if _dry_env is None: + self.netbox_writeback_dry_run = False + else: + self.netbox_writeback_dry_run = _dry_env.strip().lower() in { + "1", "true", "yes", "on", + } self.sync_backend = "apscheduler" self.sync_conflict_policy = "alert" self.sync_interval = 300 # global default: 5 min @@ -247,6 +261,13 @@ async def hydrate(self) -> None: } else: self.netbox_verify_ssl = bool(raw_verify_ssl) + raw_dry_run = core.get("netbox_writeback_dry_run", self.netbox_writeback_dry_run) + if isinstance(raw_dry_run, str): + self.netbox_writeback_dry_run = raw_dry_run.strip().lower() in { + "1", "true", "yes", "on", + } + else: + self.netbox_writeback_dry_run = bool(raw_dry_run) # Optional keys with defaults self.neo4j_uri = core.get("neo4j_uri", self.neo4j_uri) diff --git a/netcortex/worker.py b/netcortex/worker.py index 3e8d126..ce1d9d3 100644 --- a/netcortex/worker.py +++ b/netcortex/worker.py @@ -624,10 +624,14 @@ async def _netbox_writeback_loop(cfg, interval: int = 1800) -> None: cfg.netbox_url, cfg.netbox_token, verify_ssl=cfg.netbox_verify_ssl, - dry_run=False, + dry_run=cfg.netbox_writeback_dry_run, ) summary = report.get("summary", {}) - log.info("worker.netbox_writeback_done", **summary) + log.info( + "worker.netbox_writeback_done", + dry_run=cfg.netbox_writeback_dry_run, + **summary, + ) except Exception as exc: log.error("worker.netbox_writeback_failed", error=str(exc)) diff --git a/pyproject.toml b/pyproject.toml index 75e8f6c..b631094 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netcortex" -version = "0.8.0.dev3" +version = "0.8.0.dev4" description = "The intelligence layer for your network — multi-dimensional graph of the network bridging Meraki, Catalyst Center, Intersight, and more with NetBox as SoT" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_config_writeback_dry_run.py b/tests/test_config_writeback_dry_run.py new file mode 100644 index 0000000..4694502 --- /dev/null +++ b/tests/test_config_writeback_dry_run.py @@ -0,0 +1,89 @@ +"""Unit tests for the netbox_writeback_dry_run config knob. + +This setting controls whether the worker's periodic NetBox writeback loop +performs real PATCH/POST/DELETE calls or only computes the diff. It is +intentionally configurable via both env var and the core secret so that +operators can flip it during a release rollout (env var) or pin it as part +of the deployment baseline (core secret). +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from netcortex.config import BootstrapSettings, Settings + + +def _make_settings(monkeypatch: pytest.MonkeyPatch, *, env_value: str | None) -> Settings: + if env_value is None: + monkeypatch.delenv("NETBOX_WRITEBACK_DRY_RUN", raising=False) + else: + monkeypatch.setenv("NETBOX_WRITEBACK_DRY_RUN", env_value) + monkeypatch.setenv("SECRET_BACKEND", "aws_sm") + monkeypatch.setenv("AWS_REGION", "us-east-1") + monkeypatch.setenv("AWS_SECRET_PREFIX", "netcortex") + bootstrap = BootstrapSettings() # type: ignore[call-arg] + return Settings(bootstrap) + + +def test_default_is_false_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None: + s = _make_settings(monkeypatch, env_value=None) + assert s.netbox_writeback_dry_run is False + + +@pytest.mark.parametrize("truthy", ["1", "true", "TRUE", "yes", "YES", "on", "On"]) +def test_env_var_truthy_values_enable_dry_run( + monkeypatch: pytest.MonkeyPatch, truthy: str +) -> None: + s = _make_settings(monkeypatch, env_value=truthy) + assert s.netbox_writeback_dry_run is True + + +@pytest.mark.parametrize("falsy", ["0", "false", "FALSE", "no", "off", "", "anything"]) +def test_env_var_non_truthy_values_keep_writes_on( + monkeypatch: pytest.MonkeyPatch, falsy: str +) -> None: + s = _make_settings(monkeypatch, env_value=falsy) + assert s.netbox_writeback_dry_run is False + + +@pytest.mark.asyncio +async def test_hydrate_promotes_core_secret_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Even with env unset, a `netbox_writeback_dry_run` in the core secret wins.""" + s = _make_settings(monkeypatch, env_value=None) + assert s.netbox_writeback_dry_run is False + + fake_core = { + "netbox_url": "https://nb.example.test", + "netbox_token": "tok", + "netbox_writeback_dry_run": True, + } + fake_backend = AsyncMock() + fake_backend.get_core = AsyncMock(return_value=fake_core) + + with patch("netcortex.secrets.get_secret_backend", return_value=fake_backend): + await s.hydrate() + + assert s.netbox_writeback_dry_run is True + + +@pytest.mark.asyncio +async def test_hydrate_accepts_string_form_in_core_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + s = _make_settings(monkeypatch, env_value=None) + fake_core = { + "netbox_url": "https://nb.example.test", + "netbox_token": "tok", + "netbox_writeback_dry_run": "yes", + } + fake_backend = AsyncMock() + fake_backend.get_core = AsyncMock(return_value=fake_core) + with patch("netcortex.secrets.get_secret_backend", return_value=fake_backend): + await s.hydrate() + assert s.netbox_writeback_dry_run is True