| 1 | //! The g1t command line. |
| 2 | |
| 3 | mod chunk; |
| 4 | mod credentials; |
| 5 | mod image; |
| 6 | mod reference; |
| 7 | mod registry; |
| 8 | |
| 9 | use reference::Reference; |
| 10 | use std::path::{Path, PathBuf}; |
| 11 | use std::process::{Command, ExitCode, Stdio}; |
| 12 | |
| 13 | const HELP: &str = "\ |
| 14 | g1t, the command line for g1t.sh |
| 15 | |
| 16 | Usage: |
| 17 | g1t push <image>[:<tag>] [--as g1t.sh/<workspace>/<name>:<tag>] |
| 18 | [--chunk-size 90MB] [--token-stdin] |
| 19 | g1t push --archive <file.tar> --as g1t.sh/<workspace>/<name>:<tag> |
| 20 | g1t help |
| 21 | g1t --version |
| 22 | |
| 23 | Commands: |
| 24 | push Send a local docker image to g1t.sh. Each layer goes up in |
| 25 | chunks, so a layer can be any size. |
| 26 | |
| 27 | Push options: |
| 28 | --as <address> Where to push, when the image's own name is not an |
| 29 | address on g1t.sh. The tag defaults to latest. |
| 30 | --chunk-size <n> How much of a layer each request carries: 5MB to |
| 31 | 95MB, 90MB unless set. MB and MiB both mean 1,048,576 |
| 32 | bytes. |
| 33 | --token-stdin Read the g1t token from stdin. |
| 34 | --archive <file> Push a tarball `docker save` wrote, instead of asking |
| 35 | docker for the image. |
| 36 | |
| 37 | The token is the first of: --token-stdin, the G1T_TOKEN environment |
| 38 | variable, or what `docker login g1t.sh` stored. |
| 39 | "; |
| 40 | |
| 41 | fn main() -> ExitCode { |
| 42 | let args: Vec<String> = std::env::args().skip(1).collect(); |
| 43 | let result = match args.first().map(String::as_str) { |
| 44 | None | Some("help" | "-h" | "--help") => { |
| 45 | print!("{HELP}"); |
| 46 | Ok(()) |
| 47 | } |
| 48 | Some("-V" | "--version" | "version") => { |
| 49 | println!("g1t {}", env!("CARGO_PKG_VERSION")); |
| 50 | Ok(()) |
| 51 | } |
| 52 | Some("push") if args.iter().any(|a| a == "-h" || a == "--help") => { |
| 53 | print!("{HELP}"); |
| 54 | Ok(()) |
| 55 | } |
| 56 | Some("push") => PushArgs::parse(&args[1..]).and_then(|push| push.run()), |
| 57 | Some(other) => Err(format!("Unknown command `{other}`. See `g1t help`.")), |
| 58 | }; |
| 59 | match result { |
| 60 | Ok(()) => ExitCode::SUCCESS, |
| 61 | Err(error) => { |
| 62 | eprintln!("g1t: {error}"); |
| 63 | ExitCode::FAILURE |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | #[derive(Debug, PartialEq, Eq)] |
| 69 | struct PushArgs { |
| 70 | /// The local image, or the tarball it was saved to. |
| 71 | image: Option<String>, |
| 72 | archive: Option<PathBuf>, |
| 73 | target: Reference, |
| 74 | chunk_size: u64, |
| 75 | token_stdin: bool, |
| 76 | } |
| 77 | |
| 78 | impl PushArgs { |
| 79 | fn parse(args: &[String]) -> Result<PushArgs, String> { |
| 80 | let mut image = None; |
| 81 | let mut target = None; |
| 82 | let mut chunk_size = chunk::DEFAULT_CHUNK; |
| 83 | let mut token_stdin = false; |
| 84 | let mut archive = None; |
| 85 | let mut args = args.iter(); |
| 86 | while let Some(arg) = args.next() { |
| 87 | let (flag, inline) = match arg.split_once('=') { |
| 88 | Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_owned())), |
| 89 | _ => (arg.as_str(), None), |
| 90 | }; |
| 91 | let mut value = |name: &str| { |
| 92 | inline |
| 93 | .clone() |
| 94 | .or_else(|| args.next().cloned()) |
| 95 | .ok_or_else(|| format!("{name} needs a value.")) |
| 96 | }; |
| 97 | match flag { |
| 98 | "--as" => target = Some(value("--as")?), |
| 99 | "--chunk-size" => { |
| 100 | chunk_size = chunk::check_chunk(chunk::parse_size(&value("--chunk-size")?)?)? |
| 101 | } |
| 102 | "--token-stdin" => token_stdin = true, |
| 103 | "--archive" => archive = Some(PathBuf::from(value("--archive")?)), |
| 104 | flag if flag.starts_with('-') => { |
| 105 | return Err(format!("Unknown option `{flag}`. See `g1t help`.")); |
| 106 | } |
| 107 | _ if image.is_none() => image = Some(arg.clone()), |
| 108 | _ => return Err(format!("`{arg}`: push takes one image.")), |
| 109 | } |
| 110 | } |
| 111 | if image.is_some() && archive.is_some() { |
| 112 | return Err("Push an image or an --archive, not both.".to_owned()); |
| 113 | } |
| 114 | let target = match (target, &image) { |
| 115 | (Some(address), _) => Reference::parse(&address)? |
| 116 | .ok_or_else(|| format!("`{address}` does not name a registry, like g1t.sh/<workspace>/<name>:<tag>."))?, |
| 117 | (None, Some(image)) => Reference::parse(image)?.ok_or_else(|| { |
| 118 | format!("`{image}` is not an address on g1t.sh. Say where to push it: --as g1t.sh/<workspace>/<name>:<tag>.") |
| 119 | })?, |
| 120 | (None, None) if archive.is_some() => return Err("Say where to push the archive: --as g1t.sh/<workspace>/<name>:<tag>.".to_owned()), |
| 121 | (None, None) => return Err("Name the image to push: g1t push <image>[:<tag>].".to_owned()), |
| 122 | }; |
| 123 | Ok(PushArgs { |
| 124 | image, |
| 125 | archive, |
| 126 | target, |
| 127 | chunk_size, |
| 128 | token_stdin, |
| 129 | }) |
| 130 | } |
| 131 | |
| 132 | fn run(self) -> Result<(), String> { |
| 133 | let credentials = credentials::find(&self.target.host, self.token_stdin)?; |
| 134 | let work = WorkDir::new()?; |
| 135 | let tar = match (&self.archive, &self.image) { |
| 136 | (Some(archive), _) => { |
| 137 | println!("Reading {}", archive.display()); |
| 138 | archive.clone() |
| 139 | } |
| 140 | (None, image) => { |
| 141 | let image = image.as_deref().unwrap_or_default(); |
| 142 | println!("Reading {image} from docker"); |
| 143 | let tar = work.0.join("image.tar"); |
| 144 | docker_save(image, &tar)?; |
| 145 | tar |
| 146 | } |
| 147 | }; |
| 148 | let image = image::load(&tar, &work.0)?; |
| 149 | let compressed = image |
| 150 | .layers |
| 151 | .iter() |
| 152 | .filter(|l| matches!(l.source, image::Source::File(_))) |
| 153 | .count(); |
| 154 | if image.converted { |
| 155 | println!( |
| 156 | "Compressed {compressed} layer(s) saved uncompressed, and wrote the image a manifest naming them" |
| 157 | ); |
| 158 | } |
| 159 | let mut registry = registry::Registry::new( |
| 160 | self.target.base_url(), |
| 161 | self.target.host.clone(), |
| 162 | self.target.name.clone(), |
| 163 | credentials, |
| 164 | ); |
| 165 | registry.sign_in()?; |
| 166 | println!("Pushing to {}", self.target); |
| 167 | let blobs = std::iter::once(("config", &image.config)) |
| 168 | .chain(image.layers.iter().map(|l| ("layer", l))); |
| 169 | let mut seen = std::collections::HashSet::new(); |
| 170 | for (kind, blob) in blobs { |
| 171 | if !seen.insert(blob.digest.clone()) { |
| 172 | continue; |
| 173 | } |
| 174 | let short = &blob.digest[..blob.digest.len().min(19)]; |
| 175 | let size = chunk::human(blob.size); |
| 176 | let outcome = registry.push_blob(blob, &tar, self.chunk_size)?; |
| 177 | let said = match outcome { |
| 178 | registry::Outcome::Exists => "already on the registry, skipped".to_owned(), |
| 179 | registry::Outcome::Uploaded { chunks: 1 } => "uploaded".to_owned(), |
| 180 | registry::Outcome::Uploaded { chunks } => format!("uploaded in {chunks} chunks"), |
| 181 | }; |
| 182 | println!(" {kind:<6} {short} {size:>10} {said}"); |
| 183 | } |
| 184 | let ours = image.manifest_digest(); |
| 185 | let theirs = |
| 186 | registry.push_manifest(&self.target.tag, &image.media_type, &image.manifest)?; |
| 187 | if !theirs.is_empty() && theirs != ours { |
| 188 | return Err(format!( |
| 189 | "The registry kept the manifest as {theirs}, not {ours}." |
| 190 | )); |
| 191 | } |
| 192 | println!("Pushed {}", self.target); |
| 193 | println!("digest: {ours}"); |
| 194 | Ok(()) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | /// `docker save <image>`, streamed into `out`. |
| 199 | fn docker_save(image: &str, out: &Path) -> Result<(), String> { |
| 200 | let mut file = std::fs::File::create(out) |
| 201 | .map_err(|e| format!("Could not write {}: {e}", out.display()))?; |
| 202 | let mut child = Command::new("docker") |
| 203 | .args(["save", image]) |
| 204 | .stdout(Stdio::piped()) |
| 205 | .stderr(Stdio::piped()) |
| 206 | .spawn() |
| 207 | .map_err(|e| format!("Could not run docker: {e}"))?; |
| 208 | let mut stdout = child.stdout.take().ok_or("docker gave no output.")?; |
| 209 | let copied = std::io::copy(&mut stdout, &mut file); |
| 210 | let output = child |
| 211 | .wait_with_output() |
| 212 | .map_err(|e| format!("docker save failed: {e}"))?; |
| 213 | if !output.status.success() { |
| 214 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 215 | return Err(format!("docker save {image} failed: {}", stderr.trim())); |
| 216 | } |
| 217 | copied.map_err(|e| format!("Could not save the image: {e}"))?; |
| 218 | Ok(()) |
| 219 | } |
| 220 | |
| 221 | /// A directory for the saved image and compressed layers, removed after. |
| 222 | struct WorkDir(PathBuf); |
| 223 | |
| 224 | impl WorkDir { |
| 225 | fn new() -> Result<WorkDir, String> { |
| 226 | let nanos = std::time::SystemTime::now() |
| 227 | .duration_since(std::time::UNIX_EPOCH) |
| 228 | .map(|d| d.as_nanos()) |
| 229 | .unwrap_or_default(); |
| 230 | let dir = std::env::temp_dir().join(format!("g1t-push-{}-{nanos}", std::process::id())); |
| 231 | std::fs::create_dir_all(&dir) |
| 232 | .map_err(|e| format!("Could not make {}: {e}", dir.display()))?; |
| 233 | Ok(WorkDir(dir)) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | impl Drop for WorkDir { |
| 238 | fn drop(&mut self) { |
| 239 | let _ = std::fs::remove_dir_all(&self.0); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | #[cfg(test)] |
| 244 | mod tests { |
| 245 | use super::*; |
| 246 | |
| 247 | fn parse(args: &[&str]) -> Result<PushArgs, String> { |
| 248 | PushArgs::parse(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>()) |
| 249 | } |
| 250 | |
| 251 | #[test] |
| 252 | fn an_image_named_for_g1t_pushes_to_itself() { |
| 253 | let push = parse(&["g1t.sh/acme/web:1"]).unwrap(); |
| 254 | assert_eq!(push.target.to_string(), "g1t.sh/acme/web:1"); |
| 255 | assert_eq!(push.chunk_size, chunk::DEFAULT_CHUNK); |
| 256 | assert!(!push.token_stdin); |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn as_names_the_destination() { |
| 261 | let push = parse(&[ |
| 262 | "web:dev", |
| 263 | "--as", |
| 264 | "g1t.sh/acme/web:2", |
| 265 | "--chunk-size=50MB", |
| 266 | "--token-stdin", |
| 267 | ]) |
| 268 | .unwrap(); |
| 269 | assert_eq!(push.image.as_deref(), Some("web:dev")); |
| 270 | assert_eq!(push.target.to_string(), "g1t.sh/acme/web:2"); |
| 271 | assert_eq!(push.chunk_size, 50 * chunk::MIB); |
| 272 | assert!(push.token_stdin); |
| 273 | } |
| 274 | |
| 275 | #[test] |
| 276 | fn bad_pushes_are_explained() { |
| 277 | assert!(parse(&["web:dev"]).unwrap_err().contains("--as")); |
| 278 | assert!(parse(&[]).is_err()); |
| 279 | assert!( |
| 280 | parse(&["g1t.sh/acme/web", "--chunk-size", "200MB"]) |
| 281 | .unwrap_err() |
| 282 | .contains("95MiB") |
| 283 | ); |
| 284 | assert!(parse(&["g1t.sh/acme/web", "--bogus"]).is_err()); |
| 285 | assert!(parse(&["a", "b", "--as", "g1t.sh/acme/web"]).is_err()); |
| 286 | assert!( |
| 287 | parse(&["--archive", "web.tar"]) |
| 288 | .unwrap_err() |
| 289 | .contains("--as") |
| 290 | ); |
| 291 | assert!(parse(&["web:dev", "--archive", "web.tar", "--as", "g1t.sh/acme/web"]).is_err()); |
| 292 | } |
| 293 | |
| 294 | #[test] |
| 295 | fn an_archive_pushes_without_docker() { |
| 296 | let push = parse(&["--archive", "web.tar", "--as", "g1t.sh/acme/web:3"]).unwrap(); |
| 297 | assert_eq!(push.archive, Some(PathBuf::from("web.tar"))); |
| 298 | assert_eq!(push.image, None); |
| 299 | } |
| 300 | } |