<index> / <wazabiedr> / plugin-sdk
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Partie 8 — Le SDK plugin
~ lululufr
SOMMAIRE
  0  ce que possède le sdk
  1  deux surfaces — trait vs closure
  2  metadata et la macro
  3  le runner — handshake, heartbeat, shutdown
  4  eventsink — emit, mutex, sémantique d'erreur
  5  sessioncontext — shutdown sans busy-wait
  6  single-shot — pas de reconnexion interne
  7  hors périmètre

──[ 0. Ce Que Possède le SDK ]──

Le protocole de la Partie 7 est direct mais fastidieux à implémenter : ouvrir le 
pipe, écrire la frame `Hello`, lire la `HelloAck` ou le `Reject`, spawn un
thread heartbeat, gérer Ctrl+C, envoyer `Goodbye` à la sortie, fermer le pipe.
Chaque plugin réimplémenterait la même machinerie. Le SDK (`wedr-plugin-sdk`) l'absorbe en entier. Un auteur de plugin écrit une
chose : une fonction qui produit des événements. Les deux formes ci-dessous sont
des plugins complets et fonctionnels :
fn main() -> wedr_plugin_sdk::Result<()> {
    let meta = wedr_plugin_sdk::metadata!("acme-tick")?;
    wedr_plugin_sdk::run_with(meta, |sink, ctx| {
        while !ctx.is_shutting_down() {
            sink.emit("acme.tick", serde_json::json!({ "alive": true }))?;
            ctx.sleep(std::time::Duration::from_secs(10));
        }
        Ok(())
    })
}
Dix lignes contre le SDK ; tout le reste — gestion du pipe, framing, handshake, 
heartbeat, gestion des signaux — est caché.
──[ 1. Deux Surfaces — Trait vs Closure ]──

Le SDK expose deux formes d'API pour la même machinerie sous-jacente.

Le trait `Plugin` convient à de l'état long-lived et à une boucle pollée :
pub trait Plugin {
    fn metadata(&self) -> Metadata;
    fn run(&mut self, sink: &EventSink, ctx: &SessionContext) -> Result<()>;
}
L'auteur définit une struct, implémente deux méthodes, et appelle 
`run(YourPlugin::new())`. Convient quand le plugin a de la configuration, de
l'état interne, ou une forme orientée objet naturelle. `run_with` convient aux sources pilotées par callback :
pub fn run_with<F>(self, metadata: Metadata, body: F) -> Result<()>
where
    F: FnOnce(&EventSink, &SessionContext) -> Result<()>,
L'auteur fournit une closure qui tourne une fois. Utilisée quand la vraie source 
d'événements est un callback OS, un `EvtSubscribe` (Partie 10), un hook, ou
n'importe quelle API push-driven : la closure enregistre son callback contre la
source, file au callback `sink.clone()`, et parke le thread principal sur
`ctx.wait_for_shutdown()`. Defender Bridge en Partie 10 utilise cette forme. Les deux formes se compilent vers la même routine `run_inner` à l'intérieur du
SDK.
──[ 2. Metadata et la Macro ]──

Chaque plugin annonce son identité dans sa frame `Hello` :
#[derive(Debug, Clone)]
pub struct Metadata {
    pub plugin_id:      String,   // UUID du manifest
    pub plugin_version: String,   // version de la crate
    pub plugin_name:    String,   // libellé humain
}
`plugin_id` est sourcé depuis l'environnement (`WEDR_PLUGIN_ID`) plutôt que 
hard-codé dans le binaire. Le raisonnement, c'est que le même binaire de plugin
peut être installé et enrôlé plusieurs fois sous différents UUID — pour du test,
pour des déploiements multi-tenant, pour des rollouts par étapes. Hard-coder
l'UUID empêche ça. `Metadata::from_env` lit `WEDR_PLUGIN_ID` et construit la metadata :
impl Metadata {
    pub fn from_env(plugin_name: &str, plugin_version: &str) -> Result<Self> {
        let id = env::var("WEDR_PLUGIN_ID")
            .ok()
            .filter(|s| !s.is_empty())
            .ok_or(Error::MissingPluginId)?;
        Ok(Self::new(id, plugin_version, plugin_name))
    }
}
La macro `metadata!` est une petite commodité par-dessus :
#[macro_export]
macro_rules! metadata {
    ($plugin_name:expr) => {
        $crate::Metadata::from_env($plugin_name, env!("CARGO_PKG_VERSION"))
    };
}
La forme macro compte spécifiquement à cause de comment `env!` résout : 
`env!("CARGO_PKG_VERSION")` rend la version *de quel crate la l'appel est*. À
l'intérieur du SDK ça voudrait dire que chaque plugin rapporte la version du SDK
pour toujours. À l'intérieur de la macro elle s'expand au site de compilation de
l'appelant, capturant la version de la crate de l'appelant. Les macros sont le
mécanisme standard pour hisser une valeur depuis l'environnement de compilation
de l'appelant en Rust.
──[ 3. Le Runner — Handshake, Heartbeat, Shutdown ]──

