1use super::*;
2
3pub fn quota_refresh_profiles(controller: &Controller) -> Vec<QuotaRefreshRequest> {
4 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5 controller
6 .config
7 .enabled_profiles()
8 .map(|(id, profile)| QuotaRefreshRequest::for_profile(id, profile, cwd.clone()))
9 .collect()
10}
11
12pub fn spawn_quota_refresher() -> (
13 tokio::sync::watch::Sender<QuotaRefreshBatch>,
14 tokio::sync::mpsc::Receiver<QuotaUpdate>,
15) {
16 let (profiles_tx, mut profiles_rx) = tokio::sync::watch::channel(QuotaRefreshBatch::default());
17 let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(32);
18 tokio::spawn(async move {
19 let mut quotas = QuotaManager::default();
20 let mut batch = QuotaRefreshBatch::default();
21 let mut interval = tokio::time::interval(QUOTA_REFRESH_INTERVAL);
22 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
23 interval.tick().await;
24 loop {
25 tokio::select! {
26 _ = interval.tick(), if !batch.profiles.is_empty() => {
27 if !refresh_profile_quotas(
28 &mut quotas,
29 batch.generation,
30 &batch.profiles,
31 &updates_tx,
32 ).await {
33 break;
34 }
35 }
36 changed = profiles_rx.changed() => {
37 if changed.is_err() {
38 tracing::debug!("quota profile target feed closed; stopping quota refresher");
39 break;
40 }
41 batch = profiles_rx.borrow_and_update().clone();
42 if !refresh_profile_quotas(
43 &mut quotas,
44 batch.generation,
45 &batch.profiles,
46 &updates_tx,
47 ).await {
48 break;
49 }
50 }
51 }
52 }
53 quotas.shutdown().await;
54 });
55 (profiles_tx, updates_rx)
56}
57
58pub(super) async fn refresh_profile_quotas(
59 quotas: &mut QuotaManager,
60 generation: u64,
61 profiles: &[QuotaRefreshRequest],
62 updates: &tokio::sync::mpsc::Sender<QuotaUpdate>,
63) -> bool {
64 let ids = profiles
65 .iter()
66 .map(|profile| profile.profile_id.clone())
67 .collect::<Vec<_>>();
68 if updates
69 .send(QuotaUpdate::Refreshing { profile_ids: ids })
70 .await
71 .is_err()
72 {
73 tracing::debug!("quota update consumer closed before refresh started");
74 return false;
75 }
76 let delivered = AtomicBool::new(true);
79 quotas
80 .refresh_profiles(profiles.to_vec(), |quota| {
81 let delivered = &delivered;
82 async move {
83 if delivered.load(Ordering::Acquire)
84 && updates.send(QuotaUpdate::Report(quota)).await.is_err()
85 {
86 tracing::debug!("quota update consumer closed while reporting a profile");
87 delivered.store(false, Ordering::Release);
88 }
89 }
90 })
91 .await;
92 if !delivered.into_inner() {
93 return false;
94 }
95 if updates
96 .send(QuotaUpdate::Finished { generation })
97 .await
98 .is_err()
99 {
100 tracing::debug!(
101 generation,
102 "quota update consumer closed before refresh completed"
103 );
104 false
105 } else {
106 true
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum ImageRefreshReport {
116 Started { host: String, image: String },
118 Pulled { host: String, image: String },
120 Failed {
122 host: String,
123 image: String,
124 error: String,
125 },
126}
127
128pub fn spawn_image_refresher(
140 plan: impl Fn() -> Vec<ImageRefresh> + Send + 'static,
141 report: impl Fn(ImageRefreshReport) + Send + Sync + 'static,
142 cancellation: tokio_util::sync::CancellationToken,
143) -> tokio::task::JoinHandle<()> {
144 let report: Arc<dyn Fn(ImageRefreshReport) + Send + Sync> = Arc::new(report);
145 tokio::spawn(async move {
146 let mut interval = tokio::time::interval_at(
147 tokio::time::Instant::now() + IMAGE_REFRESH_DELAY,
148 IMAGE_REFRESH_INTERVAL,
149 );
150 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
153 let mut last_failures: BTreeMap<String, String> = BTreeMap::new();
156 loop {
157 tokio::select! {
158 biased;
161 _ = cancellation.cancelled() => return,
162 _ = interval.tick() => {
163 refresh_images(plan(), &report, &mut last_failures, &cancellation).await;
164 }
165 }
166 }
167 })
168}
169
170fn refresh_key(host: &str, image: &str) -> String {
173 format!("{host}|{image}")
174}
175
176pub(super) fn record_refresh_result(
183 last_failures: &mut BTreeMap<String, String>,
184 host: &str,
185 image: &str,
186 error: Option<String>,
187 report: &dyn Fn(ImageRefreshReport),
188) {
189 let key = refresh_key(host, image);
190 let Some(error) = error else {
191 last_failures.remove(&key);
192 return;
193 };
194 if last_failures.get(&key) == Some(&error) {
195 return;
196 }
197 last_failures.insert(key, error.clone());
198 report(ImageRefreshReport::Failed {
199 host: host.to_owned(),
200 image: image.to_owned(),
201 error,
202 });
203}
204
205pub(super) fn local_engine_installed(host: &ImageHost, path: Option<&std::ffi::OsStr>) -> bool {
210 match host {
211 ImageHost::LocalPodman | ImageHost::LocalDocker | ImageHost::AppleContainer => {
212 let Some(path) = path else { return false };
213 let engine = host.engine();
214 std::env::split_paths(path).any(|directory| directory.join(engine).is_file())
215 }
216 ImageHost::SshPodman(_) | ImageHost::SshDocker(_) => true,
217 }
218}
219
220pub(super) async fn refresh_images(
221 plan: Vec<ImageRefresh>,
222 report: &Arc<dyn Fn(ImageRefreshReport) + Send + Sync>,
223 last_failures: &mut BTreeMap<String, String>,
224 cancellation: &tokio_util::sync::CancellationToken,
225) {
226 if plan.is_empty() {
227 return;
228 }
229 let Ok(_upgrade_work) = crate::upgrade::activity("image download") else {
230 return;
231 };
232 let cancelled = Arc::new(AtomicBool::new(false));
235 let mut hosts = tokio::task::JoinSet::new();
236 for refresh in plan {
237 if !local_engine_installed(&refresh.host, std::env::var_os("PATH").as_deref()) {
242 tracing::debug!(
243 host = refresh.host.label(),
244 image = refresh.image,
245 "container engine is not installed; skipping the image refresh"
246 );
247 continue;
248 }
249 let executor = CancellableProcessExecutor::new(cancelled.clone());
252 let report = report.clone();
253 hosts.spawn_blocking(move || {
254 let host = refresh.host.label();
255 let lock = crate::image_pull_gate::image_pull_mutex(&refresh.host, &refresh.image);
258 let held =
259 crate::image_pull_gate::hold_image_pull(&lock, || executor.is_cancelled(), || {});
260 let outcome = held.and_then(|guard| {
261 let outcome = refresh_host_image(&refresh, &executor, &*report);
262 drop(guard);
263 outcome
264 });
265 let error = match outcome {
266 Ok(_) => None,
267 Err(error) if executor.is_cancelled() => {
268 tracing::debug!(
271 host,
272 image = refresh.image,
273 error = format!("{error:#}"),
274 "container image refresh cancelled"
275 );
276 return None;
277 }
278 Err(error) => {
279 tracing::warn!(
280 host,
281 image = refresh.image,
282 error = format!("{error:#}"),
283 "could not refresh a container image"
284 );
285 Some(format!("{error:#}"))
286 }
287 };
288 Some((host, refresh.image, error))
289 });
290 }
291 let mut cancelling = false;
292 loop {
293 tokio::select! {
294 biased;
295 _ = cancellation.cancelled(), if !cancelling => {
296 cancelling = true;
297 cancelled.store(true, Ordering::Release);
298 }
299 joined = hosts.join_next() => match joined {
300 None => return,
301 Some(Ok(None)) => {}
302 Some(Ok(Some((host, image, error)))) => {
303 record_refresh_result(last_failures, &host, &image, error, &**report);
304 }
305 Some(Err(error)) => {
306 tracing::warn!(%error, "container image refresh task failed");
307 }
308 },
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
315pub(super) enum ImageRefreshOutcome {
316 Present,
319 Unchanged,
321 Pulled { id: String },
323}
324
325pub(super) fn refresh_host_image(
336 refresh: &ImageRefresh,
337 executor: &impl CommandExecutor,
338 report: &dyn Fn(ImageRefreshReport),
339) -> Result<ImageRefreshOutcome> {
340 let host = refresh.host.label();
341 let cached = image_id(&refresh.image_id, executor);
342 if refresh.when == RefreshWhen::WhenAbsent && cached.is_some() {
343 tracing::debug!(
344 host,
345 image = refresh.image,
346 "the host already has this container image"
347 );
348 return Ok(ImageRefreshOutcome::Present);
349 }
350 if cached.is_none() {
354 report(ImageRefreshReport::Started {
355 host: host.clone(),
356 image: refresh.image.clone(),
357 });
358 }
359 run_refresh_command(&refresh.pull, executor)?;
360 let pulled = image_id(&refresh.image_id, executor);
361 let outcome = if pulled.is_some() && (cached.is_none() || pulled != cached) {
362 let id = pulled.unwrap_or_default();
363 tracing::info!(
364 host,
365 image = refresh.image,
366 id,
367 "pulled a newer container image"
368 );
369 report(ImageRefreshReport::Pulled {
370 host,
371 image: refresh.image.clone(),
372 });
373 ImageRefreshOutcome::Pulled { id }
374 } else {
375 tracing::debug!(
376 host,
377 image = refresh.image,
378 "container image is already current"
379 );
380 ImageRefreshOutcome::Unchanged
381 };
382 if let Some(prune) = &refresh.prune {
383 run_refresh_command(prune, executor)?;
384 }
385 Ok(outcome)
386}
387
388pub(super) fn image_id(command: &CommandSpec, executor: &impl CommandExecutor) -> Option<String> {
391 let output = executor.execute(command).ok()?;
392 if output.status != 0 {
393 return None;
394 }
395 let id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
396 (!id.is_empty()).then_some(id)
397}
398
399pub(super) fn run_refresh_command(
400 command: &CommandSpec,
401 executor: &impl CommandExecutor,
402) -> Result<()> {
403 let output = executor.execute(command)?;
404 if output.status != 0 {
405 bail!(
406 "{} failed with status {}: {}",
407 command.purpose,
408 output.status,
409 String::from_utf8_lossy(&output.stderr).trim()
410 );
411 }
412 Ok(())
413}
414
415pub fn complete_manual_quota_refresh(
416 pending_generation: &mut Option<u64>,
417 completed_generation: u64,
418) -> bool {
419 if *pending_generation != Some(completed_generation) {
420 return false;
421 }
422 *pending_generation = None;
423 true
424}