1use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::{EventType, SpawnContext};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16pub const ACCEPT: &str = "pulsar_accept";
17pub const REJECT: &str = "pulsar_reject";
18pub const PUBLISH: &str = "pulsar_publish";
19pub const MAX_PROPERTIES: usize = 64;
21
22#[derive(Default)]
23pub struct PulsarProtocol;
24impl PulsarProtocol {
25 pub fn new() -> Self {
26 Self
27 }
28}
29
30pub fn p(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
31 Parameter {
32 name: name.into(),
33 type_hint: kind.into(),
34 description: description.into(),
35 required,
36 }
37}
38
39pub fn action(
40 name: &str,
41 description: &str,
42 parameters: Vec<Parameter>,
43 example: Value,
44) -> ActionDefinition {
45 ActionDefinition {
46 name: name.into(),
47 description: description.into(),
48 parameters,
49 example,
50 log_template: Some(LogTemplate::new().with_info(format!("-> Pulsar {name}"))),
51 }
52}
53
54fn accept() -> ActionDefinition {
55 action(ACCEPT, "Allow the producer or subscription, or accept the message (it is stored for and delivered to the topic's subscriptions).",
56 vec![], json!({"type": ACCEPT}))
57}
58
59fn reject() -> ActionDefinition {
60 action(REJECT, "Refuse the producer, subscription or message; the client gets an authorization or not-allowed error with this text.",
61 vec![p("message", "string", "Why, as the client will see it", true)],
62 json!({"type": REJECT, "message": "not allowed on this topic"}))
63}
64
65pub fn message_params() -> Vec<Parameter> {
67 vec![
68 p(
69 "topic",
70 "string",
71 "Topic name, e.g. orders or persistent://public/default/orders",
72 true,
73 ),
74 p(
75 "payload",
76 "string",
77 "The message body as text, or hex when encoding is hex",
78 true,
79 ),
80 p(
81 "encoding",
82 "string",
83 "How payload is written: utf8 (default) or hex",
84 false,
85 ),
86 p(
87 "properties",
88 "object",
89 "String properties, e.g. {\"source\": \"netget\"}",
90 false,
91 ),
92 p("key", "string", "The message key (partition key)", false),
93 ]
94}
95
96pub fn publish() -> ActionDefinition {
97 action(PUBLISH, "Publish a message to a topic as producer netget, delivered to its subscriptions like any other.",
98 message_params(), json!({"type": PUBLISH, "topic": "replies", "payload": "got it", "properties": {"source": "netget"}}))
99}
100
101pub fn all_actions() -> Vec<ActionDefinition> {
102 vec![accept(), reject(), publish()]
103}
104
105fn event(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
106 EventType::new(id, description, json!({"type": ACCEPT}))
107 .with_parameters(params)
108 .with_actions(all_actions())
109}
110
111pub static PRODUCER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
112 event(
113 "pulsar_producer",
114 "A client asked to publish to a topic. Accept or reject the producer.",
115 vec![
116 p("topic", "string", "The full topic name", true),
117 p(
118 "producer_name",
119 "string",
120 "The producer's name (NetGet assigns one when the client gives none)",
121 true,
122 ),
123 p(
124 "access_mode",
125 "string",
126 "Shared, Exclusive, WaitForExclusive or ExclusiveWithFencing",
127 true,
128 ),
129 p(
130 "remote_addr",
131 "string",
132 "The client's address and port",
133 true,
134 ),
135 ],
136 )
137});
138
139pub static SUBSCRIBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
140 event(
141 "pulsar_subscribe",
142 "A client asked to consume from a topic. Accept or reject the subscription.",
143 vec![
144 p("topic", "string", "The full topic name", true),
145 p("subscription", "string", "The subscription name", true),
146 p(
147 "sub_type",
148 "string",
149 "Exclusive, Shared, Failover or Key_Shared",
150 true,
151 ),
152 p(
153 "consumer_name",
154 "string",
155 "The consumer's name, if it gave one",
156 false,
157 ),
158 p(
159 "remote_addr",
160 "string",
161 "The client's address and port",
162 true,
163 ),
164 ],
165 )
166});
167
168pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
169 let mut params = vec![
170 p("producer_name", "string", "Which producer sent it", true),
171 p(
172 "sequence_id",
173 "number",
174 "The producer's sequence number for it",
175 true,
176 ),
177 p(
178 "event_time",
179 "number",
180 "Event time in Unix milliseconds, if the producer set one",
181 false,
182 ),
183 p(
184 "remote_addr",
185 "string",
186 "The client's address and port",
187 true,
188 ),
189 ];
190 params.extend(message_params());
191 event("pulsar_message", "A producer published a message. Accept it (it is delivered to the topic's subscriptions) or reject it.", params)
192});
193
194pub fn check_message(v: &Value) -> Result<()> {
195 super::wire::full_topic(v["topic"].as_str().context("topic is required")?)?;
196 let payload = v["payload"].as_str().context("payload is required")?;
197 let bytes = super::wire::bytes(payload, v["encoding"].as_str())?;
198 ensure!(
199 bytes.len() <= super::wire::MAX_FRAME / 2,
200 "payload too large"
201 );
202 if let Some(props) = v.get("properties").filter(|x| !x.is_null()) {
203 let o = props
204 .as_object()
205 .context("properties must be an object of strings")?;
206 ensure!(
207 o.len() <= MAX_PROPERTIES,
208 "at most {MAX_PROPERTIES} properties"
209 );
210 ensure!(
211 o.values().all(Value::is_string),
212 "property values must be strings"
213 );
214 }
215 if let Some(k) = v.get("key").filter(|x| !x.is_null()) {
216 ensure!(k.is_string(), "key must be a string");
217 }
218 Ok(())
219}
220
221pub fn check(v: &Value) -> Result<()> {
222 match v["type"].as_str().unwrap_or_default() {
223 ACCEPT => Ok(()),
224 REJECT => {
225 let m = v["message"].as_str().context("message is required")?;
226 ensure!(
227 !m.is_empty() && m.len() <= 512,
228 "message must be 1-512 bytes"
229 );
230 Ok(())
231 }
232 PUBLISH => check_message(v),
233 other => bail!("Unknown Pulsar action {other:?}"),
234 }
235}
236
237impl Protocol for PulsarProtocol {
238 fn protocol_name(&self) -> &'static str {
239 "Apache Pulsar"
240 }
241 fn stack_name(&self) -> &'static str {
242 "ETH>IP>TCP>Pulsar"
243 }
244 fn keywords(&self) -> Vec<&'static str> {
245 vec![
246 "pulsar",
247 "apache pulsar",
248 "pulsar broker",
249 "pub/sub",
250 "message broker",
251 ]
252 }
253 fn description(&self) -> &'static str {
254 "Apache Pulsar broker (binary protocol): the model allows producers and subscriptions and accepts or refuses each message; Rust delivers to subscriptions"
255 }
256 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
257 Vec::new()
258 }
259 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
260 all_actions()
261 }
262 fn get_event_types(&self) -> Vec<EventType> {
263 vec![
264 PRODUCER_EVENT.clone(),
265 SUBSCRIBE_EVENT.clone(),
266 MESSAGE_EVENT.clone(),
267 ]
268 }
269 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
270 vec![ParameterDefinition {
271 name: "idle_timeout_secs".into(),
272 type_hint: "number".into(),
273 description: "Seconds a client may send nothing (clients ping every 30) before it is disconnected (1..=86400)".into(),
274 required: false,
275 example: json!(600),
276 default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
277 }]
278 }
279 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
280 use crate::protocol::metadata::*;
281 ProtocolMetadataV2::builder()
282 .state(DevelopmentState::Experimental)
283 .privilege_requirement(PrivilegeRequirement::None)
284 .well_known_port(6650)
285 .implementation("The Pulsar binary protocol over Tokio TCP with hand-declared prost messages: CONNECT, PARTITIONED_METADATA (non-partitioned), LOOKUP (to itself), PRODUCER, SEND (CRC32C-checked, batches split), SUBSCRIBE (Exclusive, Shared, Failover, Key_Shared as Shared), FLOW, MESSAGE, ACK (individual, cumulative, with receipts), REDELIVER_UNACKNOWLEDGED_MESSAGES, UNSUBSCRIBE, CLOSE_*, GET_LAST_MESSAGE_ID, PING/PONG")
286 .llm_control("Which producers and subscriptions are allowed, whether each message is accepted, and messages it publishes itself")
287 .e2e_testing("tests/server/pulsar: the official Python client (pulsar-client, the C++ library) and the Java CLI from Pulsar 4.0.6 produce and consume through NetGet; raw frames for checksums, bounds and a failed handler")
288 .notes("Messages live in memory while the server runs: a subscription keeps a bounded backlog (10000) for consumers without permits, and a new subscription starts at the latest message whatever position it asks for. No persistence, partitions, schemas, compression, encryption, chunking, transactions, authentication or TLS. Frames are capped at 5 MiB.")
289 .request_only("Every command answers a client's request, except MESSAGE frames delivered to consumers")
290 .answers_on_failure()
291 .max_inbound_bytes(super::wire::MAX_FRAME)
292 .build()
293 }
294 fn example_prompt(&self) -> &'static str {
295 "Pulsar broker on port 6650 that refuses messages containing passwords"
296 }
297 fn get_startup_examples(&self) -> StartupExamples {
298 let llm = json!({"type":"open_server","base_stack":"pulsar","port":6650,
299 "instruction":"Allow every producer and subscription; refuse messages whose payload mentions a password"});
300 let mut static_example = llm.clone();
301 static_example["event_handlers"] =
302 json!([{"event_pattern":"*","handler":{"type":"static","actions":[{"type":ACCEPT}]}}]);
303 let mut scripted = llm.clone();
304 scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
305 "code":"import json,sys\ni=json.load(sys.stdin); e=i['event']\nbad=i['event_type_id']=='pulsar_message' and 'password' in e['payload']\nprint(json.dumps({'actions':[{'type':'pulsar_reject','message':'no secrets'} if bad else {'type':'pulsar_accept'}]}))"}}]);
306 StartupExamples::new(llm, scripted, static_example)
307 }
308 fn group_name(&self) -> &'static str {
309 "Application"
310 }
311}
312
313impl Server for PulsarProtocol {
314 fn spawn(
315 &self,
316 ctx: SpawnContext,
317 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
318 {
319 Box::pin(super::spawn(ctx))
320 }
321 fn execute_action(&self, action: Value) -> Result<ActionResult> {
322 check(&action)?;
323 Ok(ActionResult::Custom {
324 name: action["type"].as_str().unwrap_or_default().to_string(),
325 data: action,
326 })
327 }
328}