Nornir 네트워크 자동화 – 3 (활용편)

Nornir 네트워크 자동화 – 3 (활용편)

이전 글(Nornir 네트워크 자동화 -2 (활용편))에서 nornir-netmiko 를 통해 시스코스위치에 명령을 내리고 결과를 가져오는 파이썬 스크립트를 작성했다.

이제, 남은것은 가져온 결과에서 원하는 부분을 추출하여 excel 에 저장하는 것이다.
만약, 결과를 눈으로 확인하기만 하면 된다고하면 print_result 사용만으로 충분할 수도 있겠지만, 장비의 시리얼 번호 추출이나 os 버전을 업그레이드 하기 위해 현재 os 버전을 확인하고자 한다면 결과를 파싱하여 정리하는 것이 보기에 좋을 것이다.

파싱의 원래 개념은 문자열을 토큰으로 분해하여 parse tree를 만드는 과정을 말하므로 엄격한 의미의 파싱이라 할 수 없을지도 모르겠지만, 넓은 의미로 원하는 데이타를 추출하는 행위도 의미한다.

어떤 문자열에서 원하는 문자열을 추출하기 위한 방법으로는 여러 가지가 있겠지만 정규표현식을 이용하는것이 좋다.

* 결과 파싱

아래는, 다른 프로젝트에서 사용하던 스크립트로 문자열을 입력받아 hostname, model number, os version, uptime, cpu(%idle), mem(%idle), fan, temperature, power supply 관련 내용을 추출하는 파이썬 스크립트다.

parsing.py 로 아래 내용을 작성한다.

import re


# Wanted items : hostname, model number, os version, uptime, cpu %idle, mem %idle, fan, power, temperature.
class CiscoParse:
    def __init__(self, data):
        self.data = data.split('\n')

    def hostname(self):
        p = re.compile('hostname')
        for i in self.data:
            m = p.search(i)
            if m:
                tmp = i.split(' ')
                hostname = tmp[-1]
                return hostname
        hostname = 'unknown'
        return hostname

    def dev_model(self):
        p = re.compile('^cisco WS-|Cisco WS-')
        for i in self.data:
            m = p.search(i)
            if m:
                tmp = i.split(' ')
                dev_model = tmp[1]
                return dev_model
        dev_model = 'unknown'
        return dev_model

    def os_ver(self):
        p = re.compile('IOS')
        for i in self.data:
            m = p.search(i)
            if m:
                tmp = i.split(',')
                v = re.compile('Version')
                for j in tmp:
                    s = v.search(j)
                    if s:
                        version = j.strip()
                        return version
        version = 'unknown'
        return version

    def uptime(self):
        p = re.compile('uptime|Uptime')
        for i in self.data:
            m = p.search(i)
            if m:
                tmp = i.split('is')
                uptime = tmp[-1].strip()
                return uptime
        uptime = 'unknown'
        return uptime

    def cpu_usage(self):
        p = re.compile('CPU utilization')
        for i in self.data:
            m = p.search(i)
            if m:
                i = ' '.join(i.split())
                tmp = i.split(':')
                cpu_idle = 100 - int(tmp[-1].strip('%'))
                return str(cpu_idle)
        cpu_idle = 'unknown'
        return cpu_idle

    def mem_usage(self):
        p = re.compile('Processor Pool Total:|^Total:')
        for i in self.data:
            m = p.search(i)
            if m:
                i = ' '.join(i.split())             # remove many spaces
                tmp = i.split(' ')
                total = int(tmp[-5].strip(','))
                free = int(tmp[-1])
                mem_free = int(free / total * 100)
                return str(mem_free)
        mem_free = 'unknown'
        return mem_free

    def fan(self):
        p = re.compile('Fantray|FAN')
        for i in self.data:
            m = p.search(i)
            if m:
                tmp = i.split(' ')
                fan = tmp[-1].strip('\r')
                return fan
        fan = 'unknown'
        return fan

    def temperature(self):
        p = re.compile('Chassis Temperature|TEMPERATURE')
        for i in self.data:
            m = p.search(i)
            if m:
                tp = re.compile('TEMPERATURE')
                tm = tp.search(i)
                if tm:
                    tmp = i.split(' ')
                    temperature = tmp[-1].strip('\r')
                    return temperature
                else:
                    tmp = i.split(' ')
                    temperature = tmp[-3]
                    return temperature
        temperature = 'unknown'
        return temperature

    def power_supply(self):
        ps = list()
        power_supply = str()
        p = re.compile('PS[0-9]+|POWER|Built-in')
        for i in self.data:
            m = p.search(i)
            if m:
                i = ' '.join(i.split())
                ps.append(i)
        if ps:
            power = list()
            # C3550, C2950
            p = re.compile('POWER')
            for i in ps:
                m = p.search(i)
                if m:
                    tmp = i.split(' ')
                    power.append(tmp[-1].strip())

            # C3560, C3750
            p = re.compile('Built-in')
            for i in ps:
                m = p.search(i)
                if m:
                    tmp = i.split(' ')
                    power.append(tmp[-1].strip())

            # C-4507 power supply
            p = re.compile('PS[0-9]+')
            for i in ps:
                m = p.search(i)
                if m:
                    tmp = i.split(' ')
                    status = '%s: %s, %s' % (tmp[0], tmp[-3], tmp[-2],)
                    power.append(status)

            for i in power:
                power_supply = power_supply + ' ' + i
            return power_supply
        else:
            power_supply = 'unknown'
            return power_supply

