Spécifier un registre d'artefacts et l'intégrer dans un site web

Publié le 12/04/2023

Cet article est la suite de mon précédent article Un registre statique avec Garage.

Le premier point que j’ai regardé à la suite de la lecture de cet article était de savoir si on pouvait utiliser le module ociTools de NixOS pour générer des images directement comme je voulais via nix build. Il apparait que cet outil ne génère pas des images mais des conteneurs, deux représentations différentes, que je ne sais pas comment convertir en plus. Bref, ça ne nous avance pas, et si on veut faire mieux, je crains qu’il va simplement falloir écrire notre propre module NixOS, ce qui est trop couteux en temps pour le moment : on se contentera de cette innéficacité pour le moment…

Ensuite, il est bon de noter qu’on a déjà une logique pour lister les builds statics en place écrite en nixlang pour Garage dans un fichier nommé build_index.nix. En gros elle va reprendre le concept de “tags” de Docker, et à l’aide d’une regex sur le tag, elle va classer les builds en 3 catégories : les builds de release, qui correspondent à un tag semver sans libellé (eg. 0.8.2), les builds extra, qui correspondent à un semver avec libellé (eg. 0.8.2+feat), et enfin tous les autres sont des builds de développement.

On peut alors imaginer un affichage différencié de ces buils : les builds release sont affichés par défaut, les autres sont cachés derrière un bouton. On peut aussi imaginer une gestion des cycles de vie différents : les builds release sont gardés pour toujours, les autres sont supprimés après un certain temps.

L’API S3 a un concept de Lifecycle Management et peut expirer des objets après un temps donné, c’est à dire les supprimer. Par exemple, on peut définir que les objets ayant leur préfix commençant par logs/ seront supprimés après un an. Il serait tentant d’utiliser ce système pour notre système de gestion d’artefacts mais ça pose plusieurs problèmes : à ce jour, Garage n’implémente pas les Lifecycle Management, aussi le fonctionnement des images Docker fait qu’il est possible que plusieurs version d’une même image partagent un même blob, et enfin on est amené à générer un index qui liste toutes nos images, et qui se retrouvera donc à être invalide.

Docker, et donc l’Open Container Initiative, a une spécification pour définir un index de tags pour une image donnée. Tout ça est décrit dans la spec “distribution” dans la section Content Discovery. Implémenter cette spécification pour lister nos tags améliorerait l’intéropérabilité de notre registre avec les autres clients OCI. Même si on ne pourra pas tout implémenter, par exemple le paging définit par la spec ne pourra pas être codé.

La spécification OCI pourrait nous servir d’inspiration pour notre dépôt de fichiers statiques. Tout ça dans l’espoir de faciliter l’écriture du code et de limiter le nombre de conceps à manipuler. Mais cela veut aussi dire qu’on extrait de l’index la charge de catégoriser les tags (release, extra, development). Ça deviendrait alors une convention pour celles et ceux qui intéragissent avec notre futur outil. Et ça veut dire que si on ne garbage collect pas les nightly builds, cet index peut devenir bien grand…

À travers ce panorama, je pense qu’on arrive au noeud du problème à traiter aujourd’hui : la spécification de notre index pour les fichiers statiques, la spécification de la convention à suivre pour les tags, et comment créer un outil qui facilite au maximum cette gestion.

Créer un index pour un registre OCI

On peut regarder un peu ce que donne le registre Docker pour Garage :

{
  "name": "dxflrs/garage",
  "tags": [
    "02e8eb167efa1f08d69fe7f8e6192cde726c45aa",
    "<skipped entries>",
    "fcc5033466e58e3beec05ee7748d33522b6b32b0",
    "v0.7.3",
    "v0.8-rc2",
    "v0.8.0",
    "v0.8.0-beta1",
    "v0.8.0-beta2",
    "v0.8.0-rc1",
    "v0.8.0-rc2",
    "v0.8.1",
    "v0.8.2"
  ]
}

