Partie 3: Gestion des workspaces

Lecture 5 min. ‱


Table des matiĂšres

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:

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:

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:

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