воскресенье, 1 августа 2021 г.

Простейшая инвенторка с Flask API в Docker контейнере

Привет. Совсем какой-то не сетевой топик, но что делать... жизнь она такая.

Сегодня пишем невероятно простую инвенторку с API и покуем все это дело в докер контейнер с использованием GitHub CI/CD.

Кому не хочется читать :)

https://github.com/MelHiour/simple_api_inventory

https://hub.docker.com/r/melhiour1/simple_inventory

С чего все началось?

Я в очередной раз пытался понять зачем лично мне нужен OOP. Но потом подумал, что если OOP не приходит ко мне, то я приду к нему. Решил я поэкспериментировать и написать что-то очень простое, но относительно полезное.

После недолгих раздумий решено было написать простенькую инвенторную системку для хранения данных о своем барахлишке. Я использовал для этого простую табличку, но это не всегда удобно. Дело в том, что я использую свои файерволы как некие "джамп" хосты. Сначала логинишься на них, а потом прыгаем по серверам. Было бы удобно смотреть инвенторку прям с них. Естественно на ум пришел CURL. 

Запускать, понятное дело, такие вещи удобно в контейнере. Если так, то почему бы не паковать в контейнер сразу с Github через их CI/CD систему.

Итак, погнали по списку:

  • Логика
  • Web API
  • Docker Image
  • CI/CD
  • Запускаем

Логика

Файл: https://github.com/MelHiour/simple_api_inventory/blob/main/src/helpers.py

Я по прежнему пишу отвратительный код и уже не особо переживаю на этот счет. На этот раз я попытался реализовать свои идеи в стиле Объектно Ориентированного Программирования. 

Идея проста как и все в OOП мире, мгм...

  • наша инвенторка это некий объект Inventory, в котором содержаться другие объекты Equipment. 
  • каждый объект Equipment содержит в себе инвентарные данные - ip, имя, ОС и т.д.
  • над инвентори мы можем совершать разные действия, такие как посмотреть на определенный Equipment внутри или удалить его.

Не думаю, что стоит каждый метод рассматривать отдельно. Это можно сделать по ссылке выше. Рассмотрим только их назначение. Да и не мне вас учить код писать так-то.

class Inventory:

    def __init__(self, db_file):


    def get_equipment(self):


    def get_equipment_attr(self, name, attr):


    def add_equipment(self, dictionary):


    def del_equipment(self, name):


    def update_equipment_attr(self, name, dictionary):


    def sync(self):

Первый и последний методы служат для чтения из и записи в файл. В качестве storage скрипт использует простой Json. Были идея про sqlite или какой-нибудь noSQL базы, но я решил пока не усложнять. Пока.

Все остальные методы имеют говорящее название и в целом понятно, что они должны делать. Например, метод ниже ожидает словарик на входе и добавляет из него новый атрибут, который по сути является экземпляром Equipment. Потом вызывает метод sync для записи в json и возвращает имя записанного атрибута.

    def add_equipment(self, dictionary):

        setattr(self, dictionary['name'], Equipment(dictionary))

        self.sync()

        return dictionary['name']


Класс Equipment тоже ничего особо сложного из себя не представляет. При создании экземпляра он добавляет все данные из словаря в атрибуты. И может возвращать себя как словарь, если попросить.

class Equipment:


    def __init__(self, dictionary):

        """It expects a dictionary and create an instance from it"""

        self.__dict__.update(dictionary)


    def to_dict(self):

        """Can return instance as a dictionary"""

        return self.__dict__


Вот как примерно это работает. Импортируем код, создаем инвентори. Добавляем пару устройств и смотрим что получилось. Можно получить конкретный атрибут у устройства. Можно устройство удалить.

# python3


>>> from helpers import Inventory

>>> inventory = Inventory('db.json')

File not found. It will be created


>>> inventory.get_equipment()

[]


>>> inventory.add_equipment({'name':'device1','type':'Firewall'})

'device1'


>>> inventory.add_equipment({'name':'device2','type':'Router'})

'device2'


>>> inventory.get_equipment()

['device1', 'device2']


>>> inventory.get_equipment_attr('device1', 'type')

'Firewall'


>>> inventory.del_equipment('device2')

{'name': 'device2', 'type': 'Router'}


>>> inventory.get_equipment()

['device1']

>>> quit()


Каждое изменение в инвентори пишется в json.

# cat db.json 

{"device1": {"name": "device1", "type": "Firewall"}}


По итогу, получаем инвенторку, которая реализована в виде вложенных объектов. Устройства находятся в Inventory. Все данные об устройствах хранятся в виде атрибутов. С помощью методов мы можем взаимодействовать с нашими данными. Json файл используется просто как хранилище данных. ООП!

Web API


Вообще тут тоже все очень просто. По сути цель - добиться вызова все тех же методов, только средствами API.


