1use std::path::Path;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::sync::Mutex;
7
8use anyhow::Context;
9use anyhow::Result;
10use anyhow::anyhow;
11use anyhow::bail;
12use async_trait::async_trait;
13use bollard::models::HostConfig;
14use bollard::models::LocalNodeState;
15use bollard::models::Mount;
16use bollard::models::MountType;
17use bollard::models::NodeSpecAvailabilityEnum;
18use bollard::models::NodeState;
19use crankshaft_config::backend::docker::Config;
20use crankshaft_docker::Container;
21use crankshaft_docker::Docker;
22use crankshaft_docker::EventOptions;
23use crankshaft_docker::service::Service;
24use crankshaft_events::Event;
25use crankshaft_events::TaskId;
26use crankshaft_events::next_task_id;
27use crankshaft_events::send_event;
28use futures::FutureExt;
29use futures::future::BoxFuture;
30use nonempty::NonEmpty;
31use tempfile::TempDir;
32use tokio::select;
33use tokio::sync::broadcast;
34use tokio_util::sync::CancellationToken;
35use tracing::debug;
36use tracing::info;
37
38use super::TaskRunError;
39use crate::Task;
40use crate::service::name::GeneratorIterator;
41use crate::service::name::UniqueAlphanumeric;
42use crate::task::Execution;
43use crate::task::ExecutionResult;
44use crate::task::Input;
45
46impl From<crankshaft_docker::container::ExecutionResult> for ExecutionResult {
47 fn from(execution_result: crankshaft_docker::container::ExecutionResult) -> Self {
48 Self {
49 image: Some(execution_result.image),
50 status: execution_result.status,
51 }
52 }
53}
54
55#[derive(Debug, Default, Clone, Copy)]
57pub struct SwarmResources {
58 pub nodes: usize,
60 pub cpu: u64,
62 pub memory: u64,
64 pub max_cpu: u64,
66 pub max_memory: u64,
68}
69
70#[derive(Debug, Default, Clone, Copy)]
72pub struct LocalResources {
73 pub cpu: u64,
75 pub memory: u64,
77}
78
79#[derive(Debug, Clone, Copy)]
81pub enum Resources {
82 Local(LocalResources),
84 Swarm(SwarmResources),
86}
87
88impl Resources {
89 pub fn nodes(&self) -> usize {
91 match self {
92 Self::Local(_) => 1,
93 Self::Swarm(resources) => resources.nodes,
94 }
95 }
96
97 pub fn cpu(&self) -> u64 {
99 match self {
100 Self::Local(resources) => resources.cpu,
101 Self::Swarm(resources) => resources.cpu,
102 }
103 }
104
105 pub fn memory(&self) -> u64 {
107 match self {
108 Self::Local(resources) => resources.memory,
109 Self::Swarm(resources) => resources.memory,
110 }
111 }
112
113 pub fn max_cpu(&self) -> u64 {
115 match self {
116 Self::Local(resources) => resources.cpu,
117 Self::Swarm(resources) => resources.max_cpu,
118 }
119 }
120
121 pub fn max_memory(&self) -> u64 {
123 match self {
124 Self::Local(resources) => resources.memory,
125 Self::Swarm(resources) => resources.max_memory,
126 }
127 }
128
129 pub fn use_service(&self) -> bool {
137 match self {
138 Self::Local(_) => false,
139 Self::Swarm(_) => true,
140 }
141 }
142}
143
144#[derive(Debug)]
146pub struct Backend {
147 client: Docker,
149 config: Config,
151 resources: Resources,
153 names: Arc<Mutex<GeneratorIterator<UniqueAlphanumeric>>>,
155}
156
157impl Backend {
158 pub async fn initialize_default_with(
164 config: Config,
165 names: Arc<Mutex<GeneratorIterator<UniqueAlphanumeric>>>,
166 ) -> Result<Self> {
167 let client =
168 Docker::with_defaults().context("failed to connect to the local Docker daemon")?;
169
170 let info = client
171 .info()
172 .await
173 .context("failed to retrieve local Docker daemon information")?;
174
175 let swarm = if let Some(swarm) = &info.swarm {
179 match (&swarm.node_id, swarm.local_node_state) {
180 (Some(id), Some(LocalNodeState::ACTIVE)) if !id.is_empty() => {
181 if !swarm.control_available.unwrap_or(false) {
184 bail!(
185 "the local Docker daemon is part of a swarm but cannot be used to \
186 create tasks (the node is not a manager)"
187 );
188 }
189
190 let nodes = client
193 .nodes()
194 .await
195 .context("failed to retrieve Docker swarm node list")?;
196 let mut swarm = SwarmResources::default();
197 for node in nodes.iter().filter(|n| {
198 n.description
199 .as_ref()
200 .map(|d| d.resources.is_some())
201 .unwrap_or(false)
202 && n.spec
203 .as_ref()
204 .map(|s| s.availability == Some(NodeSpecAvailabilityEnum::ACTIVE))
205 .unwrap_or(false)
206 && n.status
207 .as_ref()
208 .map(|s| s.state == Some(NodeState::READY))
209 .unwrap_or(false)
210 }) {
211 swarm.nodes += 1;
212
213 let resources = node
214 .description
215 .as_ref()
216 .unwrap()
217 .resources
218 .as_ref()
219 .unwrap();
220
221 let node_cpu: u64 = resources
222 .nano_cpus
223 .map(|n| n / 1_000_000_000)
224 .context("Docker daemon reported an active node with no CPUs")?
225 .try_into()
226 .context("node CPU count is negative")?;
227 swarm.cpu += node_cpu;
228 swarm.max_cpu = swarm.max_cpu.max(node_cpu);
229
230 let node_memory: u64 = resources
231 .memory_bytes
232 .context("Docker daemon reported an active node with no memory")?
233 .try_into()
234 .context("node memory is negative")?;
235 swarm.memory += node_memory;
236 swarm.max_memory = swarm.max_memory.max(node_memory);
237
238 debug!(
239 id = node
240 .id
241 .as_ref()
242 .context("Docker daemon reported a node without an identifier")?,
243 total_cpu = node_cpu,
244 total_memory = node_memory,
245 "found Docker swarm node"
246 );
247 }
248
249 if swarm.nodes == 0 {
250 bail!(
251 "the local Docker daemon is part of a swarm but there are no active \
252 and ready nodes"
253 );
254 }
255
256 Some(swarm)
257 }
258 (Some(id), _) if !id.is_empty() => {
259 bail!(
260 "the local Docker daemon is part of a swarm but the node state is not \
261 active"
262 );
263 }
264 _ => {
265 None
267 }
268 }
269 } else {
270 None
271 };
272
273 let resources = match swarm {
274 Some(swarm) => {
275 info!(
276 nodes = swarm.nodes,
277 cpu = swarm.cpu,
278 memory = swarm.memory,
279 max_cpu = swarm.max_cpu,
280 max_memory = swarm.max_memory,
281 "Docker backend is interacting with a swarm"
282 );
283
284 Resources::Swarm(swarm)
285 }
286 None => {
287 let cpu = info
288 .ncpu
289 .map(|n| {
290 n.try_into()
291 .context("Docker daemon reported a negative CPU count")
292 })
293 .transpose()?
294 .context("Docker daemon did not report a CPU count")?;
295 let memory = info
296 .mem_total
297 .map(|n| {
298 n.try_into()
299 .context("Docker daemon reported a negative total memory")
300 })
301 .transpose()?
302 .context("Docker daemon did not report a memory total")?;
303 info!(
304 cpu,
305 memory, "Docker backend is interacting with a local Docker daemon"
306 );
307
308 Resources::Local(LocalResources { cpu, memory })
309 }
310 };
311
312 Ok(Self {
313 client,
314 config,
315 resources,
316 names,
317 })
318 }
319
320 pub async fn initialize_default(
326 names: Arc<Mutex<GeneratorIterator<UniqueAlphanumeric>>>,
327 ) -> Result<Self> {
328 Self::initialize_default_with(Config::default(), names).await
329 }
330
331 pub fn client(&self) -> &Docker {
333 &self.client
334 }
335
336 pub fn resources(&self) -> &Resources {
338 &self.resources
339 }
340}
341
342enum Cleanup {
344 Container(Arc<Container>),
346 Service(Arc<Service>),
348}
349
350impl Cleanup {
351 async fn run(&self, canceled: bool) -> Result<()> {
353 match self {
354 Self::Container(container) => {
355 if canceled {
356 container
357 .force_remove()
358 .await
359 .context("failed to force remove container")
360 } else {
361 container
362 .remove()
363 .await
364 .context("failed to remove container")
365 }
366 }
367 Self::Service(service) => service.delete().await.context("failed to delete service"),
368 }
369 }
370}
371
372async fn find_candidate_image(
374 client: &Docker,
375 execution: &Execution,
376 token: CancellationToken,
377 events: Option<broadcast::Sender<Event>>,
378 task_id: TaskId,
379) -> Result<String, TaskRunError> {
380 let total_images = execution.images().len();
381 let events = events.map(|e| (e, task_id));
382
383 for (idx, try_image) in execution.images().iter().cloned().enumerate() {
384 match client
385 .ensure_image(&try_image, token.clone(), events.clone())
386 .await
387 .with_context(|| format!("failed to pull image `{try_image}`"))
388 {
389 Ok(Some(())) => {
390 return Ok(try_image);
391 }
392 Ok(None) => return Err(TaskRunError::Canceled),
393 Err(e) => {
394 if idx == total_images - 1 {
395 return Err(TaskRunError::from(e));
396 }
397
398 continue;
399 }
400 }
401 }
402
403 unreachable!("there should always be at least one image available")
404}
405
406#[async_trait]
407impl crate::Backend for Backend {
408 fn default_name(&self) -> &'static str {
409 "docker"
410 }
411
412 fn run(
413 &self,
414 task: Task,
415 events: Option<broadcast::Sender<Event>>,
416 token: CancellationToken,
417 ) -> Result<BoxFuture<'static, Result<NonEmpty<ExecutionResult>, TaskRunError>>> {
418 let task_id = next_task_id();
419 let client = self.client.clone();
420 let run_cleanup = self.config.cleanup();
421 let events_config = self.config.events();
422 let use_service = self.resources.use_service();
423 let names = self.names.clone();
424
425 let task_token = CancellationToken::new();
426
427 Ok(async move {
428 let task_name = task.name.unwrap_or_else(|| {
430 let mut generator = names.lock().unwrap();
431 generator.next().unwrap()
433 });
434
435 let run = async {
436 let tempdir = TempDir::new().context("failed to create temporary directory for mounts")?;
437
438 let mut mounts = Vec::new();
439 add_input_mounts(task.inputs, tempdir.path(), &mut mounts).await?;
440 add_shared_mounts(task.volumes, tempdir.path(), &mut mounts)?;
441 let mut outputs = Vec::new();
442
443 for (i, execution) in task.executions.into_iter().enumerate() {
444 if token.is_cancelled() {
445 return Err(TaskRunError::Canceled);
446 }
447
448 let image = find_candidate_image(&client, &execution, token.clone(), events.clone(), task_id).await?;
450
451 let stdout = execution.stdout.as_ref().and_then(|p| {
453 let url = task.outputs.iter().find_map(|o| if o.path == *p {
454 Some(&o.url)
455 } else {
456 None
457 })?;
458
459 match url.scheme() {
460 "file" => {
461 Some(url.to_file_path().map_err(|_| {
462 anyhow!(
463 "stdout URL `{url}` has a file scheme but cannot be represented as a file path"
464 )
465 }))
466 }
467 _ => Some(Err(anyhow!("unsupported scheme for stdout URL `{url}`")))
468 }
469
470 }).transpose()?;
471
472 let stderr = execution.stderr.as_ref().and_then(|p| {
474 let url = task.outputs.iter().find_map(|o| if o.path == *p {
475 Some(&o.url)
476 } else {
477 None
478 })?;
479
480 match url.scheme() {
481 "file" => {
482 Some(url.to_file_path().map_err(|_| {
483 anyhow!(
484 "stderr URL `{url}` has a file scheme but cannot be represented as a file path"
485 )
486 }))
487 }
488 _ => Some(Err(anyhow!("unsupported scheme for stderr URL `{url}`")))
489 }
490
491 }).transpose()?;
492
493 let options = events.clone().map(|sender| EventOptions { sender, task_id, send_start: i == 0, user_config: events_config });
494 let attach_stdout = events.is_some() && events_config.send_stdout;
495 let attach_stderr = events.is_some() && events_config.send_stderr;
496
497 let name = {
499 let mut generator = names.lock().unwrap();
500 generator.next().unwrap()
502 };
503
504 let (result, cleanup) = if use_service {
506 let mut builder = client
507 .service_builder()
508 .name(&name)
509 .image(image)
510 .program(execution.program)
511 .args(execution.args)
512 .envs(execution.env)
513 .mounts(mounts.clone())
514 .resources(task.resources.as_ref().map(Into::into).unwrap_or_default());
515
516 if let Some(stdout) = stdout {
517 builder = builder.stdout(stdout);
518 }
519
520 if let Some(stderr) = stderr {
521 builder = builder.stderr(stderr);
522 }
523
524 if let Some(work_dir) = execution.work_dir {
525 builder = builder.work_dir(work_dir);
526 }
527
528 let service = Arc::new(builder.try_build().await.map_err(|e| TaskRunError::Other(e.into()))?);
529 info!("created service `{id}` (task `{task_name}`)", id = service.id());
530
531 select! {
532 biased;
534 _= task_token.cancelled() => {
535 (Err(TaskRunError::Canceled), Cleanup::Service(service))
536 }
537 _ = token.cancelled() => {
538 (Err(TaskRunError::Canceled), Cleanup::Service(service))
539 }
540 res = service.run(&task_name, options) => {
541 (res.context("failed to run Docker service").map_err(TaskRunError::Other), Cleanup::Service(service))
542 }
543 }
544 } else {
545 let mut builder = client
546 .container_builder()
547 .name(&name)
548 .image(image)
549 .program(execution.program)
550 .args(execution.args)
551 .envs(execution.env)
552 .attach_stdout(attach_stdout)
553 .attach_stderr(attach_stderr)
554 .host_config(HostConfig {
555 mounts: Some(mounts.clone()),
556 #[cfg(unix)]
558 group_add: Some(vec![nix::unistd::Gid::effective().to_string()]),
559 ..task.resources.as_ref().map(|r| r.into()).unwrap_or_default()
560 });
561
562 if let Some(stdout) = stdout {
563 builder = builder.stdout(stdout);
564 }
565
566 if let Some(stderr) = stderr {
567 builder = builder.stderr(stderr);
568 }
569
570 if let Some(work_dir) = execution.work_dir {
571 builder = builder.work_dir(work_dir);
572 }
573
574 let container = Arc::new(
575 builder
576 .try_build()
577 .await.map_err(|e| TaskRunError::Other(e.into()))?,
578 );
579
580 info!("created container `{name}` (task `{task_name}`)", name = container.name());
581
582 select! {
583 biased;
585 _ = task_token.cancelled() => {
586 (Err(TaskRunError::Canceled), Cleanup::Container(container))
587 }
588 _ = token.cancelled() => {
589 (Err(TaskRunError::Canceled), Cleanup::Container(container))
590 }
591 res = container.run(&task_name, options) => {
592 (res.context("failed to run Docker container").map_err(TaskRunError::Other), Cleanup::Container(container))
593 }
594 }
595 };
596
597 if run_cleanup {
598 let force_remove = matches!(result, Err(TaskRunError::Canceled));
599 cleanup.run(force_remove).await?;
600 }
601
602 outputs.push(result?);
603 }
604
605 Ok(NonEmpty::from_vec(outputs).unwrap())
608 };
609
610 send_event!(events, Event::TaskCreated { id: task_id, name: task_name.clone(), tes_id: None, token: task_token.clone() });
612
613 let result: Result<NonEmpty<ExecutionResult>, _> = run.await.map(|results| {
615 NonEmpty::collect(results.into_iter().map(Into::into)).unwrap()
617 });
618
619 match &result {
621 Ok(results) => send_event!(
622 events,
623 Event::TaskCompleted {
624 id: task_id,
625 exit_statuses: NonEmpty::collect(results.iter().map(|r| r.status)).unwrap(),
627 }
628 ),
629 Err(TaskRunError::Canceled) => send_event!(
630 events,
631 Event::TaskCanceled {
632 id: task_id
633 }
634 ),
635 Err(TaskRunError::Preempted) => send_event!(
636 events,
637 Event::TaskPreempted {
638 id: task_id
639 }
640 ),
641 Err(TaskRunError::Other(e)) => send_event!(
642 events,
643 Event::TaskFailed {
644 id: task_id,
645 message: format!("{e:#}")
646 }
647 ),
648 }
649
650 result
651 }
652 .boxed())
653 }
654}
655
656async fn add_input_mounts(
665 inputs: Vec<Input>,
666 temp_dir: &Path,
667 mounts: &mut Vec<Mount>,
668) -> Result<()> {
669 for input in inputs {
670 let target = input.path;
671 let source = input.contents.fetch(temp_dir).await?;
672
673 mounts.push(Mount {
674 target: Some(target),
675 source: Some(
676 source
677 .to_str()
678 .with_context(|| {
679 format!("path `{source}` is not UTF-8", source = source.display())
680 })?
681 .to_string(),
682 ),
683 typ: Some(MountType::BIND),
684 read_only: Some(input.read_only),
685 ..Default::default()
686 });
687 }
688
689 Ok(())
690}
691
692fn add_shared_mounts(volumes: Vec<String>, tempdir: &Path, mounts: &mut Vec<Mount>) -> Result<()> {
695 for volume in volumes {
696 let path = TempDir::new_in(tempdir)
700 .with_context(|| {
701 format!(
702 "failed to create temporary directory in `{tempdir}`",
703 tempdir = tempdir.display()
704 )
705 })?
706 .keep()
707 .into_os_string()
708 .into_string()
709 .map_err(|path| {
710 anyhow!(
711 "temporary directory path `{path}` is not UTF-8",
712 path = PathBuf::from(&path).display()
713 )
714 })?;
715
716 mounts.push(Mount {
717 target: Some(volume),
718 source: Some(path),
719 typ: Some(MountType::BIND),
720 read_only: Some(false),
721 ..Default::default()
722 });
723 }
724
725 Ok(())
726}
727
728#[cfg(test)]
729#[cfg(target_os = "linux")]
730mod test {
731 use std::assert_matches;
732 use std::fs;
733
734 use anyhow::Context;
735 use futures::future::join_all;
736 use nix::unistd::Gid;
737 use tempfile::NamedTempFile;
738 use url::Url;
739
740 use super::*;
741 use crate::service::runner::Backend as _;
742 use crate::service::runner::NAME_BUFFER_LEN;
743 use crate::task::Execution;
744 use crate::task::Output;
745 use crate::task::output::Type;
746
747 async fn events(mut rx: broadcast::Receiver<Event>) -> Vec<Event> {
748 let mut events = Vec::new();
749 while let Ok(event) = rx.recv().await {
750 events.push(event);
751 }
752
753 events
754 }
755
756 async fn create_backend(config: Config) -> Result<Backend> {
757 let names = Arc::new(Mutex::new(GeneratorIterator::new(
758 UniqueAlphanumeric::default_with_expected_generations(NAME_BUFFER_LEN),
759 NAME_BUFFER_LEN,
760 )));
761
762 Backend::initialize_default_with(config, names)
763 .await
764 .context("failed to create backend")
765 }
766
767 #[tokio::test]
768 async fn backend_adds_user_egid() -> anyhow::Result<()> {
769 let backend = create_backend(Config::default()).await?;
770
771 let gid = Gid::effective();
773
774 let stdout_path = NamedTempFile::new()
775 .context("failed to create temporary file")?
776 .into_temp_path();
777
778 let results = backend
780 .run(
781 Task::builder()
782 .executions(NonEmpty::new(
783 Execution::builder()
784 .images(["ubuntu:latest"])?
785 .program("/bin/sh")
786 .args([String::from("-c"), String::from("/usr/bin/id -G")])
787 .stdout("/mnt/stdout")
788 .build(),
789 ))
790 .outputs(vec![
791 Output::builder()
792 .ty(Type::File)
793 .path("/mnt/stdout")
794 .url(
795 Url::from_file_path(&stdout_path)
796 .expect("failed to get URL for stdout path"),
797 )
798 .build(),
799 ])
800 .build(),
801 None,
802 CancellationToken::new(),
803 )
804 .context("failed to run task")?
805 .await
806 .context("task execution failed")?;
807
808 assert!(results.first().status.success(), "container failed");
809
810 let stdout = fs::read_to_string(&stdout_path).context("failed to read stdout file")?;
812 assert!(
813 stdout.contains(&gid.to_string()),
814 "task stdout of `{stdout}` did not contain the expected output"
815 );
816
817 Ok(())
818 }
819
820 #[tokio::test]
821 async fn backend_supports_fallback_images() -> anyhow::Result<()> {
822 let backend = create_backend(Config::default()).await?;
823
824 let stdout_path = NamedTempFile::new()
825 .context("failed to create temporary file")?
826 .into_temp_path();
827
828 let (events_tx, events_rx) = broadcast::channel(1024);
829 let events = tokio::task::spawn(events(events_rx));
830
831 let results = backend
832 .run(
833 Task::builder()
834 .executions(NonEmpty::new(
835 Execution::builder()
836 .images([
837 "ubuntu:super_fake_tag_that_doesnt_exist",
838 "ubuntu:this_tag_is_even_more_fake",
839 "ubuntu:latest",
840 ])?
841 .program("/bin/sh")
842 .args([
843 String::from("-c"),
844 String::from("/usr/bin/echo \"Hello, world!\""),
845 ])
846 .stdout("/mnt/stdout")
847 .build(),
848 ))
849 .outputs(vec![
850 Output::builder()
851 .ty(Type::File)
852 .path("/mnt/stdout")
853 .url(
854 Url::from_file_path(&stdout_path)
855 .expect("failed to get URL for stdout path"),
856 )
857 .build(),
858 ])
859 .build(),
860 Some(events_tx),
861 CancellationToken::new(),
862 )
863 .context("failed to run task")?
864 .await
865 .context("task execution failed")?;
866
867 let events = events.await.unwrap();
868
869 assert!(results.first().status.success(), "container failed");
870 assert_eq!(
871 events
872 .iter()
873 .filter(|e| matches!(e, Event::ImagePullFailed { .. }))
874 .count(),
875 2
876 );
877
878 let stdout = fs::read_to_string(&stdout_path).context("failed to read stdout file")?;
880 assert!(
881 stdout.contains("Hello, world!"),
882 "task stdout of `{stdout}` did not contain the expected output"
883 );
884
885 Ok(())
886 }
887
888 #[tokio::test]
889 async fn concurrent_task_events() -> anyhow::Result<()> {
890 fn assert_events(events: &[Event], stdout: &[u8]) -> TaskId {
891 assert!(events.len() == 6 || events.len() == 8);
894
895 let task_id = match &events[0] {
897 Event::TaskCreated { id, .. } => *id,
898 _ => panic!("the first event should be the created event"),
899 };
900
901 if events.len() == 6 {
902 assert_matches!(&events[1], Event::TaskContainerCreated { id, .. } if *id == task_id);
903 assert_matches!(&events[2], Event::TaskStarted { id } if *id == task_id);
904 assert_matches!(&events[3], Event::TaskStdout { id, message } if *id == task_id && message == stdout);
905 assert_matches!(&events[4], Event::TaskContainerExited { id, exit_status, .. } if *id == task_id && exit_status.success());
906 assert_matches!(&events[5], Event::TaskCompleted { id, exit_statuses } if *id == task_id && exit_statuses[0].success());
907 } else if events.len() == 8 {
908 assert_matches!(&events[1], Event::ImagePullStarted { id, name } if *id == task_id && name == "ubuntu:latest");
909 assert_matches!(&events[2], Event::ImagePullFinished { id, name } if *id == task_id && name == "ubuntu:latest");
910 assert_matches!(&events[3], Event::TaskContainerCreated { id, .. } if *id == task_id);
911 assert_matches!(&events[4], Event::TaskStarted { id } if *id == task_id);
912 assert_matches!(&events[5], Event::TaskStdout { id, message } if *id == task_id && message == stdout);
913 assert_matches!(&events[6], Event::TaskContainerExited { id, exit_status, .. } if *id == task_id && exit_status.success());
914 assert_matches!(&events[7], Event::TaskCompleted { id, exit_statuses } if *id == task_id && exit_statuses[0].success());
915 } else {
916 panic!("unexpected number of events");
917 }
918
919 task_id
920 }
921
922 let backend = Arc::new(create_backend(Config::default()).await?);
923
924 let (events1_tx, events1_rx) = broadcast::channel(1024);
925 let events1 = tokio::task::spawn(events(events1_rx));
926
927 let (events2_tx, events2_rx) = broadcast::channel(1024);
928 let events2 = tokio::task::spawn(events(events2_rx));
929
930 let backend1 = backend.clone();
932 let task1 = tokio::spawn(async move {
933 backend1
934 .run(
935 Task::builder()
936 .executions(NonEmpty::new(
937 Execution::builder()
938 .images(["ubuntu:latest"])?
939 .program("/bin/sh")
940 .args([String::from("-c"), String::from("echo task1")])
941 .build(),
942 ))
943 .build(),
944 Some(events1_tx),
945 CancellationToken::new(),
946 )
947 .expect("failed to run task")
948 .await?;
949
950 anyhow::Ok(())
951 });
952
953 let task2 = tokio::spawn(async move {
955 backend
956 .run(
957 Task::builder()
958 .executions(NonEmpty::new(
959 Execution::builder()
960 .images(["ubuntu:latest"])?
961 .program("/bin/sh")
962 .args([String::from("-c"), String::from("echo task2")])
963 .build(),
964 ))
965 .build(),
966 Some(events2_tx),
967 CancellationToken::new(),
968 )
969 .expect("failed to run task")
970 .await?;
971
972 anyhow::Ok(())
973 });
974
975 for result in join_all([task1, task2]).await {
977 result
978 .context("failed to join task")?
979 .context("task failed")?;
980 }
981
982 let events1 = events1
983 .await
984 .context("failed to wait for the first task's events")?;
985 let events2 = events2
986 .await
987 .context("failed to wait for the first task's events")?;
988
989 let task_id1 = assert_events(&events1, b"task1\n");
990 let task_id2 = assert_events(&events2, b"task2\n");
991 assert!(task_id1 != task_id2, "expected different task identifiers");
992
993 Ok(())
994 }
995}