1use futures_util::StreamExt;
2use serde_json::Value;
3use soma_fleet::{HostRecord, TopologyRevision};
4use soma_ops::{MutationSendState, ProgressEvent, Timestamp};
5use tokio_util::sync::CancellationToken;
6
7use crate::{
8 BollardReadClient, ImagePullMutator, ImagePullProgressFrame, ImagePullReceipt,
9 ImagePullRequest, InfraError, MutationFailure, MutationProgressReporter, MutationResult,
10};
11
12#[async_trait::async_trait]
13impl ImagePullMutator for BollardReadClient {
14 async fn pull_image(
15 &self,
16 host: &HostRecord,
17 request: &ImagePullRequest,
18 progress: &dyn MutationProgressReporter,
19 cancellation: &CancellationToken,
20 ) -> MutationResult<ImagePullReceipt> {
21 self.validate_host(host)
22 .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?;
23 ensure_not_expired(request.deadline(), cancellation)?;
24 let (from_image, tag) = split_image_reference(request.image());
25 let options = bollard::query_parameters::CreateImageOptions {
26 from_image: Some(from_image),
27 tag,
28 ..Default::default()
29 };
30 let mut stream = self.docker().create_image(Some(options), None, None);
31 let mut receipt = ImagePullReceipt {
32 host: host.id().clone(),
33 topology_revision: TopologyRevision::clone(host.revision()),
34 image: request.image().to_owned(),
35 send_state: MutationSendState::Sent,
36 total_events: 0,
37 progress: Vec::new(),
38 progress_truncated: false,
39 progress_delivery_errors: Vec::new(),
40 };
41 let mut sequence = 0_u64;
42 loop {
43 let remaining = remaining_duration(request.deadline())?;
44 let next = tokio::select! {
45 () = cancellation.cancelled() => {
46 return Err(MutationFailure::new(
47 MutationSendState::Unknown,
48 soma_fleet::FleetError::Cancelled.into(),
49 ));
50 }
51 () = tokio::time::sleep(remaining) => {
52 return Err(MutationFailure::new(
53 MutationSendState::Unknown,
54 soma_fleet::FleetError::DeadlineExceeded.into(),
55 ));
56 }
57 next = stream.next() => next,
58 };
59 let Some(item) = next else {
60 break;
61 };
62 let info = item.map_err(|error| {
63 MutationFailure::new(
64 MutationSendState::Unknown,
65 InfraError::Docker(error.to_string()),
66 )
67 })?;
68 sequence = sequence.saturating_add(1);
69 let value = serde_json::to_value(&info).map_err(|error| {
70 MutationFailure::new(
71 MutationSendState::Sent,
72 InfraError::Parse {
73 domain: "image-pull",
74 message: error.to_string(),
75 },
76 )
77 })?;
78 let frame = progress_frame(sequence, &value);
79 if let Some(error) = frame.error.clone() {
80 receipt.retain_frame(frame);
81 return Err(MutationFailure::new(
82 MutationSendState::Sent,
83 InfraError::Docker(error),
84 ));
85 }
86 if let Ok(event) = canonical_progress_event(request, &frame)
87 && let Err(error) = progress.report(&event)
88 {
89 receipt.retain_delivery_error(error);
90 }
91 receipt.retain_frame(frame);
92 }
93 Ok(receipt)
94 }
95}
96
97fn ensure_not_expired(deadline: Timestamp, cancellation: &CancellationToken) -> MutationResult<()> {
98 if cancellation.is_cancelled() {
99 return Err(MutationFailure::new(
100 MutationSendState::NotSent,
101 soma_fleet::FleetError::Cancelled.into(),
102 ));
103 }
104 if Timestamp::now() >= deadline {
105 return Err(MutationFailure::new(
106 MutationSendState::NotSent,
107 soma_fleet::FleetError::DeadlineExceeded.into(),
108 ));
109 }
110 Ok(())
111}
112
113fn remaining_duration(deadline: Timestamp) -> MutationResult<std::time::Duration> {
114 let remaining = deadline
115 .unix_millis()
116 .saturating_sub(Timestamp::now().unix_millis());
117 if remaining <= 0 {
118 Err(MutationFailure::new(
119 MutationSendState::Unknown,
120 soma_fleet::FleetError::DeadlineExceeded.into(),
121 ))
122 } else {
123 Ok(std::time::Duration::from_millis(remaining as u64))
124 }
125}
126
127fn split_image_reference(image: &str) -> (String, Option<String>) {
128 if image.contains('@') {
129 return (image.to_owned(), None);
130 }
131 match image.rsplit_once(':') {
132 Some((repo, tag)) if !tag.contains('/') && !tag.is_empty() => {
133 (repo.to_owned(), Some(tag.to_owned()))
134 }
135 _ => (image.to_owned(), None),
136 }
137}
138
139fn progress_frame(sequence: u64, value: &Value) -> ImagePullProgressFrame {
140 let detail = value
141 .get("progress_detail")
142 .or_else(|| value.get("progressDetail"));
143 ImagePullProgressFrame {
144 sequence,
145 status: text(value, &["status", "Status"]),
146 id: text(value, &["id", "ID", "Id"]),
147 current: detail.and_then(|value| unsigned(value, &["current", "Current"])),
148 total: detail.and_then(|value| unsigned(value, &["total", "Total"])),
149 message: text(value, &["progress", "Progress"]),
150 error: text(value, &["error", "Error"]).or_else(|| {
151 value
152 .get("error_detail")
153 .or_else(|| value.get("errorDetail"))
154 .and_then(|detail| text(detail, &["message", "Message"]))
155 }),
156 }
157}
158
159fn canonical_progress_event(
160 request: &ImagePullRequest,
161 frame: &ImagePullProgressFrame,
162) -> Result<ProgressEvent, soma_ops::ProgressError> {
163 let mut event = ProgressEvent::new(
164 request.operation_id().clone(),
165 request.operation().clone(),
166 frame.sequence,
167 Timestamp::now(),
168 "pull",
169 )?;
170 if let Some(current) = frame.current
171 && frame
172 .total
173 .is_none_or(|total| total > 0 && current <= total)
174 {
175 event = event.with_amount(current, frame.total, Some("bytes"))?;
176 }
177 let message = [
178 frame.status.as_deref(),
179 frame.id.as_deref(),
180 frame.message.as_deref(),
181 ]
182 .into_iter()
183 .flatten()
184 .collect::<Vec<_>>()
185 .join(" | ");
186 if !message.is_empty() {
187 event = event.with_message(bounded_message(&message))?;
188 }
189 Ok(event)
190}
191
192fn bounded_message(value: &str) -> String {
193 value
194 .chars()
195 .map(|character| {
196 if character.is_control() {
197 ' '
198 } else {
199 character
200 }
201 })
202 .take(1_024)
203 .collect()
204}
205
206fn text(value: &Value, names: &[&str]) -> Option<String> {
207 let object = value.as_object()?;
208 names
209 .iter()
210 .find_map(|name| object.get(*name))
211 .and_then(Value::as_str)
212 .map(str::to_owned)
213}
214
215fn unsigned(value: &Value, names: &[&str]) -> Option<u64> {
216 let object = value.as_object()?;
217 names
218 .iter()
219 .find_map(|name| object.get(*name))
220 .and_then(Value::as_u64)
221}
222
223#[cfg(test)]
224#[path = "bollard_image_pull_tests.rs"]
225mod tests;