Creación de un CI / CD casero con acciones de GitHub y Python

Una noche, cuando llegué a casa del trabajo, decidí hacer un poco de tarea. Hice varias ediciones e inmediatamente quise experimentar con ellas. Pero antes de los experimentos, tuve que ir al VPS, impulsar los cambios, reconstruir el contenedor y ejecutarlo. Entonces decidí que era hora de lidiar con la entrega continua.


Inicialmente, tenía que elegir entre Circle CI, Travis o Jenkins. Descarté a Jenkins casi de inmediato debido a la falta de necesidad de una herramienta tan poderosa. Después de una rápida lectura sobre Travis, llegué a la conclusión de que es conveniente armarlo y probarlo, pero no se puede imaginar ninguna entrega con él. Aprendí sobre Circle CI de anuncios demasiado intrusivos en YouTube. Comencé a experimentar con ejemplos, pero en algún momento me equivoqué y tuve pruebas eternas, lo que me llevó muchos minutos preciosos de montaje (en general, hay un límite suficiente para no preocuparme, pero me golpeó). Volviendo a buscar, me topé con las acciones de Github. Después de jugar con los ejemplos de Comenzar, tuve una impresión positiva, y después de echar un vistazo rápido a la documentación, llegué a la conclusión de que es genial poder guardar secretos para ensamblar, recopilar y prácticamente implementar proyectos en un solo lugar. Con ojos brillantes, rápidamente dibujó el esquema deseado, y los engranajes giraron.


plan

Primero, intentaremos hacer la prueba. Como experimental, escribí un servidor web simple en Flask con 2 puntos finales:


Listado de una aplicación web simple
from flask import Flask from flask import request, jsonify app = Flask(__name__) def validate_post_data(data: dict) -> bool: if not isinstance(data, dict): return False if not data.get('name') or not isinstance(data['name'], str): return False if data.get('age') and not isinstance(data['age'], int): return False return True @app.route('/', methods=['GET']) def hello(): return 'Hello World!' @app.route('/api', methods=['GET', 'POST']) def api(): """ /api entpoint GET - returns json= {'status': 'test'} POST - { name - str not null age - int optional } :return: """ if request.method == 'GET': return jsonify({'status': 'test'}) elif request.method == 'POST': if validate_post_data(request.json): return jsonify({'status': 'OK'}) else: return jsonify({'status': 'bad input'}), 400 def main(): app.run(host='0.0.0.0', port=8080) if __name__ == '__main__': main() 

Y algunas pruebas:


Listado de prueba de aplicación
 import unittest import app as tested_app import json class FlaskAppTests(unittest.TestCase): def setUp(self): tested_app.app.config['TESTING'] = True self.app = tested_app.app.test_client() def test_get_hello_endpoint(self): r = self.app.get('/') self.assertEqual(r.data, b'Hello World!') def test_post_hello_endpoint(self): r = self.app.post('/') self.assertEqual(r.status_code, 405) def test_get_api_endpoint(self): r = self.app.get('/api') self.assertEqual(r.json, {'status': 'test'}) def test_correct_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps({'name': 'Den', 'age': 100})) self.assertEqual(r.json, {'status': 'OK'}) self.assertEqual(r.status_code, 200) r = self.app.post('/api', content_type='application/json', data=json.dumps({'name': 'Den'})) self.assertEqual(r.json, {'status': 'OK'}) self.assertEqual(r.status_code, 200) def test_not_dict_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps([{'name': 'Den'}])) self.assertEqual(r.json, {'status': 'bad input'}) self.assertEqual(r.status_code, 400) def test_no_name_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps({'age': 100})) self.assertEqual(r.json, {'status': 'bad input'}) self.assertEqual(r.status_code, 400) def test_bad_age_post_api_endpoint(self): r = self.app.post('/api', content_type='application/json', data=json.dumps({'name': 'Den', 'age': '100'})) self.assertEqual(r.json, {'status': 'bad input'}) self.assertEqual(r.status_code, 400) if __name__ == '__main__': unittest.main() 

Salida de cobertura:


 coverage report Name Stmts Miss Cover ------------------------------------------------------------------ src/app.py 28 2 93% src/tests.py 37 0 100% ------------------------------------------------------------------ TOTAL 65 2 96% 

