Непрерывная интеграция.

Основы работы с Linux. -> Cервер непрерывной интеграции на python.

Cервер непрерывной интеграции на python.

Непрерывная интеграция включает процесс применения изменений кода после их внесений через git на серверах.

Т.е. после того, как разработчик внес изменения и залил их в git, система в автоматическом режиме производит деплой кода на тестовый сервер, проводит все необходимые операции по сборке и тестированию, и затем переносит все на боевой.

Для автоматизации подобных процессов есть такие инстументы как Jenkins.

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

Предлагаю для начала построить свой собственный сервер интеграции на основе Django и “заточить” его под один единственный проект.

Задача сервера будет заключаться в следующем:

Пользователю предлагается ввести email и надавить на кнопку “Создать рабочую область”.

Система на сервере производит следущее:

  1. Создает на диске каталог с именем предоставленного emeil-a.

  2. Клонирует в него репозиторий проекта на Django (в нашем случае).

  3. Создает и пушит новый бранч в git по имени email-а.

  4. Устанавливает все зависимости проекта.

  5. Создает конфиги где:

  6. подключает проект к БД.

  7. создает конфиг для nginx в котором описывает новый поддомен, направленный на новый репозиторий

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

  9. предоставляет доступ папке проекта

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

start page

Cоздадим стартовый проект 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)