Social
Node.js
Construye tu propio servicio de CI con Node.js + Docker + GitHub + DigitalOcean
Sun May 17 2020 19:00:00 GMT-0500 (Peru Standard Time)

Para construir nuestro servicio de Integración Continua haremos uso de Node.js y vamos a desarrollar un API sobre Express. Gracias a Docker administraremos las imágenes base en donde correrá nuestra APP y sus pruebas. Todo esto desplegado en DigitalOcean.

Arrancamos el desarrollo de este proyecto preparando el terreno donde funcionará el servicio de CI, nuestro servidor tendrá lo siguiente:

* Ubuntu 18.04
* Docker CE
* API (Express)

Para integrar nuestro API y GitHub utilizaremos webhooks y así capturar el evento “PUSH” en uno de nuestros repositorios. Esta petición hacia nuestro end-point iniciará el siguiente proceso:

1. Capturamos el evento PUSH gracias al webhook
2. Pedimos a Docker crear un nuevo contenedor a partir de una imagen base.
3. Arrancamos el contenedor y corremos las pruebas
4. Registramos los Logs generados por las pruebas

El objetivo de nuestro servicio es: por cada PUSH iniciar un contenedor y así aislar el entorno de pruebas para evitar conflictos si se requieren varios PUSH al mismo tiempo.

Los detalles del contenedor y la estructura del proyecto de pruebas lo revisaremos más adelante.

Crear un Droplet en DigitalOcean

Empezamos creando nuestro Droplet, seleccionamos Ubuntu 18.04 en el plan Standard y a modo de prueba la máquina virtual 1GB/1CPU. Esto a futuro ya dependerá de la cantidad de contenedores que mantendremos funcionando.

Seleccionamos la región del centro de datos y el modo de autenticación SSH Keys.

Finalmente asignamos un hostname a nuestro servidor, recuerden que debe ser un nombre único entre nuestros Droplets y creamos el Droplet.

El Dashboard nos informará cuando el aprovisionamiento del servidor se complete con eso nos asignará una dirección IP con la que accederemos por SSH desde nuestra consola/terminal.

Instalación de Docker CE

Una vez listo nuestro servidor accedemos por SSH utilizando la dirección IP que nos muestra el dashboard de DigitalOcean.

# ssh root@167.172.195.248

Una vez tenemos acceso al servidor podemos preparar la instalación de los paquetes necesarios. Pueden seguir a detalle este proceso en la guía escrita por Brian Hogan.

Actualizamos la lista de paquetes.

$ sudo apt update

Habilitamos el uso de HTTPS.

$ sudo apt install apt-transport-https ca-certificates curl software-properties-common

Agregamos la clave GPG para el repositorio de Docker

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

Incluimos a las fuentes apt el repositorio de Docker.

$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"

Una vez listo el repositorio debemos actualizar por segunda ocasión los paquetes.

$ sudo apt update

Con esto listo podemos instalar Docker CE.

$ sudo apt install docker-ce

Verificamos que todo se encuentre funcionando como se espera.

$ docker

Y debemos tener como respuesta el listado de subcomandos disponibles.

Instalación de Node.js, NPM y PM2

A continuación debemos instalar el entorno de ejecución Node.js y su administrador de paquetes.

$ sudo apt install nodejs

Podemos verificar la versión instalada.

$ node -v

Instalamos NPM

$ sudo apt install npm

Instalamos PM2

$ sudo npm install -g pm2

Con esto hemos completado la instalación de todas las herramientas necesarias para nuestro proyecto.

API

Una vez listo el servidor y corriendo Docker necesitamos automatizar la creación de contenedores y de igual manera ejecutar las pruebas.

Partamos de un escenario modelo en el cual estamos desarrollando nuestro proyecto en Node.js y para asegurar que cada nueva funcionalidad no afecte a lo previamente desarrollado hacemos uso de pruebas unitarias con Jest. Entonces queremos que cada vez se envie un PUSH en GitHub se ejecuten las pruebas.

Todo el código de nuestro API lo puedes encontrar en el siguiente repositorio: CI SERVER.

En nuestro CI server necesitamos un conjunto de paquetes para interactuar tanto con Docker como también con GitHub.

$ npm install express body-parser node-docker-api tar-fs github-webhook-handler

package.json

{
    "name": "ci-server",
    "version": "1.0.0",
    "description": "Integración continua con Node.js + Docker",
    "main": "server.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node server.js"
    },
    "repository": {
        "type": "git",
        "url": "git+https://github.com/carlosroec/ci-server.git"
    },
    "keywords": [
        "Node.js",
        "CI",
        "Docker"
    ],
    "author": "carlosro_ec",
    "license": "MIT",
    "bugs": {
        "url": "https://github.com/carlosroec/ci-server/issues"
    },
    "homepage": "https://github.com/carlosroec/ci-server#readme",
    "dependencies": {
        "body-parser": "^1.19.0",
        "express": "^4.17.1",
        "express-github-webhook": "^1.0.6",
        "node-docker-api": "^1.1.22",
        "tar-fs": "^2.1.0"
    }
}

