Skip to content

feat: Cisco IOS-XR gNMI support via module-anchored SetRequest encoding - #442

Open
steiler wants to merge 8 commits into
mainfrom
ciscoiosxrd2
Open

feat: Cisco IOS-XR gNMI support via module-anchored SetRequest encoding#442
steiler wants to merge 8 commits into
mainfrom
ciscoiosxrd2

Conversation

@steiler

@steiler steiler commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Adds first-class support for Cisco IOS-XR / XRd devices as gNMI targets by introducing a materialization layer that owns all NOS-specific encoding, keeping the gNMI transport driver a thin wire-execution layer.

Companion proto change: sdcio/sdc-protos#120 (adds device_profile field)

┌─────────────────────────────────────────────────────────────────────────┐
│  BEFORE                                                                 │
│                                                                         │
│  applyIntent                                                            │
│      │                                                                  │
│      ▼                                                                  │
│  TargetSource adapter  ◄── api.Entry wrapped here                       │
│      │                                                                  │
│      ▼                                                                  │
│  Target.Set(TargetSource)                                               │
│      │                                                                  │
│      ▼                                                                  │
│  gNMI driver  ── serializes internally ──► SetRequest                   │
│                                             └─ Update{origin:"", /}     │
│                                                  └─ entire JSON blob    │
│                                                                         │
│  (IOS-XR rejects: unknown native YANG keys in OpenConfig context)       │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│  AFTER                                                                  │
│                                                                         │
│  applyIntent                                                            │
│      │  api.Entry + replace flag                                        │
│      ▼                                                                  │
│  materialize.BuildPlan(entry, device-profile)                           │
│      │                                                                  │
│      ├─[generic / proto]──────────────► GnmiSetPlan (single update)     │
│      │                                                                  │
│      └─[cisco-ios-xr + json/json_ietf]                                  │
│              │                                                          │
│              ▼                                                          │
│         permodule encoder                                               │
│         group children by ModuleName                                    │
│              │                                                          │
│              ▼                                                          │
│         GnmiSetPlan                                                     │
│           ├─ Update{origin:"Cisco-IOS-XR-ip-static-cfg", /router}       │
│           ├─ Update{origin:"Cisco-IOS-XR-ifmgr-cfg",     /interfaces}   │
│           └─ ...one per module...                                       │
│                                                                         │
│      ▼                                                                  │
│  Target.Set(SouthboundSetPlan)   ◄── typed plan, no NOS logic here      │
│      │                                                                  │
│      ▼                                                                  │
│  gNMI driver (transport only) ──────► single SetRequest                 │
│                                        └─ all module Updates atomic     │
└─────────────────────────────────────────────────────────────────────────┘

Core additions:

  • New pkg/datastore/target/materialize package: translates api.Entry + device-profile into a typed SouthboundSetPlan before any transport call
  • New pkg/datastore/target/gnmi/permodule encoder: groups config tree children by YANG module name, emits one Update per module with Path.origin set to the full module name (e.g. Cisco-IOS-XR-ip-static-cfg) — all updates in a single SetRequest to preserve gNMI atomicity
  • New pkg/datastore/target/types discriminated plan type (GnmiSetPlan / NetconfSetPlan) replacing the TargetSource abstraction on the Set path
  • device-profile: cisco-ios-xr config field on SBI with closed-set validation: unknown profiles fail at load time; cisco-ios-xr + netconf is rejected; cisco-ios-xr + proto is accepted without IOS-XR shaping

Transport/driver changes:

  • gNMI driver (gnmi.go) simplified to pure transport: receives a pre-built GnmiSetPlan and executes it, with no knowledge of device-profile or module grouping
  • Replace transactions encoded as per-module delete + per-module update pairs in one SetRequest (gNMI replace field not used, consistent with existing behavior)
  • Delete paths carry origin resolved from schema metadata or schema-client lookup for choice-case entries

Removals / cleanup:

  • TargetSource interface and its adapters retired from the Set path (targetsource.go, target_source_replace.go, entryoutputadapter.go deleted)
  • applyIntent now passes raw api.Entry + replace flag to materialize instead of wrapping in a TargetSource adapter

Test coverage added:

  • permodule encoder: multi-module tree assertions (update count, origin values, path elements, JSON body shape, replace delete emission)
  • materialize: BuildPlan assertions for both generic and IOS-XR profiles
  • config: validation rejection of unknown profiles and netconf+ios-xr
  • noop and gNMI get path: adapted to new plan-based interface

Adds first-class support for Cisco IOS-XR / XRd devices as gNMI targets
by introducing a materialization layer that owns all NOS-specific encoding,
keeping the gNMI transport driver a thin wire-execution layer.

