nso-adapter 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. nso_adapter-0.3.0/PKG-INFO +53 -0
  2. nso_adapter-0.3.0/README.md +32 -0
  3. nso_adapter-0.3.0/nso_adapter/__init__.py +8 -0
  4. nso_adapter-0.3.0/nso_adapter/api/__init__.py +0 -0
  5. nso_adapter-0.3.0/nso_adapter/api/actions.py +233 -0
  6. nso_adapter-0.3.0/nso_adapter/api/bfd.py +195 -0
  7. nso_adapter-0.3.0/nso_adapter/api/bgp.py +634 -0
  8. nso_adapter-0.3.0/nso_adapter/api/capability.py +261 -0
  9. nso_adapter-0.3.0/nso_adapter/api/config.py +112 -0
  10. nso_adapter-0.3.0/nso_adapter/api/deps.py +71 -0
  11. nso_adapter-0.3.0/nso_adapter/api/devices.py +375 -0
  12. nso_adapter-0.3.0/nso_adapter/api/errors.py +170 -0
  13. nso_adapter-0.3.0/nso_adapter/api/health.py +43 -0
  14. nso_adapter-0.3.0/nso_adapter/api/intent.py +291 -0
  15. nso_adapter-0.3.0/nso_adapter/api/interface_ip.py +318 -0
  16. nso_adapter-0.3.0/nso_adapter/api/interface_mtu.py +174 -0
  17. nso_adapter-0.3.0/nso_adapter/api/interfaces.py +198 -0
  18. nso_adapter-0.3.0/nso_adapter/api/isis.py +753 -0
  19. nso_adapter-0.3.0/nso_adapter/api/jobs.py +175 -0
  20. nso_adapter-0.3.0/nso_adapter/api/l2_service.py +196 -0
  21. nso_adapter-0.3.0/nso_adapter/api/lag_config.py +185 -0
  22. nso_adapter-0.3.0/nso_adapter/api/lag_topology.py +91 -0
  23. nso_adapter-0.3.0/nso_adapter/api/logging_config.py +272 -0
  24. nso_adapter-0.3.0/nso_adapter/api/nso_instances.py +161 -0
  25. nso_adapter-0.3.0/nso_adapter/api/ospf.py +422 -0
  26. nso_adapter-0.3.0/nso_adapter/api/read_state.py +130 -0
  27. nso_adapter-0.3.0/nso_adapter/api/redistribution.py +112 -0
  28. nso_adapter-0.3.0/nso_adapter/api/route_policy.py +533 -0
  29. nso_adapter-0.3.0/nso_adapter/api/scope.py +104 -0
  30. nso_adapter-0.3.0/nso_adapter/api/secrets.py +237 -0
  31. nso_adapter-0.3.0/nso_adapter/api/snmp.py +470 -0
  32. nso_adapter-0.3.0/nso_adapter/api/static_route.py +597 -0
  33. nso_adapter-0.3.0/nso_adapter/api/subinterface.py +174 -0
  34. nso_adapter-0.3.0/nso_adapter/api/svi.py +159 -0
  35. nso_adapter-0.3.0/nso_adapter/api/timestamps.py +39 -0
  36. nso_adapter-0.3.0/nso_adapter/api/vlan.py +271 -0
  37. nso_adapter-0.3.0/nso_adapter/bindings/__init__.py +0 -0
  38. nso_adapter-0.3.0/nso_adapter/bindings/netbox/__init__.py +0 -0
  39. nso_adapter-0.3.0/nso_adapter/bindings/netbox/client.py +242 -0
  40. nso_adapter-0.3.0/nso_adapter/bindings/netbox/intent.py +112 -0
  41. nso_adapter-0.3.0/nso_adapter/bindings/netbox/mapper.py +333 -0
  42. nso_adapter-0.3.0/nso_adapter/bindings/netbox/scope.py +93 -0
  43. nso_adapter-0.3.0/nso_adapter/bindings/netbox/writer.py +66 -0
  44. nso_adapter-0.3.0/nso_adapter/config.py +223 -0
  45. nso_adapter-0.3.0/nso_adapter/core/__init__.py +0 -0
  46. nso_adapter-0.3.0/nso_adapter/core/apply.py +3063 -0
  47. nso_adapter-0.3.0/nso_adapter/core/bfd.py +73 -0
  48. nso_adapter-0.3.0/nso_adapter/core/bgp.py +255 -0
  49. nso_adapter-0.3.0/nso_adapter/core/bgp_load.py +62 -0
  50. nso_adapter-0.3.0/nso_adapter/core/cancelsafe.py +90 -0
  51. nso_adapter-0.3.0/nso_adapter/core/capability.py +505 -0
  52. nso_adapter-0.3.0/nso_adapter/core/claim.py +1008 -0
  53. nso_adapter-0.3.0/nso_adapter/core/community_dialect.py +283 -0
  54. nso_adapter-0.3.0/nso_adapter/core/failover.py +665 -0
  55. nso_adapter-0.3.0/nso_adapter/core/families.py +44 -0
  56. nso_adapter-0.3.0/nso_adapter/core/importer.py +1365 -0
  57. nso_adapter-0.3.0/nso_adapter/core/interface_ip.py +88 -0
  58. nso_adapter-0.3.0/nso_adapter/core/interface_mtu.py +78 -0
  59. nso_adapter-0.3.0/nso_adapter/core/isis.py +194 -0
  60. nso_adapter-0.3.0/nso_adapter/core/isis_canon.py +16 -0
  61. nso_adapter-0.3.0/nso_adapter/core/jobs.py +663 -0
  62. nso_adapter-0.3.0/nso_adapter/core/l2_service.py +84 -0
  63. nso_adapter-0.3.0/nso_adapter/core/lag_config.py +101 -0
  64. nso_adapter-0.3.0/nso_adapter/core/lag_intent.py +94 -0
  65. nso_adapter-0.3.0/nso_adapter/core/lag_topology.py +114 -0
  66. nso_adapter-0.3.0/nso_adapter/core/logging_config.py +91 -0
  67. nso_adapter-0.3.0/nso_adapter/core/onboarding.py +969 -0
  68. nso_adapter-0.3.0/nso_adapter/core/ospf.py +113 -0
  69. nso_adapter-0.3.0/nso_adapter/core/redistribution.py +515 -0
  70. nso_adapter-0.3.0/nso_adapter/core/refresh_engine.py +502 -0
  71. nso_adapter-0.3.0/nso_adapter/core/removal.py +1903 -0
  72. nso_adapter-0.3.0/nso_adapter/core/request_flags.py +40 -0
  73. nso_adapter-0.3.0/nso_adapter/core/route_policy.py +235 -0
  74. nso_adapter-0.3.0/nso_adapter/core/scheduler.py +835 -0
  75. nso_adapter-0.3.0/nso_adapter/core/snmp.py +130 -0
  76. nso_adapter-0.3.0/nso_adapter/core/snmp_harvest.py +99 -0
  77. nso_adapter-0.3.0/nso_adapter/core/snmp_verify.py +101 -0
  78. nso_adapter-0.3.0/nso_adapter/core/static_route.py +77 -0
  79. nso_adapter-0.3.0/nso_adapter/core/static_route_plan.py +317 -0
  80. nso_adapter-0.3.0/nso_adapter/core/static_route_reclaim.py +231 -0
  81. nso_adapter-0.3.0/nso_adapter/core/subinterface.py +69 -0
  82. nso_adapter-0.3.0/nso_adapter/core/svi.py +67 -0
  83. nso_adapter-0.3.0/nso_adapter/core/switchport_intent.py +66 -0
  84. nso_adapter-0.3.0/nso_adapter/core/sync_state.py +54 -0
  85. nso_adapter-0.3.0/nso_adapter/core/tombstone_sweep.py +150 -0
  86. nso_adapter-0.3.0/nso_adapter/core/topology_interfaces.py +170 -0
  87. nso_adapter-0.3.0/nso_adapter/core/vlan.py +203 -0
  88. nso_adapter-0.3.0/nso_adapter/core/worker.py +811 -0
  89. nso_adapter-0.3.0/nso_adapter/db_migrate.py +53 -0
  90. nso_adapter-0.3.0/nso_adapter/domain/__init__.py +0 -0
  91. nso_adapter-0.3.0/nso_adapter/domain/models.py +40 -0
  92. nso_adapter-0.3.0/nso_adapter/main.py +413 -0
  93. nso_adapter-0.3.0/nso_adapter/notifications/__init__.py +3 -0
  94. nso_adapter-0.3.0/nso_adapter/notifications/persistent_subscriber.py +64 -0
  95. nso_adapter-0.3.0/nso_adapter/notifications/sse_subscriber.py +135 -0
  96. nso_adapter-0.3.0/nso_adapter/nso/__init__.py +0 -0
  97. nso_adapter-0.3.0/nso_adapter/nso/actions.py +147 -0
  98. nso_adapter-0.3.0/nso_adapter/nso/apply.py +2141 -0
  99. nso_adapter-0.3.0/nso_adapter/nso/client.py +566 -0
  100. nso_adapter-0.3.0/nso_adapter/nso/neds.py +142 -0
  101. nso_adapter-0.3.0/nso_adapter/nso/nso_json.py +120 -0
  102. nso_adapter-0.3.0/nso_adapter/nso/read_outcome.py +116 -0
  103. nso_adapter-0.3.0/nso_adapter/nso/shape.py +31 -0
  104. nso_adapter-0.3.0/nso_adapter/secrets/__init__.py +32 -0
  105. nso_adapter-0.3.0/nso_adapter/secrets/base.py +18 -0
  106. nso_adapter-0.3.0/nso_adapter/secrets/local.py +33 -0
  107. nso_adapter-0.3.0/nso_adapter/secrets/refs.py +82 -0
  108. nso_adapter-0.3.0/nso_adapter/secrets/vault.py +171 -0
  109. nso_adapter-0.3.0/nso_adapter/store/__init__.py +0 -0
  110. nso_adapter-0.3.0/nso_adapter/store/db.py +83 -0
  111. nso_adapter-0.3.0/nso_adapter/store/device_settle.py +121 -0
  112. nso_adapter-0.3.0/nso_adapter/store/meta.py +58 -0
  113. nso_adapter-0.3.0/nso_adapter/store/models.py +2519 -0
  114. nso_adapter-0.3.0/nso_adapter/store/outcome_store.py +286 -0
  115. nso_adapter-0.3.0/nso_adapter/store/static_route_store.py +161 -0
  116. nso_adapter-0.3.0/nso_adapter/store/tombstone_store.py +62 -0
  117. nso_adapter-0.3.0/nso_adapter.egg-info/PKG-INFO +53 -0
  118. nso_adapter-0.3.0/nso_adapter.egg-info/SOURCES.txt +132 -0
  119. nso_adapter-0.3.0/nso_adapter.egg-info/dependency_links.txt +1 -0
  120. nso_adapter-0.3.0/nso_adapter.egg-info/requires.txt +13 -0
  121. nso_adapter-0.3.0/nso_adapter.egg-info/top_level.txt +1 -0
  122. nso_adapter-0.3.0/pyproject.toml +141 -0
  123. nso_adapter-0.3.0/setup.cfg +4 -0
  124. nso_adapter-0.3.0/tests/test_compose_lifecycle.py +31 -0
  125. nso_adapter-0.3.0/tests/test_config.py +103 -0
  126. nso_adapter-0.3.0/tests/test_main_lifespan.py +640 -0
  127. nso_adapter-0.3.0/tests/test_mock_discipline.py +231 -0
  128. nso_adapter-0.3.0/tests/test_netbox_client.py +306 -0
  129. nso_adapter-0.3.0/tests/test_no_direct_terminal_write.py +230 -0
  130. nso_adapter-0.3.0/tests/test_no_persisted_exception_text.py +115 -0
  131. nso_adapter-0.3.0/tests/test_secrets.py +80 -0
  132. nso_adapter-0.3.0/tests/test_sync_state.py +88 -0
  133. nso_adapter-0.3.0/tests/test_tz_discipline.py +32 -0
  134. nso_adapter-0.3.0/tests/test_vault_provider.py +256 -0
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: nso-adapter
3
+ Version: 0.3.0
4
+ Summary: Middleware adapter between Cisco NSO and NetBox
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi>=0.141.1
9
+ Requires-Dist: uvicorn[standard]>=0.52.1
10
+ Requires-Dist: httpx>=0.28
11
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.51
12
+ Requires-Dist: asyncpg>=0.30
13
+ Requires-Dist: psycopg2-binary>=2.9
14
+ Requires-Dist: alembic>=1.18.5
15
+ Requires-Dist: apscheduler>=3.11.3
16
+ Requires-Dist: hvac>=2.3
17
+ Requires-Dist: pydantic-settings>=2.7
18
+ Requires-Dist: structlog>=26.1.0
19
+ Requires-Dist: python-dotenv>=1.1
20
+ Requires-Dist: pyyaml>=6.0
21
+
22
+ # nso-adapter
23
+
24
+ Middleware service between Cisco NSO and consumers (NetBox first). Speaks NSO
25
+ RESTCONF; exposes a consumer-agnostic northbound REST API. Internally split
26
+ into a generic NSO core and pluggable consumer bindings; a NetBox binding ships
27
+ with it.
28
+
29
+ ## Documentation
30
+
31
+ - [`docs/00-plan.md`](docs/00-plan.md) — overall plan for the NSO ↔ NetBox
32
+ integration (cross-cutting; lives here because the adapter implements the
33
+ contract).
34
+ - [`docs/api-contract.md`](docs/api-contract.md) — canonical northbound REST
35
+ API contract. The NetBox plugin builds against this.
36
+ - [`docs/nso-adapter.md`](docs/nso-adapter.md) — adapter design (this repo).
37
+
38
+ Companion repos: **[`netbox-nso-plugin`](../netbox-nso-plugin)** — the NetBox
39
+ plugin that consumes this adapter; **`nso-packages`** — the NSO-side YANG
40
+ service packages (`network-state-export` read exports + the `*-reconciler`
41
+ write services) the adapter drives over RESTCONF.
42
+
43
+ ## Status
44
+
45
+ In active development (Phase 2, 2026-06). The northbound API serves ~57
46
+ endpoints across 16 config families (interfaces/IPs/MTU, VLAN/switchport,
47
+ SVI, subinterfaces, L2 services, LAG, IS-IS, OSPF, BGP, route-policy,
48
+ redistribution, static routes, BFD, SNMP, logging) — read mirrors plus a
49
+ full-replace intent store with a durable apply worker, post-apply dry-run
50
+ verification, periodic + SSE-triggered sync, and an intent-summary endpoint
51
+ for split-brain detection. `docs/api-contract.md` is kept in lock-step with
52
+ the implemented surface; `docs/00-plan.md` §6/§7 records the original
53
+ Phase 1 plan.
@@ -0,0 +1,32 @@
1
+ # nso-adapter
2
+
3
+ Middleware service between Cisco NSO and consumers (NetBox first). Speaks NSO
4
+ RESTCONF; exposes a consumer-agnostic northbound REST API. Internally split
5
+ into a generic NSO core and pluggable consumer bindings; a NetBox binding ships
6
+ with it.
7
+
8
+ ## Documentation
9
+
10
+ - [`docs/00-plan.md`](docs/00-plan.md) — overall plan for the NSO ↔ NetBox
11
+ integration (cross-cutting; lives here because the adapter implements the
12
+ contract).
13
+ - [`docs/api-contract.md`](docs/api-contract.md) — canonical northbound REST
14
+ API contract. The NetBox plugin builds against this.
15
+ - [`docs/nso-adapter.md`](docs/nso-adapter.md) — adapter design (this repo).
16
+
17
+ Companion repos: **[`netbox-nso-plugin`](../netbox-nso-plugin)** — the NetBox
18
+ plugin that consumes this adapter; **`nso-packages`** — the NSO-side YANG
19
+ service packages (`network-state-export` read exports + the `*-reconciler`
20
+ write services) the adapter drives over RESTCONF.
21
+
22
+ ## Status
23
+
24
+ In active development (Phase 2, 2026-06). The northbound API serves ~57
25
+ endpoints across 16 config families (interfaces/IPs/MTU, VLAN/switchport,
26
+ SVI, subinterfaces, L2 services, LAG, IS-IS, OSPF, BGP, route-policy,
27
+ redistribution, static routes, BFD, SNMP, logging) — read mirrors plus a
28
+ full-replace intent store with a durable apply worker, post-apply dry-run
29
+ verification, periodic + SSE-triggered sync, and an intent-summary endpoint
30
+ for split-brain detection. `docs/api-contract.md` is kept in lock-step with
31
+ the implemented surface; `docs/00-plan.md` §6/§7 records the original
32
+ Phase 1 plan.
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """NSO Adapter package.
3
+
4
+ Single source for the version string — FastAPI(version=...) and the /healthz
5
+ payload both import it; a test pins it against pyproject.toml.
6
+ """
7
+
8
+ __version__ = "0.3.0"
File without changes
@@ -0,0 +1,233 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Actions API — async device actions (sync, check-sync_state, connect, apply, sync-notify).
3
+
4
+ All actions return 202 with {job_id}.
5
+ 409 is returned if a job is already queued/running for the device.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastapi import APIRouter, Depends
11
+ from pydantic import BaseModel
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from nso_adapter.api.deps import get_db, verify_token
15
+ from nso_adapter.api.errors import (
16
+ RESP_400,
17
+ RESP_401,
18
+ RESP_404_DEVICE,
19
+ RESP_409_ACTIVE_JOB,
20
+ RESP_422_VALIDATION,
21
+ api_error,
22
+ )
23
+ from nso_adapter.core.jobs import enqueue_job
24
+ from nso_adapter.store.models import Device, JobType
25
+
26
+ router = APIRouter(prefix="/api/v1/devices", tags=["actions"])
27
+
28
+ # All action endpoints emit 401 (token) + 422 (device_id path); the responses fragments
29
+ # below add the ones each endpoint actually raises. The trigger POSTs go through
30
+ # _trigger (404 + 409-active-job); force-removal / apply-diff raise 400 bad_request.
31
+ _TRIGGER_ERRORS = {**RESP_401, **RESP_404_DEVICE, **RESP_409_ACTIVE_JOB, **RESP_422_VALIDATION}
32
+
33
+
34
+ class JobTriggerOut(BaseModel):
35
+ """The async-action envelope: the id of the enqueued job (202)."""
36
+
37
+ job_id: int
38
+
39
+
40
+ class ApplyDiffOut(BaseModel):
41
+ """apply-diff preview — {scope: native_delta} for scopes with a non-empty change."""
42
+
43
+ device_id: int
44
+ outformat: str
45
+ diffs: dict[str, str]
46
+
47
+
48
+ async def _trigger(
49
+ device_id: int,
50
+ job_type: JobType,
51
+ db: AsyncSession,
52
+ ) -> dict:
53
+ device = await db.get(Device, device_id)
54
+ if not device:
55
+ raise api_error(404, "not_found", "Device not found")
56
+
57
+ job, created = await enqueue_job(device_id, job_type, db)
58
+ if not created:
59
+ raise api_error(409, "conflict", "A job is already running for this device", {"job_id": job.id})
60
+ return {"job_id": job.id}
61
+
62
+
63
+ class ForceRemovalBody(BaseModel):
64
+ scope: str
65
+ interfaces: list[str] | None = None
66
+
67
+
68
+ @router.post(
69
+ "/{device_id}/actions/force-removal",
70
+ status_code=202,
71
+ dependencies=[Depends(verify_token)],
72
+ response_model=JobTriggerOut,
73
+ responses={**RESP_401, **RESP_404_DEVICE, **RESP_400, **RESP_422_VALIDATION},
74
+ )
75
+ async def action_force_removal(
76
+ device_id: int,
77
+ body: ForceRemovalBody,
78
+ db: AsyncSession = Depends(get_db),
79
+ ):
80
+ """Re-run a scope's removal with the collateral guard DISABLED.
81
+
82
+ The operator override for a ``removal_blocked_collateral`` failure: after
83
+ reviewing the blocked job's orphan list + dry-run preview, this deliberately
84
+ flushes the orphaned service rows (PUT-replace with only the remaining intent).
85
+
86
+ ``interface_config`` is per-instance (interface-reconciler is keyed by
87
+ ``(device, interface-name)``), so its removal job flushes exactly the interfaces named
88
+ in *interfaces* — with none, ``_replace_interface_config`` iterates an empty list and
89
+ the job succeeds having pushed NOTHING, telling the operator their orphaned addresses
90
+ were flushed while the config is still live on the device. Reject that rather than
91
+ succeed at nothing.
92
+ """
93
+ from nso_adapter.core.removal import VALID_REMOVAL_SCOPES, enqueue_removal
94
+
95
+ device = await db.get(Device, device_id)
96
+ if not device:
97
+ raise api_error(404, "not_found", "Device not found")
98
+ if body.scope not in VALID_REMOVAL_SCOPES:
99
+ raise api_error(400, "bad_request", f"Unknown removal scope {body.scope!r}")
100
+ if body.scope == "interface_config" and not body.interfaces:
101
+ raise api_error(
102
+ 400,
103
+ "bad_request",
104
+ "force-removal of interface_config requires 'interfaces': the interface-reconciler "
105
+ "is keyed per interface, so with none named the job would flush nothing.",
106
+ )
107
+ job = await enqueue_removal(db, device_id, body.scope, interfaces=body.interfaces, force=True)
108
+ await db.commit()
109
+ return {"job_id": job.id}
110
+
111
+
112
+ @router.post(
113
+ "/{device_id}/actions/sync",
114
+ status_code=202,
115
+ dependencies=[Depends(verify_token)],
116
+ response_model=JobTriggerOut,
117
+ responses=_TRIGGER_ERRORS,
118
+ )
119
+ async def action_sync(
120
+ device_id: int,
121
+ db: AsyncSession = Depends(get_db),
122
+ ):
123
+ # Operator Sync-Now = READSEM grain c: the mirror fan-out reads ONE atomic
124
+ # device-state-read build. The plugin's sync-notify below stays grain b (automatic,
125
+ # frequent - the record-served projection).
126
+ return await _trigger(device_id, JobType.sync_now, db)
127
+
128
+
129
+ @router.post(
130
+ "/{device_id}/actions/sync-from-nso",
131
+ status_code=202,
132
+ dependencies=[Depends(verify_token)],
133
+ response_model=JobTriggerOut,
134
+ responses=_TRIGGER_ERRORS,
135
+ )
136
+ async def action_sync_from_nso(
137
+ device_id: int,
138
+ db: AsyncSession = Depends(get_db),
139
+ ):
140
+ # Operator "Sync from NSO" (S5a): comprehensive CDB-only mirror read — every surface
141
+ # from ONE atomic device-state-read, NO device round-trip (Sync Now above runs the
142
+ # device sync-from first; this re-reads what NSO already knows).
143
+ return await _trigger(device_id, JobType.sync_from_nso, db)
144
+
145
+
146
+ @router.post(
147
+ "/{device_id}/actions/detect-drift",
148
+ status_code=202,
149
+ dependencies=[Depends(verify_token)],
150
+ response_model=JobTriggerOut,
151
+ responses=_TRIGGER_ERRORS,
152
+ )
153
+ async def action_detect_drift(
154
+ device_id: int,
155
+ db: AsyncSession = Depends(get_db),
156
+ ):
157
+ return await _trigger(device_id, JobType.detect_drift, db)
158
+
159
+
160
+ @router.post(
161
+ "/{device_id}/actions/connect",
162
+ status_code=202,
163
+ dependencies=[Depends(verify_token)],
164
+ response_model=JobTriggerOut,
165
+ responses=_TRIGGER_ERRORS,
166
+ )
167
+ async def action_connect(
168
+ device_id: int,
169
+ db: AsyncSession = Depends(get_db),
170
+ ):
171
+ return await _trigger(device_id, JobType.connect, db)
172
+
173
+
174
+ @router.post(
175
+ "/{device_id}/sync-notify",
176
+ status_code=202,
177
+ dependencies=[Depends(verify_token)],
178
+ response_model=JobTriggerOut,
179
+ responses=_TRIGGER_ERRORS,
180
+ )
181
+ async def sync_notify(
182
+ device_id: int,
183
+ db: AsyncSession = Depends(get_db),
184
+ ):
185
+ """Handle the NetBox plugin's notification that scope or intent changed for this device.
186
+
187
+ Triggers an immediate sync job. If a job is already running, returns 409 with
188
+ the existing job_id so the plugin can poll for the result.
189
+ """
190
+ return await _trigger(device_id, JobType.sync, db)
191
+
192
+
193
+ @router.post(
194
+ "/{device_id}/actions/apply",
195
+ status_code=202,
196
+ dependencies=[Depends(verify_token)],
197
+ response_model=JobTriggerOut,
198
+ responses=_TRIGGER_ERRORS,
199
+ )
200
+ async def action_apply(
201
+ device_id: int,
202
+ db: AsyncSession = Depends(get_db),
203
+ ):
204
+ """Phase 2 — push accepted NetBox intent to NSO via reconcile-commit service."""
205
+ return await _trigger(device_id, JobType.apply, db)
206
+
207
+
208
+ @router.get(
209
+ "/{device_id}/actions/apply-diff",
210
+ dependencies=[Depends(verify_token)],
211
+ response_model=ApplyDiffOut,
212
+ responses={**RESP_401, **RESP_404_DEVICE, **RESP_400, **RESP_422_VALIDATION},
213
+ )
214
+ async def action_apply_diff(
215
+ device_id: int,
216
+ outformat: str = "native",
217
+ db: AsyncSession = Depends(get_db),
218
+ ):
219
+ """Preview the per-scope diff the next Apply would push (NSO dry-run, no commit).
220
+
221
+ ``outformat=native`` (default): device-native rendering (CLI lines for cli NEDs,
222
+ edit-config XML for netconf NEDs). ``outformat=cli``: NSO's NED-uniform ``+``/``-``
223
+ tree diff — the "diff -u" style the preview panel renders.
224
+ """
225
+ from nso_adapter.core.apply import collect_apply_diff
226
+
227
+ if outformat not in ("native", "cli"):
228
+ raise api_error(400, "bad_request", f"Unknown outformat {outformat!r} (native|cli)")
229
+ device = await db.get(Device, device_id)
230
+ if not device:
231
+ raise api_error(404, "not_found", "Device not found")
232
+ diffs = await collect_apply_diff(db, device_id, outformat=outformat)
233
+ return {"device_id": device_id, "outformat": outformat, "diffs": diffs}
@@ -0,0 +1,195 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
3
+ """GET /api/v1/devices/{id}/bfd — per-interface BFD read mirror."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import UTC, datetime
8
+
9
+ import structlog
10
+ from fastapi import APIRouter, Depends
11
+ from pydantic import BaseModel
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from nso_adapter.api.deps import get_db, get_read_db, verify_token
16
+ from nso_adapter.api.errors import RESP_401, RESP_404_DEVICE, RESP_422_VALIDATION, IntentApplyResult, api_error
17
+ from nso_adapter.api.read_state import FamilyReadState, read_state_payload
18
+ from nso_adapter.api.timestamps import UtcInstant, iso_z
19
+ from nso_adapter.core.removal import is_cleared
20
+ from nso_adapter.store import outcome_store
21
+ from nso_adapter.store.models import BfdIntent, Device, DeviceBfdInterface, DeviceSettings
22
+
23
+ logger = structlog.get_logger(__name__)
24
+
25
+ router = APIRouter(prefix="/api/v1/devices", tags=["bfd"])
26
+
27
+
28
+ # ── Read-mirror response models (GET /bfd) ────────────────────────────────────
29
+ # OMIT shape: bound_port/min_tx/min_rx/multiplier omitted when unset ->
30
+ # exclude_unset; micro_bfd/enabled are always-present bools.
31
+
32
+
33
+ class BfdInterfaceOut(BaseModel):
34
+ interface_name: str
35
+ micro_bfd: bool
36
+ enabled: bool
37
+ bound_port: str | None = None
38
+ min_tx: int | None = None
39
+ min_rx: int | None = None
40
+ multiplier: int | None = None
41
+
42
+
43
+ class BfdConfigOut(BaseModel):
44
+ device_id: int
45
+ last_refreshed_at: str | None = None # reader formats "<iso>Z"; None when never refreshed
46
+ refresh_source: str # legacy freshness (S5 retires it); read_state is the S4 truth
47
+ read_state: FamilyReadState
48
+ interfaces: list[BfdInterfaceOut]
49
+
50
+
51
+ @router.get(
52
+ "/{device_id}/bfd",
53
+ dependencies=[Depends(verify_token)],
54
+ response_model=BfdConfigOut,
55
+ response_model_exclude_unset=True,
56
+ responses={**RESP_401, **RESP_404_DEVICE, **RESP_422_VALIDATION},
57
+ )
58
+ async def get_bfd(device_id: int, db: AsyncSession = Depends(get_read_db)):
59
+ """Return the per-interface BFD read-mirror for this device."""
60
+ device = await db.get(Device, device_id)
61
+ if not device:
62
+ raise api_error(404, "not_found", "Device not found")
63
+
64
+ # Pointer first, rows second, one snapshot (S4 D2 — benign direction).
65
+ read_state = read_state_payload(
66
+ await outcome_store.get_current_outcome(db, device_id, "bfd"), source_epoch=device.source_epoch
67
+ )
68
+
69
+ rows = (
70
+ (
71
+ await db.execute(
72
+ select(DeviceBfdInterface)
73
+ .where(DeviceBfdInterface.device_id == device_id)
74
+ .order_by(DeviceBfdInterface.interface_name)
75
+ )
76
+ )
77
+ .scalars()
78
+ .all()
79
+ )
80
+
81
+ latest = max((r.last_refreshed_at for r in rows if r.last_refreshed_at), default=None)
82
+ interfaces = []
83
+ for r in rows:
84
+ entry: dict = {
85
+ "interface_name": r.interface_name,
86
+ "micro_bfd": r.micro_bfd,
87
+ "enabled": r.enabled,
88
+ }
89
+ if r.bound_port is not None:
90
+ entry["bound_port"] = r.bound_port
91
+ if r.min_tx is not None:
92
+ entry["min_tx"] = r.min_tx
93
+ if r.min_rx is not None:
94
+ entry["min_rx"] = r.min_rx
95
+ if r.multiplier is not None:
96
+ entry["multiplier"] = r.multiplier
97
+ interfaces.append(entry)
98
+
99
+ return {
100
+ "device_id": device_id,
101
+ "last_refreshed_at": iso_z(latest),
102
+ "refresh_source": rows[0].refresh_source if rows else "never",
103
+ "read_state": read_state,
104
+ "interfaces": interfaces,
105
+ }
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # PUT /{device_id}/bfd-intent (BFD write path — deferred apply)
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ class BfdEntry(BaseModel):
114
+ interface_name: str
115
+ min_tx: int | None = None
116
+ min_rx: int | None = None
117
+ multiplier: int | None = None
118
+ micro_bfd: bool = False
119
+ accepted_at: UtcInstant | None = None
120
+
121
+
122
+ # Scalars the writer emits only when set — `if row.min_tx is not None:` (nso/apply.py)
123
+ # A merge-PATCH apply can never drop one that goes back to unset, so clearing any of
124
+ # them must enqueue a PUT-replace retract. See core.removal.is_cleared.
125
+ _STATE_FIELDS = ("min_tx", "min_rx", "multiplier")
126
+
127
+
128
+ class BfdIntentUpdate(BaseModel):
129
+ interfaces: list[BfdEntry]
130
+
131
+
132
+ @router.put(
133
+ "/{device_id}/bfd-intent",
134
+ dependencies=[Depends(verify_token)],
135
+ response_model=IntentApplyResult,
136
+ responses={**RESP_401, **RESP_404_DEVICE, **RESP_422_VALIDATION},
137
+ )
138
+ async def put_bfd_intent(device_id: int, body: BfdIntentUpdate, db: AsyncSession = Depends(get_db)):
139
+ """Replace the adapter's per-interface BFD intent mirror for this device atomically.
140
+
141
+ Full-replace: rows not in the body are deleted. ``accepted_at`` defaults to now.
142
+ If ``auto_apply`` is enabled, an apply job is enqueued; the single device Apply
143
+ commits these via the bfd-reconciler.
144
+ """
145
+ device = await db.get(Device, device_id)
146
+ if device is None:
147
+ raise api_error(404, "not_found", "Device not found")
148
+
149
+ existing = await db.execute(select(BfdIntent).where(BfdIntent.device_id == device_id))
150
+ existing_rows: dict[str, BfdIntent] = {r.interface_name: r for r in existing.scalars().all()}
151
+ new_keys = {i.interface_name for i in body.interfaces}
152
+
153
+ removed = [name for name in existing_rows if name not in new_keys]
154
+ for name in removed:
155
+ await db.delete(existing_rows[name])
156
+ await db.flush()
157
+
158
+ now = datetime.now(UTC)
159
+ count = 0
160
+ cleared = False
161
+ for item in body.interfaces:
162
+ accepted = item.accepted_at if item.accepted_at else now
163
+ row = existing_rows.get(item.interface_name)
164
+ before = {f: getattr(row, f) for f in _STATE_FIELDS} if row is not None else None
165
+ if row is None:
166
+ row = BfdIntent(device_id=device_id, interface_name=item.interface_name)
167
+ db.add(row)
168
+ row.min_tx = item.min_tx
169
+ row.min_rx = item.min_rx
170
+ row.multiplier = item.multiplier
171
+ row.micro_bfd = item.micro_bfd
172
+ row.accepted_at = accepted
173
+ if before is not None and any(is_cleared(before[f], getattr(row, f)) for f in _STATE_FIELDS):
174
+ cleared = True
175
+ count += 1
176
+
177
+ await db.flush()
178
+ settings = (
179
+ await db.execute(select(DeviceSettings).where(DeviceSettings.device_id == device_id))
180
+ ).scalar_one_or_none()
181
+ if settings and settings.auto_apply and count > 0:
182
+ from nso_adapter.core.apply import enqueue_apply
183
+
184
+ await enqueue_apply(db, device_id, force=True)
185
+
186
+ await db.commit()
187
+
188
+ replaced = False
189
+ if removed or cleared:
190
+ from nso_adapter.core.removal import replace_on_removal
191
+ from nso_adapter.nso.apply import apply_bfd_config
192
+
193
+ replaced = await replace_on_removal(db, device, removed, BfdIntent, apply_bfd_config, retract=cleared)
194
+
195
+ return {"device_id": device_id, "count": count, "removed": len(removed), "replaced": replaced}