Skip to main content

max / makenotwork

5.0 KB · 156 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 // `build()` constructs the rustls connector, which reads the
35 // process-wide provider and panics if none is installed. Idempotent.
36 crate::crypto::install_default_crypto_provider();
37
38 let http = reqwest::Client::builder()
39 .timeout(std::time::Duration::from_secs(5))
40 .connect_timeout(std::time::Duration::from_secs(3))
41 .build()
42 .expect("WAM HTTP client");
43 let token = token.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty());
44 Self {
45 http,
46 base_url,
47 token,
48 }
49 }
50
51 /// Return the ticket endpoint URL.
52 pub fn ticket_url(&self) -> String {
53 format!("{}/tickets", self.base_url.trim_end_matches('/'))
54 }
55
56 /// Create a ticket in WAM. Errors are logged but never propagated, WAM
57 /// is a best-effort notification channel, not a critical path.
58 #[tracing::instrument(skip_all, fields(source = %source, priority = %priority))]
59 pub async fn create_ticket(
60 &self,
61 title: &str,
62 body: Option<&str>,
63 priority: &str,
64 source: &str,
65 source_ref: Option<&str>,
66 ) {
67 let url = self.ticket_url();
68 let req = CreateTicketRequest {
69 title,
70 body,
71 priority,
72 source,
73 source_ref,
74 };
75
76 let mut request = self.http.post(&url).json(&req);
77 if let Some(token) = &self.token {
78 request = request.bearer_auth(token);
79 }
80
81 match request.send().await {
82 Ok(resp) if resp.status().is_success() => {
83 tracing::info!(title, source, "WAM ticket created");
84 }
85 Ok(resp) => {
86 tracing::warn!(
87 status = %resp.status(), title, source,
88 "WAM ticket creation returned non-success"
89 );
90 }
91 Err(e) => {
92 tracing::warn!(error = %e, title, source, "WAM unreachable");
93 }
94 }
95 }
96 }
97
98 #[cfg(test)]
99 mod tests {
100 use super::*;
101
102 #[test]
103 fn ticket_url_construction() {
104 let client = WamClient::new("http://100.120.174.96:7890".to_string(), None);
105 assert_eq!(client.ticket_url(), "http://100.120.174.96:7890/tickets");
106 }
107
108 #[test]
109 fn ticket_url_strips_trailing_slash() {
110 let client = WamClient::new("http://localhost:7890/".to_string(), None);
111 assert_eq!(client.ticket_url(), "http://localhost:7890/tickets");
112 }
113
114 #[test]
115 fn request_serialization_full() {
116 let req = CreateTicketRequest {
117 title: "Test ticket",
118 body: Some("Details here"),
119 priority: "high",
120 source: "test-source",
121 source_ref: Some("ref-123"),
122 };
123 let json = serde_json::to_value(&req).unwrap();
124 assert_eq!(json["title"], "Test ticket");
125 assert_eq!(json["body"], "Details here");
126 assert_eq!(json["priority"], "high");
127 assert_eq!(json["source"], "test-source");
128 assert_eq!(json["source_ref"], "ref-123");
129 }
130
131 #[test]
132 fn request_serialization_skips_none_fields() {
133 let req = CreateTicketRequest {
134 title: "Minimal",
135 body: None,
136 priority: "low",
137 source: "test",
138 source_ref: None,
139 };
140 let json = serde_json::to_value(&req).unwrap();
141 assert_eq!(json["title"], "Minimal");
142 assert!(json.get("body").is_none());
143 assert!(json.get("source_ref").is_none());
144 }
145
146 #[tokio::test]
147 async fn create_ticket_unreachable_does_not_panic() {
148 // WAM is fire-and-forget, unreachable server should not panic
149 let client = WamClient::new("http://127.0.0.1:1".to_string(), None);
150 client
151 .create_ticket("test", None, "low", "test", None)
152 .await;
153 // If we get here, the error was swallowed correctly
154 }
155 }
156