Детали можно посмотреть по ссылке выше. Вкратце, у нас есть следующий набор путей
  • /inventory/
    • GET - возвращает список устройств
    • POST - добавляет новое устройство
  • /inventory/<name>
    • GET - возвращает детали по одному устройству
    • DELETE - удаляет устройство
    • PUT - обновляет данные устройства
  • /inventory/<name>/<attr>
    • GET - возвращает атрибут устройства

@app.route('/inventory/', methods=['GET'])

def get_inventory():



@app.route('/inventory/<name>', methods=['GET'])

def get_equipment_by_name(name):



@app.route('/inventory/<name>/<attr>', methods=['GET'])

def get_equipment_by_attr(name, attr):



@app.route('/inventory/', methods=['POST'])

def add_equipment():



@app.route('/inventory/<name>', methods=['DELETE'])

def del_equipment(name):



@app.route('/inventory/<name>', methods=['PUT'])

def update_equipment_attr(name):



if __name__ == '__main__':

    inventory = Inventory('data/db.json')

    app.run(host='0.0.0.0')


Как видно, это практически калька с нашего класса. По сути, API это просто "морда" для нашего inventory объекта.

Как оно работает?

# python3 simple_inventory.py 

File not found. It will be created

 * Serving Flask app 'simple_inventory' (lazy loading)

 * Environment: production

   WARNING: This is a development server. Do not use it in a production deployment.

   Use a production WSGI server instead.

 * Debug mode: off

 * Running on all addresses.

   WARNING: This is a development server. Do not use it in a production deployment.

 * Running on http://10.20.30.12:5000/ (Press CTRL+C to quit)


А теперь поотправляем всякого.

#### Что в инвентори? Ничего.


# curl http://10.20.30.12:5000/inventory/


{"equipment":[[]]}




#### Добавим три устройства.


# curl --header "Content-Type: application/json" \

> --request POST \

> --data '{"name":"FRW1","address":"10.0.0.1","location":"Lipetsk"}' \

> http://10.20.30.12:5000/inventory/


{"name":"FRW1"}


# curl --header "Content-Type: application/json" \

> --request POST \

> --data '{"name":"FRW2","address":"192.168.0.1","location":"Voronezh","OS":"JunOS"}' \

> http://10.20.30.12:5000/inventory/


{"name":"FRW2"}


# curl --header "Content-Type: application/json" \

> --request POST \

> --data '{"name":"FRW3","address":"172.16.0.1","location":"Dublin","OS":"VyOS","type":"VM"}'\

> http://10.20.30.12:5000/inventory/


{"name":"FRW3"}




#### Так гораздо лучше


# curl http://10.20.30.12:5000/inventory/


{"equipment":[["FRW1","FRW2","FRW3"]]}




#### Можно посмотреть детальней


# curl http://10.20.30.12:5000/inventory/FRW1


{"address":"10.0.0.1","location":"Lipetsk","name":"FRW1"}




#### Или еще детальней


# curl http://10.20.30.12:5000/inventory/FRW1/address


"10.0.0.1"




#### Можно поменять что-то


# curl --header "Content-Type: application/json" \

> --request PUT \

> --data '{"address":"10.10.10.10"}' \

> http://10.20.30.12:5000/inventory/FRW1


{"address":"10.10.10.10","location":"Lipetsk","name":"FRW1"}

 

# curl http://10.20.30.12:5000/inventory/FRW1/address


"10.10.10.10"




#### А можно и удалить совсем


# curl --request DELETE http://10.20.30.12:5000/inventory/FRW2


{"OS":"JunOS","address":"192.168.0.1","location":"Voronezh","name":"FRW2"}


# curl http://10.20.30.12:5000/inventory/


{"equipment":[["FRW1","FRW3"]]} 


Выглядит все это немного громоздко из-за CURL, но можно использовать другие клиенты или вообще браузер. Главное, что это будет работать откуда угодно.

Docker Image


Для того чтобы наше "приложение" завернуть в докер контейнер нужно для начала написать простенький файл с инструкциями.

Создаем папочку /app, копируем туда requirements.txt из репозитория и ставим все, что там есть. Далее копируем два наших python файла. Создаем папку data для json файла и запускаем все это точно такой же командой, которой мы пользовались в предыдущей секции.
 

# syntax=docker/dockerfile:1


FROM python:3.8-slim-buster


WORKDIR /app


COPY requirements.txt requirements.txt

RUN pip3 install -r requirements.txt


COPY helpers.py helpers.py

COPY simple_inventory.py simple_inventory.py


RUN mkdir data


CMD [ "python3", "simple_inventory.py"]


Можно создать image из этого файла локально и потестировать, но я особого смысла не вижу. Очень уж все просто. Поэтому мы сразу сбилдим это все в GitHub.

CI/CD