nornir 태스크 실행결과는 문자열이 아니므로, 위 스크립트를 곧바로 적용할 수 없다. 이것을 해결하려면 task 결과를 하나의 문자열로 만들거나, 각 항목의 결과(data[name][i].result)를 위의 스크립트에 넣는 방법도 있다.
여기에서는, 단순하게 모든 결과를 하나의 문자열로 만들도록 스크립트를 작성하도록 한다.

코드를 아래처럼 수정한다.

    data = nr.run(task=ios_group_task)

    for i in data.keys():
        tmp_str = ''
        for j in range(0, len(data[i])):
            if data[i][j].result:
                tmp_str = tmp_str + data[i][j].result + '\n'
        print(tmp_str)

이것은 data 오브젝트(nornir result)에서 result만 추출하는 코드로 각 결과들이 tmp_str 변수에 문자열로 저장된다. 일단 코드는 동작하지만 뭔가 좀 복잡해보이며, data의 구조를 파악하기도 쉽지 않다. 이 오브젝트의 구조를 쉽게 보려면 직렬화하면 된다.

* 결과 직렬화

직렬화(serialize)는 오브젝트를 문자열과같은 연속적인 데이타로 변환하는 것을 의미한다.

nornir-salt 플러그인에서 제공되는 ResultSerializer는 task 결과를 딕셔너리로 만들어준다.

ResultSerializer를 사용하기 위해 nornir-salt 패키지를 설치한다.

(venv) CiscoPM_new>pip install nornir-salt

이제, 위의 코드를 다시 작성한다.

    nr = nr.filter(name='4')
    data = nr.run(task=ios_group_task)
    result = ResultSerializer(data)
    pprint(result)

위 코드의 결과는 아래와 같으며, 딕셔너리데이타이므로 nornir Result 타입의 데이타보다 읽기가 매우 쉽다.

{'4': {'CPU_info': 'CPU utilization for five seconds: 7%/0%; one minute: 8%; '
                   'five minutes: 7%\n'
                   ' PID Runtime(ms)   Invoked      uSecs   5Sec   1Min   5Min '
                   'TTY Process \n'
                   '   1           0         6          0  0.00%  0.00%  '
                   '0.00%   0 Chunk Manager    \n'
                   '   2         595   2068557          0  0.00%  0.00%  '

 		...

                   '0.00%   0 SNMP Traps       \n'
                   ' 301        4687  10433344          0  0.00%  0.00%  '
                   '0.00%   0 NTP              \n'
                   ' 302          59    172647          0  0.00%  0.00%  '
                   '0.00%   0 DHCPD Database  ',
       'HW_check1': 'FAN is OK\n'
                    'TEMPERATURE is OK\n'
                    'SW  PID                 Serial#     Status           Sys '
                    'Pwr  PoE Pwr  Watts\n'
                    '--  ------------------  ----------  ---------------  '
                    '-------  -------  -----\n'
                    ' 1  Built-in                                         '
                    'Good\n'
                    '\n'
                    'SW  Status          RPS Name          RPS Serial#  RPS '
                    'Port#\n'
                    '--  -------------   ----------------  -----------  '
                    '---------\n'
                    '1   Not Present     <>\n',
       'HW_check2': '% Incomplete command.\n',
       'HW_info': 'Cisco IOS Software, C3560 Software (C3560-IPBASEK9-M), '
                  'Version 12.2(55)SE1, RELEASE SOFTWARE (fc1)\n'
                  'Technical Support: http://www.cisco.com/techsupport\n'
		
		 ...
 
                  '----------               \n'
                  '*    1 52    WS-C3560-48TS      12.2(55)SE1           '
                  'C3560-IPBASEK9-M         \n'
                  '\n'
                  '\n'
                  'Configuration register is 0xF\n',
       'Hostname': 'hostname SWITCH',
       'MEM_info': 'Processor Pool Total:   78260728 Used:   18884812 Free:   '
                   '59375916\n'
                   '      I/O Pool Total:    8388608 Used:    3597176 Free:    '
                   '4791432\n'
                   'Driver te Pool Total:    1048576 Used:         40 Free:    '
                   '1048536\n'
                   '\n'
                   ' PID TTY  Allocated      Freed    Holding    Getbufs    '
                   'Retbufs Process\n'
                   '   0   0   28979524    8561976   17351620          '
                   ...

                   '0          0 NTP             \n'
                   ' 302   0        216          0       7268          '
                   '0          0 DHCPD Database  \n'
                   '                                 22481100 Total',
       'device_ip': '192.168.0.5',
       'ios_group_task': None}}

