mod parser; mod version; mod docker; mod manifest; mod dockerfile; mod metrics; mod db; mod registry; mod updater; use crate::docker::DockerRef; use crate::manifest::ManifestFile; use crate::dockerfile::DockerfileFile; use crate::db::{Db, SqliteDb}; use crate::registry::{Registries, HttpRegistry, Config, Credentials, basic_auth}; use crate::updater::{FileInput, FilePatch, update_images}; use rand::distr::{Alphanumeric, SampleString}; use std::io::Write; fn help(cmd: &str) { println!( "{} [options] [--] Search FILE for docker images and suggest updates Options: --auth Authenticate against REGISTRY (repeatable) --anonymous-auth Access REGISTRY anonymously (repeatable) --config Read config from PATH --scratch Store temporary files in DIR --metrics-port Serve Prometheus metrics on PORT (default: disabled)", cmd ); } struct Repository { url: String, key: Option, host: Option, dest: std::path::PathBuf, } enum Output { Overlay(std::fs::File, std::path::PathBuf, std::path::PathBuf), Stdout(std::io::Stdout), } impl Output { fn overlay_file(infile_path: &std::path::Path) -> Self { loop { let mut filename = std::ffi::OsString::new(); filename.push(infile_path.file_name().unwrap()); filename.push(std::ffi::OsStr::new(".edit")); filename.push(Alphanumeric.sample_string(&mut rand::rng(), 16)); let outfile_path = Some(infile_path.parent().unwrap().join(filename)); if let Ok(output_file) = std::fs::File::create_new(outfile_path.as_ref().unwrap()) { return Output::Overlay(output_file, outfile_path.unwrap().into(), infile_path.into()); } } } fn commit(&mut self) { match self { Output::Overlay(_, outfile_path, infile_path) => { std::fs::remove_file(&infile_path).unwrap(); std::fs::rename(&outfile_path, &infile_path).unwrap(); } Output::Stdout(_) => {} } } } impl std::ops::Deref for Output { type Target = dyn Write; fn deref(&self) -> &Self::Target { match self { Output::Overlay(file, _, _) => file, Output::Stdout(stdout) => stdout, } } } impl std::ops::DerefMut for Output { fn deref_mut(&mut self) -> &mut Self::Target { match self { Output::Overlay(file, _, _) => file, Output::Stdout(stdout) => stdout, } } } impl std::io::Write for Output { fn write(&mut self, buf: &[u8]) -> std::io::Result { return (**self).write(buf); } fn flush(&mut self) -> std::io::Result<()> { return (**self).flush(); } fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { return (**self).write_vectored(bufs); } fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { return (**self).write_all(buf); } fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> { return (**self).write_fmt(args); } } fn is_dockerfile(path: &std::path::Path) -> bool { if let Some(name) = path.file_name() { if let Some(name) = name.to_str() { return name == "Dockerfile" || name == "dockerfile"; } } return false; } fn is_yaml(path: &std::path::Path) -> bool { if let Some(ext) = path.extension() { if let Some(ext) = ext.to_str() { return ext == "yaml"; } } return false; } fn run_tool(db: &dyn Db, reg: &dyn Registries, repos: &Vec, mut infile_paths: Vec, overwrite: bool) -> chrono::DateTime { for repo in repos { if repo.dest.exists() { let mut gitcmd = std::process::Command::new("git"); gitcmd .arg("-C") .arg(&repo.dest) .arg("fetch") .arg("origin"); if let Some(key) = &repo.key && let Some(host) = &repo.host { gitcmd.env("GIT_SSH_COMMAND", format!("ssh -F none -o IdentitiesOnly=yes -o UserKnownHostsFile=\"{}\" -i \"{}\"", host.to_str().unwrap(), key.to_str().unwrap())); } let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { panic!("Git exited with failure"); } let mut gitcmd = std::process::Command::new("git"); gitcmd .arg("-C") .arg(&repo.dest) .arg("reset") .arg("--hard") .arg("@{u}"); let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { panic!("Git exited with failure"); } let mut gitcmd = std::process::Command::new("git"); gitcmd .arg("-C") .arg(&repo.dest) .arg("clean") .arg("--force") .arg("-d") .arg("-x"); let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { panic!("Git exited with failure"); } } else { let mut gitcmd = std::process::Command::new("git"); println!("Cloning {}", repo.url); gitcmd .arg("clone") .arg(&repo.url) .arg(&repo.dest); if let Some(key) = &repo.key && let Some(host) = &repo.host { gitcmd.env("GIT_SSH_COMMAND", format!("ssh -F none -o IdentitiesOnly=yes -o UserKnownHostsFile=\"{}\" -i \"{}\"", host.to_str().unwrap(), key.to_str().unwrap())); } let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { panic!("Git exited with failure"); } } infile_paths.push(repo.dest.clone()); } let mut inpaths = vec![]; for infile_path in infile_paths { if infile_path.is_dir() { let mut unsearched = vec![infile_path.clone()]; while let Some(next) = unsearched.pop() { for child in next.read_dir().unwrap() { let child = child.unwrap(); let path = child.path(); let ft = child.file_type().unwrap(); if ft.is_dir() { unsearched.push(path); continue; } if is_yaml(&path) || is_dockerfile(&path) { inpaths.push(path); } } } } else { inpaths.push(infile_path); }; } let mut file_inputs: Vec = vec![]; for path in inpaths { let content = std::fs::read_to_string(&path).unwrap(); let images = if is_dockerfile(&path) { DockerfileFile::parse(&content).image_refs } else { ManifestFile::parse(&content).image_tags }; file_inputs.push(FileInput { path, content, images }); } let now = chrono::offset::Utc::now(); let mut outcomes: Vec, String>> = Vec::with_capacity(file_inputs.len()); update_images(&now, db, reg, &file_inputs, &mut outcomes); for i in 0..file_inputs.len() { let file = &file_inputs[i]; match &outcomes[i] { Ok(patches) => { if patches.is_empty() { continue; } let mut out = if overwrite { Output::overlay_file(&file.path) } else { Output::Stdout(std::io::stdout()) }; let mut current_position = 0; for patch in patches { if patch.position.start > current_position { out.write_all(file.content[current_position..patch.position.start].as_bytes()).unwrap(); } out.write_all(patch.content.as_bytes()).unwrap(); current_position = patch.position.end; } out.write_all(file.content[current_position..].as_bytes()).unwrap(); out.commit(); } Err(msg) => { println!("{}: {}", file.path.display(), msg); } } } for repo in repos { let mut gitcmd = std::process::Command::new("git"); gitcmd .arg("-c") .arg("user.name=vbump") .arg("-c") .arg("user.email=jesper@jnsn.dev") .arg("-C") .arg(&repo.dest) .arg("commit") .arg("--all") .arg("--message") .arg("Update versions"); let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { metrics::get().git_operations.with_label_values(&["commit", "failure"]).inc(); println!("Commit failed, presumably there were no changes"); continue; } metrics::get().git_operations.with_label_values(&["commit", "success"]).inc(); let mut gitcmd = std::process::Command::new("git"); gitcmd .arg("-C") .arg(&repo.dest) .arg("push") .arg("origin") .arg("+HEAD:version-bump"); if let Some(key) = &repo.key && let Some(host) = &repo.host { gitcmd.env("GIT_SSH_COMMAND", format!("ssh -F none -o IdentitiesOnly=yes -o UserKnownHostsFile=\"{}\" -i \"{}\"", host.to_str().unwrap(), key.to_str().unwrap())); } let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { metrics::get().git_operations.with_label_values(&["push", "failure"]).inc(); panic!("Git exited with failure"); } metrics::get().git_operations.with_label_values(&["push", "success"]).inc(); } let now = chrono::offset::Utc::now(); let mut min_expiry = now + chrono::Duration::hours(24); for file in &file_inputs { for image_range in &file.images { let img = DockerRef::parse(&file.content, image_range); let registry = img.registry.as_ref() .map(|x| &file.content[x.clone()]) .unwrap_or("registry.hub.docker.com"); let image_name = &file.content[img.image.clone()]; if let Some((_, expires_at)) = db.get_image(registry, image_name) { if expires_at < min_expiry { min_expiry = expires_at; } } } } if min_expiry < now { min_expiry = now; } return min_expiry; } fn main() { let argv: Vec = std::env::args().collect(); let mut it = argv.iter(); let cmd = &it.next().unwrap(); let mut auths = vec![]; let mut positional: Vec<&str> = vec!(); let mut overwrite = false; let mut config_path = None; let mut scratch_path = None; let mut continuous = false; let mut metrics_port: Option = None; loop { match it.next().map(|x| x.as_str()) { None => break, Some("--auth") => { if let Some(registry) = it.next() && let Some(username) = it.next() && let Some(password) = it.next() { auths.push(Config { host: registry.clone(), credentials: Credentials::Basic(basic_auth(username, password)), cache_ttl: chrono::Duration::minutes(1440), }); } else { println!("Error: --auth requires three parameters"); help(cmd); std::process::exit(1); } }, Some("--anonymous-auth") => { if let Some(registry) = it.next() { auths.push(Config { host: registry.clone(), credentials: Credentials::Anonymous, cache_ttl: chrono::Duration::minutes(1440), }); } else { println!("Error: --anonymous-auth requires a registry parameter"); help(cmd); std::process::exit(1); } }, Some("--config") => { if let Some(path) = it.next() { config_path = Some(std::path::PathBuf::from(path)); } else { println!("Error: --config requires an parameters"); help(cmd); std::process::exit(1); } }, Some("--scratch") => { if let Some(path) = it.next() { scratch_path = Some(std::path::PathBuf::from(path)); } else { println!("Error: --scratch requires an parameters"); help(cmd); std::process::exit(1); } }, Some("--continuous") => { continuous = true; }, Some("--metrics-port") => { if let Some(port_str) = it.next() { match port_str.parse::() { Ok(port) => metrics_port = Some(port), Err(_) => { println!("Error: --metrics-port requires a valid port number"); help(cmd); std::process::exit(1); } } } else { println!("Error: --metrics-port requires a parameter"); help(cmd); std::process::exit(1); } }, Some("-i") | Some("--inplace") => { overwrite = true; }, Some("-h") | Some("--help") => { help(cmd); std::process::exit(0); }, Some(arg) => positional.push(arg), }; } let mut infile_paths = vec![]; if positional.len() < 1 { panic!("BAD ARGUMENTS"); } let sqlite_db = SqliteDb::new(&std::path::PathBuf::from(positional[0])); if positional.len() > 1 { infile_paths.push(std::path::PathBuf::from(positional[1])); } let mut repos = vec![]; let mut docker_auths: Vec<(String, Credentials)> = vec![]; let mut registry_ttls: Vec<(String, chrono::Duration)> = vec![]; let workdir = std::env::current_dir().unwrap(); if let Some(config_path) = config_path { if !config_path.is_dir() { println!("config is not a directory"); std::process::exit(1); } let mut unsearched = vec![config_path]; while let Some(next) = unsearched.pop() { for child in next.read_dir().unwrap() { let child = child.unwrap(); let path = child.path(); let ft = child.file_type().unwrap(); if ft.is_dir() { unsearched.push(path); continue; } if let Some(name) = path.file_name() { match name.to_str() { Some(".dockerconfigjson") => { let file = std::fs::File::open(&path).unwrap(); let mut reader = json_event_parser::ReaderJsonParser::new(file); match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "auths" => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid config json"), } let mut possible_key = reader.parse_next(); while let Ok(json_event_parser::JsonEvent::ObjectKey(k)) = possible_key { let key = k.into_owned(); match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "auth" => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::String(v)) => { docker_auths.push((key, Credentials::Basic(v.into_owned()))); }, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid config json"), } possible_key = reader.parse_next(); } match possible_key { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid config json"), } } Some("repository") => { let file = std::fs::File::open(&path).unwrap(); let mut reader = json_event_parser::ReaderJsonParser::new(file); match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "repositories" => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid config json"), } let mut possible_key = reader.parse_next(); while let Ok(json_event_parser::JsonEvent::ObjectKey(k)) = possible_key { let k = k.into_owned(); match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "key" => {}, _ => panic!("Invalid config json"), } let key_name = match reader.parse_next() { Ok(json_event_parser::JsonEvent::String(v)) => { v.into_owned() }, _ => panic!("Invalid config json"), }; match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "host" => {}, _ => panic!("Invalid config json"), } let host_name = match reader.parse_next() { Ok(json_event_parser::JsonEvent::String(v)) => { v.into_owned() }, _ => panic!("Invalid config json"), }; match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "dest" => {}, _ => panic!("Invalid config json"), } let dest = match reader.parse_next() { Ok(json_event_parser::JsonEvent::String(v)) => { v.into_owned() }, _ => panic!("Invalid config json"), }; match reader.parse_next() { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid config json"), } repos.push(Repository { url: k, key: Some(workdir.join(path.parent().unwrap().join(std::path::PathBuf::from(key_name)))), host: Some(workdir.join(path.parent().unwrap().join(std::path::PathBuf::from(host_name)))), dest: scratch_path.as_ref().unwrap().join(dest), }); possible_key = reader.parse_next(); } match possible_key { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid config json"), } } Some("registry") => { let file = std::fs::File::open(&path).unwrap(); let mut reader = json_event_parser::ReaderJsonParser::new(file); match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid registry config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "registries" => {}, _ => panic!("Invalid registry config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid registry config json"), } let mut possible_key = reader.parse_next(); while let Ok(json_event_parser::JsonEvent::ObjectKey(k)) = possible_key { let host = k.into_owned(); match reader.parse_next() { Ok(json_event_parser::JsonEvent::StartObject) => {}, _ => panic!("Invalid registry config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::ObjectKey(k)) if &k == "cache_ttl_minutes" => {}, _ => panic!("Invalid registry config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::Number(n)) => { let minutes: i64 = n.parse().unwrap(); registry_ttls.push((host, chrono::Duration::minutes(minutes))); }, _ => panic!("Invalid registry config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid registry config json"), } possible_key = reader.parse_next(); } match possible_key { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid registry config json"), } match reader.parse_next() { Ok(json_event_parser::JsonEvent::EndObject) => {}, _ => panic!("Invalid registry config json"), } } Some(_) | None => {} } } } } } // Merge docker_auths and registry_ttls into final configs for (host, creds) in docker_auths { let mut ttl = chrono::Duration::minutes(1440); for (h, t) in ®istry_ttls { if h == &host { ttl = *t; break; } } auths.push(Config { host, credentials: creds, cache_ttl: ttl }); } // Add registry_ttls entries that don't have auth (anonymous) for (host, ttl) in registry_ttls { let mut found = false; for c in &auths { if c.host == host { found = true; break; } } if !found { auths.push(Config { host, credentials: Credentials::Anonymous, cache_ttl: ttl, }); } } let registry = HttpRegistry::new(auths); metrics::init(); metrics::get().state.set(metrics::STATE_IDLE); if let Some(port) = metrics_port { metrics::serve(port); } loop { metrics::get().state.set(metrics::STATE_ACTIVE); let run_start = std::time::Instant::now(); let next_wakeup = run_tool(&sqlite_db, ®istry, &repos, infile_paths.clone(), overwrite); let run_duration = run_start.elapsed().as_secs_f64(); metrics::get().run_duration.set(run_duration); metrics::get().next_wakeup.set(next_wakeup.timestamp() as f64); metrics::get().state.set(metrics::STATE_IDLE); if !continuous { break; } let sleep_duration = next_wakeup - chrono::offset::Utc::now(); println!("Waiting {} seconds for next run", sleep_duration.num_seconds()); std::thread::sleep(sleep_duration.to_std().unwrap()); } }