Companion proto change: sdcio/sdc-protos#120 (adds `device_profile` field)

┌─────────────────────────────────────────────────────────────────────────┐
│  BEFORE                                                                 │
│                                                                         │
│  applyIntent                                                            │
│      │                                                                  │
│      ▼                                                                  │
│  TargetSource adapter  ◄── api.Entry wrapped here                      │
│      │                                                                  │
│      ▼                                                                  │
│  Target.Set(TargetSource)                                               │
│      │                                                                  │
│      ▼                                                                  │
│  gNMI driver  ── serializes internally ──► SetRequest                  │
│                                             └─ Update{origin:"", /}    │
│                                                  └─ entire JSON blob    │
│                                                                         │
│  (IOS-XR rejects: unknown native YANG keys in OpenConfig context)      │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│  AFTER                                                                  │
│                                                                         │
│  applyIntent                                                            │
│      │  api.Entry + replace flag                                        │
│      ▼                                                                  │
│  materialize.BuildPlan(entry, device-profile)                           │
│      │                                                                  │
│      ├─[generic / proto]──────────────► GnmiSetPlan (single update)    │
│      │                                                                  │
│      └─[cisco-ios-xr + json/json_ietf]                                 │
│              │                                                          │
│              ▼                                                          │
│         permodule encoder                                               │
│         group children by ModuleName                                    │
│              │                                                          │
│              ▼                                                          │
│         GnmiSetPlan                                                     │
│           ├─ Update{origin:"Cisco-IOS-XR-ip-static-cfg", /router}      │
│           ├─ Update{origin:"Cisco-IOS-XR-ifmgr-cfg",     /interfaces}  │
│           └─ ...one per module...                                       │
│                                                                         │
│      ▼                                                                  │
│  Target.Set(SouthboundSetPlan)   ◄── typed plan, no NOS logic here     │
│      │                                                                  │
│      ▼                                                                  │
│  gNMI driver (transport only) ──────► single SetRequest                │
│                                        └─ all module Updates atomic     │
└─────────────────────────────────────────────────────────────────────────┘

Core additions:
- New `pkg/datastore/target/materialize` package: translates `api.Entry` +
  device-profile into a typed `SouthboundSetPlan` before any transport call
- New `pkg/datastore/target/gnmi/permodule` encoder: groups config tree
  children by YANG module name, emits one `Update` per module with
  `Path.origin` set to the full module name (e.g. `Cisco-IOS-XR-ip-static-cfg`)
  — all updates in a single `SetRequest` to preserve gNMI atomicity
- New `pkg/datastore/target/types` discriminated plan type (`GnmiSetPlan` /
  `NetconfSetPlan`) replacing the `TargetSource` abstraction on the Set path
- `device-profile: cisco-ios-xr` config field on SBI with closed-set
  validation: unknown profiles fail at load time; `cisco-ios-xr` + netconf
  is rejected; `cisco-ios-xr` + proto is accepted without IOS-XR shaping

Transport/driver changes:
- gNMI driver (`gnmi.go`) simplified to pure transport: receives a pre-built
  `GnmiSetPlan` and executes it, with no knowledge of device-profile or
  module grouping
- Replace transactions encoded as per-module delete + per-module update pairs
  in one `SetRequest` (gNMI `replace` field not used, consistent with
  existing behavior)
- Delete paths carry `origin` resolved from schema metadata or schema-client
  lookup for choice-case entries

Removals / cleanup:
- `TargetSource` interface and its adapters retired from the Set path
  (`targetsource.go`, `target_source_replace.go`, `entryoutputadapter.go`
  deleted)
- `applyIntent` now passes raw `api.Entry` + replace flag to materialize
  instead of wrapping in a `TargetSource` adapter

Test coverage added:
- `permodule` encoder: multi-module tree assertions (update count, origin
  values, path elements, JSON body shape, replace delete emission)
- `materialize`: `BuildPlan` assertions for both generic and IOS-XR profiles
- `config`: validation rejection of unknown profiles and netconf+ios-xr
- `noop` and gNMI `get` path: adapted to new plan-based interface
@steiler
steiler requested a review from a team as a code owner June 1, 2026 10:24
steiler and others added 2 commits June 2, 2026 14:24
Update comment for DeviceProfile to clarify usage.
ops.ToJson/ToJsonIETF returns nil when no leaves are new or updated.
Without a nil guard, json.Marshal(nil) produces "null", and the
resulting gNMI SetRequest carries {path:{}, val:{jsonVal:"null"}}.
SROS rejects this with GMI #2052 ("Cannot set JSON value, because
last element is not leaf") because null is not valid JSON for a
container node.

