{"id":13639096,"url":"https://github.com/networkop/cue-networking","last_synced_at":"2025-04-06T17:43:16.883Z","repository":{"id":58761272,"uuid":"442266321","full_name":"networkop/cue-networking","owner":"networkop","description":"Example of using CUE to model baremetal network configurations","archived":false,"fork":false,"pushed_at":"2021-12-30T15:03:59.000Z","size":19,"stargazers_count":46,"open_issues_count":0,"forks_count":0,"subscribers_count":3,"default_branch":"main","last_synced_at":"2025-02-12T23:45:03.459Z","etag":null,"topics":["cuelang","networking"],"latest_commit_sha":null,"homepage":"","language":"CUE","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/networkop.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-12-27T20:28:32.000Z","updated_at":"2024-03-14T15:47:48.000Z","dependencies_parsed_at":"2022-09-08T04:00:44.817Z","dependency_job_id":null,"html_url":"https://github.com/networkop/cue-networking","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/networkop%2Fcue-networking","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/networkop%2Fcue-networking/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/networkop%2Fcue-networking/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/networkop%2Fcue-networking/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/networkop","download_url":"https://codeload.github.com/networkop/cue-networking/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247526675,"owners_count":20953141,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cuelang","networking"],"created_at":"2024-08-02T01:00:57.612Z","updated_at":"2025-04-06T17:43:16.865Z","avatar_url":"https://github.com/networkop.png","language":"CUE","funding_links":[],"categories":["networking","Resources"],"sub_categories":[],"readme":"# CUE For Network Configurations\n\nIn network automation, a typical device configuration is represented as a structured document. This could be YAML that is fed into a Jinja template to produce a flat (text) device config or it could be JSON that follows the device's YANG/OpenAPI model. In either case, this structured data needs to be generated by humans either manually or through code, both of which can be complex and error-prone processes. \n\nIn this repo, you'll see how to use [CUE](https://cuelang.org/) to solve the following network data configuration problems:\n\n* Validate data models based on statically-typed schemas.\n* Reduce configuration boilerplate (we'll shrink the total number of lines by 60%).\n* Separate values from data models and simplify input data structs.\n* Enforce policies and design decisions via value constrains.\n\n![](cue-networking.svg)\n\n## Sample Input\n\nThe original configs are taken from the NVIDIA Networking [EVPN Multihoming guide](\nhttps://docs.nvidia.com/networking-ethernet-software/cumulus-linux-50/Network-Virtualization/Ethernet-Virtual-Private-Network-EVPN/EVPN-Multihoming/#evpn-mh-with-head-end-replication). They are designed to configure MH EVPN in the following topology:\n\n![](https://docs.nvidia.com/networking-ethernet-software/images/cumulus-linux/EVPN-MH-example-config-citc.png)\n\nOnly two changes were made to the configs taken from the above guide:\n\n* Hostname definition was added to each config.\n* The initial `-` symbol was removed to convert data from list to YAML object/struct.\n\n## Prerequisites\n\n1. This guide assumes basic knowledge of CUE.\n\n  * For a quick introduction into the language, see the [official documentation](https://cuelang.org/docs/tutorials/). \n  * For additional tutorials and documentation see [cuetorials.com](https://cuetorials.com/).\n\n2. CUE tool must be installed:\n\n```\ngo install cuelang.org/go/cmd/cue@latest\n```\n\n3. [Dyff](https://github.com/homeport/dyff) needs to be installed (used to compare the generated and the original YAML files):\n\n```\ngo install github.com/homeport/dyff/cmd/dyff@latest\n```\n\n## Walkthrought\n\n### Environment Setup\n\nFrom the current repository, reinitialize CUE and Go modules\n\n```\nrm -rf cue.mod/\nrm -rf go.*\ncue mod init github.com/networkop/cue-networking    \ngo mod init github.com/networkop/cue-networking     \n```\n\nCreate a new directory layout to store all CUE files. This is a hierarchical layout to allow for configuration schema to be shared in top-level directories and device-specific configs to go into leaf directories. Read [this section](https://cuelang.org/docs/concepts/packages/#file-organization) for more explanation.\n\n```\nfor d in leaf1 leaf2 leaf3 leaf4 spine1 spine2; do \n  mkdir -p tmp/$d\ndone\ncd tmp\n```\n\nNow, as the first step, we'll import one of the device's configs and start creating a schema for it. The following command will ingest the original config in YAML format and produce the corresponding CUE structs with all values fully populated:\n\n```\ncue import ../originals/spine1.yml -o spine1/input.cue -p nvue -f -l '\"config\"' -l 'set.system.hostname'\n```\n\nThis is how the result of `head spine1/input.cue` would look like:\n\n```\npackage nvue\n\nconfig: spine1: set: {\n        system: hostname: \"spine1\"\n         interface: {\n                lo: {\n                        ip: address: \"10.10.10.101/32\": {}\n                        type: \"loopback\"\n                }\n                swp1: type: \"swp\"\n```\n\nWe can use the `cue eval spine1/input.cue` command to evaluate and use `--out` flag to produce the resulting YAML or JSON document. The following command will compare the YAML file generated from CUE with the original YAML file:\n\n```\ncue eval spine1/input.cue --out json | jq .config.spine1 | dyff between -b ../originals/spine1.yml -\n```\n\n### Building A Schema\n\nIf we compare configurations of both spine switches, we would see that only a few fields are different. Let's create a separate struct to store those variable parameters and put it at the top of the directory hierarchy. The following schema defines constraints on the values we expect to see and provides a concrete values for `PeerGroup` and `BridgeDomain` which hard-codes it for all device configs.\n\n```\ncat \u003c\u003cEOF \u003e top.cue\npackage nvue\n\nimport (\n  \"net\"\n)\n\n_Input: {\n  Hostname:   string\n  ASN:        \u003c=65535 \u0026 \u003e=64512\n  RouterID:   net.IPv4 \u0026 string\n  LoopbackIP: \"\\(RouterID)/32\"\n  EnableIntfs: [...string]\n  BGPIntfs: [...string]\n  VRFs: [...{name: string }]\n  PeerGroup:    \"underlay\"\n  BridgeDomain: \"br_default\"\n}\nEOF\n```\n\nCUE will not output values for fields that start with an underscore and we're using this to define schema structs that can be used to provide values but will not be visible in the final configuration. To help simplify data access in the future, we group all device configs in a `config` map with device names (strings) as its keys.\n\n```\ncat \u003c\u003cEOF \u003e\u003e top.cue\n\nconfig: [string]: set: {}\n\n_input: _Input\nEOF\n```\n\nNow let's populate the input values for `spine1` and add the device-specific config to the global `config` struct. As we've seen in the previous config snippet, `_input` is a subtype of `_Input` so all the constraints and type definitions we've defined before will apply automatically. \n\n\u003e Note that in this step we're also removing all of the previously imported CUE definitions for `spine1`.\n\n```\ncat \u003c\u003cEOF \u003e spine1/input.cue\n_input: {\n  Hostname: \"spine1\"\n  RouterID: \"10.10.10.101\"\n  EnableIntfs: [\"swp1\", \"swp2\", \"swp3\", \"swp4\"]\n  BGPIntfs: EnableIntfs\n  VRFs: [{name: \"default\"}]\n  ASN: 65199\n}\n\nconfig: \"\\(_input.Hostname)\": set: _nvue\nEOF\n\n```\n\nIt's time to start defining how the `_nvue` struct would look like. We'll put its definition in the top-level CUE file `top.cue`, so that all devices inherit the same schema. The easiest way to define the `_nvue` model is to mimic the original device configuration data model. Where necessary, we are taking concrete values from the `_input` struct and plugging them into the main model.\n\n```\ncat \u003c\u003cEOF \u003e\u003e top.cue\n_nvue: {\n\tsystem:    _system\n\tinterface: _interfaces\n\trouter: bgp: {\n\t\t_global_bgp\n\t}\n\tvrf: _vrf\n}\n\n_system: hostname: _input.Hostname\n\n_global_bgp: {\n\t\"autonomous-system\": _input.ASN\n\tenable:              \"on\"\n\t\"router-id\":         _input.RouterID\n}\n\n_interfaces: {\n\tlo: {\n\t\tip: address: \"\\(_input.LoopbackIP)\": {}\n\t\ttype: \"loopback\"\n\t}\n\tfor intf in _input.EnableIntfs {\"\\(intf)\": type: \"swp\"}\n}\n\n_vrf: {\n\tfor vrf in _input.VRFs {\n\t\t\"\\(vrf.name)\": {\n\t\t\trouter: bgp: _vrf_bgp\n\t\t\trouter: bgp: neighbor:     _neighbor\n\t\t\trouter: bgp: \"peer-group\": _peer_group\n\t\t}\n\t}\n}\n\n_vrf_bgp: {\n\t\"address-family\": {\n\t\t\"ipv4-unicast\": {\n\t\t\tenable: \"on\"\n\t\t\tredistribute: connected: enable: \"on\"\n\t\t}\n\t\t\"l2vpn-evpn\": enable: \"on\"\n\t}\n\tenable: \"on\"\n}\n\n\n_neighbor: {\n\tfor intf in _input.BGPIntfs {\n\t\t\"\\(intf)\": {\n\t\t\t\"peer-group\": \"\\(_input.PeerGroup)\"\n\t\t\ttype:         string | *\"unnumbered\"\n\t\t}\n\t}\n}\n\n_peer_group: \"\\(_input.PeerGroup)\": {\n\t\"address-family\": \"l2vpn-evpn\": enable: \"on\"\n\t\"remote-as\": string | *\"external\"\n}\n\nEOF\n```\n\nWith the above schema and concrete values defined for `spine1`, we can re-run the `dyff` command to confirm that we're still generating the same exact YAML document:\n\n```\ncue eval top.cue spine1/input.cue --out json | jq .config.spine1 | dyff between -b ../originals/spine1.yml -\n```\n\n\n### Adding Another Device\n\nRight now we are roughly at the same (or even higher) total number of lines as the original YAML. However, we already have the benefit of having our input values validated against a schema. From here on, adding another device with a similar schema becomes really easy. We just make a copy of `spine2`'s `input.cue` and adjust some of the input values:\n\n```\ncp spine1/input.cue spine2\nsed -i 's/spine1/spine2/' spine2/input.cue\nsed -i 's/101/102/' spine2/input.cue\n```\n\nRe-running the `dyff` command against spine2 should show no differences! We've just eliminated roughly 40 lines of config with just a few commands.\n\n```\ncue eval top.cue spine2/input.cue --out json | jq .config.spine2 | dyff between -b ../originals/spine2.yml -\n```\n\nHowever we can do even better. Notice how some of the input values of both spines are the same? We can move them into a shared cue file by combining both spines under the same directory:\n\n```\nmkdir spine    \nmv spine1 spine\nmv spine2 spine\ncat \u003c\u003cEOF \u003e spine/spine.cue\npackage nvue\n\n_input: {\n  EnableIntfs: [\"swp1\", \"swp2\", \"swp3\", \"swp4\"]\n  BGPIntfs: EnableIntfs\n  VRFs: [{name: \"default\"}]\n  ASN: 65199\n}\nEOF\nsed -i '/EnableIntfs/d' spine/spine1/input.cue\nsed -i '/EnableIntfs/d' spine/spine2/input.cue\nsed -i '/BGPIntfs/d' spine/spine1/input.cue\nsed -i '/BGPIntfs/d' spine/spine2/input.cue\nsed -i '/VRFs/d' spine/spine1/input.cue\nsed -i '/VRFs/d' spine/spine2/input.cue\nsed -i '/ASN/d' spine/spine1/input.cue\nsed -i '/ASN/d' spine/spine2/input.cue\n```\n\nSince CUE uses hierarchical directory layout to unify structs, the resulting values are not changed.\n\n### Scripting with CUE\n\nAt the stage we could've re-run the `dyff` command for both spine switching, adjusting it slightly to account for the new directory structure. However, CUE has a special feature that allows us to automate all tedious and repetitive commands. Let's define a new cue subcommand:\n\n```\ncat \u003c\u003cEOF \u003e nvue_tool.cue\npackage nvue\nimport (\n\t\"tool/exec\"\n\t\"tool/cli\"\n\t\"encoding/yaml\"\n)\n\nhost: *\"spine1\" | string @tag(host) \n\ncommand: diff: {\n\tdiff: exec.Run \u0026 {\n\t\tcmd: [\"dyff\", \"between\", \"-b\", \"../originals/\\(host).yml\", \"-\"]\n\t\tstdin:  yaml.Marshal(config[host])\n\t\tstdout: string\n\t}\n\n\tdisplay: cli.Print \u0026 {\n\t\ttext: diff.stdout\n\t}\n}\nEOF\n```\n\nNow we can use a much simpler CLI command to view `dyff` for a particular device:\n\n```\ncue -t host=spine1 diff ./...\n\ncue -t host=spine2 diff ./...\n\n```\n\nCUE scripting allows more than just shell command automation. Using scripting we can print data on a screen or save it in files. Let's copy the pre-created tool file and demonstrate a couple of extra commands:\n\n```\ncue ls ./...\n- Identified Devices -\nspine1\nspine2\n\ncue save ./...\nsaving spine1 in ../new/spine1.yml\nsaving spine2 in ../new/spine2.yml\n```\n\n### Adding New Device Types\n\nSo far we've dealt with two device configs that were relatively similar. Now let's add another device type that will have a much greater variability. The process of splitting the values and schema is very similar, so we'll simply copy the pre-created files, without much explanation. You can check the contents of [`leaf.cue`](./cues/leaf/leaf.cue) file to see how the top-level schema has been expanded.\n\n```\nrm -rf leaf*        \ncp -a ../cues/leaf .\n```\n\nAnother comparison between the original and generated YAML documents should indicate some discrepancies between the top-level schema in `top.cue` and what leaf switches are expecting to see.\n\n```\ncue cmp ./...\ndiff for spine2:\n\n\ndiff for spine1:\n\n\ndiff for leaf2:\n\nset.vrf.BLUE.router.bgp\n  + two map entries added:\n    neighbor:\n      swp51:\n        type: unnumbered\n        peer-group: underlay\n      swp52:\n        type: unnumbered\n        peer-group: underlay\n    peer-group:\n      underlay:\n        address-family:\n          l2vpn-evpn:\n            enable: on\n        remote-as: external\n\nset.vrf.BLUE.router.bgp.address-family\n  + one map entry added:\n    l2vpn-evpn:\n      enable: on\n\nset.vrf.RED.router.bgp\n  + two map entries added:\n    neighbor:\n      swp51:\n        type: unnumbered\n        peer-group: underlay\n      swp52:\n        type: unnumbered\n        peer-group: underlay\n    peer-group:\n      underlay:\n        address-family:\n          l2vpn-evpn:\n            enable: on\n        remote-as: external\n...\n```\n\nA closer examination of the device config should tell use that some fields are only defined for the `default` VRF and not present for others. This can be quite easily recitified with a conditional statement.\n\n```diff\n~/cue-nvue/tmp main !7 ❯ diff top.cue ../cues/top.cue\n52,53c52,55\n\u003c                       router: bgp: neighbor:     _neighbor\n\u003c                       router: bgp: \"peer-group\": _peer_group\n---\n\u003e                       if vrf.name == \"default\" {\n\u003e                               router: bgp: neighbor:     _neighbor\n\u003e                               router: bgp: \"peer-group\": _peer_group\n\u003e                       }\n```\n\nInstead of showing individual changes here, let's just copy the final version of `top.cue` over the existing one:\n\n```\ncp ../cues/top.cue .            \ncp ../cues/spine/spine.cue spine\n```\n\nRe-running the `dyff` should confirm that all files now have the right structure and values.\n\n```\ncue cmp ./...\ndiff for spine1:\n\n\ndiff for spine2:\n\n\ndiff for leaf1:\n\n\ndiff for leaf3:\n\n\ndiff for leaf2:\n\n\ndiff for leaf4:\n```\n\nAt this point we can save the generated YAML files in a new directory and use them to configure individual devices.\n\n```\ncue save ./...\nsaving leaf2 in ../new/leaf2.yml\nsaving leaf1 in ../new/leaf1.yml\nsaving leaf4 in ../new/leaf4.yml\nsaving spine1 in ../new/spine1.yml\nsaving spine2 in ../new/spine2.yml\nsaving leaf3 in ../new/leaf3.yml\n```\n\n\n### Boilerplate Reduction\n\nFinally, let's see by how much we have reduced the total number of configuration lines. In the original format we had close to 1000 lines of configuration:\n\n```\ncat ../originals/* | wc -l\n974\n```\n\nWith CUE, we've managed to reduce that number to 416, which could've been even less if we discounted all of the empty lines.\n\n```\nmv nvue_tool.cue nvue_tool\nfind ./ -type f -name \"*.cue\" -exec cat {} + | wc -l\n416\nmv nvue_tool nvue_tool.cue\n```\n\nNeedless to say that the relative reduction becomes higher as we get more devices of similar kind, e.g. more than 4 leaf switches.\n\n## Conclusions\n\nHere's a brief summary of what we've managed to achieve by modelling our network configuration in CUE:\n\n* Create a statically-typed schema for network device configs.\n* Optimised device configs and reduced the total number of config lines.\n* Split concrete values from a common data model.\n* Added validation constraints for input values to make sure things like BGP ASNs and IP addresses have correct format.\n\n\u003e This document is inspired by the official [Kubernetes tutorial](https://github.com/cue-lang/cue/blob/v0.4.0/doc/tutorial/kubernetes/README.md).","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnetworkop%2Fcue-networking","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnetworkop%2Fcue-networking","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnetworkop%2Fcue-networking/lists"}