Sommaire

Intégration continue d'Hugo avec Woodpecker

Préambule

Ce blog (construit grâce au framwork Hugo) est de base, compiler et publier manuellement.
Il est aussi versionnalisé sous le SCM Git (j’utilise la forge Gitea).

De ce fait, le processus d’ajout d’un article implique les étapes suivantes :

  • Commit dans Gitea
  • Compilation via la commande hugo
  • Déploiement via une commande rsync

Bien que j’ai automatisé les deux dernières parties dans un script, je ne suis pas satisfait de cela car je sais qu’il existe des solutions pour faire cela automatiquement.
Cela était donc un bon prétexte pour expérimenter le CI/CD.

Si vous lisez cet article, c’est que ma solution de déploiement automatique a fonctionné !
Nous allons donc voir ici ma démarche et comme d’habitude, les problèmes que j’ai eus… 😒

Introduction

Choix de la solution

Sur la recherche d’une solution, mes exigences sont les suivantes :

  • Self-hosted (non Cloud)
  • Léger
  • Déploiement via Docker
  • Documentation efficace
  • Solution moderne

Dans le cas d’un système de CI, je souhaite aussi :

  • Utilisation de conteneurs
  • Intégration avec Gitea

Mes recherches mon orienté vers Jenkins et DroneCI qui, tous les deux, permettait l’utilisation de Gitea.
J’ai testé Jenkins, mais je trouve cela trop compliqué de le câbler pour une utilisation avec des conteneurs.
Au premier abord, j’ai été séduit par Drone, mais je n’ai pas compris grand-chose à la documentation… De plus, je n’ai pas trouvé beaucoup de ressources externes sur cette solution.

Récemment (mais je ne sais déjà plus comment), j’ai découvert WoodpeckerCI qui est un fork de Drone.
La documentation est assez épurée et compréhensible, j’ai donc décidé de lui donner une chance.

Principe

Woodpecker utilise les comptes de votre forge pour fonctionner. Il n’y a alors pas de configuration particulière à faire si ce n’est l’interconnexion avec votre forge.
On va donc faire le nécessaire pour pouvoir s’authentifier grâce à l’Open Authorization 2.0 (OAuth2).

Une fois cela configuré, on ajoute notre dépôt à Woodpecker ce qui a pour conséquence de créer un Webhook dans le projet afin que ce dernier soit notifié lors d’évènements (push, pull request, …) et puisse effectuer des actions en conséquence.

Ces actions, définit dans un pipeline au sein du dépôt sont exécutées selon des contraintes qu’on lui applique.
Sans configuration particulière, le pipeline est lancé à chaque push dans le dépôt.

Hypothèses

Dans cet article, j’utiliserais les valeurs suivantes :

  • URL de Gitea : https://ennuyeux.mondomaine.mu
  • Utilisateur admin de Gitea : anup
  • URL (local) de Woodpecker : http://192.168.17.4:8000
  • URL (publique) de Woodpecker : https://pivert.mondomaine.mu

Mise en place

Gitea OAuth2

Pour générer les informations d’authentification, il faut aller dans la section Applications des paramètres de notre utilisateur (soit le lien suivant : ennuyeux.mondomaine.mu/user/settings/applications).

Dans la section Manage OAuth2 Applications, on renseigne donc nos informations :

/ci-with-woodpecker-and-hugo/img/Screenshot_1.webp
Création de l’application sur Gitea

Le Redirect URI doit obligatoirement prendre la forme <URL de Woodpecker>/authorize comme le précise la documentation.

Ainsi, on obtient alors les informations dont on aura besoin pour la suite :

/ci-with-woodpecker-and-hugo/img/Screenshot_2.webp
Obtention des informations d’authentification

On relève donc les informations Client ID et Client Secret pour après.

Woodpecker

Sur mon serveur qui va héberger Woodpecker, je crée donc le dossier qui va accueillir la configuration en Docker Compose.

Fichier docker-compose.yml :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
---
version: '3.9'

services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:latest
    container_name: woodpecker-server
    restart: unless-stopped
    environment:
      - WOODPECKER_OPEN=${MY_WOODPECKER_OPEN}
      - WOODPECKER_HOST=${MY_WOODPECKER_HOST}
      - WOODPECKER_AGENT_SECRET=${MY_WOODPECKER_AGENT_SECRET}
      - WOODPECKER_GITEA=${MY_WOODPECKER_GITEA}
      - WOODPECKER_GITEA_URL=${MY_WOODPECKER_GITEA_URL}
      - WOODPECKER_GITEA_CLIENT=${MY_WOODPECKER_GITEA_CLIENT}
      - WOODPECKER_GITEA_SECRET=${MY_WOODPECKER_GITEA_SECRET}
      - WOODPECKER_ADMIN=${MY_WOODPECKER_ADMIN}
    ports:
      - 8000:8000
    volumes:
      - /var/lib/woodpecker-server/:/var/lib/woodpecker/

  woodpecker-agent:
    image: woodpeckerci/woodpecker-agent:latest
    container_name: woodpecker-agent
    restart: unless-stopped
    depends_on:
      - woodpecker-server
    command: agent
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${MY_WOODPECKER_AGENT_SECRET}

