Tentative de remplacement de Drone-CI par Gitea Actions sous NixOS

  1. Contexte
  2. Activation de Actions dans Gitea
  3. Installation d'un runner
    1. Option 2 : token dans un fichier d'environnement
    2. Option 3 : token dans un fichier simple
  4. Mise à jour de la configuration
  5. Actions !
    1. Clonage
    2. Hugo
    3. Nodejs
    4. Installation des dépendances et compilation
    5. Déploiement
  6. Conclusion

Contexte

J'utilise Drone-CI depuis un moment maintenant, pour faire tout un tas de choses sympathiques :

  • publier des nouveaux articles sur ce blog
  • vérifier et compiler les firmwares utilisés dans ma domotique
  • lancer des tests unitaires et fonctionnels sur mes applications
  • etc.

Ce qui m'embête avec Drone, c'est qu'il n'y a pas de package (avec options) pour NixOS. Du coup, je dois le lancer dans un container docker avec toute l'infrastructure que ça suppose.

C'est assez simple avec NixOS, mais à l'usage, cela occasionne beaucoup de maintenance, parce que NixOS ne met pas à jour automatiquement les containers docker (non sans raison). La maintenance habituelle consiste donc à se rappeler de mettre à jour les containers (probablement l'étape la plus pénible), lancer quelques commandes docker pull sur ceux qui ont été mis à jour, nettoyer les vieilles images, etc.

Donc, moins j'ai de containers docker, mieux je me porte, sans même aborder les questions de philosophie personnelle.

Gitea a introduit il y a peu la version 1.19, et avec elle, Gitea Actions, son propre système d'automatisation (plus qu'une simple CI-CD), dont tout le concept est pompé sur Github Action. La compatibilité est très large, de quoi s'assurer une migration facile des utilisateurs depuis Github vers leur propre plateforme.

Et NixOS dispose depuis la version 23.05 des paquets qui vont bien : la maintenance se limitera à la mise à jour du système via la traditionnelle commande nixos-rebuild switch --upgrade, et basta.

Activation de Actions dans Gitea

{
  services.gitea = {
    # ...
    settings = {
      #...
      actions = {
        ENABLED = true;
        DEFAULT_ACTIONS_URL = "<URL de votre instance Gitea>";
      };
    };
  };
}

Précision importante : les "actions" qui seront exécutées correspondent à des dépôts git distants qui contiennent le code nécessaire à leur exécution. Dans le cas de Github Action, ces dépôts distants sont tous hébergés par Github et accessibles depuis le marketplace. Dans le cas de Gitea, les actions sont hébergées n'importe où, Github compris, mais par défaut, elles seront récupérées depuis Gitea.

Or, toutes les actions du marketplace de Github ne sont pas disponibles depuis Gitea.

