https://lafor.ge/feed.xml

Partie 3: Gestion des workspaces

2024-04-24
Les articles de la série

Bonjour à toutes et à tous 😀

Dans le précédent article, nous avons créé un pipeline de création de package à la fois de release et de pre-release pour un projet mono-crate.

Mais en Rust, il existe un mécanisme très utilisés qui se nomme les workspaces.

Ce sont des environnement de crates liées qui permettent de découper un projet lorsqu'il devient trop volumnineux ou lorsque le besoin de partager des données se fait sentir.

Workspace

Pour créer un workspace, on peut taper.

echo [workspace] >  Cargo.toml
cargo new --lib w1

Une ligne va venir se rajouter dans le Cargo.toml

Cargo.toml
[workspace]
members = ["w1"]

Et on peut rajouter autant de sous crates que l'on veut

cargo new --lib w2
cargo new --lib w3

Si l'on tente de faire

cargo package --allow-dirty

Il va le faire pour w1, w2 et w3.

C'est en soit cool, mais pas très intéressant pour nous.

Généralement les crates d'un workspace ont des cycles de versions disjoint, autrement dit ce n'est pas parce que w1 change de version que c'est le cas de w2 et de w2.

Heureusement il est possible de scoper le package à créer

cargo package -p w1 --allow-dirty

Parfait et maintenant les métadonnées.

Il est nécessaire au bon fonctionnement de la registry et ce n'est pas grave en soit que le workspace complet y soit décrit.

On vient juste s'assurer que les dépendences ne soient pas prise en compte avec

cargo metadata --format-version 1 --no-deps > metadata.json

On va donc utiliser une variable CRATE_PACKAGE

variables:
  CRATE_PACKAGE: ""

Qui va venir contrôler le package à créer.

cargo package -p $CRATE_PACKAGE --allow-dirty

Ok, et donc on contrôle comment cette variable ?

Alors on peut le faire depuis les variables de CI externes, mais ce n'est pas pratique.

Au lieu de ça, je vous propose d'utiliser ce que l'on appelle les matrix, cela permet de faire tourner en parrallèle plusieurs jobs d'un même type mais configuré différemment.

Première étape on invisibilise la tâche de release-prod en la suffixant d'un point .release-prod.

.release-prod:
  image: rust:1.77
  stage: packaging
  # seulement si c'est main
  only:
    - main
  script:
    - *prepare
    - *packaging

Puis on utilise la composition et matrix.

release-prod:
  extends: .release-prod
  when: manual
  parallel:
    matrix:
      - CRATE_PACKAGE: w1
      - CRATE_PACKAGE: w2
      - CRATE_PACKAGE: w3

Nous avons maintenant un pipeline qui permet de créer de manière indépendante des versions de releases.

Récupérer la version

Mais il reste une question en suspend.

Comment récupérer le nom et la version du package ciblé, pour le nom on l'a déjà mais nous allons tout de même le reconstruire pour être certain.

Pour faire cela, nous allons utiliser la commande cargo metadata pour lister les packages existants:

cargo metadata --format-version 1 --no-deps | jq

Nous obtenons ce json pour un workspace

{
  "packages" : [
    {
      "name" : "w1",
      "version" : "0.1.0",
      "manifest_path" : "/path/to/workspace/w1/Cargo.toml" 
    },
    {
      "name" : "w2",
      "version" : "0.1.0",
      "manifest_path" : "/path/to/workspace/w2/Cargo.toml" 
    },
    {
      "name" : "w3",
      "version" : "0.1.0",
      "manifest_path" : "/path/to/workspace/w3/Cargo.toml" 
    }
  ],
  // ...
}

Et celui-ci pour un projet "simple"

{
  "packages" : [
    {
      "name" : "subber",
      "version" : "0.1.0",
      "manifest_path" : "/path/to/subber/Cargo.toml" 
    },
  ],
  // ...
}

