Cisco Nexus上のPythonでネットワーク構成を自動化してみた

# はじめに
この記事はシスコの同志による Advent Calendar の一部として投稿しています。
他の記事は、[Cisco Systems Japan Advent Calendar 2017](https://qiita.com/advent-calendar/2017/cisco)からご覧いただけます。

ちょうど折り返しの13日目は、Cisco Nexusシリーズを紹介いたします。
Cisco Nexusシリーズは、データセンター向けのスイッチです。
プログラマ向けのインターフェイスもたくさん持っており、REST-APIやPython、BashなどをNexus上で動かすことが可能です。
詳細は、Cisco Systems Japan Advent Calendar 2017の下記記事をご覧ください。
[いまどきのネットワークOSのお話](https://qiita.com/kkureish/items/2958b3385b91c71b7050)

Nexus 9000は、VXLANネットワークで利用されることも多くなってきております。
VXLANは、L2のフレームをL3のIPパケットでカプセル化することで、自由にL2を延伸できる技術です。
その中で、下記図のようなLeaf/Spine型のトポロジーがスケーラビリティなどの観点から採用されております。

<img width="750" alt="Leaf/SpineTopology" src="https://qiita-image-store.s3.amazonaws.com/0/135003/6f4b350e-8e8e-f0c4-319a-7e2a71683b92.png">

Leaf/Spine間は、IPネットワークで形成されているため、従来L2ネットワークで抱えていた、STP (Spanning Tree Protocol)を利用したActive / Standby 冗長ではなく、全てのlinkを利用可能です。

一般的にMP-BGP VXLAN/EVPNネットワークを構築するには、下記設定が必要となります。

- Leaf/Spine間のアンダーレイネットワーク
- 情報を交換するためのMP-BGPのセッションを確立 (iBGPの場合、Route Reflectorが利用可能)
    - 一般的にSpineの一部がRoute Reflectorとして利用されます。

数台であれば、手動で設定することも可能ですが、規模が大きくなれば、手動で設定するのにも限界が出てきます。
そこで今回は、VXLAN EVPNに必要な最低限の設定を自動化します。

# 自動化の実装について
## 今回の目的
Nexus 9000にケーブルを接続するだけで必要な下記設定が投入されるようにする。

1. InterfaceにIPアドレスを設定
2. InterfaceでOSPFを有効にし、OSPFネイバーの情報を取得する
3. BGPの設定をし、Route Reflectorとのセッションを確立する

また、ケーブルが抜かれた時には、不要になった設定を消すのも自動で行います。

## 利用した機器 / トポロジー
今回は、下記機器を利用し、ネットワークを構築しました。

- Cisco Nexus 92160YC-X 3台
    - version 7.0(3)I7(1)

また、物理トポロジーは下記のようになっております。
![screenshot 2017-12-11 15.56.57.png](https://qiita-image-store.s3.amazonaws.com/0/135003/d137ddb6-ba47-e97a-9cfd-61174396aa30.png)

2台のSpineがRoute Reflectorの機能を担っており、そこへのLeafからの疎通は、OSPFを利用しております。
また、Leaf1とSpine1の間は、通常の/30のサブネットのP2PリンクでOSPFのネイバーを確立させますが、
Leaf1とSpine2の間は、IP Unnumberedという機能を利用し、Loopbackインターフェイス間でOSPFのネイバーを確立させます。

## 利用する機能

- EEM (Embedded Event Manager)
    - スイッチ上で発生したイベントをきっかけに対処ロジックを実行する機能
- On-Box Python
    - Nexus上で動作させるPython

# デモ動画
[こちら](https://github.com/dokan/N9k-auto-bgp/blob/master/demo-movies/EEM_Python_Linkup.mp4)からダウンロードできます。

# プログラムの解説
## Pythonのソースコード概要
ソースコードは、主に5つのファイルで構成されています。

- auto-bgp.py
    - Link upした時に呼び出されるファイル
    - Spineと/30のセグメントでOSPFのセッションを張る
- auto-bgp-unnumbered.py
    - Link upした時に呼び出されるファイル
    - SpineとIP　Unnumberedを利用し、OSPFのセッションを張る
- clear-auto-bgp.py
    - LinkDownした時に呼び出されるファイル
- function.py
    - 呼び出される関数群を詰め込んだファイル
- params.py
    - 利用するパラメータのみを入れたファイル
        - 例) uplinkポート等

## 主な動作
このプログラムの中では、下記のような動作をしております。

### auto-bgp.py / auto-bgp-unnumbered.py

1. Leaf SwitchのIDを取得
1. LinkUpしたInterface IDを取得
1. InterfaceIDからどちらのSpineに接続されているか、判断
1. Spine IDとLeaf IDからP2P LinkのIPアドレスを計算
    - 第4オクテットを下記のように計算
        - (LeafのID - 1) x (4 x Spineの全台数) + (SpineのID - 1) x 4 + 1
    - この計算をすると、/30のサブネットを、Leaf1から順々に利用することが可能
1. Interfaceに対して設定を流し込む
    - IPアドレスとかMTUの設定をする
    - IP Unnumberedの場合、こちらでその設定を行う
1. OSPFの設定をする
    - Interfaceに対して、Area 0 の設定をする
1. OSPFのネイバーが確立するまで待つ
1. OSPFのネイバーのRouter-IDに対して、BGP Neighborの設定をする
    - その際に、OSPFのネイバーにあるが、BGPのネイバーにないものを全て設定する

### clear-bgp.py

1. LinkDownしたInterfaceの設定を default コマンドを利用し初期化する。
2. BGPのネイバー設定を消す
    - その際に、BGPのネイバーにあるが、OSPFのネイバーにないものを全て削除する

### EEM
これらのプログラムを呼ぶために、EEMを利用しています。
Nexus上のオブジェクト トラック機能を利用して、Uplinkポート　 (Ethernet 1/49 と Ethernet1/50)を監視しています。
これらの line protocol (L2レベル)でUP/Downを取得し、それぞれに合わせ、プログラムを実行しております。

# まとめ

- Nexus上のPythonで設定の自動化を実装
- Pythonを利用すると、Nexus単体ではできないロジックを実現可能
- Nexus上のイベントをトリガーにプログラムを実行することが可能
- IP Unnumbered機能を利用すると、P2P Linkにアドレスを割り当てなくても、OSPF / BGPのネイバーを設定可能

# 参考
## ソースコード
これらは、全てGitHub上でも公開しております。
https://github.com/dokan/N9k-auto-bgp

また今回は、Cisco DevNet上の[NX-OS Python API](https://developer.cisco.com/site/nx-os/docs/#cisco-nexus-9000-series-python-sdk-user-guide-and-api-reference)を参考に開発をいたしました。

### auto-bpg.py
```python:auto-bgp/auto-bgp.py
from function import *

if __name__ == '__main__':
    leaf = getLeafID()
    if leaf is None:
        print('This is not leaf switch')
        exit()

    intf = findIf()
    if intf is None:
        print('Not Interface Related log')
        exit()
        
    spine = getSpineID(intf)
    
    ip_address = calcIP(leaf, spine)
    setInterface(intf, ip_address)
    setOSPFInterface(intf)
    
    if waitOSPFAllFull(intf):
        bgpNeighbors = getBGPNeighbors()
        ospfNeighbors = getOSPFNeighbors()

        for neighbor_addr in list_diff(ospfNeighbors, bgpNeighbors):
            setBGPNeighbor(neighbor_addr)
            
        print("Done BGP Configuration")
    else:
        print("OSPF didn't converge")
```

### auto-bgp-unnumbered.py
```python:auto-bgp/auto-bgp-unnumbered.py
from function import *

if __name__ == '__main__':
    leaf = getLeafID()
    if leaf is None:
        print('This is not leaf switch')
        exit()

    intf = findIf()
    if intf is None:
        print('Not Interface Related log')
        exit()
        
    setInterface(intf)
    setOSPFInterface(intf)
    
    if waitOSPFAllFull(intf):
        bgpNeighbors = getBGPNeighbors()
        ospfNeighbors = getOSPFNeighbors()

        for neighbor_addr in list_diff(ospfNeighbors, bgpNeighbors):
            setBGPNeighbor(neighbor_addr)
        print("Done BGP Configuration")
    else:
        print("OSPF didn't converge")
```

### clear-auto-bgp.py
```python:auto-bgp/clear-auto-bgp.py
from function import *

if __name__ == '__main__':
    intf = findIf()
    if intf is None:
        print('Not Interface Related log')
        exit()

    adminState = getAdminState(intf)
    
    commands = [
        "default interface %s" % intf,
    ]
    configure_array(commands)

    if adminState:
        interface = Interface(intf)
        interface.set_state('up')

    removeUnusedBGPNeighbor()
```

### function.py
```python:auto-bgp/function.py
from cli import *
from cisco import *
from cisco.system import *
from cisco.ospf import *
from cisco.bgp import *

from params import *

import pprint
import re
import sys
import json
import ipaddress
import time

intIdReg = re.compile("\d+\/(\d+)")

def findIf ():
    reg = re.compile('[Ee]thernet\d+\/\d+')
    for a in sys.argv[1:]:
        if reg.match (a):
            return a
    return None

def pp (content):
    pp = pprint.PrettyPrinter(indent=4)
    pp.pprint(content)

def setInterface(if_name, ip_address = None):
    interface = Interface(if_name)
    interface.set_description("Configured by AutoBGP Python")
    commands = [
        "interface %s" % if_name,
        "no switchport",
        "mtu 9216"
    ]
    if ip_address is None:
        commands.append("medium p2p")
        commands.append("ip unnumbered loopback0")
        
    configure_array(commands)
    if ip_address is not None:
        interface.set_ipaddress(ip_address, 30)
    interface.set_state('up')

def getAdminState(intf):
    result = json_clid("show interface %s" % intf)["TABLE_interface"]["ROW_interface"]["admin_state"]

    if result == "up":
        return True
    else:
        return None
    
def getLeafID():
    hostname = System().get_hostname()
    match = re.match(r'[Ll]eaf(\d+)', hostname)
    if match:
        return int(match.group(1))
    else:
        return None

def getSpineID(intf):
    if_index = parsePortNumber(intf)
        
    if if_index not in uplink_ports:
        print('Not uplink port')
        exit()
    return uplink_ports.index(if_index) + 1

def parsePortNumber(intf):
    search = intIdReg.search(intf)
    if_index = 0
    if search:
        if_index = int(search.group(1))

    return if_index

def calcIP(leaf, spine):
    ip_address = ipaddress.ip_address(unicode(start_ip))
    ip_address += (leaf - 1) * (4 * spine_num) + (spine - 1) * 4 + 1
    return str(ip_address)

def setOSPFInterface(intf):
    ospf_session = OSPFSession(ospf_process_tag)
    ospf_interface = ospf_session.OSPFInterface(intf, str(ospf_area))
    ospf_interface.cfg_ospf_priority(0)
    ospf_interface.add()
    commands = [
        "interface %s" % intf,
        "ip ospf network point-to-point"
    ]
    configure_array(commands)

def waitOSPFAllFull(intf):
    timeout = 60
    command = "show ip ospf neighbor %s" % intf
    for _ in range(timeout):
        time.sleep(1)
        ospf_neighbor = json_clid(command)

        if ospf_neighbor is None:
            continue
        ospf_neighbor = ospf_neighbor["TABLE_ctx"]["ROW_ctx"]
        if int(ospf_neighbor["nbrcount"]) > 0 \
           and ospf_neighbor["TABLE_nbr"]["ROW_nbr"]["state"] == "FULL":
            return True
    return None

def setBGPNeighbor(neighbor_addr):
    bgp_session = BGPSession(bgp_as)
    neighbor = bgp_session.BGPNeighbor(neighbor_addr, ASN=bgp_as)
    neighbor.cfg_update_source("lo0")
    commands = [
        "router bgp %i" % bgp_as,
        "neighbor %s" % neighbor_addr,
        "address-family l2vpn evpn",
        "send-community both"
    ]
    configure_array(commands)

def getBGPNeighbors():
    neighbors = []
    results = json_clid("show bgp l2vpn evpn summary")["TABLE_vrf"]["ROW_vrf"]

    if "TABLE_af" not in results:
        return []
    results = results["TABLE_af"]["ROW_af"]["TABLE_saf"]["ROW_saf"]

    if "TABLE_neighbor" not in results:
        return []
    results = results["TABLE_neighbor"]["ROW_neighbor"]
    
    if type(results) is dict:
        results = [results]

    for result in results:
        neighbors.append(result["neighborid"])
    return neighbors

def getOSPFNeighbors():
    neighbors = []
    results = json_clid("show ip ospf neighbor")
    if results is None:
        return []

    results = results["TABLE_ctx"]["ROW_ctx"]["TABLE_nbr"]["ROW_nbr"]
    if type(results) is dict:
        results = [results]

    for result in results:
        neighbors.append(result["rid"])
    return neighbors
    
def removeUnusedBGPNeighbor():
    #nextHopLoopbacks = getNextHopLoopback()
    ospfNeighbors = getOSPFNeighbors()
    bgpNeighbors = getBGPNeighbors()

    bgp_session = BGPSession(bgp_as)

    for neighbor_addr in list_diff(bgpNeighbors, ospfNeighbors):
        neighbor = bgp_session.BGPNeighbor(neighbor_addr)
        neighbor.remove()
    
def configure_array(commands):
    commands.insert(0, "conf t")
    cli(" ; ".join(commands))

def json_clid(command):
    try:
        result = clid(command)
        if result is None:
            return None
        return json.loads(clid(command))
    except:
        return None

def list_diff(a, b):
    return list(set(a) - set(b))

```

### params.py
```python:auto-bgp/params.py
#### Params
uplink_ports = [49,50]
spine_num = len(uplink_ports)
start_ip = "192.168.0.0"
ospf_process_tag = "UNDERLAY"
ospf_area = 0
bgp_as = 65000
```

## Nexus上のConfig

### EEM用のConfig
```text:Leaf1_EEM_Config
Leaf1(config)# show run eem 

!Command: show running-config eem
!Time: Mon Dec 11 16:19:51 2017

version 7.0(3)I7(1)
event manager applet if-down-49
  description "for Auto-BGP"
  event track 49 state down
  action 1.0 cli python auto-bgp/clear-auto-bgp.py Ethernet1/49
event manager applet if-down-50
  description "for Auto-BGP"
  event track 50 state down
  action 1.0 cli python auto-bgp/clear-auto-bgp.py Ethernet1/50
event manager applet if-up-49
  description "for Auto-BGP"
  event track 49 state up
  action 1.0 cli python auto-bgp/auto-bgp.py Ethernet1/49
event manager applet if-up-50
  description "for Auto-BGP"
  event track 50 state up
  action 1.0 cli python auto-bgp/auto-bgp-unnumbered.py Ethernet1/50
```

### Object TrackingのConfig
```text:Leaf1_Track_Config
Leaf1(config)# show run track 
!Command: show running-config track
!Time: Mon Dec 11 16:21:35 2017

version 7.0(3)I7(1)
track 49 interface Ethernet1/49 line-protocol
track 50 interface Ethernet1/50 line-protocol

```