Skip to main content

hm_exec/
lib.rs

1//! Pluggable CI execution backends for `hm run`.
2//!
3//! # Design
4//!
5//! The pluggable boundary is the **whole build**, not a single step.
6//! [`ExecutionBackend::start`] accepts a [`RunRequest`] and returns a
7//! [`BackendHandle`]. Calling [`BackendHandle::into_parts`] splits the handle
8//! into:
9//!
10//! - An [`EventStream`] of [`hm_plugin_protocol::events::BuildEvent`]s — hand
11//!   this to `hm-render` for terminal output.
12//! - A [`Control`] struct with `cancel()` (Ctrl-C) and `wait()` (terminal
13//!   outcome).
14//!
15//! # Backends
16//!
17//! - [`LocalBackend`] — runs the build in-process using a DAG scheduler that
18//!   executes each step inside a lightweight VM via the `hm-vm` subsystem
19//!   (a [`hm_vm::VmBackend`] + snapshot registry; Docker is one such backend).
20//! - [`CloudBackend`] — submits the build to the Harmont cloud and watches it
21//!   over the REST SDK, emitting the same `BuildEvent` stream.
22//!
23//! # Auth
24//!
25//! This crate never reads credentials from disk. The caller constructs a
26//! `HarmontClient` and injects it; `hm` owns credential loading.
27#![forbid(unsafe_code)]
28
29mod error;
30pub use error::{BackendError, Result};
31
32mod request;
33pub use request::{Plan, RunOptions, RunRequest, SourceMeta};
34
35mod outcome;
36pub use outcome::{BuildOutcome, BuildStatus, StepResultSummary, StepStatus};
37
38mod capabilities;
39pub use capabilities::Capabilities;
40
41pub mod local;
42pub use local::LocalBackend;
43
44pub mod cloud;
45pub use cloud::CloudBackend;
46
47pub use hm_plugin_protocol::events::BuildRef;
48
49use futures::StreamExt as _;
50use hm_plugin_protocol::events::BuildEvent;
51use tokio::sync::mpsc;
52use tokio::task::JoinHandle;
53use tokio_stream::wrappers::ReceiverStream;
54use tokio_util::sync::CancellationToken;
55
56/// Type alias for the boxed event stream yielded by [`BackendHandle::into_parts`].
57pub type EventStream = futures::stream::BoxStream<'static, BuildEvent>;
58
59/// A pluggable execution backend. The boundary is the WHOLE build.
60///
61/// `start` spawns it and returns a [`BackendHandle`]. Per-step execution is a
62/// private concern of the backend (see the local backend's internal
63/// `StepRunner`).
64#[async_trait::async_trait]
65pub trait ExecutionBackend: Send + Sync {
66    /// Stable id for diagnostics/telemetry ("local-docker", "cloud").
67    fn name(&self) -> &str;
68    /// What this backend can honor — consulted by the CLI before `start`.
69    fn capabilities(&self) -> Capabilities;
70    /// Begin running the whole build. Setup failures (auth, bad plan, no
71    /// daemon) fail here; a *failed build* is `Ok(handle)` resolving to
72    /// `BuildOutcome { status: Failed }`.
73    async fn start(&self, req: RunRequest) -> Result<BackendHandle>;
74}
75
76/// A running build: an event stream to render + a control half (cancel/wait).
77pub struct BackendHandle {
78    events: EventStream,
79    cancel: CancellationToken,
80    outcome: JoinHandle<Result<BuildOutcome>>,
81}
82
83impl BackendHandle {
84    /// Construct from a spawned run task that emits events into `rx` and
85    /// resolves to an outcome.
86    #[must_use]
87    pub fn spawn(
88        rx: mpsc::Receiver<BuildEvent>,
89        cancel: CancellationToken,
90        outcome: JoinHandle<Result<BuildOutcome>>,
91    ) -> Self {
92        Self {
93            events: ReceiverStream::new(rx).boxed(),
94            cancel,
95            outcome,
96        }
97    }
98
99    /// Split into the event stream (move into a renderer task) and control.
100    #[must_use]
101    pub fn into_parts(self) -> (EventStream, Control) {
102        (
103            self.events,
104            Control {
105                cancel: self.cancel,
106                outcome: self.outcome,
107            },
108        )
109    }
110}
111
112impl std::fmt::Debug for BackendHandle {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("BackendHandle").finish_non_exhaustive()
115    }
116}
117
118/// The control half of a running build: cancel + await the outcome.
119pub struct Control {
120    cancel: CancellationToken,
121    outcome: JoinHandle<Result<BuildOutcome>>,
122}
123
124impl Control {
125    /// A clone of the cancellation token (hand to a Ctrl-C handler).
126    #[must_use]
127    pub fn cancel_token(&self) -> CancellationToken {
128        self.cancel.clone()
129    }
130    /// Request cooperative cancellation (idempotent).
131    pub fn cancel(&self) {
132        self.cancel.cancel();
133    }
134    /// Await the terminal outcome.
135    ///
136    /// # Errors
137    /// Returns [`BackendError::Other`] if the spawned task panicked, or any
138    /// [`BackendError`] the backend task itself returned.
139    pub async fn wait(self) -> Result<BuildOutcome> {
140        match self.outcome.await {
141            Ok(res) => res,
142            Err(join_err) => Err(BackendError::Other(Box::new(join_err))),
143        }
144    }
145}
146
147impl std::fmt::Debug for Control {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("Control").finish_non_exhaustive()
150    }
151}