Add the nil check that existed in the old gnmi.go Set switch
(pre-materialize refactor) so a no-op reconcile never sends a
spurious null update to the device.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov

codecov Bot commented Jun 2, 2026

Copy link
Copy Markdown

@steiler

steiler commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@giacoliva if I provide proper instructions to you how to test this, whould you be able to do so?
Thanks already in advance.

@steiler

steiler commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@ipgst thank you for the extremely detailed writeup on #483 — that's exactly the kind of real-device evidence we didn't have when this PR was written. We went through the reproduction line by line against permodule's current implementation (the encoder this PR adds for device-profile: cisco-ios-xr) and found a likely root cause, plus a few things we still can't determine without your lab.

What we think is actually wrong

Cisco's own gNMI docs for native-YANG writes show origin as one of three fixed values (openconfig / cisco_native / cisco_cli), with the YANG module conveyed as a colon-prefix on the path element itself, e.g.:

path: { origin: "cisco_native" elem: { name: "Cisco-IOS-XR-shellutil-cfg:host-names" } elem: { name: "host-name" } }
val: { json_ietf_val: "\"abc\"" }

That's exactly the shape your working json_ietf_val reproduction used (module-prefixed element name, no/implicit origin). permodule.moduleRootPath in this PR does the opposite: it sets Path.Origin to the literal module name (e.g. "Cisco-IOS-XR-ip-static-cfg") and leaves the element name unprefixed — a convention that, as far as we can tell, only appears in Cisco's older Get/telemetry examples, never validated for Set. We think this is the primary defect, independent of anything else in the issue.

What we plan to change (pending your confirmation below)

  1. Drop Path.Origin = <module name>; instead prefix the module name directly onto the first path element's name (matching your working reproduction), with origin left unset or set to cisco_native.
  2. Keep the existing per-module batching (one Update per top-level module container, whole subtree as one JSON body) rather than moving to full per-leaf — less data on the wire — unless your test upgrade to new schemapb and add local schema store #1 below shows XRd rejects container-scoped bodies.
  3. Reject device-profile: cisco-ios-xr + encoding: PROTO at config-load time (mirroring how sonic is restricted to JSON_IETF-only) — your test showed string_val fails even at correct leaf/path granularity, which points to native scalar TypedValues being fundamentally unsupported here, not just differently shaped.

Could you run a few more probes for us?

Same style as your original report — gnmic --dry-run output plus the live result for each, would be hugely helpful. All against the same MgmtEth0/RP0/CPU0/0 interface/description leaf unless noted.

1. Container-scoped body (validates whether per-module batching, not just per-leaf, survives on XRd):

gnmic -a 11.4.20.12:9339 --insecure -u clab -p 'clab@123' \
  set --update '/Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]:::json_ietf:::{"description":"XRd container-scope test"}'

2a. Explicit cisco_native origin (does it change anything vs. no origin at all?):

gnmic -a 11.4.20.12:9339 --insecure -u clab -p 'clab@123' \
  set --update 'cisco_native:/Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]/description:::json_ietf:::"XRd cisco_native origin test"'

