Skip to content

g1t/crates/g1t/src/main.rs

300 lines11,008 bytesCodeBlame
1//! The g1t command line.
2
3mod chunk;
4mod credentials;
5mod image;
6mod reference;
7mod registry;
8
9use reference::Reference;
10use std::path::{Path, PathBuf};
11use std::process::{Command, ExitCode, Stdio};
12
13const HELP: &str = "\
14g1t, the command line for g1t.sh
15
16Usage:
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
23Commands:
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
27Push 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
37The token is the first of: --token-stdin, the G1T_TOKEN environment
38variable, or what `docker login g1t.sh` stored.
39";
40
41fn 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)]
69struct 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
78impl 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`.
199fn 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.
222struct WorkDir(PathBuf);
223
224impl 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
237impl Drop for WorkDir {
238 fn drop(&mut self) {
239 let _ = std::fs::remove_dir_all(&self.0);
240 }
241}
242
243#[cfg(test)]
244mod 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}