1use std::future::Future;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use bollard::models::{ContainerCreateBody, ContainerInspectResponse, NetworkingConfig};
6use bollard::query_parameters::{
7 CreateContainerOptions, CreateImageOptions, StartContainerOptions, StopContainerOptions,
8};
9use futures_util::StreamExt;
10use serde_json::json;
11use soma_fleet::{HostRecord, TopologyRevision};
12use soma_ops::{MutationSendState, Timestamp};
13use tokio_util::sync::CancellationToken;
14
15use crate::docker_map::map_container_inspect;
16use crate::{
17 BollardReadClient, ContainerRecreateFingerprint, ContainerRecreateInspector,
18 ContainerRecreateMutator, ContainerRecreateReceipt, ContainerRecreateRequest,
19 ContainerRecreateStage, InfraError, InfraResult, MutationFailure, MutationResult,
20};
21
22#[async_trait]
23impl ContainerRecreateInspector for BollardReadClient {
24 async fn recreate_fingerprint(
25 &self,
26 host: &HostRecord,
27 container: &str,
28 cancellation: &CancellationToken,
29 ) -> InfraResult<ContainerRecreateFingerprint> {
30 self.validate_host(host)?;
31 let raw = tokio::select! {
32 () = cancellation.cancelled() => return Err(soma_fleet::FleetError::Cancelled.into()),
33 result = self.docker().inspect_container(container, None) => result
34 .map_err(|error| InfraError::Docker(error.to_string()))?,
35 };
36 fingerprint(host, container, &raw)
37 }
38}
39
40#[async_trait]
41impl ContainerRecreateMutator for BollardReadClient {
42 async fn recreate_container(
43 &self,
44 host: &HostRecord,
45 request: &ContainerRecreateRequest,
46 cancellation: &CancellationToken,
47 ) -> MutationResult<ContainerRecreateReceipt> {
48 self.validate_host(host)
49 .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?;
50 ensure_not_expired(request.deadline(), cancellation)?;
51 let raw = self
52 .docker()
53 .inspect_container(&request.expected().container, None)
54 .await
55 .map_err(|error| {
56 MutationFailure::new(
57 MutationSendState::NotSent,
58 InfraError::Docker(error.to_string()),
59 )
60 })?;
61 let current = fingerprint(host, &request.expected().container, &raw)
62 .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?;
63 if current != *request.expected() {
64 return Err(MutationFailure::new(
65 MutationSendState::NotSent,
66 InfraError::InvalidRequest {
67 domain: "container-recreate",
68 message: "container configuration changed immediately before replacement"
69 .into(),
70 },
71 ));
72 }
73
74 let image = image_ref(&raw)
75 .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?;
76 let name = container_name(&raw, &request.expected().container);
77 if request.pull() {
78 pull_image(self, &image, request.deadline(), cancellation).await?;
79 }
80
81 let mut stage = ContainerRecreateStage::Prepared;
82 await_stage(
83 request.deadline(),
84 cancellation,
85 stage,
86 self.docker()
87 .stop_container(&request.expected().container, None::<StopContainerOptions>),
88 )
89 .await?;
90 stage = ContainerRecreateStage::Stopped;
91
92 await_stage(
93 request.deadline(),
94 cancellation,
95 stage,
96 self.docker()
97 .remove_container(&request.expected().container, None),
98 )
99 .await?;
100 stage = ContainerRecreateStage::Removed;
101
102 let body = create_body(&raw, &image);
103 let created = await_stage(
104 request.deadline(),
105 cancellation,
106 stage,
107 self.docker().create_container(
108 Some(CreateContainerOptions {
109 name: Some(name.clone()),
110 platform: String::new(),
111 }),
112 body,
113 ),
114 )
115 .await?;
116 stage = ContainerRecreateStage::Created;
117
118 await_stage(
119 request.deadline(),
120 cancellation,
121 stage,
122 self.docker()
123 .start_container(&created.id, None::<StartContainerOptions>),
124 )
125 .await?;
126 stage = ContainerRecreateStage::Started;
127
128 Ok(ContainerRecreateReceipt {
129 host: host.id().clone(),
130 topology_revision: TopologyRevision::clone(host.revision()),
131 original_container: request.expected().container.clone(),
132 new_container: Some(created.id),
133 name,
134 image,
135 stage,
136 send_state: MutationSendState::Sent,
137 pulled: request.pull(),
138 })
139 }
140}
141
142fn fingerprint(
143 host: &HostRecord,
144 container: &str,
145 raw: &ContainerInspectResponse,
146) -> InfraResult<ContainerRecreateFingerprint> {
147 let value = serde_json::to_value(raw).map_err(|error| InfraError::Parse {
148 domain: "container-recreate",
149 message: error.to_string(),
150 })?;
151 let neutral = map_container_inspect(host, &value)?;
152 let name = container_name(raw, container);
153 let image = image_ref(raw)?;
154 let material = json!({
155 "name": name,
156 "image": image,
157 "config": raw.config,
158 "host_config": raw.host_config,
159 "networks": raw.network_settings.as_ref().and_then(|settings| settings.networks.as_ref()),
160 });
161 let encoded = serde_json::to_vec(&material).map_err(|error| InfraError::Parse {
162 domain: "container-recreate",
163 message: error.to_string(),
164 })?;
165 let sha256 = crate::mutation::sha256_hex(&encoded);
166 ContainerRecreateFingerprint::new(container, name, image, neutral.state, sha256)
167}
168
169fn image_ref(raw: &ContainerInspectResponse) -> InfraResult<String> {
170 raw.config
171 .as_ref()
172 .and_then(|config| config.image.clone())
173 .filter(|image| !image.is_empty())
174 .ok_or_else(|| InfraError::Parse {
175 domain: "container-recreate",
176 message: "container inspection does not include an image reference".into(),
177 })
178}
179
180fn container_name(raw: &ContainerInspectResponse, fallback: &str) -> String {
181 raw.name
182 .as_deref()
183 .map(|name| name.trim_start_matches('/').to_owned())
184 .filter(|name| !name.is_empty())
185 .unwrap_or_else(|| fallback.to_owned())
186}
187
188fn create_body(raw: &ContainerInspectResponse, image: &str) -> ContainerCreateBody {
189 let config = raw.config.as_ref();
190 let networking_config = raw
191 .network_settings
192 .as_ref()
193 .and_then(|settings| settings.networks.as_ref())
194 .map(|networks| NetworkingConfig {
195 endpoints_config: Some(networks.clone()),
196 });
197 ContainerCreateBody {
198 image: Some(image.to_owned()),
199 env: config.and_then(|config| config.env.clone()),
200 cmd: config.and_then(|config| config.cmd.clone()),
201 entrypoint: config.and_then(|config| config.entrypoint.clone()),
202 labels: config.and_then(|config| config.labels.clone()),
203 working_dir: config.and_then(|config| config.working_dir.clone()),
204 user: config.and_then(|config| config.user.clone()),
205 volumes: config.and_then(|config| config.volumes.clone()),
206 host_config: raw.host_config.clone(),
207 networking_config,
208 ..Default::default()
209 }
210}
211
212async fn pull_image(
213 client: &BollardReadClient,
214 image: &str,
215 deadline: Timestamp,
216 cancellation: &CancellationToken,
217) -> MutationResult<()> {
218 let (from_image, tag) = split_image(image);
219 let mut stream = client.docker().create_image(
220 Some(CreateImageOptions {
221 from_image: Some(from_image),
222 tag,
223 ..Default::default()
224 }),
225 None,
226 None,
227 );
228 loop {
229 let remaining = remaining(deadline)?;
230 let item = tokio::select! {
231 () = cancellation.cancelled() => return Err(MutationFailure::new(
232 MutationSendState::Unknown,
233 soma_fleet::FleetError::Cancelled.into(),
234 )),
235 result = tokio::time::timeout(remaining, stream.next()) => match result {
236 Err(_) => return Err(MutationFailure::new(
237 MutationSendState::Unknown,
238 soma_fleet::FleetError::DeadlineExceeded.into(),
239 )),
240 Ok(value) => value,
241 }
242 };
243 match item {
244 None => return Ok(()),
245 Some(Ok(frame)) => {
246 let value = serde_json::to_value(frame).map_err(|error| {
247 MutationFailure::new(
248 MutationSendState::Sent,
249 InfraError::Parse {
250 domain: "container-recreate",
251 message: error.to_string(),
252 },
253 )
254 })?;
255 let error = value
256 .get("error_detail")
257 .or_else(|| value.get("errorDetail"))
258 .and_then(|detail| detail.get("message"))
259 .and_then(serde_json::Value::as_str);
260 if let Some(error) = error {
261 return Err(MutationFailure::new(
262 MutationSendState::Sent,
263 InfraError::Docker(error.to_owned()),
264 ));
265 }
266 }
267 Some(Err(error)) => {
268 return Err(MutationFailure::new(
269 MutationSendState::Unknown,
270 InfraError::Docker(error.to_string()),
271 ));
272 }
273 }
274 }
275}
276
277fn split_image(image: &str) -> (String, Option<String>) {
278 if image.contains('@') {
279 return (image.to_owned(), None);
280 }
281 let slash = image.rfind('/');
282 let colon = image.rfind(':');
283 match colon.filter(|colon| slash.is_none_or(|slash| *colon > slash)) {
284 Some(colon) => (
285 image[..colon].to_owned(),
286 Some(image[colon + 1..].to_owned()),
287 ),
288 None => (image.to_owned(), Some("latest".into())),
289 }
290}
291
292fn ensure_not_expired(deadline: Timestamp, cancellation: &CancellationToken) -> MutationResult<()> {
293 if cancellation.is_cancelled() {
294 return Err(MutationFailure::new(
295 MutationSendState::NotSent,
296 soma_fleet::FleetError::Cancelled.into(),
297 ));
298 }
299 remaining(deadline).map(|_| ())
300}
301
302fn remaining(deadline: Timestamp) -> MutationResult<Duration> {
303 let millis = deadline
304 .unix_millis()
305 .saturating_sub(Timestamp::now().unix_millis());
306 if millis <= 0 {
307 Err(MutationFailure::new(
308 MutationSendState::NotSent,
309 soma_fleet::FleetError::DeadlineExceeded.into(),
310 ))
311 } else {
312 Ok(Duration::from_millis(millis as u64))
313 }
314}
315
316async fn await_stage<T, F>(
317 deadline: Timestamp,
318 cancellation: &CancellationToken,
319 stage: ContainerRecreateStage,
320 future: F,
321) -> MutationResult<T>
322where
323 F: Future<Output = Result<T, bollard::errors::Error>>,
324{
325 let timeout = remaining(deadline)?;
326 tokio::select! {
327 () = cancellation.cancelled() => Err(MutationFailure::new(
328 MutationSendState::Unknown,
329 InfraError::Docker(format!("container recreate cancelled after stage {stage:?}")),
330 )),
331 result = tokio::time::timeout(timeout, future) => match result {
332 Err(_) => Err(MutationFailure::new(
333 MutationSendState::Unknown,
334 InfraError::Docker(format!("container recreate timed out after stage {stage:?}")),
335 )),
336 Ok(Err(error)) => Err(MutationFailure::new(
337 MutationSendState::Unknown,
338 InfraError::Docker(format!("container recreate failed after stage {stage:?}: {error}")),
339 )),
340 Ok(Ok(value)) => Ok(value),
341 }
342 }
343}
344
345#[cfg(test)]
346#[path = "bollard_recreate_tests.rs"]
347mod tests;