Ahora cree nuestra primera acción, que ejecutará las pruebas. Según la documentación, todas las acciones deben almacenarse en un directorio especial:


 $ mkdir -p .github/workflows $ touch .github/workflows/test_on_push.yaml 

Quiero que esta acción se ejecute en cualquier evento push en cualquier rama, excepto las versiones (etiquetas, porque habrá pruebas separadas):


 on: push: tags: - '!refs/tags/*' branches: - '*' 

Luego creamos una tarea que se ejecutará en la última versión disponible de Ubuntu:


 jobs: run_tests: runs-on: [ubuntu-latest] 

En pasos, comprobaremos el código, instalaremos python, instalaremos dependencias, ejecutaremos pruebas y mostraremos cobertura:


 steps: #   - uses: actions/checkout@master #  python   - uses: actions/setup-python@v1 with: python-version: '3.8' architecture: 'x64' - name: Install requirements #   run: pip install -r requirements.txt - name: Run tests run: coverage run src/tests.py - name: Tests report run: coverage report 

Todos juntos
 name: Run tests on any Push event #    push    ,    . #      on: push: tags: - '!refs/tags/*' branches: - '*' jobs: run_tests: runs-on: [ubuntu-latest] steps: #   - uses: actions/checkout@master #  python   - uses: actions/setup-python@v1 with: python-version: '3.8' architecture: 'x64' - name: Install requirements #   run: pip install -r requirements.txt - name: Run tests run: coverage run src/tests.py - name: Tests report run: coverage report 

Intentemos crear un compromiso y ver cómo funciona nuestra acción.

Pasar pruebas en la interfaz de Acciones


¡Hurra, logramos crear la primera acción y ejecutarla! Tratemos de romper algún tipo de prueba y observemos el resultado:

Las pruebas fallan en la interfaz de Acciones


Las pruebas fallaron. El indicador se puso rojo e incluso recibió una notificación por correo. Lo que necesitas! 3 de los 8 puntos del esquema objetivo pueden considerarse completados. Ahora tratemos de lidiar con el ensamblaje, el almacenamiento de nuestras imágenes acoplables.


Nota! A continuación, necesitamos una cuenta acoplable


Primero, escribiremos un Dockerfile simple en el que se ejecutará nuestra aplicación.


Dockerfile
 #     FROM python:3.8-alpine #        /app  COPY ./ /app #    RUN apk update && pip install -r /app/requirements.txt --no-cache-dir #   (  Distutils) RUN pip install -e /app #      EXPOSE 8080 #       CMD web_server #    distutils      #CMD python /app/src/app.py 

Para enviar un contenedor al concentrador, será necesario iniciar sesión en la ventana acoplable, pero como no quiero que todo el mundo conozca la contraseña de la cuenta, utilizaré los secretos integrados en GitHub. En general, solo las contraseñas se pueden poner en secreto, y el resto se codificará en * .yaml, y esto funcionará. Pero me gustaría copiar y pegar mis acciones sin cambios, y extraer toda la información específica de los secretos.



Secretos de Github


DOCKER_LOGIN: inicie sesión en hub.docker.com
DOCKER_PWD - contraseña
DOCKER_NAME: nombre del repositorio de Docker para este proyecto (debe crearse con anticipación)


Bien, la preparación está hecha, ahora creemos nuestra segunda acción:


 $ touch .github/workflows/pub_on_release.yaml 

Copiamos las pruebas de la acción anterior, con la excepción del desencadenador de inicio (no encontré cómo importar las acciones). Lo reemplazamos con "Lanzamiento al lanzamiento":


 on: release: types: [published] 

¡IMPORTANTE! Es muy importante hacer la condición correcta en el evento.
Por ejemplo, si on.release no especifica tipos, este evento desencadena al menos 2 eventos: publicado y creado. Es decir, se iniciarán 2 procesos de ensamblaje inmediatamente.


Ahora en el mismo archivo haremos otra tarea dependiendo de la primera:


 build_and_pub: needs: [run_tests] 

necesidades - dice que esta tarea no comenzará hasta que finalice run_tests