Avec cette façon de faire, je crée un fichier .env, au même endroit que le fichier Docker Compose.
Ce dernier sera alors chargé lors du docker compose up -d.

Fichier .env :

1
2
3
4
5
6
7
8
9
MY_WOODPECKER_OPEN=True
MY_WOODPECKER_HOST=https://pivert.mondomaine.mu
MY_WOODPECKER_AGENT_SECRET=01cafe02cafe03cafe04cafe05cafe06cafe07cafe08cafe09cafe10cafecafe

MY_WOODPECKER_GITEA=True
MY_WOODPECKER_GITEA_URL=https://ennuyeux.mondomaine.mu
MY_WOODPECKER_GITEA_CLIENT=a0a0a0a0-1111-2222-3333-b4b4b4b4b4b4
MY_WOODPECKER_GITEA_SECRET=gto_azertyuiop789qsdfghjklm456wxcvbn123azertyuiop789qsdf
MY_WOODPECKER_ADMIN=anup

Les deux conteneurs que compose la stack doivent se partager un secret. J’associe donc la variable MY_WOODPECKER_AGENT_SECRET avec le résultat de la commande openssl rand -hex 32.

On renseigne ainsi les valeurs de MY_WOODPECKER_GITEA_CLIENT et MY_WOODPECKER_GITEA_SECRET avec les valeurs respectives de Client ID et Client Secret obtenue sur Gitea.

Il est important de préciser la liste des utilisateurs admin de votre forge, car dans le cas contraire, certains paramètres ne seront pas paramétrables.
Si vous avez plusieurs admin, séparer les noms dans la variable MY_WOODPECKER_ADMIN par des virgules.

Reverse Proxy (Traefik)

Ce serveur étant dans mon LAN, j’ajoute un fichier de configuration dynamique pour Traefik :

Fichier traefik/config/dynamique/woodpecker.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
---
# Woodpecker CI

http:
  routers:
    woodpecker-server:
      rule: Host(`pivert.mondomaine.mu`)
      entrypoints:
        - superoueb
      service: woodpecker-server
      tls:
        certResolver: tomandjerry
  services:
    woodpecker-server:
      loadBalancer:
        servers:
          - url: 'http://192.168.17.4:8000'

Cela permet donc à mon serveur Gitea (dans le cloud), d’accéder à mon serveur Woopecker en HTTPS. Je ne suis pas fou, je n’ai pas ouvert le 8000 sur ma box !
Vous remarquerez que la variable MY_WOODPECKER_HOST tiens compte de cela.

Configuration

Activation des dépôts

Une fois logué sur Woodpecker via notre compte Gitea, on peut activer les dépôts sur lequel on voudra bénéficier de Woodpecker via le bouton Add repository.
Ainsi, on active ceux que l’on veut en cliquant sur Enable.

Vous pourrez alors constater qu’un Webhook a été crée sur les dépôts activés (dans votre projet Gitea : Settings > Webhooks).

Création de pipeline

