1use serde::{Deserialize, Serialize};
34
35pub const POLL_AGENT_UPLINK_KIND: &str = "poll_agent_uplink";
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
43#[jumo(
44 kind = "message",
45 role = "command",
46 domain = "Control",
47 module = "Control.AgentApp.FacingInterface"
48)]
49#[serde(deny_unknown_fields)]
50pub struct PollAgentUplink {
51 pub api_version: String,
52 pub kind: String,
53 pub agent_id: String,
54 pub instance_id: String,
55 pub requested_at: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
64#[jumo(kind = "struct", domain = "Control", module = "Control.Agent.Work")]
65#[serde(deny_unknown_fields)]
66pub struct AgentUplinkGrant {
67 pub enabled: bool,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub host: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub port: Option<u16>,
76 pub granted_at: String,
77}
78
79impl AgentUplinkGrant {
80 pub fn standby(granted_at: String) -> Self {
82 Self {
83 enabled: false,
84 host: None,
85 port: None,
86 granted_at,
87 }
88 }
89
90 pub fn enabled_at(host: String, port: u16, granted_at: String) -> Self {
92 Self {
93 enabled: true,
94 host: Some(host),
95 port: Some(port),
96 granted_at,
97 }
98 }
99
100 pub fn target(&self) -> Option<(&str, u16)> {
105 match (self.enabled, self.host.as_deref(), self.port) {
106 (true, Some(host), Some(port)) => {
107 let host = host.trim();
108 (!host.is_empty()).then_some((host, port))
109 }
110 _ => None,
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
122#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Protocol")]
123#[serde(deny_unknown_fields)]
124pub struct AgentUplinkState {
125 pub enabled: bool,
127 pub kind: String,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub target: Option<String>,
132 pub source: String,
136 pub output_write_failing: bool,
138}
139
140#[cfg(test)]
141mod state_tests {
142 use super::*;
143
144 #[test]
145 fn a_local_file_output_state_omits_the_target() {
146 let state = AgentUplinkState {
147 enabled: true,
148 kind: "file".to_string(),
149 target: None,
150 source: "local".to_string(),
151 output_write_failing: false,
152 };
153 let json = serde_json::to_string(&state).expect("encode");
154 assert!(!json.contains("target"), "{json}");
155 let back: AgentUplinkState = serde_json::from_str(&json).expect("decode");
156 assert_eq!(back, state);
157 }
158
159 #[test]
160 fn a_grant_driven_tcp_state_carries_the_target() {
161 let state = AgentUplinkState {
162 enabled: true,
163 kind: "tcp".to_string(),
164 target: Some("10.0.1.9:9000".to_string()),
165 source: "grant".to_string(),
166 output_write_failing: true,
167 };
168 let json = serde_json::to_string(&state).expect("encode");
169 let back: AgentUplinkState = serde_json::from_str(&json).expect("decode");
170 assert_eq!(back, state);
171 assert!(
173 serde_json::from_str::<AgentUplinkState>(
174 r#"{"enabled":true,"kind":"tcp",
175 "source":"local","output_write_failing":false,"extra":1}"#
176 )
177 .is_err()
178 );
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn standby_round_trips_and_omits_absent_target() {
188 let grant = AgentUplinkGrant::standby("2026-09-26T00:00:00Z".to_string());
190 let json = serde_json::to_string(&grant).expect("encode");
191 assert!(!json.contains("host"), "{json}");
192 assert!(!json.contains("port"), "{json}");
193 let back: AgentUplinkGrant = serde_json::from_str(&json).expect("decode");
194 assert_eq!(back, grant);
195 assert_eq!(back.target(), None);
196 }
197
198 #[test]
199 fn an_enabled_grant_carries_the_target_to_override() {
200 let grant = AgentUplinkGrant::enabled_at(
201 "c-001.gateway.example".to_string(),
202 9000,
203 "2026-09-26T00:00:00Z".to_string(),
204 );
205 assert_eq!(grant.target(), Some(("c-001.gateway.example", 9000)));
206 let json = serde_json::to_string(&grant).expect("encode");
207 let back: AgentUplinkGrant = serde_json::from_str(&json).expect("decode");
208 assert_eq!(back, grant);
209 }
210
211 #[test]
212 fn an_enabled_grant_without_a_target_falls_back_to_the_local_kind() {
213 let grant = AgentUplinkGrant {
215 enabled: true,
216 host: None,
217 port: None,
218 granted_at: "t".to_string(),
219 };
220 assert_eq!(grant.target(), None);
221
222 let blank = AgentUplinkGrant {
224 enabled: true,
225 host: Some(String::new()),
226 port: Some(9000),
227 granted_at: "t".to_string(),
228 };
229 assert_eq!(blank.target(), None);
230
231 let whitespace = AgentUplinkGrant {
233 enabled: true,
234 host: Some(" ".to_string()),
235 port: Some(9000),
236 granted_at: "t".to_string(),
237 };
238 assert_eq!(whitespace.target(), None);
239
240 let padded = AgentUplinkGrant {
242 enabled: true,
243 host: Some(" gw.example ".to_string()),
244 port: Some(9000),
245 granted_at: "t".to_string(),
246 };
247 assert_eq!(padded.target(), Some(("gw.example", 9000)));
248
249 let host_only = AgentUplinkGrant {
251 enabled: true,
252 host: Some("gw.example".to_string()),
253 port: None,
254 granted_at: "t".to_string(),
255 };
256 assert_eq!(host_only.target(), None);
257
258 let standby_with_target = AgentUplinkGrant {
260 enabled: false,
261 host: Some("gw.example".to_string()),
262 port: Some(9000),
263 granted_at: "t".to_string(),
264 };
265 assert_eq!(standby_with_target.target(), None);
266 }
267
268 #[test]
269 fn a_newer_gateway_field_is_rejected_rather_than_ignored() {
270 let json = r#"{"enabled":true,"granted_at":"t","extra":1}"#;
273 assert!(serde_json::from_str::<AgentUplinkGrant>(json).is_err());
274 }
275
276 #[test]
277 fn poll_round_trips_and_rejects_unknown_fields() {
278 let poll = PollAgentUplink {
279 api_version: crate::API_VERSION_V1.to_string(),
280 kind: POLL_AGENT_UPLINK_KIND.to_string(),
281 agent_id: "agent-1".to_string(),
282 instance_id: "inst-1".to_string(),
283 requested_at: "2026-09-26T00:00:00Z".to_string(),
284 };
285 let json = serde_json::to_string(&poll).expect("encode");
286 let back: PollAgentUplink = serde_json::from_str(&json).expect("decode");
287 assert_eq!(back, poll);
288
289 let bad = r#"{"api_version":"v1","kind":"poll_agent_uplink","agent_id":"a",
290 "instance_id":"i","requested_at":"t","extra":1}"#;
291 assert!(serde_json::from_str::<PollAgentUplink>(bad).is_err());
292 }
293}