diff options
| -rw-r--r-- | Cargo.lock | 31 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/main.rs | 69 | ||||
| -rw-r--r-- | src/metrics.rs | 147 |
4 files changed, 244 insertions, 4 deletions
@@ -33,6 +33,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -92,6 +98,12 @@ dependencies = [ ] [[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -237,6 +249,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] name = "iana-time-zone" version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -695,6 +713,18 @@ dependencies = [ ] [[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] name = "tinyjson" version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -764,6 +794,7 @@ dependencies = [ "rand", "regex", "rusqlite", + "tiny_http", "tinyjson", "ureq", "yaml-rust2", @@ -18,5 +18,6 @@ rand = "0.9.2" regex = "1.12.2" rusqlite = { version = "0.38.0", features = ["chrono"] } tinyjson = "2.5.1" +tiny_http = "0.12" ureq = "3.1.4" yaml-rust2 = "0.10.4" diff --git a/src/main.rs b/src/main.rs index a4a731a..ff3e596 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod version; mod docker; mod manifest; mod dockerfile; +mod metrics; use crate::parser::*; use crate::version::{VersionPattern, CompareOutcome}; @@ -28,7 +29,8 @@ Search FILE for docker images and suggest updates Options: --auth <REGISTRY> <USER> <PASS> Authenticate against REGISTRY (repeatable) --config <PATH> Read config from PATH ---scratch <DIR> Store temporary files in DIR", +--scratch <DIR> Store temporary files in DIR +--metrics-port <PORT> Serve Prometheus metrics on PORT (default: disabled)", cmd ); } @@ -172,36 +174,56 @@ fn perform_registry_request(registry: &str, url: &str, accept: &'static str, aut request = state.add_to_request(request); } + let start_time = std::time::Instant::now(); let response = request.call().unwrap(); + let duration = start_time.elapsed().as_secs_f64(); + metrics::get().http_request_duration.with_label_values(&[registry]).observe(duration); if response.status() == 401 { if !authentication_retry { if let Some(ref mut state) = state { - state.authenticate(&response).map_err(RegistryError::Other)?; - authentication_retry = true; - continue; + match state.authenticate(&response) { + Ok(()) => { + metrics::get().auth_attempts.with_label_values(&[registry, "success"]).inc(); + authentication_retry = true; + continue; + } + Err(e) => { + metrics::get().auth_attempts.with_label_values(&[registry, "failure"]).inc(); + return Err(RegistryError::Other(e)); + } + } } else { + metrics::get().http_request.with_label_values(&[registry, "error"]).inc(); return Err(RegistryError::Other(format!("Server {} returned 401 but we have no credentials", registry))); } } else { + metrics::get().auth_attempts.with_label_values(&[registry, "failure"]).inc(); + metrics::get().http_request.with_label_values(&[registry, "error"]).inc(); return Err(RegistryError::Other(format!("Authentication failed"))); } } if response.status() == 429 { + metrics::get().rate_limits.with_label_values(&[registry]).inc(); + metrics::get().state.set(metrics::STATE_THROTTLED); println!("Too many requests"); std::thread::sleep(std::time::Duration::from_secs(8)); + metrics::get().state.set(metrics::STATE_ACTIVE); continue; } if response.status() == 404 { + metrics::get().http_request.with_label_values(&[registry, "not_found"]).inc(); return Err(RegistryError::NotFound); } if response.status() != 200 { + metrics::get().http_request.with_label_values(&[registry, "error"]).inc(); return Err(RegistryError::Other(format!("Unexpected status code: {}", response.status()))); } + metrics::get().http_request.with_label_values(&[registry, "success"]).inc(); return Ok(response); } } @@ -227,6 +249,8 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e let mut tag = img.tag.as_ref().map(|x| file[x.clone()].to_string()); let image = &file[img.image.clone()]; + metrics::get().images_checked.with_label_values(&[registry]).inc(); + let _last_checked = match db.query_one(" SELECT last_checked FROM images WHERE registry = ?1 AND image = ?2 @@ -301,6 +325,7 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e position: img.tag.unwrap(), content: new_tag, }); + metrics::get().images_updated.with_label_values(&[registry]).inc(); } } @@ -323,6 +348,7 @@ fn update_images(db: &Connection, file: &str, auth: &mut Auth, img: DockerRef, e position: img.digest.unwrap(), content: digest_string, }); + metrics::get().images_updated.with_label_values(&[registry]).inc(); } } @@ -596,9 +622,11 @@ fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec<Repository>, mut infil 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 @@ -615,8 +643,10 @@ fn run_tool(db: &Connection, auth: &mut Auth, repos: &Vec<Repository>, mut infil 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(); } } @@ -631,6 +661,7 @@ fn main() { let mut config_path = None; let mut scratch_path = None; let mut continuous = false; + let mut metrics_port: Option<u16> = None; loop { match it.next().map(|x| x.as_str()) { @@ -668,6 +699,22 @@ fn main() { Some("--continuous") => { continuous = true; }, + Some("--metrics-port") => { + if let Some(port_str) = it.next() { + match port_str.parse::<u16>() { + 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; }, @@ -936,8 +983,22 @@ fn main() { let mut auth = Auth::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(); + run_tool(&db, &mut auth, &repos, infile_paths.clone(), overwrite); + + let run_duration = run_start.elapsed().as_secs_f64(); + metrics::get().run_duration.set(run_duration); + metrics::get().state.set(metrics::STATE_IDLE); + if !continuous { break; } println!("Waiting for next run"); diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..09617c4 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,147 @@ +use prometheus::{ + CounterVec, Gauge, HistogramVec, + Opts, Registry, TextEncoder, Encoder, + histogram_opts, +}; +use std::sync::OnceLock; +use std::thread; + +pub const STATE_IDLE: f64 = 0.0; +pub const STATE_ACTIVE: f64 = 1.0; +pub const STATE_THROTTLED: f64 = 2.0; + +pub static METRICS: OnceLock<Metrics> = OnceLock::new(); + +pub fn get() -> &'static Metrics { + METRICS.get().unwrap() +} + +pub struct Metrics { + pub state: Gauge, + pub images_checked: CounterVec, + pub images_updated: CounterVec, + pub http_request: CounterVec, + pub http_request_duration: HistogramVec, + pub rate_limits: CounterVec, + pub auth_attempts: CounterVec, + pub run_duration: Gauge, + pub git_operations: CounterVec, + registry: Registry, +} + +impl Metrics { + pub fn new() -> Self { + let registry = Registry::new(); + + let state = Gauge::with_opts( + Opts::new("vbump_state", "Current state: 0=idle, 1=active, 2=throttled"), + ).unwrap(); + registry.register(Box::new(state.clone())).unwrap(); + + let images_checked = CounterVec::new( + Opts::new("vbump_images_checked_total", "Total images checked"), + &["registry"], + ).unwrap(); + registry.register(Box::new(images_checked.clone())).unwrap(); + + let images_updated = CounterVec::new( + Opts::new("vbump_images_updated_total", "Total images with newer versions found"), + &["registry"], + ).unwrap(); + registry.register(Box::new(images_updated.clone())).unwrap(); + + let http_request = CounterVec::new( + Opts::new("vbump_http_request_total", "Total HTTP requests to registries"), + &["registry", "outcome"], + ).unwrap(); + registry.register(Box::new(http_request.clone())).unwrap(); + + let http_request_duration = HistogramVec::new( + histogram_opts!( + "vbump_http_request_duration_seconds", + "Request latency distribution", + vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] + ), + &["registry"], + ).unwrap(); + registry.register(Box::new(http_request_duration.clone())).unwrap(); + + let rate_limits = CounterVec::new( + Opts::new("vbump_rate_limits_total", "Rate limit (429) responses received"), + &["registry"], + ).unwrap(); + registry.register(Box::new(rate_limits.clone())).unwrap(); + + let auth_attempts = CounterVec::new( + Opts::new("vbump_auth_attempts_total", "Auth attempts (success/failure)"), + &["registry", "outcome"], + ).unwrap(); + registry.register(Box::new(auth_attempts.clone())).unwrap(); + + let run_duration = Gauge::with_opts( + Opts::new("vbump_run_duration_seconds", "Duration of last complete scan"), + ).unwrap(); + registry.register(Box::new(run_duration.clone())).unwrap(); + + let git_operations = CounterVec::new( + Opts::new("vbump_git_operations_total", "Git commits/pushes (success/failure)"), + &["operation", "outcome"], + ).unwrap(); + registry.register(Box::new(git_operations.clone())).unwrap(); + + Self { + state, + images_checked, + images_updated, + http_request, + http_request_duration, + rate_limits, + auth_attempts, + run_duration, + git_operations, + registry, + } + } +} + +pub fn init() { + METRICS.get_or_init(Metrics::new); +} + +pub fn serve(port: u16) { + let registry = get().registry.clone(); + + thread::spawn(move || { + let addr = format!("0.0.0.0:{}", port); + let server = match tiny_http::Server::http(&addr) { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to start metrics server on {}: {}", addr, e); + return; + } + }; + println!("Metrics server listening on {}", addr); + + for request in server.incoming_requests() { + let response = if request.url() == "/metrics" { + let encoder = TextEncoder::new(); + let metric_families = registry.gather(); + let mut buffer = Vec::new(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + + tiny_http::Response::from_data(buffer) + .with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/plain; version=0.0.4; charset=utf-8"[..], + ).unwrap() + ) + } else { + tiny_http::Response::from_string("Not Found") + .with_status_code(404) + }; + + let _ = request.respond(response); + } + }); +} |