Pour créer un pipeline, il suffit de créer un fichier .woodpecker.yaml à la racine de votre dépôt.
Vous pouvez aussi créer plusieurs pipeline dans un dossier selon le format .woodpecker/*.yml.

Application

Clone

La première action lancée lors du lancement d’un pipeline et le clonage complet du dépôt.
Cette action est implicite, il n’y a pas besoin de la déclarer.

Moi, mon clonage ne fonctionnait pas !
Pour vous expliquer pourquoi, je résume si dessous un exemple des commandes qui sont effectués lors du clonage.

1
2
3
4
5
6
7
git init -b master
git remote add origin https://ennuyeux.mondomaine.mu/Scrample/Blog.git
git fetch --no-tags --depth=1 --filter=tree:0 origin +refs/heads/master:
git reset --hard -q a727a45843fe0463bb9c9c8de55da137fb420a0b
git submodule update --init --recursive
git lfs fetch
git lfs checkout

Le dépôt de mon blog contient mon thème en tant que submodule :

Mon fichier .gitmodules :

1
2
3
[submodule "themes/myLoveIt"]
	path = themes/myLoveIt
	url = ssh://git@ennuyeux.mondomaine.mu/Scrample/myLoveIt.git

Le problème est que ce submodule est déclaré en SSH alors que Woodpecker utilise le HTTP pour cloner.

Pour résoudre cela, deux options s’offrent à moi :

  • Passer le submodule en HTTP sur le dépôt
  • Surcharger la configuration Woodpecker pour l’utiliser HTTP

La première solution ne me plait pas car je trouve cela plus simple de gérer l’authentification via SSH.

Ayant choisi la deuxième solution, j’ai donc du explicité le pipeline de clonage :

1
2
3
4
5
6
7
8
9
clone:
  git:
    image: woodpeckerci/plugin-git
    settings:
      lfs: true
      recursive: true
      submodule_override:
        themes/myLoveIt: https://ennuyeux.mondomaine.mu/Scrample/myLoveIt.git
      branch: master

Cela veut donc dire qu’au moment où Woodpecker initie la tache de clonage, il met en cache le nouveau lien du submodule afin d’utiliser celui-ci en lieu et place de celui préciser dans le fichier .gitmodules.

Build

L’étape de construction nécessite un conteneur sur lequel hugo y est installé.

Je n’ai pas réussi à faire fonctionner le plugin Drone Hugo, j’ai donc utilisé le conteneur d’un utilisateur d’Hugo qui est cité dans la documentation d’Hugo.

Précisément, j’utilise la version spéciale CI : klakegg/hugo:ext-alpine-ci

Mon début de pipeline commence donc ainsi :

1
2
3
pipeline:
  build:
    image: klakegg/hugo:ext-alpine-ci

Vous vous attendiez à quelque chose de plus long ? 😜

Deploy

Rsync

Pour la partie déploiement, cela se complique un peu.

Je fais un rsync via SSH vers le dossier qui est exposé dans la configuration de mon conteneur Nginx.

Une fois de plus, j’ai essayé le plugin Drone rsync sans succès.
De ce que j’ai diagnostiqué, c’est l’utilisation de la clé privée qui pose problème. Après de nombreux tests infructueux, je me suis donc résolue à faire mon propre conteneur pour cela.

J’ai alors créé un Dockerfile dans mon dépôt dont le contenu est le suivant :

Fichier .docker/rsync_deploy/Dockerfile :

1
2
FROM alpine:latest
RUN apk --update add --no-cache openssh-client rsync

De ce fait, j’ai deux actions à créer :

  • Construire l’image
  • Effectuer le déploiement
1
2
3
4
5
6
7
# pipeline:
  build-deploy-image:
    image: docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    commands:
      - cd .docker/rsync_deploy; docker build -t internal/rsync_deploy:latest .
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# pipeline:
  deploy:
    image: internal/rsync_deploy:latest
    commands:
      - mkdir /root/.ssh
      - chmod 700 /root/.ssh
      - echo "$SSH_KEY_BASE64" | base64 -d >/root/.ssh/id_ecdsa
      - chmod 600 /root/.ssh/id_ecdsa
      - rsync -az -e 'ssh -p 22 -i /root/.ssh/id_ecdsa -o UserKnownHostsFile=/dev/null -o LogLevel=quiet -o StrictHostKeyChecking=no' public/ root@web.mondomaine.mu:/var/www/html/
    secrets:
      - ssh_key_base64

Hugo publie le site construit dans le dossier public. C’est donc ce dossier que je copie sur mon serveur Web.

Ajustements

Afin que cela puisse fonctionner, il faut faire deux choses sur le projet :

  • Autoriser les pipelines à monter des volumes
  • Ajouter la clé privée de manière confidentielle

Sur l’interface de Woodpecker, dans les paramètres du projet, on coche la case Trusted pour pouvoir monter des volumes (notamment le socket Docker pour pouvoir créer une image).

/ci-with-woodpecker-and-hugo/img/Screenshot_3.webp
Paramètre Trusted du projet

Pour la clé privée SSH, j’ai créé un secret dans le projet. Afin de garantir que la clé passe bien dans la variable, je l’ai convertie en base64 via la commande :

1
cat ~/.ssh/id_ecdsa | base64

Ainsi, je l’ajoute sur le projet :

/ci-with-woodpecker-and-hugo/img/Screenshot_4.webp
Ajout du secret au projet
Note
Ne vous inquiétez pas, la clé de cette copie d’écran est une fausse que j’ai créé depuis IT-TOOLS. 😉

Dans le pipeline, il faut utiliser la variable en lettres capitales (d’où le $SSH_KEY_BASE64).

Notify

Pour la notification, comme d’habitude, j’utilise Gotify.
Je me suis donc appliqué à faire une notification avec un lien cliquable qui me renvoie sur la page de mon projet.

Pour cette solution, j’utilise le plugin Drone Webhook.

Afin de gérer la notion de succès et d’échec du pipeline, je crée deux actions dont une où l’exécution est forcée en cas d’échec.
On est obligé de faire cela, car si une des actions échoue, le pipeline s’arrête (et donc les actions suivantes ne sont pas lancées).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
  notify-on-success:
    image: plugins/webhook
    settings:
      urls:
        from_secret: gotify_url_tokenized
      content_type: application/json
      template: |
        {
          "extras": {
            "client::display": {
              "contentType": "text/markdown"
            }
          },
          "title": "Building : ${CI_REPO}",
          "message": "Succes on build [${CI_BUILD_NUMBER}](${CI_BUILD_LINK}) for commit ${CI_COMMIT_SHA}",
          "priority": 5
        }        

  notify-on-failure:
    image: plugins/webhook
    settings:
      urls:
        from_secret: gotify_url_tokenized
      content_type: application/json
      template: |
        {
          "extras": {
            "client::display": {
              "contentType": "text/markdown"
            }
          },
          "title": "Building : ${CI_REPO}",
          "message": "Failed on build [${CI_BUILD_NUMBER}](${CI_BUILD_LINK}) for commit ${CI_COMMIT_SHA}",
          "priority": 5
        }        
    when:
      status:
        - failure

J’utilise ici aussi un secret (gotify_url_tokenized) qui contient l’URL de Gotify avec le token :

https://gotify.mondomaine.mu/message?token=Az3rTy

TL;DR

Voici le pipeline au complet :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
clone:
  git:
    image: woodpeckerci/plugin-git
    settings:
      lfs: true
      recursive: true
      submodule_override:
        themes/myLoveIt: https://ennuyeux.mondomaine.mu/Scrample/myLoveIt.git
      branch: master

pipeline:

  build-deploy-image:
    image: docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    commands:
      - cd .docker/rsync_deploy; docker build -t internal/rsync_deploy:latest .

  build:
    image: klakegg/hugo:ext-alpine-ci

  deploy:
    image: internal/rsync_deploy:latest
    commands:
      - mkdir /root/.ssh
      - chmod 700 /root/.ssh
      - echo "$SSH_KEY_BASE64" | base64 -d >/root/.ssh/id_ecdsa
      - chmod 600 /root/.ssh/id_ecdsa
      - rsync -az -e 'ssh -p 22 -i /root/.ssh/id_ecdsa -o UserKnownHostsFile=/dev/null -o LogLevel=quiet -o StrictHostKeyChecking=no' public/ root@web.mondomaine.mu:/var/www/html/
    secrets:
      - ssh_key_base64

  notify-on-success:
    image: plugins/webhook
    settings:
      urls:
        from_secret: gotify_url_tokenized
      content_type: application/json
      template: |
        {
          "extras": {
            "client::display": {
              "contentType": "text/markdown"
            }
          },
          "title": "Building : ${CI_REPO}",
          "message": "Succes on build [${CI_BUILD_NUMBER}](${CI_BUILD_LINK}) for commit ${CI_COMMIT_SHA}",
          "priority": 5
        }                

  notify-on-failure:
    image: plugins/webhook
    settings:
      urls:
        from_secret: gotify_url_tokenized
      content_type: application/json
      template: |
        {
          "extras": {
            "client::display": {
              "contentType": "text/markdown"
            }
          },
          "title": "Building : ${CI_REPO}",
          "message": "Failed on build [${CI_BUILD_NUMBER}](${CI_BUILD_LINK}) for commit ${CI_COMMIT_SHA}",
          "priority": 5
        }                
    when:
      status:
        - failure

Utilisation

On push alors nos modifications et on admire le lancement du pipeline :

/ci-with-woodpecker-and-hugo/img/Screenshot_5.webp
Pipeline WookpeckerCI

Ainsi que la notification sur Gotify :

/ci-with-woodpecker-and-hugo/img/Screenshot_6.webp
Notification Gotify

On voit donc ici que, après la récéption du push, la construction prend au total 31 secondes.

Conclusion

Avant de mettre en place cela, mon script de déploiement engageait mon terminal pendant une vingtaine de seconde.
C’est donc le temps que j’ai gagné grâce à cet outil !

Plus sérieusement, cela m’a permis de comprendre les notions de CI/CD.
Les problèmes que j’ai rencontrés ont eu un impact positif car cela m’a permis de faire mon premier Dockerfile et d’utiliser les Messages Extras de Gotify.

En espérant que cet article puisse aider ceux qui hésite à se lancer dans l’aventure. Bon courage à tous !