`Runner` est un builder fluent :
let result = Runner::new()
    .pipe_path(r"\\.\pipe\WazabiEDR_plugin_dev")   // optionnel
    .install_signal_handler(false)                   // optionnel
    .heartbeat(Duration::from_secs(15))              // optionnel
    .run(my_plugin);
Les défauts matchent le cas commun. Ne surchargez le chemin de pipe que pour du 
test ou quand on tourne contre un agent non-défaut. Désactivez le handler de
signal si le processus hôte possède déjà la chaîne Ctrl+C — Windows n'autorise
qu'une seule chaîne de handlers de contrôle de console par processus, et
l'installer du SDK entrerait en conflit. `Runner::run` procède dans une séquence fixe :
    1. Résoudre le drapeau de shutdown global (un AtomicBool
       process-wide que chaque Runner actif partage, pour qu'un
       Ctrl+C niveau processus les atteigne tous).
    2. Ouvrir le pipe avec OpenOptions en mode read-write.
    3. Envoyer Hello ; lire HelloAck ou Reject. Un Reject
       retourne Err(Error::Rejected{reason}) immédiatement.
    4. Construire SinkInner autour du pipe ouvert.
    5. Si heartbeat est activé, spawn le thread heartbeat.
    6. Invoquer le corps du plugin (trait::run ou closure).
    7. Sur tout chemin de sortie : poser shutdown, tenter
       Goodbye (best-effort), join le thread heartbeat,
       fermer le pipe.

Un plugin qui retourne `Err(Error::Shutdown)` depuis son corps est traité comme 
une sortie propre, pas un échec — `Error::Shutdown` est la manière documentée
pour un plugin de dire "j'ai remarqué le drapeau de shutdown et je m'en vais".
Toute autre erreur se propage au `main` du plugin.
──[ 4. EventSink — Emit, Mutex, Sémantique D'Erreur ]──

`EventSink` est la surface d'API que chaque plugin touche vraiment :
pub fn emit(&self, kind: &str, payload: serde_json::Value) -> Result<()>;
pub fn emit_with_ts(&self, kind: &str, ts_unix_ns: u64,
                    payload: serde_json::Value) -> Result<()>;
`emit` stampe l'heure wall-clock courante. `emit_with_ts` accepte un timestamp 
explicite — utilisé quand le plugin forwarde des événements depuis une source
qui porte sa propre heure (une ligne de log qu'on taile a son propre timestamp ;
l'heure à laquelle le plugin l'a observée n'a pas d'importance). `EventSink` est `Clone` et le clone est pas cher (un bump de refcount `Arc`). Le
pattern commun, c'est de cloner le sink dans les callbacks producteurs :
run_with(metadata, |sink, ctx| {
    let cb_sink = sink.clone();
    let subscription = some_api::subscribe(move |evt| {
        let _ = cb_sink.emit("acme.event", evt.into_json());
    })?;
    ctx.wait_for_shutdown();
    drop(subscription);   // unsubscribe avant que le SDK ne démonte le pipe
    Ok(())
})
Sous le capot, le pipe est wrappé dans un `Mutex<File>`. Plusieurs threads 
peuvent appeler `emit` en concurrence ; le mutex est tenu juste assez longtemps
pour effectuer un `write_all + flush`. Le fait que le mutex sérialise les
*producteurs*, pas les consommateurs, veut dire qu'un agent lent à l'autre bout
ne peut pas deadlocker le plugin — il ne fait que ralentir l'émission. `emit` retourne `Err(Error::Shutdown)` une fois le drapeau de shutdown posé. Ça
rend le chemin d'échec explicite à chaque site d'emit : un plugin dans un
callback qui se déclenche après que shutdown a été signalé le découvre au moment
où il essaie d'expédier le prochain événement, sans polling explicite.
──[ 5. SessionContext — Shutdown Sans Busy-Wait ]──

`SessionContext` est le côté lecture du drapeau de shutdown plus deux helpers :
pub fn is_shutting_down(&self) -> bool;
pub fn wait_for_shutdown(&self);     // parke le thread appelant
pub fn sleep(&self, dur: Duration);  // sleep qui retourne tôt au shutdown
`is_shutting_down`, c'est un seul load `Ordering::Acquire`. Le cas boucle pollée 
(l'API trait) l'appelle à chaque frontière d'itération. `wait_for_shutdown` est le pattern standard pour les plugins pilotés par
callback. Enregistrer le callback, lui filer `sink.clone()`, puis appeler
`wait_for_shutdown` sur le thread principal. La fonction bloque sur une variable
de condition (*condvar* : primitive de synchronisation qui laisse un thread
attendre jusqu'à ce qu'un autre thread le signale ; le pairing standard, c'est
mutex + condvar) jusqu'à ce que le drapeau de shutdown bascule. `sleep` est la combinaison : un thread peut demander à dormir pendant une durée
mais être réveillé tôt si shutdown est signalé. Il attend sur la même condvar
que `wait_for_shutdown` utilise, avec un timeout. Le comportement est équivalent
à "poller `is_shutting_down` jusqu'à vrai ou jusqu'à ce que `dur` s'écoule" mais
sans l'overhead de polling — le thread est vraiment parké, et le réveil est
signalé par celui qui pose shutdown plutôt que détecté par celui qui dort.
──[ 6. Single-Shot — Pas de Reconnexion Interne ]──

`Runner` ne se reconnecte pas. Si l'agent part en pleine session, le prochain 
`emit` retourne `Err(Error::Io(BrokenPipe))`, le `run` du plugin propage ça à
`main`, et le processus sort. Le raisonnement, c'est un modèle d'échec observable par processus. Un plugin qui
sort à la perte de pipe a un état observable : vivant ou non. Un plugin avec
logique de reconnexion interne en a au moins trois : connecté, en reconnexion
(avec certains événements droppés), et "le SDK pense être connecté mais ne l'est
pas". Le troisième est le pire genre d'échec à debugger. Les superviseurs de processus qui redémarrent à la sortie sont une facilité OS
standard — `sc.exe` avec les options `failure=` appropriées,
`Restart=on-failure` de systemd sur Linux, le superviseur propre de l'agent pour
les plugins flaggés `auto_launch = true` (Partie 9). Tous fournissent le
comportement de redémarrage que la reconnexion fournirait, avec une sémantique
strictement plus claire.
──[ 7. Hors Périmètre ]──

Pour l'exhaustivité, les choses que le SDK n'essaie explicitement pas d'être :
    - Une bibliothèque de retry / backoff. L'auteur du plugin
      en importe une (ou en écrit dix lignes) si nécessaire.
    - Un scheduler. Threads simples ou n'importe quel executor
      que l'auteur préfère.
    - Un logger. Les plugins logguent vers stderr ; l'agent
      capture stderr quand il auto-launch le plugin et le
      forwarde vers son propre log.
    - Un framework de configuration. Le manifest ne porte pas
      de configuration de plugin ; le plugin lit son propre
      fichier de config via le mécanisme qu'il aime.

Le SDK est petit volontairement. La première itération était sensiblement plus 
grosse et essayait d'être un framework générique de collection d'événements ; la
seconde l'a réduit à ce qui est là. Moins de code sur le chemin entre la source
d'événements et le pipe, c'est moins de régressions et une surface plus claire à
raisonner. Prochain post : le manifest et le CLI qui l'écrit. Le mécanisme qui décide à
quels plugins l'agent acceptera des handshakes du tout.