2b. Origin = literal module name, unprefixed element (today's permodule shape — we expect this to fail; confirming):

gnmic -a 11.4.20.12:9339 --insecure -u clab -p 'clab@123' \
  set --update 'Cisco-IOS-XR-um-interface-cfg:/interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]/description:::json_ietf:::"XRd module-as-origin test"'

3. Plain JSON (non-IETF) instead of JSON_IETF, same leaf:

gnmic -a 11.4.20.12:9339 --insecure -u clab -p 'clab@123' \
  set --update '/Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]/description:::json:::"XRd plain JSON test"'

4. Non-string leaf via PROTO (confirms scalar TypedValue is rejected in general, not just for strings) — substitute any integer/boolean leaf you have under the interface or ipv4 model, e.g. an MTU-style leaf:

gnmic -a 11.4.20.12:9339 --insecure -u clab -p 'clab@123' \
  set --update '/Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]/<some-int-or-bool-leaf>:::<uint/bool>:::<value>'

5. Multi-module single SetRequest (does XRd accept two different modules' updates atomically in one SetRequest, which is what our per-module encoder would send) — using a non-management interface to avoid any connectivity risk, set both description (interface-cfg module) and an IPv4 address (Cisco-IOS-XR-um-if-ipv4-cfg module, already in your schema) in a single gnmic set invocation with two --update flags.

6. Delete at module-root container path (untested so far) — a gnmic set --delete '/Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]' followed by re-adding it, just to see if a container-scoped delete behaves sanely or has unexpected blast radius.

No pressure to run all six if some are impractical in your lab — even a subset narrows this down a lot. We'll use whatever you can share to finalize the fix rather than guessing at the wire format again. Thanks again for such a thorough report!

steiler and others added 4 commits September 7, 2026 16:48
SBI.validateSetDefaults() now rejects a gnmi-type SBI configured with
device-profile: cisco-ios-xr and any GnmiOptions.Encoding other than
JSON_IETF. This replaces the previous silent-fallthrough (PROTO) /
silent-wrong-wire-format (JSON) behavior with a fast, clear
config-load error -- both shapes are confirmed to fail against real
XRd hardware.

netconf + cisco-ios-xr is unaffected and remains accepted.

- Flip TestSBI_validateSetDefaults_DeviceProfile_CiscoIOSXRGNMIProtoIsAccepted
  to assert rejection, renamed to ..._CiscoIOSXRGNMIProtoIsRejected.
- Add TestSBI_validateSetDefaults_DeviceProfile_CiscoIOSXRGNMIPlainJSONIsRejected.
- Update DeviceProfileCiscoIOSXR's doc comment to describe the
  JSON_IETF-only restriction.

Closes ticket 01 of .scratch/cisco-ios-xr-json-ietf-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
BuildPlan's cisco-ios-xr branch now only routes to permodule.Encode
for JSON_IETF. Plain JSON is now unreachable via valid config (ticket
01 rejects it at config-load), but BuildPlan may still be called
directly (e.g. in tests), so it falls through to the generic
single-root-update path instead, matching how PROTO already falls
through.

- Narrow the cisco-ios-xr inner switch to case gnmi.Encoding_JSON_IETF.
- Update BuildPlan's doc comment to reflect json/proto rejection at
  config-load.
- Add TestBuildPlan_CiscoIOSXR_JSON_GenericPlan asserting the generic
  single-root-path shape (no per-module Origin, root path, JsonVal).

permodule package is untouched, per ticket scope.

Closes ticket 02 of .scratch/cisco-ios-xr-json-ietf-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This ADR documents the design landed by PR #442 (the module-anchored
permodule mechanism this branch is built on) but was never actually
committed anywhere in the repo -- it existed only as an uncommitted
file in a sibling worktree (sonic-device-profile checkout).

Recovering it here so ticket 03 of .scratch/cisco-ios-xr-json-ietf-only
(the live-lab-verification ADR) has real prior art to reference as
"not superseded" instead of citing a nonexistent document.

Co-authored-by: Cursor <cursoragent@cursor.com>
…_IETF-only decision

Documents two things:

1. ADR 0001's module-anchored permodule mechanism was verified against
   a real Cisco XRd instance (containerlab cisco_c8000/8201-32FH, XR
   7.10.1) and found correct as-is across per-leaf, module-root,
   cisco_native-prefixed origin, multi-module single-SetRequest,
   module-root delete, and delete+update-replace shapes. ADR 0001 is
   explicitly not superseded.

2. The JSON_IETF-only restriction (tickets 01/02) as a distinct
   decision, with the categorical-rejection evidence for plain JSON
   and PROTO against the same hardware.

Explicitly flags the residual XR version gap: verified on 7.10.1,
issue #483's reporter is on 26.2.1.

Closes ticket 03 of .scratch/cisco-ios-xr-json-ietf-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@steiler

steiler commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@ipgst following up on our earlier comment — we ran your six probes (and a couple more) against a local containerlab Cisco XRd instance ourselves rather than waiting on lab access, since #1#3 below directly settle the questions we'd asked you to confirm. Results, and the fix that landed as a result.

Lab used: clab-cisco-ixr01, image registry.srlinux.dev/pub/cisco_8201-32fh_214:7.10.1 (cisco_c8000 containerlab kind), gNMI on port 57400. Note the version gap up front: this is XR 7.10.1, you're on 26.2.1 — see the ask at the bottom.

Results (mapped to the probes we asked for)

# Probe Result
1 Container-scoped body (module-root, whole subtree as one json_ietf_val) ✅ Pass
2a origin: cisco_native + module-prefixed path element ✅ Pass
2b origin: <literal module name>, unprefixed element (today's permodule shape) Pass
3 Plain JSON (json_val, non-IETF), both per-leaf and module-root ❌ Fail — "not supported val type: 10"
4 PROTO scalars (string_val, uint_val), per-leaf ❌ Fail — "not supported val type: 1" / "3"
5 Multi-module single SetRequest (two modules, one RPC) ✅ Pass
6 Module-root delete ✅ Pass, clean, no side effects
Delete + update on the same module in one SetRequest (replace semantics) ✅ Pass

The important correction to our own hypothesis

In our last comment we suspected permodule.moduleRootPath's Path.Origin = <module name> (unprefixed element) convention — #2b above — was the actual defect, and planned to change it to match Cisco's documented cisco_native-origin / module-prefixed-element shape (#2a). That hypothesis was wrong: both #2a and #2b passed identically against XRd. permodule's existing encoding (the one this PR already ships) works correctly as-is — no defect found in it, its dispatch, or the underlying design. So we did not change permodule's wire format.

What we did confirm as genuinely broken, exactly matching what you found: plain JSON and PROTO scalar TypedValues are categorically rejected by XRd's native-YANG Set endpoint, independent of leaf/path granularity or origin annotation. XRd's Set accepts json_ietf_val only, full stop.

What shipped

  • device-profile: cisco-ios-xr + encoding: PROTO or plain encoding: JSON is now rejected at config-load time with a clear error, instead of silently reaching the device and failing with a confusing "not supported val type: N" at reconcile time — b5bc3e9.
  • materialize.BuildPlan's cisco-ios-xr dispatch is narrowed to JSON_IETF only (defensive — config validation is now the primary guard) — 3d94d84.
  • New ADR recording the verification and the restriction decision, plus the original design ADR (never actually committed until now) it builds on: ADR 0001 (6e93048), ADR 0002 (0a3d1f5).

Net effect for you: set device-profile: cisco-ios-xr with encoding: JSON_IETF — that's the only combination that's valid and the only one that's been verified end-to-end.

One prerequisite you'll still need

Your TargetConnectionProfile in the issue doesn't reference device-profile at all — that field is exposed on the config-server side by sdcio/config-server#484, which is currently open and unmerged. You'll need that landed (or a build off that branch) before device-profile: cisco-ios-xr is actually settable via the CRD.

The ask

We tested on XR 7.10.1; you're on 26.2.1 — a substantially newer train. Our verification is strong evidence, not a substitute for your hardware. Once you have config-server with #484 and can pull an updated data-server build off this branch, could you retest end-to-end with device-profile: cisco-ios-xr + encoding: JSON_IETF against your own 26.2.1 XRd and let us know here whether it holds up? Thanks again for the original writeup — it's what made all of this findable.

Posted the follow-up comment on PR #442 summarizing the live-lab
verification (all six requested probes plus the delete+update-replace
case), correcting our own earlier hypothesis about permodule's origin
shape (it works as-is, no change needed), linking the landed ADRs and
commits, flagging the config-server#484 prerequisite, and asking the
reporter to retest on their own XR 26.2.1.

#442 (comment)

All four tickets in .scratch/cisco-ios-xr-json-ietf-only are now done.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ipgst

ipgst commented Sep 10, 2026

Copy link
Copy Markdown

Hi, I’ve completed the end-to-end validation against XRd 26.2.1 and the new IOS-XR device profile works successfully.

Test environment

TargetConnectionProfile:

spec:
  port: 9339
  protocol: gnmi
  encoding: JSON_IETF
  deviceProfile: cisco-ios-xr
  insecure: true
  skipVerify: true

Test Config

spec:
  priority: 10
  revertive: true
  config:
  - path: /Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]/description
    value: SDCIO managed description

SDCIO reports the Config as Ready=True and the appliedConfig matches the requested value.

I also verified the value directly on XRd with gNMI:

gnmic -a 11.4.20.12:9339 \
  --insecure \
  -u clab \
  -p 'xxxxxxxxxx' \
  get \
  --path '/Cisco-IOS-XR-um-interface-cfg:interfaces/interface[interface-name=MgmtEth0/RP0/CPU0/0]/description' \
  --encoding json_ietf

Result:

interfaces/interface/description = "SDCIO managed description"

So I can confirm that:

deviceProfile: cisco-ios-xr
encoding: JSON_IETF

works end-to-end on XRd 26.2.1 as well.

This is especially useful because with the previous generic path on data-server v0.0.72, the same intent failed, while a direct gNMI json_ietf_val write succeeded.

Thanks for the implementation and for validating the behavior on XR 7.10.1. This confirms the same approach also works on the newer 26.2.1 train.

@steiler

steiler commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

nice but there are issues I ran into still... interfaces worked fine for me as well.
however trying to use the full set of unified-model (um) schemas, I figured that e.g. al there router (bgp, static, ospf, ... ) are defined in their own yang module. with all of them defining "/router" so they can only be destinguished by their /<namespace/modulename>:router/... but this is not yet supported by schema server... so there is still work to be done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants