Skip to main content

hm_exec/local/runner/
mod.rs

1//! Static runner interface.
2//!
3//! This module replaces the old WASM plugin system with a static DI
4//! approach. Step executors implement [`StepRunner`]. A [`RunnerRegistry`]
5//! maps runner names to concrete implementations at startup.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use anyhow::Result;
14use hm_plugin_protocol::{ExecutorInput, SnapshotRef, StepResult};
15use tokio_util::sync::CancellationToken;
16
17use crate::local::archive::ArchiveStore;
18use crate::local::events::EventBus;
19
20pub mod vm;
21
22/// Shared context threaded into every runner invocation.
23///
24/// Replaces the monolithic `OrchestratorState` that the old plugin
25/// system passed as opaque host memory. All fields are cheaply
26/// cloneable (`Arc` / `CancellationToken`).
27#[derive(Clone, Debug)]
28pub struct StepContext {
29    pub event_bus: Arc<EventBus>,
30    pub archives: Arc<ArchiveStore>,
31    pub cancel: CancellationToken,
32}
33
34/// Async trait implemented by step executors (e.g. the VM runner).
35///
36/// Each runner is identified by a string [`Self::name`] that pipeline
37/// authors reference in their step definitions.
38///
39/// The `execute` method returns a boxed future so the trait remains
40/// dyn-compatible (async fn in trait is not object-safe).
41pub trait StepRunner: Send + Sync + fmt::Debug {
42    /// Unique name for this runner (e.g. `"vm"`).
43    fn name(&self) -> &'static str;
44
45    /// Execute a single pipeline step.
46    ///
47    /// # Errors
48    ///
49    /// Implementations should return `Err` for infrastructure failures
50    /// (container boot failure, network error, etc.). A non-zero exit
51    /// code from the user command is **not** an error — it is reported
52    /// via [`StepResult::exit_code`].
53    fn execute(
54        &self,
55        ctx: &StepContext,
56        input: ExecutorInput,
57    ) -> Pin<Box<dyn Future<Output = Result<StepResult>> + Send + '_>>;
58
59    /// Reap transient snapshots once a run has finished.
60    ///
61    /// Ephemeral (uncached) leaf steps commit a snapshot purely so a
62    /// downstream `BuildsIn` child can restore from it; nothing else holds a
63    /// reference and the cache registry never tracks them. The scheduler
64    /// collects every such snapshot and calls this at run end (best-effort)
65    /// so they don't leak in the backend store. The default is a no-op for
66    /// runners that produce no reapable snapshots.
67    fn reap_snapshots<'a>(
68        &'a self,
69        _snapshots: Vec<SnapshotRef>,
70    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
71        Box::pin(async {})
72    }
73}
74
75/// Maps runner names to [`StepRunner`] implementations.
76///
77/// Constructed once at startup and shared immutably for the duration
78/// of the run.
79#[derive(Default)]
80pub struct RunnerRegistry {
81    runners: HashMap<String, Arc<dyn StepRunner>>,
82    default: Option<String>,
83}
84
85impl RunnerRegistry {
86    /// Create an empty registry.
87    #[must_use]
88    pub fn new() -> Self {
89        Self {
90            runners: HashMap::new(),
91            default: None,
92        }
93    }
94
95    /// Register a runner. When `is_default` is true the runner's name
96    /// becomes the fallback used by [`Self::resolve`] when no explicit
97    /// name is given.
98    pub fn register(&mut self, runner: Arc<dyn StepRunner>, is_default: bool) {
99        let name = runner.name().to_owned();
100        if is_default {
101            self.default = Some(name.clone());
102        }
103        self.runners.insert(name, runner);
104    }
105
106    /// Look up a runner by name, falling back to the default when
107    /// `name` is `None`.
108    #[must_use]
109    pub fn resolve(&self, name: Option<&str>) -> Option<Arc<dyn StepRunner>> {
110        let key = name.or(self.default.as_deref())?;
111        self.runners.get(key).cloned()
112    }
113
114    /// The name of the current default runner, if one has been set.
115    #[must_use]
116    pub fn default_runner_name(&self) -> Option<&str> {
117        self.default.as_deref()
118    }
119
120    /// Sorted list of all registered runner names.
121    #[must_use]
122    pub fn runner_names(&self) -> Vec<&str> {
123        let mut names: Vec<&str> = self.runners.keys().map(String::as_str).collect();
124        names.sort_unstable();
125        names
126    }
127}
128
129impl fmt::Debug for RunnerRegistry {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.debug_struct("RunnerRegistry")
132            .field("runners", &self.runners.keys().collect::<Vec<_>>())
133            .field("default", &self.default)
134            .finish()
135    }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
140mod tests {
141    use super::*;
142
143    /// Minimal stub runner for unit tests.
144    #[derive(Debug)]
145    struct StubRunner {
146        runner_name: &'static str,
147    }
148
149    impl StubRunner {
150        fn new(name: &'static str) -> Self {
151            Self { runner_name: name }
152        }
153    }
154
155    impl StepRunner for StubRunner {
156        fn name(&self) -> &'static str {
157            self.runner_name
158        }
159
160        fn execute(
161            &self,
162            _ctx: &StepContext,
163            _input: ExecutorInput,
164        ) -> Pin<Box<dyn Future<Output = Result<StepResult>> + Send + '_>> {
165            Box::pin(async {
166                Ok(StepResult {
167                    exit_code: 0,
168                    committed_snapshot: None,
169                    artifacts: vec![],
170                })
171            })
172        }
173    }
174
175    #[test]
176    fn resolve_by_name() {
177        let mut reg = RunnerRegistry::new();
178        reg.register(Arc::new(StubRunner::new("docker")), false);
179        reg.register(Arc::new(StubRunner::new("local")), false);
180
181        let runner = reg.resolve(Some("docker")).unwrap();
182        assert_eq!(runner.name(), "docker");
183
184        let runner = reg.resolve(Some("local")).unwrap();
185        assert_eq!(runner.name(), "local");
186
187        assert!(reg.resolve(Some("nope")).is_none());
188    }
189
190    #[test]
191    fn resolve_default() {
192        let mut reg = RunnerRegistry::new();
193        reg.register(Arc::new(StubRunner::new("docker")), true);
194        reg.register(Arc::new(StubRunner::new("local")), false);
195
196        // `None` name falls back to default.
197        let runner = reg.resolve(None).unwrap();
198        assert_eq!(runner.name(), "docker");
199        assert_eq!(reg.default_runner_name(), Some("docker"));
200    }
201
202    #[test]
203    fn no_default_returns_none() {
204        let mut reg = RunnerRegistry::new();
205        reg.register(Arc::new(StubRunner::new("docker")), false);
206
207        assert!(reg.resolve(None).is_none());
208        assert!(reg.default_runner_name().is_none());
209    }
210
211    #[test]
212    fn runner_names_sorted() {
213        let mut reg = RunnerRegistry::new();
214        reg.register(Arc::new(StubRunner::new("zeta")), false);
215        reg.register(Arc::new(StubRunner::new("alpha")), false);
216        reg.register(Arc::new(StubRunner::new("mid")), false);
217
218        assert_eq!(reg.runner_names(), vec!["alpha", "mid", "zeta"]);
219    }
220
221    #[test]
222    fn debug_impl() {
223        let reg = RunnerRegistry::new();
224        // Just ensure it doesn't panic.
225        let _ = format!("{reg:?}");
226    }
227}