Nous avons donc 2 cas à traiter:

  • Le premier on cherche à récupérer la version d'un package explicitement nommé par la variable $CRATE_PACKAGE
  • Le second est le cas implicite d'un état à un package et l'absence de variable $CRATE_PACKAGE

Comment procède-t-on ?

Et bien jq est bien plus qu'il ne semble être.

Il est par exemple possible de créer des conditions qui vont venir créer des branches d'exécutions.

Notre algorithm va être:

SI la "taille du tableau de package" est supérieur à 1
ALORS
  SELECTIONNER le package qui possède le nom == $CRATE_PACKAGE
SINON
  SELECTIONNER le premier package
END

En jq cela nous donne

if (.packages | length ) > 1 
then 
(.packages[] | select(.name == $PACKAGE)) 
else 
.packages[0] 
end

Puis nous pouvons récupérer ce qui nous intéresse

{"name" : .name, "version": .version, "manifest": .manifest_path}

On export le résultat dans une variable $PACKAGE_DATA

export PACKAGE_DATA=$(cargo metadata --format-version 1 --no-deps | jq --arg PACKAGE "$CRATE_PACKAGE" '(if (.packages | length ) > 1 then (.packages[] | select(.name == $PACKAGE)) else .packages[0] end) | {"name" : .name, "version": .version, "manifest": .manifest_path}')

Ensuite, nous mettons en place un garde-fou, qui a deux rôle:

  • vérifier que au moins un package existe dans le workspace
  • vérifier que le package sélectionné existe

Pour cela on vérifie que la variable n'est pas vide sinon on quitte

[ -z "$PACKAGE_DATA" ] && echo "Unknown package $PACKAGE in workspace" && exit 1

Ensuite, on récupère tranquillement:

  • la version
  • le Cargo.toml du package
  • le nom du package
export CRATE_NAME=$(jq ".name" <<< "$PACKAGE_DATA" | tr -d '"')
export CRATE_VERSION=$(jq ".version" <<< "$PACKAGE_DATA" | tr -d '"')
export CARGO_FILE=$(jq ".manifest" <<< "$PACKAGE_DATA" | tr -d '"')

Dépendances transitives

Mais il y a un autre souci, un de plus 😝

Pour créer les versions flottantes, nous modifions le Cargo.toml du package voulu

Mais ça cargo n'aime pas du tout, si dans ses dépendences il y a

w2/Cargo.toml
[package]
name = "w2"
[dependencies]
w1 = {path="../w1", version = "0.2.0", registry="private-crates"}

Et que l'on s'amuse à transformer la version du package w1

w2/Cargo.toml
[package]
name = "w1"
version = "0.2.0-124eba69"

Cargo va pas être content car il ne s'attend pas à cette version et n'a plus de repère pour créer le package

if you are looking for the prerelease package it needs to be specified explicitly
    w1 = { version = "0.2.0-124eba69" }

Il faut donc aller trafiquer le Cargo.toml de w2 pour lui donner la bonne version.

w2/Cargo.toml
[package]
name = "w2"
[dependencies]
# dynamiquement modifié
w1 = {path="../w1", version = "0.2.0-124eba69", registry="private-crates"}

Et ce, pour toutes les packages utilisant w1 dans le workspace.

Ok, on fait ça comment ?

La manière la plus "simple" est de lister les 'Cargo.toml'

cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | .manifest_path | tostring

En suite on liste en suite les dépendences qui possède w1.

tomlq '.dependencies' w2/Cargo.toml | jq ."w1"

Renvera

{
  "path": "../w1",
  "version": "0.1.0-toto"
}

Par contre

tomlq '.dependencies' w1/Cargo.toml | jq ."w1"

Renvera

null

Il est ainsi possible de fail fast sur les Cargo.toml qui ne nous intéressent pas.

Ensuite il suffit de réaliser le remplacement dans le toml

tomlq -t '.dependencies.w1.version = "0.2.0-124eba69"' w2/Cargo.toml

