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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion netcortex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
``CHANGELOG.md`` MUST be kept in sync whenever ``__version__`` changes.
"""

__version__ = "0.8.0-dev3"
__version__ = "0.8.0-dev4"
21 changes: 21 additions & 0 deletions netcortex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions netcortex/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
89 changes: 89 additions & 0 deletions tests/test_config_writeback_dry_run.py
Original file line number Diff line number Diff line change
@@ -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
Loading