¡IMPORTANTE! Si tiene varios archivos de acción, y dentro de ellos varias tareas, todas se ejecutan simultáneamente en diferentes entornos. Se crea un entorno separado para cada tarea, independiente de otras tareas. Si no especifica las necesidades, la tarea de prueba y los ensamblajes se iniciarán simultáneamente e independientemente uno del otro.


Agregue variables de entorno en las que nuestros secretos serán:


 env: LOGIN: ${{ secrets.DOCKER_LOGIN }} NAME: ${{ secrets.DOCKER_NAME }} 

Ahora los pasos de nuestra tarea, en ellos debemos iniciar sesión en la ventana acoplable, recoger el contenedor y publicarlo en el registro:


 steps: - name: Login to docker.io run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin - uses: actions/checkout@master - name: Build image run: docker build -t $LOGIN/$NAME:${GITHUB_REF:11} -f Dockerfile . - name: Push image to docker.io run: docker push $LOGIN/$NAME:${GITHUB_REF:11} 

$ {GITHUB_REF: 11} es una variable github que almacena una línea con una referencia al evento para el que funcionó el disparador (nombre de rama, etiqueta, etc.), si tenemos etiquetas con el formato "v0.0.0", entonces necesita recortar los primeros 11 caracteres, entonces queda "0.0.0".


Empuje el código y cree una nueva etiqueta. Y vemos que nuestro contenedor fue ensamblado y enviado con éxito al registro, y no iluminamos nuestra contraseña en ningún lado.



Construir y enviar contenedores en la interfaz de acciones


Verifique el centro:



Contenedor almacenado en Docker Hub


