Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

226 linesCodeBlameRaw
1//! `g1t push`: a local image to g1t's registry, each layer in chunks.
2
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5
6use crate::cli::PushArgs;
7use crate::reference::Reference;
8use crate::{chunk, credentials, image, registry};
9
10#[derive(Debug, PartialEq, Eq)]
11pub 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
20impl 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`.
116fn 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.
139struct WorkDir(PathBuf);
140
141impl 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
154impl Drop for WorkDir {
155 fn drop(&mut self) {
156 let _ = std::fs::remove_dir_all(&self.0);
157 }
158}
159
160#[cfg(test)]
161mod 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}