Skip to main content

crankshaft_docker/
service.rs

1//! Services.
2
3use std::collections::HashMap;
4#[cfg(unix)]
5use std::os::unix::process::ExitStatusExt as _;
6#[cfg(windows)]
7use std::os::windows::process::ExitStatusExt as _;
8use std::path::PathBuf;
9use std::process::ExitStatus;
10use std::time::Duration;
11
12use bollard::Docker;
13use bollard::models::ContainerWaitResponse;
14use bollard::models::TaskState;
15use bollard::query_parameters::InspectContainerOptions;
16use bollard::query_parameters::ListTasksOptions;
17use bollard::query_parameters::LogsOptionsBuilder;
18use bollard::query_parameters::WaitContainerOptions;
19
20mod builder;
21
22pub use builder::Builder;
23use crankshaft_events::Event;
24use futures::StreamExt;
25use tokio::fs::File;
26use tokio::time::sleep;
27use tracing::debug;
28use tracing::info;
29use tracing::trace;
30
31use crate::Error;
32use crate::EventOptions;
33use crate::Result;
34use crate::container::ExecutionResult;
35use crate::container::write_logs;
36
37/// A docker service.
38///
39/// Docker services are used to run tasks when Docker is configured to use a
40/// swarm.
41///
42/// This allows the swarm manager to schedule the task on available resources.
43///
44/// The service will always have a single replica of the task and the task will
45/// not restart.
46pub struct Service {
47    /// A reference to the [`Docker`] client that will be used to create this
48    /// service.
49    client: Docker,
50
51    /// The id of the service.
52    id: String,
53
54    /// The path to the file to write the container's stdout stream to.
55    stdout: Option<PathBuf>,
56
57    /// The path to the file to write the container's stderr stream to.
58    stderr: Option<PathBuf>,
59}
60
61impl Service {
62    /// Creates a new [`Service`] if you already know the id of the service.
63    ///
64    /// You should typically use a [`Builder`] unless you receive the service
65    /// name externally from a user (say, on the command line as an argument).
66    pub fn new(
67        client: Docker,
68        id: String,
69        stdout: Option<PathBuf>,
70        stderr: Option<PathBuf>,
71    ) -> Self {
72        Self {
73            client,
74            id,
75            stdout,
76            stderr,
77        }
78    }
79
80    /// Gets the id of the service.
81    pub fn id(&self) -> &str {
82        &self.id
83    }
84
85    /// Runs a service and waits for the task execution to end.
86    pub async fn run(
87        &self,
88        task_name: &str,
89        events: Option<EventOptions>,
90    ) -> Result<ExecutionResult> {
91        let stdout = match &self.stdout {
92            Some(path) => Some((
93                path.as_path(),
94                File::create(path).await.map_err(|e| {
95                    Error::Message(format!(
96                        "failed to create stdout file `{path}`: {e}",
97                        path = path.display()
98                    ))
99                })?,
100            )),
101            None => None,
102        };
103
104        let stderr = match &self.stderr {
105            Some(path) => Some((
106                path.as_path(),
107                File::create(path).await.map_err(|e| {
108                    Error::Message(format!(
109                        "failed to create stderr file `{path}`: {e}",
110                        path = path.display()
111                    ))
112                })?,
113            )),
114            None => None,
115        };
116
117        let (image, container_id, exit_code) = loop {
118            trace!(
119                "polling tasks for service `{id}` (task `{task_name}`)",
120                id = self.id
121            );
122
123            // Get the list of tasks for the service (there should be only one)
124            let tasks = self
125                .client
126                .list_tasks(Some(ListTasksOptions {
127                    filters: Some(HashMap::from_iter([(
128                        String::from("service"),
129                        vec![self.id.to_owned()],
130                    )])),
131                }))
132                .await
133                .map_err(Error::Docker)?;
134
135            if tasks.is_empty() {
136                // A task hasn't been created for the service yet, query again after a delay
137                sleep(Duration::from_millis(100)).await;
138                continue;
139            }
140
141            assert_eq!(
142                tasks.len(),
143                1,
144                "Docker service task count should always be 1"
145            );
146
147            let task = tasks.into_iter().next().unwrap();
148            let image = task
149                .spec
150                .as_ref()
151                .and_then(|spec| spec.container_spec.as_ref())
152                .and_then(|spec| spec.image.clone())
153                .expect("Docker reported a container without a state");
154
155            let status = task.status.ok_or_else(|| {
156                Error::Message("Docker daemon reported a task with no status".into())
157            })?;
158
159            match status.state {
160                Some(TaskState::NEW)
161                | Some(TaskState::PENDING)
162                | Some(TaskState::ALLOCATED)
163                | Some(TaskState::ASSIGNED)
164                | Some(TaskState::ACCEPTED)
165                | Some(TaskState::READY)
166                | Some(TaskState::PREPARING)
167                | Some(TaskState::STARTING)
168                | None => {
169                    trace!(
170                        "task has not yet started for service `{id}` (task `{task_name}`)",
171                        id = self.id
172                    );
173
174                    // Query again after a delay
175                    // TODO: make this a variable delay so as to lessen a thundering herd
176                    sleep(Duration::from_secs(1)).await;
177                }
178                Some(TaskState::RUNNING) | Some(TaskState::COMPLETE) | Some(TaskState::FAILED) => {
179                    let container_status = status.container_status.ok_or_else(|| {
180                        Error::Message(
181                            "Docker daemon reported a task with no container status".into(),
182                        )
183                    })?;
184
185                    let container_id = container_status.container_id.ok_or_else(|| {
186                        Error::Message("Docker reported a task with no container id".into())
187                    })?;
188
189                    if let Some(events) = &events {
190                        events
191                            .sender
192                            .send(Event::TaskContainerCreated {
193                                id: events.task_id,
194                                container: container_id.clone(),
195                            })
196                            .ok();
197                    }
198
199                    info!(
200                        "service `{id}` (task `{task_name}`) has started container `{container_id}",
201                        id = self.id
202                    );
203
204                    if let Some(events) = &events
205                        && events.send_start
206                    {
207                        events
208                            .sender
209                            .send(Event::TaskStarted { id: events.task_id })
210                            .ok();
211                    }
212
213                    // Write the logs
214                    let stdout_enabled = self.stdout.is_some()
215                        || events.as_ref().is_some_and(|e| e.user_config.send_stdout);
216                    let stderr_enabled = self.stderr.is_some()
217                        || events.as_ref().is_some_and(|e| e.user_config.send_stderr);
218                    if stdout_enabled || stderr_enabled {
219                        let logs = self.client.logs(
220                            &container_id,
221                            Some(
222                                LogsOptionsBuilder::new()
223                                    .stdout(stdout_enabled)
224                                    .stderr(stderr_enabled)
225                                    .follow(true)
226                                    .build(),
227                            ),
228                        );
229
230                        // Write the logs
231                        write_logs(logs, stdout, stderr, events.as_ref()).await?;
232                    }
233
234                    if status.state == Some(TaskState::RUNNING) {
235                        // Wait for the container to be completed.
236                        let mut wait_stream = self
237                            .client
238                            .wait_container(&container_id, None::<WaitContainerOptions>);
239
240                        match wait_stream.next().await {
241                            Some(Ok(ContainerWaitResponse {
242                                status_code: code, ..
243                            }))
244                            | Some(Err(bollard::errors::Error::DockerContainerWaitError {
245                                code,
246                                ..
247                            })) => {
248                                break (image, container_id, code);
249                            }
250                            Some(Err(e)) => return Err(e.into()),
251                            None => {
252                                // Get the exit code if the wait was immediate
253                                let container = self
254                                    .client
255                                    .inspect_container(
256                                        &container_id,
257                                        None::<InspectContainerOptions>,
258                                    )
259                                    .await
260                                    .map_err(Error::Docker)?;
261
262                                break (
263                                    image,
264                                    container_id,
265                                    container
266                                        .state
267                                        .ok_or_else(|| {
268                                            Error::Message(
269                                                "Docker reported a container without a state"
270                                                    .into(),
271                                            )
272                                        })?
273                                        .exit_code
274                                        .ok_or_else(|| {
275                                            Error::Message(
276                                                "Docker reported a finished contained without an \
277                                                 exit code"
278                                                    .into(),
279                                            )
280                                        })?,
281                                );
282                            }
283                        }
284                    } else {
285                        break (
286                            image,
287                            container_id,
288                            container_status.exit_code.ok_or_else(|| {
289                                Error::Message(format!(
290                                    "Docker reported a {kind} task with no exit code",
291                                    kind = if status.state == Some(TaskState::FAILED) {
292                                        "failed"
293                                    } else {
294                                        "completed"
295                                    }
296                                ))
297                            })?,
298                        );
299                    }
300                }
301                Some(TaskState::SHUTDOWN)
302                | Some(TaskState::REJECTED)
303                | Some(TaskState::ORPHANED)
304                | Some(TaskState::REMOVE) => {
305                    return Err(Error::Message(format!(
306                        "Docker task failed: {msg}",
307                        msg = status
308                            .err
309                            .as_deref()
310                            .or(status.message.as_deref())
311                            .unwrap_or("no error message was provided by the Docker daemon")
312                    )));
313                }
314            }
315        };
316
317        // See WEXITSTATUS from wait(2) to explain the shift
318        #[cfg(unix)]
319        let status = ExitStatus::from_raw((exit_code as i32) << 8);
320
321        #[cfg(windows)]
322        let status = ExitStatus::from_raw(exit_code as u32);
323
324        info!(
325            "container `{container_id}` for service `{id}` (task `{task_name}`) has exited with \
326             {status}",
327            id = self.id
328        );
329
330        if let Some(events) = &events {
331            events
332                .sender
333                .send(Event::TaskContainerExited {
334                    id: events.task_id,
335                    container: container_id,
336                    exit_status: status,
337                })
338                .ok();
339        }
340
341        Ok(ExecutionResult { image, status })
342    }
343
344    /// Deletes a service.
345    pub async fn delete(&self) -> Result<()> {
346        debug!("deleting Docker service `{id}`", id = self.id);
347        self.client
348            .delete_service(&self.id)
349            .await
350            .map_err(Error::Docker)?;
351
352        Ok(())
353    }
354}