Todo funciona, pero la tarea más difícil sigue siendo la implementación. Aquí ya necesitará un VPS y una dirección IP blanca donde implementaremos el contenedor y donde puede enviar el enlace. En teoría, en el lado del VPS o del servidor doméstico, puede ejecutar un script en la corona, que en el caso de una nueva imagen lo dispararía, o de alguna manera jugaría con el bot de telegramas. Seguramente hay muchas formas de hacer esto. Pero trabajaré con ip externa. Para no ser inteligente, escribí un pequeño servicio web en Flask con una API simple.
En resumen, hay 1 punto final "/".
Una solicitud GET devuelve json con todos los contenedores activos en el host.
POST: recibe datos en el formato:


 { "owner": "  ", "repository": "  ", "tag": "v0.0.1", #    "ports": {"8080": 8080, “443”: 443} #      } 

Lo que sucede en el host:


  1. del json recibido se recoge el nombre de la nueva imagen
  2. una nueva imagen está abultada
  3. Si la imagen se ha descargado, el contenedor actual se detiene y se elimina.
  4. Se lanza un nuevo contenedor con la publicación de puertos (bandera -p)

Todo el trabajo con docker se realiza utilizando la biblioteca docker-py


Sería muy incorrecto publicar un servicio de este tipo en Internet sin una protección mínima, e hice una apariencia de API KEY, el servicio lee el token de las variables de entorno y luego lo compara con el encabezado {Autorización: CI_TOKEN}


Listado de servidores web para implementación
 # coding=utf-8 import os import sys import logging import logging.config import logging.handlers from flask import Flask from flask import request, jsonify import docker log = logging.getLogger(__name__) app = Flask(__name__) docker_client = docker.from_env() MY_AUTH_TOKEN = os.getenv('CI_TOKEN', None) #       def init_logging(): """   :return: """ log_format = f"[%(asctime)s] [ CI/CD server ] [%(levelname)s]:%(name)s:%(message)s" formatters = {'basic': {'format': log_format}} handlers = {'stdout': {'class': 'logging.StreamHandler', 'formatter': 'basic'}} level = 'INFO' handlers_names = ['stdout'] loggers = { '': { 'level': level, 'propagate': False, 'handlers': handlers_names }, } logging.basicConfig(level='INFO', format=log_format) log_config = { 'version': 1, 'disable_existing_loggers': False, 'formatters': formatters, 'handlers': handlers, 'loggers': loggers } logging.config.dictConfig(log_config) def get_active_containers(): """     :return: """ containers = docker_client.containers.list() result = [] for container in containers: result.append({ 'short_id': container.short_id, 'container_name': container.name, 'image_name': container.image.tags, 'created': container.attrs['Created'], 'status': container.status, 'ports': container.ports, }) return result def get_container_name(item: dict) -> [str, str]: """   image  POST  :param item: :return: """ if not isinstance(item, dict): return '' owner = item.get('owner') repository = item.get('repository') tag = item.get('tag', 'latest').replace('v', '') if owner and repository and tag: return f'{owner}/{repository}:{tag}', repository if repository and tag: return f'{repository}:{tag}', repository return '', '' def kill_old_container(container_name: str) -> bool: """    ,   :param container_name: :return: """ try: #    container = docker_client.containers.get(container_name) #  container.kill() except Exception as e: #       log.warning(f'Error while delete container {container_name}, {e}') return False finally: #   ,     log.debug(docker_client.containers.prune()) log.info(f'Container deleted. container_name = {container_name}') return True def deploy_new_container(image_name: str, container_name: str, ports: dict = None): try: #   image  docker hub'a log.info(f'pull {image_name}, name={container_name}') docker_client.images.pull(image_name) log.debug('Success') kill_old_container(container_name) log.debug('Old killed') #    docker_client.containers.run(image=image_name, name=container_name, detach=True, ports=ports) except Exception as e: log.error(f'Error while deploy container {container_name}, \n{e}') return {'status': False, 'error': str(e)}, 400 log.info(f'Container deployed. container_name = {container_name}') return {'status': True}, 200 @app.route('/', methods=['GET', 'POST']) def MainHandler(): """ GET -      POST -      : { "owner": "gonfff", "repository": "ci_example", "tag": "v0.0.1", "ports": {"8080": 8080} } :return: """ if request.headers.get('Authorization') != MY_AUTH_TOKEN: return jsonify({'message': 'Bad token'}), 401 if request.method == 'GET': return jsonify(get_active_containers()) elif request.method == 'POST': log.debug(f'Recieved {request.data}') image_name, container_name = get_container_name(request.json) ports = request.json.get('ports') if request.json.get('ports') else None result, status = deploy_new_container(image_name, container_name, ports) return jsonify(result), status def main(): init_logging() if not MY_AUTH_TOKEN: log.error('There is no auth token in env') sys.exit(1) app.run(host='0.0.0.0', port=5000) if __name__ == '__main__': main() 

Para esta aplicación, también hice setup.py para instalarlo en el sistema. Puedes instalarlo usando:


 $ python3 setup.sy install 

siempre que haya descargado los archivos y se encuentre en el directorio de esta aplicación.
Después de la instalación, debe habilitar la aplicación como un servicio para que se inicie en caso de reinicio del servidor, para esto utilizamos systemd
Aquí hay un archivo de configuración de ejemplo:


 [Unit] Description=Deployment web server After=network-online.target [Service] Type=simple RestartSec=3 ExecStart=/usr/local/bin/ci_example Environment=CI_TOKEN=#<I generate it with $(openssl rand -hex 20)> [Install] WantedBy=multi-user.target 

Solo queda ejecutarlo:


 $ sudo systemctl daemon-reload $ sudo systemctl enable ci_example.service $ sudo systemctl start ci_example.service 

Puede ver el registro del servidor de entrega web utilizando el comando


 $ sudo systemctl status ci_example.service 

La parte del servidor está lista, solo queda agregar un gancho a nuestra acción. Para hacer esto, agregue los secretos de la dirección IP de nuestro servidor y el CI_TOKEN que generamos cuando instalamos la aplicación.
Al principio, quería usar una acción preparada para curl del mercado de github, pero desafortunadamente, elimina las comillas del cuerpo de la solicitud POST, lo que hizo imposible analizar json. Obviamente, esto no me convenía, y decidí usar el rizo incorporado en ubuntu (en el que recopilo contenedores), que incidentalmente tuvo un efecto positivo en el rendimiento, ya que no requiere el ensamblaje de un contenedor adicional:


 deploy: needs: [build_and_pub] runs-on: [ubuntu-latest] steps: - name: Set tag to env run: echo ::set-env name=TAG::$(echo ${GITHUB_REF:11}) - name: Send webhook for deploy run: "curl --silent --show-error --fail -X POST ${{ secrets.DEPLOYMENT_SERVER }} -H 'Authorization: ${{ secrets.DEPLOYMENT_TOKEN }}' -H 'Content-Type: application/json' -d '{\"owner\": \"${{ secrets.DOCKER_LOGIN }}\", \"repository\": \"${{ secrets.DOCKER_NAME }}\", \"tag\": \"${{ env.TAG }}\", \"ports\": {\"8080\": 8080}}'" 

Nota: es muy importante especificar el modificador --fail, de lo contrario, cualquier solicitud tendrá éxito, incluso si se recibió un error en respuesta.
También vale la pena señalar que las variables utilizadas en la solicitud no son realmente variables, sino funciones especiales llamadas con la excepción de GITHUB_REF, por lo que durante mucho tiempo no pude entender por qué la solicitud no funcionaba correctamente. Pero habiendo hecho una función, todo funcionó.


La acción construye y despliega
 name: Publish on Docker Hub and Deploy on: release: types: [published] #       jobs: run_tests: #          runs-on: [ubuntu-latest] steps: #   - uses: actions/checkout@master #  python   - uses: actions/setup-python@v1 with: python-version: '3.8' architecture: 'x64' - name: Install requirements #   run: pip install -r requirements.txt - name: Run tests #   run: coverage run src/tests.py - name: Tests report run: coverage report build_and_pub: #      needs: [run_tests] runs-on: [ubuntu-latest] env: LOGIN: ${{ secrets.DOCKER_LOGIN }} NAME: ${{ secrets.DOCKER_NAME }} steps: - name: Login to docker.io #     docker.io run: echo ${{ secrets.DOCKER_PWD }} | docker login -u ${{ secrets.DOCKER_LOGIN }} --password-stdin #   - uses: actions/checkout@master - name: Build image #  image        hub.docker .. login/repository:version run: docker build -t $LOGIN/$NAME:${GITHUB_REF:11} -f Dockerfile . - name: Push image to docker.io #    registry run: docker push $LOGIN/$NAME:${GITHUB_REF:11} deploy: #         registry,      #    curl   needs: [build_and_pub] runs-on: [ubuntu-latest] steps: - name: Set tag to env run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:11}) - name: Send webhook for deploy run: "curl --silent --show-error --fail -X POST ${{ secrets.DEPLOYMENT_SERVER }} -H 'Authorization: ${{ secrets.DEPLOYMENT_TOKEN }}' -H 'Content-Type: application/json' -d '{\"owner\": \"${{ secrets.DOCKER_LOGIN }}\", \"repository\": \"${{ secrets.DOCKER_NAME }}\", \"tag\": \"${{ env.RELEASE_VERSION }}\", \"ports\": {\"8080\": 8080}}'" 

De acuerdo, lo armamos todo, ahora crearemos una nueva versión y analizaremos las acciones.

Webhook para la interfaz de acciones de la aplicación de implementación


Todo resultó, hacemos una solicitud GET al servicio de implementación (muestra todos los contenedores activos en el host):

Desplegado en contenedor VPS


Ahora enviaremos solicitudes a nuestra aplicación implementada:

OBTENER solicitud para contenedor desplegado


Solicitud post al contenedor desplegado

Solicitud POST al contenedor desplegado


Conclusiones
GitHub Actions es una herramienta muy conveniente y flexible con la que puedes hacer muchas cosas que pueden simplificar enormemente tu vida. Todo depende de la imaginación.
Admiten pruebas de integración con servicios.
Como continuación lógica de este proyecto, puede agregar al webhook la capacidad de pasar parámetros personalizados para iniciar el contenedor.
En el futuro, trataré de tomar como base este proyecto para el despliegue de diagramas de timón cuando estudie y experimente con k8


Si tiene algún tipo de proyecto de inicio, las acciones de GitHub pueden simplificar enormemente el trabajo con el repositorio.


Los detalles de sintaxis se pueden encontrar aquí.
Todas las fuentes del proyecto .

Source: https://habr.com/ru/post/476368/


All Articles