diff --git a/Makefile b/Makefile
index 4e2c09127..9a0fe38aa 100644
--- a/Makefile
+++ b/Makefile
@@ -156,8 +156,12 @@ clean: ## Remove all generated files (bin/, dist/, coverage files)
##@ Build
-.PHONY: docs
-docs: crd-ref-docs ## Generate API reference documentation.
+.PHONY: compatibility-matrix
+compatibility-matrix: ## Regenerate the provider compatibility matrix (docs/provider-compatibility.md).
+ go run ./hack/provider-compatibility-matrix
+
+.PHONY: api-reference
+api-reference: crd-ref-docs ## Regenerate docs/api-reference/index.md from CRD types.
$(CRD_REF_DOCS) --source-path=./api --config=./hack/api-reference/config.yaml --renderer=markdown --output-path=./docs/api-reference/index.md
@sed -i.bak \
-e '/^SPDX-/d' \
@@ -169,6 +173,9 @@ docs: crd-ref-docs ## Generate API reference documentation.
docs/api-reference/index.md
@find . -type f -name "*.bak" -delete
+.PHONY: docs
+docs: api-reference compatibility-matrix ## Generate API reference and provider compatibility matrix.
+
ROOT_DIR := $(shell pwd)
DOCS_IMG ?= ironcore-dev/network-operator-docs:latest
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index 9bcbfcca4..8338a65d7 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -327,6 +327,7 @@ Package v1alpha1 contains API Schema definitions for the networking.metal.ironco
### Resource Types
- [AAA](#aaa)
+- [AccessControlList](#accesscontrollist)
- [BGP](#bgp)
- [BGPPeer](#bgppeer)
- [Banner](#banner)
@@ -630,9 +631,60 @@ _Appears in:_
| `description` _string_ | Description provides a human-readable description of the ACL entry. | | MaxLength: 63
MinLength: 1
Optional: \{\}
|
+#### AccessControlList
+AccessControlList is the Schema for the accesscontrollists API
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | |
+| `kind` _string_ | `AccessControlList` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[AccessControlListSpec](#accesscontrollistspec)_ | Specification of the desired state of the resource.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Required: \{\}
|
+| `status` _[AccessControlListStatus](#accesscontrolliststatus)_ | Status of the resource. This is set and updated automatically.
Read-only.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Optional: \{\}
|
+
+
+#### AccessControlListSpec
+
+
+
+AccessControlListSpec defines the desired state of AccessControlList
+
+
+
+_Appears in:_
+- [AccessControlList](#accesscontrollist)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
|
+| `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface.
This reference is used to link the AccessControlList to its provider-specific configuration. | | Optional: \{\}
|
+| `name` _string_ | Name is the identifier of the AccessControlList on the device.
Immutable. | | MaxLength: 63
MinLength: 1
Required: \{\}
|
+| `entries` _[ACLEntry](#aclentry) array_ | A list of rules/entries to apply. | | MaxItems: 100
MinItems: 1
Required: \{\}
|
+
+
+#### AccessControlListStatus
+
+
+
+AccessControlListStatus defines the observed state of AccessControlList.
+
+
+
+_Appears in:_
+- [AccessControlList](#accesscontrollist)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `entriesSummary` _string_ | EntriesSummary provides a human-readable summary of the number of ACL entries. | | Optional: \{\}
|
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the AccessControlList. | | Optional: \{\}
|
+
#### AddressFamily
diff --git a/docs/index.md b/docs/index.md
index cdfa087aa..26d79f931 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -19,6 +19,9 @@ hero:
- theme: alt
text: API Reference
link: /api-reference/
+ - theme: alt
+ text: Vendor Compatibility
+ link: /provider-compatibility
features:
- title: 🔌 Multi-Vendor Network Automation
diff --git a/docs/provider-compatibility.md b/docs/provider-compatibility.md
new file mode 100644
index 000000000..149340e47
--- /dev/null
+++ b/docs/provider-compatibility.md
@@ -0,0 +1,132 @@
+
+# Vendor compatibility matrix
+
+Generated by scanning provider code for `apistatus.NewUnsupportedFieldError()` calls.
+
+Full support (✓) means no such call was found. Providers that reject a field with a plain error, or whose `EnsureX` is an intentional no-op, are indistinguishable from full support.
+
+- ✓ full ~ partial  
+
+| Resource | openconfig | cisco-nxos-gnmi | cisco-iosxr-gnmi |
+|---|---|---|---|
+| [AAA](/api-reference/#aaa) | [~](#note-aaa-openconfig) | ✓ | |
+| [ACL](/api-reference/#accesscontrollist) | ✓ | ✓ | |
+| [BGP](/api-reference/#bgp) | [~](#note-bgp-openconfig) | ✓ | ✓ |
+| [BGPPeer](/api-reference/#bgppeer) | [~](#note-bgppeer-openconfig) | ✓ | ✓ |
+| [Banner](/api-reference/#banner) | [~](#note-banner-openconfig) | ✓ | |
+| [Certificate](/api-reference/#certificate) | | ✓ | |
+| [DHCPRelay](/api-reference/#dhcprelay) | | ✓ | |
+| [DNS](/api-reference/#dns) | [~](#note-dns-openconfig) | ✓ | |
+| [EVPNInstance](/api-reference/#evpninstance) | | ✓ | |
+| [EthernetSegment](/api-reference/#ethernetsegment) | | ✓ | |
+| [ISIS](/api-reference/#isis) | [~](#note-isis-openconfig) | ✓ | |
+| [Interface](/api-reference/#interface) | [~](#note-interface-openconfig) | [~](#note-interface-cisco-nxos-gnmi) | ✓ |
+| [LLDP](/api-reference/#lldp) | ✓ | ✓ | |
+| [ManagementAccess](/api-reference/#managementaccess) | [~](#note-managementaccess-openconfig) | [~](#note-managementaccess-cisco-nxos-gnmi) | |
+| [NTP](/api-reference/#ntp) | | ✓ | |
+| [NVE](/api-reference/#networkvirtualizationedge) | | ✓ | |
+| [OSPF](/api-reference/#ospf) | | ✓ | |
+| [PIM](/api-reference/#pim) | | [~](#note-pim-cisco-nxos-gnmi) | |
+| [PrefixSet](/api-reference/#prefixset) | ✓ | ✓ | ✓ |
+| [RoutingPolicy](/api-reference/#routingpolicy) | [~](#note-routingpolicy-openconfig) | ✓ | |
+| [SNMP](/api-reference/#snmp) | | ✓ | |
+| [Syslog](/api-reference/#syslog) | [~](#note-syslog-openconfig) | ✓ | |
+| [User](/api-reference/#user) | | ✓ | |
+| [VLAN](/api-reference/#vlan) | | ✓ | |
+| [VRF](/api-reference/#vrf) | [~](#note-vrf-openconfig) | ✓ | ✓ |
+
+## List of resources with incomplete support
+
+### openconfig
+
+
+**AAA**
+
+- `spec.authorization`
+- `spec.serverGroups[].type`
+
+
+**BGP**
+
+- `spec.addressFamilies`
+- `spec.addressFamilies.l2vpnEvpn`
+- `spec.adminState`
+
+
+**BGPPeer**
+
+- `spec.addressFamilies.l2vpnEvpn`
+- `spec.localAS.prependGlobalAS`
+- `spec.localAS.prependLocalAS`
+
+
+**Banner**
+
+- `spec.type`
+
+
+**DNS**
+
+- `spec.adminState`
+- `spec.servers[].vrfName`
+- `spec.sourceInterfaceName`
+
+
+**ISIS**
+
+- `spec.type`
+
+
+**Interface**
+
+- `spec.aggregation.multichassis`
+- `spec.bfd`
+- `spec.encapsulation.type`
+- `spec.ethernet.fecMode`
+- `spec.switchport`
+- `spec.switchport.mode`
+- `spec.type`
+
+
+**ManagementAccess**
+
+- `spec.grpc.gnmi`
+- `spec.ssh.sessionLimit`
+
+
+**RoutingPolicy**
+
+- `spec.statements[].actions.bgpActions.setASPath`
+- `spec.statements[].actions.bgpActions.setCommunity`
+- `spec.statements[].actions.bgpActions.setExtCommunity`
+
+
+**Syslog**
+
+- `spec.facilities`
+- `spec.servers[].vrfName`
+
+
+**VRF**
+
+- `spec.routeDistinguisher`
+- `spec.routeTargets`
+- `spec.vni`
+
+### cisco-nxos-gnmi
+
+
+**Interface**
+
+- `spec.type`
+
+
+**ManagementAccess**
+
+- `spec.grpc.serverName`
+
+
+**PIM**
+
+- `spec.interfaces[*].mode`
+
diff --git a/hack/api-reference/config.yaml b/hack/api-reference/config.yaml
index c7cb5b64d..f64659bec 100644
--- a/hack/api-reference/config.yaml
+++ b/hack/api-reference/config.yaml
@@ -3,7 +3,13 @@ processor:
- "TypeMeta$"
ignoreTypes:
- "ParseError$"
- - "List$"
+ # Exclude Kubernetes list wrapper types (FooList). Three patterns together
+ # cover all wrappers without accidentally excluding AccessControlList which
+ # is a root CRD type (its wrapper AccessControlListList is caught by the
+ # first pattern).
+ - "ListList$"
+ - "PoolList$"
+ - "[^l]List$"
render:
kubernetesVersion: 1.35
diff --git a/hack/provider-compatibility-matrix/build.go b/hack/provider-compatibility-matrix/build.go
new file mode 100644
index 000000000..bcc9309a3
--- /dev/null
+++ b/hack/provider-compatibility-matrix/build.go
@@ -0,0 +1,175 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "errors"
+ "fmt"
+ "go/types"
+ "sort"
+ "strings"
+
+ "golang.org/x/tools/go/packages"
+)
+
+// matrix is the fully-derived model the renderer consumes.
+type matrix struct {
+ // kinds are the API kinds (interface X in XProvider), sorted.
+ kinds []string
+ // columns are the concrete matrix columns, in providerColumns order.
+ columns []column
+ // support is keyed [kind][columnName] -> cell.
+ support map[string]map[string]cell
+}
+
+// column is one rendered matrix column.
+type column struct {
+ // name is the provider's registered name and the column header.
+ name string
+}
+
+// cell is one matrix entry for a (kind, column) pair.
+type cell struct {
+ // implemented is false when the provider type does not satisfy the interface;
+ // it renders as N/A.
+ implemented bool
+ // unsupportedFields are the spec field paths the EnsureX method rejects with
+ // NewUnsupportedFieldError. Empty means full support; non-empty means partial.
+ unsupportedFields []string
+}
+
+// build performs the single go/packages load and derives the matrix.
+func build() (*matrix, error) {
+ cfg := &packages.Config{
+ Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo |
+ packages.NeedSyntax | packages.NeedDeps | packages.NeedImports,
+ }
+ // Load the provider root (for interfaces) and every provider impl package.
+ patterns := make([]string, 0, 1+len(providerColumns))
+ patterns = append(patterns, providerModulePath)
+ for _, pc := range providerColumns {
+ patterns = append(patterns, pc.pkgPath)
+ }
+ pkgs, err := packages.Load(cfg, patterns...)
+ if err != nil {
+ return nil, fmt.Errorf("loading packages: %w", err)
+ }
+ if packages.PrintErrors(pkgs) > 0 {
+ return nil, errors.New("packages loaded with errors")
+ }
+
+ byPath := make(map[string]*packages.Package, len(pkgs))
+ for _, p := range pkgs {
+ byPath[p.PkgPath] = p
+ }
+ root := byPath[providerModulePath]
+ if root == nil {
+ return nil, fmt.Errorf("provider root package %q not loaded", providerModulePath)
+ }
+
+ ifaces := discoverInterfaces(root)
+ if len(ifaces) == 0 {
+ return nil, fmt.Errorf("no XProvider interfaces found in %s", providerModulePath)
+ }
+
+ m := &matrix{support: make(map[string]map[string]cell)}
+ for kind := range ifaces {
+ m.kinds = append(m.kinds, kind)
+ }
+ sort.Strings(m.kinds)
+
+ for _, pc := range providerColumns {
+ m.columns = append(m.columns, column{name: pc.registeredName})
+ pkg := byPath[pc.pkgPath]
+ if pkg == nil {
+ return nil, fmt.Errorf("provider package %q not loaded", pc.pkgPath)
+ }
+ providerType := lookupProviderType(pkg)
+ if providerType == nil {
+ return nil, fmt.Errorf("no Provider type in %q", pc.pkgPath)
+ }
+ violations := collectViolations(pkg, m.kinds)
+
+ for _, kind := range m.kinds {
+ implemented := types.Implements(providerType, ifaces[kind]) ||
+ types.Implements(types.NewPointer(providerType), ifaces[kind])
+ if m.support[kind] == nil {
+ m.support[kind] = make(map[string]cell)
+ }
+ m.support[kind][pc.registeredName] = deriveCell(implemented, violations[kind])
+ }
+ }
+ return m, nil
+}
+
+// discoverInterfaces returns kind -> *types.Interface for every exported
+// XProvider interface in the provider root package that declares an EnsureX
+// method — the marker that distinguishes resource-configuration interfaces from
+// operational ones (Maintenance, Provisioning, ConfigBackup, etc.).
+func discoverInterfaces(root *packages.Package) map[string]*types.Interface {
+ out := make(map[string]*types.Interface)
+ scope := root.Types.Scope()
+ for _, name := range scope.Names() {
+ if !strings.HasSuffix(name, "Provider") || name == "Provider" {
+ continue
+ }
+ obj := scope.Lookup(name)
+ tn, ok := obj.(*types.TypeName)
+ if !ok {
+ continue
+ }
+ iface, ok := tn.Type().Underlying().(*types.Interface)
+ if !ok {
+ continue
+ }
+ kind := strings.TrimSuffix(name, "Provider")
+ if !hasMethod(iface, "Ensure"+kind) {
+ continue
+ }
+ out[kind] = iface
+ }
+ return out
+}
+
+// hasMethod reports whether iface declares a method with the given name.
+func hasMethod(iface *types.Interface, name string) bool {
+ for m := range iface.Methods() {
+ if m.Name() == name {
+ return true
+ }
+ }
+ return false
+}
+
+// lookupProviderType returns the concrete named "Provider" type of a package.
+func lookupProviderType(pkg *packages.Package) *types.Named {
+ obj := pkg.Types.Scope().Lookup("Provider")
+ if obj == nil {
+ return nil
+ }
+ tn, ok := obj.(*types.TypeName)
+ if !ok {
+ return nil
+ }
+ named, ok := tn.Type().(*types.Named)
+ if !ok {
+ return nil
+ }
+ return named
+}
+
+// deriveCell folds the implemented floor and the collected unsupported field
+// paths into a single cell: unimplemented is N/A, implemented with rejected
+// fields is partial, implemented with none is full.
+func deriveCell(implemented bool, unsupported map[string]bool) cell {
+ c := cell{implemented: implemented}
+ if !implemented {
+ return c
+ }
+ for field := range unsupported {
+ c.unsupportedFields = append(c.unsupportedFields, field)
+ }
+ sort.Strings(c.unsupportedFields)
+ return c
+}
diff --git a/hack/provider-compatibility-matrix/main.go b/hack/provider-compatibility-matrix/main.go
new file mode 100644
index 000000000..d086a1d86
--- /dev/null
+++ b/hack/provider-compatibility-matrix/main.go
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+const (
+ providerModulePath = "github.com/ironcore-dev/network-operator/internal/provider"
+ outputPath = "docs/provider-compatibility.md"
+ // apiRefPath is the generated API reference; resource names link into it when a
+ // matching heading exists.
+ apiRefPath = "docs/api-reference/index.md"
+)
+
+// kindToAnchor overrides the default lowercased-kind → anchor mapping for kinds
+// whose provider interface name does not match the CRD type name used in the
+// API reference headings.
+var kindToAnchor = map[string]string{
+ "ACL": "accesscontrollist",
+ "NVE": "networkvirtualizationedge",
+}
+
+type providerColumn struct {
+ // registeredName is the string a Device CR's spec.provider carries; it is
+ // also the column header.
+ registeredName string
+ // pkgPath is the Go package implementing the provider.
+ pkgPath string
+}
+
+// providerColumns enumerates the providers rendered in the matrix, one column
+// each.
+var providerColumns = []providerColumn{
+ {registeredName: "openconfig", pkgPath: providerModulePath + "/openconfig"},
+ {registeredName: "cisco-nxos-gnmi", pkgPath: providerModulePath + "/cisco/nxos"},
+ {registeredName: "cisco-iosxr-gnmi", pkgPath: providerModulePath + "/cisco/iosxr"},
+}
+
+func main() {
+ m, err := build()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "provider-compatibility-matrix:", err)
+ os.Exit(1)
+ }
+ if err := os.WriteFile(outputPath, []byte(m.render()), 0o644); err != nil {
+ fmt.Fprintln(os.Stderr, "provider-compatibility-matrix:", err)
+ os.Exit(1)
+ }
+ fmt.Fprintln(os.Stderr, "provider-compatibility-matrix: wrote", outputPath)
+}
diff --git a/hack/provider-compatibility-matrix/parse.go b/hack/provider-compatibility-matrix/parse.go
new file mode 100644
index 000000000..f7fab1db0
--- /dev/null
+++ b/hack/provider-compatibility-matrix/parse.go
@@ -0,0 +1,288 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "go/ast"
+ "go/types"
+ "strconv"
+ "strings"
+
+ "golang.org/x/tools/go/packages"
+)
+
+// Constructor names in the apistatus package. Only NewUnsupportedFieldError
+// counts toward the matrix — it is the "this device cannot do X" signal.
+// NewInvalidArgumentError (malformed input) and NewFailedPreconditionError
+// (transient device state) are universal, not capability gaps.
+const (
+ apistatusPkgPath = "github.com/ironcore-dev/network-operator/internal/apistatus"
+ unsupportedCtor = "NewUnsupportedFieldError"
+ invalidArgumentCtor = "NewInvalidArgumentError"
+)
+
+// collectViolations returns, per kind, the spec field paths a provider's EnsureX
+// method rejects with NewUnsupportedFieldError, following same-package calls to
+// any depth. No rejected fields renders as full; one or more as partial.
+func collectViolations(pkg *packages.Package, kinds []string) map[string]map[string]bool {
+ ensureToKind := make(map[string]string, len(kinds))
+ for _, k := range kinds {
+ ensureToKind["Ensure"+k] = k
+ }
+ // Index every function/method declaration by its *types.Func so callee
+ // resolution during the walk is an O(1) lookup.
+ funcDecls := indexFuncDecls(pkg)
+
+ out := make(map[string]map[string]bool)
+ for _, file := range pkg.Syntax {
+ for _, decl := range file.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok || fn.Body == nil {
+ continue
+ }
+ kind, ok := ensureToKind[fn.Name.Name]
+ if !ok {
+ continue
+ }
+ for _, field := range collectFieldsFrom(pkg, funcDecls, fn, map[*types.Func]bool{}) {
+ if out[kind] == nil {
+ out[kind] = make(map[string]bool)
+ }
+ out[kind][field] = true
+ }
+ }
+ }
+ return out
+}
+
+// indexFuncDecls maps each in-package function/method to its declaration.
+func indexFuncDecls(pkg *packages.Package) map[*types.Func]*ast.FuncDecl {
+ out := make(map[*types.Func]*ast.FuncDecl)
+ for _, file := range pkg.Syntax {
+ for _, decl := range file.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok || fn.Body == nil {
+ continue
+ }
+ if obj, ok := pkg.TypesInfo.Defs[fn.Name].(*types.Func); ok {
+ out[obj] = fn
+ }
+ }
+ }
+ return out
+}
+
+// collectFieldsFrom returns every unsupported spec field path reachable from fn:
+// literals raised directly, plus those in same-package callees (transitively).
+// visited guards against cycles.
+func collectFieldsFrom(pkg *packages.Package, funcDecls map[*types.Func]*ast.FuncDecl, fn *ast.FuncDecl, visited map[*types.Func]bool) []string {
+ // A STYLE 2 body collects FieldViolation literals into a slice and spreads it
+ // into NewUnsupportedFieldError(violations...). Capturing every literal in the
+ // body is only safe when the body raises no NewInvalidArgumentError — otherwise
+ // an InvalidArgument field would be misattributed as unsupported.
+ hasInvalidArgument := false
+ ast.Inspect(fn.Body, func(n ast.Node) bool {
+ if _, ok := n.(*ast.FuncLit); ok {
+ return false // don't descend into closures
+ }
+ if call, ok := n.(*ast.CallExpr); ok && isAPIStatusCall(pkg, call, invalidArgumentCtor) {
+ hasInvalidArgument = true
+ }
+ return true
+ })
+
+ var fields []string
+ ast.Inspect(fn.Body, func(n ast.Node) bool {
+ if _, ok := n.(*ast.FuncLit); ok {
+ return false // don't descend into closures
+ }
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ if isAPIStatusCall(pkg, call, unsupportedCtor) {
+ // STYLE 1: FieldViolation literals passed directly to the constructor.
+ hadDirectLit := false
+ for _, arg := range call.Args {
+ if lit, ok := arg.(*ast.CompositeLit); ok && isAPIStatusLit(pkg, lit, "FieldViolation") {
+ hadDirectLit = true
+ if field := fieldValue(lit); field != "" {
+ fields = append(fields, field)
+ }
+ }
+ }
+ // STYLE 2: the constructor received a spread slice (violations...), not
+ // literals. Capture every FieldViolation literal in the body, but only
+ // when the body cannot also produce an InvalidArgument violation.
+ if !hadDirectLit && !hasInvalidArgument {
+ fields = append(fields, allFieldLiterals(pkg, fn.Body)...)
+ }
+ return true
+ }
+ // Any other call: follow it if it resolves to an in-package declaration.
+ if callee, decl := calleeDecl(pkg, funcDecls, call); decl != nil && !visited[callee] {
+ visited[callee] = true
+ fields = append(fields, collectFieldsFrom(pkg, funcDecls, decl, visited)...)
+ }
+ return true
+ })
+ return fields
+}
+
+// allFieldLiterals returns the field path of every FieldViolation literal in body.
+func allFieldLiterals(pkg *packages.Package, body *ast.BlockStmt) []string {
+ var fields []string
+ ast.Inspect(body, func(n ast.Node) bool {
+ if _, ok := n.(*ast.FuncLit); ok {
+ return false // don't descend into closures
+ }
+ if lit, ok := n.(*ast.CompositeLit); ok && isAPIStatusLit(pkg, lit, "FieldViolation") {
+ if field := fieldValue(lit); field != "" {
+ fields = append(fields, field)
+ }
+ }
+ return true
+ })
+ return fields
+}
+
+// calleeDecl resolves a call to its declaration, but only when that declaration
+// lives in pkg. Returns (nil, nil) for calls into other packages.
+func calleeDecl(pkg *packages.Package, funcDecls map[*types.Func]*ast.FuncDecl, call *ast.CallExpr) (*types.Func, *ast.FuncDecl) {
+ var ident *ast.Ident
+ switch fn := call.Fun.(type) {
+ case *ast.Ident:
+ ident = fn
+ case *ast.SelectorExpr:
+ ident = fn.Sel
+ default:
+ return nil, nil
+ }
+ obj, ok := pkg.TypesInfo.Uses[ident].(*types.Func)
+ if !ok {
+ return nil, nil
+ }
+ return obj, funcDecls[obj]
+}
+
+// calleeName returns the name of a call's callee, or "" if it is neither a
+// selector nor a bare identifier.
+func calleeName(call *ast.CallExpr) string {
+ switch fn := call.Fun.(type) {
+ case *ast.SelectorExpr:
+ return fn.Sel.Name
+ case *ast.Ident:
+ return fn.Name
+ default:
+ return ""
+ }
+}
+
+// isAPIStatusCall reports whether call invokes a function with the given name
+// from the apistatus package. For qualified calls (pkg.Func), the package is
+// verified through type info; bare identifiers are trusted by name alone since
+// they are either dot-imports or same-package definitions.
+func isAPIStatusCall(pkg *packages.Package, call *ast.CallExpr, funcName string) bool {
+ switch fn := call.Fun.(type) {
+ case *ast.SelectorExpr:
+ if fn.Sel.Name != funcName {
+ return false
+ }
+ obj, ok := pkg.TypesInfo.Uses[fn.Sel].(*types.Func)
+ if !ok {
+ return fn.Sel.Name == funcName
+ }
+ return obj.Pkg() != nil && obj.Pkg().Path() == apistatusPkgPath
+ case *ast.Ident:
+ return fn.Name == funcName
+ default:
+ return false
+ }
+}
+
+// isAPIStatusLit reports whether a composite literal is a struct with the given
+// type name from the apistatus package. For qualified types (pkg.Type), the
+// package is verified; bare identifiers are trusted by name alone.
+func isAPIStatusLit(pkg *packages.Package, lit *ast.CompositeLit, typeName string) bool {
+ switch t := lit.Type.(type) {
+ case *ast.SelectorExpr:
+ if t.Sel.Name != typeName {
+ return false
+ }
+ obj, ok := pkg.TypesInfo.Uses[t.Sel].(*types.TypeName)
+ if !ok {
+ return t.Sel.Name == typeName
+ }
+ return obj.Pkg() != nil && obj.Pkg().Path() == apistatusPkgPath
+ case *ast.Ident:
+ return t.Name == typeName
+ default:
+ return false
+ }
+}
+
+// fieldValue extracts the field path from a FieldViolation literal's Field element.
+// See staticString for how dynamic values are handled.
+func fieldValue(lit *ast.CompositeLit) string {
+ for _, elt := range lit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+ key, ok := kv.Key.(*ast.Ident)
+ if !ok || key.Name != "Field" {
+ continue
+ }
+ return staticString(kv.Value)
+ }
+ return ""
+}
+
+// staticString resolves an expression to a static field path: a string literal
+// verbatim, or a Sprintf format string with verbs stripped. "" for anything else.
+func staticString(expr ast.Expr) string {
+ switch v := expr.(type) {
+ case *ast.BasicLit:
+ if s, err := strconv.Unquote(v.Value); err == nil {
+ return s
+ }
+ case *ast.CallExpr:
+ if calleeName(v) == "Sprintf" && len(v.Args) > 0 {
+ if lit, ok := v.Args[0].(*ast.BasicLit); ok {
+ if s, err := strconv.Unquote(lit.Value); err == nil {
+ return stripVerbs(s)
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// stripVerbs removes fmt verb runs (%s, %d, %[1]v, %%, ...) from a format string,
+// turning "spec.servers[%s].vrfName" into "spec.servers[].vrfName".
+func stripVerbs(format string) string {
+ var b strings.Builder
+ for i := 0; i < len(format); i++ {
+ if format[i] != '%' {
+ b.WriteByte(format[i])
+ continue
+ }
+ i++ // skip '%'
+ if i < len(format) && format[i] == '%' {
+ b.WriteByte('%') // literal percent
+ continue
+ }
+ // Skip flags, width, precision, arg index, and the verb letter.
+ for i < len(format) && !isVerbLetter(format[i]) {
+ i++
+ }
+ // i now points at the verb letter; the loop's i++ consumes it.
+ }
+ return b.String()
+}
+
+// isVerbLetter reports whether b terminates a fmt verb (an ASCII letter).
+func isVerbLetter(b byte) bool {
+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
+}
diff --git a/hack/provider-compatibility-matrix/parse_test.go b/hack/provider-compatibility-matrix/parse_test.go
new file mode 100644
index 000000000..624595d57
--- /dev/null
+++ b/hack/provider-compatibility-matrix/parse_test.go
@@ -0,0 +1,184 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "go/ast"
+ "go/importer"
+ "go/parser"
+ "go/token"
+ "go/types"
+ "reflect"
+ "sort"
+ "testing"
+
+ "golang.org/x/tools/go/packages"
+)
+
+// loadTestPkg type-checks src as a standalone package for the collectors. An
+// apistatus stub is prepended so the constructors resolve without the real dep.
+func loadTestPkg(t *testing.T, src string) *packages.Package {
+ t.Helper()
+ const apistatusStub = `
+type FieldViolation struct{ Field, Description string }
+type statusError struct{}
+func (statusError) Error() string { return "" }
+func NewUnsupportedFieldError(...FieldViolation) error { return statusError{} }
+func NewInvalidArgumentError(...FieldViolation) error { return statusError{} }
+`
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, "src.go", "package p\nimport \"fmt\"\nvar _ = fmt.Sprintf\n"+apistatusStub+src, parser.ParseComments)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ info := &types.Info{
+ Defs: map[*ast.Ident]types.Object{},
+ Uses: map[*ast.Ident]types.Object{},
+ }
+ conf := types.Config{Importer: importer.Default()}
+ typ, err := conf.Check("p", fset, []*ast.File{file}, info)
+ if err != nil {
+ t.Fatalf("typecheck: %v", err)
+ }
+ return &packages.Package{Syntax: []*ast.File{file}, TypesInfo: info, Types: typ, Fset: fset}
+}
+
+func TestCollectViolations(t *testing.T) {
+ tests := []struct {
+ name string
+ src string
+ want map[string][]string
+ }{
+ {
+ name: "style 1 inline literal",
+ src: `
+type Provider struct{}
+func (Provider) EnsureBGP() error {
+ return NewUnsupportedFieldError(FieldViolation{Field: "spec.adminState"})
+}`,
+ want: map[string][]string{"BGP": {"spec.adminState"}},
+ },
+ {
+ name: "style 2 slice-collected via helper",
+ src: `
+type Provider struct{}
+func (Provider) EnsureDNS() error { return validateDNS() }
+func validateDNS() error {
+ var v []FieldViolation
+ v = append(v, FieldViolation{Field: "spec.adminState"})
+ v = append(v, FieldViolation{Field: "spec.sourceInterfaceName"})
+ return NewUnsupportedFieldError(v...)
+}`,
+ want: map[string][]string{"DNS": {"spec.adminState", "spec.sourceInterfaceName"}},
+ },
+ {
+ name: "mixed constructors: invalid-argument fields excluded",
+ src: `
+type Provider struct{}
+func (Provider) EnsureInterface() error {
+ if false {
+ return NewInvalidArgumentError(FieldViolation{Field: "spec.encapsulation"})
+ }
+ return NewUnsupportedFieldError(FieldViolation{Field: "spec.type"})
+}`,
+ want: map[string][]string{"Interface": {"spec.type"}},
+ },
+ {
+ name: "transitive method-to-method chain",
+ src: `
+type Provider struct{}
+func (p Provider) EnsureInterface() error { return p.ensureSub() }
+func (Provider) ensureSub() error {
+ return NewUnsupportedFieldError(FieldViolation{Field: "spec.encapsulation.type"})
+}`,
+ want: map[string][]string{"Interface": {"spec.encapsulation.type"}},
+ },
+ {
+ name: "fmt.Sprintf field path has verbs stripped",
+ src: `
+type Provider struct{}
+func (Provider) EnsureDNS() error {
+ return NewUnsupportedFieldError(FieldViolation{Field: fmt.Sprintf("spec.servers[%s].vrfName", "x")})
+}`,
+ want: map[string][]string{"DNS": {"spec.servers[].vrfName"}},
+ },
+ {
+ name: "style 2 guard: helper mixing invalid-argument captures nothing",
+ src: `
+type Provider struct{}
+func (Provider) EnsureVLAN() error { return validateVLAN() }
+func validateVLAN() error {
+ var v []FieldViolation
+ v = append(v, FieldViolation{Field: "spec.badInput"})
+ if false {
+ return NewInvalidArgumentError(v...)
+ }
+ return NewUnsupportedFieldError(v...)
+}`,
+ // The helper contains a NewInvalidArgumentError call, so the STYLE 2
+ // slice-harvest is suppressed to avoid misattribution.
+ want: map[string][]string{"VLAN": nil},
+ },
+ {
+ name: "cycle is safe",
+ src: `
+type Provider struct{}
+func (p Provider) EnsureBGP() error { return p.a() }
+func (p Provider) a() error { return p.b() }
+func (p Provider) b() error {
+ _ = p.a()
+ return NewUnsupportedFieldError(FieldViolation{Field: "spec.x"})
+}`,
+ want: map[string][]string{"BGP": {"spec.x"}},
+ },
+ {
+ name: "closure with invalid-argument does not suppress outer style 2",
+ src: `
+type Provider struct{}
+func (Provider) EnsureBGP() error {
+ var v []FieldViolation
+ v = append(v, FieldViolation{Field: "spec.mode"})
+ _ = func() { NewInvalidArgumentError(FieldViolation{Field: "spec.bad"}) }
+ return NewUnsupportedFieldError(v...)
+}`,
+ want: map[string][]string{"BGP": {"spec.mode"}},
+ },
+ }
+ kinds := []string{"BGP", "DNS", "Interface", "VLAN"}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ pkg := loadTestPkg(t, tt.src)
+ got := collectViolations(pkg, kinds)
+ for kind, wantFields := range tt.want {
+ var gotFields []string
+ for f := range got[kind] {
+ gotFields = append(gotFields, f)
+ }
+ sort.Strings(gotFields)
+ sort.Strings(wantFields)
+ if len(gotFields) == 0 && len(wantFields) == 0 {
+ continue
+ }
+ if !reflect.DeepEqual(gotFields, wantFields) {
+ t.Errorf("kind %s: got %v, want %v", kind, gotFields, wantFields)
+ }
+ }
+ })
+ }
+}
+
+func TestStripVerbs(t *testing.T) {
+ tests := []struct{ in, want string }{
+ {"spec.servers[%s].vrfName", "spec.servers[].vrfName"},
+ {"spec.x[%d].y[%d]", "spec.x[].y[]"},
+ {"spec.plain", "spec.plain"},
+ {"a%[1]vb", "ab"},
+ {"100%% done", "100% done"},
+ }
+ for _, tt := range tests {
+ if got := stripVerbs(tt.in); got != tt.want {
+ t.Errorf("stripVerbs(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
diff --git a/hack/provider-compatibility-matrix/render.go b/hack/provider-compatibility-matrix/render.go
new file mode 100644
index 000000000..91d1bce97
--- /dev/null
+++ b/hack/provider-compatibility-matrix/render.go
@@ -0,0 +1,164 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// Cell glyphs.
+const (
+ glyphFull = "✓"
+ glyphPartial = "~"
+)
+
+// render produces the full Markdown matrix document.
+func (m *matrix) render() string {
+ var b strings.Builder
+ b.WriteString("\n")
+ b.WriteString("# Vendor compatibility matrix\n\n")
+ b.WriteString("Generated by scanning provider code for `apistatus.NewUnsupportedFieldError()` calls.\n\n")
+ b.WriteString("Full support (✓) means no such call was found. Providers that reject a field with a plain error, or whose `EnsureX` is an intentional no-op, are indistinguishable from full support.\n\n")
+ fmt.Fprintf(&b, "- %s full %s partial  \n\n",
+ glyphFull, glyphPartial)
+
+ // Header
+ b.WriteString("| Resource |")
+ for _, col := range m.columns {
+ fmt.Fprintf(&b, " %s |", col.name)
+ }
+ b.WriteString("\n|---|")
+ for range m.columns {
+ b.WriteString("---|")
+ }
+ b.WriteString("\n")
+
+ // Notes, each reachable from its cell glyph via an explicit anchor. Collected
+ // per provider so the Notes section can group them under the provider heading.
+ type note struct {
+ anchor string
+ kind string
+ fields []string
+ }
+ notesByColumn := make(map[string][]note)
+
+ // Resource names link into the API reference when a matching heading exists.
+ anchors := apiRefAnchors(apiRefPath)
+
+ for _, kind := range m.kinds {
+ b.WriteString("| ")
+ b.WriteString(resourceCell(kind, anchors))
+ b.WriteString(" |")
+ for _, col := range m.columns {
+ c := m.support[kind][col.name]
+ glyph := renderGlyph(c)
+ if c.implemented && len(c.unsupportedFields) > 0 {
+ anchor := noteAnchor(kind, col.name)
+ notesByColumn[col.name] = append(notesByColumn[col.name],
+ note{anchor: anchor, kind: kind, fields: c.unsupportedFields})
+ glyph = fmt.Sprintf("[%s](#%s)", glyph, anchor)
+ }
+ b.WriteString(" ")
+ b.WriteString(glyph)
+ b.WriteString(" |")
+ }
+ b.WriteString("\n")
+ }
+
+ hasNotes := false
+ for _, col := range m.columns {
+ if len(notesByColumn[col.name]) > 0 {
+ hasNotes = true
+ break
+ }
+ }
+ if hasNotes {
+ b.WriteString("\n## List of resources with incomplete support\n\n")
+ for _, col := range m.columns {
+ ns := notesByColumn[col.name]
+ if len(ns) == 0 {
+ continue
+ }
+ fmt.Fprintf(&b, "### %s\n\n", col.name)
+ for _, n := range ns {
+ fmt.Fprintf(&b, "\n**%s**\n\n", n.anchor, n.kind)
+ for _, f := range n.fields {
+ fmt.Fprintf(&b, "- `%s`\n", f)
+ }
+ b.WriteString("\n")
+ }
+ }
+ }
+ return b.String()
+}
+
+// noteAnchor returns the anchor id linking a partial cell to its note.
+func noteAnchor(kind, colName string) string {
+ return "note-" + slug(kind) + "-" + slug(colName)
+}
+
+// slug lowercases s and collapses runs of non-[a-z0-9] characters to a dash.
+func slug(s string) string {
+ var b strings.Builder
+ dash := false
+ for _, r := range strings.ToLower(s) {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ dash = false
+ continue
+ }
+ if !dash && b.Len() > 0 {
+ b.WriteByte('-')
+ dash = true
+ }
+ }
+ return strings.TrimRight(b.String(), "-")
+}
+
+// apiRefAnchors returns the lowercased H4 heading names from the API reference.
+// A missing file yields nil, so the matrix still generates without resource links.
+func apiRefAnchors(path string) map[string]bool {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil
+ }
+ defer f.Close()
+ anchors := make(map[string]bool)
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if name, ok := strings.CutPrefix(line, "#### "); ok {
+ anchors[strings.ToLower(strings.TrimSpace(name))] = true
+ }
+ }
+ return anchors
+}
+
+// resourceCell renders a kind as an API reference link, or plain text if no
+// matching anchor exists. kindToAnchor overrides take precedence.
+func resourceCell(kind string, anchors map[string]bool) string {
+ anchor := strings.ToLower(kind)
+ if override, ok := kindToAnchor[kind]; ok {
+ anchor = override
+ }
+ if anchors[anchor] {
+ return fmt.Sprintf("[%s](/api-reference/#%s)", kind, anchor)
+ }
+ return kind
+}
+
+// renderGlyph returns the cell glyph: full, partial, or empty when unimplemented.
+func renderGlyph(c cell) string {
+ switch {
+ case !c.implemented:
+ return ""
+ case len(c.unsupportedFields) == 0:
+ return glyphFull
+ default:
+ return glyphPartial
+ }
+}
diff --git a/hack/provider-compatibility-matrix/render_test.go b/hack/provider-compatibility-matrix/render_test.go
new file mode 100644
index 000000000..e3038a0ae
--- /dev/null
+++ b/hack/provider-compatibility-matrix/render_test.go
@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testMatrix() *matrix {
+ return &matrix{
+ kinds: []string{"BGP", "DNS", "VLAN"},
+ columns: []column{{name: "openconfig"}, {name: "cisco-nxos-gnmi"}},
+ support: map[string]map[string]cell{
+ "BGP": {
+ "openconfig": {implemented: true, unsupportedFields: []string{"spec.adminState"}},
+ "cisco-nxos-gnmi": {implemented: true},
+ },
+ "DNS": {
+ "openconfig": {implemented: true, unsupportedFields: []string{"spec.a", "spec.b"}},
+ "cisco-nxos-gnmi": {implemented: false},
+ },
+ "VLAN": {
+ "openconfig": {implemented: false},
+ "cisco-nxos-gnmi": {implemented: true, unsupportedFields: []string{"spec.mode"}},
+ },
+ },
+ }
+}
+
+func TestRenderNotesGroupedByProvider(t *testing.T) {
+ out := testMatrix().render()
+ ocIdx := strings.Index(out, "### openconfig")
+ nxIdx := strings.Index(out, "### cisco-nxos-gnmi")
+ if ocIdx < 0 || nxIdx < 0 {
+ t.Fatalf("missing provider headings in Notes:\n%s", out)
+ }
+ if ocIdx > nxIdx {
+ t.Errorf("provider headings out of column order: openconfig should precede cisco-nxos-gnmi")
+ }
+
+ for _, want := range []string{
+ "\n**BGP**",
+ "\n**DNS**",
+ "\n**VLAN**",
+ "- `spec.adminState`",
+ "- `spec.a`",
+ "- `spec.b`",
+ "- `spec.mode`",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("render output missing %q", want)
+ }
+ }
+}
+
+func TestRenderGlyphLinksAndFullCells(t *testing.T) {
+ out := testMatrix().render()
+ if !strings.Contains(out, "["+glyphPartial+"](#note-bgp-openconfig)") {
+ t.Errorf("partial glyph not linked to note anchor:\n%s", out)
+ }
+ // A full cell renders the bare glyph with no link.
+ if strings.Contains(out, "["+glyphFull+"](") {
+ t.Errorf("full glyph should not be a link")
+ }
+}
+
+func TestRenderResourceLinks(t *testing.T) {
+ dir := t.TempDir()
+ ref := filepath.Join(dir, "index.md")
+ if err := os.WriteFile(ref, []byte("#### BGP\n\n#### DNS\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got := apiRefAnchors(ref)
+ if !got["bgp"] || !got["dns"] {
+ t.Fatalf("apiRefAnchors missing headings: %v", got)
+ }
+
+ anchors := map[string]bool{"bgp": true}
+ if link := resourceCell("BGP", anchors); link != "[BGP](/api-reference/#bgp)" {
+ t.Errorf("linked kind = %q", link)
+ }
+ if plain := resourceCell("VLAN", anchors); plain != "VLAN" {
+ t.Errorf("unmatched kind should be plain text, got %q", plain)
+ }
+}
+
+func TestApiRefAnchorsMissingFile(t *testing.T) {
+ if got := apiRefAnchors(filepath.Join(t.TempDir(), "nope.md")); len(got) != 0 {
+ t.Errorf("missing file should yield empty set, got %v", got)
+ }
+}
+
+func TestSlug(t *testing.T) {
+ tests := []struct{ in, want string }{
+ {"openconfig", "openconfig"},
+ {"cisco-nxos-gnmi", "cisco-nxos-gnmi"},
+ {"BGPPeer", "bgppeer"},
+ {"a.b c", "a-b-c"},
+ }
+ for _, tt := range tests {
+ if got := slug(tt.in); got != tt.want {
+ t.Errorf("slug(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}