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}