On peut reproduire le même contenu sur notre registre en commençant par récupérer tous les manifests qui ne commencent pas par sha256 :

aws s3 ls s3://registry.deuxfleurs.org/v2/albatros/manifests/ | \
  tr -s ' ' | \
  cut -d' ' -f 4 | \
  grep -v '^sha256:'
# 0.9

On peut construire le JSON à la main pour cette fois :

{
  "name": "albatros",
  "tags": [
    "0.9"
  ]
}

L’envoyer :

aws s3 cp /tmp/tags.json s3://registry.deuxfleurs.org/v2/albatros/tags/list

Et ensuite s’assurer qu’on est bien compatible :

crane ls registry.deuxfleurs.org/albatros
# 0.9

À ma connaissance, le client Docker ne permet pas de récupérer ce fichier directement.

Bien entendu, il est peu probable que dégainer l’outil crane soit la première solution qui vienne à l’esprit des gens. Nos utilisateurs vont plutôt se rendre sur notre site web et s’attendre à voir les conteneurs listés sur une page dédiée ! En définissant des CORS sur notre bucket, on va permettre à un script JS astucieux de récupérer ce fichier via des requêtes XHR et de construire l’index.

On pourrait aussi appeler ces fichiers depuis le générateur de site statique, et régulièrement regénérer le site web pour intégrer ces modifications. Aujourd’hui on utilise XHR sur la page de Garage, et ça a quand même l’avantage de la simplicité, alors pas de raison de changer :-)

On va configurer les CORS de sorte que n’importe qui puisse lire notre index. On réalise ce choix car on ne peut pas spécifier de sous-domaine autorisé : c’est tout ou rien. Et vu qu’on ne sait pas par avance tous les sites webs qui pourraient vouloir requêter notre registre, on autorise tout le monde. Par contre on autorise seulement le GET, et voilà ce que ça donne au final :

export CORS='{"CORSRules":[{"AllowedHeaders":["*"],"AllowedMethods":["GET"],"AllowedOrigins":["*"]}]}'
aws s3api put-bucket-cors --bucket registry.deuxfleurs.org --cors-configuration $CORS

Voilà, maintenant on a tout pour générer une page web.

Générer une page web pour nos conteneurs

On peut commencer simplement avec un squelette basique :

e<!doctype html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>reg</title>
  </head>
  <body>
    <pre>list</pre>
    <script type="text/javascript">
      console.log("hello world")
    </script>
  </body>
</html> 

On va avoir besoin d’une API du navigateur nommée FileReader mais cette dernière utilise la sémantique des callbacks. On va créer un wrapper avec des Promises pour simplifier son utilisation :

const reader = blob => {
  const tmp = new FileReader();
  return new Promise((resolve, reject) => {
    tmp.onerror = () => {
      tmp.abort()
      reject(new DOMException("Problem parsing blob"))
    }
    tmp.onload = () => {
      resolve(tmp.result)
    }
    tmp.readAsText(blob)
  })
}

Et maintenant on peut simplement écrire notre logique pour récupérer la liste des tags :

const albatros_tags = async () => {
  const res = await fetch('https://registry.deuxfleurs.org/v2/albatros/tags/list')
  const blob = await res.blob()
  const txt = await reader(blob)
  const tags = JSON.parse(txt)
  return tags
}

(async () => console.log(await albatros_tags()))()

Ensuite il ne reste plus qu’à l’insérer dans le DOM de la page :

const inject_list = manifest => {
  const c = manifest.tags.map(t => `registry.deuxfleurs.org/albatros:${t}`).join('\n')
  document.querySelector('pre').textContent = c
}

(async () => inject_list(await albatros_tags()))()

Comme je disais au dessus, on a plusieurs types de builds. On va les classer selon cette regex :

