1use soma_fleet::HostRecord;
2use soma_ops::{MutationSendState, Timestamp, VerificationStatus};
3use tokio_util::sync::CancellationToken;
4
5use crate::{
6 ComposePullClient, ComposePullOutcome, ComposePullRequest, ComposePulledImage,
7 ImageListOptions, ImageReader, InfraError, MutationFailure, MutationProgressReporter,
8 MutationResult, MutationVerification,
9};
10
11#[derive(Debug, Clone, Copy, Default)]
13pub struct ComposePullEngine;
14
15impl ComposePullEngine {
16 pub async fn execute(
18 &self,
19 compose: &dyn ComposePullClient,
20 images: &dyn ImageReader,
21 host: &HostRecord,
22 request: &ComposePullRequest,
23 progress: &dyn MutationProgressReporter,
24 cancellation: &CancellationToken,
25 ) -> MutationResult<ComposePullOutcome> {
26 ensure_admitted(request, cancellation)?;
27 let config = compose
28 .config(host, request.project(), request.deadline(), cancellation)
29 .await
30 .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?;
31 let expected = expected_images(&config.services, request.service())?;
32 let before_rows = images
33 .list_images(host, &ImageListOptions::default(), cancellation)
34 .await
35 .map_err(|error| MutationFailure::new(MutationSendState::NotSent, error))?;
36 let before = expected
37 .iter()
38 .map(|(service, reference)| {
39 (
40 service.clone(),
41 reference.clone(),
42 crate::image_pull_engine::find_image(&before_rows, reference),
43 )
44 })
45 .collect::<Vec<_>>();
46 let receipt = compose
47 .pull_compose_images(host, request, progress, cancellation)
48 .await?;
49 let after_read = images
50 .list_images(host, &ImageListOptions::default(), cancellation)
51 .await;
52 let (rows, verification_status, summary) = match after_read {
53 Ok(after_rows) => verified_rows(before, &after_rows),
54 Err(error) => {
55 let rows = before
56 .into_iter()
57 .map(|(service, reference, before)| ComposePulledImage {
58 service,
59 reference,
60 before,
61 after: None,
62 changed: false,
63 verified: false,
64 })
65 .collect();
66 (
67 rows,
68 VerificationStatus::Inconclusive,
69 format!("Compose pull completed but image verification failed: {error}"),
70 )
71 }
72 };
73 let changed = rows.iter().any(|row| row.changed);
74 Ok(ComposePullOutcome {
75 host: host.id().clone(),
76 topology_revision: host.revision().clone(),
77 project: request.project().name().to_owned(),
78 service: request.service().map(str::to_owned),
79 send_state: receipt.send_state,
80 images: rows,
81 changed,
82 progress_delivery_errors: receipt.progress_delivery_errors,
83 output_truncated: receipt.output_truncated,
84 verification_status,
85 verification: MutationVerification {
86 status: format!("{verification_status:?}").to_ascii_lowercase(),
87 summary,
88 },
89 })
90 }
91}
92
93type BeforeImage = (String, String, Option<crate::ImageIdentity>);
94
95fn verified_rows(
96 before: Vec<BeforeImage>,
97 after_rows: &[crate::ImageSummary],
98) -> (Vec<ComposePulledImage>, VerificationStatus, String) {
99 let rows = before
100 .into_iter()
101 .map(|(service, reference, before)| {
102 let after = crate::image_pull_engine::find_image(after_rows, &reference);
103 let changed = match (&before, &after) {
104 (Some(before), Some(after)) => before.id != after.id,
105 (None, Some(_)) => true,
106 _ => false,
107 };
108 ComposePulledImage {
109 service,
110 reference,
111 before,
112 verified: after.is_some(),
113 after,
114 changed,
115 }
116 })
117 .collect::<Vec<_>>();
118 if rows.iter().all(|row| row.verified) {
119 (
120 rows,
121 VerificationStatus::Verified,
122 "all configured Compose image references resolve locally".into(),
123 )
124 } else {
125 (
126 rows,
127 VerificationStatus::Failed,
128 "one or more configured Compose image references were not found locally".into(),
129 )
130 }
131}
132
133fn expected_images(
134 services: &std::collections::BTreeMap<String, crate::ComposeServiceConfig>,
135 selected: Option<&str>,
136) -> MutationResult<Vec<(String, String)>> {
137 if let Some(selected) = selected {
138 let service = services
139 .get(selected)
140 .ok_or_else(|| invalid_request(format!("Compose service {selected} was not found")))?;
141 let image = service
142 .image
143 .clone()
144 .filter(|image| !image.is_empty())
145 .ok_or_else(|| {
146 invalid_request(format!("Compose service {selected} has no image reference"))
147 })?;
148 return Ok(vec![(selected.to_owned(), image)]);
149 }
150 let images = services
151 .iter()
152 .filter_map(|(name, service)| service.image.clone().map(|image| (name.clone(), image)))
153 .filter(|(_, image)| !image.is_empty())
154 .collect::<Vec<_>>();
155 if images.is_empty() {
156 Err(invalid_request(
157 "Compose project has no pullable image references".into(),
158 ))
159 } else {
160 Ok(images)
161 }
162}
163
164fn invalid_request(message: String) -> MutationFailure {
165 MutationFailure::new(
166 MutationSendState::NotSent,
167 InfraError::InvalidRequest {
168 domain: "compose-pull",
169 message,
170 },
171 )
172}
173
174fn ensure_admitted(
175 request: &ComposePullRequest,
176 cancellation: &CancellationToken,
177) -> MutationResult<()> {
178 if cancellation.is_cancelled() {
179 return Err(MutationFailure::new(
180 MutationSendState::NotSent,
181 soma_fleet::FleetError::Cancelled.into(),
182 ));
183 }
184 if Timestamp::now() >= request.deadline() {
185 return Err(MutationFailure::new(
186 MutationSendState::NotSent,
187 soma_fleet::FleetError::DeadlineExceeded.into(),
188 ));
189 }
190 Ok(())
191}
192
193#[cfg(test)]
194#[path = "compose_pull_engine_tests.rs"]
195mod tests;