use microsandbox::sandbox::{PullProgress, PullProgressHandle};
use microsandbox::{MicrosandboxError, MicrosandboxResult, Sandbox};
use std::collections::HashMap;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::Instant;
pub const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullUpdate {
pub image: String,
pub downloaded_bytes: u64,
pub total_bytes: Option<u64>,
pub complete: bool,
}
#[derive(Debug, Default)]
pub struct PullTracker {
image: String,
total_bytes: Option<u64>,
layer_bytes: HashMap<usize, u64>,
downloading: bool,
last_sent: Option<Instant>,
}
impl PullTracker {
pub fn update(&mut self, event: &PullProgress, now: Instant) -> Option<PullUpdate> {
match event {
PullProgress::Resolved {
reference,
total_download_bytes,
..
} => self.resolve(reference, *total_download_bytes),
PullProgress::LayerDownloadProgress {
layer_index,
downloaded_bytes,
..
} => {
self.downloading = true;
self.record(*layer_index, *downloaded_bytes);
self.throttled(now)
}
PullProgress::LayerDownloadComplete {
layer_index,
downloaded_bytes,
..
} => {
self.record(*layer_index, *downloaded_bytes);
self.sent(now)
}
PullProgress::Complete { .. } => self.downloading.then(|| self.message(true)),
_ => None,
}
}
fn resolve(&mut self, reference: &str, total_bytes: Option<u64>) -> Option<PullUpdate> {
self.image = reference.to_string();
self.total_bytes = total_bytes;
None
}
fn record(&mut self, layer_index: usize, downloaded_bytes: u64) {
let bytes = self.layer_bytes.entry(layer_index).or_default();
*bytes = (*bytes).max(downloaded_bytes);
}
fn throttled(&mut self, now: Instant) -> Option<PullUpdate> {
if self
.last_sent
.is_some_and(|last| now.duration_since(last) < PROGRESS_INTERVAL)
{
return None;
}
self.sent(now)
}
fn sent(&mut self, now: Instant) -> Option<PullUpdate> {
if !self.downloading {
return None;
}
self.last_sent = Some(now);
Some(self.message(false))
}
fn message(&self, complete: bool) -> PullUpdate {
PullUpdate {
image: self.image.clone(),
downloaded_bytes: self.layer_bytes.values().sum(),
total_bytes: self.total_bytes,
complete,
}
}
}
pub async fn wait_for_create(
progress: PullProgressHandle,
mut task: JoinHandle<MicrosandboxResult<Sandbox>>,
mut on_progress: impl FnMut(PullUpdate) + Send,
) -> MicrosandboxResult<Sandbox> {
let mut events = progress.into_receiver();
let mut tracker = PullTracker::default();
let mut report = |event: PullProgress| {
if let Some(message) = tracker.update(&event, Instant::now()) {
on_progress(message);
}
};
loop {
tokio::select! {
Some(event) = events.recv() => report(event),
result = &mut task => {
while let Ok(event) = events.try_recv() {
report(event);
}
return result.map_err(|err| {
MicrosandboxError::Custom(format!("sandbox creation task failed: {err}"))
})?;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use microsandbox_image::PullProgressSender;
use std::sync::Arc;
const IMAGE: &str = "ghcr.io/wmeints/firebrick-base:test";
fn resolved(total: Option<u64>) -> PullProgress {
PullProgress::Resolved {
reference: Arc::from(IMAGE),
manifest_digest: Arc::from("sha256:abc"),
layer_count: 2,
total_download_bytes: total,
}
}
fn layer_progress(layer_index: usize, downloaded_bytes: u64) -> PullProgress {
PullProgress::LayerDownloadProgress {
layer_index,
digest: Arc::from("sha256:layer"),
downloaded_bytes,
total_bytes: None,
}
}
fn layer_complete(layer_index: usize, downloaded_bytes: u64) -> PullProgress {
PullProgress::LayerDownloadComplete {
layer_index,
digest: Arc::from("sha256:layer"),
downloaded_bytes,
}
}
fn complete() -> PullProgress {
PullProgress::Complete {
reference: Arc::from(IMAGE),
layer_count: 2,
}
}
fn progress(downloaded_bytes: u64, total_bytes: Option<u64>) -> PullUpdate {
PullUpdate {
image: IMAGE.to_string(),
downloaded_bytes,
total_bytes,
complete: false,
}
}
fn track(events: &[PullProgress]) -> Vec<PullUpdate> {
let mut tracker = PullTracker::default();
let start = Instant::now();
(0u32..)
.zip(events)
.filter_map(|(step, event)| tracker.update(event, start + PROGRESS_INTERVAL * step))
.collect()
}
#[test]
fn cached_image_sends_no_progress() {
let events = [
PullProgress::Resolving {
reference: Arc::from(IMAGE),
},
resolved(Some(300)),
complete(),
];
assert_eq!(track(&events), vec![]);
}
#[test]
fn cached_layers_alone_send_no_progress() {
let events = [resolved(Some(300)), layer_complete(0, 100), complete()];
assert_eq!(track(&events), vec![]);
}
#[test]
fn adds_up_the_bytes_of_the_layers() {
let events = [
resolved(Some(300)),
layer_progress(0, 50),
layer_progress(1, 20),
layer_progress(0, 100),
layer_complete(1, 200),
complete(),
];
assert_eq!(
track(&events),
vec![
progress(50, Some(300)),
progress(70, Some(300)),
progress(120, Some(300)),
progress(300, Some(300)),
PullUpdate {
complete: true,
..progress(300, Some(300))
},
]
);
}
#[test]
fn counts_cached_layers_once_a_layer_downloads() {
let events = [
resolved(Some(300)),
layer_complete(0, 100),
layer_progress(1, 50),
];
assert_eq!(track(&events), vec![progress(150, Some(300))]);
}
#[test]
fn keeps_the_total_unset_when_the_manifest_has_no_sizes() {
let events = [resolved(None), layer_progress(0, 50)];
assert_eq!(track(&events), vec![progress(50, None)]);
}
async fn fail_after_a_pull(sender: PullProgressSender) -> MicrosandboxResult<Sandbox> {
sender.send(resolved(Some(300)));
sender.send(layer_progress(0, 300));
sender.send(complete());
Err(MicrosandboxError::Custom("boom".to_string()))
}
#[tokio::test]
async fn wait_for_create_reports_the_events_sent_with_the_result() {
let (handle, sender) = microsandbox_image::progress_channel();
let task = tokio::spawn(fail_after_a_pull(sender));
let mut updates = vec![];
let result = wait_for_create(handle, task, |update| updates.push(update)).await;
assert_eq!(result.err().map(|err| err.to_string()), Some("boom".into()));
assert_eq!(
updates.last(),
Some(&PullUpdate {
complete: true,
..progress(300, Some(300))
})
);
}
#[test]
fn sends_completed_layers_despite_the_throttle() {
let mut tracker = PullTracker::default();
let start = Instant::now();
tracker.update(&resolved(Some(300)), start);
let first = tracker.update(&layer_progress(0, 50), start);
let completed = tracker.update(&layer_complete(0, 300), start + PROGRESS_INTERVAL / 2);
assert_eq!(first, Some(progress(50, Some(300))));
assert_eq!(completed, Some(progress(300, Some(300))));
}
#[test]
fn throttles_progress_but_not_completion() {
let mut tracker = PullTracker::default();
let start = Instant::now();
tracker.update(&resolved(Some(300)), start);
let first = tracker.update(&layer_progress(0, 50), start);
let skipped = tracker.update(&layer_progress(0, 60), start + PROGRESS_INTERVAL / 2);
let completed = tracker.update(&complete(), start + PROGRESS_INTERVAL / 2);
assert_eq!(first, Some(progress(50, Some(300))));
assert_eq!(skipped, None);
assert_eq!(
completed,
Some(PullUpdate {
complete: true,
..progress(60, Some(300))
})
);
}
}