Безперервна інтеграція.

Основи роботи з Linux. -> Сервер безперервної інтеграції на python.

Сервер безперервної інтеграції на python.

Безперервна інтеграція включає процес застосування змін коду після внесення їх через git на серверах.

Тобто. після того, як розробник вніс зміни та залив їх у git, система в автоматичному режимі виробляє деплой коду на тестовий сервер, проводить усі необхідні операції зі складання та тестування, а потім переносить усе на бойовий.

Для автоматизації таких процесів є такі інструменти як Jenkins.

Він є окремим сервером з безліччю функцій і навіть своєю мовою для опису сценаріїв. Він здатний обслуговувати відразу безліч проектів, але досить складний у вивченні.

Пропоную спочатку побудувати свій власний сервер інтеграції на основі Django і “заточити” його під один єдиний проект.

Завдання сервера полягатиме в наступному:

Користувачеві пропонується ввести email та натиснути на кнопку “Створити робочу область”.

Система на сервері робить таке:

  1. Створює на диску каталог із ім’ям наданого emeil-a.

  2. Клонує репозиторій проекту на Django (у нашому випадку).

3.Створює і пушить новий бранч у git на ім’я email-а.

  1. Встановлює всі залежності проекту.

  2. Створює конфіги де:

  3. Підключає проект до БД.

  4. створює конфіг для nginx, в якому описує новий піддомен, спрямований на новий репозиторій

  5. підключає репозиторій до папки media (одна для всіх оскільки вона велика)

  6. надає доступ до папки проекту

Нам знадобиться налаштувати доменну зону і вказати в записі А, що ми хочемо мати довільну кількість піддоменів.

Start page

Створимо стартовий проект Django з ім’ям ci.

django-admin startproject ci

Файл залежностей requirements.txt

Django

Скрипт установки install

python3 -m venv venv
. ./venv/bin/activate
pip3 install -r requirements.txt
cd ci
./manage.py migrate

Створюємо додаток

./manage.py startapp main

Включаємо його у налаштуваннях.

Головний сторінки шаблону.

<html lang="en">
<!-- Basic -->
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title> CI </title>
</head>

<body>

    <h1> Создать рабочую область </h1>
    <form method="POST" action="" >
        {% csrf_token %}
        Email 
        <input type="text" name="email" />
        <button>Создать</button>
    </form>
</body>
</html>

Нам доведеться десь зберігати змінні оточення.

Для цього створимо файл .env

Визначаємо папку, де створюватимемо оточення та репозиторій, з якого клонуватимемо проекти в оточення.

WORK_DIR=/home/zdimon/tmp
GIT_URL=git@github.com:zdimon/pressa-besa.git

Встановимо пакет для читання файлів такого типу.

pip install python-dotenv

І пропишемо в settings.py

from dotenv import load_dotenv
load_dotenv()

WORK_DIR = os.getenv('WORK_DIR')
GIT_URL = os.getenv('GIT_URL')

Створимо модель і застосуємо міграцію.

from django.db import models

class Env(models.Model):
    email = models.CharField(max_length=60,unique=True)
    port = models.IntegerField(default=8080,unique=True)

Адмінка.

from django.contrib import admin

from .models import Env

@admin.register(Env)
class EnvAdmin(admin.ModelAdmin):
    list_display = ['email','port']

Створимо клас форми у файлі forms.py

from django.forms import ModelForm
from .models import Env


class EnvForm(ModelForm):
    class Meta:
        model = Env
        fields = ['email']

Виводимо у шаблоні.

<form method="POST" action="" >
    {% csrf_token %}
    {{ form }}
    <button>Создать</button>
</form>

Відпрацюємо сабміт.

from django.shortcuts import render
from .forms import EnvForm

def index(request):
    if request.method == 'POST':
        form = EnvForm(request.POST)
        if form.is_valid():
            form.save()
    else:
        form = EnvForm()
    return render(request,'index.html', {"form": form})

Результат

start page

Реалізуємо моделі додавання номера порту для кожного нового запису.

from django.db import models
from django.db.models.signals import post_save
from django.db.models import Max

class Env(models.Model):
    email = models.CharField(max_length=60,unique=True)
    port = models.IntegerField(default=8080)

    @classmethod
    def post_create(cls, sender, instance, created, *args, **kwargs):
        if created:
            maxp = Env.objects.aggregate(Max('port'))
            print(maxp)
            instance.port = maxp["port__max"]+1
            instance.save()

post_save.connect(Env.post_create, sender=Env)

У файлі tasks.py створимо функцію створення каталогу.

from django.conf import settings
import os

