GitLab DevOps et déploiement en continu par FTP – Partie 2
Il y a deux semaines, j’ai publié un article avec pour objectif de créer un script exécutable par un pipeline GitLab pour déployer des fichiers par FTP sur un serveur en se basant sur l’historique des commits dans git. Cette semaine, je reprends donc où j’avais laissé.
Pour résumer, on avait une liste d’actions (add, edit, delete et moved/rename) faites sur les fichiers par commits directement de notre dépôt. Ça ressemblait à :
1 2 3 4 5 |
{ status: 'move', path: '/www/test.txt', previousPath: '/test.txt' } |
1 |
{ status: 'edit', path: '/deployer/ftp-functions.js' } |
1 |
{ status: 'delete', path: '/www/test.txt' } |
Ou encore
1 |
{ status: 'add', path: '/test.txt' } |
Convertisseur pour les entrées git
Comme je l’ai expliqué en conclusion de la première partie, si on dépile les entrées d’opérations git dans le bon ordre, on va arriver au bon résultat de toute façon. Par contre, ce ne serait pas très optimal et rappelons-nous que l’objectif de ce petit projet est d’éviter une copie de tous les fichiers du dépôt en supposant que nous supportions une application de plusieurs mégaoctets. Ce serait évidemment contre productif de déployer deux fois le même fichier parce qu’il a été modifié dans deux commits différents.
L’objectif de ce convertisseur est donc de transformer les objets reçus par notre utilitaire git en objets qui seront passés en paramètres à un utilitaire FTP. Ici, je vous épargne la construction de l’algorithme parce qu’il ne fait appel qu’à une logique simple que, je suis persuadé, vous êtes assez créatifs pour inventer par vous-même. En gros, j’exécute les opérations sur une tableau. Le tableau qui restera à la fin sera le résultat des opérations FTP à faire. Je vous laisse quand même le code :
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 |
module.exports = class Git2FtpFunctions { convert(gitActions) { const mergedGitActions = gitActions.flatMap(x => x); const ftpActions = []; for (let i = mergedGitActions.length - 1; i >= 0; i--) { const gitAction = mergedGitActions[i]; switch (gitAction.status) { case 'add': addCopyNewIfNotInList(gitAction.path, ftpActions); break; case 'edit': addCopyIfNotInList(gitAction.path, ftpActions); break; case 'move': addCopyNewIfNotInList(gitAction.path, ftpActions); addDeleteIfNotInList(gitAction.previousPath, ftpActions); break; case 'delete': addDeleteIfNotInList(gitAction.path, ftpActions); break; } } return ftpActions; } } const addCopyNewIfNotInList = (filePath, ftpActions) => { let existingFtpAction = ftpActions.find(ftpAction => ftpAction.path === filePath); if (existingFtpAction && existingFtpAction.status === 'delete') { existingFtpAction.status = 'copy'; } else if (existingFtpAction && existingFtpAction.status !== 'delete') { existingFtpAction.status = 'copy-new'; } else { ftpActions.push({ status: 'copy-new', path: filePath }); } } const addCopyIfNotInList = (filePath, ftpActions) => { let existingFtpAction = ftpActions.find(ftpAction => ftpAction.path === filePath); if (existingFtpAction && existingFtpAction.status !== 'copy-new') { existingFtpAction.status = 'copy'; } else if (!existingFtpAction) { ftpActions.push({ status: 'copy', path: filePath }); } } const addDeleteIfNotInList = (filePath, ftpActions) => { const existingFtpAction = ftpActions.find(ftpAction => ftpAction.path === filePath); if (existingFtpAction && existingFtpAction.status !== 'copy-new') { existingFtpAction.status = 'delete'; } else if (existingFtpAction && existingFtpAction.status === 'copy-new') { const idx = ftpActions.findIndex(ftpAction => ftpAction.path === filePath); ftpActions.splice(idx, 1); } else { ftpActions.push({ status: 'delete', path: filePath }); } } |
Il y a deux choses importantes ici. Dans un premier temps, on parcours la liste des commits dans le désordre (plus vieux au plus récent) pour garder l’historique des opérations sur les fichiers. On arrive donc avec une suite d’actions FTP se résumant à copier un fichier local sur le serveur ou supprimer un fichier du serveur. Le but de l’exercice ici c’est, par exemple, de ne pas copier un fichier s’il a été ajouté dans un commit, puis supprimé dans un autre plus récent.
Exécution du déploiement
Pour faire la connexion FTP, j’utiliserai ssh2-sftp-client, un client FTP node qui supporte SSH. Vous connaissez la chanson :
1 |
npm i ssh2-sftp-client |
Puis, créons notre classe avec la méthode pour qu’elle soit exécutable de façon asynchrone :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const FtpClient = require('ssh2-sftp-client'); module.exports = class FtpFunctions { constructor() { this.ftp = new FtpClient(); } executeCommands(ftpActions) { return new Promise(async (resolve, reject) => { try { resolve(); } catch (ex) { reject(ex); } }) } } |
Maintenant, rien de plus simple. On ouvre au connexion avec le serveur :
1 2 3 4 5 6 |
await this.ftp.connect({ "host": "sftp.example.com", "port": "22", "username": "usr", "password": "mdp" }); |
On copie/supprime les fichiers :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
for (let i = 0; i < ftpActions.length; i++) { const ftpAction = ftpActions[i]; const localPath = '/home/test/project' + ftpAction.path; const remotePath = '/srv/myapp/project' + ftpAction.path; switch (ftpAction.status) { case 'copy': case 'copy-new': await this.ftp.put(localPath, remotePath); break; case 'delete': await this.ftp.delete(remotePath); break; } } |
Puis on ferme la connexion FTP :
1 |
await this.ftp.end(); |
Modifions maintenant deploy.js pour appeler notre nouvelle méthode :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
const request = require('request-promise'); const GitFunctions = require('./git-functions'); const Git2FtpFunfctions = require('./git-2-ftp-functions'); const FtpFunctions = require('./ftp-functions'); const git = new GitFunctions(); const git2ftp = new Git2FtpFunfctions(); const ftp = new FtpFunctions(); const deploy = async () => { const deployInfo = JSON.parse(await request('https://your-awesome-application/deploy.json')); const lastCommitDeployedHash = deployInfo.lastCommitDeployed; const commitsToDeploy = await git.fetchCommitsSinceHash(lastCommitDeployedHash); const ftpActions = git2ftp.convert(commitsToDeploy); ftp.executeCommands(ftpActions); }; deploy(); |
Mise à jour du deploy.json
Une fois que les fichiers sont copiés, il nous faut mettre à jour le fichier deploy.json qui se trouve à la racine de votre serveur. Pour se faire, on a qu’à pousser un nouveau fichier JSON par FTP avec le hash de commit le plus récent parmi la liste des commits à déployer. On voit bien dans l’exemple ci-dessus que se dessine un nouvel objet pour gérer ce hash sur le serveur. On aurait donc besoin de deux méthodes pour obtenir et mettre à jour le commit hash. Créons une classe DeploymentFunctions :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
module.exports = class DeploymentFunctions { getLastCommitHashDeployed() { return new Promise(async (resolve, reject) => { try { resolve(); } catch (ex) { reject(ex); } }); } updateLastCommitDeployed(newHash) { return new Promise(async (resolve, reject) => { try { resolve(); } catch (ex) { reject(ex); } }); } } |
Pour la méthode getLastCommitHashDeployed(), on a qu’à recopier le code du fichier deploy.js qui fait la requête vers votre serveur. Pour la deuxième, nous allons devoir créer un fichier localement puis le pousser par FTP sur le serveur :
1 2 3 4 5 6 |
const deploy = {lastCommitDeployed: newHash}; const localPathToDeployJson = './deploy.json'; const remotePathToDeployJson = '/srv/myapp/project/deploy.json'; fs.writeFileSync(localPathToDeployJson, JSON.stringify(deploy)); await this.ftp.copy(localPathToDeployJson, remotePathToDeployJson) fs.unlinkSync(localPathToDeployJson); |
Dans cet extrait de code, this.ftp est une instance de notre classe FtpFunctions à laquelle j’ai ajouté une méthode pour copier un fichier directement :
1 2 3 4 5 6 7 8 9 10 11 12 |
copy(localFile, remoteFile) { return new Promise(async (resolve, reject) => { try { await this.ftp.connect(ftpInfo); await this.ftp.put(localFile, remoteFile); await this.ftp.end(); resolve(); } catch (ex) { reject(ex); } }); } |
Puis, on modifie fetchCommitsSinceHash() pour qu’elle nous retourne les hash et on garde le dernier pour la mise à jour. Ainsi, si on exécute de nouveau, le git-diff que fait dans GitFunctions devrait nous retrouner un tableau de commits vide, donc rien à faire comme déploiement.
Reste qu’à connecter ça au pipeline GitLab, mais ça sera pour la prochaine fois! Je vous push une copie du code avec un peu de refactoring.
Conclusion
Aujourd’hui, on a exploré comment on peut faire des copies de fichiers vers un serveur FTP via une connexion SSH en utilisant node. À ce moment-ci du projet, le script devrait être 100% fonctionnel pour être exécuté localement. On fait un diff avec git, on optimise les opérations git pour en faire une suite d’opérations FTP, puis on exécute la liste d’opération FTP. On se retrouve donc avec une migration basée uniquement sur les fichiers modifiés.
Pour le prochain article, on va configurer un pipeline dans GitLab qui sera responsable d’exécuter notre script via une image Docker sur un agent fourni gratuitement par GitLab. Notre script s’exécutera donc directement sur les serveurs de GitLab dès qu’on fera un push sur une branche spécifique. En attendant, prenez quelques secondes pour partager cet article avec vos collègues et bon confinement!
Commentaires
Laisser un commentaire