이제, ios_pm 함수는 아래와 같다.

def ios_pm():
    nr = InitNornir(
        runner={
            'plugin': 'threaded',
            'options': {
                'num_workers': 30,
            },
        },
        inventory={
            'plugin': 'SimpleInventory',
            'options': {
                'host_file': 'hosts.yaml',
            }
        }
    )
    nr = nr.filter(name='4')
    data = nr.run(task=ios_group_task)
    result = ResultSerializer(data)
    return result

결과에서 원하는 문자를 추출하는 result_parsing 함수를 만든다.

from parsing import CiscoParse

...

def result_parsing(result):
    checked_list = list()
    for i in result.keys():
        tmp_str = ''
        for j in result[i].keys():
            if j == 'device_ip':
                ipa = result[i]['device_ip']
            else:
                ipa = 'unknown'
            if result[i][j]:
                tmp_str = tmp_str + result[i][j] + '\n'
        cisco_parse = CiscoParse(tmp_str)
        tmp = {
            'ip': result[i]['device_ip'],
            'hostname': cisco_parse.hostname(),
            'dev_model': cisco_parse.dev_model(),
            'os_version': cisco_parse.os_ver(),
            'uptime': cisco_parse.uptime(),
            'cpu_idle': cisco_parse.cpu_usage(),
            'mem_free': cisco_parse.mem_usage(),
            'fan': cisco_parse.fan(),
            'temperature': cisco_parse.temperature(),
            'power': cisco_parse.power_supply(),
        }
        checked_list.append(tmp)
    return checked_list


if __name__ == '__main__':
    hosts = get_hosts_file('cisco.xlsx')
    hosts_to_yaml(hosts)
    result = ios_pm()
    pprint(result_parsing(result))

이 스크립트의 실행결과는 아래같은 딕셔너리를 가지는 리스트가 된다.

[{'cpu_idle': '95',
  'dev_model': 'WS-C3560-48TS',
  'fan': 'OK',
  'hostname': 'SWITCH',
  'ip': '192.168.0.5',
  'mem_free': '75',
  'os_version': 'Version 12.2(55)SE1',
  'power': ' Good',
  'temperature': 'OK',
  'uptime': '17 weeks, 22 hours, 17 minutes'}]

* 보고서 작성

이제 거의 다 왔다.
마지막 단계는 이 리스트 데이타를 엑셀에 보기좋게(?) 저장하기만 하면 되는 것이다.

먼저, 오늘 날자로 워크시트를 만들고 제목과, 점검 항목을 만드는 함수를 만든다.

def create_worksheet(excel_file):
    today = datetime.date.today()
    book = load_workbook(excel_file)
    sheet_title = 'report_%s' % (today,)
    header = ['IP', 'Host Name', 'Model', 'Ver.', 'UP Time', 'CPU(% idle)', 'MEM(% idle)', 'Fan', 'Temp.', 'Power']
    try:
        book.create_sheet(title=sheet_title)
        sheet = book[sheet_title]
        # sheet.merge_cells('A1:J1')
        sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(header))
        sheet['A1'] = 'Proactive Maintenance Report - %s' % (today,)
        sheet['A1'].font = Font(size=14, bold=True, underline='single')
        sheet['A1'].alignment = Alignment(horizontal='center')
        sheet.append(header)
        book.save(excel_file)
        book.close()
    except Exception as e:
        print('Some error occurred. \n  %s' % (e,))
        book.close()
    finally:
        book.close()

마지막으로 리스트로부터 각 항목을 엑셀의 셀에 적고 저장하는 report 함수를 작성한다.

def report(check_list, excel_file):
    today = datetime.date.today()
    book = load_workbook(excel_file)
    sheet_title = f'report_{today}'
    sheet = book[sheet_title]
    for i in check_list:
        data = [
            i['ip'],
            i['hostname'],
            i['dev_model'],
            i['os_version'],
            i['uptime'],
            i['cpu_idle'],
            i['mem_free'],
            i['fan'],
            i['temperature'],
            i['power'],
        ]
        sheet.append(data)
    book.save(excel_file)
    book.close()

