Skip to Content
Mélodium 0.10.4 is now available!
DocsExemplesPipeline CI

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é.

Note

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.toml

repo_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 le Cargo.toml du 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-slim plus un conteneur de service postgres:16 ; une vérification de connectivité psql contre DATABASE_URL prouve que le sidecar est bien accessible, puis cargo test exécute la suite de tests propre au dépôt. Les tests de hexyl sont indépendants de la base de données, donc c’est la vérification psql, et non cargo test, qui démontre réellement le fonctionnement des service_containers ; pointez repo_url vers 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.data

La 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.b
bothOk: and<bool>() buildOkStream.stream -> bothOk.a testOkStream.stream -> bothOk.b

L’é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.trigger

Motifs 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_commands tokenise chaque chaîne de commande littéralement, telle qu’elle est écrite : les fonctionnalités shell comme l’expansion ${VAR}, &&, cd ou 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