Rendre Les Scripts Bash Lisibles Par Un Dev JS
Nous avons choisi récemment dans l'équipe avec la quelle je travaille de nous séparer de nos outil actuels de CI. À cette occasion, j'ai retravaillé les scripts bash pour intégrer des fonctionnalités issues de la CLI GitHub. Et reprendre des scripts qui ont été réalisé par d'autres m'ont fait prendre conscience d'une chose.
Bash Est Illisible
Bash... C'est le paradis des oneliners, des engeances démoniaques, suscitant autant l'incompréhension que la consternation... C'est ce genre de oneliners :
find . -type f -empty -prune -o -type f -printf "%s\t" -exec file --brief --mime-type '{}' \; | awk 'BEGIN {printf("%12s\t%12s\n","bytes","type")} {type=$2; a[type]+=$1} END {for (i in a) printf("%12u\t%12s\n", a[i], i)|"sort -nr"}'
Pour les admins les plus aguerris, cette commande se lit comme un "Martine est un poweruser". Cependant pour les devs avec avec une appétence pour l'ops qui ne pratiquent pas assez, la lecture tient du casse-tête.
C'est pour nous épargner ce genre d'atrocités que Google met à disposition : Google ZX ! Google ZX, c´est la promesse d'un monde meilleur ! Des potits lapins et des potits chats trop mignons dans un monde tout joyeux joyeux!
Google ZX
Google ZX, c'est un wrapper NodeJS pour Bash sous forme d'une librairie. Donc exécuter du bash dans du code JS. Je ne pense pas que le bash soit particulièrement compliqué à comprendre en soi, c'est une question de pratique comme je le disais, mais je lui trouve deux très gros défauts : Il est pratiquement illisible au premier coup d’œil et il est donc difficilement maintenable. C'est pourquoi quand j'ai eu vent de cette librairie qui promet tant, j'ai décidé de l'essayer.
Installation Et Premiers Pas Avec ZX
Les prérequis :
- Être sur un hôte Linux afin de tester les commandes,
- avoir installé Node (16+).
J'installe la lib dans mon projet :
yarn add -D zx
Étape suivante, je crée un fichier script.mjs
(extension nécessaire pour ZX) et je complète la première ligne qui est un shebang un peu spécial :
(Le shebang est là pour spécifier que l'on se trouve dans un script et permet de définir l’interpréteur qui exécutera les lignes de commandes)
#!/usr/bin/env zx
Avant de pouvoir exécuter ce script je dois lui attribuer les droits d'exécution avec un chmod +x script.js
Voilà, j'ai tout ce qu'il faut pour tester ma première commande.
Afin de faciliter mes premiers tests, j'ai généré un json appelé test.json
.
J'aimerais stocker le contenu du json dans une variable. Instinctivement en bash, je ferais ceci :
BASE_JSON= $(cat test.jon)
La transformation en javascrip avec ZX donne ceci :
const baseJson = await $`cat test.json`;
Pour décortiquer nous avons :
await
: Le shell étant asynchrone, tous les appels seront effectués avec untop level await
.$
: C'est la fonction générale de ZX qui permet d'appeler les commandes bash entourées de backticks.
Premier constat, on a une sortie en console, ça a l'air de fonctionner. Deuxième constat, le linter n'aime pas du tout cette syntaxe.
Je règle ça en ajoutant l'import de la commande dans le fichier :
import { $ } from 'zx'
Le linter est presque content, il ne reste plus que l'await
qui le chagrine.
J'ajoute un type "module" dans le package.json et il redevient de bonne humeur.
"name": "zx-hackday",
"version": "1.0.0",
"description": "HD about google zx",
"main": "index.mjs",
"type": "module",
Je vais pouvoir tester des choses. Pour commencer, je transforme la sortie du cat en JSON utilisable dans du JS.
const baseJson = $`cat test.json`;
const people = JSON.parse(baseJson.toString());
console.log(people);
Essayer Sans Comprendre
J'ai l'affichage du cat
et celui du log
...
Ceci dit, c'est normal, puisque le stdout/err est bien géré par ZX.
Mais ZX a des options !
Dont celle qui va nous intéresser $.verbose = false
qui va désactiver la sortie du stdout.
On ne voudra pas abuser de cette option, la sortie des commandes bash est souvent d'une grande aide en cas de problème.
const jsonFile = 'test.json';
$.verbose = false;
const baseJson = $`cat ${jsonFile}`;
$.verbose = true;
const people = JSON.parse(baseJson.toString());
console.log(people);
La commande est moins verbeuse, parfait ! Mais je trouve assez fastidieux de devoir l'écrire pour chaque commande dont je ne veux pas le stdout. Customisons un peu les choses en l'intégrant dans une fonction afin de l'appeler en cas de besoin.
const noOut = args => {
$.verbose = false;
$`${args}`;
$.verbose = true;
};
L’approche est un peu candide mais testons. Résultat : ça ne fonctionne pas. J'ai essayé rapidement quelques méthodes mais je ne vois pas. Je n'aime pas quand je ne comprends pas. et que fait-on quand on ne comprend pas ?
On Cherche À Comprendre
Allons faire un tour dans le code de la lib
On voit alors que la ref $
est une simple fonction qui récupère des arguments. La notation des doubles backticks me rappelle tout de même les templates litterals
.
Je me rends sur la doc MDN et je découvre alors les tagged templates.
Le fonctionnement est assez particulier. Si on regarde la spec, la signature de la fonction ressemble à ça :
const myTag = (strings, ...args) {}
Ce qui se passe derrière c'est que chaque élément de la chaine entre backticks est un élément du tableau strings[]
et ensuite on a les expressions pour l'interpolation.
Donc, un tagged template, c'est une fonction appelée de manière un peu singulière.
Voici ma fonction modifiée :
export const noOut = (command, args) => {
$.verbose = false;
$(command, args);
$.verbose = true;
};
Et l'appel :
const baseJson = noOut(`cat ${jsonFile}`);
Victoire ! On a maintenant une version silencieuse à la demande, mais surtout, je comprends mieux la mécanique derrière GoogleZX.
Transformation D'Un Script Bash vers JS
Après des premiers tests pour comprendre le fonctionnement, il est temps de passer aux choses sérieuses. J'ai un script bash que j'aimerais transformer :
downloadFile(){
if [ $RELEASE_TAG == "notag" ]; then
if [[ ${GIT_REFERENCE_SHORT} == "master" || ${GIT_REFERENCE_SHORT} == "main" || ${GIT_REFERENCE_SHORT} == "develop" || ${GIT_REFERENCE_SHORT} == "demo" ]]; then
echo "Downloading release from the last ${GIT_REFERENCE_SHORT} build"
runID=$(gh run list --branch ${GIT_REFERENCE_SHORT} --workflow "NodeJS Build CI" --repo ${GITHUB_REPOSITORY} --json "url,conclusion,databaseId" --jq "[.[] | select(.conclusion==\"success\")][0] | .databaseId")
if [ -z "$runID" ]; then
echo "No successfull NodeJS Build CI run found"
exit 0
fi
gh run download ${runID} --repo ${GITHUB_REPOSITORY} -n release
echo "Download successfull"
extractTar
else
echo "Downloading release ${GIT_REFERENCE_SHORT}"
makeRelease
echo "Download and extraction successfull"
fi
else
echo "Tag found, downloading release ${RELEASE_TAG}"
makeRelease
fi
}
Le nœud du problème dans cette portion de code, c'est cette ligne :
runID=$(gh run list --branch ${GIT_REFERENCE_SHORT} --workflow "NodeJS Build CI" --repo ${GITHUB_REPOSITORY} --json "url,conclusion,databaseId" --jq "[.[] | select(.conclusion==\"success\")][0] | .databaseId")
JQ est un outil qui permet de parser du JSON en ligne de commande.
Bien que compréhensible, le flag jq
hérisse le poil de ceux qui ne connaissent pas sa syntaxe qui est parfois compliquée à lire.
Après un peu de réflexion j'arrive à ce résultat en JS :
const allowedBranchesReferences = ['master', 'main', 'develop', 'demo'];
const downloadFile = (reference, repository, tag) => {
if (tag) {
return downloadFileWithTag(reference, repository, tag);
}
return downloadFileWithoutTag(reference, repository);
};
const getLastRuns = async (reference, repository) => {
const shLastRuns = await $`gh run list --branch ${reference} --workflow "NodeJS Build CI" --repo ${repository} --json "url,conclusion,databaseId,updatedAt"`;
const lastRuns = JSON.parse(shLastRuns.toString());
return lastRuns;
};
const getLastSuccessfulRunID = (reference, repository) => {
const lastRuns = getLastRuns(reference, repository);
const sucessfulRuns = lastRuns.filter(run => run.conclusion === 'success');
const lastSuccessfulRun = sucessfulRuns[0];
return lastSuccessfulRun.databaseId;
};
export const downloadFileWithoutTag = async (reference, repository) => {
if (allowedBranchesReferences.includes(reference)) {
const runId = getLastSuccessfulRunID(reference, repository);
if (!runId) {
displayErrorAndExit('No successful NodeJS build found');
}
$`gh run download ${runId} --repo ${repository} -n release`;
extractTar();
} else {
$`echo your branch has no automation for deployment`;
}
};
const displayErrorAndExit = errorMessage => {
$`echo ${errorMessage} & exit 1`;
};
const downloadFileWithTag = (reference, repository, tag) => {
/* code */
};
Pour être clair, oui, c'est beaucoup plus "verbeux", mais c'est tellement plus compréhensible pour quelqu'un qui ne fait pas de bash. Et qui pourra, cerise sur le gâteau : intervenir sur ce morceau de code sans un master 2 en bash.
ZX Est Pertinent
Le bash, c'est le royaume de la concision au détriment de la lisibilité. Ce qui va à l'encontre de ce qu'on fait chez Marmelab avec JS : du code compréhensible et maintenable.
La grande force de ZX, c'est de pouvoir "dépiler" des lignes interminables et incompréhensibles en un code clair et organisé. Bien-sûr, la lib ne permet pas tout et contient encore quelques rares bugs ci et là. Mais c'est du Javascript et c'est donc adaptable en fonction des besoins pour peu que l'on mette un petit peu les mains dans le moteur. J'ai pris soin de faire lire les deux versions à des collègues et le constat est clair, la version JS du script est beaucoup plus compréhensible que la version bash.
En bref, je suis très satisfait de Google ZX. C'est assez simple d'accès mais surtout, ça solutionne une problématique que l'on rencontre fréquemment.
Je pense que sur les projets sur les quels des dev JS doivent intervenir régulièrement et qui comprend une partie de scripting bash, je pousserai à l'utilisation de ZX. Et tous les dev JS pourront enfin dire : Moi aussi, je pratique le bash ou presque.