Skip to main content

hm_exec/cloud/
backend.rs

1//! The cloud [`ExecutionBackend`]: archive the worktree, submit the whole build
2//! to Harmont Cloud, and watch it to completion. The server schedules and runs;
3//! this backend is an *observer* (see [`Capabilities::cloud`]).
4
5use harmont_cloud::{HarmontClient, HarmontError, builds::NewBuild};
6use hm_plugin_protocol::events::{BuildEvent, BuildRef};
7use tokio::sync::mpsc;
8use tokio_util::sync::CancellationToken;
9
10use std::path::Path;
11
12use crate::{
13    BackendError, BackendHandle, BuildOutcome, BuildStatus, Capabilities, ExecutionBackend, Result,
14    RunRequest,
15};
16
17/// Soft warning threshold for the (compressed) source archive. Above this we
18/// nudge the user toward a `.gitignore` fix but still upload.
19const ARCHIVE_WARN_BYTES: u64 = 4 * 1024 * 1024;
20
21/// Hard cap for the (compressed) source archive. The build request base64-
22/// encodes this blob into a single JSON POST; base64 inflates by ~4/3, and the
23/// backend's JSON body limit is ~8 MB, so a 6 MiB raw cap keeps the encoded
24/// body (plus the IR + envelope) comfortably under that limit. Over the cap we
25/// fail fast BEFORE the upload.
26const ARCHIVE_CAP_BYTES: u64 = 6 * 1024 * 1024;
27
28/// Submits the whole build to Harmont Cloud and watches it; the server schedules.
29#[derive(Debug)]
30pub struct CloudBackend {
31    client: HarmontClient,
32    /// API base used as the SSE log stream host during `watch_build`.
33    api_base: String,
34    /// Dashboard (SPA) base used to build the human-clickable watch URL. This
35    /// is the `app.` host, NOT `api.` — a link built from `api_base` lands on
36    /// raw JSON. Resolved via [`hm_config::app_url`] at the call site.
37    app_base: String,
38    org: String,
39}
40
41impl CloudBackend {
42    /// Construct a `CloudBackend`.
43    ///
44    /// `client`/`api_base` come from the CLI's resolved cloud settings;
45    /// `app_base` is the dashboard host the watch URL points at (see the field
46    /// docs); `org` is the resolved organization slug.
47    #[must_use]
48    // `api_base` (the API host) and `app_base` (the dashboard host) are two
49    // distinct hosts that must not be confused — the whole point of this fix —
50    // so the near-identical names are deliberate and documented.
51    #[allow(clippy::similar_names)]
52    pub const fn new(
53        client: HarmontClient,
54        api_base: String,
55        app_base: String,
56        org: String,
57    ) -> Self {
58        Self {
59            client,
60            api_base,
61            app_base,
62            org,
63        }
64    }
65}
66
67#[async_trait::async_trait]
68impl ExecutionBackend for CloudBackend {
69    fn name(&self) -> &'static str {
70        "cloud"
71    }
72
73    fn capabilities(&self) -> Capabilities {
74        Capabilities::cloud()
75    }
76
77    async fn start(&self, req: RunRequest) -> Result<BackendHandle> {
78        // Archive the worktree (fail fast as a setup error).
79        let source_tgz = crate::local::build_archive_bytes(&req.repo_root)
80            .map_err(|e| BackendError::Local(format!("archiving worktree: {e}")))?;
81
82        // Guard the upload size BEFORE the POST: warn when large, fail fast over
83        // the cap (so the user never waits on a doomed upload), and always show
84        // the size so the upload isn't a silent gulf of evaluation.
85        guard_archive_size(source_tgz.len(), &req.repo_root)?;
86
87        // Submit. Fail fast on auth/rejection BEFORE returning a handle so the
88        // CLI can surface the doctrine error without a half-started stream.
89        let build = self
90            .client
91            .submit_build(NewBuild {
92                org: self.org.clone(),
93                pipeline: req.pipeline_slug.clone(),
94                branch: req.source.branch.clone(),
95                commit: req.source.commit.clone(),
96                message: req.source.message.clone(),
97                pipeline_ir: req.plan.ir_json.clone(), // verbatim
98                source_tgz,
99                env: req.env.clone().into_iter().collect(),
100            })
101            .await
102            .map_err(map_harmont_err)?;
103
104        // Build the dashboard URL from the app host (NOT the API host) and the
105        // SPA route shape `/:orgSlug/pipelines/:slug/builds/:number`. A link
106        // built from `api_base` or without the `pipelines/` segment is
107        // unclickable — it lands on raw JSON or a 404.
108        let watch_url = Some(dashboard_build_url(
109            &self.app_base,
110            &self.org,
111            &req.pipeline_slug,
112            build.number,
113        ));
114        let build_ref = BuildRef {
115            run_id: uuid::Uuid::new_v4(),
116            number: Some(build.number),
117            org: Some(self.org.clone()),
118            pipeline: req.pipeline_slug.clone(),
119        };
120
121        let (tx, rx) = mpsc::channel(1024);
122        let cancel = CancellationToken::new();
123
124        // Emit `BuildAccepted` immediately (the CLI prints the watch line from
125        // this). `try_send` can't fail: the receiver is alive and the buffer
126        // is empty.
127        let _ = tx.try_send(BuildEvent::BuildAccepted {
128            build: build_ref.clone(),
129            watch_url: watch_url.clone(),
130        });
131
132        // `--no-watch`: detach. The build was accepted server-side; resolve to
133        // a terminal "submitted" outcome at once. The server keeps running it.
134        if !req.options.watch {
135            let now = chrono::Utc::now();
136            let outcome = BuildOutcome {
137                build: build_ref,
138                status: BuildStatus::Passed,
139                steps: vec![],
140                started_at: now,
141                finished_at: now,
142                watch_url,
143            };
144            let join = tokio::spawn(async move {
145                drop(tx); // close the stream so the renderer terminates
146                Ok(outcome)
147            });
148            return Ok(BackendHandle::spawn(rx, cancel, join));
149        }
150
151        let client = self.client.clone();
152        let api_base = self.api_base.clone();
153        let org = self.org.clone();
154        let pipeline = req.pipeline_slug.clone();
155        let number = build.number;
156        let token = cancel.clone();
157        let started = chrono::Utc::now();
158        let join = tokio::spawn(async move {
159            let exit = tokio::select! {
160                biased;
161                () = token.cancelled() => {
162                    // Cancel server-side (best-effort) and report Canceled.
163                    let _ = client.cancel_build(&org, &pipeline, number).await;
164                    return Ok(BuildOutcome {
165                        build: build_ref,
166                        status: BuildStatus::Canceled,
167                        steps: vec![],
168                        started_at: started,
169                        finished_at: chrono::Utc::now(),
170                        watch_url,
171                    });
172                }
173                r = crate::cloud::watch::watch_build(&client, &api_base, &org, &pipeline, number, tx) => {
174                    r.map_err(|e| BackendError::LogStream(e.to_string()))?
175                }
176            };
177            // Map the terminal exit code `watch_build` reports back to a
178            // verdict. 130 is a server-side cancel (see
179            // `watch::exit_code_for_state`) — report it as Canceled, NOT a
180            // failure, mirroring the local scheduler.
181            let status = match exit {
182                0 => BuildStatus::Passed,
183                130 => BuildStatus::Canceled,
184                _ => BuildStatus::Failed,
185            };
186            Ok(BuildOutcome {
187                build: build_ref,
188                status,
189                // TODO(v1 follow-up): collect per-step summaries from the
190                // `StepEnd` events `watch_build` already emits.
191                steps: vec![],
192                started_at: started,
193                finished_at: chrono::Utc::now(),
194                watch_url,
195            })
196        });
197        Ok(BackendHandle::spawn(rx, cancel, join))
198    }
199}
200
201/// Map the SDK error onto the backend-boundary error (the CLI maps THIS to the
202/// project error doctrine). Exhaustive over [`HarmontError`].
203fn map_harmont_err(e: HarmontError) -> BackendError {
204    match e {
205        HarmontError::Unauthorized => BackendError::Unauthorized,
206        HarmontError::Api {
207            status: _,
208            code,
209            message,
210        } => BackendError::Rejected { code, message },
211        HarmontError::NotFound(w) => BackendError::NotFound(w),
212        HarmontError::Transport(m) => BackendError::Transport(m),
213        HarmontError::Decode(m) | HarmontError::LogStream(m) => BackendError::LogStream(m),
214    }
215}
216
217/// Build the human-clickable dashboard URL for a build, matching the SPA route
218/// `/:orgSlug/pipelines/:slug/builds/:number`. `app_base` is the dashboard
219/// host (see [`CloudBackend`]'s `app_base` field) with no trailing slash.
220fn dashboard_build_url(app_base: &str, org: &str, slug: &str, number: i64) -> String {
221    format!("{app_base}/{org}/pipelines/{slug}/builds/{number}")
222}
223
224/// Render a byte count as a human "N.N MB" (mebibytes, one decimal).
225fn human_mb(bytes: u64) -> String {
226    #[allow(clippy::cast_precision_loss)] // display-only; precision is irrelevant
227    let mb = bytes as f64 / (1024.0 * 1024.0);
228    format!("{mb:.1} MB")
229}
230
231/// Guard the source-archive upload: announce its size, warn when large, and
232/// reject (fail fast) when over the cap.
233///
234/// `archive_bytes` is the compressed (`.tar.gz`) length actually shipped.
235/// `repo_root` is only walked for the "biggest paths" hint, and only when the
236/// archive is large enough to warn or reject — so the common (small) case pays
237/// nothing extra.
238fn guard_archive_size(archive_len: usize, repo_root: &Path) -> Result<()> {
239    let bytes = archive_len as u64;
240
241    // Always close the gulf of evaluation: a multi-second silent upload is a
242    // wide gulf. Name the size up front.
243    tracing::info!("uploading source archive ({})", human_mb(bytes));
244
245    if bytes <= ARCHIVE_WARN_BYTES {
246        return Ok(());
247    }
248
249    let largest = crate::local::top_level_sizes(repo_root);
250    let offenders: Vec<(String, u64)> = largest.into_iter().take(3).collect();
251
252    if bytes > ARCHIVE_CAP_BYTES {
253        return Err(BackendError::SourceTooLarge {
254            observed_bytes: bytes,
255            cap_bytes: ARCHIVE_CAP_BYTES,
256            largest_paths: offenders,
257        });
258    }
259
260    // Over the warn threshold but under the cap: nudge toward a .gitignore fix.
261    let hint = offenders
262        .iter()
263        .map(|(name, sz)| format!("{name} ({})", human_mb(*sz)))
264        .collect::<Vec<_>>()
265        .join(", ");
266    tracing::warn!(
267        "source archive is {} (largest: {}). Add big build artifacts to .gitignore to speed up uploads.",
268        human_mb(bytes),
269        if hint.is_empty() {
270            "—".to_string()
271        } else {
272            hint
273        },
274    );
275    Ok(())
276}
277
278#[cfg(test)]
279#[allow(clippy::unwrap_used, clippy::panic, clippy::cast_possible_truncation)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn watch_url_uses_app_host_and_pipelines_path() {
285        // Mirrors hm_config::app_url(DEFAULT_API_URL) -> https://app.harmont.dev.
286        assert_eq!(
287            dashboard_build_url("https://app.harmont.dev", "acme", "web", 42),
288            "https://app.harmont.dev/acme/pipelines/web/builds/42"
289        );
290    }
291
292    #[test]
293    fn archive_under_warn_passes() {
294        // A tiny archive (well under the warn threshold) never walks the tree
295        // and never errors; repo_root is irrelevant.
296        guard_archive_size(1024, std::path::Path::new("/nonexistent")).unwrap();
297    }
298
299    #[test]
300    fn archive_over_cap_fails_with_source_too_large() {
301        let tmp = tempfile::tempdir().unwrap();
302        let err = guard_archive_size(ARCHIVE_CAP_BYTES as usize + 1, tmp.path()).unwrap_err();
303        match err {
304            BackendError::SourceTooLarge {
305                observed_bytes,
306                cap_bytes,
307                ..
308            } => {
309                assert_eq!(observed_bytes, ARCHIVE_CAP_BYTES + 1);
310                assert_eq!(cap_bytes, ARCHIVE_CAP_BYTES);
311            }
312            other => panic!("expected SourceTooLarge, got {other:?}"),
313        }
314    }
315
316    #[test]
317    fn human_mb_formats_one_decimal() {
318        assert_eq!(human_mb(6 * 1024 * 1024), "6.0 MB");
319        assert_eq!(human_mb(1536 * 1024), "1.5 MB");
320    }
321}