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 type QuotaCacheLoader =
15 Arc<dyn Fn(&QuotaRefreshRequest) -> Option<crate::quota::ProfileQuota> + Send + Sync>;
16
17pub(super) fn next_probe_at(report: Option<&crate::quota::ProfileQuota>) -> u64 {
21 report.map_or(0, |report| {
22 (report.refreshed_at_epoch_seconds + QUOTA_REFRESH_INTERVAL.as_secs())
23 .max(report.rate_limited_until_epoch_seconds.unwrap_or(0))
24 })
25}
26
27fn on_hold(report: Option<&crate::quota::ProfileQuota>, now: u64) -> bool {
29 report
30 .and_then(|report| report.rate_limited_until_epoch_seconds)
31 .is_some_and(|until| until > now)
32}
33
34pub fn spawn_quota_refresher(
41 cache: QuotaCacheLoader,
42) -> (
43 tokio::sync::watch::Sender<QuotaRefreshBatch>,
44 tokio::sync::mpsc::Receiver<QuotaUpdate>,
45) {
46 let (profiles_tx, mut profiles_rx) = tokio::sync::watch::channel(QuotaRefreshBatch::default());
47 let (updates_tx, updates_rx) = tokio::sync::mpsc::channel(32);
48 tokio::spawn(async move {
49 let mut quotas = QuotaManager::default();
50 let mut batch = QuotaRefreshBatch::default();
51 let mut identities: std::collections::BTreeMap<String, String> = Default::default();
54 loop {
55 let now = epoch_seconds();
56 let wake = batch
57 .profiles
58 .iter()
59 .map(|request| next_probe_at(quotas.report(&request.profile_id)))
60 .min()
61 .map(|due| Duration::from_secs(due.saturating_sub(now).max(5)));
62 tokio::select! {
63 _ = tokio::time::sleep(wake.unwrap_or_default()), if wake.is_some() => {}
64 changed = profiles_rx.changed() => {
65 if changed.is_err() {
66 tracing::debug!("quota profile target feed closed; stopping quota refresher");
67 break;
68 }
69 batch = profiles_rx.borrow_and_update().clone();
70 if !adopt_profiles(&mut quotas, &mut identities, &batch, &cache, &updates_tx).await {
71 break;
72 }
73 }
74 }
75 let now = epoch_seconds();
76 let due = batch
77 .profiles
78 .iter()
79 .filter(|request| {
80 let report = quotas.report(&request.profile_id);
81 !on_hold(report, now) && (batch.refresh || next_probe_at(report) <= now)
84 })
85 .cloned()
86 .collect::<Vec<_>>();
87 if due.is_empty() && !batch.refresh {
91 continue;
92 }
93 let generation = batch.generation;
94 batch.refresh = false;
95 if !refresh_profile_quotas(&mut quotas, generation, &due, &updates_tx).await {
96 break;
97 }
98 }
99 quotas.shutdown().await;
100 });
101 (profiles_tx, updates_rx)
102}
103
104async fn adopt_profiles(
108 quotas: &mut QuotaManager,
109 identities: &mut std::collections::BTreeMap<String, String>,
110 batch: &QuotaRefreshBatch,
111 cache: &QuotaCacheLoader,
112 updates: &tokio::sync::mpsc::Sender<QuotaUpdate>,
113) -> bool {
114 let keep = batch
115 .profiles
116 .iter()
117 .map(|request| request.profile_id.clone())
118 .collect::<std::collections::BTreeSet<_>>();
119 identities.retain(|id, _| keep.contains(id));
120 quotas.retain_profiles(&keep).await;
121 for request in &batch.profiles {
122 let identity = request.cache_identity();
123 if identities.get(&request.profile_id) == Some(&identity) {
124 continue;
125 }
126 identities.insert(request.profile_id.clone(), identity);
127 quotas.forget(&request.profile_id);
128 let load = cache.clone();
129 let for_request = request.clone();
130 let stored = match tokio::task::spawn_blocking(move || load(&for_request)).await {
131 Ok(stored) => stored,
132 Err(error) => {
133 tracing::warn!(%error, "stored quota read task failed");
134 None
135 }
136 };
137 let Some(stored) = stored.filter(|report| {
138 report.error.is_none() && next_probe_at(Some(report)) > epoch_seconds()
139 }) else {
140 continue;
141 };
142 tracing::debug!(
143 profile_id = %request.profile_id,
144 refreshed_at = stored.refreshed_at_epoch_seconds,
145 "using the stored quota report; the next probe waits for it to age"
146 );
147 quotas.seed(stored.clone());
148 let outcome = QuotaRefreshOutcome {
149 report: stored,
150 credentials_changed: false,
151 };
152 if updates.send(QuotaUpdate::Report(outcome)).await.is_err() {
153 return false;
154 }
155 }
156 true
157}
158
159pub(super) async fn refresh_profile_quotas(
160 quotas: &mut QuotaManager,
161 generation: u64,
162 profiles: &[QuotaRefreshRequest],
163 updates: &tokio::sync::mpsc::Sender<QuotaUpdate>,
164) -> bool {
165 let ids = profiles
166 .iter()
167 .map(|profile| profile.profile_id.clone())
168 .collect::<Vec<_>>();
169 if updates
170 .send(QuotaUpdate::Refreshing { profile_ids: ids })
171 .await
172 .is_err()
173 {
174 tracing::debug!("quota update consumer closed before refresh started");
175 return false;
176 }
177 let delivered = AtomicBool::new(true);
180 quotas
181 .probe(profiles.to_vec(), |quota| {
182 let delivered = &delivered;
183 async move {
184 if delivered.load(Ordering::Acquire)
185 && updates.send(QuotaUpdate::Report(quota)).await.is_err()
186 {
187 tracing::debug!("quota update consumer closed while reporting a profile");
188 delivered.store(false, Ordering::Release);
189 }
190 }
191 })
192 .await;
193 if !delivered.into_inner() {
194 return false;
195 }
196 if updates
197 .send(QuotaUpdate::Finished { generation })
198 .await
199 .is_err()
200 {
201 tracing::debug!(
202 generation,
203 "quota update consumer closed before refresh completed"
204 );
205 false
206 } else {
207 true
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum ImageRefreshReport {
217 Started { host: String, image: String },
219 Pulled { host: String, image: String },
221 Failed {
223 host: String,
224 image: String,
225 error: String,
226 },
227}
228
229pub fn spawn_image_refresher(
241 plan: impl Fn() -> Vec<ImageRefresh> + Send + 'static,
242 report: impl Fn(ImageRefreshReport) + Send + Sync + 'static,
243 cancellation: tokio_util::sync::CancellationToken,
244) -> tokio::task::JoinHandle<()> {
245 let report: Arc<dyn Fn(ImageRefreshReport) + Send + Sync> = Arc::new(report);
246 tokio::spawn(async move {
247 let mut interval = tokio::time::interval_at(
248 tokio::time::Instant::now() + IMAGE_REFRESH_DELAY,
249 IMAGE_REFRESH_INTERVAL,
250 );
251 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
254 let mut last_failures: BTreeMap<String, String> = BTreeMap::new();
257 loop {
258 tokio::select! {
259 biased;
262 _ = cancellation.cancelled() => return,
263 _ = interval.tick() => {
264 refresh_images(plan(), &report, &mut last_failures, &cancellation).await;
265 }
266 }
267 }
268 })
269}
270
271fn refresh_key(host: &str, image: &str) -> String {
274 format!("{host}|{image}")
275}
276
277pub(super) fn record_refresh_result(
284 last_failures: &mut BTreeMap<String, String>,
285 host: &str,
286 image: &str,
287 error: Option<String>,
288 report: &dyn Fn(ImageRefreshReport),
289) {
290 let key = refresh_key(host, image);
291 let Some(error) = error else {
292 last_failures.remove(&key);
293 return;
294 };
295 if last_failures.get(&key) == Some(&error) {
296 return;
297 }
298 last_failures.insert(key, error.clone());
299 report(ImageRefreshReport::Failed {
300 host: host.to_owned(),
301 image: image.to_owned(),
302 error,
303 });
304}
305
306pub(super) fn local_engine_installed(host: &ImageHost, path: Option<&std::ffi::OsStr>) -> bool {
311 match host {
312 ImageHost::LocalPodman | ImageHost::LocalDocker | ImageHost::AppleContainer => {
313 crate::targets::program_on_path(host.engine(), path)
314 }
315 ImageHost::SshPodman(_) | ImageHost::SshDocker(_) => true,
316 }
317}
318
319pub(super) async fn refresh_images(
320 plan: Vec<ImageRefresh>,
321 report: &Arc<dyn Fn(ImageRefreshReport) + Send + Sync>,
322 last_failures: &mut BTreeMap<String, String>,
323 cancellation: &tokio_util::sync::CancellationToken,
324) {
325 if plan.is_empty() {
326 return;
327 }
328 let cancelled = Arc::new(AtomicBool::new(false));
333 let mut hosts = tokio::task::JoinSet::new();
334 for refresh in plan {
335 if !local_engine_installed(&refresh.host, std::env::var_os("PATH").as_deref()) {
340 tracing::debug!(
341 host = refresh.host.label(),
342 image = refresh.image,
343 "container engine is not installed; skipping the image refresh"
344 );
345 continue;
346 }
347 let executor = CancellableProcessExecutor::new(cancelled.clone());
350 let report = report.clone();
351 hosts.spawn_blocking(move || {
352 let host = refresh.host.label();
353 let lock = crate::image_pull_gate::image_pull_mutex(&refresh.host, &refresh.image);
356 let held =
357 crate::image_pull_gate::hold_image_pull(&lock, || executor.is_cancelled(), || {});
358 let outcome = held.and_then(|guard| {
359 let outcome = refresh_host_image(&refresh, &executor, &*report);
360 drop(guard);
361 outcome
362 });
363 let error = match outcome {
364 Ok(_) => None,
365 Err(error) if executor.is_cancelled() => {
366 tracing::debug!(
369 host,
370 image = refresh.image,
371 error = format!("{error:#}"),
372 "container image refresh cancelled"
373 );
374 return None;
375 }
376 Err(error) => {
377 tracing::warn!(
378 host,
379 image = refresh.image,
380 error = format!("{error:#}"),
381 "could not refresh a container image"
382 );
383 Some(format!("{error:#}"))
384 }
385 };
386 Some((host, refresh.image, error))
387 });
388 }
389 let mut cancelling = false;
390 loop {
391 tokio::select! {
392 biased;
393 _ = cancellation.cancelled(), if !cancelling => {
394 cancelling = true;
395 cancelled.store(true, Ordering::Release);
396 }
397 joined = hosts.join_next() => match joined {
398 None => return,
399 Some(Ok(None)) => {}
400 Some(Ok(Some((host, image, error)))) => {
401 record_refresh_result(last_failures, &host, &image, error, &**report);
402 }
403 Some(Err(error)) => {
404 tracing::warn!(%error, "container image refresh task failed");
405 }
406 },
407 }
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
413pub(super) enum ImageRefreshOutcome {
414 Present,
417 Unchanged,
419 Pulled { id: String },
421}
422
423pub(super) fn refresh_host_image(
434 refresh: &ImageRefresh,
435 executor: &impl CommandExecutor,
436 report: &dyn Fn(ImageRefreshReport),
437) -> Result<ImageRefreshOutcome> {
438 let host = refresh.host.label();
439 let cached = image_id(&refresh.image_id, executor);
440 if refresh.when == RefreshWhen::WhenAbsent && cached.is_some() {
441 tracing::debug!(
442 host,
443 image = refresh.image,
444 "the host already has this container image"
445 );
446 return Ok(ImageRefreshOutcome::Present);
447 }
448 if cached.is_none() {
452 report(ImageRefreshReport::Started {
453 host: host.clone(),
454 image: refresh.image.clone(),
455 });
456 }
457 run_refresh_command(&refresh.pull, executor)?;
458 let pulled = image_id(&refresh.image_id, executor);
459 let outcome = if pulled.is_some() && (cached.is_none() || pulled != cached) {
460 let id = pulled.unwrap_or_default();
461 tracing::info!(
462 host,
463 image = refresh.image,
464 id,
465 "pulled a newer container image"
466 );
467 report(ImageRefreshReport::Pulled {
468 host,
469 image: refresh.image.clone(),
470 });
471 ImageRefreshOutcome::Pulled { id }
472 } else {
473 tracing::debug!(
474 host,
475 image = refresh.image,
476 "container image is already current"
477 );
478 ImageRefreshOutcome::Unchanged
479 };
480 if let Some(prune) = &refresh.prune {
481 run_refresh_command(prune, executor)?;
482 }
483 Ok(outcome)
484}
485
486pub(super) fn image_id(command: &CommandSpec, executor: &impl CommandExecutor) -> Option<String> {
489 let output = executor.execute(command).ok()?;
490 if output.status != 0 {
491 return None;
492 }
493 let id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
494 (!id.is_empty()).then_some(id)
495}
496
497pub(super) fn run_refresh_command(
498 command: &CommandSpec,
499 executor: &impl CommandExecutor,
500) -> Result<()> {
501 let output = executor.execute(command)?;
502 if output.status != 0 {
503 bail!(
504 "{} failed with status {}: {}",
505 command.purpose,
506 output.status,
507 String::from_utf8_lossy(&output.stderr).trim()
508 );
509 }
510 Ok(())
511}
512
513pub fn complete_manual_quota_refresh(
519 pending_cycles: &mut Option<u64>,
520 finished_cycles: u64,
521) -> bool {
522 if !pending_cycles.is_some_and(|pending| finished_cycles > pending) {
523 return false;
524 }
525 *pending_cycles = None;
526 true
527}