//! Issuing a declared action. //! //! The mirror of [`crate::poll`]: where polling reads state on a timer, this //! writes it once, on demand, and reports the outcome back on a channel the UI //! drains. The split is the same and for the same reason — the network effect //! lives at the edge, off the render loop, so a slow or hung daemon cannot //! freeze the surface you are trying to read. //! //! The viewer never learns what an action *means*. It has a method, a URL, an //! optional body, and a bearer token, all declared by the producer, and it //! issues exactly that. `promote` and `rollback` are opaque strings here. use std::time::Duration; use ops_status::{Action, Method}; use tokio::sync::mpsc; use crate::config::Source; /// The result of one fired action, tagged with the key that fired it so the UI /// can name it in the footer without tracking the request itself. #[derive(Debug, Clone)] pub(crate) struct Outcome { pub key: String, /// `Ok` carries the 2xx status code; `Err` a short reason. pub result: Result, } /// How long a fired action may take before it counts as failed. /// /// Far longer than a poll's 10s: a declared action can be a real operation /// (sando's `promote` runs synchronously today), and cutting the connection /// early would abort work the server is mid-way through. Still bounded, so a /// daemon that never answers does not leak a task forever. A synchronous /// operation that genuinely outlasts this is a daemon-shape problem, not a /// viewer one; the next poll still shows the true resulting state. const ACTION_TIMEOUT: Duration = Duration::from_mins(2); /// Body text kept from a failed response, past which it is noise in a one-line /// footer. const BODY_KEEP: usize = 120; /// Fire `action` at `source`, reporting the outcome on `tx`. /// /// Spawns a detached task and returns immediately; the caller must be inside a /// Tokio runtime (the UI loop is). A send failure means the UI is gone, which /// is not this task's problem to solve. pub(crate) fn fire(source: &Source, action: Action, key: String, tx: mpsc::Sender) { let url = source.action_url(&action.url); let token = source.token(); tokio::spawn(async move { let result = issue(url, action, token).await; let _ = tx.send(Outcome { key, result }).await; }); } async fn issue(url: String, action: Action, token: Option) -> Result { let client = reqwest::Client::builder() .timeout(ACTION_TIMEOUT) .build() .map_err(|e| format!("client: {e}"))?; let mut request = client.request(method(action.method), &url); if let Some(token) = token { request = request.bearer_auth(token); } if let Some(body) = &action.body { request = request.json(body); } let response = request.send().await.map_err(short_error)?; let status = response.status(); if status.is_success() { return Ok(status.as_u16()); } // A non-2xx from an action is usually meaningful — a 409 is "a gate said // no", not a transport failure — so keep a little of the body, unlike a // poll where the status code alone is the whole story. let code = status.as_u16(); if code == 401 || code == 403 { return Err("unauthorized (check the source's token_env)".into()); } let body = response.text().await.unwrap_or_default(); let trimmed = body.trim(); if trimmed.is_empty() { Err(format!("HTTP {code}")) } else { Err(format!("HTTP {code}: {}", clip(trimmed))) } } fn method(method: Method) -> reqwest::Method { match method { Method::Get => reqwest::Method::GET, Method::Post => reqwest::Method::POST, Method::Put => reqwest::Method::PUT, Method::Delete => reqwest::Method::DELETE, } } /// One line's worth of a failure body, newlines flattened. fn clip(body: &str) -> String { let flat = body.replace('\n', " "); if flat.chars().count() <= BODY_KEEP { return flat; } let kept: String = flat.chars().take(BODY_KEEP.saturating_sub(1)).collect(); format!("{kept}…") } /// The same short-reason treatment `poll` gives transport errors: the footer has /// one line, so keep the part that says what broke. // e is consumed into the short string. #[allow(clippy::needless_pass_by_value)] fn short_error(e: reqwest::Error) -> String { if e.is_timeout() { return "timed out".into(); } if e.is_connect() { return "connection refused".into(); } let text = e.to_string(); text.split(':') .next_back() .unwrap_or(&text) .trim() .to_string() } #[cfg(test)] mod tests { use super::*; fn source(url: &str) -> Source { toml::from_str(&format!("name = \"sando\"\nurl = \"{url}\"\n")).unwrap() } #[test] fn methods_map_across_to_reqwest() { assert_eq!(method(Method::Get), reqwest::Method::GET); assert_eq!(method(Method::Post), reqwest::Method::POST); assert_eq!(method(Method::Put), reqwest::Method::PUT); assert_eq!(method(Method::Delete), reqwest::Method::DELETE); } #[test] fn a_failure_body_is_clipped_to_one_line() { assert_eq!(clip("gate\nnot\nsatisfied"), "gate not satisfied"); let long = clip(&"x".repeat(400)); assert!(long.ends_with('…')); assert_eq!(long.chars().count(), BODY_KEEP); } #[tokio::test] async fn an_unreachable_daemon_reports_a_short_reason_not_a_url_chain() { // Port 1 on loopback refuses immediately. let err = issue( "http://127.0.0.1:1/rollback/b".into(), Action { label: "Roll back".into(), method: Method::Post, url: "/rollback/b".into(), confirm: true, danger: true, body: None, }, None, ) .await .unwrap_err(); assert!(err.len() < 60, "{err:?}"); assert!(!err.contains("http://"), "{err:?}"); } #[test] fn a_body_carrying_action_serializes_it() { // Bento's actions take a JSON body; this asserts the wiring compiles and // the body survives onto the request builder without a per-daemon shape. let action = Action { label: "Publish".into(), method: Method::Post, url: "/publish".into(), confirm: true, danger: false, body: Some(serde_json::json!({"app": "goingson", "target": "macos"})), }; assert_eq!( source("http://x").action_url(&action.url), "http://x/publish" ); assert!(action.body.is_some()); } }