const release_semver = /^v?[0-9]+\.[0-9]+\.[0-9]+$/;
const prerelease_semver = /^v?[0-9]+\.[0-9]+\.[0-9]+-.*$/;

const find_cat = t => {
  if (t.match(release_semver)) return 'release';
  if (t.match(prerelease_semver)) return 'prerelease';
  return 'dev'
};

const categorize = tags => tags.reduce((acc, t) => {
  acc[find_cat(t)].push(t)
  return acc
}, {'release': [], 'prerelease': [], 'dev': []});

Ce qui nous donne la classification suivante avec notre extrait pour Garage par exemple :

{
  "release": [
    "v0.7.3",
    "v0.8.0",
    "v0.8.1",
    "v0.8.2"
  ],
  "prerelease": [
    "v0.8.0-beta1",
    "v0.8.0-beta2",
    "v0.8.0-rc1",
    "v0.8.0-rc2"
  ],
  "dev": [
    "02e8eb167efa1f08d69fe7f8e6192cde726c45aa",
    "fcc5033466e58e3beec05ee7748d33522b6b32b0",
    "v0.8-rc2"
  ]
}

Note 1 : on voit qu’une prerelease dont le tag a été raté est passé dans “dev”. Je pense que c’est un comportement intéressant : si on se rate sur un tag, qu’il est trop bizarre, alors on le classe en développement, c’est tout de suite visible qu’on a eu un problème.

Note 2 : se pose la question de comment ont trie à l’intérieur d’une catégorie, aujourd’hui on reprend l’ordre de la liste initiale. Dans le cadre de Docker, il apparait que c’est l’ordre alphabétique qui est choisi. Ce qui marche à peu près pour le semver mais pas du tout pour les commits. Nous on voudrait simplement trier par date mais on a pas accès à cette information dans la liste des tags. Par contre, on peut simplement dire que dans notre implémentation, le générateur qui se charge de construire la liste, ordonne du plus récent au moins récent les tags, tout simplement !

Spécifier notre registre statique

Aujourd’hui une URL de téléchargement de Garage ressemble à ça :

https://garagehq.deuxfleurs.fr/_releases/v0.8.2/x86_64-unknown-linux-musl/garage

Si on décompose ça veut dire :

<host>/_releases/<tag>/<llvm target triple>/<binary>

On peut en apprendre plus sur les target triple de LLVM dans ce billet de blog : What’s an LLVM target triple?

Déjà le préfix de chemin _releases ne fait plus sens dans notre cas : il était là pour ne pas rentrer en conflit avec le géérateur de site statique (d’où le underscore), et parce que les releases étaient partagées avec le site web. Dans download.deuxfleurs.org on ne va stocker que des releases, donc ça n’a plus grand sens.

Pour autant, garder un préfixe, ça a du sens, parce qu’il va décrire comment la hiérarchie sous-jacente va se constituer, ainsi que de permettre de migrer plus tard vers de nouvelles hiérarchies.

Se pose aussi la question du triple LLVM, c’est ce qui est utilisé par Rust, mais ce n’est jamais ce qu’on affiche, à la place on utilise la notation du projet Go, notation aussi utilisée par le projet Docker. Au passage, je la trouve plus simple à comprendre car elle ne contient qu’une combinaison d’un OS et d’une architecture. Mais se pose aussi la question de ce qui se passe si on prévoit de supporter une combinaison qui n’existe pas pour Go. Et en même temps, la notation LLVM est complexe à lire et contient des informations que je considère comme des détails internes : ainsi notre choix d’utiliser musl est lié à notre choix de compiler en interne, qui est lié à l’idée qu’une fois l’OS et l’architecture identifiée, on veut que Garage tourne. Bref, tout ça me fait penser qu’on devrait adopter la notation de Go dans une démarche de penser à l’utilisateur final. Et si ça pose problème, il sera toujours temps de spécifier un df-dist-v2.

Donc on pourrait avoir ces URL :

