coodev_runner/runner/
mod.rs

1mod command;
2mod docker;
3mod job;
4mod plugins;
5mod runner_builder;
6mod secret;
7mod sender;
8mod step;
9mod utils;
10mod volumes;
11mod workflow;
12mod workflow_runner;
13
14pub use self::{
15  job::{Job, JobContext},
16  plugins::{Plugin, PluginManager},
17  runner_builder::RunnerBuilder,
18  secret::{GlobalSecret, OrganizationSecret, RepositorySecret, Secret},
19  sender::{EventHandler, JobMessageSender, MessageSender, WorkflowMessageSender},
20  step::{Step, StepRunContext, StepSecret, StepVolume},
21  utils::{get_workflow_event_payload, should_skip_workflow},
22  volumes::{GlobalVolume, OrganizationVolume, RepositoryVolume, Volume},
23  workflow::Workflow,
24  workflow_runner::{WorkflowRunContext, WorkflowRunOptions, WorkflowRunner},
25};
26use crate::{
27  actions::{Action, Actions},
28  constants::COODEV_CONTAINER_IMAGE,
29  error,
30  user_config::{UserCommandStep, UserJob, UserStep, UserWorkflow},
31  WorkflowEvent, WorkflowMessage,
32};
33use derive_builder::Builder;
34use parking_lot::Mutex;
35use serde::{Deserialize, Serialize};
36use std::{collections::HashMap, env, path::PathBuf, sync::Arc};
37use tokio::sync::Semaphore;
38
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub enum GithubAuthorization {
41  PersonalAccessToken(String),
42  GithubApp { app_id: u64, private_key: String },
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
46#[serde(rename_all = "snake_case")]
47pub enum WorkflowRefType {
48  Branch,
49  Tag,
50}
51
52#[derive(Serialize, Deserialize, Debug, Clone, Builder)]
53pub struct CreateWorkflowOptions {
54  pub config: String,
55  pub event: WorkflowEvent,
56}
57
58pub struct RunnerInner {
59  // internal
60  actions: Actions,
61  secrets: HashMap<String, Secret>,
62  volumes: HashMap<String, Volume>,
63  plugin_manager: PluginManager,
64  workflow_message_sender: MessageSender,
65}
66
67#[derive(Clone)]
68pub struct Runner {
69  github_authorization: GithubAuthorization,
70  // optional
71  working_dir: Option<PathBuf>,
72  inner: Arc<Mutex<RunnerInner>>,
73  parallel_semaphore: Arc<Semaphore>,
74}
75
76impl Runner {
77  pub fn builder() -> RunnerBuilder {
78    RunnerBuilder::default()
79  }
80
81  pub fn register_action<T>(&self, name: impl Into<String>, action: T) -> &Self
82  where
83    T: Action + 'static,
84  {
85    self
86      .inner
87      .lock()
88      .actions
89      .register(name.into(), Box::new(action) as Box<dyn Action>);
90
91    self
92  }
93
94  pub fn register_secret(&self, secret: Secret) -> &Self {
95    match secret.clone() {
96      Secret::Repository(RepositorySecret {
97        key, repository, ..
98      }) => {
99        self
100          .inner
101          .lock()
102          .secrets
103          .insert(format!("{}@{}", key.to_uppercase(), repository), secret);
104      }
105      Secret::Organization(OrganizationSecret {
106        key, organization, ..
107      }) => {
108        self
109          .inner
110          .lock()
111          .secrets
112          .insert(format!("{}@{}", key.to_uppercase(), organization), secret);
113      }
114      Secret::Global(GlobalSecret { key, .. }) => {
115        self.inner.lock().secrets.insert(key.to_uppercase(), secret);
116      }
117    }
118
119    self
120  }
121
122  pub fn register_volume(&self, volume: Volume) -> &Self {
123    match volume.clone() {
124      Volume::Repository(RepositoryVolume {
125        key, repository, ..
126      }) => {
127        self
128          .inner
129          .lock()
130          .volumes
131          .insert(format!("{}@{}", key.to_uppercase(), repository), volume);
132      }
133      Volume::Organization(OrganizationVolume {
134        key, organization, ..
135      }) => {
136        self
137          .inner
138          .lock()
139          .volumes
140          .insert(format!("{}@{}", key.to_uppercase(), organization), volume);
141      }
142      Volume::Global(GlobalVolume { key, .. }) => {
143        self.inner.lock().volumes.insert(key.to_uppercase(), volume);
144      }
145    }
146
147    self
148  }
149
150  pub fn register_plugin<T>(&self, plugin: T) -> &Self
151  where
152    T: Plugin + 'static,
153  {
154    plugin.on_init(self);
155
156    self
157      .inner
158      .lock()
159      .plugin_manager
160      .register(Box::new(plugin) as Box<dyn Plugin>);
161
162    self
163  }
164
165  pub fn on_event<F>(&self, event_handler: F) -> &Self
166  where
167    F: Fn(&WorkflowMessage) + Send + Sync + 'static,
168  {
169    self
170      .inner
171      .lock()
172      .workflow_message_sender
173      .register(Box::new(event_handler) as EventHandler);
174
175    self
176  }
177
178  pub fn create_workflow_runner(
179    &self,
180    options: CreateWorkflowOptions,
181  ) -> crate::Result<WorkflowRunner> {
182    let event = options.event.clone();
183    let workflow = self.parse_workflow(options)?;
184    let workflow_runner = self.create_workflow_runner_from_normalized_workflow(workflow, event)?;
185
186    Ok(workflow_runner)
187  }
188
189  pub fn create_workflow_runner_from_normalized_workflow(
190    &self,
191    workflow: Workflow,
192    event: WorkflowEvent,
193  ) -> crate::Result<WorkflowRunner> {
194    let runner_working_dir = match self.working_dir {
195      Some(ref path) => path.clone(),
196      #[allow(deprecated)]
197      None => env::home_dir()
198        .ok_or(
199          error::Error::workflow_config_error(
200            "Failed to get working directory. Please set working directory by Runner::builder().working_dir()"
201          )
202        )?
203        .join("coodev-runner"),
204    };
205
206    let workflow_runner = WorkflowRunner::builder()
207      .workflow(workflow)
208      .workflow_semaphore(self.parallel_semaphore.clone())
209      .workflow_message_sender(self.inner.lock().workflow_message_sender.clone())
210      .runner_working_dir(runner_working_dir)
211      .github_authorization(self.github_authorization.clone())
212      .runner_inner(self.inner.clone())
213      .event(event)
214      .build()
215      .map_err(|err| error::Error::internal_runtime_error(err.to_string()))?;
216
217    self
218      .inner
219      .lock()
220      .plugin_manager
221      .on_workflow_runner_init(&workflow_runner);
222
223    Ok(workflow_runner)
224  }
225
226  pub fn parse_workflow(&self, options: CreateWorkflowOptions) -> crate::Result<Workflow> {
227    let workflow = UserWorkflow::from_str(&options.config)?;
228    let mut jobs = HashMap::new();
229    for (job_id, job) in workflow.jobs {
230      let normalized_job = self.normalize_job(job, &options)?;
231
232      jobs.insert(job_id, normalized_job);
233    }
234
235    let workflow = Workflow {
236      name: workflow.name,
237      on: workflow.on,
238      jobs,
239    };
240
241    Ok(workflow)
242  }
243
244  fn normalize_job(&self, job: UserJob, options: &CreateWorkflowOptions) -> crate::Result<Job> {
245    let image = job.image.unwrap_or(COODEV_CONTAINER_IMAGE.to_string());
246    // TODO: validate working directories cannot be duplicated and override `/home/runner/work/{repository}`
247    // let working_dir = job.working_dir.unwrap_or("/home/runner/work".to_string());
248    let steps = self.normalize_steps(job.steps, image.clone(), &options)?;
249    Ok(Job {
250      name: job.name,
251      image,
252      steps,
253      depends_on: job.depends_on,
254      working_dirs: job.working_dirs,
255    })
256  }
257
258  fn normalize_steps(
259    &self,
260    steps: Vec<UserStep>,
261    job_image: String,
262    options: &CreateWorkflowOptions,
263  ) -> crate::Result<Vec<Step>> {
264    let event_payload = get_workflow_event_payload(options.event.clone())?;
265    let mut normalized_steps = vec![];
266
267    let steps = self.normalize_user_steps(steps)?;
268    for step in steps.iter() {
269      if let UserStep::Command(command_step) = step.clone() {
270        let UserCommandStep {
271          name,
272          image,
273          run,
274          continue_on_error,
275          environments,
276          volumes,
277          secrets,
278          timeout,
279          security_opts,
280        } = command_step;
281        let runner_dir = "/home/runner".to_string();
282        let cache_dir = "/home/runner/cache".to_string();
283
284        let inner_state = self.inner.lock();
285
286        // Normalize volumes
287        let mut step_volumes: Vec<StepVolume> = vec![];
288        if let Some(volumes) = volumes {
289          for volume in volumes {
290            let idx = volume.find(':').ok_or(error::Error::workflow_config_error(
291              "Invalid volume format. The format should be `volume_key:to`",
292            ))?;
293            let (volume_key, volume_to) = volume.split_at(idx);
294
295            let uppercase_key = volume_key.to_uppercase();
296            let repository = format!("{}/{}", event_payload.repo_owner, event_payload.repo_name);
297            let repo_volume_key = format!("{}@{}", uppercase_key, repository);
298            if inner_state.volumes.contains_key(&repo_volume_key) {
299              step_volumes.push(StepVolume {
300                key: repo_volume_key,
301                to: volume_to[1..].to_string(),
302              });
303              continue;
304            }
305
306            let org_volume_key = format!("{}@{}", uppercase_key, event_payload.repo_owner);
307            if inner_state.volumes.contains_key(&org_volume_key) {
308              step_volumes.push(StepVolume {
309                key: org_volume_key,
310                to: volume_to[1..].to_string(),
311              });
312              continue;
313            }
314
315            if inner_state.volumes.contains_key(&uppercase_key) {
316              step_volumes.push(StepVolume {
317                key: uppercase_key,
318                to: volume_to[1..].to_string(),
319              });
320              continue;
321            }
322
323            return Err(error::Error::workflow_config_error(format!(
324              "Volume `{}` is not defined.",
325              volume_key
326            )));
327          }
328        }
329
330        let mut step_secrets: Vec<StepSecret> = vec![];
331        if let Some(secret_keys) = secrets {
332          for secret_key in secret_keys {
333            let uppercase_key = secret_key.to_uppercase();
334            let repository = format!("{}/{}", event_payload.repo_owner, event_payload.repo_name);
335            let repo_secret_key = format!("{}@{}", uppercase_key, repository);
336            if inner_state.secrets.contains_key(&repo_secret_key) {
337              step_secrets.push(StepSecret {
338                key: repo_secret_key,
339              });
340              continue;
341            }
342
343            let org_secret_key = format!("{}@{}", uppercase_key, event_payload.repo_owner);
344            if inner_state.secrets.contains_key(&org_secret_key) {
345              step_secrets.push(StepSecret {
346                key: org_secret_key,
347              });
348              continue;
349            }
350
351            if inner_state.secrets.contains_key(&uppercase_key) {
352              step_secrets.push(StepSecret { key: uppercase_key });
353              continue;
354            }
355
356            return Err(error::Error::workflow_config_error(format!(
357              "Secret `{}` is not defined.",
358              secret_key
359            )));
360          }
361        }
362
363        let timeout = timeout.unwrap_or("60m".to_string());
364        let timeout = humantime::parse_duration(&timeout).map_err(|err| {
365          log::error!("Invalid timeout format: {}", err);
366          error::Error::workflow_config_error(
367            "Invalid timeout format. The format should like `60m` or `1h`.",
368          )
369        })?;
370
371        let normalized_step = Step {
372          name,
373          image: image.unwrap_or(job_image.clone()),
374          run,
375          continue_on_error: continue_on_error.unwrap_or(false),
376          environments,
377          runner_dir,
378          working_dir: format!("/home/runner/work/{}", event_payload.repo_name),
379          cache_dir,
380          volumes: step_volumes,
381          secrets: step_secrets,
382          timeout,
383          security_opts,
384        };
385        normalized_steps.push(normalized_step);
386      } else {
387        return Err(error::Error::unsupported_feature(
388          "Currently, only `command` step is supported.",
389        ));
390      }
391    }
392
393    Ok(normalized_steps)
394  }
395
396  fn normalize_user_steps(&self, user_steps: Vec<UserStep>) -> crate::Result<Vec<UserStep>> {
397    let mut pre_steps = vec![];
398    let mut steps = vec![];
399    let mut post_steps = vec![];
400
401    for step in user_steps {
402      if let UserStep::Action(user_action_step) = &step {
403        let action_name = &user_action_step.uses;
404        let action = self.inner.lock().actions.get(action_name).ok_or_else(|| {
405          error::Error::workflow_config_error(format!("Action `{}` is not found", action_name))
406        })?;
407
408        let action_steps = action.normalize(user_action_step.clone())?;
409        if let Some(pre) = action_steps.pre {
410          pre_steps.push(pre);
411        }
412
413        if let Some(post) = action_steps.post {
414          post_steps.insert(0, post)
415        }
416
417        steps.push(action_steps.run);
418        continue;
419      }
420
421      steps.push(step.clone());
422    }
423
424    let steps: Vec<UserStep> = vec![]
425      .into_iter()
426      .chain(pre_steps.into_iter())
427      .chain(steps.into_iter())
428      .chain(post_steps.into_iter())
429      .collect();
430
431    Ok(steps)
432  }
433}
434
435#[cfg(test)]
436mod tests {
437  use crate::{
438    runner::{plugins::Plugin, GithubAuthorization, Runner, Secret, Volume, WorkflowRunOptions},
439    utils::test::{
440      create_runner, create_workflow_options, create_workflow_runner, enable_logger,
441      get_default_run_options, load_config, run_workflow,
442    },
443    WorkflowState,
444  };
445  use std::path::PathBuf;
446  use tokio::fs;
447
448  #[tokio::test]
449  #[ignore]
450  async fn test() -> anyhow::Result<()> {
451    let res = run_workflow(
452      r#"
453      name: Test workflow
454      on: 
455        push:
456          branches:
457            - master
458      jobs:
459        test:
460          image: ubuntu
461          steps:
462            - name: Test step
463              run: echo "Hello world"
464        test2:
465          image: ubuntu
466          steps:
467            - name: Test step
468              environments:
469                TEST: "test"
470              run: |
471                pwd
472                echo "Hello world2 ${TEST}"
473      "#,
474    )
475    .await?;
476
477    assert_eq!(res.state, WorkflowState::Succeeded);
478
479    Ok(())
480  }
481
482  #[tokio::test]
483  #[ignore]
484  async fn test_working_directories() -> anyhow::Result<()> {
485    let res = run_workflow(
486      r#"
487      jobs:
488        test:
489          working-directories:
490          - /root/.cache
491          steps:
492            - name: Test create file
493              run: |
494                mkdir -p /root/.cache
495                echo "Hello world" > /root/.cache/test.txt
496            - name: Test step
497              run: |
498                contents=$(cat /root/.cache/test.txt)
499                if [ "$contents" != "Hello world" ]; then
500                  exit 1
501                fi
502                echo "Contents: $contents"
503      "#,
504    )
505    .await?;
506
507    assert_eq!(res.state, WorkflowState::Succeeded);
508
509    Ok(())
510  }
511
512  #[tokio::test]
513  #[ignore]
514  async fn test_timeout() -> anyhow::Result<()> {
515    let res = run_workflow(
516      r#"
517      jobs:
518        test:
519          steps:
520            - name: Timeout step
521              timeout: 5s
522              run: sleep 1m
523      "#,
524    )
525    .await?;
526
527    assert_eq!(res.state, WorkflowState::Cancelled);
528
529    Ok(())
530  }
531
532  #[tokio::test]
533  #[ignore]
534  async fn test_continue_on_error() -> anyhow::Result<()> {
535    let res = run_workflow(
536      r#"
537      jobs:
538        test:
539          image: ubuntu
540          steps:
541            - name: Throw error
542              run: exit 1
543            - name: Test step
544              allow-failure: true
545              run: echo "\e[31mHello world\e[0m"
546      "#,
547    )
548    .await?;
549
550    assert_eq!(res.state, WorkflowState::Failed);
551
552    Ok(())
553  }
554
555  #[tokio::test]
556  #[ignore]
557  async fn test_buildkit() -> anyhow::Result<()> {
558    let res = run_workflow(
559      r#"
560      jobs:
561        test:
562          image: ubuntu
563          steps:
564            - name: Build Docker image
565              image: moby/buildkit:rootless
566              environments:
567                BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
568              security-opts:
569                - seccomp=unconfined
570                - apparmor=unconfined
571              run: |
572                echo "FROM alpine" > Dockerfile
573
574                buildctl-daemonless.sh build \
575                  --frontend dockerfile.v0 \
576                  --local context=. \
577                  --local dockerfile=. \
578                  --output type=image
579      "#,
580    )
581    .await?;
582
583    assert_eq!(res.state, WorkflowState::Succeeded);
584
585    Ok(())
586  }
587
588  #[tokio::test]
589  #[ignore]
590  async fn test_secret() -> anyhow::Result<()> {
591    let runner = create_runner()?;
592
593    runner
594      .register_secret(
595        Secret::new("TEST_SECRET")
596          .value("test_secret_value")
597          .build()?,
598      )
599      .register_secret(
600        Secret::new("lowercase_secret")
601          .value("test_lowercase_secret_value")
602          .owner("panghu-huang")
603          .repository("octocrate")
604          .build()?,
605      )
606      .register_secret(
607        Secret::new("owner_lowercase_secret")
608          .value("test_owner_lowercase_secret_value")
609          .owner("panghu-huang")
610          .build()?,
611      );
612
613    let workflow_runner = runner.create_workflow_runner(create_workflow_options(
614      r#"
615      name: Test workflow
616      on: 
617        push:
618          branches:
619            - master
620
621      jobs:
622        test:
623          image: ubuntu
624          steps:
625            - name: Test step
626              secrets:
627                - TEST_SECRET
628                - lowercase_secret
629                - owner_lowercase_secret
630              run: |
631                echo "TEST_SECRET is $TEST_SECRET"
632                echo "LOWERCASE_SECRET is $LOWERCASE_SECRET"
633                echo "OWNER_LOWERCASE_SECRET is $OWNER_LOWERCASE_SECRET"
634      "#,
635    ))?;
636
637    let res = workflow_runner.run(get_default_run_options()).await?;
638
639    assert_eq!(res.state, WorkflowState::Succeeded);
640
641    Ok(())
642  }
643
644  #[tokio::test]
645  async fn test_out_of_scope_secret() -> anyhow::Result<()> {
646    let runner = create_runner()?;
647
648    runner
649      .register_secret(
650        Secret::new("lowercase_secret")
651          .value("global_secret")
652          .build()?,
653      )
654      .register_secret(
655        Secret::new("lowercase_secret")
656          .value("test_lowercase_secret_value")
657          .owner("panghu-huang")
658          .repository("github-api")
659          .build()?,
660      )
661      .register_secret(
662        Secret::new("owner_lowercase_secret")
663          .value("test_owner_lowercase_secret_value")
664          .owner("coodevjs")
665          .build()?,
666      );
667
668    let res = runner.create_workflow_runner(create_workflow_options(
669      r#"
670      name: Test workflow
671      jobs:
672        test:
673          image: ubuntu
674          steps:
675            - name: Test step
676              secrets:
677                - lowercase_secret
678                - owner_lowercase_secret
679              run: |
680                echo "LOWERCASE_SECRET is $LOWERCASE_SECRET"
681                echo "OWNER_LOWERCASE_SECRET is $OWNER_LOWERCASE_SECRET"
682      "#,
683    ));
684
685    match res {
686      Err(err) => assert_eq!(
687        err.to_string(),
688        "Failed to parse user config: Secret `owner_lowercase_secret` is not defined."
689      ),
690      _ => assert!(false),
691    }
692
693    Ok(())
694  }
695
696  #[tokio::test]
697  #[ignore]
698  async fn test_volume() -> anyhow::Result<()> {
699    let runner = create_runner()?;
700    #[allow(deprecated)]
701    let home_dir = std::env::home_dir().unwrap();
702    let filepath = PathBuf::from(home_dir).join("test.txt");
703    let filepath = filepath.to_str().ok_or(anyhow::anyhow!(
704      "Failed to convert path to string: {:?}",
705      filepath
706    ))?;
707
708    fs::write(filepath, "test volume content").await?;
709
710    runner.register_volume(
711      Volume::new("test_volume")
712        .path(filepath)
713        .owner("panghu-huang")
714        .repository("octocrate")
715        .build()?,
716    );
717
718    let workflow_runner = runner.create_workflow_runner(create_workflow_options(
719      r#"
720      name: Test workflow
721      jobs:
722        test:
723          image: ubuntu
724          steps:
725            - name: Test step
726              volumes:
727                - test_volume:/home/runner/work/octocrate/test.txt
728              run: |
729                echo "Before"
730                pwd
731                ls
732                echo "File content: $(cat ./test.txt)"
733                echo "After"
734      "#,
735    ))?;
736
737    let res = workflow_runner.run(get_default_run_options()).await?;
738
739    fs::remove_file(filepath).await?;
740
741    assert_eq!(res.state, WorkflowState::Succeeded);
742
743    Ok(())
744  }
745
746  #[tokio::test]
747  #[ignore]
748  async fn depends_on() -> anyhow::Result<()> {
749    let res = run_workflow(
750      r#"
751      name: Test workflow
752      jobs:
753        test:
754          image: ubuntu
755          steps:
756            - name: Test step
757              run: |
758                sleep 5s
759                echo "Hello world"
760        test2:
761          image: ubuntu
762          steps:
763            - name: Test step
764              environments:
765                TEST: "test"
766              run: echo "Hello world2 ${TEST}"
767        test3:
768          image: ubuntu
769          depends-on:
770            - test
771            - test2
772          steps:
773            - name: Test step
774              environments:
775                TEST: "test"
776              run: echo "Hello world3 ${TEST}"
777      "#,
778    )
779    .await?;
780    assert_eq!(res.state, WorkflowState::Succeeded);
781
782    Ok(())
783  }
784
785  #[tokio::test]
786  #[ignore]
787  async fn cancel() -> anyhow::Result<()> {
788    let workflow_runner = create_workflow_runner(
789      r#"
790      name: Test workflow
791      jobs:
792        test:
793          image: ubuntu
794          steps:
795            - name: Test step
796              run: |
797                sleep 5s
798                echo "Hello world"
799        test2:
800          image: ubuntu
801          steps:
802            - name: Test step
803              environments:
804                TEST: "test"
805              run: |
806                sleep 5s
807                echo "Hello world2 ${TEST}"
808      "#,
809    )?;
810
811    let cloned = workflow_runner.clone();
812    let handle = tokio::spawn(async move {
813      tokio::time::sleep(std::time::Duration::from_secs(5)).await;
814      cloned.cancel().await.unwrap();
815      log::info!("Workflow cancelled");
816    });
817    let res = workflow_runner.run(get_default_run_options()).await?;
818
819    handle.await?;
820
821    assert_eq!(res.state, WorkflowState::Cancelled);
822
823    Ok(())
824  }
825
826  #[tokio::test]
827  #[ignore]
828  async fn queue() -> anyhow::Result<()> {
829    enable_logger();
830    let config = load_config()?;
831    let github_authorization = GithubAuthorization::GithubApp {
832      app_id: config.github_app_id,
833      private_key: config.github_app_private_key,
834    };
835    let runner = Runner::builder()
836      .github_authorization(github_authorization)
837      .parallel_size(3 as usize)
838      .build()?;
839
840    let mut handles = vec![];
841
842    for idx in 0..10 {
843      let sleep = 10 - idx;
844      let workflow_runner = runner.create_workflow_runner(create_workflow_options(format!(
845        r#"
846          name: Test workflow
847          
848          jobs:
849            test:
850              image: ubuntu
851              steps:
852                - name: Test step
853                  run: |
854                    echo "Running {idx}"
855                    sleep {sleep}s
856                    echo "Finished {idx}"
857          "#
858      )))?;
859
860      let handle = tokio::spawn(async move {
861        let options = WorkflowRunOptions {
862          run_id: idx.to_string(),
863          environments: None,
864        };
865        workflow_runner.run(options).await.unwrap()
866      });
867
868      handles.push(handle);
869    }
870
871    for handle in handles {
872      let res = handle.await?;
873      assert_eq!(res.state, WorkflowState::Succeeded);
874    }
875
876    Ok(())
877  }
878
879  #[tokio::test]
880  #[ignore]
881  async fn test_plugin() -> anyhow::Result<()> {
882    enable_logger();
883    let runner = create_runner()?;
884
885    struct TestPlugin;
886
887    impl Plugin for TestPlugin {
888      fn on_init(&self, _runner: &Runner) {
889        log::info!("TestPlugin init");
890      }
891    }
892
893    runner.register_plugin(TestPlugin);
894
895    Ok(())
896  }
897
898  #[tokio::test]
899  #[ignore]
900  async fn test_on_event() -> anyhow::Result<()> {
901    enable_logger();
902    let runner = create_runner()?;
903
904    runner.on_event(|event| {
905      log::info!("Event: {:?}", event);
906    });
907
908    let workflow_runner = runner.create_workflow_runner(create_workflow_options(format!(
909      r#"
910        name: Test workflow
911        
912        jobs:
913          test:
914            image: ubuntu
915            steps:
916              - name: Test step
917                run: |
918                  echo "Running"
919        "#
920    )))?;
921
922    let _res = workflow_runner.run(get_default_run_options()).await?;
923
924    Ok(())
925  }
926}