├── .gitignore ├── pkg ├── device │ ├── l2proto │ │ ├── lldp.go │ │ └── arista_eos_lldp.go │ └── device.go └── inventory │ ├── yaml.go │ ├── group.go │ ├── filter.go │ └── inventory.go ├── go.mod ├── example.yml ├── cmd └── cli │ └── main.go ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /pkg/device/l2proto/lldp.go: -------------------------------------------------------------------------------- 1 | package l2proto 2 | 3 | var ( 4 | LldpShowCmd = map[string]string{ 5 | "arista_eos": "show lldp neighbor", 6 | } 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/device/l2proto/arista_eos_lldp.go: -------------------------------------------------------------------------------- 1 | package l2proto 2 | 3 | const EOS_LLDP_TEMPLATE string = ` 4 | Value NEIGHBOR (\S+) 5 | Value LOCAL_INTERFACE (\S+) 6 | Value NEIGHBOR_INTERFACE (\S+) 7 | 8 | Start 9 | ^Port.*TTL -> LLDP 10 | 11 | LLDP 12 | ^${LOCAL_INTERFACE}\s+${NEIGHBOR}\s+${NEIGHBOR_INTERFACE}\s+.* -> Record 13 | ` 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module pneuma 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/scrapli/scrapligo v0.1.2 7 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b 8 | ) 9 | 10 | require ( 11 | github.com/creack/pty v1.1.11 // indirect 12 | github.com/sirikothe/gotextfsm v1.0.0 // indirect 13 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect 14 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /pkg/inventory/yaml.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "pneuma/pkg/device" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type YamlInventory struct { 13 | Filename string 14 | } 15 | 16 | func (i *YamlInventory) NewInventory() (inv *Inventory, err error) { 17 | inv = &Inventory{ 18 | Devices: make(map[string]*device.Device), 19 | Groups: make(map[string]*Group), 20 | } 21 | b, err := ioutil.ReadFile(i.Filename) 22 | if err != nil { 23 | return inv, errors.New("unable to read file") 24 | } 25 | err = yaml.Unmarshal(b, inv) 26 | if err != nil { 27 | return inv, errors.New("unable to unmarshal YAML") 28 | } 29 | fmt.Println("merging") 30 | _ = inv.MergeGroupsAndDevices() 31 | _ = inv.SetPort() 32 | _ = inv.SetTimeout() 33 | _ = inv.CreateConnections() 34 | return 35 | } 36 | -------------------------------------------------------------------------------- /example.yml: -------------------------------------------------------------------------------- 1 | --- 2 | devices: 3 | edgeRouter1: 4 | name: "edge1-slc1" 5 | metadata: 6 | labels: 7 | zone: 2 8 | platform: arista_eos 9 | connection: 10 | hostname: 172.20.20.201 11 | username: admin 12 | password: admin 13 | groups: 14 | - slc-dc 15 | - edge-routers 16 | spec: 17 | routerId: 192.168.254.10 18 | groups: 19 | slc-dc: 20 | name: slc-dc 21 | metadata: 22 | labels: 23 | dc: slc1 24 | connection: 25 | timeout: 5s 26 | spec: 27 | ntpServers: 28 | - 10.10.10.5 29 | - 10.10.20.5 30 | edge-routers: 31 | name: edge-routers 32 | metadata: 33 | labels: 34 | role: edge 35 | connection: 36 | port: 22 37 | spec: 38 | bgp: 39 | globalPrefixLimit: 950000 -------------------------------------------------------------------------------- /cmd/cli/main.go: -------------------------------------------------------------------------------- 1 | // This is only an example and isn't an actual tool that you should use. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "pneuma/pkg/device" 7 | "pneuma/pkg/inventory" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func main() { 13 | i := inventory.YamlInventory{Filename: "example.yml"} 14 | inv, err := i.NewInventory() 15 | 16 | // Send some commands 17 | inv.Devices["edgeRouter1"].Connection.Open() 18 | defer inv.Devices["edgeRouter1"].Connection.Close() 19 | r, _ := inv.Devices["edgeRouter1"].GetLldpNeighbors() 20 | fmt.Println(r) 21 | fmt.Println("---") 22 | r, _ = inv.Devices["edgeRouter1"].Connection.SendCommand("show version") 23 | fmt.Println(r) 24 | fmt.Println("---") 25 | 26 | // Filter the inventory 27 | newInv, _ := inv.FilterMetadata( 28 | &device.Metadata{ 29 | Labels: map[string]string{ 30 | "dc": "slc1", 31 | }, 32 | Platform: "arista_eos", 33 | }) 34 | fmt.Println(newInv.Devices) 35 | fmt.Println("---") 36 | y, _ := yaml.Marshal(newInv.Devices["edgeRouter1"].Spec) 37 | fmt.Println(string(y)) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/inventory/group.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import "time" 4 | 5 | // Metadata has information for filtering devices 6 | type Metadata struct { 7 | Labels map[string]string `yaml:"labels"` 8 | Platform string `yaml:"platform"` // Platform of the Device 9 | Groups []string `yaml:"groups"` 10 | Connection *struct { 11 | Port int `yaml:"port"` // Port to connect to 12 | Username string `yaml:"username"` // Username to use for authentication purposes 13 | Password string `yaml:"password"` // Password to use for authentication purposes 14 | Timeout time.Duration `yaml:"timeout"` // Timeout to use for initial connection and all send commands 15 | } `yaml:"connection"` 16 | } 17 | 18 | // Group represents a network element/device 19 | type Group struct { 20 | Name string `yaml:"name"` // Name is the name of the Device 21 | Spec map[string]interface{} `yaml:"spec"` // Spec defines a Device's attributes 22 | Metadata *Metadata `yaml:"metadata"` // Metadata for connections and built-in filtering 23 | } 24 | -------------------------------------------------------------------------------- /pkg/device/device.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "fmt" 5 | "github.com/scrapli/scrapligo/driver/base" 6 | "github.com/scrapli/scrapligo/driver/network" 7 | "pneuma/pkg/device/l2proto" 8 | "time" 9 | ) 10 | 11 | type Connection struct { 12 | *network.Driver 13 | } 14 | 15 | // GetLldpNeighbors gets LLDP neighbors based on Connection.Driver 16 | func (d *Device) GetLldpNeighbors() (r *base.Reponse, err error) { 17 | r, err := d.SendCommand(l2proto.LldpShowCmd[d.Metadata.Platform]) 18 | return 19 | } 20 | 21 | func (d *Device) SendCommand(s string, o ...base.SendOption) (r *base.Response, err error) { 22 | r, err = d.Connection.SendCommand(s, o...) 23 | fmt.Println(err) 24 | return 25 | } 26 | 27 | // Metadata has information for filtering devices 28 | type Metadata struct { 29 | Labels map[string]string `yaml:"labels"` 30 | Platform string `yaml:"platform"` // Platform of the Device 31 | Groups []string `yaml:"groups"` 32 | Connection *struct { 33 | Port int `yaml:"port"` // Port to connect to 34 | Hostname string `yaml:"hostname"` // Hostname/FQDN/IP to connect to 35 | Username string `yaml:"username"` // Username to use for authentication purposes 36 | Password string `yaml:"password"` // Password to use for authentication purposes 37 | Timeout time.Duration `yaml:"timeout"` // Timeout to use for initial connection and all send commands 38 | } `yaml:"connection"` 39 | } 40 | 41 | // Device represents a network element/device 42 | type Device struct { 43 | Name string `yaml:"name"` // Name is the name of the Device 44 | Spec map[string]interface{} `yaml:"spec"` // Spec defines a Device's attributes 45 | Metadata *Metadata `yaml:"metadata"` // Metadata for connections and built-in filtering 46 | Connection *Connection // Connection is the scrapligo SSH connection 47 | } 48 | -------------------------------------------------------------------------------- /pkg/inventory/filter.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "errors" 5 | "pneuma/pkg/device" 6 | "regexp" 7 | ) 8 | 9 | // CustomFilterFunc is a function that can be used to filter the inventory 10 | type CustomFilterFunc func(*device.Device) (bool, error) 11 | 12 | // Filter filters the hosts in the Inventory, returning a copy of the current 13 | // Inventory instance but with only the hosts that passed the filter 14 | func (i *Inventory) Filter(f CustomFilterFunc) (*Inventory, error) { 15 | filtered := &Inventory{ 16 | Devices: make(map[string]*device.Device), 17 | } 18 | for deviceId, device := range i.Devices { 19 | match, err := f(device) 20 | if err != nil { 21 | return filtered, errors.New("custom filter function returned error") 22 | } 23 | if match { 24 | filtered.Devices[deviceId] = device 25 | } 26 | } 27 | return filtered, nil 28 | } 29 | 30 | // FilterNameRegex filters an Inventory's device.Device.Name by the regex s 31 | func (i *Inventory) FilterNameRegex(s string) (*Inventory, error) { 32 | regex := regexp.MustCompile(s) 33 | return i.Filter(func(d *device.Device) (bool, error) { 34 | if regex.MatchString(d.Name) { 35 | return true, nil 36 | } 37 | return false, nil 38 | }) 39 | } 40 | 41 | // FilterMetadata filters an inventory based on Inventory.Devices[].Metadata 42 | // Currently, only supports device.Metadata.Labels and device.Metadata.Platform 43 | func (i *Inventory) FilterMetadata(m *device.Metadata) (*Inventory, error) { 44 | if m.Connection != nil { 45 | return nil, errors.New("`m.Connection`: unsupported") 46 | } 47 | 48 | return i.Filter(func(d *device.Device) (bool, error) { 49 | if (m.Platform != "" && m.Platform != d.Metadata.Platform) || !matchesLabels(d, m.Labels) { 50 | return false, nil 51 | } 52 | return true, nil 53 | }) 54 | } 55 | 56 | // matchesLabels checks for a subset of labels in Device.Metadata.Labels 57 | func matchesLabels(d *device.Device, labels map[string]string) bool { 58 | for k, v := range labels { 59 | labelValue, hasLabel := d.Metadata.Labels[k] 60 | if !hasLabel || labelValue != v { 61 | return false 62 | } 63 | } 64 | return true 65 | } 66 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/carlmontanari/difflibgo v0.0.0-20210718170140-424f52054f94/go.mod h1:+3MuSIeC3qmdSesR12cTLeb47R/Vvo+bHdB6hC5HShk= 2 | github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= 3 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 5 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 6 | github.com/scrapli/scrapligo v0.1.2 h1:LCGJH8EZChPFr2AxrqFjAjCY9ImeLTBWey6axkzEyUs= 7 | github.com/scrapli/scrapligo v0.1.2/go.mod h1:0tHMgiCiTuWOvSceFU7klaYThXvRZNvc7k+fmQrtH54= 8 | github.com/sirikothe/gotextfsm v1.0.0 h1:4kKwbUziG9G+31PfLY+vI3FzYK/kcByh4ndT3NyPMkc= 9 | github.com/sirikothe/gotextfsm v1.0.0/go.mod h1:CJYqpTg9u5VPCoD0VEl9E68prCIiWQD8m457k098DdQ= 10 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= 11 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 12 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 13 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= 15 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 16 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= 17 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 18 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 19 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 20 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 24 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /pkg/inventory/inventory.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "time" 5 | "pneuma/pkg/device" 6 | 7 | "github.com/scrapli/scrapligo/driver/base" 8 | "github.com/scrapli/scrapligo/driver/core" 9 | ) 10 | 11 | // Inventory represents a collection of Device 12 | type Inventory struct { 13 | Devices map[string]*device.Device // Devices represents a collection of device.Device 14 | Groups map[string]*Group // Groups represent a generic collection of Group 15 | } 16 | 17 | // InventoryPlugin is an interface for Inventory plugins 18 | // Currently, nothing implements this interface, but it exists as a soft suggestion on how to write other Inventory structs. 19 | type InventoryPlugin interface { 20 | NewInventory() (*Inventory, error) 21 | } 22 | 23 | // MergeGroupsAndDevices updates all device.Device according to supported Group params 24 | func (i *Inventory) MergeGroupsAndDevices() (err error) { 25 | for deviceID, device := range i.Devices { 26 | for _, g := range device.Metadata.Groups { 27 | group := i.Groups[g] 28 | if device.Metadata.Connection.Port == 0 && group.Metadata.Connection.Port != 0 { 29 | device.Metadata.Connection.Port = group.Metadata.Connection.Port 30 | } 31 | if device.Metadata.Connection.Timeout == 0 && group.Metadata.Connection.Timeout != 0 { 32 | device.Metadata.Connection.Timeout = group.Metadata.Connection.Timeout 33 | } 34 | if device.Metadata.Connection.Username == "" && group.Metadata.Connection.Username != "" { 35 | device.Metadata.Connection.Username = group.Metadata.Connection.Username 36 | } 37 | if device.Metadata.Connection.Password == "" && group.Metadata.Connection.Password != "" { 38 | device.Metadata.Connection.Password = group.Metadata.Connection.Password 39 | } 40 | device.Spec = mergeMaps(group.Spec, device.Spec) 41 | device.Metadata.Labels = mergeLabels(group.Metadata.Labels, device.Metadata.Labels) 42 | i.Devices[deviceID] = device 43 | } 44 | } 45 | return 46 | } 47 | 48 | // SetPort sets Inventory.Devices[].Metadata.Connection.Port to 22 if it isn't set 49 | func (i *Inventory) SetPort() (err error) { 50 | for _, d := range i.Devices { 51 | if d.Metadata.Connection.Port == 0 { 52 | d.Metadata.Connection.Port = 22 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | // SetTimeout sets a default timeout of 5 seconds in Inventory.Devices[].Metadata.Connection.Timeout if it isn't set 59 | func (i *Inventory) SetTimeout() (err error) { 60 | for _, d := range i.Devices { 61 | if d.Metadata.Connection.Timeout == 0 { 62 | d.Metadata.Connection.Timeout = time.Second * 10 63 | } 64 | } 65 | return nil 66 | } 67 | 68 | // CreateConnections sets up the Scrapligo connections on Device.Connection based on Inventory.Devices[].Metadata.Connection 69 | func (i *Inventory) CreateConnections() (err error) { 70 | for _, d := range i.Devices { 71 | d.Connection = &device.Connection{} 72 | d.Connection.Driver, _ = core.NewCoreDriver( 73 | d.Metadata.Connection.Hostname, 74 | d.Metadata.Platform, 75 | base.WithAuthUsername(d.Metadata.Connection.Username), 76 | base.WithAuthPassword(d.Metadata.Connection.Password), 77 | base.WithPort(d.Metadata.Connection.Port), 78 | base.WithTimeoutSocket(d.Metadata.Connection.Timeout), 79 | ) 80 | } 81 | return nil 82 | } 83 | 84 | // mergeMaps recursively merges two map[string]interface{} objects 85 | // Its primary use is to inherit all of Group.Spec into a device.Device.Spec based on device.Device.Groups membership 86 | func mergeMaps(original, updated map[string]interface{}) map[string]interface{} { 87 | merged := make(map[string]interface{}, len(original)) 88 | for k, v := range original { 89 | merged[k] = v 90 | } 91 | for k, v := range updated { 92 | if v, ok := v.(map[string]interface{}); ok { 93 | if updatedValue, ok := merged[k]; ok { 94 | if updatedValue, ok := updatedValue.(map[string]interface{}); ok { 95 | merged[k] = mergeMaps(updatedValue, v) 96 | continue 97 | } 98 | } 99 | } 100 | merged[k] = v 101 | } 102 | return merged 103 | } 104 | 105 | func mergeLabels(groupLabels, deviceLabels map[string]string) map[string]string { 106 | mergedLabels := make(map[string]string) 107 | for k, v := range groupLabels { 108 | mergedLabels[k] = v 109 | } 110 | for k, v := range deviceLabels { 111 | mergedLabels[k] = v 112 | } 113 | return mergedLabels 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [`pneuma` is a breath of fresh air][pneuma]. 2 | 3 | `pneuma` is an experiment in learning Go with a focus on networking. It 4 | is heavily inspired by projects like [`nornir`][nornir] 5 | and [`gornir`][gornir], but with a heavier focus on screen-scraping. 6 | 7 | `pneuma` leverages [`scrapligo`][scrapligo] for interacting with network 8 | devices. It provides two things that `scrapligo` doesn't contain out of 9 | the box: an inventory feature and a series of standardized structs for 10 | working with data present on a network device. 11 | 12 | ## Project Status 13 | 14 | `pneuma` is an experiment. It shouldn't even be considered Alpha. It 15 | lacks tests, has no commitments to stability or backwards-compatibility, 16 | and should generally be considered unreliable. 17 | 18 | `pneuma` is not used on any production network. It is not developed or 19 | tested against a production network. It isn't even developed against a 20 | model of a production network. 21 | 22 | ## Why? 23 | 24 | I created `pneuma` to help me learn the basics of Go. Its scope is 25 | fairly small, but I'm also very familiar with the subject, which means 26 | I'm not learning about things that I don't care about yet (like 27 | databases, APIs, etc.). 28 | 29 | Screen-scraping isn't sexy. Everyone wants to use APIs to do 30 | everything. While it's a lofty goal, my personal experience has been 31 | that these APIs are unstable, unreliable, and change in weird ways from 32 | one version to the next. A network device's CLI tends to be a little 33 | more stable, changes less often, and isn't a weird Frankenstein mash-up 34 | of OpenConfig + vendor-native models. Some vendors do a much better 35 | job of supporting OpenConfig. Some do a better job at documenting their 36 | models. But for those vendors that do a bad job of this, I would much 37 | rather spend my time translating my expertise in the CLI into a library 38 | than I would trying to dig through opaque models to find something that 39 | would hopefully be what I'm looking for. 40 | 41 | ## pneuma's Inventory 42 | 43 | `pneuma`'s inventory is simple and influenced by the Kubernetes API 44 | object structure. There are devices and groups. While the inventory is 45 | designed in a way that _hopefully_ supports pluggable inventory sources, 46 | that is currently untested. So, we'll look at everything through the 47 | lens of the default YAML-based source. 48 | 49 | There are `groups` and `devices`. `groups` are completely optional: 50 | you do not need them at all. They exist primarily to collect common 51 | parts of `devices`. A Device can be a member of one or more `groups`. 52 | When loading a YAML inventory source, each Device is iterated upon. 53 | When a Device lists Group memberships, those `groups` are looked up, 54 | and their data is recursively merged into the Device's data. This is 55 | done in order, so if you have multiple `groups` with conflicting data, 56 | the last item in the group takes precedence. If a particular piece of 57 | data is present in both the Device and the Group, the Device's data 58 | takes precendence. 59 | 60 | `devices` and `groups` each have the following structure: 61 | 62 | ```yaml 63 | : 64 | id: 65 | name: str 66 | metadata: 67 | labels: 68 | k: str 69 | platform: str 70 | connection: 71 | hostname: str 72 | username: str 73 | password: str 74 | port: int 75 | timeout: str 76 | # if `` is `devices`, there's also a `groups[]`: 77 | groups: [str] 78 | spec: {} 79 | ``` 80 | 81 | Additionally, `devices` have a `.metadata.groups[]` that lists its group 82 | membership. 83 | 84 | The YAML below shows a basic example. There are two groups: one to 85 | contain settings specific to a datacenter (`slc-dc`), and one to contain 86 | settings that apply to all edge routers. We specify these as group 87 | memberships under `.devices.edgeRouter1.metadata.groups[]`. 88 | 89 | ```yaml 90 | --- 91 | devices: 92 | edgeRouter1: 93 | name: "edge1-slc1" 94 | metadata: 95 | labels: 96 | zone: 2 97 | platform: arista_eos 98 | connection: 99 | hostname: 172.20.20.201 100 | username: admin 101 | password: admin 102 | groups: 103 | - slc-dc 104 | - edge-routers 105 | spec: 106 | routerId: 192.168.254.10 107 | groups: 108 | slc-dc: 109 | name: slc-dc 110 | metadata: 111 | labels: 112 | dc: slc1 113 | connection: 114 | timeout: 5s 115 | spec: 116 | ntpServers: 117 | - 10.10.10.5 118 | - 10.10.20.5 119 | edge-routers: 120 | name: edge-routers 121 | metadata: 122 | labels: 123 | role: edge 124 | connection: 125 | port: 22 126 | spec: 127 | bgp: 128 | globalPrefixLimit: 950000 129 | ``` 130 | 131 | After loading, you will effectively have the following `devices`: 132 | 133 | ```yaml 134 | devices: 135 | edgeRouter1: 136 | metadata: 137 | labels: 138 | dc: slc1 139 | role: edge 140 | zone: "2" 141 | platform: arista_eos 142 | groups: 143 | - slc-dc 144 | - edge-routers 145 | connection: 146 | port: 22 147 | hostname: 172.20.20.201 148 | username: admin 149 | password: admin 150 | timeout: 5s 151 | groups: 152 | - slc-dc 153 | - edge-routers 154 | spec: 155 | bgp: 156 | globalPrefixLimit: 950000 157 | ntpServers: 158 | - 10.10.10.5 159 | - 10.10.20.5 160 | routerId: 192.168.254.10 161 | ``` 162 | 163 | Taking inspiration from [nornir][nornir] and [gornir][gornir], you can 164 | also filter your inventory: 165 | 166 | ```go 167 | package main 168 | 169 | import ( 170 | "fmt" 171 | "pneuma/pkg/device" 172 | "pneuma/pkg/inventory" 173 | 174 | "gopkg.in/yaml.v3" 175 | ) 176 | 177 | func main() { 178 | inv := inventory.YamlInventory{Filename: "example.yml"} 179 | i, err := inv.NewInventory() 180 | newInv, _ := i.FilterMetadata( 181 | &device.Metadata{ 182 | Labels: map[string]string{ 183 | "dc": "slc1", 184 | }, 185 | Platform: "arista_eos", 186 | }) 187 | y, _ := yaml.Marshal(newInv.Devices["edgeRouter1"].Spec) 188 | fmt.Println(string(y)) 189 | } 190 | ``` 191 | 192 | Besides the built-in filters (`FilterMetadata`, `FilterNameRegex`), you 193 | can also pass your own `CustomFilterFunc` to 194 | `*inventory.Inventory.Filter`. 195 | 196 | ## Sending Commands 197 | 198 | Each `Device` should have a `.metadata.platform` and a 199 | `.metadata.connection` to define how to interact with a device. This 200 | can be provided per device or in a group. The platform should be in the 201 | same format as [`scrapligo` expects][scrapligo-format]. 202 | 203 | Once done, you can use the `Device.SendCommands` method. For example: 204 | 205 | ```go 206 | package main 207 | 208 | import ( 209 | "fmt" 210 | "pneuma/pkg/device" 211 | "pneuma/pkg/inventory" 212 | 213 | "gopkg.in/yaml.v3" 214 | ) 215 | 216 | func main() { 217 | i := inventory.YamlInventory{Filename: "example.yml"} 218 | inv, err := i.NewInventory() 219 | 220 | // Send some commands 221 | inv.Devices["edgeRouter1"].Connection.Open() 222 | defer inv.Devices["edgeRouter1"].Connection.Close() 223 | r, _ = inv.Devices["edgeRouter1"].Connection.SendCommand("show version") 224 | fmt.Println(r) 225 | } 226 | ``` 227 | 228 | ## The Future 229 | 230 | In the future, I want to bake in some common parsing, similar to what 231 | something like [NAPALM][napalm], probably into an OpenConfig-esque data 232 | structure. I don't particularly love YANG or how (seemingly 233 | unnecessarily) nested some of those models are, so there will be some 234 | deviation. Of course, that assumes I continue working on this. 235 | 236 | [pneuma]: https://en.wikipedia.org/wiki/Pneuma 237 | [nornir]: https://nornir.readthedocs.io/en/latest/ 238 | [gornir]: https://pkg.go.dev/github.com/nornir-automation/gornir 239 | [scrapligo]: https://github.com/scrapli/scrapligo 240 | [scrapligo-format]: https://github.com/scrapli/scrapligo/blob/main/driver/core/factory.go#L14-L25 241 | [napalm]: https://napalm.readthedocs.io/en/latest/ 242 | --------------------------------------------------------------------------------