Skip to main content

max / makenotwork

6.9 KB · 196 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 = crate::tls::client(ACTION_TIMEOUT).map_err(|e| format!("client: {e}"))?;
59
60 let mut request = client.request(method(action.method), &url);
61 if let Some(token) = token {
62 request = request.bearer_auth(token);
63 }
64 if let Some(body) = &action.body {
65 request = request.json(body);
66 }
67
68 let response = request.send().await.map_err(short_error)?;
69 let status = response.status();
70 if status.is_success() {
71 return Ok(status.as_u16());
72 }
73 // A non-2xx from an action is usually meaningful — a 409 is "a gate said
74 // no", not a transport failure — so keep a little of the body, unlike a
75 // poll where the status code alone is the whole story.
76 let code = status.as_u16();
77 if code == 401 || code == 403 {
78 return Err("unauthorized (check the source's token_env)".into());
79 }
80 let body = response.text().await.unwrap_or_default();
81 let trimmed = body.trim();
82 if trimmed.is_empty() {
83 Err(format!("HTTP {code}"))
84 } else {
85 Err(format!("HTTP {code}: {}", clip(trimmed)))
86 }
87 }
88
89 fn method(method: Method) -> reqwest::Method {
90 match method {
91 Method::Get => reqwest::Method::GET,
92 Method::Post => reqwest::Method::POST,
93 Method::Put => reqwest::Method::PUT,
94 Method::Delete => reqwest::Method::DELETE,
95 }
96 }
97
98 /// One line's worth of a failure body, newlines flattened.
99 fn clip(body: &str) -> String {
100 let flat = body.replace('\n', " ");
101 if flat.chars().count() <= BODY_KEEP {
102 return flat;
103 }
104 let kept: String = flat.chars().take(BODY_KEEP.saturating_sub(1)).collect();
105 format!("{kept}")
106 }
107
108 /// The same short-reason treatment `poll` gives transport errors: the footer has
109 /// one line, so keep the part that says what broke.
110 // e is consumed into the short string.
111 #[allow(clippy::needless_pass_by_value)]
112 fn short_error(e: reqwest::Error) -> String {
113 if e.is_timeout() {
114 return "timed out".into();
115 }
116 if e.is_connect() {
117 return "connection refused".into();
118 }
119 let text = e.to_string();
120 text.split(':')
121 .next_back()
122 .unwrap_or(&text)
123 .trim()
124 .to_string()
125 }
126
127 #[cfg(test)]
128 mod tests {
129 use super::*;
130
131 fn source(url: &str) -> Source {
132 toml::from_str(&format!("name = \"sando\"\nurl = \"{url}\"\n")).unwrap()
133 }
134
135 #[test]
136 fn methods_map_across_to_reqwest() {
137 assert_eq!(method(Method::Get), reqwest::Method::GET);
138 assert_eq!(method(Method::Post), reqwest::Method::POST);
139 assert_eq!(method(Method::Put), reqwest::Method::PUT);
140 assert_eq!(method(Method::Delete), reqwest::Method::DELETE);
141 }
142
143 #[test]
144 fn a_failure_body_is_clipped_to_one_line() {
145 assert_eq!(clip("gate\nnot\nsatisfied"), "gate not satisfied");
146 let long = clip(&"x".repeat(400));
147 assert!(long.ends_with(''));
148 assert_eq!(long.chars().count(), BODY_KEEP);
149 }
150
151 // The only test here that opens a socket, so the only one miri cannot run: it
152 // builds a real reqwest client on a tokio runtime, and miri has no shim for
153 // the syscalls under either. Ignored rather than opted out of at the sweep
154 // level, which would take the other 80 tests' UB checking with it.
155 #[tokio::test]
156 #[cfg_attr(miri, ignore)]
157 async fn an_unreachable_daemon_reports_a_short_reason_not_a_url_chain() {
158 // Port 1 on loopback refuses immediately.
159 let err = issue(
160 "http://127.0.0.1:1/rollback/b".into(),
161 Action {
162 label: "Roll back".into(),
163 method: Method::Post,
164 url: "/rollback/b".into(),
165 confirm: true,
166 danger: true,
167 body: None,
168 },
169 None,
170 )
171 .await
172 .unwrap_err();
173 assert!(err.len() < 60, "{err:?}");
174 assert!(!err.contains("http://"), "{err:?}");
175 }
176
177 #[test]
178 fn a_body_carrying_action_serializes_it() {
179 // Bento's actions take a JSON body; this asserts the wiring compiles and
180 // the body survives onto the request builder without a per-daemon shape.
181 let action = Action {
182 label: "Publish".into(),
183 method: Method::Post,
184 url: "/publish".into(),
185 confirm: true,
186 danger: false,
187 body: Some(serde_json::json!({"app": "goingson", "target": "macos"})),
188 };
189 assert_eq!(
190 source("http://x").action_url(&action.url),
191 "http://x/publish"
192 );
193 assert!(action.body.is_some());
194 }
195 }
196