Fondamentaux
Initiation à Rust pour cette base de code
Avant de lire : Initiation au cloud native, dont les exemples utilisent le vocabulaire, et des bases de Rust (The Rust Programming Language, chapitres 1 à 10).
Ce n’est pas un tutoriel Rust. C’est le sous-ensemble de Rust dont vous avez besoin pour lire ce dépôt,
chaque idée étant rattachée à un fichier que vous pouvez ouvrir. Si un concept est nouveau pour vous, les liens « En savoir plus »
mènent à la source officielle ; revenez ici pour voir comment le moteur l’utilise. Ensuite, vous pourrez
ouvrir n’importe quel crate du workspace et suivre ses types d’erreur, ses traits, ses blocs unsafe
et ses tests sans buter sur le langage.
Les chemins sont relatifs à la racine du dépôt. Les symboles sont nommés pour que vous puissiez les chercher avec grep —
les numéros de ligne sont volontairement omis, car ils bougent à chaque PR.
La toolchain épinglée se trouve dans rust-toolchain.toml (un channel fixe, plus rustfmt et
clippy). rustup la respecte automatiquement, vous compilez donc avec le même compilateur que la CI.
3.1 Le workspace Cargo#
Le dépôt est un seul workspace Cargo : un Cargo.toml racine avec [workspace] members = [...]
et un Cargo.toml par crate. Trois conventions comptent ici :
- Chaque chemin et chaque version sont écrits une seule fois, à la racine. La racine a une
table
[workspace.dependencies]qui liste à la fois les crates propres au moteur (parpath) et chaque crate tiers (parversion). Un membre n’écrit jamais de version ; il écrit :
# crates/contexts/delonix-node/Cargo.toml
[dependencies]
delonix-model = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
libc = { workspace = true }
Un membre peut ajouter features = [...], et c’est tout. default-features = false vit à
la racine, car un membre ne peut pas désactiver des valeurs par défaut que le workspace a activées.
scripts/arch_fitness.py (fonction inline_versions) fait échouer le build si un membre écrit
sa propre version.
-
Le répertoire est la couche. Les crates vivent dans
crates/foundation/,crates/contexts/,crates/adapters/,crates/providers/,crates/interfaces/et les binaires dansbins/(ADR-0040).scripts/arch_fitness.pyrefuse un crate dont le répertoire ne correspond pas à sa couche déclarée, ainsi qu’une dépendance qui va à l’encontre de la direction autorisée. Les règles de couche sont enseignées plus loin dans le parcours, dans Architecture — Les couches et la direction autorisée. -
Les lints sont hérités. La racine déclare
[workspace.lints.clippy]avecundocumented_unsafe_blocks = "deny", et chaque membre y adhère avec[lints] workspace = true. Chaque blocunsafeporte donc un commentaire// SAFETY:(voir §3.4).
La racine définit aussi [workspace.package] (la version, l’edition et la license partagées), que
les membres consomment sous la forme version.workspace = true. La version n’est pas décorative : voir
Alignement de version pour la règle et
Les gates exécutés par la CI pour le gate de version.
En savoir plus : Cargo Book —
Workspaces,
[workspace.dependencies],
[lints],
rust-toolchain.toml.
3.2 Erreurs : une seule Error, et des codes de sortie dérivés de son type#
Presque chaque fonction faillible du moteur renvoie delonix_model::Result<T>, un alias sur
l’enum partagé défini dans crates/foundation/delonix-model/src/error.rs. L’enum vit dans le crate
de fondation pur delonix-model, dont peut dépendre n’importe quel autre crate du moteur. Certains
adapters définissent leur propre erreur et la convertissent en celle-ci (§5.2 des
Conventions de code). Il est construit avec
thiserror : #[derive(Error)] génère Display à partir de
l’attribut #[error("...")], et #[from] génère des impls From afin que ? convertisse automatiquement une erreur
de plus bas niveau :
// crates/foundation/delonix-model/src/error.rs
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
Les variantes portent sur ce que l’appelant doit faire ensuite, pas sur le sous-système qui a échoué :
NotFound, NotRunning, Conflict, Unavailable (un outil ou une fonctionnalité du noyau manquant),
Timeout, Invalid, Registry, Runtime { context, message } pour un appel système échoué, etc.
Les commentaires de documentation de chaque variante expliquent pourquoi elle existe ; lisez-les avant d’ajouter une variante.
Ces variantes deviennent des codes de sortie de processus en un seul endroit :
crates/foundation/delonix-model/src/exitcode.rs, fonction for_error. La CLI réexporte le
module (pub use delonix_model::exitcode; dans bins/delonix-runtime-bin/src/cmd/mod.rs) et
bins/delonix-runtime-bin/src/main.rs appelle std::process::exit(cmd::exitcode::for_error(&e)).
// crates/foundation/delonix-model/src/exitcode.rs
pub fn for_error(e: &Error) -> i32 {
match e {
Error::NotFound(_) | Error::VmNotFound(_) => NOT_FOUND,
Error::NotRunning(_) => NOT_RUNNING,
// ...
Deux choses à remarquer :
- Le
matchest exhaustif, sans bras_ =>. Ajouter une variante àErrorarrête le build dansfor_error(et dansError::codedanserror.rs) jusqu’à ce que quelqu’un décide de sa classe. C’est un usage délibéré du compilateur comme liste de contrôle. - Les messages sont traduits pour l’opérateur, les scripts doivent donc se brancher sur le code de sortie (ou le
code machine stable de
Error::code), jamais sur le texte du message.
En savoir plus : The Rust Book —
Recoverable errors with Result,
l’opérateur ? ;
Rust by Example — Defining an error type ;
thiserror sur docs.rs.
3.3 Les traits comme ports : VmBackend et le registre de backends#
Le moteur parle aux providers à travers des traits (« ports »), et un provider est une implémentation
de l’un d’eux. L’exemple le plus clair est VmBackend dans crates/adapters/delonix-vm/src/lib.rs :
pub trait VmBackend {
fn id(&self) -> &'static str;
fn available(&self) -> bool;
fn boot(&self, vmdir: &Path, cfg: &VmConfig, overlay: &str,
on: &dyn Fn(CreateStage)) -> Result<Boot>;
fn is_running(&self, vm: &Vm) -> bool;
fn ip(&self, vm: &Vm) -> Option<String>;
// ... more methods, many with default bodies
}
Implémentations : CloudHypervisorBackend et LibvirtBackend dans le même fichier, et
ProxmoxBackend dans crates/providers/delonix-proxmox/src/lib.rs. Les méthodes ayant un corps
par défaut dans le trait (par exemple auto_selectable) permettent à un nouveau backend d’hériter d’un comportement raisonnable
et de ne redéfinir que ce qui diffère.
Les backends sont choisis à l’exécution, ils sont donc manipulés comme objets trait, Box<dyn VmBackend>.
Ils sont créés au moyen d’un registre de fabriques :
// crates/adapters/delonix-vm/src/lib.rs
pub type BackendFactory = Box<dyn Fn() -> Result<Box<dyn VmBackend>> + Send + Sync>;
static BACKENDS: std::sync::OnceLock<std::sync::RwLock<Vec<BackendRegistration>>> =
std::sync::OnceLock::new();
- La fabrique est une closure (
dyn Fn), car un backend distant a besoin d’une configuration capturée (endpoint, nœud, identifiant) qu’un simple pointeurfn()ne peut pas transporter. Send + Syncest requis parce que la table est unstaticà l’échelle du processus ; la contrainte porte sur la closure, pas sur le traitVmBackend.OnceLockinitialise la table paresseusement (builtin_backends()y place les deux backends locaux), etRwLockpermet àregister_backendd’en ajouter un troisième au démarrage. Le binaire le fait dansbins/delonix-runtime-bin/src/cmd/vmbackends.rs(register_configured).
La décision derrière cette forme est l’ADR-0008. Le même
motif « trait + implémentations + un seul endroit qui choisit » apparaît ailleurs (par exemple le
port VmNetwork, conservé dans un OnceLock<Box<dyn VmNetwork>> près du début du même fichier).
En savoir plus : The Rust Book —
Traits,
Trait objects,
Closures,
Send et Sync ;
documentation std — OnceLock.
3.4 unsafe, FFI et appels système Linux#
Un moteur de containers, ce sont surtout des appels système. Ce dépôt atteint le noyau à travers trois crates :
| Crate | Utilisé pour | Exemple dans ce dépôt |
|---|---|---|
nix |
Enveloppes plus ou moins sûres : clone, setns, unshare, pivot_root, fork, mount, signaux |
use nix:: dans crates/ |
libc |
Appels bruts que nix n’enveloppe pas, ou lorsque la structure exacte compte |
libc:: dans crates/ (peer_uid) ; libc::flock dans crates/ |
rustix |
La nouvelle API de montage (fsopen/fsconfig/fsmount/move_mount) |
fsopen_overlay dans crates/ |
Chaque bloc unsafe indique pourquoi il est correct, juste à côté (le lint du workspace impose la
présence du commentaire ; les relecteurs imposent sa véracité) :
// crates/contexts/delonix-node/src/peer_cred.rs
// SAFETY: getsockopt on SO_PEERCRED with a correctly-sized ucred buffer.
let r = unsafe { libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, ...) };
Où naît le container#
fn spawn dans crates/adapters/delonix-linux/src/lib.rs se termine par :
// SAFETY: single-threaded; the child mounts the container and does `exec`.
let cloned = unsafe { clone(cb, &mut stack, flags, Some(Signal::SIGCHLD as i32)) };
L’enfant exécute container_init, qui appelle setup_rootfs (bind mounts, puis pivot_root),
drop_capabilities, installe seccomp, et enfin execvp. setns est la façon dont exec entre dans les
namespaces d’un container existant. Les maps uid/gid d’un user namespace sont écrites depuis le
parent (write_userns_maps, qui utilise les utilitaires newuidmap/newgidmap lorsqu’ils sont disponibles).
Pourquoi fork/clone dans un processus multi-thread est dangereux#
Après fork ou clone, seul le thread appelant existe dans l’enfant, mais l’enfant hérite de
chaque verrou dans l’état où il était — y compris des verrous détenus par des threads qui n’existent plus. Si un autre thread
détenait le verrou de l’allocateur à cet instant, la première allocation de l’enfant bloque pour toujours. fork
atténue une partie de ce problème avec les gestionnaires pthread_atfork ; un clone brut ne les exécute pas.
Ce dépôt a deux règles concrètes à cause de cela, toutes deux expliquées dans des commentaires que vous devriez lire :
serve docker-apin’appelle jamaisspawndans le processus. Le commentaire de documentation au-dessus de l’utilitaire de ré-exécution dansbins/delonix-runtime-bin/src/cmd/dockerapi.rsexplique que le serveur est un runtime tokio multi-thread ; il ré-exécute donc le binaire (__apirun <spec.json>) pour obtenir un processus neuf mono-thread dans lequel la précondition decloneest de nouveau vraie.- Après un
forkbrut, uniquement du travail async-signal-safe.reexec_mappedetreexec_mapped_holddanscrates/adapters/delonix-linux/src/lib.rsprécalculent chaque allocation avant le fork. Le commentaire surreexec_mapped_holdconsigne aussi pourquoi ils utilisent unforkbrut au lieu destd::process::Command+pre_exec:Command::spawnattend que l’enfant atteigneexec, et un hookpre_execqui bloque en attendant le parent provoque un interblocage.
La nouvelle API de montage, et pourquoi elle existe ici#
mount_overlay_if_marked monte la racine overlay d’un container via rustix::mount::fsopen et
un appel fsconfig_set_string(&fs, "lowerdir+", lower) par couche. Le mount(2) classique regroupe
toutes les options dans une unique chaîne data de la taille d’une page, que le noyau tronque silencieusement pour
les images comportant de nombreuses couches. La mesure et la décision se trouvent dans
l’ADR-0037. C’est un bon exemple de l’habitude du dépôt :
le commentaire /// au-dessus d’un appel système non évident explique la défaillance qui l’a motivé.
En savoir plus : The Rust Book — Unsafe Rust ;
le Rustonomicon (en particulier
FFI) ; pages de manuel
clone(2),
setns(2),
pivot_root(2),
fork(2),
signal-safety(7),
fsopen(2).
3.5 Sérialisation : serde, manifestes YAML, schéma généré#
L’état sur disque est en JSON ; les manifestes sont en YAML. Les deux passent par
des derives serde.
Enregistrements rétrocompatibles. Un enregistrement écrit par un binaire plus ancien doit toujours se charger. La règle
est la suivante : un nouveau champ reçoit #[serde(default)], et lorsque « absent » doit signifier autre chose que la
valeur par défaut du type, il devient une Option :
// bins/delonix-runtime-bin/src/cmd/vmimage.rs (VmImage)
#[serde(default)]
pub cloud_init: Option<bool>,
Ici, None (chaque enregistrement écrit avant que le champ n’existe) est lu comme « oui » ; un simple bool
aurait valu false par défaut et aurait silencieusement changé le comportement des anciennes images. Vous verrez le
même raisonnement dans crates/contexts/delonix-compute/src/record.rs : Mount::propagation est une
Option (#[serde(default, skip_serializing_if = "Option::is_none")]), et Mount::optional est un
simple bool avec #[serde(default)], parce que false est ce que signifiait tout enregistrement plus
ancien.
Manifestes. bins/delonix-runtime-bin/src/cmd/manifest.rs analyse le YAML multi-document avec
serde_yaml::Deserializer::from_str(text) vers ManifestDoc, dont le spec reste une
serde_yaml::Value brute jusqu’à ce que le Kind propriétaire le désérialise dans sa spec typée.
Schéma généré. Les types de spec dérivent schemars::JsonSchema (par exemple PodSpec dans
crates/contexts/delonix-compute/src/pod.rs), et bins/delonix-runtime-bin/src/cmd/schema.rs
génère à partir d’eux le JSON Schema publié (ADR-0007).
Notez le commentaire à cet endroit : schemars respecte #[serde(rename)] mais pas #[serde(alias)].
En savoir plus : serde.rs —
attributs de champ (default, skip_serializing_if, alias) ;
serde_yaml ; schemars.
3.6 La CLI : clap derive et sortie traduite#
Le binaire delonix est bins/delonix-runtime-bin. Son niveau supérieur est un derive
clap dans src/main.rs : #[derive(Parser)] struct Cli contenant une
option globale --l18n et #[command(subcommand)] cmd: Cmd, où enum Cmd est un
#[derive(Subcommand)]. Chaque groupe a son propre module et son propre enum dans src/cmd/ — par exemple
pub enum ContainerCmd dans src/cmd/container.rs.
Les commentaires de documentation (///) sur les variantes et les champs deviennent le texte de --help. Vous verrez
#[allow(clippy::large_enum_variant)] sur les enums de commandes, avec un commentaire expliquant pourquoi : ils sont
analysés une fois par invocation, mettre les variantes en boîte n’apporterait donc rien.
Les chaînes sources sont en anglais ; le portugais vient d’un catalogue. src/cmd/po.rs embarque
data/pt.po avec include_str! et expose :
po::t("already exists, nothing to do")— une chaîne fixe ;po::tf("port {port} is taken by '{owner}'", &[("port", &hp), ("owner", &ow)])— un modèle avec des espaces réservés nommés, car une traduction peut les réordonner ;po::translate_help, qui réécrit le texte d’aide de clap après quepo::peek_langa décidé de la langue avant l’analyse.
Une nouvelle chaîne destinée à l’utilisateur est en anglais dans le code, plus une entrée dans data/pt.po. La politique
de langue (LANG-01) et son gate sont traités dans Flux de contribution.
En savoir plus : tutoriel clap derive ;
std — include_str!.
3.7 Async et gRPC — et pourquoi la plus grande partie du moteur est synchrone#
Le cœur du moteur est synchrone : la CLI démarre, fait son travail avec des appels système bloquants et des E/S de fichiers, puis se termine. Il n’y a pas de daemon ni de runtime async ambiant. Le code async n’apparaît que dans les interfaces qui servent un protocole :
- Serveur CRI —
crates/interfaces/delonix-cri. Les stubs gRPC sont générés au moment du build :build.rsappelletonic_build::configure()...compile_protos(&["proto/api.proto"], &["proto"]). Cela nécessite queprotocsoit installé (la CI installeprotobuf-compiler) ; unprotocmanquant est l’échec le plus courant du premier build.serve_blockingdanssrc/lib.rscrée son propre runtimetokio::runtime::Builder::new_multi_thread(), et les gestionnaires déportent le travail bloquant du moteur hors des workers async au moyen de l’utilitaireblockingdanssrc/runtime_svc.rs— sinon uncloneou un appel shell àdelonixbloquerait les workers tokio. - Reverse proxy L7 —
bins/delonix-runtime-bin/src/cmd/ingress_proxy.rsutilisehyperdirectement (hyper::service::service_fn) sur son propre runtime tokio. - Serveur MCP —
crates/interfaces/delonix-mcputilisermcpsur stdio (rmcp::transport::io::stdio). - Télémétrie —
crates/adapters/delonix-telemetry/src/telemetry.rsexporte des spans OpenTelemetry depuis un thread dédié avec un client HTTP bloquant, précisément pour que la CLI synchrone n’ait pas besoin d’un runtime.
Le contrat de nœud sous proto/delonix/node/v1 est une API protobuf distincte avec son propre gate
(scripts/contract_gate.py, buf) ; voir Compiler et tester.
En savoir plus : Asynchronous Programming in Rust ;
tutoriel Tokio ;
tonic et tonic-build ;
installation de Protocol Buffers.
3.8 Concurrence et état partagé#
Il n’y a pas de base de données. L’état est constitué de fichiers JSON sous la racine d’état, et plusieurs processus (deux
invocations de la CLI, le serveur CRI, un superviseur) peuvent toucher le même enregistrement en même temps. Le motif pour
chaque lecture-modification-écriture est update avec une closure, sous un flock exclusif :
// crates/adapters/delonix-state/src/store.rs (Store::update)
let id = self.load(id_or_name)?.id;
let _lock = FileLock::acquire(&self.lock_path(&id))?;
// Re-read UNDER the lock ...
let mut c = self.load(&id)?;
if !f(&mut c) { return Ok(c); }
self.save(&c)?;
JsonStore<T>::update dans le même fichier est la version générique pour les autres types d’enregistrement. Tous deux
refusent de s’exécuter lorsque le verrou ne peut pas être pris (Error::Lock) au lieu de continuer sans verrou.
(SecretStore::update dans secret.rs est l’exception : son verrou est au mieux (best-effort).) Règles qui
en découlent :
- Ne faites jamais
load→ modification →saveà la main pour un enregistrement qu’un autre processus peut écrire ; vous perdrez des mises à jour. Utilisezupdate. - Relisez à l’intérieur du verrou. La valeur que vous avez chargée plus tôt est peut-être déjà obsolète.
FileLocklibère le verrou dansDrop— le motif RAII.
À l’intérieur d’un même processus, l’état partagé utilise les types standard : un
Arc<std::sync::RwLock<Arc<Vec<Route>>>> contient la table de routes remplaçable à chaud du proxy
(SharedRoutes dans cmd/ingress_proxy.rs), et le tableau de bord partage son échantillon lent au moyen d’un
Arc<Mutex<...>> (cmd/dash.rs).
Une idée apparentée que vous rencontrerez dans crates/foundation/delonix-model/src/typestate.rs : le
motif typestate, où les états du cycle de vie sont des types et où les transitions illégales ne compilent pas
(ses tests de documentation incluent des exemples compile_fail).
En savoir plus : The Rust Book —
Shared-state concurrency,
Drop ;
flock(2) ;
std — Arc,
RwLock.
3.9 Tests#
- Les tests unitaires vivent à côté du code, dans un
#[cfg(test)] mod tests { ... }en bas du fichier. Beaucoup de fonctions pures existent précisément pour qu’une décision puisse être testée comme une donnée (par exemplereconcile::plandanscrates/contexts/delonix-stack/src/reconcile.rs). - Les tests d’intégration vivent dans
crates/<layer>/<crate>/tests/*.rset n’utilisent que l’API publique du crate. Exemple :crates/interfaces/delonix-cri/tests/grpc_status.rsdémarre le serveur CRI sur un socket Unix et lui parle avec le client gRPC généré (c’est pourquoibuild.rsdéfinitbuild_client(true)). Les tests souscrates/providers/*/tests/live.rsont besoin d’un vrai provider et sont à activer explicitement. - Les tests de propriété utilisent
proptest: voircrates/adapters/delonix-sdn/tests/ip_invariants.rs(proptest! { ... }). - Les tests de documentation s’exécutent aussi — y compris les blocs
compile_failcomme ceux detypestate.rs. - Les benchmarks utilisent
criterion(crates/adapters/delonix-oci/benches/). - Noms. Les noms de tests décrivent le comportement prouvé. Beaucoup de tests existants ont des
noms en portugais ; les nouveaux identifiants sont en anglais (LANG-01, imposé comme ratchet (cliquet) par
scripts/lang_ratchet.py). - Les tests ne doivent pas toucher l’état réel de l’hôte. Prenez un répertoire temporaire et passez-le (les stores prennent un chemin racine) plutôt que d’appeler du code qui résout la racine d’état réelle. Tout ce qui a besoin de vrais namespaces, de vrais cgroups ou d’un holder réseau relève de la validation réelle/E2E décrite dans Compiler et tester.
En savoir plus : The Rust Book — Writing tests, Test organization ; rustdoc — Documentation tests.
3.10 Outillage imposé par la CI#
| Outil | Commande CI (depuis .github/workflows/ci.yml) |
Ce qu’il détecte |
|---|---|---|
| rustfmt | cargo fmt --all --check |
dérive de formatage |
| clippy | cargo clippy --workspace --all-targets --locked -- -D warnings |
n’importe quel avertissement, y compris undocumented_unsafe_blocks |
| tests | cargo test --workspace --locked --no-fail-fast |
régressions |
| cargo-deny | EmbarkStudios/ avec deny.toml |
avis RUSTSEC, licences, sources des crates |
Le dépôt a aussi des gates (contrôles CI) en Python (scripts/arch_fitness.py, scripts/lang_ratchet.py et d’autres).
La liste complète et la façon d’exécuter chacun localement se trouvent dans Compiler et tester.
En savoir plus : Clippy, rustfmt, cargo-deny.
Suivant : Préparer votre environnement — un hôte capable de compiler l’arborescence et d’exécuter les chemins réels, et les pièges de l’hôte qui ressemblent à des bugs du moteur.