greentic_operator/ingress/
control_directive.rs1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3use serde_json::Value as JsonValue;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct DispatchTarget {
7 pub tenant: String,
8 pub team: Option<String>,
9 pub pack: String,
10 pub flow: Option<String>,
11 pub node: Option<String>,
12}
13
14#[derive(Clone, Debug)]
15pub struct IngressReply {
16 pub text: Option<String>,
17 pub card_cbor: Option<JsonValue>,
18 pub status_code: Option<u16>,
19 pub reason_code: Option<String>,
20}
21
22#[derive(Clone, Debug)]
23pub enum ControlDirective {
24 Continue,
25 Dispatch { target: DispatchTarget },
26 Respond { reply: IngressReply },
27 Deny { reply: IngressReply },
28}
29
30pub fn try_parse_control_directive(output: &JsonValue) -> Option<ControlDirective> {
31 let decoded = decode_directive_json(output).unwrap_or_else(|| output.clone());
32 let action = decoded
33 .get("action")
34 .and_then(JsonValue::as_str)
35 .map(|value| value.trim().to_ascii_lowercase())?;
36 match action.as_str() {
37 "continue" => Some(ControlDirective::Continue),
38 "dispatch" => parse_dispatch(decoded.get("target"))
39 .map(|target| ControlDirective::Dispatch { target }),
40 "respond" => Some(ControlDirective::Respond {
41 reply: parse_reply(&decoded, false),
42 }),
43 "deny" => Some(ControlDirective::Deny {
44 reply: parse_reply(&decoded, true),
45 }),
46 _ => None,
47 }
48}
49
50fn decode_directive_json(output: &JsonValue) -> Option<JsonValue> {
51 let object = output.as_object()?;
52 for key in [
53 "hook_decision_cbor_b64",
54 "cbor_b64",
55 "hook_decision_cbor",
56 "result_cbor_b64",
57 ] {
58 let Some(raw) = object.get(key).and_then(JsonValue::as_str) else {
59 continue;
60 };
61 let Ok(bytes) = STANDARD.decode(raw) else {
62 continue;
63 };
64 if let Ok(value) = serde_cbor::from_slice::<JsonValue>(&bytes) {
65 return Some(value);
66 }
67 }
68 None
69}
70
71fn parse_dispatch(raw: Option<&JsonValue>) -> Option<DispatchTarget> {
72 let raw = raw?;
73 if let Some(target) = raw.as_str() {
74 return parse_dispatch_target_string(target);
75 }
76 let map = raw.as_object()?;
77 let tenant = map.get("tenant")?.as_str()?.trim().to_string();
78 let team = map
79 .get("team")
80 .and_then(JsonValue::as_str)
81 .map(str::trim)
82 .filter(|value| !value.is_empty())
83 .map(ToString::to_string);
84 let pack = map.get("pack")?.as_str()?.trim().to_string();
85 let flow = map
86 .get("flow")
87 .and_then(JsonValue::as_str)
88 .map(str::trim)
89 .filter(|value| !value.is_empty())
90 .map(ToString::to_string);
91 let node = map
92 .get("node")
93 .and_then(JsonValue::as_str)
94 .map(str::trim)
95 .filter(|value| !value.is_empty())
96 .map(ToString::to_string);
97 if tenant.is_empty() || pack.is_empty() {
98 return None;
99 }
100 Some(DispatchTarget {
101 tenant,
102 team,
103 pack,
104 flow,
105 node,
106 })
107}
108
109fn parse_dispatch_target_string(raw: &str) -> Option<DispatchTarget> {
110 let segments = raw
111 .split('/')
112 .map(str::trim)
113 .filter(|value| !value.is_empty())
114 .collect::<Vec<_>>();
115 if segments.len() < 3 || segments.len() > 5 {
116 return None;
117 }
118 let tenant = segments[0].to_string();
119 let team = Some(segments[1].to_string()).filter(|value| !value.is_empty());
120 let pack = segments[2].to_string();
121 if tenant.is_empty() || pack.is_empty() {
122 return None;
123 }
124 Some(DispatchTarget {
125 tenant,
126 team,
127 pack,
128 flow: segments.get(3).map(|value| value.to_string()),
129 node: segments.get(4).map(|value| value.to_string()),
130 })
131}
132
133fn parse_reply(decoded: &JsonValue, deny: bool) -> IngressReply {
134 let text = decoded
135 .get("response_text")
136 .and_then(JsonValue::as_str)
137 .map(ToString::to_string)
138 .or_else(|| {
139 if deny {
140 decoded
141 .get("reason")
142 .and_then(|value| value.get("text"))
143 .and_then(JsonValue::as_str)
144 .map(ToString::to_string)
145 } else {
146 None
147 }
148 });
149 let reason_code = decoded
150 .get("reason_code")
151 .and_then(JsonValue::as_str)
152 .map(ToString::to_string)
153 .or_else(|| {
154 decoded
155 .get("reason")
156 .and_then(|value| value.get("code"))
157 .and_then(JsonValue::as_str)
158 .map(ToString::to_string)
159 });
160 let status_code = decoded
161 .get("status_code")
162 .and_then(JsonValue::as_u64)
163 .map(|value| value as u16)
164 .or(if deny { Some(403) } else { Some(200) });
165 IngressReply {
166 text,
167 card_cbor: decoded.get("response_card").cloned(),
168 status_code,
169 reason_code,
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use serde_json::json;
176
177 use super::*;
178
179 #[test]
180 fn parse_dispatch_string_target() {
181 let directive = try_parse_control_directive(&json!({
182 "action": "dispatch",
183 "target": "acme/default/pack-a/flow-x/node-y"
184 }))
185 .expect("directive");
186 let ControlDirective::Dispatch { target } = directive else {
187 panic!("expected dispatch");
188 };
189 assert_eq!(target.tenant, "acme");
190 assert_eq!(target.team.as_deref(), Some("default"));
191 assert_eq!(target.pack, "pack-a");
192 assert_eq!(target.flow.as_deref(), Some("flow-x"));
193 assert_eq!(target.node.as_deref(), Some("node-y"));
194 }
195
196 #[test]
197 fn parse_respond_directive() {
198 let directive = try_parse_control_directive(&json!({
199 "action": "respond",
200 "response_text": "ok"
201 }))
202 .expect("directive");
203 let ControlDirective::Respond { reply } = directive else {
204 panic!("expected respond");
205 };
206 assert_eq!(reply.text.as_deref(), Some("ok"));
207 assert_eq!(reply.status_code, Some(200));
208 }
209
210 #[test]
211 fn parse_dispatch_target_requires_min_segments() {
212 let directive = try_parse_control_directive(&json!({
213 "action": "dispatch",
214 "target": "acme/default"
215 }));
216 assert!(directive.is_none());
217 }
218
219 #[test]
220 fn parse_deny_defaults_to_forbidden() {
221 let directive = try_parse_control_directive(&json!({
222 "action": "deny",
223 "reason": { "code": "blocked", "text": "denied by policy" }
224 }))
225 .expect("directive");
226 let ControlDirective::Deny { reply } = directive else {
227 panic!("expected deny");
228 };
229 assert_eq!(reply.reason_code.as_deref(), Some("blocked"));
230 assert_eq!(reply.text.as_deref(), Some("denied by policy"));
231 assert_eq!(reply.status_code, Some(403));
232 }
233}