지금까지 만든 함수들을 바탕으로 main.py 파일을 작성한다.

from cisco_pm import get_hosts_file, hosts_to_yaml, ios_pm, result_parsing, create_worksheet, report


def main(excel_file):
    now = datetime.now()
    print('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>')
    print(f'Starting proactive maintenance: {now}')
    hosts = get_hosts_file(excel_file)
    hosts_to_yaml(hosts)
    result = ios_pm()
    chk_list = result_parsing(result)
    create_worksheet(excel_file)
    report(chk_list, excel_file)
    now = datetime.now()
    print('<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<')
    print(f'End proactive maintenance: {now}')

    
if __name__ == '__main__':
    main('cisco.xlsx')

실행하면, (인벤토리에 118개의 장비가 등록)

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Starting proactive maintenance: 2021-06-21 16:23:56.792458
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
End proactive maintenance: 2021-06-21 16:24:21.042789

지금까지 Nornir를 사용하여 시스코 스위치를 점검하는 스크립트를 작성해보았다. 사실 지금까지는 기능 위주로 코드를 작성했으므로 개선의 여지가 많다.

몇 가지 개선할 사항을 적어보면서 글을 마무리한다.

* 에러처리
* 특정 장치만 점검하도록하는 필터(이것은 hosts.yaml의 data 부분을 활용할 수 있다).
* 엑셀파일이름 지정
* SimpleInventory 대신 DictInventory 사용(hosts.yaml 파일 만들 필요 없음)

* 마치며…

몇가지 사항을 수정한 전체 코드는 github에 있다.

시스코 iOS 스위치 점검 스크립트: https://github.com/snowffoxx/CiscoPM
익스트림 Exos 스위치 점검 스크립트: https://github.com/snowffoxx/ExosPM

기타사항:
아래처럼 netmiko timeout 오류 발생시 처리 방법.

 BOX_3F ** changed : False *********************************************
vvvv Switch Info. ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Traceback (most recent call last):
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\netmiko\base_connection.py", line 935, in establish_connection
    self.remote_conn_pre.connect(**ssh_connect_params)
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\paramiko\client.py", line 412, in connect
    server_key = t.get_remote_server_key()
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\paramiko\transport.py", line 834, in get_remote_server_key
    raise SSHException("No existing session")
paramiko.ssh_exception.SSHException: No existing session

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\nornir\core\task.py", line 99, in start
    r = self.task(self, **self.params)
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\nornir_netmiko\tasks\netmiko_send_command.py", line 26, in netmiko_send_command
    net_connect = task.host.get_connection(CONNECTION_NAME, task.nornir.config)
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\nornir\core\inventory.py", line 502, in get_connection
    extras=conn.extras,
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\nornir\core\inventory.py", line 553, in open_connection
    configuration=configuration,
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\nornir_netmiko\connections\netmiko.py", line 59, in open
    connection = ConnectHandler(**parameters)
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\netmiko\ssh_dispatcher.py", line 326, in ConnectHandler
    return ConnectionClass(*args, **kwargs)
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\netmiko\base_connection.py", line 350, in __init__
    self._open()
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\netmiko\base_connection.py", line 355, in _open
    self.establish_connection()
  File "D:\Python_Project\NetRobo\venv\lib\site-packages\netmiko\base_connection.py", line 980, in establish_connection
    raise NetmikoTimeoutException(msg)
netmiko.ssh_exception.NetmikoTimeoutException: Paramiko: 'No existing session' error: try increasing 'conn_timeout' to 10 seconds or larger.

^^^^ END Switch Info. ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

hosts.yaml에 아래와같이 타임아웃값을 주었으나 해결되지 않음.

BOX_3F:
    groups:
        - WIFI_L2
    hostname: 172.16.245.37
    connection_options:
        netmiko:
            extras:
                timeout: 20

파이썬 가상환경에 설치된 netmiko 패키지 중에서 base_connection.py ((venv) CiscoPM_new\venv\Lib\site-packages\netmiko>base_connection.py) 파일에 타임아웃값이 기본 5초로 설정된것 확인.

...
conn_timeout=5,
...

이 값을 20초로 변경후 오류 발생하지 않는다.

참고문서: https://nornir-salt.readthedocs.io/en/latest/index.html

1 comment

    • snowffox on 2022년 5월 4일 at 1:07 오후
    • Reply

    netmiko global_delay_factor를 사용하면 응답이 빨라서 원하는 결과가 잘리는 현상을 방지할 수 있다.
    사용법은
    https://github.com/nornir-automation/nornir/discussions/633
    를 참고하면 된다.

snowffox에 답글 남기기 응답 취소

Your email address will not be published.