mod parser; use crate::parser::*; use base64::prelude::*; use rand::distr::{Alphanumeric, SampleString}; use rusqlite::Connection; use rusqlite::OptionalExtension; use yaml_rust2::parser::Parser; use yaml_rust2::Event; use std::collections::HashMap; use std::ops::Range; use std::sync; use std::io::Read; use std::io::Seek; use std::io::Write; fn help(cmd: &str) { println!( "{} [options] [--] Search FILE for docker images and suggest updates Options: --auth Authenticate against REGISTRY (repeatable) --config Read config from PATH --scratch Store temporary files in DIR", cmd ); } #[derive(Debug)] enum AuthMethod { Basic, Bearer{realm: String, service: String, scope: String}, } impl AuthMethod { fn from_header(header: &ureq::http::HeaderValue) -> Option { let header_str = header.to_str().unwrap(); let parse = parse_authenticate_header(header_str).unwrap(); if &header_str[parse.scheme.clone()] == "Basic" { return Some(AuthMethod::Basic); } if &header_str[parse.scheme.clone()] == "Bearer" { return Some(AuthMethod::Bearer{ realm: header_str[parse.realm.unwrap()].to_string(), scope: header_str[parse.scope.unwrap()].to_string(), service: header_str[parse.service.unwrap()].to_string(), }); } return None; } } fn basic_auth(user: &str, pass: &str) -> String { return BASE64_STANDARD.encode(format!("{}:{}", user, pass)); } struct AuthInfo { host: String, auth: String, } enum AuthStage { Idle, Authorized(String), } struct AuthState { info: AuthInfo, stage: AuthStage, } struct Auth { states: HashMap } impl Auth { fn new(infos: Vec) -> Self { let mut states = HashMap::new(); for info in infos { states.insert(info.host.clone(), AuthState { info: info, stage: AuthStage::Idle, }); } return Auth { states } } } impl AuthState{ fn add_to_request(&self, req: ureq::RequestBuilder) -> ureq::RequestBuilder { if let AuthStage::Authorized(x) = &self.stage { return req.header("Authorization", x); } return req } fn authenticate(&mut self, response: &ureq::http::Response) -> Result<(), String> { match self.stage { AuthStage::Authorized(_) => // The token must have expired self.stage = AuthStage::Idle, AuthStage::Idle => {}, } let auth_header = response.headers().get("www-authenticate").unwrap(); match AuthMethod::from_header(auth_header) { Some(AuthMethod::Basic) => { let basic_auth = format!("Basic {}", self.info.auth); self.stage = AuthStage::Authorized(basic_auth); return Ok(()); }, Some(AuthMethod::Bearer{realm, scope, service}) => { let basic_auth = format!("Basic {}", self.info.auth); let url = format!("{}?service={}&scope={}", realm, service, scope); let body = ureq::get(url) .config().http_status_as_error(false).build() .header("Authorization", basic_auth) .call(); let body = body.map_err(|x| format!("Server {} authentication request failed: {}", self.info.host, x.to_string()))? .body_mut().read_to_string().expect(format!("Server {} responded with something non-string like", self.info.host).as_str()); let body = body.parse::() .unwrap(); let token = body["token"].get::().unwrap(); self.stage = AuthStage::Authorized(format!("Bearer {}", token)); return Ok(()); }, None => Err(format!("Server {} provided us with a challenge, but we didn't understand it", self.info.host)), } } } fn perform_registry_request(registry: &str, url: &str, accept: &'static str, auth: &mut Auth) -> Result, String> { let mut state = auth.states.get_mut(registry); let url = format!("https://{}{}", registry, &url); let mut authentication_retry = false; loop { let mut request = ureq::get(&url) .header("Accept", accept) .config().http_status_as_error(false).build(); if let Some(ref state) = state { request = state.add_to_request(request); } let response = request.call().unwrap(); if response.status() == 401 && !authentication_retry { if let Some(ref mut state) = state { state.authenticate(&response)?; authentication_retry = true; continue; } else { return Err(format!("Server {} returned 401 but we have no credentials", registry)); } } if response.status() != 200 { return Err(format!("Unexpected status code: {}", response.status())) } return Ok(response); } return Err("Authorization failed".to_string()); } struct Repository { url: String, key: Option, dest: std::path::PathBuf, } enum YContext { InDocument, InObject, InSequence, InValue(bool), } #[derive(Debug)] struct ManifestFile { image_tags: Vec>, } impl ManifestFile { fn parse(content: &str) -> Self { let mut yaml = Parser::new_from_str(content); let mut images = vec!(); let mut scope = vec!(); loop { let (ev, mark) = yaml.next_token().unwrap(); match ev { Event::StreamStart => {} Event::StreamEnd => { break; } Event::DocumentStart => { scope.push(YContext::InDocument); } Event::DocumentEnd => { assert!(matches!(scope.pop().unwrap(), YContext::InDocument)); scope.pop_if(|x| matches!(x, YContext::InValue(_))); }, Event::MappingStart(_, _) => { scope.push(YContext::InObject); }, Event::MappingEnd => { assert!(matches!(scope.pop().unwrap(), YContext::InObject)); scope.pop_if(|x| matches!(x, YContext::InValue(_))); }, Event::SequenceStart(_, _) => { scope.push(YContext::InSequence); }, Event::SequenceEnd => { assert!(matches!(scope.pop().unwrap(), YContext::InSequence)); scope.pop_if(|x| matches!(x, YContext::InValue(_))); }, Event::Scalar(ref txt, _, _, _) => { let parent = scope.last().unwrap(); match parent { YContext::InObject => { // We are the key of a mapping, which means the next even is the value scope.push(YContext::InValue(txt == "image")); }, YContext::InSequence => {}, YContext::InValue(img) => { if *img { images.push(mark.index()..mark.index() + txt.len()); } scope.pop(); }, // This should only happen for entirely empty documents YContext::InDocument => assert!(txt == ""), _ => panic!(), } }, x => todo!("{:?}", x), } } return Self{ image_tags: images, }; } } #[derive(Debug, Clone)] struct DockerRef { registry: Option>, image: Range, tag: Option>, digest: Option>, } impl DockerRef{ fn parse(content: &str, chunk: &Range) -> DockerRef { let mut string_range = chunk.clone(); let mut digest = None; if let Some(idx) = content[string_range.clone()].rfind("@") { digest = Some(string_range.start+idx+1..string_range.end); string_range.end = string_range.start+idx; } let mut tag = None; if let Some(idx) = content[string_range.clone()].rfind(":") { tag = Some(string_range.start+idx+1..string_range.end); string_range.end = string_range.start+idx; } let mut registry = None; let image; if let Some(idx) = content[string_range.clone()].find("/") { let head = &content[string_range.clone()][..idx]; if head.contains(":") || head.contains(".") { registry = Some(string_range.start..string_range.start+idx); image = string_range.start+idx+1..string_range.end; } else { registry = None; image = string_range; } } else { image = string_range; } return DockerRef { registry, image, tag, digest, }; } } #[derive(Debug)] struct FilePatch { position: Range, content: String, } #[derive(Debug)] enum VersionPart { String(String), Number(u64), Hash, } #[derive(Debug)] struct VersionPattern { parts: Vec, } #[derive(Debug)] enum CompareOutcome { Higher, Lower, Incompatible, Identical, } impl VersionPattern { fn parse(tag: &str) -> Self { static RE: sync::LazyLock = sync::LazyLock::new(|| regex::Regex::new(r"(?[a-f0-9]{32})|(?[^0-9]+)|(?[0-9]+)").unwrap()); let mut parts = vec![]; for it in RE.captures_iter(tag) { if let Some(x) = it.name("str") { parts.push(VersionPart::String(x.as_str().to_string())); } else if let Some(x) = it.name("num") { parts.push(VersionPart::Number(x.as_str().parse().unwrap())); } else if let Some(_) = it.name("hash") { parts.push(VersionPart::Hash); } } return VersionPattern { parts, } } fn compare(&self, other: &Self) -> CompareOutcome { if self.parts.len() != other.parts.len() { return CompareOutcome::Incompatible; } let mut state = CompareOutcome::Identical; for (self_part, other_part) in self.parts.iter().zip(other.parts.iter()) { match (&state, self_part, other_part) { (_, VersionPart::String(x1), VersionPart::String(x2)) => if x1 != x2 { return CompareOutcome::Incompatible }, (_, VersionPart::String(_), _) => return CompareOutcome::Incompatible, (CompareOutcome::Identical, VersionPart::Number(x1), VersionPart::Number(x2)) => { if x1 > x2 { state = CompareOutcome::Lower; } else if x1 < x2 { state = CompareOutcome::Higher; } }, (_, VersionPart::Number(_), VersionPart::Number(_)) => {}, (_, VersionPart::Number(_), _) => return CompareOutcome::Incompatible, (_, VersionPart::Hash, VersionPart::Hash) => {}, (_, VersionPart::Hash, _) => return CompareOutcome::Incompatible, } } return state; } } fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, edits: &mut Vec) -> Result<(), String> { let registry = img.registry.map(|x| &file[x]).unwrap_or("registry.hub.docker.com"); let mut tag = img.tag.as_ref().map(|x| file[x.clone()].to_string()); if let Some(ref tag_str) = tag { let mut current = VersionPattern::parse(&tag_str); let mut new_tag = None; let mut url = format!("/v2/{}/tags/list", &file[img.image.clone()]); loop { let mut response = perform_registry_request(registry, &url, "application/vnd.oci.image.index.v1+json", auth).unwrap(); let content_type = response.headers()["Content-Type"].to_str().unwrap(); if !content_type.starts_with("application/json") { return Err(format!("Unexpected Content-Type: {}", content_type)); } let body = response.body_mut().read_to_string().unwrap(); let body = body.parse::().unwrap(); for it in body["tags"].get::>().unwrap().iter() { let candidate_str = it.get::().unwrap(); db.execute(" INSERT INTO known_images(registry, image, tag, discovered) VALUES (?1, ?2, ?3, ?4) ", (registry, &file[img.image.clone()], candidate_str, chrono::offset::Utc::now())).unwrap(); let candidate = VersionPattern::parse(candidate_str); match current.compare(&candidate) { CompareOutcome::Higher => { new_tag = Some(candidate_str.clone()); current = candidate; }, CompareOutcome::Lower => {}, CompareOutcome::Incompatible => {}, CompareOutcome::Identical => {}, } } if let Some(link_header) = response.headers().get("link") { let link_str = link_header.to_str().unwrap(); let link = extract_next_page(&link_str).unwrap(); url = link_str[link.next_uri.unwrap().clone()].to_string(); } else { break; } } if let Some(new_tag) = new_tag { tag = Some(new_tag.clone()); edits.push(FilePatch { position: img.tag.unwrap(), content: new_tag, }); } } if let Some(ref digest) = img.digest { // Find the digest for the selected tag let url = format!("/v2/{}/manifests/{}", &file[img.image.clone()], tag.as_deref().unwrap_or("latest")); let response = perform_registry_request(registry, &url, "application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json", auth).unwrap(); let digest_string = response.headers()["docker-content-digest"].to_str().unwrap().to_string(); if file[digest.clone()] != digest_string { edits.push(FilePatch{ position: img.digest.unwrap(), content: digest_string, }); } } return Ok(()); } 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 run_tool(db: &Connection, auth: &mut Auth, repos: &Vec, mut infile_paths: Vec, overwrite: bool) { 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 { gitcmd.env("GIT_SSH_COMMAND", format!("ssh -F none -o IdentitiesOnly=yes -i \"{}\"", 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"); gitcmd .arg("clone") .arg(&repo.url) .arg(&repo.dest); if let Some(key) = &repo.key { gitcmd.env("GIT_SSH_COMMAND", format!("ssh -F none -o IdentitiesOnly=yes -i \"{}\"", 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 let Some(ext) = path.extension() { if ext == "yaml" { inpaths.push(path); } } } } } else { inpaths.push(infile_path); }; } for infile_path in inpaths { let mut file = std::fs::File::open(&infile_path).unwrap(); let mut file_content = String::new(); file.read_to_string(&mut file_content).unwrap(); let images = ManifestFile::parse(&file_content); let mut failed = None; let mut edits = vec![]; for ref image in images.image_tags { println!("Checking image {}", &file_content[image.clone()]); let image_ref = DockerRef::parse(&file_content, image); if let Err(msg) = update_images(db, &file_content, auth, image_ref, &mut edits) { failed = Some(msg); break; } } if let Some(msg) = failed { println!("{}: {} ", infile_path.display(), msg); continue; } let mut out = if overwrite { Output::overlay_file(&infile_path) } else { Output::Stdout(std::io::stdout()) }; let mut current_position = 0; file.seek(std::io::SeekFrom::Start(0)).unwrap(); let mut file_block = file.take(0); for edit in edits { if edit.position.start > current_position { file_block.set_limit((edit.position.start - current_position) as u64); std::io::copy(&mut file_block, &mut out).unwrap(); } out.write_all(edit.content.as_bytes()).unwrap(); // We should have been able to just seek to the position.end here, but that doesn't work // for whatever reason. What we can do is calculate the amount to skip ahead, and then do // that. let skip = (edit.position.end - edit.position.start) as i64; file_block.set_limit(skip as u64); file_block.seek(std::io::SeekFrom::Current(skip)).unwrap(); current_position = edit.position.end; } file_block.set_limit(u64::MAX); std::io::copy(&mut file_block, &mut out).unwrap(); out.commit(); } for repo in repos { let mut gitcmd = std::process::Command::new("git"); gitcmd .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() { println!("Commit failed, presumably there were no changes"); continue; } 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 { gitcmd.env("GIT_SSH_COMMAND", format!("ssh -F none -o IdentitiesOnly=yes -i \"{}\"", key.to_str().unwrap())); } let exit = gitcmd.status() .expect("Git command failed"); if !exit.success() { panic!("Git exited with failure"); } } } 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; 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(AuthInfo { host: registry.clone(), auth: basic_auth(username, password), }); } else { println!("Error: --auth requires three parameters"); 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("-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 db = Connection::open(positional[0]).unwrap(); if positional.len() > 1 { infile_paths.push(std::path::PathBuf::from(positional[1])); } { db.execute(" CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY NOT NULL ) ", ()).unwrap(); let newest_migration: u32 = db.query_row(" SELECT MAX(id) FROM migrations ", [], |row| row.get::<_, Option>(0)).unwrap().unwrap_or(0); if newest_migration < 1 { db.execute("INSERT INTO migrations(id) VALUES (?1)", (1, )).unwrap(); } if newest_migration < 2 { db.execute(" CREATE TABLE known_images ( id INTEGER PRIMARY KEY NOT NULL, registry TEXT NOT NULL, image TEXT NOT NULL, tag TEXT NOT NULL ) ", ()).unwrap(); db.execute("INSERT INTO migrations(id) VALUES (?1)", (2, )).unwrap(); } if newest_migration < 3 { db.execute(" ALTER TABLE known_images ADD COLUMN discovered DATETIME NOT NULL ", ()).unwrap(); db.execute("INSERT INTO migrations(id) VALUES (?1)", (3, )).unwrap(); } if newest_migration < 4 { db.execute(" CREATE UNIQUE INDEX known_images__registry_image_tag ON known_images(registry, image, tag) ", ()).unwrap(); db.execute("INSERT INTO migrations(id) VALUES (?1)", (4, )).unwrap(); } } let mut repos = vec![]; let workdir = std::env::current_dir().unwrap(); if let Some(config_path) = config_path { if !config_path.is_dir() { 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)) => { auths.push(AuthInfo { host: key, auth: 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 == "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)))), 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(_) | None => {} } } } } } let mut auth = Auth::new(auths); loop { run_tool(&db, &mut auth, &repos, infile_paths.clone(), overwrite); if !continuous { break; } println!("Waiting for next run"); std::thread::sleep(std::time::Duration::from_hours(24)); } }