list tags:    <host>/df-dist-v1/<name>
list flavors: <host>/df-dist-v1/<name>/<tag>
blobs:        <host>/df-dist-v1/<name>/<tag>/<go_os>/<go_arch>/<binary>

Pour la liste des tags, on reprend le format de Docker (avec la subtilité que nos tags doivent être listés par ordre chronologique, du plus récent au plus vieux) :

{
  "name": "albatros",
  "tags": [
    "0.9"
  ]
}

Pour la déclinaison (flavor), on s’inspire du manifest multi arch de Docker :

{
  "flavors": [
    {
      "resources": [ 
        { "path": "albatros" }
      ],
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "resources": [ 
        { "path": "albatros" }
      ],
      "platform": {
        "architecture": "386",
        "os": "linux"
      }
    }
  ]
}

Dans resources, on va lister tous les fichiers qu’on stocke pour une flavor donnée. Aujourd’hui on a que le path d’enregistré pour chaque fichier, mais on pourrait avoir plus tard un rôle, par exemple pour différencier le binaire de son checksum, ou encore des symboles de debug. L’interface pourrait alors adapter son affichage. On pourrait aussi stocker la taille et plein d’autres infos.

À partir de toutes ces infos, on peut aussi reconstruire le chemin du blob qu’on cherche. Normalement on a tout ce qu’il faut.

Mise en place manuelle pour Albatros

On va commencer par compiler nos différents binaires :

nix build .#packages.x86_64-linux.albatros -o df/linux/amd64/albatros
nix build .#packages.i686-linux.albatros -o df/linux/386/albatros
nix build .#packages.aarch64-linux.albatros -o df/linux/arm64/albatros
nix build .#packages.armv6l-linux.albatros -o df/linux/arm/albatros

Les envoyer :

aws s3 sync df/ s3://download.deuxfleurs.org/df-dist-v1/albatros/0.9/
curl -I https://download.deuxfleurs.org/df-dist-v1/albatros/0.9/linux/amd64/albatros

Envoyer le manifest préalablement écrit à la main :

aws s3 cp /tmp/flavor.json s3://download.deuxfleurs.org/df-dist-v1/albatros/0.9
curl https://download.deuxfleurs.org/df-dist-v1/albatros/0.9

Et enfin notre liste de tags :

aws s3 cp /tmp/list.json s3://download.deuxfleurs.org/df-dist-v1/albatros
curl https://download.deuxfleurs.org/df-dist-v1/albatros

On peut valider que tout ça fonctionne bien depuis un navigateur, il faut commencer par les CORS :

export CORS='{"CORSRules":[{"AllowedHeaders":["*"],"AllowedMethods":["GET"],"AllowedOrigins":["*"]}]}'
aws s3api put-bucket-cors --bucket download.deuxfleurs.org --cors-configuration $CORS

Ensuite on peut coder ces quelques fonctions utilitaires, une fois qu’on a choisi notre tag :

const release_info = async () => {
  const res = await fetch('https://download.deuxfleurs.org/df-dist-v1/albatros/0.9')
  const blob = await res.blob()
  const txt = await reader(blob)
  const info = JSON.parse(txt)
  return info
}

const get_links = manifest => manifest.flavors.map(f =>
  `https://download.deuxfleurs.org/df-dist-v1/albatros/0.9/${f.platform.os}/${f.platform.architecture}/${f.resources[0].path}`
);
// https://download.deuxfleurs.org/df-dist-v1/albatros/0.9/linux/amd64/albatros
// ...

Tout fonctionne comme prévu !

Conclusion

On a vu comment gérer nos index à la fois pour notre registre de conteneur et notre diffusion de binaires statiques. On a aussi vu comment manipuler ces index depuis un navigateur afin de créer une page de téléchargement sur un site web. Cette fois-ci c’est la bonne, la prochaine étape on voit comment automatiser tout ça.