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) + } + } +}