logo cosasdedevs

Automatizar deploy de proyecto Django con Github

Automatizar deploy de proyecto Django con Github

My Profile
May 17, 2020

¡Hey! El tutorial de hoy es un tutorial que tenía muchas ganas de hacer. Era algo que no había hecho hasta ahora y ya tenía ganas de meterme con ello 😁. No os voy a engañar, es una de las cosas que no es que sea muy difícil pero que me ha tomado muuuuucho tiempo porque he pasado por muchos procesos de ensayo y error 😅. Eso si, la satisfacción de verlo funcionar vale la pena. Además una vez hecho nos va a ahorrar unos cuantos a la hora de subir nuestros nuevos cambios a producción.

Antes de seguir, para poder probar que todo funciona correctamente, necesitarás haber desplegado ya tu proyecto en producción para que GitHub realice la llamada a nuestro proyecto cuando hagamos un cambio, si aún no lo has hecho, aquí te explico cómo desplegar un proyecto de Django en un vps y en este tutorial te enseño como configurar el dominio y cómo añadir los certificados SSL.

Y dicho esto, vamos a empezar. Lo primero será crear un proyecto, el entorno virtual y después lo activaremos, si no tenéis claro como hacerlo podéis ver este tutorial donde explico cómo crearlo.

django-admin startproject auto_deploy_github
python3 -m venv env
env/Scripts/activate

Después crearemos el archivo requirements.txt en la raíz del proyecto y añadimos las librerías que vamos a usar que será la última versión LTS de Django, la librería de Git para Python y requests, ya que más adelante la necesitaremos para obtener el listado de ips válidas.

django==2.2.12
GitPython==3.1.2
requests==2.23.0

Ahora que ya sabemos las dependencias que necesitamos, lanzamos pip install y después el runserver para verificar que el proyecto funciona.

pip install -r requirements.txt
python manage.py runserver

Ahora que ya tenemos el proyecto funcionando, vamos a crear un app que es donde guardaremos toda la lógica del deploy.

python manage.py startapp deploy 

Después de generar la app, accedemos al archivo deploy/views.py y añadimos las librerías que usaremos y la función que realizará todo el proceso cuando llamen a nuestra URL.

# Django libraries
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseServerError
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from django.core.mail import send_mail
from django.utils.encoding import force_bytes

# Config
from auto_deploy_github.settings import *

# Other libraries
from pathlib import Path
from hashlib import sha1
import requests
import json
import ipaddress
import git
import getpass
import hmac
import subprocess
from http import HTTPStatus

def AutoDeploy(request):
    return HttpResponse('It works.')

Ahora vamos al archivo auto_deploy_github/urls.py y creamos la URL para poder acceder a nuestra función para automatizar el deploy

from django.conf.urls import url
from django.contrib import admin
from django.urls import path
from deploy import views

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    path(
        route='deploy/', 
        view=views.AutoDeploy,
        name='auto_deploy'
    ),
]

Una vez hecho esto y corriendo el proyecto, vamos a la URL http://127.0.0.1:8000/deploy/ para comprobar que todo está ok.

Ahora que ya tenemos la URL operativa, sería momento de subir los cambios y agregar la URL al webhook de GitHub, para ello vamos a las settings de nuestro proyecto en GitHub, después en el menú lateral pulsamos en Webhooks y pulsamos el botón de Add webhook. Nos pedirá que confirmemos el password, lo insertamos y ya podremos acceder.

Una vez dentro, deberemos añadir la URL a la que llamará GitHub cuando realicemos un push al proyecto, el content type, en el que nosotros seleccionaremos application/json y un secreto. Yo he generado un secreto de 50 caracteres desde esta página https://passwordsgenerator.net/.

También nos preguntará que eventos queremos que nos notifique, ahí he dejado la opción por defecto "Just the push event." Para finalizar pulsamos en add webhook.

Al hacerlo, lanzará un ping a la URL para verificar que todo está ok.

Ahora que ya tenemos el webhook listo, es hora de volver al código, abrimos nuestro archivo deploy/views.py y añadimos estos dos decoradores a nuestra función AutoDeploy:

@require_POST
@csrf_exempt
def AutoDeploy(request):