def create_dir(env):
    login = env.email.replace('@','---')
    path = os.path.join(settings.WORK_DIR,login)
    os.mkdir(path)

Викличемо її під час створення запису.

def post_create(cls, sender, instance, created, *args, **kwargs):
    if created:
        ...
        create_dir(instance)

Встановимо бібліотеку для гіта

pip install GitPython

Клонування репозиторію.

import git

def git_clone(env)
    path = os.path.join(settings.WORK_DIR,env.email.replace('@','---'))
    git.Git(path).clone(settings.GIT_URL)

Але ми стикаємося з проблемою часу виконання т.к. процес може бути довгим і користувача не варто змушувати чекати довгого завантаження сторінки.

Для цього придумали відкладені завдання (типу отримай ЩАС html сторінку з OK, а ми в роботі, і потім буде вихлоп).

Вирішуємо їх за допомогою celery (основний гравець на полі python).

Встановлення.

pip install redis
pip install celery

Необхідно створити програму celery у новому файлі celery_app.py поряд з settings.py.

from celery import Celery
from django.conf import settings
import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ci.settings')


app = Celery()
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

В settings.py установить Redis як брокер повідомлень.

REDIS_HOST = os.getenv('SQL_HOST', 'redis-server')
REDIS_PORT = os.getenv('SQL_PORT', '6379')

CELERY_BROKER_URL = 'redis://' + REDIS_HOST + ':' + str(REDIS_PORT)

Додати імпорт до init.py

from .celery_app import app as celery_app

Запуск вокера.

celery -A ci worker -l info

ci - ім’я проекту

Задекоруємо функції.

from django.conf import settings
import os
import git
from celery.decorators import task

@task()
def create_dir(env):
    login = env.email.replace('@','---')
    path = os.path.join(settings.WORK_DIR,login)
    os.mkdir(path)

@task()
def git_clone(env):
    path = os.path.join(settings.WORK_DIR,env.email.replace('@','---'))
    git.Git(path).clone(settings.GIT_URL)

Передамо на виконання вокеру селери.

create_dir.delay(instance)
git_clone.delay(instance)

Передача об’єкта не працює, тому передамо id.

git_clone.delay(instance.id)

І виберемо із бази об’єкт.

@task()
def git_clone(env_id):
    from .models import Env
    env = Env.objects.get(pk=env_id)
    path = os.path.join(settings.WORK_DIR,env.email.replace('@','---'))
    git.Git(path).clone(settings.GIT_URL)

from .models import Env - імпорт проводимо всередині функції, щоб уникнути циклічного імпорту.

Конфіг nginx

Додаємо додаткові змінні до .env

NGINX_PATH=/etc/nginx/sites-enabled
DOMAIN=local
MEDIA_PATH=/home/zdimon/Desktop/work/pressa-besa/media/
PROJECT_PATH=pressa-besa
DB_PATH=/home/zdimon/Desktop/work/pressa-besa/backend/db.sqlite3

Прописуємо у settings.

DOMAIN = os.getenv('DOMAIN')
NGINX_PATH = os.getenv('NGINX_PATH')
MEDIA_PATH = os.getenv('MEDIA_PATH')
PROJECT_PATH = os.getenv('PROJECT_PATH')
DB_PATH = os.getenv('DB_PATH')

Перш ніж писати в системний каталог, створимо в ньому підкаталог і встановимо права на запис.

sudo chown $UID:$GID /etc/nginx/sites-enabled

Також, якщо ви хочете, щоб робочий каталог знаходився в домашній директорії окремого користувача, необхідно дати права на запис тому користувачеві від імені якого стартує система.

Наприклад, якщо сервер інтеграції стартує від імені користувача pressa, а працювати повинні від імені користувача dev

sudo setfacl -m u:pressa:rwx /home/dev

Даємо права dev для запису в директорії, створені від імені pressa, додаючи його до групи pressa

sudo usermod -a -G pressa dev

Функція запису конфігу nginx.

def nginx_conf(env_id):
    from .models import Env
    env = Env.objects.get(pk=env_id)
    path = os.path.join(settings.BASE_DIR, 'tpl', 'nginx_vhost.conf')
    with open(path, 'r') as f:
        tpl = f.read()

    tpl = tpl.replace('%media_path%', settings.MEDIA_PATH)
    sname = '%s.%s' % (env.email.replace('@', '---'), settings.DOMAIN)
    tpl = tpl.replace('%server_name%', sname)
    tpl = tpl.replace('%port%', str(env.port))
    conf_path = os.path.join(
        settings.NGINX_PATH, env.email.replace('@', '---'))
    with open(conf_path, 'w+') as f:
        f.write(tpl)