"...как много в этом слове для сердца русского слилось..."

В принципе, тут все тоже самое, что и в предыдущем посте, даже еще проще.

Для запуска CI/CD нам нужно создать файл .github/workflows/main.yaml и описать в нем что и как нужно делать, а именно:

Запускать пайплайн при git push или pull_request в ветку main. Изменения в README.md игнорировать.

on:
  push:
    branches: [ main ]
    paths-ignore:
      - '**/README.md'
  pull_request:
    branches: [ main ]
  workflow_dispatch:

Первая часть этой вакханалии будет билдить наше микроприложение на разных версиях Python, проверять форматирование с помощью flake8 и запускать тесты. Да, я их тоже написал, но не будет о грустном.

jobs:
  build-and-test:

    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest]
        python-version: [3.6, 3.7, 3.8]

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install flake8 pytest mock 
        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
    - name: Lint with flake8
      run: |
        flake8 .  --max-line-length=130 --statistics --exclude test*
    - name: Test with pytest
      run: |
        pytest --doctest-modules --junitxml=test-report/test-results-${{ matrix.python-version }}.xml

После успешного прохождения предыдущего шага будем делать image и заливать его на Dockerhub, чтобы ВСЕ могли пользоваться нашим приложением! Логинимся и запускаем билд с нашим Dockerfile.

  image-build:
    needs: build-and-test
    runs-on: ubuntu-latest
    
    steps:
      - name: Check Out Repo 
        uses: actions/checkout@v2

      - name: Login to Docker Hub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v1

      - name: Build and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          context: ./
          file: ./Dockerfile
          push: true
          tags: ${{ secrets.DOCKER_HUB_USERNAME }}/simple_inventory:latest

      - name: Image digest
        run: echo ${{ steps.docker_build.outputs.digest }}

Запускаем!


И после пары минут, видим, что второй шаг завершился!


Ну штош, теперь image лежит на dockerhub!


Пришло время его запустить.

Запускаем

Для того, чтобы все это запустить вам конечно же понадобиться докер, который в наше время невероятно легко поставить даже на утюг. Я в тебя верю, дорогой читатель.

Как не сложно заметить наше приложение работает по HTTP без вообще какой-либо авторизации. Это не очень прикольно, поэтому "перед" приложением нужно разместить прокси с TLS и авторизацией.

Создаем папку для того, чтобы приложение складывало туда свои файлики. И создаем сам файлик с командой запуска (не самый лучший подход, да)

/srv/simple_inventory/

├── data

└── run


Файл запуска в моем случае выглядит примерно так

# cat simple_inventory/run 


docker run --name simple_inventory \

-p 5000:5000/tcp \

-v /srv/simple_inventory/data:/app/data \

--restart always \

melhiour1/simple_inventory


Создаем контейнер
публикуем порт 5000
подтыкаем папку data к /app/data (туда упадет db.json)
всегда рестартуем контейнер, если что
далее имя образа

Все, можно запускать!

# bash simple_inventory/run 


Unable to find image 'melhiour1/simple_inventory:latest' locally

latest: Pulling from melhiour1/simple_inventory

33847f680f63: Already exists 

e8124950597e: Pull complete 

cc636c24d49d: Downloading [======================>                            ]  4.934MB/10.73MB

1fbf3ac5d4b6: Download complete 

937cec37db4e: Download complete 

c492c4ebd07b: Verifying Checksum 

7232db23c932: Download complete 

e02af92bd35d: Downloading [======================>                            ]  1.897MB/4.285MB

98c19ae47b01: Download complete 

368280d54981: Waiting 

517871cb6a3c: Waiting 


Вуаля!

# curl http://localhost:5000/inventory/

{"equipment":[[]]}


Все работает!

# curl --header "Content-Type: application/json" \

> --request POST \

> --data '{"name":"FRW1","address":"10.0.0.1","location":"Lipetsk"}' \

> http://localhost:5000/inventory/

{"name":"FRW1"}



# curl http://localhost:5000/inventory/

{"equipment":[["FRW1"]]}



# cat simple_inventory/data/db.json 

{"FRW1": {"name": "FRW1", "address": "10.0.0.1", "location": "Lipetsk"}}


Как уже говорил, тут еще нужно прокси, но мы про это пока не будем.

Вместо выводов

Само приложение конечно будет абсолютно бесполезно практически любому, но для меня это как раз то, что нужно. Можно развернуть какой-нибудь Netbox, но он реально большой и для моих 5 виртуалок оно не особо и нужно.

Сама CI/CD и докер часть как мне кажется относительно проста, но позволяет "задеплоить" изменения кода без вообще какого-либо вмешательства, что классно. Мой docker хост периодически обновляет образы для контейнеров, так что изменения рано или поздно "долетят" и до него.

Пойду заполнять интвентори!

Комментариев нет:

Отправить комментарий