El primero hará que solo se permitan llamadas de tipo POST y el segundo se saltará las validaciones de token csrf, ya que no es un formulario.

Después de esto, comprobaremos si es una IP válida la que nos está llamando. GitHub tiene un listado de IPs que podemos recuperar en formato json, para acceder al listado lo podemos hacer desde esta url https://api.github.com/meta. Después recuperamos la URL de la petición y comprobamos si está en la white list.

@require_POST
@csrf_exempt
def AutoDeploy(request):

    ip = get_client_ip(request)

    ips = requests.get(
        'https://api.github.com/meta',
    ).json()

    is_valid_ip = False
    for hook_ip in ips['hooks']:
        if ipaddress.ip_address(ip) in ipaddress.ip_network(hook_ip):
            is_valid_ip = True
            break

    if not is_valid_ip:
        send_deploy_email(
            'Deploy git ejemplo (invalid ip)',
            f"From ip: {ip}",
        )
        return HttpResponseForbidden('Permission denied.')


def get_client_ip(request):
    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
    if x_forwarded_for:
        ip = x_forwarded_for.split(',')[0]
    else:
        ip = request.META.get('REMOTE_ADDR')
    return ip


def send_deploy_email(subject, body):

    from_email = 'no-reply@ejemplo.com'
    if(getpass.getuser() != 'www-data'):
        from_email = 'no-reply@ejemplo.local'

    send_mail(
        subject,
        body,
        from_email,
        ['miemail@gmail.com'],
        fail_silently=False,
    )

Para realizar esta comprobación, hemos creado la función get_client_ip que retornará la IP del request, después utilizamos la librería requests para obtener el listado de IPs válidas, las que buscamos está dentro de la clave 'hooks' y una vez hecho esto simplemente validaremos si está en la white list o no.

Si ocurre algún problema, mostraremos un mensaje de error y nos enviaremos un email para poder revisar que problemas pueden haber en el proceso.

Una vez hecho esto, añadiremos el secreto que creamos anteriormente en el archivo settings.py, para nada es recomendable commitear este tipo de información, solo lo haremos de esta forma para usarlo en el ejemplo.

GIT_KEY = 'qrE6E8HXFPJ8WLCM8UVztpmQyVJKbNCwp8TZU7QTBauKyVHrBF'

Ahora que tenemos el secreto, pasaremos a verificar la firma, si queréis más información sobre el proceso podéis verlo en la documentación de GitHub.

@require_POST
@csrf_exempt
def AutoDeploy(request):

    ip = get_client_ip(request)

    ips = requests.get(
        'https://api.github.com/meta',
    ).json()

    is_valid_ip = False
    for hook_ip in ips['hooks']:
        if ipaddress.ip_address(ip) in ipaddress.ip_network(hook_ip):
            is_valid_ip = True
            break

    if not is_valid_ip:
        send_deploy_email(
            'Deploy git ejemplo (invalid ip)',
            f"From ip: {ip}",
        )
        return HttpResponseForbidden('Permission denied.')

    # Verificamos que la información enviada es válida.
    header_signature = request.headers['X-Hub-Signature']
    if header_signature is None:
        return HttpResponseForbidden('Permission denied. (invalid signature)')

    sha_name, signature = header_signature.split('=')
    if sha_name != 'sha1':
        return HttpResponseServerError('Operation not supported.', status=501)

    mac = hmac.new(force_bytes(GIT_KEY), msg=force_bytes(request.body), digestmod=sha1)
    if not hmac.compare_digest(force_bytes(mac.hexdigest()), force_bytes(signature)):
        send_deploy_email(
            'Deploy git ejemplo (invalid secret)',
            f"From ip: {ip}",
        )
        return HttpResponseForbidden(f'Permission denied.')

Ahora que ya tenemos las validaciones, es hora de pasar a la acción. Nosotros necesitaremos que se realice el git pull, después compruebe si hay algo que migrar y por último lanzar de nuevo el archivo requeriments.txt para revisar si hay que instalar nuevas librerías. Para los dos últimos pasos, crearemos un archivo bash llamado sincro_require_ddbb.sh en el directorio raíz con el siguiente código:

#! /bin/bash

source env/bin/activate
python manage.py migrate
pip install -r requirements.txt
deactivate

Volvemos al archivo deploy/views.py y añadimos la última parte de nuestro código.

@require_POST
@csrf_exempt
def AutoDeploy(request):

    ip = get_client_ip(request)

    ips = requests.get(
        'https://api.github.com/meta',
    ).json()

    is_valid_ip = False
    for hook_ip in ips['hooks']:
        if ipaddress.ip_address(ip) in ipaddress.ip_network(hook_ip):
            is_valid_ip = True
            break

    if not is_valid_ip:
        send_deploy_email(
            'Deploy git ejemplo(invalid ip)',
            f"From ip: {ip}",
        )
        return HttpResponseForbidden('Permission denied.')

    # Verificamos que la información enviada es válida.
    header_signature = request.headers['X-Hub-Signature']
    if header_signature is None:
        return HttpResponseForbidden('Permission denied.')

    sha_name, signature = header_signature.split('=')
    if sha_name != 'sha1':
        return HttpResponseServerError('Operation not supported.', status=501)

    mac = hmac.new(force_bytes(env('GIT_KEY')), msg=force_bytes(request.body), digestmod=sha1)
    if not hmac.compare_digest(force_bytes(mac.hexdigest()), force_bytes(signature)):
        send_deploy_email(
            'Deploy git ejemplo(invalid secret)',
            f"From ip: {ip}",
        )
        return HttpResponseForbidden(f'Permission denied.')
    

    g = git.cmd.Git(BASE_DIR)
    res = g.pull()

    command = f'./sincro_require_ddbb.sh'
    process = subprocess.Popen(command.split(), cwd=BASE_DIR, stdout=subprocess.PIPE)
    output, error = process.communicate()

    result = [];
    if output is not None:
        result.append(output.decode('utf-8'))
    if error is not None:
        result.append(error.decode('utf-8'))

    mig_and_req = '\n'.join(result)

    send_deploy_email(
        'Deploy git ejemplo',
        f"From ip: {ip}\nResult git: \n{res}\nResult migrations and requirements: \n{mig_and_req}",
    )
    touch = Path(f'{BASE_DIR}/auto_deploy_git/wsgi.py').touch()

    return HttpResponse('The proccess has been done succesfully.')

En esta parte del código, lo primero que hacemos es declarar un objeto con la clase git y le pasamos por parámetro el directorio donde queremos que trabaje, después lanzamos el comando pull y guardamos la respuesta.

Después lanzaremos el archivo bash que creamos anteriormente con subprocess, este nos permite lanzar comandos como si estuviéramos en la terminal, y capturamos el output y posibles errores.

Para confirmar que todo está ok, enviaremos un log con el resultado del git pull, las migraciones y la instalación del archivo requirements.txt.

Por último, para reflejar en nuestro servidor los cambios realizados y si habéis seguido el tutorial en el que explicaba como realizar el deploy en un vps, debemos hacer una modificación en el archivo auto_deploy_git/wsgi.py cada vez que realicemos un push, para ello usaremos la librería Path con el método touch que lo que hará será modificar la fecha de actualización del archivo, esto hará que la configuración del server detecte los cambios y recargue el proyecto.

¡Y listo! Ya tenemos automatizado el deploy. Cada vez que hagamos un cambio y lo subamos a nuestro repositorio, los cambios se reflejarán en producción.

Como siempre dejo el enlace al proyecto en mi GitHub, si os ayuda os agradecería que dejaseis una estrella y si queréis estar al tanto cada vez que suba nuevo contenido, podéis seguirme en mi Twitter donde os informaré cada vez que suba nuevos tutoriales.

Nos leemos 👋

411 vistas

Nos tomamos en serio tu privacidad

Utilizamos cookies propias y de terceros para mejorar la experiencia del usuario a través de su navegación. Si pulsas entendido aceptas su uso. Ver política de cookies.

🐍 Sígueme en Twitter

Si te gusta el contenido que subo y no quieres perderte nada, sígueme en Twitter y te avisaré cada vez que cree contenido nuevo 💪
Luego ¡Te sigo!