Ce serait si simple si cela marchait aussi facilement mais il y a un soucis

Lorque que l'on tente de variabiliser

tomlq --arg PAKAGE w1  -t ".dependencies.$PACKAGE.version = "0.2.0-124eba69"" w2/Cargo.toml

On arrive sur cette erreur

jq: error: syntax error, unexpected '$', expecting FORMAT or QQSTRING_START (Unix shell quoting issues?) at <top-level>, line 1:
.dependencies.$PACKAGE.version = "0.2.0-124eba69

La solution de déport que j'ai trouvé est de templatiser l'expression à coup de bash expansion

FILE=w2/Cargo.toml
PACKAGE=w1
VERSION=0.2.0-124eba69
COMMAND=$(tomlq -t ".dependencies.$PACKAGE.version = \"$VERSION\"" "$FILE")
echo "$COMMAND"

Et cette fois-ci c'est fonctionnel !

[package]
name = "w2"
[dependencies]
w1 = {path="../w1", version = "0.2.0-124eba69", registry="private-crates"}

On se rapproche, maintenant, nous voulons appliquer cette modification à tous les sous package.

Nous sommes capable de les lister, plus qu'à appliquer.

Et pour ça il nous faut un autre outil qui se nomme xargs, c'est une usine à gaz, donc je ne vais pas rentrer dans les détails mais l'idée ici c'est qu'il va prendre les résultat ligne par ligne et les appliquer à des traitements.

Par exemple

$ cat toto
titi
tata
tutu
$ cat toto | xargs -I {} echo {}+suffix

Cela affichera

titi+suffix
tata+suffix
tutu+suffix

Si vous êtes sur windows wsl, attention a bien créer le fichier sans CRLF, xargs ne prend en compte que le LF et traite le CR comme un caractère indifférent.

On vient créer un script à coup de heredoc

cat << 'EOF' > ~/upgrade-cargo.sh
        FILE=$1
        PACKAGE=$2
        VERSION=$3
      
        result=$([ "null" != "$(tomlq '.dependencies' "$FILE" | jq ."$PACKAGE")" ] && tomlq -t ".dependencies.$PACKAGE.version = \"$VERSION\"" "$FILE")
        [ -n "$result" ] && echo "$result" > "$FILE"
        exit 0
      EOF

Et finalement on rassemble tout !

cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | .manifest_path | tostring' |  xargs -I {}  bash ~/upgrade-cargo.sh {} $PACKAGE $CRATE_VERSION
.gitlab-ci.yml
stages:
  - packaging

variables:
  # gitlab token
  CRATE_PACKAGE_TOKEN: $CI_JOB_TOKEN
  # l'ID du projet qui supportera les crates par défaut le project du job
  CRATE_PACKAGE_PROJECT_ID: $CI_PROJECT_ID
  # API user
  CRATE_PACKAGE_USER_API: JOB-TOKEN
  # Host du proxy SSH
  CRATE_PACKAGE_ENDPOINT: noa-crates.cleverapps.io
  # Port d'écoute du proxy SSH
  CRATE_PACKAGE_PORT: 22066
  # Utilisateur associé au token
  CRATE_PACKAGE_USER: personal-token
  # Package ciblé
  CRATE_PACKAGE: ""

.dependencies: &dependencies
  # dépendences
  - apt update && apt install -y yq

.ssh-connexion: &ssh-connexion
  # configuration SSH
  - mkdir -p ~/.ssh && chmod -R 700 ~/.ssh
  - |
    cat << EOF > ~/.ssh/config 
    Host $CRATE_PACKAGE_ENDPOINT
        User $CRATE_PACKAGE_USER:$CRATE_PACKAGE_TOKEN
        Port $CRATE_PACKAGE_PORT
    EOF
  # création de la paire de clefs
  - ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
  # ajout de la clef publique du proxy
  - ssh-keyscan -p $CRATE_PACKAGE_PORT $CRATE_PACKAGE_ENDPOINT >> ~/.ssh/known_hosts

