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.
- nso_adapter-0.3.0/PKG-INFO +53 -0
- nso_adapter-0.3.0/README.md +32 -0
- nso_adapter-0.3.0/nso_adapter/__init__.py +8 -0
- nso_adapter-0.3.0/nso_adapter/api/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/api/actions.py +233 -0
- nso_adapter-0.3.0/nso_adapter/api/bfd.py +195 -0
- nso_adapter-0.3.0/nso_adapter/api/bgp.py +634 -0
- nso_adapter-0.3.0/nso_adapter/api/capability.py +261 -0
- nso_adapter-0.3.0/nso_adapter/api/config.py +112 -0
- nso_adapter-0.3.0/nso_adapter/api/deps.py +71 -0
- nso_adapter-0.3.0/nso_adapter/api/devices.py +375 -0
- nso_adapter-0.3.0/nso_adapter/api/errors.py +170 -0
- nso_adapter-0.3.0/nso_adapter/api/health.py +43 -0
- nso_adapter-0.3.0/nso_adapter/api/intent.py +291 -0
- nso_adapter-0.3.0/nso_adapter/api/interface_ip.py +318 -0
- nso_adapter-0.3.0/nso_adapter/api/interface_mtu.py +174 -0
- nso_adapter-0.3.0/nso_adapter/api/interfaces.py +198 -0
- nso_adapter-0.3.0/nso_adapter/api/isis.py +753 -0
- nso_adapter-0.3.0/nso_adapter/api/jobs.py +175 -0
- nso_adapter-0.3.0/nso_adapter/api/l2_service.py +196 -0
- nso_adapter-0.3.0/nso_adapter/api/lag_config.py +185 -0
- nso_adapter-0.3.0/nso_adapter/api/lag_topology.py +91 -0
- nso_adapter-0.3.0/nso_adapter/api/logging_config.py +272 -0
- nso_adapter-0.3.0/nso_adapter/api/nso_instances.py +161 -0
- nso_adapter-0.3.0/nso_adapter/api/ospf.py +422 -0
- nso_adapter-0.3.0/nso_adapter/api/read_state.py +130 -0
- nso_adapter-0.3.0/nso_adapter/api/redistribution.py +112 -0
- nso_adapter-0.3.0/nso_adapter/api/route_policy.py +533 -0
- nso_adapter-0.3.0/nso_adapter/api/scope.py +104 -0
- nso_adapter-0.3.0/nso_adapter/api/secrets.py +237 -0
- nso_adapter-0.3.0/nso_adapter/api/snmp.py +470 -0
- nso_adapter-0.3.0/nso_adapter/api/static_route.py +597 -0
- nso_adapter-0.3.0/nso_adapter/api/subinterface.py +174 -0
- nso_adapter-0.3.0/nso_adapter/api/svi.py +159 -0
- nso_adapter-0.3.0/nso_adapter/api/timestamps.py +39 -0
- nso_adapter-0.3.0/nso_adapter/api/vlan.py +271 -0
- nso_adapter-0.3.0/nso_adapter/bindings/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/bindings/netbox/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/bindings/netbox/client.py +242 -0
- nso_adapter-0.3.0/nso_adapter/bindings/netbox/intent.py +112 -0
- nso_adapter-0.3.0/nso_adapter/bindings/netbox/mapper.py +333 -0
- nso_adapter-0.3.0/nso_adapter/bindings/netbox/scope.py +93 -0
- nso_adapter-0.3.0/nso_adapter/bindings/netbox/writer.py +66 -0
- nso_adapter-0.3.0/nso_adapter/config.py +223 -0
- nso_adapter-0.3.0/nso_adapter/core/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/core/apply.py +3063 -0
- nso_adapter-0.3.0/nso_adapter/core/bfd.py +73 -0
- nso_adapter-0.3.0/nso_adapter/core/bgp.py +255 -0
- nso_adapter-0.3.0/nso_adapter/core/bgp_load.py +62 -0
- nso_adapter-0.3.0/nso_adapter/core/cancelsafe.py +90 -0
- nso_adapter-0.3.0/nso_adapter/core/capability.py +505 -0
- nso_adapter-0.3.0/nso_adapter/core/claim.py +1008 -0
- nso_adapter-0.3.0/nso_adapter/core/community_dialect.py +283 -0
- nso_adapter-0.3.0/nso_adapter/core/failover.py +665 -0
- nso_adapter-0.3.0/nso_adapter/core/families.py +44 -0
- nso_adapter-0.3.0/nso_adapter/core/importer.py +1365 -0
- nso_adapter-0.3.0/nso_adapter/core/interface_ip.py +88 -0
- nso_adapter-0.3.0/nso_adapter/core/interface_mtu.py +78 -0
- nso_adapter-0.3.0/nso_adapter/core/isis.py +194 -0
- nso_adapter-0.3.0/nso_adapter/core/isis_canon.py +16 -0
- nso_adapter-0.3.0/nso_adapter/core/jobs.py +663 -0
- nso_adapter-0.3.0/nso_adapter/core/l2_service.py +84 -0
- nso_adapter-0.3.0/nso_adapter/core/lag_config.py +101 -0
- nso_adapter-0.3.0/nso_adapter/core/lag_intent.py +94 -0
- nso_adapter-0.3.0/nso_adapter/core/lag_topology.py +114 -0
- nso_adapter-0.3.0/nso_adapter/core/logging_config.py +91 -0
- nso_adapter-0.3.0/nso_adapter/core/onboarding.py +969 -0
- nso_adapter-0.3.0/nso_adapter/core/ospf.py +113 -0
- nso_adapter-0.3.0/nso_adapter/core/redistribution.py +515 -0
- nso_adapter-0.3.0/nso_adapter/core/refresh_engine.py +502 -0
- nso_adapter-0.3.0/nso_adapter/core/removal.py +1903 -0
- nso_adapter-0.3.0/nso_adapter/core/request_flags.py +40 -0
- nso_adapter-0.3.0/nso_adapter/core/route_policy.py +235 -0
- nso_adapter-0.3.0/nso_adapter/core/scheduler.py +835 -0
- nso_adapter-0.3.0/nso_adapter/core/snmp.py +130 -0
- nso_adapter-0.3.0/nso_adapter/core/snmp_harvest.py +99 -0
- nso_adapter-0.3.0/nso_adapter/core/snmp_verify.py +101 -0
- nso_adapter-0.3.0/nso_adapter/core/static_route.py +77 -0
- nso_adapter-0.3.0/nso_adapter/core/static_route_plan.py +317 -0
- nso_adapter-0.3.0/nso_adapter/core/static_route_reclaim.py +231 -0
- nso_adapter-0.3.0/nso_adapter/core/subinterface.py +69 -0
- nso_adapter-0.3.0/nso_adapter/core/svi.py +67 -0
- nso_adapter-0.3.0/nso_adapter/core/switchport_intent.py +66 -0
- nso_adapter-0.3.0/nso_adapter/core/sync_state.py +54 -0
- nso_adapter-0.3.0/nso_adapter/core/tombstone_sweep.py +150 -0
- nso_adapter-0.3.0/nso_adapter/core/topology_interfaces.py +170 -0
- nso_adapter-0.3.0/nso_adapter/core/vlan.py +203 -0
- nso_adapter-0.3.0/nso_adapter/core/worker.py +811 -0
- nso_adapter-0.3.0/nso_adapter/db_migrate.py +53 -0
- nso_adapter-0.3.0/nso_adapter/domain/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/domain/models.py +40 -0
- nso_adapter-0.3.0/nso_adapter/main.py +413 -0
- nso_adapter-0.3.0/nso_adapter/notifications/__init__.py +3 -0
- nso_adapter-0.3.0/nso_adapter/notifications/persistent_subscriber.py +64 -0
- nso_adapter-0.3.0/nso_adapter/notifications/sse_subscriber.py +135 -0
- nso_adapter-0.3.0/nso_adapter/nso/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/nso/actions.py +147 -0
- nso_adapter-0.3.0/nso_adapter/nso/apply.py +2141 -0
- nso_adapter-0.3.0/nso_adapter/nso/client.py +566 -0
- nso_adapter-0.3.0/nso_adapter/nso/neds.py +142 -0
- nso_adapter-0.3.0/nso_adapter/nso/nso_json.py +120 -0
- nso_adapter-0.3.0/nso_adapter/nso/read_outcome.py +116 -0
- nso_adapter-0.3.0/nso_adapter/nso/shape.py +31 -0
- nso_adapter-0.3.0/nso_adapter/secrets/__init__.py +32 -0
- nso_adapter-0.3.0/nso_adapter/secrets/base.py +18 -0
- nso_adapter-0.3.0/nso_adapter/secrets/local.py +33 -0
- nso_adapter-0.3.0/nso_adapter/secrets/refs.py +82 -0
- nso_adapter-0.3.0/nso_adapter/secrets/vault.py +171 -0
- nso_adapter-0.3.0/nso_adapter/store/__init__.py +0 -0
- nso_adapter-0.3.0/nso_adapter/store/db.py +83 -0
- nso_adapter-0.3.0/nso_adapter/store/device_settle.py +121 -0
- nso_adapter-0.3.0/nso_adapter/store/meta.py +58 -0
- nso_adapter-0.3.0/nso_adapter/store/models.py +2519 -0
- nso_adapter-0.3.0/nso_adapter/store/outcome_store.py +286 -0
- nso_adapter-0.3.0/nso_adapter/store/static_route_store.py +161 -0
- nso_adapter-0.3.0/nso_adapter/store/tombstone_store.py +62 -0
- nso_adapter-0.3.0/nso_adapter.egg-info/PKG-INFO +53 -0
- nso_adapter-0.3.0/nso_adapter.egg-info/SOURCES.txt +132 -0
- nso_adapter-0.3.0/nso_adapter.egg-info/dependency_links.txt +1 -0
- nso_adapter-0.3.0/nso_adapter.egg-info/requires.txt +13 -0
- nso_adapter-0.3.0/nso_adapter.egg-info/top_level.txt +1 -0
- nso_adapter-0.3.0/pyproject.toml +141 -0
- nso_adapter-0.3.0/setup.cfg +4 -0
- nso_adapter-0.3.0/tests/test_compose_lifecycle.py +31 -0
- nso_adapter-0.3.0/tests/test_config.py +103 -0
- nso_adapter-0.3.0/tests/test_main_lifespan.py +640 -0
- nso_adapter-0.3.0/tests/test_mock_discipline.py +231 -0
- nso_adapter-0.3.0/tests/test_netbox_client.py +306 -0
- nso_adapter-0.3.0/tests/test_no_direct_terminal_write.py +230 -0
- nso_adapter-0.3.0/tests/test_no_persisted_exception_text.py +115 -0
- nso_adapter-0.3.0/tests/test_secrets.py +80 -0
- nso_adapter-0.3.0/tests/test_sync_state.py +88 -0
- nso_adapter-0.3.0/tests/test_tz_discipline.py +32 -0
- 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.
|
|
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}
|