| 1 | //! `g1t push`: a local image to g1t's registry, each layer in chunks. |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | use std::process::{Command, Stdio}; |
| 5 | |
| 6 | use crate::cli::PushArgs; |
| 7 | use crate::reference::Reference; |
| 8 | use crate::{chunk, credentials, image, registry}; |
| 9 | |
| 10 | #[derive(Debug, PartialEq, Eq)] |
| 11 | pub struct Push { |
| 12 | /// The local image, or the tarball it was saved to. |
| 13 | pub image: Option<String>, |
| 14 | pub archive: Option<PathBuf>, |
| 15 | pub target: Reference, |
| 16 | pub chunk_size: u64, |
| 17 | pub token_stdin: bool, |
| 18 | } |
| 19 | |
| 20 | impl Push { |
| 21 | pub fn from_args(args: PushArgs) -> Result<Push, String> { |
| 22 | let PushArgs { |
| 23 | image, |
| 24 | target, |
| 25 | chunk_size, |
| 26 | token_stdin, |
| 27 | archive, |
| 28 | } = args; |
| 29 | let target = match (target, &image) { |
| 30 | (Some(address), _) => Reference::parse(&address)? |
| 31 | .ok_or_else(|| format!("`{address}` does not name a registry, like g1t.sh/<workspace>/<name>:<tag>."))?, |
| 32 | (None, Some(image)) => Reference::parse(image)?.ok_or_else(|| { |
| 33 | format!("`{image}` is not an address on g1t.sh. Say where to push it: --as g1t.sh/<workspace>/<name>:<tag>.") |
| 34 | })?, |
| 35 | (None, None) if archive.is_some() => { |
| 36 | return Err("Say where to push the archive: --as g1t.sh/<workspace>/<name>:<tag>.".to_owned()); |
| 37 | } |
| 38 | (None, None) => return Err("Name the image to push: g1t push <image>[:<tag>].".to_owned()), |
| 39 | }; |
| 40 | Ok(Push { |
| 41 | image, |
| 42 | archive, |
| 43 | target, |
| 44 | chunk_size, |
| 45 | token_stdin, |
| 46 | }) |
| 47 | } |
| 48 | |
| 49 | pub fn run(self) -> Result<(), String> { |
| 50 | let credentials = credentials::find(&self.target.host, self.token_stdin)?; |
| 51 | let work = WorkDir::new()?; |
| 52 | let tar = match (&self.archive, &self.image) { |
| 53 | (Some(archive), _) => { |
| 54 | println!("Reading {}", archive.display()); |
| 55 | archive.clone() |
| 56 | } |
| 57 | (None, image) => { |
| 58 | let image = image.as_deref().unwrap_or_default(); |
| 59 | println!("Reading {image} from docker"); |
| 60 | let tar = work.0.join("image.tar"); |
| 61 | docker_save(image, &tar)?; |
| 62 | tar |
| 63 | } |
| 64 | }; |
| 65 | let image = image::load(&tar, &work.0)?; |
| 66 | let compressed = image |
| 67 | .layers |
| 68 | .iter() |
| 69 | .filter(|l| matches!(l.source, image::Source::File(_))) |
| 70 | .count(); |
| 71 | if image.converted { |
| 72 | println!( |
| 73 | "Compressed {compressed} layer(s) saved uncompressed, and wrote the image a manifest naming them" |
| 74 | ); |
| 75 | } |
| 76 | let mut registry = registry::Registry::new( |
| 77 | self.target.base_url(), |
| 78 | self.target.host.clone(), |
| 79 | self.target.name.clone(), |
| 80 | credentials, |
| 81 | ); |
| 82 | registry.sign_in()?; |
| 83 | println!("Pushing to {}", self.target); |
| 84 | let blobs = std::iter::once(("config", &image.config)) |
| 85 | .chain(image.layers.iter().map(|l| ("layer", l))); |
| 86 | let mut seen = std::collections::HashSet::new(); |
| 87 | for (kind, blob) in blobs { |
| 88 | if !seen.insert(blob.digest.clone()) { |
| 89 | continue; |
| 90 | } |
| 91 | let short = &blob.digest[..blob.digest.len().min(19)]; |
| 92 | let size = chunk::human(blob.size); |
| 93 | let outcome = registry.push_blob(blob, &tar, self.chunk_size)?; |
| 94 | let said = match outcome { |
| 95 | registry::Outcome::Exists => "already on the registry, skipped".to_owned(), |
| 96 | registry::Outcome::Uploaded { chunks: 1 } => "uploaded".to_owned(), |
| 97 | registry::Outcome::Uploaded { chunks } => format!("uploaded in {chunks} chunks"), |
| 98 | }; |
| 99 | println!(" {kind:<6} {short} {size:>10} {said}"); |
| 100 | } |
| 101 | let ours = image.manifest_digest(); |
| 102 | let theirs = |
| 103 | registry.push_manifest(&self.target.tag, &image.media_type, &image.manifest)?; |
| 104 | if !theirs.is_empty() && theirs != ours { |
| 105 | return Err(format!( |
| 106 | "The registry kept the manifest as {theirs}, not {ours}." |
| 107 | )); |
| 108 | } |
| 109 | println!("Pushed {}", self.target); |
| 110 | println!("digest: {ours}"); |
| 111 | Ok(()) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /// `docker save <image>`, streamed into `out`. |
| 116 | fn docker_save(image: &str, out: &Path) -> Result<(), String> { |
| 117 | let mut file = std::fs::File::create(out) |
| 118 | .map_err(|e| format!("Could not write {}: {e}", out.display()))?; |
| 119 | let mut child = Command::new("docker") |
| 120 | .args(["save", image]) |
| 121 | .stdout(Stdio::piped()) |
| 122 | .stderr(Stdio::piped()) |
| 123 | .spawn() |
| 124 | .map_err(|e| format!("Could not run docker: {e}"))?; |
| 125 | let mut stdout = child.stdout.take().ok_or("docker gave no output.")?; |
| 126 | let copied = std::io::copy(&mut stdout, &mut file); |
| 127 | let output = child |
| 128 | .wait_with_output() |
| 129 | .map_err(|e| format!("docker save failed: {e}"))?; |
| 130 | if !output.status.success() { |
| 131 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 132 | return Err(format!("docker save {image} failed: {}", stderr.trim())); |
| 133 | } |
| 134 | copied.map_err(|e| format!("Could not save the image: {e}"))?; |
| 135 | Ok(()) |
| 136 | } |
| 137 | |
| 138 | /// A directory for the saved image and compressed layers, removed after. |
| 139 | struct WorkDir(PathBuf); |
| 140 | |
| 141 | impl WorkDir { |
| 142 | fn new() -> Result<WorkDir, String> { |
| 143 | let nanos = std::time::SystemTime::now() |
| 144 | .duration_since(std::time::UNIX_EPOCH) |
| 145 | .map(|d| d.as_nanos()) |
| 146 | .unwrap_or_default(); |
| 147 | let dir = std::env::temp_dir().join(format!("g1t-push-{}-{nanos}", std::process::id())); |
| 148 | std::fs::create_dir_all(&dir) |
| 149 | .map_err(|e| format!("Could not make {}: {e}", dir.display()))?; |
| 150 | Ok(WorkDir(dir)) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | impl Drop for WorkDir { |
| 155 | fn drop(&mut self) { |
| 156 | let _ = std::fs::remove_dir_all(&self.0); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | #[cfg(test)] |
| 161 | mod tests { |
| 162 | use super::*; |
| 163 | use crate::cli::{Cli, Command}; |
| 164 | use clap::Parser; |
| 165 | |
| 166 | fn parse(args: &[&str]) -> Result<Push, String> { |
| 167 | let argv = std::iter::once("g1t") |
| 168 | .chain(std::iter::once("push")) |
| 169 | .chain(args.iter().copied()); |
| 170 | let cli = Cli::try_parse_from(argv).map_err(|e| e.to_string())?; |
| 171 | match cli.command { |
| 172 | Command::Push(args) => Push::from_args(args), |
| 173 | _ => unreachable!(), |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | #[test] |
| 178 | fn an_image_named_for_g1t_pushes_to_itself() { |
| 179 | let push = parse(&["g1t.sh/acme/web:1"]).unwrap(); |
| 180 | assert_eq!(push.target.to_string(), "g1t.sh/acme/web:1"); |
| 181 | assert_eq!(push.chunk_size, chunk::DEFAULT_CHUNK); |
| 182 | assert!(!push.token_stdin); |
| 183 | } |
| 184 | |
| 185 | #[test] |
| 186 | fn as_names_the_destination() { |
| 187 | let push = parse(&[ |
| 188 | "web:dev", |
| 189 | "--as", |
| 190 | "g1t.sh/acme/web:2", |
| 191 | "--chunk-size=50MB", |
| 192 | "--token-stdin", |
| 193 | ]) |
| 194 | .unwrap(); |
| 195 | assert_eq!(push.image.as_deref(), Some("web:dev")); |
| 196 | assert_eq!(push.target.to_string(), "g1t.sh/acme/web:2"); |
| 197 | assert_eq!(push.chunk_size, 50 * chunk::MIB); |
| 198 | assert!(push.token_stdin); |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn bad_pushes_are_explained() { |
| 203 | assert!(parse(&["web:dev"]).unwrap_err().contains("--as")); |
| 204 | assert!(parse(&[]).is_err()); |
| 205 | assert!( |
| 206 | parse(&["g1t.sh/acme/web", "--chunk-size", "200MB"]) |
| 207 | .unwrap_err() |
| 208 | .contains("95MiB") |
| 209 | ); |
| 210 | assert!(parse(&["g1t.sh/acme/web", "--bogus"]).is_err()); |
| 211 | assert!(parse(&["a", "b", "--as", "g1t.sh/acme/web"]).is_err()); |
| 212 | assert!( |
| 213 | parse(&["--archive", "web.tar"]) |
| 214 | .unwrap_err() |
| 215 | .contains("--as") |
| 216 | ); |
| 217 | assert!(parse(&["web:dev", "--archive", "web.tar", "--as", "g1t.sh/acme/web"]).is_err()); |
| 218 | } |
| 219 | |
| 220 | #[test] |
| 221 | fn an_archive_pushes_without_docker() { |
| 222 | let push = parse(&["--archive", "web.tar", "--as", "g1t.sh/acme/web:3"]).unwrap(); |
| 223 | assert_eq!(push.archive, Some(PathBuf::from("web.tar"))); |
| 224 | assert_eq!(push.image, None); |
| 225 | } |
| 226 | } |