.packaging: &packaging
  # packaging
  - cargo package -p $PACKAGE --allow-dirty
  - cargo metadata --format-version 1 > metadata.json
  - ls target/package/$CRATE_FILE
  # upload
  - 'curl -i --header "$CRATE_PACKAGE_USER_API: $CRATE_PACKAGE_TOKEN" --upload-file target/package/${CRATE_FILE} "${CI_API_V4_URL}/projects/${CRATE_PACKAGE_PROJECT_ID}/packages/generic/${CRATE_NAME}/${CRATE_VERSION}/$CRATE_FILE"'
  - 'curl -i --header "$CRATE_PACKAGE_USER_API: $CRATE_PACKAGE_TOKEN" --upload-file metadata.json "${CI_API_V4_URL}/projects/${CRATE_PACKAGE_PROJECT_ID}/packages/generic/${CRATE_NAME}/${CRATE_VERSION}/metadata.json"'

.version: &version
  # récupération des informations du paquet
  - |
     export PACKAGE_DATA=$(cargo metadata --format-version 1 --no-deps | jq --arg PACKAGE "$PACKAGE" '(if (.packages | length ) > 1 then (.packages[] | select(.name == $PACKAGE)) else .packages[0] end) | {"name" : .name, "version": .version, "manifest": .manifest_path}')
  # on quitte si le package n'existe
  - '[ -z "$PACKAGE_DATA" ] && echo "Unknown package $PACKAGE in workspace" && exit 1'
  - export CRATE_NAME=$(jq ".name" <<< "$PACKAGE_DATA" | tr -d '"')
  - export CRATE_VERSION=$(jq ".version" <<< "$PACKAGE_DATA" | tr -d '"')
  - export CARGO_FILE=$(jq ".manifest" <<< "$PACKAGE_DATA" | tr -d '"')
  - export CRATE_FILE=${CRATE_NAME}-${CRATE_VERSION}.crate

.prepare: &prepare
  - *dependencies
  - *ssh-connexion
  - *version

.release-dev:
  image: rust:1.77
  stage: packaging
  # manuel
  when: manual
  # si la branche n'est pas main
  rules:
    - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
  script:
    - *prepare
    # on créé la version flottante
    - export CRATE_VERSION=$CRATE_VERSION-$CI_COMMIT_SHORT_SHA
    - export CRATE_FILE=${CRATE_NAME}-${CRATE_VERSION}.crate
    - |
      cat << 'EOF' > ~/upgrade-cargo.sh
        FILE=$1
        PACKAGE=$2
        VERSION=$3
      
        result=$([ "null" != "$(tomlq '.dependencies' "$FILE" | jq ."$PACKAGE")" ] && tomlq -t ".dependencies.$PACKAGE.version = \"$VERSION\"" "$FILE")
        [ -n "$result" ] && echo "$result" > "$FILE"
        exit 0
      EOF
    # On remplace la version
    - |
      cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | .manifest_path | tostring' |  xargs -I {}  bash ~/upgrade-cargo.sh {} $PACKAGE $CRATE_VERSION
    - tomlq --arg VERSION $CRATE_VERSION -t '.package.version = $VERSION' $CARGO_FILE > Cargo.toml.modified
    - mv Cargo.toml.modified $CARGO_FILE
    - *packaging

.release-prod:
  image: rust:1.77
  stage: packaging
  # seulement si c'est main
  only:
    - main
  script:
    - *prepare
    - *packaging

release-dev:
  extends: .release-dev
  when: manual
  parallel:
    matrix:
      - PACKAGE: w1
      - PACKAGE: w2
      - PACKAGE: w3

release-prod:
  extends: .release-prod
  when: manual
  parallel:
    matrix:
      - PACKAGE: w1
      - PACKAGE: w2
      - PACKAGE: w3
avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.