Конфіг supervisor

Встановлюємо супервізор

sudo apt install supervisor

Дивимося шлях яким він инклудит конфи

sudo nano /etc/supervisor/supervisord.conf

[include]
files = /etc/supervisor/conf.d/*.conf

Змінимо шлях.

[include]
files = /home/zdimon/Desktop/work/pressa-besa/ci/configs/supervisor/*.conf

Перевантажимо

sudo service supervisor restart

Отримуємо помилку

start page

Значить запускати будемо через sudo

Перегляд завдань

supervisorctl

Пробуємо тестовий сервер у файлі test.conf.

[program:test]
user = zdimon
directory = /home/zdimon/Desktop/work/pressa-besa/ci/ci
command = python3 manage.py runserver 0.0.0.0:9898
autostart = true
autorestart = true

start page

Пробуємо додати оточення та лог помилок.

environment=PYTHONPATH="/home/zdimon/Desktop/work/pressa-besa/ci/venv"
stderr_logfile=/home/zdimon/Desktop/work/pressa-besa/ci/logs/error.log

Можемо спостерігати у лозі

ModuleNotFoundError: No module named 'celery'

start page

Схоже, що оточення не підключається.

Пробую застосувати бінарник інтерпретатора.

command = /home/zdimon/Desktop/work/pressa-besa/ci/venv/bin/python3 manage.py runserver 0.0.0.0:9898

Тепер усе працює.

start page

Створюємо шаблон під конфіг

[program:%name%]
directory = %prj_dir%
command = %env_dir%/bin/python3 manage.py runserver 0.0.0.0:%port%
autostart = true
autorestart = true
environment=PYTHONPATH="%env_dir%"
stderr_logfile=/home/zdimon/Desktop/work/pressa-besa/ci/logs/%name%.log

Оформлюємо функцію під конфіг.

def supervisor_conf(env_id):
    from .models import Env
    env = Env.objects.get(pk=env_id)
    path = os.path.join(settings.BASE_DIR, 'tpl', 'supervisor.conf')
    with open(path, 'r') as f:
        tpl = f.read()
    sname = '%s.%s' % (env.email.replace('@', '---'), settings.DOMAIN)
    tpl = tpl.replace('%name%', sname)
    tpl = tpl.replace('%port%', str(env.port))
    prj_dir = os.path.join(settings.WORK_DIR, env.email.replace(
        '@', '---'), settings.PROJECT_PATH)
    tpl = tpl.replace('%prj_dir%', prj_dir)
    tpl = tpl.replace('%env_dir%', settings.ENV_PATH)
    filename = '%s.conf' % env.email.replace('@', '---')
    conf_path = os.path.join(
        settings.BASE_DIR, 'configs', 'supervisor', filename)
    with open(conf_path, 'w+') as f:
        f.write(tpl)

Залишилося зробити рестарт supervisor-а та nginx-а.

bashCommand = "sudo service supervisor restart"
process = subprocess.Popen(bashCommand.split(), stdout=subprocess.PIPE)
output, error = process.communicate()
print(error)

Отримали запит пароля у вікні з селера.

start page

Значить будемо запускати з-під рута.

sudo celery -A ci worker -l info

Тоді відвалився git т.к. у git не додані ключі суперкористувача.

Please make sure you have the correct access rights
and the repository exists.'

Пробуємо придушити запит пароля для sudo

sudo visudo

Вставляємо рядок.

zdimon ALL=(ALL) NOPASSWD: ALL

Видалення робочої області.

Створимо завдання для celery, яка видалятиме всю область при видаленні користувача з моделі Env.

@task()
def clear_env(email):
    import subprocess
    # remove env path
    env_path = os.path.join(settings.WORK_DIR, email)
    bashCommand = "sudo rm -r %s" % env_path
    process = subprocess.Popen(bashCommand.split(), stdout=subprocess.PIPE)
    output, error = process.communicate()
    print(error)

    # remove nginx conf
    nginx_path = os.path.join(
        settings.NGINX_PATH, email)
    os.remove(nginx_path)

    # remove supervisor conf
    filename = '%s.conf' % email
    supervisor_conf_path = os.path.join(
        settings.BASE_DIR, 'configs', 'supervisor', filename)
    os.remove(supervisor_conf_path)

У це завдання ми передаватимемо email т.к. на момент її виклику запису в базі може вже не бути і ми не зможемо її вибрати по id.

Викликаємо завдання.

from django.db.models.signals import pre_delete

def pre_delete_handler(sender, instance, using, **kwargs):
    clear_env.delay(normalize_email(instance.email))


pre_delete.connect(pre_delete_handler, sender=Env)