Skip to main content

max / makenotwork

6.6 KB · 194 lines History Blame Raw
1 //! Issuing a declared action.
2 //!
3 //! The mirror of [`crate::poll`]: where polling reads state on a timer, this
4 //! writes it once, on demand, and reports the outcome back on a channel the UI
5 //! drains. The split is the same and for the same reason — the network effect
6 //! lives at the edge, off the render loop, so a slow or hung daemon cannot
7 //! freeze the surface you are trying to read.
8 //!
9 //! The viewer never learns what an action *means*. It has a method, a URL, an
10 //! optional body, and a bearer token, all declared by the producer, and it
11 //! issues exactly that. `promote` and `rollback` are opaque strings here.
12
13 use std::time::Duration;
14
15 use ops_status::{Action, Method};
16 use tokio::sync::mpsc;
17
18 use crate::config::Source;
19
20 /// The result of one fired action, tagged with the key that fired it so the UI
21 /// can name it in the footer without tracking the request itself.
22 #[derive(Debug, Clone)]
23 pub(crate) struct Outcome {
24 pub key: String,
25 /// `Ok` carries the 2xx status code; `Err` a short reason.
26 pub result: Result<u16, String>,
27 }
28
29 /// How long a fired action may take before it counts as failed.
30 ///
31 /// Far longer than a poll's 10s: a declared action can be a real operation
32 /// (sando's `promote` runs synchronously today), and cutting the connection
33 /// early would abort work the server is mid-way through. Still bounded, so a
34 /// daemon that never answers does not leak a task forever. A synchronous
35 /// operation that genuinely outlasts this is a daemon-shape problem, not a
36 /// viewer one; the next poll still shows the true resulting state.
37 const ACTION_TIMEOUT: Duration = Duration::from_mins(2);
38
39 /// Body text kept from a failed response, past which it is noise in a one-line
40 /// footer.
41 const BODY_KEEP: usize = 120;
42
43 /// Fire `action` at `source`, reporting the outcome on `tx`.
44 ///
45 /// Spawns a detached task and returns immediately; the caller must be inside a
46 /// Tokio runtime (the UI loop is). A send failure means the UI is gone, which
47 /// is not this task's problem to solve.
48 pub(crate) fn fire(source: &Source, action: Action, key: String, tx: mpsc::Sender<Outcome>) {
49 let url = source.action_url(&action.url);
50 let token = source.token();
51 tokio::spawn(async move {
52 let result = issue(url, action, token).await;
53 let _ = tx.send(Outcome { key, result }).await;
54 });
55 }
56
57 async fn issue(url: String, action: Action, token: Option<String>) -> Result<u16, String> {
58 let client = reqwest::Client::builder()
59 .timeout(ACTION_TIMEOUT)
60 .build()
61 .map_err(|e| format!("client: {e}"))?;
62
63 let mut request = client.request(method(action.method), &url);
64 if let Some(token) = token {
65 request = request.bearer_auth(token);
66 }
67 if let Some(body) = &action.body {
68 request = request.json(body);
69 }
70
71 let response = request.send().await.map_err(short_error)?;
72 let status = response.status();
73 if status.is_success() {
74 return Ok(status.as_u16());
75 }
76 // A non-2xx from an action is usually meaningful — a 409 is "a gate said
77 // no", not a transport failure — so keep a little of the body, unlike a
78 // poll where the status code alone is the whole story.
79 let code = status.as_u16();
80 if code == 401 || code == 403 {
81 return Err("unauthorized (check the source's token_env)".into());
82 }
83 let body = response.text().await.unwrap_or_default();
84 let trimmed = body.trim();
85 if trimmed.is_empty() {
86 Err(format!("HTTP {code}"))
87 } else {
88 Err(format!("HTTP {code}: {}", clip(trimmed)))
89 }
90 }
91
92 fn method(method: Method) -> reqwest::Method {
93 match method {
94 Method::Get => reqwest::Method::GET,
95 Method::Post => reqwest::Method::POST,
96 Method::Put => reqwest::Method::PUT,
97 Method::Delete => reqwest::Method::DELETE,
98 }
99 }
100
101 /// One line's worth of a failure body, newlines flattened.
102 fn clip(body: &str) -> String {
103 let flat = body.replace('\n', " ");
104 if flat.chars().count() <= BODY_KEEP {
105 return flat;
106 }
107 let kept: String = flat.chars().take(BODY_KEEP.saturating_sub(1)).collect();
108 format!("{kept}")
109 }
110
111 /// The same short-reason treatment `poll` gives transport errors: the footer has
112 /// one line, so keep the part that says what broke.
113 // e is consumed into the short string.
114 #[allow(clippy::needless_pass_by_value)]
115 fn short_error(e: reqwest::Error) -> String {
116 if e.is_timeout() {
117 return "timed out".into();
118 }
119 if e.is_connect() {
120 return "connection refused".into();
121 }
122 let text = e.to_string();
123 text.split(':')
124 .next_back()
125 .unwrap_or(&text)
126 .trim()
127 .to_string()
128 }
129
130 #[cfg(test)]
131 mod tests {
132 use super::*;
133
134 fn source(url: &str) -> Source {
135 toml::from_str(&format!("name = \"sando\"\nurl = \"{url}\"\n")).unwrap()
136 }
137
138 #[test]
139 fn methods_map_across_to_reqwest() {
140 assert_eq!(method(Method::Get), reqwest::Method::GET);
141 assert_eq!(method(Method::Post), reqwest::Method::POST);
142 assert_eq!(method(Method::Put), reqwest::Method::PUT);
143 assert_eq!(method(Method::Delete), reqwest::Method::DELETE);
144 }
145
146 #[test]
147 fn a_failure_body_is_clipped_to_one_line() {
148 assert_eq!(clip("gate\nnot\nsatisfied"), "gate not satisfied");
149 let long = clip(&"x".repeat(400));
150 assert!(long.ends_with(''));
151 assert_eq!(long.chars().count(), BODY_KEEP);
152 }
153
154 #[tokio::test]
155 async fn an_unreachable_daemon_reports_a_short_reason_not_a_url_chain() {
156 // Port 1 on loopback refuses immediately.
157 let err = issue(
158 "http://127.0.0.1:1/rollback/b".into(),
159 Action {
160 label: "Roll back".into(),
161 method: Method::Post,
162 url: "/rollback/b".into(),
163 confirm: true,
164 danger: true,
165 body: None,
166 },
167 None,
168 )
169 .await
170 .unwrap_err();
171 assert!(err.len() < 60, "{err:?}");
172 assert!(!err.contains("http://"), "{err:?}");
173 }
174
175 #[test]
176 fn a_body_carrying_action_serializes_it() {
177 // Bento's actions take a JSON body; this asserts the wiring compiles and
178 // the body survives onto the request builder without a per-daemon shape.
179 let action = Action {
180 label: "Publish".into(),
181 method: Method::Post,
182 url: "/publish".into(),
183 confirm: true,
184 danger: false,
185 body: Some(serde_json::json!({"app": "goingson", "target": "macos"})),
186 };
187 assert_eq!(
188 source("http://x").action_url(&action.url),
189 "http://x/publish"
190 );
191 assert!(action.body.is_some());
192 }
193 }
194