Безперервна інтеграція включає процес застосування змін коду після внесення їх через git на серверах.
Тобто. після того, як розробник вніс зміни та залив їх у git, система в автоматичному режимі виробляє деплой коду на тестовий сервер, проводить усі необхідні операції зі складання та тестування, а потім переносить усе на бойовий.
Для автоматизації таких процесів є такі інструменти як Jenkins.
Він є окремим сервером з безліччю функцій і навіть своєю мовою для опису сценаріїв. Він здатний обслуговувати відразу безліч проектів, але досить складний у вивченні.
Пропоную спочатку побудувати свій власний сервер інтеграції на основі Django і “заточити” його під один єдиний проект.
Завдання сервера полягатиме в наступному:
Користувачеві пропонується ввести email та натиснути на кнопку “Створити робочу область”.
Система на сервері робить таке:
Створює на диску каталог із ім’ям наданого emeil-a.
Клонує репозиторій проекту на Django (у нашому випадку).
3.Створює і пушить новий бранч у git на ім’я email-а.
Встановлює всі залежності проекту.
Створює конфіги де:
Підключає проект до БД.
створює конфіг для nginx, в якому описує новий піддомен, спрямований на новий репозиторій
підключає репозиторій до папки media (одна для всіх оскільки вона велика)
надає доступ до папки проекту
Нам знадобиться налаштувати доменну зону і вказати в записі А, що ми хочемо мати довільну кількість піддоменів.
Створимо стартовий проект 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})
Результат
Реалізуємо моделі додавання номера порту для кожного нового запису.
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 - імпорт проводимо всередині функції, щоб уникнути циклічного імпорту.
Додаємо додаткові змінні до .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)
Встановлюємо супервізор
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
Отримуємо помилку
Значить запускати будемо через 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
Пробуємо додати оточення та лог помилок.
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'
Схоже, що оточення не підключається.
Пробую застосувати бінарник інтерпретатора.
command = /home/zdimon/Desktop/work/pressa-besa/ci/venv/bin/python3 manage.py runserver 0.0.0.0:9898
Тепер усе працює.
Створюємо шаблон під конфіг
[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)
Отримали запит пароля у вікні з селера.
Значить будемо запускати з-під рута.
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)