Vous vous demandez sans doute pourquoi se donner tant de mal ? Pour, d'une part, s'assurer que tout est construit correctement (i.e. on ne va pas péter la moitié du tree et se faire brûler en place publique) et d'autre part, vouloir distribuer les paquets résultants à un panel de cobayes^Wtesteurs bienveillants pour s'assurer que les paquets ont toujours un comportement correct au runtime. Et finalement, on veut pouvoir distribuer ces paquets en les signant avec son sang pour prouver que OUI MONSIEUR, C'EST MOI QUI LES AI FAITS. Ainsi, le gentil testeur saura qui insulter quand son desktop sera tout pété.
Venons-en à la petite histoire ayant donné naissance à cet article. Suite à un don massif de pinpindollars^Wmachines en 2010, nous nous sommes retrouvés avec un stock de Sun v20z prenant la poussière du côté de la banlieue est de Paris. Ni une ni deux, en faisant jouer les relations de Huggy les bons tuyaux, le tout se retrouva dans une pauvre 205 (qui rendra l'âme peu après, d'ailleurs) direction le sous-sol d'une université que nous situerons dans une ville helvétique.
Cette généreuse université va ainsi devenir (et ceci depuis bientôt 4 ans !) l'hôte bienveillant d'OPI, aka OpenBSD Ports Infrastructure. Quel nom magnifiquement pompeux pour un tas de machines dans un rack poussiéreux dans un cimetière des éléphants informatiques.
C'est à peu près à la même époque qu'espie@ se lance dans la réécriture en Perl de dpb(1), le « distributed ports builder ». N'écoutant que ma folie et mon inconscience, je me dis alors que cette infrastructure serait parfaite pour jouer les cobayes de ce magnifique monstre de programmation objet bourrée d'auto-vivification. Moi non plus, j'ai toujours rien compris si ça peut vous rassurer.
dpb(1) part d'une idée simple : soit un ensemble de machines étant configurées avec un ports-tree partagé via NFS, et chacune des machines ayant un état de mise à jour cohérent (i.e. elles utilisent toutes la même architecture, la même version de -CURRENT, ... des clones, quoi), il va essayer de distribuer les jobs sur le cluster en maximisant son utilisation CPU et en minimisant le temps nécessaire pour un bulk build complet. Et tout ça avec une interface graphique en curses qui ferait pâlir un sapin de Noël.
D'ailleurs, un petit screenshot de terminal (dommage, on rate les couleurs façon sapin de Noël) étant plus parlant que des mots :
26 May 07:50:25 [3764]
LISTING [1828] on opi-5 at devel/py-wbem
devel/gmp(build)+ [1055] on opi-2 67%
biology/emboss(build)+ [8139] on opi-6 14%
devel/gsl(build)+ [10421] on opi-3 19%
converters/libiconv(build)+ [9726] on opi-4 45%
~devel/log4cplus(build)+ [10767] on opi-2 32%
~devel/netbeans(fake)+ [27581] on opi-6 5%
2*devel/libelf(build)+ [30957] 35%
~audio/libid3tag(build)+ [18019] on opi-4 28%
~devel/libusb1(build)+ [20915] on opi-3 7%
~devel/p5-IO-String(build)+ [18608] on opi-5 21%
<virtualenv-1.11.4.tar.gz(#1) [9427]
Hosts: localhost opi-2 opi-3 opi-4 opi-5 opi-6
I=3301 B=406 Q=3998 T=1521 F=0 !=77
E=sysutils/mcollective(opi-3) graphics/blender(opi-4)
On voit la liste des jobs en cours, leur statut, le nom de la machine sur laquelle le job s'exécute, son avancement (calculé empiriquement sur la taille du fichier log de sortie du build, oui monsieur, y'en a un peu plus, je vous le mets quand même ?), la liste des hosts disponibles, une ligne cryptique de statut global sur l'ensemble du bulk, et une dernière ligne avec les ports ayant échoué.
Donc résumons, juste pour lancer un bulk build, il faut :
- une bouteille de whisky ;
- une dinde ;
- un ports-tree (/usr/ports pour ceux qui suivent) à jour ;
- un /usr/src/sys à jour (car security/lsof aime bien aller poker les headers du noyau, va savoir...) ;
- un /usr/src/distrib et /usr/xenocara/distrib à jour, car databases/pkglocatedb-src en a besoin (mais plus pour longtemps !) ;
- les 3 dernières choses partagées par NFS. Oui, NFS, pas Amazon Cloud EC2 ni BTRFS ni ZFS ;
- un nœud « maître » servant de serveur NFS pour l'étape précédente ;
- boire un whisky après tout ce NFS ;
- fourrer la dinde de Noël ;
- un lot de machines « esclaves » aussi à jour, et à jour de la même façon... des clones quoi. Je n'ai pas essayé de partager /usr via NFS, ni de faire du diskless(8), mais ça pourrait ;
- vider /usr/ports/packages des résultats et logs du bulk build précédent (car il y a toujours un truc précédent. D'ailleurs, il faudrait sortir la dinde précédente du four : elle est carbonisée) ;
- boire un ouissqui ;
- nettoyer chacune des machines de tous les packages installés pour partir sur des bases propres ;
- appliquer les patches qu'on veut tester dans ce bulk build ;
- mettre la dinde au four ;
- lancer dpb(1) sur le nœud maître (ou un autre, en fait, tant qu'il a accès par clef SSH sans passphrase aux autres nœuds...), en appliquant éventuellement quelques options sur la ligne de commandes ;
- regarder l'écran pendant un temps variable..., aller faire autre chose en attendant, comme boire un coup ;
- voir qu'il y a plein d'erreurs dans la ligne E= ;
- jurer que quelqu'un a encore pété un bout du tree, en disant bien que c'est la dernière fois qu'on vous fait ce coup-là, non mais !
- mettre le four à thermostat 180 ;
- boire un whisssskkky pour oublier ;
- admirer le résultat dans /usr/ports/packages ;
- répéter depuis l'étape 1 si la bouteille n'est pas vide.
En pratique, j'ai 3-4 scripts shell qui s'occupent de tout ça, et l'étape qui prend le plus de temps est clairement celle du cvs up, car CVS est génial, n'est-ce pas ?
- Un script s'occupe de « préparer » le bulk build (i.e. virer les anciens packages, archiver les logs, updater les différents checkouts CVS...) ;
- Un script s'occupe de lancer dpb(1) avec les bonnes options ;
- Un autre script sera lancé par dpb(1) sur chacun des hosts avant le départ du bulk ; il s'occupe de nettoyer la machine et de mettre en place un /usr/local tout neuf :
cd /var/db/pkg && sudo pkg_delete -xqc *
sudo rm -rf /usr/local/* /var/db/pkg/* /var/db/pkg/.[!.]*
sudo mtree -qdef /etc/mtree/4.4BSD.dist -p / -U
- Enfin, un dernier script fera du reporting par mail à la fin du bulk build, histoire de savoir quand c'est prêt, et qu'on peut servir à table.
Par défaut, les logs de dpb sont stockés dans /usr/ports/logs/${ARCH} (original, hein ?) ; c'est l'espace de travail du builder, et il stocke des infos dans un certain nombre de fichiers :
affinity/ dump.log init.opi-4.log opi-4.sig.log
affinity.log engine.log init.opi-5.log opi-5.sig.log
all.diff equiv.log init.opi-6.log opi-6.sig.log
awaiting-locks.log failures/ junk.log packages/
build.log failures.tgz localhost.sig.log paths/
clean.log fetch/ locks/ performance.log
concurrent.log finished.log ls-failures size.log
debug.log init.localhost.log ls-packages stats.log
dependencies.log init.opi-2.log opi-2.sig.log term-report.log
dist/ init.opi-3.log opi-3.sig.log vars.log
En pratique, beaucoup de ces fichiers/répertoires sont à usage interne à dpb(1), et on n'a pas besoin de s'en occuper, sauf si on se sent vraiment l'âme d'un hacker Perl fou.
Ceux qui vont nous intéresser pour comprendre dpb(1) sont principalement :
- build.log : logue une ligne pour chaque pkgpath buildé avec les timings de chaque étape.
x11/gnustep/gemas opi-2/2 27.00 30023 max_stuck=46/depends=48.00/
show-prepare-results=3.00/build=6.00/package=18.00/clean=0.00
- engine.log : logue une ligne par changement d'état de job (ici, B pour « j'ai commencé à builder tel pkgpath »).
3764@1401139349: B: devel/hs-case-insensitive
- stats.log : donne la taille de chacune des queues de jobs. I est le nombre de packages déjà buildés ; B est le nombre de packages déjà buildés mais non installables (des dépendances au runtime n'ont pas encore été buildées) ; Q est le nombre de jobs pouvant être lancés si un slot se libère ; T est le nombre de jobs dans la queue mais ne pouvant pas encore être buildés pour des dépendances non encore disponibles ; F est le nombre de jobs de téléchargement (la récupération des fichiers sources nécessaires au build d'un port est un job à part, ce qui permet aussi d'utiliser dpb(1) pour « mirrorer » un ensemble de distfiles).
3764 1401139117 1401139117 I=3641 B=418 Q=3709 T=1406 F=0
- packages/ et paths/ contiennent les logs des builds de chacun des pkgpaths (1,2 Gi de logs de nos jours).
- locks/ contient les locks internes à dpb(1). Lorsqu'un job échoue, il laisse un lock derrière lui, et dpb(1) va régulièrement sonder son répertoire de locks, donnant ainsi la possibilité à l'utilisateur de corriger une erreur temporaire et de remettre le job dans la queue en supprimant le lock correspondant.
Donc, ce petit setup est en production depuis 2010, et a servi à tester un nombre incalculable de diffs, bon an mal an. Jusqu'ici, votre serviteur se tape la maintenance du bouzin avec ces scripts, et gère les mises à jour en rebootant sur bsd.rd de temps en temps quand les librairies du basesystem commencent à sentir un peu le rance. Vu qu'une mise à jour prend environ 2 mn et qu'on peut paralléliser le tout avec tmux... c'est pas la mort. On fetche les sets et kernels dans un répertoire partagé par HTTP, on SSH la console série du nœud maître, on le reboote sur bsd.rd, on upgrade et ensuite, on fait pareil pour les nœuds esclaves cachés derrière le maître dans un LAN privé en 10.0.0.x.
Et pourtant, je suis un gros feignant, donc me vient l'envie d'automatiser tout ça encore un peu plus, et d'ajouter une touche de signify(1) sur le tout.
1. Tag:highway=steps
Pour ceci, plusieurs étapes, qui montrent quand même qu'OpenBSD est doté de fonctionnalités dignes d'un OS du XXIème siècle...
1.1 multipass-autoinstall^Wupgrade
Depuis la version 5.5, on peut pré-configurer le processus d'installation d'OpenBSD en utilisant pxeboot et en servant au nœud un fichier de configuration (tel que décrit dans autoinstall(8)) contenant les réponses aux questions posées par le processus d'installation. Il est aussi possible de faire la même chose pour un processus d'upgrade - dans l'immédiat, il n'est pas (encore) possible de donner un partitionnement au script d'installation, ce qui est un point bloquant : en effet, le partitionnement par défaut n'est pas du tout pensé pour une machine de build (où on a surtout besoin de place dans /usr/obj...), donc à moins de coller WRKOBJDIR dans /home, je ne peux pas vraiment faire de réinstallation complète à chaque reboot, mais c'est définitivement prévu.
Pour rappel, jusqu'ici une mise à jour d'OpenBSD est vraiment super compliquée :
Welcome to the OpenBSD/amd64 5.5 installation program.
(I)nstall, (U)pgrade, (A)utoinstall or (S)hell? U<ENTER>
...
Terminal type? [vt220]<ENTER>
Available disks are: sd0 sd1.
Which disk is the root disk? ('?' for details) [sd0]<ENTER>
Root filesystem? [sd0a]<ENTER>
- Vérification des partitions :
Checking root filesystem (fsck -fp /dev/sd0a)...OK.
Mounting root filesystem (mount -o ro /dev/sd0a /mnt)...OK.
Force checking of clean non-root filesystems? [no]<ENTER>
fsck -p /dev/sd0g...OK.
...
fsck -p /dev/sd0e...OK.
/dev/sd0a on /mnt type ffs (rw, local)
...
/dev/sd0e on /mnt/var type ffs (rw, local, nodev, nosuid)
- Choix de la source et liste des sets à récupérer :
Let's upgrade the sets!
Location of sets? (cd disk http or 'done') [cd] disk<ENTER>
Is the disk partition already mounted? [no] yes<ENTER>
Pathname to the sets? (or 'done') [5.5/amd64] /home/release/releasedir<ENTER>
Select sets by entering a set name, a file name pattern or 'all'. De-select
sets by prepending a '-' to the set name, file name pattern or 'all'. Selected
sets are labelled '[X]'.
[X] bsd [X] base55.tgz [X] game55.tgz [X] xfont55.tgz
[X] bsd.rd [X] comp55.tgz [X] xbase55.tgz [X] xserv55.tgz
[X] bsd.mp [X] man55.tgz [X] xshare55.tgz
Set name(s)? (or 'abort' or 'done') [done]<ENTER>
- Récupération de la signature et des sets :
Verifying SHA256.sig 100% |**************************| 2062 00:00
Signature Verified
Verifying bsd 100% |**************************| 11578 KB 00:00
Verifying bsd.rd 100% |**************************| 8840 KB 00:00
...
Verifying xserv55.tgz 100% |**************************| 25006 KB 00:00
- Installation/mise à jour sur le disque :
Installing bsd 100% |**************************| 11578 KB 00:00
Installing bsd.rd 100% |**************************| 8840 KB 00:00
...
Installing xserv55.tgz 100% |**************************| 25006 KB 00:15
Location of sets? (cd disk http or 'done') [done]<ENTER>
Making all device nodes... done.
Multiprocessor machine; using bsd.mp instead of bsd.
# reboot<ENTER>
Donc résumons :
- sudo cp /home/release/releasedir/bsd.rd /bsd && sudo reboot <Enter>
- 'U', 5 * <Enter>
- disk <Enter> yes <Enter>
- /home/release/releasedir 3 * <Enter> reboot <Enter>
Dur, hein ?
Dans mon cas, je pointe vers /home/release/releasedir, qui est le cache local des sets que j'aurai préchargés pour aller plus vite (et qui sera servi aux nœuds esclaves par HTTP), mais je pourrais pousser la fainéantise jusqu'à laisser le miroir par défaut et m'épargner 2 lignes à taper.
Du coup, cherchons à automatiser ça.
1.1.1 tftpd/dhcpd
Il y a un cas particulier de l'œuf et de la poule ici : je ne peux pas « pxebooter » le nœud maître en l'utilisant lui-même comme source, donc l'exemple qui suit s'appliquera surtout aux nœuds esclaves qui, de toute façon, sont déjà clients DHCP du nœud maître. Donc, il suffit d'ajouter l'entrée « filename » au dhcpd.conf pour que les nœuds « pxebootent » en allant chercher le bon fichier :
option domain-name "local";
option domain-name-servers 10.0.0.1;
option routers 10.0.0.1;
next-server 10.0.0.1;
subnet 10.0.0.0 netmask 255.255.255.0 {
range 10.0.0.20 10.0.0.25;
host opi-2 {
hardware ethernet 00:09:3d:11:fc:19;
filename "auto_upgrade";
fixed-address 10.0.0.2;
}
host opi-3 {
hardware ethernet 00:09:3d:11:fc:5b;
filename "auto_upgrade";
fixed-address 10.0.0.3;
}
host opi-4 {
hardware ethernet 00:09:3d:11:f4:c8;
filename "auto_upgrade";
fixed-address 10.0.0.4;
}
host opi-5 {
hardware ethernet 00:09:3d:11:f1:f4;
filename "auto_upgrade";
fixed-address 10.0.0.5;
}
host opi-6 {
hardware ethernet 00:09:3d:11:f4:cb;
filename "auto_upgrade";
fixed-address 10.0.0.6;
}
}
Activer les services dhcpd(8) et tftpd(8) via /etc/rc.conf.local :
dhcpd_flags='bge0'
tftpd_flags='/tftpboot'
1.1.2 upgrade.conf
Ensuite, il faut configurer nginx(1) pour qu'il serve à sa racine un fichier upgrade.conf commun à tous les nœuds :
$ cat /var/www/htdocs/upgrade.conf
Location of sets = http
HTTP Server = 10.0.0.1
Server directory = sets
Déjà, à ce stade-là et sans activer pxeboot(8), on peut booter manuellement un esclave sur bsd.rd et choisir (A)utoinstall. Après avoir demandé sur quelle interface réseau faire une requête DHCP, bsd.rd va se débrouiller tout seul pour récupérer le fichier upgrade.conf et poursuivre l'upgrade sans demander son reste. Plus que 2 * <Enter> à taper, il prend la réponse par défaut pour chacune des questions auxquelles on ne répond pas dans upgrade.conf, et il reboote tout seul à la fin.
(I)nstall, (U)pgrade, (A)utoinstall or (S)hell? A<ENTER>
Available network interfaces are: bge0 bge1.
Which network interface should be used for the initial DHCP request? (or 'done') [bge0]<ENTER>
DHCPREQUEST on bge0 to 255.255.255.255
DHCPACK from 10.0.0.1 (00:09:3d:11:f3:96)
bound to 10.0.0.2 -- renewal in 21600 seconds.
Trying 10.0.0.1...
Requesting http://10.0.0.1/upgrade.conf
Performing non-interactive upgrade...
Terminal type? [vt220] vt220
Available disks are: sd0.
Which disk is the root disk? ('?' for details) [sd0] sd0
Root filesystem? [sd0a] sd0a
...
Force checking of clean non-root filesystems? [no] no
...
Ça commence à prendre forme.... Il reste à automatiser le reste.
1.1.3 Configuration finale du pxeboot
Installer pxeboot(8) dans /tftpboot, et préparer un fichier boot.conf pour que le boot se fasse par défaut sur la console série :
# cp /usr/mdec/pxeboot /tftpboot/auto_upgrade
$ cat /tftpboot/etc/boot.conf
stty com0 9600
set tty com0
set timeout 5
À l'étape d'après, on va booter un esclave sur bsd.rd via PXE. Tout d'abord, il faut dire au BIOS de booter par défaut en PXEBOOT : dans mon cas (sun v20z), c'est dans les options d'ordre de boot.
Ensuite, copier un kernel d'upgrade (donc bsd.rd) en tant que kernel de boot :
# cp /home/release/releasedir/bsd.rd /tftpboot/bsd
À partir de maintenant, si on reboote un esclave, il devrait charger directement le kernel nommé bsd via TFTP au boot et, vu que le fichier de pxeboot s'appelle auto_upgrade, bsd.rd va passer en mode automatique après 5 secondes et essayer de récupérer le fichier de configuration de l'upgrade.
>> OpenBSD/amd64 PXEBOOT 3.23
boot>
booting tftp:/bsd: 4195280+1362101+2920496+0+520656 [100+344808+223871]=0xd212e0
...
PXE boot MAC address 00:09:3d:11:f4:c8, interface bge0
root on rd0a swap on rd0b dump on rd0b
erase ^?, werase ^W, kill ^U, intr ^C, status ^T
Ici, on voit qu'il a bien booté via PXE, et a récupéré bsd.
Welcome to the OpenBSD/amd64 5.5 installation program.
Starting non-interactive mode in 5 seconds...
(I)nstall, (U)pgrade, (A)utoinstall or (S)hell?
bge0: no link ... got link
DHCPDISCOVER on bge0 - interval 3
DHCPDISCOVER on bge0 - interval 3
DHCPOFFER from 10.0.0.1 (00:09:3d:11:f3:96)
DHCPREQUEST on bge0 to 255.255.255.255
DHCPACK from 10.0.0.1 (00:09:3d:11:f3:96)
bound to 10.0.0.4 -- renewal in 21600 seconds.
Trying 10.0.0.1...
Requesting http://10.0.0.1/00:09:3d:11:f4:c8-upgrade.conf
ftp: Error retrieving file: 404 Not Found
Trying 10.0.0.1...
Requesting http://10.0.0.1/upgrade.conf
Performing non-interactive upgrade...
Terminal type? [vt220] vt220
Sauf que, à ce stade-là, vu qu'à la fin de l'upgrade bsd.rd va rebooter automatiquement, au boot suivant la machine va encore récupérer tftp:/bsd pour booter, et repartir en boucle pour une mise à jour.... Pour résoudre ce petit souci, il suffit d'ajouter boot hd0:/bsd à boot.conf pour que l'host boote sur son kernel normal après le reboot - mais ceci, uniquement entre le moment où tous les esclaves se sont lancés dans l'upgrade, et avant qu'ils rebootent - la fenêtre de temps est réduite !
1.2 dpb
dpb(1) a besoin d'un environnement stable pour tourner correctement. Il va utiliser un répertoire de travail pour stocker son état durant la durée du bulk (environ 24h) qui sera par défaut /usr/ports/logs/${ARCH}. En entrée, on lui fournit un fichier contenant la liste des esclaves à utiliser, ainsi que leurs caractéristiques :
$ cat /usr/ports/infrastructure/db/hosts-amd64
localhost
opi-2
opi-3
opi-4
opi-5
opi-6
STARTUP=/home/landry/bin/clean_host.sh
Dans mon cas, les 6 nœuds sont équivalents, mais je pourrais spécifier des valeurs de poids si les machines n'étaient pas équivalentes, ou avaient moins de CPU, ou plus de mémoire, ou utilisant un chroot.... La dernière ligne pointe vers le script qui sera lancé sur chaque esclave avant le bulk, dans mon cas pour vider la machine de tous les espaces de travail et packages installés.
Enfin, on peut lui passer des options sur la ligne de commandes pour influencer « globalement » le build :
# dpb -M3.5G -DSTUCK_TIMEOUT=7200 -h /usr/ports/infrastructure/db/hosts-amd64
Ici, je veux qu'un job ayant duré 2h sans progression soit tué, et je veux que les jobs dont la taille prise par le build est inférieure à 3,5 Go soient « buildés » dans un système de fichiers tmpfs (en fait, 99 % des ports, à part certains monstres...).
On lance et on laisse cuire pendant 24h... Il restera à voir la partie reporting du build, qui extraira les logs des ports ayant éventuellement échoué, et enverra un mail aux personnes responsables. Mais cette partie ne sera pas couverte ici !
1.3 signify && pkg_sign
Maintenant qu'on a environ 25 Gi de paquets tout chauds, on veut pouvoir les distribuer à des machines clientes... et pouvoir s'assurer que ce qui est installé correspond bien à ce qui a été buildé. Ça tombe bien, un processus de signature des fichiers d'installation d'OpenBSD a récemment été mis en place, et il peut être réutilisé pour les paquets.
1.3.1 Petit aparte sur les signatures
OpenBSD n'utilise pas GPG comme tout le monde et ne propose pas de PKI, ni d'infrastructure de confiance. On veut juste s'assurer que le fichier que l'utilisateur récupère a bien été signé par la clef privée dont on a la clef publique. Une alternative équivalente serait d'utiliser des URL scp:// pour les miroirs, mais ça demande de mettre en place des clefs SSH.
L'utilitaire se nomme signify et permet de :
- créer un couple clef privée/publique,
- signer un fichier (ou un ensemble de fichiers) en utilisant la clef privée,
- vérifier la signature d'un fichier, en utilisant la clef publique,
- vérifier un ensemble de fichiers en utilisant une liste de sommes de contrôle sur les fichiers.
C'est cette dernière qui est utilisée lors du processus d'installation : on récupère SHA256 et SHA256.sig, on vérifie que SHA256 a bien été signé par la clef privée (à ce moment-là, la clef publique correspondante est présente dans le RAMDISK d'installation) et ensuite, on vérifie la somme de contrôle pour chacun des fichiers de l'installation avant de les décompresser.
1.3.2 Intégration avec les paquets
Tout d'abord, il faut générer la clef que nous allons utiliser. Elle doit être suffixée par -pkg pour spécifier à signify que nous allons signer des paquets avec :
# signify -G -n -p /etc/signify/opi-pkg.pub -s /etc/signify/opi-pkg
Si l'on n'utilise pas -n, signify ne va pas demander une passphrase pour la clef, ce qui poserait problème par la suite, car la passphrase serait demandée à chaque paquet, ce qui serait peu pratique. Dans notre cas, on doit juste s'assurer que la clef privée est bien gardée secrète.
Par contre, on peut copier la clef publique dans l'espace du serveur web, pour que les utilisateurs aient juste à la copier sur leur machine. Ici, évidemment, se pose le problème de la transmission sécurisée de ladite clef publique... HTTPS ? Envoi d'une clef USB par la Poste ?
# cp /etc/signify/opi-pkg.pub /var/www/htdocs/
Une fois ceci fait (et une fois pour toutes), on peut passer à la signature proprement dite des paquets. Encore, 3 alternatives, selon son niveau de paranoïa :
1) Copier le résultat du bulk sur une machine indépendante, qui fera uniquement la signature. Dans ce cas, la clef privée n'est pas sur les machines qui compilent. En effet, étant donné que le processus de build compile et fait tourner un volume de code important provenant de sources tierces, jusqu'à quel point peut-on faire confiance à ces sources de ne pas chercher à accéder à la clef privée ?
2) Faire la signature des paquets sur la même machine, une fois le bulk terminé. On donne à pkg_sign(1) la clef privée, un répertoire d'entrée et un répertoire destination, et on laisse faire un moment. Il va ouvrir chaque paquet, signer la packing-list (correspondant à la liste des fichiers contenus dans le paquet, ainsi que leurs sommes de contrôle plus quelques annotations) et copier le paquet signé dans sa destination finale.
# pkg_sign -j4 -C -s signify -s /etc/signify/opi-pkg -o /var/www/htdocs/amd64 -S /usr/ports/packages/amd64/all
L'option -C dit à pkg_sign(1) de générer un fichier SHA256 avec la somme de contrôle de tous les paquets dans le répertoire. Actuellement, ce fichier n'est pas utilisé, car rien n'assure que la distribution des paquets se fait de manière atomique sur les miroirs ; on peut très bien faire une mise à jour au milieu d'une synchronisation du miroir et se retrouver avec un fichier SHA256 ne contenant pas les bonnes sommes de contrôle des fichiers déjà synchronisés. On peut cependant signer ce fichier, pour le jour où la distribution sur les miroirs se fera de manière atomique.
# signify -S -s /etc/signify/opi-pkg -m SHA256 -x SHA256.sig
3) Enfin, faire la signature du paquet à la volée, directement lors de la compilation. Ceci implique de manuellement créer le fichier SHA256 à la fin du bulk (si on veut vraiment l'utiliser), et de copier les paquets dans une espace servi par Nginx. Cette variante peut se faire en configurant SIGNING_PARAMETERS dans /etc/mk.conf pour pointer vers la clef à utiliser. Lors de la création initiale du paquet, pkg_create(1) va directement signer la packing-list, évitant ainsi une procédure en deux temps.
$ grep SIGNING /etc/mk.conf
SIGNING_PARAMETERS=-s signify -s /etc/signify/opi-pkg
Et voilà, il ne reste plus qu'à servir !
1.4 Nginx
Ici, rien de compliqué, il faut juste dire au nginx(8) du système de base de se lancer via rc.conf.local, et les paquets seront disponibles en ligne.
nginx_flags=
Enfin, les clients n'ont plus qu'à récupérer la clef publique et pointer PKG_PATH vers l'URL publique du dépôt. À noter que pkg_add(1) refuse d'installer un paquet signé pour lequel on n'a pas la clef correspondante, et il affiche un message d'avertissement si on essaie d'installer un paquet non signé (ici screen--shm) :
$ export PKG_PATH=http://opi.ini.uzh.ch/amd64
$ sudo pkg_add -n screen--
Can't find key /etc/signify/opi-pkg.pub for signer /etc/signify/opi-pkg.pub
Fatal error: screen-4.0.3p4 is corrupted
$ sudo pkg_add -n screen--shm
UNSIGNED PACKAGE http://opi.ini.uzh.ch/amd64/screen-4.0.3p4-shm.tgz: install
anyway? [y/N/a] N
UNSIGNED PACKAGES: screen-4.0.3p4-shm
Fatal error: Unsigned package http://opi.ini.uzh.ch/amd64/screen-4.0.3p4-shm.tgz
$ sudo ftp -o /etc/signify/opi-pkg.pub http://opi.ini.uzh.ch/opi-pkg.pub
$ sudo pkg_add -n screen--
screen-4.0.3p4: ok
Enfin, on peut aussi jouer avec l'option SIGNER, pour spécifier qu'on ne fait confiance qu'à certaines des clefs présentes dans /etc/signify.
$ sudo pkg_add -n -DSIGNER=openbsd-55-pkg screen--
Package signed by untrusted party opi-pkg
Fatal error: screen-4.0.3p4 is corrupted
$ sudo pkg_add -n -DSIGNER=opi-pkg screen--
screen-4.0.3p4: ok
2. On mélange le tout dans un chaudron
Maintenant qu'on a tous les bouts, reste à orchestrer le tout à distance, pour n'avoir qu'à lancer un nombre minimal de commandes pour :
1) Mettre à jour les machines,
2) Mettre à jour l'arbre des ports,
3) Lancer un bulk build.
2.1 Ansible
Pour cela, ansible est l'outil parfait ! Il a seulement besoin de SSH et Python sur la machine distante, et peut même bootstrapper Python sur les machines distantes.
$ sudo pkg_add ansible
Une fois installé, on liste les nœuds à contrôler via le fichier /etc/ansible/hosts, qui se structure selon une liste de groupes, comme un fichier de configuration .ini :
[master]
opi
[slaves]
opi-[2:6]
[all]
opi
opi-[2:6]
[all:vars]
ansible_python_interpreter=/usr/local/bin/python2.7
Ainsi, en utilisant la cible slaves, une commande sera lancée sur tous les nœuds esclaves, et chacun des nœuds peut être commandé de manière indépendante. Ansible cherche par défaut à utiliser /usr/bin/python sur la machine distante, mais on peut lui spécifier un autre chemin.
$ ansible -m setup -a 'filter=ansible_os_family' master
opi | success >> {
"ansible_facts": {
"ansible_os_family": "OpenBSD"
},
}
$ ansible -m setup -a 'filter=ansible_all_ipv4_addresses' opi-2
opi-2 | success >> {
"ansible_facts": {
"ansible_all_ipv4_addresses": [
"10.0.0.2"
]
},
}
On voit qu'ansible peut être utilisé interactivement, en appelant un module (ici setup) et en lui passant des arguments (ici un filtre pour afficher uniquement une propriété), et retourne une structure JSON.
Dans notre cas, nous allons utiliser des playbooks, qui permettent de décrire un enchaînement d'actions à appliquer sur des nœuds, avec la notion de rôles pour structurer ces actions.
2.2 virtualenv
Petit aparte sur l'interpréteur... Pour éviter d'influencer le bulk build, on va plutôt utiliser virtualenv pour s'assurer que l'interpréteur Python utilisé par Ansible ne rentre pas en conflit avec celui que le bulk build va construire : on veut un environnement propre.
Du coup, on se trouve face à un problème classique... On ne peut plus utiliser le nœud « maître » comme nœud de compilation, car il va faire l'orchestration et a donc besoin d'une installation d'Ansible complète. L'environnement Python installé par virtualenv (et disponible par NFS) ne sera utilisé que par les nœuds « esclaves ». Une autre alternative serait d'installer toutes les dépendances d'ansible sur le nœud maître dans l'environnement virtualenv en utilisant pip.
$ sudo pkg_add py-virtualenv
$ virtualenv --always-copy venv
$ cp /usr/local/lib/libpython2.7.so.0.0 venv/lib
$ for f in functools pipes tempfile glob socket getpass ConfigParser StringIO \
io random __future__ hashlib string shlex keyword collections heapq \
subprocess traceback pickle struct shutil platform ; do
cp /usr/local/lib/python2.7/$f.py venv/lib/python2.7/ ;
done
$ mkdir venv/lib/python2.7/json/
$ for j in __init__ decoder encoder scanner ; do
cp /usr/local/lib/python2.7/json/$j.py venv/lib/python2.7/json ;
done
Avec cet environnement, on peut désinstaller complètement le paquet Python des nœuds distants, et utiliser l'environnement que nous venons de construire. Ainsi, on modifie le fichier de configuration d'Ansible hosts, pour spécifier quel interpréteur utiliser sur chacun des nœuds.
[master:vars]
ansible_python_interpreter=/usr/local/bin/python2.7
[slaves:vars]
ansible_python_interpreter=env LD_LIBRARY_PATH=~/venv/lib \
PYTHONPATH=~/venv/lib/python-2.7 PYTHONHOME=~/venv ~/venv/bin/python
Vérifions qu'ansible peut toujours s'exécuter sur les nœuds distants :
$ ansible -m shell -a 'hostname' slaves
opi-3 | success | rc=0 >>
opi-3.ini.uzh.ch
opi-2 | success | rc=0 >>
opi-2.ini.uzh.ch
opi-5 | success | rc=0 >>
opi-5.ini.uzh.ch
opi-4 | success | rc=0 >>
opi-4.ini.uzh.ch
opi-6 | success | rc=0 >>
opi-6.ini.uzh.ch
Ainsi, on peut communiquer avec chacun des nœuds via ansible, même sur les esclaves qui n'ont pas le paquet Python installé. On va donc écrire notre ensemble de playbooks Ansible pour pouvoir reconfigurer à volonté tous les aspects de notre cluster, que ce soit les parties serveur (NFS, TFTP, DHCP, Web) ou client (builder, client NFS...). On veut aussi pouvoir lancer un build à partir d'Ansible, ainsi que mettre à jour les nœuds. Je ne vais pas rentrer dans le détail complet de mon setup, car ça prendrait le reste du magazine et il faut laisser de la place aux copains, mais tout est disponible en ligne sur le dépôt git http://cgit.rhaalovely.net/ansible-opi/. Cependant, je ne vais pas me gâcher le plaisir de vous décortiquer quelques exemples...
2.3 Playbooks Ansible
Un playbook peut, au choix, être directement une liste d'actions/tâches, ou une liste de rôles à appliquer. Un rôle se définit avec une sous-liste d'actions, ainsi que des fichiers d'entrée que l'on veut distribuer sur les nœuds.
- Playbook reconfiguration.yml pour reconfigurer tout le serveur, puis tous les esclaves. :
- name: configure master
sudo: yes
hosts: master
roles:
- { role: dhcp-server, tags: dhcp}
- { role: tftp-server, tags: tftp}
- { role: nfs-server, tags: nfs}
- { role: dns-server, tags: dns}
- { role: web-server, tags: web}
- name: configure slaves
remote_user: landry
sudo: yes
hosts: slaves
roles:
- { role: nfs-client, tags: nfs}
- builder
- Playbook upgrade.yml, qui va récupérer les sets d'installation sur le miroir OpenBSD, modifier la configuration TFTP, rebooter les esclaves, attendre qu'ils aient fini leur mise à jour et vérifier que tout s'est bien passé. Les commentaires et noms de tâches permettent clairement de voir l'enchaînement :
- name: GET SETS
hosts: master
sudo: yes
tasks:
- name: fetch new sets/kernels/sigs
get_url: url={{mirror}}/{{item}} dest={{sets_path}}
with_items: sets_list
- name: RECONFIGURE TFTP
hosts: master
sudo: yes
tasks:
- name: tell the slaves to boot via tftp
lineinfile: dest=/tftpboot/etc/boot.conf line="boot hd0a:/bsd" state=absent
- name: rotate tftpd logfile
file: name=/var/log/tftpd state={{item}}
notify: bug syslogd
with_items:
- absent
- touch
handlers:
- name: bug syslogd
# tftpd chroots, so if we restart syslogd it loses its connection to it
command: pkill -HUP syslogd
- name: REBOOT SLAVES
hosts: slaves
sudo: yes
tasks:
- name: reboot slaves
command: reboot
ignore_errors: yes
Ici, un bon gros hack de derrière les fagots : on veut détecter que tous les esclaves ont bien commencé la procédure de mise à jour, donc on attend que chacune des IP ait bien fait une requête TFTP pour /etc/boot.conf. Hop, pirouette, cacahuète.
# cant use wait_for on the ssh port, since the hosts wont come back directly
- name: WAIT SLAVES UPGRADE AND RECONFIGURE TFTP
hosts: master
sudo: yes
tasks:
- name: wait for all the slaves upgrading
wait_for:
path: /var/log/tftpd
search_regex: "{{item}}. read request for '/etc/boot.conf'"
with_items:
- 10.0.0.2
- 10.0.0.3
- 10.0.0.4
- 10.0.0.5
- 10.0.0.6
- name: fix boot.conf so that slaves boot off their disk
lineinfile: dest=/tftpboot/etc/boot.conf line="boot hd0a:/bsd" state=present
- name: WAIT SLAVES FINAL REBOOT AND RUN SYSMERGE
hosts: slaves
gather_facts: no
tasks:
- name: wait for each slave
local_action:
module: wait_for
host: "{{ inventory_hostname }}"
port: 22
delay: 240
state: started
- name: test that all the slaves are upgraded
shell: uname -a >> status
- name: run sysmerge in batch mode
command: SM_PATH=http://10.0.0.1/sets/ sudo sysmerge -b
2.4 Rôles
- Le rôle dhcp-server : ici, petit détail, le module de gestion de services d'Ansible ne sait pas encore correctement gérer le fichier rc.conf.local qui liste les services à démarrer par défaut sur OpenBSD, donc on doit manuellement y ajouter une ligne en utilisant le module lineinfile (fichier roles/dhcp-server/tasks/main.yml).
- name: install dhcpd.conf
copy: src=dhcpd.conf dest=/etc/dhcpd.conf
- name: enable dhcpd
lineinfile: dest=/etc/rc.conf.local line="dhcpd_flags='bge0'"
- name: start dhcpd
service: name=dhcpd state=started
- Le rôle builder : ici, on s'assure que les fichiers nécessaires pour configurer le build de ports sont installés, que l'utilisateur est dans le bon groupe et la bonne classe de login et enfin, que les partitions objdir sont montées et que l'on peut écrire dedans (fichier roles/builder/tasks/main.yml).
- name: copy mk.conf and login.conf
tags: configfile
copy: src={{item}} dest=/etc/{{item}}
with_items:
- mk.conf
- login.conf
- name: ensure user is in wsrc and staff login class
tags: user
user: name=landry append=yes groups=wsrc login_class=staff
- name: ensure objdir/ports and objdir/tmpfs are writable to wsrc
file: state=directory mode=664 group=wsrc name={{item}}
with_items:
- /usr/obj/ports
- /usr/obj/tmpfs
- name: mount tmpfs dir
mount: name=/usr/obj/tmpfs src=swap fstype=tmpfs opts=rw state=mounted
- Le rôle nfs-client : il se compose d'une seule tâche, qui va s'assurer que chacune des partitions est correctement montée sur chacun des clients NFS. Ici, on voit aussi un exemple de syntaxe YAML pour découper une longue ligne d'options à passer au module en une table de paramètres, beaucoup plus lisible (fichier roles/nfs-client/tasks/main.yml).
- name: setup mount points
mount:
name: '{{item}}'
src: 'opi:{{item}}'
fstype: nfs
opts: rw,nodev,nosuid,soft,bg,intr,-r=16384,-w=16384
state: mounted
with_items:
- /home
- /usr/ports
- /usr/ports/logs
- /usr/src
- /usr/xenocara
- Le rôle tftp-server : ici, beaucoup de choses ; on remplit /tftboot avec les fichiers venant du miroir OpenBSD, on met notre fichier de configuration boot.conf, et on modifie syslog.conf pour que les messages de debug de tftpd aillent dans un fichier séparé, qui sera utilisé pour détecter que tous les nœuds ont commencé la mise à jour après avoir booté via TFTP (cf. le playbook un peu plus haut). Il s'agit du fichier roles/tftp-server/tasks/main.yml.
- name: create /tftpboot
file: name=/tftpboot state=directory
- name: populate /tftpboot with local boot.conf
copy: src=boot.conf dest=/tftpboot/etc/boot.conf
- name: fetch bsd.rd and pxeboot
get_url: url={{mirror}}/{{item}} dest=/tftpboot/
with_items:
- bsd.rd
- pxeboot
# remember that tftpd chroots to /tftpboot so the symlinks
# need to be relative to that dir.. or just use hardlinks
- name: populate /tftpboot with hardlinks
file: src={{item.src}} dest={{item.dest}} state=hard force=yes
with_items:
- { src: /etc/random.seed, dest: /tftpboot/etc/random.seed }
- { src: /tftpboot/bsd.rd, dest: /tftpboot/bsd }
- { src: /tftpboot/pxeboot, dest: /tftpboot/auto_upgrade }
- name: enable tftpd
lineinfile: dest=/etc/rc.conf.local line="tftpd_flags='-v /tftpboot'"
- name: log tftpd messages in a separate file, match
tags: syslog
lineinfile: dest=/etc/syslog.conf line='!tftpd' state=present
- name: log tftpd messages in a separate file, target
tags: syslog
# whole line needs to be quoted for \t to expand?
lineinfile: "dest=/etc/syslog.conf line='*.*\t/var/log/tftpd' insertafter='!tftpd'"
notify: restart syslogd
- name: start tftpd
service: name=tftpd state=started
Ainsi, à l'exécution d'un playbook, on voit clairement chacune des étapes exécutées et leur statut, ainsi que les modifications effectivement apportées. On peut aussi limiter l'exécution à un ensemble de nœuds ou de tâches.
$ ansible-playbook reconfigure.yml --limit=opi-2,opi-4 --tags=user,configfile
PLAY [configure slaves]
GATHERING FACTS
ok: [opi-2]
ok: [opi-4]
TASK: [builder | copy mk.conf and login.conf]
ok: [opi-4] => (item=mk.conf)
changed: [opi-2] => (item=mk.conf)
changed: [opi-4] => (item=login.conf)
ok: [opi-2] => (item=login.conf)
TASK: [builder | ensure user is in wsrc and staff login class]
changed: [opi-4]
ok: [opi-2]
PLAY RECAP
opi-2 : ok=3 changed=1 unreachable=0 failed=0
opi-4 : ok=3 changed=1 unreachable=0 failed=0
Ansible permet aussi de lister toutes les tâches qui seraient exécutées par un playbook :
$ ansible-playbook reconfigure.yml --list-tasks
playbook: reconfigure.yml
play #1 (configure master):
install dhcpd.conf
enable dhcpd
start dhcpd
create /tftpboot
populate /tftpboot with files
populate /tftpboot with symlinks
enable tftpd
start tftpd
enable nfsd/portmap/mountd
setup exports
start portmap/mountd/nfsd
copy unbound.conf
enable unbound
start unbound
enable nginx
play #2 (configure slaves):
setup mount points
copy mk.conf and login.conf
ensure user is in wsrc and staff login class
ensure objdir/ports and objdir/tmpfs are writable to wsrc
mount tmpfs dir
Finalement, on obtient une vue claire des différentes étapes nécessaires pour reproduire à volonté un environnement donné sur de nouvelles machines. Reste à faire la même chose pour les cvs update, et lancer dpb... Rendez-vous sur le dépôt git pour plus de détails !
Conclusion
Après un certain nombre d'heures, l'objectif de départ est atteint : on a remplacé un nombre conséquent d'actions manuelles et de bouts de scripts écrits sur un coin de serviette dans un pub par un ensemble limité de commandes ansible, qui vont se charger de tout le sale boulot d'automatisation, pendant que l'administrateur est déjà parti faire autre chose... Après tout, n'est-ce pas le boulot de sysadmin que de se faciliter la tâche pour ne plus rien avoir à faire ?