Skip to main content

crankshaft_engine/service/runner/backend/
docker.rs

1//! A Docker backend.
2
3use 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/// Represents resource information about a Docker swarm.
56#[derive(Debug, Default, Clone, Copy)]
57pub struct SwarmResources {
58    /// The number of nodes in the swarm.
59    pub nodes: usize,
60    /// The total CPUs available to the swarm.
61    pub cpu: u64,
62    /// The total memory of the swarm, in bytes.
63    pub memory: u64,
64    /// The maximum CPUs for any of the nodes in the swarm.
65    pub max_cpu: u64,
66    /// The maximum memory for any of the nodes in the swarm.
67    pub max_memory: u64,
68}
69
70/// Represents resource information about a local Docker daemon.
71#[derive(Debug, Default, Clone, Copy)]
72pub struct LocalResources {
73    /// The total CPUs available to the local Docker daemon.
74    pub cpu: u64,
75    /// The total memory available to the local Docker daemon, in bytes.
76    pub memory: u64,
77}
78
79/// Represents information about Docker's available resources.
80#[derive(Debug, Clone, Copy)]
81pub enum Resources {
82    /// The resources are for a local Docker daemon.
83    Local(LocalResources),
84    /// The resources are for a Docker swarm.
85    Swarm(SwarmResources),
86}
87
88impl Resources {
89    /// Gets the number of nodes.
90    pub fn nodes(&self) -> usize {
91        match self {
92            Self::Local(_) => 1,
93            Self::Swarm(resources) => resources.nodes,
94        }
95    }
96
97    /// Gets the total CPUs available.
98    pub fn cpu(&self) -> u64 {
99        match self {
100            Self::Local(resources) => resources.cpu,
101            Self::Swarm(resources) => resources.cpu,
102        }
103    }
104
105    /// Gets the total memory available, in bytes.
106    pub fn memory(&self) -> u64 {
107        match self {
108            Self::Local(resources) => resources.memory,
109            Self::Swarm(resources) => resources.memory,
110        }
111    }
112
113    /// Gets the maximum CPUs available for a single node.
114    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    /// Gets the maximum memory available for a single node, in bytes.
122    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    /// Determines if the docker backend will use a service instead of a
130    /// container based on the resources available.
131    ///
132    /// A service should only be used when Docker is in a swarm with more than
133    /// one node. This allows for the Swarm manager to schedule the container.
134    ///
135    /// Otherwise, we'll use a single local container.
136    pub fn use_service(&self) -> bool {
137        match self {
138            Self::Local(_) => false,
139            Self::Swarm(_) => true,
140        }
141    }
142}
143
144/// A local execution backend.
145#[derive(Debug)]
146pub struct Backend {
147    /// A handle to the inner docker client.
148    client: Docker,
149    /// Configuration for the backend.
150    config: Config,
151    /// The available resources reported by Docker.
152    resources: Resources,
153    /// The unique name generator for tasks without names.
154    names: Arc<Mutex<GeneratorIterator<UniqueAlphanumeric>>>,
155}
156
157impl Backend {
158    /// Attempts to initialize a new Docker [`Backend`] with the default
159    /// connection settings and the provided configuration for the backend.
160    ///
161    /// Note that, currently, we connect [using defaults](Docker::with_defaults)
162    /// when attempting to connect to the Docker daemon.
163    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        // Check to see if the daemon is part of an active swarm or not
176        // If the daemon is part of a swarm, but the node is not active or a manager, we
177        // can't spawn tasks
178        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                    // Part of an active swarm, check to see if the node is a manager
182                    // Default is false as documented here: https://docs.docker.com/reference/api/engine/version/v1.47/#tag/System/operation/SystemInfo
183                    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                    // Only look at active and ready nodes in the swarm that are reporting their
191                    // resources
192                    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                    // Not part of a swarm
266                    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    /// Attempts to initialize a new Docker [`Backend`] with the default
321    /// connection settings and default backend configuration.
322    ///
323    /// Note that, currently, we connect [using defaults](Docker::with_defaults)
324    /// when attempting to connect to the Docker daemon.
325    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    /// Gets a reference to the inner Docker client.
332    pub fn client(&self) -> &Docker {
333        &self.client
334    }
335
336    /// Gets information about the resources available to the Docker backend.
337    pub fn resources(&self) -> &Resources {
338        &self.resources
339    }
340}
341
342/// Helper for cleaning up a container or service.
343enum Cleanup {
344    /// The cleanup is for a container.
345    Container(Arc<Container>),
346    /// The cleanup is for a service.
347    Service(Arc<Service>),
348}
349
350impl Cleanup {
351    /// Runs cleanup.
352    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
372/// Attempt to find a candidate image for the given execution.
373async 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            // Generate a name of the task if one wasn't provided
429            let task_name = task.name.unwrap_or_else(|| {
430                let mut generator = names.lock().unwrap();
431                // SAFETY: the name generator should _never_ run out of entries.
432                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                    // First, ensure the execution's image exists
449                    let image = find_candidate_image(&client, &execution, token.clone(), events.clone(), task_id).await?;
450
451                    // Look for the path where the caller wants stdout saved to
452                    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                    // Look for the path where the caller wants stderr saved to
473                    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                    // Generate a name for the service or container
498                    let name = {
499                        let mut generator = names.lock().unwrap();
500                        // SAFETY: the name generator should _never_ run out of entries.
501                        generator.next().unwrap()
502                    };
503
504                    // Check to see if we should use the service API for running the task
505                    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                            // Always poll the cancellation token first
533                            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                                // Ensure the caller's group id is added so that the container can access the mounts and working directory
557                                #[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                            // Always poll the cancellation token first
584                            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                // SAFETY: each task _must_ have at least one execution, so at least one
606                // execution result _must_ exist at this stage. Thus, this will always unwrap.
607                Ok(NonEmpty::from_vec(outputs).unwrap())
608            };
609
610            // Send the created event
611            send_event!(events, Event::TaskCreated { id: task_id, name: task_name.clone(), tes_id: None, token: task_token.clone() });
612
613            // Run the task to completion
614            let result: Result<NonEmpty<ExecutionResult>, _> = run.await.map(|results| {
615                // SAFETY: NonEmpty -> NonEmpty
616                NonEmpty::collect(results.into_iter().map(Into::into)).unwrap()
617            });
618
619            // Send an event for the result
620            match &result {
621                Ok(results) => send_event!(
622                    events,
623                    Event::TaskCompleted {
624                        id: task_id,
625                        // SAFETY: NonEmpty -> NonEmpty
626                        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
656/// Adds input mounts to the list of mounts.
657///
658/// Bind mounts are created for any input specified as a path.
659///
660/// For inputs not specified by a path, the contents are fetched and written to
661/// a file within the provided temporary directory.
662///
663/// Errors may be returned if an input's contents could not be fetched.
664async 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
692/// Gets the shared mounts (if any exist) from the shared volumes in a [`Task`]
693/// (via [`Task::shared_volumes()`]).
694fn add_shared_mounts(volumes: Vec<String>, tempdir: &Path, mounts: &mut Vec<Mount>) -> Result<()> {
695    for volume in volumes {
696        // Create new temporary directory in the provided temporary directory
697        // The call to `into_path` will prevent the directory from being deleted on
698        // drop; instead, we're relying on the parent temporary directory to delete it
699        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        // Get the current user's effective gid
772        let gid = Gid::effective();
773
774        let stdout_path = NamedTempFile::new()
775            .context("failed to create temporary file")?
776            .into_temp_path();
777
778        // Run the task
779        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        // Assert that the command output had the user's group added
811        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        // Assert that the command was run
879        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            // There should be six or eight events generated
892            // Eight events indicates the image was pulled
893            assert!(events.len() == 6 || events.len() == 8);
894
895            // The first event should be the created event; extract the id
896            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        // Spawn the first task
931        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        // Spawn the second task
954        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        // Wait for the tasks to complete and check for errors
976        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}