Le paramètre DEFAULT_ACTIONS_URL est optionnel : s'il est laissé vide, Gitea Actions tentera de récupérer l'action correspondante depuis Gitea (à moins qu'une URL absolue ne soit fournie dans le workspace, mais nous verrons ça plus tard). La philosophie du concept appelle à auto-héberger les actions dont on a besoin sur sa propre instance de Gitea. En renseignant l'URL de votre propre instance dans ce paramètre, vous direz à Gitea d'aller chercher les actions demandées sur votre instance de Gitea, pour toute action dont on utilisera la notation courte (donc hors URL absolue).

Installation d'un runner

L'installation d'un runner est facile mais nécessite certaines précisions.

Tout d'abord, il faut obtenir un token depuis Gitea. Selon où vous le demandez, il est possible que le runner soit attribué à un unique projet, à une organisation, ou à toutes les organisations de l'instance. Dans tous les cas, c'est dans les paramètres (du projet, de l'organisation, ou de l'instance de Gitea) qu'il faut aller, puis dans l'onglet "Runners". On clique ensuite sur le bouton "Create runner", et on copie le token.

Pour intégrer ce token à notre configuration de NixOS, nous avons plusieurs options :

  1. coller le token directement dans la configuration : c'est peu sécurisé, surtout si versionnez votre configuration de NixOS
  2. coller le token dans un fichier d'environnement, où il prendra la valeur d'une variable nommée TOKEN
  3. coller le token dans un fichier simple qui sera lu par NixOS au moment de la reconfiguration : c'est l'option que j'ai choisi, simplement pour des raisons cosmétiques

Considérons mon bloc de configuration :

{
  services.gitea-actions-runner = {
    instances = {
      nom-du-runner = {
        enable = true;
        name = "<nom du runner>";
        url = "<URL de votre instance Gitea>";
        token = builtins.readFile ./gitea-actions-runner-token;
        labels = [
          "ubuntu-latest:docker://node:18-bullseye"
          "native:host"
        ];
      };
    };
  };
}

Commençons par préciser que l'on peut disposer de plusieurs runners sur la même machine, quoique pour des raisons de performances, on évitera. Chaque runner doit porter un nom unique qui l'identifiera dans Gitea. On se servira de ce nom plus tard, dans le cas où l'on veut cibler un runner particulier au sein d'une action donnée. Le nom du runner sera à ajuster, ici aux lignes 4 et 6.

Option 2 : token dans un fichier d'environnement

Pour le token, si vous choisissez de le coller dans un fichier d'environnement, il faut créer le fichier avec le contenu suivant :

TOKEN="<token>"

Où, évidemment, on remplacera <token> par le token copié précédemment. Du coup, la ligne 8 doit être modifiée, et le paramètre doit pointer vers le fichier ainsi créé :

tokenFile = ./gitea-actions-runner.env;

Option 3 : token dans un fichier simple

Si vous avez plutôt choisi la troisième option, NixOS va peupler le paramètre token avec le contenu d'un fichier qu'il va lire au moment de la reconfiguration (c'est ce que fait builtins.readFile). La création de ce fichier se fait avec la commande suivante :

echo -n "<token>" > ./gitea-actions-runner-token

Pour rappel, l'option -n permet d'éviter la ligne vide en fin de fichier (ce qui invaliderait le token), et là aussi, on remplacera <token> par le token copié précédemment.


Ensuite, on doit configurer des labels. En - très - gros, il s'agit d'associer un nom à un container dans lequel seront exécutées les actions ou à la machine hôte.

Le package NixOS ne propose pas encore de valeur par défaut, et ce paramètre ne peut pas être vide. Heureusement, la documentation de Gitea nous indique les valeurs utilisées par défaut par l'exécutable - mais sont obsolètes. Je me suis inspiré de la valeur d'exemple donnée par NixOS pour ma propre configuration.

Dans l'exemple que je vous montre, je pourrai lancer mes actions sur "ubuntu-latest", qui correspond à l'image docker de nodejs v18 sur une debian bullseye. Et ce n'est qu'en écrivant ces lignes que je me rends compte que cet exemple est mauvais : le label ubuntu-latest ne correspond en rien à l'image utilisée… Je propose au lecteur, en guise d'exercice, de modifier cette ligne comme bon lui semblera 😊

Autre label que j'utilise, native:host, qui permettra de lancer les actions sur la cible native, la machine hôte. Pour des raisons de sécurité, c'est déconseillé dans le cas où vous ne maîtrisez pas les actions qui seront lancées (par exemple, si vous avez des dépôts publics accessibles en écriture). Dans mon cas, où personne à part moi n'a accès en écriture à mon instance, je peux me le permettre.

Mise à jour de la configuration

Il ne reste plus qu'à faire :

nixos-rebuild switch

Et tout devrait s'installer sans problème.

Actions !

Arrivé là, vous avez un Gitea Actions fonctionnel, il faudra juste se farcir la documentation des Actions de Github. Vous n'êtes pas obligé de lire ce qui vient parce que je vais râler fort, vous voilà prévenu.

Bon, là, je vous avoue, ça se corse un peu. Je n'ai jamais utilisé Github Action, donc j'avance en terre inconnue. Et honnêtement, pour le moment, ça ne me donne pas vraiment envie. Autant évacuer tout de suite mes griefs.

Je trouve ça absurdement compliqué. Comparé à Drone, où l'on lance un container docker dans lequel on exécute une série de commandes, avec Actions, on effectue grosso-modo un git clone de chaque dépôt correspondant à une action. Si le processus comprend 20 actions, il y aura 20 clonages à faire. Prenons un exemple concret : la publication de mon blog.

Clonage

La première étape de tout processus en CI-CD consiste à obtenir un accès au code source, et cela passe généralement par le clonage du dépôt d'origine. Avec Gitea Actions, cela passe - par exemple - par le "code" yaml suivant :

name: Récupération des sources
  uses: actions/checkout@v3

Donc, pour récupérer mes sources sur mon serveur, il faut d'abord cloner le dépôt https://github.com/actions/checkout. Surprise : c'est tout en javascript. Le redistribuable pèse pas loin d'1Mo de javascript, et les sources sont imbitables et en plus pas documentées. Mais que fait donc tout ce javascript que le binaire git ne fait pas, à part quelques options de configuration ? Est-ce que 1Mo de javascript sont nécessaires pour passer de :

git clone <URL de mon dépôt>

à :

name: Récupération des sources
  uses: actions/checkout@v3

? Réponse partielle : si l'exécutable git n'est pas disponible sur le système, on passe par l'API de Github et là ça se complique vraiment. En outre, j'ai remarqué quelques portions de code consacrées à la télémétrie…

Donc, premier grief, on retrouve la philosophie nodejs de télécharger la moitié d'internet pour lancer une simple commande. Et, puisque j'ai choisi d'auto-héberger les actions, je m'inflige l'obligation de garder les dépôts locaux synchronisés avec les dépôts distants.

Deuxième grief, la télémétrie, donc. Surtout quand elle n'est pas mentionnée dans la documentation, et qu'elle n'est pas optionnelle. Comme quoi, ce n'est pas parce qu'on opte pour la licence MIT qu'on est forcément très transparent…

Troisième grief, l'option LFS. J'utilise LFS pour stocker différents médias utilisés notamment sur mon blog, tels que les images et les vidéos. Pour "activer" l'usage de LFS, il faut rajouter une ligne :

name: Récupération des sources
  uses: actions/checkout@v3
  with:
    lfs: true

Je soupçonne là encore une intégration avec l'API de Github, parce qu'au niveau de git, il n'est nullement nécessaire de préciser qu'on veut utiliser LFS lors d'un git clone. Mais sans cette option, le clonage de mon dépôt se fait sans LFS sans que je sois au courant. Ce n'est que bien plus tard, lors de la compilation des fichiers du site, que Hugo plante en essayant d'accéder à des fichiers qui n'existent pas.

Et c'est symptomatique d'absolument tout l'écosystème javascript (et Go, d'ailleurs) : rien, absolument rien n'est intuitif. Tout est - mal - repensé, tout est superposition d'abstractions, et un utilisateur habitué à utiliser des outils de bas niveau ne comprend plus rien au fonctionnement des applications.

Ce fonctionnement contre-intuitif m'a forcé à remettre en question toute la chaîne de production de mon blog : est-ce que j'ai introduit un bug dans la gestion des images ? est-ce que j'utilise la bonne version d'Hugo ? me manque-t'il une dépendance ? pourquoi ça fonctionne sous Drone et pas sous Actions ? quelles différences entre les images dockers ? etc.

À aucun moment je n'ai eu droit à un message explicite. Et c'est encore une fois symptomatique de la philosophie moderne du développement : on doit cacher les erreurs et les problèmes à l'utilisateur pour ne pas créer de situations anxiogènes. On n'arrête l'exécution d'un processus qu'en cas de situation critique. On doit poursuivre l'exécution coûte que coûte.

Résultat, Hugo refuse de compiler certains templates parce que le "format d'une image est incorrect". Je modifie le template en question, essayant de corriger un bug qui n'existe pas. Je relance la CI, je dois attendre d'arriver à la compilation des ressources après avoir téléchargé la moitié d'Internet pour la centième fois, et constater que le "bug" s'est "étendu" à d'autres fichiers.

Quand une application me dit "format d'image incorrect", je me dis qu'il manque une librairie particulière (du genre libpng ou libjpeg). Je ne pense pas du tout à activer une option yaml pour que le clonage se fasse avec LFS…

C'est pas fini…

Hugo

Prochaine étape, "initialiser" Hugo. Cela passe par l'action actions-hugo, qui permet de choisir une version du générateur de sites statiques, entre autres.

name: Initialisation de Hugo
  uses: actions/actions-hugo@v2
  with:
    hugo-version: "latest"
    extended: true

Ce n'est pas comme s'il était déjà installé dans mon image docker… (qui diffère de celle proposée dans la configuration du runner donnée plus haut). Donc, pareil : le dépôt de l'action est cloné, Hugo est téléchargé… Heureusement que je suis enfin passé à la fibre optique, j'imagine le temps que ça m'aurait pris de débugguer tout ça sur de l'ADSL…

Nodejs

De façon similaire à Hugo, il faut initialiser nodejs…

name: Initialisation de node.js
  uses: actions/setup-node@v3
  with:
    node-version: "latest"

Rebelotte, encore un dépôt externe à cloner.

Installation des dépendances et compilation

Ça y est, c'est l'étape où je peux faire un npm i pour installer quelques dépendances pour construire mon blog.

C'est littéralement l'étape 1 dans un processus exploitant Drone :

kind: pipeline
type: docker
name: publish

steps:
  - name: build
    image: klakegg/hugo:ext-alpine-ci
    commands:
      - npm i
      - npm run prod

Pour le coup, cette étape ne présente aucune difficulté avec Gitea Actions (même si je l'ai scindée ici), rien à cloner :

name: Installation des dépendances
  run: npm i
name: Construction du site
  run: npm run prod

Déploiement

Dernier clou dans le cercueil de Gitea Actions : impossible, dans la version 1.19.3 fournie par NixOS, d'exploiter les secrets nouvellement introduits pour stocker une clé privée. Sauf que là encore, aucun message d'erreur n'est assez explicite pour me le dire. Mais c'est de ma faute, j'aurai dû mieux lire ce qui est à l'écran avant de me plaindre. Explications.

Pour déployer, je passe par rsync. J'ai donc besoin de quelques informations de base mais qui doivent rester confidentielles, notamment l'utilisateur SSH et sa clé privée. Ça tombe bien, Gitea a introduit le stockage de secrets dans sa version 1.19, justement pour qu'on puisse s'en servir dans les actions. Tout à la fois content de trouver cette fonctionnalité assez attendue parmi les utilisateurs et inquiet qu'un truc tourne mal, je renseigne vite fait les quelques secrets dont j'ai besoin - dont cette fameuse clé privée - et j'ajoute l'action à mon projet :

name: Déploiement
  uses: https://github.com/easingthemes/ssh-deploy@main
  env:
    SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    ARGS: "-rlgoDzvc -i --delete"
    SOURCE: "public/"
    REMOTE_HOST: ${{ secrets.SSH_HOST }}
    REMOTE_USER: ${{ secrets.SSH_USER }}
    TARGET: ${{ secrets.TARGET }}
    EXCLUDE: "/node_modules/"

Je lance, j'attends qu'on arrive à cette étape, et ça crashe, avec un message me disant que ma clé est invalide, dans une magistrale illustration de ma remarque d'avant (on poursuit d'exécution quoiqu'il arrive). L'action a spammé mon serveur SSH après son infructueuse tentative d'utilisation de ma clé "invalide", continuant chaque étape comme si la clé avait été acceptée, pour faire… je ne sais pas trop quoi en fait, je voulais juste un rsync moi…

[DIR] Creating /***/.ssh dir in workspace ***
[DIR] dir created.
[FILE] writing /***/.ssh/known_hosts file ... 0
[SSH] known_hosts file ensured /***/.ssh
[DIR] /***/.ssh dir exist
[FILE] writing /***/.ssh/deploy_key_***_1686345375116 file ... 457
[SSH] key added to `.ssh` dir  /***/.ssh deploy_key_***_1686345375116
rsync  version 3.2.3  protocol version 31
Copyright (C) 1996-2020 by Andrew Tridgell, Wayne Davison, and others.
Web site: https://rsync.samba.org/
Capabilities:
    64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,
    socketpairs, hardlinks, hardlink-specials, symlinks, IPv6, atimes,
    batchfiles, inplace, append, ACLs, xattrs, optional protect-args, iconv,
    symtimes, prealloc, stop-at, no crtimes
Optimizations:
    SIMD, asm, openssl-crypto
Checksum list:
    xxh128 xxh3 xxh64 (xxhash) md5 md4 none
Compress list:
    zstd lz4 zlibx zlib none

rsync comes with ABSOLUTELY NO WARRANTY.  This is free software, and you
are welcome to redistribute it under certain conditions.  See the GNU
General Public Licence for details.
✅️ [CLI] Rsync exists
[Rsync] Starting Rsync Action: /workspace/Blog/blog/public/ to ***@***:***
[Rsync] excluding folders /node_modules/
Warning: Permanently added '***' (ED25519) to the list of known hosts.

Load key "/***/.ssh/deploy_key_***_1686345375116": invalid format

Permission denied, please try again.

Permission denied, please try again.

Received disconnect from *** port 22:2: Too many authentication failures
Disconnected from *** port 22

rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: unexplained error (code 255) at io.c(228) [sender=3.2.3]

[Rsync] error:
Error: rsync exited with code 255
    at ChildProcess.<anonymous> (/run/act/actions/https---github.com-easingthemes-ssh-deploy@main/dist/index.js:2:2603)
    at ChildProcess.emit (node:events:511:28)
    at ChildProcess._handle.onexit (node:internal/child_process:293:12) {
  code: 255
}
[Rsync] stderr:
Warning: Permanently added '***' (ED25519) to the list of known hosts.
Load key "/***/.ssh/deploy_key_***_1686345375116": invalid format
Permission denied, please try again.
Permission denied, please try again.
Received disconnect from *** port 22:2: Too many authentication failures
Disconnected from *** port 22
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: unexplained error (code 255) at io.c(228) [sender=3.2.3]

❌️ [Rsync] stdout:

[Rsync] command:
================================================================
rsync /workspace/Blog/blog/public/ ***@***:*** --rsh "ssh -p 22 -i /***/.ssh/deploy_key_***_1686345375116 -o StrictHostKeyChecking=no" --recursive --exclude=/node_modules/ -rlgoDzvc -i --delete
================================================================
[ERROR] rsync exited with code 255

Warning: Permanently added '***' (ED25519) to the list of known hosts.
Load key "/***/.ssh/deploy_key_***_1686345375116": invalid format
Permission denied, please try again.
Permission denied, please try again.
Received disconnect from *** port 22:2: Too many authentication failures
Disconnected from *** port 22
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: unexplained error (code 255) at io.c(228) [sender=3.2.3]

Pourtant, cette même clé privée était utilisée par Drone depuis quelques années, sans le moindre problème.

En fait, c'est Gitea qu'il faut pointer du doigt ici. Le formulaire pour soumettre un secret supprime les espaces et les lignes vides, au début et à la fin de la chaîne soumise. Or, une ligne vide est exigée en fin de chaîne pour qu'une clé privée soit considérée valide. Et Gitea m'a prévenu : dans la zone de texte, où je colle ma clé privée, il est clairement indiqué que les espaces vides au début et en fin de chaîne seront supprimés, ce que je n'ai vu qu'après une journée de recherche de tout ce qui peut bien merder avec ma clé privée. Et comme c'est un secret, je ne peux même pas essayer de l'afficher pour la débugguer, et vu qu'elle aurait de toute façon été formatée pour l'affichage dans l'onglet de la CI, je n'aurai probablement pas vu que la ligne vide finale était absente.

Mais de toute façon, je ne cherchais pas un problème avec ma clé de prime-abord puisqu'elle a toujours fonctionné. J'ai cherché un problème dans la gestion des secrets, leur expression, et leur usage, qui se fait avec les outils de templating de Go, et qui m'insupportent.

Conclusion

J'ai perdu trop de temps pour arriver à un résultat non-fonctionnel. C'est très dommage, parce le concept et son intégration sont bons, dans l'idée.

Ce système permet l'exécution des actions par matrices, ce qui simplifie considérablement les tests sur plusieurs plateformes différentes (par exemple Laravel 9 avec PHP 7 et PHP 8 d'une part, Laravel 10 et PHP 8 d'autre part). Il permet aussi le partage d'actions préfabriquées par la communauté.

Mais tant que le système devra s'appuyer sur les actions Github existantes, il sera grévé par du code de mauvaise qualité, dans lequel on ne peut pas avoir confiance, et qui a le potentiel d'introduire du code malveillant dans des composants plutôt sensibles de la chaîne de production de code, et ce de façon à peu près inaperçue.

Avec Drone, on choisi bien son container docker, ou on le construit soi-même, et c'est tout : chaque étape est une simple commande. L'intégration n'est pas aussi poussée que Gitea Actions, ce qui n'est pas forcément une mauvaise chose tant que Gitea nous permet de faire ce qu'on veut avec les hooks. Mais au moins, ça fonctionne, et si la moindre commande retourne une erreur, tout s'arrête. Et en plus on peut être notifié via des canaux de boomers, genre les emails. Sérieusement, dans le milieu Go/JS, vous savez qu'il y a des alternatives à Slack ?

Du coup, pour le moment, Gitea Actions, c'est non pour moi. Le logiciel est léger, mais ce qui en est fait est d'une lourdeur et d'une pénibilité bien supérieure à celle de maintenir un container docker. Les habitués de Github ne devraient pas avoir trop de mal à s'en sortir, ils continueront probablement d'utiliser les actions qu'ils ont toujours utilisé. Mais moi qui n'y ais jamais mis les pieds, c'est, je le répète, absurdement compliqué.

En ce qui me concerne, j'arrête Gitea Actions, et je vais creuser du côté de Woodpecker, fork de Drone qui dispose de paquets pour NixOS.

Le pire c'est que pour une fois, je ne suis pas fier de mon rant : j'avais vraiment envie que ça fonctionne, que ça me plaise, que ça me corresponde, que ça m'enthousiasme. Mais j'en suis très loin…

Plus d'informations