Skip to main content

max / makenotwork

4.8 KB · 152 lines History Blame Raw
1 //! HTTP client for the WAM (Whack-a-Mole) ticket manager.
2 //!
3 //! WAM runs on the tailnet. The MNW server creates tickets for operational
4 //! events that need human attention (stale refunds, dead webhooks, etc.). When
5 //! WAM is configured with a shared token, set `WAM_TOKEN` here to the same value
6 //! and it is presented as a bearer token on every request; without it WAM must
7 //! be running unauthenticated (tailnet-ACL only) or requests are rejected.
8
9 use serde::Serialize;
10
11 /// WAM ticket creation client.
12 #[derive(Clone)]
13 pub struct WamClient {
14 http: reqwest::Client,
15 base_url: String,
16 token: Option<String>,
17 }
18
19 #[derive(Serialize)]
20 struct CreateTicketRequest<'a> {
21 title: &'a str,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 body: Option<&'a str>,
24 priority: &'a str,
25 source: &'a str,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 source_ref: Option<&'a str>,
28 }
29
30 impl WamClient {
31 /// Build a client for `base_url`. `token`, when `Some`, is sent as a bearer
32 /// token on every request (matching WAM's shared-secret auth).
33 pub fn new(base_url: String, token: Option<String>) -> Self {
34 let http = reqwest::Client::builder()
35 .timeout(std::time::Duration::from_secs(5))
36 .connect_timeout(std::time::Duration::from_secs(3))
37 .build()
38 .expect("WAM HTTP client");
39 let token = token.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty());
40 Self {
41 http,
42 base_url,
43 token,
44 }
45 }
46
47 /// Return the ticket endpoint URL.
48 pub fn ticket_url(&self) -> String {
49 format!("{}/tickets", self.base_url.trim_end_matches('/'))
50 }
51
52 /// Create a ticket in WAM. Errors are logged but never propagated, WAM
53 /// is a best-effort notification channel, not a critical path.
54 #[tracing::instrument(skip_all, fields(source = %source, priority = %priority))]
55 pub async fn create_ticket(
56 &self,
57 title: &str,
58 body: Option<&str>,
59 priority: &str,
60 source: &str,
61 source_ref: Option<&str>,
62 ) {
63 let url = self.ticket_url();
64 let req = CreateTicketRequest {
65 title,
66 body,
67 priority,
68 source,
69 source_ref,
70 };
71
72 let mut request = self.http.post(&url).json(&req);
73 if let Some(token) = &self.token {
74 request = request.bearer_auth(token);
75 }
76
77 match request.send().await {
78 Ok(resp) if resp.status().is_success() => {
79 tracing::info!(title, source, "WAM ticket created");
80 }
81 Ok(resp) => {
82 tracing::warn!(
83 status = %resp.status(), title, source,
84 "WAM ticket creation returned non-success"
85 );
86 }
87 Err(e) => {
88 tracing::warn!(error = %e, title, source, "WAM unreachable");
89 }
90 }
91 }
92 }
93
94 #[cfg(test)]
95 mod tests {
96 use super::*;
97
98 #[test]
99 fn ticket_url_construction() {
100 let client = WamClient::new("http://100.120.174.96:7890".to_string(), None);
101 assert_eq!(client.ticket_url(), "http://100.120.174.96:7890/tickets");
102 }
103
104 #[test]
105 fn ticket_url_strips_trailing_slash() {
106 let client = WamClient::new("http://localhost:7890/".to_string(), None);
107 assert_eq!(client.ticket_url(), "http://localhost:7890/tickets");
108 }
109
110 #[test]
111 fn request_serialization_full() {
112 let req = CreateTicketRequest {
113 title: "Test ticket",
114 body: Some("Details here"),
115 priority: "high",
116 source: "test-source",
117 source_ref: Some("ref-123"),
118 };
119 let json = serde_json::to_value(&req).unwrap();
120 assert_eq!(json["title"], "Test ticket");
121 assert_eq!(json["body"], "Details here");
122 assert_eq!(json["priority"], "high");
123 assert_eq!(json["source"], "test-source");
124 assert_eq!(json["source_ref"], "ref-123");
125 }
126
127 #[test]
128 fn request_serialization_skips_none_fields() {
129 let req = CreateTicketRequest {
130 title: "Minimal",
131 body: None,
132 priority: "low",
133 source: "test",
134 source_ref: None,
135 };
136 let json = serde_json::to_value(&req).unwrap();
137 assert_eq!(json["title"], "Minimal");
138 assert!(json.get("body").is_none());
139 assert!(json.get("source_ref").is_none());
140 }
141
142 #[tokio::test]
143 async fn create_ticket_unreachable_does_not_panic() {
144 // WAM is fire-and-forget, unreachable server should not panic
145 let client = WamClient::new("http://127.0.0.1:1".to_string(), None);
146 client
147 .create_ticket("test", None, "low", "test", None)
148 .await;
149 // If we get here, the error was swallowed correctly
150 }
151 }
152