Vamos a trabajar sobre la siguiente estructura del proyecto (vamos a colocar la estructura relevante por lo que no se mostrará carpetas como node_modules o archivos como package-lock.json, README), podemos crear cada uno de los archivos y según avancemos con el desarrollo agregamos código.

ci-server
    └───src
        └───docker-images
            └───node-10
            │   Dockerfile
            └───node-12
            │   Dockerfile
        └───routes
        │   health.js
        │   manager.js
        │   runner.js
        │   webhook.js
    │   server.js
    │   package.json    

Vamos a dividir en grupos las acciones/rutas a ejecutar dentro del server, tenemos por un lado al grupo ~manager~ donde manejaremos todo lo relacionado a la construcción, eliminación de imágenes y por otro lado tenemos al grupo ~webhook~ donde manejaremos la ejecución automatizada de los contenedores. Además contamos con ~health~ donde tenemos una ruta para monitorear a nuestro servidor y finalmente contamos con ~runner~ donde replicamos la funcionalidad del webhook pero sin las validaciones requeridas por GitHub.

Imágenes de Docker

En nuestro server podemos manejar varias imágenes, cada una descrita en su propio Dockerfile. Veamos una imagen que tiene como base Node v10.x.x

src/docker-images/node-10/Dockerfile

FROM node:10

WORKDIR /usr/src/app

ARG REPO_URL
ARG REPO_NAME

RUN git clone $REPO_URL

WORKDIR /usr/src/app/$REPO_NAME

RUN npm install

CMD git pull && npm run test

Partimos de una imagen base Node versión 10, indicamos el path donde será nuestro directorio de trabajo WORKDIR /usr/src/app, será ahí donde colocaremos nuestro código. Queremos generalizar el uso de esta imagen por lo que pasaremos dos argumentos ARG REPO_URL ARG REPO_NAME al momento de construirla, estos son los valores del repositorio a clonar y el nombre del mismo. Una vez definido el repositorio podemos clonarlo y ejecutar la instalación de los paquetes propios de nuestro proyecto. El último comando CMD git pull && npm run test no se ejecutará al momento de la construcción de la imagen, este paso se ejecutará al momento de arrancar un container creado a partir de esta imagen.

Node.js + Docker

Definimos cada acción en una ruta de nuestro Express, hay que tener en cuenta que no consideramos ningún tipo de seguridad así que todo lo expuesto en este proyecto será accesible a través del API.

Desde Node.js podemos gestionar Docker sea de manera local o remota, para esto usaremos el package node-docker-api Además necesitamos tar-fs para empaquetar todos los archivos necesarios, enviarlos a Docker y proceder con la creación de la imagen.

src/routes/manager.js

const express = require('express');
const { Docker } = require('node-docker-api');
const tar = require('tar-fs');

const router = express.Router();

Nuestro primer end-point será el responsable de construir la imagen a partir del archivo Dockerfile que indiquemos.

URL: [SERVER]:[PORT]/api/build-image
METHOD: POST
BODY: 
{
	"image": {
		"version":"node-10",
		"name": "ci-test"
	},
	"repository": {
		"url": "https://github.com/carlosroec/ci-test.git",
		"name": "ci-test"
	}
}

En la propiedad image.version definimos el archivo Dockerfile que vamos a utilizar (tenemos dos opciones para nuestro server: node-10, node-12) y como image.name se debe utilizar el mismo nombre del repositorio en nuestro caso el proyecto de pruebas es ci-test

En el objeto repository definimos la url y name de nuestro proyecto de pruebas.

src/routes/manager.js

const promisifyStream = stream => new Promise((resolve, reject) => {
    stream.on('data', data => console.log(data.toString()))
    stream.on('end', resolve)
    stream.on('error', reject)
});

const buildImage = async (req, res) => {
    try {
        const { image, repository } = req.body;

        const docker = new Docker({ socketPath: '/var/run/docker.sock' });
        const tarStream = tar.pack(`./src/docker-images/${image.version}`);
        const stream = await docker.image.build(tarStream, {
            t: image.name,
            buildargs: {
                REPO_URL: repository.url,
                REPO_NAME: repository.name
            }
        });
        
        await promisifyStream(stream);

        res.json({
            status: 'done'
        });
    } catch (err) {
        res.status(500).send({
            status: 'error',
            error: err
        });
    }
}

router.post('/build-image', buildImage);

module.exports = router;

