Skip to main content

cloudpub_client/plugins/
plugin_trait.rs

1use crate::config::{ClientConfig, ClientOpts};
2use crate::shell::SubProcess;
3use anyhow::{bail, Context, Result};
4use async_trait::async_trait;
5use cloudpub_common::fair_channel::FairSender;
6use cloudpub_common::protocol::message::Message;
7use cloudpub_common::protocol::{Break, ErrorInfo, ErrorKind, ServerEndpoint};
8use cloudpub_common::utils::is_tcp_port_available;
9use parking_lot::RwLock;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tokio::task::JoinHandle;
13use tokio::time::{self, Duration, Instant};
14use tracing::{debug, error};
15
16/// ClientEndpoint intentionally has no PartialEq (see common/build.rs);
17/// its deterministic prost encoding stands in for config equality.
18fn encode_client_config(endpoint: &ServerEndpoint) -> Vec<u8> {
19    use cloudpub_common::prost::Message as ProstMessage;
20    endpoint
21        .client
22        .as_ref()
23        .map(|c| c.encode_to_vec())
24        .unwrap_or_default()
25}
26
27#[async_trait]
28pub trait Plugin: Send + Sync {
29    /// Name of the plugin
30    fn name(&self) -> &'static str;
31
32    /// Setup the plugin environment
33    async fn setup(
34        &self,
35        config: &Arc<RwLock<ClientConfig>>,
36        opts: &ClientOpts,
37        command_rx: &mut mpsc::Receiver<Message>,
38        result_tx: &mpsc::Sender<Message>,
39    ) -> Result<()>;
40
41    /// Publish a service using this plugin
42    async fn publish(
43        &self,
44        endpoint: &ServerEndpoint,
45        config: &Arc<RwLock<ClientConfig>>,
46        opts: &ClientOpts,
47        result_tx: &mpsc::Sender<Message>,
48    ) -> Result<SubProcess>;
49}
50
51pub struct PluginHandle {
52    guid: String,
53    /// prost-encoded ClientEndpoint this handle was spawned for, used to
54    /// recognize duplicate EndpointStart deliveries for the same config
55    client_config: Vec<u8>,
56    server: Arc<RwLock<Option<SubProcess>>>,
57    cancel_tx: mpsc::Sender<Message>,
58    task: JoinHandle<()>,
59}
60
61impl PluginHandle {
62    pub fn spawn(
63        plugin: Arc<dyn Plugin>,
64        mut endpoint: ServerEndpoint,
65        config: Arc<RwLock<ClientConfig>>,
66        opts: ClientOpts,
67        to_server_tx: FairSender<Message>,
68    ) -> Self {
69        let guid = endpoint.guid.clone();
70        let client_config = encode_client_config(&endpoint);
71        let guid_for_task = guid.clone();
72        let server = Arc::new(RwLock::new(None));
73        let server_task = server.clone();
74
75        // Per-process cancel and event channels
76        let (cancel_tx, mut cancel_rx) = mpsc::channel::<Message>(1);
77        let (proc_event_tx, mut proc_event_rx) = mpsc::channel::<Message>(1024);
78
79        let task = tokio::spawn(async move {
80            // Forward per-process events to the main client event channel
81            tokio::spawn({
82                let mut endpoint = endpoint.clone();
83                let to_server_tx = to_server_tx.clone();
84                let guid_for_task = guid_for_task.clone();
85                async move {
86                    use tokio::time::{Duration, Instant};
87
88                    let mut last_progress_time = Instant::now() - Duration::from_secs(1); // Allow first progress immediately
89
90                    while let Some(mut msg) = proc_event_rx.recv().await {
91                        let should_send = match &mut msg {
92                            Message::Progress(progress_info) => {
93                                progress_info.guid = guid_for_task.clone();
94
95                                let now = Instant::now();
96                                // Always send 0% and 100% progress messages, or throttle to 1 per second
97                                if progress_info.current == 0
98                                    || progress_info.current >= progress_info.total
99                                    || now.duration_since(last_progress_time)
100                                        >= Duration::from_secs(1)
101                                {
102                                    last_progress_time = now;
103                                    true
104                                } else {
105                                    false
106                                }
107                            }
108                            Message::Error(info) => {
109                                // CLOUDPUB-1505: keep the failure reason for
110                                // the trailing offline status below - the one
111                                // the server stores as the publication's error.
112                                endpoint.error = info.message.clone();
113                                true
114                            }
115                            _ => true, // Always send non-progress messages
116                        };
117
118                        if should_send {
119                            to_server_tx.send(msg.clone()).await.ok();
120                        }
121                    }
122
123                    endpoint.status = Some("offline".to_string());
124                    to_server_tx
125                        .send(Message::EndpointStatus(endpoint.clone()))
126                        .await
127                        .ok();
128                }
129            });
130
131            // Initial lifecycle status: waiting
132            endpoint.status = Some("waiting".to_string());
133            let _ = to_server_tx
134                .send(Message::EndpointStatus(endpoint.clone()))
135                .await;
136
137            let res: Result<()> = async {
138                // Long running setup: cancellable via cancel_rx
139                plugin
140                    .setup(&config, &opts, &mut cancel_rx, &proc_event_tx)
141                    .await
142                    .context("Failed to setup plugin")?;
143
144                let server_process = plugin
145                    .publish(&endpoint, &config, &opts, &proc_event_tx)
146                    .await
147                    .context("Failed to publish plugin service")?;
148
149                // Wait until the port is bound
150                let now = Instant::now();
151                while is_tcp_port_available("127.0.0.1", server_process.port)
152                    .await
153                    .context("Check port availability")?
154                {
155                    if server_process.result.read().is_err() {
156                        return Ok(()); // Error already reported by subprocess
157                    }
158
159                    if now.elapsed() > Duration::from_secs(60) {
160                        bail!("{}", crate::t!("error-start-server"));
161                    }
162                    debug!(
163                        "Waiting for server to start on port {}",
164                        server_process.port
165                    );
166                    time::sleep(Duration::from_secs(1)).await;
167                }
168
169                *server_task.write() = Some(server_process);
170                Ok(())
171            }
172            .await;
173
174            match res {
175                Ok(()) => {
176                    endpoint.status = Some("online".into());
177                    let _ = to_server_tx.send(Message::EndpointStatus(endpoint)).await;
178                }
179                Err(e) => {
180                    error!("Error handling endpoint {}: {:#}", &guid_for_task, e);
181                    // Through the forwarder, not straight to the server: the
182                    // forwarder owns the trailing offline EndpointStatus, and
183                    // only a message it has seen can put the reason into it
184                    // (CLOUDPUB-1505). {:#} keeps the whole anyhow chain;
185                    // to_string() would carry only the outermost context.
186                    let _ = proc_event_tx
187                        .send(Message::Error(ErrorInfo {
188                            kind: ErrorKind::PublishFailed.into(),
189                            message: format!("{:#}", e),
190                            guid: guid_for_task,
191                        }))
192                        .await;
193                }
194            }
195        });
196
197        Self {
198            guid,
199            client_config,
200            server,
201            cancel_tx,
202            task,
203        }
204    }
205
206    /// True when this handle already serves exactly this endpoint
207    /// configuration: a duplicate EndpointStart (the server re-sends them
208    /// after every EndpointStartAll and on repeated registrations) must
209    /// not bounce a running publication.
210    pub fn same_config(&self, endpoint: &ServerEndpoint) -> bool {
211        self.client_config == encode_client_config(endpoint)
212    }
213
214    /// The publication is being set up or its subprocess is alive. A
215    /// finished task with no healthy subprocess means setup failed or the
216    /// server died - a fresh spawn is warranted then.
217    pub fn is_healthy(&self) -> bool {
218        if !self.task.is_finished() {
219            return true;
220        }
221        match self.server.read().as_ref() {
222            Some(process) => process.result.read().is_ok(),
223            None => false,
224        }
225    }
226
227    pub fn port(&self) -> Option<u16> {
228        self.server.read().as_ref().map(|s| s.port)
229    }
230
231    pub fn guid(&self) -> &str {
232        &self.guid
233    }
234
235    pub fn send_break(&self) {
236        let _ = self.cancel_tx.try_send(Message::Break(Break {
237            guid: self.guid.clone(),
238        }));
239    }
240}
241
242impl Drop for PluginHandle {
243    fn drop(&mut self) {
244        // Abort main task and drop cancel_tx to notify setup to stop
245        self.task.abort();
246        // cancel_tx dropped here; receiver sees end-of-stream and should terminate
247        let _ = self.cancel_tx.try_send(Message::Break(Break {
248            guid: self.guid.clone(),
249        }));
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use anyhow::anyhow;
257    use cloudpub_common::fair_channel::fair_channel;
258    use tokio::time::timeout;
259
260    // BUG (CLOUDPUB-1505): a publication whose plugin setup fails comes back
261    // as offline with an EMPTY error, so the dashboard shows "clicked -
262    // nothing happened" while the agent log alone holds the reason.
263    // HYPOTHESIS: PluginHandle::spawn reports the failure twice, both times
264    // without the cause reaching the status the server stores:
265    //  - Message::Error carries e.to_string(), which is only the outermost
266    //    context ("Failed to setup plugin"), not the anyhow chain;
267    //  - the forwarder task sends the final EndpointStatus(offline) from its
268    //    own endpoint clone that never learned the failure, and the server
269    //    (control.rs) faithfully stores that empty error.
270    // SOLUTION: format the ErrorInfo message with {:#} so the whole chain
271    // travels, route it through the forwarder so the trailing offline status
272    // inherits the reason, and assert the LAST status the server would see
273    // is offline WITH the cause.
274    // RESULT: the user sees the real reason ("404 Not Found") in the row.
275    // STATUS: FIXED - see CLOUDPUB-1505
276    struct SetupFails;
277
278    #[async_trait]
279    impl Plugin for SetupFails {
280        fn name(&self) -> &'static str {
281            "setup_fails"
282        }
283
284        async fn setup(
285            &self,
286            _config: &Arc<RwLock<ClientConfig>>,
287            _opts: &ClientOpts,
288            _command_rx: &mut mpsc::Receiver<Message>,
289            _result_tx: &mpsc::Sender<Message>,
290        ) -> Result<()> {
291            // Two levels below the context spawn() adds - the shape of the
292            // real 404 chain from the stand, minus the localized wrapping.
293            Err(anyhow!("HTTP request failed with status: 404 Not Found")
294                .context("web server package download failed"))
295        }
296
297        async fn publish(
298            &self,
299            _endpoint: &ServerEndpoint,
300            _config: &Arc<RwLock<ClientConfig>>,
301            _opts: &ClientOpts,
302            _result_tx: &mpsc::Sender<Message>,
303        ) -> Result<SubProcess> {
304            unreachable!("setup failed before publish was reached")
305        }
306    }
307
308    #[tokio::test]
309    async fn failed_plugin_setup_reports_reason_in_offline_status() {
310        let (to_server_tx, mut to_server_rx) = fair_channel::<Message>(16);
311        let endpoint = ServerEndpoint {
312            guid: "guid-1505".to_string(),
313            ..Default::default()
314        };
315
316        let handle = PluginHandle::spawn(
317            Arc::new(SetupFails),
318            endpoint,
319            Arc::new(RwLock::new(ClientConfig::default())),
320            ClientOpts::default(),
321            to_server_tx,
322        );
323
324        let mut error_message: Option<String> = None;
325        let mut last_status: Option<(Option<String>, String)> = None;
326        // FairReceiver has no sender tracking: recv() would wait forever once
327        // the tasks are done, so drain until a quiet window says they are.
328        loop {
329            match timeout(Duration::from_secs(1), to_server_rx.recv()).await {
330                Ok(Some(msg)) => match msg {
331                    Message::Error(info) => error_message = Some(info.message),
332                    Message::EndpointStatus(ep) => {
333                        last_status = Some((ep.status.clone(), ep.error.clone()))
334                    }
335                    other => debug!("unexpected message: {:?}", other),
336                },
337                Ok(None) => break,
338                Err(_) => break,
339            }
340        }
341
342        let error_message = error_message.expect("no Message::Error reached the server channel");
343        assert!(
344            error_message.contains("Failed to setup plugin")
345                && error_message.contains("web server package download failed")
346                && error_message.contains("404 Not Found"),
347            "Error message must carry the full chain, got: {}",
348            error_message
349        );
350
351        let (status, status_error) =
352            last_status.expect("no EndpointStatus reached the server channel");
353        assert_eq!(
354            status.as_deref(),
355            Some("offline"),
356            "expected the final offline status, got status={:?} error={}",
357            status,
358            status_error
359        );
360        assert!(
361            status_error.contains("404 Not Found"),
362            "the offline status must carry the failure reason, got: {}",
363            status_error
364        );
365
366        drop(handle);
367    }
368}