//! Caches container images as gzipped `docker save` tarballs in S3. //! //! Instances download these tarballs via pre-signed URLs and `docker load` them, so they never //! authenticate against a registry (no piped token, no IAM role, no credential helper). use crate::aws::{ s3::{cache_and_presign, object_exists, presign_url, UploadSource, PRESIGN_DURATION}, services::{image_s3_key, sanitize_image}, Architecture, Error, }; use aws_sdk_s3::Client as S3Client; use std::{path::Path, process::Stdio}; use tokio::process::Command; use tracing::info; /// Caches a single image (for one architecture) as a gzipped tarball in S3 and returns a /// pre-signed download URL. Skips the `docker save` work if the tarball is already cached. pub(crate) async fn cache_image( s3_client: &S3Client, bucket: &str, tag_directory: &Path, image: &'static str, architecture: Architecture, ) -> Result { let key = image_s3_key(image, architecture); if object_exists(s3_client, bucket, &key).await? { info!(image, architecture = %architecture, "image already cached in S3"); return presign_url(s3_client, bucket, &key, PRESIGN_DURATION).await; } info!(image, architecture = %architecture, "caching image to S3"); let stem = format!("{}-{}", sanitize_image(image), architecture.as_str()); let tar_path = tag_directory.join(format!("{stem}.tar")); let gz_path = tag_directory.join(format!("{stem}.tar.gz")); let tar_str = tar_path.to_string_lossy().into_owned(); let platform = format!("linux/{}", architecture.as_str()); // Pull and save the target platform explicitly. The containerd image store records a tag's // full multi-platform index even when a pull fetches only one platform's blobs, so a bare // `docker save` attempts to export every manifest in the index and fails on the platforms // that were never pulled. `docker save --platform` (Docker 28+) exports only the pulled // platform and also lets the deployer build tarballs for any target architecture. Pull and // save never run the image, so the deployer's own architecture is irrelevant. run_command("docker", &["pull", "--platform", &platform, image]).await?; run_command( "docker", &[ "save", "--platform", &platform, image, "-o", tar_str.as_str(), ], ) .await?; // `gzip -f` replaces the tar with `{tar}.gz` (== gz_path). run_command("gzip", &["-f", tar_str.as_str()]).await?; let url = cache_and_presign( s3_client, bucket, &key, UploadSource::File(&gz_path), PRESIGN_DURATION, ) .await?; let _ = std::fs::remove_file(&gz_path); Ok(url) } async fn run_command(program: &str, args: &[&str]) -> Result<(), Error> { let command = format!("{program} {}", args.join(" ")); let output = Command::new(program) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await?; if output.status.success() { return Ok(()); } let mut stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !stdout.is_empty() { if !stderr.is_empty() { stderr.push('\n'); } stderr.push_str(&stdout); } Err(Error::CommandFailed { command, stderr }) }