En la función buildImage nos conectamos a Docker de manera local y empaquetamos el archivo Dockerfile. Al ejecutar el build de la imagen debemos recordar que necesitamos enviar como argumentos la URL y Name del repositorio de pruebas con eso cada imagen ya tendrá esa información. Docker nos permite capturar todo lo que sucede cada vez que ejecuta un comando, por lo que a través de Streams podemos registrar el paso a paso del proceso de construcción de la imagen, para eso definimos la función promisifyStream la cual retorna un Promise y resuelve la misma solo cuando se haya concluido la mensajería desde Docker.

Webhook

Una de las características para desarrolladores de GitHub es la capacidad de informar cada interacción en un repositorio por medio de webhooks. Para nuestro caso necesitamos que GitHub nos informe cada vez que se ejecute un PUSH en nuestro repositorio. En nuestro servidor debemos preparar un end-point donde reciba toda la información que GitHub envía además de validar la misma, como medida de seguridad toda esta carga de datos se envía firmada y nosotros debemos indicar un secret al momento de crear el webhook.

Esta configuración la hacemos directamente sobre nuestro repositorio de pruebas.

Accedemos a: Settings > Webhooks

Agregamos un nuevo webhook, necesitamos la URL de nuestro end-point http://[SERVER]:[PORT]/api/webhook . Además en Content type indicamos que nos envíe el payload como JSON, definimos nuestro texto secret el cual lo usaremos también en nuestro end-point y así validar/procesar todos los request que lleguen solo de GitHub con esto si alguien externo consigue el end-point y realiza peticiones estas se denegaran automáticamente al no contar con la firma correcta.

Ahora en nuestro server definamos el end-point.

URL: [SERVER]:[PORT]/api/webhook
METHOD: POST

Hacemos uso del package express-github-webhook es un middleware para Express que incluye todo el proceso de validación del payload enviado por GitHub.

Una vez lo requerimos debemos inicializarlo con la ruta donde escuchará el request además del secret y así poder verificar la firma del payload.

src/routes/webhook.js

const { Docker } = require('node-docker-api');
const GithubWebHook = require('express-github-webhook');

const webhookHandler = GithubWebHook({ path: '/api/webhook', secret: 'jdhhhf465hsdjh' });

Definimos Docker para la conexión a nuestra instalación local y definimos GithubWebHook con los argumentos requeridos, en este caso path: '/api/webhook' la ruta que anteriormente usamos al momento de crear el webhook en GitHub al igual que el secret.

Ahora debemos indicar en qué evento debe ejecutar este proceso para nuestro caso vamos a hacerlo con cada PUSH hacia nuestro repositorio.

const promisifyStream = stream => new Promise((resolve, reject) => {
    stream.on('data', data => console.log(data.toString()))
    stream.on('end', resolve)
    stream.on('error', reject)
});

webhookHandler.on('push', async (event, repo, data) => {
    const docker = new Docker({ socketPath: '/var/run/docker.sock' });
    
    // construimos el container en base a la imagen indicada
    const containerName = `ci-${Math.floor(Math.random() * 1000)}`;
    const container = await docker.container.create({
        "Image": repo,
        "name": containerName
    });

    // arrancamos el container y sus pruebas
    await container.start();

    const logsStream = await container.logs({
        follow: true,
        stdout: true,
        stderr: true,
        timestamps: true,
    });

    await promisifyStream(logsStream);

    // eliminamos el container luego de ejecutar las pruebas
    await container.delete({ force: true });
});

module.exports = webhookHandler;

Cuando GitHub nos reporta un push, nos conectamos a Docker, creamos dinámicamente el contenedor en base a la imagen previamente construida, recuerden que la creamos con el mismo nombre del repo y es aquí donde tomamos ese valor desde el payload.

Arrancamos el container container.start() esto ejecuta internamente el comando git pull && npm run test. Todos los logs generados de este proceso los podemos revisar en la consola de nuestro server.

Finalmente eliminamos el container con eso mantenerlos activos el menor tiempo posible y cada container solo pertenezca a un PUSH.

Deploy

Volvemos a nuestro Droplet

# ssh root@167.172.195.248

Creamos un directorio donde clonar el repo ci-server

$ mkdir /var/www

$ cd /var/www

Clonamos el repositorio

$ git clone https://github.com/carlosroec/ci-server.git

$ cd ci-server

Para asegurar el funcionamiento de nuestro server usaremos PM2 y así ejecutar nuestro server.js

pm2 start server.js

A partir de este momento nuestro server esta listo para recibir los requests desde GitHub.

Todos los logs los manejará el propio server internamente, para un siguiente tutorial nos queda enviar esa información hacia un archivo, base de datos o servicio externo de logs.

Código

CI Server

CI Test

Author
Carlos G. Rodriguez

Autor y creador de NodeHispano