Pipeline CI
Source: showcase/ci_pipeline See in Playground
Un pipeline CI en trois étapes s’exécutant entièrement sur des conteneurs cloud provisionnés, combinant cicd, work et process dans un même scénario. build et test s’exécutent en parallèle ; package ne démarre qu’une fois les deux terminées, et seulement si aucune n’a échoué.
Nécessite un jeton API Mélodium Services, ou une configuration locale podman/docker compose (voir le paramètre location de CicdDispatchEngine). Placez MELODIUM_API_TOKEN dans l’environnement et exécutez avec --api-report ; voir Cadence.CI pour obtenir un jeton et suivre l’exécution. Rien d’autre n’est nécessaire : repo_url pointe par défaut vers un dépôt public réel.
Exécution
cd showcase/ci_pipeline
export MELODIUM_API_TOKEN="my-api-token"
melodium run --api-report Compo.tomlrepo_url pointe par défaut vers sharkdp/hexyl, une petite crate binaire Rust réelle, sous licence permissive, sans dépendance externe à la compilation ou aux tests, choisie spécifiquement pour que cet exemple soit exécutable tel quel. Passez --repo_url pour compiler autre chose :
melodium run --api-report Compo.toml \
--repo_url "https://github.com/your-org/your-project.git"- Étape 1, build : conteneur
rust:1.90-slim;cargo build --release; le nom du binaire est relu depuis leCargo.tomldu dépôt cloné (fonctionne pour n’importe quelle crate mono-binaire classique, pas seulement celle par défaut), et il est renvoyé en flux localement. - Étape 2, test :
rust:1.90-slimplus un conteneur de servicepostgres:16; une vérification de connectivitépsqlcontreDATABASE_URLprouve que le sidecar est bien accessible, puiscargo testexécute la suite de tests propre au dépôt. Les tests dehexylsont indépendants de la base de données, donc c’est la vérificationpsql, et noncargo test, qui démontre réellement le fonctionnement desservice_containers; pointezrepo_urlvers un projet avec des tests d’intégration adossés à une base de données pour exercer le sidecar plus complètement. - Étape 3, package :
debian:bookworm-slim; regroupe le binaire de l’étape 1 dans une archive.tar.gz, renvoyée en flux et écrite sur disque.
Fonctionnement
Un seul modèle CicdDispatchEngine gère toutes les questions d’infrastructure, et chaque étape est un sous-traitement encapsulant simpleStep ou simpleStepWithInput, qui abstraient le provisionnement, l’exécution, les E/S de fichiers et le nettoyage en un seul appel :
treatment main(
const repo_url: string = "https://github.com/sharkdp/hexyl.git",
const output: string = "artifact.tar.gz"
)
model dispatcher: CicdDispatchEngine()
{
...
build[dispatcher=dispatcher](repo_url=repo_url)
startup.trigger -> build.trigger
test[dispatcher=dispatcher](repo_url=repo_url)
startup.trigger -> test.trigger
...
}Les deux étapes sont explicitement fixées au même arch (|wrap<Arch>(|arm64())) : un conteneur de service n’a pas d’option « non spécifié » pour son arch, contrairement à une étape elle-même, donc les deux doivent s’accorder explicitement, ou le déploiement est purement et simplement rejeté avant même que l’un des conteneurs démarre.
Étape 1 : build, avec le nom du binaire lu depuis Cargo.toml
build encapsule simpleStep dans un conteneur rust:1.90-slim, clone le dépôt et compile un binaire en mode release. out_file = "binary" indique à simpleStep de renvoyer /mnt/data/binary en flux via data: Stream<byte> :
commands = [
|command("sh", ["-c", "apt-get update -qq && apt-get install -y --no-install-recommends git"]),
|command("sh", ["-c", "git clone --depth 1 ${REPO_URL} /workspace"]),
|command("sh", ["-c", "cd /workspace && cargo build --release 2>&1"]),
|command("sh", ["-c", "BIN=$(grep -m1 '^name' /workspace/Cargo.toml | cut -d '\"' -f2) && cp \"/workspace/target/release/$BIN\" /mnt/data/binary"])
],
variables = |wrap<StringMap>(|map([|entry("REPO_URL", repo_url)])),
out_file = "binary"commands exécute chaque entrée comme un exec direct, jamais via un shell : ${REPO_URL} est une véritable variable d’environnement OS pour le processus (issue de variables), mais rien n’interprète la syntaxe ${...} à moins que la commande exécutée ne soit elle-même un shell. Chaque entrée nécessitant des fonctionnalités de shell (expansion de variable, &&, cd, 2>&1, $(...)) est explicitement enveloppée dans un sh -c.
Étape 2 : test, avec un sidecar Postgres
test utilise la même image, mais y attache un |service_container Postgres 16 accessible par le nom d’hôte "postgres". Une étape psql -c "SELECT 1;" prouve que le sidecar est réellement accessible via DATABASE_URL avant que cargo test ne s’exécute :
service_containers = [
|service_container(
"postgres",
2048,
2000,
4096,
|arm64(),
[],
"postgres:16",
_,
|wrap<StringMap>(|map([
|entry("POSTGRES_USER", "ci"),
|entry("POSTGRES_PASSWORD", "ci"),
|entry("POSTGRES_DB", "ci_test")
])),
_
)
],
commands = [
|command("sh", ["-c", "apt-get update -qq && apt-get install -y --no-install-recommends git postgresql-client"]),
|command("sh", ["-c", "git clone --depth 1 ${REPO_URL} /workspace"]),
|command("sh", ["-c", "psql \"${DATABASE_URL}\" -c \"SELECT 1;\""]),
|command("sh", ["-c", "cd /workspace && cargo test 2>&1"])
],La suite de tests propre à hexyl ne touche pas la base de données, donc c’est cette vérification psql, et non cargo test, qui prouve réellement le câblage des service_containers : cargo test réussirait ici de façon identique, que DATABASE_URL pointe ou non vers une base réellement accessible.
Étape 3 : package, alimentée directement par le binaire de l’étape 1
package encapsule simpleStepWithInput, recevant les octets du binaire en tant que data: Stream<byte>, transmis directement depuis build.data sans passer par le disque local, les écrivant dans /mnt/data/binary via in_file = "binary" sur un conteneur debian:bookworm-slim, puis les archivant :
package[dispatcher=dispatcher](output=output)
packageGate: filterBlock<void>()
startup.trigger -> packageGate.value
bothOkLast.last -> packageGate.select
packageGate.accepted -> package.trigger
build.data -> package.dataLa sortie du système de fichiers d’un conteneur devient l’entrée du système de fichiers d’un autre, sans jamais passer par la machine qui exécute ce programme.
Une véritable porte ET sur un signal optionnel
success/error/failed sont mutuellement exclusifs, mais un seul est garanti de se déclencher réellement selon ce qui s’est passé. flock<T>() attend que ses deux entrées Block se résolvent, avec une valeur ou une fermeture vide, et transmet celle(s) qui a effectivement porté une valeur. Lui fournir directement success laisserait passer le succès d’une seule étape même après l’échec de l’autre, puisque l’entrée success de cette dernière se fermerait simplement vide tout en étant considérée comme « résolue ».
À la place, les trois résultats de chaque étape sont d’abord transformés en un unique Block<bool> garanti, à l’aide de deux one<bool>() chaînés (true depuis success, false depuis error ou failed), puis seulement ensuite combinés avec and() :
buildOkOrError: one<bool>()
buildSuccessTrue.emit -> buildOkOrError.a
buildErrorFalse.emit -> buildOkOrError.b
buildOk: one<bool>()
buildOkOrError.value -> buildOk.a
buildFailedFalse.emit -> buildOk.bbothOk: and<bool>()
buildOkStream.stream -> bothOk.a
testOkStream.stream -> bothOk.bL’étape 3 est conditionnée à ce Block<bool> combiné via filterBlock, de sorte qu’elle ne se déclenche que lorsque les deux étapes ont véritablement réussi.
Fusion des erreurs
Deux one<void>() chaînés fusionnent les quatre signaux d’échec/erreur (build.failed, test.failed, build.error, test.error) en un seul signal oneAnyFailed pour la journalisation, sans réécrire la vérification quatre fois :
failedOrError1: one<void>()
failedOrError2: one<void>()
build.failed -> failedOrError1.a
test.failed -> failedOrError1.b
build.error -> failedOrError2.a
test.error -> failedOrError2.b
oneAnyFailed: one<void>()
failedOrError1.value -> oneAnyFailed.a
failedOrError2.value -> oneAnyFailed.b
logAbort: logErrorMessage(label="ci", message="pipeline aborted: skipping package stage")
oneAnyFailed.value -> logAbort.triggerMotifs clés
simpleStep/simpleStepWithInput: la différence est exactement ce qu’elle laisse penser, à savoir si l’étape a besoin de données transmises en entrée (sous forme de fichier à/mnt/data/<in_file>) en plus de ce qu’elle renvoie en sortie.service_containers: des conteneurs sidecar qui vivent aux côtés du conteneur principal d’une étape pendant toute sa durée ; les paramètres de|service_container(...)suivent l’ordre documenté (name, memory, cpu, storage, arch, mounts, image, pull_secret, env, command).|raw_commandstokenise chaque chaîne de commande littéralement, telle qu’elle est écrite : les fonctionnalités shell comme l’expansion${VAR},&&,cdou les redirections ne fonctionnent qu’à travers un|command("sh", ["-c", "..."])explicite.
Dépendances
[dependencies]
std = "0.10.4" # flux de base, journalisation, structures de données
fs = "0.10.4" # lecture/écriture de fichiers locaux
process = "0.10.4" # exécution de processus externes
work = "0.10.4" # provisionnement de runners cloud
distrib = "0.10.4" # distribution de flux entre runners
cicd = "0.10.4" # dispatch et orchestration d'étapes CI/CD