1use std::collections::BTreeMap;
28use std::future::Future;
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use rmpv::Value;
33use thiserror::Error;
34
35use crate::client::{ClientError, PluginIpcClient};
36use crate::context::PluginContext;
37
38#[async_trait]
46pub trait Plugin: Send + Sync {
47 fn new() -> Self
50 where
51 Self: Sized;
52
53 async fn on_install(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
54 Ok(())
55 }
56 async fn on_enable(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
57 Ok(())
58 }
59 async fn on_configure(
60 &mut self,
61 _ctx: &PluginContext,
62 _config: &BTreeMap<String, Value>,
63 ) -> Result<(), ClientError> {
64 Ok(())
65 }
66 async fn on_start(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
67 Ok(())
68 }
69 async fn on_stop(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
70 Ok(())
71 }
72 async fn on_disable(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
73 Ok(())
74 }
75}
76
77#[derive(Debug, Error)]
79pub enum RunnerError {
80 #[error("missing plugin id argument")]
82 MissingPluginId,
83 #[error("no supervisor socket/token supplied (set --socket and --token)")]
88 NoBridge,
89 #[error("ipc connect failed: {0}")]
91 Connect(#[from] ClientError),
92 #[error(
97 "plugin host session lost; exiting so the unit restarts with a fresh socket and token"
98 )]
99 SessionLost,
100}
101
102pub const TOKEN_CREDENTIAL_NAME: &str = "ados-plugin-token";
105
106#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct RunnerArgs {
112 pub plugin_id: String,
113 pub socket_path: Option<String>,
114 pub token: Option<String>,
115 pub agent_id: String,
116 pub data_dir: Option<String>,
120}
121
122impl RunnerArgs {
123 pub fn parse<F>(args: &[String], env: F) -> Result<Self, RunnerError>
133 where
134 F: Fn(&str) -> Option<String>,
135 {
136 let mut plugin_id: Option<String> = None;
137 let mut socket_path: Option<String> = None;
138 let mut token: Option<String> = None;
139 let mut agent_id: Option<String> = None;
140
141 let mut i = 0;
142 while i < args.len() {
143 let arg = &args[i];
144 match arg.as_str() {
145 "--socket" => {
146 socket_path = args.get(i + 1).cloned();
147 i += 2;
148 }
149 "--token" => {
150 token = args.get(i + 1).cloned();
151 i += 2;
152 }
153 "--agent-id" => {
154 agent_id = args.get(i + 1).cloned();
155 i += 2;
156 }
157 other if other.starts_with("--socket=") => {
158 socket_path = Some(other["--socket=".len()..].to_string());
159 i += 1;
160 }
161 other if other.starts_with("--token=") => {
162 token = Some(other["--token=".len()..].to_string());
163 i += 1;
164 }
165 other if other.starts_with("--agent-id=") => {
166 agent_id = Some(other["--agent-id=".len()..].to_string());
167 i += 1;
168 }
169 _ if plugin_id.is_none() => {
170 plugin_id = Some(arg.clone());
171 i += 1;
172 }
173 _ => {
174 i += 1;
175 }
176 }
177 }
178
179 let credential = read_credential(&env);
180 let launch_value = |key: &str| {
181 credential
182 .get(key)
183 .filter(|v| !v.is_empty())
184 .cloned()
185 .or_else(|| env(key).filter(|v| !v.is_empty()))
186 };
187 Ok(Self {
188 plugin_id: plugin_id.ok_or(RunnerError::MissingPluginId)?,
189 socket_path: socket_path.or_else(|| launch_value("ADOS_PLUGIN_SOCKET")),
190 token: token.or_else(|| launch_value("ADOS_PLUGIN_TOKEN")),
191 agent_id: agent_id
192 .or_else(|| launch_value("ADOS_PLUGIN_AGENT_ID"))
193 .unwrap_or_default(),
194 data_dir: launch_value("ADOS_PLUGIN_DATA_DIR"),
195 })
196 }
197}
198
199fn read_credential<F>(env: &F) -> BTreeMap<String, String>
203where
204 F: Fn(&str) -> Option<String>,
205{
206 let Some(dir) = env("CREDENTIALS_DIRECTORY").filter(|d| !d.is_empty()) else {
207 return BTreeMap::new();
208 };
209 let path = std::path::Path::new(&dir).join(TOKEN_CREDENTIAL_NAME);
210 let Ok(body) = std::fs::read_to_string(path) else {
211 return BTreeMap::new();
212 };
213 body.lines()
214 .filter_map(|line| line.split_once('='))
215 .filter(|(key, _)| !key.is_empty())
216 .map(|(key, value)| (key.to_string(), value.to_string()))
217 .collect()
218}
219
220pub async fn run_plugin<P, S>(
234 plugin_version: impl Into<String>,
235 static_config: BTreeMap<String, Value>,
236 shutdown: S,
237) -> Result<(), RunnerError>
238where
239 P: Plugin,
240 S: Future<Output = ()>,
241{
242 let argv: Vec<String> = std::env::args().skip(1).collect();
243 let args = RunnerArgs::parse(&argv, |k| std::env::var(k).ok())?;
244 run_plugin_with::<P, S>(args, plugin_version, static_config, shutdown).await
245}
246
247pub async fn run_plugin_with<P, S>(
251 args: RunnerArgs,
252 plugin_version: impl Into<String>,
253 static_config: BTreeMap<String, Value>,
254 shutdown: S,
255) -> Result<(), RunnerError>
256where
257 P: Plugin,
258 S: Future<Output = ()>,
259{
260 let (Some(socket_path), Some(token)) = (args.socket_path.clone(), args.token.clone()) else {
261 return Err(RunnerError::NoBridge);
262 };
263
264 let ipc = Arc::new(PluginIpcClient::new(
265 args.plugin_id.clone(),
266 token,
267 socket_path,
268 ));
269 ipc.connect().await?;
270
271 ensure_data_dir(args.data_dir.as_deref());
272
273 let ctx = PluginContext::new(
274 ipc.clone(),
275 plugin_version,
276 args.agent_id,
277 args.data_dir,
278 static_config.clone(),
279 );
280
281 let mut plugin = P::new();
282 let result = drive(
283 &mut plugin,
284 &ctx,
285 &static_config,
286 shutdown,
287 ipc.session_lost(),
288 )
289 .await;
290
291 ipc.close().await;
293 result
294}
295
296fn ensure_data_dir(data_dir: Option<&str>) {
305 if let Some(dir) = data_dir {
306 if let Err(e) = std::fs::create_dir_all(dir) {
307 tracing::warn!(dir, error = %e, "could not create plugin data dir");
308 }
309 }
310}
311
312async fn drive<P, S, L>(
321 plugin: &mut P,
322 ctx: &PluginContext,
323 config: &BTreeMap<String, Value>,
324 shutdown: S,
325 session_lost: L,
326) -> Result<(), RunnerError>
327where
328 P: Plugin,
329 S: Future<Output = ()>,
330 L: Future<Output = ()>,
331{
332 plugin.on_install(ctx).await?;
333 plugin.on_enable(ctx).await?;
334 plugin.on_configure(ctx, config).await?;
335 plugin.on_start(ctx).await?;
336
337 tracing::info!(plugin_id = %ctx.plugin_id, "plugin ready");
338 let lost = tokio::select! {
339 () = shutdown => false,
340 () = session_lost => true,
341 };
342
343 if lost {
344 tracing::error!(
345 plugin_id = %ctx.plugin_id,
346 "plugin host session lost; stopping so the unit restarts with a fresh session"
347 );
348 for (hook, result) in [
349 ("on_stop", plugin.on_stop(ctx).await),
350 ("on_disable", plugin.on_disable(ctx).await),
351 ] {
352 if let Err(e) = result {
353 tracing::debug!(plugin_id = %ctx.plugin_id, hook, error = %e, "teardown hook failed after session loss");
354 }
355 }
356 return Err(RunnerError::SessionLost);
357 }
358
359 plugin.on_stop(ctx).await?;
360 plugin.on_disable(ctx).await?;
361 tracing::info!(plugin_id = %ctx.plugin_id, "plugin clean exit");
362 Ok(())
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn no_env(_: &str) -> Option<String> {
370 None
371 }
372
373 #[test]
374 fn parses_positional_id_and_space_separated_options() {
375 let argv = vec![
376 "com.example.demo".to_string(),
377 "--socket".to_string(),
378 "/run/ados/plugins/x.sock".to_string(),
379 "--token".to_string(),
380 "v1|p|s|0|600||sig".to_string(),
381 "--agent-id".to_string(),
382 "agent-42".to_string(),
383 ];
384 let args = RunnerArgs::parse(&argv, no_env).unwrap();
385 assert_eq!(args.plugin_id, "com.example.demo");
386 assert_eq!(
387 args.socket_path.as_deref(),
388 Some("/run/ados/plugins/x.sock")
389 );
390 assert_eq!(args.token.as_deref(), Some("v1|p|s|0|600||sig"));
391 assert_eq!(args.agent_id, "agent-42");
392 }
393
394 #[test]
395 fn parses_equals_form_options() {
396 let argv = vec![
397 "com.example.demo".to_string(),
398 "--socket=/tmp/y.sock".to_string(),
399 "--token=tok".to_string(),
400 ];
401 let args = RunnerArgs::parse(&argv, no_env).unwrap();
402 assert_eq!(args.socket_path.as_deref(), Some("/tmp/y.sock"));
403 assert_eq!(args.token.as_deref(), Some("tok"));
404 assert_eq!(args.agent_id, "");
406 }
407
408 #[test]
409 fn falls_back_to_env_for_socket_token_agent() {
410 let argv = vec!["com.example.demo".to_string()];
411 let env = |k: &str| match k {
412 "ADOS_PLUGIN_SOCKET" => Some("/env/sock".to_string()),
413 "ADOS_PLUGIN_TOKEN" => Some("env-tok".to_string()),
414 "ADOS_PLUGIN_AGENT_ID" => Some("env-agent".to_string()),
415 _ => None,
416 };
417 let args = RunnerArgs::parse(&argv, env).unwrap();
418 assert_eq!(args.socket_path.as_deref(), Some("/env/sock"));
419 assert_eq!(args.token.as_deref(), Some("env-tok"));
420 assert_eq!(args.agent_id, "env-agent");
421 }
422
423 #[test]
424 fn explicit_option_overrides_env() {
425 let argv = vec![
426 "com.example.demo".to_string(),
427 "--socket".to_string(),
428 "/explicit/sock".to_string(),
429 ];
430 let env = |k: &str| match k {
431 "ADOS_PLUGIN_SOCKET" => Some("/env/sock".to_string()),
432 _ => None,
433 };
434 let args = RunnerArgs::parse(&argv, env).unwrap();
435 assert_eq!(args.socket_path.as_deref(), Some("/explicit/sock"));
436 }
437
438 #[test]
439 fn missing_plugin_id_errors() {
440 let argv = vec!["--socket".to_string(), "/tmp/x".to_string()];
441 let err = RunnerArgs::parse(&argv, no_env).unwrap_err();
442 assert!(matches!(err, RunnerError::MissingPluginId));
443 }
444
445 #[test]
446 fn the_token_credential_supplies_what_the_options_do_not() {
447 let dir = tempfile::tempdir().unwrap();
450 std::fs::write(
451 dir.path().join(TOKEN_CREDENTIAL_NAME),
452 "ADOS_PLUGIN_TOKEN=pay=load|sig==\n\
453 ADOS_PLUGIN_SOCKET=/run/ados/plugins/com.example.demo/host.sock\n\
454 ADOS_PLUGIN_AGENT_ID=drone-abc\n\
455 ADOS_PLUGIN_DATA_DIR=/var/ados/plugin-data/com.example.demo/drones/drone-abc\n",
456 )
457 .unwrap();
458 let creds = dir.path().to_string_lossy().into_owned();
459 let env = move |k: &str| match k {
460 "CREDENTIALS_DIRECTORY" => Some(creds.clone()),
461 "ADOS_PLUGIN_TOKEN" => Some("stale-env".to_string()),
462 _ => None,
463 };
464 let argv = vec![
465 "com.example.demo".to_string(),
466 "--agent-id=explicit".to_string(),
467 ];
468 let args = RunnerArgs::parse(&argv, env).unwrap();
469 assert_eq!(args.token.as_deref(), Some("pay=load|sig=="));
470 assert_eq!(
471 args.socket_path.as_deref(),
472 Some("/run/ados/plugins/com.example.demo/host.sock")
473 );
474 assert_eq!(args.agent_id, "explicit");
475 assert_eq!(
476 args.data_dir.as_deref(),
477 Some("/var/ados/plugin-data/com.example.demo/drones/drone-abc")
478 );
479 }
480
481 #[test]
482 fn an_unreadable_credential_falls_back_to_the_environment() {
483 let dir = tempfile::tempdir().unwrap();
484 let missing = dir.path().join("absent").to_string_lossy().into_owned();
485 let env = move |k: &str| match k {
486 "CREDENTIALS_DIRECTORY" => Some(missing.clone()),
487 "ADOS_PLUGIN_TOKEN" => Some("env-tok".to_string()),
488 _ => None,
489 };
490 let args = RunnerArgs::parse(&["com.example.demo".to_string()], env).unwrap();
491 assert_eq!(args.token.as_deref(), Some("env-tok"));
492 assert_eq!(args.socket_path, None);
493 }
494
495 struct DummyPlugin {
498 started: bool,
499 }
500
501 #[async_trait]
502 impl Plugin for DummyPlugin {
503 fn new() -> Self {
504 Self { started: false }
505 }
506 async fn on_start(&mut self, _ctx: &PluginContext) -> Result<(), ClientError> {
507 self.started = true;
508 Ok(())
509 }
510 }
511
512 #[test]
513 fn dummy_plugin_constructs_with_only_new() {
514 let p = DummyPlugin::new();
515 assert!(!p.started);
516 }
517
518 #[test]
519 fn ensure_data_dir_creates_the_nested_leaf() {
520 let base = tempfile::tempdir().unwrap();
524 let leaf = base.path().join("com.example.plugin/drones/drone-xyz");
525 assert!(!leaf.exists());
526
527 ensure_data_dir(Some(leaf.to_str().unwrap()));
528 assert!(
529 leaf.is_dir(),
530 "the nested data dir must exist after the call"
531 );
532
533 std::fs::write(leaf.join("state.json"), b"{}").expect("write under data dir");
535
536 ensure_data_dir(Some(leaf.to_str().unwrap()));
538 ensure_data_dir(None);
540 }
541
542 #[tokio::test]
543 async fn no_bridge_when_socket_or_token_absent() {
544 let args = RunnerArgs {
546 plugin_id: "com.example.demo".to_string(),
547 socket_path: None,
548 token: None,
549 agent_id: String::new(),
550 data_dir: None,
551 };
552 let err = run_plugin_with::<DummyPlugin, _>(
553 args,
554 "1.0.0",
555 BTreeMap::new(),
556 std::future::ready(()),
557 )
558 .await
559 .unwrap_err();
560 assert!(matches!(err, RunnerError::NoBridge));
561 }
562
563 #[tokio::test]
564 async fn a_dropped_host_session_ends_the_runner_with_an_error() {
565 use ados_protocol::plugin::{Envelope, PROTOCOL_VERSION};
566 use tokio::io::{AsyncReadExt, AsyncWriteExt};
567
568 let dir = tempfile::tempdir().expect("tempdir");
573 let sock = dir.path().join("p.sock");
574 let listener = tokio::net::UnixListener::bind(&sock).expect("bind");
575 let host = tokio::spawn(async move {
576 let (mut stream, _) = listener.accept().await.expect("accept");
577 let len = stream.read_u32().await.expect("hello header") as usize;
578 let mut body = vec![0u8; len];
579 stream.read_exact(&mut body).await.expect("hello body");
580 let hello = Envelope::from_msgpack(&body).expect("hello envelope");
581 let ready = Envelope {
582 version: PROTOCOL_VERSION,
583 kind: "response".to_string(),
584 method: String::new(),
585 capability: String::new(),
586 args: Value::Map(vec![(Value::from("ready"), Value::Boolean(true))]),
587 request_id: hello.request_id,
588 token: String::new(),
589 error: None,
590 };
591 stream
592 .write_all(&ready.encode_frame().unwrap())
593 .await
594 .expect("ready");
595 drop(stream);
597 });
598
599 let args = RunnerArgs {
600 plugin_id: "com.example.demo".to_string(),
601 socket_path: Some(sock.to_string_lossy().into_owned()),
602 token: Some("tok".to_string()),
603 agent_id: String::new(),
604 data_dir: None,
605 };
606 let result = tokio::time::timeout(
608 std::time::Duration::from_secs(5),
609 run_plugin_with::<DummyPlugin, _>(
610 args,
611 "1.0.0",
612 BTreeMap::new(),
613 std::future::pending(),
614 ),
615 )
616 .await
617 .expect("the runner must stop once the host session is lost");
618 host.await.expect("fake host");
619 assert!(
620 matches!(result, Err(RunnerError::SessionLost)),
621 "got {result:?}"
622 );
623 }
624}